From 28cc4730629575aba15f3c268fb4233513028a9f Mon Sep 17 00:00:00 2001 From: Hakan Seven Date: Fri, 4 Sep 2026 22:25:55 +0300 Subject: [PATCH] Add persistent live OpenCADStudio control --- .github/workflows/control.yml | 36 + Cargo.lock | 1 + Cargo.toml | 2 + docs/automation/README.md | 78 +- docs/automation/install.sh | 20 + docs/automation/live.py | 130 +++ docs/automation/mcp_server.py | 51 + docs/automation/mcp_smoke.py | 19 + docs/automation/requirements-mcp.txt | 1 + .../codex/opencadstudio-control/SKILL.md | 20 + .../opencadstudio-control/agents/openai.yaml | 4 + src/app/automation.rs | 64 +- src/app/command_driver.rs | 38 +- src/app/control/actions.rs | 281 ++++++ src/app/control/mod.rs | 929 ++++++++++++++++++ src/app/control/transport.rs | 103 ++ src/app/mod.rs | 12 +- src/app/update/command.rs | 5 +- src/app/update/mod.rs | 18 +- src/app/view/mod.rs | 8 +- src/ui/command_line.rs | 8 + src/ui/statusbar/mod.rs | 31 + 22 files changed, 1764 insertions(+), 95 deletions(-) create mode 100644 .github/workflows/control.yml create mode 100755 docs/automation/install.sh create mode 100644 docs/automation/live.py create mode 100644 docs/automation/mcp_server.py create mode 100644 docs/automation/mcp_smoke.py create mode 100644 docs/automation/requirements-mcp.txt create mode 100644 integrations/codex/opencadstudio-control/SKILL.md create mode 100644 integrations/codex/opencadstudio-control/agents/openai.yaml create mode 100644 src/app/control/actions.rs create mode 100644 src/app/control/mod.rs create mode 100644 src/app/control/transport.rs diff --git a/.github/workflows/control.yml b/.github/workflows/control.yml new file mode 100644 index 000000000..156be04dd --- /dev/null +++ b/.github/workflows/control.yml @@ -0,0 +1,36 @@ +name: OCS control contract +on: + push: + branches: [main] + pull_request: + workflow_dispatch: +jobs: + native: + strategy: + fail-fast: false + matrix: + os: [ubuntu-latest, windows-latest, macos-latest] + runs-on: ${{ matrix.os }} + timeout-minutes: 60 + steps: + - uses: actions/checkout@v4 + - uses: dtolnay/rust-toolchain@stable + - name: Validate control contract + run: cargo test --lib app::control::tests + - name: Validate Python adapters + if: runner.os != 'Windows' + run: python3 -m py_compile docs/automation/live.py docs/automation/mcp_server.py + mcp: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - run: python3 -m pip install -r docs/automation/requirements-mcp.txt + - run: python3 docs/automation/mcp_smoke.py + web: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - uses: dtolnay/rust-toolchain@stable + with: + targets: wasm32-unknown-unknown + - run: cargo check --lib --target wasm32-unknown-unknown diff --git a/Cargo.lock b/Cargo.lock index 0e39fbc44..cf694c99d 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -25,6 +25,7 @@ dependencies = [ "iced", "iced_aw", "iced_core", + "iced_runtime", "image", "inventory", "js-sys", diff --git a/Cargo.toml b/Cargo.toml index beea91a83..149490736 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -20,6 +20,7 @@ serde_json = "1" serde = { version = "1", features = ["derive"] } semver = "1" iced = { git = "https://github.com/iced-rs/iced.git", rev = "23604ff22ab0aad9e00b9327cb7b8546ed84db39", features = ["image", "svg", "advanced", "canvas", "smol", "markdown"] } +iced_runtime = { git = "https://github.com/iced-rs/iced.git", rev = "23604ff22ab0aad9e00b9327cb7b8546ed84db39" } iced_core = { git = "https://github.com/iced-rs/iced.git", rev = "23604ff22ab0aad9e00b9327cb7b8546ed84db39" } iced_aw = { git = "https://github.com/HakanSeven12/iced_aw.git", branch = "agent/fix-iced-fonts", features = ["menu", "context_menu", "color_picker", "drop_down"] } bytemuck = { version = "1.25", features = ["derive"] } @@ -65,6 +66,7 @@ acadrust = { git = "https://git@github.com/HakanSeven12/cadcodec.git", rev = "5b [target.'cfg(not(target_arch = "wasm32"))'.dependencies] +getrandom = "0.3" ocs_plugin_api = { path = "crates/ocs_plugin_api", features = ["host"] } meshopt = "0.6.2" i18n-embed = { version = "0.16", features = ["desktop-requester"] } diff --git a/docs/automation/README.md b/docs/automation/README.md index 80b73f268..dbcb45aa8 100644 --- a/docs/automation/README.md +++ b/docs/automation/README.md @@ -1,73 +1,27 @@ -# Headless automation API +# OpenCADStudio automation -Open CAD Studio can run without a GUI and be driven over a line-based JSON -protocol — for scripts, batch jobs, or AI agents. +`--serve` remains available for isolated headless scripts. `live.py` connects to the real desktop application through a private per-process descriptor under the user's OCS configuration directory. The GUI owns every mutation: transport threads only validate and enqueue requests. -```sh -OpenCADStudio --serve # stdin/stdout transport -OpenCADStudio --serve --port 4242 # listen on 127.0.0.1:4242 instead -``` +The live protocol requires protocol version 1, stable session/document IDs, an expected edit revision for mutations, and a unique request ID. Commands report `waiting_input` while interactive work remains. Asynchronous file and renderer work remains `running` until its real callback finishes. A bounded operation cache makes retries with the same payload idempotent; after a restart or an expired operation, clients must read state instead of replaying a mutation. -It reads one JSON request per line and writes one JSON response per line — over -**stdin/stdout**, or over a **local TCP socket** with `--port`. The active -document persists across requests (and, on the socket, across reconnects), so a -caller can act → observe → act. +Operations include document state and activation, paged entity queries, layer/header/property inspection, command discovery, start/run/step/cancel, WCS/UCS/relative points, entity and structure picks, selection, property edits, semantic UI actions, undo/redo, protected save, event polling, and current-window PNG capture. The status bar includes a control switch whose green, yellow, and red states mean ready, busy, and off. -## Protocol +The Web build exposes `ocs_control_submit(json)` and `ocs_control_take(ticket)`. They enter the same bounded GUI queue and return the same semantic responses; browser code polls the ticket until a response is present. -| 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":"query","type":"Line","layer":"Walls"}` | per-entity `{handle,type,layer,…geometry}` (Line/Circle/Arc/Point/Ellipse/Text/MText/Polyline/Insert; filters + `limit` optional) | -| `{"op":"layers"}` | layers `{name,color,off,frozen,locked}` + the current layer | -| `{"op":"header"}` | drawing variables (units, PDMODE/PDSIZE, LTSCALE, …) | -| `{"op":"select","handles":["2B"]}` | set the selection (by `handles`, `type`, or `layer`; `clear` to deselect) → `{"ok":true,"selected":N}` | -| `{"op":"undo"}` / `{"op":"redo"}` | step the document history → entity summary | -| `{"op":"save","path":"out.dwg"}` | `{"ok":true,"saved":"out.dwg"}` (path optional once opened/saved) | +Run `./docs/automation/install.sh` once. It installs the release binary and pinned MCP environment in `~/.local/share/ocs-control`, registers the `opencadstudio` stdio server in the user's Codex configuration, and leaves source-independent launch paths. The personal `opencadstudio-control` skill tells later Codex sessions how to use it safely. -Selection drives modify commands — `select` the targets (e.g. the handles a -`query` returned), then `run("ERASE")`: - -```python -ocs.select(type="Line"); ocs.run("ERASE") # erase every line -ids = [e["handle"] for e in ocs.query(layer="Walls")["entities"]] -ocs.select(handles=ids); ocs.run("ERASE") -``` - -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. - -Interactive draw commands take their points as coordinate tokens; the tool is -started, the points are fed, and it is terminated as if Enter were pressed: - -``` -{"op":"run","cmd":"LINE 0,0 10,10 10,20"} → two Line segments -{"op":"run","cmd":"CIRCLE 5,5 3"} → centre 5,5 radius 3 -``` - -Coordinates are `x,y` or `x,y,z`; `@dx,dy` is relative to the previous point. -Inline-argument commands (`PDMODE 3`, `LAYER Walls`) are passed through as-is. - -> Coverage is growing: commands whose options are typed keywords or coordinates -> work; ones that still rely on on-screen picking (object selection by clicking) -> are being wired next. - -## Python client - -[`ocs.py`](ocs.py) is a ~100-line client — nothing to compile: +The legacy client is unchanged: ```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") +with Ocs(binary="OpenCADStudio") as app: + app.new() + app.run("LINE 0,0 100,0") ``` -Any language can speak the same protocol over a subprocess pipe. +For direct live diagnostics: + +```sh +python3 docs/automation/live.py --launch +python3 docs/automation/live.py '{"op":"state"}' +``` diff --git a/docs/automation/install.sh b/docs/automation/install.sh new file mode 100755 index 000000000..2a8514fbb --- /dev/null +++ b/docs/automation/install.sh @@ -0,0 +1,20 @@ +#!/bin/sh +set -eu +ROOT=$(CDPATH= cd -- "$(dirname -- "$0")/../.." && pwd) +DEST=${XDG_DATA_HOME:-"$HOME/.local/share"}/ocs-control +mkdir -p "$DEST" +cargo build --manifest-path "$ROOT/Cargo.toml" --release +cp "$ROOT/target/release/OpenCADStudio" "$DEST/OpenCADStudio.new" +chmod 755 "$DEST/OpenCADStudio.new" +mv -f "$DEST/OpenCADStudio.new" "$DEST/OpenCADStudio" +cp "$ROOT/docs/automation/live.py" "$DEST/live.py" +cp "$ROOT/docs/automation/mcp_server.py" "$DEST/mcp_server.py" +uv venv "$DEST/venv" +uv pip install --python "$DEST/venv/bin/python" -r "$ROOT/docs/automation/requirements-mcp.txt" +SKILLS=${CODEX_HOME:-"$HOME/.codex"}/skills +mkdir -p "$SKILLS/opencadstudio-control/agents" +cp "$ROOT/integrations/codex/opencadstudio-control/SKILL.md" "$SKILLS/opencadstudio-control/SKILL.md" +cp "$ROOT/integrations/codex/opencadstudio-control/agents/openai.yaml" "$SKILLS/opencadstudio-control/agents/openai.yaml" +codex mcp remove opencadstudio >/dev/null 2>&1 || true +codex mcp add opencadstudio -- "$DEST/venv/bin/python" "$DEST/mcp_server.py" +printf 'Installed OpenCADStudio and its persistent Codex MCP connection.\n' diff --git a/docs/automation/live.py b/docs/automation/live.py new file mode 100644 index 000000000..eec6a22b4 --- /dev/null +++ b/docs/automation/live.py @@ -0,0 +1,130 @@ +"""Persistent local GUI client; no CAD implementation lives in this adapter.""" +from __future__ import annotations +import json +import os +from pathlib import Path +import socket +import subprocess +import sys +import time +import uuid +from typing import Any + +READ_OPS = {"state", "hello", "query", "entities", "layers", "header", "properties", "measure", "history", "commands", "events", "operation"} + + +def discovery_dir() -> Path: + if sys.platform == "win32": + base = Path(os.environ["APPDATA"]) + elif sys.platform == "darwin": + base = Path.home() / "Library/Application Support" + else: + base = Path(os.environ.get("XDG_CONFIG_HOME", Path.home() / ".config")) + return base / "OpenCADStudio/automation" + + +def exchange(descriptor: dict, request: dict, timeout: float = 15) -> dict: + payload = {**request, "token": descriptor["token"], "session_id": descriptor["session_id"], "protocol": 1} + wire = (json.dumps(payload, ensure_ascii=False) + "\n").encode() + if len(wire) > 1_048_576: + raise ValueError("Request exceeds 1 MiB") + with socket.create_connection(("127.0.0.1", descriptor["port"]), timeout=timeout) as sock: + sock.sendall(wire) + with sock.makefile("rb") as stream: + line = stream.readline(16 * 1024 * 1024 + 1) + if not line or len(line) > 16 * 1024 * 1024: + raise ConnectionError("No valid OCS response; query request_id before retrying a mutation") + return json.loads(line) + + +def sessions() -> list[dict]: + found = [] + for path in sorted(discovery_dir().glob("*.json")): + descriptor = None + try: + if os.name == "posix" and (path.stat().st_uid != os.getuid() or path.stat().st_mode & 0o077): + continue + descriptor = json.loads(path.read_text()) + state = exchange(descriptor, {"op": "hello"}, timeout=1) + if state.get("ok") and state.get("session_id") == descriptor["session_id"]: + found.append({"descriptor": descriptor, "state": state}) + except (OSError, ValueError, KeyError): + # A crashed process cannot remove its descriptor. Only reap a file + # whose recorded PID is certainly gone; a busy live session keeps + # its discovery record for the next call. + try: + os.kill(int(descriptor["pid"]), 0) + except (ProcessLookupError, TypeError, KeyError): + try: + path.unlink() + except OSError: + pass + continue + return found + + +class LiveOcs: + def __init__(self, session_id: str | None = None): + available = sessions() + if session_id: + available = [s for s in available if s["state"]["session_id"] == session_id] + if len(available) != 1: + raise ValueError("Choose session_id from sessions; found " + str(len(available))) + self.descriptor = available[0]["descriptor"] + self.state = available[0]["state"] + self.client_id = uuid.uuid4().hex + + def request(self, request: dict, wait_seconds: float = 30) -> dict: + req = dict(request) + if req.get("op") not in READ_OPS: + req.setdefault("request_id", uuid.uuid4().hex) + req.setdefault("client_id", self.client_id) + req.setdefault("document_id", self.state["document_id"]) + req.setdefault("revision", self.state["revision"]) + if req.get("op") in {"input", "property", "run", "action", "save", "undo", "redo"}: + req.setdefault("selection", self.state.get("selection", [])) + response = exchange(self.descriptor, req) + deadline = time.monotonic() + min(max(wait_seconds, 0), 60) + while response.get("status") in ("accepted", "running") and time.monotonic() < deadline: + time.sleep(0.05) + response = exchange(self.descriptor, {"op": "operation", "request_id": req["request_id"]}) + if "state" in response: + self.state = response["state"] + elif req.get("op") in ("hello", "state") and response.get("ok"): + self.state = response + return response + + +def launch(binary: str | None = None) -> list[dict]: + existing = sessions() + if existing: + return existing + executable = binary or os.environ.get("OCS_BINARY", str(Path.home() / ".local/share/ocs-control/OpenCADStudio")) + log_dir = Path.home() / ".local/share/ocs-control" + log_dir.mkdir(parents=True, exist_ok=True) + with (log_dir / "gui.log").open("ab") as log: + proc = subprocess.Popen([executable, "--new-instance"], stdin=subprocess.DEVNULL, stdout=log, stderr=log, start_new_session=True) + deadline = time.monotonic() + 30 + while time.monotonic() < deadline: + if proc.poll() is not None: + raise RuntimeError(f"OCS exited ({proc.returncode}); inspect {log_dir / 'gui.log'}") + available = sessions() + if available: + return available + time.sleep(0.2) + raise TimeoutError("OCS is still starting; call sessions again") + + +if __name__ == "__main__": + import argparse + parser = argparse.ArgumentParser() + parser.add_argument("request", nargs="?", help="JSON request; omit to list sessions") + parser.add_argument("--session") + parser.add_argument("--launch", action="store_true") + args = parser.parse_args() + if args.launch: + launch() + if args.request: + print(json.dumps(LiveOcs(args.session).request(json.loads(args.request)), ensure_ascii=False)) + else: + print(json.dumps([s["state"] for s in sessions()], ensure_ascii=False)) diff --git a/docs/automation/mcp_server.py b/docs/automation/mcp_server.py new file mode 100644 index 000000000..c16b022f6 --- /dev/null +++ b/docs/automation/mcp_server.py @@ -0,0 +1,51 @@ +"""Local stdio MCP adapter for a running OpenCADStudio GUI.""" +from pathlib import Path +import tempfile +from typing import Any +from mcp.server.fastmcp import FastMCP, Image +from live import LiveOcs, READ_OPS, launch, sessions + +mcp = FastMCP("OpenCADStudio", instructions="Use ocs_sessions, then ocs_read(state/commands) before editing. All changes target the actual GUI document. Preserve session_id, document_id and revisions. waiting_input means the command or dialog still needs input. Poll running operations; never replay a mutation with a new request_id after a timeout. Geometry is handled by OCS and its kernel.") +clients: dict[str, LiveOcs] = {} + + +def client(session_id: str) -> LiveOcs: + if session_id not in clients: + clients[session_id] = LiveOcs(session_id) + return clients[session_id] + + +@mcp.tool() +def ocs_sessions(launch_if_none: bool = True) -> list[dict[str, Any]]: + """List real OCS GUI sessions and documents. Launch installed OCS if none is running.""" + available = launch() if launch_if_none else sessions() + return [s["state"] for s in available] + + +@mcp.tool() +def ocs_read(session_id: str, op: str = "state", parameters: dict[str, Any] | None = None) -> dict[str, Any]: + """Read state, commands/actions, query (type/layer/offset/limit), entities, layers, header, properties, measure(handles), history, events(after), operation(request_id).""" + if op not in READ_OPS: + raise ValueError("Use ocs_execute for mutations") + return client(session_id).request({**(parameters or {}), "op": op}) + + +@mcp.tool() +def ocs_execute(session_id: str, request: dict[str, Any], wait_seconds: float = 30) -> dict[str, Any]: + """Execute one semantic action. op: new/open(path)/activate(document_id)/start(cmd)/run(cmd)/input(kind,text or point)/select(handles)/property(field,value)/action(name)/save(path)/undo/redo/cancel. Input kinds: text, token, point(space wcs/ucs/relative), entity or structure(handle,point), selection, enter. Supply document_id and revision from state. request_id is the idempotency key. Accepted/running is not completion. Query operation after a timeout.""" + return client(session_id).request(request, wait_seconds) + + +@mcp.tool() +def ocs_capture(session_id: str) -> Image: + """Capture the actual current OCS window as PNG for visual verification.""" + with tempfile.TemporaryDirectory(prefix="ocs-capture-") as directory: + path = Path(directory) / "window.png" + result = client(session_id).request({"op": "capture", "path": str(path)}) + if not result.get("ok") or result.get("status") != "completed": + raise RuntimeError(str(result)) + return Image(data=path.read_bytes(), format="png") + + +if __name__ == "__main__": + mcp.run(transport="stdio") diff --git a/docs/automation/mcp_smoke.py b/docs/automation/mcp_smoke.py new file mode 100644 index 000000000..bedbef3d8 --- /dev/null +++ b/docs/automation/mcp_smoke.py @@ -0,0 +1,19 @@ +"""Protocol smoke test for the bundled MCP adapter.""" +import asyncio +from pathlib import Path +import sys +from mcp import ClientSession, StdioServerParameters +from mcp.client.stdio import stdio_client + +async def main() -> None: + server = Path(__file__).with_name("mcp_server.py") + params = StdioServerParameters(command=sys.executable, args=[str(server)]) + async with stdio_client(params) as (read, write): + async with ClientSession(read, write) as session: + await session.initialize() + tools = await session.list_tools() + names = {tool.name for tool in tools.tools} + assert names == {"ocs_sessions", "ocs_read", "ocs_execute", "ocs_capture"}, names + +if __name__ == "__main__": + asyncio.run(main()) diff --git a/docs/automation/requirements-mcp.txt b/docs/automation/requirements-mcp.txt new file mode 100644 index 000000000..2eea1d5fb --- /dev/null +++ b/docs/automation/requirements-mcp.txt @@ -0,0 +1 @@ +mcp==1.29.1 diff --git a/integrations/codex/opencadstudio-control/SKILL.md b/integrations/codex/opencadstudio-control/SKILL.md new file mode 100644 index 000000000..1c4b3831c --- /dev/null +++ b/integrations/codex/opencadstudio-control/SKILL.md @@ -0,0 +1,20 @@ +--- +name: opencadstudio-control +description: Inspect and control the user's live OpenCADStudio desktop session through its persistent MCP tools. Use for drawing, editing, selecting, measuring, changing properties or layers, opening/saving/exporting files, reviewing the current OCS UI, or visually verifying OCS results. Do not use for source-code changes to the OpenCADStudio repository. +--- + +# OpenCADStudio Control + +Use the `ocs_*` MCP tools to act on the actual running application. Call `ocs_sessions` first; it opens the installed application when none is running. If multiple sessions exist, select the intended `session_id` from the listed documents instead of guessing. + +Read `state` before an edit. Carry its `session_id`, `document_id`, `revision`, and selection into mutations. Treat `request_id` as the idempotency key: after a timeout, query that operation and never replay it with a new ID until its absence is established. Refresh state after `stale_state`, `selection_changed`, `document_closed`, or `session_changed`. + +Prefer semantic commands and inputs: + +- Use `start` followed by typed `input` calls for interactive geometry, points, entity handles, structure picks, selection completion, text, Enter, and cancellation. +- Use `run` for a complete command line, and respect `waiting_input`; inspect its prompt and language-independent option keywords before continuing. +- Use `property` with IDs returned by `properties`, and `action` with IDs returned by `commands`. +- Let OCS and its geometry kernel calculate geometry. Verify important results with entity/document queries and a current `ocs_capture` image. +- Save to an explicit path, wait for `completed`, reopen when persistence or round-trip correctness matters, and inspect state afterward. + +Keep user edits and open tabs intact unless the task requires changing them. A successful transport response can still report `waiting_input`, `failed`, `cancelled`, or a running operation; only `completed` means that operation finished. diff --git a/integrations/codex/opencadstudio-control/agents/openai.yaml b/integrations/codex/opencadstudio-control/agents/openai.yaml new file mode 100644 index 000000000..4ae375464 --- /dev/null +++ b/integrations/codex/opencadstudio-control/agents/openai.yaml @@ -0,0 +1,4 @@ +interface: + display_name: "OpenCADStudio Control" + short_description: "Control and inspect the live OpenCADStudio GUI" + default_prompt: "Use the live OpenCADStudio session to complete this CAD task and verify the result." diff --git a/src/app/automation.rs b/src/app/automation.rs index 16846f5e1..822b6eea3 100644 --- a/src/app/automation.rs +++ b/src/app/automation.rs @@ -193,6 +193,19 @@ fn entity_json(e: &acadrust::EntityType) -> Value { impl OpenCADStudio { /// Handle one JSON request line and return the JSON response. pub(crate) fn automation_op(&mut self, line: &str) -> Value { + if let Ok(req) = serde_json::from_str::(line) { + if req["protocol"].is_number() { + let id = req["request_id"].clone(); + let (response, task) = self.control_request(req); + if let Err(error) = self.drive_headless_task(task) { + return err(error); + } + if matches!(response["status"].as_str(), Some("accepted" | "running")) { + return self.control_request(json!({"op":"operation","request_id":id})).0; + } + return response; + } + } let res = self.automation_op_inner(line); // Most automation ops mutate `Scene::selected` directly rather than // going through `update()` (`select` calls `deselect_all` / @@ -203,7 +216,7 @@ impl OpenCADStudio { res } - fn automation_op_inner(&mut self, line: &str) -> Value { + pub(super) fn automation_op_inner(&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}")), @@ -220,6 +233,7 @@ impl OpenCADStudio { self.tabs[i].scene.bump_geometry(); self.entity_summary() } + #[cfg(not(target_arch = "wasm32"))] "open" => { let Some(path) = req["path"].as_str() else { return err("open: missing \"path\""); @@ -249,6 +263,8 @@ impl OpenCADStudio { Err(e) => err(format!("open: {e}")), } } + #[cfg(target_arch = "wasm32")] + "open" => err("open: use the browser file action"), "run" => { let cmd = req["cmd"].as_str().unwrap_or("").to_string(); if cmd.is_empty() { @@ -256,11 +272,16 @@ impl OpenCADStudio { } let i = self.active_tab; let before = self.tabs[i].scene.document.entities().count(); - self.run_headless(&cmd); + let error_revision = self.command_line.error_revision; + if let Err(error) = self.run_headless(&cmd) { return err(error); } + if self.command_line.error_revision != error_revision { + return err(self.command_line.last_error.clone().unwrap_or_default()); + } let after = self.tabs[i].scene.document.entities().count(); json!({ "ok": true, "cmd": cmd, + "status": if self.tabs[i].active_cmd.is_some() { "waiting_input" } else { "completed" }, "entities": after, "added": after as i64 - before as i64, }) @@ -269,12 +290,17 @@ impl OpenCADStudio { "query" => self.entity_query(&req), "layers" => { let i = self.active_tab; + let offset = req["offset"].as_u64().unwrap_or(0) as usize; + let limit = req["limit"].as_u64().unwrap_or(1000).min(10_000) as usize; + let count = self.tabs[i].scene.document.layers.iter().count(); let layers: Vec = self .tabs[i] .scene .document .layers .iter() + .skip(offset) + .take(limit) .map(|l| { let mut o = json!({ "name": l.name, @@ -295,6 +321,8 @@ impl OpenCADStudio { json!({ "ok": true, "current": self.tabs[i].scene.document.header.current_layer_name, + "count": count, + "next_offset": (offset + layers.len() < count).then_some(offset + layers.len()), "layers": layers, }) } @@ -385,8 +413,28 @@ impl OpenCADStudio { /// [`OpenCADStudio::run_command_line`] (see `cmd_result.rs`), which the GUI /// command line uses too so both process `UCS Z 90` / `LINE 0,0 10,10` / /// `PDMODE 3` identically. - fn run_headless(&mut self, cmd: &str) { - let _ = self.run_command_line(cmd); + fn run_headless(&mut self, cmd: &str) -> Result<(), String> { + let task = self.run_command_line(cmd); + self.drive_headless_task(task) + } + + pub(super) fn drive_headless_task(&mut self, task: iced::Task) -> Result<(), String> { + use iced::futures::StreamExt; + let mut streams = Vec::new(); + if let Some(stream) = iced_runtime::task::into_stream(task) { streams.push(stream); } + while let Some(stream) = streams.last_mut() { + match iced::futures::executor::block_on(stream.next()) { + Some(iced_runtime::Action::Output(message)) => { + let next = self.update(message); + if let Some(stream) = iced_runtime::task::into_stream(next) { streams.push(stream); } + } + Some(iced_runtime::Action::Widget(_)) | Some(iced_runtime::Action::Tick) | Some(iced_runtime::Action::Reload) => {}, + Some(action) => return Err(format!("GUI runtime required for {action:?}")), + None => { streams.pop(); } + } + } + self.finish_all_pending_history(); + Ok(()) } /// List entities (handle, type, layer, basic geometry), optionally filtered @@ -395,7 +443,8 @@ impl OpenCADStudio { let i = self.active_tab; let type_filter = req["type"].as_str(); let layer_filter = req["layer"].as_str(); - let limit = req["limit"].as_u64().unwrap_or(1000) as usize; + let limit = req["limit"].as_u64().unwrap_or(1000).min(10000) as usize; + let offset = req["offset"].as_u64().unwrap_or(0); let mut entities = Vec::new(); let mut matched = 0u64; @@ -411,7 +460,7 @@ impl OpenCADStudio { } } matched += 1; - if entities.len() < limit { + if matched > offset && entities.len() < limit { entities.push(entity_json(e)); } } @@ -419,6 +468,7 @@ impl OpenCADStudio { "ok": true, "count": matched, "returned": entities.len(), + "next_offset": if offset + (entities.len() as u64) < matched { Some(offset + entities.len() as u64) } else { None }, "entities": entities, }) } @@ -796,7 +846,7 @@ mod tests { // (feed_active_cmd is the same path the GUI submit offers first). let _ = app.run_command_line("PICKDRAG"); assert!(app.tabs[app.active_tab].active_cmd.is_some(), "prompt must open"); - app.feed_active_cmd("1"); + let _ = app.feed_active_cmd("1"); assert!(app.pick_drag_rect, "PICKDRAG 1 via the prompt must switch"); } diff --git a/src/app/command_driver.rs b/src/app/command_driver.rs index 992158d88..d9d09d660 100644 --- a/src/app/command_driver.rs +++ b/src/app/command_driver.rs @@ -435,40 +435,41 @@ impl OpenCADStudio { // the first-word + fed-tokens path below. (#162) if crate::plugin::try_dispatch(self, i, cmd) { let toks: Vec = tokens.iter().map(|s| s.to_string()).collect(); - self.finish_active_command(&toks); - return Task::none(); + return self.finish_active_command(&toks); } if tokens[0].eq_ignore_ascii_case("BACKGROUND") || tokens[0].eq_ignore_ascii_case("COLORSCHEME") { return self.dispatch_command(cmd); } - let _ = self.dispatch_command(tokens[0]); + let start_task = self.dispatch_command(tokens[0]); if self.tabs[i].active_cmd.is_none() { // Not an interactive tool — an inline-argument command (`PDMODE 3`). return self.dispatch_command(cmd); } let toks: Vec = tokens.iter().map(|s| s.to_string()).collect(); - self.finish_active_command(&toks); - Task::none() + let finish_task = self.finish_active_command(&toks); + Task::batch([start_task, finish_task]) } /// Feed `tokens[1..]` to the active interactive command as points / option /// keywords, then terminate it as if Enter were pressed. No-op when no /// command is active. - pub(super) fn finish_active_command(&mut self, tokens: &[String]) { + pub(super) fn finish_active_command(&mut self, tokens: &[String]) -> Task { let i = self.active_tab; if self.tabs[i].active_cmd.is_none() { - return; + return Task::none(); } self.last_point = None; + let mut tasks = Vec::new(); for tok in &tokens[1..] { if self.tabs[i].active_cmd.is_none() { break; } - self.feed_active_cmd(tok); + tasks.push(self.feed_active_cmd(tok)); } - let _ = self.feed_command(StepInput::Enter); + tasks.push(self.feed_command(StepInput::Enter)); + Task::batch(tasks) } /// Classify one typed token into a [`StepInput`] and route it through the @@ -577,7 +578,7 @@ impl OpenCADStudio { Some(self.feed_command(StepInput::SelectionComplete(handles))) } - pub(super) fn feed_active_cmd(&mut self, token: &str) { + pub(super) fn feed_active_cmd(&mut self, token: &str) -> Task { let i = self.active_tab; // Object-pick step: the token is a handle (as returned by `query`). if self.tabs[i] @@ -594,8 +595,7 @@ impl OpenCADStudio { .as_mut() .and_then(|c| c.on_text_input(token)); if let Some(r) = consumed { - let _ = self.apply_cmd_result(r); - return; + return self.apply_cmd_result(r); } let accepts_points = self.tabs[i] .active_cmd @@ -622,9 +622,9 @@ impl OpenCADStudio { if self.command_point_allowed(i, wcs) { self.last_point = Some(wcs); self.push_ucs_to_cmd(i); - let _ = self.feed_command(StepInput::Point(wcs)); + return self.feed_command(StepInput::Point(wcs)); } - return; + return Task::none(); } } if let Ok(v) = u64::from_str_radix(token.trim_start_matches("0x"), 16) { @@ -642,9 +642,9 @@ impl OpenCADStudio { ) }) .unwrap_or(glam::Vec3::ZERO); - let _ = self.feed_command(StepInput::EntityPick(handle, pt.as_dvec3())); + return self.feed_command(StepInput::EntityPick(handle, pt.as_dvec3())); } - return; + return Task::none(); } if let Some((coord, kind)) = super::helpers::parse_coord(token) { // Match the GUI command line: typed coordinates are in the active @@ -667,13 +667,13 @@ impl OpenCADStudio { }, }; if !self.command_point_allowed(i, wcs) { - return; + return Task::none(); } self.last_point = Some(wcs); self.push_ucs_to_cmd(i); - let _ = self.feed_command(StepInput::Point(wcs)); + return self.feed_command(StepInput::Point(wcs)); } else { - let _ = self.feed_command(StepInput::Text(token.to_string())); + return self.feed_command(StepInput::Text(token.to_string())); } } diff --git a/src/app/control/actions.rs b/src/app/control/actions.rs new file mode 100644 index 000000000..f6e9df8a9 --- /dev/null +++ b/src/app/control/actions.rs @@ -0,0 +1,281 @@ +use super::*; +use crate::scene::model::object::PropValue; + +fn control_color(value: &Value) -> Result { + if let Some(index) = value.as_i64().and_then(|v| i16::try_from(v).ok()) { + return Ok(acadrust::types::Color::from_index(index)); + } + if let Some(rgb) = value + .get("rgb") + .and_then(Value::as_array) + .filter(|v| v.len() == 3) + { + let component = |i: usize| rgb[i].as_u64().and_then(|v| u8::try_from(v).ok()); + return match (component(0), component(1), component(2)) { + (Some(r), Some(g), Some(b)) => Ok(acadrust::types::Color::from_rgb(r, g, b)), + _ => Err(failure( + "invalid_color", + "RGB components must be 0 through 255", + )), + }; + } + Err(failure( + "invalid_color", + "Use an index number or {rgb:[r,g,b]}", + )) +} + +fn control_lineweight(value: &Value) -> Result { + let raw = value + .as_i64() + .and_then(|v| i16::try_from(v).ok()) + .ok_or_else(|| failure("invalid_lineweight", "Use the raw lineweight value"))?; + let candidate = acadrust::types::LineWeight::from_value(raw); + crate::ui::properties::lw_options() + .iter() + .any(|v| v.0 == candidate) + .then_some(candidate) + .ok_or_else(|| failure("invalid_lineweight", "Value is not a standard lineweight")) +} + +fn color_value(color: acadrust::types::Color) -> Value { + if let Some((r, g, b)) = color.rgb() { + json!({"rgb":[r,g,b]}) + } else { + json!(color.index()) + } +} +pub(super) const NAMES: &[&str] = &[ + "close_modal", + "close_document", + "toggle_properties", + "toggle_layers", + "toggle_grid", + "toggle_snap", + "toggle_ortho", + "mtext_insert", + "mtext_commit", + "mtext_cancel", + "text_input", + "text_commit", + "layer_visible", + "layer_locked", + "layer_frozen", + "layer_current", + "view_home", + "zoom_extents", + "undo", + "redo", +]; +impl OpenCADStudio { + pub(super) fn control_properties(&mut self) -> Value { + self.refresh_properties(); + json!({"ok":true,"sections":self.tabs[self.active_tab].properties.sections.iter().map(|s|json!({"title":s.title,"properties":s.props.iter().map(|p|{ + let (kind,value,options)=match &p.value{ + PropValue::ReadOnly(v)|PropValue::ReadOnlyWithTooltip{value:v,..}=>("readonly",json!(v),Value::Null), + PropValue::EditText(v)=>("number",json!(v),Value::Null), + PropValue::PlainText(v)=>("text",json!(v),Value::Null), + PropValue::Choice{selected,options}=>("choice",json!(selected),json!(options)), + PropValue::EditChoice{value,options}=>("editable_choice",json!(value),json!(options)), + PropValue::LayerChoice(v)=>("layer",json!(v),Value::Null), + PropValue::LinetypeChoice(v)=>("linetype",json!(v),Value::Null), + PropValue::BoolToggle{value,..}=>("bool",json!(value),Value::Null), + PropValue::ColorChoice(value)|PropValue::NamedColorChoice{color:value,..}=>("color",color_value(*value),Value::Null), + PropValue::ColorVaries=>("color",Value::Null,Value::Null), + PropValue::LwChoice(value)|PropValue::FieldLwChoice{value,..}=>("lineweight",json!(value.value()),json!(crate::ui::properties::lw_options().iter().map(|v|v.0.value()).collect::>())), + PropValue::LwVaries|PropValue::FieldLwVaries{..}=>("lineweight",Value::Null,json!(crate::ui::properties::lw_options().iter().map(|v|v.0.value()).collect::>())), + PropValue::AttrText{tag,value}=>("attribute",json!({"tag":tag,"value":value}),Value::Null), + other=>("specialized",json!(format!("{other:?}")),Value::Null), + };json!({"id":p.field,"label":p.label,"kind":kind,"value":value,"options":options}) + }).collect::>()})).collect::>()}) + } + pub(super) fn control_set_property(&mut self, req: &Value) -> Result, Value> { + self.refresh_properties(); + let field = string(req, "field")?; + let p = self.tabs[self.active_tab] + .properties + .sections + .iter() + .flat_map(|s| &s.props) + .find(|p| p.field == field) + .cloned() + .ok_or_else(|| { + failure( + "unknown_property", + "Read properties for the current selection", + ) + })?; + let value = req["value"] + .as_str() + .map(str::to_owned) + .unwrap_or_else(|| req["value"].to_string()); + Ok(match p.value { + PropValue::EditText(_) | PropValue::PlainText(_) | PropValue::EditChoice { .. } => { + let input = self.update(Message::PropGeomInput { + field: p.field, + value, + }); + let commit = self.update(Message::PropGeomCommit(p.field)); + Task::batch([input, commit]) + } + PropValue::Choice { options, .. } => { + if !options.contains(&value) { + return Err(failure( + "invalid_choice", + "Value is not an available option", + )); + } + self.update(Message::PropGeomChoiceChanged { + field: p.field, + value, + }) + } + PropValue::LayerChoice(_) => self.update(Message::PropLayerChanged(value)), + PropValue::LinetypeChoice(_) => self.update(Message::PropLinetypeChanged(value)), + PropValue::HatchPatternChoice(_) => { + self.update(Message::PropHatchPatternChanged(value)) + } + PropValue::BoolToggle { field, value: old } => { + let v = req["value"] + .as_bool() + .ok_or_else(|| failure("invalid_value", "Expected boolean"))?; + if old != v { + self.update(Message::PropBoolToggle(field)) + } else { + Task::none() + } + } + PropValue::AttrText { tag, .. } => { + let input = self.update(Message::PropAttrInput { + tag: tag.clone(), + value, + }); + let commit = self.update(Message::PropAttrCommit(tag)); + Task::batch([input, commit]) + } + PropValue::ColorChoice(_) + | PropValue::NamedColorChoice { .. } + | PropValue::ColorVaries => { + let color = control_color(&req["value"])?; + if p.field == "background_color" { + self.update(Message::PropBgColorChanged(color)) + } else if matches!( + p.field, + "gradient_color_1" + | "gradient_color_2" + | "dim_line_color" + | "dim_ext_line_color" + | "dim_text_color" + | "dim_text_fill_color" + | "line_color" + | "text_color" + | "block_content_color" + | "background_fill_color" + ) { + self.update(Message::PropColorFieldChanged { + field: p.field.into(), + color, + }) + } else { + self.update(Message::PropColorChanged(color)) + } + } + PropValue::LwChoice(_) | PropValue::LwVaries => { + self.update(Message::PropLwChanged(control_lineweight(&req["value"])?)) + } + PropValue::FieldLwChoice { field, .. } | PropValue::FieldLwVaries { field } => self + .update(Message::PropFieldLwChanged { + field, + value: control_lineweight(&req["value"])?, + }), + PropValue::ReadOnly(_) | PropValue::ReadOnlyWithTooltip { .. } => { + return Err(failure("readonly_property", "Property cannot be edited")) + } + _ => { + return Err(failure( + "unsupported_property", + "Use the corresponding command for this property type", + )) + } + }) + } + pub(super) fn control_ui_action(&mut self, req: &Value) -> Result, Value> { + let name = string(req, "name")?; + let msg = match name { + "close_modal" => Message::CloseModal, + "close_document" => Message::TabClose(self.active_tab), + "toggle_properties" => Message::ToggleProperties, + "toggle_layers" => Message::ToggleLayers, + "toggle_grid" => Message::ToggleGrid, + "toggle_snap" => Message::ToggleSnapEnabled, + "toggle_ortho" => Message::ToggleOrtho, + "mtext_insert" => { + if self.mtext_editor.is_none() { + return Err(failure("editor_closed", "MText editor is closed")); + } + Message::MTextInsert(string(req, "value")?.into()) + } + "mtext_commit" => Message::MTextOk, + "mtext_cancel" => Message::MTextCancel, + "text_input" => Message::TextInlineInput(string(req, "value")?.into()), + "text_commit" => Message::TextInlineOk, + "pointer_move" | "pointer_press" | "pointer_release" => { + let x = req["x"] + .as_f64() + .filter(|v| v.is_finite()) + .ok_or_else(|| failure("invalid_point", "Missing finite x"))? + as f32; + let y = req["y"] + .as_f64() + .filter(|v| v.is_finite()) + .ok_or_else(|| failure("invalid_point", "Missing finite y"))? + as f32; + let size = self.tabs[self.active_tab].scene.selection.borrow().vp_size; + if x < 0. || y < 0. || x > size.0 || y > size.1 { + return Err(failure( + "outside_viewport", + "Pointer coordinates are outside viewport_size", + )); + } + let move_task = self.update(Message::ViewportMove(iced::Point::new(x, y))); + let event = match name { + "pointer_press" => self.update(Message::ViewportLeftPress), + "pointer_release" => self.update(Message::ViewportLeftRelease), + _ => Task::none(), + }; + return Ok(Task::batch([move_task, event])); + } + "view_home" => Message::ViewCubeHome, + "zoom_extents" => return Ok(self.dispatch_command("ZOOM EXTENTS")), + "undo" => Message::Undo, + "redo" => Message::Redo, + "layer_visible" | "layer_locked" | "layer_frozen" | "layer_current" => { + let layer = string(req, "layer")?; + let index = self.tabs[self.active_tab] + .scene + .document + .layers + .iter() + .position(|l| l.name == layer) + .ok_or_else(|| failure("unknown_layer", "Layer does not exist"))?; + match name { + "layer_visible" => Message::LayerToggleVisible(index), + "layer_locked" => Message::LayerToggleLock(index), + "layer_frozen" => Message::LayerToggleFreeze(index), + _ => { + let select = self.update(Message::LayerSelect(index)); + let current = self.update(Message::LayerSetCurrent); + return Ok(Task::batch([select, current])); + } + } + } + _ => { + return Err(failure( + "unknown_action", + "Read commands.actions for supported action IDs", + )) + } + }; + Ok(self.update(msg)) + } +} diff --git a/src/app/control/mod.rs b/src/app/control/mod.rs new file mode 100644 index 000000000..90b0717b7 --- /dev/null +++ b/src/app/control/mod.rs @@ -0,0 +1,929 @@ +//! Semantic control shared by the GUI, headless client and web adapter. +use super::{Message, OpenCADStudio}; +use crate::command::StepInput; +use iced::Task; +use serde_json::{json, Value}; +use std::{collections::VecDeque, sync::OnceLock}; +#[cfg(not(target_arch = "wasm32"))] +mod transport; +#[cfg(not(target_arch = "wasm32"))] +pub(super) use transport::subscribe; + +pub(super) fn session_id() -> &'static str { + static ID: OnceLock = OnceLock::new(); + ID.get_or_init(|| { + #[cfg(not(target_arch = "wasm32"))] + { + let mut bytes = [0u8; 16]; + getrandom::fill(&mut bytes).expect("OS random source"); + bytes.iter().map(|v| format!("{v:02x}")).collect() + } + #[cfg(target_arch = "wasm32")] + { + format!("web-{}", js_sys::Date::now()) + } + }) +} + +#[derive(Debug, Clone)] +pub struct Envelope { + pub request: Value, + pub reply: Reply, +} +#[derive(Debug, Clone)] +pub enum Reply { + Native(std::sync::mpsc::Sender), + Web(String), +} +impl Reply { + pub(super) fn send(self, value: Value) { + match self { + Self::Native(sender) => { + let _ = sender.send(value); + } + Self::Web(id) => web_store(id, value), + } + } +} + +#[cfg(not(target_arch = "wasm32"))] +fn web_store(_: String, _: Value) {} + +#[cfg(target_arch = "wasm32")] +static WEB_QUEUE: std::sync::Mutex> = + std::sync::Mutex::new(VecDeque::new()); +#[cfg(target_arch = "wasm32")] +static WEB_RESULTS: std::sync::Mutex> = + std::sync::Mutex::new(VecDeque::new()); +#[cfg(target_arch = "wasm32")] +static WEB_TICKET: std::sync::atomic::AtomicU64 = std::sync::atomic::AtomicU64::new(1); + +#[cfg(target_arch = "wasm32")] +fn web_store(id: String, value: Value) { + let mut queue = WEB_RESULTS.lock().unwrap(); + queue.push_back((id, value)); + while queue.len() > 64 { + queue.pop_front(); + } +} + +#[cfg(target_arch = "wasm32")] +#[wasm_bindgen::prelude::wasm_bindgen] +pub fn ocs_control_submit(request: String) -> String { + let serial = WEB_TICKET.fetch_add(1, std::sync::atomic::Ordering::Relaxed); + let id = format!("web-{}-{serial}", js_sys::Date::now()); + let value = serde_json::from_str(&request) + .unwrap_or_else(|error| json!({"op":"invalid","parse_error":error.to_string()})); + let mut queue = WEB_QUEUE.lock().unwrap(); + if queue.len() >= 64 { + drop(queue); + web_store(id.clone(), failure("busy", "Web control queue full")); + } else { + queue.push_back((id.clone(), value)); + } + id +} + +#[cfg(target_arch = "wasm32")] +#[wasm_bindgen::prelude::wasm_bindgen] +pub fn ocs_control_take(id: &str) -> Option { + let mut queue = WEB_RESULTS.lock().unwrap(); + let position = queue.iter().position(|(candidate, _)| candidate == id)?; + Some(queue.remove(position).unwrap().1.to_string()) +} + +#[cfg(target_arch = "wasm32")] +pub(super) fn web_request() -> Option { + let (id, request) = WEB_QUEUE.lock().unwrap().pop_front()?; + Some(Envelope { + request, + reply: Reply::Web(id), + }) +} +#[derive(Default)] +pub(super) struct State { + pending: Option, + completed: VecDeque<(String, Value, Value)>, + owner: Option<(u64, String)>, + pub enabled: bool, + serial: u64, + events: VecDeque, + pub(super) routing: bool, +} +impl State { + pub(super) fn new() -> Self { + Self { + enabled: true, + ..Self::default() + } + } +} +struct Operation { + id: String, + request: Value, + pending: usize, + error_revision: u64, + document_id: Option, + origin_document: u64, + geometry_revision: u64, + result: Value, +} +fn failure(code: &str, error: impl ToString) -> Value { + json!({"ok":false,"status":"failed","code":code,"error":error.to_string()}) +} +fn string<'a>(req: &'a Value, key: &str) -> Result<&'a str, Value> { + req[key] + .as_str() + .filter(|v| !v.is_empty()) + .ok_or_else(|| failure("invalid_request", format!("Missing {key}"))) +} +fn handle(req: &Value) -> Result { + u64::from_str_radix(string(req, "handle")?.trim_start_matches("0x"), 16) + .map(acadrust::Handle::new) + .map_err(|_| failure("invalid_handle", "Expected hexadecimal handle")) +} +#[cfg(not(target_arch = "wasm32"))] +fn plugin_ids() -> Vec { + crate::plugin::external::with_manager(|m| m.ids()) +} +#[cfg(target_arch = "wasm32")] +fn plugin_ids() -> Vec { + Vec::new() +} + +fn point(req: &Value) -> Result { + let values = req["point"] + .as_array() + .filter(|v| v.len() == 3) + .ok_or_else(|| failure("invalid_point", "Expected three finite coordinates"))?; + let mut p = [0.; 3]; + for (i, v) in values.iter().enumerate() { + p[i] = v + .as_f64() + .filter(|v| v.is_finite()) + .ok_or_else(|| failure("invalid_point", "Expected finite coordinates"))?; + } + Ok(glam::DVec3::from_array(p)) +} + +impl OpenCADStudio { + pub(super) fn control_busy(&self) -> bool { + self.control.pending.is_some() + } + + pub(super) fn control_state(&self) -> Value { + let tab = &self.tabs[self.active_tab]; + let command = tab.active_cmd.as_ref().map(|c| json!({ + "name":c.name(),"prompt":c.prompt(),"options":c.options().iter().map(|o|json!({"label":o.label,"keyword":o.keyword})).collect::>(), + "entity_pick":c.needs_entity_pick(),"structure_pick":c.needs_structure_point_pick(),"selection":c.is_selection_gathering(),"tangent_pick":c.needs_tangent_pick(),"free_text":c.is_free_text_step() + })); + json!({"ok":true,"protocol":1,"session_id":session_id(),"version":env!("CARGO_PKG_VERSION"), + "mode":if self.main_window.is_some(){"gui"}else{"headless"},"enabled":self.control.enabled, + "document_id":tab.id,"revision":tab.edit_revision,"geometry_revision":tab.scene.geometry_epoch,"camera_revision":tab.scene.camera_generation, + "plugins":plugin_ids(), + "documents":self.tabs.iter().map(|t|json!({"id":t.id,"title":t.tab_title,"path":t.current_path,"dirty":t.dirty,"revision":t.edit_revision,"start":t.is_start})).collect::>(), + "selection":tab.scene.selected_handles_in_order().iter().map(|h|format!("{:X}",h.value())).collect::>(), + "command":command,"modal":self.active_modal.as_ref().map(|m|format!("{m:?}")), + "layout":tab.scene.current_layout, + "ucs":tab.active_ucs.as_ref().map(|u|json!({"name":u.name,"origin":[u.origin.x,u.origin.y,u.origin.z],"x_axis":[u.x_axis.x,u.x_axis.y,u.x_axis.z],"y_axis":[u.y_axis.x,u.y_axis.y,u.y_axis.z],"elevation":u.elevation})), + "cursor":{"world":tab.last_cursor_world.to_array(),"screen":[tab.last_cursor_screen.x,tab.last_cursor_screen.y]}, + "viewport_size":({let s=tab.scene.selection.borrow();[s.vp_size.0,s.vp_size.1]}), + "camera":({let c=tab.scene.camera.borrow();json!({"target":c.target.to_array(),"rotation":[c.rotation.x,c.rotation.y,c.rotation.z,c.rotation.w],"distance":c.distance,"fov_y":c.fov_y,"projection":format!("{:?}",c.projection),"yaw":c.yaw,"pitch":c.pitch})}), + "mtext_editor":self.mtext_editor.as_ref().map(|e|json!({"text":e.content.text(),"height":e.height,"style":e.style})), + "text_editor":self.text_inline.is_some(),"event_cursor":self.control.serial, + "operation":self.control.pending.as_ref().map(|p| &p.id), + "capabilities":["commands","step_input","entity_pick","structure_pick","selection","properties","layers","history","documents","events","capture","measure"] + }) + } + + pub(super) fn control_request(&mut self, req: Value) -> (Value, Task) { + let op = req["op"].as_str().unwrap_or(""); + let query = matches!( + op, + "state" + | "hello" + | "operation" + | "events" + | "commands" + | "properties" + | "measure" + | "query" + | "entities" + | "layers" + | "header" + | "history" + ); + if !query && !self.control.enabled { + return ( + failure("disabled", "Automation is stopped. Enable it in OCS."), + Task::none(), + ); + } + if req["protocol"].as_u64().is_some_and(|v| v != 1) { + return ( + failure("protocol_mismatch", "Protocol 1 required"), + Task::none(), + ); + } + if req["session_id"] + .as_str() + .is_some_and(|v| v != session_id()) + { + return ( + failure( + "session_changed", + "Reconnect; do not replay mutations in a new session", + ), + Task::none(), + ); + } + if op == "hello" || op == "state" { + return (self.control_state(), Task::none()); + } + if op == "operation" { + let id = req["request_id"].as_str().unwrap_or(""); + let response = if let Some(p) = self.control.pending.as_ref().filter(|p| p.id == id) { + json!({"ok":true,"status":"running","request_id":p.id}) + } else { + self.control + .completed + .iter() + .find(|(i, _, _)| i == id) + .map(|(_, _, r)| r.clone()) + .unwrap_or_else(|| { + failure( + "unknown_operation", + "Operation absent or expired; do not automatically replay", + ) + }) + }; + return (response, Task::none()); + } + if op == "events" { + let cursor = req["after"].as_u64().unwrap_or(0); + return ( + json!({"ok":true,"cursor":self.control.serial,"resync":self.control.events.front().is_some_and(|e|cursor+1cursor).collect::>()}), + Task::none(), + ); + } + if op == "commands" { + let mut names = crate::command::all_registered_command_names(); + let mut names: Vec = names.drain(..).map(str::to_owned).collect(); + names.extend(self.command_line.dynamic_commands.iter().cloned()); + names.sort_unstable(); + names.dedup(); + return ( + json!({"ok":true,"commands":names,"actions":actions::NAMES}), + Task::none(), + ); + } + let id = if query { + String::new() + } else { + let id = match string(&req, "request_id") { + Ok(v) if v.len() <= 128 => v.to_owned(), + _ => { + return ( + failure( + "request_id_required", + "Supply a unique request_id (maximum 128 bytes)", + ), + Task::none(), + ) + } + }; + if let Some((_, old, result)) = self.control.completed.iter().find(|(i, _, _)| i == &id) + { + return ( + if old == &req { + result.clone() + } else { + failure("request_id_reused", "Request payload changed") + }, + Task::none(), + ); + } + if let Some(p) = &self.control.pending { + return ( + if p.id == id && p.request == req { + json!({"ok":true,"status":"running","request_id":id}) + } else { + failure("busy", "Wait for the running operation") + }, + Task::none(), + ); + } + id + }; + if let Some(id) = req["document_id"].as_u64() { + if !self.tabs.iter().any(|t| t.id == id) { + return ( + failure("document_closed", "Document no longer exists"), + Task::none(), + ); + } + if self.tabs[self.active_tab].id != id && op != "activate" { + return ( + failure( + "document_not_active", + "Activate the requested document first", + ), + Task::none(), + ); + } + } else if !query && !matches!(op, "new" | "open" | "stop") { + return ( + failure("document_required", "Read state and supply document_id"), + Task::none(), + ); + } + let tab = &self.tabs[self.active_tab]; + if req["revision"] + .as_u64() + .is_some_and(|v| v != tab.edit_revision) + || req["geometry_revision"] + .as_u64() + .is_some_and(|v| v != tab.scene.geometry_epoch) + || req["camera_revision"] + .as_u64() + .is_some_and(|v| v != tab.scene.camera_generation) + { + return ( + json!({"ok":false,"status":"failed","code":"stale_state","error":"Refresh state before editing","state":self.control_state()}), + Task::none(), + ); + } + if query { + let response = match op { + "properties" => self.control_properties(), + "measure" => self.control_measure(&req), + "history" => { + json!({"ok":true,"entries":self.command_line.history.iter().map(|e|json!({"kind":format!("{:?}",e.kind),"text":e.text})).collect::>()}) + } + _ => self.automation_op_inner(&req.to_string()), + }; + return (response, Task::none()); + } + let client = req["client_id"].as_str().unwrap_or("default").to_owned(); + if tab.active_cmd.is_some() + && (self.control.owner.as_ref() != Some(&(tab.id, client.clone())) + || matches!(op, "run" | "start" | "new" | "open" | "activate")) + && op != "cancel" + { + return ( + failure("command_busy", "An interactive command is already active"), + Task::none(), + ); + } + if matches!(op, "run" | "start" | "input") && tab.is_start { + return ( + failure("no_document", "Create or open a drawing first"), + Task::none(), + ); + } + if let Some(expected) = req["selection"].as_array() { + let actual: Vec = tab + .scene + .selected_handles_in_order() + .iter() + .map(|h| json!(format!("{:X}", h.value()))) + .collect(); + if &actual != expected { + return ( + failure( + "selection_changed", + "Read state and confirm the current selection", + ), + Task::none(), + ); + } + } + let doc = if matches!(op, "new" | "open" | "activate") { + None + } else { + Some(tab.id) + }; + self.control.pending = Some(Operation { + id: id.clone(), + request: req.clone(), + pending: 1, + error_revision: self.command_line.error_revision, + document_id: doc, + origin_document: tab.id, + geometry_revision: tab.scene.geometry_epoch, + result: json!({}), + }); + self.control.routing = true; + let action = self.control_action(&req); + self.control.routing = false; + let task = match action { + Ok(t) => t, + Err(error) => { + self.control.pending.take(); + return (error, Task::none()); + } + }; + if self.tabs[self.active_tab].active_cmd.is_some() { + self.control.owner = Some((self.tabs[self.active_tab].id, client)); + } + if matches!(op, "new" | "activate") + && self + .control + .pending + .as_ref() + .is_some_and(|pending| pending.origin_document != self.tabs[self.active_tab].id) + { + if let Some(pending) = self.control.pending.as_mut() { + pending.document_id = Some(self.tabs[self.active_tab].id); + } + } + self.finish_all_pending_history(); + let task = self.control_track(task); + if let Some(p) = self.control.pending.as_mut() { + p.pending -= 1; + } + self.control_settle(); + let response = self + .control + .completed + .iter() + .find(|(i, _, _)| i == &id) + .map(|(_, _, r)| r.clone()) + .unwrap_or_else(|| json!({"ok":true,"status":"accepted","request_id":id})); + (response, task) + } + + fn control_action(&mut self, req: &Value) -> Result, Value> { + let i = self.active_tab; + Ok(match req["op"].as_str().unwrap_or("") { + "new" => self.update(Message::TabNew), + "open" => self.update(Message::OpenExternal(std::path::PathBuf::from(string( + req, "path", + )?))), + "activate" => { + let i = self + .tabs + .iter() + .position(|t| Some(t.id) == req["document_id"].as_u64()) + .ok_or_else(|| failure("document_closed", "Document absent"))?; + self.update(Message::TabSwitch(i)) + } + "run" => self.run_command_line(string(req, "cmd")?), + "start" => self.dispatch_command(string(req, "cmd")?), + "input" => { + if self.tabs[i].active_cmd.is_none() { + return Err(failure("no_command", "No command waiting for input")); + } + match string(req, "kind")? { + "text" => self + .feed_command(StepInput::Text(req["text"].as_str().unwrap_or("").into())), + "token" => self.feed_active_cmd(string(req, "text")?), + "point" => { + let mut p = point(req)?; + match req["space"].as_str().unwrap_or("wcs") { + "wcs" => {} + "ucs" => { + if let Some(u) = &self.tabs[i].active_ucs { + p = super::helpers::ucs_to_wcs(p, u); + } + } + "relative" => { + let base = self.last_point.ok_or_else(|| { + failure( + "no_base_point", + "Relative input needs a previous point", + ) + })?; + if let Some(u) = &self.tabs[i].active_ucs { + p = super::helpers::ucs_rotate_vec(p, u); + } + p += base; + } + _ => return Err(failure("invalid_space", "Use wcs, ucs or relative")), + }; + self.feed_command(StepInput::Point(p)) + } + "entity" | "structure" => { + let h = handle(req)?; + if self.tabs[i].scene.document.get_entity(h).is_none() { + return Err(failure("entity_absent", "Unknown entity")); + } + let p = point(req)?; + self.feed_command(if req["kind"] == "structure" { + StepInput::StructurePick(h, p) + } else { + StepInput::EntityPick(h, p) + }) + } + "selection" => { + let hs = self.tabs[i].scene.selected_handles_in_order(); + self.feed_command(StepInput::SelectionComplete(hs)) + } + "enter" => self.feed_command(StepInput::Enter), + _ => return Err(failure("invalid_input", "Unknown input kind")), + } + } + "cancel" => self.update(Message::CommandEscape), + "undo" => self.update(Message::Undo), + "redo" => self.update(Message::Redo), + "select" => { + if let Some(hs) = req["handles"].as_array() { + for v in hs { + let h = u64::from_str_radix( + v.as_str().unwrap_or("").trim_start_matches("0x"), + 16, + ) + .map(acadrust::Handle::new) + .map_err(|_| failure("invalid_handle", "Expected hexadecimal handle"))?; + if self.tabs[i].scene.document.get_entity(h).is_none() { + return Err(failure( + "entity_absent", + format!("Entity {} does not exist", v), + )); + } + } + } + let result = self.automation_op_inner(&req.to_string()); + if result["ok"] != true { + return Err(result); + } + self.refresh_properties(); + Task::none() + } + "property" => self.control_set_property(req)?, + "action" => self.control_ui_action(req)?, + #[cfg(not(target_arch = "wasm32"))] + "save" => { + let path = req["path"] + .as_str() + .map(std::path::PathBuf::from) + .or_else(|| self.tabs[i].current_path.clone()) + .ok_or_else(|| failure("path_required", "Supply a save path"))?; + if self.main_window.is_none() { + self.save_tab_synchronously_protected(i, path, true) + .map_err(|e| failure("save_failed", e))?; + Task::none() + } else { + self.prepare_native_save(i); + self.queue_native_save( + i, + path, + acadrust::DxfVersion::AC1032, + super::SavePurpose::SaveAs, + super::SaveContinuation::None, + true, + true, + ) + } + } + "capture" => { + let window = self + .main_window + .ok_or_else(|| failure("gui_required", "Capture requires a GUI window"))?; + let path = string(req, "path")?.to_owned(); + iced::window::screenshot(window) + .map(move |s| Message::ControlScreenshot(path.clone(), Some(s))) + } + "stop" => { + self.control.enabled = false; + Task::none() + } + _ => return Err(failure("unknown_operation", "Unknown operation")), + }) + } + + pub(super) fn control_track(&mut self, task: Task) -> Task { + if task.units() == 0 { + return task; + } + let Some(p) = self.control.pending.as_mut() else { + return task; + }; + p.pending += 1; + let id = p.id.clone(); + let end = id.clone(); + task.map(move |m| Message::ControlStep(id.clone(), Box::new(m))) + .chain(Task::done(Message::ControlTaskDone(end))) + } + pub(super) fn control_step(&mut self, id: String, msg: Message) -> Task { + if !self.control.pending.as_ref().is_some_and(|p| p.id == id) { + return Task::none(); + } + let previous = self.active_tab; + let target = self.control.pending.as_ref().and_then(|p| p.document_id); + if let Some(doc) = target { + let Some(i) = self.tabs.iter().position(|t| t.id == doc) else { + self.command_line + .push_error("Automation target document closed"); + return Task::none(); + }; + self.active_tab = i; + } + self.control.routing = true; + let task = self.update(msg); + self.control.routing = false; + if let Some(pending) = self.control.pending.as_mut() { + if pending.document_id.is_none() + && matches!( + pending.request["op"].as_str(), + Some("new" | "open" | "activate") + ) + && pending.origin_document != self.tabs[self.active_tab].id + { + pending.document_id = Some(self.tabs[self.active_tab].id); + } + } + if target.is_some() && previous < self.tabs.len() { + self.active_tab = previous; + } + self.control_track(task) + } + pub(super) fn control_task_done(&mut self, id: &str) { + if let Some(p) = self.control.pending.as_mut().filter(|p| p.id == id) { + p.pending = p.pending.saturating_sub(1); + } + self.control_settle(); + } + pub(super) fn control_observe_user_message(&mut self, message: &Message) { + if self.control.routing || self.control.owner.is_none() { + return; + } + if matches!( + message, + Message::CommandSubmit + | Message::CommandFinalize + | Message::CommandOptionPick(_) + | Message::CommandEscape + | Message::ViewportLeftPress + | Message::ViewportLeftRelease + | Message::PanePress(_) + | Message::PaneRelease(_) + | Message::ShortcutPressed(_) + | Message::MTextOk + | Message::MTextCancel + | Message::TextInlineOk + ) { + self.control.owner = None; + } + } + pub(super) fn control_settle(&mut self) { + if self.control.pending.as_ref().is_none_or(|p| p.pending != 0) || self.opening.is_some() { + return; + } + #[cfg(not(target_arch = "wasm32"))] + if !self.active_save_jobs.is_empty() || self.pending_native_thumbnail_save.is_some() { + return; + } + let p = self.control.pending.take().unwrap(); + let previous = self.active_tab; + if let Some(index) = p + .document_id + .and_then(|id| self.tabs.iter().position(|tab| tab.id == id)) + { + self.active_tab = index; + } + let failed = self.command_line.error_revision != p.error_revision; + let waiting = self.tabs[self.active_tab].active_cmd.is_some() + || self.active_modal.is_some() + || self.mtext_editor.is_some() + || self.text_inline.is_some(); + if !waiting { + self.control.owner = None; + } + let status = if failed { + "failed" + } else if p.request["op"] == "cancel" { + "cancelled" + } else if waiting { + "waiting_input" + } else { + "completed" + }; + let changes = self.tabs[self.active_tab].scene.replay_since(p.geometry_revision) + .map(|values| values.into_iter().take(1000).map(|(handle,kind)|json!({"handle":format!("{:X}",handle.value()),"kind":format!("{kind:?}")})).collect::>()); + let response = json!({"ok":!failed,"status":status,"request_id":p.id,"error":if failed{self.command_line.last_error.clone()}else{None},"result":p.result,"changes":changes,"state":self.control_state()}); + self.control.serial += 1; + self.control.events.push_back(json!({"sequence":self.control.serial,"request_id":p.id,"status":status,"document_id":self.tabs[self.active_tab].id,"revision":self.tabs[self.active_tab].edit_revision})); + while self.control.events.len() > 128 { + self.control.events.pop_front(); + } + self.control + .completed + .push_back((p.id, p.request, response)); + while self.control.completed.len() > 128 { + self.control.completed.pop_front(); + } + if previous < self.tabs.len() { + self.active_tab = previous; + } + } + pub(super) fn control_measure(&self, req: &Value) -> Value { + let tab = &self.tabs[self.active_tab]; + let requested: Vec = req["handles"] + .as_array() + .map(|a| { + a.iter() + .filter_map(|v| v.as_str()) + .filter_map(|v| u64::from_str_radix(v.trim_start_matches("0x"), 16).ok()) + .map(acadrust::Handle::new) + .collect() + }) + .unwrap_or_else(|| tab.scene.selected_handles_in_order()); + let mut out = Vec::new(); + for handle in requested { + let Some(entity) = tab.scene.document.get_entity(handle) else { + continue; + }; + let bb = entity.as_entity().bounding_box(); + let metrics = tab + .scene + .meshes + .get(&handle) + .or_else(|| tab.scene.block_meshes.get(&handle)) + .cloned() + .or_else(|| { + let h = &tab.scene.document.header; + crate::entities::solid3d::tessellate_volume( + entity, + [1.; 4], + h.facet_resolution, + crate::entities::solid3d::display_deflection(h, h.facet_resolution), + h.isolines.max(0) as usize, + ) + }); + out.push(json!({"handle":format!("{:X}",handle.value()),"type":crate::entities::names::ui_name(entity),"bounds":{"min":[bb.min.x,bb.min.y,bb.min.z],"max":[bb.max.x,bb.max.y,bb.max.z]},"mesh":metrics.map(|m|json!({"vertices":m.metrics.vertices,"triangles":m.metrics.triangles,"surface_area":m.metrics.surface_area,"volume":m.metrics.volume,"centroid":m.metrics.centroid}))})); + } + json!({"ok":true,"document_id":tab.id,"geometry_revision":tab.scene.geometry_epoch,"measurements":out}) + } + + pub(super) fn control_screenshot( + &mut self, + path: String, + screenshot: Option, + ) { + let result = (|| -> Result { + let s = screenshot.ok_or("Renderer did not return an image")?; + image::save_buffer_with_format( + &path, + &s.rgba, + s.size.width, + s.size.height, + image::ColorType::Rgba8, + image::ImageFormat::Png, + ) + .map_err(|e| e.to_string())?; + Ok( + json!({"path":path,"width":s.size.width,"height":s.size.height,"scale_factor":s.scale_factor,"document_id":self.tabs[self.active_tab].id,"revision":self.tabs[self.active_tab].edit_revision,"camera_revision":self.tabs[self.active_tab].scene.camera_generation}), + ) + })(); + match result { + Ok(v) => { + if let Some(p) = self.control.pending.as_mut() { + p.result = v; + } + } + Err(e) => self.command_line.push_error(&e), + } + } +} +mod actions; + +#[cfg(test)] +mod tests { + use super::*; + fn request(app: &mut OpenCADStudio, mut req: Value) -> Value { + let state = app.control_state(); + req["protocol"] = json!(1); + req["document_id"] = state["document_id"].clone(); + req["revision"] = state["revision"].clone(); + req["client_id"] = json!("test"); + if req.get("request_id").is_none() { + req["request_id"] = json!(format!("req-{}", app.control.serial)); + } + let (r, t) = app.control_request(req.clone()); + app.drive_headless_task(t).unwrap(); + if matches!(r["status"].as_str(), Some("accepted" | "running")) { + app.control_request(json!({"op":"operation","request_id":req["request_id"]})) + .0 + } else { + r + } + } + #[test] + fn control_stepwise_drawing_undo_and_properties() { + let mut app = OpenCADStudio::new_for_test(); + assert_eq!( + request(&mut app, json!({"op":"new"}))["status"], + "completed" + ); + assert_eq!( + request(&mut app, json!({"op":"start","cmd":"LINE"}))["status"], + "waiting_input" + ); + assert_eq!( + request( + &mut app, + json!({"op":"input","kind":"point","point":[0.,0.,0.]}) + )["ok"], + true + ); + assert_eq!( + request( + &mut app, + json!({"op":"input","kind":"point","point":[10.,0.,0.]}) + )["ok"], + true + ); + assert_eq!( + request(&mut app, json!({"op":"input","kind":"enter"}))["status"], + "completed" + ); + assert_eq!(app.automation_op(r#"{"op":"entities"}"#)["total"], 1); + request(&mut app, json!({"op":"select","type":"LINE"})); + assert!(!app.control_properties()["sections"] + .as_array() + .unwrap() + .is_empty()); + let changed = request(&mut app, json!({"op":"property","field":"color","value":1})); + assert_eq!(changed["ok"], true, "{changed}"); + assert_eq!( + app.tabs[app.active_tab] + .scene + .document + .entities() + .next() + .unwrap() + .common() + .color + .index(), + Some(1) + ); + assert_eq!(request(&mut app, json!({"op":"undo"}))["ok"], true); + assert_eq!(app.automation_op(r#"{"op":"entities"}"#)["total"], 1); + request(&mut app, json!({"op":"undo"})); + assert_eq!(app.automation_op(r#"{"op":"entities"}"#)["total"], 0); + } + #[test] + fn control_retry_is_idempotent_and_stale_state_rejected() { + let mut app = OpenCADStudio::new_for_test(); + request(&mut app, json!({"op":"new"})); + let state = app.control_state(); + let req = json!({"op":"run","cmd":"LINE 0,0 10,0","request_id":"line","document_id":state["document_id"],"revision":state["revision"]}); + let (first, t) = app.control_request(req.clone()); + app.drive_headless_task(t).unwrap(); + let repeat = app.control_request(req.clone()).0; + assert!(repeat["ok"] == true, "{first} {repeat}"); + assert_eq!(app.automation_op(r#"{"op":"entities"}"#)["total"], 1); + let mut stale = req; + stale["request_id"] = json!("stale"); + assert_eq!(app.control_request(stale).0["code"], "stale_state"); + } + #[test] + fn control_errors_and_missing_entities_do_not_report_success() { + let mut app = OpenCADStudio::new_for_test(); + request(&mut app, json!({"op":"new"})); + let result = request(&mut app, json!({"op":"run","cmd":"NONEXISTENT_COMMAND"})); + assert_eq!(result["status"], "failed", "{result}"); + assert_eq!( + request(&mut app, json!({"op":"select","handles":["FFFFFFFF"]}))["code"], + "entity_absent" + ); + assert_eq!( + app.automation_op(r#"{"op":"run","cmd":"NONEXISTENT_COMMAND"}"#)["ok"], + false + ); + } + #[test] + fn direct_user_input_takes_command_ownership() { + let mut app = OpenCADStudio::new_for_test(); + request(&mut app, json!({"op":"new"})); + request(&mut app, json!({"op":"start","cmd":"LINE"})); + let _ = app.update(Message::ViewportLeftPress); + let result = request( + &mut app, + json!({"op":"input","kind":"point","point":[10.,0.,0.]}), + ); + assert_eq!(result["code"], "command_busy"); + } + #[test] + fn control_preserves_other_documents_and_waits_for_async_open() { + let mut app = OpenCADStudio::new_for_test(); + request(&mut app, json!({"op":"new"})); + request(&mut app, json!({"op":"run","cmd":"CIRCLE 0,0 3"})); + let id = app.control_state()["document_id"].as_u64().unwrap(); + let path = std::env::temp_dir().join(format!("ocs-control-{}.dxf", session_id())); + assert_eq!( + request(&mut app, json!({"op":"save","path":path}))["ok"], + true + ); + request(&mut app, json!({"op":"new"})); + let opened = request(&mut app, json!({"op":"open","path":path})); + assert_eq!(opened["status"], "completed", "{opened}"); + assert!(app.tabs.iter().any(|t| t.id == id)); + assert_eq!( + app.tabs[app.active_tab].scene.document.entities().count(), + 1 + ); + let _ = std::fs::remove_file(path); + } +} diff --git a/src/app/control/transport.rs b/src/app/control/transport.rs new file mode 100644 index 000000000..aa29f10ba --- /dev/null +++ b/src/app/control/transport.rs @@ -0,0 +1,103 @@ +//! Private local discovery and a bounded GUI message queue. +use super::{session_id, Envelope, Reply}; +use iced::futures::{channel::mpsc, Stream}; +use serde_json::{json, Value}; +use std::{ + io::{BufRead, BufReader, Read, Write}, + net::TcpListener, + sync::{ + atomic::{AtomicUsize, Ordering}, + Arc, + }, + time::Duration, +}; + +pub(in crate::app) fn subscribe() -> iced::Subscription { + iced::Subscription::run(worker) +} + +fn worker() -> impl Stream { + iced::stream::channel(32, |sender| async move { + std::thread::spawn(move || { + if let Err(error) = listen(sender) { + eprintln!("Automation unavailable: {error}"); + } + }); + iced::futures::future::pending::<()>().await; + }) +} + +fn listen(sender: mpsc::Sender) -> std::io::Result<()> { + let listener = TcpListener::bind(("127.0.0.1", 0))?; + let dir = crate::config::config_dir() + .ok_or_else(|| std::io::Error::other("No user directory"))? + .join("automation"); + std::fs::create_dir_all(&dir)?; + #[cfg(unix)] + { + use std::os::unix::fs::PermissionsExt; + std::fs::set_permissions(&dir, std::fs::Permissions::from_mode(0o700))?; + } + let mut secret = [0u8; 32]; + getrandom::fill(&mut secret).map_err(std::io::Error::other)?; + let token: String = secret.iter().map(|v| format!("{v:02x}")).collect(); + let descriptor = json!({"protocol":1,"session_id":session_id(),"pid":std::process::id(),"port":listener.local_addr()?.port(),"token":token,"executable":std::env::current_exe()?.to_string_lossy()}); + let path = dir.join(format!("{}.json", session_id())); + let mut options = std::fs::OpenOptions::new(); + options.write(true).create_new(true); + #[cfg(unix)] + { + use std::os::unix::fs::OpenOptionsExt; + options.mode(0o600); + } + let mut file = options.open(&path)?; + write!(file, "{descriptor}")?; + let clients = Arc::new(AtomicUsize::new(0)); + for stream in listener.incoming().flatten() { + if clients.fetch_add(1, Ordering::SeqCst) >= 8 { + clients.fetch_sub(1, Ordering::SeqCst); + continue; + } + let clients = clients.clone(); + let mut sender = sender.clone(); + let token = token.clone(); + std::thread::spawn(move || { + let _ = stream.set_read_timeout(Some(Duration::from_secs(15))); + let _ = stream.set_write_timeout(Some(Duration::from_secs(15))); + if let Ok(write_half) = stream.try_clone() { + let mut reader = BufReader::new(stream); + let mut writer = write_half; + // One bounded request per connection; operation polling reconnects. + let mut line = String::new(); + if reader.by_ref().take(1_048_577).read_line(&mut line).is_ok() + && line.len() <= 1_048_576 + && line.ends_with('\n') + { + if let Ok(mut request) = serde_json::from_str::(&line) { + if request["token"].as_str() == Some(token.as_str()) { + if let Some(object) = request.as_object_mut() { + object.remove("token"); + } + let (reply, response) = std::sync::mpsc::channel(); + let result = if sender + .try_send(Envelope { + request, + reply: Reply::Native(reply), + }) + .is_ok() + { + response.recv_timeout(Duration::from_secs(15)).unwrap_or_else(|_| json!({"ok":false,"code":"response_timeout","error":"Query operation status with the same request_id; execution may continue."})) + } else { + json!({"ok":false,"code":"busy","error":"GUI queue full"}) + }; + let _ = writeln!(writer, "{result}"); + } + } + } + } + clients.fetch_sub(1, Ordering::SeqCst); + }); + } + let _ = std::fs::remove_file(path); + Ok(()) +} diff --git a/src/app/mod.rs b/src/app/mod.rs index 3053b067f..516200fd7 100644 --- a/src/app/mod.rs +++ b/src/app/mod.rs @@ -1,6 +1,6 @@ mod alias; -#[cfg(not(target_arch = "wasm32"))] mod automation; +mod control; pub(crate) mod config; #[cfg(not(target_arch = "wasm32"))] pub use automation::{export_headless, serve}; @@ -341,6 +341,7 @@ pub(crate) enum TextEntryMode { pub(super) struct OpenCADStudio { start: Instant, + control: control::State, tabs: Vec, active_tab: usize, hovered_doc_tab: Option, @@ -1651,7 +1652,7 @@ impl ClipboardDeps { /// Which in-canvas modal dialog is currently open (Plan B). At most one shows /// at a time; dialog-specific data lives in its own fields. Closed via the /// modal's ✕ (`Message::CloseModal`). -#[derive(Clone, Copy, PartialEq, Eq)] +#[derive(Debug, Clone, Copy, PartialEq, Eq)] pub enum ModalKind { About, Shortcuts, @@ -1806,6 +1807,12 @@ pub enum ArrowKey { #[derive(Debug, Clone)] pub enum Message { + ControlRequest(control::Envelope), + PollWebControl, + ControlStep(String, Box), + ControlTaskDone(String), + ControlScreenshot(String, Option), + ControlToggle, Tick(Instant), /// Periodic drain of plugin-to-host requests that arrived outside a host /// call (e.g. mutations from the Python REPL). @@ -3257,6 +3264,7 @@ impl OpenCADStudio { // explicitly (File → New); we never auto-spawn Drawing1. let start_tab = DocumentTab::new_start(); let mut app = Self { + control: control::State::new(), start: Instant::now(), tabs: vec![start_tab], active_tab: 0, diff --git a/src/app/update/command.rs b/src/app/update/command.rs index 1f28e2587..c91e0f3e6 100644 --- a/src/app/update/command.rs +++ b/src/app/update/command.rs @@ -537,13 +537,14 @@ pub(super) fn on_tab_close(&mut self, idx: usize) -> Task { if toks.len() > 1 && !wants_spaces { self.command_line.input.clear(); if self.tabs[i].active_cmd.is_some() { + let mut tasks = Vec::new(); for tok in &toks { if self.tabs[i].active_cmd.is_none() { break; } - self.feed_active_cmd(tok); + tasks.push(self.feed_active_cmd(tok)); } - return Task::none(); + return Task::batch(tasks); } return self.run_command_line(&raw); } diff --git a/src/app/update/mod.rs b/src/app/update/mod.rs index be766631c..23090c06d 100644 --- a/src/app/update/mod.rs +++ b/src/app/update/mod.rs @@ -261,6 +261,7 @@ impl OpenCADStudio { } pub fn update(&mut self, msg: Message) -> Task { + self.control_observe_user_message(&msg); let perf_started = crate::perf::enabled().then(Instant::now); let perf_label = perf_message_label(&msg); // A modal dialog must capture the keyboard the same way it already @@ -335,6 +336,7 @@ impl OpenCADStudio { if self.tabs[i].active_cmd.is_none() { self.block_palette.placing = None; } + self.control_settle(); task } @@ -350,6 +352,19 @@ impl OpenCADStudio { fn update_inner(&mut self, msg: Message) -> Task { match msg { + Message::ControlRequest(envelope) => { + let (response, task) = self.control_request(envelope.request); + envelope.reply.send(response); + task + } + #[cfg(target_arch="wasm32")] + Message::PollWebControl => if let Some(request)=super::control::web_request(){self.update(Message::ControlRequest(request))}else{Task::none()}, + #[cfg(not(target_arch="wasm32"))] + Message::PollWebControl => Task::none(), + Message::ControlStep(id, message) => self.control_step(id, *message), + Message::ControlTaskDone(id) => { self.control_task_done(&id); Task::none() } + Message::ControlScreenshot(path, screenshot) => { self.control_screenshot(path, screenshot); Task::none() } + Message::ControlToggle => { self.control.enabled = !self.control.enabled; Task::none() } // Drain plugin-to-host requests that arrived outside of a host call. // This runs on a periodic timer so long-lived plugin sessions such // as the Python REPL can mutate the document without requiring a @@ -1816,8 +1831,7 @@ impl OpenCADStudio { if kw.is_empty() { return self.feed_command(crate::command::StepInput::Enter); } - self.feed_active_cmd(&kw); - Task::none() + self.feed_active_cmd(&kw) } Message::CommandSubmit => self.on_command_submit(), diff --git a/src/app/view/mod.rs b/src/app/view/mod.rs index e10cda835..32b936d5d 100644 --- a/src/app/view/mod.rs +++ b/src/app/view/mod.rs @@ -1987,6 +1987,8 @@ impl OpenCADStudio { self.quick_properties, tab.scene.selection_filter_active(), self.selection_cycling, + self.control.enabled, + self.control_busy(), &self.statusbar_config, status_menu_data, ) @@ -2403,7 +2405,12 @@ impl OpenCADStudio { } }) }; + #[cfg(not(target_arch = "wasm32"))] + let control = super::control::subscribe().map(Message::ControlRequest); + #[cfg(target_arch = "wasm32")] + let control = iced::time::every(std::time::Duration::from_millis(50)).map(|_|Message::PollWebControl); iced::Subscription::batch([ + control, frames, history_tick, grip_dwell, @@ -3934,4 +3941,3 @@ mod tests { assert!(crate::ui::style::common::canvas_is_light(bg)); } } - diff --git a/src/ui/command_line.rs b/src/ui/command_line.rs index e2a78cd49..0b689b267 100644 --- a/src/ui/command_line.rs +++ b/src/ui/command_line.rs @@ -69,6 +69,8 @@ pub struct CommandLine { /// instead of submitting. Saved in the user config. pub literal_spaces: bool, pub history: Vec, + pub error_revision: u64, + pub last_error: Option, /// Successfully dispatched commands used for ↑/↓ recall, newest last. /// Stored separately because recall also maintains its own cursor and draft. pub cmd_recall: Vec, @@ -116,6 +118,8 @@ impl Default for CommandLine { input: String::new(), literal_spaces: false, history: Vec::new(), + error_revision: 0, + last_error: None, cmd_recall: Vec::new(), recent_commands: Vec::new(), recall_cursor: None, @@ -307,6 +311,8 @@ impl CommandLine { self.push(EntryKind::Output, msg.to_string()); } pub fn push_error(&mut self, msg: &str) { + self.error_revision = self.error_revision.wrapping_add(1); + self.last_error = Some(msg.to_owned()); self.push(EntryKind::Error, format!("*{}* {msg}", t!("Invalid"))); } /// Append an error unless it is already the latest history line. Repeated @@ -314,6 +320,8 @@ impl CommandLine { /// with identical copies (#498). #[cfg(not(target_arch = "wasm32"))] pub fn push_error_once(&mut self, msg: &str) { + self.error_revision = self.error_revision.wrapping_add(1); + self.last_error = Some(msg.to_owned()); let text = format!("*{}* {msg}", t!("Invalid")); if let Some(last) = self .history diff --git a/src/ui/statusbar/mod.rs b/src/ui/statusbar/mod.rs index 0a84008c1..f3fde6804 100644 --- a/src/ui/statusbar/mod.rs +++ b/src/ui/statusbar/mod.rs @@ -122,6 +122,8 @@ impl StatusBar { selection_filter_active: bool, // Whether selection cycling is enabled. selection_cycling: bool, + control_enabled: bool, + control_busy: bool, // Which pills the user has chosen to show on the bar. config: &'a StatusBarConfig, menu_data: StatusMenuData<'a>, @@ -493,6 +495,35 @@ impl StatusBar { // use the remaining space on the final tab row when they fit; otherwise // WrapBar adds another right-aligned row. let mut left: Vec> = Vec::new(); + let control_label = if !control_enabled { + "Control: off" + } else if control_busy { + "Control: busy" + } else { + "Control: ready" + }; + let control_color = if !control_enabled { + Color::from_rgb(0.90, 0.35, 0.35) + } else if control_busy { + Color::from_rgb(0.95, 0.72, 0.25) + } else { + Color::from_rgb(0.35, 0.85, 0.55) + }; + left.push( + button(text(control_label).size(11)) + .on_press(Message::ControlToggle) + .style(move |theme: &Theme, status| { + let mut style = button::subtle(theme, status); + style.background = Some(Background::Color(control_color)); + style.text_color = Color::from_rgb(0.08, 0.08, 0.10); + style.border.color = control_color; + style.border.width = 1.0; + style + }) + .padding([4, 8]) + .width(Length::Fixed(100.0)) + .into(), + ); left.push(menu_btn); if show_layout_tabs { let reorderable_layouts: Arc<[String]> = reorderable_layouts.into();