feat: establish facade-first runtime boundary
This commit is contained in:
72
src/App.tsx
72
src/App.tsx
@@ -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 }) {
|
||||
|
||||
Reference in New Issue
Block a user