"""Run a job of Rhino-side scripts in a locally launched Rhino 8 and collect the result. A job is a JSON spec: {"steps": [{"name": str, "script": , "globals": {...}}, ...], "units_mm_tolerance": 0.001 | null, # set doc units = mm and absolute tolerance before the steps "save": | null, "result": } Each step script is exec'd inside Rhino with `__rhino_doc__` (the active doc) plus its `globals`; its stdout is captured per step. Rhino is launched with /runscript and exits by itself. Launch facts (spike 2026-10-08): launch from PowerShell/CreateProcess, not Git Bash (it rewrites '/nosplash' into a path); keep the /runscript path free of spaces and non-ASCII (no nested quotes); set doc.Modified = False before _-Exit or Rhino waits on a save prompt; the argument must read /runscript="..." (a fully quoted "/runscript=..." is ignored and Rhino just sits idle). """ import json import os import subprocess import time import uuid from pathlib import Path RHINO_EXE = Path(os.environ.get("PIERAPP_RHINO", r"C:\Program Files\Rhino 8\System\Rhino.exe")) JOB_ROOT = Path(os.environ.get("LOCALAPPDATA", r"C:\Temp")) / "PierApp" / "jobs" JOB_PY = r'''#! python3 import io, json, os, sys, time, traceback import Rhino SPEC = json.load(open(r"%(spec)s", encoding="utf-8")) open(SPEC["started"], "w").write(time.strftime("%%H:%%M:%%S")) doc = Rhino.RhinoDoc.ActiveDoc res = {"ok": False, "steps": [], "python": sys.version} opened = None try: if SPEC.get("open"): opened = Rhino.RhinoDoc.OpenHeadless(SPEC["open"]) if opened is None: raise RuntimeError("cannot open " + SPEC["open"]) doc = opened tol = SPEC.get("units_mm_tolerance") if tol is not None: doc.ModelUnitSystem = Rhino.UnitSystem.Millimeters doc.ModelAbsoluteTolerance = tol for st in SPEC["steps"]: t0, buf, old = time.time(), io.StringIO(), sys.stdout sys.stdout = buf try: g = {"__rhino_doc__": doc, "__name__": "__main__", "__file__": st["script"]} g.update(st.get("globals", {})) exec(compile(open(st["script"], encoding="utf-8").read(), st["script"], "exec"), g) finally: sys.stdout = old res["steps"].append({"name": st["name"], "secs": round(time.time() - t0, 2), "stdout": buf.getvalue()}) if SPEC.get("save"): doc.ClearUndoRecords(True) res["saved"] = doc.WriteFile(SPEC["save"], Rhino.FileIO.FileWriteOptions()) res["objects"] = doc.Objects.Count res["ok"] = True except Exception: res["error"] = traceback.format_exc() finally: tmp = SPEC["result"] + ".tmp" json.dump(res, open(tmp, "w", encoding="utf-8"), ensure_ascii=False, indent=1) os.replace(tmp, SPEC["result"]) if opened is not None: opened.Dispose() Rhino.RhinoDoc.ActiveDoc.Modified = False ''' class RhinoJobError(RuntimeError): pass class RhinoStartError(RhinoJobError): """Rhino did not start the job script in time (retryable).""" def _window_title(pid): """Main window title of a process (diagnosis of a stalled Rhino, e.g. a modal dialog).""" try: out = subprocess.run(["powershell", "-NoProfile", "-Command", f"(Get-Process -Id {pid}).MainWindowTitle"], capture_output=True, timeout=20) return out.stdout.decode("mbcs", errors="replace").strip() # console code page (cp949 here) except Exception as e: return f"? ({e})" def _launch_and_wait(job, start_timeout, run_timeout, exit_grace): """Start Rhino on job.py; return when result.json exists. start_timeout: seconds until job.py writes its `started` marker (2026-10-12: a launch once stalled 23 min); exceeded -> Rhino killed, RhinoStartError (retryable). run_timeout: seconds from start to result (long jobs such as a full fingerprint take ~12 min); exceeded -> Rhino killed, RhinoJobError. A Rhino that does not exit within `exit_grace` seconds after writing the result is killed (the result is already on disk). The main window title is reported on every kill to help find the cause (e.g. a modal dialog).""" rp, sp = job / "result.json", job / "started" sp.unlink(missing_ok=True) # Pass a raw command line: Rhino only honours /runscript="..." (quote after '='), while a list argument # would be quoted as a whole ("/runscript=...") and silently ignored. proc = subprocess.Popen(f'"{RHINO_EXE}" /nosplash /notemplate ' f'/runscript="_-RunPythonScript {job / "job.py"} _-Exit"') t0, t_start = time.time(), None while not rp.exists(): if proc.poll() is not None: raise RhinoJobError(f"Rhino exited (code {proc.returncode}) without a result: {job}") if t_start is None and sp.exists(): t_start = time.time() if t_start is None and time.time() - t0 > start_timeout: title = _window_title(proc.pid) proc.kill() raise RhinoStartError(f"Rhino did not start the job within {start_timeout}s (window: '{title}'): {job}") if t_start is not None and time.time() - t_start > run_timeout: title = _window_title(proc.pid) proc.kill() raise RhinoJobError(f"Rhino job ran over {run_timeout}s (window: '{title}'): {job}") time.sleep(0.5) try: proc.wait(timeout=exit_grace) return "exited" except subprocess.TimeoutExpired: title = _window_title(proc.pid) proc.kill() return f"killed after result (did not exit in {exit_grace}s, window: '{title}')" def run_job(steps, save=None, units_mm_tolerance=None, open_path=None, start_timeout=600, run_timeout=3600, retries=1, exit_grace=30): """Run `steps` in a fresh Rhino; return the result dict (raises RhinoJobError on failure). open_path: run the steps on that .3dm opened headless (reload test) instead of the empty active doc. A launch that does not start the script within start_timeout is killed and retried `retries` times. """ if not RHINO_EXE.exists(): raise RhinoJobError(f"Rhino 8 not found: {RHINO_EXE}") job = JOB_ROOT / (time.strftime("%Y%m%d_%H%M%S_") + uuid.uuid4().hex[:6]) job.mkdir(parents=True, exist_ok=True) spec = {"steps": [{"name": s["name"], "script": str(Path(s["script"]).resolve()), "globals": s.get("globals", {})} for s in steps], "units_mm_tolerance": units_mm_tolerance, "open": str(Path(open_path).resolve()) if open_path else None, "save": str(job / "out.3dm") if save else None, "result": str(job / "result.json"), "started": str(job / "started")} (job / "job.json").write_text(json.dumps(spec, ensure_ascii=False, indent=1), encoding="utf-8") (job / "job.py").write_text(JOB_PY % {"spec": job / "job.json"}, encoding="utf-8") if any(ord(c) > 127 or c == " " for c in str(job)): raise RhinoJobError(f"job folder must be ASCII without spaces: {job}") t0, attempts = time.time(), [] for attempt in range(retries + 1): try: exit_note = _launch_and_wait(job, start_timeout, run_timeout, exit_grace) break except RhinoStartError as e: attempts.append(str(e)) if attempt == retries: raise RhinoJobError(" | ".join(attempts)) res = json.loads((job / "result.json").read_text(encoding="utf-8")) res["job_dir"], res["wall_secs"] = str(job), round(time.time() - t0, 1) res["rhino_exit"], res["failed_attempts"] = exit_note, attempts if not res.get("ok"): raise RhinoJobError(res.get("error", "unknown error")) if save: save = Path(save) save.parent.mkdir(parents=True, exist_ok=True) save.write_bytes((job / "out.3dm").read_bytes()) res["saved_to"] = str(save) return res