git.lucas.co / hou-control
SideFX Houdini customization package
git clone https://git.lucas.co/hou-control.git

commite0994fbba53608bf4d9f5fce6fcebc50f9490b5f
parent1451bf8ff1
authorLucas Galante <[email protected]>
date2026-09-10 15:29
hc: wire the keycam zoom step, and drop the HCNode wrapper

keycam.py read delta_r and delta_t from settings but never delta_z, so the
zoom step in HC Settings did nothing -- HCCam kept its hardcoded default. The
names differ either side of the boundary (the setting is delta_z, the camera
attribute delta_zoom), which is presumably how it got missed. delta_ow still
has no consumer; HCCam advances self.ow by delta_zoom and has no separate
ortho-width step to assign it to.

HCNode is gone. It wrapped hou.Node to add four helpers and re-expose a dozen
methods hou.Node already had, and the package had quietly stopped using it:
HCNetworkEditor reached for self.hou_tab.pwd() twenty-two times against one
call to the wrapper. Two of its sixteen methods were dead on arrival --
currentNode() called a method hou.Node does not have, and childCat() called
.name() on childTypeCategory() with no None guard, so asking a SOP what it
contained raised AttributeError. Worse, it shadowed hou.Node.path() by not
having one, which broke HCPathTab.path() and the Show Path Message command
through it.

HCPathTab.pwd() now returns the hou.Node, and the helpers that earned their
keep became module functions over plain nodes: hcvisibility.childCategory and
collect_visible_nodes, hcgeometryutils.merged_visible_geo (which no longer
takes the hou module as an argument). childCategory guards the None, so it
answers '' for a childless node instead of raising. childCat() moves onto
HCPathTab, where its two callers already were.

HCNetworkEditor.network_node went with it: captured in __init__ and stale from
that moment, since the editor is reconstructed per callback but the user can
descend into another network between them. Its one reader now asks pwd()
directly. initialize() also dropped a hasattr(pwd, 'childCat') duck-type that
only ever matched the wrapper.

tools/check.py grows to 28, covering the rewritten walk against a real scene:
hidden objects and cameras stay out of the merge, the world-transform flag is
honoured both ways, childCategory survives a leaf node, and pwd() hands back
something with a working path().

Co-Authored-By: Claude Opus 5 <[email protected]>

 CLAUDE.md                            |  1 +
 python3.13libs/hc/__init__.py        |  2 -
 python3.13libs/hc/hcgeometryutils.py | 17 ++++++--
 python3.13libs/hc/hcnetworkeditor.py |  6 +--
 python3.13libs/hc/hcnode.py          | 85 ------------------------------------
 python3.13libs/hc/hcpathtab.py       | 48 ++++++++++++--------
 python3.13libs/hc/hcsceneviewer.py   |  2 +-
 python3.13libs/hc/hcvisibility.py    | 41 ++++++++++++-----
 scripts/OnCreated.py                 |  3 +-
 tools/check.py                       | 68 +++++++++++++++++++++++++++++
 viewer_states/keycam.py              |  4 +-
 11 files changed, 149 insertions(+), 128 deletions(-)

