diff --git a/exporter/SynthesisFusionAddin/src/Parser/SynthesisParser/JointHierarchy.py b/exporter/SynthesisFusionAddin/src/Parser/SynthesisParser/JointHierarchy.py index 9e782c8f42..f24ac30067 100644 --- a/exporter/SynthesisFusionAddin/src/Parser/SynthesisParser/JointHierarchy.py +++ b/exporter/SynthesisFusionAddin/src/Parser/SynthesisParser/JointHierarchy.py @@ -236,6 +236,14 @@ def __init__(self, design: adsk.fusion.Design) -> None: # populate all joints self.dynamicJoints: dict[str, adsk.fusion.Joint] = dict() + # Maps an occurrence's entityToken to every joint touching it, along with that joint's two + # resolved occurrences. Populated once in __getAllJoints() and consulted from _populateNode() + # Avoids using `occ.joints` accessor, which is a documented Fusion API crash + # (native access violation) on certain occurrences (e.g. large/derived components). + self.occurrenceJoints: dict[ + str, list[tuple[adsk.fusion.Joint, adsk.fusion.Occurrence, adsk.fusion.Occurrence]] + ] = dict() + self.simulationNodesRef: dict[str, SimulationNode] = dict() # TODO: need to look through every single joint and find the starting point that is connected to ground @@ -243,6 +251,7 @@ def __init__(self, design: adsk.fusion.Design) -> None: self.__getAllJoints() # dynamic joint node for grounded components and static components + logger.log(10, f"Populating node tree for grounded occurrence '{self.grounded.name}'") populate_node_result = self._populateNode(self.grounded, None, None, is_ground=True) if populate_node_result.is_err(): # We need the value to proceed message = populate_node_result.unwrap_err()[0] @@ -256,9 +265,11 @@ def __init__(self, design: adsk.fusion.Design) -> None: self.simulationNodesRef["GROUND"] = self.groundSimNode # combine all ground prior to this possibly + logger.log(10, f"Looking for grounded joints ({len(self.groundedConnections)} grounded connection(s))") _ = self._lookForGroundedJoints() # creates the axis elements - adds all elements to axisNodes + logger.log(10, f"Populating {len(self.dynamicJoints)} axis/axes") for key, value in self.dynamicJoints.items(): populate_axis_result = self._populateAxis(key, value) if populate_axis_result.is_err(): @@ -267,63 +278,109 @@ def __init__(self, design: adsk.fusion.Design) -> None: ___: Err[None] = Err(message, ErrorSeverity.Fatal) raise RuntimeError() + logger.log(10, "Linking all axis/axes") __ = self._linkAllAxis() + logger.log(10, "JointParser initialization complete") # self.groundSimNode.printLink() + def _resolveOccurrenceFromGeometry(self, entity: Any, jointName: str, label: str) -> adsk.fusion.Occurrence | None: + try: + resolved = entity.assemblyContext if entity is not None else None + except Exception as e: + resolved = None + _: Err[None] = Err(f"Exception resolving {label} for joint '{jointName}': {e}", ErrorSeverity.Warning) + + if resolved is None: + __: Err[None] = Err( + f"Could not resolve {label} for joint '{jointName}' " + "(geometry/origin reference is broken or orphaned, e.g. from a copy-paste)", + ErrorSeverity.Warning, + ) + + return resolved + def __getAllJoints(self) -> Result[None]: logger.log(10, "Getting Joints") - for joint in list(self.design.rootComponent.allJoints) + list(self.design.rootComponent.allAsBuiltJoints): + allJoints = list(self.design.rootComponent.allJoints) + list(self.design.rootComponent.allAsBuiltJoints) + logger.log(10, f"Found {len(allJoints)} total joints/as-built-joints in design") + + for index, joint in enumerate(allJoints): + jointName = joint.name if joint else "None" + logger.log(10, f"[{index + 1}/{len(allJoints)}] Resolving occurrences for joint '{jointName}'") + + occurrenceOne: adsk.fusion.Occurrence | None = None + occurrenceTwo: adsk.fusion.Occurrence | None = None + if joint and joint.occurrenceOne and joint.occurrenceTwo: occurrenceOne = joint.occurrenceOne occurrenceTwo = joint.occurrenceTwo else: # Non-fatal since it's recovered in the next two statements - _: Err[None] = Err("Found joint without two occurrences", ErrorSeverity.Warning) + _ = Err(f"Joint '{jointName}' found without two occurrences", ErrorSeverity.Warning) if occurrenceOne is None: - if joint.geometryOrOriginOne.entityOne.assemblyContext is None: - ____: Err[None] = Err( - "occurrenceOne and entityOne's assembly context are None", ErrorSeverity.Fatal + try: + geometryOrOriginOne = joint.geometryOrOriginOne + entityOne = geometryOrOriginOne.entityOne if geometryOrOriginOne is not None else None + except Exception as e: + entityOne = None + _ = Err( + f"Exception accessing geometryOrOriginOne for joint '{jointName}': {e}", ErrorSeverity.Warning ) - occurrenceOne = joint.geometryOrOriginOne.entityOne.assemblyContext + occurrenceOne = self._resolveOccurrenceFromGeometry(entityOne, jointName, "occurrenceOne") if occurrenceTwo is None: - if joint.geometryOrOriginTwo.entityTwo.assemblyContext is None: - __: Err[None] = Err("occurrenceOne and entityTwo's assembly context are None", ErrorSeverity.Fatal) - occurrenceTwo = joint.geometryOrOriginTwo.entityTwo.assemblyContext + try: + geometryOrOriginTwo = joint.geometryOrOriginTwo + entityTwo = geometryOrOriginTwo.entityTwo if geometryOrOriginTwo is not None else None + except Exception as e: + entityTwo = None + _ = Err( + f"Exception accessing geometryOrOriginTwo for joint '{jointName}': {e}", ErrorSeverity.Warning + ) + occurrenceTwo = self._resolveOccurrenceFromGeometry(entityTwo, jointName, "occurrenceTwo") oneEntityToken = "" twoEntityToken = "" # TODO: Fix change to if statement with Result returning - try: - oneEntityToken = occurrenceOne.entityToken - except: - oneEntityToken = occurrenceOne.name - - try: - twoEntityToken = occurrenceTwo.entityToken - except: - twoEntityToken = occurrenceTwo.name + if occurrenceOne is not None: + try: + oneEntityToken = occurrenceOne.entityToken + except Exception: + oneEntityToken = occurrenceOne.name + + if occurrenceTwo is not None: + try: + twoEntityToken = occurrenceTwo.entityToken + except Exception: + twoEntityToken = occurrenceTwo.name + + if occurrenceOne is None or occurrenceTwo is None: + # Already logged above (either missing both occurrences, or an unresolvable geometry/origin + # reference) - skip this joint rather than aborting the whole export + continue + + self.occurrenceJoints.setdefault(oneEntityToken, []).append((joint, occurrenceOne, occurrenceTwo)) + if twoEntityToken != oneEntityToken: + self.occurrenceJoints.setdefault(twoEntityToken, []).append((joint, occurrenceOne, occurrenceTwo)) typeJoint = joint.jointMotion.jointType if typeJoint != 0: if oneEntityToken not in self.dynamicJoints.keys(): self.dynamicJoints[oneEntityToken] = joint - - # TODO: Check if this is fatal or not - if occurrenceTwo is None and occurrenceOne is None: - ___: Err[None] = Err( - f"Occurrences that connect joints could not be found\n\t1: {occurrenceOne}\n\t2: {occurrenceTwo}", - ErrorSeverity.Fatal, - ) else: if oneEntityToken == self.grounded.entityToken: self.groundedConnections.append(occurrenceTwo) elif twoEntityToken == self.grounded.entityToken: self.groundedConnections.append(occurrenceOne) + logger.log( + 10, + f"Finished Getting Joints: {len(self.dynamicJoints)} dynamic joint(s), " + f"{len(self.groundedConnections)} grounded connection(s)", + ) return Ok(None) def _linkAllAxis(self) -> Result[None]: @@ -415,37 +472,32 @@ def _populateNode( if populate_result.is_fatal(): return populate_result - # if not is_ground: # THIS IS A BUG - OCCURRENCE ACCESS VIOLATION - # this is the current reason for wrapping in try except pass - for joint in occ.joints: - if joint and joint.occurrenceOne and joint.occurrenceTwo: - occurrenceOne = joint.occurrenceOne - occurrenceTwo = joint.occurrenceTwo - connection = None - rigid = joint.jointMotion.jointType == 0 - - if rigid: - if joint.occurrenceOne == occ: - connection = joint.occurrenceTwo - if joint.occurrenceTwo == occ: - connection = joint.occurrenceOne - else: - if joint.occurrenceOne != occ: - connection = joint.occurrenceOne - - if connection is not None: - if prev is None or connection.entityToken != prev.data.entityToken: - populate_result = self._populateNode( - connection, - node, - (OccurrenceRelationship.CONNECTION if rigid else OccurrenceRelationship.NEXT), - is_ground=is_ground, - ) - if populate_result.is_fatal(): - return populate_result + # Deliberately not using the `occ.joints` accessor here as it is a documented Fusion API + # crash. Instead look up joints touching occurrences from the map built once in __getAllJoints() + occJoints = self.occurrenceJoints.get(occ.entityToken, []) + for joint, occurrenceOne, occurrenceTwo in occJoints: + connection = None + rigid = joint.jointMotion.jointType == 0 + + if rigid: + if occurrenceOne.entityToken == occ.entityToken: + connection = occurrenceTwo + if occurrenceTwo.entityToken == occ.entityToken: + connection = occurrenceOne else: - # Check if this joint occurance violation is really a fatal error or just something we should filter on - return Err("Joint without two occurrences", ErrorSeverity.Fatal) + if occurrenceOne.entityToken != occ.entityToken: + connection = occurrenceOne + + if connection is not None: + if prev is None or connection.entityToken != prev.data.entityToken: + populate_result = self._populateNode( + connection, + node, + (OccurrenceRelationship.CONNECTION if rigid else OccurrenceRelationship.NEXT), + is_ground=is_ground, + ) + if populate_result.is_fatal(): + return populate_result if prev is not None: edge = DynamicEdge(relationship, node) diff --git a/exporter/SynthesisFusionAddin/src/Parser/SynthesisParser/Joints.py b/exporter/SynthesisFusionAddin/src/Parser/SynthesisParser/Joints.py index e7b7a1e239..76dbc633c1 100644 --- a/exporter/SynthesisFusionAddin/src/Parser/SynthesisParser/Joints.py +++ b/exporter/SynthesisFusionAddin/src/Parser/SynthesisParser/Joints.py @@ -268,9 +268,18 @@ def _addJointInstance( def _addRigidGroup(joint: adsk.fusion.Joint, assembly: assembly_pb2.Assembly) -> None: - if joint.jointMotion.jointType != 0 or not ( - joint.occurrenceOne.isLightBulbOn and joint.occurrenceTwo.isLightBulbOn - ): + if joint.jointMotion.jointType != 0: + return + + if joint.occurrenceOne is None or joint.occurrenceTwo is None: + # A rigid joint can end up with a broken/orphaned occurrence reference (e.g. after copy-pasting + # a component that carried a joint along with it) - skip it rather than crashing the export. + _: Err[None] = Err( + f"Rigid joint '{joint.name}' has a missing occurrence, skipping rigid group", ErrorSeverity.Warning + ) + return + + if not (joint.occurrenceOne.isLightBulbOn and joint.occurrenceTwo.isLightBulbOn): return mira_group = joint_pb2.RigidGroup() diff --git a/exporter/SynthesisFusionAddin/src/Parser/SynthesisParser/Parser.py b/exporter/SynthesisFusionAddin/src/Parser/SynthesisParser/Parser.py index 3776bfa100..7dd115889e 100644 --- a/exporter/SynthesisFusionAddin/src/Parser/SynthesisParser/Parser.py +++ b/exporter/SynthesisFusionAddin/src/Parser/SynthesisParser/Parser.py @@ -29,6 +29,7 @@ def reload() -> None: import importlib importlib.reload(Components) + importlib.reload(JointHierarchy) importlib.reload(Joints) importlib.reload(Materials) importlib.reload(PDMessage) diff --git a/exporter/SynthesisFusionAddin/src/UI/ConfigCommand.py b/exporter/SynthesisFusionAddin/src/UI/ConfigCommand.py index d8fc53f9d4..97ed9ca407 100644 --- a/exporter/SynthesisFusionAddin/src/UI/ConfigCommand.py +++ b/exporter/SynthesisFusionAddin/src/UI/ConfigCommand.py @@ -6,6 +6,7 @@ import json import os import re +import traceback import webbrowser from typing import Any @@ -363,6 +364,7 @@ def export(exporterOptions: moduleExporterOptions.ExporterOptions, html_args: ad try: Parser.Parser(exporterOptions).export() except RuntimeError as e: + logger.error(f"Export failed with RuntimeError:\n{traceback.format_exc()}") html_args.returnData = json.dumps({"_err": str(e)}) return exporterOptions.writeToDesign() @@ -434,7 +436,8 @@ def notify(self, _: adsk.core.CommandEventArgs) -> None: try: Parser.Parser(exporterOptions).export() - except: + except Exception: + logger.error(f"Export failed:\n{traceback.format_exc()}") jointConfigTab.reset() gamepieceConfigTab.reset()