diff --git a/README.md b/README.md index 9997a55..b3a2940 100644 --- a/README.md +++ b/README.md @@ -17,13 +17,26 @@ No custom server needed — uses Houdini's built-in `openport` + `hcommand` prot ## Quick Start -### 1. Install the Shelf Tool (once) +### 1. Install (once) -Copy `houdini/shelf/hermes_bridge.shelf` to your Houdini toolbar, or run the shelf script directly in Houdini's Python Shell. +```bash +cd houdini && python3 install.py +``` -### 2. Open the Bridge (every session) +Or manually: copy `hermes_bridge_panel.py` to `~/houdini21.0/scripts/python/`. -Click the **Hermes Bridge** shelf button in Houdini. It opens port 12345 and shows a status dialog. +### 2. Open the Panel (every session) + +In Houdini Python Shell (`Alt+Shift+P`): +```python +exec(open(hou.homeHoudiniDirectory() + '/scripts/python/hermes_bridge_panel.py').read()) +show() +``` + +The panel lets you: +- Choose a port number +- Open/Close the bridge with one click +- Copy a ready-to-use prompt: *"Hey Hermes, I have a Houdini session running on [hostname] (port 12345)"* ### 3. Use from Hermes diff --git a/houdini/install.py b/houdini/install.py new file mode 100644 index 0000000..7e611d9 --- /dev/null +++ b/houdini/install.py @@ -0,0 +1,59 @@ +#!/usr/bin/env python3 +""" +Install Hermes Bridge into Houdini. + +Copies the Python panel to ~/houdini21.0/scripts/python/ +and the shelf tool to ~/houdini21.0/toolbar/ + +Usage: python install.py [--houdini-dir ~/houdini21.0] +""" +import os +import sys +import shutil +import argparse + +FILES = { + "hermes_bridge_panel.py": "scripts/python/", + "hermes_bridge.shelf": "toolbar/", +} + + +def get_houdini_dir(): + """Auto-detect Houdini prefs directory.""" + home = os.path.expanduser("~") + for d in sorted(os.listdir(home), reverse=True): + if d.startswith("houdini") and os.path.isdir(os.path.join(home, d)): + return os.path.join(home, d) + return os.path.join(home, "houdini21.0") + + +def main(): + parser = argparse.ArgumentParser(description="Install Hermes Bridge into Houdini") + parser.add_argument( + "--houdini-dir", + default=get_houdini_dir(), + help=f"Houdini preferences directory (default: {get_houdini_dir()})", + ) + args = parser.parse_args() + + src_dir = os.path.dirname(os.path.abspath(__file__)) + + for filename, subdir in FILES.items(): + src = os.path.join(src_dir, filename) + dst_dir = os.path.join(os.path.expanduser(args.houdini_dir), subdir) + dst = os.path.join(dst_dir, filename) + + if not os.path.exists(src): + print(f" SKIP {filename} — source not found") + continue + + os.makedirs(dst_dir, exist_ok=True) + shutil.copy2(src, dst) + print(f" COPY {filename} -> {dst}") + + print(f"\nInstalled to {args.houdini_dir}") + print("Restart Houdini — the shelf tool appears under 'Hermes Bridge'.") + + +if __name__ == "__main__": + main() diff --git a/houdini/python_panel/hermes_bridge_panel.py b/houdini/python_panel/hermes_bridge_panel.py new file mode 100644 index 0000000..984239b --- /dev/null +++ b/houdini/python_panel/hermes_bridge_panel.py @@ -0,0 +1,269 @@ +""" +Hermes Bridge Panel — Mini UI for controlling the Hermes-Houdini Bridge. +Run in Houdini Python Shell: exec(open('/path/to/hermes_bridge_panel.py').read()) +Or install as a permanent Python Panel. +""" +import hou +import socket +import os +import json + +try: + from PySide2 import QtWidgets, QtCore, QtGui +except ImportError: + from PySide6 import QtWidgets, QtCore, QtGui + + +# ── Config persistence ────────────────────────────────── +CONFIG_FILE = os.path.join(hou.homeHoudiniDirectory(), "hermes_bridge.json") + + +def load_config(): + try: + with open(CONFIG_FILE) as f: + return json.load(f) + except (FileNotFoundError, json.JSONDecodeError): + return {"port": 12345} + + +def save_config(cfg): + with open(CONFIG_FILE, "w") as f: + json.dump(cfg, f, indent=2) + + +# ── Bridge logic ──────────────────────────────────────── +def bridge_status(port): + result = hou.hscript("openport")[0] + return f"Port {port} is open" in result + + +def open_bridge(port): + hou.hscript(f"closeport {port}") + result, _ = hou.hscript(f"openport {port} 0.0.0.0") + return result.strip() + + +def close_bridge(port): + result, _ = hou.hscript(f"closeport {port}") + return result.strip() + + +# ── UI ────────────────────────────────────────────────── +class HermesBridgePanel(QtWidgets.QWidget): + def __init__(self): + super().__init__() + self.cfg = load_config() + self.port = self.cfg["port"] + self.hostname = socket.gethostname() + self._init_ui() + self._refresh_status() + + def _init_ui(self): + self.setWindowTitle("Hermes Bridge") + self.setMinimumWidth(380) + self.setStyleSheet(""" + QWidget { + background: #1e1e1e; + color: #e0e0e0; + font-size: 13px; + } + QPushButton { + border: none; + border-radius: 4px; + padding: 8px 16px; + font-weight: bold; + } + QPushButton#btnToggle { + background: #2d2d2d; + color: #e0e0e0; + padding: 10px; + font-size: 14px; + } + QPushButton#btnToggle[active="true"] { + background: #1a3a1a; + border: 1px solid #4a4; + } + QPushButton#btnCopy { + background: #1565c0; + color: white; + } + QPushButton#btnCopy:hover { + background: #1976d2; + } + QLineEdit, QSpinBox { + background: #2d2d2d; + border: 1px solid #444; + border-radius: 3px; + padding: 6px 8px; + color: #e0e0e0; + } + QLabel#statusLabel { + font-size: 16px; + font-weight: bold; + } + QLabel#hintLabel { + color: #888; + font-size: 11px; + } + """) + + layout = QtWidgets.QVBoxLayout() + layout.setSpacing(10) + layout.setContentsMargins(16, 16, 16, 16) + + # ── Header ────────────────────────────────────── + header = QtWidgets.QHBoxLayout() + icon = QtWidgets.QLabel("\U0001f916") # robot + icon.setStyleSheet("font-size: 24px;") + title = QtWidgets.QLabel("Hermes Bridge") + title.setStyleSheet("font-size: 18px; font-weight: bold; color: #fff;") + header.addWidget(icon) + header.addWidget(title) + header.addStretch() + layout.addLayout(header) + + # ── Status ────────────────────────────────────── + self.status_label = QtWidgets.QLabel("\u25cf Not connected") + self.status_label.setObjectName("statusLabel") + self.status_label.setStyleSheet("color: #ff5555;") + layout.addWidget(self.status_label) + + # ── Port control ──────────────────────────────── + port_layout = QtWidgets.QHBoxLayout() + port_layout.addWidget(QtWidgets.QLabel("Port:")) + self.port_spin = QtWidgets.QSpinBox() + self.port_spin.setRange(1024, 65535) + self.port_spin.setValue(self.port) + self.port_spin.setFixedWidth(100) + self.port_spin.valueChanged.connect(self._on_port_changed) + port_layout.addWidget(self.port_spin) + port_layout.addStretch() + layout.addLayout(port_layout) + + # ── Toggle button ─────────────────────────────── + self.btn_toggle = QtWidgets.QPushButton("Open Bridge") + self.btn_toggle.setObjectName("btnToggle") + self.btn_toggle.setCursor(QtGui.QCursor(QtCore.Qt.PointingHandCursor)) + self.btn_toggle.clicked.connect(self._toggle_bridge) + layout.addWidget(self.btn_toggle) + + # ── Prompt preset ─────────────────────────────── + prompt_label = QtWidgets.QLabel("Copy this to Hermes:") + prompt_label.setObjectName("hintLabel") + layout.addWidget(prompt_label) + + prompt_layout = QtWidgets.QHBoxLayout() + self.prompt_field = QtWidgets.QLineEdit() + self.prompt_field.setReadOnly(True) + self.prompt_field.setText(self._build_prompt()) + self.prompt_field.setCursorPosition(0) + prompt_layout.addWidget(self.prompt_field) + + self.btn_copy = QtWidgets.QPushButton("Copy") + self.btn_copy.setObjectName("btnCopy") + self.btn_copy.setFixedWidth(70) + self.btn_copy.setCursor(QtGui.QCursor(QtCore.Qt.PointingHandCursor)) + self.btn_copy.clicked.connect(self._copy_prompt) + prompt_layout.addWidget(self.btn_copy) + layout.addLayout(prompt_layout) + + # ── Alternatives ──────────────────────────────── + alt_label = QtWidgets.QLabel( + "Or just say: \"Hermes, I have Houdini open\"" + ) + alt_label.setObjectName("hintLabel") + alt_label.setWordWrap(True) + layout.addWidget(alt_label) + + # ── Bottom ────────────────────────────────────── + layout.addStretch() + version = QtWidgets.QLabel("v1.0 | hhb " + self.hostname) + version.setObjectName("hintLabel") + layout.addWidget(version) + + self.setLayout(layout) + + def _build_prompt(self): + return ( + f"Hey Hermes, I have a Houdini session running on " + f"{self.hostname} (port {self.port})." + ) + + def _refresh_status(self): + is_open = bridge_status(self.port) + if is_open: + self.status_label.setText(f"\u25cf Connected \u2014 {self.hostname}:{self.port}") + self.status_label.setStyleSheet("color: #4caf50; font-size: 16px; font-weight: bold;") + self.btn_toggle.setText("Close Bridge") + self.btn_toggle.setProperty("active", "true") + self.btn_toggle.setStyleSheet(""" + QPushButton#btnToggle { + background: #2d1a1a; + border: 1px solid #a44; + color: #ff8888; + padding: 10px; + font-size: 14px; + font-weight: bold; + border-radius: 4px; + } + QPushButton#btnToggle:hover { + background: #3d2020; + } + """) + else: + self.status_label.setText(f"\u25cf Not connected") + self.status_label.setStyleSheet("color: #ff5555; font-size: 16px; font-weight: bold;") + self.btn_toggle.setText("Open Bridge") + self.btn_toggle.setProperty("active", "false") + self.btn_toggle.setStyleSheet(""" + QPushButton#btnToggle { + background: #1a2d1a; + border: 1px solid #4a4; + color: #88cc88; + padding: 10px; + font-size: 14px; + font-weight: bold; + border-radius: 4px; + } + QPushButton#btnToggle:hover { + background: #203d20; + } + """) + self.prompt_field.setText(self._build_prompt()) + + def _toggle_bridge(self): + if bridge_status(self.port): + close_bridge(self.port) + else: + open_bridge(self.port) + self._refresh_status() + + def _on_port_changed(self, value): + was_open = bridge_status(self.port) + if was_open: + close_bridge(self.port) + self.port = value + self.cfg["port"] = value + save_config(self.cfg) + if was_open: + open_bridge(self.port) + self._refresh_status() + + def _copy_prompt(self): + QtWidgets.QApplication.clipboard().setText(self.prompt_field.text()) + self.btn_copy.setText("\u2713 Copied!") + QtCore.QTimer.singleShot(2000, lambda: self.btn_copy.setText("Copy")) + + +# ── Launch ────────────────────────────────────────────── +def show(): + """Show or focus the Hermes Bridge panel.""" + for widget in QtWidgets.QApplication.allWidgets(): + if isinstance(widget, HermesBridgePanel): + widget.raise_() + widget.activateWindow() + return widget + + panel = HermesBridgePanel() + panel.show() + return panel diff --git a/houdini/shelf/hermes_bridge.shelf b/houdini/shelf/hermes_bridge.shelf new file mode 100644 index 0000000..48fcd21 --- /dev/null +++ b/houdini/shelf/hermes_bridge.shelf @@ -0,0 +1,13 @@ + + + + + + + +