diff --git a/.github/workflows/python-package.yml b/.github/workflows/python-package.yml index 28789fd7..47ba8740 100644 --- a/.github/workflows/python-package.yml +++ b/.github/workflows/python-package.yml @@ -14,10 +14,12 @@ jobs: strategy: fail-fast: false matrix: - python-version: ["3.9", "3.12"] + python-version: ["3.9", "3.10", "3.12"] include: - python-version: "3.9" - install-arguments: ". PySide2 usd-core==22.5 PyOpenGL pygraphviz" + install-arguments: ". PySide2 usd-core==21.11 PyOpenGL" # 21.11 enables AR-2.0 by default + - python-version: "3.10" + install-arguments: ". PySide2 usd-core==23.2 PyOpenGL" - python-version: "3.12" install-arguments: ".[full]" steps: diff --git a/.readthedocs.yml b/.readthedocs.yml index 220123c9..4765e4a2 100644 --- a/.readthedocs.yml +++ b/.readthedocs.yml @@ -2,7 +2,7 @@ version: 2 # required for PY39+ build: os: ubuntu-20.04 tools: - python: "3.9" + python: "3.10" apt_packages: - "graphviz" diff --git a/docs/source/conf.py b/docs/source/conf.py index dc90d189..ce0fbbf3 100644 --- a/docs/source/conf.py +++ b/docs/source/conf.py @@ -31,22 +31,23 @@ # Add any Sphinx extension module names here, as strings. They can be # extensions coming with Sphinx (named 'sphinx.ext.*') or your custom # ones. -extensions = ['sphinx.ext.autodoc', - 'sphinx.ext.doctest', - 'sphinx.ext.intersphinx', - 'sphinx.ext.inheritance_diagram', - 'sphinx.ext.todo', - 'sphinx.ext.coverage', - 'sphinx.ext.viewcode', - 'sphinx.ext.graphviz', - "myst_parser", - 'sphinx_copybutton', - 'sphinx_toggleprompt', - 'sphinx_togglebutton', - 'sphinx_inline_tabs', - 'hoverxref.extension', - 'sphinx.ext.autosectionlabel', - 'sphinx_autodoc_typehints'] +extensions = [ + 'sphinx.ext.autodoc', + 'sphinx.ext.doctest', + 'sphinx.ext.intersphinx', + 'sphinx.ext.inheritance_diagram', + 'sphinx.ext.todo', + 'sphinx.ext.coverage', + 'sphinx.ext.viewcode', + 'sphinx.ext.graphviz', + "myst_parser", + 'sphinx_copybutton', + 'sphinx_toggleprompt', + 'sphinx_togglebutton', + 'sphinx_inline_tabs', + 'hoverxref.extension', + 'sphinx.ext.autosectionlabel', +] # Offset to play well with copybutton toggleprompt_offset_right = 35 @@ -103,9 +104,9 @@ # built documents. # # The short X.Y version. -version = '0.17' +version = '0.18' # The full version, including alpha/beta/rc tags. -release = '0.17.1' +release = '0.18.0' # The language for content autogenerated by Sphinx. Refer to documentation # for a list of supported languages. diff --git a/docs/source/install.rst b/docs/source/install.rst index af111086..ff0fb99a 100644 --- a/docs/source/install.rst +++ b/docs/source/install.rst @@ -34,7 +34,7 @@ DCC apps and other environments bundle them outside of ``pip``. To include them, .. tab:: Maya - Visit the `official docs `_ for more details. + Visit the `official docs `_ for more details. .. code-block:: bash @@ -46,7 +46,7 @@ Extra Dependencies The following optional dependencies should be installed separately. -- `graphviz `_ and `pygraphviz`_ for graph widgets. See conda example below for instructions. +- `graphviz`_ for graph widgets. See conda example below for instructions. - `usdview `_ (hopefully will be available soon via `pypi `_). In the meantime, it can be built from USD source (`conda recipe `_). @@ -69,11 +69,11 @@ walk-through on how to start using ``The Grill`` tools with a fresh 2. Launch `Anaconda Prompt `_ (it came as part of the `miniconda`_ installation). -3. Create a new ``conda`` environment with ``python=3.9``, for example: +3. Create a new ``conda`` environment with ``python=3.10``, for example: .. code:: PowerShell - (base) C:\>conda create -n grilldemo01 python=3.9 + (base) C:\>conda create -n grilldemo01 python=3.10 4. Activate that environment: @@ -88,84 +88,11 @@ walk-through on how to start using ``The Grill`` tools with a fresh (grilldemo01) C:\>python -m pip install grill[full] -6. If missing, (optionally) install `pygraphviz`_ via ``conda``: +6. If missing, (optionally) install `graphviz`_ via ``conda``: - .. warning:: - - At the moment, installing `pygraphviz`_ can be tricky. Hopefully a simpler pip+wheel based solution comes with `pygraphviz#167 `_. - - Versions older than ``pip-23.3.2`` may have trouble installing `pygraphviz`_ in Windows for DCCs like ``Maya`` and ``Houdini``. - If you come through this trouble, visit `pygraphviz#468 `_ and try to install with this exact particular version of ``pip``. - The below tests ran successfully with ``Maya-2024`` and ``Houdini-20.0`` on ``Windows-10`` and ``pip-23.3.2``. - - The current ``pip`` version can be extracted like so: - - .. tab:: Standalone Python - - .. code:: PowerShell - - python -m pip -V - - .. tab:: Houdini - - .. code:: PowerShell - - hython -m pip -V - - .. tab:: Maya - - .. code:: PowerShell - - mayapy -m pip -V - - To update to ``23.3.2``, update the interpreter command to run: - - .. tab:: Standalone Python - - .. code:: PowerShell - - python -m pip install -U pip==23.3.2 - - .. tab:: Houdini - - .. code:: PowerShell - - hython -m pip install -U pip==23.3.2 - - .. tab:: Maya - - .. code:: PowerShell - - mayapy -m pip install -U pip==23.3.2 - - .. tab:: Standalone Python - - Replace ``--global-option`` to the correct ``Include`` and ``Lib`` paths on the system (where ``graphviz\cgraph.h`` and ``cgraph.lib`` paths exist, respectively): - - .. code:: PowerShell - - (grilldemo01) C:\>conda install --channel conda-forge pygraphviz - (grilldemo01) C:\>python -m pip install --global-option=build_ext --global-option="-IC:\Users\Christian\.conda\envs\glowdeps\Library\include" --global-option="-LC:\Users\Christian\.conda\envs\glowdeps\Library\lib" pygraphviz - - .. tab:: Houdini - - Replace ``--global-option`` to the correct ``Include`` and ``Lib`` paths on the system (where ``graphviz\cgraph.h`` and ``cgraph.lib`` paths exist, respectively): - - .. code:: PowerShell - - (grilldemo01) C:\>conda install --channel conda-forge pygraphviz - (grilldemo01) C:\Program Files\Side Effects Software\Houdini 19.5.534\bin>hython -m pip install -vvv --use-pep517 --config-settings="--global-option=build_ext" --config-settings="--global-option=-IC:\Users\Christian\.conda\envs\pygraphviz310\Library\include" --config-settings="--global-option=-LC:\Users\Christian\.conda\envs\pygraphviz310\Library\lib" pygraphviz - - .. tab:: Maya - - Replace ``--global-option`` to the correct ``Include`` and ``Lib`` paths on the system (where ``graphviz\cgraph.h`` and ``cgraph.lib`` paths exist, respectively) **and** the Maya Python ``include`` and ``lib`` paths: - - .. code:: PowerShell - - (grilldemo01) C:\>conda install --channel conda-forge pygraphviz - (grilldemo01) C:\Program Files\Autodesk\Maya2023\bin>mayapy -m pip install -U pip==23.3.2 - (grilldemo01) C:\Program Files\Autodesk\Maya2023\bin>mayapy -m pip install -vvv --use-pep517 --config-settings="--global-option=build_ext" --config-settings="--global-option=-IC:\Users\Christian\.conda\envs\pygraphviz310\Library\include;C:\Program Files\Autodesk\Maya2024\include\Python39\Python" --config-settings="--global-option=-LC:\Users\Christian\.conda\envs\pygraphviz310\Library\lib;C:\Program Files\Autodesk\Maya2024\lib" pygraphviz + .. code:: PowerShell + (grilldemo01) C:\>conda install conda-forge::graphviz 7. You should be able to see the ``👨‍🍳 Grill`` menu in **USDView**, **Maya** and **Houdini***. @@ -191,7 +118,7 @@ walk-through on how to start using ``The Grill`` tools with a fresh The manual execution of this step might be removed in the future. -.. _pygraphviz: https://pygraphviz.github.io/documentation/stable/install.html +.. _graphviz: http://graphviz.org .. _miniconda: https://docs.conda.io/en/latest/miniconda.html .. _Anaconda: https://docs.anaconda.com/anaconda/user-guide/getting-started/ .. _conda: https://docs.conda.io/projects/conda/en/latest/index.html diff --git a/grill/__startup__/maya.py b/grill/__startup__/maya.py index a0cfe06d..364e33f8 100644 --- a/grill/__startup__/maya.py +++ b/grill/__startup__/maya.py @@ -1,5 +1,9 @@ from maya import cmds -from PySide2 import QtCore + +if cmds.about(qt=True).startswith("6"): + from PySide6 import QtCore +else: + from PySide2 import QtCore def install(): diff --git a/grill/cook/__init__.py b/grill/cook/__init__.py index b6a3e727..ec29d245 100644 --- a/grill/cook/__init__.py +++ b/grill/cook/__init__.py @@ -86,15 +86,16 @@ def _fetch_layer(identifier: str, context: Ar.ResolverContext) -> Sdf.Layer: # TODO: see how to make this repo_path better, seems very experimental atm. if context.IsEmpty(): raise ValueError(f"Empty {context=} while fetching {identifier=}") - repo_path = Path(context.Get()[0].GetSearchPath()[0]) # or just Repository.get()? - Sdf.Layer.CreateNew(str(repo_path / identifier)) - if not (layer := Sdf.Layer.FindOrOpen(identifier)): - raise RuntimeError(f"Make sure a resolver context with statement is being used. {context=}, {identifier=}") + # CreateNew adds overhead vs CreateAnonymous but already provides an identifier and ability to call layer.Save() + return Sdf.Layer.CreateNew(str(repo_path / identifier)) + return layer -def _asset_identifier(path): +def asset_identifier(path): + """Since identifiers from relative paths can become absolute when opening existing assets, this function ensures to return the value expected to be authored in layers.""" + # TODO: temporary public. mmm # Expect identifiers to not have folders in between. if not path: raise ValueError("Can not extract asset identifier from empty path.") @@ -105,29 +106,18 @@ def _asset_identifier(path): return str(path.relative_to(Repository.get())) -@typing.overload -def fetch_stage(identifier: str, context: Ar.ResolverContext = None, load=Usd.Stage.LoadAll) -> Usd.Stage: - ... - - -@typing.overload -def fetch_stage(identifier: UsdAsset, context: Ar.ResolverContext = None, load=Usd.Stage.LoadAll) -> Usd.Stage: - ... # TODO: evaluate if it's worth to keep this, or if identifier can be a relative path - - -@functools.singledispatch -def fetch_stage(identifier, context: Ar.ResolverContext = None, load=Usd.Stage.LoadAll) -> Usd.Stage: +def fetch_stage(identifier: typing.Union[str, UsdAsset], context: Ar.ResolverContext = None, load=Usd.Stage.LoadAll) -> Usd.Stage: """Retrieve the `stage `_ whose root `layer `_ matches the given ``identifier``. If the `layer `_ does not exist, it is created in the repository. - If an open matching `stage `_ is found on the `global cache `_, return it. - Otherwise open it, populate the `cache `_ and return it. - .. attention:: ``identifier`` must be a valid :class:`grill.names.UsdAsset` name. """ + if isinstance(identifier, UsdAsset): + identifier = identifier.name + if not context: context = Ar.ResolverContext(Ar.DefaultResolverContext([str(Repository.get())])) @@ -136,16 +126,6 @@ def fetch_stage(identifier, context: Ar.ResolverContext = None, load=Usd.Stage.L return Usd.Stage.Open(layer, load=load) -@fetch_stage.register(UsdAsset) -def _(identifier: UsdAsset, *args, **kwargs) -> Usd.Stage: - return fetch_stage.registry[object](identifier.name, *args, **kwargs) - - -@fetch_stage.register(str) -def _(identifier: str, *args, **kwargs) -> Usd.Stage: - return fetch_stage(UsdAsset(identifier), *args, **kwargs) - - def define_taxon(stage: Usd.Stage, name: str, *, references: tuple[Usd.Prim] = tuple(), id_fields: typing.Mapping[str, str] = types.MappingProxyType({})) -> Usd.Prim: """Define a new `taxon group `_ for asset `taxonomy `_. @@ -162,6 +142,8 @@ def define_taxon(stage: Usd.Stage, name: str, *, references: tuple[Usd.Prim] = t # (e.g. Windows considers both the same but Linux does not) raise ValueError(f"Can not define a taxon with reserved name: '{_TAXONOMY_NAME}'.") + if not Sdf.Path.IsValidNamespacedIdentifier(name): + raise ValueError(f"{name=} must be a valid identifier for a prim") reserved_fields = {_TAXONOMY_UNIQUE_ID, _UNIT_UNIQUE_ID} reserved_fields.update([i.name for i in reserved_fields]) if intersection:=reserved_fields.intersection(id_fields): @@ -187,10 +169,12 @@ def define_taxon(stage: Usd.Stage, name: str, *, references: tuple[Usd.Prim] = t return prim -def itaxa(prims, taxon, *taxa): - """Yields prims that are part of the given taxa.""" - taxa_names = {i if isinstance(i, str) else i.GetName() for i in (taxon, *taxa)} - return (prim for prim in prims if taxa_names.intersection(prim.GetAssetInfoByKey(_ASSETINFO_TAXA_KEY) or {})) +def itaxa(stage: Usd.Stage) -> typing.Generator[Usd.Prim]: + """For the given stage, iterate existing taxa under the taxonomy hierarchy.""" + return filter( + lambda prim: prim.GetAssetInfoByKey(_ASSETINFO_TAXA_KEY), + _usd.iprims(stage, root_paths={_TAXONOMY_ROOT_PATH}, traverse_predicate=Usd.PrimAllPrimsPredicate) + ) def _catalogue_path(taxon: Usd.Prim) -> Sdf.Path: @@ -230,7 +214,7 @@ def create_many(taxon, names, labels=tuple()) -> typing.List[Usd.Prim]: # existing = {i.GetName() for i in _iter_taxa(taxon.GetStage(), *taxon.GetCustomDataByKey(_ASSETINFO_TAXA_KEY))} taxonomy_layer = _find_layer_matching(_TAXONOMY_FIELDS, stage.GetLayerStack()) - taxonomy_id = _asset_identifier(taxonomy_layer.identifier) + taxonomy_id = asset_identifier(taxonomy_layer.identifier) context = stage.GetPathResolverContext() if context.IsEmpty(): # Use a resolver context that is populated with the repository only when the context is empty. context = Ar.ResolverContext(Ar.DefaultResolverContext([str(Repository.get())])) @@ -241,7 +225,7 @@ def create_many(taxon, names, labels=tuple()) -> typing.List[Usd.Prim]: catalogue_asset = current_asset_name.get(**_CATALOGUE_FIELDS) with Ar.ResolverContextBinder(context): catalogue_layer = _fetch_layer(str(catalogue_asset), context) - catalogue_id = _asset_identifier(catalogue_layer.identifier) + catalogue_id = asset_identifier(catalogue_layer.identifier) root_layer.subLayerPaths.insert(0, catalogue_id) # TODO: try setting this on session layer? @@ -281,7 +265,7 @@ def _fetch_layer_for_unit(name): if not stage.GetPrimAtPath(path:=scope_path.AppendChild(name)): stage.OverridePrim(path) layer = _fetch_layer_for_unit(name) - layer_id = _asset_identifier(layer.identifier) + layer_id = asset_identifier(layer.identifier) prims_info.append((name, label or name, path, layer, Sdf.Reference(layer_id))) prims_info = {stage.GetPrimAtPath(info[2]): info for info in prims_info} @@ -295,7 +279,7 @@ def _fetch_layer_for_unit(name): modelAPI = Usd.ModelAPI(prim) modelAPI.SetKind(Kind.Tokens.component) modelAPI.SetAssetName(name) - modelAPI.SetAssetIdentifier(_asset_identifier(layer.identifier)) + modelAPI.SetAssetIdentifier(asset_identifier(layer.identifier)) catalogue_layer.SetPermissionToEdit(current_permission) return list(prims_info) @@ -333,7 +317,7 @@ def taxonomy_context(stage: Usd.Stage) -> Usd.EditContext: taxonomy_layer = _fetch_layer(str(taxonomy_asset), context) # Use paths relative to our repository to guarantee portability # taxonomy_id = str(Path(taxonomy_layer.realPath).relative_to(Repository.get())) - taxonomy_id = _asset_identifier(taxonomy_layer.identifier) + taxonomy_id = asset_identifier(taxonomy_layer.identifier) taxonomy_root = Sdf.CreatePrimInLayer(taxonomy_layer, _TAXONOMY_ROOT_PATH) taxonomy_root.specifier = Sdf.SpecifierClass taxonomy_layer.defaultPrim = taxonomy_root.name @@ -383,7 +367,7 @@ def spawn_unit(parent, child, path=Sdf.Path.emptyPath, label=""): return spawn_many(parent, child, [path or child.GetName()], [label])[0] -def spawn_many(parent: Usd.Prim, child: Usd.Prim, paths: list[Sdf.Path], labels: list[str] = []): +def spawn_many(parent: Usd.Prim, child: Usd.Prim, paths: list[Sdf.Path], labels: list[str] = ()): """Spawn many instances of a prim unit as descendants of another. * Both parent and child must be existing units in the catalogue. @@ -394,6 +378,8 @@ def spawn_many(parent: Usd.Prim, child: Usd.Prim, paths: list[Sdf.Path], labels: 2. Ensuring intermediate prims between ``parent`` and spawned children are also `models `_. 3. Setting explicit `instanceable `_. on spawned children that are components. + Spawned prims and ancestors are `defined `_. + .. seealso:: :func:`spawn_unit` and :func:`create_unit` """ if parent == child: @@ -411,10 +397,11 @@ def spawn_many(parent: Usd.Prim, child: Usd.Prim, paths: list[Sdf.Path], labels: paths_to_create.append(path) labels = itertools.chain(labels, itertools.repeat("")) try: - reference = _asset_identifier(Usd.ModelAPI(child).GetAssetIdentifier().path) + reference = asset_identifier(Usd.ModelAPI(child).GetAssetIdentifier().path) except ValueError: raise ValueError(f"Could not extract identifier from {child} to spawn under {parent}.") parent_stage = parent.GetStage() + # Ensure prims are defined to spawn units unto (paths might be deep e.g. /world/parent/nested/path/for/child) spawned = [parent_stage.DefinePrim(path) for path in paths_to_create] child_is_model = child.IsModel() with Sdf.ChangeBlock(): @@ -505,8 +492,8 @@ def _inherit_or_specialize_unit(method, context_unit): raise ValueError(f"{target_prim} is not a valid unit in the catalogue.") context_unit = context_unit or target_prim - if not Usd.ModelAPI(context_unit).GetAssetName(): - raise ValueError(f"{context_unit=} needs to be a valid unit in the catalogue.") + if not (modelAPI:=Usd.ModelAPI(context_unit)).GetAssetName(): + raise ValueError(f"{context_unit=} needs to be a valid unit in the catalogue. Currently it has a kind of '{modelAPI.GetKind()}' and asset info of {modelAPI.GetAssetInfo()}") broadcast_method = type(method) if not target_prim.GetPath().HasPrefix(context_unit.GetPath()): @@ -519,11 +506,13 @@ def _inherit_or_specialize_unit(method, context_unit): except ValueError as exc: raise ValueError( f"Could not find an appropriate edit target node for a {broadcast_method.__name__}'s arc targeting {target_path} for {target_prim}. " - f"""Is there a composition arc bringing "{target_prim.GetName()}"'s unit into "{context_unit.GetName()}"'s layer stack at {context_asset}?""" + f"""Is there a composition arc bringing "{target_prim.GetName()}"'s prim unit into "{context_unit.GetName()}"'s layer stack at {context_asset}?""" ) from exc -def taxonomy_graph(prims, url_id_prefix): +@functools.singledispatch +def taxonomy_graph(prims: Usd.Prim, url_id_prefix) -> nx.DiGraph: + """Get the hierarchical taxonomy representation of existing prims.""" graph = nx.DiGraph(tooltip="Taxonomy Graph") graph.graph.update( graph={'rankdir': 'LR'}, @@ -537,8 +526,15 @@ def taxonomy_graph(prims, url_id_prefix): # TODO: # - Guarantee taxa will be unique (no duplicated short names), raise here? - # - Fail with clear error message when provided prims are not taxa for taxon in prims: + if not (taxa_key:=taxon.GetAssetInfoByKey(_ASSETINFO_TAXA_KEY)): + raise ValueError(f"Prim {taxon} is not a taxon. Expected to find asset info in key '{_ASSETINFO_TAXA_KEY}' but found '{taxa_key}'. Complete prim's asset info: {taxon.GetAssetInfo()}") graph.add_node(taxon_name:=taxon.GetName(), tooltip=taxon.GetPath(), href=f"{url_id_prefix}{taxon_name}",) graph.add_edges_from(itertools.zip_longest(set(taxon.GetAssetInfoByKey(_ASSETINFO_TAXA_KEY)) - {taxon_name}, (), fillvalue=taxon_name)) return graph + + +@taxonomy_graph.register(Usd.Stage) +def _(stage: Usd.Stage, url_id_prefix) -> nx.DiGraph: + # Convenience for the stage + return taxonomy_graph(itaxa(stage), url_id_prefix) diff --git a/grill/usd/__init__.py b/grill/usd/__init__.py index d0efd8ad..2b5c1773 100644 --- a/grill/usd/__init__.py +++ b/grill/usd/__init__.py @@ -75,38 +75,8 @@ def iprims(stage: Usd.Stage, root_paths: typing.Iterable[Sdf.Path] = tuple(), pr ) -@typing.overload -def edit_context(payload: Sdf.Payload, /, prim: Usd.Prim) -> Usd.EditContext: - ... - - -@typing.overload -def edit_context(reference: Sdf.Reference, /, prim: Usd.Prim) -> Usd.EditContext: - ... - - -@typing.overload -def edit_context(inherits: Usd.Inherits, /, path: Sdf.Path, layer: Sdf.Layer) -> Usd.EditContext: - ... - - -@typing.overload -def edit_context(specializes: Usd.Specializes, /, path: Sdf.Path, layer: Sdf.Layer) -> Usd.EditContext: - ... - - -@typing.overload -def edit_context(variant: Usd.VariantSet, /, layer: Sdf.Layer) -> Usd.EditContext: - ... - - -@typing.overload -def edit_context(prim: Usd.Prim, /, query_filter: Usd.PrimCompositionQuery.Filter, arc_predicate: typing.Callable) -> Usd.EditContext: - ... - - @functools.singledispatch -def edit_context(obj, /, *args, **kwargs) -> Usd.EditContext: +def edit_context(prim: Usd.Prim, /, query_filter: Usd.PrimCompositionQuery.Filter, arc_predicate: typing.Callable[[Usd.CompositionArc], bool]) -> Usd.EditContext: """Composition arcs target layer stacks. These functions help create EditTargets for the first matching node's root layer stack from prim's composition arcs. This allows for "chained" context switching while preserving the same stage objects. @@ -232,11 +202,6 @@ def Sphere "child" ( } """ - raise TypeError(f"Not implemented: {locals()}") # lazy - - -@edit_context.register -def _(prim: Usd.Prim, /, query_filter, arc_predicate): # https://blogs.mathworks.com/developer/2015/03/31/dont-get-in-too-deep/ # with write.context(prim, dict(kingdom="assets")): # prim.GetAttribute("abc").Set(True) @@ -254,7 +219,7 @@ def _(prim: Usd.Prim, /, query_filter, arc_predicate): @edit_context.register(Sdf.Reference) @edit_context.register(Sdf.Payload) -def _(arc: typing.Union[Sdf.Payload, Sdf.Reference], /, prim): +def _(arc, /, prim: Usd.Prim) -> Usd.EditContext: identifier = arc.assetPath with Ar.ResolverContextBinder(prim.GetStage().GetPathResolverContext()): # Use Layer.Find since layer should have been open for the prim to exist. @@ -274,12 +239,12 @@ def _(arc: typing.Union[Sdf.Payload, Sdf.Reference], /, prim): @edit_context.register(Usd.Inherits) @edit_context.register(Usd.Specializes) -def _(arc_type: typing.Union[Usd.Inherits, Usd.Specializes], /, path, layer): - return _edit_context_by_arc(arc_type.GetPrim(), type(arc_type), path, layer) +def _(arc, /, path: Sdf.Path, layer: Sdf.Layer) -> Usd.EditContext: + return _edit_context_by_arc(arc.GetPrim(), type(arc), path, layer) @edit_context.register -def _(variant_set: Usd.VariantSet, /, layer): +def _(variant_set: Usd.VariantSet, /, layer) -> Usd.EditContext: with contextlib.suppress(Tf.ErrorException): return variant_set.GetVariantEditContext() # ----- From Pixar ----- diff --git a/grill/views/README.md b/grill/views/README.md index 8119ecdb..624b9d2d 100644 --- a/grill/views/README.md +++ b/grill/views/README.md @@ -1,3 +1,3 @@ The `grill.views` package provides `Qt` widgets to author and inspect `USD` scene graphs. -Convenience launchers and menus for **USDView**, **Houdini** and **Maya** are provided (appearing under the `👨‍🍳 Grill` menu), but any DCC or environment with `USD` and `PySide2` should be able to use the widgets. +Convenience launchers and menus for **USDView**, **Houdini** and **Maya** are provided (appearing under the `👨‍🍳 Grill` menu), but any DCC or environment with `USD` and `PySide(2|6)` should be able to use the widgets. diff --git a/grill/views/_core.py b/grill/views/_core.py index f090f882..59da5875 100644 --- a/grill/views/_core.py +++ b/grill/views/_core.py @@ -114,16 +114,6 @@ def _run(args: list): return error, result.stdout.decode() -@cache -def _ensure_dot(): - """For usage only when DCC python interpreter fails to install pygraphviz properly.""" - if dotpath := _which("dot"): - # https://github.com/pygraphviz/pygraphviz/issues/360 - # TODO: is this the best approach to solve current Houdini and Maya failing to import graphviz?? - if hasattr(os, "add_dll_directory"): - os.add_dll_directory(Path(dotpath).parent) # sigh, patch pygraphviz? - - @contextlib.contextmanager def wait(): try: diff --git a/grill/views/_graph.py b/grill/views/_graph.py index 3c0bcbc7..a8db2d7b 100644 --- a/grill/views/_graph.py +++ b/grill/views/_graph.py @@ -10,12 +10,12 @@ import networkx as nx from itertools import chain from functools import cache +from collections import ChainMap from networkx import drawing from . import _core from ._qt import QtCore, QtGui, QtWidgets, QtSvg -_core._ensure_dot() _logger = logging.getLogger(__name__) @@ -34,46 +34,54 @@ _IS_QT5 = QtCore.qVersion().startswith("5") # TODO: -# - Popup everytime a new graph is loaded in Houdini or Maya ( it's on _run_prog func line 1380 of agraph.py ) -# https://github.com/pygraphviz/pygraphviz/pull/514 # - Should toggling "precise source layer" on LayerStack compostiion view preserve node position for _GraphViewer? # - Tooltip on nodes for _GraphViewer # - Context menu items # - Ability to move further in canvas after Nodes don't exist -# - when switching a node left to right with precise source layers, the source node plugs do not refresh if we're moving the target node +# - when switching a node left to right with precise source layers, the source node ports do not refresh if we're moving the target node # - refactor conditionals for _GraphSVGViewer from the description module _NO_PEN = QtGui.QPen(QtCore.Qt.NoPen) -_DOT_ENVIRONMENT_ERROR = """In order to display composition arcs in a graph, +_DOT_ENVIRONMENT_ERROR = """In order to display content in this graph view, the 'dot' command must be available on the current environment. Please make sure graphviz is installed and 'dot' available on the system's PATH environment variable. -For more details on installing graphviz, visit https://pygraphviz.github.io/documentation/stable/install.html +For more details on installing graphviz, visit: + - https://graphviz.org/download/ or + - https://grill.readthedocs.io/en/latest/install.html#conda-environment-example """ -def _convert_graphviz_to_html_label(label): +def _adjust_graphviz_html_table_label(label): # TODO: these checks below rely on internals from the grill (layer stack composition uses record shapes, connection viewer uses html) - if label.startswith("{"): # We're a record. Split the label into individual fields - fields = label.strip("{}").split("|") - label = '' - for index, field in enumerate(fields): - port, text = field.strip("<>").split(">", 1) - bgcolor = "white" if index % 2 == 0 else "#f0f6ff" # light blue - # text = f'{text}' - text = f'{text}' - label += f"" - label += "
{text}
" - elif label.startswith("<"): + if label.startswith("<"): # Contract: HTML graphviz labels start with a double <<, additionally, ROUNDED is internal to graphviz # QGraphicsTextItem seems to have trouble with HTML rounding, so we're controlling this via paint + custom style label = label.removeprefix("<").removesuffix(">").replace('table border="1" cellspacing="2" style="ROUNDED"', "table") return label +def _get_html_table_from_ports(**ports): + label = '' + for index, (name, text) in enumerate(ports.items()): + bgcolor = "white" if index % 2 == 0 else "#f0f6ff" # light blue + text = f'{text}' + label += f"" + label += "
{text}
" + return label + + +def _get_ports_from_label(label) -> dict[str, str]: + if not label.startswith("{"): # Only for record labels. + raise ValueError(f"Label needs to start with '{{' to extract ports from it, for example: '{{item|another_item}}'. Got label: '{label}'") + # see https://graphviz.org/doc/info/shapes.html#record + fields = label.strip("{}").split("|") + return dict(field.strip("<>").split(">", 1) for field in fields) + + @cache def _dot_2_svg(sourcepath): print(f"Creating svg for: {sourcepath}") @@ -85,26 +93,16 @@ def _dot_2_svg(sourcepath): class _Node(QtWidgets.QGraphicsTextItem): - def __init__(self, parent=None, label="", color="", fillcolor="", plugs: tuple =None, active_plugs: set = frozenset(), visible=True): + # Note: keep 'label' as an argument to use as much as possible as-is for clients to provide their own HTML style + def __init__(self, parent=None, label="", color="", fillcolor="", ports: tuple = (), visible=True): super().__init__(parent) self._edges = [] - self._plugs = plugs = dict(zip(plugs, range(len(plugs)))) or {} # {identifier: index} - - plug_items = {} # {index: (QEllipse, QEllipse)} - radius = 4 - def _plug_item(): - item = QtWidgets.QGraphicsEllipseItem(-radius, -radius, 2 * radius, 2 * radius) - item.setPen(_NO_PEN) - return item - self._active_plugs = active_plugs - self._active_plugs_by_side = dict() # {index: {left[int]: {}, right[int]: {}} - for plug_index in active_plugs: - plug_items[plugs[plug_index]] = (_plug_item(), _plug_item()) - self._active_plugs_by_side[plugs[plug_index]] = {0: dict(), 1: dict()} - self._plug_items = plug_items + self._ports = dict(zip(ports, range(len(ports)))) or {} # {identifier: index} + self._active_ports_by_side = dict() # {index: {left[int]: {}, right[int]: {}} + self._port_items = {} # {index: (QEllipse, QEllipse)} self._pen = QtGui.QPen(QtGui.QColor(color), 1, QtCore.Qt.SolidLine, QtCore.Qt.RoundCap, QtCore.Qt.RoundJoin) self._fillcolor = QtGui.QColor(fillcolor) - self.setHtml("" + _convert_graphviz_to_html_label(label)) + self.setHtml("" + label) # Temp measure: allow PySide6 interaction, but not in PySide2 as this causes a crash on windows: # https://stackoverflow.com/questions/67264846/pyqt5-program-crashes-when-editable-qgraphicstextitem-is-clicked-with-right-mo # https://bugreports.qt.io/browse/QTBUG-89563 @@ -167,48 +165,62 @@ def itemChange(self, change: QtWidgets.QGraphicsItem.GraphicsItemChange, value): edge.adjust() return super().itemChange(change, value) - def _activatePlug(self, edge, plug_index, side, position): - if plug_index is None: + def _activatePort(self, edge, port, side, position): + if port is None: return # we're at the center, nothing to draw nor activate - plugs_by_side = self._active_plugs_by_side[plug_index] # {index: {left[int]: {}, right[int]: {}} - plugs_by_side[side][edge] = True + try: + ports_by_side = self._active_ports_by_side[port] # {index: {left[int]: {}, right[int]: {}} + except KeyError: # first time we're activating a port, so add a visual ellipse for it + radius = 4 + + def _add_port_item(): + item = QtWidgets.QGraphicsEllipseItem(-radius, -radius, 2 * radius, 2 * radius) + item.setPen(_NO_PEN) + self.scene().addItem(item) + return item + + self._port_items[port] = (_add_port_item(), _add_port_item()) + self._active_ports_by_side[port] = ports_by_side = {0: dict(), 1: dict()} + + ports_by_side[side][edge] = True other_side = bool(not side) - inactive_plugs = plugs_by_side[other_side] - inactive_plugs.pop(edge, None) - plug_items = self._plug_items[plug_index] # {index: (QEllipse, QEllipse)} - if not inactive_plugs: - plug_items[other_side].setVisible(False) - this_item = plug_items[side] + inactive_ports = ports_by_side[other_side] + inactive_ports.pop(edge, None) + port_items = self._port_items[port] # {index: (QEllipse, QEllipse)} + if not inactive_ports: + port_items[other_side].setVisible(False) + this_item = port_items[side] this_item.setVisible(True) this_item.setBrush(edge._brush) - plug_items[side].setPos(position) + port_items[side].setPos(position) class _Edge(QtWidgets.QGraphicsItem): - def __init__(self, source: _Node, target: _Node, *, source_plug: int =None, target_plug: int =None, label="", color="", is_bidirectional=False, parent: QtWidgets.QGraphicsItem = None): + def __init__(self, source: _Node, target: _Node, *, source_port: int = None, target_port: int = None, label="", color="", is_bidirectional=False, parent: QtWidgets.QGraphicsItem = None): super().__init__(parent) source.add_edge(self) target.add_edge(self) self._source = source self._target = target - self._source_plug = source_plug - self._target_plug = target_plug - self._is_source_plugged = source_plug is not None - self._is_target_plugged = target_plug is not None + self._source_port = source_port + self._target_port = target_port + self._is_source_port_used = source_port is not None + self._is_target_port_used = target_port is not None self._is_cycle = is_cycle = source == target - self._plug_positions = plug_positions = {} + self._port_positions = port_positions = {} outer_shift = 10 # surrounding rect has ~5 px top and bottom - for node, plug, max_plug_idx in (source, source_plug, max(source._plugs.values(), default=0)), (target, target_plug, max(target._plugs.values(), default=0)): + # TODO: this is the main reason of why Node._ports has {port: index}. See if it can be removed + for node, port, max_port_idx in (source, source_port, max(source._ports.values(), default=0)), (target, target_port, max(target._ports.values(), default=0)): bounds = node.boundingRect() - if plug is None: - plug_positions[node, plug] = {None: QtCore.QPointF(bounds.right() - 5, bounds.height() / 2 - 20) if is_cycle else bounds.center()} + if port is None: + port_positions[node, port] = {None: QtCore.QPointF(bounds.right() - 5, bounds.height() / 2 - 20) if is_cycle else bounds.center()} continue - # max_plug_idx can be 0, so we add 1 since this needs to be 1-index based - port_size = (bounds.height() - outer_shift) / (max_plug_idx + 1) - y_pos = (plug * port_size) + (port_size / 2) + (outer_shift / 2) - plug_positions[node, plug] = { + # max_port_idx can be 0, so we add 1 since this needs to be 1-index based + port_size = (bounds.height() - outer_shift) / (max_port_idx + 1) + y_pos = (port * port_size) + (port_size / 2) + (outer_shift / 2) + port_positions[node, port] = { 0: QtCore.QPointF(0, y_pos), # left 1: QtCore.QPointF(bounds.right(), y_pos), # right } @@ -219,7 +231,7 @@ def __init__(self, source: _Node, target: _Node, *, source_plug: int =None, targ self._line = QtCore.QLineF() self.setZValue(-1) - self._spline_path = QtGui.QPainterPath() if (self._is_source_plugged or self._is_target_plugged) else None + self._spline_path = QtGui.QPainterPath() if (self._is_source_port_used or self._is_target_port_used) else None self._colors = colors = color.split(":") main_color = QtGui.QColor(colors[0]) @@ -255,8 +267,8 @@ def boundingRect(self) -> QtCore.QRectF: @property def _cycle_start_position(self): - if not self._is_source_plugged: - return self._source.pos() + self._plug_positions[self._source, self._source_plug][None] + if not self._is_source_port_used: + return self._source.pos() + self._port_positions[self._source, self._source_port][None] return self._line.p1() + QtCore.QPointF(-3, -31) @@ -269,14 +281,14 @@ def adjust(self): source_on_left = self._is_cycle or (self._source.boundingRect().center().x() + source_pos.x() < target_bounds.center().x() + target_pos.x()) - is_source_plugged = self._is_source_plugged - is_target_plugged = self._is_target_plugged - source_side = source_on_left if is_source_plugged else None - target_side = not source_side if is_target_plugged else None - source_point = source_pos + self._plug_positions[self._source, self._source_plug][source_side] - target_point = target_pos + self._plug_positions[self._target, self._target_plug][target_side] + is_source_port_used = self._is_source_port_used + is_target_port_used = self._is_target_port_used + source_side = source_on_left if is_source_port_used else None + target_side = not source_side if is_target_port_used else None + source_point = source_pos + self._port_positions[self._source, self._source_port][source_side] + target_point = target_pos + self._port_positions[self._target, self._target_port][target_side] - if not is_target_plugged: + if not is_target_port_used: line = QtCore.QLineF(source_point, target_point) if not self._spline_path and self._bidirectional_shift and source_point != target_point: # offset in case of bidirectional connections when we are not using splines (as lines would overlap) @@ -309,15 +321,15 @@ def adjust(self): falloff = (length / 100) ** 2 if length < 100 else 1 control_point_shift = (1 if source_on_left else -1) * 75 * falloff - control_point1 = source_point + QtCore.QPointF(control_point_shift, 0) if is_source_plugged else source_point - control_point2 = target_point + QtCore.QPointF(-control_point_shift, 0) if is_target_plugged else target_point + control_point1 = source_point + QtCore.QPointF(control_point_shift, 0) if is_source_port_used else source_point + control_point2 = target_point + QtCore.QPointF(-control_point_shift, 0) if is_target_port_used else target_point self._spline_path = QtGui.QPainterPath() self._spline_path.moveTo(source_point) self._spline_path.cubicTo(control_point1, control_point2, target_point) - self._source._activatePlug(self, self._source_plug, source_side, source_point) - self._target._activatePlug(self, self._target_plug, target_side, target_point) + self._source._activatePort(self, self._source_port, source_side, source_point) + self._target._activatePort(self, self._target_port, target_side, target_point) if self._label_text: self._label_text.setPos((source_point + target_point) / 2) @@ -583,42 +595,66 @@ def _load_graph(self, graph): self.scene().clear() self.viewport().update() + _default_text_interaction = QtCore.Qt.LinksAccessibleByMouse if _IS_QT5 else QtCore.Qt.TextBrowserInteraction + if not _core._which("dot"): # dot has not been installed print(_DOT_ENVIRONMENT_ERROR) text_item = QtWidgets.QGraphicsTextItem() text_item.setPlainText(_DOT_ENVIRONMENT_ERROR) - text_item.setTextInteractionFlags(QtCore.Qt.LinksAccessibleByMouse if _IS_QT5 else QtCore.Qt.TextBrowserInteraction) + text_item.setTextInteractionFlags(_default_text_interaction) self.scene().addItem(text_item) return - try: # exit early if pygraphviz is not installed, needed for positions - positions = drawing.nx_agraph.graphviz_layout(graph, prog='dot') + try: # exit early if pydot is not installed, needed for positions + positions = drawing.nx_pydot.graphviz_layout(graph, prog='dot') except ImportError as exc: - message = str(exc) + message = f"{exc}\n\n{_DOT_ENVIRONMENT_ERROR}" print(message) text_item = QtWidgets.QGraphicsTextItem() text_item.setPlainText(message) + text_item.setTextInteractionFlags(_default_text_interaction) self.scene().addItem(text_item) return print("LOADING GRAPH") self._nodes_map.clear() edge_color = graph.graph.get('edge', {}).get("color", "") + graph_node_attrs = graph.graph.get('node', {}) def _add_node(nx_node): node_data = graph.nodes[nx_node] + ports = node_data.get('ports', ()) + nodes_attrs = ChainMap(node_data, graph_node_attrs) + if (shape := nodes_attrs.get('shape')) == 'record': + try: + label = node_data['label'] + except KeyError: + raise ValueError(f"'label' must be supplied when 'record' shape is set for node: '{nx_node}' with data: {node_data}") + if ports: + raise ValueError(f"record 'shape' and 'ports' are mutually exclusive, pick one for node: '{nx_node}' with data: {node_data}") + try: + ports = _get_ports_from_label(label) + except ValueError as exc: + raise ValueError(f"In order to use the 'record' shape, a record 'label' in the form of: '{{text1|text2}}' must be used") from exc + label = _get_html_table_from_ports(**ports) + else: + label = node_data.get('label') + if shape in {'none', 'plaintext'}: + if not label: + raise ValueError(f"A label must be provided for when using 'none' or 'plaintext' shapes for {nx_node}, {node_data=}") + label = _adjust_graphviz_html_table_label(label) + elif not label: + label = str(nx_node) + item = _Node( - label=node_data.get('label', str(nx_node)), - color=graph.graph.get('node', {}).get("color", ""), - fillcolor=graph.graph.get('node', {}).get("fillcolor", "white"), - plugs=node_data.get('plugs', {}), - visible=node_data.get('style', "") != "invis", - active_plugs=node_data.get('active_plugs', set()), + label=label, + color=nodes_attrs.get("color", ""), + fillcolor=nodes_attrs.get("fillcolor", "white"), + ports=ports, + visible=nodes_attrs.get('style', "") != "invis", ) item.linkActivated.connect(self._graph_url_changed) self.scene().addItem(item) - for each_plug in chain.from_iterable(item._plug_items.values()): - self.scene().addItem(each_plug) return item max_y = max(pos[1] for pos in positions.values()) @@ -646,9 +682,10 @@ def _add_node(nx_node): color = edge_data.get('color', edge_color) label = edge_data.get('label', '') kwargs = dict() - if source._plugs or target._plugs: - kwargs['target_plug'] = target._plugs[edge_data['headport']] if edge_data.get('headport') is not None else None - kwargs['source_plug'] = source._plugs[edge_data['tailport']] if edge_data.get('tailport') is not None else None + if source._ports or target._ports: + kwargs['target_port'] = target._ports[edge_data['headport']] if edge_data.get('headport') is not None else None + kwargs['source_port'] = source._ports[edge_data['tailport']] if edge_data.get('tailport') is not None else None + edge = _Edge(source, target, color=color, label=label, is_bidirectional=is_bidirectional, **kwargs) self.scene().addItem(edge) @@ -680,6 +717,7 @@ def __init__(self, *args, **kwargs): self.setScene(scene) def load(self, filepath): + filepath = filepath.toLocalFile() if isinstance(filepath, QtCore.QUrl) else filepath scene = self.scene() scene.clear() @@ -721,6 +759,7 @@ def __init__(self, *args, **kwargs): layout.addWidget(self._error_view) layout.setContentsMargins(0, 0, 0, 0) self._error_view.setVisible(False) + self._error_view.setLineWrapMode(QtWidgets.QTextBrowser.NoWrap) self.setLayout(layout) self._dot2svg = None self._threadpool = QtCore.QThreadPool() @@ -805,7 +844,7 @@ def _subgraph_dot_path(self, node_indices: tuple): fd, fp = tempfile.mkstemp() try: - nx.nx_agraph.write_dot(subgraph, fp) + nx.nx_pydot.write_dot(subgraph, fp) except ImportError as exc: error = f"{exc}\n\n{_DOT_ENVIRONMENT_ERROR}" else: diff --git a/grill/views/_qt.py b/grill/views/_qt.py index ef752e80..c7cc6036 100644 --- a/grill/views/_qt.py +++ b/grill/views/_qt.py @@ -1,5 +1,10 @@ try: # only while transition from PySide2 to PySide6 happens - from PySide6 import QtWidgets, QtGui, QtCore, QtCharts, QtSvg, QtTest + from PySide6 import QtWidgets, QtGui, QtCore, QtSvg, QtTest + try: + from PySide6 import QtCharts + except ImportError: + # Maya-2025.3 bundles PySide6, but fails to bring QtCharts :c + pass except ImportError: from PySide2 import QtWidgets, QtGui, QtCore, QtSvg, QtTest if not hasattr(QtCore, "__enter__"): diff --git a/grill/views/description.py b/grill/views/description.py index 474faa54..c48dc4b0 100644 --- a/grill/views/description.py +++ b/grill/views/description.py @@ -150,7 +150,6 @@ def _add_node(pcp_node): ids_by_root_layer[root_layer] = index = len(all_nodes) attrs = dict(style='rounded,filled', shape='record', href=f"{url_prefix}{index}", fillcolor="white", color="darkslategray") - plugs = [] label = '{' tooltip = 'LayerStack:' for layer, layer_index in sublayers.items(): @@ -160,12 +159,8 @@ def _add_node(pcp_node): # For new line: https://stackoverflow.com/questions/16671966/multiline-tooltip-for-pydot-graph # For Windows path sep: https://stackoverflow.com/questions/15094591/how-to-escape-forwardslash-character-in-html-but-have-it-passed-correctly-to-jav tooltip += f" {layer_index}: {(layer.realPath or layer.identifier)}".replace('\\', '/') - plugs.append(layer_index) label += f"{'' if layer_index == 0 else '|'}<{layer_index}>{_layer_label(layer)}" label += '}' - # attrs['plugs'] = dict(zip(plugs, range(len(plugs)))) - attrs['plugs'] = tuple(plugs) - attrs['active_plugs'] = set() # all active connections, for GUI all_nodes[index] = dict(label=label, tooltip=tooltip, **attrs) return index, sublayers @@ -182,8 +177,8 @@ def _compute_composition(_prim): # arc.GetTargetNode().origin nor arc.GetTargetNode().GetOriginRootNode() source_idx, source_layers = _add_node(arc.GetIntroducingNode()) source_port = source_layers[source_layer] - all_nodes[source_idx]['active_plugs'].add(source_port) # all connections, for GUI - all_edges[source_idx, target_idx][source_port][arc.GetArcType()].update( + # implementation detail: convert source_port to a string since it's serialized as the label in graphviz + all_edges[source_idx, target_idx][str(source_port)][arc.GetArcType()].update( {func.__name__: is_fun for func in _USD_COMPOSITION_ARC_QUERY_METHODS if (is_fun := func(arc))} ) @@ -191,7 +186,7 @@ def _compute_composition(_prim): all_nodes = dict() # {int: dict} all_edges = defaultdict( # { (source_node: int, target_node: int): - lambda: defaultdict( # { source_port: int: + lambda: defaultdict( # { source_port: str: lambda: defaultdict( # { Pcp.ArcType: _USD_COMPOSITION_ARC_QUERY_DEFAULTS # { HasArcs: bool, IsImplicit: bool, ... } ) # } @@ -234,7 +229,7 @@ def _graph_from_connections(prim: Usd.Prim) -> nx.MultiDiGraph: graph.graph['edge'] = {"color": 'crimson'} all_nodes = dict() # {node_id: {graphviz_attr: value}} - edges = list() # [(source_node_id, target_node_id, {source_plug_name, target_plug_name, graphviz_attrs})] + edges = list() # [(source_node_id, target_node_id, {source_port_name, target_port_name, graphviz_attrs})] @cache def _get_node_id(api): @@ -245,7 +240,7 @@ def _add_edges(src_node, src_name, tgt_node, tgt_name): tooltip = f"{src_node}.{src_name} -> {tgt_node}.{tgt_name}" edges.append((src_node, tgt_node, {"tailport": src_name, "headport": tgt_name, "tooltip": tooltip})) - plug_colors = { + port_colors = { UsdShade.Input: outline_color, # blue UsdShade.Output: "#F08080" # "lightcoral", # pink } @@ -260,20 +255,18 @@ def traverse(api: UsdShade.ConnectableAPI): node_id = _get_node_id(current_prim) label = f'<' label += table_row.format(port="", color="white", text=f'{api.GetPrim().GetName()}') - plugs = {"": 0} # {graphviz port name: port index order} - active_plugs = set() - for index, plug in enumerate(chain(api.GetInputs(), api.GetOutputs()), start=1): # we start at 1 because index 0 is the node itself - plug_name = plug.GetBaseName() - sources, __ = plug.GetConnectedSources() # (valid, invalid): we care only about valid sources (index 0) - color = plug_colors[type(plug)] if isinstance(plug, UsdShade.Output) or sources else background_color - label += table_row.format(port=plug_name, color=color, text=f'{plug_name}') + ports = [""] # port names for this node. Empty string is used to refer to the node itself (no port). + 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}') for source in sources: - _add_edges(_get_node_id(source.source.GetPrim()), source.sourceName, node_id, plug_name) + _add_edges(_get_node_id(source.source.GetPrim()), source.sourceName, node_id, port_name) traverse(source.source) - plugs[plug_name] = index - active_plugs.add(plug_name) # TODO: add only actual plugged properties, right now we're adding all of them + ports.append(port_name) label += '
>' - all_nodes[node_id] = dict(label=label, plugs=plugs, active_plugs=active_plugs) + all_nodes[node_id] = dict(label=label, ports=ports) traverse(connections_api) @@ -327,7 +320,12 @@ def _nx_graph_edge_filter(*, has_specs=None, ancestral=None, implicit=None, intr class _ConnectableAPIViewer(QtWidgets.QDialog): def __init__(self, parent=None, **kwargs): super().__init__(parent=parent, **kwargs) - self._graph_view = _graph._GraphViewer(parent=self) + if nx.__version__.startswith("2"): + # TODO: Remove this if-statement when Py-3.9 / networkx-2 support is dropped (starting Py-3.10) + # Use SVG when networkx-2 is in use, as there are fixes to pydot graph inspection which only exist in nx-3 + self._graph_view = _graph._GraphSVGViewer(parent=self) + else: + self._graph_view = _graph._GraphViewer(parent=self) vertical = QtWidgets.QSplitter(QtCore.Qt.Vertical) vertical.addWidget(self._graph_view) self.setFocusProxy(self._graph_view) diff --git a/grill/views/houdini.py b/grill/views/houdini.py index 5a24e2bf..01fae0ff 100644 --- a/grill/views/houdini.py +++ b/grill/views/houdini.py @@ -3,7 +3,6 @@ from functools import cache, lru_cache, partial from . import sheets as _sheets, description as _description, create as _create, stats as _stats, _core _description._PALETTE.set(0) # (0 == dark, 1 == light) -_core._ensure_dot() def _stage_on_widget(widget_creator, _cache=True): diff --git a/grill/views/maya.py b/grill/views/maya.py index d04ffbfa..57d9a1df 100644 --- a/grill/views/maya.py +++ b/grill/views/maya.py @@ -1,8 +1,12 @@ from functools import cache, partial from maya import cmds -from PySide2 import QtWidgets -from shiboken2 import wrapInstance +from ._qt import QtWidgets + +if cmds.about(qt=True).startswith("6"): + from shiboken6 import wrapInstance +else: + from shiboken2 import wrapInstance import ufe import mayaUsd @@ -11,7 +15,6 @@ from . import description as _description, sheets as _sheets, create as _create, _core, stats as _stats _description._PALETTE.set(0) # (0 == dark, 1 == light) -_core._ensure_dot() @cache diff --git a/grill/views/usdview.py b/grill/views/usdview.py index 2c1e6774..564b32e3 100644 --- a/grill/views/usdview.py +++ b/grill/views/usdview.py @@ -198,20 +198,33 @@ class SelectedHierarchyTextMenuItem(AllHierarchyTextMenuItem): _subtitle = "Selection Only" -class GrillAttributeEditorMenuItem(attributeViewContextMenu.AttributeViewContextMenuItem): +class _GrillAttributeViewContextMenuItem(attributeViewContextMenu.AttributeViewContextMenuItem): + """A prim context menu item class that allows special Grill behavior like being added to submenus.""" + _items = [] + + def __init_subclass__(cls, **kwargs): + # _GetContextMenuItems(item, dataModel) signature is inverse than AttributeViewContextMenuItem(dataModel, item) + _GrillAttributeViewContextMenuItem._items.append(lambda *args: cls(*reversed(args))) + @property def _attributes(self): return [i for i in self._dataModel.selection.getProps() if isinstance(i, Usd.Attribute)] def ShouldDisplay(self): - return ( - self._role == attributeViewContextMenu.PropertyViewDataRoles.ATTRIBUTE and - attr.GetMetadata('allowedTokens') if len(self._attributes) == 1 and (attr := self._attributes[0]).GetTypeName() == Sdf.ValueTypeNames.Token else True - ) + return self._role == attributeViewContextMenu.PropertyViewDataRoles.ATTRIBUTE def IsEnabled(self): return self._item and self._attributes + +class GrillAttributeEditorMenuItem(_GrillAttributeViewContextMenuItem): + + def ShouldDisplay(self): + return ( + super().ShouldDisplay() and + attr.GetMetadata('allowedTokens') if len(self._attributes) == 1 and (attr := self._attributes[0]).GetTypeName() == Sdf.ValueTypeNames.Token else True + ) + def GetText(self): if (selected := len(self._attributes)) == 1 and self._attributes[0].GetTypeName() in {Sdf.ValueTypeNames.Bool, Sdf.ValueTypeNames.Token}: return "Set Value|..." @@ -233,6 +246,48 @@ def _GetSubCommands(self): tokens = attribute.GetMetadata('allowedTokens') return [(value, partial(attribute.Set, value)) for value in tokens] + +class GrillAttributeClearMenuItem(_GrillAttributeViewContextMenuItem): + + def GetText(self): + return f"Clear Value{'s' if len(self._attributes) > 1 else ''}" + + def RunCommand(self): + with Sdf.ChangeBlock(): + for attribute in self._attributes: + assert attribute.Clear() + + def IsEnabled(self): + # Usd.Attribute.Clear operates only on specs with an existing authored default value at the current edit target + try: + # USD>=24.11 + spec_getter = Usd.EditTarget.GetAttributeSpecForScenePath + except AttributeError: + # USD<24.11 + # Usd.EditTarget.GetSpecForScenePath did not work on earlier usd versions. It was fixed in 24.11. + def spec_getter(edit_target, path): + return edit_target.GetLayer().GetAttributeAtPath(edit_target.MapToSpecPath(path)) + + return super().IsEnabled() and any( + (spec := spec_getter(attr.GetStage().GetEditTarget(), attr.GetPath())) and spec.default + for attr in self._attributes + ) + + +class GrillAttributeBlockMenuItem(_GrillAttributeViewContextMenuItem): + + def GetText(self): + return f"Block Value{'s' if len(self._attributes) > 1 else ''}" + + def RunCommand(self): + with Sdf.ChangeBlock(): + for attribute in self._attributes: + attribute.Block() + + def IsEnabled(self): + return super().IsEnabled() and any(attr.HasAuthoredValue() for attr in self._attributes) + + class _ValueEditor(QtWidgets.QDialog): def __init__(self, *args, **kwargs): super().__init__(*args, **kwargs) @@ -346,8 +401,7 @@ def _extend_menu(_extender, original, *args): for module, member_name, extender in ( (primContextMenuItems, "_GetContextMenuItems", _GrillPrimContextMenuItem._items), (layerStackContextMenu, "_GetContextMenuItems", (GrillContentBrowserLayerMenuItem,)), - # _GetContextMenuItems(item, dataModel) signature is inverse than GrillAttributeEditorMenuItem(dataModel, item) - (attributeViewContextMenu, "_GetContextMenuItems", (lambda *args: GrillAttributeEditorMenuItem(*reversed(args)),)) + (attributeViewContextMenu, "_GetContextMenuItems", _GrillAttributeViewContextMenuItem._items), ): setattr(module, member_name, partial(_extend_menu, extender, getattr(module, member_name))) diff --git a/setup.cfg b/setup.cfg index b6ea1556..a092ba7c 100644 --- a/setup.cfg +++ b/setup.cfg @@ -1,6 +1,6 @@ [metadata] name = grill -version = 0.17.1 +version = 0.18.0 description = Pipeline tools for (but not limited to) audiovisual projects. long_description = file: README.md long_description_content_type = text/markdown @@ -13,10 +13,16 @@ classifiers = Programming Language :: Python :: 3.10 Programming Language :: Python :: 3.11 Programming Language :: Python :: 3.12 + Programming Language :: Python :: 3.13 [options] -install_requires = grill-names>=2.6.0; networkx; numpy; printree -# pygraphviz has trouble on some environments, so I've removed it from the current requires and moved to "full" until https://github.com/pygraphviz/pygraphviz/issues/167 is resolved. In the meantime, installation will be clarified in the docs. +install_requires = + grill-names>=2.6.0 + printree + numpy + pydot>=3.0.1 + networkx>=3.4; python_version > "3.9" + networkx<=2.8.3; python_version <= "3.9" include_package_data = True packages = find_namespace: @@ -25,23 +31,25 @@ include = grill.* [options.extras_require] # USD build: -# conda create -n py312usd2408build python=3.12 -# conda activate py312usd2408build +# conda create -n py313usd2411build python=3.13 +# conda activate py313usd2411build # conda install -c conda-forge cmake=3.27 # python -m pip install PySide6 PyOpenGL jinja2 # conda install -c rdonnelly vs2019_win-64 -# python "A:\write\code\git\OpenUSD\build_scripts\build_usd.py" -v "A:\write\builds\py312usd2408build" +# python "A:\write\code\git\OpenUSD\build_scripts\build_usd.py" -v "A:\write\builds\py313usd2411build" # # --- dev env ---: -# conda create -n py312usd2408 python=3.12 -# conda activate py312usd2408 +# conda create -n py313usd2411 python=3.13 +# conda activate py313usd2411 # runtime dependencies: -# conda install --channel conda-forge pygraphviz -# python -m pip install grill-names>=2.6.0 networkx numpy printree PyOpenGL pyside6 +# conda install conda-forge::graphviz +# python -m pip install grill-names>=2.6.0 networkx>=3.4 pydot>=3.0.1 numpy printree PyOpenGL pyside6 # docs dependencies: -# python -m pip install sphinx myst-parser sphinx-toggleprompt sphinx-copybutton sphinx-togglebutton sphinx-hoverxref sphinx_autodoc_typehints sphinx-inline-tabs shibuya +# python -m pip install sphinx myst-parser sphinx-toggleprompt sphinx-copybutton sphinx-togglebutton sphinx-hoverxref>=1.4.1 sphinx_autodoc_typehints sphinx-inline-tabs shibuya # For EDGEDB (coming up) # python -m pip install edgedb +# To install packages in editable mode, cd to desired package repo, then: +# python -m pip install -e . docs = sphinx; myst-parser; sphinx-toggleprompt; sphinx-copybutton; sphinx-togglebutton; sphinx-hoverxref>=1.4.1; sphinx_autodoc_typehints; sphinx-inline-tabs; shibuya; usd-core -full = PySide6; usd-core; PyOpenGL; pygraphviz +full = PySide6; usd-core; PyOpenGL diff --git a/tests/mini_test_bed/Catalogue-world-test.1.usda b/tests/mini_test_bed/Catalogue-world-test.1.usda new file mode 100644 index 00000000..f14fff48 --- /dev/null +++ b/tests/mini_test_bed/Catalogue-world-test.1.usda @@ -0,0 +1,73 @@ +#usda 1.0 + +def "Catalogue" ( + kind = "group" +) +{ + def "Shade" ( + kind = "group" + ) + { + def "Color" ( + kind = "group" + ) + { + over "ModelDefault" ( + prepend references = @Shade-Color-ModelDefault.1.usda@ + ) + { + } + } + } + + def "Model" ( + kind = "group" + ) + { + def "Elements" ( + kind = "group" + ) + { + over "Apartment" ( + prepend references = @Model-Elements-Apartment.1.usda@ + ) + { + } + } + + def "Buildings" ( + kind = "group" + ) + { + over "Multi_Story_Building" ( + prepend references = @Model-Buildings-Multi_Story_Building.1.usda@ + ) + { + } + } + + def "Blocks" ( + kind = "group" + ) + { + over "Block_With_Inherited_Windows" ( + prepend references = @Model-Blocks-Block_With_Inherited_Windows.1.usda@ + ) + { + } + + over "Block_With_Specialized_Windows" ( + prepend references = @Model-Blocks-Block_With_Specialized_Windows.1.usda@ + ) + { + } + + over "Block" ( + prepend references = @Model-Blocks-Block.1.usda@ + ) + { + } + } + } +} + diff --git a/tests/mini_test_bed/Geom-Elements-Apartment.1.usda b/tests/mini_test_bed/Geom-Elements-Apartment.1.usda new file mode 100644 index 00000000..9c989ca2 --- /dev/null +++ b/tests/mini_test_bed/Geom-Elements-Apartment.1.usda @@ -0,0 +1,27 @@ +#usda 1.0 +( + defaultPrim = "Origin" +) + +def "Origin" +{ + def Mesh "Floor" + { + uniform bool doubleSided = 1 + int[] faceVertexCounts = [4, 4, 4, 4, 4, 4, 4, 4, 4, 4, 4, 4, 4, 4, 4, 4, 4, 4, 4, 4, 4, 4, 4, 4, 4, 4, 4, 4, 4, 4, 4, 4, 4, 4, 4, 4, 4, 4, 4, 4, 4, 4, 4, 4, 4, 4, 4, 4, 4] + int[] faceVertexIndices = [0, 1, 9, 8, 1, 2, 10, 9, 2, 3, 11, 10, 3, 4, 12, 11, 4, 5, 13, 12, 5, 6, 14, 13, 6, 7, 15, 14, 8, 9, 17, 16, 9, 10, 18, 17, 10, 11, 19, 18, 11, 12, 20, 19, 12, 13, 21, 20, 13, 14, 22, 21, 14, 15, 23, 22, 16, 17, 25, 24, 17, 18, 26, 25, 18, 19, 27, 26, 19, 20, 28, 27, 20, 21, 29, 28, 21, 22, 30, 29, 22, 23, 31, 30, 24, 25, 33, 32, 25, 26, 34, 33, 26, 27, 35, 34, 27, 28, 36, 35, 28, 29, 37, 36, 29, 30, 38, 37, 30, 31, 39, 38, 32, 33, 41, 40, 33, 34, 42, 41, 34, 35, 43, 42, 35, 36, 44, 43, 36, 37, 45, 44, 37, 38, 46, 45, 38, 39, 47, 46, 40, 41, 49, 48, 41, 42, 50, 49, 42, 43, 51, 50, 43, 44, 52, 51, 44, 45, 53, 52, 45, 46, 54, 53, 46, 47, 55, 54, 48, 49, 57, 56, 49, 50, 58, 57, 50, 51, 59, 58, 51, 52, 60, 59, 52, 53, 61, 60, 53, 54, 62, 61, 54, 55, 63, 62] + point3f[] points = [(-4, 0, 4), (-2.857143, 0, 4), (-1.7142857, 0, 4), (-0.5714286, 0, 4), (0.5714286, 0, 4), (1.7142857, 0, 4), (2.857143, 0, 4), (4, 0, 4), (-4, 0, 2.857143), (-2.857143, 0, 2.857143), (-1.7142857, 0, 2.857143), (-0.5714286, 0, 2.857143), (0.5714286, 0, 2.857143), (1.7142857, 0, 2.857143), (2.857143, 0, 2.857143), (4, 0, 2.857143), (-4, 0, 1.7142857), (-2.857143, 0, 1.7142857), (-1.7142857, 0, 1.7142857), (-0.5714286, 0, 1.7142857), (0.5714286, 0, 1.7142857), (1.7142857, 0, 1.7142857), (2.857143, 0, 1.7142857), (4, 0, 1.7142857), (-4, 0, 0.5714286), (-2.857143, 0, 0.5714286), (-1.7142857, 0, 0.5714286), (-0.5714286, 0, 0.5714286), (0.5714286, 0, 0.5714286), (1.7142857, 0, 0.5714286), (2.857143, 0, 0.5714286), (4, 0, 0.5714286), (-4, 0, -0.5714286), (-2.857143, 0, -0.5714286), (-1.7142857, 0, -0.5714286), (-0.5714286, 0, -0.5714286), (0.5714286, 0, -0.5714286), (1.7142857, 0, -0.5714286), (2.857143, 0, -0.5714286), (4, 0, -0.5714286), (-4, 0, -1.7142857), (-2.857143, 0, -1.7142857), (-1.7142857, 0, -1.7142857), (-0.5714286, 0, -1.7142857), (0.5714286, 0, -1.7142857), (1.7142857, 0, -1.7142857), (2.857143, 0, -1.7142857), (4, 0, -1.7142857), (-4, 0, -2.857143), (-2.857143, 0, -2.857143), (-1.7142857, 0, -2.857143), (-0.5714286, 0, -2.857143), (0.5714286, 0, -2.857143), (1.7142857, 0, -2.857143), (2.857143, 0, -2.857143), (4, 0, -2.857143), (-4, 0, -4), (-2.857143, 0, -4), (-1.7142857, 0, -4), (-0.5714286, 0, -4), (0.5714286, 0, -4), (1.7142857, 0, -4), (2.857143, 0, -4), (4, 0, -4)] + } + + def Sphere "Volume" + { + double radius = 2 + float3 xformOp:rotateXYZ.timeSamples = { + 0: (12, 0, 0), + 192: (12, 0, 1440), + } + double3 xformOp:translate = (0, 2, 0) + uniform token[] xformOpOrder = ["xformOp:translate", "xformOp:rotateXYZ"] + } +} + diff --git a/tests/mini_test_bed/Model-Blocks-Block.1.usda b/tests/mini_test_bed/Model-Blocks-Block.1.usda new file mode 100644 index 00000000..d4cbe210 --- /dev/null +++ b/tests/mini_test_bed/Model-Blocks-Block.1.usda @@ -0,0 +1,51 @@ +#usda 1.0 +( + defaultPrim = "Origin" +) + +def "Origin" ( + prepend apiSchemas = ["GeomModelAPI"] + assetInfo = { + asset identifier = @Model-Blocks-Block.1.usda@ + string name = "Block" + } + displayName = "Block" + prepend inherits = + kind = "assembly" + prepend references = @main-Taxonomy-test.1.usda@ + prepend specializes = +) +{ + def "Building1" ( + prepend references = @Model-Buildings-Multi_Story_Building.1.usda@ + ) + { + double3 xformOp:translate = (-54, 126, 1) + uniform token[] xformOpOrder = ["xformOp:translate"] + } + + def "Building2" ( + prepend references = @Model-Buildings-Multi_Story_Building.1.usda@ + ) + { + double3 xformOp:translate = (54, 126, 1) + uniform token[] xformOpOrder = ["xformOp:translate"] + } + + def "Building3" ( + prepend references = @Model-Buildings-Multi_Story_Building.1.usda@ + ) + { + double3 xformOp:translate = (-54, -126, 1) + uniform token[] xformOpOrder = ["xformOp:translate"] + } + + def "Building4" ( + prepend references = @Model-Buildings-Multi_Story_Building.1.usda@ + ) + { + double3 xformOp:translate = (54, -126, 1) + uniform token[] xformOpOrder = ["xformOp:translate"] + } +} + diff --git a/tests/mini_test_bed/Model-Blocks-Block_With_Inherited_Windows.1.usda b/tests/mini_test_bed/Model-Blocks-Block_With_Inherited_Windows.1.usda new file mode 100644 index 00000000..37c500ad --- /dev/null +++ b/tests/mini_test_bed/Model-Blocks-Block_With_Inherited_Windows.1.usda @@ -0,0 +1,68 @@ +#usda 1.0 +( + defaultPrim = "Origin" +) + +def "Origin" ( + prepend apiSchemas = ["GeomModelAPI"] + assetInfo = { + asset identifier = @Model-Blocks-Block_With_Inherited_Windows.1.usda@ + string name = "Block_With_Inherited_Windows" + } + displayName = "Block_With_Inherited_Windows" + prepend inherits = + kind = "assembly" + prepend references = @main-Taxonomy-test.1.usda@ + prepend specializes = +) +{ + def "Building1" ( + prepend references = @Model-Buildings-Multi_Story_Building.1.usda@ + ) + { + double3 xformOp:translate = (-18, 42, 1) + uniform token[] xformOpOrder = ["xformOp:translate"] + } + + def "Building2" ( + prepend references = @Model-Buildings-Multi_Story_Building.1.usda@ + ) + { + double3 xformOp:translate = (18, 42, 1) + uniform token[] xformOpOrder = ["xformOp:translate"] + } + + def "Building3" ( + prepend references = @Model-Buildings-Multi_Story_Building.1.usda@ + ) + { + double3 xformOp:translate = (-18, -42, 1) + uniform token[] xformOpOrder = ["xformOp:translate"] + } + + def "Building4" ( + prepend references = @Model-Buildings-Multi_Story_Building.1.usda@ + ) + { + double3 xformOp:translate = (18, -42, 1) + uniform token[] xformOpOrder = ["xformOp:translate"] + } +} + +over "Inherited" +{ + over "Model" + { + over "Elements" + { + over "Apartment" ( + variants = { + string color = "blue" + } + ) + { + } + } + } +} + diff --git a/tests/mini_test_bed/Model-Blocks-Block_With_Specialized_Windows.1.usda b/tests/mini_test_bed/Model-Blocks-Block_With_Specialized_Windows.1.usda new file mode 100644 index 00000000..1647e1d2 --- /dev/null +++ b/tests/mini_test_bed/Model-Blocks-Block_With_Specialized_Windows.1.usda @@ -0,0 +1,68 @@ +#usda 1.0 +( + defaultPrim = "Origin" +) + +def "Origin" ( + prepend apiSchemas = ["GeomModelAPI"] + assetInfo = { + asset identifier = @Model-Blocks-Block_With_Specialized_Windows.1.usda@ + string name = "Block_With_Specialized_Windows" + } + displayName = "Block_With_Specialized_Windows" + prepend inherits = + kind = "assembly" + prepend references = @main-Taxonomy-test.1.usda@ + prepend specializes = +) +{ + def "Building1" ( + prepend references = @Model-Buildings-Multi_Story_Building.1.usda@ + ) + { + double3 xformOp:translate = (-36, 84, 1) + uniform token[] xformOpOrder = ["xformOp:translate"] + } + + def "Building2" ( + prepend references = @Model-Buildings-Multi_Story_Building.1.usda@ + ) + { + double3 xformOp:translate = (36, 84, 1) + uniform token[] xformOpOrder = ["xformOp:translate"] + } + + def "Building3" ( + prepend references = @Model-Buildings-Multi_Story_Building.1.usda@ + ) + { + double3 xformOp:translate = (-36, -84, 1) + uniform token[] xformOpOrder = ["xformOp:translate"] + } + + def "Building4" ( + prepend references = @Model-Buildings-Multi_Story_Building.1.usda@ + ) + { + double3 xformOp:translate = (36, -84, 1) + uniform token[] xformOpOrder = ["xformOp:translate"] + } +} + +over "Specialized" +{ + over "Model" + { + over "Elements" + { + over "Apartment" ( + variants = { + string color = "red" + } + ) + { + } + } + } +} + diff --git a/tests/mini_test_bed/Model-Buildings-Multi_Story_Building.1.usda b/tests/mini_test_bed/Model-Buildings-Multi_Story_Building.1.usda new file mode 100644 index 00000000..eb20c429 --- /dev/null +++ b/tests/mini_test_bed/Model-Buildings-Multi_Story_Building.1.usda @@ -0,0 +1,81 @@ +#usda 1.0 +( + defaultPrim = "Origin" +) + +def "Origin" ( + prepend apiSchemas = ["GeomModelAPI"] + assetInfo = { + asset identifier = @Model-Buildings-Multi_Story_Building.1.usda@ + string name = "Multi_Story_Building" + } + displayName = "Multi_Story_Building" + prepend inherits = + kind = "assembly" + prepend references = @main-Taxonomy-test.1.usda@ + prepend specializes = +) +{ + def PointInstancer "Windows" ( + kind = "group" + ) + { + point3f[] positions = [(0, 0, 0), (0, 0, 8), (0, 0, 16), (8, 0, 0), (8, 0, 8), (8, 0, 16), (16, 0, 0), (16, 0, 8), (16, 0, 16), (0, 10.5, 0), (0, 10.5, 8), (0, 10.5, 16), (8, 10.5, 0), (8, 10.5, 8), (8, 10.5, 16), (16, 10.5, 0), (16, 10.5, 8), (16, 10.5, 16), (0, 21, 0), (0, 21, 8), (0, 21, 16), (8, 21, 0), (8, 21, 8), (8, 21, 16), (16, 21, 0), (16, 21, 8), (16, 21, 16)] + int[] protoIndices = [4, 2, 3, 4, 1, 4, 3, 2, 4, 4, 2, 3, 4, 2, 2, 1, 2, 4, 4, 0, 4, 2, 4, 1, 3, 3, 1] + prepend rel prototypes = [ + , + , + , + , + , + ] + + def "Apartment" ( + instanceable = true + prepend references = @Model-Elements-Apartment.1.usda@ + ) + { + } + + def "Apartment_blue" ( + instanceable = true + prepend references = @Model-Elements-Apartment.1.usda@ + variants = { + string color = "blue" + } + ) + { + } + + def "Apartment_constant" ( + instanceable = true + prepend references = @Model-Elements-Apartment.1.usda@ + variants = { + string color = "constant" + } + ) + { + } + + def "Apartment_red" ( + instanceable = true + prepend references = @Model-Elements-Apartment.1.usda@ + variants = { + string color = "red" + } + ) + { + } + + def "Apartment_spectrum" ( + instanceable = true + prepend references = @Model-Elements-Apartment.1.usda@ + variants = { + string color = "spectrum" + } + ) + { + } + } +} + diff --git a/tests/mini_test_bed/Model-Elements-Apartment.1.usda b/tests/mini_test_bed/Model-Elements-Apartment.1.usda new file mode 100644 index 00000000..5f0ddff6 --- /dev/null +++ b/tests/mini_test_bed/Model-Elements-Apartment.1.usda @@ -0,0 +1,114 @@ +#usda 1.0 +( + defaultPrim = "Origin" +) + +def "Origin" ( + prepend apiSchemas = ["GeomModelAPI"] + assetInfo = { + asset identifier = @Model-Elements-Apartment.1.usda@ + string name = "Apartment" + } + displayName = "Apartment" + prepend inherits = + kind = "component" + prepend references = @main-Taxonomy-test.1.usda@ + prepend specializes = + prepend variantSets = "color" +) +{ + def "Geom" ( + displayName = "Geom" + prepend payload = @Geom-Elements-Apartment.1.usda@ + prepend references = @Shade-Color-ModelDefault.1.usda@ + ) + { + } + variantSet "color" = { + "blue" { + over "Geom" + { + over "Floor" + { + color3f[] primvars:displayColor = [(0, 0, 1)] ( + elementSize = 1 + interpolation = "constant" + ) + } + + over "Volume" + { + color3f[] primvars:displayColor = [(0, 0, 1)] ( + elementSize = 1 + interpolation = "constant" + ) + } + } + + } + "constant" { + over "Geom" + { + over "Floor" + { + color3f[] primvars:displayColor = [(0.12633544, 0.5881332, 0.28553134)] ( + elementSize = 1 + interpolation = "constant" + ) + } + + over "Volume" + { + color3f[] primvars:displayColor = [(0.34147033, 0.09201872, 0.5665109)] ( + elementSize = 1 + interpolation = "constant" + ) + } + } + + } + "red" { + over "Geom" + { + over "Floor" + { + color3f[] primvars:displayColor = [(1, 0, 0)] ( + elementSize = 1 + interpolation = "constant" + ) + } + + over "Volume" + { + color3f[] primvars:displayColor = [(1, 0, 0)] ( + elementSize = 1 + interpolation = "constant" + ) + } + } + + } + "spectrum" { + over "Geom" + { + over "Floor" + { + color3f[] primvars:displayColor = [(1, 0, 0), (1, 0.0952381, 0), (1, 0.1904762, 0), (1, 0.2857143, 0), (1, 0.3809524, 0), (1, 0.47619048, 0), (1, 0.5714286, 0), (1, 0.6666667, 0), (1, 0.7619048, 0), (1, 0.85714287, 0), (1, 0.95238096, 0), (0.95238096, 1, 0), (0.85714287, 1, 0), (0.7619048, 1, 0), (0.6666667, 1, 0), (0.5714286, 1, 0), (0.47619048, 1, 0), (0.3809524, 1, 0), (0.2857143, 1, 0), (0.1904762, 1, 0), (0.0952381, 1, 0), (0, 1, 0), (0, 1, 0.0952381), (0, 1, 0.1904762), (0, 1, 0.2857143), (0, 1, 0.3809524), (0, 1, 0.47619048), (0, 1, 0.5714286), (0, 1, 0.6666667), (0, 1, 0.7619048), (0, 1, 0.85714287), (0, 1, 0.95238096), (0, 0.95238096, 1), (0, 0.85714287, 1), (0, 0.7619048, 1), (0, 0.6666667, 1), (0, 0.5714286, 1), (0, 0.47619048, 1), (0, 0.3809524, 1), (0, 0.2857143, 1), (0, 0.1904762, 1), (0, 0.0952381, 1), (0, 0, 1), (0.0952381, 0, 1), (0.1904762, 0, 1), (0.2857143, 0, 1), (0.3809524, 0, 1), (0.47619048, 0, 1), (0.5714286, 0, 1), (0.6666667, 0, 1), (0.7619048, 0, 1), (0.85714287, 0, 1), (0.95238096, 0, 1), (1, 0, 0.95238096), (1, 0, 0.85714287), (1, 0, 0.7619048), (1, 0, 0.6666667), (1, 0, 0.5714286), (1, 0, 0.47619048), (1, 0, 0.3809524), (1, 0, 0.2857143), (1, 0, 0.1904762), (1, 0, 0.0952381), (1, 0, 0)] ( + elementSize = 64 + interpolation = "vertex" + ) + } + + over "Volume" + { + color3f[] primvars:displayColor = [(1, 0, 0), (1, 0.06593407, 0), (1, 0.13186814, 0), (1, 0.1978022, 0), (1, 0.26373628, 0), (1, 0.32967034, 0), (1, 0.3956044, 0), (1, 0.46153846, 0), (1, 0.52747256, 0), (1, 0.5934066, 0), (1, 0.6593407, 0), (1, 0.72527474, 0), (1, 0.7912088, 0), (1, 0.85714287, 0), (1, 0.9230769, 0), (1, 0.989011, 0), (0.94505495, 1, 0), (0.8791209, 1, 0), (0.8131868, 1, 0), (0.74725276, 1, 0), (0.6813187, 1, 0), (0.61538464, 1, 0), (0.5494506, 1, 0), (0.48351648, 1, 0), (0.41758242, 1, 0), (0.35164836, 1, 0), (0.2857143, 1, 0), (0.21978022, 1, 0), (0.15384616, 1, 0), (0.08791209, 1, 0), (0.021978023, 1, 0), (0, 1, 0.043956045), (0, 1, 0.10989011), (0, 1, 0.17582418), (0, 1, 0.24175824), (0, 1, 0.30769232), (0, 1, 0.37362638), (0, 1, 0.43956044), (0, 1, 0.50549453), (0, 1, 0.5714286), (0, 1, 0.63736266), (0, 1, 0.7032967), (0, 1, 0.7692308), (0, 1, 0.83516484), (0, 1, 0.9010989), (0, 1, 0.96703297), (0, 0.96703297, 1), (0, 0.9010989, 1), (0, 0.83516484, 1), (0, 0.7692308, 1), (0, 0.7032967, 1), (0, 0.63736266, 1), (0, 0.5714286, 1), (0, 0.50549453, 1), (0, 0.43956044, 1), (0, 0.37362638, 1), (0, 0.30769232, 1), (0, 0.24175824, 1), (0, 0.17582418, 1), (0, 0.10989011, 1), (0, 0.043956045, 1), (0.021978023, 0, 1), (0.08791209, 0, 1), (0.15384616, 0, 1), (0.21978022, 0, 1), (0.2857143, 0, 1), (0.35164836, 0, 1), (0.41758242, 0, 1), (0.48351648, 0, 1), (0.5494506, 0, 1), (0.61538464, 0, 1), (0.6813187, 0, 1), (0.74725276, 0, 1), (0.8131868, 0, 1), (0.8791209, 0, 1), (0.94505495, 0, 1), (1, 0, 0.989011), (1, 0, 0.9230769), (1, 0, 0.85714287), (1, 0, 0.7912088), (1, 0, 0.72527474), (1, 0, 0.6593407), (1, 0, 0.5934066), (1, 0, 0.52747256), (1, 0, 0.46153846), (1, 0, 0.3956044), (1, 0, 0.32967034), (1, 0, 0.26373628), (1, 0, 0.1978022), (1, 0, 0.13186814), (1, 0, 0.06593407), (1, 0, 0)] ( + elementSize = 92 + interpolation = "vertex" + ) + } + } + + } + } +} + diff --git a/tests/mini_test_bed/Shade-Color-ModelDefault.1.usda b/tests/mini_test_bed/Shade-Color-ModelDefault.1.usda new file mode 100644 index 00000000..5b2e2c89 --- /dev/null +++ b/tests/mini_test_bed/Shade-Color-ModelDefault.1.usda @@ -0,0 +1,21 @@ +#usda 1.0 +( + defaultPrim = "Origin" +) + +def "Origin" ( + prepend apiSchemas = ["GeomModelAPI"] + assetInfo = { + asset identifier = @Shade-Color-ModelDefault.1.usda@ + string name = "ModelDefault" + } + displayName = "🎨 Model Default" + prepend inherits = + kind = "subcomponent" + prepend references = @main-Taxonomy-test.1.usda@ + prepend specializes = +) +{ + color3f[] primvars:displayColor = [(0.6, 0.8, 0.9)] +} + diff --git a/tests/mini_test_bed/main-Taxonomy-test.1.usda b/tests/mini_test_bed/main-Taxonomy-test.1.usda new file mode 100644 index 00000000..0712e202 --- /dev/null +++ b/tests/mini_test_bed/main-Taxonomy-test.1.usda @@ -0,0 +1,76 @@ +#usda 1.0 +( + defaultPrim = "Taxonomy" +) + +class "Taxonomy" +{ + def "Color" ( + assetInfo = { + dictionary grill = { + dictionary fields = { + string cluster = "Color" + string kingdom = "Shade" + } + dictionary taxa = { + int Color = 0 + } + } + } + prepend inherits = + ) + { + } + + def Xform "Elements" ( + assetInfo = { + dictionary grill = { + dictionary fields = { + string cluster = "Elements" + string kingdom = "Model" + } + dictionary taxa = { + int Elements = 0 + } + } + } + prepend inherits = + ) + { + } + + def "Buildings" ( + assetInfo = { + dictionary grill = { + dictionary fields = { + string cluster = "Buildings" + } + dictionary taxa = { + int Buildings = 0 + } + } + } + prepend inherits = + prepend references = + ) + { + } + + def "Blocks" ( + assetInfo = { + dictionary grill = { + dictionary fields = { + string cluster = "Blocks" + } + dictionary taxa = { + int Blocks = 0 + } + } + } + prepend inherits = + prepend references = + ) + { + } +} + diff --git a/tests/mini_test_bed/main-world-test.1.usda b/tests/mini_test_bed/main-world-test.1.usda new file mode 100644 index 00000000..469c04d0 --- /dev/null +++ b/tests/mini_test_bed/main-world-test.1.usda @@ -0,0 +1,8 @@ +#usda 1.0 +( + subLayers = [ + @Catalogue-world-test.1.usda@, + @main-Taxonomy-test.1.usda@ + ] +) + diff --git a/tests/test_cook.py b/tests/test_cook.py index 78654870..6e0e3e28 100644 --- a/tests/test_cook.py +++ b/tests/test_cook.py @@ -5,31 +5,25 @@ from pathlib import Path -from pxr import Usd, UsdGeom, Sdf, Ar, UsdUtils +from pxr import Usd, UsdGeom, Sdf, Ar, Tf -from grill import cook, names, usd as gusd, tokens +from grill import cook, names, tokens logger = logging.getLogger(__name__) -# 2024-02-03 - Python-3.12 & USD-23.11 +# 2024-11-09 - Python-3.13 & USD-24.11 # python -m unittest --durations 0 test_cook # Slowest test durations # ---------------------------------------------------------------------- -# 0.058s test_define_taxon (test_cook.TestCook.test_define_taxon) -# 0.056s test_inherited_and_specialized_contexts (test_cook.TestCook.test_inherited_and_specialized_contexts) -# 0.050s test_create_on_previous_stage (test_cook.TestCook.test_create_on_previous_stage) -# 0.047s test_asset_unit (test_cook.TestCook.test_asset_unit) -# 0.034s test_spawn_unit (test_cook.TestCook.test_spawn_unit) -# 0.034s test_spawn_unit_with_absolute_paths (test_cook.TestCook.test_spawn_unit_with_absolute_paths) -# 0.033s test_create_many (test_cook.TestCook.test_create_many) -# 0.032s test_spawn_many (test_cook.TestCook.test_spawn_many) -# 0.023s test_fetch_stage (test_cook.TestCook.test_fetch_stage) -# 0.007s test_edit_context (test_cook.TestCook.test_edit_context) -# 0.006s test_match (test_cook.TestCook.test_match) -# 0.001s test_spawn_many_invalid (test_cook.TestCook.test_spawn_many_invalid) +# 0.044s test_inherited_and_specialized_contexts (test_cook.TestCook.test_inherited_and_specialized_contexts) +# 0.036s test_asset_unit (test_cook.TestCook.test_asset_unit) +# 0.024s test_spawn_many (test_cook.TestCook.test_spawn_many) +# 0.024s test_create_many_in_memory (test_cook.TestCook.test_create_many_in_memory) +# 0.014s test_taxonomy (test_cook.TestCook.test_taxonomy) +# 0.010s test_fetch_stage (test_cook.TestCook.test_fetch_stage) # # ---------------------------------------------------------------------- -# Ran 12 tests in 0.385s +# Ran 6 tests in 0.152s class TestCook(unittest.TestCase): @@ -44,34 +38,30 @@ def tearDown(self) -> None: def test_fetch_stage(self): root_asset = self.root_asset - - # TODO: cleanup this test. fetch_stage used to keep stages in a cache but not anymore. root_stage = cook.fetch_stage(root_asset) + # fetching stage outside of AR _context should resolve to same stage - self.assertEqual(root_stage.GetRootLayer().identifier, root_asset.name) + self.assertEqual(cook.asset_identifier(root_stage.GetRootLayer().identifier), root_asset.name) repo_path = cook.Repository.get() resolver_ctx = Ar.DefaultResolverContext([str(repo_path)]) + + # confirm that a stage from a layer created separately is fetched with the correct resolver context + usd_opened = str(names.UsdAsset.get_anonymous(item='usd_opened')) + Sdf.Layer.CreateNew(str(repo_path / usd_opened)) + + with self.assertRaisesRegex(Tf.ErrorException, "Failed to open layer"): + # no resolver context, so unable to open stage + Usd.Stage.Open(usd_opened) + with Ar.ResolverContextBinder(resolver_ctx): - # inside an AR resolver _context, a new layer and custom stage should end up - # in that stage not resolving to the same as the one from write.fetch_stage - usd_opened = str(names.UsdAsset.get_anonymous(item='usd_opened')) - Sdf.Layer.CreateNew(str(repo_path / usd_opened)) - non_cache_stage = Usd.Stage.Open(usd_opened) - cached_stage = cook.fetch_stage(usd_opened) - self.assertIsNot(non_cache_stage, cached_stage) - # Even after fetching once, subsequent fetches should be different - self.assertIsNot(cached_stage, cook.fetch_stage(usd_opened)) - - # creating a new layer + stage + adding it to the cache manually - # should still have fetch_stage to retrieve a different stage. - sdf_opened = str(names.UsdAsset.get_default(item='sdf_opened')) - Sdf.Layer.CreateNew(str(repo_path / sdf_opened)) - cached_layer = Sdf.Layer.FindOrOpen(sdf_opened) - opened_stage = Usd.Stage.Open(cached_layer) - cache = UsdUtils.StageCache.Get() - cache.Insert(opened_stage) - self.assertIsNot(opened_stage, cook.fetch_stage(sdf_opened)) + opened_stage = Usd.Stage.Open(usd_opened) + + # when fetching the same asset identifier, the root layer should be the same + fetched_stage = cook.fetch_stage(usd_opened) + self.assertIs(opened_stage.GetRootLayer(), fetched_stage.GetRootLayer()) + # but the stages should be different + self.assertIsNot(opened_stage, fetched_stage) in_memory = Usd.Stage.CreateInMemory() from_memory = str(names.UsdAsset.get_anonymous(item='from_memory')) @@ -79,217 +69,149 @@ def test_fetch_stage(self): # a stage with an empty resolver that fetches a valid identifier should fail. cook.fetch_stage(from_memory, context=in_memory.GetPathResolverContext()) - unbound_resolver = str(names.UsdAsset.get_anonymous(item='unbound_resolver')) - with self.assertRaises(RuntimeError): - # directly fetching a new layer without a context with statement should fail - cook._fetch_layer(unbound_resolver, root_stage.GetPathResolverContext()) - - def test_match(self): - root_stage = cook.fetch_stage(self.root_asset) - with self.assertRaises(ValueError): - cook._find_layer_matching(dict(missing='tokens'), root_stage.GetLayerStack()) - - def test_edit_context(self): - with self.assertRaises(TypeError): - gusd.edit_context(object(), cook.fetch_stage(self.root_asset)) + def test_taxonomy(self): + stage = Usd.Stage.CreateInMemory() - def test_define_taxon(self): # An anonymous stage (non grill anonymous) should fail to define taxon. - anon_stage = Usd.Stage.CreateInMemory() - with self.assertRaises(ValueError): - cook.define_taxon(anon_stage, "ShouldFail") + with self.assertRaisesRegex(ValueError, "Could not find a valid pipeline layer"): + cook.define_taxon(stage, "ShouldFail") + # Same stage containing a grill anon layer on its stack should succeed. - anon_pipeline = cook.fetch_stage(names.UsdAsset.get_anonymous()) - anon_stage.GetRootLayer().subLayerPaths.append(anon_pipeline.GetRootLayer().realPath) - self.assertTrue(cook.define_taxon(anon_stage, "ShouldSucceed").IsValid()) + anon_pipeline = Sdf.Layer.CreateNew(str(cook.Repository.get() / names.UsdAsset.get_anonymous().name)) + stage.GetRootLayer().subLayerPaths.append(anon_pipeline.realPath) # Now, test stages fetched from the start via "common" pipeline calls. - root_stage = cook.fetch_stage(self.root_asset) - with self.assertRaisesRegex(ValueError, "reserved name"): - cook.define_taxon(root_stage, cook._TAXONOMY_NAME) + cook.define_taxon(stage, cook._TAXONOMY_NAME) + + with self.assertRaisesRegex(ValueError, "must be a valid identifier for a prim"): + cook.define_taxon(stage, "/InvalidName") with self.assertRaisesRegex(ValueError, "reserved id fields"): - cook.define_taxon(root_stage, "taxonomy_not_allowed", id_fields={cook._TAXONOMY_UNIQUE_ID: "by_id_value"}) + cook.define_taxon(stage, "taxonomy_not_allowed", id_fields={cook._TAXONOMY_UNIQUE_ID: "by_id_value"}) with self.assertRaisesRegex(ValueError, "reserved id fields"): - cook.define_taxon(root_stage, "taxonomy_not_allowed", id_fields={cook._TAXONOMY_UNIQUE_ID.name: "by_id_name"}) + cook.define_taxon(stage, "taxonomy_not_allowed", id_fields={cook._TAXONOMY_UNIQUE_ID.name: "by_id_name"}) with self.assertRaisesRegex(ValueError, "invalid id_field keys"): - cook.define_taxon(root_stage, "nonexistingfield", id_fields={str(uuid.uuid4()): "by_id_name"}) - - displayable = cook.define_taxon(root_stage, "DisplayableName") - # idempotent call should keep previously created prim - self.assertEqual(displayable, cook.define_taxon(root_stage, "DisplayableName")) - - person = cook.define_taxon(root_stage, "Person", references=(displayable,)) - - with cook.taxonomy_context(root_stage): - displayable.CreateAttribute("label", Sdf.ValueTypeNames.String) + cook.define_taxon(stage, "nonexistingfield", id_fields={str(uuid.uuid4()): "by_id_name"}) missing_or_empty_fields_msg = f"Missing or empty '{cook._FIELDS_KEY}'" - not_taxon = root_stage.DefinePrim("/not/a/taxon") + not_taxon = stage.DefinePrim("/not/a/taxon") with self.assertRaisesRegex(ValueError, missing_or_empty_fields_msg): - cook.create_unit(not_taxon, "WillFail") + cook.create_unit(not_taxon, "NoTaxon") not_taxon.SetAssetInfoByKey(cook._ASSETINFO_KEY, {}) with self.assertRaisesRegex(ValueError, missing_or_empty_fields_msg): - cook.create_unit(not_taxon, "WillFail") + cook.create_unit(not_taxon, "EmptyAssetInfo") not_taxon.SetAssetInfoByKey(cook._ASSETINFO_KEY, {'invalid': 42}) with self.assertRaisesRegex(ValueError, missing_or_empty_fields_msg): - cook.create_unit(not_taxon, "WillFail") + cook.create_unit(not_taxon, "InvalidAssetInfo") not_taxon.SetAssetInfoByKey(cook._ASSETINFO_KEY, {cook._FIELDS_KEY: 42}) with self.assertRaisesRegex(TypeError, f"Expected mapping on key '{cook._FIELDS_KEY}'"): - cook.create_unit(not_taxon, "WillFail") + cook.create_unit(not_taxon, "InvalidAssetInfo") not_taxon.SetAssetInfoByKey(cook._ASSETINFO_KEY, {cook._FIELDS_KEY: {}}) with self.assertRaisesRegex(ValueError, missing_or_empty_fields_msg): - cook.create_unit(not_taxon, "WillFail") - - emil = cook.create_unit(person, "EmilSinclair", label="Emil Sinclair") - self.assertEqual(emil, cook.create_unit(person, "EmilSinclair")) - - with cook.unit_context(emil): - emil.GetVariantSet("Transport").SetVariantSelection("HorseDrawnCarriage") - - hero = cook.define_taxon(root_stage, "Hero", references=(person,)) - batman = cook.create_unit(hero, "Batman") - expected_people = [emil, batman] # batman is also a person - expected_heroes = [batman] - stage_prims = root_stage.Traverse() - self.assertEqual(expected_people, list(cook.itaxa(stage_prims, person))) - self.assertEqual(expected_heroes, list(cook.itaxa(stage_prims, hero))) - - def test_create_on_previous_stage(self): - """Confirm that creating assets on a previously saved stage works. - - The default behavior from layer identifiers that are relative to the resolver search path is to be absolute - when a stage using them is re-opened, so: - original_identifier.usda - becomes - /absolute/path/original_identifier.usda - """ - root_asset = names.UsdAsset.get_anonymous() - root_stage = cook.fetch_stage(root_asset) - # creates taxonomy.usda and adds it to the stage layer stack - cook.define_taxon(root_stage, "FirstTaxon") - root_stage.Save() - del root_stage + cook.create_unit(not_taxon, "EmptyFields") - reopened_stage = cook.fetch_stage(root_asset) - # the taxonomy.usda now has as identifier /absolute/path/taxonomy.usda, so confirm we can use it still - cook.create_many(cook.define_taxon(reopened_stage, "SecondTaxon"), ["A", "B"]) + first = cook.define_taxon(stage, "first") + # idempotent call should keep previously created prim + self.assertEqual(first, cook.define_taxon(stage, "first")) - def test_asset_unit(self): - stage = cook.fetch_stage(self.root_asset) - taxon_name = "Person" - person = cook.define_taxon(stage, taxon_name) - unit_name = "EmilSinclair" - emil = cook.create_unit(person, unit_name, label="Emil Sinclair") - unit_asset = cook.unit_asset(emil) - unit_id = names.UsdAsset(unit_asset.identifier) - self.assertEqual(unit_name, getattr(unit_id, cook._UNIT_UNIQUE_ID.name)) - self.assertEqual(taxon_name, getattr(unit_id, cook._TAXONOMY_UNIQUE_ID.name)) + second = cook.define_taxon(stage, "second", references=(first,)) + self.assertTrue(second.IsValid()) - not_a_unit = stage.DefinePrim(emil.GetPath().AppendChild("not_a_unit")) - with self.assertRaisesRegex(ValueError, "Missing or empty"): - cook.unit_asset(not_a_unit) + third = cook.define_taxon(stage, "third", references=(first,)) - layer = Sdf.Layer.CreateAnonymous() - with self.assertRaisesRegex(ValueError, "Could not find appropriate node for edit target"): - gusd.edit_context(not_a_unit, Usd.PrimCompositionQuery.Filter(), lambda arc: arc.GetTargetNode().layerStack.identifier.rootLayer == layer) + found_taxa = set(cook.itaxa(stage)) + self.assertSetEqual(set(found_taxa), {first, second, third}) - # break the unit model API - Usd.ModelAPI(emil).SetAssetIdentifier("") - without_modelapi = cook.unit_asset(emil) - self.assertEqual(unit_asset, without_modelapi) # we should get the same result + with self.assertRaisesRegex(ValueError, "is not a taxon."): + cook.taxonomy_graph([first.GetParent()], "") - Usd.ModelAPI(emil).SetAssetName("not_emil") - with self.assertRaisesRegex(ValueError, "Could not find layer matching"): - cook.unit_asset(emil) + graph_from_stage = cook.taxonomy_graph(found_taxa, "") + first_successors = set(graph_from_stage.successors(first.GetName())) + self.assertEqual(first_successors, {second.GetName(), third.GetName()}) + self.assertEqual(set(cook.taxonomy_graph(stage, "").nodes), set(graph_from_stage.nodes)) - def test_create_many(self): + def test_asset_unit(self): stage = cook.fetch_stage(self.root_asset) - taxon = cook.define_taxon(stage, "Anon") - cook.create_many(taxon, ("first", "second")) + taxon_name = "taxon" + unit_name = "unit" + unit = cook.create_unit(cook.define_taxon(stage, "taxon"), "unit") + unit_asset = cook.unit_asset(unit) + unit_id = names.UsdAsset(cook.asset_identifier(unit_asset.identifier)) + self.assertEqual(unit_name, getattr(unit_id, cook._UNIT_UNIQUE_ID.name)) + self.assertEqual(taxon_name, getattr(unit_id, cook._TAXONOMY_UNIQUE_ID.name)) - def test_spawn_unit(self): - stage = cook.fetch_stage(self.root_asset) - id_fields = {tokens.ids.CGAsset.kingdom.name: "K"} - taxon = cook.define_taxon(stage, "Another", id_fields=id_fields) - parent, child = cook.create_many(taxon, ['A', 'B']) - with cook.unit_context(parent): - for path, value in ( - ("", (2, 15, 6)), - ("Deeper/Nested/Golden1", (-4, 5, 1)), - ("Deeper/Nested/Golden2", (-4, -10, 1)), - ("Deeper/Nested/Golden3", (0, 10, -2)), - ): - cook.spawn_unit(parent, child, path) - - def test_spawn_unit_with_absolute_paths(self): - stage = cook.fetch_stage(self.root_asset) - id_fields = {tokens.ids.CGAsset.kingdom.name: "K"} - taxon = cook.define_taxon(stage, "Another", id_fields=id_fields) - parent, child = cook.create_many(taxon, ['A', 'B']) - valid_path = parent.GetPath().AppendPath("Deeper/Nested/Golden1") - invalid_path = "/invalid/path" - self.assertTrue(cook.spawn_unit(parent, child, valid_path)) - with self.assertRaisesRegex(ValueError, "needs to be a child path of parent path"): - cook.spawn_unit(parent, child, invalid_path) + # When asset identifier is empty or non existing, the fallback inspection of the prim itself should get the same result + Usd.ModelAPI(unit).SetAssetIdentifier("") + self.assertEqual(cook.unit_asset(unit).identifier, unit_asset.identifier) + + def test_create_many_in_memory(self): + stage = Usd.Stage.CreateInMemory() + # Root_asset is an empty anonymous asset with a pipeline compliant identifier. Create and sublayer it + anon_pipeline = Sdf.Layer.CreateNew(str(cook.Repository.get() / self.root_asset.name)) + stage.GetRootLayer().subLayerPaths.append(anon_pipeline.identifier) + cook.create_many(cook.define_taxon(stage, "Anon"), ("first", "second")) def test_spawn_many(self): stage = cook.fetch_stage(self.root_asset) - id_fields = {tokens.ids.CGAsset.kingdom.name: "K"} - taxon = cook.define_taxon(stage, "Another", id_fields=id_fields) - parent, child = cook.create_many(taxon, ['A', 'B']) - cook.spawn_many(parent, child, ["b"], labels=["1", "2"]) - self.assertEqual(len(parent.GetChildren()), 1) - def test_spawn_many_invalid(self): - stage = Usd.Stage.CreateInMemory() parent = stage.DefinePrim("/a") with self.assertRaisesRegex(ValueError, "Can not spawn .* to itself."): cook.spawn_many(parent, parent, ["impossible"]) child = stage.DefinePrim("/b") # child needs to be a grill unit with self.assertRaisesRegex(ValueError, "Could not extract identifier from"): cook.spawn_many(parent, child, ["b"]) + invalid_path = "/invalid/path" + with self.assertRaisesRegex(ValueError, "needs to be a child path of parent path"): + cook.spawn_unit(parent, child, invalid_path) + + id_fields = {tokens.ids.CGAsset.kingdom.name: "K"} + taxon = cook.define_taxon(stage, "Another", id_fields=id_fields) + parent, child = cook.create_many(taxon, ['A', 'B']) + cook.spawn_many(parent, child, ["b", "nested/c"], labels=["1", "2", "3"]) + self.assertEqual(len(parent.GetChildren()), 2) def test_inherited_and_specialized_contexts(self): stage = cook.fetch_stage(self.root_asset) id_fields = {tokens.ids.CGAsset.kingdom.name: "K"} taxon = cook.define_taxon(stage, "Another", id_fields=id_fields) - parent, via_s, via_i, not_under_context = cook.create_many(taxon, ['parent', 'via_s', 'via_i', 'not_under_context']) + parent_unit, to_be_specialized, to_be_inherited, not_under_context = cook.create_many( + taxon, ['parent_unit', 'to_be_specialized', 'to_be_inherited', 'not_under_context'] + ) not_a_unit = stage.DefinePrim("/vanilla_prim") + with self.assertRaisesRegex(ValueError, "is not a valid unit"): cook.specialized_context(not_a_unit) - with self.assertRaisesRegex(ValueError, "needs to be a valid unit"): - cook.specialized_context(via_s, via_s.GetParent()) + # current parent prim is not a valid unit in the catalogue (it's just a group) + cook.specialized_context(to_be_specialized, to_be_specialized.GetParent()) with self.assertRaisesRegex(ValueError, "is not a descendant"): - cook.specialized_context(parent, via_s) + cook.specialized_context(parent_unit, to_be_specialized) + # return + spawned_invalid = cook.spawn_unit(parent_unit, not_under_context) - spawned_invalid = cook.spawn_unit(parent, not_under_context) with self.assertRaisesRegex(ValueError, "Is there a composition arc bringing"): # TODO: find a more meaningful message (higher level) than the edit target context one. - cook.specialized_context(spawned_invalid, parent) - - with cook.unit_context(parent): - via_s_spawned = cook.spawn_unit(parent, via_s) - via_i_spawned = cook.spawn_unit(parent, via_i) - - with cook.inherited_context(not_under_context): - UsdGeom.Gprim(not_under_context).MakeInvisible() + cook.specialized_context(spawned_invalid, parent_unit) - with cook.specialized_context(via_s_spawned, parent): - UsdGeom.Gprim(via_s_spawned).MakeInvisible() + with cook.unit_context(parent_unit): + specialized_spawned = cook.spawn_unit(parent_unit, to_be_specialized) + inherited_spawned = cook.spawn_unit(parent_unit, to_be_inherited) - with cook.inherited_context(via_i_spawned): - UsdGeom.Gprim(via_i_spawned).MakeInvisible() + with cook.inherited_context(parent_unit): + UsdGeom.Gprim(parent_unit).MakeInvisible() + with cook.specialized_context(specialized_spawned, parent_unit): + UsdGeom.Gprim(specialized_spawned).MakeInvisible() + with cook.inherited_context(inherited_spawned): + UsdGeom.Gprim(inherited_spawned).MakeInvisible() def _check_broadcasted_invisibility(asset, prim, method): target_stage = Usd.Stage.Open(asset) @@ -298,9 +220,9 @@ def _check_broadcasted_invisibility(asset, prim, method): self.assertEqual(authored, 'invisible') for target_asset, target_prim, broadcast_type in ( - (not_under_context, not_under_context, Usd.Inherits), # non-referenced, no context, asset unit is the target - (via_i_spawned, via_i_spawned, Usd.Inherits), # referenced asset unit, no context, asset unit is the target - (parent, via_s_spawned, Usd.Specializes), # referenced, context unit is the target + (parent_unit, parent_unit, Usd.Inherits), # non-referenced, no context, asset unit is the target + (inherited_spawned, inherited_spawned, Usd.Inherits), # referenced asset unit, no context, asset unit is the target + (parent_unit, specialized_spawned, Usd.Specializes), # referenced, context unit is the target ): with self.subTest(target_asset=str(target_asset), target_prim=str(target_prim), broadcast_type=str(broadcast_type)): _check_broadcasted_invisibility(cook.unit_asset(target_asset), target_prim, broadcast_type) diff --git a/tests/test_data/_mini_graph.dot b/tests/test_data/_mini_graph.dot new file mode 100644 index 00000000..5e1cd174 --- /dev/null +++ b/tests/test_data/_mini_graph.dot @@ -0,0 +1,21 @@ +digraph { +rankdir=LR; +edge [color=crimson]; +1 [label="{x:y:z|z}", style=rounded, shape=record]; +2 [label="{a|b}", style=rounded, shape=record]; +3 [label="{c|d}", style=rounded, shape=record]; +parent [shape=box, fillcolor="#afd7ff", color="#1E90FF", style="filled,rounded"]; +child1 [shape=box, fillcolor="#afd7ff", color="#1E90FF", style="filled,rounded"]; +child2 [shape=box, fillcolor="#afd7ff", color="#1E90FF", style=invis]; +ancestor [ports="('', 'cycle_in', 'roughness', 'cycle_out', 'surface')", shape=none, label=<
ancestor
cycle_in
roughness
cycle_out
surface
>]; +successor [ports="('', 'surface')", shape=none, label=<
successor
surface
>]; +1 -> 1 [key=0, color="sienna:crimson:orange"]; +1 -> 2 [key=0, color=crimson]; +2 -> 1 [key=0, color=seagreen]; +3 -> 2 [key=0, color=steelblue, tailport=five]; +3 -> 1 [key=0, color=hotpink, tailport=five]; +parent -> child1 [key=0]; +parent -> child2 [key=0, label=invis]; +ancestor -> ancestor [key=0, tailport="cycle_out", headport="cycle_in", tooltip="ancestor.cycle_out -> ancestor.cycle_in"]; +ancestor -> successor [key=0, tailport=surface, headport=surface, tooltip="ancestor.surface -> successor.surface"]; +} diff --git a/tests/test_data/_mini_graph.svg b/tests/test_data/_mini_graph.svg new file mode 100644 index 00000000..c94b0774 --- /dev/null +++ b/tests/test_data/_mini_graph.svg @@ -0,0 +1,138 @@ + + + + + + + + + +1 + +x:y:z + +z + + + +1->1 + + + + + + + +2 + +a + +b + + + +1->2 + + + + + +2->1 + + + + + +3 + +c + +d + + + +3:five->1 + + + + + +3:five->2 + + + + + +parent + +parent + + + +child1 + +child1 + + + +parent->child1 + + + + + + +parent->child2 + + +invis + + + +ancestor + + +ancestor + +cycle_in + +roughness + +cycle_out + +surface + + + + +ancestor:cycle_out->ancestor:cycle_in + + + + + + + + +successor + + +successor + +surface + + + + +ancestor:surface->successor:surface + + + + + + + + diff --git a/tests/test_usd.py b/tests/test_usd.py index 2d6a2462..06a3f0b6 100644 --- a/tests/test_usd.py +++ b/tests/test_usd.py @@ -4,6 +4,19 @@ import grill.usd as gusd +# 2024-11-09 - Python-3.13 & USD-24.11 +# python -m unittest --durations 0 test_usd +# ..... +# Slowest test durations +# ---------------------------------------------------------------------- +# 0.026s test_edit_context (test_usd.TestUSD.test_edit_context) +# 0.001s test_format_tree (test_usd.TestUSD.test_format_tree) +# 0.001s test_make_plane (test_usd.TestUSD.test_make_plane) +# +# (durations < 0.001s were hidden; use -v to show these durations) +# ---------------------------------------------------------------------- +# Ran 5 tests in 0.030s + class TestUSD(unittest.TestCase): def test_edit_context(self): diff --git a/tests/test_views.py b/tests/test_views.py index bdf77363..bb1a8029 100644 --- a/tests/test_views.py +++ b/tests/test_views.py @@ -4,6 +4,7 @@ import shutil import tempfile import unittest +from pathlib import Path from unittest import mock from pxr import Usd, UsdGeom, Sdf, UsdShade @@ -20,28 +21,30 @@ # but don't want to use that since that needs to be set prior to an application initialization (which grill can't control as in USDView, Maya, Houdini...) # https://stackoverflow.com/questions/56159475/qt-webengine-seems-to-be-initialized -# 2024-02-03 +# There's about ~0.4s overhead from creating a QApplication for the tests. + +# 2024-11-09 - Python-3.13 & USD-24.11 # python -m unittest --durations 0 test_views # Slowest test durations # ---------------------------------------------------------------------- -# 1.963s test_scenegraph_composition (test_views.TestViews.test_scenegraph_composition) -# 1.882s test_taxonomy_editor (test_views.TestViews.test_taxonomy_editor) -# 1.579s test_content_browser (test_views.TestViews.test_content_browser) -# 0.789s test_spreadsheet_editor (test_views.TestViews.test_spreadsheet_editor) -# 0.383s test_horizontal_scroll (test_views.TestGraphicsViewport.test_horizontal_scroll) -# 0.329s test_connection_view (test_views.TestViews.test_connection_view) -# 0.322s test_layer_stack_hovers (test_views.TestViews.test_layer_stack_hovers) -# 0.204s test_dot_call (test_views.TestViews.test_dot_call) -# 0.169s test_display_color_editor (test_views.TestViews.test_display_color_editor) -# 0.167s test_stats (test_views.TestViews.test_stats) -# 0.121s test_prim_filter_data (test_views.TestViews.test_prim_filter_data) -# 0.116s test_prim_composition (test_views.TestViews.test_prim_composition) -# 0.106s test_create_assets (test_views.TestViews.test_create_assets) -# 0.014s test_pan (test_views.TestGraphicsViewport.test_pan) +# 0.354s test_spreadsheet_editor (tests.test_views.TestViews.test_spreadsheet_editor) +# 0.288s test_connection_view (tests.test_views.TestViews.test_connection_view) +# 0.237s test_taxonomy_editor (tests.test_views.TestViews.test_taxonomy_editor) +# 0.236s test_content_browser (tests.test_views.TestViews.test_content_browser) +# 0.217s test_scenegraph_composition (tests.test_views.TestViews.test_scenegraph_composition) +# 0.180s test_dot_call (tests.test_views.TestViews.test_dot_call) +# 0.051s test_prim_filter_data (tests.test_views.TestViews.test_prim_filter_data) +# 0.050s test_create_assets (tests.test_views.TestViews.test_create_assets) +# 0.038s test_stats (tests.test_views.TestViews.test_stats) +# 0.037s test_graph_views (tests.test_views.TestViews.test_graph_views) +# 0.032s test_prim_composition (tests.test_views.TestViews.test_prim_composition) +# 0.029s test_display_color_editor (tests.test_views.TestViews.test_display_color_editor) +# 0.004s test_pan (tests.test_views.TestViews.test_pan) +# 0.001s test_horizontal_scroll (tests.test_views.TestViews.test_horizontal_scroll) # # (durations < 0.001s were hidden; use -v to show these durations) # ---------------------------------------------------------------------- -# Ran 18 tests in 8.216s +# Ran 18 tests in 2.141s class TestPrivate(unittest.TestCase): @@ -81,142 +84,80 @@ def test_common_paths(self): ] self.assertEqual(actual, expected) - def test_core(self): - _core._ensure_dot() + +_test_bed = Path(__file__).parent / "mini_test_bed" / "main-world-test.1.usda" class TestViews(unittest.TestCase): - def setUp(self): - self._app = QtWidgets.QApplication.instance() or QtWidgets.QApplication([]) - - sphere = Usd.Stage.CreateInMemory() - UsdGeom.Sphere.Define(sphere, "/sph") - root_path = "/root" - sphere_root = sphere.DefinePrim(root_path) - sphere_root.CreateAttribute("greet", Sdf.ValueTypeNames.String).Set("hello") - sphere.SetDefaultPrim(sphere_root) - - capsule = Usd.Stage.CreateInMemory() - UsdGeom.Capsule.Define(capsule, "/cap") - root_path = "/root" - capsule_root = capsule.DefinePrim(root_path) - capsule_root.CreateAttribute("who", Sdf.ValueTypeNames.String).Set("world") - capsule.SetDefaultPrim(capsule_root) - - merge = Usd.Stage.CreateInMemory() - for i in (capsule, sphere): - merge.GetRootLayer().subLayerPaths.append(i.GetRootLayer().identifier) - merge.SetDefaultPrim(merge.GetPrimAtPath(root_path)) - - world = Usd.Stage.CreateInMemory() - self.nested = world.DefinePrim("/nested/child") - self.sibling = world.DefinePrim("/nested/sibling") - self.nested.GetReferences().AddReference(merge.GetRootLayer().identifier) - - self.capsule = capsule - self.sphere = sphere - self.merge = merge - self.world = world + @classmethod + def setUpClass(cls): + cls._app = QtWidgets.QApplication.instance() or QtWidgets.QApplication([]) + + def setUp(self): self._tmpf = tempfile.mkdtemp() self._token = cook.Repository.set(cook.Path(self._tmpf) / "repo") - self.rootf = names.UsdAsset.get_anonymous() - self.grill_world = gworld = cook.fetch_stage(self.rootf.name) - self.person = cook.define_taxon(gworld, "Person") - self.agent = cook.define_taxon(gworld, "Agent", references=(self.person,)) - self.generic_agent = cook.create_unit(self.agent, "GenericAgent") def tearDown(self) -> None: cook.Repository.reset(self._token) - # Reset all members to USD objects to ensure the used layers are cleared - # (otherwise in Windows this can cause failure to remove the temporary files) - self.generic_agent = None - self.agent = None - self.person = None - self.grill_world = None - self.capsule = None - self.sphere = None - self.merge = None - self.world = None - self.nested = None - self.sibling = None shutil.rmtree(self._tmpf) - self._app.quit() + + @classmethod + def tearDownClass(cls): + cls._app.quit() def test_connection_view(self): - for graph_viewer in _graph.GraphView, _graph._GraphSVGViewer: - with self.subTest(graph_viewer=graph_viewer): - _graph._GraphViewer = graph_viewer - if graph_viewer == _graph._GraphSVGViewer: - for pixmap_enabled in True, False: - with self.subTest(pixmap_enabled=pixmap_enabled): - _graph._USE_SVG_VIEWPORT = pixmap_enabled - self._sub_test_connection_view() - else: - self._sub_test_connection_view() - - def _sub_test_connection_view(self): # https://openusd.org/release/tut_simple_shading.html stage = Usd.Stage.CreateInMemory() material = UsdShade.Material.Define(stage, '/TexModel/boardMat') pbrShader = UsdShade.Shader.Define(stage, '/TexModel/boardMat/PBRShader') - pbrShader.CreateInput("roughness", Sdf.ValueTypeNames.Float).Set(0.4) - material.CreateSurfaceOutput().ConnectToSource(pbrShader.ConnectableAPI(), "surface") + roughness_name = "roughness" + pbrShader.CreateInput(roughness_name, Sdf.ValueTypeNames.Float).Set(0.4) + surface_name = "surface" + material.CreateSurfaceOutput().ConnectToSource(pbrShader.ConnectableAPI(), surface_name) # Ensure cycles don't cause recursion cycle_input = pbrShader.CreateInput("cycle_in", Sdf.ValueTypeNames.Float) cycle_output = pbrShader.CreateOutput("cycle_out", Sdf.ValueTypeNames.Float) - cycle_output.ConnectToSource(cycle_input) - description._graph_from_connections(material) + cycle_input.ConnectToSource(cycle_output) viewer = description._ConnectableAPIViewer() + # GraphView capabilities are tested elsewhere, so mock 'view' here. + viewer._graph_view.view = lambda indices: None viewer.setPrim(material) + graph = viewer._graph_view._graph + self.assertEqual(graph.nodes[str(material.GetPrim().GetPath())]['ports'], ['', surface_name]) + self.assertEqual(graph.nodes[str(pbrShader.GetPrim().GetPath())]['ports'], ['', cycle_input.GetBaseName(), roughness_name, cycle_output.GetBaseName(), surface_name]) viewer.setPrim(None) def test_scenegraph_composition(self): - for graph_viewer in _graph.GraphView, _graph._GraphSVGViewer: - with self.subTest(graph_viewer=graph_viewer): - _graph._GraphViewer = graph_viewer - if graph_viewer == _graph._GraphSVGViewer: - for pixmap_enabled in True, False: - with self.subTest(pixmap_enabled=pixmap_enabled): - _graph._USE_SVG_VIEWPORT = pixmap_enabled - self._sub_test_scenegraph_composition() - self._sub_test_layer_stack_bidirectionality() - else: - self._sub_test_scenegraph_composition() - self._sub_test_layer_stack_bidirectionality() - - def _sub_test_scenegraph_composition(self): - widget = description.LayerStackComposition() - widget.setStage(self.world) - - # cheap. All these layers affect a single prim - affectedPaths = dict.fromkeys((i.GetRootLayer() for i in (self.capsule, self.sphere, self.merge)), 1) + """Confirm that bidirectionality between layer stacks completes. - # the world affects both root and the nested prims, stage layer stack is included - affectedPaths.update(dict.fromkeys(self.world.GetLayerStack(), 3)) + Bidirectionality in the composition graph is achieved by: + - parent_stage -> child_stage via a reference, payload arcs + - child_stage -> parent_stage via a inherits, specializes arcs + """ + stage = Usd.Stage.Open(str(_test_bed)) - for row in range(widget._layers.model.rowCount()): - layer = widget._layers.model._objects[row] - widget._layers.table.selectRow(row) - expectedAffectedPrims = affectedPaths[layer] - actualListedPrims = widget._prims.model.rowCount() - self.assertEqual(expectedAffectedPrims, actualListedPrims) + widget = description.LayerStackComposition() + # GraphView capabilities are tested elsewhere, so mock 'view' here. + widget._graph_view.view = lambda indices: None + widget.setStage(stage) widget._layers.table.selectAll() - self.assertEqual(len(affectedPaths), widget._layers.model.rowCount()) - self.assertEqual(3, widget._prims.model.rowCount()) + expectedAffectedPrims = 306 + actualListedPrims = widget._prims.model.rowCount() + self.assertEqual(expectedAffectedPrims, actualListedPrims) - widget.setPrimPaths({"/nested/sibling"}) - widget.setStage(self.world) - - widget._layers.table.selectAll() - self.assertEqual(2, widget._layers.model.rowCount()) - self.assertEqual(1, widget._prims.model.rowCount()) + widget._graph_precise_source_ports.setChecked(True) + widget._update_graph_from_graph_info(widget._computed_graph_info) widget._has_specs.setChecked(True) widget._graph_edge_include[description.Pcp.ArcTypeReference].setChecked(False) - # add_dll_directory only on Windows - os.add_dll_directory = lambda path: print(f"Added {path}") if not hasattr(os, "add_dll_directory") else os.add_dll_directory + widget.setPrimPaths({"/Catalogue/Model/Elements/Apartment"}) + widget.setStage(stage) + + widget._layers.table.selectAll() + self.assertEqual(5, widget._layers.model.rowCount()) + self.assertEqual(1, widget._prims.model.rowCount()) _core._which.cache_clear() with mock.patch("grill.views.description._which") as patch: # simulate dot is not in the environment @@ -224,107 +165,20 @@ def _sub_test_scenegraph_composition(self): widget._graph_view.view([0,1]) _core._which.cache_clear() - with mock.patch("grill.views.description.nx.nx_agraph.write_dot") as patch: # simulate pygraphviz is not installed + with mock.patch("grill.views.description.nx.nx_agraph.write_dot") as patch: # simulate pydot not installed patch.side_effect = ImportError widget._graph_view.view([0]) widget.deleteLater() - def _sub_test_layer_stack_bidirectionality(self): - """Confirm that bidirectionality between layer stacks completes. - - Bidirectionality in the composition graph is achieved by: - - parent_stage -> child_stage via a reference, payload arcs - - child_stage -> parent_stage via a inherits, specializes arcs - """ - parent_stage = Usd.Stage.CreateInMemory() - child_stage = Usd.Stage.CreateInMemory() - prim = parent_stage.DefinePrim("/a/b") - child_prim = child_stage.DefinePrim("/child") - child_prim.GetInherits().AddInherit("/foo") - child_prim.GetSpecializes().AddSpecialize("/foo") - child_stage.SetDefaultPrim(child_prim) - child_identifier = child_stage.GetRootLayer().identifier - prim.GetReferences().AddReference(child_identifier) - prim.GetPayloads().AddPayload(child_identifier) - - widget = description.LayerStackComposition() - widget.setStage(parent_stage) - widget._layers.table.selectAll() - - graph_view = widget._graph_view - - def test_layer_stack_hovers(self): - _graph._GraphViewer = _graph.GraphView - _graph._USE_SVG_VIEWPORT = False - - parent_stage = Usd.Stage.CreateInMemory() - child_stage = Usd.Stage.CreateInMemory() - prim = parent_stage.DefinePrim("/a/b") - child_prim = child_stage.DefinePrim("/child") - child_prim.GetInherits().AddInherit("/foo") - child_prim.GetSpecializes().AddSpecialize("/foo") - child_stage.SetDefaultPrim(child_prim) - child_identifier = child_stage.GetRootLayer().identifier - prim.GetReferences().AddReference(child_identifier) - prim.GetPayloads().AddPayload(child_identifier) - - widget = description.LayerStackComposition() - widget.setStage(parent_stage) - widget._graph_precise_source_ports.setChecked(True) - widget._has_specs.setCheckState(QtCore.Qt.CheckState.PartiallyChecked) - - widget._layers.table.selectAll() - graph_view = widget._graph_view - cycle_collected = False - nodes_hovered_checked = False - for item in graph_view.scene().items(): - item.boundingRect() # trigger bounding rect logic - if isinstance(item, _graph._Edge): - cycle_collected = True - if isinstance(item, _graph._Node) and item.isVisible(): - nodes_hovered_checked = True - - # Test hover with no modifiers - event = QtWidgets.QGraphicsSceneHoverEvent(QtCore.QEvent.GraphicsSceneHoverMove) - center = item.sceneBoundingRect().center() - event.setScenePos(center) - item.hoverEnterEvent(event) - self.assertEqual(item.cursor().shape(), QtGui.Qt.ArrowCursor) - self.assertEqual(item.textInteractionFlags(), item._default_text_interaction) - item.hoverLeaveEvent(event) - - # Test hover with Ctrl modifier - event = QtWidgets.QGraphicsSceneHoverEvent(QtCore.QEvent.GraphicsSceneHoverMove) - event.setScenePos(center) - event.setModifiers(QtCore.Qt.ControlModifier) - item.hoverEnterEvent(event) - self.assertEqual(item.cursor().shape(), QtGui.Qt.PointingHandCursor) - item.hoverLeaveEvent(event) - - # Test hover with Alt modifier - event = QtWidgets.QGraphicsSceneHoverEvent(QtCore.QEvent.GraphicsSceneHoverMove) - event.setScenePos(item.sceneBoundingRect().center()) - event.setModifiers(QtCore.Qt.AltModifier) - item.hoverEnterEvent(event) - self.assertEqual(item.cursor().shape(), QtGui.Qt.ClosedHandCursor) - self.assertEqual(item.textInteractionFlags(), QtCore.Qt.NoTextInteraction) - item.hoverLeaveEvent(event) - - self.assertTrue(cycle_collected) - self.assertTrue(nodes_hovered_checked) - def test_prim_composition(self): - for pixmap_enabled in True, False: - with self.subTest(pixmap_enabled=pixmap_enabled): - description._SVG_AS_PIXMAP = pixmap_enabled - self._sub_test_prim_composition() - - def _sub_test_prim_composition(self): temp = Usd.Stage.CreateInMemory() - temp.GetRootLayer().subLayerPaths = [self.nested.GetStage().GetRootLayer().identifier] - prim = temp.GetPrimAtPath(self.nested.GetPath()) + ancestor = temp.DefinePrim("/a") + prim = temp.DefinePrim("/b") + prim.GetReferences().AddReference(Sdf.Reference(primPath=ancestor.GetPath())) widget = description.PrimComposition() + # DotView capabilities are tested elsewhere, so mock 'setDotPath' here. + widget._dot_view.setDotPath = lambda fp: None widget.setPrim(prim) # cheap. prim is affected by 2 layers @@ -347,8 +201,7 @@ def _sub_test_prim_composition(self): widget.clear() def test_create_assets(self): - stage = cook.fetch_stage(str(self.rootf)) - + stage = cook.fetch_stage(cook.UsdAsset.get_anonymous()) for each in range(1, 6): cook.define_taxon(stage, f"Option{each}") @@ -378,40 +231,27 @@ def test_create_assets(self): widget._apply() def test_taxonomy_editor(self): - for graph_viewer in _graph.GraphView, _graph._GraphSVGViewer: - with self.subTest(graph_viewer=graph_viewer): - _graph._GraphViewer = graph_viewer - if graph_viewer == _graph._GraphSVGViewer: - for pixmap_enabled in True, False: - with self.subTest(pixmap_enabled=pixmap_enabled): - _graph._USE_SVG_VIEWPORT = pixmap_enabled - self._sub_test_taxonomy_editor() - else: - self._sub_test_taxonomy_editor() - - def _sub_test_taxonomy_editor(self): - stage = cook.fetch_stage(str(self.rootf.get_anonymous())) - - existing = [cook.define_taxon(stage, f"Option{each}") for each in range(1, 6)] + class MiniAsset(names.UsdAsset): + drop = ('code', 'media', 'area', 'stream', 'step', 'variant', 'part') + DEFAULT_SUFFIX = "usda" + + cook.UsdAsset = MiniAsset + stage = Usd.Stage.Open(str(_test_bed)) + existing = list(cook.itaxa(stage)) widget = create.TaxonomyEditor() - if isinstance(widget._graph_view, _graph.GraphView): - with self.assertRaisesRegex(LookupError, "Could not find sender"): - invalid_uril = QtCore.QUrl(f"{widget._graph_view.url_id_prefix}not_a_digit") - widget._graph_view._graph_url_changed(invalid_uril) - else: - with self.assertRaisesRegex(RuntimeError, "'graph' attribute not set yet"): - invalid_uril = QtCore.QUrl(f"{widget._graph_view.url_id_prefix}not_a_digit") - widget._graph_view._graph_url_changed(invalid_uril) + # GraphView capabilities are tested elsewhere, so mock 'view' here. + widget._graph_view.view = lambda indices: None + widget.setStage(stage) widget._amount.setValue(3) # TODO: create 10 assets, clear tmp directory valid_data = ( - ['NewType1', 'Option1', 'Id1', ], + ['NewType1', existing[0].GetName(), 'Id1', ], ['NewType2', '', 'Id2', ], ) data = valid_data + ( - ['', 'Option1', 'Id3', ], + ['', existing[0].GetName(), 'Id3', ], ) QtWidgets.QApplication.instance().clipboard().setText('') @@ -444,53 +284,38 @@ def _sub_test_taxonomy_editor(self): selected_items = widget._existing.table.selectedIndexes() self.assertEqual(len(selected_items), len(valid_data) + len(existing)) - if isinstance(widget._graph_view, _graph.GraphView): - sender = next(iter(widget._graph_view._nodes_map.values()), None) - self.assertIsNotNone(sender, msg=f"Expected sender to be an actual object of type {_graph._Node}. Got None, check pygraphviz / pydot requirements") - sender.linkActivated.emit("") - else: - valid_url = QtCore.QUrl(f"{widget._graph_view.url_id_prefix}{existing[-1].GetName()}") - widget._graph_view._graph_url_changed(valid_url) - # Nitpick, wait for dot 2 svg conversions to finish - # This does not crash the program but an exception is logged when race - # conditions apply (e.g. the object is deleted before the runnable completes). - # This logged exception comes in the form of: - # RuntimeError: Internal C++ object (_Dot2SvgSignals) already deleted. - # Solution seems to be to block and wait for all runnables to complete. - widget._graph_view._threadpool.waitForDone(10_000) - def test_spreadsheet_editor(self): widget = sheets.SpreadsheetEditor() widget._model_hierarchy.setChecked(False) # default is True - self.world.OverridePrim("/child_orphaned") - self.nested.SetInstanceable(True) + stage = Usd.Stage.Open(str(_test_bed)) + stage.SetEditTarget(stage.GetSessionLayer()) + with Sdf.ChangeBlock(): + stage.OverridePrim("/child_orphaned") + stage.GetPrimAtPath("/Catalogue/Model/Blocks").SetActive(False) widget._orphaned.setChecked(True) - assert self.nested.IsInstance() - widget.setStage(self.world) - self.assertEqual(self.world, widget.stage) + widget.setStage(stage) + self.assertEqual(stage, widget.stage) widget.table.scrollContentsBy(10, 10) widget.table.selectAll() - expected_rows = {0, 1, 2, 3} # 3 prims from path: /nested, /nested/child, /nested/sibling, /child_orphaned - visible_rows = ({i.row() for i in widget.table.selectedIndexes()}) - self.assertEqual(expected_rows, visible_rows) widget.table.clearSelection() - widget._column_options[0]._line_filter.setText("chi") + widget._column_options[0]._line_filter.setText("hade") widget._column_options[0]._updateMask() widget.table.resizeColumnToContents(0) widget.table.selectAll() - expected_rows = {0, 1} # 1 prim from filtered name: /nested/child + expected_rows = {0, 1, 2} # 1 prim from filtered name: /Catalogue/Shade /Catalogue/Shade/Color /Catalogue/Shade/Color/ModelDefault visible_rows = ({i.row() for i in widget.table.selectedIndexes()}) - self.assertEqual(expected_rows, visible_rows) + self.assertEqual(visible_rows, expected_rows) widget._copySelection() clip = QtWidgets.QApplication.instance().clipboard().text() data = tuple(csv.reader(io.StringIO(clip), delimiter=csv.excel_tab.delimiter)) expected_data = ( - ['/nested/child', 'child', '', '', '', 'True', '', 'False'], - ['/child_orphaned', 'child_orphaned', '', '', '', 'False', '', 'False'], + ['/Catalogue/Shade', 'Shade', '', '', '', 'False', '', 'False'], + ['/Catalogue/Shade/Color', 'Color', '', '', '', 'False', '', 'False'], + ['/Catalogue/Shade/Color/ModelDefault', 'ModelDefault', 'ModelDefault', '', '', 'False', '', 'False'], ) self.assertEqual(data, expected_data) @@ -498,7 +323,7 @@ def test_spreadsheet_editor(self): widget._model_hierarchy.click() # enables model hierarchy, which we don't have any widget.table.selectAll() - expected_rows = set() + expected_rows = {0, 1} visible_rows = ({i.row() for i in widget.table.selectedIndexes()}) self.assertEqual(expected_rows, visible_rows) @@ -512,33 +337,17 @@ def test_spreadsheet_editor(self): widget._column_options[0]._line_filter.setText("") widget._model_hierarchy.click() # disables model hierarchy, which we don't have any widget.table.selectAll() - _log = lambda *args: print(args) - with mock.patch(f"{QtWidgets.__name__}.QMessageBox.warning", new=_log): + with mock.patch(f"{QtWidgets.__name__}.QMessageBox.warning", new=lambda *args: print(args)): widget._pasteClipboard() widget.model._prune_children = {Sdf.Path("/pruned")} - gworld = self.grill_world - with cook.unit_context(self.generic_agent): - child_agent = gworld.DefinePrim(self.generic_agent.GetPath().AppendChild("child")) - child_attr = child_agent.CreateAttribute("agent_greet", Sdf.ValueTypeNames.String, custom=False) - child_attr.Set("aloha") - agent_id = cook.unit_asset(self.generic_agent) - for i in range(3): - agent = gworld.DefinePrim(f"/Instanced/Agent{i}") - agent.GetReferences().AddReference(agent_id.identifier) - agent.SetInstanceable(True) - agent.SetActive(False) - gworld.OverridePrim("/non/existing/prim") - gworld.DefinePrim("/pruned/prim") - inactive = gworld.DefinePrim("/another_inactive") - inactive.SetActive(False) - gworld.GetRootLayer().subLayerPaths.append(self.world.GetRootLayer().identifier) + widget._column_options[0]._line_filter.setText("") widget.table.clearSelection() widget._active.setChecked(False) widget._classes.setChecked(True) widget._filters_logical_op.setCurrentIndex(1) - widget.stage = gworld + widget.stage = stage widget.table.selectAll() expected_colors = {str(each.value): each for each in sheets._PrimTextColor} # colors are not hashable expected_fonts = {each.weight() for each in ( # font not hashable in PySide2 @@ -556,11 +365,10 @@ def test_spreadsheet_editor(self): expected_colors.pop(color_key, None) collected_fonts.add(font_key) - self.assertEqual(expected_colors, dict()) self.assertEqual(expected_fonts, collected_fonts) def test_prim_filter_data(self): - stage = cook.fetch_stage(self.rootf) + stage = cook.fetch_stage(cook.UsdAsset.get_anonymous()) person = cook.define_taxon(stage, "Person") agent = cook.define_taxon(stage, "Agent", references=(person,)) generic = cook.create_unit(agent, "GenericAgent") @@ -599,32 +407,14 @@ def test_dot_call(self): self.assertIsNotNone(error) def test_content_browser(self): - stage = cook.fetch_stage(self.rootf) - taxon = cook.define_taxon(stage, "Another") - parent, child = cook.create_many(taxon, ['A', 'B']) - for path, value in ( - ("", (2, 15, 6)), - ("Deeper/Nested/Golden1", (-4, 5, 1)), - ("Deeper/Nested/Golden2", (-4, -10, 1)), - ("Deeper/Nested/Golden3", (0, 10, -2)), - ): - spawned = UsdGeom.Xform(cook.spawn_unit(parent, child, path)) - spawned.AddTranslateOp().Set(value=value) - variant_set_name = "testset" - variant_name = "testvar" - vset = child.GetVariantSet(variant_set_name) - vset.AddVariant(variant_name) - vset.SetVariantSelection(variant_name) - with vset.GetVariantEditContext(): - stage.DefinePrim(child.GetPath().AppendChild("in_variant")) - path_with_variant = child.GetPath().AppendVariantSelection(variant_set_name, variant_name) - - layers = stage.GetLayerStack() - args = stage.GetLayerStack(), None, stage.GetPathResolverContext(), (Sdf.Path("/"), spawned.GetPrim().GetPath(), path_with_variant) - anchor = layers[0] + stage = Usd.Stage.Open(str(_test_bed)) - def _log(*args): - print(args) + path_with_variant = Sdf.Path("/Origin{color=blue}Geom/Floor.primvars:displayColor") + spawned_path = Sdf.Path("/Catalogue/Model/Buildings/Multi_Story_Building/Windows/Apartment") + apartments_layer = Sdf.Layer.FindOrOpen(str(_test_bed.parent / "Model-Elements-Apartment.1.usda")) + layers = stage.GetLayerStack() + [apartments_layer] + args = layers, None, stage.GetPathResolverContext(), (Sdf.Path("/"), spawned_path, path_with_variant) + anchor = layers[0] _core_run = _core._run @@ -632,7 +422,7 @@ def _fake_run(run_args: list): return "", Sdf.Layer.FindOrOpen(run_args[-1]).ExportToString() # sdffilter still not coming via pypi, so patch for now - with mock.patch("grill.views.description._core._run", new=_fake_run if not description._which("sdffilter") else _core_run): + with mock.patch("grill.views.description._core._run", new=_fake_run): dialog = description._start_content_browser(*args) browser: description._PseudoUSDBrowser = dialog.findChild(description._PseudoUSDBrowser) assert browser._browsers_by_layer.values() @@ -644,7 +434,7 @@ def _fake_run(run_args: list): browser_tab: description._PseudoUSDTabBrowser = first_browser_widget.findChild(description._PseudoUSDTabBrowser) browser._on_identifier_requested(anchor, layers[1].identifier) - with mock.patch(f"{QtWidgets.__name__}.QMessageBox.warning", new=_log): + with mock.patch(f"{QtWidgets.__name__}.QMessageBox.warning", new=lambda *args: print(args)): browser._on_identifier_requested(anchor, "/missing/file.usd") _, empty_png = tempfile.mkstemp(suffix=".png") browser._on_identifier_requested(anchor, empty_png) @@ -677,12 +467,11 @@ def _fake_run(run_args: list): for child in dialog.findChildren(description._PseudoUSDBrowser): child._resolved_layers.clear() - prim_index = parent.GetPrimIndex() - _, sourcepath = tempfile.mkstemp() - prim_index.DumpToDotGraph(sourcepath) - targetpath = f"{sourcepath}.png" # create a temporary file loadable by our image tab - _core_run([_core._which("dot"), sourcepath, "-Tpng", "-o", targetpath]) + image = QtGui.QImage(QtCore.QSize(1, 1), QtGui.QImage.Format_RGB888) + image.fill(QtGui.QColor(255, 0, 0)) + targetpath = str(_test_bed.with_suffix(".jpg")) + image.save(targetpath, "JPG") browser._on_identifier_requested(anchor, targetpath) invalid_crate_layer = Sdf.Layer.CreateAnonymous() @@ -716,11 +505,13 @@ def GprimSphere "Sphere" self.assertEqual(result, "") def test_display_color_editor(self): - stage = cook.fetch_stage(self.rootf) + stage = cook.fetch_stage(cook.UsdAsset.get_anonymous()) sphere = UsdGeom.Sphere.Define(stage, "/volume") color_var = sphere.GetDisplayColorPrimvar() editor = _attributes._DisplayColorEditor(color_var) - editor._update_value() + + with mock.patch("grill.views._attributes.QtWidgets.QColorDialog.getColor", new=lambda *_, **__: QtGui.QColor(255, 255, 0)): + editor._color_launchers["Color"][0].click() color_var.SetInterpolation(UsdGeom.Tokens.vertex) editor = _attributes._DisplayColorEditor(color_var) @@ -733,24 +524,221 @@ def test_display_color_editor(self): with self.assertRaises(TypeError): # atm some gprim types are not supported editor._update_value() + editor = _attributes._DisplayColorEditor(UsdGeom.Primvar()) + self.assertEqual(len(editor._value), 1) + def test_stats(self): empty = stats.StageStats() self.assertEqual(empty._usd_tree.topLevelItemCount(), 0) - widget = stats.StageStats(stage=self.world) + stage = Usd.Stage.Open(str(_test_bed)) + widget = stats.StageStats(stage=stage) self.assertGreater(widget._usd_tree.topLevelItemCount(), 1) current = _qt.QtCharts del _qt.QtCharts - stats.StageStats(stage=self.world) + stats.StageStats(stage=stage) _qt.QtCharts = current + def test_graph_views(self): + viewer = _graph.GraphView() -class TestGraphicsViewport(unittest.TestCase): - def setUp(self): - self._app = QtWidgets.QApplication.instance() or QtWidgets.QApplication([]) + with ( + mock.patch(f"grill.views._graph.drawing.nx_pydot.graphviz_layout", new=lambda graph, **__: dict.fromkeys(graph.nodes, (0,0))), + ): + for invalid_node_data, error_message in ( + (dict(shape='record'), "'label' must be supplied"), + (dict(shape='record', label='no record'), "a record 'label' in the form of"), + (dict(shape='record', label='{1}'), "a record 'label' in the form of"), + (dict(shape='record', label='{<0>1}', ports=('first', 'second')), "record 'shape' and 'ports' are mutually exclusive"), + (dict(shape='none'), "A label must be provided"), + ): + invalid_graph = _graph.nx.MultiDiGraph() + invalid_graph.add_nodes_from([(1, invalid_node_data)]) + with self.assertRaisesRegex(ValueError, error_message): + viewer.graph = invalid_graph + + viewer = _graph.GraphView() + viewer.view(tuple()) + + nodes_info = { + 1: dict( + label="{x:y:z|z}", + style="rounded", # these can be set at the graph level + shape="record", + ), + 2: dict( + label="{a|b}", + style='rounded', + shape="record", + ), + 3: dict( + label="{c|d}", + style='rounded', + shape="record", + ), + "parent": dict( + shape="box", fillcolor="#afd7ff", color="#1E90FF", style="filled,rounded" + ), + "child1": dict( + shape="box", fillcolor="#afd7ff", color="#1E90FF", style="filled,rounded" + ), + "child2": dict( + shape="box", fillcolor="#afd7ff", color="#1E90FF", style="invis" + ), + } + edges_info = ( + (1, 1, dict(color='sienna:crimson:orange')), + (1, 2, dict(color='crimson')), + (2, 1, dict(color='seagreen')), + (3, 2, dict(color='steelblue', tailport='five')), + (3, 1, dict(color='hotpink', tailport='five')), + ("parent", "child1"), + ("parent", "child2", dict(label='invis')), + ) - def tearDown(self): - self._app.quit() + graph = _graph.nx.MultiDiGraph() + graph.add_nodes_from(nodes_info.items()) + graph.add_edges_from(edges_info) + graph.graph['graph'] = {'rankdir': 'LR'} + graph.graph['edge'] = {"color": 'crimson'} + outline_color = "#4682B4" # 'steelblue' + background_color = "#F0FFFF" # 'azure' + table_row = '{text}' + + connection_nodes = dict( + ancestor=dict( + ports=('', 'cycle_in', 'roughness', 'cycle_out', 'surface'), + shape='none', + connections=dict( + surface=[('successor', 'surface')], + cycle_out=[('ancestor', 'cycle_in')], + ), + ), + successor=dict( + ports=('', 'surface'), + shape='none', + connections=dict(), + ) + ) + connection_edges = [] + + def _add_edges(src_node, src_name, tgt_node, tgt_name): + tooltip = f"{src_node}.{src_name} -> {tgt_node}.{tgt_name}" + connection_edges.append((src_node, tgt_node, {"tailport": src_name, "headport": tgt_name, "tooltip": tooltip})) + + for node, data in connection_nodes.items(): + label = f'<' + label += table_row.format(port="", color="white", + text=f'{node}') + for port in data['ports']: + if not port: + continue + sources = data['connections'].get(port, []) # (valid, invalid): we care only about valid sources (index 0) + color = r"#F08080" if sources else background_color + label += table_row.format(port=port, color=color, text=f'{port}') + for source_node, source_port in sources: + # node_id='ancestor', port_name='cycle_out', ancestor, source.sourceName='cycle_in' + # tooltip='/TexModel/boardMat/PBRShader.cycle_in -> /TexModel/boardMat/PBRShader.cycle_out' + _add_edges(node, port, source_node, source_port) + + label += '
>' + data['label'] = label + data.pop('connections', None) + + graph.add_nodes_from(connection_nodes.items()) + graph.add_edges_from(connection_edges) + + widget = QtWidgets.QFrame() + splitter = QtWidgets.QSplitter(QtCore.Qt.Horizontal) + + def _use_test_dot(subgraph, fp): + shutil.copy(Path(__file__).parent / "test_data/_mini_graph.dot", fp) + + def _use_test_svg(self, filepath): + return self._on_dot_result(str(Path(__file__).parent / "test_data/_mini_graph.svg")) + + def _test_positions(graph, prog): + return { + 1: (218.75, 90.1), + 2: (322.75, 90.1), + 3: (76.125, 61.1), + 'parent': (76.125, 190.1), + 'child1': (218.75, 217.1), + 'child2': (218.75, 163.1), + 'ancestor': (76.125, 316.1), + 'successor': (218.75, 282.1), + } + + with ( + mock.patch(f"grill.views._graph.nx.nx_pydot.write_dot", new=_use_test_dot), + mock.patch(f"grill.views._graph._DotViewer.setDotPath", new=_use_test_svg), + mock.patch(f"grill.views._graph.drawing.nx_pydot.graphviz_layout", new=_test_positions), + ): + for cls in _graph.GraphView, _graph._GraphSVGViewer: + for pixmap_enabled in ((True, False) if cls == _graph._GraphSVGViewer else (False,)): + _graph._USE_SVG_VIEWPORT = pixmap_enabled + + child = cls(parent=widget) + + if isinstance(child, _graph.GraphView): + with self.assertRaisesRegex(LookupError, "Could not find sender"): + invalid_uril = QtCore.QUrl(f"{child.url_id_prefix}not_a_digit") + child._graph_url_changed(invalid_uril) + else: + with self.assertRaisesRegex(RuntimeError, "'graph' attribute not set yet"): + invalid_uril = QtCore.QUrl(f"{child.url_id_prefix}not_a_digit") + child._graph_url_changed(invalid_uril) + child._on_dot_error("nothing set yet") + + if cls == _graph._GraphSVGViewer and not pixmap_enabled: + # QWebEngineView in use, no need to test its 'load' method + child._graph_view.load = lambda fp: None + child._graph = graph + child.view(graph.nodes) + child.setMinimumWidth(150) + splitter.addWidget(child) + + if isinstance(child, _graph.GraphView): + for item in child.scene().items(): + item.boundingRect() # trigger bounding rect logic + if isinstance(item, _graph._Edge): + cycle_collected = True + if isinstance(item, _graph._Node): + nodes_hovered_checked = True + + # Test hover with no modifiers + event = QtWidgets.QGraphicsSceneHoverEvent(QtCore.QEvent.GraphicsSceneHoverMove) + center = item.sceneBoundingRect().center() + event.setScenePos(center) + item.hoverEnterEvent(event) + self.assertEqual(item.cursor().shape(), QtGui.Qt.ArrowCursor) + self.assertEqual(item.textInteractionFlags(), item._default_text_interaction) + item.hoverLeaveEvent(event) + + # Test hover with Ctrl modifier + event = QtWidgets.QGraphicsSceneHoverEvent(QtCore.QEvent.GraphicsSceneHoverMove) + event.setScenePos(center) + event.setModifiers(QtCore.Qt.ControlModifier) + item.hoverEnterEvent(event) + self.assertEqual(item.cursor().shape(), QtGui.Qt.PointingHandCursor) + item.hoverLeaveEvent(event) + + # Test hover with Alt modifier + event = QtWidgets.QGraphicsSceneHoverEvent(QtCore.QEvent.GraphicsSceneHoverMove) + event.setScenePos(item.sceneBoundingRect().center()) + event.setModifiers(QtCore.Qt.AltModifier) + item.hoverEnterEvent(event) + self.assertEqual(item.cursor().shape(), QtGui.Qt.ClosedHandCursor) + self.assertEqual(item.textInteractionFlags(), QtCore.Qt.NoTextInteraction) + item.hoverLeaveEvent(event) + + item.itemChange(QtWidgets.QGraphicsItem.ItemPositionHasChanged, (1,1)) + + self.assertTrue(cycle_collected) + self.assertTrue(nodes_hovered_checked) + + child.filter_edges = lambda src, tgt, port: src not in graph.nodes + child.view(graph.nodes) def test_zoom(self): """Zoom is triggered by ctrl + mouse wheel""" @@ -774,6 +762,7 @@ def test_zoom(self): # Assert that the scale has changed according to the zoom logic self.assertGreater(zoomed_in_scale, initial_scale) + angleDelta_zoomOut = QtCore.QPoint(-120, 0) # ZOOM OUT @@ -853,3 +842,4 @@ def test_pan(self): # Confirm no further move is performed self.assertEqual(last_vertical_scroll_bar, vertical_scroll_bar.value()) self.assertEqual(last_horizontal_scroll_bar, horizontal_scroll_bar.value()) +