feat: establish facade-first runtime boundary

This commit is contained in:
2026-08-02 02:39:37 -04:00
parent 3978833c5c
commit df0053cfe5
16 changed files with 1004 additions and 34 deletions

View File

@@ -1,4 +1,4 @@
import { useEffect, useMemo, useState } from 'react'
import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
import type { ComponentType, ReactNode } from 'react'
import {
AlertTriangle,
@@ -53,6 +53,7 @@ import {
ZoomOut,
} from 'lucide-react'
import { menuDefinitions, pinnedWorkbenches, workbenchDefinitions, type MenuName, type WorkbenchId } from './freecadManifest'
import { createMockFacade, type BitBybitWebCadFacade, type DocumentSnapshot, type ModelTreeItem } from './facade'
type Page = 'start' | 'projects' | 'workspace' | 'import' | 'export' | 'settings' | 'help' | 'diagnostics' | 'sync'
type Workbench = WorkbenchId
@@ -104,16 +105,6 @@ const projects = [
{ name: 'Valve Body Study', path: 'Shared / Review / Valve Body Study', modified: '22 Jul 2026', objects: 31, state: 'Read only' },
]
const treeData = [
{ id: 'origin', label: 'Origin', type: 'folder', children: ['XY_Plane', 'XZ_Plane', 'YZ_Plane'] },
{ id: 'body', label: 'Body', type: 'body', state: 'active', children: ['sketch', 'pad', 'pocket', 'fillet'] },
{ id: 'sketch', label: 'Sketch', type: 'sketch', state: 'valid', detail: 'Fully constrained' },
{ id: 'pad', label: 'Pad', type: 'feature', state: 'valid', detail: 'Length 42 mm' },
{ id: 'pocket', label: 'Pocket', type: 'feature', state: 'warning', detail: 'Through all' },
{ id: 'fillet', label: 'Fillet', type: 'feature', state: 'valid', detail: 'Radius 3 mm' },
{ id: 'reference', label: 'Reference geometry', type: 'folder', children: ['DatumPlane', 'DatumAxis'] },
]
const IconButton = ({ icon: IconComponent, label, onClick, active = false, disabled = false }: { icon: Icon; label: string; onClick?: () => void; active?: boolean; disabled?: boolean }) => (
<button className={`icon-button ${active ? 'is-active' : ''}`} title={label} aria-label={label} onClick={onClick} disabled={disabled}>
<IconComponent size={16} strokeWidth={1.8} />
@@ -129,12 +120,20 @@ const Badge = ({ tone, children }: { tone: 'cyan' | 'green' | 'amber' | 'red' |
function App() {
const [page, setPage] = useState<Page>(pageFromPath)
const [workbench, setWorkbench] = useState<Workbench>('Part Design')
const [leftTab, setLeftTab] = useState<'model' | 'tasks'>('model')
const [rightTab, setRightTab] = useState<'data' | 'view'>('data')
const [bottomOpen, setBottomOpen] = useState(true)
const [selectedObject, setSelectedObject] = useState('pad')
const [notice, setNotice] = useState('')
const facade = useMemo(() => createMockFacade(), [])
const [facadeState, setFacadeState] = useState(() => facade.getState())
useEffect(() => facade.subscribe((event) => {
if (event.type === 'state.changed') setFacadeState(event.state)
if (event.type === 'notice') {
setNotice(event.message)
window.setTimeout(() => setNotice(''), 2600)
}
}), [facade])
useEffect(() => {
const handlePopState = () => setPage(pageFromPath())
@@ -159,13 +158,14 @@ function App() {
}
const openWorkspace = () => navigate('workspace')
const workbench = facadeState.activeWorkbench
const selectedObject = facadeState.selectedObjectId
const currentWorkbench = workbenchMeta[workbench]
const CurrentIcon = currentWorkbench.icon
const showNotice = (message: string) => {
setNotice(message)
window.setTimeout(() => setNotice(''), 2600)
}
const showNotice = useCallback((message: string) => facade.notify(message), [facade])
const setWorkbench = useCallback((value: Workbench) => facade.gui.workbench.setActive(value), [facade])
const setSelectedObject = useCallback((id: string) => id ? facade.selection.select(id) : facade.selection.clear(), [facade])
return (
<div className="app-shell">
@@ -173,7 +173,7 @@ function App() {
{page === 'workspace' ? (
<Workspace
workbench={workbench}
setWorkbench={(value) => { setWorkbench(value); setLeftTab('tasks'); showNotice(`${value} workbench loaded`) }}
setWorkbench={(value) => { setWorkbench(value); setLeftTab('tasks') }}
leftTab={leftTab}
setLeftTab={setLeftTab}
rightTab={rightTab}
@@ -182,6 +182,8 @@ function App() {
setBottomOpen={setBottomOpen}
selectedObject={selectedObject}
setSelectedObject={setSelectedObject}
document={facadeState.document}
facade={facade}
workbenchIcon={CurrentIcon}
showNotice={showNotice}
/>
@@ -229,7 +231,7 @@ function TopBar({ page, workbench, onNavigate, onOpenWorkspace }: { page: Page;
)
}
function Workspace({ workbench, setWorkbench, leftTab, setLeftTab, rightTab, setRightTab, bottomOpen, setBottomOpen, selectedObject, setSelectedObject, workbenchIcon: WorkbenchIcon, showNotice }: {
function Workspace({ workbench, setWorkbench, leftTab, setLeftTab, rightTab, setRightTab, bottomOpen, setBottomOpen, selectedObject, setSelectedObject, document, facade, workbenchIcon: WorkbenchIcon, showNotice }: {
workbench: Workbench
setWorkbench: (value: Workbench) => void
leftTab: 'model' | 'tasks'
@@ -240,6 +242,8 @@ function Workspace({ workbench, setWorkbench, leftTab, setLeftTab, rightTab, set
setBottomOpen: (open: boolean) => void
selectedObject: string
setSelectedObject: (id: string) => void
document: DocumentSnapshot
facade: BitBybitWebCadFacade
workbenchIcon: Icon
showNotice: (message: string) => void
}) {
@@ -272,10 +276,10 @@ 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 selectedObject={selectedObject} setSelectedObject={setSelectedObject} showNotice={showNotice} /><div className="combo-property"><div className="property-heading"><div><span className="eyebrow">Property view</span><h2>{selectedObject === 'pad' ? 'Pad' : selectedObject === 'pocket' ? 'Pocket' : 'Body'}</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} 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>{selectedObject === 'pad' ? 'Pad' : selectedObject === 'pocket' ? 'Pocket' : 'Body'}</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} 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>}
</aside>
<section className="viewport-region">
<Viewport selectedObject={selectedObject} setSelectedObject={setSelectedObject} workbench={workbench} showNotice={showNotice} />
<Viewport selectedObject={selectedObject} setSelectedObject={setSelectedObject} workbench={workbench} facade={facade} showNotice={showNotice} />
<div className="viewport-bottom-left"><div className="view-chip"><Circle size={8} fill="currentColor" /> Perspective</div><div className="view-chip">Grid 10 mm</div></div>
<div className="viewport-bottom-right"><div className="axis-widget"><span className="axis-x">X</span><span className="axis-y">Y</span><span className="axis-z">Z</span><div className="axis-origin" /></div></div>
</section>
@@ -299,18 +303,18 @@ function FunctionRail({ workbench, showNotice }: { workbench: Workbench; showNot
return <aside className="function-rail" aria-label={`${workbench} commands`}>{groups.map((group) => <div className="rail-group" key={group.label}><span className="rail-group-label">{group.label}</span>{group.commands.slice(0, 5).map((item) => { const IconComponent = iconMap[item.icon] || MoreHorizontal; return <button key={item.id} title={`${item.label}${item.shortcut ? ` (${item.shortcut})` : ''}`} aria-label={item.label} onClick={() => showNotice(`${item.label} command staged`)}><IconComponent size={15} /></button> })}</div>)}</aside>
}
function ModelTree({ selectedObject, setSelectedObject, showNotice }: { selectedObject: string; setSelectedObject: (id: string) => void; showNotice: (message: string) => void }) {
function ModelTree({ document, selectedObject, setSelectedObject, showNotice }: { document: DocumentSnapshot; selectedObject: string; setSelectedObject: (id: string) => void; showNotice: (message: string) => void }) {
const [expanded, setExpanded] = useState<Record<string, boolean>>({ origin: false, body: true, reference: false })
return <div className="model-tree">
<div className="tree-toolbar"><div className="tree-search"><Search size={14} /><input placeholder="Filter objects" /></div><IconButton icon={MoreHorizontal} label="Model tree actions" /></div>
<div className="tree-root"><div className="tree-document-row"><ChevronDown size={14} /><FileBox size={15} className="icon-cyan" /><span>Pump Housing</span><span className="tree-version">v18</span></div>
{treeData.map((item) => <TreeItem key={item.id} item={item} level={1} expanded={expanded[item.id]} onToggle={() => setExpanded((current) => ({ ...current, [item.id]: !current[item.id] }))} selectedObject={selectedObject} setSelectedObject={setSelectedObject} showNotice={showNotice} />)}
<div className="tree-root"><div className="tree-document-row"><ChevronDown size={14} /><FileBox size={15} className="icon-cyan" /><span>{document.label}</span><span className="tree-version">v{document.version}</span></div>
{document.tree.map((item) => <TreeItem key={item.id} item={item} level={1} expanded={expanded[item.id]} onToggle={() => setExpanded((current) => ({ ...current, [item.id]: !current[item.id] }))} selectedObject={selectedObject} setSelectedObject={setSelectedObject} showNotice={showNotice} />)}
</div>
<div className="tree-footer"><span><span className="legend-dot valid" />Valid</span><span><span className="legend-dot warning" />Needs review</span></div>
</div>
}
function TreeItem({ item, level, expanded, onToggle, selectedObject, setSelectedObject, showNotice }: { item: typeof treeData[number]; level: number; expanded?: boolean; onToggle: () => void; selectedObject: string; setSelectedObject: (id: string) => void; showNotice: (message: string) => void }) {
function TreeItem({ item, level, expanded, onToggle, selectedObject, setSelectedObject, showNotice }: { item: ModelTreeItem; level: number; expanded?: boolean; onToggle: () => void; selectedObject: string; setSelectedObject: (id: string) => void; showNotice: (message: string) => void }) {
const hasChildren = item.children && item.children.length > 0
const itemIcon = item.type === 'body' ? Box : item.type === 'sketch' ? CircleDot : item.type === 'feature' ? SquareStack : Layers3
const ItemIcon = itemIcon
@@ -345,8 +349,22 @@ 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 Viewport({ selectedObject, setSelectedObject, workbench, showNotice }: { selectedObject: string; setSelectedObject: (id: string) => void; workbench: Workbench; showNotice: (message: string) => void }) {
return <div className="viewport"><div className="viewport-header"><div className="viewport-title"><span className="eyebrow">{workbench}</span><strong>Body / Fillet</strong></div><div className="viewport-actions"><IconButton icon={ZoomOut} label="Zoom out" /><IconButton icon={ZoomIn} label="Zoom in" /><IconButton icon={Rotate3D} label="Orbit view" active /></div></div><div className="viewport-grid" onClick={() => setSelectedObject('')}><div className="grid-lines" /><div className={`mock-part ${selectedObject === 'pad' ? 'part-selected' : ''}`} onClick={(event) => { event.stopPropagation(); setSelectedObject('pad'); showNotice('Selected Pad') }}><div className="part-top" /><div className="part-front"><span className="part-hole one" /><span className="part-hole two" /><span className="part-cut" /></div><div className="part-side" /></div><div className="mock-datum-plane" /><div className="selection-callout"><span className="callout-line" /><span>Pad</span></div></div><div className="viewport-legend"><span><span className="legend-swatch selected" />Selected</span><span><span className="legend-swatch edge" />Edges</span><span><span className="legend-swatch datum" />Datum</span></div></div>
function Viewport({ selectedObject, setSelectedObject, workbench, facade, showNotice }: { selectedObject: string; setSelectedObject: (id: string) => void; workbench: Workbench; facade: BitBybitWebCadFacade; showNotice: (message: string) => void }) {
const hostRef = useRef<HTMLDivElement>(null)
useEffect(() => {
const host = hostRef.current
if (!host) return
const adapter = facade.viewport.createAdapter()
try {
adapter.mount(host)
adapter.setSelection(selectedObject)
return () => adapter.dispose()
} catch (error) {
showNotice(`WebGL viewport unavailable: ${error instanceof Error ? error.message : 'unknown error'}`)
adapter.dispose()
}
}, [facade, showNotice, selectedObject])
return <div className="viewport"><div className="viewport-header"><div className="viewport-title"><span className="eyebrow">{workbench}</span><strong>Body / Fillet</strong></div><div className="viewport-actions"><IconButton icon={ZoomOut} label="Zoom out" /><IconButton icon={ZoomIn} label="Zoom in" /><IconButton icon={Rotate3D} label="Orbit view" active /></div></div><div className="viewport-grid" onClick={() => setSelectedObject('')}><div className="three-viewport-host" ref={hostRef} aria-label="Three.js viewport" /><div className="grid-lines" /><div className={`mock-part ${selectedObject === 'pad' ? 'part-selected' : ''}`} onClick={(event) => { event.stopPropagation(); setSelectedObject('pad'); showNotice('Selected Pad') }}><div className="part-top" /><div className="part-front"><span className="part-hole one" /><span className="part-hole two" /><span className="part-cut" /></div><div className="part-side" /></div><div className="mock-datum-plane" /><div className="selection-callout"><span className="callout-line" /><span>Pad</span></div></div><div className="viewport-legend"><span><span className="legend-swatch selected" />Selected</span><span><span className="legend-swatch edge" />Edges</span><span><span className="legend-swatch datum" />Datum</span></div></div>
}
function PageFrame({ page, onNavigate, onOpenWorkspace, showNotice }: { page: Page; onNavigate: (page: Page) => void; onOpenWorkspace: () => void; showNotice: (message: string) => void }) {