- Code\rebar_pipeline, review_pipeline, pier-program을 정션에서 실제 폴더로 전환 - Code\rhino_mcp 및 CLAUDE_rhino배경.md 추가, .mcp.json은 PYTHONPATH로 새 위치 사용 - pier-program 내부 .git 제거(이력은 backup\pier-program_20261028.bundle에 보관) - worktree 가이드 반영(CLAUDE.local.md, HANDOFF.md) - D:\Rhino-MCP 경로 참조를 새 위치로 치환 Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
168 lines
7.9 KiB
Python
168 lines
7.9 KiB
Python
"""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": <abs path>, "globals": {...}}, ...],
|
|
"units_mm_tolerance": 0.001 | null, # set doc units = mm and absolute tolerance before the steps
|
|
"save": <abs .3dm path> | null,
|
|
"result": <abs .json path>}
|
|
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
|