Skip to content

Commit 655b0f7

Browse files
authored
Instance Check (#45)
* usdview submenus for selecting prototype and instances * note on crashes when opening Prim Composition due to QtWebEngine * remove printree dependency * update actions version and enabled pythonwarnings as errors Signed-off-by: Christian López Barrón <chris.gfz@gmail.com>
1 parent 69faa25 commit 655b0f7

File tree

11 files changed

+181
-46
lines changed

11 files changed

+181
-46
lines changed

.github/workflows/python-package.yml

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -22,9 +22,9 @@ jobs:
2222
- python-version: "3.13"
2323
install-arguments: ".[full,create]"
2424
steps:
25-
- uses: actions/checkout@v4
25+
- uses: actions/checkout@v5
2626
- name: Set up Python ${{ matrix.python-version }}
27-
uses: actions/setup-python@v5
27+
uses: actions/setup-python@v6
2828
with:
2929
python-version: ${{ matrix.python-version }}
3030
- name: Set up Graphviz
@@ -41,7 +41,7 @@ jobs:
4141
python -m pip install ${{ matrix.install-arguments }}
4242
- name: Test
4343
run: |
44-
pytest --cov .
44+
PYTHONWARNINGS=error,ignore:::pyparsing pytest --cov --durations=10 .
4545
# https://github.com/marketplace/actions/codecov
4646
- name: Codecov Report
4747
uses: codecov/codecov-action@v4

docs/source/conf.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -163,7 +163,7 @@
163163
}
164164
html_title = '👨‍🍳 The Grill'
165165
html_theme_options = {
166-
"accent_color": "sky",
166+
"accent_color": "blue",
167167
"github_url": "https://github.com/thegrill/grill",
168168
"globaltoc_expand_depth": 2,
169169
"toctree_collapse": True,

docs/source/prim_composition.rst

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,10 @@ Uses :ref:`Prim Index <glossary:index>` and its utilities (:usdcpp:`PcpPrimIndex
55

66
To visualize the :ref:`glossary:composition` graph, the ``graphviz`` library needs to be available in the environment.
77

8+
.. important::
9+
10+
If you experience crashes when launching this widget, it could be due to issues with ``QtWebEngine``. Try setting environment variable ``GRILL_SVG_VIEW_AS_PIXMAP=1`` to avoid use of ``QtWebEngine`` (`see environment variables <views.html#environment-variables>`_ for more details).
11+
812
.. tab:: USDView
913

1014
.. image:: images/prim_composition_usdview.gif

grill/cook/__init__.py

Lines changed: 13 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -139,6 +139,7 @@ def define_taxon(stage: Usd.Stage, name: str, *, references: tuple[Usd.Prim] = t
139139
Optional ``field=value`` items can be provided for identification purposes through ``id_fields``.
140140
141141
"""
142+
# This could create a new schema in the future if codeless schemas are allowed to be registered at runtime
142143
if name == _TAXONOMY_NAME:
143144
# TODO: prevent upper case lower case mismatch handle between multiple OS?
144145
# (e.g. Windows considers both the same but Linux does not)
@@ -406,6 +407,7 @@ def spawn_many(parent: Usd.Prim, child: Usd.Prim, paths: list[Sdf.Path], labels:
406407
# Ensure prims are defined to spawn units unto (paths might be deep e.g. /world/parent/nested/path/for/child)
407408
spawned = [parent_stage.DefinePrim(path) for path in paths_to_create]
408409
child_is_model = child.IsModel()
410+
checked_parents = set()
409411
with Sdf.ChangeBlock():
410412
# Action of bringing a unit from our catalogue turns parent into an assembly only if child is a model.
411413
if child_is_model and not (parent_model := Usd.ModelAPI(parent)).IsKind(Kind.Tokens.assembly):
@@ -431,9 +433,13 @@ def spawn_many(parent: Usd.Prim, child: Usd.Prim, paths: list[Sdf.Path], labels:
431433
# Action of bringing a unit from our catalogue turns parent into an assembly only if child is a model.
432434
if child_is_model:
433435
# check for all intermediate parents of our spawned unit to ensure valid model hierarchy
434-
for inner_parent in _usd.iprims(parent_stage, [parent_path], lambda p: p == spawned_unit.GetParent()):
436+
inner_parent = spawned_unit.GetParent()
437+
while inner_parent != parent and inner_parent not in checked_parents:
435438
if not inner_parent.IsModel():
436439
Usd.ModelAPI(inner_parent).SetKind(Kind.Tokens.group)
440+
checked_parents.add(inner_parent)
441+
inner_parent = inner_parent.GetParent()
442+
437443
if not child.IsGroup():
438444
# Sensible defaults: component prims are instanced
439445
spawned_unit.SetInstanceable(True)
@@ -551,3 +557,9 @@ def taxonomy_graph(prims: Usd.Prim, url_id_prefix: str) -> nx.DiGraph:
551557
def _(stage: Usd.Stage, url_id_prefix: str) -> nx.DiGraph:
552558
# Convenience for the stage
553559
return taxonomy_graph(itaxa(stage), url_id_prefix)
560+
561+
562+
def filter_taxa(prims: abc.Iterable[Usd.Prim], taxon: Usd.Prim | str, *taxa: Usd.Prim) -> abc.Iterator[Usd.Prim]:
563+
"""From the given prims, yield those that are part of the given taxa."""
564+
taxa_names = {i if isinstance(i, str) else i.GetName() for i in (taxon, *taxa)}
565+
return (prim for prim in prims if taxa_names.intersection(prim.GetAssetInfoByKey(_ASSETINFO_TAXA_KEY) or {}))

grill/usd/__init__.py

Lines changed: 50 additions & 28 deletions
Original file line numberDiff line numberDiff line change
@@ -13,7 +13,6 @@
1313
from collections import abc
1414

1515
from pxr import Usd, UsdGeom, Sdf, Plug, Ar, Tf
16-
from printree import TreePrinter
1716

1817
logger = logging.getLogger(__name__)
1918

@@ -294,31 +293,6 @@ def is_target(arc):
294293
return edit_context(prim, query_filter, is_target)
295294

296295

297-
@contextlib.contextmanager
298-
def _prim_tree_printer(predicate, prims_to_include: abc.Container = frozenset()):
299-
prim_entry = Usd.Prim.GetName if predicate != Usd.PrimIsModel else lambda prim: f"{prim.GetName()} ({Usd.ModelAPI(prim).GetKind()})"
300-
301-
class PrimTreePrinter(TreePrinter):
302-
"""For everything else, use usdtree from the vanilla USD toolset"""
303-
304-
def ftree(self, prim: Usd.Prim):
305-
self.ROOT = f"{super().ROOT}{prim_entry(prim)}"
306-
return super().ftree(prim)
307-
308-
# another duck
309-
Usd.Prim.__iter__ = lambda prim: (p for p in prim.GetFilteredChildren(predicate) if not prims_to_include or p in prims_to_include)
310-
Usd.Prim.items = lambda prim: ((prim_entry(p), p) for p in prim)
311-
current = type(abc.Mapping).__instancecheck__ # can't unregister abc.Mapping.register, so use __instancecheck__
312-
313-
type(abc.Mapping).__instancecheck__ = lambda cls, inst: current(cls, inst) or (cls == abc.Mapping and type(inst) == Usd.Prim)
314-
try:
315-
yield PrimTreePrinter()
316-
finally:
317-
type(abc.Mapping).__instancecheck__ = current
318-
del Usd.Prim.__iter__
319-
del Usd.Prim.items
320-
321-
322296
def _format_prim_hierarchy(prims, include_descendants=True, predicate=Usd.PrimDefaultPredicate):
323297
for prim in prims:
324298
if prim.IsPseudoRoot():
@@ -328,8 +302,56 @@ def _format_prim_hierarchy(prims, include_descendants=True, predicate=Usd.PrimDe
328302
root_paths = dict.fromkeys(common_paths((prim.GetPath() for prim in prims)))
329303
prims_to_tree = (prim for prim in prims if prim.GetPath() in root_paths)
330304

331-
with _prim_tree_printer(predicate, set(prims) if not include_descendants else set()) as printer:
332-
return "\n".join(printer.ftree(prim) for prim in prims_to_tree)
305+
prim_entry = Usd.Prim.GetName if predicate != Usd.PrimIsModel else lambda prim: f"{prim.GetName()} ({Usd.ModelAPI(prim).GetKind()})"
306+
prims_to_include = set(prims) if not include_descendants else set()
307+
308+
def ftree(prim, prefix="", last=True, buffer=None):
309+
if buffer is None: # we're at the root
310+
buffer = []
311+
marker, child_prefix = "┐", ""
312+
elif not last:
313+
marker, child_prefix = '├── ', f"{prefix}│ "
314+
else:
315+
marker, child_prefix = '└── ', f"{prefix} "
316+
317+
buffer.append(f"{prefix}{marker}{prim_entry(prim)}")
318+
for index, child in enumerate(children := prim.GetFilteredChildren(predicate)):
319+
if not prims_to_include or child in prims_to_include:
320+
ftree(child, child_prefix, index == len(children)-1, buffer)
321+
return buffer
322+
323+
return "\n".join(chain.from_iterable(ftree(prim) for prim in prims_to_tree))
324+
325+
326+
def iter_recursive_instances(prims: abc.Iterable[Usd.Prim]) -> abc.Iterator[Usd.Prim]:
327+
"""For the given prims, recursively iterate over all instances from their prototypes."""
328+
329+
visited_prototypes = set()
330+
331+
def get_instances_from_prototype_child(child):
332+
child_path = child.GetPath()
333+
root_path = child_path.GetPrefixes()[0] # contract: all prototypes are root prims like /__Prototype_3
334+
stage = child.GetStage()
335+
proto = stage.GetPrimAtPath(root_path)
336+
rel_path = child_path.MakeRelativePath(root_path)
337+
return [stage.GetPrimAtPath(instance.GetPath().AppendPath(rel_path)) for instance in proto.GetInstances()]
338+
339+
def visit_prototype(prototype, instances_getter):
340+
if prototype in visited_prototypes:
341+
return
342+
visited_prototypes.add(prototype)
343+
for instance in (instances:=instances_getter(prototype)):
344+
yield instance
345+
yield from iterate_instances(instances)
346+
347+
def iterate_instances(prims):
348+
for prim in prims:
349+
if (is_instance := prim.IsInstance()) or prim.IsPrototype():
350+
yield from visit_prototype(prim.GetPrototype() if is_instance else prim, Usd.Prim.GetInstances)
351+
if (is_proxy := prim.IsInstanceProxy()) or prim.IsInPrototype(): # we're beneath an instance
352+
yield from visit_prototype(prim.GetPrimInPrototype() if is_proxy else prim, get_instances_from_prototype_child)
353+
354+
yield from iterate_instances(prims)
333355

334356

335357
# add other mesh creation utilities here?

grill/views/usdview.py

Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -175,6 +175,43 @@ class GrillPrimConnectionViewerMenuItem(GrillPrimCompositionMenuItem):
175175
_subtitle = "Connections"
176176

177177

178+
class GrillSelectPrimPrototypeMenuItem(GrillPrimCompositionMenuItem):
179+
180+
@property
181+
def _subtitle(self):
182+
return f"Select Prototype{'s' if len(self._selectionDataModel.getPrims()) > 1 else ''}"
183+
184+
def IsEnabled(self):
185+
return any((prim.IsInstance() or prim.IsInstanceProxy()) for prim in self._selectionDataModel.getPrims())
186+
187+
def RunCommand(self):
188+
selection_model = self._selectionDataModel
189+
prims = selection_model.getPrims()
190+
with selection_model.batchPrimChanges:
191+
selection_model.clearPrims()
192+
for prim in prims:
193+
if prim.IsInstance() and (proto := prim.GetPrototype()):
194+
selection_model.addPrim(proto)
195+
elif prim.IsInstanceProxy():
196+
selection_model.addPrim(prim.GetPrimInPrototype())
197+
198+
199+
class GrillSelectPrimPrototypeInstancesMenuItem(GrillPrimCompositionMenuItem):
200+
"""Recursively (e.g. traversing nested instancing) select all instances of the selected prims"""
201+
_subtitle = "Select Prototype Instances"
202+
203+
def IsEnabled(self):
204+
return any((prim.IsInstance() or prim.IsInstanceProxy() or prim.IsInPrototype()) for prim in self._selectionDataModel.getPrims())
205+
206+
def RunCommand(self):
207+
selection_model = self._selectionDataModel
208+
prims = selection_model.getPrims()
209+
with selection_model.batchPrimChanges:
210+
selection_model.clearPrims()
211+
for instance in sorted(gusd.iter_recursive_instances(prims), key=lambda p: p.GetPath()):
212+
selection_model.addPrim(instance)
213+
214+
178215
class AllHierarchyTextMenuItem(_GrillPrimContextMenuItem):
179216
_include_descendants = True
180217
_subtitle = "All Descendants"

setup.cfg

Lines changed: 10 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
[metadata]
22
name = grill
3-
version = 0.19.0
3+
version = 0.19.1
44
description = Pipeline tools for (but not limited to) audiovisual projects.
55
long_description = file: README.md
66
long_description_content_type = text/markdown
@@ -17,7 +17,6 @@ classifiers =
1717

1818
[options]
1919
install_requires =
20-
printree
2120
numpy
2221
pydot>=3.0.1
2322
networkx>=3.4; python_version > "3.9"
@@ -30,19 +29,19 @@ include = grill.*
3029

3130
[options.extras_require]
3231
# USD build:
33-
# conda create -n py313usd2411build python=3.13
34-
# conda activate py313usd2411build
35-
# conda install -c conda-forge cmake=3.27
36-
# python -m pip install PySide6 PyOpenGL jinja2
32+
# conda create -n py314usdbuild python=3.14 -c conda-forge
33+
# conda activate py314usdbuild
34+
# conda install conda-forge::cmake=3.27
35+
# python -m pip install PySide6 PyOpenGL jinja2 <- fails 2025/10/12
3736
# conda install -c rdonnelly vs2019_win-64
38-
# python "A:\write\code\git\OpenUSD\build_scripts\build_usd.py" -v "A:\write\builds\py313usd2411build"
37+
# python "A:\write\code\git\OpenUSD\build_scripts\build_usd.py" -v "A:\write\builds\py314usdbuild"
3938
#
4039
# --- dev env ---:
41-
# conda create -n py313usd2411 python=3.13
42-
# conda activate py313usd2411
40+
# conda create -n py314usd python=3.13
41+
# conda activate py314usd
4342
# runtime dependencies:
44-
# conda install conda-forge::graphviz
45-
# python -m pip install grill-names>=2.6.0 networkx>=3.4 pydot>=3.0.1 numpy printree PyOpenGL pyside6
43+
# conda install conda-forge::graphviz=11 # 13.1.2 brings up libffi which messes up _ctypes from python-3.13, and 12 fails to load gvplugin_pango.dll
44+
# python -m pip install grill-names>=2.6.0 networkx>=3.4 pydot>=3.0.1 numpy PyOpenGL pyside6
4645
# docs dependencies:
4746
# 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 sphinxcontrib-doxylink
4847
# For EDGEDB (coming up)

tests/mini_test_bed/Model-Blocks-Block.1.usda

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,7 @@ def "Origin" (
1818
{
1919
def "Building1" (
2020
prepend references = @Model-Buildings-Multi_Story_Building.1.usda@
21+
instanceable = true
2122
)
2223
{
2324
double3 xformOp:translate = (-54, 126, 1)
@@ -26,6 +27,7 @@ def "Origin" (
2627

2728
def "Building2" (
2829
prepend references = @Model-Buildings-Multi_Story_Building.1.usda@
30+
instanceable = true
2931
)
3032
{
3133
double3 xformOp:translate = (54, 126, 1)
@@ -34,6 +36,7 @@ def "Origin" (
3436

3537
def "Building3" (
3638
prepend references = @Model-Buildings-Multi_Story_Building.1.usda@
39+
instanceable = true
3740
)
3841
{
3942
double3 xformOp:translate = (-54, -126, 1)
@@ -42,6 +45,7 @@ def "Origin" (
4245

4346
def "Building4" (
4447
prepend references = @Model-Buildings-Multi_Story_Building.1.usda@
48+
instanceable = true
4549
)
4650
{
4751
double3 xformOp:translate = (54, -126, 1)

tests/test_cook.py

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -142,6 +142,16 @@ def test_taxonomy(self):
142142
self.assertEqual(first_successors, {second.GetName(), third.GetName()})
143143
self.assertEqual(set(cook.taxonomy_graph(stage, "").nodes), set(graph_from_stage.nodes))
144144

145+
def test_filter_taxa(self):
146+
stage = cook.fetch_stage(self.root_asset)
147+
root = cook.define_taxon(stage, "Root")
148+
parent, child = cook.create_many(root, ['A', 'B'])
149+
another = cook.define_taxon(stage, "Another")
150+
cook.create_many(another, ['C', 'D'])
151+
inherited = cook.define_taxon(stage, "Inherited", references=[root])
152+
grandchild = cook.create_unit(inherited, 'E')
153+
self.assertSetEqual({parent, child, grandchild}, set(cook.filter_taxa(stage.Traverse(), root)))
154+
145155
def test_asset_unit(self):
146156
stage = cook.fetch_stage(self.root_asset)
147157
taxon_name = "taxon"

tests/test_usd.py

Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
import unittest
2+
from pathlib import Path
23

34
from pxr import Usd, UsdGeom, Sdf
45

@@ -16,6 +17,7 @@
1617
# (durations < 0.001s were hidden; use -v to show these durations)
1718
# ----------------------------------------------------------------------
1819
# Ran 5 tests in 0.030s
20+
_test_bed = Path(__file__).parent / "mini_test_bed" / "main-world-test.1.usda"
1921

2022

2123
class TestUSD(unittest.TestCase):
@@ -103,3 +105,34 @@ def test_make_plane(self):
103105
self.assertEqual(80, len(mesh.GetPointsAttr().Get()))
104106
self.assertEqual(252, len(mesh.GetFaceVertexIndicesAttr().Get()))
105107
self.assertEqual(63, len(mesh.GetFaceVertexCountsAttr().Get()))
108+
109+
def test_recursive_instances(self):
110+
"""Confirm we can collect all instances recursively"""
111+
112+
paths = (
113+
"/Catalogue/Model/Buildings/Multi_Story_Building/Windows/Apartment",
114+
"/Catalogue/Model/Buildings/Multi_Story_Building/Windows/Apartment_blue",
115+
)
116+
stage = Usd.Stage.Open(str(_test_bed))
117+
expected_prim_paths = {Sdf.Path(path) for path in (
118+
"/Catalogue/Model/Blocks/Block/Building1/Windows/Apartment",
119+
"/Catalogue/Model/Blocks/Block/Building1/Windows/Apartment_blue",
120+
"/Catalogue/Model/Blocks/Block/Building2/Windows/Apartment",
121+
"/Catalogue/Model/Blocks/Block/Building2/Windows/Apartment_blue",
122+
"/Catalogue/Model/Blocks/Block/Building3/Windows/Apartment",
123+
"/Catalogue/Model/Blocks/Block/Building3/Windows/Apartment_blue",
124+
"/Catalogue/Model/Blocks/Block/Building4/Windows/Apartment",
125+
"/Catalogue/Model/Blocks/Block/Building4/Windows/Apartment_blue",
126+
"/Catalogue/Model/Buildings/Multi_Story_Building/Windows/Apartment",
127+
"/Catalogue/Model/Buildings/Multi_Story_Building/Windows/Apartment_blue",
128+
"/__Prototype_1/Windows/Apartment",
129+
"/__Prototype_1/Windows/Apartment_blue",
130+
)}
131+
prims = (stage.GetPrimAtPath(p) for p in paths)
132+
result = [instance.GetPath() for instance in gusd.iter_recursive_instances(prims)]
133+
134+
non_proto__result_paths = {path for path in result if not str(path).startswith("/__Prototype_")}
135+
non_proto_expected_paths = {path for path in expected_prim_paths if not str(path).startswith("/__Prototype_")}
136+
137+
self.assertEqual(len(result), len(expected_prim_paths))
138+
self.assertSetEqual(non_proto__result_paths, non_proto_expected_paths)

0 commit comments

Comments
 (0)