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 000000000..aa1e98f07 Binary files /dev/null and b/docs/automation/__pycache__/ocs.cpython-312.pyc differ 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() } }