From fd68e214c4d776121a743723c2fc79c8ccb37d3d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Luis=C3=A3o?= <98217717+Luisao-official@users.noreply.github.com> Date: Sun, 6 Jul 2025 11:31:29 -0300 Subject: [PATCH] feat: Enhance camera orbiting This commit introduces a new orbiting functionality by implementing a polar coordinate system (constrained to the Z-axis). This new method makes the orbit camera a lil more intuitive Key enhancements: - A new `moveCamForAxes` function for smoother, more predictable orbiting. - Improved exception handling with the addition of a `log_exception` function. - Constrained camera pitch to prevent unexpected camera rolls. --- Joystick Control.py | 336 +++++++++++++++++++++++--------------------- 1 file changed, 173 insertions(+), 163 deletions(-) diff --git a/Joystick Control.py b/Joystick Control.py index 2f3123f..cdd768f 100644 --- a/Joystick Control.py +++ b/Joystick Control.py @@ -2,14 +2,17 @@ import sys import platform import subprocess +import traceback # i want to get rid of those bare excepts from . import config from .lib import fusionAddInUtils as futil -from adsk.core import LogLevels +from adsk.core import LogLevels # type: ignore + def installPygameWindows(): virtualenvDirName = f"{config.ADDIN_NAME}Venv" - # Clean up path in case we crashed somewhere, sys should not contain our virtualenv yet + # 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() @@ -19,41 +22,69 @@ def installPygameWindows(): 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}: 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) + 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()) + + 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}: 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) + 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': +if platform.system() == "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) + 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) + # 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 @@ -71,8 +102,6 @@ def installPygameWindows(): # 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 @@ -106,7 +135,6 @@ def installPygameWindows(): 0: ViewOrientations.FrontViewOrientation, 1: ViewOrientations.BackViewOrientation, 2: HOME_ORIENTATION, - 9: CONSTRAIN_ORIENTATION, } @@ -132,7 +160,9 @@ def handle_key_event(self, key: Key): 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()}") + 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: @@ -158,8 +188,11 @@ def run(self): handle_key_event=self.handle_key_event, alive=alive, ) - except: - pass + except Exception as e: + log_exception( + f"{config.ADDIN_NAME}: Exception in pyjoystick thread: {e}") + # boom + class PyGameThread(Thread): def __init__(self, event: Event): @@ -172,7 +205,7 @@ def run(self): while alive(): for event in pygame.event.get(): if event.type == pygame.QUIT: - self.stopped = True + 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: @@ -190,8 +223,11 @@ def run(self): if event.type == pygame.JOYAXISMOTION: axes[event.axis] = event.value - except: - pass + except Exception as e: + log_exception( + f"{config.ADDIN_NAME}: Exception in pygame thread: {e}") + # bang + class RenderThread(Thread): """ @@ -213,8 +249,17 @@ def run(self): getZoomAxis(axes), ) sleep(0.01) - except: - pass + except Exception as e: + log_exception( + f"{config.ADDIN_NAME}: Exception in render thread: {e}") + # boop + + +# tool exception handling function +def log_exception(message: str): + # still not ideal, but better than nothing (I guess if we make .handle_error() do the logging, we can remove this) + futil.log(f"{config.ADDIN_NAME}: {message}", LogLevels.ErrorLogLevel) + futil.log(traceback.format_exc(), LogLevels.ErrorLogLevel) def run(context): @@ -236,8 +281,11 @@ def run(context): renderThread = RenderThread(stopFlag) renderThread.start() - except: + except Exception as e: + log_exception( + f"{config.ADDIN_NAME}: Exception in run: {e}") futil.handle_error("run") + # well this is more of a crash than an error def stop(context): @@ -246,7 +294,9 @@ def stop(context): futil.clear_handlers() stopFlag.set() - except: + except Exception as e: + log_exception( + f"{config.ADDIN_NAME}: Exception in stop: {e}") futil.handle_error("stop") @@ -293,7 +343,7 @@ def getZoomAxis(axes: list[float]) -> float: """ 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)) + return deadZone(((axes[ZOOM_POS_AXIS] + 1) / 2) - ((axes[ZOOM_NEG_AXIS] + 1) / 2)) def hatCam(hatName: str): @@ -310,7 +360,7 @@ def buttonCam(button: int): orientCam(BUTTON_TO_VIEW.get(button)) -def orientCam(nextOrientation: ViewOrientations | Literal[-1]) -> Camera : +def orientCam(nextOrientation: ViewOrientations | Literal[-1]) -> Camera: """ Orient the activeViewport's camera to the chosen orientation @@ -324,137 +374,114 @@ def orientCam(nextOrientation: ViewOrientations | Literal[-1]) -> Camera : 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) +from math import sin, cos, pi +# Its down here for testing purposes, but you can move it up if you want + +# starting global orbit state (simple polar coordinates) +# this is the reason why the default camera buttons are conflicting with the orbit +# when you press them, right now they ain't updating these states, so as you move the joystick, you go back to where these were +yaw_angle = 0.0 # radians +pitch_angle = 0.0 # radians +orbit_radius = 10.0 # will init properly on first call + + def moveCamForAxes( panXAxis: float = 0, panYAxis: float = 0, - rotateXAxis: float = 0, - rotateYAxis: float = 0, + rotateXAxis: float = 0, # joystick horizontal orbit -> yaw + rotateYAxis: float = 0, # joystick vertical orbit -> pitch zoomAxis: float = 0, ) -> None: + """ + Move the camera based on the joystick axes, using polar coordinates for orbiting, and consttraining the up vector to the local up vector (to remove camera roll) + + An effect of that approach is that the camera can't do loops around the object due to my constraining the pitch angle. + This is a feature, not a bug, (well it is to avoid a kind of bug really, but anyway) as it makes the orbit smooth and predictable. + + Args: + panXAxis (float): joystick axis for panning left/right + panYAxis (float): joystick axis for panning up/down + rotateXAxis (float): joystick axis for orbiting around the object horizontally + rotateYAxis (float): joystick axis for orbiting around the object vertically + zoomAxis (float): joystick axis for zooming in/out + + Returns: + None: this function modifies the camera directly, so it doesn't return anything + """ + + global yaw_angle, pitch_angle, orbit_radius + if ( - panXAxis == 0 - and panYAxis == 0 - and rotateXAxis == 0 - and rotateYAxis == 0 - and zoomAxis == 0 + 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 + view_vec = target.vectorTo(eye) + orbit_radius = view_vec.length + + yaw_angle += rotateXAxis * 0.05 + pitch_angle += rotateYAxis * 0.05 + + MAX_PITCH = radians(89.9) + + # if you unconstrain (comment line below) the pitch angle + # you'll se that the 360 orbit is not smooth + pitch_angle = max(-MAX_PITCH, min(MAX_PITCH, pitch_angle)) + + orbit_radius *= (1 - zoomAxis * 0.05) + + cx = cos(yaw_angle) * cos(pitch_angle) + cy = sin(yaw_angle) * cos(pitch_angle) + cz = sin(pitch_angle) + orbit_offset = Vector3D.create(cx, cy, cz) + orbit_offset.normalize() + orbit_offset.scaleBy(orbit_radius) + + new_eye = target.copy() + new_eye.translateBy(orbit_offset) + + # Panning is unchanged + world_up = Vector3D.create(0, 0, 1) + view_dir = new_eye.vectorTo(target) + side_vec = view_dir.crossProduct(world_up) + side_vec.normalize() + local_up = side_vec.crossProduct(view_dir) + local_up.normalize() + + pan_vec = Vector3D.create(0, 0, 0) + if panXAxis != 0: + pan_h = side_vec.copy() + pan_h.scaleBy(scalePanAxis(panXAxis) * orbit_radius * PAN_ZOOM_COMPENSATION) + pan_vec.add(pan_h) + if panYAxis != 0: + pan_v = local_up.copy() + pan_v.scaleBy(scalePanAxis(panYAxis) * orbit_radius * PAN_ZOOM_COMPENSATION) + pan_vec.add(pan_v) + + target.translateBy(pan_vec) + new_eye.translateBy(pan_vec) + + # Here’s the fix: use the local_up, not a forced global up. + cam.eye = new_eye cam.target = target - cam.eye = eye + cam.upVector = local_up + cam.isSmoothTransition = False 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 @@ -582,28 +609,6 @@ def constrain(vector: Vector3D) -> Vector3D: 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 @@ -615,12 +620,17 @@ def setCam(cam: Camera): 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 \ No newline at end of file + 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