-
-
Notifications
You must be signed in to change notification settings - Fork 0
/
Joystick Control.py
626 lines (503 loc) · 18.1 KB
/
Joystick Control.py
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
import os
import sys
import platform
import subprocess
from . import config
from .lib import fusionAddInUtils as futil
from adsk.core import LogLevels
def installPygameWindows():
virtualenvDirName = f"{config.ADDIN_NAME}Venv"
# Clean up path in case we crashed somewhere, sys should not contain our virtualenv yet
sys.path = [dir for dir in sys.path if dir.find(virtualenvDirName) == -1]
original_sys_path = sys.path.copy()
virtualenv = os.path.join(sys.path[0], virtualenvDirName)
python = os.path.join(sys.path[0], "Python", "python.exe")
virtualenvSitePackages = os.path.join(virtualenv, "Lib", "site-packages")
if not os.path.isdir(virtualenv):
futil.log(f"{config.ADDIN_NAME}: missing virtualenv, creating...", LogLevels.WarningLogLevel)
subprocess.check_call([python, '-m', 'venv', virtualenv])
futil.log(f"{config.ADDIN_NAME}: virtualenv exists, attempting to import from virtualenv", LogLevels.InfoLogLevel)
# in case of script failure, the virtualenv might already be in the path from a previous run
if not virtualenv in sys.path:
sys.path.insert(0, virtualenvSitePackages)
try:
import pygame
return(True, original_sys_path.copy())
except:
try:
futil.log(f"{config.ADDIN_NAME}: missing pygame, installing...", LogLevels.WarningLogLevel)
subprocess.check_call([os.path.join(virtualenv, "Scripts", "pip.exe"), "install", "--upgrade", "pygame"])
futil.log(f"{config.ADDIN_NAME}: pygame installed", LogLevels.InfoLogLevel)
return (True, original_sys_path.copy())
except:
futil.handle_error("Failed to install and import pygame. See text console for more details", True)
return (False, original_sys_path.copy())
installedPygame = False
if platform.system() is 'Windows':
(installedPygame, original_sys_path) = installPygameWindows()
if installedPygame:
try:
import pygame
futil.log(f"{config.ADDIN_NAME}: pygame installed", LogLevels.InfoLogLevel)
except:
futil.handle_error(f"{config.ADDIN_NAME}: Failed to import pygame, falling back to use pyjoystick (less gamepad support). See text console for more details", True)
installedPygame = False
sys.path = original_sys_path
else:
#TODO: figure out where the python executable is on mac
futil.handle_error("Sorry, this OS is unsupported, falling back to use pyjoystick (less gamepad support)", True)
# pyjoystic must be imported after pygame as it causes dynamic linking issues with SDL2
if not installedPygame:
from .Modules.pyjoystick.sdl2 import run_event_loop
from .Modules.pyjoystick.interface import KeyTypes, Key, Joystick
from adsk.core import Vector3D, Matrix3D, Application, Point3D, Camera, ViewOrientations
from time import sleep
from math import pow, pi, radians
from adsk import doEvents
from threading import Event, Thread
from typing import Literal
# Special number to tell camera to go orientation home
HOME_ORIENTATION = -1
# Special number to tell the camera to constrain the upVector to a primary axis
CONSTRAIN_ORIENTATION = -2
# Configure as you wish
ZOOM_SCALE = 0.1
PAN_AXIS_SCALE = 0.1
ROTATION_AXIS_SCALE = 0.01
PAN_ZOOM_COMPENSATION = 0.0005
ZOOM_EXTENT_MULTIPLIER = 0.1
AXIS_DEADZONE = 0.15
if not installedPygame:
PAN_X_AXIS = 0
PAN_Y_AXIS = 1
ZOOM_POS_AXIS = 2
ROTATE_X_AXIS = 3
ROTATE_Y_AXIS = 4
ZOOM_NEG_AXIS = 5
else:
PAN_X_AXIS = 0
PAN_Y_AXIS = 1
ROTATE_X_AXIS = 2
ROTATE_Y_AXIS = 3
ZOOM_POS_AXIS = 4
ZOOM_NEG_AXIS = 5
HAT_TO_VIEW = {
Key.HAT_NAME_UP: ViewOrientations.TopViewOrientation,
Key.HAT_NAME_DOWN: ViewOrientations.BottomViewOrientation,
Key.HAT_NAME_LEFT: ViewOrientations.LeftViewOrientation,
Key.HAT_NAME_RIGHT: ViewOrientations.RightViewOrientation,
}
BUTTON_TO_VIEW = {
0: ViewOrientations.FrontViewOrientation,
1: ViewOrientations.BackViewOrientation,
2: HOME_ORIENTATION,
9: CONSTRAIN_ORIENTATION,
}
class PyJoystickThread(Thread):
"""
pyjoystick's ThreadEventManager doesn't seem to work, so we're just running
it in our own thread here.
"""
def __init__(self, event: Event):
Thread.__init__(self)
self.stopped = event
def handle_key_event(self, key: Key):
"""
Assigns axis values into the global axes variable so the RenderThread
can pick them up
Args:
key (Key): joystick keys
"""
if key.keytype is KeyTypes.AXIS:
if key.number < 6:
axes[key.number] = key.get_proper_value()
else:
futil.log(f"{config.ADDIN_NAME}: unknown axis: {key.number}: {key.get_proper_value()}")
elif key.keytype is KeyTypes.HAT:
hatCam(key.get_hat_name())
elif key.keytype is KeyTypes.BUTTON and key.value == 0:
buttonCam(key.number)
def add(self, joy: Joystick):
"""
useless, but pyjoystick doesn't work without it
"""
return
def remove(self, joy: Joystick):
"""
useless, but pyjoystick doesn't work without it
"""
return
def run(self):
try:
run_event_loop(
add_joystick=self.add,
remove_joystick=self.remove,
handle_key_event=self.handle_key_event,
alive=alive,
)
except:
pass
class PyGameThread(Thread):
def __init__(self, event: Event):
Thread.__init__(self)
self.stopped = event
def run(self):
try:
self.pygameJoysticks = {}
while alive():
for event in pygame.event.get():
if event.type == pygame.QUIT:
self.stopped = True
# Handle hotplugging, also initializes the joysticks when plugged in (otherwise we don't get the other events)
if event.type == pygame.JOYDEVICEADDED:
joy = pygame.joystick.Joystick(event.device_index)
self.pygameJoysticks[joy.get_instance_id()] = joy
if event.type == pygame.JOYDEVICEREMOVED:
del self.pygameJoysticks[event.instance_id]
if event.type == pygame.JOYBUTTONDOWN:
buttonCam(event.button)
if event.type == pygame.JOYHATMOTION:
hatCam(pygameToHatName(event.value))
if event.type == pygame.JOYAXISMOTION:
axes[event.axis] = event.value
except:
pass
class RenderThread(Thread):
"""
Handles translating the current axes into movements of the camera
"""
def __init__(self, event: Event):
super().__init__()
self.stopped = event
def run(self):
while alive():
try:
moveCamForAxes(
getPanXAxis(axes),
getPanYAxis(axes),
getRotateXAxis(axes),
getRotateYAxis(axes),
getZoomAxis(axes),
)
sleep(0.01)
except:
pass
def run(context):
try:
global stopFlag
global axes
global app
app = Application.get()
axes = [0.0, 0.0, 0.0, 0.0, 0.0, 0.0]
stopFlag = Event()
if installedPygame:
pygame.init()
pygameJoystickThread = PyGameThread(stopFlag)
pygameJoystickThread.start()
else:
joystickThread = PyJoystickThread(stopFlag)
joystickThread.start()
renderThread = RenderThread(stopFlag)
renderThread.start()
except:
futil.handle_error("run")
def stop(context):
try:
# Remove all of the event handlers your app has created
futil.clear_handlers()
stopFlag.set()
except:
futil.handle_error("stop")
def deadZone(axis: float) -> float:
"""
Check if an axis is inside the AXIS_DEADZONE and return 0 if so.
"""
if (abs(axis)) < AXIS_DEADZONE:
return 0
return axis
def getPanXAxis(axes: list[float]) -> float:
"""
Get the axis for X panning. Configure this by updating PAN_X_AXIS
"""
return deadZone(axes[PAN_X_AXIS])
def getPanYAxis(axes: list[float]) -> float:
"""
Get the axis for Y panning. Configure this by updating PAN_Y_AXIS
"""
return deadZone(axes[PAN_Y_AXIS]) * -1
def getRotateXAxis(axes: list[float]) -> float:
"""
Get the axis for X rotation. Configure this by updating ROTATE_X_AXIS
"""
return deadZone(axes[ROTATE_X_AXIS])
def getRotateYAxis(axes: list[float]) -> float:
"""
Get the axis for Y rotation. Configure this by updating ROTATE_Y_AXIS
"""
return deadZone(axes[ROTATE_Y_AXIS]) * -1
def getZoomAxis(axes: list[float]) -> float:
"""
Get the axis for zoom. Configure this by updating ZOOM_POS_AXIS and ZOOM_NEG_AXIS
"""
if not installedPygame:
return deadZone(axes[ZOOM_POS_AXIS] - axes[ZOOM_NEG_AXIS])
return deadZone(((axes[ZOOM_POS_AXIS] + 1)/2) - ((axes[ZOOM_NEG_AXIS] + 1)/2))
def hatCam(hatName: str):
"""
Orient the camera for a given hat direction press
"""
orientCam(HAT_TO_VIEW.get(hatName))
def buttonCam(button: int):
"""
Orient the camera for a given button press
"""
orientCam(BUTTON_TO_VIEW.get(button))
def orientCam(nextOrientation: ViewOrientations | Literal[-1]) -> Camera :
"""
Orient the activeViewport's camera to the chosen orientation
Args:
nextOrientation (int): Should be one of the ViewOrientations, or HOME_ORIENTATION to send the viewport to the configured home
"""
if nextOrientation is None:
return
cam = app.activeViewport.camera
cam.isSmoothTransition = False
if nextOrientation == HOME_ORIENTATION:
app.activeViewport.goHome()
return
elif nextOrientation == CONSTRAIN_ORIENTATION:
upVector = getFrontVector().crossProduct(getLeftVector())
cam.upVector = getConstrainedVector(upVector)
cam.isSmoothTransition = True
else:
cam.viewOrientation = nextOrientation
setCam(cam)
def moveCamForAxes(
panXAxis: float = 0,
panYAxis: float = 0,
rotateXAxis: float = 0,
rotateYAxis: float = 0,
zoomAxis: float = 0,
) -> None:
if (
panXAxis == 0
and panYAxis == 0
and rotateXAxis == 0
and rotateYAxis == 0
and zoomAxis == 0
):
return
cam = app.activeViewport.camera
horizontalRotationMatrix = Matrix3D.create()
verticalRotationMatrix = Matrix3D.create()
target = cam.target.copy()
eye = cam.eye.copy()
frontVector = getFrontVector()
leftVector = getLeftVector()
# Update the upVector early during a horizontal rotation before continuing
# so that other calculations are correct
horizontalRotationMatrix.setToRotation(
axisToRadian(rotateYAxis), leftVector, target
)
upVector = newUpFromRotatingHorizontal(cam, horizontalRotationMatrix)
constrainedUpVector = getConstrainedVector(upVector)
# failed attempts to get the correct upVector
# upVector = newUpFromInvertedHorizontal(cam, horizontalRotationMatrix)
# upVector = newUpFromCrossProduct(frontVector, leftVector)
# upVector = newUpFromRotatedFrontVector(eye, target, leftVector)
zoomVector = getZoomVector(zoomAxis, frontVector)
verticalPanVector = getVerticalPanVector(scalePanAxis(panYAxis), upVector)
horizontalPanVector = getHorizontalPanVector(scalePanAxis(panXAxis), leftVector)
verticalRotationMatrix.setToRotation(axisToRadian(rotateXAxis), constrainedUpVector, target)
panVector = horizontalPanVector.copy()
panVector.add(verticalPanVector)
panVector.scaleBy(frontVector.length * PAN_ZOOM_COMPENSATION)
# Translate target and eye to "pan"
target.translateBy(panVector)
eye.translateBy(panVector)
if zoomVector.length > 0:
eye.translateBy(zoomVector)
extentVector = target.asVector()
extentVector.subtract(eye.asVector())
cam.setExtents(
extentVector.length * ZOOM_EXTENT_MULTIPLIER,
extentVector.length * ZOOM_EXTENT_MULTIPLIER,
)
# Rotate only the eye
eye.transformBy(horizontalRotationMatrix)
eye.transformBy(verticalRotationMatrix)
# Apply changes
cam.upVector = upVector
cam.isSmoothTransition = False
cam.target = target
cam.eye = eye
setCam(cam)
def newUpFromInvertedHorizontal(
cam: Camera, horizontalRotationMatrix: Matrix3D
) -> Vector3D:
"""
Doesn't work...
Idea was to invert the horizontal rotation we used to move the eye and apply
that to the previous upVector so it rotates in line
"""
invertedRotation = horizontalRotationMatrix.copy()
invertedRotation.invert()
newUp = cam.upVector.copy()
newUp.transformBy(invertedRotation)
return newUp
def newUpFromRotatingHorizontal(
cam: Camera, horizontalRotationMatrix: Matrix3D
) -> Vector3D:
"""
Apply the horizontal rotation matrix to the previous upVector
"""
newUp = cam.upVector.copy()
newUp.transformBy(horizontalRotationMatrix)
return newUp
def newUpFromCrossProduct(frontVector: Vector3D, leftVector: Vector3D) -> Vector3D:
"""
Doesn't work...
Idea was to get the cross product from the frontVector and leftVector (which should be the correct up vector?)
"""
return frontVector.crossProduct(leftVector)
def newUpFromRotatedFrontVector(eye: Point3D, target: Point3D, leftVector: Vector3D):
"""
Doesn't work...
Idea was to take the current frontVector and rotate it 90 degress along the leftVector to create a proper upVector
"""
newUp = eye.vectorTo(target)
perpendicularMatrix = Matrix3D.create()
perpendicularMatrix.setToRotation(radians(90), leftVector, eye)
newUp.transformBy(perpendicularMatrix)
def alive():
"""
Determine if the add-in is still alive
Returns:
bool: if the add-in is alive
"""
if stopFlag.isSet():
return False
return True
def scalePanAxis(axis: float) -> float:
"""
Scale a pan axis such that it accelerates making 0->1 a nice curve
Args:
axis (float): the pan axis as a float
Returns:
float: scaled axis to the curve
"""
return pow(axis / 2 * 10, 3) * PAN_AXIS_SCALE
def axisToRadian(axis: float) -> float:
"""
Get radians for a given axis
Args:
axis (float): the rotation axis as a float
Returns:
float: radians to rotate the camera
"""
return pi * 2 * axis * ROTATION_AXIS_SCALE
def getZoomVector(zoomAxis: float, frontVector: Vector3D) -> Vector3D:
"""
Get a vector for a zoom movement
Args:
zoomAxis (float): the zoom axis as a float
frontVector (Vector3d): the vector between the eye and target from the camera
Returns:
Vector3D: A vector representing how far to move the eye towards the target
"""
zoomVector = frontVector.copy()
zoomVector.scaleBy(zoomAxis * ZOOM_SCALE)
return zoomVector
def getVerticalPanVector(scale: float, upVector: Vector3D) -> Vector3D:
"""
Get a vertical vector for a pan movement
Args:
scaledAxis (float): the vertical pan axis as a float
upvector (Vector3d): the vector pointing up (expects a constrained vector)
Returns:
Vector3D: A vector representing how far to move the camera (eye and target) to pan up/down
"""
vecV = constrain(upVector.copy())
vecV.scaleBy(scale)
return vecV
def getHorizontalPanVector(
scaledAxis: float,
leftVector: Vector3D,
) -> Vector3D:
"""
Get a horizontal vector for a pan movement
Args:
scaledAxis (float): the horizontal pan axis as a float
leftVector (Vector3d): the vector pointing left (expects a constrained vector)
Returns:
Vector3D: A vector representing how far to move the camera (eye and target) to pan left/right
"""
vecH = constrain(leftVector.copy())
vecH.scaleBy(scaledAxis)
return vecH
def getFrontVector() -> Vector3D:
cam = app.activeViewport.camera
return cam.target.vectorTo(cam.eye)
def getLeftVector() -> Vector3D:
"""
Get a vector that points left from the current camera view
Args:
upVector (Vector3D): vector pointing up from camera view
target (Point3d): target for the camera view
eye (Point3d): eye for the camera view
Returns:
Vector3D: A left pointing vector
"""
cam = app.activeViewport.camera
return cam.upVector.crossProduct(cam.target.vectorTo(cam.eye))
def constrain(vector: Vector3D) -> Vector3D:
"""
Scale a vector such that the max absolute value of any component will be 1
Args:
vector (Vector3D): vector to scale
Returns:
Vector3D: A scaled vector
"""
maxPos = max(vector.asArray())
maxNeg = abs(min(vector.asArray()))
maxAbs = max(maxNeg, maxPos)
vector.scaleBy(1 / maxAbs)
return vector
def getConstrainedVector(vector) -> Vector3D:
"""
Get a pure primary direction that the vector is closest to
"""
absX = abs(vector.x)
absY = abs(vector.y)
absZ = abs(vector.z)
biggest = max(absX, absY, absZ)
if (biggest == absX):
if vector.x > 0:
return Vector3D.create(1, 0, 0)
return Vector3D.create(-1, 0, 0)
elif (biggest == absY):
if vector.y > 0:
return Vector3D.create(0, 1, 0)
return Vector3D.create(0, -1, 0)
else:
if vector.z > 0:
return Vector3D.create(0, 0, 1)
return Vector3D.create(0, 0, -1)
def setCam(cam: Camera):
"""
Set the activeViewport to the given cam, makes sure to let F360 do it's work to update the view
Args:
cam (Camer): camera to set the viewport to
"""
app.activeViewport.camera = cam
doEvents()
app.activeViewport.refresh()
def pygameToHatName(value):
"""
Convert pygame hat tuple to hat name
"""
match value:
case (-1, 0): return Key.HAT_NAME_LEFT
case (0, 1): return Key.HAT_NAME_UP
case (1, 0): return Key.HAT_NAME_RIGHT
case (0, -1): return Key.HAT_NAME_DOWN