UNPKG

@eroscripts/osr-emu

Version:

A web-based graphical emulator for open source strokers.

586 lines (491 loc) 18 kB
/* eslint-disable jsdoc/check-param-names */ import * as THREE from 'three'; import { OrbitControls } from 'three/examples/jsm/controls/OrbitControls.js'; import Axis from './lib/axis.js'; import OSR2Model from './lib/models/osr2/osr2.js'; import SR6Model from './lib/models/sr6/sr6.js'; import SSR1Model from './lib/models/ssr1/ssr1.js'; import { forEachMesh } from './lib/util.js'; const modelInfoByType = { SR6: { constructor: SR6Model, cameraPosition: new THREE.Vector3(262.1988183293872, 487.3523930568582, 194.12937273009945), controlTarget: new THREE.Vector3(-62.330470689438, 28.868408052774832, -17.219160432091634), primaryAxes: ['L0', 'L1', 'L2', 'R1', 'R2'] as const, }, OSR2: { constructor: OSR2Model, cameraPosition: new THREE.Vector3(245.03116537126925, 427.3026288938325, 190.74318476308787), controlTarget: new THREE.Vector3(-36.81235747311321, 6.186822929643268, 15.874049352801714), primaryAxes: ['L0', 'R1', 'R2'] as const, }, SSR1: { constructor: SSR1Model, cameraPosition: new THREE.Vector3(223.80161934006662, 284.7018829005695, 262.21634318512554), controlTarget: new THREE.Vector3(13.385718044675617, -66.86882881039317, 111.35424951515378), primaryAxes: ['L0'] as const, }, }; const COMMAND_REGEX = /^([LRVA]\d)(\d+)$/; const COMMAND_EXTENSION_REGEX = /^([LRVA]\d)(\d+)(I|S)(\d+)$/; const SAVE_PREFERENCE_REGEX = /^\$(\w+)-(?<min>\d+)-(?<max>\d+)$/; const CUSTOM_COMMAND_REGEX = /^#([^\s:]+)(?::(?:"([^"]*)"|([^"\s]*)))?$/; const AXIS_NAMES: Record<axisId, string> = { L0: 'Up', L1: 'Left', L2: 'Forward', R0: 'Twist', R1: 'Roll', R2: 'Pitch', V0: 'Vibe1', V1: 'Vibe2', A0: 'Valve', A1: 'Suck', }; export type axisId = `${'L' | 'R'}${'0' | '1' | '2'}` | `${'V' | 'A'}${'0' | '1'}`; type modelVariant = | 'Twist' & { addAxis?: 'R0' } | 'Vibes' & { addAxis?: 'V0' | 'V1' } | 'Valve' & { addAxis?: 'A0' } | 'Suck' & { addAxis?: 'A1' }; type show<T> = T extends infer U ? U & unknown : never; type modelAxisId<Model extends keyof typeof modelInfoByType, ModelVariant extends modelVariant> = { SR6: 'L0' | 'L1' | 'L2' | 'R1' | 'R2'; OSR2: 'L0' | 'R1' | 'R2'; SSR1: 'L0'; Twist: 'R0'; Vibes: 'V0' | 'V1'; Valve: 'A0'; Suck: 'A1'; }[Model | ModelVariant] & axisId; type emulatorAxisId<Emulator extends OSREmulator<any, any>> = | Emulator extends OSREmulator<infer Model, infer ModelVariant> ? modelAxisId<Model, ModelVariant> : never; /** * TCode Command Specification: * * Device Commands: * - D0: device/firmware info * - D1: TCode version response * - D2: load response for each axis * - DSTOP: stop moving * * User Preferences (stored for reading with D2): * - $<axis>-<min>-<max>: save axis range preferences * * Custom Commands: * - #<command_name><custom_value>? * - custom_value: "" | ":<value>" | ':"<quoted_value>"' */ export type Command<axis extends string> = | `${axis}${number}` | `${axis}${number}${'' | `${'I' | 'S'}${number}`}` | 'D0' | 'D1' | 'D2' | 'DSTOP' | `$${axis}-${number}-${number}` | `#${string}${'' | `:${string}`}`; class OSREmulator<Model extends keyof typeof modelInfoByType = never, ModelVariant extends modelVariant = 'Twist'> { _buffer = ''; _axisEmulator: Record<axisId, Axis> = { L0: new Axis('L0'), // Stroke L1: new Axis('L1'), // Forward L2: new Axis('L2'), // Left R0: new Axis('R0'), // Twist R1: new Axis('R1'), // Roll R2: new Axis('R2'), // Pitch V0: new Axis('V0'), // Vibe1 V1: new Axis('V1'), // Vibe2 A0: new Axis('A0'), // Valve A1: new Axis('A1'), // Suck }; _scale: Record<axisId, number> = { L0: 1, // Stroke L1: 1, // Forward L2: 1, // Left R0: 1, // Twist R1: 1, // Roll R2: 1, // Pitch V0: 1, // Vibe1 V1: 1, // Vibe2 A0: 1, // Valve A1: 1, // Suck }; _userPreferences: Record<axisId, { min: number; max: number }> = { L0: { min: 0, max: 9999 }, L1: { min: 0, max: 9999 }, L2: { min: 0, max: 9999 }, R0: { min: 0, max: 9999 }, R1: { min: 0, max: 9999 }, R2: { min: 0, max: 9999 }, V0: { min: 0, max: 9999 }, V1: { min: 0, max: 9999 }, A0: { min: 0, max: 9999 }, A1: { min: 0, max: 9999 }, }; _element; _osrModel!: InstanceType<typeof modelInfoByType[Model]['constructor']>; _modelType: keyof typeof modelInfoByType; _sceneHelpers: (THREE.DirectionalLightHelper | THREE.CameraHelper | THREE.PointLightHelper | THREE.AxesHelper)[] | null; _objectHelpers: { object: THREE.Object3D; helper: THREE.Object3D }[] | null; _modelVariants: ModelVariant[]; _resizeObserver!: ResizeObserver; _boundResizeListener!: () => void; _animationFrameRequestId!: number; keyLight!: THREE.PointLight; fillLight!: THREE.DirectionalLight; backLight!: THREE.PointLight; ambientLight!: THREE.AmbientLight; camera!: THREE.PerspectiveCamera; renderer!: THREE.WebGLRenderer; scene!: THREE.Scene; controls!: OrbitControls; customCommands: Record<string, (this: OSREmulator<Model, ModelVariant>, value: string) => string | string[] | undefined> = {}; get osrModel() { return this._osrModel; } get availableAxes(): emulatorAxisId<this>[] { const axes: string[] = modelInfoByType[this._modelType].primaryAxes.slice(); if (this._modelVariants.includes('Twist' as any)) axes.push('R0'); axes.sort(); if (this._modelVariants.includes('Vibes' as any)) axes.push('V0', 'V1'); if (this._modelVariants.includes('Valve' as any)) axes.push('A0'); if (this._modelVariants.includes('Suck' as any)) axes.push('A1'); return axes as emulatorAxisId<this>[]; } /** * Get a map of axis name to current decimal value (between zero and one). */ get axes(): Record<emulatorAxisId<this>, number> { return Object.fromEntries( this.availableAxes .map(axis => [axis, this._axisEmulator[axis].getPosition() / 10000]), ) as Record<emulatorAxisId<this>, number>; } get objectHelpers() { return this._objectHelpers; } get sceneHelpers() { return this._sceneHelpers; } /** * Creates a new OSR Emulator instance * @param element - HTML element or selector string to attach the 3D canvas * @param options - Configuration options for the emulator */ constructor(element: HTMLElement | string, options: { scale?: Record<axisId, number>; sceneHelpers?: (THREE.DirectionalLightHelper | THREE.CameraHelper | THREE.PointLightHelper | THREE.AxesHelper)[]; objectHelpers?: { object: THREE.Object3D; helper: THREE.Object3D }[]; model?: Model; modelVariant?: ModelVariant[]; }) { if (element instanceof HTMLElement) { this._element = element; } else if (typeof element === 'string') { const el = document.querySelector(element); if (!el) { throw new Error(`Element not found: ${element}`); } this._element = el; } else { throw new TypeError(`Invalid element: ${element}`); } this._scale = { ...this._scale, ...(options?.scale || {}) }; this._sceneHelpers = options?.sceneHelpers ? [] : null; this._objectHelpers = options?.objectHelpers ? [] : null; this._modelType = (options?.model ?? 'OSR2').toUpperCase() as 'OSR2' | 'SR6' | 'SSR1'; this._modelVariants = options?.modelVariant ?? ['Twist'] as any; this._initCanvas(); } /** * Execute a single TCode command and return any response * @param command - The TCode command to execute * @returns Response string if the command produces output */ writeCommand(command: show<Command<emulatorAxisId<this>>>): string | undefined { return this._executeCommand(command).join('\n') || undefined; } /** * Process input string containing TCode commands (supports multiple commands and newlines) * @param input - Input string containing one or more TCode commands * @returns Combined response string if any commands produce output */ write(input: string): string | undefined { if (typeof input !== 'string') { return; } const response: string[] = []; for (const byte of input) { this._buffer += byte; if (byte === '\n') { response.push(...this._executeCommand(this._buffer)); this._buffer = ''; } } return response.length > 0 ? `${response.join('\n')}\n` : undefined; } /** * Clean up resources and remove the emulator from the DOM */ destroy() { this._resizeObserver.unobserve(this._element); window.removeEventListener('resize', this._boundResizeListener); window.cancelAnimationFrame(this._animationFrameRequestId); this._element.innerHTML = ''; this.renderer.dispose(); this.renderer.forceContextLoss(); } _executeCommand(buffer: string): string[] { const trimmedBuffer = buffer.trim(); const result: string[] = []; // Split commands but preserve original case for custom commands const commands = trimmedBuffer.split(/\s/).map(c => c.trim()).filter(c => c.length > 0); for (const originalCommand of commands) { // Only uppercase non-custom commands const command = originalCommand.startsWith('#') ? originalCommand : originalCommand.toUpperCase(); if (COMMAND_REGEX.test(command)) { this._handleAxisCommand(command); } else if (COMMAND_EXTENSION_REGEX.test(command)) { this._handleAxisExtensionCommand(command); } else if (command === 'D0') { result.push(this._handleDeviceInfoCommand()); } else if (command === 'D1') { result.push(this._handleVersionCommand()); } else if (command === 'D2') { result.push(...this._handleLoadResponseCommand()); } else if (command === 'DSTOP') { this._handleStopCommand(); } else if (command.startsWith('$') && SAVE_PREFERENCE_REGEX.test(command)) { this._handleSavePreferenceCommand(command); } else if (originalCommand.startsWith('#')) { // Use original case for custom commands const response = this._handleCustomCommand(originalCommand); if (response) { result.push(...response); } } else { console.error(`OSR-EMU: Unknown command: ${command}`); } } return result; } private _handleAxisCommand(command: string): void { const match = COMMAND_REGEX.exec(command)!; const axis = match[1] as axisId; const value = match[2]; if (!this.availableAxes.includes(axis as any)) { console.warn(`OSR-EMU: Axis ${axis} not available for model ${this._modelType}`); return; } const parseValue = (value: string) => Number(value.substring(0, 4).padEnd(4, '0')); this._axisEmulator[axis].set(parseValue(value)); } private _handleAxisExtensionCommand(command: string): void { const match = COMMAND_EXTENSION_REGEX.exec(command)!; const axis = match[1] as axisId; const value = match[2]; const ext = match[3] as 'I' | 'S'; const extValue = match[4]; if (!this.availableAxes.includes(axis as any)) { console.warn(`OSR-EMU: Axis ${axis} not available for model ${this._modelType}`); return; } const parseValue = (value: string) => Number(value.substring(0, 4).padEnd(4, '0')); this._axisEmulator[axis].set(parseValue(value), ext, Number(extValue)); } private _handleDeviceInfoCommand(): string { return `${this._modelType}-emu-ESP32.ino`; } private _handleVersionCommand(): string { return 'TCode v0.3'; } private _handleLoadResponseCommand(): string[] { const result: string[] = []; for (const axis of this.availableAxes) { result.push(`${axis} ${ this._userPreferences[axis].min } ${ this._userPreferences[axis].max } ${AXIS_NAMES[axis]}`); } return result; } private _handleStopCommand(): void { // Stop all axis movement by setting them to their current position for (const axis of this.availableAxes) { const currentPos = this._axisEmulator[axis].getPosition(); this._axisEmulator[axis].set(currentPos); } } private _handleSavePreferenceCommand(command: string): void { const { groups } = command.match(SAVE_PREFERENCE_REGEX)!; const axis = groups!.axis as axisId; const min = Number(groups!.min); const max = Number(groups!.max); if (min >= 0 && max <= 9999 && min <= max) { this._userPreferences[axis] = { min, max }; } else { console.warn(`OSR-EMU: Invalid preference range for ${axis}: ${min}-${max}`); } } private _handleCustomCommand(command: string): string[] { const customMatch = command.match(CUSTOM_COMMAND_REGEX); if (!customMatch) { console.error(`OSR-EMU: Invalid custom command format: ${command}`); return []; } const commandName = customMatch[1]; const quotedValue = customMatch[2]; const unquotedValue = customMatch[3]; const value = quotedValue !== undefined ? quotedValue : (unquotedValue || ''); if (this.customCommands[commandName]) { const response = this.customCommands[commandName].call(this, value); if (response) { // Handle both string and string array responses return Array.isArray(response) ? response : [response]; } } else { console.warn(`OSR-EMU: Unknown custom command: ${command}`); } return []; } _initCanvas() { const scene = new THREE.Scene(); const camera = new THREE.PerspectiveCamera(50, this._computeAspectRatio(), 0.1, 1000); const modelInfo = modelInfoByType[this._modelType]; camera.position.copy(modelInfo.cameraPosition); camera.up.set(0, 0, 1); const renderer = new THREE.WebGLRenderer(); renderer.shadowMap.enabled = true; renderer.shadowMap.type = THREE.PCFSoftShadowMap; const controls = new OrbitControls(camera, renderer.domElement); controls.maxDistance = 700; controls.target.copy(modelInfo.controlTarget); controls.update(); this.camera = camera; this.renderer = renderer; this.scene = scene; this.controls = controls; this._setupLighting(scene); this._loadModel(scene); this._element.innerHTML = ''; this._element.appendChild(renderer.domElement); this._buffer = ''; this._resize(); this._animate(); this._boundResizeListener = this._resize.bind(this); this._resizeObserver = new ResizeObserver(this._boundResizeListener); this._resizeObserver.observe(this._element); window.addEventListener('resize', this._boundResizeListener); } _render() { this.renderer.render(this.scene, this.camera); } _animate() { this._animationFrameRequestId = requestAnimationFrame(this._animate.bind(this)); this.controls.update(); this._updatePositions(); this._render(); } _resize() { const viewport = this._element.getBoundingClientRect(); this.camera.aspect = this._computeAspectRatio(); this.camera.updateProjectionMatrix(); this.renderer.setSize(viewport.width, viewport.height); } _computeAspectRatio() { const viewport = this._element.getBoundingClientRect(); return viewport.width / viewport.height; } _setupLighting(scene: THREE.Scene) { const keyLight = new THREE.PointLight(0xFFFFFF); keyLight.intensity = 0.75; keyLight.position.set(200, 180, 50); keyLight.castShadow = true; const fillLight = new THREE.DirectionalLight(0xFFFFFF, 1); fillLight.intensity = 1; fillLight.position.set(-225, 225, 225); fillLight.castShadow = true; for (const side of ['left', 'right', 'bottom', 'top'] as const) { fillLight.shadow.camera[side] *= 50; } const backLight = new THREE.PointLight(0xFFFFFF); backLight.intensity = 1; backLight.position.set(70, -100, 200); backLight.castShadow = true; const ambientLight = new THREE.AmbientLight(0xFFFFFF, 0.625); this.keyLight = keyLight; this.fillLight = fillLight; this.backLight = backLight; this.ambientLight = ambientLight; if (this._modelType === 'SSR1') { // Slight adjustents to lighting for SSR1... this.keyLight.position.z = 220; this.backLight.position.y = -200; } scene.add(keyLight); scene.add(fillLight); scene.add(backLight); scene.add(ambientLight); if (this._sceneHelpers) { this._sceneHelpers = this._sceneHelpers.concat([ new THREE.DirectionalLightHelper(fillLight, 100), new THREE.CameraHelper(fillLight.shadow.camera), new THREE.PointLightHelper(keyLight, 5), new THREE.PointLightHelper(backLight, 5), new THREE.AxesHelper(500), ]); this._sceneHelpers.forEach(h => scene.add(h)); } } _loadModel(scene: THREE.Scene) { const Model = modelInfoByType[this._modelType].constructor; this._osrModel = new Model() as any; const osrGroup = new THREE.Group(); const { objects, orientation } = this._osrModel.load(); for (const object of Object.values(objects)) { forEachMesh(object, (mesh) => { mesh.receiveShadow = true; mesh.castShadow = true; }); osrGroup.add(object); if (this._objectHelpers) { const helper = new THREE.AxesHelper(100); osrGroup.add(helper); this._objectHelpers.push({ object, helper, }); } } osrGroup.rotation.set(orientation, 0, 0); scene.add(osrGroup); } _updatePositions() { this._osrModel.preRender(this.axes as any, this._scale); this._render(); // Update the position of object helpers if enabled. if (this._objectHelpers) { for (const { object, helper } of this._objectHelpers) { helper.position.set(object.position.x, object.position.y, object.position.z); helper.rotation.set(object.rotation.x, object.rotation.y, object.rotation.z); } } } } export default OSREmulator; export { OSREmulator };