@eroscripts/osr-emu
Version:
A web-based graphical emulator for open source strokers.
586 lines (491 loc) • 18 kB
text/typescript
/* 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 };