feat: establish facade-first runtime boundary

This commit is contained in:
2026-08-02 02:39:37 -04:00
parent 3978833c5c
commit df0053cfe5
16 changed files with 1004 additions and 34 deletions

3
src/facade/index.ts Normal file
View File

@@ -0,0 +1,3 @@
export { createMockFacade } from './mockFacade'
export { ThreeViewportAdapter } from './threeViewport'
export type { BitBybitWebCadFacade, CommandState, DocumentSnapshot, FacadeEvent, FacadeState, ModelTreeItem, TaskSnapshot } from './types'

71
src/facade/mockFacade.ts Normal file
View File

@@ -0,0 +1,71 @@
import { workbenchDefinitions, type WorkbenchId } from '../freecadManifest'
import type {
BitBybitWebCadFacade,
CommandState,
DocumentSnapshot,
ExecuteCommandInput,
FacadeEvent,
FacadeListener,
FacadeState,
ModelTreeItem,
TaskSnapshot,
Unsubscribe,
} from './types'
import { ThreeViewportAdapter } from './threeViewport'
const initialTree: ModelTreeItem[] = [
{ id: 'origin', label: 'Origin', type: 'folder', children: ['XY_Plane', 'XZ_Plane', 'YZ_Plane'] },
{ id: 'body', label: 'Body', type: 'body', state: 'active', children: ['sketch', 'pad', 'pocket', 'fillet'] },
{ id: 'sketch', label: 'Sketch', type: 'sketch', state: 'valid', detail: 'Fully constrained' },
{ id: 'pad', label: 'Pad', type: 'feature', state: 'valid', detail: 'Length 42 mm' },
{ id: 'pocket', label: 'Pocket', type: 'feature', state: 'warning', detail: 'Through all' },
{ id: 'fillet', label: 'Fillet', type: 'feature', state: 'valid', detail: 'Radius 3 mm' },
{ id: 'reference', label: 'Reference geometry', type: 'folder', children: ['DatumPlane', 'DatumAxis'] },
]
const createDocument = (label = 'Pump Housing'): DocumentSnapshot => ({
id: 'doc-pump-housing', label, version: 18, dirty: true, readOnly: false, units: 'mm', tree: initialTree.map((item) => ({ ...item, children: item.children ? [...item.children] : undefined })),
})
const commandState = (commandId: string, activeWorkbench: WorkbenchId): CommandState => {
const known = Object.values(workbenchDefinitions).some((definition) => definition.groups.some((group) => group.commands.some((command) => command.id === commandId)))
if (!known) return { id: commandId, status: 'disabled', reason: 'Command is not registered in the active manifest.' }
if (commandId === 'pad' && activeWorkbench !== 'Part Design') return { id: commandId, status: 'disabled', reason: 'Switch to Part Design to use Pad.' }
return { id: commandId, status: 'enabled' }
}
export function createMockFacade(): BitBybitWebCadFacade {
let state: FacadeState = { apiVersion: '0.1', activeWorkbench: 'Part Design', selectedObjectId: 'pad', document: createDocument(), task: null, lastNotice: '' }
const listeners = new Set<FacadeListener>()
let requestSequence = 0
const emit = (event: FacadeEvent) => listeners.forEach((listener) => listener(event))
const emitState = () => emit({ type: 'state.changed', state: getState() })
const getState = () => ({ ...state, document: { ...state.document, tree: state.document.tree.map((item) => ({ ...item, children: item.children ? [...item.children] : undefined })) }, task: state.task ? { ...state.task, draft: { ...state.task.draft } } : null })
const notify = (message: string) => { state = { ...state, lastNotice: message }; emit({ type: 'notice', message }); emitState() }
const setActive = (id: WorkbenchId) => { state = { ...state, activeWorkbench: id }; emitState(); notify(`${id} workbench loaded`) }
const select = (objectId: string) => { state = { ...state, selectedObjectId: objectId }; emitState() }
const beginTask = (commandId: string, draft: Record<string, unknown> = {}) => { const task: TaskSnapshot = { id: `task-${++requestSequence}`, commandId, title: workbenchDefinitions[state.activeWorkbench].taskTitle, status: 'preview', draft }; state = { ...state, task }; emitState(); return task }
const execute = ({ commandId, payload }: ExecuteCommandInput) => {
const requestId = `req-${++requestSequence}`
const status = commandState(commandId, state.activeWorkbench)
if (status.status === 'disabled') { emit({ type: 'command.failed', commandId, requestId, message: status.reason }); notify(status.reason || 'Command is disabled'); return requestId }
emit({ type: 'command.started', commandId, requestId })
if (commandId === 'new-document') state = { ...state, document: createDocument('Untitled document') }
else if (commandId === 'save') state = { ...state, document: { ...state.document, dirty: false, version: state.document.version + 1 } }
else if (commandId === 'select-object' && typeof payload?.objectId === 'string') select(payload.objectId)
else if (commandId === 'create-body') beginTask('create-body')
else if (commandId === 'create-sketch') beginTask('create-sketch')
emit({ type: 'command.completed', commandId, requestId }); emitState(); return requestId
}
const facade: BitBybitWebCadFacade = {
app: { document: { getActive: () => getState().document, create: (label) => { state = { ...state, document: createDocument(label), selectedObjectId: '' }; emitState(); return getState().document }, markDirty: () => { state = { ...state, document: { ...state.document, dirty: true } }; emitState() } } },
gui: { workbench: { list: () => Object.keys(workbenchDefinitions) as WorkbenchId[], getActive: () => state.activeWorkbench, setActive }, command: { getState: (commandId) => commandState(commandId, state.activeWorkbench), list: (workbench) => workbenchDefinitions[workbench].groups.flatMap((group) => group.commands), execute } },
selection: { getObjectId: () => state.selectedObjectId, select, clear: () => select('') },
task: { getActive: () => getState().task, begin: beginTask, update: (draft) => { if (state.task) state = { ...state, task: { ...state.task, draft: { ...state.task.draft, ...draft } } }; emitState() }, apply: () => { if (state.task) state = { ...state, task: { ...state.task, status: 'completed' } }; emitState() }, cancel: () => { if (state.task) state = { ...state, task: { ...state.task, status: 'cancelled' } }; emitState() } },
viewport: { createAdapter: () => new ThreeViewportAdapter() },
getState, subscribe: (listener) => { listeners.add(listener); return () => { listeners.delete(listener) } }, notify,
}
return facade
}

