Files
Pier_v1/Code/교각모델링앱/runner.py
T
ahnyongjooandClaude Sonnet 5.5 964a841de1 chore(rebar): Pier_T_rebar 작업 폴더 초기 구성 및 외부 의존 파일 통합
- 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>
2026-10-28 11:20:18 +09:00

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