diff --git a/.gitignore b/.gitignore index 00058fc..49b7692 100644 --- a/.gitignore +++ b/.gitignore @@ -94,3 +94,9 @@ claude/ !python/synapse/_vendor/** python/synapse/_vendor/**/__pycache__/ python/synapse/_vendor/**/*.pyc + +# SCOUT reconnaissance artifacts (read-only investigation output) +.scout/ + +# FORGE build-orchestration artifacts (run logs, specs) +.forge/ diff --git a/README.md b/README.md index 8b17868..5cf1db8 100644 --- a/README.md +++ b/README.md @@ -200,6 +200,8 @@ setx ANTHROPIC_API_KEY "sk-ant-..." Launch a fresh Houdini after running `setx` — the new value only reaches processes started after. +Or run the helper **`set_anthropic_key.bat`** at the repo root: it prompts for the key, persists it with `setx`, and reminds you to relaunch Houdini — so you don't have to remember the command or the system-vs-shell-scope gotcha. + **Forward-compat — `hou.secure`.** When SideFX ships a secure-credentials API in a future Houdini release, SYNAPSE's auth resolver picks it up automatically. Confirmed **not present** in Houdini 21.0.671 (`dir(hou)` only exposes `secureSelectionOption`). No action needed today. **You're good if:** in Houdini's Python Shell, `import os; print(bool(os.environ.get('ANTHROPIC_API_KEY')))` prints `True`. @@ -239,7 +241,7 @@ daemon.stop() | **Perception channel — `TopsEventBridge`** (Spike 3.1) | Scaffolded. 47 tests across basic + hostile. Standalone mode only. Live PDG cook lands at Mile 5. | | **Perception channel — `SceneLoadBridge`** (Spike 3.2) | Scaffolded. 24 tests across basic + hostile. Composes a `TopsEventBridge`; auto-warm on `hou.hipFile.AfterLoad`. Live integration at Mile 5. | | **Tools ported through the Dispatcher** | **1** — `synapse_inspect_stage` (flat `/stage` AST). | -| **Tools still on the Sprint 2 WebSocket path** | **104** — registry tools working in production, awaiting port. (Plus 6 group-info knowledge tools that don't need porting — they serve local content without Houdini.) | +| **Tools still on the Sprint 2 WebSocket path** | **108** — registry tools working in production, awaiting port (104 → 108 with the v5.9.0 SCOUT→FORGE additions below). (Plus 6 group-info knowledge tools that don't need porting — they serve local content without Houdini.) | The port pattern is mechanical and documented in `docs/crucible_protocol.md` + the `spike(1)` commit message. Every legacy tool gets: @@ -247,6 +249,41 @@ The port pattern is mechanical and documented in `docs/crucible_protocol.md` + t 2. A schema dict (description + JSON Schema) registered alongside the function. 3. The WS adapter branch in `mcp_server.py` swapped from `synapse_inspect_stage`-style direct dispatch to `dispatcher.execute('', kwargs)`. +### v5.9.0 — SCOUT → FORGE: 7 verified capabilities + +A read-only **SCOUT** recon cross-referenced the Houdini 21.0.671 capability surface against the live tool registry, surfaced 7 opportunities, and **V1-verified every one against the exact target build** (21.0.671 `hython`) before any code was written. A **FORGE** MOE agent team then built and unit-tested them, with **CRUCIBLE** adversarial review gating the merge. Registry **104 → 108 tools**: + +- `houdini_set_payload_loadstate` — USD payload load/unload + activation +- `houdini_create_point_instancer` — `UsdGeom.PointInstancer` authoring +- `houdini_shot_render_ready` — shot-template composite orchestrator +- `cops_create_copnet` — modern Copernicus `copnet` (distinct from the legacy `cop2net` the existing COPs tools build on) +- `houdini_reference_usd` + `karma_visible`/`purpose`/`kind` — non-clobbering Karma-visibility metadata on import (completes the BL-008 advisory-only partial) +- `houdini_modify_usd_prim` + `instanceable` +- branch-aware, path-keyed upstream Karma-LOP discovery in the render walk + +Plus bridge/panel hardening: read-only tool failures surface as JSON-RPC errors instead of success-with-`isError`, and the panel resolves the Anthropic key through the canonical auth layer with an actionable "set it + relaunch" message. + +```mermaid +%%{init: {'theme':'base', 'themeVariables': {'primaryColor':'#1e293b','primaryTextColor':'#f1f5f9','primaryBorderColor':'#0f172a','lineColor':'#f59e0b','secondaryColor':'#334155','tertiaryColor':'#475569'}}}%% +flowchart LR + S["SCOUT
read-only recon
RAG + codebase"] -->|7 opportunities
V1-verified on 21.0.671| F["FORGE
MOE agent team
build + unit test"] + F -->|diff| R["CRUCIBLE
adversarial review"] + R -->|fix-forward| F + R -->|108 tools, green| P["PR 4
shipped"] + classDef scout fill:#1e293b,stroke:#f59e0b,color:#f1f5f9 + classDef forge fill:#1e293b,stroke:#3b82f6,color:#f1f5f9 + classDef cruc fill:#1e293b,stroke:#ef4444,color:#f1f5f9 + classDef gate fill:#334155,stroke:#22c55e,color:#f1f5f9 + class S scout + class F forge + class R cruc + class P gate +``` + +Behavioral verification (Karma cook of `copnet`, EXR landing, USD editableStage round-trips) is deferred to a live 21.0.671 session. + +--- + ### Sprint 3 progress — Mile 4 of 6 closed ```mermaid diff --git a/houdini/python_panels/synapse_panel.pypanel b/houdini/python_panels/synapse_panel.pypanel index eb9f6e3..704a49e 100644 --- a/houdini/python_panels/synapse_panel.pypanel +++ b/houdini/python_panels/synapse_panel.pypanel @@ -199,9 +199,17 @@ class _FallbackClaudeWorker(QtCore.QThread): def run(self): try: - api_key = os.environ.get("ANTHROPIC_API_KEY", "") + try: + from synapse.host.auth import get_anthropic_api_key + api_key = get_anthropic_api_key() or "" + except Exception: + api_key = os.environ.get("ANTHROPIC_API_KEY", "") if not api_key: - self.stream_error.emit("ANTHROPIC_API_KEY not set in environment") + self.stream_error.emit( + 'No Anthropic API key found. Set it at the SYSTEM level and ' + 'relaunch Houdini: setx ANTHROPIC_API_KEY "sk-ant-..." ' + "(a terminal-scoped `set` won't carry into Houdini on Windows)." + ) return body = { diff --git a/mcp_tools_cops.py b/mcp_tools_cops.py index c63a5e0..7352b44 100644 --- a/mcp_tools_cops.py +++ b/mcp_tools_cops.py @@ -28,6 +28,7 @@ TOOL_NAMES = [ # Foundation "cops_create_network", + "cops_create_copnet", "cops_create_node", "cops_connect", "cops_set_opencl", @@ -55,6 +56,7 @@ # Dispatch entries for this group DISPATCH_KEYS = { "cops_create_network": ("cops_create_network", "identity"), + "cops_create_copnet": ("cops_create_copnet", "identity"), "cops_create_node": ("cops_create_node", "identity"), "cops_connect": ("cops_connect", "identity"), "cops_set_opencl": ("cops_set_opencl", "identity"), diff --git a/mcp_tools_usd.py b/mcp_tools_usd.py index 1c83487..94f62da 100644 --- a/mcp_tools_usd.py +++ b/mcp_tools_usd.py @@ -29,6 +29,9 @@ "houdini_create_usd_prim", "houdini_modify_usd_prim", "houdini_reference_usd", + "houdini_set_payload_loadstate", + "houdini_create_point_instancer", + "houdini_shot_render_ready", "houdini_query_prims", "houdini_manage_variant_set", "houdini_manage_collection", @@ -47,8 +50,11 @@ "houdini_get_usd_attribute": ("get_usd_attribute", "filter_keys:node,prim_path,attribute_name"), "houdini_set_usd_attribute": ("set_usd_attribute", "filter_keys:node,prim_path,attribute_name,value"), "houdini_create_usd_prim": ("create_usd_prim", "filter_keys:node,prim_path,prim_type"), - "houdini_modify_usd_prim": ("modify_usd_prim", "filter_keys:node,prim_path,kind,purpose,active"), + "houdini_modify_usd_prim": ("modify_usd_prim", "filter_keys:node,prim_path,kind,purpose,active,instanceable"), "houdini_reference_usd": ("reference_usd", "identity"), + "houdini_set_payload_loadstate": ("set_payload_loadstate", "identity"), + "houdini_create_point_instancer": ("create_point_instancer", "identity"), + "houdini_shot_render_ready": ("shot_render_ready", "identity"), "houdini_query_prims": ("query_prims", "identity"), "houdini_manage_variant_set": ("manage_variant_set", "identity"), "houdini_manage_collection": ("manage_collection", "identity"), diff --git a/python/synapse/__init__.py b/python/synapse/__init__.py index b2a9437..17f1f3e 100644 --- a/python/synapse/__init__.py +++ b/python/synapse/__init__.py @@ -58,7 +58,7 @@ __title__ = "Synapse" -__version__ = "5.8.0" +__version__ = "5.9.0" __author__ = "Joe Ibrahim" __license__ = "MIT" __product__ = "Synapse - AI-Houdini Bridge" diff --git a/python/synapse/core/protocol.py b/python/synapse/core/protocol.py index 04aa1be..72dc2fd 100644 --- a/python/synapse/core/protocol.py +++ b/python/synapse/core/protocol.py @@ -64,6 +64,9 @@ class CommandType(Enum): GET_STAGE_INFO = "get_stage_info" SET_USD_ATTRIBUTE = "set_usd_attribute" GET_USD_ATTRIBUTE = "get_usd_attribute" + SET_PAYLOAD_LOADSTATE = "set_payload_loadstate" + CREATE_POINT_INSTANCER = "create_point_instancer" + SHOT_RENDER_READY = "shot_render_ready" QUERY_PRIMS = "query_prims" MANAGE_VARIANT_SET = "manage_variant_set" MANAGE_COLLECTION = "manage_collection" @@ -101,6 +104,7 @@ class CommandType(Enum): # Copernicus (COPs) — Foundation COPS_CREATE_NETWORK = "cops_create_network" + COPS_CREATE_COPNET = "cops_create_copnet" COPS_CREATE_NODE = "cops_create_node" COPS_CONNECT = "cops_connect" COPS_SET_OPENCL = "cops_set_opencl" diff --git a/python/synapse/mcp/_tool_registry.py b/python/synapse/mcp/_tool_registry.py index 00082a5..4f13bd3 100644 --- a/python/synapse/mcp/_tool_registry.py +++ b/python/synapse/mcp/_tool_registry.py @@ -248,14 +248,15 @@ def _delete_node_payload(args: dict) -> dict: False, True, False), ("houdini_modify_usd_prim", "modify_usd_prim", - _filter_keys(("node", "prim_path", "kind", "purpose", "active")), - "Modify USD prim metadata: kind, purpose, or active state.", + _filter_keys(("node", "prim_path", "kind", "purpose", "active", "instanceable")), + "Modify USD prim metadata: kind, purpose, active state, or instanceable flag.", {"type": "object", "properties": { "node": {"type": "string", "description": "LOP node to wire after (optional)"}, "prim_path": {"type": "string", "description": "USD prim path"}, "kind": {"type": "string", "description": "Model kind"}, "purpose": {"type": "string", "description": "Prim purpose"}, "active": {"type": "boolean", "description": "Whether the prim is active"}, + "instanceable": {"type": "boolean", "description": "Set the prim's instanceable flag"}, }, "required": ["prim_path"]}, False, True, False), @@ -522,9 +523,56 @@ def _delete_node_payload(args: dict) -> dict: "mode": {"type": "string", "enum": ["reference", "payload", "sublayer"], "description": "Import mode: reference (default), payload (deferred load), or sublayer (most Karma-compatible)"}, "parent": {"type": "string", "description": "Parent LOP network path"}, + "karma_visible": {"type": "boolean", "description": "Author purpose/kind on the referenced prim for Karma visibility (default: true)"}, + "purpose": {"type": "string", "description": "USD purpose to author non-clobberingly (default: default)"}, + "kind": {"type": "string", "description": "USD model kind to author non-clobberingly (default: component)"}, }, "required": ["file"]}, False, True, False), + ("houdini_set_payload_loadstate", "set_payload_loadstate", _identity, + "Control USD payload load state and prim activation. Load/unload a payload by " + "prim path and/or toggle the prim's active flag. Use to defer-load or release " + "heavy referenced assets.", + {"type": "object", "properties": { + "node": {"type": "string", "description": "LOP node to wire after (optional)"}, + "prim_path": {"type": "string", "description": "USD prim path carrying the payload"}, + "action": {"type": "string", "enum": ["load", "unload"], + "description": "Load or unload the payload (optional)"}, + "active": {"type": "boolean", "description": "Set prim active/inactive (optional)"}, + }, "required": ["prim_path"]}, + False, True, False), + + ("houdini_create_point_instancer", "create_point_instancer", _identity, + "Author a UsdGeom.PointInstancer: scatter prototype prims across positions. " + "Minimal valid setup -- defines the instancer, sets the prototypes relationship, " + "protoIndices (defaults to zeros), and positions.", + {"type": "object", "properties": { + "node": {"type": "string", "description": "LOP node to wire after (optional)"}, + "prim_path": {"type": "string", "description": "USD prim path for the PointInstancer"}, + "prototypes": {"type": "array", "items": {"type": "string"}, + "description": "Prototype prim paths to instance"}, + "positions": {"type": "array", "items": {"type": "array", "items": {"type": "number"}}, + "description": "Instance positions as [[x,y,z], ...]"}, + }, "required": ["prim_path"]}, + False, True, False), + + ("houdini_shot_render_ready", "shot_render_ready", _identity, + "Composite orchestrator: get a shot render-ready in one call. Runs " + "create_textured_material -> solaris_assemble_chain -> safe_render in sequence, " + "threading outputs, and returns a per-step summary with any errors. Orchestrates " + "existing primitives -- does not re-implement them.", + {"type": "object", "properties": { + "diffuse_map": {"type": "string", "description": "Diffuse/albedo texture for the material step (optional)"}, + "material_name": {"type": "string", "description": "Material name (optional)"}, + "geo_pattern": {"type": "string", "description": "Geometry prim pattern to assign the material to (optional)"}, + "parent": {"type": "string", "description": "LOP network path for assembly (default: /stage)"}, + "rop_path": {"type": "string", "description": "Render ROP path (auto-discovers if omitted)"}, + "width": {"type": "integer", "description": "Render width override"}, + "height": {"type": "integer", "description": "Render height override"}, + "skip_render": {"type": "boolean", "description": "Assemble only, skip the render step (default: false)"}, + }, "required": []}, + False, True, False), + ("houdini_query_prims", "query_prims", _identity, "Query USD stage prims with filtering by type, purpose, and name pattern. " "Returns matching prims with their paths, types, and metadata.", @@ -1019,6 +1067,15 @@ def _delete_node_payload(args: dict) -> dict: }, "required": []}, False, True, False), + ("cops_create_copnet", "cops_create_copnet", _identity, + "Create a modern Copernicus 'copnet' network (H21 Copernicus, distinct from legacy cop2net).", + {"type": "object", "properties": { + "parent": {"type": "string", "description": "Parent node path (default: /obj)"}, + "name": {"type": "string", "description": "Network name (default: copnet)"}, + "starter": {"type": "string", "description": "Optional COP node type to create inside the new copnet"}, + }, "required": []}, + False, True, False), + ("cops_create_node", "cops_create_node", _identity, "Create a COP node inside a COP network.", {"type": "object", "properties": { diff --git a/python/synapse/mcp/server.py b/python/synapse/mcp/server.py index f6bc42f..59a19e2 100644 --- a/python/synapse/mcp/server.py +++ b/python/synapse/mcp/server.py @@ -75,6 +75,14 @@ def _dumps(obj) -> bytes: logger = logging.getLogger("synapse.mcp") + +def _isError_text(result: dict) -> str: + """Extract a human-readable error message from a dispatch_tool isError result.""" + content = result.get("content", []) + if content and isinstance(content[0], dict): + return content[0].get("text", "Unknown error") + return "Unknown error" + # SSE event types SSE_RENDER_PROGRESS = "synapse/render_progress" SSE_GATE_REQUEST = "synapse/gate_request" @@ -423,7 +431,13 @@ def _handle_tools_call(self, params: dict, session_id: Optional[str] = None) -> ) except ImportError: result = dispatch_tool(handler, tool_name, arguments) - if self._circuit_breaker and not (isinstance(result, dict) and result.get("isError")): + # Propagate tool errors to the JSON-RPC layer here too — read-only + # tools skip resilience, but a handler failure must still surface as + # a JSON-RPC error rather than a success result with isError buried + # inside (matches the non-read-only path below). + if isinstance(result, dict) and result.get("isError"): + raise JsonRpcError(INTERNAL_ERROR, _isError_text(result)) + if self._circuit_breaker: self._circuit_breaker.record_success() return result @@ -486,10 +500,7 @@ def _handle_tools_call(self, params: dict, session_id: Optional[str] = None) -> # Propagate tool errors to JSON-RPC layer so MCP clients detect failures if isinstance(result, dict) and result.get("isError"): - error_text = "Unknown error" - content = result.get("content", []) - if content and isinstance(content[0], dict): - error_text = content[0].get("text", error_text) + error_text = _isError_text(result) # Only record infrastructure failures for circuit breaker # (not user errors like "node not found") if any(k in error_text.lower() for k in ("timeout", "main thread", "crashed", "unresponsive")): diff --git a/python/synapse/panel/bridge_adapter.py b/python/synapse/panel/bridge_adapter.py index 529e17a..3afed26 100644 --- a/python/synapse/panel/bridge_adapter.py +++ b/python/synapse/panel/bridge_adapter.py @@ -255,6 +255,7 @@ def _dispatch(*_args, **_kwargs): from synapse.core.protocol import SynapseResponse integrity_dict = result.integrity.to_dict() if result.integrity else {} return SynapseResponse( + id=command.id, success=False, error="Bridge: {}".format(result.error or "Unknown error"), data={"_integrity": integrity_dict}, diff --git a/python/synapse/panel/claude_worker.py b/python/synapse/panel/claude_worker.py index 7494cfc..e42471b 100644 --- a/python/synapse/panel/claude_worker.py +++ b/python/synapse/panel/claude_worker.py @@ -14,7 +14,6 @@ import http.client import json import logging -import os import ssl from typing import Optional @@ -91,9 +90,21 @@ def get_messages(self) -> list[dict]: def run(self) -> None: """Entry point executed on the background thread.""" try: - api_key = os.environ.get("ANTHROPIC_API_KEY", "") + # Resolve via the canonical auth layer: hou.secure (where available) + # then the ANTHROPIC_API_KEY env var, whitespace-stripped, never + # raising. Avoids the old raw os.environ read that missed keys stored + # in hou.secure and surfaced a cryptic message. + from ..host.auth import get_anthropic_api_key + api_key = get_anthropic_api_key() if not api_key: - self.stream_error.emit("ANTHROPIC_API_KEY not set in environment") + self.stream_error.emit( + "No Anthropic API key found. Set it at the SYSTEM level so " + "Houdini inherits it, then relaunch Houdini: " + 'setx ANTHROPIC_API_KEY "sk-ant-..." ' + "(a terminal-scoped `set` won't carry into Houdini on Windows). " + "On builds exposing hou.secure you can instead run, in Houdini's " + "Python shell: hou.secure.setPassword('synapse_anthropic', 'sk-ant-...')." + ) return self._conversation_loop(api_key) diff --git a/python/synapse/server/handlers.py b/python/synapse/server/handlers.py index 1bdf89d..3e8b7b0 100644 --- a/python/synapse/server/handlers.py +++ b/python/synapse/server/handlers.py @@ -90,6 +90,9 @@ "modify_usd_prim": AuditCategory.PIPELINE, "set_usd_attribute": AuditCategory.PIPELINE, "reference_usd": AuditCategory.PIPELINE, + "set_payload_loadstate": AuditCategory.PIPELINE, + "create_point_instancer": AuditCategory.PIPELINE, + "shot_render_ready": AuditCategory.RENDER, "query_prims": AuditCategory.PIPELINE, "manage_variant_set": AuditCategory.PIPELINE, "manage_collection": AuditCategory.PIPELINE, @@ -139,6 +142,7 @@ "hda_list": AuditCategory.PIPELINE, # Copernicus (COPs) — Foundation "cops_create_network": AuditCategory.PIPELINE, + "cops_create_copnet": AuditCategory.PIPELINE, "cops_create_node": AuditCategory.PIPELINE, "cops_connect": AuditCategory.PIPELINE, "cops_set_opencl": AuditCategory.PIPELINE, @@ -419,6 +423,9 @@ def _register_handlers(self): # USD scene assembly (reference / sublayer / payload) + prim queries reg.register("reference_usd", self._handle_reference_usd) + reg.register("set_payload_loadstate", self._handle_set_payload_loadstate) + reg.register("create_point_instancer", self._handle_create_point_instancer) + reg.register("shot_render_ready", self._handle_shot_render_ready) reg.register("query_prims", self._handle_query_prims) reg.register("manage_variant_set", self._handle_manage_variant_set) reg.register("manage_collection", self._handle_manage_collection) @@ -501,6 +508,7 @@ def _register_handlers(self): # Copernicus (COPs) — Foundation reg.register("cops_create_network", self._handle_cops_create_network) + reg.register("cops_create_copnet", self._handle_cops_create_copnet) reg.register("cops_create_node", self._handle_cops_create_node) reg.register("cops_connect", self._handle_cops_connect) reg.register("cops_set_opencl", self._handle_cops_set_opencl) diff --git a/python/synapse/server/handlers_cops.py b/python/synapse/server/handlers_cops.py index 0567ee7..e52880e 100644 --- a/python/synapse/server/handlers_cops.py +++ b/python/synapse/server/handlers_cops.py @@ -87,6 +87,71 @@ def _on_main(): return run_on_main(_on_main) + def _handle_cops_create_copnet(self, payload: Dict) -> Dict: + """Create a modern Copernicus 'copnet' network container. + + Distinct from the legacy 'cop2net' built by _handle_cops_create_network: + H21 Copernicus uses the 'copnet' node type. This is the foundational + modern-Copernicus surface — all 20 existing cops_* tools build on the + legacy cop2net; this adds the modern container without rewriting them. + + Payload: + parent (str): Parent node path (default: '/obj' — mirrors + _handle_cops_create_network, a container creator). + name (str): Network name (default: 'copnet'). + starter (str): Optional single COP node type to create inside the + new copnet so the network is non-empty (mirrors the + initial_nodes pattern of create_network, single-node form). + + Returns: + Dict with network path, type name, and optional starter node path. + + Note: + Behavioral verification (does the copnet cook through Karma XPU) + is DEFERRED — the live bridge is down. This handler only builds + the network; it intentionally performs no cook() calls. + """ + if not HOU_AVAILABLE: + raise RuntimeError(_HOUDINI_UNAVAILABLE) + + parent_path = resolve_param_with_default(payload, "parent", "/obj") + name = resolve_param_with_default(payload, "name", "copnet") + starter = resolve_param_with_default(payload, "starter", None) + + from .main_thread import run_on_main + + def _on_main(): + parent = hou.node(parent_path) + if parent is None: + raise ValueError( + f"Couldn't find parent node '{parent_path}' -- " + "check the path and try again" + ) + + with hou.undos.group("synapse_cops_create_copnet"): + network = parent.createNode("copnet", name) + if network is None: + raise RuntimeError( + "Couldn't create copnet -- " + "make sure Copernicus is available in your Houdini build" + ) + network.moveToGoodPosition() + + starter_path = None + if starter: + child = network.createNode(str(starter)) + if child is not None: + child.moveToGoodPosition() + starter_path = child.path() + + return { + "network_path": network.path(), + "type": network.type().name(), + "starter_node": starter_path, + } + + return run_on_main(_on_main) + def _handle_cops_create_node(self, payload: Dict) -> Dict: """Create a COP node inside a COP network. diff --git a/python/synapse/server/handlers_render.py b/python/synapse/server/handlers_render.py index 0428bc4..7f8e2f5 100644 --- a/python/synapse/server/handlers_render.py +++ b/python/synapse/server/handlers_render.py @@ -224,21 +224,43 @@ def _render_on_main(): # If ROP has no output path, check the upstream Karma LOP if not artist_output.strip() and lop_target_node is not None: try: - # Walk from the LOP target up to find a karma node with picture - _walk = lop_target_node - for _ in range(20): # bounded walk + # Branch-aware bounded breadth-first walk: a Karma LOP + # feeding the ROP may be reachable only via a non-zero + # input index (e.g. inputs()[1] of a merge/switch LOP), + # so follow ALL inputs -- not just inputs()[0]. A visited + # set + node budget keep diamond/cyclic graphs from + # looping forever. First non-empty picture wins (BFS pops + # shallowest first -> nearest Karma LOP). + _queue = [lop_target_node] + _visited = set() + _budget = 20 # max nodes visited (matches prior 20-hop cap) + while _queue and _budget > 0: + _walk = _queue.pop(0) + # Key on .path() (stable per node): hou.Node.inputs() + # returns FRESH wrappers each call, so id() never dedups + # real nodes -- matches the .path()/.sessionId() dedup + # convention in guards.py / network_trace.py. + try: + _key = _walk.path() + except Exception: + _key = id(_walk) + if _key in _visited: + continue + _visited.add(_key) + _budget -= 1 # count distinct nodes visited, not revisits kp = _walk.parm("picture") if kp: _val = kp.eval() or "" # noqa: S307 if _val.strip(): artist_output = _val break - # Try parent's children (sibling karma nodes) - inputs = _walk.inputs() - if inputs: - _walk = inputs[0] - else: - break + # Enqueue every upstream input, not just index 0 + try: + _queue.extend( + _i for _i in _walk.inputs() if _i is not None + ) + except Exception: + pass except Exception: pass # best-effort upstream discovery diff --git a/python/synapse/server/handlers_usd.py b/python/synapse/server/handlers_usd.py index b9a8a83..d42cdc9 100644 --- a/python/synapse/server/handlers_usd.py +++ b/python/synapse/server/handlers_usd.py @@ -82,6 +82,69 @@ def _usd_to_json(value): return str(value) +def _coerce_bool(value, default: bool = True) -> bool: + """Coerce a value to bool, treating stringified false-tokens as False. + + Real booleans pass straight through. The strings 'false', '0', 'no', + and 'off' (case-insensitive, whitespace-trimmed) -> False. Anything + else falls back to Python truthiness. ``None`` returns ``default``. + """ + if value is None: + return default + if isinstance(value, bool): + return value + if isinstance(value, str): + if value.strip().lower() in ("false", "0", "no", "off", ""): + return False + return True + return bool(value) + + +def _karma_visibility_pythonscript( + prim_path: str, + purpose: str = "default", + kind: str = "component", +) -> str: + """Return a pythonscript-LOP code string that authors purpose + kind. + + The emitted script runs inside Houdini (the ``pxr`` import lives inside + the returned string, NOT at this call site, so this helper has no + Houdini/pxr dependency and is unit-testable standalone). + + Behavior of the emitted script: + - Resolves the target prim on the editable stage. When ``prim_path`` is + "/" it falls back to ``stage.GetDefaultPrim()``. + - Authors ``purpose`` ONLY if the purpose attribute has no authored value + (non-clobbering — respects existing pipeline opinions). + - Authors ``kind`` ONLY if the prim has no kind already set. + + Args: + prim_path: USD prim path to author on ("/" -> default prim). + purpose: USD purpose token (default "default"). + kind: USD model kind (default "component"). + + Returns: + Python source string suitable for a pythonscript LOP "python" parm. + """ + return "\n".join([ + "from pxr import Usd, UsdGeom", + "stage = hou.pwd().editableStage()", + f"_prim_path = {repr(prim_path)}", + "if _prim_path == '/':", + " prim = stage.GetDefaultPrim()", + "else:", + " prim = stage.GetPrimAtPath(_prim_path)", + "if prim:", + " imageable = UsdGeom.Imageable(prim)", + " purpose_attr = imageable.GetPurposeAttr()", + " if not purpose_attr.HasAuthoredValue():", + f" purpose_attr.Set({repr(purpose)})", + " model_api = Usd.ModelAPI(prim)", + " if not model_api.GetKind():", + f" model_api.SetKind({repr(kind)})", + ]) + + class UsdHandlerMixin: """Mixin providing USD/Solaris stage handlers.""" @@ -382,6 +445,7 @@ def _handle_modify_usd_prim(self, payload: Dict) -> Dict: kind = resolve_param(payload, "kind", required=False) purpose = resolve_param(payload, "purpose", required=False) active = resolve_param(payload, "active", required=False) + instanceable = resolve_param(payload, "instanceable", required=False) # Validate before touching hou.* mods = {} @@ -391,10 +455,13 @@ def _handle_modify_usd_prim(self, payload: Dict) -> Dict: mods["purpose"] = purpose if active is not None: mods["active"] = active + if instanceable is not None: + mods["instanceable"] = bool(instanceable) if not mods: raise ValueError( - "No changes specified -- pass at least one of: kind, purpose, or active" + "No changes specified -- pass at least one of: " + "kind, purpose, active, or instanceable" ) from .main_thread import run_on_main @@ -421,6 +488,8 @@ def _on_main(): lines.append(f" UsdGeom.Imageable(prim).GetPurposeAttr().Set({repr(purpose)})") if active is not None: lines.append(f" prim.SetActive({active})") + if instanceable is not None: + lines.append(f" prim.SetInstanceable({bool(instanceable)})") code = "\n".join(lines) py_lop.parm("python").set(code) @@ -470,6 +539,14 @@ def _handle_reference_usd(self, payload: Dict) -> Dict: mode = resolve_param_with_default(payload, "mode", "reference") parent = resolve_param_with_default(payload, "parent", "/stage") + # Karma visibility metadata (BL-008): for reference/payload modes, + # non-clobberingly author purpose + kind so the asset renders in Karma. + karma_visible = _coerce_bool( + resolve_param(payload, "karma_visible", required=False), default=True + ) + purpose = resolve_param_with_default(payload, "purpose", "default") + kind = resolve_param_with_default(payload, "kind", "component") + # Validate mode before touching hou.* if mode not in ("sublayer", "reference", "payload"): raise ValueError( @@ -520,14 +597,46 @@ def _on_main(): "prim_path": prim_path, } - # Add Karma visibility advisory for non-sublayer modes if mode in ("reference", "payload"): - result["advisory"] = ( - "For Karma rendering, 'sublayer' is the most reliable import " - "mode. If this asset isn't visible in renders, try switching " - "to mode='sublayer' or ensure the imported prims have " - "purpose='default' and kind='component' metadata." - ) + if karma_visible: + # BL-008: author purpose/kind non-clobberingly downstream + # of the reference LOP so the asset renders in Karma. + # NOTE: created on parent_node (=/stage), wired off the + # just-created reference LOP -- NOT node.parent(). + kv_lop = parent_node.createNode( + "pythonscript", "karma_visibility" + ) + kv_lop.setInput(0, node) + kv_lop.moveToGoodPosition() + kv_lop.parm("python").set( + _karma_visibility_pythonscript(prim_path, purpose, kind) + ) + try: + kv_lop.cook(force=True) + result["karma_visibility"] = { + "node": kv_lop.path(), + "purpose": purpose, + "kind": kind, + "policy": "non-clobbering", + } + except hou.OperationFailed as e: + # Soft failure -- the reference still imported. + result["karma_visibility_error"] = ( + "The reference imported, but authoring Karma " + f"visibility metadata hit a snag -- {e}. The " + "pythonscript node is still in the network so you " + "can inspect it. The asset may need purpose/kind " + "set manually for Karma to render it." + ) + else: + # karma_visible disabled -- keep the legacy advisory. + result["advisory"] = ( + "For Karma rendering, 'sublayer' is the most reliable " + "import mode. If this asset isn't visible in renders, " + "try switching to mode='sublayer' or ensure the imported " + "prims have purpose='default' and kind='component' " + "metadata." + ) return result @@ -1239,3 +1348,265 @@ def _walk(node, depth: int): } return run_on_main(_on_main) + + def _handle_set_payload_loadstate(self, payload: Dict) -> Dict: + """Control USD payload load state and/or prim activation (BL-008). + + Generates a pythonscript LOP that loads/unloads a payload by prim + path and/or toggles the prim's active flag. Use to defer-load or + release heavy referenced assets. + + Payload: + prim_path (str, required): USD prim path carrying the payload. + action ("load" | "unload", optional): Load or unload the payload. + active (bool, optional): Set the prim active/inactive. + node (str, optional): LOP node to wire after. + """ + if not HOU_AVAILABLE: + raise RuntimeError(_HOUDINI_UNAVAILABLE) + + node_path_arg = resolve_param(payload, "node", required=False) + prim_path = resolve_param(payload, "prim_path") + action = resolve_param(payload, "action", required=False) + active = resolve_param(payload, "active", required=False) + + if action is not None and action not in ("load", "unload"): + raise ValueError( + f"'{action}' isn't a recognized action -- use 'load' or 'unload'" + ) + if action is None and active is None: + raise ValueError( + "No change specified -- pass 'action' (load/unload) and/or 'active'" + ) + + from .main_thread import run_on_main + + def _on_main(): + node = self._resolve_lop_node(node_path_arg) + + with hou.undos.group("SYNAPSE: set_payload_loadstate"): + parent = node.parent() + safe_name = prim_path.rstrip("/").rsplit("/", 1)[-1] or "prim" + py_lop = parent.createNode("pythonscript", f"loadstate_{safe_name}") + py_lop.setInput(0, node) + py_lop.moveToGoodPosition() + + lines = [ + "from pxr import Sdf", + "stage = hou.pwd().editableStage()", + f"_prim_path = Sdf.Path({repr(prim_path)})", + ] + if action == "load": + lines.append("stage.Load(_prim_path)") + elif action == "unload": + lines.append("stage.Unload(_prim_path)") + if active is not None: + lines.append("prim = stage.GetPrimAtPath(_prim_path)") + lines.append("if prim:") + lines.append(f" prim.SetActive({bool(active)})") + + code = "\n".join(lines) + py_lop.parm("python").set(code) + + try: + py_lop.cook(force=True) + except hou.OperationFailed as e: + error_msg = str(e) + return { + "created_node": py_lop.path(), + "prim_path": prim_path, + "cook_error": ( + f"The node was created but hit a snag when cooking -- {error_msg}. " + "The pythonscript node is still in the network so you can inspect it. " + "Check that the prim path exists and carries a payload." + ), + } + + result = { + "created_node": py_lop.path(), + "prim_path": prim_path, + } + if action is not None: + result["action"] = action + if active is not None: + result["active"] = bool(active) + return result + + return run_on_main(_on_main) + + def _handle_create_point_instancer(self, payload: Dict) -> Dict: + """Author a minimal-but-valid UsdGeom.PointInstancer. + + Defines a PointInstancer at prim_path, sets its prototypes + relationship, protoIndices (defaults to zeros, one per position), + and positions. Correctness over completeness -- this is a minimal + valid setup the artist can build on. + + Payload: + prim_path (str, required): USD prim path for the PointInstancer. + prototypes (list[str], optional): Prototype prim paths to instance. + positions (list[[x,y,z]], optional): Instance positions. + node (str, optional): LOP node to wire after. + """ + if not HOU_AVAILABLE: + raise RuntimeError(_HOUDINI_UNAVAILABLE) + + node_path_arg = resolve_param(payload, "node", required=False) + prim_path = resolve_param(payload, "prim_path") + prototypes = resolve_param(payload, "prototypes", required=False) or [] + positions = resolve_param(payload, "positions", required=False) or [] + + if not isinstance(prototypes, (list, tuple)): + raise ValueError("'prototypes' must be a list of prim path strings") + if not isinstance(positions, (list, tuple)): + raise ValueError("'positions' must be a list of [x, y, z] triples") + if positions and not prototypes: + raise ValueError( + "'positions' require at least one 'prototypes' entry -- " + "protoIndices would otherwise reference a non-existent prototype" + ) + + from .main_thread import run_on_main + + def _on_main(): + node = self._resolve_lop_node(node_path_arg) + + with hou.undos.group("SYNAPSE: create_point_instancer"): + parent = node.parent() + safe_name = prim_path.rstrip("/").rsplit("/", 1)[-1] or "instancer" + py_lop = parent.createNode("pythonscript", f"instancer_{safe_name}") + py_lop.setInput(0, node) + py_lop.moveToGoodPosition() + + proto_list = [str(p) for p in prototypes] + pos_list = [[float(c) for c in pos] for pos in positions] + # protoIndices: one per position, all referencing prototype 0. + proto_indices = [0] * len(pos_list) + + lines = [ + "from pxr import UsdGeom, Sdf, Vt, Gf", + "stage = hou.pwd().editableStage()", + f"instancer = UsdGeom.PointInstancer.Define(stage, {repr(prim_path)})", + f"_protos = {repr(proto_list)}", + f"_positions = {repr(pos_list)}", + # positions/protoIndices are only valid alongside prototypes; + # with none, author an empty-but-valid instancer (no dangling + # protoIndices pointing at a non-existent prototype 0). + "if _protos:", + " instancer.CreatePrototypesRel().SetTargets(" + "[Sdf.Path(p) for p in _protos])", + " instancer.CreatePositionsAttr().Set(" + "Vt.Vec3fArray([Gf.Vec3f(*p) for p in _positions]))", + f" instancer.CreateProtoIndicesAttr().Set(Vt.IntArray({repr(proto_indices)}))", + ] + + code = "\n".join(lines) + py_lop.parm("python").set(code) + + try: + py_lop.cook(force=True) + except hou.OperationFailed as e: + error_msg = str(e) + return { + "created_node": py_lop.path(), + "prim_path": prim_path, + "cook_error": ( + f"The node was created but hit a snag when cooking -- {error_msg}. " + "The pythonscript node is still in the network so you can inspect it. " + "Check that the prototype paths resolve on the stage." + ), + } + + return { + "created_node": py_lop.path(), + "prim_path": prim_path, + "prototypes": proto_list, + "instance_count": len(pos_list), + } + + return run_on_main(_on_main) + + def _handle_shot_render_ready(self, payload: Dict) -> Dict: + """Composite orchestrator: get a shot render-ready in one call. + + Runs the existing primitives in sequence -- create_textured_material + -> solaris_assemble_chain -> safe_render -- threading outputs between + them, and returns a per-step summary. Each step is individually + try/excepted so a partial pipeline still returns a structured summary. + + This orchestrates existing handlers only; it does not re-implement + their logic. + + Payload: + diffuse_map (str, optional): Diffuse texture for the material step. + material_name (str, optional): Material name. + geo_pattern (str, optional): Geometry prim pattern to assign to. + parent (str, optional): LOP network path for assembly (default /stage). + rop_path (str, optional): Render ROP path (auto-discovers if omitted). + width / height (int, optional): Render resolution overrides. + skip_render (bool, optional): Assemble only, skip the render step. + """ + steps: List[Dict] = [] + passed = True + + # --- Step 1: textured material ------------------------------------- + material_usd_path = None + mat_payload = { + "name": resolve_param_with_default( + payload, "material_name", "shot_material" + ), + } + diffuse_map = resolve_param(payload, "diffuse_map", required=False) + if diffuse_map is not None: + mat_payload["diffuse_map"] = diffuse_map + for opt in ("roughness_map", "normal_map", "metalness_map", + "displacement_map", "opacity_map", "geo_pattern"): + val = resolve_param(payload, opt, required=False) + if val is not None: + mat_payload[opt] = val + try: + mat_result = self._handle_create_textured_material(mat_payload) + material_usd_path = (mat_result or {}).get("material_usd_path") + steps.append({"step": "create_textured_material", "result": mat_result}) + except Exception as e: # noqa: BLE001 -- record, don't abort + passed = False + steps.append({"step": "create_textured_material", "error": str(e)}) + + # --- Step 2: assemble the Solaris chain ---------------------------- + assemble_payload = { + "parent": resolve_param_with_default(payload, "parent", "/stage"), + } + try: + assemble_result = self._handle_solaris_assemble_chain(assemble_payload) + steps.append({"step": "solaris_assemble_chain", "result": assemble_result}) + except Exception as e: # noqa: BLE001 + passed = False + steps.append({"step": "solaris_assemble_chain", "error": str(e)}) + + # --- Step 3: safe render ------------------------------------------- + skip_render = _coerce_bool( + resolve_param(payload, "skip_render", required=False), default=False + ) + if skip_render: + steps.append({"step": "safe_render", "result": {"skipped": True}}) + else: + render_payload = {} + for opt in ("rop_path", "width", "height", "soho_foreground"): + val = resolve_param(payload, opt, required=False) + if val is not None: + render_payload[opt] = val + try: + render_result = self._handle_safe_render(render_payload) + steps.append({"step": "safe_render", "result": render_result}) + # safe_render returns {passed: bool, ...} -- thread it through. + if isinstance(render_result, dict) and render_result.get("passed") is False: + passed = False + except Exception as e: # noqa: BLE001 + passed = False + steps.append({"step": "safe_render", "error": str(e)}) + + return { + "steps": steps, + "passed": passed, + "material_usd_path": material_usd_path, + } diff --git a/set_anthropic_key.bat b/set_anthropic_key.bat new file mode 100644 index 0000000..760d87c --- /dev/null +++ b/set_anthropic_key.bat @@ -0,0 +1,38 @@ +@echo off +setlocal +echo. +echo SYNAPSE - persist your Anthropic API key +echo ======================================== +echo. +echo Houdini launches as its own process and only inherits PERSISTENT +echo (User) environment variables, so a plain "set" in a terminal never +echo reaches it. This saves your key with setx so a freshly-launched +echo Houdini can see it. Your key is NOT stored in this file. +echo. +set "KEY=%~1" +if not defined KEY set /p "KEY= Paste your Anthropic API key (sk-ant-...): " +if not defined KEY goto :nokey +setx ANTHROPIC_API_KEY "%KEY%" >nul 2>&1 +if errorlevel 1 goto :failed +echo. +echo [OK] Saved ANTHROPIC_API_KEY to your User environment. +echo. +echo NEXT (required): fully quit and reopen Houdini so it picks up the key. +echo Already-open apps will not see the change. +echo. +echo Verify in Houdini's Python Shell: +echo import os; print(bool(os.environ.get("ANTHROPIC_API_KEY"))) +echo -- should print True +echo. +goto :end +:nokey +echo. +echo [X] No key entered - nothing changed. +goto :end +:failed +echo. +echo [X] setx failed - open a normal Command Prompt and run it again. +:end +echo. +pause +endlocal diff --git a/tests/test_cops.py b/tests/test_cops.py index 7540e21..ab4ed61 100644 --- a/tests/test_cops.py +++ b/tests/test_cops.py @@ -1,7 +1,7 @@ """Tests for Copernicus (COPs) handlers. -Tests all 20 COP handlers across 4 phases: - - Foundation: create_network, create_node, connect, set_opencl, read_layer_info +Tests all 21 COP handlers across 4 phases: + - Foundation: create_network, create_copnet, create_node, connect, set_opencl, read_layer_info - Pipeline: to_materialx, composite_aovs, analyze_render, slap_comp - Procedural: create_solver, procedural_texture, growth_propagation, reaction_diffusion, pixel_sort, stylize @@ -795,9 +795,10 @@ def test_batch_cook_multiple(self, handler): class TestCopsRegistration: def test_all_cops_commands_registered(self, handler): - """Verify all 20 COPs handlers are registered.""" + """Verify all 21 COPs handlers are registered.""" cops_commands = [ - "cops_create_network", "cops_create_node", "cops_connect", + "cops_create_network", "cops_create_copnet", "cops_create_node", + "cops_connect", "cops_set_opencl", "cops_read_layer_info", "cops_to_materialx", "cops_composite_aovs", "cops_analyze_render", "cops_slap_comp", @@ -813,16 +814,16 @@ def test_all_cops_commands_registered(self, handler): assert cmd in registered, f"'{cmd}' not registered" def test_cops_count(self, handler): - """Verify exactly 20 COPs commands are registered.""" + """Verify exactly 21 COPs commands are registered.""" cops_cmds = [c for c in handler._registry.registered_types if c.startswith("cops_")] - assert len(cops_cmds) == 20 + assert len(cops_cmds) == 21 def test_protocol_command_types(self): """Verify CommandType enum has all COPs entries.""" ct = protocol_mod.CommandType cops_types = [m for m in ct.__members__ if m.startswith("COPS_")] - assert len(cops_types) == 20 + assert len(cops_types) == 21 def test_read_only_commands(self): """Verify read-only COPs commands are in _READ_ONLY_COMMANDS.""" @@ -876,7 +877,7 @@ def test_copernicus_recipe_count(self): class TestCopsMcpTools: def test_mcp_tool_defs_exist(self): - """Verify all 20 COPs tools are in mcp/tools.py _TOOL_DEFS.""" + """Verify all 21 COPs tools are in mcp/tools.py _TOOL_DEFS.""" # Import the mcp tools module mcp_tools_path = _base / "mcp" / "tools.py" if "synapse.mcp" not in sys.modules: @@ -896,7 +897,7 @@ def test_mcp_tool_defs_exist(self): tool_names = [t[0] for t in mcp_tools_mod.TOOL_DEFS] cops_tools = [n for n in tool_names if n.startswith("cops_")] - assert len(cops_tools) == 20 + assert len(cops_tools) == 21 def test_mcp_tool_group_module(self): """Verify mcp_tools_cops.py has correct tool count.""" @@ -906,6 +907,6 @@ def test_mcp_tool_group_module(self): mod = importlib.util.module_from_spec(spec) spec.loader.exec_module(mod) - assert len(mod.TOOL_NAMES) == 20 - assert len(mod.DISPATCH_KEYS) == 20 + assert len(mod.TOOL_NAMES) == 21 + assert len(mod.DISPATCH_KEYS) == 21 assert "GROUP_KNOWLEDGE" in dir(mod) diff --git a/tests/test_forge_copernicus.py b/tests/test_forge_copernicus.py new file mode 100644 index 0000000..990f850 --- /dev/null +++ b/tests/test_forge_copernicus.py @@ -0,0 +1,246 @@ +"""FORGE C4 — Tests for the modern Copernicus 'copnet' handler. + +Covers the single new Foundation handler `_handle_cops_create_copnet` added +to CopsHandlerMixin. This handler builds an H21 Copernicus 'copnet' network +container (distinct from the legacy 'cop2net' used by the 20 existing tools). + +Structural only — NO behavioral/cook assertions. The live Synapse bridge is +down, so whether the copnet cooks through Karma XPU is DEFERRED. These tests +assert that the handler requests a 'copnet' node type and returns a node path. + +Mock-based -- no Houdini required. Bootstrap mirrors tests/test_cops.py. +""" + +import importlib.util +import sys +import types +from pathlib import Path +from unittest.mock import MagicMock, patch + +import pytest + +# --------------------------------------------------------------------------- +# Bootstrap: hou stub (mirrors tests/test_cops.py lines 25-54) +# --------------------------------------------------------------------------- + +if "hou" not in sys.modules: + _hou = types.ModuleType("hou") + _hou.node = MagicMock() + _hou.frame = MagicMock(return_value=1.0) + _hou.setFrame = MagicMock() + _hou.fps = MagicMock(return_value=24.0) + _hou.hipFile = MagicMock() + _hou.hipFile.name = MagicMock(return_value="/tmp/test.hip") + _hou.playbar = MagicMock() + _hou.playbar.frameRange = MagicMock(return_value=(1, 100)) + _hou.selectedNodes = MagicMock(return_value=[]) + _hou.undos = MagicMock() + _hou.text = MagicMock() + _hou.text.expandString = MagicMock(return_value="/tmp/houdini_temp") + sys.modules["hou"] = _hou +else: + _hou = sys.modules["hou"] + +if "hdefereval" not in sys.modules: + _hdefereval = types.ModuleType("hdefereval") + _hdefereval.executeInMainThreadWithResult = lambda fn, *a, **k: fn(*a, **k) + # executeDeferred: call fn immediately (run_on_main uses this + threading.Event) + _hdefereval.executeDeferred = lambda fn, *a, **k: fn(*a, **k) + sys.modules["hdefereval"] = _hdefereval +else: + _hdefereval = sys.modules["hdefereval"] + if not hasattr(_hdefereval, "executeInMainThreadWithResult"): + _hdefereval.executeInMainThreadWithResult = lambda fn, *a, **k: fn(*a, **k) + if not hasattr(_hdefereval, "executeDeferred"): + _hdefereval.executeDeferred = lambda fn, *a, **k: fn(*a, **k) + +# Bootstrap synapse package modules +_base = Path(__file__).resolve().parent.parent / "python" / "synapse" + +for mod_name, mod_path in [ + ("synapse", _base), + ("synapse.core", _base / "core"), + ("synapse.server", _base / "server"), + ("synapse.session", _base / "session"), + ("synapse.routing", _base / "routing"), + ("synapse.memory", _base / "memory"), +]: + if mod_name not in sys.modules: + pkg = types.ModuleType(mod_name) + pkg.__path__ = [str(mod_path)] + sys.modules[mod_name] = pkg + +for mod_name, fpath in [ + ("synapse.core.protocol", _base / "core" / "protocol.py"), + ("synapse.core.aliases", _base / "core" / "aliases.py"), + ("synapse.core.errors", _base / "core" / "errors.py"), + ("synapse.server.handlers", _base / "server" / "handlers.py"), +]: + if mod_name not in sys.modules: + spec = importlib.util.spec_from_file_location(mod_name, fpath) + mod = importlib.util.module_from_spec(spec) + sys.modules[mod_name] = mod + spec.loader.exec_module(mod) + +handlers_mod = sys.modules["synapse.server.handlers"] +protocol_mod = sys.modules["synapse.core.protocol"] + +# Get the hou reference from handlers_cops.py (where the handler actually +# resolves nodes -- patch THIS one, not the test-local _hou). +_cops_mod = sys.modules.get("synapse.server.handlers_cops") +_handlers_hou = _cops_mod.hou if _cops_mod else handlers_mod.hou + +if not hasattr(_handlers_hou, "node"): + _handlers_hou.node = MagicMock() + + +# --------------------------------------------------------------------------- +# Mock helpers (single-purpose, copnet-focused) +# --------------------------------------------------------------------------- + +class _MockCategory: + def __init__(self, name="Cop"): + self._name = name + + def name(self): + return self._name + + +class _MockNodeType: + def __init__(self, name="copnet", category="Cop"): + self._name = name + self._cat = _MockCategory(category) + + def name(self): + return self._name + + def category(self): + return self._cat + + +def _make_node(path, type_name): + """Create a generic mock node with .path() and .type().name().""" + node = MagicMock() + node.path.return_value = path + node.name.return_value = path.rsplit("/", 1)[-1] + node.type.return_value = _MockNodeType(type_name) + node.moveToGoodPosition = MagicMock() + node.children.return_value = [] + return node + + +def _make_copnet(path="/obj/copnet1"): + """Create a mock 'copnet' network whose createNode yields child COP nodes.""" + net = _make_node(path, "copnet") + + def _create_child(node_type, name=None): + child_name = name or node_type + return _make_node(f"{path}/{child_name}", node_type) + + net.createNode = MagicMock(side_effect=_create_child) + return net + + +def _make_parent_node(path="/obj", net_path="/obj/copnet1"): + """Create a mock parent. createNode('copnet', ...) returns a copnet mock.""" + parent = MagicMock() + parent.path.return_value = path + + def _create_node(node_type, name=None): + if node_type == "copnet": + return _make_copnet(net_path) + child_name = name or node_type + return _make_node(f"{path}/{child_name}", node_type) + + parent.createNode = MagicMock(side_effect=_create_node) + return parent + + +# --------------------------------------------------------------------------- +# Fixture +# --------------------------------------------------------------------------- + +@pytest.fixture +def handler(): + """SynapseHandler instance. + + INTEGRATOR registers 'cops_create_copnet' in handlers.py during the merge + phase. During this parallel build phase the registration may not yet be + wired, so we self-register the method onto the instance registry if absent. + This keeps the test green standalone and stays correct once wired. + """ + h = handlers_mod.SynapseHandler() + if h._registry.get("cops_create_copnet") is None: + h._registry.register("cops_create_copnet", h._handle_cops_create_copnet) + return h + + +def _cmd(payload, cmd_id="forge-copnet"): + return handlers_mod.SynapseCommand( + type="cops_create_copnet", id=cmd_id, payload=payload + ) + + +# --------------------------------------------------------------------------- +# Tests +# --------------------------------------------------------------------------- + +class TestCopsCreateCopnet: + def test_create_copnet_requests_copnet_node_type(self, handler): + """KEY ASSERTION: the handler asks the parent for a 'copnet' node.""" + parent = _make_parent_node("/obj", "/obj/copnet1") + with patch.object(_handlers_hou, "node", return_value=parent): + result = handler.handle(_cmd({"parent": "/obj", "name": "copnet1"})) + assert result.success, result.error + # First positional arg of the network creation must be 'copnet'. + first_call = parent.createNode.call_args_list[0] + assert first_call.args[0] == "copnet" + parent.createNode.assert_any_call("copnet", "copnet1") + + def test_create_copnet_returns_node_path(self, handler): + parent = _make_parent_node("/obj", "/obj/copnet1") + with patch.object(_handlers_hou, "node", return_value=parent): + result = handler.handle(_cmd({"parent": "/obj", "name": "copnet1"})) + assert result.success, result.error + assert "network_path" in result.data + assert result.data["network_path"] == "/obj/copnet1" + assert result.data["type"] == "copnet" + + def test_create_copnet_default_parent_and_name(self, handler): + """Empty payload -> default parent '/obj', default name 'copnet'.""" + parent = _make_parent_node("/obj", "/obj/copnet") + with patch.object(_handlers_hou, "node", return_value=parent) as p: + result = handler.handle(_cmd({})) + assert result.success, result.error + # parent resolved from default '/obj' + p.assert_called_with("/obj") + first_call = parent.createNode.call_args_list[0] + assert first_call.args[0] == "copnet" # node type + assert first_call.args[1] == "copnet" # default name + + def test_create_copnet_bad_parent(self, handler): + with patch.object(_handlers_hou, "node", return_value=None): + result = handler.handle(_cmd({"parent": "/nonexistent"})) + assert not result.success + assert "Couldn't find" in result.error + + def test_create_copnet_with_starter(self, handler): + """Optional starter creates one child inside the new copnet.""" + net = _make_copnet("/obj/copnet1") + parent = MagicMock() + parent.path.return_value = "/obj" + parent.createNode = MagicMock(return_value=net) + with patch.object(_handlers_hou, "node", return_value=parent): + result = handler.handle(_cmd({"starter": "null"})) + assert result.success, result.error + # The network (not the parent) created the starter child. + net.createNode.assert_any_call("null") + assert isinstance(result.data["starter_node"], str) + assert result.data["starter_node"] == "/obj/copnet1/null" + + def test_create_copnet_no_starter_is_none(self, handler): + parent = _make_parent_node("/obj", "/obj/copnet1") + with patch.object(_handlers_hou, "node", return_value=parent): + result = handler.handle(_cmd({"parent": "/obj"})) + assert result.success, result.error + assert result.data["starter_node"] is None diff --git a/tests/test_forge_render.py b/tests/test_forge_render.py new file mode 100644 index 0000000..f968a2d --- /dev/null +++ b/tests/test_forge_render.py @@ -0,0 +1,278 @@ +"""FORGE tests for the branch-aware upstream Karma-LOP walk in _handle_render. + +Cluster: BRAINSTEM (render/recovery). Opportunity C3. + +The upstream-picture discovery inside RenderHandlerMixin._handle_render used to +follow only inputs()[0], so a Karma LOP reachable solely via a branched input +(inputs()[1] of a merge/switch LOP) was never visited and its `picture` parm +never found. The fix is a bounded breadth-first walk over ALL node.inputs() +with a visited set + node budget. + +These tests reproduce the missed-branch case, guard the direct-loppath happy +path against regression, and prove a cyclic graph still terminates. + +Bootstrap mirrors tests/test_render.py verbatim (stub hou/hdefereval into +sys.modules, importlib-load synapse.server.handlers, grab +_handlers_hou = handlers_mod.hou, `handler` fixture -> SynapseHandler()). +The fake graph follows the conftest _MockNode contract (parm/inputs/path/type) +but is built with MagicMock nodes inline, matching the sibling test +test_upstream_karma_lop_picture_discovered that lives next to it. + +Mock-based -- no Houdini required. +""" + +import importlib.util +import sys +import types +from pathlib import Path +from unittest.mock import MagicMock, patch + +import pytest + +# --------------------------------------------------------------------------- +# Bootstrap: load handlers without Houdini (verbatim from tests/test_render.py) +# --------------------------------------------------------------------------- + +if "hou" not in sys.modules: + _hou = types.ModuleType("hou") + _hou.node = MagicMock() + _hou.frame = MagicMock(return_value=24.0) + _hou.text = MagicMock() + _hou.text.expandString = MagicMock(return_value="/tmp/houdini_temp") + _hou.undos = MagicMock() + sys.modules["hou"] = _hou +else: + _hou = sys.modules["hou"] + if not hasattr(_hou, "undos"): + _hou.undos = MagicMock() + +if "hdefereval" not in sys.modules: + _hdefereval = types.ModuleType("hdefereval") + sys.modules["hdefereval"] = _hdefereval +else: + _hdefereval = sys.modules["hdefereval"] + +if not hasattr(_hdefereval, "executeDeferred"): + _hdefereval.executeDeferred = lambda fn: fn() + +_handlers_path = Path(__file__).resolve().parent.parent / "python" / "synapse" / "server" / "handlers.py" +_proto_path = Path(__file__).resolve().parent.parent / "python" / "synapse" / "core" / "protocol.py" +_aliases_path = Path(__file__).resolve().parent.parent / "python" / "synapse" / "core" / "aliases.py" + +for mod_name, mod_path in [ + ("synapse", Path(__file__).resolve().parent.parent / "python" / "synapse"), + ("synapse.core", Path(__file__).resolve().parent.parent / "python" / "synapse" / "core"), + ("synapse.server", Path(__file__).resolve().parent.parent / "python" / "synapse" / "server"), + ("synapse.session", Path(__file__).resolve().parent.parent / "python" / "synapse" / "session"), +]: + if mod_name not in sys.modules: + pkg = types.ModuleType(mod_name) + pkg.__path__ = [str(mod_path)] + sys.modules[mod_name] = pkg + +for mod_name, fpath in [ + ("synapse.core.protocol", _proto_path), + ("synapse.core.aliases", _aliases_path), + ("synapse.server.handlers", _handlers_path), +]: + if mod_name not in sys.modules: + spec = importlib.util.spec_from_file_location(mod_name, fpath) + mod = importlib.util.module_from_spec(spec) + sys.modules[mod_name] = mod + spec.loader.exec_module(mod) + +handlers_mod = sys.modules["synapse.server.handlers"] + +_handlers_hou = handlers_mod.hou +if not hasattr(_handlers_hou, "undos"): + _handlers_hou.undos = MagicMock() + + +# --------------------------------------------------------------------------- +# Fixtures +# --------------------------------------------------------------------------- + +@pytest.fixture +def handler(): + h = handlers_mod.SynapseHandler() + h._bridge = MagicMock() + return h + + +# --------------------------------------------------------------------------- +# Helpers: build conftest _MockNode-style LOP nodes with MagicMock +# --------------------------------------------------------------------------- + +def _make_lop(path, *, picture=None, inputs=None): + """Build a MagicMock LOP node honoring the _MockNode contract. + + picture: str path returned by parm("picture").eval(), or None for no parm. + inputs: list of upstream nodes returned by .inputs() (default []). + """ + node = MagicMock() + node.path.return_value = path + + if picture is not None: + pic_parm = MagicMock() + pic_parm.eval.return_value = picture + node.parm.side_effect = lambda n: pic_parm if n == "picture" else None + else: + node.parm.side_effect = lambda n: None + + node.inputs.return_value = list(inputs or []) + return node + + +# --------------------------------------------------------------------------- +# Tests: branch-aware upstream Karma-LOP discovery (Opportunity C3) +# --------------------------------------------------------------------------- + +class TestForgeBranchWalk: + + def _patched_render(self, handler, node_map, rop_path): + """Run _handle_render inline with hou.node resolving via node_map.""" + import hdefereval + hdefereval.executeInMainThreadWithResult = lambda fn, *a, **kw: fn(*a, **kw) + + def _hou_node(p): + return node_map.get(p) + + with patch.object(_handlers_hou, "node", side_effect=_hou_node, create=True), \ + patch.object(_handlers_hou, "frame", return_value=1.0, create=True), \ + patch.object(_handlers_hou, "text", MagicMock(expandString=MagicMock(return_value="/tmp/houdini_temp")), create=True), \ + patch("time.sleep", return_value=None), \ + patch("pathlib.Path.exists", return_value=True), \ + patch("pathlib.Path.stat", return_value=MagicMock(st_size=1024)), \ + patch("pathlib.Path.mkdir", return_value=None): + return handler._handle_render({"node": rop_path}) + + def test_branch_input_karma_lop_discovered(self, handler): + """Karma LOP reachable ONLY via inputs()[1] (a branch) is found. + + Graph: + ROP /out/usdrender1 (empty outputimage/picture, loppath=/stage/merge1) + /stage/merge1 picture=None inputs=[null1, karma1] <-- branch + /stage/null1 picture=None inputs=[] (dead-end on input slot 0) + /stage/karma1 picture=/renders/branch_beauty.$F4.exr inputs=[] + + Old linear inputs()[0] walk descends into null1 and terminates, + never reaching karma1 on slot 1. The BFS over all inputs reaches it. + """ + # ROP node: empty outputimage, loppath -> merge1 + rop = MagicMock() + rop.path.return_value = "/out/usdrender1" + rop.type.return_value.name.return_value = "usdrender" + + out_parm = MagicMock() + out_parm.eval.return_value = "" + + loppath_parm = MagicMock() + loppath_parm.eval.return_value = "/stage/merge1" + + def _rop_parm(n): + if n in ("outputimage", "picture"): + return out_parm + if n == "loppath": + return loppath_parm + return None + + rop.parm.side_effect = _rop_parm + + karma_lop = _make_lop("/stage/karma1", picture="/renders/branch_beauty.$F4.exr", inputs=[]) + dead_end = _make_lop("/stage/null1", picture=None, inputs=[]) + # input slot 0 = dead_end (non-karma), input slot 1 = karma (the branch) + merge_lop = _make_lop("/stage/merge1", picture=None, inputs=[dead_end, karma_lop]) + + node_map = { + "/out/usdrender1": rop, + "/stage/merge1": merge_lop, + "/stage/null1": dead_end, + "/stage/karma1": karma_lop, + } + + result = self._patched_render(handler, node_map, "/out/usdrender1") + + # BFS reached the branch -> picked up branch_beauty + assert "output_file" in result + assert "branch_beauty" in result["output_file"] + + def test_direct_loppath_karma_still_found(self, handler): + """Regression guard: zero-branch happy path still discovers picture. + + loppath -> /stage/karma1 directly, karma1.inputs() -> []. The rewrite + must not break the original single-node case. + """ + rop = MagicMock() + rop.path.return_value = "/out/usdrender1" + rop.type.return_value.name.return_value = "usdrender" + + out_parm = MagicMock() + out_parm.eval.return_value = "" + + loppath_parm = MagicMock() + loppath_parm.eval.return_value = "/stage/karma1" + + def _rop_parm(n): + if n in ("outputimage", "picture"): + return out_parm + if n == "loppath": + return loppath_parm + return None + + rop.parm.side_effect = _rop_parm + + karma_lop = _make_lop("/stage/karma1", picture="/renders/direct_beauty.$F4.exr", inputs=[]) + + node_map = { + "/out/usdrender1": rop, + "/stage/karma1": karma_lop, + } + + result = self._patched_render(handler, node_map, "/out/usdrender1") + + assert "output_file" in result + assert "direct_beauty" in result["output_file"] + + def test_cyclic_graph_terminates(self, handler): + """Safety: a cycle (A.inputs->[B], B.inputs->[A], no picture) must not hang. + + With no discoverable picture the handler falls through to the default + EXR path. The visited set + node budget guarantee termination. + """ + rop = MagicMock() + rop.path.return_value = "/out/usdrender1" + rop.type.return_value.name.return_value = "usdrender" + + out_parm = MagicMock() + out_parm.eval.return_value = "" + + loppath_parm = MagicMock() + loppath_parm.eval.return_value = "/stage/A" + + def _rop_parm(n): + if n in ("outputimage", "picture"): + return out_parm + if n == "loppath": + return loppath_parm + return None + + rop.parm.side_effect = _rop_parm + + node_a = _make_lop("/stage/A", picture=None, inputs=[]) + node_b = _make_lop("/stage/B", picture=None, inputs=[]) + node_a.inputs.return_value = [node_b] + node_b.inputs.return_value = [node_a] # cycle + + node_map = { + "/out/usdrender1": rop, + "/stage/A": node_a, + "/stage/B": node_b, + } + + # Default $HIP expandString returns "$HIP" (unchanged) -> temp EXR path. + result = self._patched_render(handler, node_map, "/out/usdrender1") + + # No picture discovered -> fell through to the default EXR render path, + # and crucially the call returned (did not hang). + assert "output_file" in result + assert result["output_file"].endswith(".exr") diff --git a/tests/test_forge_usd.py b/tests/test_forge_usd.py new file mode 100644 index 0000000..68f03eb --- /dev/null +++ b/tests/test_forge_usd.py @@ -0,0 +1,459 @@ +"""Tests for FORGE cluster HANDS_USD additions to handlers_usd.py. + +Covers: + - Pure-function _karma_visibility_pythonscript codegen (NO hou). + - _coerce_bool string/bool coercion (NO hou). + - Handler-level checks for reference_usd (karma_visible), modify_usd_prim + (instanceable), set_payload_loadstate, create_point_instancer, and the + shot_render_ready composite orchestrator. + +Bootstrap mirrors tests/test_render.py: a hou/hdefereval stub is injected +into sys.modules and handlers are loaded via importlib bypassing the package +__init__. Pure-function tests do not touch hou at all. +""" + +import importlib.util +import sys +import types +from pathlib import Path +from unittest.mock import MagicMock + +import pytest + +# --------------------------------------------------------------------------- +# Bootstrap: load handlers without Houdini (mirrors test_render.py) +# --------------------------------------------------------------------------- + +if "hou" not in sys.modules: + _hou = types.ModuleType("hou") + _hou.node = MagicMock() + _hou.frame = MagicMock(return_value=24.0) + _hou.text = MagicMock() + _hou.text.expandString = MagicMock(return_value="/tmp/houdini_temp") + _hou.undos = MagicMock() + # OperationFailed must be a real exception class for except clauses. + _hou.OperationFailed = type("OperationFailed", (Exception,), {}) + sys.modules["hou"] = _hou +else: + _hou = sys.modules["hou"] + if not hasattr(_hou, "undos"): + _hou.undos = MagicMock() + if not hasattr(_hou, "OperationFailed"): + _hou.OperationFailed = type("OperationFailed", (Exception,), {}) + +if "hdefereval" not in sys.modules: + _hdefereval = types.ModuleType("hdefereval") + sys.modules["hdefereval"] = _hdefereval +else: + _hdefereval = sys.modules["hdefereval"] + +if not hasattr(_hdefereval, "executeDeferred"): + _hdefereval.executeDeferred = lambda fn: fn() + +_root = Path(__file__).resolve().parent.parent / "python" / "synapse" +_handlers_path = _root / "server" / "handlers.py" +_proto_path = _root / "core" / "protocol.py" +_aliases_path = _root / "core" / "aliases.py" +_usd_path = _root / "server" / "handlers_usd.py" + +for mod_name, mod_path in [ + ("synapse", _root), + ("synapse.core", _root / "core"), + ("synapse.server", _root / "server"), + ("synapse.session", _root / "session"), +]: + if mod_name not in sys.modules: + pkg = types.ModuleType(mod_name) + pkg.__path__ = [str(mod_path)] + sys.modules[mod_name] = pkg + +for mod_name, fpath in [ + ("synapse.core.protocol", _proto_path), + ("synapse.core.aliases", _aliases_path), + ("synapse.server.handlers", _handlers_path), +]: + if mod_name not in sys.modules: + spec = importlib.util.spec_from_file_location(mod_name, fpath) + mod = importlib.util.module_from_spec(spec) + sys.modules[mod_name] = mod + spec.loader.exec_module(mod) + +handlers_mod = sys.modules["synapse.server.handlers"] +usd_mod = sys.modules["synapse.server.handlers_usd"] + +_handlers_hou = handlers_mod.hou +if not hasattr(_handlers_hou, "undos"): + _handlers_hou.undos = MagicMock() +if not hasattr(_handlers_hou, "OperationFailed"): + _handlers_hou.OperationFailed = type("OperationFailed", (Exception,), {}) + +# The hou object that handlers_usd.py imported (may differ if a prior test +# replaced sys.modules["hou"]). Ensure it has the bits the handlers touch. +_usd_hou = usd_mod.hou +if _usd_hou is not None: + if not hasattr(_usd_hou, "undos"): + _usd_hou.undos = MagicMock() + if not hasattr(_usd_hou, "OperationFailed"): + _usd_hou.OperationFailed = type("OperationFailed", (Exception,), {}) + + +# --------------------------------------------------------------------------- +# Fixtures +# --------------------------------------------------------------------------- + +@pytest.fixture +def handler(): + h = handlers_mod.SynapseHandler() + h._bridge = MagicMock() + return h + + +def _setup_lop_mock(handler): + """Mock _resolve_lop_node -> LOP whose parent().createNode() -> py_lop.""" + lop_node = MagicMock() + lop_node.path.return_value = "/stage/lop" + parent = MagicMock() + lop_node.parent.return_value = parent + + py_lop = MagicMock() + py_lop.path.return_value = "/stage/py_lop" + parent.createNode.return_value = py_lop + + handler._resolve_lop_node = MagicMock(return_value=lop_node) + return lop_node, parent, py_lop + + +def _last_python_code(py_lop): + """Extract the code string passed to py_lop.parm('python').set(code).""" + return py_lop.parm.return_value.set.call_args[0][0] + + +# =========================================================================== +# (A) PURE-FUNCTION tests — _karma_visibility_pythonscript (NO hou) +# =========================================================================== + +class TestKarmaVisibilityPythonscript: + def test_returns_string_without_hou(self): + # Must not raise / not import hou at call time. + code = usd_mod._karma_visibility_pythonscript("/World/asset") + assert isinstance(code, str) + assert code # non-empty + + def test_non_clobbering_guards_present(self): + code = usd_mod._karma_visibility_pythonscript("/World/asset") + # Purpose guarded by HasAuthoredValue, kind guarded by GetKind() truthiness. + assert "HasAuthoredValue" in code + assert "GetKind()" in code + assert "if not" in code + + def test_purpose_default_value_and_api(self): + code = usd_mod._karma_visibility_pythonscript("/World/asset") + assert "GetPurposeAttr" in code + assert "'default'" in code + assert ".Set(" in code + + def test_kind_default_value_and_api(self): + code = usd_mod._karma_visibility_pythonscript("/World/asset") + assert "SetKind" in code + assert "'component'" in code + assert "ModelAPI" in code + + def test_default_prim_fallback_guarded(self): + code = usd_mod._karma_visibility_pythonscript("/World/asset") + assert "GetDefaultPrim" in code + # Fallback is guarded on prim_path == '/'. + assert "== '/'" in code + + def test_custom_values_reflected(self): + code = usd_mod._karma_visibility_pythonscript( + "/World/asset", purpose="render", kind="assembly" + ) + assert "'render'" in code + assert "'assembly'" in code + assert "/World/asset" in code + + def test_editable_stage_used(self): + code = usd_mod._karma_visibility_pythonscript("/World/asset") + assert "editableStage" in code + # pxr import lives INSIDE the emitted script, not at call time. + assert "from pxr import" in code + + +# =========================================================================== +# (A) PURE-FUNCTION tests — _coerce_bool (NO hou) +# =========================================================================== + +class TestCoerceBool: + @pytest.mark.parametrize("token", ["false", "FALSE", "False", "0", "no", + "NO", "off", "Off", " false ", ""]) + def test_falsey_strings(self, token): + assert usd_mod._coerce_bool(token) is False + + @pytest.mark.parametrize("token", ["true", "True", "1", "yes", "on", + "anything"]) + def test_truthy_strings(self, token): + assert usd_mod._coerce_bool(token) is True + + def test_real_bools_passthrough(self): + assert usd_mod._coerce_bool(True) is True + assert usd_mod._coerce_bool(False) is False + + def test_none_uses_default(self): + assert usd_mod._coerce_bool(None) is True + assert usd_mod._coerce_bool(None, default=False) is False + + +# =========================================================================== +# (B) HANDLER-LEVEL tests +# =========================================================================== + +class TestReferenceUsdKarmaVisible: + def _setup_parent(self): + """Mock hou.node(parent) -> parent_node; createNode returns ref + kv.""" + parent_node = MagicMock() + + ref_lop = MagicMock() + ref_lop.path.return_value = "/stage/ref_import" + + kv_lop = MagicMock() + kv_lop.path.return_value = "/stage/karma_visibility" + + # First createNode -> reference LOP, second -> karma_visibility LOP. + parent_node.createNode.side_effect = [ref_lop, kv_lop] + return parent_node, ref_lop, kv_lop + + def test_karma_visible_true_creates_second_lop(self, handler, monkeypatch): + parent_node, ref_lop, kv_lop = self._setup_parent() + monkeypatch.setattr(_usd_hou, "node", lambda p: parent_node) + # Use a $HIP path so the on-disk isfile() check is short-circuited. + + result = handler._handle_reference_usd({ + "file": "$HIP/asset.usd", + "mode": "reference", + "prim_path": "/World/asset", + "karma_visible": True, + }) + + # Two LOPs created: reference + karma_visibility. + assert parent_node.createNode.call_count == 2 + assert "karma_visibility" in result + assert result["karma_visibility"]["purpose"] == "default" + assert result["karma_visibility"]["kind"] == "component" + assert result["karma_visibility"]["policy"] == "non-clobbering" + assert "advisory" not in result + # The kv LOP got karma codegen wired off the ref LOP. + kv_lop.setInput.assert_called_once_with(0, ref_lop) + code = _last_python_code(kv_lop) + assert "GetPurposeAttr" in code + + def test_karma_visible_string_false_keeps_advisory(self, handler, monkeypatch): + parent_node, ref_lop, kv_lop = self._setup_parent() + monkeypatch.setattr(_usd_hou, "node", lambda p: parent_node) + + result = handler._handle_reference_usd({ + "file": "$HIP/asset.usd", + "mode": "reference", + "prim_path": "/World/asset", + "karma_visible": "false", + }) + + # Only the reference LOP created; no karma_visibility LOP. + assert parent_node.createNode.call_count == 1 + assert "karma_visibility" not in result + assert "advisory" in result + + def test_payload_mode_default_karma_visible(self, handler, monkeypatch): + parent_node, ref_lop, kv_lop = self._setup_parent() + monkeypatch.setattr(_usd_hou, "node", lambda p: parent_node) + + # No karma_visible key -> defaults True. + result = handler._handle_reference_usd({ + "file": "$HIP/asset.usd", + "mode": "payload", + "prim_path": "/World/asset", + }) + + assert result["mode"] == "payload" + assert "karma_visibility" in result + + def test_sublayer_mode_no_karma(self, handler, monkeypatch): + parent_node = MagicMock() + sub_lop = MagicMock() + sub_lop.path.return_value = "/stage/sublayer_import" + parent_node.createNode.return_value = sub_lop + monkeypatch.setattr(_usd_hou, "node", lambda p: parent_node) + + result = handler._handle_reference_usd({ + "file": "$HIP/asset.usd", + "mode": "sublayer", + }) + + assert "karma_visibility" not in result + assert "advisory" not in result + + +class TestModifyUsdPrimInstanceable: + def test_instanceable_codegen_and_mods(self, handler): + _, _, py_lop = _setup_lop_mock(handler) + + result = handler._handle_modify_usd_prim({ + "prim_path": "/World/asset", + "instanceable": True, + }) + + assert result["modifications"]["instanceable"] is True + code = _last_python_code(py_lop) + assert "SetInstanceable(True)" in code + + def test_instanceable_false(self, handler): + _, _, py_lop = _setup_lop_mock(handler) + + handler._handle_modify_usd_prim({ + "prim_path": "/World/asset", + "instanceable": False, + }) + code = _last_python_code(py_lop) + assert "SetInstanceable(False)" in code + + +class TestSetPayloadLoadstate: + def test_unload_action(self, handler): + _, _, py_lop = _setup_lop_mock(handler) + + result = handler._handle_set_payload_loadstate({ + "prim_path": "/World/heavy", + "action": "unload", + }) + + assert result["action"] == "unload" + code = _last_python_code(py_lop) + assert "stage.Unload" in code + assert "Sdf.Path" in code + + def test_load_with_active(self, handler): + _, _, py_lop = _setup_lop_mock(handler) + + handler._handle_set_payload_loadstate({ + "prim_path": "/World/heavy", + "action": "load", + "active": True, + }) + code = _last_python_code(py_lop) + assert "stage.Load" in code + assert "SetActive(True)" in code + + def test_requires_some_change(self, handler): + _setup_lop_mock(handler) + with pytest.raises(ValueError): + handler._handle_set_payload_loadstate({"prim_path": "/World/heavy"}) + + def test_rejects_bad_action(self, handler): + _setup_lop_mock(handler) + with pytest.raises(ValueError): + handler._handle_set_payload_loadstate({ + "prim_path": "/World/heavy", + "action": "purge", + }) + + +class TestCreatePointInstancer: + def test_minimal_codegen(self, handler): + _, _, py_lop = _setup_lop_mock(handler) + + result = handler._handle_create_point_instancer({ + "prim_path": "/World/scatter", + "prototypes": ["/World/proto/tree"], + "positions": [[0, 0, 0], [1, 0, 1], [2, 0, 2]], + }) + + assert result["instance_count"] == 3 + assert result["prototypes"] == ["/World/proto/tree"] + code = _last_python_code(py_lop) + assert "PointInstancer" in code + assert "ProtoIndices" in code or "protoIndices" in code + assert "Positions" in code or "positions" in code + # protoIndices defaults to zeros, one per position. + assert "[0, 0, 0]" in code # the IntArray of zeros + + def test_empty_prototypes_still_valid(self, handler): + _, _, py_lop = _setup_lop_mock(handler) + result = handler._handle_create_point_instancer({ + "prim_path": "/World/scatter", + }) + assert result["instance_count"] == 0 + code = _last_python_code(py_lop) + assert "PointInstancer" in code + + +class TestShotRenderReady: + def test_orchestrates_in_order(self, handler): + calls = [] + + def _mat(payload): + calls.append("material") + return {"material_usd_path": "/stage/mat", "name": "m"} + + def _assemble(payload): + calls.append("assemble") + return {"chain": ["/stage/a", "/stage/b"]} + + def _render(payload): + calls.append("render") + return {"passed": True, "checks": []} + + handler._handle_create_textured_material = _mat + handler._handle_solaris_assemble_chain = _assemble + handler._handle_safe_render = _render + + result = handler._handle_shot_render_ready({ + "diffuse_map": "/tex/albedo.exr", + }) + + assert calls == ["material", "assemble", "render"] + assert result["passed"] is True + assert result["material_usd_path"] == "/stage/mat" + step_names = [s["step"] for s in result["steps"]] + assert step_names == [ + "create_textured_material", "solaris_assemble_chain", "safe_render" + ] + + def test_step_error_captured_not_raised(self, handler): + def _mat(payload): + raise RuntimeError("material boom") + + handler._handle_create_textured_material = _mat + handler._handle_solaris_assemble_chain = lambda p: {"chain": []} + handler._handle_safe_render = lambda p: {"passed": True} + + result = handler._handle_shot_render_ready({}) + + assert result["passed"] is False + mat_step = result["steps"][0] + assert mat_step["step"] == "create_textured_material" + assert "material boom" in mat_step["error"] + # Pipeline still produced a structured summary with all steps attempted. + assert len(result["steps"]) == 3 + + def test_safe_render_failed_marks_not_passed(self, handler): + handler._handle_create_textured_material = lambda p: {"material_usd_path": "x"} + handler._handle_solaris_assemble_chain = lambda p: {"chain": []} + handler._handle_safe_render = lambda p: {"passed": False, "suggestion": "fix cam"} + + result = handler._handle_shot_render_ready({}) + assert result["passed"] is False + + def test_skip_render(self, handler): + called = {"render": False} + + def _render(payload): + called["render"] = True + return {"passed": True} + + handler._handle_create_textured_material = lambda p: {"material_usd_path": "x"} + handler._handle_solaris_assemble_chain = lambda p: {"chain": []} + handler._handle_safe_render = _render + + result = handler._handle_shot_render_ready({"skip_render": True}) + assert called["render"] is False + render_step = result["steps"][-1] + assert render_step["result"]["skipped"] is True diff --git a/tests/test_mcp_protocol.py b/tests/test_mcp_protocol.py index 4e7e05d..8f840d8 100644 --- a/tests/test_mcp_protocol.py +++ b/tests/test_mcp_protocol.py @@ -1032,6 +1032,34 @@ def test_all_tools_covered_by_groups(self): # Tests: Safe / Progressive Render MCP tools # ========================================================================= +@pytest.fixture +def _auto_approve_bridge(): + """Auto-approve consent for the NON-read-only dispatch tests below. + + synapse_safe_render / synapse_render_progressively route through the + LosslessExecutionBridge. In CI ``synapse.core.gates`` is importable, so the + bridge singleton wires a real HumanGate; an unattended APPROVE-gated op then + times out to safe-default rejection. These tests verify DISPATCH, not + consent, so inject an auto-approving ``consent_callback`` (bridge Path 2). + Production gating is untouched -- this only swaps the process-local bridge + singleton for the duration of the test, then restores it. + """ + import synapse.panel.bridge_adapter as ba + prev = ba._bridge + _b = ba.LosslessExecutionBridge(consent_callback=lambda op: True) + # In CI synapse.core.gates is importable, so __init__ auto-wires a real + # HumanGate, and _check_consent consults the gate (Path 1) BEFORE the + # callback (Path 2). Null the gate on this throwaway test bridge so the + # injected auto-approve callback is the one consulted. This does NOT touch + # production gating -- it only configures the process-local test singleton. + _b._gate = None + ba._bridge = _b + try: + yield + finally: + ba._bridge = prev + + class TestSafeRenderMCPTools: """Verify safe_render and render_progressively are properly registered in MCP.""" @@ -1068,7 +1096,7 @@ def test_render_progressively_schema(self): assert "resolution" in props assert "samples" in props - def test_safe_render_dispatch(self): + def test_safe_render_dispatch(self, _auto_approve_bridge): response = MagicMock() response.success = True response.data = {"passed": True} @@ -1078,7 +1106,7 @@ def test_safe_render_dispatch(self): assert "isError" not in result assert len(result["content"]) == 1 - def test_render_progressively_dispatch(self): + def test_render_progressively_dispatch(self, _auto_approve_bridge): response = MagicMock() response.success = True response.data = {"success": True}