UNPKG

@eroscripts/osr-emu

Version:

A web-based graphical emulator for open source strokers.

565 lines (474 loc) 24.6 kB
import type { Group, Object3DEventMap } from 'three'; import type { axisId } from '../../../osr-emu.js'; import { MeshPhongMaterial, MeshStandardMaterial, Quaternion, Vector3 } from 'three'; import { circleCircleIntersection, circleSphereIntersection, constrain, degToRad, loadComplexObj, loadObj, loadObjs, mapDecimal, recomputeNormals, servoToRotation, vectorDirection, } from '../../util.js'; import caseGeometry from '../common/case.js'; import bearingGeometry from '../common/rod-end-bearing.js'; import armGeometry from './geometry/arm.js'; import baseGeometry from './geometry/base.js'; import bearingArmGeometry from './geometry/bearing-arm.js'; import leftPitcherGeometry from './geometry/left-pitcher.js'; import lidGeometry from './geometry/lid.js'; import mainArmServoGeometry from './geometry/main-arm-servos.js'; import leftPitcherLinkGeometry from './geometry/pitcher-link-left.js'; import rightPitcherLinkGeometry from './geometry/pitcher-link-right.js'; import receiverGeometry from './geometry/receiver.js'; import rightPitcherGeometry from './geometry/right-pitcher.js'; /** * 3D Model of the SR6 */ export default class SR6Model { _orientation = -Math.PI / 2; // Rotation around the x-axis. objects!: Record<string, Group<Object3DEventMap>>; _lastLeftPitcherEndBearingPosition!: Vector3; load() { const ayvaBlueMaterial = new MeshPhongMaterial({ color: 0x3F5173 }); const darkMaterial = new MeshPhongMaterial({ color: 0x1E1E1E }); const servoMaterial = new MeshPhongMaterial({ color: 0x131313 }); const bearingMaterial = new MeshStandardMaterial({ color: 0xC0C0C0, metalness: 1, roughness: 0.5, }); const base = loadObj(baseGeometry, darkMaterial); const mainArmServos = loadObj(mainArmServoGeometry, servoMaterial); const lid = loadObj(lidGeometry, ayvaBlueMaterial); const receiver = loadObj(receiverGeometry, ayvaBlueMaterial); const modCase = loadObj(caseGeometry, darkMaterial); const [ upperLeftArm, upperRightArm, lowerLeftArm, lowerRightArm, ] = loadObjs(4, armGeometry, ayvaBlueMaterial); const [ upperLeftLink, upperRightLink, lowerLeftLink, lowerRightLink, ] = loadObjs(4, bearingArmGeometry, darkMaterial); const [ // TODO: Include these as part of the link model with different material (the same way the pitcher links are loaded). upperLeftFrontBearing, upperLeftBackBearing, upperRightFrontBearing, upperRightBackBearing, lowerRightFrontBearing, lowerRightBackBearing, lowerLeftFrontBearing, lowerLeftBackBearing, ] = loadObjs(8, bearingGeometry, bearingMaterial, recomputeNormals); const pitcherLinkModelConfig = { rodEndBearing: { processMesh: recomputeNormals, material: bearingMaterial, }, pitcherLink: { material: darkMaterial, }, }; const leftPitcher = loadObj(leftPitcherGeometry, ayvaBlueMaterial); const leftPitcherLink = loadComplexObj(leftPitcherLinkGeometry, pitcherLinkModelConfig); const rightPitcher = loadObj(rightPitcherGeometry, ayvaBlueMaterial); const rightPitcherLink = loadComplexObj(rightPitcherLinkGeometry, pitcherLinkModelConfig); this.objects = { base, lid, mainArmServos, lowerRightArm, upperRightArm, lowerLeftArm, upperLeftArm, lowerRightLink, upperRightLink, lowerLeftLink, upperLeftLink, leftPitcher, rightPitcher, upperRightBackBearing, upperRightFrontBearing, lowerRightBackBearing, lowerRightFrontBearing, upperLeftBackBearing, upperLeftFrontBearing, lowerLeftBackBearing, lowerLeftFrontBearing, leftPitcherLink, rightPitcherLink, receiver, modCase, }; this._setBaseArmPositions(); return { objects: this.objects, orientation: this._orientation }; } preRender(axes: Record<axisId, number>, scale: Record<axisId, number>) { this._setBaseArmPositions(); this._performKinematics(axes, scale); } /** * Set the positions of all the arms (where they attach to the base model). */ _setBaseArmPositions() { const armX = 58.5; const upperArmY = 0; const lowerArmY = 30; const armZ = 59.92 - 10; // Servo axle is about 10mm from the edge of the servo. const pitcherArmX = 14.318; const pitcherArmYZ = [-29.72, 49.325]; const { leftPitcher, rightPitcher, upperLeftArm, upperRightArm, lowerLeftArm, lowerRightArm, } = this.objects; upperLeftArm.position.fromArray([armX, upperArmY, armZ]); upperLeftArm.rotation.y = Math.PI; upperRightArm.position.fromArray([-armX, upperArmY, armZ]); lowerLeftArm.position.fromArray([armX, lowerArmY, armZ]); lowerLeftArm.rotation.x = Math.PI; lowerLeftArm.rotation.y = Math.PI; lowerRightArm.position.fromArray([-armX, lowerArmY, armZ]); lowerRightArm.rotation.x = Math.PI; leftPitcher.position.fromArray([pitcherArmX, ...pitcherArmYZ]); rightPitcher.position.fromArray([-pitcherArmX, ...pitcherArmYZ]); } /** * This method uses a combination of Forward Kinematics and Inverse Kinematics to position everything. * * The main arms' position and rotation are calculated via Forward Kinematics. The servo angles are * generated using code ripped directly from the actual SR6 firmware. The position of the receiver is then * calculated from the main arm positions. * * From there, we do a direct rotation of the expected pitch on the receiver, and then do Inverse Kinematics * to calculate the rotation of the pitcher servos and position of the pitcher angle links. * * @param {object} axes - map of current values for each axis * @param {object} scale - map of scale for each axis */ _performKinematics(axes: Record<axisId, number>, scale: Record<axisId, number>) { const pitchRange = 50; // mm const upVector = new Vector3(0, 0, 1); const pitchAngle = scale.R2 * axes.R2 * degToRad(pitchRange) - degToRad(pitchRange / 2); const pitchBearingAngle = pitchAngle - degToRad(14.763202965644615); // Angle to the pitch holes on the receiver (from main arm holes) const receiverDirection = new Vector3(-1, 0, 0); const pitcherBearingVector = new Vector3(0, -75, 0); const angleLinkLength = 185; // mm const receiverWidth = 145.5; // mm const upperLinkOffset = 6; // mm const swayRange = 30; // mm const pitchQuaternion = new Quaternion() .setFromAxisAngle(new Vector3(1, 0, 0), pitchAngle); const pitchBearingQuaternion = new Quaternion() .setFromAxisAngle(new Vector3(1, 0, 0), pitchBearingAngle); const twistQuaternion = new Quaternion() .setFromAxisAngle(new Vector3(0, -1, 0), scale.R0 * axes.R0 * degToRad(240) - degToRad(120)); const { lowerLeftServoAngle, upperLeftServoAngle, lowerRightServoAngle, upperRightServoAngle, leftPitchServoAngle, rightPitchServoAngle, } = this._computeFirmwareServoAngles(axes, scale); const { lowerRightArm, upperRightArm, lowerLeftArm, upperLeftArm, leftPitcher, rightPitcher, lowerRightLink, upperRightLink, lowerLeftLink, upperLeftLink, upperRightBackBearing, upperRightFrontBearing, lowerRightBackBearing, lowerRightFrontBearing, upperLeftBackBearing, upperLeftFrontBearing, lowerLeftBackBearing, lowerLeftFrontBearing, leftPitcherLink, rightPitcherLink, receiver, modCase, } = this.objects; leftPitcher.rotation.x = leftPitchServoAngle; rightPitcher.rotation.x = rightPitchServoAngle; upperLeftArm.rotation.x = upperLeftServoAngle; upperRightArm.rotation.x = upperRightServoAngle; lowerLeftArm.rotation.x = lowerLeftServoAngle; lowerRightArm.rotation.x = lowerRightServoAngle; upperLeftLink.position.copy(this._computeLinkPosition(upperLeftArm.position, upperLeftServoAngle)); upperRightLink.position.copy(this._computeLinkPosition(upperRightArm.position, upperRightServoAngle, -1)); lowerLeftLink.position.copy(this._computeLinkPosition(lowerLeftArm.position, lowerLeftServoAngle)); lowerRightLink.position.copy(this._computeLinkPosition(lowerRightArm.position, lowerRightServoAngle, -1)); upperLeftBackBearing.position.copy(upperLeftLink.position); upperRightBackBearing.position.copy(upperRightLink.position); lowerLeftBackBearing.position.copy(lowerLeftLink.position); lowerRightBackBearing.position.copy(lowerRightLink.position); const { intersection: leftIntersectionPoint, center: leftIntersectionCenter, radius: leftIntersectionRadius, } = this._computeMainLinkIntersectionPoint(upperLeftLink.position, lowerLeftLink.position); const { intersection: rightIntersectionPoint, center: rightIntersectionCenter, radius: rightIntersectionRadius, } = this._computeMainLinkIntersectionPoint(upperRightLink.position, lowerRightLink.position); if (leftIntersectionPoint && rightIntersectionPoint) { const actualWidth = rightIntersectionPoint.clone().sub(leftIntersectionPoint).length(); if (actualWidth > receiverWidth) { // Keep the main arms attached to the receiver by applying an offset // when the distance between the attachment points is too large. const offset = (actualWidth - receiverWidth) / 2; const rightCopy = rightIntersectionPoint.clone(); const leftCopy = leftIntersectionPoint.clone(); rightIntersectionPoint.addScaledVector(leftCopy.clone().sub(rightCopy).normalize(), offset); leftIntersectionPoint.addScaledVector(rightCopy.clone().sub(leftCopy).normalize(), offset); } const swayArc = scale.L2 * swayRange * ((axes.L2 - 0.5) / 0.5); const rightArcAngle = swayArc / rightIntersectionRadius; const leftArcAngle = swayArc / leftIntersectionRadius; if (rightArcAngle || leftArcAngle) { // To apply sway, we rotate the main arms around the Y axis (relative to their center points) const rotationUpVector = new Vector3(0, 1, 0); rightIntersectionPoint.sub(rightIntersectionCenter).applyAxisAngle(rotationUpVector, rightArcAngle).add(rightIntersectionCenter); leftIntersectionPoint.sub(leftIntersectionCenter).applyAxisAngle(rotationUpVector, leftArcAngle).add(leftIntersectionCenter); } // The upper links of the main arms are attached on the outside, so we shift them over by an offset. const upperLeftVector = leftIntersectionPoint.clone().sub(rightIntersectionPoint).normalize(); const upperRightVector = rightIntersectionPoint.clone().sub(leftIntersectionPoint).normalize(); const leftIntersectionPointShifted = leftIntersectionPoint.clone().addScaledVector(upperLeftVector, upperLinkOffset); const rightIntersectionPointShifted = rightIntersectionPoint.clone().addScaledVector(upperRightVector, upperLinkOffset); const upperLeftLookAt = this._toWorldCoordinates(leftIntersectionPointShifted); const upperLeftLookAtReversed = this._toWorldCoordinates( upperLeftBackBearing.position.clone().sub(leftIntersectionPointShifted).add(upperLeftBackBearing.position), ); const upperRightLookAt = this._toWorldCoordinates(rightIntersectionPointShifted); const upperRightLookAtReversed = this._toWorldCoordinates( upperRightBackBearing.position.clone().sub(rightIntersectionPointShifted).add(upperRightBackBearing.position), ); const lowerLeftLookAt = this._toWorldCoordinates(leftIntersectionPoint); const lowerLeftLookAtReversed = this._toWorldCoordinates( lowerLeftBackBearing.position.clone().sub(leftIntersectionPoint).add(lowerLeftBackBearing.position), ); const lowerRightLookAt = this._toWorldCoordinates(rightIntersectionPoint); const lowerRightLookAtReversed = this._toWorldCoordinates( lowerRightBackBearing.position.clone().sub(rightIntersectionPoint).add(lowerRightBackBearing.position), ); // Position and aim the main link bearings in the right direction. // TODO: Some of this can go away once we merge rod end bearings into the link model. upperLeftLink.up.fromArray([0, 0, 1]); upperLeftLink.lookAt(upperLeftLookAt); upperLeftBackBearing.up.copy(upVector); upperLeftBackBearing.lookAt(upperLeftLookAtReversed); upperRightLink.up.copy(upVector); upperRightLink.lookAt(upperRightLookAt); upperRightBackBearing.up.copy(upVector); upperRightBackBearing.lookAt(upperRightLookAtReversed); lowerLeftLink.up.fromArray([0, 0, 1]); lowerLeftLink.lookAt(lowerLeftLookAt); lowerLeftBackBearing.up.copy(upVector); lowerLeftBackBearing.lookAt(lowerLeftLookAtReversed); lowerRightLink.up.fromArray([0, 0, 1]); lowerRightLink.lookAt(lowerRightLookAt); lowerRightBackBearing.up.copy(upVector); lowerRightBackBearing.lookAt(lowerRightLookAtReversed); upperLeftFrontBearing.position.copy(leftIntersectionPointShifted); upperLeftFrontBearing.up.copy(upVector); upperLeftFrontBearing.lookAt(this._toWorldCoordinates(leftIntersectionPointShifted.clone().sub(upperLeftLink.position).add(leftIntersectionPointShifted))); upperRightFrontBearing.position.copy(rightIntersectionPointShifted); upperRightFrontBearing.up.copy(upVector); upperRightFrontBearing.lookAt(this._toWorldCoordinates(rightIntersectionPointShifted.clone().sub(upperRightLink.position).add(rightIntersectionPointShifted))); lowerLeftFrontBearing.position.copy(leftIntersectionPoint); lowerLeftFrontBearing.up.copy(upVector); lowerLeftFrontBearing.lookAt(this._toWorldCoordinates(leftIntersectionPoint.clone().sub(lowerLeftLink.position).add(leftIntersectionPoint))); lowerRightFrontBearing.position.copy(rightIntersectionPoint); lowerRightFrontBearing.up.copy(upVector); lowerRightFrontBearing.lookAt(this._toWorldCoordinates(rightIntersectionPoint.clone().sub(lowerRightLink.position).add(rightIntersectionPoint))); const receiverMainBearingAxis = rightIntersectionPoint.clone() .sub(leftIntersectionPoint) .normalize(); const rotationAxis = receiverDirection.clone().cross(receiverMainBearingAxis).normalize(); const rotationAngle = Math.acos(receiverDirection.dot(receiverMainBearingAxis)); const rollQuaternion = new Quaternion().setFromAxisAngle(rotationAxis, rotationAngle); const leftPitcherEndBearingPosition = new Vector3(0, -55, 0) .applyQuaternion( rollQuaternion.clone().multiply(pitchBearingQuaternion), ) .add(leftIntersectionPoint); // TODO: Split out inverse kinematics into reusable function for both left and right. const leftPitcherServoIntersectionPoints = circleSphereIntersection( leftPitcher.position, 75, // Distance between pitcher hole and arm hole on the receiver... new Vector3(-1, 0, 0), leftPitcherEndBearingPosition, angleLinkLength, // Length of pitcher link arm to the hole... ); if (leftPitcherServoIntersectionPoints) { const position = this._intersectionPointsToPosition(leftPitcherServoIntersectionPoints); leftPitcherLink.position.copy(position); // Correct left servo angle using inverse kinematics. const correctLeftPitcherVector = leftPitcherLink.position.clone().sub(leftPitcher.position); const currentLeftPitcherVector = pitcherBearingVector.clone().applyAxisAngle(receiverDirection, -leftPitchServoAngle); const correction = correctLeftPitcherVector.angleTo(currentLeftPitcherVector); const direction = vectorDirection(correctLeftPitcherVector, currentLeftPitcherVector, new Vector3(1, 0, 0)); leftPitcher.rotation.x -= (correction * direction); leftPitcherLink.up.fromArray([0, 0, 1]); leftPitcherLink.lookAt(this._toWorldCoordinates(leftPitcherEndBearingPosition)); const rightPitcherEndBearingPosition = leftPitcherEndBearingPosition.clone() .add(rightIntersectionPoint.clone() .sub(leftIntersectionPoint)); const rightPitcherServoIntersectionPoints = circleSphereIntersection( rightPitcher.position, 75, // Distance between pitcher hole and arm hole on the receiver... new Vector3(-1, 0, 0), rightPitcherEndBearingPosition, angleLinkLength, // Length of pitcher link arm to the hole... ); if (rightPitcherServoIntersectionPoints) { const position = this._intersectionPointsToPosition(rightPitcherServoIntersectionPoints); rightPitcherLink.position.copy(position); // Correct right servo angle. const correctRightPitcherVector = rightPitcherLink.position.clone().sub(rightPitcher.position); const currentRightPitcherVector = pitcherBearingVector.clone().applyAxisAngle(receiverDirection, -rightPitchServoAngle); const correction = correctRightPitcherVector.angleTo(currentRightPitcherVector); const direction = vectorDirection(correctRightPitcherVector, currentRightPitcherVector, new Vector3(1, 0, 0)); rightPitcher.rotation.x -= (correction * direction); } rightPitcherLink.up.fromArray([0, 0, 1]); rightPitcherLink.lookAt(this._toWorldCoordinates(rightPitcherEndBearingPosition)); this._lastLeftPitcherEndBearingPosition = leftPitcherEndBearingPosition; } else { console.warn('Invalid model arrangement. No intersection found for positioning pitcher link!'); } const receiverQuaternion = rollQuaternion.multiply(pitchQuaternion); receiver.position.copy(leftIntersectionPoint.clone().lerp(rightIntersectionPoint, 0.5)); receiver.setRotationFromQuaternion(receiverQuaternion); modCase.position.copy(receiver.position); modCase.setRotationFromQuaternion(receiverQuaternion.clone().multiply(twistQuaternion)); } else { console.warn('No valid intersection found for link arms!'); } } _computeMainLinkIntersectionPoint(upperLinkPosition: Vector3, lowerLinkPosition: Vector3) { const linkArmLength = 175; // mm const intersection = circleCircleIntersection( upperLinkPosition, linkArmLength, lowerLinkPosition, linkArmLength, ); if (!intersection || intersection.radius === 0) { throw new Error('No intersection in computeMainLinkIntersectionPoint'); } const tangentVector = upperLinkPosition.clone().sub(lowerLinkPosition).cross(new Vector3(1, 0, 0)).normalize(); return { intersection: intersection.center.clone().add(tangentVector.clone().multiplyScalar(intersection.radius)), center: intersection.center, radius: intersection.radius, }; } _computeLinkPosition(armPosition: Vector3, angle: number, scale = 1) { const leftAxis = new Vector3(1, 0, 0); return new Vector3(0, -50, 0).applyAxisAngle(leftAxis, angle).add(armPosition).addScaledVector(leftAxis, scale * 14.5); } _toWorldCoordinates(vector: Vector3) { return vector.clone().applyAxisAngle(new Vector3(1, 0, 0), this._orientation); } _intersectionPointsToPosition(points: Vector3[]) { return points.length === 1 ? points[0] : points[0].y < points[1].y ? points[0] : points[1]; } /** * Compute the angles for the servos based on the algorithm from the actual SR6 Firmware. */ _computeFirmwareServoAngles(axes: Record<axisId, number>, _scale: Record<axisId, number>) { const pitchServoZero = 1580; const rightPitchServoZero = (1515.105 - pitchServoZero) + 1515.105; const servoZero = 1515.105; const servoFrequency = 330; const servoInterval = 1000000 / servoFrequency; const msPerRad = 637; const setMainServo = (x: number, y: number) => { // Function to calculate the angle for the main arm servos // Inputs are target x,y coords of receiver pivot in 1/100 of a mm x /= 100; y /= 100; // Convert to mm const gamma = Math.atan2(x, y); // Angle of line from servo pivot to receiver pivot const csq = x * x + y * y; // Square of distance between servo pivot and receiver pivot const c = Math.sqrt(csq); // Distance between servo pivot and receiver pivot let betaCos = (csq - 28125) / (100 * c); betaCos = betaCos < -1 ? -1 : betaCos > 1 ? 1 : betaCos; const beta = Math.acos(betaCos); // Angle between c-line and servo arm return msPerRad * (gamma + beta - 3.14159); // Servo signal output, from neutral }; const setPitchServo = (x: number, y: number, z: number, pitch: number) => { // Function to calculate the angle for the pitcher arm servos // Inputs are target x,y,z coords of receiver upper pivot in 1/100 of a mm // Also pitch in 1/100 of a degree pitch *= 0.0001745; // Convert to radians x += 5500 * Math.sin(0.2618 + pitch); y -= 5500 * Math.cos(0.2618 + pitch); x /= 100; y /= 100; z /= 100; // Convert to mm const bsq = 36250 - (75 + z) * (75 + z); // Equivalent arm length const gamma = Math.atan2(x, y); // Angle of line from servo pivot to receiver pivot const csq = x * x + y * y; // Square of distance between servo pivot and receiver pivot const c = Math.sqrt(csq); // Distance between servo pivot and receiver pivot let betaCos = (csq + 5625 - bsq) / (150 * c); betaCos = betaCos < -1 ? -1 : betaCos > 1 ? 1 : betaCos; const beta = Math.acos(betaCos); // Angle between c-line and servo arm return msPerRad * (gamma + beta - 3.14159); // Servo signal output, from neutral }; const roll = mapDecimal(axes.R1, 0, 0.9999, -3000, 3000); const pitch = mapDecimal(axes.R2, 0, 0.9999, -2500, 2500); const fwd = mapDecimal(axes.L1, 0, 0.9999, -3000, 3000); // 60 mm const thrust = mapDecimal(axes.L0, 0, 0.9999, -6000, 6000); // 120 mm stroke length const side = mapDecimal(axes.L2, 0, 0.9999, -3000, 3000); // 60 mm // Main arms const out1 = setMainServo(16248 - fwd, 1500 + thrust + roll); // Lower left servo const out2 = setMainServo(16248 - fwd, 1500 - thrust - roll); // Upper left servo const out5 = setMainServo(16248 - fwd, 1500 - thrust + roll); // Upper right servo const out6 = setMainServo(16248 - fwd, 1500 + thrust - roll); // Lower right servo // Pitchers const out3 = setPitchServo(16248 - fwd, 4500 - thrust, side - 1.5 * roll, -pitch); const out4 = setPitchServo(16248 - fwd, 4500 - thrust, -side + 1.5 * roll, -pitch); // Set Servos const lowerLeftServo = mapDecimal(servoZero - out1, 0, servoInterval, 0, 65535); const upperLeftServo = mapDecimal(servoZero + out2, 0, servoInterval, 0, 65535); const leftPitchServo = mapDecimal(constrain(pitchServoZero - out3, pitchServoZero - 600, pitchServoZero + 1000), 0, servoInterval, 0, 65535); const rightPitchServo = mapDecimal(constrain(rightPitchServoZero + out4, rightPitchServoZero - 1000, rightPitchServoZero + 600), 0, servoInterval, 0, 65535); const upperRightServo = mapDecimal(servoZero - out5, 0, servoInterval, 0, 65535); const lowerRightServo = mapDecimal(servoZero + out6, 0, servoInterval, 0, 65535); const scale = 0.5; // 180 degrees rotation return { lowerLeftServoAngle: servoToRotation(lowerLeftServo, scale) - Math.PI, upperLeftServoAngle: servoToRotation(upperLeftServo, scale), lowerRightServoAngle: servoToRotation(lowerRightServo, -scale) - Math.PI, upperRightServoAngle: servoToRotation(upperRightServo, -scale), leftPitchServoAngle: servoToRotation(leftPitchServo, -scale), rightPitchServoAngle: servoToRotation(rightPitchServo, scale), }; } }