From 6d29b8458c51948d4baa2f5c74c5d6ae3840fd41 Mon Sep 17 00:00:00 2001 From: Hakan Seven Date: Wed, 17 Jun 2026 21:22:46 +0300 Subject: [PATCH] =?UTF-8?q?feat(automation):=20query=20op=20=E2=80=94=20li?= =?UTF-8?q?st=20entities=20with=20detail=20and=20filters?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add a query op to the headless server: list entities with handle, type, layer and basic geometry (Line start/end, Circle/Arc centre+radius, Point location), filtered by type and/or layer and capped by limit. Closes the observe half of an agent's act/observe loop. ocs.py gains ocs.query(). Co-Authored-By: Claude Opus 4.8 --- docs/automation/README.md | 1 + docs/automation/ocs.py | 9 +++++ src/app/automation.rs | 79 +++++++++++++++++++++++++++++++++++++++ 3 files changed, 89 insertions(+) diff --git a/docs/automation/README.md b/docs/automation/README.md index bb76fdd90..5ec9b6247 100644 --- a/docs/automation/README.md +++ b/docs/automation/README.md @@ -19,6 +19,7 @@ can act → observe → act. | `{"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}` (filters + `limit` optional) | | `{"op":"save","path":"out.dwg"}` | `{"ok":true,"saved":"out.dwg"}` (path optional once opened/saved) | Every response has `"ok"`; failures carry `"error"`. `run` drives Open CAD diff --git a/docs/automation/ocs.py b/docs/automation/ocs.py index 461d4af21..11a380574 100644 --- a/docs/automation/ocs.py +++ b/docs/automation/ocs.py @@ -70,6 +70,15 @@ class Ocs: """Total entity count and a breakdown by type.""" return self._send(op="entities") + def query( + self, + type: Optional[str] = None, + layer: Optional[str] = None, + limit: Optional[int] = None, + ) -> dict[str, Any]: + """List entities (handle, type, layer, geometry), optionally filtered.""" + return self._send(op="query", type=type, layer=layer, limit=limit) + def save(self, path: Optional[str] = None) -> dict[str, Any]: """Write the document (defaults to the opened/last-saved path).""" return self._send(op="save", path=path) diff --git a/src/app/automation.rs b/src/app/automation.rs index 195ca1255..fcdffa926 100644 --- a/src/app/automation.rs +++ b/src/app/automation.rs @@ -53,6 +53,44 @@ fn err(msg: impl std::fmt::Display) -> Value { json!({ "ok": false, "error": msg.to_string() }) } +fn v3(v: acadrust::types::Vector3) -> Value { + json!([v.x, v.y, v.z]) +} + +/// One entity as JSON: handle, type, layer, plus basic geometry for the common +/// types (others carry only the common fields). +fn entity_json(e: &acadrust::EntityType) -> Value { + use acadrust::EntityType as E; + let c = e.common(); + let mut obj = json!({ + "handle": format!("{:X}", c.handle.value()), + "type": crate::entities::names::ui_name(e), + "layer": c.layer, + }); + let map = obj.as_object_mut().expect("json object"); + match e { + E::Line(l) => { + map.insert("start".into(), v3(l.start)); + map.insert("end".into(), v3(l.end)); + } + E::Circle(cc) => { + map.insert("center".into(), v3(cc.center)); + map.insert("radius".into(), json!(cc.radius)); + } + E::Arc(a) => { + map.insert("center".into(), v3(a.center)); + map.insert("radius".into(), json!(a.radius)); + map.insert("start_angle".into(), json!(a.start_angle)); + map.insert("end_angle".into(), json!(a.end_angle)); + } + E::Point(p) => { + map.insert("location".into(), v3(p.location)); + } + _ => {} + } + obj +} + impl OpenCADStudio { /// Handle one JSON request line and return the JSON response. pub(crate) fn automation_op(&mut self, line: &str) -> Value { @@ -112,6 +150,7 @@ impl OpenCADStudio { }) } "entities" => self.entity_summary(), + "query" => self.entity_query(&req), "save" => { let i = self.active_tab; let path = req["path"] @@ -190,6 +229,40 @@ impl OpenCADStudio { } } + /// List entities (handle, type, layer, basic geometry), optionally filtered + /// by `type` and/or `layer`, capped by `limit` (default 1000). + fn entity_query(&self, req: &Value) -> Value { + 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 mut entities = Vec::new(); + let mut matched = 0u64; + for e in self.tabs[i].scene.document.entities() { + if let Some(tf) = type_filter { + if !crate::entities::names::ui_name(e).eq_ignore_ascii_case(tf) { + continue; + } + } + if let Some(lf) = layer_filter { + if e.common().layer != lf { + continue; + } + } + matched += 1; + if entities.len() < limit { + entities.push(entity_json(e)); + } + } + json!({ + "ok": true, + "count": matched, + "returned": entities.len(), + "entities": entities, + }) + } + /// Count of entities in the active document, total and by type. fn entity_summary(&self) -> Value { let i = self.active_tab; @@ -235,6 +308,12 @@ mod tests { assert_eq!(r["by_type"]["Line"], 2); assert_eq!(r["by_type"]["Circle"], 1); + // query returns per-entity detail and honours a type filter. + let r = app.automation_op(r#"{"op":"query","type":"Circle"}"#); + assert_eq!(r["count"], 1); + assert_eq!(r["entities"][0]["type"], "Circle"); + assert_eq!(r["entities"][0]["radius"], 3.0); + // Errors are reported, never panics. assert_eq!(app.automation_op(r#"{"op":"bogus"}"#)["ok"], false); assert_eq!(app.automation_op("not json")["ok"], false);