From 7f10b69e3c3e907dd1f8b481dbf4a17356e981ee Mon Sep 17 00:00:00 2001 From: Hakan Seven Date: Wed, 17 Jun 2026 21:05:12 +0300 Subject: [PATCH] feat(automation): headless JSON server (--serve) + Python client MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add an external automation API (issue #29 / #100 track 2): `OpenCADStudio --serve` runs without a GUI and is driven over a line-based JSON protocol on stdin/stdout — open / new / run / entities / save. State persists across requests so a script or AI agent can act, observe, and act again. `run` drives the app's existing command system rather than a separate binding, so coverage grows with the app; synchronous commands apply now, pick-based interactive ones come once coordinate feeding is wired. Ships a ~100-line example ocs.py client over the same protocol — no FFI to maintain. Headless works because the app already constructs and dispatches GUI-less in tests. Co-Authored-By: Claude Opus 4.8 --- docs/automation/README.md | 46 +++++ .../__pycache__/ocs.cpython-312.pyc | Bin 0 -> 4954 bytes docs/automation/ocs.py | 90 ++++++++ src/app/automation.rs | 194 ++++++++++++++++++ src/app/mod.rs | 4 + src/main.rs | 7 + 6 files changed, 341 insertions(+) create mode 100644 docs/automation/README.md create mode 100644 docs/automation/__pycache__/ocs.cpython-312.pyc create mode 100644 docs/automation/ocs.py create mode 100644 src/app/automation.rs diff --git a/docs/automation/README.md b/docs/automation/README.md new file mode 100644 index 000000000..e41d91be4 --- /dev/null +++ b/docs/automation/README.md @@ -0,0 +1,46 @@ +# Headless automation API + +Open CAD Studio can run without a GUI and be driven over a line-based JSON +protocol — for scripts, batch jobs, or AI agents. + +```sh +OpenCADStudio --serve +``` + +It reads one JSON request per line on **stdin** and writes one JSON response per +line on **stdout**. The active document persists across requests, so a caller +can act → observe → act. + +## Protocol + +| Request | Response | +|---------|----------| +| `{"op":"new"}` | `{"ok":true,"total":0,"by_type":{}}` | +| `{"op":"open","path":"plan.dwg"}` | entity summary | +| `{"op":"run","cmd":"LAYER Walls"}` | `{"ok":true,"cmd":...,"entities":N,"added":D}` | +| `{"op":"entities"}` | `{"ok":true,"total":N,"by_type":{"Line":42,...}}` | +| `{"op":"save","path":"out.dwg"}` | `{"ok":true,"saved":"out.dwg"}` (path optional once opened/saved) | + +Every response has `"ok"`; failures carry `"error"`. `run` drives Open CAD +Studio's **own** command system — no separate bindings to maintain — so its +coverage grows with the app. + +> **Status (first increment):** `run` applies synchronous commands (system +> variables, layer ops, …). Pick-based interactive commands (drawing by clicking +> points) need coordinate feeding and are not wired headless yet. + +## Python client + +[`ocs.py`](ocs.py) is a ~100-line client — nothing to compile: + +```python +from ocs import Ocs + +with Ocs(binary="OpenCADStudio") as ocs: # spawns `--serve` + ocs.open("plan.dwg") + ocs.run("LAYER Walls") + print(ocs.entities()) + ocs.save("plan_out.dwg") +``` + +Any language can speak the same protocol over a subprocess pipe. diff --git a/docs/automation/__pycache__/ocs.cpython-312.pyc b/docs/automation/__pycache__/ocs.cpython-312.pyc new file mode 100644 index 0000000000000000000000000000000000000000..aa1e98f07a42a30e9776864fbe63c775db560073 GIT binary patch literal 4954 zcmb_gTW}NC8Q#^dzk%+B zylInj6*uqLBxjk1?UEUX5jSs;@seSa*H4}z6Kd zQnA=sy=>?51}9Uv3|0k;Mn(k56wz%;T-}=E#C3@25(m~1omi%Aj7;m?pybVo@eC<3 z$8~a!6&rGFgD~TRvcZ9Px?tp7Qi3J8fZ)k4HSJ+&Ar&ts# zS-few!~laK+yUfox~9R0l1bdu{|3mFe%Arv^^cN62Zu@jbS3LnN(OYVq*AG?S6j~E z`enmsA`1ct7>SMPxjf0ixgagK%xq8t0K23!lzc;g)C4IbNK7}mP^KIoW6WVwD4r>2 zZj{XE7B{9+vHPgXN!e@HZ4j)`MxIHAJmsWa@wD+0s;6${e1-Q*IjCyep|L6%(xeO) z7NYW`QVAs$SQVUBJ#rq;r(`}4CV-6kdJc@1z(zO`(wVx_@5})Pf}>%ANKBAXItgC<`#?DSg> zwe+H%ZV`MM7V-3B15BgPN)^@ydx+viXmE5R*CkJVoq>b#$6J61T!UQ8!*ml69dsL& zuZO|GBxvY5I+*QLfjSI*73v7x0q#YeY@?kt0<{L?ZBR#P7wx7|SQ(=|GzKH>^aa`u zb)4>`ai}{gT5^}C0KmDx2f@jbglq#da_v{pSe4vBLXuQyrb0^i7G)~WhFre`WkGE; z8;viYr6H<(rrr*`?4pznfPlTFLbS0-qj5WE$x=m1hBMW+z?)sTkBow(!@>)B5vV}d zJ=HZ9T(51qJj2bZkqmn=UY-VzmIGJeh0mdRdg{3|=f*r0N4;l!coE;$ zIo_QRwhb!OCDS(DY_=K)@KW_&AI$OBptvS&cJI5}J-FUIc(0@DXP4IEeVd(oSJNM* zH@kXQPkeOZ-j1Go-8~Q6BR#Pt^~cc%+oWj6N^CjylekchTmy304B-&s_$f425frGm zeoqrkA%L`*whrD^c~R!=E{a-wRK6XY`JwDKKre`(Sf}yp#RlbslniC6djl)XSq>T} z+GWnMZHNqD>&Xa1-DllUcpBo0kwVynrxv(ldtuAbDNlyjUL;X*x*2M=2ZpOrfifH!-xH7}`j@w50wy(Y4w6;@X}g8=a$T@zG5nt1ee> z3~nU)*0jDS@r}T4?Wpd@0fGM)-^Yj+G}w2`_ZT}WS7EM>x%^e63cPOp>QWW^pm*8w z)#tB<7DI)^mPzQM3D(mPzQ^5Br+5anO2c*P`VBsm7ek;Ak<24G=7pTOH@vWcA)?w* zw&!e&w4$4g`uflXCwSp`X1WI3FWR(REP*-BSY@6ULidze!xfs6RD9)7Ao1{23`-ns z0>Yqgjo~aeY#JyDHeZHWUW5XaM2oLPm!m6*<-}dBcU|lKCH-!Gf z(Z2Nr$sMf^bqkc{M~h1;HW!u`Zm2hmJBdNitQ&_uIsEbAPfy>>ZtOX>(Rq9=ejI3) z+Mm>{hUI=8coCY_Y}YV`AC*4~)lmclmFlhuSBIoS2gr&gw?ZJel#7r8q&&qo=5NUW zZ|RoIQm_^(G#ifF`d~1;d$kSi1&EK}Bw~F{`deTIZ!c9h5(8`6fX{BTM^}MRIGQ17 zJe%D`WH?wS7~XjnOql58)Wy^3Q}4b(DAVU5rAv7#`qo<^b`bUh=c>DT_DyKQ2y)HP zj9YB|+b~eYcrS9MW6-PhK=GLQcWw?M`{^|;{ruQ}^?ca|?(3N#FzJ+MAUphz8ET=03v&NLC)w4|Y3NR<`!VV#uP0Dow?;t{6op=l> zS~YAEuQ9Zy4T)=HT2hPuRmF~8L{}k#OWhpE5H)7>vgL}m3x*xkHiM=uox3BL?^4KQ z-Fy=C5PR&^#H2STVip8|6PE}SBVvD@cG1TS&BX@Ry1Nc zFdcl&z6H~z0m*GdrKiRAL5cM~GZuYGT#=eZ%eN<1jw~Np zIq}02_u|`E&Mco<8DAd18{fYk-+w1Q@F1i_d+QUK<;sPU`{A&gW*ToQU85>oV^RHP{BE-6<`vw{Lj#KS^d|bBoBNpwf{xh@mGle YXYwg|Y4M?i^}n^Fa?ckM6oT3R0)WyftN;K2 literal 0 HcmV?d00001 diff --git a/docs/automation/ocs.py b/docs/automation/ocs.py new file mode 100644 index 000000000..461d4af21 --- /dev/null +++ b/docs/automation/ocs.py @@ -0,0 +1,90 @@ +"""Thin Python client for the Open CAD Studio headless automation server. + +Launches `OpenCADStudio --serve` and talks to it over a line-based JSON protocol +(one request object per line on stdin, one response per line on stdout). There +is nothing to compile or maintain on the Python side — every method is one JSON +message; the real work is Open CAD Studio's own command system. + + from ocs import Ocs + + with Ocs(binary="OpenCADStudio") as ocs: + ocs.open("plan.dwg") + ocs.run("LAYER Walls") + print(ocs.entities()) # {"total": 42, "by_type": {...}} + ocs.save("plan_out.dwg") + +Each call returns the parsed response dict and raises `OcsError` on `ok: false`. +""" + +from __future__ import annotations + +import json +import subprocess +from typing import Any, Optional + + +class OcsError(RuntimeError): + """Raised when the server replies with `{"ok": false, ...}`.""" + + +class Ocs: + def __init__(self, binary: str = "OpenCADStudio") -> None: + self.proc = subprocess.Popen( + [binary, "--serve"], + stdin=subprocess.PIPE, + stdout=subprocess.PIPE, + text=True, + bufsize=1, + ) + self._read() # the {"ready": true} greeting + + # ── protocol ──────────────────────────────────────────────────────────── + def _read(self) -> dict[str, Any]: + line = self.proc.stdout.readline() + if not line: + raise OcsError("server closed the connection") + return json.loads(line) + + def _send(self, **req: Any) -> dict[str, Any]: + self.proc.stdin.write(json.dumps(req) + "\n") + self.proc.stdin.flush() + resp = self._read() + if not resp.get("ok", False): + raise OcsError(resp.get("error", "unknown error")) + return resp + + # ── operations ────────────────────────────────────────────────────────── + def new(self) -> dict[str, Any]: + """Start an empty document.""" + return self._send(op="new") + + def open(self, path: str) -> dict[str, Any]: + """Load a DWG/DXF drawing.""" + return self._send(op="open", path=path) + + def run(self, cmd: str) -> dict[str, Any]: + """Run a command through Open CAD Studio's command system.""" + return self._send(op="run", cmd=cmd) + + def entities(self) -> dict[str, Any]: + """Total entity count and a breakdown by type.""" + return self._send(op="entities") + + def save(self, path: Optional[str] = None) -> dict[str, Any]: + """Write the document (defaults to the opened/last-saved path).""" + return self._send(op="save", path=path) + + # ── lifecycle ─────────────────────────────────────────────────────────── + def close(self) -> None: + if self.proc.stdin: + self.proc.stdin.close() + try: + self.proc.wait(timeout=5) + except subprocess.TimeoutExpired: + self.proc.kill() + + def __enter__(self) -> "Ocs": + return self + + def __exit__(self, *_exc: object) -> None: + self.close() diff --git a/src/app/automation.rs b/src/app/automation.rs new file mode 100644 index 000000000..360e0fd06 --- /dev/null +++ b/src/app/automation.rs @@ -0,0 +1,194 @@ +//! Headless automation server (`OpenCADStudio --serve`). +//! +//! Drives the app without a GUI over a line-based JSON protocol: one request +//! object per line on stdin, one response object per line on stdout. State (the +//! active document) persists across requests, so an external process — a script +//! or an AI agent — can act, observe, and act again. +//! +//! Operations: +//! - `{"op":"new"}` — start an empty document +//! - `{"op":"open","path":"file.dwg"}` — load a drawing +//! - `{"op":"run","cmd":"LAYER Walls"}` — run a command (the same dispatcher +//! the GUI command line uses) +//! - `{"op":"entities"}` — summary count by entity type +//! - `{"op":"save","path":"out.dwg"}` — write the document (path optional +//! once opened/saved) + +use std::io::{BufRead, Write}; +use std::path::PathBuf; + +use serde_json::{json, Value}; + +use super::OpenCADStudio; + +/// Run the headless JSON server until stdin closes. +pub fn serve() { + let mut app = OpenCADStudio::new(); + let stdin = std::io::stdin(); + let stdout = std::io::stdout(); + + emit( + &stdout, + json!({"ok": true, "ready": true, "version": env!("CARGO_PKG_VERSION")}), + ); + + for line in stdin.lock().lines() { + let Ok(line) = line else { break }; + let line = line.trim(); + if line.is_empty() { + continue; + } + let resp = app.automation_op(line); + emit(&stdout, resp); + } +} + +fn emit(stdout: &std::io::Stdout, value: Value) { + let mut o = stdout.lock(); + let _ = writeln!(o, "{value}"); + let _ = o.flush(); +} + +fn err(msg: impl std::fmt::Display) -> Value { + json!({ "ok": false, "error": msg.to_string() }) +} + +impl OpenCADStudio { + /// Handle one JSON request line and return the JSON response. + pub(crate) fn automation_op(&mut self, line: &str) -> Value { + let req: Value = match serde_json::from_str(line) { + Ok(v) => v, + Err(e) => return err(format!("invalid JSON: {e}")), + }; + match req["op"].as_str().unwrap_or("") { + "new" => { + let i = self.active_tab; + self.tabs[i].scene.document = acadrust::CadDocument::new(); + self.tabs[i].current_path = None; + self.tabs[i].scene.bump_geometry(); + self.entity_summary() + } + "open" => { + let Some(path) = req["path"].as_str() else { + return err("open: missing \"path\""); + }; + let bytes = match std::fs::read(path) { + Ok(b) => b, + Err(e) => return err(format!("open: {e}")), + }; + let name = PathBuf::from(path) + .file_name() + .map(|n| n.to_string_lossy().into_owned()) + .unwrap_or_else(|| path.to_string()); + match crate::io::load_bytes(&name, bytes) { + Ok(doc) => { + let i = self.active_tab; + self.tabs[i].scene.document = doc; + self.tabs[i].current_path = Some(PathBuf::from(path)); + self.tabs[i].scene.bump_geometry(); + self.entity_summary() + } + Err(e) => err(format!("open: {e}")), + } + } + "run" => { + let cmd = req["cmd"].as_str().unwrap_or("").to_string(); + if cmd.is_empty() { + return err("run: missing \"cmd\""); + } + let i = self.active_tab; + let before = self.tabs[i].scene.document.entities().count(); + // The returned Task drives GUI follow-up; synchronous commands + // (system variables, layer ops, …) have already applied. Pick- + // based interactive commands need coordinate feeding — not yet + // wired headless. + let _ = self.dispatch_command(&cmd); + let after = self.tabs[i].scene.document.entities().count(); + json!({ + "ok": true, + "cmd": cmd, + "entities": after, + "added": after as i64 - before as i64, + }) + } + "entities" => self.entity_summary(), + "save" => { + let i = self.active_tab; + let path = req["path"] + .as_str() + .map(PathBuf::from) + .or_else(|| self.tabs[i].current_path.clone()); + let Some(path) = path else { + return err("save: no \"path\" and the document has none"); + }; + match crate::io::save(&self.tabs[i].scene.document, &path) { + Ok(()) => { + self.tabs[i].current_path = Some(path.clone()); + json!({ "ok": true, "saved": path.to_string_lossy() }) + } + Err(e) => err(format!("save: {e}")), + } + } + "" => err("missing \"op\""), + other => err(format!("unknown op: {other}")), + } + } + + /// Count of entities in the active document, total and by type. + fn entity_summary(&self) -> Value { + let i = self.active_tab; + let mut by_type: std::collections::BTreeMap = Default::default(); + let mut total = 0u64; + for e in self.tabs[i].scene.document.entities() { + *by_type + .entry(crate::entities::names::ui_name(e).to_string()) + .or_default() += 1; + total += 1; + } + json!({ "ok": true, "total": total, "by_type": by_type }) + } +} + +#[cfg(test)] +mod tests { + use crate::app::OpenCADStudio; + + #[test] + fn automation_ops_round_trip() { + let mut app = OpenCADStudio::new_for_test(); + + let r = app.automation_op(r#"{"op":"new"}"#); + assert_eq!(r["ok"], true); + assert_eq!(r["total"], 0); + + // A synchronous command runs through the real dispatcher. + let r = app.automation_op(r#"{"op":"run","cmd":"PDMODE 3"}"#); + assert_eq!(r["ok"], true); + assert_eq!(r["cmd"], "PDMODE 3"); + + let r = app.automation_op(r#"{"op":"entities"}"#); + assert_eq!(r["ok"], true); + + // Errors are reported, never panics. + assert_eq!(app.automation_op(r#"{"op":"bogus"}"#)["ok"], false); + assert_eq!(app.automation_op("not json")["ok"], false); + assert_eq!(app.automation_op(r#"{"op":"run"}"#)["ok"], false); + } + + #[test] + fn save_then_open_round_trips() { + let mut app = OpenCADStudio::new_for_test(); + let path = std::env::temp_dir().join("ocs_automation_test.dxf"); + let p = path.to_string_lossy(); + app.automation_op(r#"{"op":"new"}"#); + assert_eq!( + app.automation_op(&format!(r#"{{"op":"save","path":"{p}"}}"#))["ok"], + true + ); + assert_eq!( + app.automation_op(&format!(r#"{{"op":"open","path":"{p}"}}"#))["ok"], + true + ); + let _ = std::fs::remove_file(&path); + } +} diff --git a/src/app/mod.rs b/src/app/mod.rs index aa43a9618..ce09d5728 100644 --- a/src/app/mod.rs +++ b/src/app/mod.rs @@ -1,3 +1,7 @@ +#[cfg(not(target_arch = "wasm32"))] +mod automation; +#[cfg(not(target_arch = "wasm32"))] +pub use automation::serve; mod cmd_result; mod commands; mod document; diff --git a/src/main.rs b/src/main.rs index d7c2b29d6..7b01737f1 100644 --- a/src/main.rs +++ b/src/main.rs @@ -39,6 +39,13 @@ fn main() -> iced::Result { } #[cfg(not(target_arch = "wasm32"))] { + // Headless automation server — drive the app over JSON on stdin/stdout + // with no GUI (issue #29). `--serve` is the opt-in; everything else + // launches the editor. + if std::env::args().skip(1).any(|a| a == "--serve") { + app::serve(); + return Ok(()); + } app::run() } }