584 lines
62 KiB
TypeScript
584 lines
62 KiB
TypeScript
import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
|
|
import type { ComponentType, ReactNode } from 'react'
|
|
import {
|
|
AlertTriangle,
|
|
Archive,
|
|
ArrowDownToLine,
|
|
ArrowLeft,
|
|
ArrowRight,
|
|
Box,
|
|
Check,
|
|
CheckCircle2,
|
|
ChevronDown,
|
|
ChevronRight,
|
|
Circle,
|
|
CircleDot,
|
|
Clock3,
|
|
Code2,
|
|
Command,
|
|
Copy,
|
|
Download,
|
|
FileBox,
|
|
FilePlus2,
|
|
FileText,
|
|
FolderOpen,
|
|
HardDrive,
|
|
HelpCircle,
|
|
Layers3,
|
|
LayoutGrid,
|
|
ListTree,
|
|
Menu,
|
|
MoreHorizontal,
|
|
PanelBottom,
|
|
PanelLeft,
|
|
PanelRight,
|
|
Pause,
|
|
Pencil,
|
|
Plus,
|
|
Rotate3D,
|
|
Save,
|
|
Search,
|
|
Settings2,
|
|
SlidersHorizontal,
|
|
Sparkles,
|
|
SquareStack,
|
|
Trash2,
|
|
Undo2,
|
|
Redo2,
|
|
RefreshCw,
|
|
Upload,
|
|
UserRound,
|
|
WandSparkles,
|
|
X,
|
|
ZoomIn,
|
|
ZoomOut,
|
|
} from 'lucide-react'
|
|
import { menuDefinitions, pinnedWorkbenches, workbenchDefinitions, type MenuName, type WorkbenchId } from './freecadManifest'
|
|
import { createMockFacade, type BitBybitViewportAdapter, type BitBybitWebCadFacade, type DocumentSnapshot, type FcstdInspection, type ModelTreeItem, type ObjectPropertySnapshot, type PropertyValue, type ShapeHandle } from './facade'
|
|
|
|
type Page = 'start' | 'projects' | 'workspace' | 'import' | 'export' | 'settings' | 'help' | 'diagnostics' | 'sync'
|
|
type Workbench = WorkbenchId
|
|
|
|
type Icon = ComponentType<{ size?: string | number; strokeWidth?: string | number; className?: string }>
|
|
|
|
const pageFromPath = (): Page => {
|
|
const path = window.location.pathname
|
|
if (path.startsWith('/projects')) return 'projects'
|
|
if (path.startsWith('/workspace')) return 'workspace'
|
|
if (path.startsWith('/import')) return 'import'
|
|
if (path.startsWith('/export')) return 'export'
|
|
if (path.startsWith('/settings')) return 'settings'
|
|
if (path.startsWith('/help')) return 'help'
|
|
if (path.startsWith('/diagnostics')) return 'diagnostics'
|
|
if (path.startsWith('/sync')) return 'sync'
|
|
return 'start'
|
|
}
|
|
|
|
const iconMap: Record<string, Icon> = {
|
|
box: Box, 'circle-dot': CircleDot, 'square-stack': SquareStack, 'file-text': FileText, 'layout-grid': LayoutGrid,
|
|
layers: Layers3, archive: Archive, pencil: Pencil, copy: Copy, 'arrow-up': ArrowDownToLine, 'arrow-down': ArrowDownToLine,
|
|
rotate: Rotate3D, 'sliders-horizontal': SlidersHorizontal, search: Search, upload: Upload, download: Download, 'file-plus': FilePlus2,
|
|
'code-2': Code2, 'settings-2': Settings2, 'circle-check': CheckCircle2, 'triangle-alert': AlertTriangle, eye: CircleDot,
|
|
'zoom-in': ZoomIn, 'grid-3x3': LayoutGrid, play: Check, plus: Plus, minus: X, circle: Circle, route: Rotate3D, 'git-branch': Layers3,
|
|
'layers-2': Layers3, 'flip-horizontal': ArrowLeft, repeat: Rotate3D, 'refresh-cw': Rotate3D, shuffle: MoreHorizontal,
|
|
'corner-down-right': SlidersHorizontal, scissors: X, triangle: Box, paperclip: Copy, 'move-horizontal': ArrowRight, 'move-vertical': ArrowDownToLine,
|
|
magnet: CircleDot, move: ArrowRight, type: FileText, 'panels-top-left': LayoutGrid, maximize: PanelRight, table: LayoutGrid, tag: FileText,
|
|
'rows-3': ListTree, link: Copy, paintbrush: WandSparkles, pin: PanelLeft, anchor: CircleDot, split: ArrowRight, target: CircleDot,
|
|
protractor: SlidersHorizontal, activity: Rotate3D, lock: Circle, spline: Rotate3D, crosshair: CircleDot, 'file-box': FileBox,
|
|
}
|
|
|
|
const workbenchIconKeys: Record<Workbench, string> = {
|
|
'Part Design': 'box', Part: 'box', Sketcher: 'circle-dot', Draft: 'square-stack', BIM: 'layers', TechDraw: 'file-text', Spreadsheet: 'layout-grid', Assembly: 'layers', CAM: 'settings-2', FEM: 'activity', Mesh: 'archive', Surface: 'spline', Inspection: 'search',
|
|
}
|
|
|
|
const workbenchMeta: Record<Workbench, { icon: Icon; accent: string; description: string }> = Object.fromEntries(
|
|
Object.entries(workbenchDefinitions).map(([id, definition]) => [id, { icon: iconMap[workbenchIconKeys[id as Workbench]] || MoreHorizontal, accent: id === 'Sketcher' ? 'violet' : id === 'Part' ? 'amber' : 'cyan', description: definition.description }]),
|
|
) as Record<Workbench, { icon: Icon; accent: string; description: string }>
|
|
|
|
function CuboidIcon({ size = 18, strokeWidth = 1.8, className }: { size?: string | number; strokeWidth?: string | number; className?: string }) {
|
|
return <Box size={size} strokeWidth={strokeWidth} className={className} />
|
|
}
|
|
|
|
const projects = [
|
|
{ name: 'Pump Housing', path: 'Local / Mechanical / Pump Housing', modified: 'Just now', objects: 18, state: 'Unsaved' },
|
|
{ name: 'Mounting Bracket', path: 'Local / Concepts / Mounting Bracket', modified: 'Yesterday', objects: 9, state: 'Saved' },
|
|
{ name: 'Motor Assembly', path: 'Local / Assemblies / Motor Assembly', modified: '28 Jul 2026', objects: 142, state: 'Saved' },
|
|
{ name: 'Valve Body Study', path: 'Shared / Review / Valve Body Study', modified: '22 Jul 2026', objects: 31, state: 'Read only' },
|
|
]
|
|
|
|
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} />
|
|
</button>
|
|
)
|
|
|
|
const Badge = ({ tone, children }: { tone: 'cyan' | 'green' | 'amber' | 'red' | 'muted'; children: ReactNode }) => (
|
|
<span className={`badge badge-${tone}`}>
|
|
<span className="badge-dot" />
|
|
{children}
|
|
</span>
|
|
)
|
|
|
|
function App() {
|
|
const [page, setPage] = useState<Page>(pageFromPath)
|
|
const [leftTab, setLeftTab] = useState<'model' | 'tasks'>('model')
|
|
const [rightTab, setRightTab] = useState<'data' | 'view'>('data')
|
|
const [bottomOpen, setBottomOpen] = useState(true)
|
|
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(() => facade.project.subscribeExternalChanges((notice) => {
|
|
setNotice(`Project saved in another tab · v${notice.documentVersion}`)
|
|
window.setTimeout(() => setNotice(''), 2600)
|
|
}), [facade])
|
|
|
|
useEffect(() => () => facade.geometry.dispose(), [facade])
|
|
|
|
useEffect(() => {
|
|
const handlePopState = () => setPage(pageFromPath())
|
|
window.addEventListener('popstate', handlePopState)
|
|
return () => window.removeEventListener('popstate', handlePopState)
|
|
}, [])
|
|
|
|
const navigate = (next: Page, replace = false) => {
|
|
const paths: Record<Page, string> = {
|
|
start: '/start',
|
|
projects: '/projects',
|
|
workspace: '/workspace/pump-housing',
|
|
import: '/import',
|
|
export: '/export',
|
|
settings: '/settings',
|
|
help: '/help',
|
|
diagnostics: '/diagnostics',
|
|
sync: '/sync',
|
|
}
|
|
window.history[replace ? 'replaceState' : 'pushState']({}, '', paths[next])
|
|
setPage(next)
|
|
}
|
|
|
|
const openWorkspace = () => navigate('workspace')
|
|
const workbench = facadeState.activeWorkbench
|
|
const selectedObject = facadeState.selectedObjectId
|
|
const currentWorkbench = workbenchMeta[workbench]
|
|
const CurrentIcon = currentWorkbench.icon
|
|
|
|
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" data-persistence-mode={facadeState.persistence.mode}>
|
|
<TopBar page={page} workbench={workbench} facade={facade} onNavigate={navigate} onOpenWorkspace={openWorkspace} />
|
|
{page === 'workspace' ? (
|
|
<Workspace
|
|
workbench={workbench}
|
|
setWorkbench={(value) => { setWorkbench(value); setLeftTab('tasks') }}
|
|
leftTab={leftTab}
|
|
setLeftTab={setLeftTab}
|
|
rightTab={rightTab}
|
|
setRightTab={setRightTab}
|
|
bottomOpen={bottomOpen}
|
|
setBottomOpen={setBottomOpen}
|
|
selectedObject={selectedObject}
|
|
setSelectedObject={setSelectedObject}
|
|
document={facadeState.document}
|
|
facade={facade}
|
|
workbenchIcon={CurrentIcon}
|
|
showNotice={showNotice}
|
|
/>
|
|
) : (
|
|
<PageFrame page={page} onNavigate={navigate} onOpenWorkspace={openWorkspace} showNotice={showNotice} facade={facade} />
|
|
)}
|
|
{notice && <div className="toast"><CheckCircle2 size={16} />{notice}</div>}
|
|
</div>
|
|
)
|
|
}
|
|
|
|
function TopBar({ page, workbench, facade, onNavigate, onOpenWorkspace }: { page: Page; workbench: Workbench; facade: BitBybitWebCadFacade; onNavigate: (page: Page) => void; onOpenWorkspace: () => void }) {
|
|
const [openMenu, setOpenMenu] = useState<MenuName | null>(null)
|
|
const document = facade.getState().document
|
|
const handleMenuCommand = (command: string) => {
|
|
setOpenMenu(null)
|
|
if (command === 'help' || command === 'shortcuts') onNavigate('help')
|
|
else if (command === 'diagnostics') onNavigate('diagnostics')
|
|
else if (command === 'preferences' || command === 'project-settings' || command === 'parameters') onNavigate('settings')
|
|
else if (command === 'import') onNavigate('import')
|
|
else if (command === 'export') onNavigate('export')
|
|
else if (command === 'open') onNavigate('projects')
|
|
else onOpenWorkspace()
|
|
}
|
|
return (
|
|
<header className="topbar">
|
|
<div className="brand-lockup" onClick={() => onNavigate('start')} role="button" tabIndex={0}>
|
|
<div className="brand-mark"><span /><span /><span /></div>
|
|
<div><div className="brand-name">BitBybit</div><div className="brand-product">CAD studio</div></div>
|
|
</div>
|
|
<nav className="top-menu" aria-label="Application menu">
|
|
{(Object.keys(menuDefinitions) as MenuName[]).map((menu) => <div className="menu-wrapper" key={menu}><button className={openMenu === menu ? 'is-open' : ''} onClick={() => setOpenMenu(openMenu === menu ? null : menu)} aria-expanded={openMenu === menu}>{menu}</button>{openMenu === menu && <div className="menu-popover">{menuDefinitions[menu].map((item) => <button key={item.command} onClick={() => handleMenuCommand(item.command)}><span>{item.label}</span>{'shortcut' in item && <kbd>{item.shortcut}</kbd>}</button>)}</div>}</div>)}
|
|
</nav>
|
|
<div className="topbar-spacer" />
|
|
<div className="topbar-context">
|
|
<div className="context-document"><span className="context-dot" />{document.label} <span className="context-separator">/</span> {workbench}</div>
|
|
<span className="save-state"><span className="save-dot" />{document.dirty ? 'Unsaved changes' : 'Saved locally'}</span>
|
|
</div>
|
|
<IconButton icon={Undo2} label="Undo" onClick={() => facade.history.undo()} disabled={!facade.history.canUndo()} />
|
|
<IconButton icon={Redo2} label="Redo" onClick={() => facade.history.redo()} disabled={!facade.history.canRedo()} />
|
|
<div className="topbar-divider" />
|
|
<button className="command-search" onClick={() => onNavigate('help')}><Search size={15} /><span>Search commands</span><kbd>⌘ K</kbd></button>
|
|
<IconButton icon={HelpCircle} label="Help" onClick={() => onNavigate('help')} />
|
|
<button className="avatar-button" title="Account and sync" onClick={() => onNavigate('sync')}><UserRound size={16} /><span>MS</span></button>
|
|
</header>
|
|
)
|
|
}
|
|
|
|
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'
|
|
setLeftTab: (tab: 'model' | 'tasks') => void
|
|
rightTab: 'data' | 'view'
|
|
setRightTab: (tab: 'data' | 'view') => void
|
|
bottomOpen: boolean
|
|
setBottomOpen: (open: boolean) => void
|
|
selectedObject: string
|
|
setSelectedObject: (id: string) => void
|
|
document: DocumentSnapshot
|
|
facade: BitBybitWebCadFacade
|
|
workbenchIcon: Icon
|
|
showNotice: (message: string) => void
|
|
}) {
|
|
return (
|
|
<main className="workspace-page">
|
|
<div className="workspace-toolbar">
|
|
<div className="workbench-picker"><WorkbenchIcon size={17} /><select value={workbench} onChange={(event) => setWorkbench(event.target.value as Workbench)} aria-label="Workbench">{Object.keys(workbenchDefinitions).map((option) => <option key={option}>{option}</option>)}</select><ChevronDown size={14} /></div>
|
|
<div className="toolbar-divider" />
|
|
<IconButton icon={FilePlus2} label="New document" onClick={() => { facade.gui.command.execute({ commandId: 'new-document' }); showNotice('New document prepared') }} />
|
|
<IconButton icon={FolderOpen} label="Open project" onClick={() => showNotice('Project browser opened')} />
|
|
<IconButton icon={Save} label="Save project" onClick={() => { facade.gui.command.execute({ commandId: 'save' }); showNotice('Saved to local workspace') }} />
|
|
<IconButton icon={RefreshCw} label="Recompute document" disabled={document.readOnly} onClick={() => { void facade.app.document.recomputeAsync().then((result) => showNotice(result.status === 'completed' ? 'Recompute completed' : `Recompute ${result.status}`)) }} />
|
|
<div className="toolbar-divider" />
|
|
<IconButton icon={Box} label="Create body" onClick={() => { facade.gui.command.execute({ commandId: 'create-body' }); showNotice('Create Body task opened') }} active={workbench === 'Part Design'} />
|
|
<IconButton icon={CircleDot} label="Create sketch" onClick={() => { facade.gui.command.execute({ commandId: 'create-sketch' }); showNotice('Select a plane to create a sketch') }} />
|
|
<IconButton icon={Rotate3D} label="Transform" />
|
|
<IconButton icon={SlidersHorizontal} label="Measure" />
|
|
<div className="toolbar-spacer" />
|
|
<div className="toolbar-status"><span className="status-pulse" />Local workspace</div>
|
|
<IconButton icon={PanelLeft} label="Toggle left panel" />
|
|
<IconButton icon={PanelRight} label="Toggle right panel" />
|
|
</div>
|
|
<WorkbenchNavBar workbench={workbench} setWorkbench={setWorkbench} showNotice={showNotice} />
|
|
<div className="document-tabs">
|
|
<button className="doc-tab is-active"><FileBox size={15} /><span>{document.label}</span>{document.dirty && <span className="doc-unsaved" />}<X size={13} /></button>
|
|
<button className="doc-tab" onClick={() => showNotice('Open Mounting Bracket') }><FileBox size={15} /><span>Mounting Bracket</span><X size={13} /></button>
|
|
<button className="new-tab" title="New document"><Plus size={16} /></button>
|
|
<div className="document-tab-spacer" />
|
|
<span className="document-meta">{document.units} · {document.objects.length} objects · v{document.version}</span>
|
|
</div>
|
|
<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><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} documentVersion={document.version} recomputeGeneration={document.recompute?.generation ?? 0} 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>
|
|
<aside className="task-dock right-panel"><div className="task-dock-title"><div><span className="eyebrow">Task panel</span><h2>{workbenchDefinitions[workbench].taskTitle}</h2></div><IconButton icon={MoreHorizontal} label="Task panel actions" /></div><TaskPanel workbench={workbench} facade={facade} showNotice={showNotice} /></aside>
|
|
<FunctionRail workbench={workbench} facade={facade} showNotice={showNotice} />
|
|
</div>
|
|
<div className={`bottom-drawer ${bottomOpen ? 'is-open' : ''}`}>
|
|
<div className="bottom-drawer-header"><div className="drawer-tabs"><button className="is-active"><PanelBottom size={14} />Report view <span className="tab-count">2</span></button><button><Clock3 size={14} />Jobs <span className="tab-count tab-count-green">1</span></button><button><Code2 size={14} />Diagnostics</button></div><button className="drawer-toggle" onClick={() => setBottomOpen(!bottomOpen)} title={bottomOpen ? 'Collapse bottom panel' : 'Expand bottom panel'}>{bottomOpen ? <ChevronDown size={15} /> : <ChevronRight size={15} />}</button></div>
|
|
{bottomOpen && <div className="bottom-drawer-content"><div className="report-line">{document.recompute?.status === 'failed' ? <AlertTriangle size={14} className="icon-amber" /> : <CheckCircle2 size={14} className="icon-green" />}<span>Recompute {document.recompute?.status || 'idle'}</span><span className="report-context">{document.recompute?.order.length || 0} objects in plan</span><span className="report-time">generation {document.recompute?.generation || 0}</span></div>{(document.recompute?.errors || []).slice(0, 2).map((error) => <div className="report-line" key={`${error.objectId}-${error.code}`}><AlertTriangle size={14} className="icon-amber" /><span>{error.message}</span><button onClick={() => showNotice(`Dependency review: ${error.objectId}`)}>Review dependency</button><span className="report-time">{error.code}</span></div>)}</div>}
|
|
</div>
|
|
</main>
|
|
)
|
|
}
|
|
|
|
function WorkbenchNavBar({ workbench, setWorkbench, showNotice }: { workbench: Workbench; setWorkbench: (value: Workbench) => void; showNotice: (message: string) => void }) {
|
|
return <div className="workbench-nav" aria-label="Workbench navigation"><span className="nav-caption">Workbench</span>{pinnedWorkbenches.map((item) => { const IconComponent = workbenchMeta[item].icon; return <button key={item} className={workbench === item ? 'is-active' : ''} onClick={() => { setWorkbench(item); showNotice(`${item} workbench loaded`) }}><IconComponent size={13} /><span>{item}</span></button> })}<button className="nav-more" onClick={() => showNotice('All workbenches available from the picker')}><MoreHorizontal size={14} /><span>More workbenches</span></button></div>
|
|
}
|
|
|
|
function FunctionRail({ workbench, facade, showNotice }: { workbench: Workbench; facade: BitBybitWebCadFacade; showNotice: (message: string) => void }) {
|
|
const groups = workbenchDefinitions[workbench].groups
|
|
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; const state = facade.gui.command.getState(item.id); const title = state.status === 'disabled' && state.reason ? `${item.label}: ${state.reason}` : `${item.label}${item.shortcut ? ` (${item.shortcut})` : ''}`; return <button key={item.id} title={title} aria-label={title} disabled={state.status === 'disabled'} onClick={() => { facade.gui.command.execute({ commandId: item.id }); showNotice(`${item.label} command staged`) }}><IconComponent size={15} /></button> })}</div>)}</aside>
|
|
}
|
|
|
|
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 })
|
|
const itemsById = new Map(document.tree.map((item) => [item.id, item]))
|
|
const childIds = new Set(document.tree.flatMap((item) => item.children || []))
|
|
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>{document.label}</span><span className="tree-version">v{document.version}</span></div>
|
|
{document.tree.filter((item) => !childIds.has(item.id)).map((item) => <TreeItem key={item.id} item={item} level={1} expanded={expanded[item.id]} onToggle={() => setExpanded((current) => ({ ...current, [item.id]: !current[item.id] }))} itemsById={itemsById} 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, itemsById, selectedObject, setSelectedObject, showNotice }: { item: ModelTreeItem; level: number; expanded?: boolean; onToggle: () => void; itemsById: Map<string, ModelTreeItem>; 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
|
|
return <>
|
|
<button className={`tree-row ${selectedObject === item.id ? 'is-selected' : ''} ${item.state === 'active' ? 'is-active' : ''}`} style={{ paddingLeft: `${level * 14}px` }} onClick={() => { setSelectedObject(item.id); if (item.state === 'warning') showNotice('This feature has a reference warning') }} onDoubleClick={() => showNotice(`Editing ${item.label}`)}>
|
|
{hasChildren ? <span onClick={(event) => { event.stopPropagation(); onToggle() }}>{expanded ? <ChevronDown size={13} /> : <ChevronRight size={13} />}</span> : <span className="tree-spacer" />}
|
|
<ItemIcon size={15} className={item.type === 'body' ? 'icon-cyan' : item.state === 'warning' ? 'icon-amber' : 'icon-muted'} />
|
|
<span className="tree-label">{item.label}</span>
|
|
{item.state === 'warning' && <AlertTriangle size={13} className="icon-amber" />}
|
|
{item.state === 'active' && <span className="active-marker" />}
|
|
</button>
|
|
{expanded && hasChildren && <div className="tree-children">{item.children?.map((childId) => { const child = itemsById.get(childId); const ChildIcon = child?.type === 'sketch' ? CircleDot : child?.type === 'feature' ? SquareStack : Circle; return child ? <button className={`tree-row tree-child ${selectedObject === child.id ? 'is-selected' : ''}`} key={child.id} onClick={() => { setSelectedObject(child.id); if (child.state === 'warning') showNotice('This feature has a reference warning') }} onDoubleClick={() => showNotice(`Editing ${child.label}`)}><span className="tree-spacer" /><ChildIcon size={child.type === 'feature' ? 15 : 13} className={child.state === 'warning' ? 'icon-amber' : 'icon-muted'} /><span className="tree-label">{child.label}</span>{child.state === 'warning' && <AlertTriangle size={13} className="icon-amber" />}</button> : <div className="tree-row tree-child" key={childId}><span className="tree-spacer" /><Circle size={8} className="icon-muted" /><span className="tree-label">{childId}</span></div> })}</div>}
|
|
</>
|
|
}
|
|
|
|
function TaskPanel({ workbench, facade, showNotice }: { workbench: Workbench; facade: BitBybitWebCadFacade; showNotice: (message: string) => void }) {
|
|
const definition = workbenchDefinitions[workbench]
|
|
const isSketch = workbench === 'Sketcher'
|
|
const activeTask = facade.task.getActive()
|
|
const primitiveType = activeTask?.commandId === 'primitive' && typeof activeTask.draft.primitiveType === 'string' ? activeTask.draft.primitiveType : 'Box'
|
|
const primitiveFields = primitiveType === 'Box' ? ['width', 'length', 'height'] : primitiveType === 'Cylinder' ? ['radius', 'height', 'angle'] : primitiveType === 'Sphere' ? ['radius'] : ['radius1', 'radius2', 'height', 'angle']
|
|
const primitiveUnits: Record<string, string> = { width: 'mm', length: 'mm', height: 'mm', radius: 'mm', radius1: 'mm', radius2: 'mm', angle: 'deg' }
|
|
const primitiveFallbacks: Record<string, number> = { width: 10, length: 10, height: 10, radius: 5, radius1: 5, radius2: 0, angle: 360 }
|
|
const primitiveLabel = (field: string) => field === 'radius1' ? 'Bottom radius' : field === 'radius2' ? 'Top radius' : field[0].toUpperCase() + field.slice(1)
|
|
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>{activeTask?.commandId === 'primitive' && <><label className="field-label">Primitive type<select className="field-input" value={primitiveType} onChange={(event) => facade.task.update({ primitiveType: event.target.value })}><option>Box</option><option>Cylinder</option><option>Sphere</option><option>Cone</option></select></label>{primitiveFields.map((field) => <label className="field-label" key={field}>{primitiveLabel(field)} <span className="field-unit">{primitiveUnits[field]}</span><input className="field-input" type="number" min={field === 'radius2' ? 0 : 0.001} step={primitiveUnits[field] === 'deg' ? 1 : 0.1} value={typeof activeTask.draft[field] === 'number' ? Number(activeTask.draft[field]) : primitiveFallbacks[field]} onChange={(event) => facade.task.update({ [field]: Number(event.target.value) })} /></label>)}</>}{activeTask?.commandId !== 'primitive' && <><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 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 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::PropertyAngle' || 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::PropertyAngle' || property.type === 'App::PropertyPercent' ? 0 : undefined} max={property.type === 'App::PropertyAngle' ? 360 : 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, documentVersion, recomputeGeneration, showNotice }: { selectedObject: string; setSelectedObject: (id: string) => void; workbench: Workbench; facade: BitBybitWebCadFacade; documentVersion: number; recomputeGeneration: number; showNotice: (message: string) => void }) {
|
|
const hostRef = useRef<HTMLDivElement>(null)
|
|
const adapterRef = useRef<BitBybitViewportAdapter | null>(null)
|
|
useEffect(() => {
|
|
const host = hostRef.current
|
|
if (!host) return
|
|
const adapter = facade.viewport.createAdapter()
|
|
adapterRef.current = adapter
|
|
let cancelled = false
|
|
const shapes: ShapeHandle[] = []
|
|
let ownsShapes = false
|
|
try {
|
|
adapter.mount(host)
|
|
} catch (error) {
|
|
showNotice(`WebGL viewport unavailable: ${error instanceof Error ? error.message : 'unknown error'}`)
|
|
adapter.dispose()
|
|
adapterRef.current = null
|
|
return
|
|
}
|
|
const loadGeometry = async () => {
|
|
const capabilities = await facade.geometry.initialize()
|
|
if (capabilities.status !== 'ready') throw new Error(capabilities.reason || 'OCCT geometry runtime unavailable')
|
|
const document = facade.app.document.getActive()
|
|
const featureIds = [selectedObject, 'fillet', 'chamfer', 'revolution', 'intersection', 'cut', 'union', 'pocket', 'pad', 'box', 'cylinder', 'sphere', 'cone'].filter(Boolean)
|
|
const storedShape = featureIds.map((objectId) => facade.geometry.getObjectShape(objectId)).find((shape): shape is ShapeHandle => Boolean(shape))
|
|
if (storedShape) {
|
|
const mesh = await facade.geometry.mesh(storedShape, 0.05)
|
|
if (!cancelled) adapter.setMesh(mesh)
|
|
return
|
|
}
|
|
ownsShapes = true
|
|
const profile = { outer: [[-1.35, -0.7, 0], [1.35, -0.7, 0], [1.35, 0.7, 0], [-1.35, 0.7, 0]] as [number, number, number][] }
|
|
const pad = await facade.geometry.pad({ profile, length: 1.6, direction: [0, 0, 1], documentId: document.id, documentVersion: document.version })
|
|
shapes.push(pad)
|
|
const pocketProfile = { outer: [[-0.55, -0.32, 0], [0.55, -0.32, 0], [0.55, 0.32, 0], [-0.55, 0.32, 0]] as [number, number, number][] }
|
|
const pocket = await facade.geometry.pocket({ base: pad, profile: pocketProfile, length: 1.8, direction: [0, 0, 1], documentId: document.id, documentVersion: document.version })
|
|
shapes.push(pocket)
|
|
if (cancelled) {
|
|
await Promise.all(shapes.map((entry) => facade.geometry.release(entry)))
|
|
return
|
|
}
|
|
const mesh = await facade.geometry.mesh(pocket, 0.05)
|
|
if (!cancelled) adapter.setMesh(mesh)
|
|
}
|
|
void loadGeometry().catch((error: unknown) => {
|
|
if (!cancelled) showNotice(`Geometry preview unavailable: ${error instanceof Error ? error.message : String(error)}`)
|
|
})
|
|
return () => {
|
|
cancelled = true
|
|
if (ownsShapes) void Promise.all(shapes.map((entry) => facade.geometry.release(entry)))
|
|
adapter.dispose()
|
|
adapterRef.current = null
|
|
}
|
|
}, [facade, showNotice, selectedObject, documentVersion, recomputeGeneration])
|
|
|
|
useEffect(() => adapterRef.current?.setSelection(selectedObject), [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><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, facade }: { page: Page; onNavigate: (page: Page) => void; onOpenWorkspace: () => void; showNotice: (message: string) => void; facade: BitBybitWebCadFacade }) {
|
|
const pages: Record<Exclude<Page, 'workspace'>, ReactNode> = {
|
|
start: <StartPage onNavigate={onNavigate} onOpenWorkspace={onOpenWorkspace} showNotice={showNotice} />,
|
|
projects: <ProjectsPage onNavigate={onNavigate} onOpenWorkspace={onOpenWorkspace} showNotice={showNotice} />,
|
|
import: <FileFlowPage mode="import" onNavigate={onNavigate} showNotice={showNotice} facade={facade} />,
|
|
export: <FileFlowPage mode="export" onNavigate={onNavigate} showNotice={showNotice} facade={facade} />,
|
|
settings: <SettingsPage onNavigate={onNavigate} showNotice={showNotice} />,
|
|
help: <HelpPage onNavigate={onNavigate} showNotice={showNotice} />,
|
|
diagnostics: <DiagnosticsPage onNavigate={onNavigate} showNotice={showNotice} facade={facade} />,
|
|
sync: <SyncPage onNavigate={onNavigate} showNotice={showNotice} />,
|
|
}
|
|
return <div className="page-shell"><div className="page-content">{pages[page as Exclude<Page, 'workspace'>]}</div></div>
|
|
}
|
|
|
|
function PageHeader({ eyebrow, title, description, actions, onBack }: { eyebrow: string; title: string; description: string; actions?: ReactNode; onBack?: () => void }) {
|
|
return <div className="page-header"><div className="page-heading"><div className="page-eyebrow">{onBack && <button className="back-button" onClick={onBack} title="Back"><ArrowLeft size={15} /></button>}<span>{eyebrow}</span></div><h1>{title}</h1><p>{description}</p></div>{actions && <div className="page-actions">{actions}</div>}</div>
|
|
}
|
|
|
|
function StartPage({ onNavigate, onOpenWorkspace, showNotice }: { onNavigate: (page: Page) => void; onOpenWorkspace: () => void; showNotice: (message: string) => void }) {
|
|
return <div className="start-page">
|
|
<PageHeader eyebrow="Local-first workspace" title="Start a new design." description="A focused CAD workspace for parametric parts, drawings and assemblies." actions={<><button className="button button-outline" onClick={() => onNavigate('projects')}><FolderOpen size={16} />Open project</button><button className="button button-primary" onClick={onOpenWorkspace}><Plus size={16} />New document</button></>} />
|
|
<div className="start-grid"><section className="start-hero panel-surface"><div className="hero-copy"><span className="section-kicker"><Sparkles size={14} />FreeCAD-compatible workspace</span><h2>Model with a clear history.</h2><p>Build parts through ordered features, inspect every dependency and keep your project local by default.</p><div className="hero-actions"><button className="button button-primary" onClick={onOpenWorkspace}><FilePlus2 size={16} />Create blank document</button><button className="text-button" onClick={() => showNotice('Example gallery opened')}>Browse examples <ArrowRight size={14} /></button></div></div><div className="hero-schematic"><div className="schematic-axis axis-x-line" /><div className="schematic-axis axis-y-line" /><div className="schematic-block block-a" /><div className="schematic-block block-b" /><div className="schematic-ring" /><span className="schematic-label label-a">Feature history</span><span className="schematic-label label-b">Parametric part</span></div></section><section className="quick-panel panel-surface"><div className="section-title"><div><span className="section-kicker">Quick access</span><h3>Keep moving</h3></div><MoreHorizontal size={16} /></div><QuickAction icon={FolderOpen} title="Open a project" detail="Browse local files" onClick={() => onNavigate('projects')} /><QuickAction icon={Upload} title="Import a model" detail="STEP, IGES, STL and mesh" onClick={() => onNavigate('import')} /><QuickAction icon={WandSparkles} title="Start from an example" detail="Parametric reference models" onClick={() => showNotice('Example gallery opened')} /></section></div>
|
|
<section className="recent-section"><div className="section-title"><div><span className="section-kicker">Recent projects</span><h3>Your local workspace</h3></div><button className="text-button" onClick={() => onNavigate('projects')}>View all <ArrowRight size={14} /></button></div><div className="recent-grid">{projects.slice(0, 3).map((project, index) => <ProjectCard project={project} key={project.name} index={index} onClick={onOpenWorkspace} />)}</div></section>
|
|
<div className="start-footer"><div><HardDrive size={15} /><span>Local storage capability</span><Badge tone="cyan">Probe on open</Badge></div><div><span>BitBybit CAD Studio</span><span className="footer-separator">·</span><span>FreeCAD UI baseline 1.1.1</span></div></div>
|
|
</div>
|
|
}
|
|
|
|
function QuickAction({ icon: ActionIcon, title, detail, onClick }: { icon: Icon; title: string; detail: string; onClick: () => void }) {
|
|
return <button className="quick-action" onClick={onClick}><span className="quick-icon"><ActionIcon size={17} /></span><span><strong>{title}</strong><small>{detail}</small></span><ArrowRight size={15} /></button>
|
|
}
|
|
|
|
function ProjectCard({ project, index, onClick }: { project: typeof projects[number]; index: number; onClick: () => void }) {
|
|
return <button className="project-card" onClick={onClick}><div className={`project-preview preview-${index}`}><div className="preview-shape" /><div className="preview-grid" /></div><div className="project-card-info"><div className="project-card-title"><strong>{project.name}</strong><MoreHorizontal size={15} /></div><span>{project.path}</span><div className="project-card-meta"><span>{project.modified}</span><span>{project.objects} objects</span><Badge tone={project.state === 'Unsaved' ? 'amber' : project.state === 'Read only' ? 'muted' : 'green'}>{project.state}</Badge></div></div></button>
|
|
}
|
|
|
|
function ProjectsPage({ onNavigate, onOpenWorkspace, showNotice }: { onNavigate: (page: Page) => void; onOpenWorkspace: () => void; showNotice: (message: string) => void }) {
|
|
return <div className="projects-page"><PageHeader eyebrow="Project manager" title="Your projects." description="Local-first project storage with explicit save and recovery states." actions={<><button className="button button-outline" onClick={() => onNavigate('import')}><Upload size={16} />Import</button><button className="button button-primary" onClick={onOpenWorkspace}><Plus size={16} />New project</button></>} /><div className="manager-toolbar"><div className="large-search"><Search size={16} /><input placeholder="Search projects" /><kbd>/</kbd></div><div className="toolbar-select"><span>Sort by</span><select><option>Last modified</option><option>Name</option><option>Object count</option></select><ChevronDown size={14} /></div><IconButton icon={LayoutGrid} label="Grid view" active /><IconButton icon={ListTree} label="List view" /></div><div className="project-list">{projects.map((project, index) => <div className="project-list-row" key={project.name}><div className={`list-thumbnail preview-${index}`}><div className="preview-shape" /><div className="preview-grid" /></div><div className="list-main"><strong>{project.name}</strong><span>{project.path}</span></div><span className="list-count">{project.objects} objects</span><span className="list-modified">{project.modified}</span><Badge tone={project.state === 'Unsaved' ? 'amber' : project.state === 'Read only' ? 'muted' : 'green'}>{project.state}</Badge><button className="row-action" title="More project actions" onClick={() => showNotice(`Actions for ${project.name}`)}><MoreHorizontal size={16} /></button></div>)}</div><div className="storage-banner"><div className="storage-icon"><HardDrive size={18} /></div><div><strong>Local storage</strong><span>3.4 GB of 10 GB used · Last backup just now</span></div><button className="text-button" onClick={() => onNavigate('settings')}>Manage storage <ArrowRight size={14} /></button></div></div>
|
|
}
|
|
|
|
function FileFlowPage({ mode, onNavigate, showNotice, facade }: { mode: 'import' | 'export'; onNavigate: (page: Page) => void; showNotice: (message: string) => void; facade: BitBybitWebCadFacade }) {
|
|
const isImport = mode === 'import'
|
|
const [fcstdReport, setFcstdReport] = useState<FcstdInspection | null>(null)
|
|
const [selectedFormat, setSelectedFormat] = useState<'STEP' | 'STL' | 'GLB / GLTF' | 'Web CAD package'>('STEP')
|
|
const [exporting, setExporting] = useState(false)
|
|
const fileInputRef = useRef<HTMLInputElement>(null)
|
|
const inspectFile = async (file: File) => {
|
|
if (!file.name.toLowerCase().endsWith('.fcstd')) { showNotice('Only FCStd inspection is connected to this import boundary'); return }
|
|
try {
|
|
const report = facade.project.fcstd.inspect(new Uint8Array(await file.arrayBuffer()))
|
|
setFcstdReport(report)
|
|
showNotice(`FCStd inspected: ${report.compatibility.level}`)
|
|
} catch (error) { setFcstdReport(null); showNotice(`FCStd rejected: ${error instanceof Error ? error.message : String(error)}`) }
|
|
}
|
|
const exportModel = async () => {
|
|
if (selectedFormat !== 'STEP' && selectedFormat !== 'STL') { showNotice(`${selectedFormat} export is not implemented yet`); return }
|
|
const objectIds = ['fillet', 'chamfer', 'revolution', 'intersection', 'cut', 'union', 'pocket', 'pad', 'box', 'cylinder', 'sphere', 'cone']
|
|
const shape = objectIds.map((objectId) => facade.geometry.getObjectShape(objectId)).find((candidate): candidate is ShapeHandle => Boolean(candidate))
|
|
if (!shape) { showNotice('Recompute a valid Part or Part Design shape before exporting'); return }
|
|
setExporting(true)
|
|
try {
|
|
const result = selectedFormat === 'STEP' ? await facade.geometry.exportStep(shape, `${facade.app.document.getActive().label}.step`) : await facade.geometry.exportStl(shape, `${facade.app.document.getActive().label}.stl`)
|
|
const blob = new Blob([result.text], { type: result.mediaType })
|
|
const url = URL.createObjectURL(blob)
|
|
const anchor = document.createElement('a')
|
|
anchor.href = url
|
|
anchor.download = result.fileName
|
|
anchor.click()
|
|
URL.revokeObjectURL(url)
|
|
showNotice(`${result.format.toUpperCase()} export downloaded`)
|
|
} catch (error) {
|
|
showNotice(`Export failed: ${error instanceof Error ? error.message : String(error)}`)
|
|
} finally { setExporting(false) }
|
|
}
|
|
return <div className="flow-page"><PageHeader eyebrow={isImport ? 'Import model' : 'Export project'} title={isImport ? 'Bring a model into your workspace.' : 'Export a clean deliverable.'} description={isImport ? 'Review format, units and compatibility before the document is created.' : 'Choose an exchange format and keep the native project available for future edits.'} onBack={() => onNavigate('start')} /><div className="flow-layout"><div className="flow-steps"><FlowStep index="01" title="Choose file" active={!isImport} complete={isImport} /><FlowStep index="02" title="Review mapping" active={isImport} complete={false} /><FlowStep index="03" title="Confirm" active={false} complete={false} /></div><section className="flow-card panel-surface"><div className="flow-card-header"><div><span className="section-kicker">Step 01 / 03</span><h2>{isImport ? 'Select a CAD file' : 'Select an export format'}</h2></div><Badge tone="cyan">Local only</Badge></div>{isImport ? <><div className="drop-zone"><div className="drop-icon"><Upload size={20} /></div><strong>Drop a model here</strong><span>or browse from your device</span><input ref={fileInputRef} type="file" accept=".FCStd,.fcstd" hidden onChange={(event) => { const file = event.target.files?.[0]; if (file) void inspectFile(file); event.currentTarget.value = '' }} /><button className="button button-outline" onClick={() => fileInputRef.current?.click()}><FolderOpen size={15} />Browse files</button><small>STEP · IGES · STL · OBJ · PLY · FCStd</small></div><div className="flow-note"><AlertTriangle size={15} /><span>FCStd is currently a read-only metadata inspection boundary. Python/macros are isolated and never executed.</span></div>{fcstdReport && <div className="flow-note"><CheckCircle2 size={15} className="icon-green" /><span>{fcstdReport.label} · {fcstdReport.objects.length} objects · {fcstdReport.compatibility.level}; {fcstdReport.compatibility.warnings.join(' ') || 'No compatibility warnings.'}</span></div>}</> : <div className="format-grid">{['STEP', 'IGES', 'STL', 'GLB / GLTF', 'Web CAD package'].map((format, index) => <button key={format} className={`format-card ${selectedFormat === format ? 'is-selected' : ''}`} disabled={format === 'IGES' || format === 'GLB / GLTF' || format === 'Web CAD package'} onClick={() => { setSelectedFormat(format as typeof selectedFormat); showNotice(`${format} selected`) }}><span className="format-icon">{index === 4 ? <FileBox size={18} /> : <FileText size={18} />}</span><strong>{format}</strong><small>{index === 0 ? 'Best for editable solids' : index === 2 ? 'Mesh export' : 'Not implemented'}</small>{selectedFormat === format && <Check size={15} className="format-check" />}</button>)}</div>}<div className="flow-footer"><button className="button button-quiet" onClick={() => onNavigate('start')}>Cancel</button><button className="button button-primary" disabled={isImport ? false : exporting} onClick={() => { if (isImport) showNotice(fcstdReport ? 'Metadata review complete; import mapping remains disabled' : 'Choose a file to review'); else void exportModel() }}><span>{isImport ? 'Continue' : exporting ? 'Exporting' : `Export ${selectedFormat}`}</span>{exporting ? <RefreshCw size={15} /> : <ArrowRight size={15} />}</button></div></section></div></div>
|
|
}
|
|
|
|
function FlowStep({ index, title, active, complete }: { index: string; title: string; active: boolean; complete: boolean }) {
|
|
return <div className={`flow-step ${active ? 'is-active' : ''} ${complete ? 'is-complete' : ''}`}><span className="flow-index">{complete ? <Check size={13} /> : index}</span><span>{title}</span></div>
|
|
}
|
|
|
|
function SettingsPage({ onNavigate, showNotice }: { onNavigate: (page: Page) => void; showNotice: (message: string) => void }) {
|
|
return <div className="settings-page"><PageHeader eyebrow="Preferences" title="Workspace settings." description="Keep the FreeCAD workflow familiar and tune the browser workspace around it." onBack={() => onNavigate('start')} actions={<button className="button button-primary" onClick={() => showNotice('Preferences saved')}><Save size={16} />Save changes</button>} /><div className="settings-layout"><aside className="settings-nav"><button className="is-active"><SlidersHorizontal size={15} />General</button><button><Rotate3D size={15} />Navigation</button><button><Circle size={15} />Units & precision</button><button><Layers3 size={15} />Display</button><button><Command size={15} />Shortcuts</button><button><HardDrive size={15} />Storage</button><button><UserRound size={15} />Privacy & security</button></aside><section className="settings-content panel-surface"><SettingSection title="Document defaults" detail="New documents inherit these values."><SettingRow label="Unit system" detail="Used for new projects and property editors"><select><option>Metric (mm, kg, s)</option><option>Imperial (in, lb, s)</option></select></SettingRow><SettingRow label="Autosave interval" detail="A local snapshot is created after inactivity"><select><option>Every 5 minutes</option><option>Every 10 minutes</option><option>Manual only</option></select></SettingRow></SettingSection><SettingSection title="Viewport" detail="The default FreeCAD navigation preset is kept intact."><SettingRow label="Navigation preset" detail="Orbit, pan and zoom mapping"><select><option>FreeCAD CAD</option><option>Trackpad</option><option>Blender</option></select></SettingRow><SettingRow label="Display mode" detail="Default rendering for precise edges"><select><option>Flat lines</option><option>Shaded</option><option>Wireframe</option></select></SettingRow><SettingRow label="Show selection edges" detail="Highlight selected sub-shapes"><input type="checkbox" defaultChecked /></SettingRow></SettingSection><SettingSection title="Local storage" detail="No design data leaves this browser unless you export or sync it."><SettingRow label="OPFS workspace" detail="3.4 GB of 10 GB used"><span className="storage-meter"><span /></span></SettingRow><SettingRow label="Diagnostic uploads" detail="Share only when you explicitly attach a report"><span className="setting-value">Off</span></SettingRow></SettingSection></section></div></div>
|
|
}
|
|
|
|
function SettingSection({ title, detail, children }: { title: string; detail: string; children: ReactNode }) {
|
|
return <section className="setting-section"><div className="setting-section-heading"><h2>{title}</h2><p>{detail}</p></div>{children}</section>
|
|
}
|
|
|
|
function SettingRow({ label, detail, children }: { label: string; detail: string; children: ReactNode }) {
|
|
return <div className="setting-row"><div><strong>{label}</strong><span>{detail}</span></div><div className="setting-control">{children}</div></div>
|
|
}
|
|
|
|
function HelpPage({ onNavigate, showNotice }: { onNavigate: (page: Page) => void; showNotice: (message: string) => void }) {
|
|
const helpItems = [{ title: 'Create a feature-based part', detail: 'Body → Sketch → Pad → Pocket → Fillet', icon: Box }, { title: 'Understand the model tree', detail: 'Objects, links, Tip and recompute states', icon: ListTree }, { title: 'Navigate the viewport', detail: 'FreeCAD CAD preset and view controls', icon: Rotate3D }, { title: 'Recover a project', detail: 'Snapshots, compatibility and backups', icon: Archive }]
|
|
return <div className="help-page"><PageHeader eyebrow="Help center" title="Find your next command." description="FreeCAD workflow references, keyboard shortcuts and compatibility notes." onBack={() => onNavigate('start')} /><div className="help-search"><Search size={18} /><input placeholder="Search commands, workbenches and help" autoFocus /><kbd>⌘ K</kbd></div><div className="help-grid">{helpItems.map((item) => <button className="help-card panel-surface" key={item.title} onClick={() => showNotice(`${item.title} opened`)}><span className="help-icon"><item.icon size={18} /></span><span><strong>{item.title}</strong><small>{item.detail}</small></span><ArrowRight size={15} /></button>)}</div><section className="shortcut-panel panel-surface"><div className="section-title"><div><span className="section-kicker">Keyboard</span><h3>Core shortcuts</h3></div><button className="text-button" onClick={() => showNotice('Shortcut editor opened')}>Customize <ArrowRight size={14} /></button></div><div className="shortcut-grid"><Shortcut keyName="⌘ K" label="Command search" /><Shortcut keyName="Space" label="Toggle visibility" /><Shortcut keyName="F" label="Fit all" /><Shortcut keyName="Esc" label="Cancel task or selection" /><Shortcut keyName="⌘ Z" label="Undo" /><Shortcut keyName="⌘ S" label="Save project" /></div></section></div>
|
|
}
|
|
|
|
function Shortcut({ keyName, label }: { keyName: string; label: string }) {
|
|
return <div className="shortcut-row"><kbd>{keyName}</kbd><span>{label}</span></div>
|
|
}
|
|
|
|
function DiagnosticsPage({ onNavigate, showNotice, facade }: { onNavigate: (page: Page) => void; showNotice: (message: string) => void; facade: BitBybitWebCadFacade }) {
|
|
const [recovery, setRecovery] = useState<{ integrity: string; lastSavedVersion: number | null; warnings: string[] } | null>(null)
|
|
const [geometry, setGeometry] = useState(() => facade.geometry.capabilities())
|
|
const document = facade.getState().document
|
|
const persistence = facade.project.capabilities()
|
|
const geometryTone = geometry.status === 'ready' ? 'green' : geometry.status === 'failed' || geometry.status === 'unavailable' ? 'amber' : 'cyan'
|
|
const geometryValue = geometry.status === 'ready' ? 'Ready' : geometry.status === 'initializing' ? 'Loading' : geometry.status
|
|
useEffect(() => { void facade.project.recovery(facade.getState().document.id).then(setRecovery).catch((error: unknown) => showNotice(`Recovery report unavailable: ${error instanceof Error ? error.message : String(error)}`)) }, [facade, showNotice])
|
|
useEffect(() => {
|
|
let disposed = false
|
|
void facade.geometry.initialize().then((capabilities) => { if (!disposed) setGeometry(capabilities) }).catch((error: unknown) => { if (!disposed) showNotice(`Geometry capability probe failed: ${error instanceof Error ? error.message : String(error)}`) })
|
|
return () => { disposed = true }
|
|
}, [facade, showNotice])
|
|
return <div className="diagnostics-page"><PageHeader eyebrow="System status" title="Runtime checks." description="A concise view of browser capabilities, local storage and the current document runtime." onBack={() => onNavigate('start')} actions={<button className="button button-outline" onClick={() => showNotice('Diagnostic package prepared')}><Download size={16} />Export report</button>} /><div className="health-grid"><HealthCard label="WebAssembly" value={geometryValue} detail={geometry.reason || 'BitBybit OCCT package loads on demand'} tone={geometryTone} icon={Code2} /><HealthCard label="Local storage" value={persistence.mode === 'sqlite-opfs' ? 'SQLite + OPFS' : persistence.mode === 'sqlite-memory' ? 'Memory fallback' : 'Unavailable'} detail={`Schema v${persistence.schemaVersion || 'n/a'} · ${persistence.crossTabWriteLock || 'local queue'}`} tone={persistence.mode === 'unavailable' ? 'amber' : 'cyan'} icon={HardDrive} /><HealthCard label="Viewport" value="WebGL2" detail="Three.js 0.185.1" tone="cyan" icon={Rotate3D} /><HealthCard label="Document" value={`${document.recompute?.errors.length || 0} warnings`} detail={`${document.label} · v${document.version}`} tone={document.recompute?.errors.length ? 'amber' : 'green'} icon={AlertTriangle} /></div><section className="diagnostic-table panel-surface"><div className="section-title"><div><span className="section-kicker">Runtime</span><h3>Capability checks</h3></div><span className="last-checked">Last checked just now</span></div><DiagnosticRow name="BitBybit Facade" value="Connected" detail="API v0.1 · single public entry" tone="green" /><DiagnosticRow name="Geometry Worker" value={geometryValue} detail={`${geometry.provider} ${geometry.version} · Box, Boolean, feature and export boundaries`} tone={geometryTone} /><DiagnosticRow name="SQLite WASM" value={persistence.sqliteWasm ? 'Worker ready' : 'Memory fallback'} detail={`Schema v${persistence.schemaVersion || 'n/a'} · ${persistence.reason || 'SQLite worker configured'}`} tone={persistence.sqliteWasm ? 'green' : 'cyan'} /><DiagnosticRow name="OPFS" value={persistence.opfs ? 'Available' : 'Fallback'} detail="Cross-origin isolation and browser OPFS capability" tone={persistence.opfs ? 'green' : 'cyan'} /><DiagnosticRow name="Recovery" value={recovery?.integrity || 'Checking'} detail={recovery ? `Last saved v${recovery.lastSavedVersion ?? 'none'}${recovery.warnings.length ? ` · ${recovery.warnings.length} warning(s)` : ''}` : 'Checking SQLite integrity and saved snapshot'} tone={recovery?.integrity === 'ok' ? 'green' : 'cyan'} /><DiagnosticRow name="FreeCAD baseline" value="1.1.1" detail="Compatibility manifest loaded; unsupported commands remain disabled" tone="cyan" /><DiagnosticRow name="Document warnings" value={String(document.recompute?.errors.length || 0)} detail="Recompute diagnostics from the current document" tone={document.recompute?.errors.length ? 'amber' : 'green'} onClick={() => showNotice('Document diagnostics opened')} /></section></div>
|
|
}
|
|
|
|
function HealthCard({ label, value, detail, tone, icon: HealthIcon }: { label: string; value: string; detail: string; tone: 'green' | 'cyan' | 'amber'; icon: Icon }) {
|
|
return <div className="health-card panel-surface"><div className={`health-icon ${tone}`}><HealthIcon size={17} /></div><span className="section-kicker">{label}</span><strong>{value}</strong><small>{detail}</small></div>
|
|
}
|
|
|
|
function DiagnosticRow({ name, value, detail, tone, onClick }: { name: string; value: string; detail: string; tone: 'green' | 'cyan' | 'amber'; onClick?: () => void }) {
|
|
return <button className="diagnostic-row" onClick={onClick}><span className="diagnostic-name"><span className={`status-dot ${tone}`} />{name}</span><span className="diagnostic-detail">{detail}</span><strong className={`diagnostic-value ${tone}`}>{value}</strong><ChevronRight size={15} /></button>
|
|
}
|
|
|
|
function SyncPage({ onNavigate, showNotice }: { onNavigate: (page: Page) => void; showNotice: (message: string) => void }) {
|
|
return <div className="sync-page"><PageHeader eyebrow="Account and sync" title="Stay local by default." description="Cloud features are optional. Your current workspace is stored in this browser." onBack={() => onNavigate('start')} /><div className="sync-grid"><section className="sync-card panel-surface"><div className="sync-card-header"><span className="sync-logo"><UserRound size={18} /></span><div><span className="section-kicker">Not signed in</span><h2>Local workspace</h2></div><Badge tone="green">Active</Badge></div><p>Projects, recovery snapshots and cached geometry remain on this device until you explicitly export or sync them.</p><div className="sync-stats"><div><strong>4</strong><span>projects</span></div><div><strong>3.4 GB</strong><span>storage used</span></div><div><strong>Just now</strong><span>last backup</span></div></div><button className="button button-outline" onClick={() => showNotice('Sign-in flow is ready for the next phase')}><UserRound size={16} />Connect an account</button></section><section className="sync-card panel-surface"><div className="section-title"><div><span className="section-kicker">Optional services</span><h3>Choose when to share</h3></div><Settings2 size={16} /></div><SyncRow title="Project sync" detail="Keep a remote copy and resolve conflicts" disabled /><SyncRow title="Team review links" detail="Share read-only documents" disabled /><SyncRow title="Diagnostic uploads" detail="Attach reports only when requested" disabled /></section></div></div>
|
|
}
|
|
|
|
function SyncRow({ title, detail, disabled }: { title: string; detail: string; disabled?: boolean }) {
|
|
return <div className={`sync-row ${disabled ? 'is-disabled' : ''}`}><div><strong>{title}</strong><span>{detail}</span></div><span className="toggle"><span /></span></div>
|
|
}
|
|
|
|
export default App
|