diff --git a/config/runtime-baseline.json b/config/runtime-baseline.json index 4c6e775..2336aa2 100644 --- a/config/runtime-baseline.json +++ b/config/runtime-baseline.json @@ -3,8 +3,8 @@ "entrypoint": "BitBybitWebCadFacade", "frontendFramework": "React 18 + Vite 6 + TypeScript 5", "geometry": { "provider": "@bitbybit-dev/occt-worker", "kernel": "OCCT WASM", "version": "1.1.1", "execution": "dedicated worker", "wasmSha256": "1b6a8fc7b83d222854f73b66af8d0e45ad4fdee025904b447e885d4e18ae8656", "status": "core-feature-integration" }, - "viewport": { "provider": "Three.js internal adapter", "package": "three", "version": "0.185.1", "baseline": "WebGL2", "status": "adapter-skeleton" }, - "persistence": { "database": "@sqlite.org/sqlite-wasm 3.53.0-build1", "filesystem": "OPFS VFS with in-memory fallback", "writer": "single worker", "crossOriginIsolation": "required for OPFS worker", "status": "worker-skeleton" }, + "viewport": { "provider": "Three.js internal adapter", "package": "three", "version": "0.185.1", "baseline": "WebGL2", "status": "occt-mesh-integration" }, + "persistence": { "database": "@sqlite.org/sqlite-wasm 3.53.0-build1", "filesystem": "OPFS VFS with in-memory fallback", "writer": "single worker", "crossOriginIsolation": "required for OPFS worker", "status": "schema-v1-resource-autosave-and-properties" }, "targetBrowsers": "Capability matrix to be locked in P0-02", - "status": "facade-first-integration-slice" + "status": "core-feature-and-property-integration" } diff --git a/docs/web-cad-implementation-plan.zh-CN.md b/docs/web-cad-implementation-plan.zh-CN.md index 06ac388..7ee591c 100644 --- a/docs/web-cad-implementation-plan.zh-CN.md +++ b/docs/web-cad-implementation-plan.zh-CN.md @@ -1590,6 +1590,7 @@ P0 基线/治理 | P1-05 MockFacadeAdapter | `DONE` | `src/facade/mockFacade.ts`,React 工作区已通过事件投影工作台、选择、文档树和通知 | | 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 和重计算仍待实现 | +| P4-02 Property/Link/Unit | `IN PROGRESS` | DocumentObject 已携带 Data/View 类型化属性元数据;编辑经过 Facade 校验、版本、dirty、Undo/Redo、autosave 和 SQLite `object_properties`;Expression 求值、单位换算、多选 mixed 与链接循环仍待实现 | | 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` 目录、引用计数、读取和释放;浏览器验证通过,配额治理、垃圾回收扫描和大资源压力仍待补齐 | | P2-04 自动保存调度 | `IN PROGRESS` | `ProjectAutosaveScheduler` 在 Facade 文档事务后按空闲窗口合并最新版本,显式 Save 仍立即写入;恢复报告、崩溃点检测和用户可配置策略仍待补齐 | @@ -1680,3 +1681,21 @@ OCCT Boolean 结果可能与源形状共享底层拓扑。1.1.1 的 `deleteShape | 释放全部特征结果后访问 Pocket 基体 | `PASS` | 基体仍为 24 顶点、12 三角形,包围盒不变 | Pad/Pocket 的方向向量在 Facade 内归一化,`length` 单独控制尺寸;`reversed` 改变方向,`symmetricToPlane` 将起始面移动到长度中点。Revolution Worker 插件支持任意轴原点和方向,弥补 Bitbybit 1.1.1 高层 `revolve` 固定绕原点轴的限制。当前 Pocket 是长度模式;`Through all`、`Up to face`、双向长度和轮廓支持面映射继续由 P4-02/P4-05 与 P3-04 后续任务实现。 + +### 16.15 P4-02 Property 元数据与编辑器验证记录 + +DocumentSnapshot 现在区分模型树投影与 `DocumentObjectSnapshot` 真值。每个对象包含 FreeCAD 风格的 `typeId` 和类型化 Property:String、Length、Bool、Enumeration、Link、Color、Percent、Float;metadata 同时声明 Data/View scope、group、unit、readOnly、hidden、recompute、options 和 expression。React 不再按 Pad/Pocket 名称硬编码属性行。 + +| 检查 | 结果 | 证据 | +|---|---|---| +| Data 编辑 | `PASS` | 浏览器将 Pad Length 从 42 改为 50,树版本 v18→v19、对象 Status→Touched、tree state→dirty | +| View/Data 分离 | `PASS` | Visibility 等 View 属性提交文档事务但不把几何对象标记 Touched | +| 类型控件 | `PASS` | View 标签由 metadata 生成 1 个 checkbox、2 个 enumeration、2 个 numeric、2 个 color 控件 | +| 校验 | `PASS` | 负 Length、越界 Percent、未知 Enumeration、无效颜色、缺失 Link、只读 Property 在 Facade 拒绝 | +| Undo/Redo | `PASS` | Length 42→50 可撤销回 42、重做至 50,Property 和树投影同步 | +| SQLite/OPFS | `PASS` | Chrome `sqlite-opfs` 保存 v19 后重新加载 Length=61、Status=Touched、tree state=dirty | +| 移动布局 | `PASS` | 390×844 下 Combo View 560 px、Property Editor 280 px,7 个控件无横向溢出 | + +持久化复用 schema v1 已有 `object_properties` 表:`value_json` 保存完整 Property snapshot,`property_type` 保留可查询类型;删除/重写对象时依赖外键级联清理旧属性。内存降级、autosave、Undo/Redo 和 Facade state 均使用深拷贝,避免 options/property 数组共享引用。 + +当前限制:Expression 只展示 metadata,尚无解析/依赖求值;Length 以文档 mm 基值保存,尚无 Quantity 单位换算;PropertyLink 只校验本 Document 目标存在,未实现循环和删除传播;多选 mixed、批量编辑、重置默认值和属性搜索仍由 P4-02/P6-02 后续任务完成。属性变更只做轻量 Touched 标记,不在 UI 线程同步执行 OCCT 重计算。 diff --git a/src/App.tsx b/src/App.tsx index 8eb90bc..b07d125 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -53,7 +53,7 @@ import { ZoomOut, } from 'lucide-react' import { menuDefinitions, pinnedWorkbenches, workbenchDefinitions, type MenuName, type WorkbenchId } from './freecadManifest' -import { createMockFacade, type BitBybitViewportAdapter, type BitBybitWebCadFacade, type DocumentSnapshot, type ModelTreeItem, type ShapeHandle } from './facade' +import { createMockFacade, type BitBybitViewportAdapter, type BitBybitWebCadFacade, type DocumentSnapshot, type ModelTreeItem, type ObjectPropertySnapshot, type PropertyValue, type ShapeHandle } from './facade' type Page = 'start' | 'projects' | 'workspace' | 'import' | 'export' | 'settings' | 'help' | 'diagnostics' | 'sync' type Workbench = WorkbenchId @@ -278,7 +278,7 @@ function Workspace({ workbench, setWorkbench, leftTab, setLeftTab, rightTab, set
@@ -340,21 +340,38 @@ function TaskPanel({ workbench, facade, showNotice }: { workbench: Workbench; fa return
Active command

{isSketch ? 'Edit Sketch' : definition.taskTitle}

Preview
1
{isSketch ? 'Geometry and constraints' : definition.objectType}{definition.taskSummary}
Changes remain local until the document is recomputed.
} -function DataProperties({ selectedObject, selectedItem, showNotice }: { selectedObject: string; selectedItem?: ModelTreeItem; showNotice: (message: string) => void }) { - const label = selectedItem?.label || 'Body' - const isPocket = label.toLowerCase().startsWith('pocket') - const isSketch = selectedItem?.type === 'sketch' - const objectType = selectedItem?.type === 'body' ? 'PartDesign::Body' : isSketch ? 'Sketcher::SketchObject' : isPocket ? 'PartDesign::Pocket' : 'PartDesign::Feature' - const status = selectedItem?.state === 'warning' ? 'Warning' : selectedItem?.state === 'readonly' ? 'Read-only' : 'Valid' - return
Identity
showNotice('Label editor opened')} />
Parameters
showNotice('Length editor opened')} /> showNotice('Boolean editor opened')} />
Dependencies
Expressions
Length42 mm
+function PropertyPanel({ facade, objectId, scope, showNotice }: { facade: BitBybitWebCadFacade; objectId: string; scope: 'data' | 'view'; showNotice: (message: string) => void }) { + const object = facade.app.document.getObject(objectId) + if (!object) return
No object selected
+ const properties = object.properties.filter((property) => property.scope === scope && !property.hidden) + const groups = new Map() + properties.forEach((property) => groups.set(property.group, [...(groups.get(property.group) ?? []), property])) + return
{[...groups].map(([group, entries]) =>
{group}
{entries.map((property) => )}
)}
} -function PropertyRow({ label, value, editable = false, link = false, tone, onClick }: { label: string; value: string; editable?: boolean; link?: boolean; tone?: 'amber' | 'green'; onClick?: () => void }) { - return
{label}
-} - -function ViewProperties({ showNotice }: { showNotice: (message: string) => void }) { - return
Display
showNotice('Visibility toggled')} /> showNotice('Display mode menu opened')} /> showNotice('Transparency editor opened')} />
Appearance
showNotice('Color picker opened')} /> showNotice('Color picker opened')} /> showNotice('Line width editor opened')} />
View state
+function PropertyEditor({ facade, objectId, property, showNotice }: { facade: BitBybitWebCadFacade; objectId: string; property: ObjectPropertySnapshot; showNotice: (message: string) => void }) { + const commit = (value: PropertyValue) => { + try { + facade.app.document.setProperty({ objectId, propertyName: property.name, value }) + return true + } catch (error) { + showNotice(error instanceof Error ? error.message : String(error)) + return false + } + } + const formatted = `${String(property.value ?? '')}${property.unit ? ` ${property.unit}` : ''}` + let editor: ReactNode + if (property.readOnly) editor = {formatted} + else if (property.type === 'App::PropertyBool') editor = commit(event.target.checked)} /> + else if (property.type === 'App::PropertyEnumeration') editor = + else if (property.type === 'App::PropertyLink') { + const document = facade.app.document.getActive() + const ids = [...new Set(document.tree.flatMap((item) => [item.id, ...(item.children ?? [])]))] + editor = + } else if (property.type === 'App::PropertyColor') editor = + else if (property.type === 'App::PropertyLength' || property.type === 'App::PropertyPercent' || property.type === 'App::PropertyFloat') editor = + else editor = { if (!commit(event.target.value)) event.target.value = String(property.value ?? '') }} onKeyDown={(event) => { if (event.key === 'Enter') event.currentTarget.blur() }} /> + return <>
{property.label}
{editor}
{property.expression &&
{property.expression}
} } function Viewport({ selectedObject, setSelectedObject, workbench, facade, showNotice }: { selectedObject: string; setSelectedObject: (id: string) => void; workbench: Workbench; facade: BitBybitWebCadFacade; showNotice: (message: string) => void }) { diff --git a/src/facade/index.ts b/src/facade/index.ts index 38eebc8..d6984b7 100644 --- a/src/facade/index.ts +++ b/src/facade/index.ts @@ -3,4 +3,4 @@ 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, CommandState, CreateBoxInput, CreateConeInput, CreateCylinderInput, CreateSphereInput, DocumentSnapshot, FacadeEvent, FacadeState, GeometryCapabilities, GeometryDocumentContext, LinearFeatureParameters, MeshAsset, ModelTreeItem, PadInput, PersistenceCapabilities, Placement, PlanarProfile, PocketInput, Point3, ProjectResource, ProjectSaveResult, RevolutionInput, ShapeHandle, SubshapeRef, TaskSnapshot } from './types' +export type { ApplyPlacementInput, BitBybitViewportAdapter, BitBybitWebCadFacade, BooleanCutInput, BooleanIntersectionInput, BooleanUnionInput, CommandState, CreateBoxInput, CreateConeInput, CreateCylinderInput, CreateSphereInput, DocumentObjectSnapshot, DocumentSnapshot, FacadeEvent, FacadeState, GeometryCapabilities, GeometryDocumentContext, LinearFeatureParameters, MeshAsset, ModelTreeItem, ObjectPropertySnapshot, PadInput, PersistenceCapabilities, Placement, PlanarProfile, PocketInput, Point3, ProjectResource, ProjectSaveResult, PropertyValue, RevolutionInput, SetPropertyInput, ShapeHandle, SubshapeRef, TaskSnapshot } from './types' diff --git a/src/facade/mockFacade.ts b/src/facade/mockFacade.ts index 1310173..376554c 100644 --- a/src/facade/mockFacade.ts +++ b/src/facade/mockFacade.ts @@ -10,6 +10,10 @@ import type { FacadeRequestContext, FacadeState, ModelTreeItem, + DocumentObjectSnapshot, + ObjectPropertySnapshot, + PropertyValue, + SetPropertyInput, TaskSnapshot, Unsubscribe, } from './types' @@ -27,8 +31,61 @@ const initialTree: ModelTreeItem[] = [ { id: 'reference', label: 'Reference geometry', type: 'folder', children: ['DatumPlane', 'DatumAxis'] }, ] +const typeIdForItem = (item: ModelTreeItem) => item.type === 'body' ? 'PartDesign::Body' : item.type === 'sketch' ? 'Sketcher::SketchObject' : item.id.startsWith('pad') ? 'PartDesign::Pad' : item.id.startsWith('pocket') ? 'PartDesign::Pocket' : item.id.startsWith('fillet') ? 'PartDesign::Fillet' : item.type === 'feature' ? 'PartDesign::Feature' : 'App::DocumentObjectGroup' + +const commonProperties = (item: ModelTreeItem): ObjectPropertySnapshot[] => [ + { name: 'Label', label: 'Label', group: 'Identity', scope: 'data', type: 'App::PropertyString', value: item.label }, + { name: 'TypeId', label: 'Type', group: 'Identity', scope: 'data', type: 'App::PropertyString', value: typeIdForItem(item), readOnly: true }, + { name: 'Status', label: 'Status', group: 'Identity', scope: 'data', type: 'App::PropertyString', value: item.state === 'warning' ? 'Warning' : 'Valid', readOnly: true }, +] + +const viewProperties = (): ObjectPropertySnapshot[] => [ + { name: 'Visibility', label: 'Visibility', group: 'Display', scope: 'view', type: 'App::PropertyBool', value: true }, + { name: 'DisplayMode', label: 'Display mode', group: 'Display', scope: 'view', type: 'App::PropertyEnumeration', value: 'Flat lines', options: ['Flat lines', 'Shaded', 'Wireframe'] }, + { name: 'Transparency', label: 'Transparency', group: 'Display', scope: 'view', type: 'App::PropertyPercent', value: 0, unit: '%' }, + { name: 'ShapeColor', label: 'Shape color', group: 'Appearance', scope: 'view', type: 'App::PropertyColor', value: '#579a9c' }, + { name: 'LineColor', label: 'Line color', group: 'Appearance', scope: 'view', type: 'App::PropertyColor', value: '#8cb7b6' }, + { name: 'LineWidth', label: 'Line width', group: 'Appearance', scope: 'view', type: 'App::PropertyFloat', value: 1, unit: 'px' }, + { name: 'SelectionStyle', label: 'Selection style', group: 'View state', scope: 'view', type: 'App::PropertyEnumeration', value: 'Object + edges', options: ['Object + edges', 'Object', 'Bound box'] }, +] + +const featureProperties = (item: ModelTreeItem): ObjectPropertySnapshot[] => { + if (item.id.startsWith('pad')) return [ + { name: 'Length', label: 'Length', group: 'Parameters', scope: 'data', type: 'App::PropertyLength', value: 42, unit: 'mm', recompute: true, expression: '42 mm' }, + { name: 'Profile', label: 'Profile', group: 'Parameters', scope: 'data', type: 'App::PropertyLink', value: 'sketch', recompute: true }, + { name: 'Reversed', label: 'Reversed', group: 'Parameters', scope: 'data', type: 'App::PropertyBool', value: false, recompute: true }, + { name: 'Midplane', label: 'Symmetric to plane', group: 'Parameters', scope: 'data', type: 'App::PropertyBool', value: false, recompute: true }, + ] + if (item.id.startsWith('pocket')) return [ + { name: 'Type', label: 'Type', group: 'Parameters', scope: 'data', type: 'App::PropertyEnumeration', value: 'Through all', options: ['Dimension', 'Through all', 'Up to face'], recompute: true }, + { name: 'Length', label: 'Length', group: 'Parameters', scope: 'data', type: 'App::PropertyLength', value: 18, unit: 'mm', recompute: true }, + { name: 'Profile', label: 'Profile', group: 'Parameters', scope: 'data', type: 'App::PropertyLink', value: 'sketch', recompute: true }, + { name: 'Reversed', label: 'Reversed', group: 'Parameters', scope: 'data', type: 'App::PropertyBool', value: false, recompute: true }, + ] + if (item.id.startsWith('fillet')) return [ + { name: 'Radius', label: 'Radius', group: 'Parameters', scope: 'data', type: 'App::PropertyLength', value: 3, unit: 'mm', recompute: true }, + { name: 'Base', label: 'Base', group: 'Dependencies', scope: 'data', type: 'App::PropertyLink', value: 'pocket', recompute: true }, + ] + if (item.type === 'sketch') return [ + { name: 'Support', label: 'Support', group: 'Attachment', scope: 'data', type: 'App::PropertyLink', value: 'XY_Plane', recompute: true }, + { name: 'ConstraintStatus', label: 'Solver state', group: 'Constraints', scope: 'data', type: 'App::PropertyString', value: 'Fully constrained', readOnly: true }, + ] + if (item.type === 'body') return [ + { name: 'Tip', label: 'Tip', group: 'Part Design', scope: 'data', type: 'App::PropertyLink', value: 'fillet', recompute: true }, + ] + return [] +} + +const createObjectSnapshot = (item: ModelTreeItem): DocumentObjectSnapshot => ({ id: item.id, typeId: typeIdForItem(item), properties: [...commonProperties(item), ...featureProperties(item), ...viewProperties()] }) + +const cloneDocumentSnapshot = (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 })) })), +}) + const createDocument = (label = 'Pump Housing'): DocumentSnapshot => ({ - id: 'doc-pump-housing', label, version: 18, dirty: true, readOnly: false, units: 'mm', tree: initialTree.map((item) => ({ ...item, children: item.children ? [...item.children] : undefined })), + id: 'doc-pump-housing', label, version: 18, dirty: true, readOnly: false, units: 'mm', tree: initialTree.map((item) => ({ ...item, children: item.children ? [...item.children] : undefined })), objects: initialTree.map(createObjectSnapshot), }) const selectionRequired = new Set(['pad', 'pocket', 'revolution', 'fillet', 'chamfer', 'hole', 'linear-pattern', 'polar-pattern', 'measure-distance', 'measure-angle', 'measure-area']) @@ -50,6 +107,23 @@ const commandState = (commandId: string, activeWorkbench: WorkbenchId, selectedO return { id: commandId, status: 'enabled' } } +const validatePropertyValue = (document: DocumentSnapshot, property: ObjectPropertySnapshot, value: PropertyValue) => { + if (property.readOnly) throw new Error(`${property.label} is read-only.`) + if (property.type === 'App::PropertyBool' && typeof value !== 'boolean') throw new TypeError(`${property.label} requires a boolean value.`) + if ((property.type === 'App::PropertyString' || property.type === 'App::PropertyEnumeration' || property.type === 'App::PropertyColor') && typeof value !== 'string') throw new TypeError(`${property.label} requires a string value.`) + if ((property.type === 'App::PropertyLength' || property.type === 'App::PropertyPercent' || property.type === 'App::PropertyFloat') && (typeof value !== 'number' || !Number.isFinite(value))) throw new TypeError(`${property.label} requires a finite numeric value.`) + if (property.type === 'App::PropertyLength' && (value as number) < 0) throw new RangeError(`${property.label} cannot be negative.`) + if (property.type === 'App::PropertyPercent' && ((value as number) < 0 || (value as number) > 100)) throw new RangeError(`${property.label} must be between 0 and 100.`) + if (property.type === 'App::PropertyEnumeration' && !property.options?.includes(value as string)) throw new RangeError(`${property.label} is not a registered enumeration value.`) + if (property.type === 'App::PropertyColor' && !/^#[0-9a-f]{6}$/i.test(value as string)) throw new RangeError(`${property.label} requires a #RRGGBB color.`) + if (property.type === 'App::PropertyLink') { + if (value !== null && typeof value !== 'string') throw new TypeError(`${property.label} requires an object link.`) + const knownIds = new Set(document.tree.flatMap((item) => [item.id, ...(item.children ?? [])])) + if (value !== null && !knownIds.has(value)) throw new RangeError(`${property.label} target does not exist in this document.`) + } + if (property.name === 'Label' && !(value as string).trim()) throw new RangeError('Label cannot be empty.') +} + export function createMockFacade(): BitBybitWebCadFacade { const projectPersistence = createSqliteProjectPersistence() const geometryRuntime = new BitbybitGeometryRuntime() @@ -62,7 +136,7 @@ export function createMockFacade(): BitBybitWebCadFacade { const emit = (event: FacadeEvent) => listeners.forEach((listener) => listener(event)) const emitState = () => emit({ type: 'state.changed', state: getState() }) - const getState = () => ({ ...state, diagnostics: state.diagnostics.map((diagnostic) => ({ ...diagnostic })), document: { ...state.document, tree: state.document.tree.map((item) => ({ ...item, children: item.children ? [...item.children] : undefined })) }, task: state.task ? { ...state.task, draft: { ...state.task.draft } } : null }) + const getState = () => ({ ...state, diagnostics: state.diagnostics.map((diagnostic) => ({ ...diagnostic })), document: cloneDocumentSnapshot(state.document), task: state.task ? { ...state.task, draft: { ...state.task.draft } } : null }) const commit = (next: FacadeState) => { undoStack.push(getState()); redoStack.length = 0; state = next; if (next.document.dirty) autosave.schedule(next.document); emitState() } const notify = (message: string) => { state = { ...state, lastNotice: message }; emit({ type: 'notice', message }); emitState() } const setActive = (id: WorkbenchId) => { state = { ...state, activeWorkbench: id }; emitState(); notify(`${id} workbench loaded`) } @@ -86,7 +160,32 @@ export function createMockFacade(): BitBybitWebCadFacade { if (body) body.children = [...(body.children || []), objectId] tree.push(item) } - return { document: { ...document, version: document.version + 1, dirty: true, tree }, objectId } + return { document: { ...document, version: document.version + 1, dirty: true, tree, objects: [...document.objects.map((object) => ({ ...object, properties: object.properties.map((property) => ({ ...property })) })), createObjectSnapshot(item)] }, objectId } + } + const setProperty = ({ objectId, propertyName, value }: SetPropertyInput) => { + const objectIndex = state.document.objects.findIndex((object) => object.id === objectId) + if (objectIndex < 0) throw new Error(`Document object does not exist: ${objectId}`) + const sourceObject = state.document.objects[objectIndex] + const propertyIndex = sourceObject.properties.findIndex((property) => property.name === propertyName) + if (propertyIndex < 0) throw new Error(`Property does not exist: ${objectId}.${propertyName}`) + const sourceProperty = sourceObject.properties[propertyIndex] + validatePropertyValue(state.document, sourceProperty, value) + if (Object.is(sourceProperty.value, value)) return + + const document = cloneDocumentSnapshot(state.document) + const object = document.objects[objectIndex] + object.properties[propertyIndex] = { ...object.properties[propertyIndex], value } + const treeItem = document.tree.find((item) => item.id === objectId) + if (propertyName === 'Label' && treeItem) treeItem.label = String(value) + if (sourceProperty.recompute && treeItem && treeItem.state !== 'active') { + treeItem.state = 'dirty' + const status = object.properties.find((property) => property.name === 'Status') + if (status) status.value = 'Touched' + } + document.version += 1 + document.dirty = true + commit({ ...state, document }) + notify(`${sourceProperty.label} updated`) } const applyTask = () => { const task = state.task @@ -126,7 +225,7 @@ export function createMockFacade(): BitBybitWebCadFacade { } const facade: BitBybitWebCadFacade = { - app: { document: { getActive: () => getState().document, create: (label) => { commit({ ...state, document: createDocument(label), selectedObjectId: '' }); return getState().document }, markDirty: () => { commit({ ...state, document: { ...state.document, dirty: true } }) } } }, + app: { document: { getActive: () => getState().document, getObject: (objectId) => { const object = state.document.objects.find((candidate) => candidate.id === objectId); return object ? { ...object, properties: object.properties.map((property) => ({ ...property, options: property.options ? [...property.options] : undefined })) } : null }, create: (label) => { commit({ ...state, document: createDocument(label), selectedObjectId: '' }); return getState().document }, markDirty: () => { commit({ ...state, document: { ...state.document, dirty: true } }) }, setProperty } }, history: { canUndo: () => undoStack.length > 0, canRedo: () => redoStack.length > 0, undo: () => { const previous = undoStack.pop(); if (!previous) return; redoStack.push(getState()); state = previous; emitState(); notify('Undo applied') }, redo: () => { const next = redoStack.pop(); if (!next) return; undoStack.push(getState()); state = next; emitState(); notify('Redo applied') } }, 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('') }, diff --git a/src/facade/persistenceWorker.ts b/src/facade/persistenceWorker.ts index 122f5ad..b50d569 100644 --- a/src/facade/persistenceWorker.ts +++ b/src/facade/persistenceWorker.ts @@ -1,6 +1,6 @@ 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, ProjectResource } from './types' +import type { DocumentObjectSnapshot, DocumentSnapshot, ModelTreeItem, ObjectPropertySnapshot, PersistenceCapabilities, ProjectResource } from './types' type PersistenceRequest = | { id: number; type: 'initialize' } @@ -87,6 +87,7 @@ const saveDocument = (document: DocumentSnapshot) => { const parentByChild = new Map() for (const item of document.tree) for (const childId of item.children || []) parentByChild.set(childId, item.id) document.tree.forEach((item, ordinal) => database?.exec({ sql: 'INSERT INTO objects(id, document_id, parent_id, label, object_type, state, detail, children_json, ordinal) VALUES(?, ?, ?, ?, ?, ?, ?, ?, ?)', bind: [item.id, document.id, parentByChild.get(item.id) || null, item.label, item.type, item.state || null, item.detail || null, JSON.stringify(item.children || []), ordinal] })) + for (const object of document.objects) for (const property of object.properties) database.exec({ sql: 'INSERT INTO object_properties(document_id, object_id, name, value_json, property_type, updated_at) VALUES(?, ?, ?, ?, ?, ?)', bind: [document.id, object.id, property.name, JSON.stringify(property), property.type, now] }) database.exec('COMMIT;') } catch (error) { database.exec('ROLLBACK;') @@ -101,6 +102,19 @@ const loadDocument = (documentId: string): DocumentSnapshot | null => { const row = documents[0] if (!row) return null const objects = database.exec({ sql: 'SELECT id, label, object_type, state, detail, children_json FROM objects WHERE document_id = ? ORDER BY ordinal', bind: [documentId], rowMode: 'object', returnValue: 'resultRows' }) as Array> + const propertyRows = database.exec({ sql: 'SELECT object_id, value_json FROM object_properties WHERE document_id = ? ORDER BY object_id, name', bind: [documentId], rowMode: 'object', returnValue: 'resultRows' }) as Array> + const propertiesByObject = new Map() + for (const propertyRow of propertyRows) { + const properties = propertiesByObject.get(String(propertyRow.object_id)) ?? [] + properties.push(JSON.parse(String(propertyRow.value_json)) as ObjectPropertySnapshot) + propertiesByObject.set(String(propertyRow.object_id), properties) + } + const tree: ModelTreeItem[] = objects.map((object) => ({ id: String(object.id), label: String(object.label), type: String(object.object_type) as ModelTreeItem['type'], state: object.state ? String(object.state) as ModelTreeItem['state'] : undefined, detail: object.detail ? String(object.detail) : undefined, children: object.children_json ? JSON.parse(String(object.children_json)) as string[] : undefined })) + const objectSnapshots: DocumentObjectSnapshot[] = tree.map((item) => { + const properties = propertiesByObject.get(item.id) ?? [] + const typeId = properties.find((property) => property.name === 'TypeId')?.value + return { id: item.id, typeId: typeof typeId === 'string' ? typeId : item.type, properties } + }) return { id: String(row.id), label: String(row.label), @@ -108,7 +122,8 @@ const loadDocument = (documentId: string): DocumentSnapshot | null => { dirty: Boolean(row.dirty), readOnly: Boolean(row.read_only), units: String(row.units), - tree: objects.map((object) => ({ id: String(object.id), label: String(object.label), type: String(object.object_type) as ModelTreeItem['type'], state: object.state ? String(object.state) as ModelTreeItem['state'] : undefined, detail: object.detail ? String(object.detail) : undefined, children: object.children_json ? JSON.parse(String(object.children_json)) as string[] : undefined })), + tree, + objects: objectSnapshots, } } diff --git a/src/facade/projectStore.ts b/src/facade/projectStore.ts index 61c5065..de2cc6f 100644 --- a/src/facade/projectStore.ts +++ b/src/facade/projectStore.ts @@ -5,7 +5,7 @@ type WorkerInput = { type: 'initialize' | 'dispose' } | { type: 'save-document'; 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 })) }) +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 })) })) }) export interface ProjectPersistenceClient { initialize(): Promise diff --git a/src/facade/types.ts b/src/facade/types.ts index 5c11966..0ad1a4a 100644 --- a/src/facade/types.ts +++ b/src/facade/types.ts @@ -4,11 +4,34 @@ export type ModelTreeItem = { id: string label: string type: 'document' | 'folder' | 'body' | 'sketch' | 'feature' - state?: 'active' | 'valid' | 'warning' | 'readonly' + state?: 'active' | 'valid' | 'dirty' | 'warning' | 'readonly' detail?: string children?: string[] } +export type PropertyValue = string | number | boolean | null + +export type ObjectPropertySnapshot = { + name: string + label: string + group: string + scope: 'data' | 'view' + type: 'App::PropertyString' | 'App::PropertyLength' | 'App::PropertyBool' | 'App::PropertyEnumeration' | 'App::PropertyLink' | 'App::PropertyColor' | 'App::PropertyPercent' | 'App::PropertyFloat' + value: PropertyValue + unit?: string + readOnly?: boolean + hidden?: boolean + recompute?: boolean + options?: string[] + expression?: string +} + +export type DocumentObjectSnapshot = { + id: string + typeId: string + properties: ObjectPropertySnapshot[] +} + export type DocumentSnapshot = { id: string label: string @@ -17,6 +40,13 @@ export type DocumentSnapshot = { readOnly: boolean units: string tree: ModelTreeItem[] + objects: DocumentObjectSnapshot[] +} + +export type SetPropertyInput = { + objectId: string + propertyName: string + value: PropertyValue } export type PersistenceCapabilities = { @@ -239,8 +269,10 @@ export interface BitBybitWebCadFacade { readonly app: { document: { getActive(): DocumentSnapshot + getObject(objectId: string): DocumentObjectSnapshot | null create(label?: string): DocumentSnapshot markDirty(): void + setProperty(input: SetPropertyInput): void } } readonly history: { diff --git a/src/styles.css b/src/styles.css index 71e6764..41d5921 100644 --- a/src/styles.css +++ b/src/styles.css @@ -349,6 +349,19 @@ button:focus-visible, input:focus-visible, select:focus-visible { outline: 2px s .combo-property { min-height: 210px; flex: 0 1 42%; overflow: hidden; border-top: 1px solid var(--line); } .combo-property .property-heading { min-height: 55px; padding-top: 11px; } .combo-property .properties-scroll { max-height: calc(100% - 89px); } +.properties-empty { padding: 20px 13px; color: var(--text-muted); font-size: 10px; } +.property-editor { min-width: 0; display: flex; justify-content: flex-end; align-items: center; } +.property-readonly { max-width: 100%; overflow: hidden; color: var(--text-soft); font-size: 10px; text-overflow: ellipsis; white-space: nowrap; } +.property-control { width: 100%; min-width: 0; height: 24px; padding: 0 5px; border: 1px solid transparent; border-radius: 2px; background: transparent; color: var(--text-soft); font-size: 10px; text-align: right; } +.property-control:hover, .property-control:focus { border-color: var(--line); background: var(--bg-raised); outline: none; } +.property-control option { background: var(--bg-raised); color: var(--text); } +.property-checkbox { width: 14px; height: 14px; accent-color: var(--cyan); } +.property-number { width: 100%; display: flex; align-items: center; gap: 4px; color: var(--text-muted); font-size: 9px; } +.property-number .property-control { min-width: 0; } +.property-link-control { color: var(--cyan); } +.property-color { display: flex; align-items: center; gap: 5px; color: var(--text-muted); font-size: 9px; } +.property-color input { width: 22px; height: 18px; padding: 0; border: 1px solid var(--line); border-radius: 2px; background: transparent; } +.property-expression { min-height: 20px; display: flex; align-items: center; justify-content: flex-end; gap: 5px; color: var(--cyan); font-size: 9px; } @media (max-width: 1050px) { .workspace-content { grid-template-columns: 260px minmax(0, 1fr) 300px 40px; } @@ -358,9 +371,9 @@ button:focus-visible, input:focus-visible, select:focus-visible { outline: 2px s .workspace-content { grid-template-columns: 1fr; } .function-rail { display: none; } .task-dock { order: 3; min-height: 320px; max-height: 380px; border-left: 0; border-bottom: 1px solid var(--line); } - .left-panel { order: 2; min-height: 360px; max-height: 440px; } + .left-panel { order: 2; min-height: 560px; max-height: 620px; } .combo-model { height: 100%; } - .combo-property { min-height: 205px; } + .combo-property { min-height: 280px; } .viewport-region { order: 1; } .workbench-nav { overflow-x: auto; } .workbench-nav .nav-more, .nav-caption { display: none; } diff --git a/tests/facade.test.ts b/tests/facade.test.ts index 00a0576..ce5a5a3 100644 --- a/tests/facade.test.ts +++ b/tests/facade.test.ts @@ -100,7 +100,7 @@ test('persistence writes are serialized and continue after a failed write', asyn test('autosave scheduler coalesces idle document changes and flushes the latest version', async () => { const savedVersions: number[] = [] const scheduler = new ProjectAutosaveScheduler(async (document) => { savedVersions.push(document.version); return { documentId: document.id, documentVersion: document.version, persistedAt: Date.now(), mode: 'sqlite-memory' } }, 5) - const document = (version: number) => ({ id: 'doc', label: 'Autosave', version, dirty: true, readOnly: false, units: 'mm', tree: [] }) + const document = (version: number) => ({ id: 'doc', label: 'Autosave', version, dirty: true, readOnly: false, units: 'mm', tree: [], objects: [] }) scheduler.schedule(document(1)) scheduler.schedule(document(2)) await new Promise((resolve) => setTimeout(resolve, 15)) @@ -172,12 +172,37 @@ test('document mutations use the facade history boundary', () => { assert.equal(facade.app.document.getActive().label, 'Untitled document') }) +test('typed properties are validated, versioned and undoable through the document boundary', () => { + const facade = createMockFacade() + const before = facade.app.document.getActive().version + assert.equal(facade.app.document.getObject('pad')?.properties.find((property) => property.name === 'Length')?.value, 42) + facade.app.document.setProperty({ objectId: 'pad', propertyName: 'Length', value: 50 }) + assert.equal(facade.app.document.getActive().version, before + 1) + assert.equal(facade.app.document.getActive().tree.find((item) => item.id === 'pad')?.state, 'dirty') + assert.equal(facade.app.document.getObject('pad')?.properties.find((property) => property.name === 'Status')?.value, 'Touched') + assert.throws(() => facade.app.document.setProperty({ objectId: 'pad', propertyName: 'Length', value: -1 }), /negative/) + assert.equal(facade.app.document.getActive().version, before + 1) + facade.history.undo() + assert.equal(facade.app.document.getObject('pad')?.properties.find((property) => property.name === 'Length')?.value, 42) + facade.history.redo() + assert.equal(facade.app.document.getObject('pad')?.properties.find((property) => property.name === 'Length')?.value, 50) +}) + +test('view properties do not mark the geometry object touched', () => { + const facade = createMockFacade() + facade.app.document.setProperty({ objectId: 'fillet', propertyName: 'Visibility', value: false }) + assert.equal(facade.app.document.getActive().tree.find((item) => item.id === 'fillet')?.state, 'valid') + assert.equal(facade.app.document.getObject('fillet')?.properties.find((property) => property.name === 'Visibility')?.value, false) +}) + test('project persistence remains behind the facade contract', async () => { const facade = createMockFacade() + facade.app.document.setProperty({ objectId: 'pad', propertyName: 'Length', value: 55 }) const saved = await facade.project.save() assert.equal(saved.documentId, 'doc-pump-housing') const loaded = await facade.project.load('doc-pump-housing') 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') })