forked from 1b-agent/opencadstudio-upstream
Add persistent live OpenCADStudio control
This commit is contained in:
1 parent
9d991d7feb
commit
28cc473062
22 files changed
+1764
-95
No files matched your search
@@ -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
|
||||
Generated
+1
@@ -25,6 +25,7 @@ dependencies = [
|
||||
"iced",
|
||||
"iced_aw",
|
||||
"iced_core",
|
||||
"iced_runtime",
|
||||
"image",
|
||||
"inventory",
|
||||
"js-sys",
|
||||
|
||||
@@ -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://[email protected]/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"] }
|
||||
|
||||
+16
-62
@@ -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"}'
|
||||
```
|
||||
Executable
+20
@@ -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'
|
||||
@@ -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))
|
||||
@@ -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")
|
||||
@@ -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())
|
||||
@@ -0,0 +1 @@
|
||||
mcp==1.29.1
|
||||
@@ -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.
|
||||
@@ -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."
|
||||
+57
-7
@@ -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::<Value>(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<Value> = 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<super::Message>) -> 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");
|
||||
}
|
||||
|
||||
|
||||
+19
-19
@@ -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<String> = 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<String> = 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<Message> {
|
||||
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<Message> {
|
||||
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()));
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,281 @@
|
||||
use super::*;
|
||||
use crate::scene::model::object::PropValue;
|
||||
|
||||
fn control_color(value: &Value) -> Result<acadrust::types::Color, Value> {
|
||||
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<acadrust::types::LineWeight, Value> {
|
||||
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::<Vec<_>>())),
|
||||
PropValue::LwVaries|PropValue::FieldLwVaries{..}=>("lineweight",Value::Null,json!(crate::ui::properties::lw_options().iter().map(|v|v.0.value()).collect::<Vec<_>>())),
|
||||
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::<Vec<_>>()})).collect::<Vec<_>>()})
|
||||
}
|
||||
pub(super) fn control_set_property(&mut self, req: &Value) -> Result<Task<Message>, 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<Task<Message>, 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))
|
||||
}
|
||||
}
|
||||
@@ -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<String> = 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<Value>),
|
||||
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<VecDeque<(String, Value)>> =
|
||||
std::sync::Mutex::new(VecDeque::new());
|
||||
#[cfg(target_arch = "wasm32")]
|
||||
static WEB_RESULTS: std::sync::Mutex<VecDeque<(String, Value)>> =
|
||||
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<String> {
|
||||
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<Envelope> {
|
||||
let (id, request) = WEB_QUEUE.lock().unwrap().pop_front()?;
|
||||
Some(Envelope {
|
||||
request,
|
||||
reply: Reply::Web(id),
|
||||
})
|
||||
}
|
||||
#[derive(Default)]
|
||||
pub(super) struct State {
|
||||
pending: Option<Operation>,
|
||||
completed: VecDeque<(String, Value, Value)>,
|
||||
owner: Option<(u64, String)>,
|
||||
pub enabled: bool,
|
||||
serial: u64,
|
||||
events: VecDeque<Value>,
|
||||
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<u64>,
|
||||
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<acadrust::Handle, Value> {
|
||||
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<String> {
|
||||
crate::plugin::external::with_manager(|m| m.ids())
|
||||
}
|
||||
#[cfg(target_arch = "wasm32")]
|
||||
fn plugin_ids() -> Vec<String> {
|
||||
Vec::new()
|
||||
}
|
||||
|
||||
fn point(req: &Value) -> Result<glam::DVec3, Value> {
|
||||
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::<Vec<_>>(),
|
||||
"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::<Vec<_>>(),
|
||||
"selection":tab.scene.selected_handles_in_order().iter().map(|h|format!("{:X}",h.value())).collect::<Vec<_>>(),
|
||||
"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<Message>) {
|
||||
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+1<e["sequence"].as_u64().unwrap_or(0)),"events":self.control.events.iter().filter(|e|e["sequence"].as_u64().unwrap_or(0)>cursor).collect::<Vec<_>>()}),
|
||||
Task::none(),
|
||||
);
|
||||
}
|
||||
if op == "commands" {
|
||||
let mut names = crate::command::all_registered_command_names();
|
||||
let mut names: Vec<String> = 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::<Vec<_>>()})
|
||||
}
|
||||
_ => 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<Value> = 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<Task<Message>, 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<Message>) -> Task<Message> {
|
||||
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<Message> {
|
||||
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::<Vec<_>>());
|
||||
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<acadrust::Handle> = 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<iced::window::Screenshot>,
|
||||
) {
|
||||
let result = (|| -> Result<Value, String> {
|
||||
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);
|
||||
}
|
||||
}
|
||||
@@ -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<Envelope> {
|
||||
iced::Subscription::run(worker)
|
||||
}
|
||||
|
||||
fn worker() -> impl Stream<Item = Envelope> {
|
||||
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<Envelope>) -> 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::<Value>(&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(())
|
||||
}
|
||||
+10
-2
@@ -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<DocumentTab>,
|
||||
active_tab: usize,
|
||||
hovered_doc_tab: Option<usize>,
|
||||
@@ -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<Message>),
|
||||
ControlTaskDone(String),
|
||||
ControlScreenshot(String, Option<iced::window::Screenshot>),
|
||||
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,
|
||||
|
||||
@@ -537,13 +537,14 @@ pub(super) fn on_tab_close(&mut self, idx: usize) -> Task<Message> {
|
||||
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);
|
||||
}
|
||||
|
||||
+16
-2
@@ -261,6 +261,7 @@ impl OpenCADStudio {
|
||||
}
|
||||
|
||||
pub fn update(&mut self, msg: Message) -> Task<Message> {
|
||||
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<Message> {
|
||||
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(),
|
||||
|
||||
+7
-1
@@ -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));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -69,6 +69,8 @@ pub struct CommandLine {
|
||||
/// instead of submitting. Saved in the user config.
|
||||
pub literal_spaces: bool,
|
||||
pub history: Vec<HistoryEntry>,
|
||||
pub error_revision: u64,
|
||||
pub last_error: Option<String>,
|
||||
/// Successfully dispatched commands used for ↑/↓ recall, newest last.
|
||||
/// Stored separately because recall also maintains its own cursor and draft.
|
||||
pub cmd_recall: Vec<String>,
|
||||
@@ -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
|
||||
|
||||
@@ -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<Element<'_, Message>> = 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();
|
||||
|
||||
Reference in new issue
Block a user