# Objective
Place the HDA `ALTE_lop_sceneInspector.1.0.hdanc` downstream of a problematic node in the LOP network. Click **Extract Scene Info** to generate a structured text report including:
- The upstream node graph (cook status and non-default parameters only)
- The USD prim hierarchy (types, active state, purpose, variant selections)
- **Authored** attribute values (explicitly set ones, not schema defaults)
- USD composition arcs (references, payloads, inherits, specializes)
- The stage layer stack (strongest to weakest)
This report is designed to be pasted directly into an AI assistant for debugging help.
> [!NOTE] Difference from PythonPrinter
> The **[[HOUDINI HDA PythonPrinter]]** is a **live** tool — it listens to future events and transcribes them as reproducible Python code. The **SceneInspector** is a **static** tool — it reads the present state at click time and formats it for an AI. Both are complementary.
>
> | | PythonPrinter | SceneInspector |
> |---|---|---|
> | Trigger | Continuous (callbacks) | One-shot (button) |
> | Orientation | Future changes | Present state |
> | Output | Executable Python code | Structured text for AI |
> | USD awareness | None | Full (prims, attribs, arcs, layers) |
# HDA
- ![[../../../../DoNOTDelete/Attachments/lop_ALTE.sceneInspector.2.0.hdanc]]
**Name :** `ALTE_lop_sceneInspector.1.0.hdanc`
**Context :** LOP (Solaris)
**Operator :** `ALTE::sceneInspector::1.0`
# Parameters
| Parameter | Type | Default | Description |
|---|---|---|---|
| Extract Scene Info | Button | — | Triggers the extraction |
| `maxNodeDepth` | Int | 50 | Max depth for upstream node graph traversal |
| `maxPrimDepth` | Int | 8 | Max depth in the USD prim hierarchy |
| `includeAttribs` | Toggle | ON | Include authored attribute values |
| `includeComposition` | Toggle | ON | Include composition arcs |
| `outputToFile` | Toggle | OFF | Write the report to a .txt file |
| `outputFilePath` | File | `$HIP/sceneInspector_report.txt` | File path (visible when outputToFile is ON) |
**Button callback:** `hou.phm().extract_scene_info(kwargs)`
# Script
To place in **Type Properties > Scripts > Python Module**.
```python
"""
ALTE_lop_sceneInspector
-----------------------
Static snapshot tool for Houdini LOPs / Solaris.
Extracts a human-readable text report of:
1. Upstream node graph topology
2. USD prim hierarchy
3. USD attribute values (optional)
4. USD composition arcs (optional)
5. USD layer stack
For use with AI assistants (Claude, ChatGPT, etc.) for debugging.
Author : ALTE Protocol
Version : 1.0
Context : LOP (Solaris)
"""
import hou
import datetime
# ─────────────────────────────────────────────────────────────────────────────
# SECTION 0 — CONSTANTS & FORMATTING HELPERS
# ─────────────────────────────────────────────────────────────────────────────
BOX_TL = "╔"; BOX_TR = "╗"; BOX_BL = "╚"; BOX_BR = "╝"
BOX_H = "═"; BOX_V = "║"
SEP_H = "━"
INDENT = " "
# Attributes that are almost always noise for an AI — skip them to reduce size.
# Extend this list on heavy scenes.
SKIP_ATTRIB_PREFIXES = (
"primvars:skel",
"skel:",
"points",
"normals",
"faceVertexCounts",
"faceVertexIndices",
"ids",
"velocities",
"accelerations",
)
MAX_ATTRIB_VALUE_LEN = 200 # truncate long values so the report stays readable
def _now():
"""ISO-style timestamp for the report header."""
return datetime.datetime.now().strftime("%Y-%m-%d %H:%M:%S")
def _section(number, title):
"""Returns a bold section header line."""
bar = SEP_H * 3
return f"\n{bar} [{number}] {title} {bar}\n"
def _header(hda_node):
"""Returns the decorative header block at the top of the report."""
title = " SCENE INSPECTOR — AI DEBUG REPORT "
width = len(title)
top = BOX_TL + BOX_H * width + BOX_TR
mid = BOX_V + title + BOX_V
bot = BOX_BL + BOX_H * width + BOX_BR
return (
f"{top}\n{mid}\n{bot}\n"
f"Generated : {_now()}\n"
f"HDA : {hda_node.path()}\n"
f"Houdini : {hou.applicationVersionString()}\n"
)
def _truncate(value_str, max_len=MAX_ATTRIB_VALUE_LEN):
"""Clips a string representation of an attribute value."""
if len(value_str) > max_len:
return value_str[:max_len] + f" ... [{len(value_str)} chars total]"
return value_str
# ─────────────────────────────────────────────────────────────────────────────
# SECTION 1 — UPSTREAM NODE GRAPH
# ─────────────────────────────────────────────────────────────────────────────
def _parm_overrides(node):
"""
Returns a list of ' paramName: value' strings for every parameter
that is NOT at its default value.
WHY parm.isAtDefault(): We only want what the artist intentionally
changed — default values are noise for an AI.
We iterate parmTuples (groups like translate x/y/z) rather than
individual parms to keep the output concise.
"""
overrides = []
try:
for pt in node.parmTuples():
try:
if pt.isAtDefault():
continue
val = pt.eval()
# Unwrap single-element tuples: (1.0,) -> 1.0
if isinstance(val, (tuple, list)) and len(val) == 1:
val = val[0]
overrides.append(f"{INDENT*2}{pt.name()}: {val}")
except Exception:
# Non-evaluable parms (complex expressions) — skip silently
pass
except Exception:
pass
return overrides
def _node_status(node):
"""
Returns 'OK', 'WARN: <msg>', or 'ERROR: <msg>' for a given node.
node.errors() / node.warnings() return lists of strings.
"""
try:
errors = node.errors()
warnings = node.warnings()
if errors:
return f"ERROR: {errors[0]}"
if warnings:
return f"WARN: {warnings[0]}"
return "OK"
except Exception:
return "UNKNOWN"
def build_node_graph_section(hda_node, max_depth):
"""
BFS walk from the HDA input towards upstream.
WHY BFS and not DFS? BFS gives a natural "levels of distance" ordering —
nodes closest to the HDA appear first, which is what an AI needs
to understand the immediate cook context.
"""
lines = [_section(1, "UPSTREAM NODE GRAPH")]
input_node = hda_node.input(0)
if input_node is None:
lines.append(" [No input connected]\n")
return lines
queue = [(input_node, 0)] # (node, depth)
visited = {input_node}
count = 0
while queue and count < max_depth:
node, depth = queue.pop(0)
count += 1
indent_str = INDENT * depth
status = _node_status(node)
type_name = node.type().name()
lines.append(f"{indent_str}[{node.path()}] {type_name} | {status}")
# Non-default parameters
overrides = _parm_overrides(node)
for ov in overrides:
lines.append(f"{indent_str}{ov}")
# Walk further upstream
for upstream in node.inputs():
if upstream is not None and upstream not in visited:
visited.add(upstream)
queue.append((upstream, depth + 1))
if count >= max_depth:
lines.append(f"\n [Truncated at maxNodeDepth={max_depth}]")
lines.append("")
return lines
# ─────────────────────────────────────────────────────────────────────────────
# SECTION 2 — USD PRIM HIERARCHY
# ─────────────────────────────────────────────────────────────────────────────
def _prim_one_liner(prim, depth):
"""
Formats a prim on a single line:
/path/to/prim (TypeName) active|inactive [purpose: X] variant: k=v, ...
One line per prim so the AI can scan the hierarchy quickly.
"""
indent_str = INDENT * depth
path = prim.GetPath().pathString
type_name = prim.GetTypeName() or "—"
active_str = "active" if prim.IsActive() else "inactive"
# Purpose (render / proxy / guide / default)
purpose_str = ""
purpose_attr = prim.GetAttribute("purpose")
if purpose_attr and purpose_attr.IsAuthored():
purpose_val = purpose_attr.Get()
if purpose_val and purpose_val != "default":
purpose_str = f" [purpose: {purpose_val}]"
# Variant selections
variant_str = ""
variant_sets = prim.GetVariantSets()
vs_names = variant_sets.GetNames()
if vs_names:
selections = []
for vs_name in vs_names:
vs = variant_sets.GetVariantSet(vs_name)
sel = vs.GetVariantSelection()
if sel:
selections.append(f"{vs_name}={sel}")
if selections:
variant_str = f" variant: {', '.join(selections)}"
return f"{indent_str}{path} ({type_name}) {active_str}{purpose_str}{variant_str}"
def build_prim_hierarchy_section(stage, max_depth):
"""
Traverses the USD stage and builds the prim tree filtered by max_depth.
WHY stage.Traverse()? It is the idiomatic USD Python API — it handles
edge cases correctly (instance proxies, etc.).
Depth is filtered afterwards via the number of '/' in the path.
"""
lines = [_section(2, "USD PRIM HIERARCHY")]
if stage is None:
lines.append(" [Stage not available — node may not be cooked]\n")
return lines
prim_count = 0
for prim in stage.Traverse():
path_str = prim.GetPath().pathString
# Depth = number of '/' minus 1 (root '/' is depth 0)
depth = path_str.count("/") - 1
if depth > max_depth:
continue
if prim.GetTypeName() == "HoudiniLayerInfo":
continue
lines.append(_prim_one_liner(prim, depth))
prim_count += 1
if prim_count == 0:
lines.append(" [No prims found in stage]")
lines.append(f"\n Total prims shown: {prim_count} (maxPrimDepth={max_depth})")
lines.append("")
return lines
# ─────────────────────────────────────────────────────────────────────────────
# SECTION 3 — USD ATTRIBUTE VALUES
# ─────────────────────────────────────────────────────────────────────────────
def _should_skip_attrib(attr_name):
"""
Returns True for attributes that are too large or too low-level to be
useful for an AI (point arrays, face indices, skinning data, etc.)
"""
for prefix in SKIP_ATTRIB_PREFIXES:
if attr_name.startswith(prefix):
return True
return False
def build_attributes_section(stage, max_depth):
"""
For each prim up to max_depth, lists authored attribute values.
WHY attr.IsAuthored()? In USD, every prim has hundreds of attributes
with fallback values from the schema. We only want the ones an artist
explicitly set.
"""
lines = [_section(3, "USD ATTRIBUTES (authored only)")]
if stage is None:
lines.append(" [Stage not available]\n")
return lines
for prim in stage.Traverse():
depth = prim.GetPath().pathString.count("/") - 1
if depth > max_depth:
continue
authored_attrs = []
for attr in prim.GetAttributes():
if not attr.IsAuthored():
continue
if _should_skip_attrib(attr.GetName()):
continue
try:
val = attr.Get()
val_str = _truncate(str(val))
authored_attrs.append(f"{INDENT*2}{attr.GetName()} = {val_str}")
except Exception:
authored_attrs.append(f"{INDENT*2}{attr.GetName()} = [unreadable]")
if authored_attrs:
lines.append(f"{INDENT}{prim.GetPath().pathString}:")
lines.extend(authored_attrs)
lines.append("")
return lines
# ─────────────────────────────────────────────────────────────────────────────
# SECTION 4 — USD COMPOSITION ARCS
# ─────────────────────────────────────────────────────────────────────────────
def _arc_description(arc):
"""
Converts a USD composition arc into a human-readable string:
'arcType → @layerIdentifier@</primPath>'
This is the standard USD notation for references.
"""
try:
# .displayName may not exist on older USD builds — fallback
try:
arc_type = arc.GetArcType().displayName
except AttributeError:
arc_type = str(arc.GetArcType())
target_node = arc.GetTargetNode()
if target_node:
layer_stack = target_node.layerStack
if layer_stack and layer_stack.layers:
layer_id = layer_stack.layers[0].identifier
else:
layer_id = "?"
target_path = target_node.path.pathString
return f"{arc_type} → @{layer_id}@<{target_path}>"
return f"{arc_type} → [no target]"
except Exception as e:
return f"[arc read error: {e}]"
def build_composition_section(stage, max_depth):
"""
For each prim up to max_depth, lists composition arcs via
Usd.PrimCompositionQuery.
WHY PrimCompositionQuery over prim.GetPrimStack()?
PrimCompositionQuery gives typed arcs (Reference, Payload, Inherit,
Specialize, VariantSet) separately — more readable for an AI.
GetPrimStack() gives raw Sdf.PrimSpec objects, which are lower level.
WHY arc.HasSpecs() as a filter?
HasSpecs() returns True only for arcs with authored opinions —
the right filter for "what an AI needs to know".
"""
try:
from pxr import Usd
except ImportError:
return [_section(4, "USD COMPOSITION ARCS"),
" [pxr not importable in this context]\n"]
lines = [_section(4, "USD COMPOSITION ARCS")]
if stage is None:
lines.append(" [Stage not available]\n")
return lines
arc_count = 0
for prim in stage.Traverse():
depth = prim.GetPath().pathString.count("/") - 1
if depth > max_depth:
continue
query = Usd.PrimCompositionQuery(prim)
arcs = query.GetCompositionArcs()
# Filter with HasSpecs(): only arcs with authored opinions
meaningful = [a for a in arcs if a.HasSpecs()]
if meaningful:
lines.append(f"{INDENT}{prim.GetPath().pathString}:")
for arc in meaningful:
lines.append(f"{INDENT*2}{_arc_description(arc)}")
arc_count += len(meaningful)
if arc_count == 0:
lines.append(" [No composition arcs found at this depth]")
lines.append("")
return lines
# ─────────────────────────────────────────────────────────────────────────────
# SECTION 5 — USD LAYER STACK
# ─────────────────────────────────────────────────────────────────────────────
def build_layer_stack_section(stage):
"""
Lists all layers in the stage, strongest to weakest.
WHY includeSessionLayers=True?
The Houdini session layer (created per session) is often invisible to
artists but is a common source of mysterious overrides. Including it
explicitly helps the AI understand the scope of opinions.
"""
lines = [_section(5, "USD LAYER STACK (strongest → weakest)")]
if stage is None:
lines.append(" [Stage not available]\n")
return lines
try:
layer_stack = stage.GetLayerStack(includeSessionLayers=True)
except Exception:
layer_stack = stage.GetLayerStack()
if not layer_stack:
lines.append(" [Empty layer stack]")
else:
for i, layer in enumerate(layer_stack):
identifier = layer.identifier
is_session = "[session]" if "session" in identifier.lower() else ""
is_anon = "[anonymous]" if identifier.startswith("anon:") else ""
is_dirty = " [DIRTY]" if layer.dirty else ""
sublayer_paths = layer.subLayerPaths
sub_str = f" ({len(sublayer_paths)} sublayer refs)" if sublayer_paths else ""
tag = " ".join(filter(None, [is_anon, is_session]))
tag_str = f" {tag}" if tag else ""
lines.append(f" Layer {i:02d}: {identifier}{tag_str}{is_dirty}{sub_str}")
lines.append("")
return lines
# ─────────────────────────────────────────────────────────────────────────────
# MAIN ENTRY POINT — Button Callback
# ─────────────────────────────────────────────────────────────────────────────
def extract_scene_info(kwargs):
"""
Called by the 'Extract Scene Info' button.
Reads all HDA parameters, assembles the 5 sections, then:
- Always prints to the Houdini Python Shell / console
- Optionally writes to a .txt file if outputToFile is ON
kwargs is the standard Houdini button callback dict:
{ 'node': <hou.LopNode>, 'parm': <hou.Parm>, ... }
"""
hda_node = kwargs["node"]
# ── Read parameters ──────────────────────────────────────────────────────
max_node_depth = hda_node.parm("maxNodeDepth").eval()
max_prim_depth = hda_node.parm("maxPrimDepth").eval()
include_attribs = bool(hda_node.parm("includeAttribs").eval())
include_comp = bool(hda_node.parm("includeComposition").eval())
output_to_file = bool(hda_node.parm("outputToFile").eval())
output_file_path = hda_node.parm("outputFilePath").eval()
# ── Get the USD stage ────────────────────────────────────────────────────
# node.stage() is the LOP-specific method that returns the pxr.Usd.Stage
# at the node's output. May return None if the node has not cooked.
try:
stage = hda_node.stage()
except Exception as e:
stage = None
print(f"[sceneInspector] WARNING: Could not get stage — {e}")
# ── Assemble report ──────────────────────────────────────────────────────
all_lines = []
all_lines.append(_header(hda_node))
all_lines += build_node_graph_section(hda_node, max_node_depth)
all_lines += build_prim_hierarchy_section(stage, max_prim_depth)
if include_attribs:
all_lines += build_attributes_section(stage, max_prim_depth)
else:
all_lines.append(_section(3, "USD ATTRIBUTES") + " [Skipped — includeAttribs is OFF]\n")
if include_comp:
all_lines += build_composition_section(stage, max_prim_depth)
else:
all_lines.append(_section(4, "USD COMPOSITION ARCS") + " [Skipped — includeComposition is OFF]\n")
all_lines += build_layer_stack_section(stage)
# ── Footer ───────────────────────────────────────────────────────────────
all_lines.append(SEP_H * 50)
all_lines.append(f"END OF REPORT — {_now()}")
all_lines.append(SEP_H * 50)
report = "\n".join(all_lines)
# ── Output: Console ──────────────────────────────────────────────────────
print(report)
# ── Output: File (optional) ──────────────────────────────────────────────
if output_to_file and output_file_path:
# hou.expandString resolves $HIP, $JOB, etc.
resolved_path = hou.expandString(output_file_path)
try:
with open(resolved_path, "w", encoding="utf-8") as f:
f.write(report)
print(f"\n[sceneInspector] Report written to: {resolved_path}")
except Exception as e:
print(f"\n[sceneInspector] ERROR writing file: {e}")
else:
print("\n[sceneInspector] Report printed to console only.")
```
# Typical Usage
1. Connect the SceneInspector downstream of the problematic node
2. Make sure the node is cooked (no blocking upstream error)
3. Click **Extract Scene Info**
4. Copy the Python Shell content (Ctrl+A, Ctrl+C)
5. Paste into Claude or ChatGPT with the problem context:
> *"Here is the state of my Houdini Solaris scene. I have the following problem: [description]. Can you help me?"*
> [!TIP] Testing the script without building the HDA
> Paste the script into the Houdini Python Shell, then call it directly:
> ```python
> extract_scene_info({"node": hou.node("/stage/null1")})
> ```
> This allows fast iteration without rebuilding the HDA each time.
> [!WARNING] Very large scenes
> On shots with >50k prims, `stage.Traverse()` can be slow. Reduce `maxPrimDepth` to 3-4 for a faster report.
# Voir aussi
- [[HOUDINI HDA PythonPrinter]] — outil complémentaire pour le logging live d'events
- [[SOLARIS MOC]] — map of contents Solaris
- [[../TROUBLESHOOTING/SOLARIS Fix and problems]] — problèmes courants Solaris
- [[../SCENE GRAPH/SOLARIS Scene graph details]] — comprendre le scene graph USD