P3/P4: execute OCCT features and export shapes
This commit is contained in:
41
src/App.tsx
41
src/App.tsx
@@ -289,7 +289,7 @@ function Workspace({ workbench, setWorkbench, leftTab, setLeftTab, rightTab, set
|
||||
{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} showNotice={showNotice} />
|
||||
<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>
|
||||
@@ -382,7 +382,7 @@ function PropertyEditor({ facade, objectId, property, showNotice }: { facade: Bi
|
||||
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, showNotice }: { selectedObject: string; setSelectedObject: (id: string) => void; workbench: Workbench; facade: BitBybitWebCadFacade; showNotice: (message: string) => void }) {
|
||||
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(() => {
|
||||
@@ -392,6 +392,7 @@ function Viewport({ selectedObject, setSelectedObject, workbench, facade, showNo
|
||||
adapterRef.current = adapter
|
||||
let cancelled = false
|
||||
const shapes: ShapeHandle[] = []
|
||||
let ownsShapes = false
|
||||
try {
|
||||
adapter.mount(host)
|
||||
} catch (error) {
|
||||
@@ -404,6 +405,14 @@ function Viewport({ selectedObject, setSelectedObject, workbench, facade, showNo
|
||||
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', 'pocket', 'pad'].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)
|
||||
@@ -422,11 +431,11 @@ function Viewport({ selectedObject, setSelectedObject, workbench, facade, showNo
|
||||
})
|
||||
return () => {
|
||||
cancelled = true
|
||||
void Promise.all(shapes.map((entry) => facade.geometry.release(entry)))
|
||||
if (ownsShapes) void Promise.all(shapes.map((entry) => facade.geometry.release(entry)))
|
||||
adapter.dispose()
|
||||
adapterRef.current = null
|
||||
}
|
||||
}, [facade, showNotice])
|
||||
}, [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>
|
||||
@@ -474,6 +483,8 @@ function ProjectsPage({ onNavigate, onOpenWorkspace, showNotice }: { onNavigate:
|
||||
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 }
|
||||
@@ -483,7 +494,27 @@ function FileFlowPage({ mode, onNavigate, showNotice, facade }: { mode: 'import'
|
||||
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>
|
||||
const exportModel = async () => {
|
||||
if (selectedFormat !== 'STEP' && selectedFormat !== 'STL') { showNotice(`${selectedFormat} export is not implemented yet`); return }
|
||||
const objectIds = ['fillet', 'chamfer', 'pocket', 'pad']
|
||||
const shape = objectIds.map((objectId) => facade.geometry.getObjectShape(objectId)).find((candidate): candidate is ShapeHandle => Boolean(candidate))
|
||||
if (!shape) { showNotice('Recompute a valid Part Design feature 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 }) {
|
||||
|
||||
@@ -408,7 +408,11 @@ export class BitbybitGeometryRuntime {
|
||||
validatePocketInput(input)
|
||||
const base = this.resolveShape(input.base).reference
|
||||
const client = await this.readyClient()
|
||||
const tool = await this.createExtrusion(client, input)
|
||||
const direction = input.direction ?? [0, 1, 0]
|
||||
const featureInput = input.throughAll
|
||||
? { ...input, length: await this.throughAllLength(client, base, direction), symmetricToPlane: true }
|
||||
: input
|
||||
const tool = await this.createExtrusion(client, featureInput)
|
||||
const kernelShape = await client.occt.booleans.difference({ shape: base, shapes: [tool], keepEdges: false })
|
||||
return this.registerShape(kernelShape, input.documentId, input.documentVersion)
|
||||
}
|
||||
@@ -502,6 +506,25 @@ export class BitbybitGeometryRuntime {
|
||||
return client.occt.operations.extrude({ shape: face, direction: extrusion })
|
||||
}
|
||||
|
||||
private async throughAllLength(client: BitByBitOCCT, shape: KernelShapeReference, direction: Point3) {
|
||||
const mesh = await client.occt.shapeToMesh({ shape, precision: 0.1, adjustYtoZ: false })
|
||||
const directionLength = magnitude(direction)
|
||||
const normalized = direction.map((coordinate) => coordinate / directionLength) as Point3
|
||||
let minimum = Infinity
|
||||
let maximum = -Infinity
|
||||
for (const face of mesh.faceList) {
|
||||
for (let index = 0; index < face.vertexCoord.length; index += 3) {
|
||||
const point: Point3 = [face.vertexCoord[index], face.vertexCoord[index + 1], face.vertexCoord[index + 2]]
|
||||
const projection = dot(point, normalized)
|
||||
minimum = Math.min(minimum, projection)
|
||||
maximum = Math.max(maximum, projection)
|
||||
}
|
||||
}
|
||||
if (!Number.isFinite(minimum) || !Number.isFinite(maximum)) throw new Error('Through-all pocket cannot determine the base Shape extent.')
|
||||
const span = Math.max(maximum - minimum, 1)
|
||||
return span + Math.max(1, span * 0.05)
|
||||
}
|
||||
|
||||
private registerShape(reference: KernelShapeReference, documentId: string, documentVersion: number) {
|
||||
const handle: ShapeHandle = {
|
||||
id: `shape-${Date.now().toString(36)}-${(++this.sequence).toString(36)}`,
|
||||
|
||||
@@ -7,7 +7,7 @@ export type { ApplyPlacementInput, BitBybitViewportAdapter, BitBybitWebCadFacade
|
||||
export { createEdgeSubshapeRefs, createSubshapeRefs, createVertexSubshapeRefs, matchSubshapes, signatureForEdge, signatureForFace, signatureForVertex } 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 { createFacadeGeometryRecomputeExecutor, executeFacadeRecomputeNode, RecomputeCoordinator } from './recomputeEngine'
|
||||
export type { RecomputeExecutionError, RecomputeExecutionOptions, RecomputeExecutionResult, RecomputeExecutionStatus, RecomputeGeometryRuntime, RecomputeNodeContext, RecomputeNodeExecutor, RecomputeNodeResult, RecomputeProgress } from './recomputeEngine'
|
||||
export { DEFAULT_FCSTD_LIMITS, inspectFcstdArchive } from './fcstd'
|
||||
export type { FcstdArchiveLimits, FcstdCompatibilityReport, FcstdEntryMetadata, FcstdEntryRole, FcstdInspection, FcstdObjectSummary, FcstdObjectSupport } from './fcstd'
|
||||
|
||||
@@ -16,6 +16,7 @@ import type {
|
||||
SetPropertyInput,
|
||||
SetExpressionInput,
|
||||
RecomputeResult,
|
||||
ShapeHandle,
|
||||
TaskSnapshot,
|
||||
Unsubscribe,
|
||||
} from './types'
|
||||
@@ -25,7 +26,7 @@ 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 { createFacadeGeometryRecomputeExecutor, RecomputeCoordinator, type RecomputeExecutionOptions } from './recomputeEngine'
|
||||
import { inspectFcstdArchive } from './fcstd'
|
||||
|
||||
const initialTree: ModelTreeItem[] = [
|
||||
@@ -38,7 +39,7 @@ const initialTree: ModelTreeItem[] = [
|
||||
{ id: 'reference', label: 'Reference geometry', type: 'folder', children: ['DatumPlane', 'DatumAxis'] },
|
||||
]
|
||||
|
||||
const typeIdForItem = (item: ModelTreeItem) => item.type === 'body' ? 'PartDesign::Body' : item.type === 'sketch' ? 'Sketcher::SketchObject' : item.id.startsWith('pad') ? 'PartDesign::Pad' : item.id.startsWith('pocket') ? 'PartDesign::Pocket' : item.id.startsWith('fillet') ? 'PartDesign::Fillet' : item.type === 'feature' ? 'PartDesign::Feature' : 'App::DocumentObjectGroup'
|
||||
const typeIdForItem = (item: ModelTreeItem) => item.type === 'body' ? 'PartDesign::Body' : item.type === 'sketch' ? 'Sketcher::SketchObject' : item.id.startsWith('pad') ? 'PartDesign::Pad' : item.id.startsWith('pocket') ? 'PartDesign::Pocket' : item.id.startsWith('fillet') ? 'PartDesign::Fillet' : item.id.startsWith('chamfer') ? 'PartDesign::Chamfer' : item.type === 'feature' ? 'PartDesign::Feature' : 'App::DocumentObjectGroup'
|
||||
|
||||
const commonProperties = (item: ModelTreeItem): ObjectPropertySnapshot[] => [
|
||||
{ name: 'Label', label: 'Label', group: 'Identity', scope: 'data', type: 'App::PropertyString', value: item.label },
|
||||
@@ -74,6 +75,10 @@ const featureProperties = (item: ModelTreeItem): ObjectPropertySnapshot[] => {
|
||||
{ name: 'Radius', label: 'Radius', group: 'Parameters', scope: 'data', type: 'App::PropertyLength', value: 3, unit: 'mm', recompute: true },
|
||||
{ name: 'Base', label: 'Base', group: 'Dependencies', scope: 'data', type: 'App::PropertyLink', value: 'pocket', recompute: true },
|
||||
]
|
||||
if (item.id.startsWith('chamfer')) return [
|
||||
{ name: 'Distance', label: 'Distance', group: 'Parameters', scope: 'data', type: 'App::PropertyLength', value: 2, unit: 'mm', recompute: true },
|
||||
{ name: 'Base', label: 'Base', group: 'Dependencies', scope: 'data', type: 'App::PropertyLink', value: 'pocket', recompute: true },
|
||||
]
|
||||
if (item.type === 'sketch') return [
|
||||
{ name: 'Support', label: 'Support', group: 'Attachment', scope: 'data', type: 'App::PropertyLink', value: 'XY_Plane', recompute: true },
|
||||
{ name: 'ConstraintStatus', label: 'Solver state', group: 'Constraints', scope: 'data', type: 'App::PropertyString', value: 'Fully constrained', readOnly: true },
|
||||
@@ -216,12 +221,18 @@ const markDocumentTouched = (document: DocumentSnapshot, objectIds: Iterable<str
|
||||
export function createMockFacade(): BitBybitWebCadFacade {
|
||||
const projectPersistence = createSqliteProjectPersistence()
|
||||
const geometryRuntime = new BitbybitGeometryRuntime()
|
||||
const featureShapes = new Map<string, ShapeHandle>()
|
||||
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,
|
||||
createFacadeGeometryRecomputeExecutor(geometryRuntime, featureShapes),
|
||||
(documentId) => state.document.id === documentId ? state.document.version : null,
|
||||
)
|
||||
const clearFeatureShapes = () => {
|
||||
const retained = [...featureShapes.values()]
|
||||
featureShapes.clear()
|
||||
void Promise.all(retained.map((shape) => geometryRuntime.release(shape)))
|
||||
}
|
||||
const listeners = new Set<FacadeListener>()
|
||||
const undoStack: FacadeState[] = []
|
||||
const redoStack: FacadeState[] = []
|
||||
@@ -440,7 +451,7 @@ export function createMockFacade(): BitBybitWebCadFacade {
|
||||
emit({ type: 'diagnostic.added', diagnostic, context }); emit({ type: 'command.failed', commandId, context, message: status.reason }); notify(status.reason || 'Command is disabled'); return requestId
|
||||
}
|
||||
emit({ type: 'command.started', commandId, context })
|
||||
if (commandId === 'new-document') commit({ ...state, document: createDocument('Untitled document'), selectedObjectId: '' })
|
||||
if (commandId === 'new-document') { clearFeatureShapes(); commit({ ...state, document: createDocument('Untitled document'), selectedObjectId: '' }) }
|
||||
else if (commandId === 'save') {
|
||||
const savedDocument = { ...state.document, dirty: false, version: state.document.version + 1 }
|
||||
commit({ ...state, document: savedDocument })
|
||||
@@ -458,13 +469,13 @@ export function createMockFacade(): BitBybitWebCadFacade {
|
||||
}
|
||||
|
||||
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) => { 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') } },
|
||||
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(); clearFeatureShapes(); 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; clearFeatureShapes(); redoStack.push(getState()); state = previous; emitState(); notify('Undo applied') }, redo: () => { const next = redoStack.pop(); if (!next) return; clearFeatureShapes(); 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(), subscribeExternalChanges: (listener) => projectPersistence.subscribeExternalChanges(listener), save: (document = getState().document) => projectPersistence.save(document), load: (documentId) => projectPersistence.load(documentId), recovery: (documentId) => projectPersistence.recovery(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), topology: (shape, precision) => geometryRuntime.topology(shape, precision), release: (shape) => geometryRuntime.release(shape), dispose: () => geometryRuntime.dispose() },
|
||||
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), topology: (shape, precision) => geometryRuntime.topology(shape, precision), getObjectShape: (objectId) => { const shape = featureShapes.get(objectId); return shape ? { ...shape } : null }, release: (shape) => geometryRuntime.release(shape), dispose: () => { clearFeatureShapes(); geometryRuntime.dispose() } },
|
||||
viewport: { createAdapter: () => new ThreeViewportAdapter() },
|
||||
getState, subscribe: (listener) => { listeners.add(listener); return () => { listeners.delete(listener) } }, notify,
|
||||
}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { DependencyGraph, type RecomputeState } from './dependencyGraph'
|
||||
import { cloneSketch, solveSketch } from './sketcher'
|
||||
import type { DocumentObjectSnapshot, DocumentSnapshot } from './types'
|
||||
import type { ChamferInput, DocumentObjectSnapshot, DocumentSnapshot, FilletInput, PadInput, PlanarProfile, PocketInput, ShapeHandle } from './types'
|
||||
|
||||
export type RecomputeExecutionStatus = 'completed' | 'failed' | 'cancelled' | 'stale'
|
||||
|
||||
@@ -29,6 +29,15 @@ export type RecomputeNodeExecutor = (
|
||||
context: RecomputeNodeContext,
|
||||
) => Promise<RecomputeNodeResult>
|
||||
|
||||
export type RecomputeGeometryRuntime = {
|
||||
capabilities(): { status: string }
|
||||
pad(input: PadInput): Promise<ShapeHandle>
|
||||
pocket(input: PocketInput): Promise<ShapeHandle>
|
||||
fillet(input: FilletInput): Promise<ShapeHandle>
|
||||
chamfer(input: ChamferInput): Promise<ShapeHandle>
|
||||
release(shape: ShapeHandle): Promise<void>
|
||||
}
|
||||
|
||||
export type RecomputeProgress = {
|
||||
generation: number
|
||||
documentVersion: number
|
||||
@@ -217,3 +226,97 @@ export const executeFacadeRecomputeNode: RecomputeNodeExecutor = async (object,
|
||||
}
|
||||
return { status: 'success', updatedObject }
|
||||
}
|
||||
|
||||
const propertyValue = (object: DocumentObjectSnapshot, name: string) => object.properties.find((property) => property.name === name)?.value
|
||||
const linkedObject = (object: DocumentObjectSnapshot, name: string, document: DocumentSnapshot) => {
|
||||
const value = propertyValue(object, name)
|
||||
return typeof value === 'string' ? document.objects.find((candidate) => candidate.id === value) : undefined
|
||||
}
|
||||
|
||||
const pointsEqual = (left: [number, number, number], right: [number, number, number], tolerance = 1e-7) => left.every((value, index) => Math.abs(value - right[index]) <= tolerance)
|
||||
|
||||
const sketchProfile = (sketch: DocumentObjectSnapshot['sketch']): { profile?: PlanarProfile; code?: string; message?: string } => {
|
||||
if (!sketch) return { code: 'PROFILE_MISSING', message: 'Feature profile does not reference a Sketcher object.' }
|
||||
const geometry = sketch.geometry.filter((candidate) => !candidate.construction)
|
||||
if (geometry.some((candidate) => candidate.type !== 'line')) return { code: 'PROFILE_UNSUPPORTED', message: 'OCCT feature recompute currently requires a closed line-loop sketch profile.' }
|
||||
const segments = geometry.filter((candidate): candidate is Extract<typeof candidate, { type: 'line' }> => candidate.type === 'line')
|
||||
if (segments.length < 3) return { code: 'PROFILE_OPEN', message: 'Feature profile requires at least three connected line segments.' }
|
||||
|
||||
const first = segments[0]
|
||||
const ring: [number, number, number][] = [[first.start.x, first.start.y, 0]]
|
||||
let current: [number, number, number] = [first.end.x, first.end.y, 0]
|
||||
const remaining = segments.slice(1)
|
||||
while (remaining.length > 0 && !pointsEqual(current, ring[0])) {
|
||||
const index = remaining.findIndex((segment) => pointsEqual([segment.start.x, segment.start.y, 0], current) || pointsEqual([segment.end.x, segment.end.y, 0], current))
|
||||
if (index < 0) return { code: 'PROFILE_OPEN', message: 'Feature profile line segments do not form a closed loop.' }
|
||||
const segment = remaining.splice(index, 1)[0]
|
||||
if (pointsEqual([segment.start.x, segment.start.y, 0], current)) current = [segment.end.x, segment.end.y, 0]
|
||||
else current = [segment.start.x, segment.start.y, 0]
|
||||
ring.push(current)
|
||||
}
|
||||
if (!pointsEqual(current, ring[0]) || remaining.length > 0) return { code: 'PROFILE_OPEN', message: 'Feature profile line segments do not form one closed loop.' }
|
||||
ring.pop()
|
||||
return { profile: { outer: ring } }
|
||||
}
|
||||
|
||||
const geometryFailure = (objectId: string, code: string, message: string): RecomputeNodeResult => ({ status: 'failed', errors: [{ objectId, code, message }] })
|
||||
|
||||
/**
|
||||
* Adds real OCCT feature execution without putting transient ShapeHandles in the
|
||||
* persisted document snapshot. The map is deliberately owned by the Facade and
|
||||
* keeps the last successful shape when a later feature fails.
|
||||
*/
|
||||
export const createFacadeGeometryRecomputeExecutor = (
|
||||
geometry: RecomputeGeometryRuntime,
|
||||
shapes: Map<string, ShapeHandle> = new Map(),
|
||||
): RecomputeNodeExecutor => async (object, document, context) => {
|
||||
const base = await executeFacadeRecomputeNode(object, document, context)
|
||||
if (base.status === 'failed' || object.sketch || geometry.capabilities().status !== 'ready') return base
|
||||
if (!['PartDesign::Pad', 'PartDesign::Pocket', 'PartDesign::Fillet', 'PartDesign::Chamfer'].includes(object.typeId)) return base
|
||||
|
||||
const requiresProfile = object.typeId === 'PartDesign::Pad' || object.typeId === 'PartDesign::Pocket'
|
||||
const profileObject = requiresProfile ? linkedObject(object, 'Profile', document) : undefined
|
||||
const profile = requiresProfile ? sketchProfile(profileObject?.sketch) : { profile: undefined }
|
||||
if (requiresProfile && !profile.profile) return geometryFailure(object.id, profile.code || 'PROFILE_INVALID', profile.message || 'Feature profile is invalid.')
|
||||
if (context.signal.aborted) throw new DOMException('Recompute cancelled.', 'AbortError')
|
||||
|
||||
const numberProperty = (name: string, fallback: number) => {
|
||||
const value = propertyValue(object, name)
|
||||
return typeof value === 'number' && Number.isFinite(value) ? value : fallback
|
||||
}
|
||||
const documentContext = { documentId: context.documentId, documentVersion: context.documentVersion }
|
||||
try {
|
||||
let result: ShapeHandle
|
||||
if (object.typeId === 'PartDesign::Pad') {
|
||||
result = await geometry.pad({ ...documentContext, profile: profile.profile as PlanarProfile, length: numberProperty('Length', 1), direction: [0, 0, 1], reversed: propertyValue(object, 'Reversed') === true, symmetricToPlane: propertyValue(object, 'Midplane') === true })
|
||||
} else if (object.typeId === 'PartDesign::Pocket') {
|
||||
const pocketType = propertyValue(object, 'Type')
|
||||
if (pocketType === 'Up to face') return geometryFailure(object.id, 'UP_TO_FACE_UNSUPPORTED', 'Pocket Up to face requires a persistent support face and is not implemented yet.')
|
||||
const baseObject = linkedObject(object, 'Base', document)
|
||||
const baseShape = baseObject ? shapes.get(baseObject.id) : undefined
|
||||
if (!baseShape) return geometryFailure(object.id, 'BASE_SHAPE_MISSING', 'Pocket base has no valid recomputed Shape.')
|
||||
result = await geometry.pocket({ ...documentContext, base: baseShape, profile: profile.profile as PlanarProfile, length: numberProperty('Length', 1), direction: [0, 0, 1], reversed: propertyValue(object, 'Reversed') === true, throughAll: pocketType === 'Through all' })
|
||||
} else if (object.typeId === 'PartDesign::Fillet') {
|
||||
const baseObject = linkedObject(object, 'Base', document)
|
||||
const baseShape = baseObject ? shapes.get(baseObject.id) : undefined
|
||||
if (!baseShape) return geometryFailure(object.id, 'BASE_SHAPE_MISSING', 'Fillet base has no valid recomputed Shape.')
|
||||
result = await geometry.fillet({ ...documentContext, base: baseShape, radius: numberProperty('Radius', 1) })
|
||||
} else {
|
||||
const baseObject = linkedObject(object, 'Base', document)
|
||||
const baseShape = baseObject ? shapes.get(baseObject.id) : undefined
|
||||
if (!baseShape) return geometryFailure(object.id, 'BASE_SHAPE_MISSING', 'Chamfer base has no valid recomputed Shape.')
|
||||
result = await geometry.chamfer({ ...documentContext, base: baseShape, distance: numberProperty('Distance', 1) })
|
||||
}
|
||||
if (context.signal.aborted) {
|
||||
await geometry.release(result)
|
||||
throw new DOMException('Recompute cancelled.', 'AbortError')
|
||||
}
|
||||
const previous = shapes.get(object.id)
|
||||
shapes.set(object.id, result)
|
||||
if (previous && previous.id !== result.id) await geometry.release(previous)
|
||||
return base
|
||||
} catch (error) {
|
||||
if (context.signal.aborted || isAbortError(error)) throw error
|
||||
return geometryFailure(object.id, 'GEOMETRY_EXECUTION_FAILED', error instanceof Error ? error.message : String(error))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -257,6 +257,7 @@ export type PadInput = GeometryDocumentContext & LinearFeatureParameters
|
||||
|
||||
export type PocketInput = GeometryDocumentContext & LinearFeatureParameters & {
|
||||
base: ShapeHandle
|
||||
throughAll?: boolean
|
||||
}
|
||||
|
||||
export type RevolutionInput = GeometryDocumentContext & {
|
||||
@@ -424,6 +425,7 @@ export interface BitBybitWebCadFacade {
|
||||
mesh(shape: ShapeHandle, precision?: number): Promise<MeshAsset>
|
||||
subshapes(shape: ShapeHandle, precision?: number): Promise<SubshapeRef[]>
|
||||
topology(shape: ShapeHandle, precision?: number): Promise<SubshapeTopology>
|
||||
getObjectShape(objectId: string): ShapeHandle | null
|
||||
release(shape: ShapeHandle): Promise<void>
|
||||
dispose(): void
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user