View File

@@ -0,0 +1,75 @@
import * as THREE from 'three'
import type { BitBybitViewportAdapter } from './types'
/** Internal renderer boundary. React receives only the adapter contract. */
export class ThreeViewportAdapter implements BitBybitViewportAdapter {
private host: HTMLElement | null = null
private renderer: THREE.WebGLRenderer | null = null
private scene: THREE.Scene | null = null
private camera: THREE.PerspectiveCamera | null = null
private selection: THREE.Mesh | null = null
private frame = 0
mount(host: HTMLElement) {
this.host = host
this.scene = new THREE.Scene()
this.scene.background = new THREE.Color(0x0e1417)
this.camera = new THREE.PerspectiveCamera(38, 1, 0.1, 1000)
this.camera.position.set(3.8, 3.1, 5.2)
this.camera.lookAt(0, 0, 0)
this.renderer = new THREE.WebGLRenderer({ antialias: true, alpha: false, powerPreference: 'high-performance' })
this.renderer.setPixelRatio(Math.min(window.devicePixelRatio || 1, 2))
this.renderer.setSize(host.clientWidth || 1, host.clientHeight || 1, false)
host.appendChild(this.renderer.domElement)
const grid = new THREE.GridHelper(8, 16, 0x31555a, 0x1e3439)
grid.rotation.x = 0
this.scene.add(grid)
const geometry = new THREE.BoxGeometry(2.7, 1.6, 1.4)
const material = new THREE.MeshStandardMaterial({ color: 0x579a9c, roughness: 0.62, metalness: 0.1 })
this.selection = new THREE.Mesh(geometry, material)
this.selection.position.y = 0.8
this.scene.add(this.selection)
this.scene.add(new THREE.HemisphereLight(0xb8eeee, 0x142125, 2.1))
const key = new THREE.DirectionalLight(0xffffff, 2.4)
key.position.set(4, 6, 4)
this.scene.add(key)
const render = () => {
if (!this.renderer || !this.scene || !this.camera) return
this.selection && (this.selection.rotation.y += 0.002)
this.renderer.render(this.scene, this.camera)
this.frame = requestAnimationFrame(render)
}
this.resize(host.clientWidth || 1, host.clientHeight || 1)
render()
}
resize(width: number, height: number, devicePixelRatio = Math.min(window.devicePixelRatio || 1, 2)) {
if (!this.renderer || !this.camera) return
this.camera.aspect = Math.max(width, 1) / Math.max(height, 1)
this.camera.updateProjectionMatrix()
this.renderer.setPixelRatio(Math.min(devicePixelRatio, 2))
this.renderer.setSize(Math.max(width, 1), Math.max(height, 1), false)
}
setSelection(objectId: string) {
if (!this.selection) return
const material = this.selection.material as THREE.MeshStandardMaterial
material.color.set(objectId ? 0x5ed6d6 : 0x579a9c)
material.emissive.set(objectId ? 0x123c3c : 0x000000)
}
dispose() {
cancelAnimationFrame(this.frame)
this.selection?.geometry.dispose()
if (this.selection?.material instanceof THREE.Material) this.selection.material.dispose()
this.renderer?.dispose()
this.renderer?.domElement.remove()
this.host = null
this.renderer = null
this.scene = null
this.camera = null
this.selection = null
}
getBackend() { return 'webgl2' as const }
}

