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 }) {
|
||||
|
||||
3
src/facade/index.ts
Normal file
3
src/facade/index.ts
Normal file
@@ -0,0 +1,3 @@
|
||||
export { createMockFacade } from './mockFacade'
|
||||
export { ThreeViewportAdapter } from './threeViewport'
|
||||
export type { BitBybitWebCadFacade, CommandState, DocumentSnapshot, FacadeEvent, FacadeState, ModelTreeItem, TaskSnapshot } from './types'
|
||||
71
src/facade/mockFacade.ts
Normal file
71
src/facade/mockFacade.ts
Normal file
@@ -0,0 +1,71 @@
|
||||
import { workbenchDefinitions, type WorkbenchId } from '../freecadManifest'
|
||||
import type {
|
||||
BitBybitWebCadFacade,
|
||||
CommandState,
|
||||
DocumentSnapshot,
|
||||
ExecuteCommandInput,
|
||||
FacadeEvent,
|
||||
FacadeListener,
|
||||
FacadeState,
|
||||
ModelTreeItem,
|
||||
TaskSnapshot,
|
||||
Unsubscribe,
|
||||
} from './types'
|
||||
import { ThreeViewportAdapter } from './threeViewport'
|
||||
|
||||
const initialTree: ModelTreeItem[] = [
|
||||
{ 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 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 })),
|
||||
})
|
||||
|
||||
const commandState = (commandId: string, activeWorkbench: WorkbenchId): CommandState => {
|
||||
const known = Object.values(workbenchDefinitions).some((definition) => definition.groups.some((group) => group.commands.some((command) => command.id === commandId)))
|
||||
if (!known) return { id: commandId, status: 'disabled', reason: 'Command is not registered in the active manifest.' }
|
||||
if (commandId === 'pad' && activeWorkbench !== 'Part Design') return { id: commandId, status: 'disabled', reason: 'Switch to Part Design to use Pad.' }
|
||||
return { id: commandId, status: 'enabled' }
|
||||
}
|
||||
|
||||
export function createMockFacade(): BitBybitWebCadFacade {
|
||||
let state: FacadeState = { apiVersion: '0.1', activeWorkbench: 'Part Design', selectedObjectId: 'pad', document: createDocument(), task: null, lastNotice: '' }
|
||||
const listeners = new Set<FacadeListener>()
|
||||
let requestSequence = 0
|
||||
|
||||
const emit = (event: FacadeEvent) => listeners.forEach((listener) => listener(event))
|
||||
const emitState = () => emit({ type: 'state.changed', state: getState() })
|
||||
const getState = () => ({ ...state, 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 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`) }
|
||||
const select = (objectId: string) => { state = { ...state, selectedObjectId: objectId }; emitState() }
|
||||
const beginTask = (commandId: string, draft: Record<string, unknown> = {}) => { const task: TaskSnapshot = { id: `task-${++requestSequence}`, commandId, title: workbenchDefinitions[state.activeWorkbench].taskTitle, status: 'preview', draft }; state = { ...state, task }; emitState(); return task }
|
||||
const execute = ({ commandId, payload }: ExecuteCommandInput) => {
|
||||
const requestId = `req-${++requestSequence}`
|
||||
const status = commandState(commandId, state.activeWorkbench)
|
||||
if (status.status === 'disabled') { emit({ type: 'command.failed', commandId, requestId, message: status.reason }); notify(status.reason || 'Command is disabled'); return requestId }
|
||||
emit({ type: 'command.started', commandId, requestId })
|
||||
if (commandId === 'new-document') state = { ...state, document: createDocument('Untitled document') }
|
||||
else if (commandId === 'save') state = { ...state, document: { ...state.document, dirty: false, version: state.document.version + 1 } }
|
||||
else if (commandId === 'select-object' && typeof payload?.objectId === 'string') select(payload.objectId)
|
||||
else if (commandId === 'create-body') beginTask('create-body')
|
||||
else if (commandId === 'create-sketch') beginTask('create-sketch')
|
||||
emit({ type: 'command.completed', commandId, requestId }); emitState(); return requestId
|
||||
}
|
||||
|
||||
const facade: BitBybitWebCadFacade = {
|
||||
app: { document: { getActive: () => getState().document, create: (label) => { state = { ...state, document: createDocument(label), selectedObjectId: '' }; emitState(); return getState().document }, markDirty: () => { state = { ...state, document: { ...state.document, dirty: true } }; emitState() } } },
|
||||
gui: { workbench: { list: () => Object.keys(workbenchDefinitions) as WorkbenchId[], getActive: () => state.activeWorkbench, setActive }, command: { getState: (commandId) => commandState(commandId, state.activeWorkbench), list: (workbench) => workbenchDefinitions[workbench].groups.flatMap((group) => group.commands), execute } },
|
||||
selection: { getObjectId: () => state.selectedObjectId, select, clear: () => select('') },
|
||||
task: { getActive: () => getState().task, begin: beginTask, update: (draft) => { if (state.task) state = { ...state, task: { ...state.task, draft: { ...state.task.draft, ...draft } } }; emitState() }, apply: () => { if (state.task) state = { ...state, task: { ...state.task, status: 'completed' } }; emitState() }, cancel: () => { if (state.task) state = { ...state, task: { ...state.task, status: 'cancelled' } }; emitState() } },
|
||||
viewport: { createAdapter: () => new ThreeViewportAdapter() },
|
||||
getState, subscribe: (listener) => { listeners.add(listener); return () => { listeners.delete(listener) } }, notify,
|
||||
}
|
||||
return facade
|
||||
}
|
||||
75
src/facade/threeViewport.ts
Normal file
75
src/facade/threeViewport.ts
Normal file
@@ -0,0 +1,75 @@
|
||||
import * as THREE from 'three'
|
||||
import type { BitBybitViewportAdapter } from './types'
|
||||
|
||||
/** Internal renderer boundary. React receives only the adapter contract. */
|
||||
export class ThreeViewportAdapter implements BitBybitViewportAdapter {
|
||||
private host: HTMLElement | null = null
|
||||
private renderer: THREE.WebGLRenderer | null = null
|
||||
private scene: THREE.Scene | null = null
|
||||
private camera: THREE.PerspectiveCamera | null = null
|
||||
private selection: THREE.Mesh | null = null
|
||||
private frame = 0
|
||||
|
||||
mount(host: HTMLElement) {
|
||||
this.host = host
|
||||
this.scene = new THREE.Scene()
|
||||
this.scene.background = new THREE.Color(0x0e1417)
|
||||
this.camera = new THREE.PerspectiveCamera(38, 1, 0.1, 1000)
|
||||
this.camera.position.set(3.8, 3.1, 5.2)
|
||||
this.camera.lookAt(0, 0, 0)
|
||||
this.renderer = new THREE.WebGLRenderer({ antialias: true, alpha: false, powerPreference: 'high-performance' })
|
||||
this.renderer.setPixelRatio(Math.min(window.devicePixelRatio || 1, 2))
|
||||
this.renderer.setSize(host.clientWidth || 1, host.clientHeight || 1, false)
|
||||
host.appendChild(this.renderer.domElement)
|
||||
const grid = new THREE.GridHelper(8, 16, 0x31555a, 0x1e3439)
|
||||
grid.rotation.x = 0
|
||||
this.scene.add(grid)
|
||||
const geometry = new THREE.BoxGeometry(2.7, 1.6, 1.4)
|
||||
const material = new THREE.MeshStandardMaterial({ color: 0x579a9c, roughness: 0.62, metalness: 0.1 })
|
||||
this.selection = new THREE.Mesh(geometry, material)
|
||||
this.selection.position.y = 0.8
|
||||
this.scene.add(this.selection)
|
||||
this.scene.add(new THREE.HemisphereLight(0xb8eeee, 0x142125, 2.1))
|
||||
const key = new THREE.DirectionalLight(0xffffff, 2.4)
|
||||
key.position.set(4, 6, 4)
|
||||
this.scene.add(key)
|
||||
const render = () => {
|
||||
if (!this.renderer || !this.scene || !this.camera) return
|
||||
this.selection && (this.selection.rotation.y += 0.002)
|
||||
this.renderer.render(this.scene, this.camera)
|
||||
this.frame = requestAnimationFrame(render)
|
||||
}
|
||||
this.resize(host.clientWidth || 1, host.clientHeight || 1)
|
||||
render()
|
||||
}
|
||||
|
||||
resize(width: number, height: number, devicePixelRatio = Math.min(window.devicePixelRatio || 1, 2)) {
|
||||
if (!this.renderer || !this.camera) return
|
||||
this.camera.aspect = Math.max(width, 1) / Math.max(height, 1)
|
||||
this.camera.updateProjectionMatrix()
|
||||
this.renderer.setPixelRatio(Math.min(devicePixelRatio, 2))
|
||||
this.renderer.setSize(Math.max(width, 1), Math.max(height, 1), false)
|
||||
}
|
||||
|
||||
setSelection(objectId: string) {
|
||||
if (!this.selection) return
|
||||
const material = this.selection.material as THREE.MeshStandardMaterial
|
||||
material.color.set(objectId ? 0x5ed6d6 : 0x579a9c)
|
||||
material.emissive.set(objectId ? 0x123c3c : 0x000000)
|
||||
}
|
||||
|
||||
dispose() {
|
||||
cancelAnimationFrame(this.frame)
|
||||
this.selection?.geometry.dispose()
|
||||
if (this.selection?.material instanceof THREE.Material) this.selection.material.dispose()
|
||||
this.renderer?.dispose()
|
||||
this.renderer?.domElement.remove()
|
||||
this.host = null
|
||||
this.renderer = null
|
||||
this.scene = null
|
||||
this.camera = null
|
||||
this.selection = null
|
||||
}
|
||||
|
||||
getBackend() { return 'webgl2' as const }
|
||||
}
|
||||
106
src/facade/types.ts
Normal file
106
src/facade/types.ts
Normal file
@@ -0,0 +1,106 @@
|
||||
import type { CommandDefinition, WorkbenchId } from '../freecadManifest'
|
||||
|
||||
export type ModelTreeItem = {
|
||||
id: string
|
||||
label: string
|
||||
type: 'document' | 'folder' | 'body' | 'sketch' | 'feature'
|
||||
state?: 'active' | 'valid' | 'warning' | 'readonly'
|
||||
detail?: string
|
||||
children?: string[]
|
||||
}
|
||||
|
||||
export type DocumentSnapshot = {
|
||||
id: string
|
||||
label: string
|
||||
version: number
|
||||
dirty: boolean
|
||||
readOnly: boolean
|
||||
units: string
|
||||
tree: ModelTreeItem[]
|
||||
}
|
||||
|
||||
export type CommandState = {
|
||||
id: string
|
||||
status: 'hidden' | 'disabled' | 'enabled' | 'active'
|
||||
reason?: string
|
||||
}
|
||||
|
||||
export type TaskSnapshot = {
|
||||
id: string
|
||||
commandId: string
|
||||
title: string
|
||||
status: 'idle' | 'preview' | 'committing' | 'cancelled' | 'completed' | 'failed'
|
||||
draft: Record<string, unknown>
|
||||
}
|
||||
|
||||
export type FacadeState = {
|
||||
apiVersion: '0.1'
|
||||
activeWorkbench: WorkbenchId
|
||||
selectedObjectId: string
|
||||
document: DocumentSnapshot
|
||||
task: TaskSnapshot | null
|
||||
lastNotice: string
|
||||
}
|
||||
|
||||
export type FacadeEvent =
|
||||
| { type: 'state.changed'; state: FacadeState }
|
||||
| { type: 'notice'; message: string }
|
||||
| { type: 'command.started' | 'command.completed' | 'command.failed'; commandId: string; requestId: string; message?: string }
|
||||
|
||||
export type FacadeListener = (event: FacadeEvent) => void
|
||||
export type Unsubscribe = () => void
|
||||
|
||||
export type ViewportBackend = 'webgl2' | 'webgpu'
|
||||
|
||||
export interface BitBybitViewportAdapter {
|
||||
mount(host: HTMLElement): void
|
||||
resize(width: number, height: number, devicePixelRatio?: number): void
|
||||
setSelection(objectId: string): void
|
||||
dispose(): void
|
||||
getBackend(): ViewportBackend
|
||||
}
|
||||
|
||||
export type ExecuteCommandInput = {
|
||||
commandId: string
|
||||
payload?: Record<string, unknown>
|
||||
}
|
||||
|
||||
export interface BitBybitWebCadFacade {
|
||||
readonly app: {
|
||||
document: {
|
||||
getActive(): DocumentSnapshot
|
||||
create(label?: string): DocumentSnapshot
|
||||
markDirty(): void
|
||||
}
|
||||
}
|
||||
readonly gui: {
|
||||
workbench: {
|
||||
list(): WorkbenchId[]
|
||||
getActive(): WorkbenchId
|
||||
setActive(id: WorkbenchId): void
|
||||
}
|
||||
command: {
|
||||
getState(commandId: string): CommandState
|
||||
list(workbench: WorkbenchId): CommandDefinition[]
|
||||
execute(input: ExecuteCommandInput): string
|
||||
}
|
||||
}
|
||||
readonly selection: {
|
||||
getObjectId(): string
|
||||
select(objectId: string): void
|
||||
clear(): void
|
||||
}
|
||||
readonly task: {
|
||||
getActive(): TaskSnapshot | null
|
||||
begin(commandId: string, draft?: Record<string, unknown>): TaskSnapshot
|
||||
update(draft: Record<string, unknown>): void
|
||||
apply(): void
|
||||
cancel(): void
|
||||
}
|
||||
readonly viewport: {
|
||||
createAdapter(): BitBybitViewportAdapter
|
||||
}
|
||||
getState(): FacadeState
|
||||
subscribe(listener: FacadeListener): Unsubscribe
|
||||
notify(message: string): void
|
||||
}
|
||||
@@ -299,6 +299,7 @@ button:focus-visible, input:focus-visible, select:focus-visible { outline: 2px s
|
||||
.combo-model { height: calc(100% - 39px); min-height: 0; display: flex; flex-direction: column; }.model-tree { min-height: 190px; flex: 1 1 58%; display: flex; flex-direction: column; }.tree-toolbar { display: flex; gap: 5px; padding: 10px 9px; }.tree-search { min-width: 0; flex: 1; display: flex; align-items: center; gap: 7px; height: 28px; padding: 0 8px; border: 1px solid var(--line); background: var(--bg-raised); border-radius: 3px; color: var(--text-muted); }.tree-search input { font-size: 11px; }.tree-root { padding: 0 4px; overflow: auto; }.tree-document-row { height: 29px; display: flex; align-items: center; gap: 6px; padding: 0 8px; color: var(--text); font-size: 11px; font-weight: 650; }.tree-version { margin-left: auto; color: var(--text-muted); font-size: 9px; font-weight: 500; }.tree-row { width: 100%; min-height: 29px; display: flex; align-items: center; gap: 5px; padding-right: 8px; border: 0; border-left: 2px solid transparent; background: transparent; color: var(--text-soft); text-align: left; font-size: 11px; cursor: pointer; }.tree-row:hover { background: var(--bg-hover); color: var(--text); }.tree-row.is-selected { color: var(--text); border-left-color: var(--cyan); background: var(--cyan-soft); }.tree-row.is-active .tree-label { color: var(--cyan); }.tree-label { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }.tree-spacer { width: 13px; }.tree-child { color: var(--text-muted); }.tree-children .tree-row { padding-left: 29px !important; }.active-marker { width: 5px; height: 5px; margin-left: auto; background: var(--cyan); border-radius: 50%; }.tree-footer { display: flex; gap: 13px; margin-top: auto; padding: 12px 12px 14px; border-top: 1px solid var(--line-soft); color: var(--text-muted); font-size: 9px; }.tree-footer span { display: inline-flex; align-items: center; gap: 5px; }.legend-dot { width: 5px; height: 5px; display: inline-block; border-radius: 50%; }.legend-dot.valid { background: var(--green); }.legend-dot.warning { background: var(--amber); }
|
||||
.task-panel { height: calc(100% - 39px); display: flex; flex-direction: column; overflow: auto; }.task-header { display: flex; align-items: center; gap: 9px; padding: 18px 15px; border-bottom: 1px solid var(--line-soft); }.task-header .badge { margin-left: auto; }.task-icon { width: 30px; height: 30px; display: grid; place-items: center; color: var(--cyan); background: var(--cyan-soft); border-radius: 4px; }.task-header h2 { margin: 4px 0 0; font-size: 13px; }.task-body { padding: 16px 15px; }.task-step { display: flex; gap: 9px; padding-bottom: 16px; margin-bottom: 16px; border-bottom: 1px solid var(--line-soft); }.step-index { width: 19px; height: 19px; display: grid; place-items: center; color: #142124; background: var(--cyan); border-radius: 50%; font-size: 10px; font-weight: 700; flex-shrink: 0; }.task-step strong, .task-step span { display: block; }.task-step strong { font-size: 11px; }.task-step span { color: var(--text-muted); font-size: 10px; line-height: 1.4; margin-top: 3px; }.field-label { display: block; color: var(--text-soft); font-size: 10px; margin-bottom: 14px; }.field-unit { float: right; color: var(--text-muted); }.field-input { width: 100%; height: 30px; display: block; margin-top: 6px; padding: 0 8px; border: 1px solid var(--line); border-radius: 3px; background: var(--bg-raised); color: var(--text); font-size: 11px; }.field-input:focus { border-color: #527479; outline: none; }.check-row { display: flex; align-items: center; gap: 7px; color: var(--text-soft); font-size: 10px; margin-top: 3px; }.check-row input { accent-color: var(--cyan); }.task-note { display: flex; align-items: flex-start; gap: 7px; margin-top: 18px; color: var(--amber); font-size: 10px; line-height: 1.4; }.task-footer { display: flex; gap: 5px; margin-top: auto; padding: 11px 12px; border-top: 1px solid var(--line); }.task-footer .button { flex: 1; padding: 0 7px; min-height: 31px; font-size: 11px; }
|
||||
.viewport-region { min-width: 0; position: relative; background: #0e1417; overflow: hidden; }.viewport { position: absolute; inset: 0; display: flex; flex-direction: column; }.viewport-header { height: 44px; display: flex; align-items: center; justify-content: space-between; padding: 0 16px; border-bottom: 1px solid rgba(85, 113, 119, .22); background: rgba(15, 22, 25, .86); z-index: 1; }.viewport-title { display: flex; flex-direction: column; gap: 3px; }.viewport-title strong { font-size: 11px; font-weight: 600; }.viewport-title .eyebrow { color: var(--text-muted); }.viewport-actions { display: flex; gap: 3px; }.viewport-grid { min-height: 0; flex: 1; position: relative; display: grid; place-items: center; overflow: hidden; cursor: crosshair; }.grid-lines { position: absolute; inset: -25%; opacity: .37; transform: perspective(500px) rotateX(62deg) translateY(13%); background-image: linear-gradient(rgba(81, 130, 136, .22) 1px, transparent 1px), linear-gradient(90deg, rgba(81, 130, 136, .22) 1px, transparent 1px); background-size: 28px 28px; }.mock-part { position: relative; z-index: 2; width: 285px; height: 180px; transform: translate(5%, -4%) skewY(-20deg) rotate(21deg); filter: drop-shadow(24px 22px 0 rgba(0, 0, 0, .16)); cursor: pointer; }.part-front { position: absolute; inset: 0; border: 1px solid #8bd2cf; background: #447d82; box-shadow: inset -12px -14px 0 rgba(13, 36, 41, .25); }.part-top { position: absolute; width: 285px; height: 64px; top: -48px; left: 0; border: 1px solid #83c7c6; background: #60999a; transform: skewX(-35deg); transform-origin: bottom left; }.part-side { position: absolute; width: 50px; height: 180px; right: -34px; top: -24px; border: 1px solid #6ba9a9; background: #34636b; transform: skewY(55deg); transform-origin: top left; }.part-hole { position: absolute; z-index: 3; width: 38px; height: 38px; border-radius: 50%; border: 7px solid #223f44; background: #111b1e; box-shadow: 0 0 0 1px #95d9d5, inset 0 0 0 3px #17282c; }.part-hole.one { left: 55px; top: 51px; }.part-hole.two { right: 50px; top: 51px; }.part-cut { position: absolute; width: 125px; height: 25px; left: 81px; bottom: 31px; border: 1px solid rgba(168, 224, 219, .72); background: rgba(31, 70, 74, .42); }.part-selected .part-front { background: #4e9799; border-color: #b9f1eb; box-shadow: 0 0 0 2px rgba(94, 214, 214, .3), inset -12px -14px 0 rgba(13, 36, 41, .25); }.mock-datum-plane { position: absolute; z-index: 1; width: 320px; height: 205px; border: 1px dashed rgba(239, 190, 114, .42); background: rgba(239, 190, 114, .04); transform: translate(28%, 25%) skewY(-22deg) rotate(21deg); }.selection-callout { position: absolute; z-index: 3; top: 25%; left: 68%; display: flex; align-items: center; gap: 7px; color: var(--cyan); font-size: 10px; }.callout-line { width: 42px; height: 1px; background: var(--cyan); position: relative; }.callout-line::after { content: ''; width: 5px; height: 5px; position: absolute; left: 0; top: -2px; border-radius: 50%; background: var(--cyan); }.viewport-legend { position: absolute; z-index: 4; left: 16px; bottom: 14px; display: flex; gap: 12px; color: var(--text-muted); font-size: 9px; }.viewport-legend span { display: inline-flex; align-items: center; gap: 5px; }.legend-swatch { width: 12px; height: 3px; display: inline-block; border-radius: 2px; }.legend-swatch.selected { background: var(--cyan); }.legend-swatch.edge { background: #8cb7b6; }.legend-swatch.datum { border-top: 1px dashed var(--amber); }.viewport-bottom-left { position: absolute; z-index: 5; left: 16px; bottom: 42px; display: flex; gap: 5px; }.view-chip { display: flex; align-items: center; gap: 5px; padding: 5px 7px; color: var(--text-muted); background: rgba(25, 34, 38, .85); border: 1px solid rgba(93, 124, 129, .32); border-radius: 3px; font-size: 9px; }.axis-widget { width: 64px; height: 64px; position: relative; margin: 0 23px 23px 0; }.axis-origin { width: 7px; height: 7px; position: absolute; left: 28px; top: 30px; border-radius: 50%; background: #d9e3e3; }.axis-widget::before, .axis-widget::after { content: ''; position: absolute; left: 31px; top: 31px; width: 29px; height: 1px; transform-origin: left center; }.axis-widget::before { background: #d77f7e; transform: rotate(-18deg); }.axis-widget::after { background: #78d19f; transform: rotate(-80deg); }.axis-x, .axis-y, .axis-z { position: absolute; font-size: 9px; font-weight: 700; }.axis-x { right: 0; top: 19px; color: #e58b88; }.axis-y { left: 18px; top: 0; color: #7bd7a0; }.axis-z { left: 19px; bottom: 0; color: #8abcf0; }
|
||||
.three-viewport-host { position: absolute; inset: 0; z-index: 1; opacity: .28; pointer-events: none; }.three-viewport-host canvas { width: 100%; height: 100%; display: block; }
|
||||
.properties-panel { display: flex; flex-direction: column; }.property-heading { display: flex; align-items: flex-start; justify-content: space-between; min-height: 75px; padding: 16px 13px 10px; }.property-heading h2 { margin: 5px 0 0; font-size: 15px; font-weight: 650; }.property-tabs { height: 34px; }.property-tabs button { padding: 0 14px; }.properties-scroll { overflow: auto; padding-bottom: 20px; }.property-group { border-bottom: 1px solid var(--line-soft); padding: 0 13px 10px; }.property-group-title { display: flex; align-items: center; justify-content: space-between; padding: 12px 0 8px; color: var(--text-muted); font-size: 10px; font-weight: 700; letter-spacing: .08em; text-transform: uppercase; }.property-row { min-height: 28px; display: grid; grid-template-columns: 43% 57%; align-items: center; gap: 5px; }.property-label { color: var(--text-muted); font-size: 10px; }.property-value { min-width: 0; display: flex; align-items: center; justify-content: flex-end; gap: 6px; border: 0; padding: 4px 0; background: transparent; color: var(--text-soft); text-align: right; font-size: 10px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }.property-value:disabled { opacity: 1; }.property-value.is-editable, .property-value.is-link { color: var(--text); cursor: pointer; }.property-value.is-editable:hover { color: var(--cyan); }.property-value.is-link { color: var(--cyan); }.status-pill { height: 16px; display: inline-flex; align-items: center; padding: 0 4px; border-radius: 2px; font-size: 8px; }.status-pill.green { color: var(--green); background: var(--green-soft); }.status-pill.amber { color: var(--amber); background: var(--amber-soft); }.expression-row { min-height: 29px; display: flex; align-items: center; gap: 6px; color: var(--text-muted); font-size: 10px; }.expression-row .expression-value { margin-left: auto; color: var(--cyan); }.expression-row button { display: grid; place-items: center; border: 0; background: transparent; color: var(--text-muted); cursor: pointer; }.expression-row button:hover { color: var(--text); }
|
||||
.bottom-drawer { height: 35px; flex-shrink: 0; border-top: 1px solid var(--line); background: var(--bg-panel); transition: height .16s ease; }.bottom-drawer.is-open { height: 122px; }.bottom-drawer-header { height: 35px; display: flex; align-items: center; justify-content: space-between; }.drawer-tabs { height: 100%; display: flex; align-items: stretch; gap: 2px; padding-left: 9px; }.drawer-tabs button { display: flex; align-items: center; gap: 6px; border: 0; border-bottom: 2px solid transparent; background: transparent; color: var(--text-muted); font-size: 10px; padding: 0 8px; cursor: pointer; }.drawer-tabs button.is-active { color: var(--text); border-bottom-color: var(--cyan); }.drawer-toggle { width: 32px; height: 30px; margin-right: 5px; display: grid; place-items: center; border: 0; color: var(--text-muted); background: transparent; cursor: pointer; }.drawer-toggle:hover { color: var(--text); }.bottom-drawer-content { padding: 5px 13px 9px; border-top: 1px solid var(--line-soft); }.report-line { min-height: 30px; display: flex; align-items: center; gap: 8px; color: var(--text-soft); font-size: 10px; }.report-context { color: var(--text-muted); }.report-line button { border: 0; background: transparent; color: var(--cyan); font-size: 10px; cursor: pointer; }.report-time { margin-left: auto; color: var(--text-muted); }.icon-green { color: var(--green); }.icon-amber { color: var(--amber); }.icon-cyan { color: var(--cyan); }.icon-muted { color: var(--text-muted); }
|
||||
.toast { position: fixed; z-index: 30; right: 22px; bottom: 22px; display: flex; align-items: center; gap: 8px; padding: 11px 13px; color: #10231d; background: var(--green); border-radius: 4px; box-shadow: 0 10px 25px rgba(0, 0, 0, .28); font-size: 11px; font-weight: 650; }
|
||||
|
||||
Reference in New Issue
Block a user