# 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