106
src/facade/types.ts Normal file
View File

@@ -0,0 +1,106 @@
import type { CommandDefinition, WorkbenchId } from '../freecadManifest'
export type ModelTreeItem = {
id: string
label: string
type: 'document' | 'folder' | 'body' | 'sketch' | 'feature'
state?: 'active' | 'valid' | 'warning' | 'readonly'
detail?: string
children?: string[]
}
export type DocumentSnapshot = {
id: string
label: string
version: number
dirty: boolean
readOnly: boolean
units: string
tree: ModelTreeItem[]
}
export type CommandState = {
id: string
status: 'hidden' | 'disabled' | 'enabled' | 'active'
reason?: string
}
export type TaskSnapshot = {
id: string
commandId: string
title: string
status: 'idle' | 'preview' | 'committing' | 'cancelled' | 'completed' | 'failed'
draft: Record<string, unknown>
}
export type FacadeState = {
apiVersion: '0.1'
activeWorkbench: WorkbenchId
selectedObjectId: string
document: DocumentSnapshot
task: TaskSnapshot | null
lastNotice: string
}
export type FacadeEvent =
| { type: 'state.changed'; state: FacadeState }
| { type: 'notice'; message: string }
| { type: 'command.started' | 'command.completed' | 'command.failed'; commandId: string; requestId: string; message?: string }
export type FacadeListener = (event: FacadeEvent) => void
export type Unsubscribe = () => void
export type ViewportBackend = 'webgl2' | 'webgpu'
export interface BitBybitViewportAdapter {
mount(host: HTMLElement): void
resize(width: number, height: number, devicePixelRatio?: number): void
setSelection(objectId: string): void
dispose(): void
getBackend(): ViewportBackend
}
export type ExecuteCommandInput = {
commandId: string
payload?: Record<string, unknown>
}
export interface BitBybitWebCadFacade {
readonly app: {
document: {
getActive(): DocumentSnapshot
create(label?: string): DocumentSnapshot
markDirty(): void
}
}
readonly gui: {
workbench: {
list(): WorkbenchId[]
getActive(): WorkbenchId
setActive(id: WorkbenchId): void
}
command: {
getState(commandId: string): CommandState
list(workbench: WorkbenchId): CommandDefinition[]
execute(input: ExecuteCommandInput): string
}
}
readonly selection: {
getObjectId(): string
select(objectId: string): void
clear(): void
}
readonly task: {
getActive(): TaskSnapshot | null
begin(commandId: string, draft?: Record<string, unknown>): TaskSnapshot
update(draft: Record<string, unknown>): void
apply(): void
cancel(): void
}
readonly viewport: {
createAdapter(): BitBybitViewportAdapter
}
getState(): FacadeState
subscribe(listener: FacadeListener): Unsubscribe
notify(message: string): void
}