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

@@ -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
<div className="workspace-content">
<aside className="combo-panel left-panel">
<div className="panel-tabs"><button className={leftTab === 'model' ? 'is-active' : ''} onClick={() => setLeftTab('model')}><ListTree size={14} />Model</button><button className={leftTab === 'tasks' ? 'is-active' : ''} onClick={() => setLeftTab('tasks')}><SlidersHorizontal size={14} />Tasks <span className="tab-count">1</span></button></div>
{leftTab === 'model' ? <div className="combo-model"><ModelTree document={document} selectedObject={selectedObject} setSelectedObject={setSelectedObject} showNotice={showNotice} /><div className="combo-property"><div className="property-heading"><div><span className="eyebrow">Property view</span><h2>{document.tree.find((item) => item.id === selectedObject)?.label || 'No selection'}</h2></div><IconButton icon={MoreHorizontal} label="More object actions" /></div><div className="panel-tabs property-tabs"><button className={rightTab === 'data' ? 'is-active' : ''} onClick={() => setRightTab('data')}>Data</button><button className={rightTab === 'view' ? 'is-active' : ''} onClick={() => setRightTab('view')}>View</button></div>{rightTab === 'data' ? <DataProperties selectedObject={selectedObject} selectedItem={document.tree.find((item) => item.id === selectedObject)} showNotice={showNotice} /> : <ViewProperties showNotice={showNotice} />}</div></div> : <div className="model-task-summary"><span className="eyebrow">Combo View task tab</span><p>Use the right Task panel for command parameters. This tab stays available for selection and dependency context.</p><button className="button button-outline" onClick={() => showNotice('Selection filter enabled')}><Search size={14} />Selection filter</button></div>}
{leftTab === 'model' ? <div className="combo-model"><ModelTree document={document} selectedObject={selectedObject} setSelectedObject={setSelectedObject} showNotice={showNotice} /><div className="combo-property"><div className="property-heading"><div><span className="eyebrow">Property view</span><h2>{document.tree.find((item) => item.id === selectedObject)?.label || 'No selection'}</h2></div><IconButton icon={MoreHorizontal} label="More object actions" /></div><div className="panel-tabs property-tabs"><button className={rightTab === 'data' ? 'is-active' : ''} onClick={() => setRightTab('data')}>Data</button><button className={rightTab === 'view' ? 'is-active' : ''} onClick={() => setRightTab('view')}>View</button></div><PropertyPanel key={rightTab} facade={facade} objectId={selectedObject} scope={rightTab} showNotice={showNotice} /></div></div> : <div className="model-task-summary"><span className="eyebrow">Combo View task tab</span><p>Use the right Task panel for command parameters. This tab stays available for selection and dependency context.</p><button className="button button-outline" onClick={() => showNotice('Selection filter enabled')}><Search size={14} />Selection filter</button></div>}
</aside>
<section className="viewport-region">
<Viewport selectedObject={selectedObject} setSelectedObject={setSelectedObject} workbench={workbench} facade={facade} showNotice={showNotice} />
@@ -340,21 +340,38 @@ function TaskPanel({ workbench, facade, showNotice }: { workbench: Workbench; fa
return <div className="task-panel"><div className="task-actions-top"><button className="button button-primary" onClick={() => { facade.task.apply(); showNotice('Task accepted') }}>OK</button><button className="button button-outline" onClick={() => { facade.task.update({ preview: true }); showNotice('Preview applied') }}>Apply</button><button className="button button-quiet" onClick={() => { facade.task.cancel(); showNotice('Task cancelled') }}>Cancel</button></div><div className="task-header"><div className="task-icon"><Pencil size={16} /></div><div><span className="eyebrow">Active command</span><h2>{isSketch ? 'Edit Sketch' : definition.taskTitle}</h2></div><Badge tone="cyan">Preview</Badge></div><div className="task-body"><div className="task-step"><span className="step-index">1</span><div><strong>{isSketch ? 'Geometry and constraints' : definition.objectType}</strong><span>{definition.taskSummary}</span></div></div><label className="field-label">Primary value <span className="field-unit">mm</span><input className="field-input" value={isSketch ? 'Fully constrained' : '42.00'} readOnly /></label><label className="field-label">Operation<select className="field-input"><option>{workbench === 'Part Design' ? 'Dimension' : 'Contextual preview'}</option><option>Through all</option><option>Up to face</option></select></label><label className="check-row"><input type="checkbox" defaultChecked /><span>Preview result in viewport</span></label><div className="task-note"><AlertTriangle size={14} /><span>Changes remain local until the document is recomputed.</span></div></div></div>
}
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 <div className="properties-scroll"><div className="property-group"><div className="property-group-title">Identity <ChevronDown size={14} /></div><PropertyRow label="Label" value={label} editable onClick={() => showNotice('Label editor opened')} /><PropertyRow label="Type" value={objectType} /><PropertyRow label="Status" value={status} tone={status === 'Warning' ? 'amber' : 'green'} /></div><div className="property-group"><div className="property-group-title">Parameters <ChevronDown size={14} /></div><PropertyRow label="Length" value={isSketch ? 'Fully constrained' : isPocket ? 'Through all' : selectedItem?.detail || '42.00 mm'} editable onClick={() => showNotice('Length editor opened')} /><PropertyRow label="Profile" value="Sketch" link /><PropertyRow label="Direction" value="Normal sketch axis" /><PropertyRow label="Reversed" value="false" editable onClick={() => showNotice('Boolean editor opened')} /></div><div className="property-group"><div className="property-group-title">Dependencies <ChevronDown size={14} /></div><PropertyRow label="Base" value="Body" link /><PropertyRow label="Support" value="XY_Plane" link /><PropertyRow label="Children" value="Pocket, Fillet" link /></div><div className="property-group"><div className="property-group-title">Expressions <ChevronDown size={14} /></div><div className="expression-row"><Code2 size={13} /><span>Length</span><span className="expression-value">42 mm</span><button title="Edit expression" onClick={() => showNotice('Expression editor opened')}><Pencil size={13} /></button></div></div></div>
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 <div className="properties-empty">No object selected</div>
const properties = object.properties.filter((property) => property.scope === scope && !property.hidden)
const groups = new Map<string, ObjectPropertySnapshot[]>()
properties.forEach((property) => groups.set(property.group, [...(groups.get(property.group) ?? []), property]))
return <div className="properties-scroll">{[...groups].map(([group, entries]) => <div className="property-group" key={group}><div className="property-group-title">{group}<ChevronDown size={14} /></div>{entries.map((property) => <PropertyEditor key={`${objectId}-${property.name}-${String(property.value)}`} facade={facade} objectId={objectId} property={property} showNotice={showNotice} />)}</div>)}</div>
}
function PropertyRow({ label, value, editable = false, link = false, tone, onClick }: { label: string; value: string; editable?: boolean; link?: boolean; tone?: 'amber' | 'green'; onClick?: () => void }) {
return <div className="property-row"><span className="property-label">{label}</span><button className={`property-value ${editable ? 'is-editable' : ''} ${link ? 'is-link' : ''}`} onClick={onClick} disabled={!editable && !link}>{value}{tone && <span className={`status-pill ${tone}`}>{tone === 'green' ? 'OK' : 'Review'}</span>}</button></div>
}
function ViewProperties({ showNotice }: { showNotice: (message: string) => void }) {
return <div className="properties-scroll"><div className="property-group"><div className="property-group-title">Display <ChevronDown size={14} /></div><PropertyRow label="Visibility" value="Visible" editable onClick={() => showNotice('Visibility toggled')} /><PropertyRow label="Display mode" value="Flat lines" editable onClick={() => showNotice('Display mode menu opened')} /><PropertyRow label="Transparency" value="0 %" editable onClick={() => showNotice('Transparency editor opened')} /></div><div className="property-group"><div className="property-group-title">Appearance <ChevronDown size={14} /></div><PropertyRow label="Shape color" value="Graphite / 02" editable onClick={() => showNotice('Color picker opened')} /><PropertyRow label="Line color" value="Steel / 04" editable onClick={() => showNotice('Color picker opened')} /><PropertyRow label="Line width" value="1.0 px" editable onClick={() => showNotice('Line width editor opened')} /></div><div className="property-group"><div className="property-group-title">View state <ChevronDown size={14} /></div><PropertyRow label="Selection style" value="Object + edges" /><PropertyRow label="Tessellation" value="Adaptive" /><PropertyRow label="Render cache" value="Ready" tone="green" /></div></div>
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 = <span className="property-readonly">{formatted}</span>
else if (property.type === 'App::PropertyBool') editor = <input className="property-checkbox" type="checkbox" checked={Boolean(property.value)} aria-label={property.label} onChange={(event) => commit(event.target.checked)} />
else if (property.type === 'App::PropertyEnumeration') editor = <select className="property-control" value={String(property.value)} aria-label={property.label} onChange={(event) => commit(event.target.value)}>{property.options?.map((option) => <option key={option}>{option}</option>)}</select>
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 = <select className="property-control property-link-control" value={String(property.value ?? '')} aria-label={property.label} onChange={(event) => commit(event.target.value || null)}><option value="">None</option>{ids.filter((id) => id !== objectId).map((id) => <option value={id} key={id}>{document.tree.find((item) => item.id === id)?.label ?? id}</option>)}</select>
} else if (property.type === 'App::PropertyColor') editor = <label className="property-color"><input type="color" value={String(property.value)} aria-label={property.label} onChange={(event) => commit(event.target.value)} /><span>{String(property.value).toUpperCase()}</span></label>
else if (property.type === 'App::PropertyLength' || property.type === 'App::PropertyPercent' || property.type === 'App::PropertyFloat') editor = <label className="property-number"><input className="property-control" type="number" defaultValue={Number(property.value)} min={property.type === 'App::PropertyLength' || property.type === 'App::PropertyPercent' ? 0 : undefined} max={property.type === 'App::PropertyPercent' ? 100 : undefined} step={property.type === 'App::PropertyLength' ? 0.1 : 1} aria-label={property.label} onBlur={(event) => { if (!commit(Number(event.target.value))) event.target.value = String(property.value) }} onKeyDown={(event) => { if (event.key === 'Enter') event.currentTarget.blur() }} /><span>{property.unit}</span></label>
else editor = <input className="property-control" defaultValue={String(property.value ?? '')} aria-label={property.label} onBlur={(event) => { if (!commit(event.target.value)) event.target.value = String(property.value ?? '') }} onKeyDown={(event) => { if (event.key === 'Enter') event.currentTarget.blur() }} />
return <><div className="property-row"><span className="property-label">{property.label}</span><div className="property-editor">{editor}</div></div>{property.expression && <div className="property-expression"><Code2 size={12} /><span>{property.expression}</span></div>}</>
}
function Viewport({ selectedObject, setSelectedObject, workbench, facade, showNotice }: { selectedObject: string; setSelectedObject: (id: string) => void; workbench: Workbench; facade: BitBybitWebCadFacade; showNotice: (message: string) => void }) {