P4-02 P6-02: add metadata-driven property editor

This commit is contained in:
2026-08-02 07:53:35 -04:00
parent 8d8d1b7c26
commit 23b7f58df5
10 changed files with 250 additions and 30 deletions

View File

@@ -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'

View File

@@ -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('') },

View File

@@ -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<string, string>()
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<Record<string, string | number | null>>
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<Record<string, string>>
const propertiesByObject = new Map<string, ObjectPropertySnapshot[]>()
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,
}
}

View File

@@ -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<PersistenceCapabilities>

View File

@@ -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: {