feat: establish facade-first runtime boundary
This commit is contained in:
3
src/facade/index.ts
Normal file
3
src/facade/index.ts
Normal file
@@ -0,0 +1,3 @@
|
||||
export { createMockFacade } from './mockFacade'
|
||||
export { ThreeViewportAdapter } from './threeViewport'
|
||||
export type { BitBybitWebCadFacade, CommandState, DocumentSnapshot, FacadeEvent, FacadeState, ModelTreeItem, TaskSnapshot } from './types'
|
||||
71
src/facade/mockFacade.ts
Normal file
71
src/facade/mockFacade.ts
Normal file
@@ -0,0 +1,71 @@
|
||||
import { workbenchDefinitions, type WorkbenchId } from '../freecadManifest'
|
||||
import type {
|
||||
BitBybitWebCadFacade,
|
||||
CommandState,
|
||||
DocumentSnapshot,
|
||||
ExecuteCommandInput,
|
||||
FacadeEvent,
|
||||
FacadeListener,
|
||||
FacadeState,
|
||||
ModelTreeItem,
|
||||
TaskSnapshot,
|
||||
Unsubscribe,
|
||||
} from './types'
|
||||
import { ThreeViewportAdapter } from './threeViewport'
|
||||
|
||||
const initialTree: ModelTreeItem[] = [
|
||||
{ id: 'origin', label: 'Origin', type: 'folder', children: ['XY_Plane', 'XZ_Plane', 'YZ_Plane'] },
|
||||
{ id: 'body', label: 'Body', type: 'body', state: 'active', children: ['sketch', 'pad', 'pocket', 'fillet'] },
|
||||
{ id: 'sketch', label: 'Sketch', type: 'sketch', state: 'valid', detail: 'Fully constrained' },
|
||||
{ id: 'pad', label: 'Pad', type: 'feature', state: 'valid', detail: 'Length 42 mm' },
|
||||
{ id: 'pocket', label: 'Pocket', type: 'feature', state: 'warning', detail: 'Through all' },
|
||||
{ id: 'fillet', label: 'Fillet', type: 'feature', state: 'valid', detail: 'Radius 3 mm' },
|
||||
{ id: 'reference', label: 'Reference geometry', type: 'folder', children: ['DatumPlane', 'DatumAxis'] },
|
||||
]
|
||||
|
||||
const createDocument = (label = 'Pump Housing'): DocumentSnapshot => ({
|
||||
id: 'doc-pump-housing', label, version: 18, dirty: true, readOnly: false, units: 'mm', tree: initialTree.map((item) => ({ ...item, children: item.children ? [...item.children] : undefined })),
|
||||
})
|
||||
|
||||
const commandState = (commandId: string, activeWorkbench: WorkbenchId): CommandState => {
|
||||
const known = Object.values(workbenchDefinitions).some((definition) => definition.groups.some((group) => group.commands.some((command) => command.id === commandId)))
|
||||
if (!known) return { id: commandId, status: 'disabled', reason: 'Command is not registered in the active manifest.' }
|
||||
if (commandId === 'pad' && activeWorkbench !== 'Part Design') return { id: commandId, status: 'disabled', reason: 'Switch to Part Design to use Pad.' }
|
||||
return { id: commandId, status: 'enabled' }
|
||||
}
|
||||
|
||||
export function createMockFacade(): BitBybitWebCadFacade {
|
||||
let state: FacadeState = { apiVersion: '0.1', activeWorkbench: 'Part Design', selectedObjectId: 'pad', document: createDocument(), task: null, lastNotice: '' }
|
||||
const listeners = new Set<FacadeListener>()
|
||||
let requestSequence = 0
|
||||
|
||||
const emit = (event: FacadeEvent) => listeners.forEach((listener) => listener(event))
|
||||
const emitState = () => emit({ type: 'state.changed', state: getState() })
|
||||
const getState = () => ({ ...state, document: { ...state.document, tree: state.document.tree.map((item) => ({ ...item, children: item.children ? [...item.children] : undefined })) }, task: state.task ? { ...state.task, draft: { ...state.task.draft } } : null })
|
||||
const notify = (message: string) => { state = { ...state, lastNotice: message }; emit({ type: 'notice', message }); emitState() }
|
||||
const setActive = (id: WorkbenchId) => { state = { ...state, activeWorkbench: id }; emitState(); notify(`${id} workbench loaded`) }
|
||||
const select = (objectId: string) => { state = { ...state, selectedObjectId: objectId }; emitState() }
|
||||
const beginTask = (commandId: string, draft: Record<string, unknown> = {}) => { const task: TaskSnapshot = { id: `task-${++requestSequence}`, commandId, title: workbenchDefinitions[state.activeWorkbench].taskTitle, status: 'preview', draft }; state = { ...state, task }; emitState(); return task }
|
||||
const execute = ({ commandId, payload }: ExecuteCommandInput) => {
|
||||
const requestId = `req-${++requestSequence}`
|
||||
const status = commandState(commandId, state.activeWorkbench)
|
||||
if (status.status === 'disabled') { emit({ type: 'command.failed', commandId, requestId, message: status.reason }); notify(status.reason || 'Command is disabled'); return requestId }
|
||||
emit({ type: 'command.started', commandId, requestId })
|
||||
if (commandId === 'new-document') state = { ...state, document: createDocument('Untitled document') }
|
||||
else if (commandId === 'save') state = { ...state, document: { ...state.document, dirty: false, version: state.document.version + 1 } }
|
||||
else if (commandId === 'select-object' && typeof payload?.objectId === 'string') select(payload.objectId)
|
||||
else if (commandId === 'create-body') beginTask('create-body')
|
||||
else if (commandId === 'create-sketch') beginTask('create-sketch')
|
||||
emit({ type: 'command.completed', commandId, requestId }); emitState(); return requestId
|
||||
}
|
||||
|
||||
const facade: BitBybitWebCadFacade = {
|
||||
app: { document: { getActive: () => getState().document, create: (label) => { state = { ...state, document: createDocument(label), selectedObjectId: '' }; emitState(); return getState().document }, markDirty: () => { state = { ...state, document: { ...state.document, dirty: true } }; emitState() } } },
|
||||
gui: { workbench: { list: () => Object.keys(workbenchDefinitions) as WorkbenchId[], getActive: () => state.activeWorkbench, setActive }, command: { getState: (commandId) => commandState(commandId, state.activeWorkbench), list: (workbench) => workbenchDefinitions[workbench].groups.flatMap((group) => group.commands), execute } },
|
||||
selection: { getObjectId: () => state.selectedObjectId, select, clear: () => select('') },
|
||||
task: { getActive: () => getState().task, begin: beginTask, update: (draft) => { if (state.task) state = { ...state, task: { ...state.task, draft: { ...state.task.draft, ...draft } } }; emitState() }, apply: () => { if (state.task) state = { ...state, task: { ...state.task, status: 'completed' } }; emitState() }, cancel: () => { if (state.task) state = { ...state, task: { ...state.task, status: 'cancelled' } }; emitState() } },
|
||||
viewport: { createAdapter: () => new ThreeViewportAdapter() },
|
||||
getState, subscribe: (listener) => { listeners.add(listener); return () => { listeners.delete(listener) } }, notify,
|
||||
}
|
||||
return facade
|
||||
}
|
||||
75
src/facade/threeViewport.ts
Normal file
75
src/facade/threeViewport.ts
Normal file
@@ -0,0 +1,75 @@
|
||||
import * as THREE from 'three'
|
||||
import type { BitBybitViewportAdapter } from './types'
|
||||
|
||||
/** Internal renderer boundary. React receives only the adapter contract. */
|
||||
export class ThreeViewportAdapter implements BitBybitViewportAdapter {
|
||||
private host: HTMLElement | null = null
|
||||
private renderer: THREE.WebGLRenderer | null = null
|
||||
private scene: THREE.Scene | null = null
|
||||
private camera: THREE.PerspectiveCamera | null = null
|
||||
private selection: THREE.Mesh | null = null
|
||||
private frame = 0
|
||||
|
||||
mount(host: HTMLElement) {
|
||||
this.host = host
|
||||
this.scene = new THREE.Scene()
|
||||
this.scene.background = new THREE.Color(0x0e1417)
|
||||
this.camera = new THREE.PerspectiveCamera(38, 1, 0.1, 1000)
|
||||
this.camera.position.set(3.8, 3.1, 5.2)
|
||||
this.camera.lookAt(0, 0, 0)
|
||||
this.renderer = new THREE.WebGLRenderer({ antialias: true, alpha: false, powerPreference: 'high-performance' })
|
||||
this.renderer.setPixelRatio(Math.min(window.devicePixelRatio || 1, 2))
|
||||
this.renderer.setSize(host.clientWidth || 1, host.clientHeight || 1, false)
|
||||
host.appendChild(this.renderer.domElement)
|
||||
const grid = new THREE.GridHelper(8, 16, 0x31555a, 0x1e3439)
|
||||
grid.rotation.x = 0
|
||||
this.scene.add(grid)
|
||||
const geometry = new THREE.BoxGeometry(2.7, 1.6, 1.4)
|
||||
const material = new THREE.MeshStandardMaterial({ color: 0x579a9c, roughness: 0.62, metalness: 0.1 })
|
||||
this.selection = new THREE.Mesh(geometry, material)
|
||||
this.selection.position.y = 0.8
|
||||
this.scene.add(this.selection)
|
||||
this.scene.add(new THREE.HemisphereLight(0xb8eeee, 0x142125, 2.1))
|
||||
const key = new THREE.DirectionalLight(0xffffff, 2.4)
|
||||
key.position.set(4, 6, 4)
|
||||
this.scene.add(key)
|
||||
const render = () => {
|
||||
if (!this.renderer || !this.scene || !this.camera) return
|
||||
this.selection && (this.selection.rotation.y += 0.002)
|
||||
this.renderer.render(this.scene, this.camera)
|
||||
this.frame = requestAnimationFrame(render)
|
||||
}
|
||||
this.resize(host.clientWidth || 1, host.clientHeight || 1)
|
||||
render()
|
||||
}
|
||||
|
||||
resize(width: number, height: number, devicePixelRatio = Math.min(window.devicePixelRatio || 1, 2)) {
|
||||
if (!this.renderer || !this.camera) return
|
||||
this.camera.aspect = Math.max(width, 1) / Math.max(height, 1)
|
||||
this.camera.updateProjectionMatrix()
|
||||
this.renderer.setPixelRatio(Math.min(devicePixelRatio, 2))
|
||||
this.renderer.setSize(Math.max(width, 1), Math.max(height, 1), false)
|
||||
}
|
||||
|
||||
setSelection(objectId: string) {
|
||||
if (!this.selection) return
|
||||
const material = this.selection.material as THREE.MeshStandardMaterial
|
||||
material.color.set(objectId ? 0x5ed6d6 : 0x579a9c)
|
||||
material.emissive.set(objectId ? 0x123c3c : 0x000000)
|
||||
}
|
||||
|
||||
dispose() {
|
||||
cancelAnimationFrame(this.frame)
|
||||
this.selection?.geometry.dispose()
|
||||
if (this.selection?.material instanceof THREE.Material) this.selection.material.dispose()
|
||||
this.renderer?.dispose()
|
||||
this.renderer?.domElement.remove()
|
||||
this.host = null
|
||||
this.renderer = null
|
||||
this.scene = null
|
||||
this.camera = null
|
||||
this.selection = null
|
||||
}
|
||||
|
||||
getBackend() { return 'webgl2' as const }
|
||||
}
|
||||
106
src/facade/types.ts
Normal file
106
src/facade/types.ts
Normal file
@@ -0,0 +1,106 @@
|
||||
import type { CommandDefinition, WorkbenchId } from '../freecadManifest'
|
||||
|
||||
export type ModelTreeItem = {
|
||||
id: string
|
||||
label: string
|
||||
type: 'document' | 'folder' | 'body' | 'sketch' | 'feature'
|
||||
state?: 'active' | 'valid' | 'warning' | 'readonly'
|
||||
detail?: string
|
||||
children?: string[]
|
||||
}
|
||||
|
||||
export type DocumentSnapshot = {
|
||||
id: string
|
||||
label: string
|
||||
version: number
|
||||
dirty: boolean
|
||||
readOnly: boolean
|
||||
units: string
|
||||
tree: ModelTreeItem[]
|
||||
}
|
||||
|
||||
export type CommandState = {
|
||||
id: string
|
||||
status: 'hidden' | 'disabled' | 'enabled' | 'active'
|
||||
reason?: string
|
||||
}
|
||||
|
||||
export type TaskSnapshot = {
|
||||
id: string
|
||||
commandId: string
|
||||
title: string
|
||||
status: 'idle' | 'preview' | 'committing' | 'cancelled' | 'completed' | 'failed'
|
||||
draft: Record<string, unknown>
|
||||
}
|
||||
|
||||
export type FacadeState = {
|
||||
apiVersion: '0.1'
|
||||
activeWorkbench: WorkbenchId
|
||||
selectedObjectId: string
|
||||
document: DocumentSnapshot
|
||||
task: TaskSnapshot | null
|
||||
lastNotice: string
|
||||
}
|
||||
|
||||
export type FacadeEvent =
|
||||
| { type: 'state.changed'; state: FacadeState }
|
||||
| { type: 'notice'; message: string }
|
||||
| { type: 'command.started' | 'command.completed' | 'command.failed'; commandId: string; requestId: string; message?: string }
|
||||
|
||||
export type FacadeListener = (event: FacadeEvent) => void
|
||||
export type Unsubscribe = () => void
|
||||
|
||||
export type ViewportBackend = 'webgl2' | 'webgpu'
|
||||
|
||||
export interface BitBybitViewportAdapter {
|
||||
mount(host: HTMLElement): void
|
||||
resize(width: number, height: number, devicePixelRatio?: number): void
|
||||
setSelection(objectId: string): void
|
||||
dispose(): void
|
||||
getBackend(): ViewportBackend
|
||||
}
|
||||
|
||||
export type ExecuteCommandInput = {
|
||||
commandId: string
|
||||
payload?: Record<string, unknown>
|
||||
}
|
||||
|
||||
export interface BitBybitWebCadFacade {
|
||||
readonly app: {
|
||||
document: {
|
||||
getActive(): DocumentSnapshot
|
||||
create(label?: string): DocumentSnapshot
|
||||
markDirty(): void
|
||||
}
|
||||
}
|
||||
readonly gui: {
|
||||
workbench: {
|
||||
list(): WorkbenchId[]
|
||||
getActive(): WorkbenchId
|
||||
setActive(id: WorkbenchId): void
|
||||
}
|
||||
command: {
|
||||
getState(commandId: string): CommandState
|
||||
list(workbench: WorkbenchId): CommandDefinition[]
|
||||
execute(input: ExecuteCommandInput): string
|
||||
}
|
||||
}
|
||||
readonly selection: {
|
||||
getObjectId(): string
|
||||
select(objectId: string): void
|
||||
clear(): void
|
||||
}
|
||||
readonly task: {
|
||||
getActive(): TaskSnapshot | null
|
||||
begin(commandId: string, draft?: Record<string, unknown>): TaskSnapshot
|
||||
update(draft: Record<string, unknown>): void
|
||||
apply(): void
|
||||
cancel(): void
|
||||
}
|
||||
readonly viewport: {
|
||||
createAdapter(): BitBybitViewportAdapter
|
||||
}
|
||||
getState(): FacadeState
|
||||
subscribe(listener: FacadeListener): Unsubscribe
|
||||
notify(message: string): void
|
||||
}
|
||||
Reference in New Issue
Block a user