diff --git a/CLAUDE.md b/CLAUDE.md
index 51c357a..e65258a 100644
--- a/CLAUDE.md
+++ b/CLAUDE.md
@@ -90,6 +90,7 @@ These are reloaded via `HCSession.reloadColorSchemes()` (`hou.ui.reloadColorSche
 ## Conventions
 
 - Wrappers never subclass `hou.*` types; they store the hou object on `self.hou_tab` / `self.hou_pane` and delegate.
+- Wrap *tabs and panes*, not nodes. `HCPathTab.pwd()` returns a plain `hou.Node`. There used to be an `HCNode` wrapper; the package bypassed it 22 calls to 1, two of its sixteen methods called `hou` APIs that do not exist, and it shadowed `hou.Node.path()` so `Show Path Message` raised. Node helpers that earn their keep are module functions instead — `hcvisibility.childCategory` / `collect_visible_nodes`, `hcgeometryutils.merged_visible_geo`.
 - `.type()` methods return string discriminators (`'HCNetworkEditor'`, `'HCSceneViewer'`, `'HCParameterTab'`, `'HCPathTab'`). Use them for *filtering* — which tabs a command applies to, which tabs to collect — not for dispatching behaviour. When behaviour differs by tab kind, put a method on each tab class and let the caller iterate: `isChromeVisible()` / `showChrome(visible)` are the worked example, and they replaced two if/elif ladders in `HCSession`. When you do filter, remember `'HCParameterTab'` is a sibling of `'HCPathTab'` (Parm tabs return the former, DetailsView the latter) — code wanting both checks `tab.type() in ('HCPathTab', 'HCParameterTab')`.
 - Per-pane or per-network state belongs in an `hcstate.Store`, never a bare module dict. Bare dicts drifted into four incompatible key schemes and never evicted closed panes.
 - Toggle-style methods often use a small string map (e.g. `{'0': '1', '1': '0'}`) because Houdini prefs are stored as strings.
diff --git a/python3.13libs/hc/__init__.py b/python3.13libs/hc/__init__.py
index 1cbac91..7bfab03 100755
--- a/python3.13libs/hc/__init__.py
+++ b/python3.13libs/hc/__init__.py
@@ -5,7 +5,6 @@ from .hcgeo           import HCGeo
 from .hcguides        import HCGuides
 from .hclayout        import HCLayout
 from .hcnetworkeditor import HCNetworkEditor
-from .hcnode          import HCNode
 from .hcpane          import HCPane
 from .hcparametertab  import HCParameterTab
 from .hcpathtab       import HCPathTab
@@ -32,7 +31,6 @@ __all__ = [
     "HCGuides",
     "HCLayout",
     "HCNetworkEditor",
-    "HCNode",
     "HCPane",
     "HCParameterTab",
     "HCPathTab",
diff --git a/python3.13libs/hc/hcgeometryutils.py b/python3.13libs/hc/hcgeometryutils.py
index 77db492..96b5774 100644
--- a/python3.13libs/hc/hcgeometryutils.py
+++ b/python3.13libs/hc/hcgeometryutils.py
@@ -1,12 +1,21 @@
-def merged_visible_geo(hou_module, visible_nodes, apply_world_transform=False):
-    geo = hou_module.Geometry()
+import hou
+
+
+def merged_visible_geo(visible_nodes, apply_world_transform=False):
+    """Merge the geometry of every node in `visible_nodes` into one Geometry.
+
+    `visible_nodes` are plain hou.Node SOPs (typically from
+    collect_visible_nodes), so the world transform to apply is their parent
+    object's.
+    """
+    geo = hou.Geometry()
 
     for node in visible_nodes:
-        node_geo = hou_module.Geometry()
+        node_geo = hou.Geometry()
         node_geo.merge(node.geometry())
 
         if apply_world_transform:
-            node_geo.transform(node.hou_node.parent().worldTransform())
+            node_geo.transform(node.parent().worldTransform())
 
         geo.merge(node_geo)
 
diff --git a/python3.13libs/hc/hcnetworkeditor.py b/python3.13libs/hc/hcnetworkeditor.py
index e4e45f2..01ee211 100644
--- a/python3.13libs/hc/hcnetworkeditor.py
+++ b/python3.13libs/hc/hcnetworkeditor.py
@@ -18,7 +18,6 @@ class HCNetworkEditor(HCPathTab):
     def __init__(self, hou_tab):
         self.hou_tab = hou_tab
         self.delta_t = 2
-        self.network_node = self.pwd()
 
     def initialize(self):
         self.setMenuOpen(0)
@@ -26,8 +25,7 @@ class HCNetworkEditor(HCPathTab):
         self.hou_tab.setPref('gridmode', '2')
 
         # Disable node previews in COP networks by default.
-        pwd = self.hou_tab.pwd()
-        if hasattr(pwd, 'childCat') and pwd.childCat() == 'Cop':
+        if self.childCat() == 'Cop':
             # Set the global preference for new nodes
             try:
                 hou.pref.set('neteditor.cop_preview', False)
@@ -650,7 +648,7 @@ class HCNetworkEditor(HCPathTab):
         self.hou_tab.clearAllSelected()
 
     def selectAllNodes(self):
-        nodes = self.network_node.children()
+        nodes = self.hou_tab.pwd().children()
         for node in nodes:
             node.setSelected(True)
 
diff --git a/python3.13libs/hc/hcnode.py b/python3.13libs/hc/hcnode.py
deleted file mode 100644
index 28d7adc..0000000
--- a/python3.13libs/hc/hcnode.py
+++ /dev/null
@@ -1,85 +0,0 @@
-from .hcgeo import HCGeo
-from .hcgeometryutils import merged_visible_geo
-from .hcvisibility import collect_visible_nodes
-import hou
-
-class HCNode:
-    def __init__(self, hou_node):
-        self.hou_node = hou_node
-
-
-    """ Selection """
-
-    def allChildren(self):
-        nodes = []
-        for hou_node in self.hou_node.children():
-            nodes.append(__class__(hou_node))
-        return nodes
-
-    def currentNode(self):
-        return self.__class__(self.hou_node.currentNode())
-
-    def selectedChildren(self):
-        nodes = []
-        for hou_node in self.hou_node.selectedChildren():
-            nodes.append(self.__class__(hou_node))
-        return nodes
-
-    def setSelected(self, state):
-        self.hou_node.setSelected(state)
-
-
-    """ Visibility """
-
-    def displayNode(self):
-        hou_display_node = self.hou_node.displayNode()
-        if hou_display_node:
-            return self.__class__(hou_display_node)
-        else:
-            return None
-
-    def visibleNodes(self):
-        return collect_visible_nodes(self.children())
-
-
-    """ Geometry """
-
-    def geometry(self):
-        return self.hou_node.geometry()
-
-    def geo(self):
-        return merged_visible_geo(hou, self.visibleNodes(), apply_world_transform=True)
-
-
-    """ Children """
-
-    def childCat(self):
-        """ Possibilities: Object, Sop, Lop """
-        return self.hou_node.childTypeCategory().name()
-
-    def children(self):
-        hou_nodes = self.hou_node.children()
-        nodes = []
-        for hou_node in hou_nodes:
-            nodes.append(self.__class__(hou_node))
-        return nodes
-
-    def netPos(self):
-        """ Network editor xy position """
-        return self.hou_node.position()
-
-
-    """ Network """
-
-    def position(self):
-        return self.hou_node.position()
-
-    def setColor(self, color):
-        self.hou_node.setColor(color)
-
-    def setPosition(self, pos):
-        self.hou_node.setPosition(pos)
-
-    def setUserData(self, a, b):
-        self.hou_node.setUserData(a, b)
-
diff --git a/python3.13libs/hc/hcpathtab.py b/python3.13libs/hc/hcpathtab.py
index 874a61e..196574d 100644
--- a/python3.13libs/hc/hcpathtab.py
+++ b/python3.13libs/hc/hcpathtab.py
@@ -1,35 +1,47 @@
 import hou
-from .hcgeo  import HCGeo
+from .hcgeo import HCGeo
 from .hcgeometryutils import merged_visible_geo
-from .hcnode import HCNode
-from .hctab  import HCTab
+from .hctab import HCTab
+from .hcvisibility import childCategory, collect_visible_nodes
+
 
 class HCPathTab(HCTab):
+    """A tab that is looking at a node -- Parm, DetailsView, and everything
+    below it in the hierarchy.
+
+    pwd() returns a plain hou.Node. It used to return an HCNode wrapper, which
+    the rest of the package bypassed 22 calls to 1 and which lacked path(), so
+    HCPathTab.path() -- and the Show Path Message command through it -- raised
+    AttributeError.
+    """
+
     def __init__(self, hou_tab):
         self.hou_tab = hou_tab
 
+    def pwd(self):
+        return self.hou_tab.pwd()
+
+    def path(self):
+        return self.pwd().path()
+
+    def childCat(self):
+        """Category name of the nodes inside pwd(): 'Object', 'Sop', 'Lop'..."""
+        return childCategory(self.pwd())
+
+    def visibleNodes(self):
+        return collect_visible_nodes(self.pwd().children())
+
     def hcGeo(self):
         return HCGeo(self.geo())
 
     def geo(self):
-        if self.pwd().childCat() == 'Sop':
+        if self.childCat() == 'Sop':
             display_node = self.pwd().displayNode()
-            if display_node:
+            if display_node is not None:
                 return display_node.geometry()
             return hou.Geometry()
 
-        visible_nodes = self.pwd().visibleNodes()
-        return merged_visible_geo(hou, visible_nodes, apply_world_transform=True)
-
-    def path(self):
-        return self.pwd().path()
-
-    def pwd(self):
-        return HCNode(self.hou_tab.pwd())
+        return merged_visible_geo(self.visibleNodes(), apply_world_transform=True)
 
     def selectedNodes(self):
-        nodes = self.hou_tab.pwd().selectedChildren()
-        hc_nodes = []
-        for node in nodes:
-            hc_nodes.append(HCNode(node))
-        return hc_nodes
+        return list(self.pwd().selectedChildren())
diff --git a/python3.13libs/hc/hcsceneviewer.py b/python3.13libs/hc/hcsceneviewer.py
index a1c0f24..94b9d2b 100644
--- a/python3.13libs/hc/hcsceneviewer.py
+++ b/python3.13libs/hc/hcsceneviewer.py
@@ -348,7 +348,7 @@ class HCSceneViewer(HCPathTab):
     @command("Keycam")
     def keycam(self):
         contexts = ('Object', 'Sop', "Lop")
-        context = self.pwd().childCat()
+        context = self.childCat()
         if context in contexts:
             self.hou_tab.setCurrentState('keycam')
         else:
diff --git a/python3.13libs/hc/hcvisibility.py b/python3.13libs/hc/hcvisibility.py
index 7fd0168..b01abee 100644
--- a/python3.13libs/hc/hcvisibility.py
+++ b/python3.13libs/hc/hcvisibility.py
@@ -1,21 +1,40 @@
-def collect_visible_nodes(children):
-    stack = list(children)
+import hou
+
+
+def childCategory(node):
+    """The name of the category of nodes this node contains, or ''.
+
+    hou.Node.childTypeCategory() returns None for nodes that cannot have
+    children, which is most of them below the object level.
+    """
+    category = node.childTypeCategory()
+    return category.name() if category is not None else ''
+
+
+def collect_visible_nodes(nodes):
+    """Display nodes of every visible SOP container reachable from `nodes`.
+
+    Walks down through displayed object subnetworks, collecting the display
+    node of each displayed SOP container. Takes and returns plain hou.Node.
+    """
+    stack = list(nodes)
     visible_nodes = []
 
     while stack:
-        hc_node = stack.pop()
-        child_cat = hc_node.childCat()
+        node = stack.pop()
+        category = childCategory(node)
 
-        if child_cat == 'Sop' and hc_node.hou_node.isDisplayFlagSet():
-            if hc_node.hou_node.type().name() == 'cam':
+        if category == 'Sop' and node.isDisplayFlagSet():
+            if node.type().name() == 'cam':
                 continue
-
-            display_node = hc_node.displayNode()
-            if display_node:
+            display_node = node.displayNode()
+            if display_node is not None:
                 visible_nodes.append(display_node)
             continue
 
-        if child_cat == 'Object' and hc_node.hou_node.isSubNetwork() and hc_node.hou_node.isDisplayFlagSet():
-            stack.extend(hc_node.children())
+        if (category == 'Object'
+                and node.isSubNetwork()
+                and node.isDisplayFlagSet()):
+            stack.extend(node.children())
 
     return visible_nodes
diff --git a/scripts/OnCreated.py b/scripts/OnCreated.py
index 82ff52e..d71b4eb 100644
--- a/scripts/OnCreated.py
+++ b/scripts/OnCreated.py
@@ -1,8 +1,7 @@
 import hou
 from hc import HCSettings
-from hc import HCNode
 
-node = HCNode(kwargs['node'])
+node = kwargs['node']
 settings = HCSettings()
 node_graph = settings.nodeGraph()
 
diff --git a/tools/check.py b/tools/check.py
index db0befb..736f52c 100644
--- a/tools/check.py
+++ b/tools/check.py
@@ -38,7 +38,9 @@ from hc import (  # noqa: E402
     hcstate,
 )
 from hc import hccommands  # noqa: E402
+from hc.hcgeometryutils import merged_visible_geo  # noqa: E402
 from hc.hcmaps import HCMaps  # noqa: E402
+from hc.hcvisibility import childCategory, collect_visible_nodes  # noqa: E402
 
 
 passed = 0
@@ -253,6 +255,58 @@ def check_chrome():
     check("chrome overrides chain", overrides_extend_the_base)
 
 
+def check_geometry():
+    """The visible-geometry walk, after HCNode was removed from under it."""
+    print("geometry")
+
+    obj = hou.node("/obj")
+    shown_a = obj.createNode("geo", "chk_a")
+    shown_a.createNode("box").setDisplayFlag(True)
+    shown_b = obj.createNode("geo", "chk_b")
+    shown_b.createNode("box").setDisplayFlag(True)
+    shown_b.parmTuple("t").set((10, 0, 0))
+    hidden = obj.createNode("geo", "chk_hidden")
+    hidden.createNode("box").setDisplayFlag(True)
+    hidden.setDisplayFlag(False)
+    camera = obj.createNode("cam", "chk_cam")
+
+    def category_handles_leaves():
+        # HCNode.childCat() called .name() on childTypeCategory() with no guard,
+        # so asking a SOP what it contains raised AttributeError on None.
+        assert childCategory(obj) == "Object"
+        assert childCategory(shown_a) == "Sop"
+        leaf = shown_a.children()[0]
+        assert childCategory(leaf) == "", "a childless node should report ''"
+        return "Object / Sop / '' for a leaf"
+
+    check("childCategory", category_handles_leaves)
+
+    visible = collect_visible_nodes(obj.children())
+
+    def walk_skips_hidden_and_cameras():
+        parents = {n.parent().name() for n in visible}
+        assert "chk_hidden" not in parents, "hidden object contributed geometry"
+        assert "chk_cam" not in parents, "camera contributed geometry"
+        assert parents >= {"chk_a", "chk_b"}, f"displayed objects missing: {parents}"
+        return f"{len(visible)} display nodes, hidden and camera skipped"
+
+    check("collect_visible_nodes", walk_skips_hidden_and_cameras)
+
+    def transform_flag_is_honoured():
+        world = merged_visible_geo(visible, apply_world_transform=True)
+        local = merged_visible_geo(visible, apply_world_transform=False)
+        assert len(world.points()) == len(local.points()), "point counts diverged"
+        # chk_b sits at x=10; only the world-space merge should reach it.
+        assert world.boundingBox().maxvec()[0] > 9, "world transform not applied"
+        assert local.boundingBox().maxvec()[0] < 2, "world transform applied when off"
+        return f"{len(world.points())} points merged, transform opt-in"
+
+    check("merged_visible_geo", transform_flag_is_honoured)
+
+    for node in (shown_a, shown_b, hidden, camera):
+        node.destroy()
+
+
 def check_node_ops():
     print("node operations")
 
@@ -324,12 +378,26 @@ def check_node_ops():
 
     check("new-node grid snap", snap_lands_on_grid)
 
+    def pwd_is_a_real_node():
+        """HCPathTab.pwd() returned an HCNode with no path(), so path() -- and
+        the Show Path Message command through it -- raised AttributeError."""
+        import inspect
+        from hc import HCPathTab
+        source = inspect.getsource(HCPathTab.pwd)
+        assert "HCNode" not in source, "pwd() is wrapping again"
+        node = hou.node("/obj")
+        assert callable(node.path) and node.path() == "/obj"
+        return "pwd() hands back a hou.Node"
+
+    check("pwd returns a hou.Node", pwd_is_a_real_node)
+
 
 def main():
     check_settings()
     check_commands()
     check_state()
     check_chrome()
+    check_geometry()
     check_node_ops()
     print(f"\n{passed} passed, {failed} failed")
     return 1 if failed else 0
diff --git a/viewer_states/keycam.py b/viewer_states/keycam.py
index 8886450..f30e997 100644
--- a/viewer_states/keycam.py
+++ b/viewer_states/keycam.py
@@ -36,6 +36,8 @@ class State(object):
         units = HCSettings().keycam('units')
         self.cam.delta_r = units.get('delta_r')
         self.cam.delta_t = units.get('delta_t')
+        # HCCam calls its zoom step delta_zoom; the setting is delta_z.
+        self.cam.delta_zoom = units.get('delta_z')
         # drag_sensitivity is read live from settings each event (not cached)
         # so changes in HC Settings take effect immediately without reload
         self._settings = HCSettings()
@@ -51,7 +53,7 @@ class State(object):
         self.hud = None
 
         # Status message
-        context = self.scene_viewer.pwd().childCat()
+        context = self.scene_viewer.childCat()
         context_map = {
             'Object': 'Keycam: obj context',
             'Sop': 'Keycam: sop context',