diff --git a/docs/source/connections_viewer.rst b/docs/source/connections_viewer.rst index 38085dd4..d749f769 100644 --- a/docs/source/connections_viewer.rst +++ b/docs/source/connections_viewer.rst @@ -1,9 +1,30 @@ Connections Viewer ------------------ -From a given :ref:`glossary:prim`, recursively traverse its :ref:`Connections ` through :usdcpp:`UsdShadeConnectableAPI`. +From a given :ref:`glossary:prim`, recursively traverse its upstream :ref:`Connections `. -Every node represents a :ref:`glossary:prim` with its name at the top, and each of its :usdcpp:`Inputs ` (dark blue if :usdcpp:`connected `, otherwise light blue) and :usdcpp:`Outputs ` (red) below. +When the prim has :usdcpp:`UsdShadeConnectableAPI` inputs or outputs, connections are followed through the shading API. Otherwise, connections are followed through :usdcpp:`UsdAttribute` connection paths. This includes **OpenExec invertible rigs** (``IrJointScope``, ``IrFkController``, ``IrSwitchController``, and related prims) where animation and controller wiring is authored via attribute ``.connect`` relationships. + +Every node represents a :ref:`glossary:prim` with its name at the top (and schema type when available). Ports below represent connected attributes or shade inputs/outputs. + +Port colors +~~~~~~~~~~~ + +**UsdShade prims** + +- :usdcpp:`Inputs ` — dark blue if :usdcpp:`connected `, otherwise light blue +- :usdcpp:`Outputs ` — red (light coral) + +**OpenExec / attribute-connected prims** + +When schema metadata is available, port colors reflect ``irRole``: + +- **InputAttribute** — dark blue if connected, otherwise light blue (controller inputs, wired avars) +- **OutputAttribute** — red (light coral); computed outputs such as ``out:space`` and ``posed:space`` +- **SwitchAttribute** — gold (switch tokens, rotation order) +- **PassthroughAttribute** — light gray (passthrough spaces such as ``in:defaultSpace``) + +The viewer shows the **authored connection graph** on the USD stage. It does not display the compiled OpenExec evaluation network (that requires C++ ``ExecUsdSystem`` diagnostics today). .. tab:: USDView diff --git a/docs/source/end_to_end.rst b/docs/source/end_to_end.rst index fd98d890..02163ec1 100644 --- a/docs/source/end_to_end.rst +++ b/docs/source/end_to_end.rst @@ -7,7 +7,7 @@ Creating Assets Repository Path ~~~~~~~~~~~~~~~ -Creating assets requires a repository path to be set. This change lasts during the duration of the current application (or python process), so it is needed only once. +Creating assets requires a repository path to be set. Setting it lasts for the duration of the current application (or python process), so it is needed only once. **API:** :py:data:`grill.cook.Repository` diff --git a/grill/cook/__init__.py b/grill/cook/__init__.py index 5292b243..bb09733e 100644 --- a/grill/cook/__init__.py +++ b/grill/cook/__init__.py @@ -363,7 +363,7 @@ def spawn_unit(parent: Usd.Prim, child: Usd.Prim, path: Sdf.Path = Sdf.Path.empt 1. Turning parent into an :ref:`glossary:assembly`. 2. Ensuring intermediate prims between parent and child are also :ref:`glossary:model`. - 3. Setting explicit :ref:`glossary:instanceable`. on spawned children that are components. + 3. Setting explicit :ref:`glossary:instanceable` metadata on spawned children that are components. .. seealso:: :func:`spawn_many` and :func:`create_unit` """ @@ -375,11 +375,11 @@ def spawn_many(parent: Usd.Prim, child: Usd.Prim, paths: list[Sdf.Path], labels: * Both parent and child must be existing units in the catalogue. * ``paths`` can be relative or absolute. If absolute, they must include ``parent``'s `path `_ as a prefix. - * A valid `Model Hierarchy `_ is preserved by: + * A valid :ref:`glossary:model hierarchy` is preserved by: - 1. Turning parent into an `assembly `_ if ``child`` is a Model. - 2. Ensuring intermediate prims between ``parent`` and spawned children are also `models `_. - 3. Setting explicit `instanceable `_. on spawned children that are components. + 1. Turning parent into an :ref:`glossary:assembly` if ``child`` is a :ref:`glossary:model`. + 2. Ensuring intermediate prims between ``parent`` and spawned children are also :ref:`glossary:model`. + 3. Setting explicit :ref:`glossary:instanceable` metadata on spawned children that are components. Spawned prims and ancestors are `defined `_. diff --git a/grill/views/description.py b/grill/views/description.py index 2c407604..3dd6faf8 100644 --- a/grill/views/description.py +++ b/grill/views/description.py @@ -222,18 +222,102 @@ def _compute_composition(_prim): ) -def _graph_from_connections(prim: Usd.Prim) -> nx.MultiDiGraph: - connections_api = UsdShade.ConnectableAPI(prim) +_CONNECTION_GRAPH_OUTLINE = "#4682B4" # steelblue +_CONNECTION_GRAPH_BACKGROUND = "#F0FFFF" # azure +_CONNECTION_GRAPH_OUTPUT = "#F08080" # lightcoral +_CONNECTION_GRAPH_SWITCH = "#DAA520" # goldenrod +_CONNECTION_GRAPH_PASSTHROUGH = "#D3D3D3" # lightgray +_CONNECTION_TABLE_ROW = ( + '{text}' +) + + +def _graphviz_port_id(port_name: str) -> str: + """Graphviz port identifiers cannot contain ':' (e.g. posed:space).""" + return port_name.replace(":", "__") + + +def _init_connection_graph() -> nx.MultiDiGraph: graph = nx.MultiDiGraph() - outline_color = "#4682B4" # 'steelblue' - background_color = "#F0FFFF" # 'azure' graph.graph['graph'] = {'rankdir': 'LR'} - - graph.graph['node'] = {'shape': 'none', 'color': outline_color, 'fillcolor': background_color} # color and fillcolor used for HTML view + graph.graph['node'] = { + 'shape': 'none', + 'color': _CONNECTION_GRAPH_OUTLINE, + 'fillcolor': _CONNECTION_GRAPH_BACKGROUND, + } graph.graph['edge'] = {"color": 'crimson'} + return graph - all_nodes = dict() # {node_id: {graphviz_attr: value}} - edges = list() # [(source_node_id, target_node_id, {source_port_name, target_port_name, graphviz_attrs})] + +def _make_port_table_node( + prim_name: str, + port_entries: list[tuple[str, str]], + *, + prim_type: str = "", +) -> tuple[str, list]: + """Build a Graphviz HTML table label and ports list for connection graph nodes.""" + outline_color = _CONNECTION_GRAPH_OUTLINE + background_color = _CONNECTION_GRAPH_BACKGROUND + header = prim_name if not prim_type else f"{prim_name} ({prim_type})" + label = ( + f'<' + ) + label += _CONNECTION_TABLE_ROW.format( + port="", + color="white", + text=f'{header}', + ) + ports = [""] + for port_name, color in port_entries: + port_id = _graphviz_port_id(port_name) + label += _CONNECTION_TABLE_ROW.format( + port=port_id, + color=color, + text=f'{port_name}', + ) + ports.append(port_id) + label += '
>' + return label, ports + + +def _port_color_for_attribute( + prim: Usd.Prim, + attr_name: str, + *, + has_connections: bool, + is_edge_tail: bool, + is_edge_head: bool, +) -> str: + prop = prim.GetPrimDefinition().GetPropertyDefinition(attr_name) + role = prop.GetMetadata("irRole") if prop else None + if role == "OutputAttribute": + return _CONNECTION_GRAPH_OUTPUT + if role == "SwitchAttribute": + return _CONNECTION_GRAPH_SWITCH + if role == "PassthroughAttribute": + return _CONNECTION_GRAPH_PASSTHROUGH + if role == "InputAttribute": + return ( + _CONNECTION_GRAPH_OUTLINE + if has_connections or is_edge_head + else _CONNECTION_GRAPH_BACKGROUND + ) + if is_edge_tail and not is_edge_head: + return _CONNECTION_GRAPH_OUTPUT + if has_connections or is_edge_head: + return _CONNECTION_GRAPH_OUTLINE + return _CONNECTION_GRAPH_BACKGROUND + + +def _graph_from_shade_connections(prim: Usd.Prim) -> nx.MultiDiGraph: + connections_api = UsdShade.ConnectableAPI(prim) + graph = _init_connection_graph() + outline_color = _CONNECTION_GRAPH_OUTLINE + background_color = _CONNECTION_GRAPH_BACKGROUND + + all_nodes = dict() + edges = list() @cache def _get_node_id(api): @@ -245,31 +329,32 @@ def _add_edges(src_node, src_name, tgt_node, tgt_name): edges.append((src_node, tgt_node, {"tailport": src_name, "headport": tgt_name, "tooltip": tooltip})) port_colors = { - UsdShade.Input: outline_color, # blue - UsdShade.Output: "#F08080" # "lightcoral", # pink + UsdShade.Input: outline_color, + UsdShade.Output: _CONNECTION_GRAPH_OUTPUT, } - table_row = '{text}' traversed_prims = set() + def traverse(api: UsdShade.ConnectableAPI): current_prim = api.GetPrim() if current_prim in traversed_prims: return traversed_prims.add(current_prim) node_id = _get_node_id(current_prim) - label = f'<' - label += table_row.format(port="", color="white", text=f'{api.GetPrim().GetName()}') - ports = [""] # port names for this node. Empty string is used to refer to the node itself (no port). + port_entries = list() for port in chain(api.GetInputs(), api.GetOutputs()): port_name = port.GetBaseName() - sources, __ = port.GetConnectedSources() # (valid, invalid): we care only about valid sources (index 0) - color = port_colors[type(port)] if isinstance(port, UsdShade.Output) or sources else background_color - label += table_row.format(port=port_name, color=color, text=f'{port_name}') + sources, __ = port.GetConnectedSources() + color = ( + port_colors[type(port)] + if isinstance(port, UsdShade.Output) or sources + else background_color + ) + port_entries.append((port_name, color)) for source in sources: _add_edges(_get_node_id(source.source.GetPrim()), source.sourceName, node_id, port_name) traverse(source.source) - ports.append(port_name) - label += '
>' + label, ports = _make_port_table_node(api.GetPrim().GetName(), port_entries) all_nodes[node_id] = dict(label=label, ports=ports) traverse(connections_api) @@ -279,6 +364,89 @@ def traverse(api: UsdShade.ConnectableAPI): return graph +def _graph_from_attribute_connections(prim: Usd.Prim) -> nx.MultiDiGraph: + graph = _init_connection_graph() + stage = prim.GetStage() + traversed_prims = set() + edges = list() + edge_tails = set() + edge_heads = set() + + def traverse(current_prim: Usd.Prim): + if current_prim in traversed_prims: + return + traversed_prims.add(current_prim) + node_id = str(current_prim.GetPath()) + for attr in current_prim.GetAttributes(): + for src_path in attr.GetConnections(): + src_attr = stage.GetAttributeAtPath(src_path) + if not src_attr: + continue + src_prim = src_attr.GetPrim() + src_node = str(src_prim.GetPath()) + src_name = src_attr.GetName() + tgt_name = attr.GetName() + tooltip = f"{src_node}.{src_name} -> {node_id}.{tgt_name}" + edge_tails.add((src_node, src_name)) + edge_heads.add((node_id, tgt_name)) + edges.append(( + src_node, + node_id, + { + "tailport": _graphviz_port_id(src_name), + "headport": _graphviz_port_id(tgt_name), + "tooltip": tooltip, + }, + )) + traverse(src_prim) + + traverse(prim) + + all_nodes = dict() + for current_prim in traversed_prims: + node_id = str(current_prim.GetPath()) + port_names = set() + for attr in current_prim.GetAttributes(): + name = attr.GetName() + if ( + attr.GetConnections() + or (node_id, name) in edge_tails + or (node_id, name) in edge_heads + ): + port_names.add(name) + + port_entries = [ + ( + port_name, + _port_color_for_attribute( + current_prim, + port_name, + has_connections=bool(current_prim.GetAttribute(port_name).GetConnections()), + is_edge_tail=(node_id, port_name) in edge_tails, + is_edge_head=(node_id, port_name) in edge_heads, + ), + ) + for port_name in sorted(port_names) + ] + label, ports = _make_port_table_node( + current_prim.GetName(), + port_entries, + prim_type=current_prim.GetTypeName(), + ) + all_nodes[node_id] = dict(label=label, ports=ports) + + graph.add_nodes_from(all_nodes.items()) + graph.add_edges_from(edges) + return graph + + +def _graph_from_connections(prim: Usd.Prim) -> nx.MultiDiGraph: + connections_api = UsdShade.ConnectableAPI(prim) + if connections_api.GetInputs() or connections_api.GetOutputs(): + return _graph_from_shade_connections(prim) + return _graph_from_attribute_connections(prim) + + def _launch_content_browser(layers, parent, context, paths=tuple()): dialog = _start_content_browser(layers, parent, context, paths) dialog.show() diff --git a/tests/mini_test_bed/waddlerRig.usda b/tests/mini_test_bed/waddlerRig.usda new file mode 100644 index 00000000..6b5c7285 --- /dev/null +++ b/tests/mini_test_bed/waddlerRig.usda @@ -0,0 +1,137 @@ +#usda 1.0 +( + defaultPrim = "Rig" + doc = """ + This scene defines a "waddler," a rig with two JointScopes and a + SwitchController that allows one or the other JointScope to act as the + parent. I.e., when the switch attribute is set to 'rig1', Joint1 acts as the + parent and Joint1's avars control the posing and Joint2 inherits + transformation from Joint1. When the switch attribute is set to 'rig2', the + roles are reversed, and Joint2's avars drive the overall pose. And + importantly, rotate avars pivot about the origin of whichever JointScope is + currently acting as the parent, allowing the rig to "waddle," as it + alternately pivots about one end or the other. + """ +) + +def Scope "Rig" { + def Scope "Anim" { + token switch = "rig1" ( + allowedTokens = ["rig1", "rig2"] + ) + + def IrJointScope "Joint1" { + double guide:length = 10.0 + matrix4d posed:space.connect = [ + + ] + + def IrJointScope "Joint2" { + double rest:tz = 10.0 + matrix4d posed:space.connect = [ + + ] + } + } + } + + def Scope "Control" { + def IrSwitchController "Switch1" { + token switch.connect = + matrix4d rig1:space.connect = + matrix4d rig2:space.connect = + } + + def IrSwitchController "Switch2" { + token switch.connect = + matrix4d rig1:space.connect = + matrix4d rig2:space.connect = + } + + def Scope "Rig1" { + def IrFkController "FK1" { + double in:tx.connect = + double in:ty.connect = + double in:tz.connect = + double in:rx.connect = + double in:ry.connect = + double in:rz.connect = + double in:rspin.connect = + token in:rotationOrder.connect = [ + + ] + matrix4d in:defaultSpace.connect = [ + + ] + } + + def IrFkController "FK2" { + double in:tx.connect = + double in:ty.connect = + double in:tz.connect = + double in:rx.connect = + double in:ry.connect = + double in:rz.connect = + double in:rspin.connect = [ + + ] + token in:rotationOrder.connect = [ + + ] + matrix4d in:defaultSpace.connect = [ + + ] + + matrix4d parentIn:defaultSpace.connect = [ + + ] + matrix4d parentIn:space.connect = [ + + ] + } + } + + def Scope "Rig2" { + def IrFkController "FK1" { + double in:tx.connect = + double in:ty.connect = + double in:tz.connect = + double in:rx.connect = + double in:ry.connect = + double in:rz.connect = + double in:rspin.connect = + token in:rotationOrder.connect = [ + + ] + matrix4d in:defaultSpace.connect = [ + + ] + + matrix4d parentIn:defaultSpace.connect = [ + + ] + matrix4d parentIn:space.connect = [ + + ] + } + + def IrFkController "FK2" { + double in:tx.connect = + double in:ty.connect = + double in:tz.connect = + double in:rx.connect = + double in:ry.connect = + double in:rz.connect = + double in:rspin.connect = [ + + ] + token in:rotationOrder.connect = [ + + ] + matrix4d in:defaultSpace.connect = [ + + ] + } + } + } +} diff --git a/tests/test_views.py b/tests/test_views.py index a80f8775..0bde4c2c 100644 --- a/tests/test_views.py +++ b/tests/test_views.py @@ -127,6 +127,27 @@ def test_connection_view(self): self.assertEqual(graph.nodes[str(pbrShader.GetPrim().GetPath())]['ports'], ['', cycle_input.GetBaseName(), roughness_name, cycle_output.GetBaseName(), surface_name]) viewer.setPrim(None) + def test_exec_connection_view(self): + stage = Usd.Stage.Open(str(Path(__file__).parent / "mini_test_bed" / "waddlerRig.usda")) + joint2 = stage.GetPrimAtPath("/Rig/Anim/Joint1/Joint2") + viewer = description._ConnectableAPIViewer() + viewer._graph_view.view = lambda indices: None + viewer.setPrim(joint2) + graph = viewer._graph_view._graph + + self.assertIn("/Rig/Control/Switch2", graph.nodes) + self.assertIn("/Rig/Control/Rig1/FK2", graph.nodes) + self.assertTrue(any( + edge[2].get("tooltip") == ( + "/Rig/Control/Switch2.out:space -> /Rig/Anim/Joint1/Joint2.posed:space" + ) + for edge in graph.edges(data=True) + )) + switch2_ports = graph.nodes["/Rig/Control/Switch2"]["ports"] + for port_name in ("switch", "rig1__space", "rig2__space", "out__space"): + self.assertIn(port_name, switch2_ports) + viewer.setPrim(None) + def test_scenegraph_composition(self): """Confirm that bidirectionality between layer stacks completes.