P7/P4: harden recompute and FCStd boundaries
This commit is contained in:
39
src/App.tsx
39
src/App.tsx
@@ -45,6 +45,7 @@ import {
|
||||
Trash2,
|
||||
Undo2,
|
||||
Redo2,
|
||||
RefreshCw,
|
||||
Upload,
|
||||
UserRound,
|
||||
WandSparkles,
|
||||
@@ -53,7 +54,7 @@ import {
|
||||
ZoomOut,
|
||||
} from 'lucide-react'
|
||||
import { menuDefinitions, pinnedWorkbenches, workbenchDefinitions, type MenuName, type WorkbenchId } from './freecadManifest'
|
||||
import { createMockFacade, type BitBybitViewportAdapter, type BitBybitWebCadFacade, type DocumentSnapshot, type ModelTreeItem, type ObjectPropertySnapshot, type PropertyValue, type ShapeHandle } from './facade'
|
||||
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
|
||||
@@ -190,7 +191,7 @@ function App() {
|
||||
showNotice={showNotice}
|
||||
/>
|
||||
) : (
|
||||
<PageFrame page={page} onNavigate={navigate} onOpenWorkspace={openWorkspace} showNotice={showNotice} />
|
||||
<PageFrame page={page} onNavigate={navigate} onOpenWorkspace={openWorkspace} showNotice={showNotice} facade={facade} />
|
||||
)}
|
||||
{notice && <div className="toast"><CheckCircle2 size={16} />{notice}</div>}
|
||||
</div>
|
||||
@@ -199,6 +200,7 @@ function App() {
|
||||
|
||||
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')
|
||||
@@ -220,8 +222,8 @@ function TopBar({ page, workbench, facade, onNavigate, onOpenWorkspace }: { page
|
||||
</nav>
|
||||
<div className="topbar-spacer" />
|
||||
<div className="topbar-context">
|
||||
<div className="context-document"><span className="context-dot" />Pump Housing <span className="context-separator">/</span> {workbench}</div>
|
||||
<span className="save-state"><span className="save-dot" />Unsaved changes</span>
|
||||
<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()} />
|
||||
@@ -257,6 +259,7 @@ function Workspace({ workbench, setWorkbench, leftTab, setLeftTab, rightTab, set
|
||||
<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') }} />
|
||||
@@ -269,11 +272,11 @@ function Workspace({ workbench, setWorkbench, leftTab, setLeftTab, rightTab, set
|
||||
</div>
|
||||
<WorkbenchNavBar workbench={workbench} setWorkbench={setWorkbench} showNotice={showNotice} />
|
||||
<div className="document-tabs">
|
||||
<button className="doc-tab is-active"><FileBox size={15} /><span>Pump Housing</span><span className="doc-unsaved" /><X size={13} /></button>
|
||||
<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">mm · 18 objects · v0.1 draft</span>
|
||||
<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">
|
||||
@@ -290,7 +293,7 @@ function Workspace({ workbench, setWorkbench, leftTab, setLeftTab, rightTab, set
|
||||
</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"><CheckCircle2 size={14} className="icon-green" /><span>Recompute completed</span><span className="report-context">Document / Body / Fillet</span><span className="report-time">142 ms</span></div><div className="report-line"><AlertTriangle size={14} className="icon-amber" /><span>Reference face may change after Pocket edit</span><button onClick={() => showNotice('Dependency review opened')}>Review dependency</button><span className="report-time">just now</span></div></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>
|
||||
)
|
||||
@@ -420,12 +423,12 @@ function Viewport({ selectedObject, setSelectedObject, workbench, facade, showNo
|
||||
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 }: { page: Page; onNavigate: (page: Page) => void; onOpenWorkspace: () => void; showNotice: (message: string) => void }) {
|
||||
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} />,
|
||||
export: <FileFlowPage mode="export" onNavigate={onNavigate} 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} />,
|
||||
@@ -459,9 +462,19 @@ function ProjectsPage({ onNavigate, onOpenWorkspace, showNotice }: { onNavigate:
|
||||
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 }: { mode: 'import' | 'export'; onNavigate: (page: Page) => void; showNotice: (message: string) => void }) {
|
||||
function FileFlowPage({ mode, onNavigate, showNotice, facade }: { mode: 'import' | 'export'; onNavigate: (page: Page) => void; showNotice: (message: string) => void; facade: BitBybitWebCadFacade }) {
|
||||
const isImport = mode === 'import'
|
||||
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><button className="button button-outline" onClick={() => showNotice('File picker opened')}><FolderOpen size={15} />Browse files</button><small>STEP · IGES · STL · OBJ · PLY · FCStd</small></div><div className="flow-note"><AlertTriangle size={15} /><span>Imported objects will appear as editable or read-only proxies depending on format support.</span></div></> : <div className="format-grid">{['STEP', 'IGES', 'STL', 'GLB / GLTF', 'Web CAD package'].map((format, index) => <button key={format} className={`format-card ${index === 0 ? 'is-selected' : ''}`} onClick={() => 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' : 'Exchange format'}</small>{index === 0 && <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" onClick={() => showNotice(isImport ? 'Next: review mapping' : 'Export queued')}><span>{isImport ? 'Continue' : 'Export'}</span><ArrowRight size={15} /></button></div></section></div></div>
|
||||
const [fcstdReport, setFcstdReport] = useState<FcstdInspection | null>(null)
|
||||
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)}`) }
|
||||
}
|
||||
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 ${index === 0 ? 'is-selected' : ''}`} onClick={() => 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' : 'Exchange format'}</small>{index === 0 && <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" onClick={() => showNotice(isImport ? (fcstdReport ? 'Metadata review complete; import mapping remains disabled' : 'Choose a file to review') : 'Export queued')}><span>{isImport ? 'Continue' : 'Export'}</span><ArrowRight size={15} /></button></div></section></div></div>
|
||||
}
|
||||
|
||||
function FlowStep({ index, title, active, complete }: { index: string; title: string; active: boolean; complete: boolean }) {
|
||||
@@ -490,7 +503,7 @@ function Shortcut({ keyName, label }: { keyName: string; label: string }) {
|
||||
}
|
||||
|
||||
function DiagnosticsPage({ onNavigate, showNotice }: { onNavigate: (page: Page) => void; showNotice: (message: string) => void }) {
|
||||
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="Ready" detail="Runtime package loaded on demand" tone="green" icon={Code2} /><HealthCard label="Local storage" value="Capability probe" detail="SQLite WASM + OPFS worker" tone="cyan" icon={HardDrive} /><HealthCard label="Viewport" value="WebGL2" detail="WebGPU can be enabled" tone="cyan" icon={Rotate3D} /><HealthCard label="Document" value="2 warnings" detail="Pump Housing · v18" tone="amber" 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 · mock domain adapter" tone="green" /><DiagnosticRow name="Geometry Worker" value="Planned" detail="FreeCAD/OCCT WASM P3 gate" tone="cyan" /><DiagnosticRow name="SQLite WASM" value="Worker configured" detail="Schema v1 · OPFS VFS with memory fallback" tone="cyan" /><DiagnosticRow name="OPFS" value="Capability probe" detail="Requires cross-origin isolation; export fallback is explicit" tone="cyan" /><DiagnosticRow name="FreeCAD baseline" value="1.1.1" detail="Compatibility manifest loaded" tone="cyan" /><DiagnosticRow name="Document warnings" value="2" detail="One downstream reference needs review" tone="amber" onClick={() => showNotice('Document diagnostics opened')} /></section></div>
|
||||
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="Ready" detail="BitBybit OCCT package loads on demand" tone="green" icon={Code2} /><HealthCard label="Local storage" value="Capability probe" detail="SQLite WASM + OPFS worker" tone="cyan" icon={HardDrive} /><HealthCard label="Viewport" value="WebGL2" detail="WebGPU can be enabled" tone="cyan" icon={Rotate3D} /><HealthCard label="Document" value="2 warnings" detail="Pump Housing · v18" tone="amber" 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="BitBybit OCCT 1.1.1" detail="WASM Worker with Box, Boolean, feature and export boundaries" tone="green" /><DiagnosticRow name="SQLite WASM" value="Worker configured" detail="Schema v4 · OPFS VFS with memory fallback" tone="cyan" /><DiagnosticRow name="OPFS" value="Capability probe" detail="Requires cross-origin isolation; export fallback is explicit" tone="cyan" /><DiagnosticRow name="FreeCAD baseline" value="1.1.1" detail="Compatibility manifest loaded; unsupported commands remain disabled" tone="cyan" /><DiagnosticRow name="Document warnings" value="2" detail="One downstream reference needs review" tone="amber" 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 }) {
|
||||
|
||||
243
src/facade/fcstd.ts
Normal file
243
src/facade/fcstd.ts
Normal file
@@ -0,0 +1,243 @@
|
||||
import { unzipSync } from 'fflate'
|
||||
import { XMLParser } from 'fast-xml-parser'
|
||||
|
||||
export type FcstdArchiveLimits = {
|
||||
maxArchiveBytes: number
|
||||
maxEntries: number
|
||||
maxEntryBytes: number
|
||||
maxTotalUncompressedBytes: number
|
||||
maxCompressionRatio: number
|
||||
}
|
||||
|
||||
export type FcstdEntryRole = 'document' | 'gui-document' | 'shape' | 'thumbnail' | 'script' | 'resource'
|
||||
|
||||
export type FcstdEntryMetadata = {
|
||||
path: string
|
||||
compressedBytes: number
|
||||
uncompressedBytes: number
|
||||
compressionMethod: number
|
||||
role: FcstdEntryRole
|
||||
}
|
||||
|
||||
export type FcstdObjectSupport = 'recognized' | 'proxy' | 'blocked'
|
||||
|
||||
export type FcstdObjectSummary = {
|
||||
name: string
|
||||
label: string
|
||||
typeId: string
|
||||
propertyCount: number
|
||||
support: FcstdObjectSupport
|
||||
}
|
||||
|
||||
export type FcstdCompatibilityReport = {
|
||||
level: 'metadata-compatible' | 'partial' | 'blocked'
|
||||
readOnly: true
|
||||
codeExecutionBlocked: true
|
||||
recognizedObjects: number
|
||||
proxyObjects: number
|
||||
blockedObjects: number
|
||||
unknownTypeIds: string[]
|
||||
warnings: string[]
|
||||
}
|
||||
|
||||
export type FcstdInspection = {
|
||||
format: 'FCStd'
|
||||
schemaVersion: string
|
||||
label: string
|
||||
entries: FcstdEntryMetadata[]
|
||||
objects: FcstdObjectSummary[]
|
||||
compatibility: FcstdCompatibilityReport
|
||||
}
|
||||
|
||||
export const DEFAULT_FCSTD_LIMITS: FcstdArchiveLimits = {
|
||||
maxArchiveBytes: 256 * 1024 * 1024,
|
||||
maxEntries: 20_000,
|
||||
maxEntryBytes: 128 * 1024 * 1024,
|
||||
maxTotalUncompressedBytes: 512 * 1024 * 1024,
|
||||
maxCompressionRatio: 200,
|
||||
}
|
||||
|
||||
const recognizedTypeIds = new Set([
|
||||
'App::DocumentObjectGroup',
|
||||
'App::FeaturePython',
|
||||
'Part::Feature',
|
||||
'Part::FeaturePython',
|
||||
'PartDesign::Body',
|
||||
'PartDesign::Feature',
|
||||
'PartDesign::Pad',
|
||||
'PartDesign::Pocket',
|
||||
'PartDesign::Fillet',
|
||||
'PartDesign::Chamfer',
|
||||
'PartDesign::Revolution',
|
||||
'Sketcher::SketchObject',
|
||||
])
|
||||
|
||||
const blockedTypeId = (typeId: string) => /(?:FeaturePython|PythonFeature|::Python)/i.test(typeId)
|
||||
|
||||
const entryRole = (path: string): FcstdEntryRole => {
|
||||
const lower = path.toLowerCase()
|
||||
if (lower === 'document.xml') return 'document'
|
||||
if (lower === 'guidocument.xml') return 'gui-document'
|
||||
if (lower === 'thumbnails/thumbnail.png') return 'thumbnail'
|
||||
if (lower.endsWith('.brp') || lower.endsWith('.brep')) return 'shape'
|
||||
if (lower.endsWith('.py') || lower.endsWith('.fcmacro') || lower.includes('/macro')) return 'script'
|
||||
return 'resource'
|
||||
}
|
||||
|
||||
const validateEntryPath = (path: string) => {
|
||||
if (!path || path.includes('\0') || path.includes('\\') || path.startsWith('/') || /^[A-Za-z]:/.test(path)) throw new Error(`Unsafe FCStd entry path: ${path || '<empty>'}`)
|
||||
const segments = path.split('/')
|
||||
if (segments.some((segment) => segment === '..' || segment === '.' || ['__proto__', 'prototype', 'constructor'].includes(segment))) throw new Error(`Unsafe FCStd entry path: ${path}`)
|
||||
}
|
||||
|
||||
const findEndOfCentralDirectory = (bytes: Uint8Array) => {
|
||||
const view = new DataView(bytes.buffer, bytes.byteOffset, bytes.byteLength)
|
||||
const minimum = Math.max(0, bytes.byteLength - 65_557)
|
||||
for (let offset = bytes.byteLength - 22; offset >= minimum; offset -= 1) {
|
||||
if (view.getUint32(offset, true) === 0x06054b50) return offset
|
||||
}
|
||||
throw new Error('FCStd is not a valid ZIP archive: end-of-central-directory record was not found.')
|
||||
}
|
||||
|
||||
const inspectZipDirectory = (bytes: Uint8Array, limits: FcstdArchiveLimits): FcstdEntryMetadata[] => {
|
||||
if (bytes.byteLength > limits.maxArchiveBytes) throw new RangeError(`FCStd archive exceeds ${limits.maxArchiveBytes} bytes.`)
|
||||
if (bytes.byteLength < 22) throw new Error('FCStd is not a valid ZIP archive.')
|
||||
const view = new DataView(bytes.buffer, bytes.byteOffset, bytes.byteLength)
|
||||
const eocd = findEndOfCentralDirectory(bytes)
|
||||
const diskNumber = view.getUint16(eocd + 4, true)
|
||||
const centralDirectoryDisk = view.getUint16(eocd + 6, true)
|
||||
const entriesOnDisk = view.getUint16(eocd + 8, true)
|
||||
const entryCount = view.getUint16(eocd + 10, true)
|
||||
const directorySize = view.getUint32(eocd + 12, true)
|
||||
const directoryOffset = view.getUint32(eocd + 16, true)
|
||||
const commentLength = view.getUint16(eocd + 20, true)
|
||||
if (diskNumber !== 0 || centralDirectoryDisk !== 0 || entriesOnDisk !== entryCount) throw new Error('Multi-disk FCStd archives are not supported.')
|
||||
if (entryCount === 0xffff || directorySize === 0xffffffff || directoryOffset === 0xffffffff) throw new Error('ZIP64 FCStd archives are outside the supported import boundary.')
|
||||
if (entryCount > limits.maxEntries) throw new RangeError(`FCStd archive exceeds ${limits.maxEntries} entries.`)
|
||||
if (eocd + 22 + commentLength > bytes.byteLength || directoryOffset + directorySize > eocd) throw new Error('FCStd central directory is truncated or inconsistent.')
|
||||
|
||||
const decoder = new TextDecoder('utf-8', { fatal: true })
|
||||
const entries: FcstdEntryMetadata[] = []
|
||||
const paths = new Set<string>()
|
||||
let cursor = directoryOffset
|
||||
let totalUncompressed = 0
|
||||
for (let index = 0; index < entryCount; index += 1) {
|
||||
if (cursor + 46 > eocd || view.getUint32(cursor, true) !== 0x02014b50) throw new Error('FCStd central directory contains an invalid file header.')
|
||||
const flags = view.getUint16(cursor + 8, true)
|
||||
const compressionMethod = view.getUint16(cursor + 10, true)
|
||||
const compressedBytes = view.getUint32(cursor + 20, true)
|
||||
const uncompressedBytes = view.getUint32(cursor + 24, true)
|
||||
const nameLength = view.getUint16(cursor + 28, true)
|
||||
const extraLength = view.getUint16(cursor + 30, true)
|
||||
const entryCommentLength = view.getUint16(cursor + 32, true)
|
||||
const diskStart = view.getUint16(cursor + 34, true)
|
||||
const next = cursor + 46 + nameLength + extraLength + entryCommentLength
|
||||
if (next > eocd) throw new Error('FCStd central-directory entry is truncated.')
|
||||
if ((flags & 0x1) !== 0) throw new Error('Encrypted FCStd entries are not supported.')
|
||||
if (diskStart !== 0) throw new Error('Multi-disk FCStd entries are not supported.')
|
||||
if (compressionMethod !== 0 && compressionMethod !== 8) throw new Error(`Unsupported FCStd ZIP compression method: ${compressionMethod}.`)
|
||||
if (compressedBytes === 0xffffffff || uncompressedBytes === 0xffffffff) throw new Error('ZIP64 FCStd entries are outside the supported import boundary.')
|
||||
const path = decoder.decode(bytes.subarray(cursor + 46, cursor + 46 + nameLength))
|
||||
validateEntryPath(path)
|
||||
if (paths.has(path)) throw new Error(`Duplicate FCStd entry path: ${path}`)
|
||||
paths.add(path)
|
||||
if (uncompressedBytes > limits.maxEntryBytes) throw new RangeError(`FCStd entry ${path} exceeds ${limits.maxEntryBytes} bytes.`)
|
||||
const ratio = uncompressedBytes === 0 ? 0 : compressedBytes === 0 ? Number.POSITIVE_INFINITY : uncompressedBytes / compressedBytes
|
||||
if (ratio > limits.maxCompressionRatio) throw new RangeError(`FCStd entry ${path} exceeds the maximum compression ratio.`)
|
||||
totalUncompressed += uncompressedBytes
|
||||
if (totalUncompressed > limits.maxTotalUncompressedBytes) throw new RangeError(`FCStd archive exceeds ${limits.maxTotalUncompressedBytes} uncompressed bytes.`)
|
||||
entries.push({ path, compressedBytes, uncompressedBytes, compressionMethod, role: entryRole(path) })
|
||||
cursor = next
|
||||
}
|
||||
if (cursor !== directoryOffset + directorySize) throw new Error('FCStd central-directory size does not match its entries.')
|
||||
return entries
|
||||
}
|
||||
|
||||
const asArray = <T>(value: T | T[] | undefined): T[] => value === undefined ? [] : Array.isArray(value) ? value : [value]
|
||||
|
||||
const attribute = (node: unknown, name: string): string => {
|
||||
if (!node || typeof node !== 'object') return ''
|
||||
const record = node as Record<string, unknown>
|
||||
const value = record[`@_${name}`] ?? record[name]
|
||||
return value === undefined || value === null ? '' : String(value)
|
||||
}
|
||||
|
||||
const propertyValue = (property: Record<string, unknown>): string => {
|
||||
for (const value of Object.values(property)) {
|
||||
if (!value || typeof value !== 'object') continue
|
||||
const candidate = attribute(value, 'value')
|
||||
if (candidate) return candidate
|
||||
}
|
||||
return ''
|
||||
}
|
||||
|
||||
const parseDocumentXml = (bytes: Uint8Array) => {
|
||||
const xml = new TextDecoder('utf-8', { fatal: true }).decode(bytes)
|
||||
if (/<!DOCTYPE|<!ENTITY/i.test(xml)) throw new Error('FCStd Document.xml declarations and entities are not allowed.')
|
||||
const parsed = new XMLParser({
|
||||
ignoreAttributes: false,
|
||||
attributeNamePrefix: '@_',
|
||||
parseTagValue: false,
|
||||
processEntities: false,
|
||||
allowBooleanAttributes: false,
|
||||
}).parse(xml) as Record<string, unknown>
|
||||
const root = (parsed.Document ?? parsed) as Record<string, unknown>
|
||||
const objectDeclarations = asArray((((root.Objects as Record<string, unknown> | undefined)?.Object) as Record<string, unknown> | Record<string, unknown>[] | undefined))
|
||||
const objectData = asArray((((root.ObjectData as Record<string, unknown> | undefined)?.Object) as Record<string, unknown> | Record<string, unknown>[] | undefined))
|
||||
const dataByName = new Map(objectData.map((data) => [attribute(data, 'name'), data]))
|
||||
const documentProperties = asArray((((root.Properties as Record<string, unknown> | undefined)?.Property) as Record<string, unknown> | Record<string, unknown>[] | undefined))
|
||||
const labelProperty = documentProperties.find((property) => attribute(property, 'name') === 'Label')
|
||||
const objects = objectDeclarations.map((declaration): FcstdObjectSummary => {
|
||||
const name = attribute(declaration, 'name') || '<unnamed>'
|
||||
const typeId = attribute(declaration, 'type') || 'App::DocumentObject'
|
||||
const data = dataByName.get(name)
|
||||
const properties = asArray((((data?.Properties as Record<string, unknown> | undefined)?.Property) as Record<string, unknown> | Record<string, unknown>[] | undefined))
|
||||
const objectLabelProperty = properties.find((property) => attribute(property, 'name') === 'Label')
|
||||
const support: FcstdObjectSupport = blockedTypeId(typeId) ? 'blocked' : recognizedTypeIds.has(typeId) ? 'recognized' : 'proxy'
|
||||
return { name, label: objectLabelProperty ? propertyValue(objectLabelProperty) || name : name, typeId, propertyCount: properties.length, support }
|
||||
})
|
||||
return {
|
||||
schemaVersion: attribute(root, 'SchemaVersion') || attribute(root, 'schemaVersion') || 'unknown',
|
||||
label: labelProperty ? propertyValue(labelProperty) || 'Unnamed FreeCAD document' : 'Unnamed FreeCAD document',
|
||||
objects,
|
||||
}
|
||||
}
|
||||
|
||||
export const inspectFcstdArchive = (bytes: Uint8Array, limitOverrides: Partial<FcstdArchiveLimits> = {}): FcstdInspection => {
|
||||
const limits = { ...DEFAULT_FCSTD_LIMITS, ...limitOverrides }
|
||||
for (const [name, value] of Object.entries(limits)) if (!Number.isSafeInteger(value) || value <= 0) throw new RangeError(`FCStd limit ${name} must be a positive safe integer.`)
|
||||
const entries = inspectZipDirectory(bytes, limits)
|
||||
const documentEntry = entries.find((entry) => entry.path.toLowerCase() === 'document.xml')
|
||||
if (!documentEntry) throw new Error('FCStd archive does not contain Document.xml.')
|
||||
const files = unzipSync(bytes)
|
||||
for (const entry of entries) {
|
||||
const content = files[entry.path]
|
||||
if (!content || content.byteLength !== entry.uncompressedBytes) throw new Error(`FCStd entry ${entry.path} did not decompress to its declared size.`)
|
||||
}
|
||||
const document = parseDocumentXml(files[documentEntry.path])
|
||||
const warnings: string[] = []
|
||||
const scriptEntries = entries.filter((entry) => entry.role === 'script')
|
||||
if (scriptEntries.length > 0) warnings.push(`${scriptEntries.length} script or macro resource(s) were isolated and will not execute.`)
|
||||
const blockedObjects = document.objects.filter((object) => object.support === 'blocked')
|
||||
if (blockedObjects.length > 0) warnings.push(`${blockedObjects.length} Python-backed object(s) require a non-executing proxy.`)
|
||||
const proxyObjects = document.objects.filter((object) => object.support === 'proxy')
|
||||
if (proxyObjects.length > 0) warnings.push(`${proxyObjects.length} unrecognized object type(s) require a read-only proxy.`)
|
||||
const level: FcstdCompatibilityReport['level'] = blockedObjects.length > 0 ? 'blocked' : proxyObjects.length > 0 || scriptEntries.length > 0 ? 'partial' : 'metadata-compatible'
|
||||
return {
|
||||
format: 'FCStd',
|
||||
schemaVersion: document.schemaVersion,
|
||||
label: document.label,
|
||||
entries,
|
||||
objects: document.objects,
|
||||
compatibility: {
|
||||
level,
|
||||
readOnly: true,
|
||||
codeExecutionBlocked: true,
|
||||
recognizedObjects: document.objects.filter((object) => object.support === 'recognized').length,
|
||||
proxyObjects: proxyObjects.length,
|
||||
blockedObjects: blockedObjects.length,
|
||||
unknownTypeIds: [...new Set(proxyObjects.map((object) => object.typeId))].sort(),
|
||||
warnings,
|
||||
},
|
||||
}
|
||||
}
|
||||
@@ -7,3 +7,7 @@ export type { ApplyPlacementInput, BitBybitViewportAdapter, BitBybitWebCadFacade
|
||||
export { createSubshapeRefs, matchSubshapes, signatureForFace } from './topologyNaming'
|
||||
export { BasicSketchSolverAdapter, cloneSketch, createSketch, solveSketch } from './sketcher'
|
||||
export type { SketchConstraint, SketchDiagnostic, SketchGeometry, SketchPoint, SketchPointRef, SketchSnapshot, SketchSolveOptions, SketchSolveResult, SketchSolverAdapter, SketchSolverStatus } from './sketcher'
|
||||
export { executeFacadeRecomputeNode, RecomputeCoordinator } from './recomputeEngine'
|
||||
export type { RecomputeExecutionError, RecomputeExecutionOptions, RecomputeExecutionResult, RecomputeExecutionStatus, RecomputeNodeContext, RecomputeNodeExecutor, RecomputeNodeResult, RecomputeProgress } from './recomputeEngine'
|
||||
export { DEFAULT_FCSTD_LIMITS, inspectFcstdArchive } from './fcstd'
|
||||
export type { FcstdArchiveLimits, FcstdCompatibilityReport, FcstdEntryMetadata, FcstdEntryRole, FcstdInspection, FcstdObjectSummary, FcstdObjectSupport } from './fcstd'
|
||||
|
||||
@@ -25,6 +25,8 @@ import { ThreeViewportAdapter } from './threeViewport'
|
||||
import { DependencyGraph, createRecomputeSnapshot, type DependencyEdge } from './dependencyGraph'
|
||||
import { convertQuantity, evaluateQuantityExpression, getUnit, quantityDimensionForUnit, quantityFromNumber, quantityFromUnit, type Quantity } from './units'
|
||||
import { cloneSketch, createSketch, solveSketch, type SketchConstraint, type SketchGeometry, type SketchSnapshot } from './sketcher'
|
||||
import { executeFacadeRecomputeNode, RecomputeCoordinator, type RecomputeExecutionOptions } from './recomputeEngine'
|
||||
import { inspectFcstdArchive } from './fcstd'
|
||||
|
||||
const initialTree: ModelTreeItem[] = [
|
||||
{ id: 'origin', label: 'Origin', type: 'folder', children: ['XY_Plane', 'XZ_Plane', 'YZ_Plane'] },
|
||||
@@ -130,6 +132,7 @@ const createDocument = (label = 'Pump Housing'): DocumentSnapshot => {
|
||||
|
||||
const selectionRequired = new Set(['pad', 'pocket', 'revolution', 'fillet', 'chamfer', 'hole', 'linear-pattern', 'polar-pattern', 'measure-distance', 'measure-angle', 'measure-area', 'solve-sketch'])
|
||||
const systemCommands = new Set(['new-document', 'save', 'select-object'])
|
||||
const implementedCommandIds = new Set(['new-document', 'save', 'select-object', 'create-body', 'create-sketch', 'new-sketch', 'pad', 'pocket', 'fillet', 'chamfer', 'solve-sketch'])
|
||||
const featureCommands: Record<string, { label: string; detail: string }> = {
|
||||
'create-body': { label: 'Body', detail: 'Part Design body' },
|
||||
'create-sketch': { label: 'Sketch', detail: 'Fully constrained' },
|
||||
@@ -142,6 +145,7 @@ const featureCommands: Record<string, { label: string; detail: string }> = {
|
||||
const commandState = (commandId: string, activeWorkbench: WorkbenchId, selectedObjectId: string): CommandState => {
|
||||
const known = systemCommands.has(commandId) || 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 (!implementedCommandIds.has(commandId)) return { id: commandId, status: 'disabled', reason: 'Command is visible in the FreeCAD-compatible manifest but its BitBybit business executor is not implemented yet.' }
|
||||
if (commandId === 'pad' && activeWorkbench !== 'Part Design') return { id: commandId, status: 'disabled', reason: 'Switch to Part Design to use Pad.' }
|
||||
if (selectionRequired.has(commandId) && !selectedObjectId) return { id: commandId, status: 'disabled', reason: 'Select a compatible object or sub-shape first.' }
|
||||
return { id: commandId, status: 'enabled' }
|
||||
@@ -212,6 +216,10 @@ export function createMockFacade(): BitBybitWebCadFacade {
|
||||
const geometryRuntime = new BitbybitGeometryRuntime()
|
||||
const autosave = new ProjectAutosaveScheduler((document) => projectPersistence.save(document))
|
||||
let state: FacadeState = { apiVersion: '0.1', activeWorkbench: 'Part Design', selectedObjectId: 'pad', document: createDocument(), persistence: projectPersistence.capabilities(), task: null, lastNotice: '', diagnostics: [] }
|
||||
const recomputeCoordinator = new RecomputeCoordinator(
|
||||
executeFacadeRecomputeNode,
|
||||
(documentId) => state.document.id === documentId ? state.document.version : null,
|
||||
)
|
||||
const listeners = new Set<FacadeListener>()
|
||||
const undoStack: FacadeState[] = []
|
||||
const redoStack: FacadeState[] = []
|
||||
@@ -332,6 +340,35 @@ export function createMockFacade(): BitBybitWebCadFacade {
|
||||
emitState()
|
||||
return { ...plan, generation, status: nextRecompute.status, errors }
|
||||
}
|
||||
const recomputeDocumentAsync = async (options: RecomputeExecutionOptions = {}) => {
|
||||
const source = cloneDocumentSnapshot(state.document)
|
||||
const result = await recomputeCoordinator.run(source, options)
|
||||
if ((result.status !== 'completed' && result.status !== 'failed') || state.document.id !== source.id || state.document.version !== source.version) return result
|
||||
|
||||
const document = cloneDocumentSnapshot(state.document)
|
||||
const updates = new Map(result.objectUpdates.map((object) => [object.id, object]))
|
||||
document.objects = document.objects.map((object) => updates.has(object.id) ? updates.get(object.id) as DocumentObjectSnapshot : object)
|
||||
for (const objectId of result.affected) {
|
||||
const item = document.tree.find((candidate) => candidate.id === objectId)
|
||||
if (!item || item.state === 'readonly') continue
|
||||
if (result.objectStates[objectId] === 'up-to-date') item.state = item.type === 'body' ? 'active' : 'valid'
|
||||
else if (result.objectStates[objectId] === 'error' || result.objectStates[objectId] === 'upstream-failed') item.state = 'warning'
|
||||
const status = document.objects.find((candidate) => candidate.id === objectId)?.properties.find((property) => property.name === 'Status')
|
||||
if (status) status.value = result.objectStates[objectId] === 'up-to-date' ? 'Valid' : 'Warning'
|
||||
}
|
||||
document.recompute = {
|
||||
generation: result.generation,
|
||||
status: result.status,
|
||||
objectStates: result.objectStates,
|
||||
dirtyObjects: result.dirtyObjects,
|
||||
order: result.order,
|
||||
errors: result.errors,
|
||||
}
|
||||
state = { ...state, document }
|
||||
if (document.dirty) autosave.schedule(document)
|
||||
emitState()
|
||||
return result
|
||||
}
|
||||
const getSketch = (objectId: string) => {
|
||||
const object = state.document.objects.find((candidate) => candidate.id === objectId)
|
||||
return object?.sketch ? cloneSketch(object.sketch) : null
|
||||
@@ -395,7 +432,8 @@ export function createMockFacade(): BitBybitWebCadFacade {
|
||||
const context: FacadeRequestContext = { apiVersion: state.apiVersion, requestId, documentId: state.document.id, documentVersion: state.document.version, workbench: state.activeWorkbench }
|
||||
const status = commandState(commandId, state.activeWorkbench, state.selectedObjectId)
|
||||
if (status.status === 'disabled') {
|
||||
const diagnostic: Diagnostic = { id: `diag-${++requestSequence}`, severity: 'warning', code: 'COMMAND_DISABLED', message: status.reason || 'Command is disabled', objectId: state.selectedObjectId || undefined, requestId }
|
||||
const code = status.reason?.startsWith('Command is visible in the FreeCAD-compatible manifest') ? 'COMMAND_UNIMPLEMENTED' : 'COMMAND_DISABLED'
|
||||
const diagnostic: Diagnostic = { id: `diag-${++requestSequence}`, severity: 'warning', code, message: status.reason || 'Command is disabled', objectId: state.selectedObjectId || undefined, requestId }
|
||||
state = { ...state, diagnostics: [...state.diagnostics, diagnostic] }
|
||||
emit({ type: 'diagnostic.added', diagnostic, context }); emit({ type: 'command.failed', commandId, context, message: status.reason }); notify(status.reason || 'Command is disabled'); return requestId
|
||||
}
|
||||
@@ -412,17 +450,18 @@ export function createMockFacade(): BitBybitWebCadFacade {
|
||||
}
|
||||
else if (commandId === 'select-object' && typeof payload?.objectId === 'string') select(payload.objectId)
|
||||
else if (commandId === 'solve-sketch') { solveSketchObject(state.selectedObjectId); notify('Sketch solver completed') }
|
||||
else if (commandId === 'new-sketch') beginTask('create-sketch', { source: state.selectedObjectId || null })
|
||||
else if (featureCommands[commandId]) beginTask(commandId, { source: state.selectedObjectId || null })
|
||||
emit({ type: 'command.completed', commandId, context }); emitState(); return requestId
|
||||
}
|
||||
|
||||
const facade: BitBybitWebCadFacade = {
|
||||
app: { document: { getActive: () => getState().document, getObject: (objectId) => { const object = state.document.objects.find((candidate) => candidate.id === objectId); return object ? { ...object, properties: object.properties.map((property) => ({ ...property, options: property.options ? [...property.options] : undefined })), sketch: object.sketch ? cloneSketch(object.sketch) : undefined } : null }, create: (label) => { commit({ ...state, document: createDocument(label), selectedObjectId: '' }); return getState().document }, markDirty: () => { commit({ ...state, document: { ...state.document, dirty: true } }) }, setProperty, setExpression, recompute: recomputeDocument, getDependencies: () => (state.document.dependencies ?? []).map((edge) => ({ ...edge })) }, expression: { evaluate: (expression, variables = {}) => evaluateQuantityExpression(expression, new Map(Object.entries(variables))), dimensionForUnit: quantityDimensionForUnit }, sketcher: { get: getSketch, addGeometry: addSketchGeometry, addConstraint: addSketchConstraint, solve: solveSketchObject } },
|
||||
app: { document: { getActive: () => getState().document, getObject: (objectId) => { const object = state.document.objects.find((candidate) => candidate.id === objectId); return object ? { ...object, properties: object.properties.map((property) => ({ ...property, options: property.options ? [...property.options] : undefined })), sketch: object.sketch ? cloneSketch(object.sketch) : undefined } : null }, create: (label) => { recomputeCoordinator.cancel(); commit({ ...state, document: createDocument(label), selectedObjectId: '' }); return getState().document }, markDirty: () => { commit({ ...state, document: { ...state.document, dirty: true } }) }, setProperty, setExpression, recompute: recomputeDocument, recomputeAsync: recomputeDocumentAsync, cancelRecompute: () => recomputeCoordinator.cancel(), getDependencies: () => (state.document.dependencies ?? []).map((edge) => ({ ...edge })) }, expression: { evaluate: (expression, variables = {}) => evaluateQuantityExpression(expression, new Map(Object.entries(variables))), dimensionForUnit: quantityDimensionForUnit }, sketcher: { get: getSketch, addGeometry: addSketchGeometry, addConstraint: addSketchConstraint, solve: solveSketchObject } },
|
||||
history: { canUndo: () => undoStack.length > 0, canRedo: () => redoStack.length > 0, undo: () => { const previous = undoStack.pop(); if (!previous) return; redoStack.push(getState()); state = previous; emitState(); notify('Undo applied') }, redo: () => { const next = redoStack.pop(); if (!next) return; undoStack.push(getState()); state = next; emitState(); notify('Redo applied') } },
|
||||
gui: { workbench: { list: () => Object.keys(workbenchDefinitions) as WorkbenchId[], getActive: () => state.activeWorkbench, setActive }, command: { getState: (commandId) => commandState(commandId, state.activeWorkbench, state.selectedObjectId), 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: applyTask, cancel: () => { if (state.task) state = { ...state, task: { ...state.task, status: 'cancelled' } }; emitState() } },
|
||||
project: { capabilities: () => projectPersistence.capabilities(), save: (document = getState().document) => projectPersistence.save(document), load: (documentId) => projectPersistence.load(documentId), resource: projectPersistence.resource },
|
||||
project: { capabilities: () => projectPersistence.capabilities(), save: (document = getState().document) => projectPersistence.save(document), load: (documentId) => projectPersistence.load(documentId), fcstd: { inspect: (bytes, limits) => inspectFcstdArchive(bytes, limits) }, resource: projectPersistence.resource },
|
||||
geometry: { capabilities: () => geometryRuntime.capabilities(), initialize: () => geometryRuntime.initialize(), createBox: (input) => geometryRuntime.createBox(input), createCylinder: (input) => geometryRuntime.createCylinder(input), createSphere: (input) => geometryRuntime.createSphere(input), createCone: (input) => geometryRuntime.createCone(input), applyPlacement: (input) => geometryRuntime.applyPlacement(input), union: (input) => geometryRuntime.union(input), cut: (input) => geometryRuntime.cut(input), intersection: (input) => geometryRuntime.intersection(input), fillet: (input) => geometryRuntime.fillet(input), chamfer: (input) => geometryRuntime.chamfer(input), exportStep: (shape, fileName) => geometryRuntime.exportStep(shape, fileName), exportStl: (shape, fileName, precision) => geometryRuntime.exportStl(shape, fileName, precision), pad: (input) => geometryRuntime.pad(input), pocket: (input) => geometryRuntime.pocket(input), revolution: (input) => geometryRuntime.revolution(input), mesh: (shape, precision) => geometryRuntime.mesh(shape, precision), subshapes: (shape, precision) => geometryRuntime.subshapes(shape, precision), release: (shape) => geometryRuntime.release(shape), dispose: () => geometryRuntime.dispose() },
|
||||
viewport: { createAdapter: () => new ThreeViewportAdapter() },
|
||||
getState, subscribe: (listener) => { listeners.add(listener); return () => { listeners.delete(listener) } }, notify,
|
||||
|
||||
208
src/facade/recomputeEngine.ts
Normal file
208
src/facade/recomputeEngine.ts
Normal file
@@ -0,0 +1,208 @@
|
||||
import { DependencyGraph, type RecomputeState } from './dependencyGraph'
|
||||
import { cloneSketch, solveSketch } from './sketcher'
|
||||
import type { DocumentObjectSnapshot, DocumentSnapshot } from './types'
|
||||
|
||||
export type RecomputeExecutionStatus = 'completed' | 'failed' | 'cancelled' | 'stale'
|
||||
|
||||
export type RecomputeExecutionError = {
|
||||
objectId: string
|
||||
code: string
|
||||
message: string
|
||||
}
|
||||
|
||||
export type RecomputeNodeContext = {
|
||||
documentId: string
|
||||
documentVersion: number
|
||||
generation: number
|
||||
signal: AbortSignal
|
||||
}
|
||||
|
||||
export type RecomputeNodeResult = {
|
||||
status: 'success' | 'failed'
|
||||
errors?: RecomputeExecutionError[]
|
||||
updatedObject?: DocumentObjectSnapshot
|
||||
}
|
||||
|
||||
export type RecomputeNodeExecutor = (
|
||||
object: DocumentObjectSnapshot,
|
||||
document: DocumentSnapshot,
|
||||
context: RecomputeNodeContext,
|
||||
) => Promise<RecomputeNodeResult>
|
||||
|
||||
export type RecomputeProgress = {
|
||||
generation: number
|
||||
documentVersion: number
|
||||
objectId: string
|
||||
completed: number
|
||||
total: number
|
||||
state: RecomputeState
|
||||
}
|
||||
|
||||
export type RecomputeExecutionOptions = {
|
||||
dirtyObjectIds?: string[]
|
||||
onProgress?: (progress: RecomputeProgress) => void
|
||||
}
|
||||
|
||||
export type RecomputeExecutionResult = {
|
||||
generation: number
|
||||
documentVersion: number
|
||||
status: RecomputeExecutionStatus
|
||||
affected: string[]
|
||||
order: string[]
|
||||
completed: string[]
|
||||
failed: string[]
|
||||
skipped: string[]
|
||||
dirtyObjects: string[]
|
||||
objectStates: Record<string, RecomputeState>
|
||||
objectUpdates: DocumentObjectSnapshot[]
|
||||
errors: RecomputeExecutionError[]
|
||||
}
|
||||
|
||||
const isAbortError = (error: unknown) => error instanceof Error && error.name === 'AbortError'
|
||||
|
||||
export class RecomputeCoordinator {
|
||||
private active: { generation: number; controller: AbortController } | null = null
|
||||
private generation = 0
|
||||
|
||||
constructor(
|
||||
private readonly executeNode: RecomputeNodeExecutor,
|
||||
private readonly currentDocumentVersion: (documentId: string) => number | null,
|
||||
) {}
|
||||
|
||||
cancel() {
|
||||
this.active?.controller.abort()
|
||||
}
|
||||
|
||||
async run(document: DocumentSnapshot, options: RecomputeExecutionOptions = {}): Promise<RecomputeExecutionResult> {
|
||||
this.active?.controller.abort()
|
||||
const controller = new AbortController()
|
||||
const generation = Math.max(this.generation, document.recompute?.generation ?? 0) + 1
|
||||
this.generation = generation
|
||||
this.active = { generation, controller }
|
||||
|
||||
const graph = new DependencyGraph(document.dependencies ?? [], document.objects.map((object) => object.id))
|
||||
const dirtyObjectIds = options.dirtyObjectIds ?? document.recompute?.dirtyObjects ?? []
|
||||
const plan = graph.plan(dirtyObjectIds)
|
||||
const objectStates: Record<string, RecomputeState> = {
|
||||
...Object.fromEntries(document.objects.map((object) => [object.id, 'up-to-date' as const])),
|
||||
...(document.recompute?.objectStates ?? {}),
|
||||
}
|
||||
for (const objectId of plan.affected) objectStates[objectId] = 'recomputing'
|
||||
|
||||
const completed: string[] = []
|
||||
const failed: string[] = []
|
||||
const skipped: string[] = []
|
||||
const errors: RecomputeExecutionError[] = []
|
||||
const objectUpdates: DocumentObjectSnapshot[] = []
|
||||
const objectById = new Map(document.objects.map((object) => [object.id, object]))
|
||||
|
||||
for (const cycle of plan.cycles) {
|
||||
const message = `Dependency cycle: ${cycle.join(' -> ')}`
|
||||
for (const objectId of cycle) {
|
||||
objectStates[objectId] = 'error'
|
||||
failed.push(objectId)
|
||||
errors.push({ objectId, code: 'DEPENDENCY_CYCLE', message })
|
||||
}
|
||||
}
|
||||
|
||||
const terminalResult = (status: RecomputeExecutionStatus): RecomputeExecutionResult => {
|
||||
if (status === 'cancelled' || status === 'stale') {
|
||||
for (const objectId of plan.affected) if (!completed.includes(objectId)) objectStates[objectId] = 'touched'
|
||||
}
|
||||
if (this.active?.generation === generation) this.active = null
|
||||
return {
|
||||
generation,
|
||||
documentVersion: document.version,
|
||||
status,
|
||||
affected: plan.affected,
|
||||
order: plan.order,
|
||||
completed,
|
||||
failed,
|
||||
skipped,
|
||||
dirtyObjects: plan.affected.filter((objectId) => objectStates[objectId] !== 'up-to-date'),
|
||||
objectStates,
|
||||
objectUpdates,
|
||||
errors,
|
||||
}
|
||||
}
|
||||
|
||||
for (const objectId of plan.order) {
|
||||
if (controller.signal.aborted || this.active?.generation !== generation) return terminalResult('cancelled')
|
||||
if (this.currentDocumentVersion(document.id) !== document.version) return terminalResult('stale')
|
||||
|
||||
const failedDependency = graph.dependenciesOf(objectId).find((dependencyId) => objectStates[dependencyId] === 'error' || objectStates[dependencyId] === 'upstream-failed')
|
||||
if (failedDependency) {
|
||||
objectStates[objectId] = 'upstream-failed'
|
||||
skipped.push(objectId)
|
||||
errors.push({ objectId, code: 'UPSTREAM_FAILED', message: `Dependency ${failedDependency} did not recompute successfully.` })
|
||||
options.onProgress?.({ generation, documentVersion: document.version, objectId, completed: completed.length, total: plan.order.length, state: 'upstream-failed' })
|
||||
continue
|
||||
}
|
||||
|
||||
const object = objectById.get(objectId)
|
||||
if (!object) {
|
||||
objectStates[objectId] = 'error'
|
||||
failed.push(objectId)
|
||||
errors.push({ objectId, code: 'OBJECT_NOT_FOUND', message: `Document object does not exist: ${objectId}` })
|
||||
continue
|
||||
}
|
||||
|
||||
try {
|
||||
const result = await this.executeNode(object, document, {
|
||||
documentId: document.id,
|
||||
documentVersion: document.version,
|
||||
generation,
|
||||
signal: controller.signal,
|
||||
})
|
||||
if (controller.signal.aborted || this.active?.generation !== generation) return terminalResult('cancelled')
|
||||
if (this.currentDocumentVersion(document.id) !== document.version) return terminalResult('stale')
|
||||
if (result.status === 'failed') {
|
||||
objectStates[objectId] = 'error'
|
||||
failed.push(objectId)
|
||||
errors.push(...(result.errors?.length ? result.errors : [{ objectId, code: 'RECOMPUTE_FAILED', message: `${objectId} failed to recompute.` }]))
|
||||
} else {
|
||||
objectStates[objectId] = 'up-to-date'
|
||||
completed.push(objectId)
|
||||
if (result.updatedObject) objectUpdates.push(result.updatedObject)
|
||||
}
|
||||
} catch (error) {
|
||||
if (controller.signal.aborted || isAbortError(error)) return terminalResult('cancelled')
|
||||
objectStates[objectId] = 'error'
|
||||
failed.push(objectId)
|
||||
errors.push({ objectId, code: 'RECOMPUTE_EXCEPTION', message: error instanceof Error ? error.message : String(error) })
|
||||
}
|
||||
options.onProgress?.({ generation, documentVersion: document.version, objectId, completed: completed.length, total: plan.order.length, state: objectStates[objectId] })
|
||||
}
|
||||
|
||||
if (this.active?.generation === generation) this.active = null
|
||||
return terminalResult(errors.length > 0 ? 'failed' : 'completed')
|
||||
}
|
||||
}
|
||||
|
||||
export const executeFacadeRecomputeNode: RecomputeNodeExecutor = async (object, _document, context) => {
|
||||
if (context.signal.aborted) throw new DOMException('Recompute cancelled.', 'AbortError')
|
||||
const expressionError = object.properties.find((property) => property.expressionError)
|
||||
if (expressionError) {
|
||||
return {
|
||||
status: 'failed',
|
||||
errors: [{ objectId: object.id, code: 'EXPRESSION_ERROR', message: expressionError.expressionError as string }],
|
||||
}
|
||||
}
|
||||
if (!object.sketch) return { status: 'success' }
|
||||
|
||||
const solved = solveSketch(object.sketch)
|
||||
if (solved.status === 'conflicting' || solved.status === 'invalid') {
|
||||
return {
|
||||
status: 'failed',
|
||||
errors: solved.diagnostics.map((diagnostic) => ({ objectId: object.id, code: diagnostic.code, message: diagnostic.message })),
|
||||
}
|
||||
}
|
||||
const updatedObject: DocumentObjectSnapshot = {
|
||||
...object,
|
||||
properties: object.properties.map((property) => property.name === 'ConstraintStatus'
|
||||
? { ...property, value: solved.status === 'solved' ? 'Fully constrained' : `Under-constrained (${solved.degreesOfFreedom} DOF)` }
|
||||
: { ...property, options: property.options ? [...property.options] : undefined }),
|
||||
sketch: cloneSketch(solved.snapshot),
|
||||
}
|
||||
return { status: 'success', updatedObject }
|
||||
}
|
||||
@@ -2,6 +2,8 @@ import type { CommandDefinition, WorkbenchId } from '../freecadManifest'
|
||||
import type { DependencyEdge, RecomputeSnapshot, RecomputePlan } from './dependencyGraph'
|
||||
import type { Quantity, QuantityDimension } from './units'
|
||||
import type { SketchConstraint, SketchGeometry, SketchSnapshot, SketchSolveResult } from './sketcher'
|
||||
import type { RecomputeExecutionOptions, RecomputeExecutionResult } from './recomputeEngine'
|
||||
import type { FcstdArchiveLimits, FcstdInspection } from './fcstd'
|
||||
|
||||
export type ModelTreeItem = {
|
||||
id: string
|
||||
@@ -317,6 +319,8 @@ export interface BitBybitWebCadFacade {
|
||||
setProperty(input: SetPropertyInput): void
|
||||
setExpression(input: SetExpressionInput): void
|
||||
recompute(): RecomputeResult
|
||||
recomputeAsync(options?: RecomputeExecutionOptions): Promise<RecomputeExecutionResult>
|
||||
cancelRecompute(): void
|
||||
getDependencies(): DependencyEdge[]
|
||||
}
|
||||
expression: {
|
||||
@@ -364,6 +368,9 @@ export interface BitBybitWebCadFacade {
|
||||
capabilities(): PersistenceCapabilities
|
||||
save(document?: DocumentSnapshot): Promise<ProjectSaveResult>
|
||||
load(documentId: string): Promise<DocumentSnapshot | null>
|
||||
fcstd: {
|
||||
inspect(bytes: Uint8Array, limits?: Partial<FcstdArchiveLimits>): FcstdInspection
|
||||
}
|
||||
resource: {
|
||||
put(bytes: Uint8Array, mediaType: string): Promise<ProjectResource>
|
||||
get(hash: string): Promise<Uint8Array | null>
|
||||
|
||||
Reference in New Issue
Block a user