diff --git a/crates/engine/src/cmd/inquiry.rs b/crates/engine/src/cmd/inquiry.rs index e8fba876..b70dae9f 100644 --- a/crates/engine/src/cmd/inquiry.rs +++ b/crates/engine/src/cmd/inquiry.rs @@ -42,7 +42,7 @@ pub fn specs() -> Vec { CommandSpec::new("status", "Status", run_status).menu(&["Tools", "Inquiry", "Status"]).noundo(), CommandSpec::new("drawing.inspect", "Inspect Drawing", run_inspect).params("{entities?: bool, limit?: n}").noundo(), CommandSpec::new("entities", "Query Entities", run_entities) - .params("{type?, layer?, window?: [[x,y],[x,y]], crossing?: bool (default true; false = entirely inside), limit?, offset?} → {count (all matches), returned, offset, entities}") + .params("{type?, layer?, selected?: bool (default false; true = current selection in active space), window?: [[x,y],[x,y]], crossing?: bool (default true; false = entirely inside), limit?, offset?} → {count (all matches), returned, offset, entities}") .noundo(), CommandSpec::new("shapes", "Analyze Shapes", shapes::run).params(shapes::PARAMS).noundo(), CommandSpec::new("texts", "Query Texts", run_texts) @@ -436,14 +436,17 @@ fn run_entities(s: &mut Session, p: &Value) -> Result { }) .transpose()?; let crossing = given("crossing").map_or(Ok(true), |v| v.as_bool().ok_or_else(|| bad(CMD, "`crossing` must be true or false")))?; + let selected = given("selected").map_or(Ok(false), |v| v.as_bool().ok_or_else(|| bad(CMD, "`selected` must be true or false")))?; let limit = count("limit", 500)?; let offset = count("offset", 0)?; let st = s.state()?; let d = &st.doc; let Some(store) = d.space(&st.space) else { return Ok(json!({ "count": 0, "returned": 0, "entities": [] })) }; let tol = window.as_ref().map(crate::select::window_tol).unwrap_or(0.0); + let selection: std::collections::HashSet = if selected { st.selection.iter().copied().collect() } else { Default::default() }; let matches: Vec<&std::sync::Arc> = store .iter() + .filter(|e| !selected || selection.contains(&e.handle)) .filter(|e| ty.as_ref().is_none_or(|t| e.kind.type_name().to_ascii_lowercase() == *t || e.kind.dxf_name().to_ascii_lowercase() == *t)) .filter(|e| layer.as_ref().is_none_or(|l| e.common.layer.to_ascii_lowercase() == *l)) .filter(|e| window.as_ref().is_none_or(|w| crate::select::in_window(d, e, None, w, crossing, tol))) diff --git a/crates/engine/tests/query_entities.rs b/crates/engine/tests/query_entities.rs index e6fab682..4d99ca7d 100644 --- a/crates/engine/tests/query_entities.rs +++ b/crates/engine/tests/query_entities.rs @@ -20,9 +20,85 @@ fn malformed_filters_are_errors() { json!({"limit": -1}), json!({"offset": "1"}), json!({"crossing": "yes"}), + json!({"selected": "true"}), + json!({"selected": 1}), + json!({"selected": []}), + json!({"selected": {}}), ] { assert!(s.execute("entities", &p).is_err(), "{p} must be rejected"); } let r = s.execute("entities", &json!({"window": [[-5, -5], [5, 5]]})).unwrap(); assert_eq!(r["count"], 1, "{r}"); } + +#[test] +fn selection_filter_combines_before_paging_without_changing_state() { + let mut s = Session::new(); + s.execute("layer.new", &json!({"name": "측정", "current": true})).unwrap(); + for x in [0, 10, 20] { + s.execute("circle", &json!({"center": [x, 0], "radius": 2})).unwrap(); + } + s.execute("line", &json!({"points": [[0, 0], [3, 4]]})).unwrap(); + let all = s.execute("entities", &json!({})).unwrap(); + let hs: Vec<_> = all["entities"].as_array().unwrap().iter().map(|e| e["handle"].clone()).collect(); + // Leave a redo entry to prove the query doesn't clear redo or create an undo entry. + s.execute("circle", &json!({"center": [99, 99], "radius": 1})).unwrap(); + s.undo().unwrap(); + s.execute("select", &json!({"handles": [hs[0], hs[2], hs[3]]})).unwrap(); + let before = s.state().unwrap().clone(); + let report = s.execute("drawing.inspect", &json!({})).unwrap(); + let selected = s.execute("entities", &json!({"selected": true})).unwrap(); + assert_eq!(selected["count"], 3); + assert_eq!(selected["entities"][0]["handle"], hs[0]); + assert_eq!(selected["entities"][1]["handle"], hs[2]); + assert_eq!(s.execute("entities", &json!({"selected": false})).unwrap(), all); + assert_eq!(s.execute("entities", &json!({"selected": null})).unwrap(), all); + assert_eq!(s.execute("entities", &json!({})).unwrap(), all); + let page = s.execute("entities", &json!({"selected": true, "type": "circle", "layer": "측정", "limit": 1, "offset": 1})).unwrap(); + assert_eq!(page, json!({"count": 2, "returned": 1, "offset": 1, "entities": [all["entities"][2]]})); + let window = json!({"selected": true, "type": "circle", "layer": "측정", "window": [[-3, -3], [3, 3]], "crossing": false}); + assert_eq!(s.execute("entities", &window).unwrap()["count"], 1); + assert_eq!(s.execute("entities", &json!({"selected": true, "layer": "other"})).unwrap()["count"], 0); + assert_eq!(s.execute("entities", &json!({"selected": true, "limit": 0})).unwrap()["returned"], 0); + assert_eq!(s.execute("entities", &json!({"selected": true, "offset": 99})).unwrap()["returned"], 0); + assert!(s.execute("entities", &json!({"selected": "true"})).is_err()); + assert_eq!(s.execute("drawing.inspect", &json!({})).unwrap(), report); + let after = s.state().unwrap(); + assert!(std::sync::Arc::ptr_eq(&before.doc, &after.doc)); + assert!(std::sync::Arc::ptr_eq(&before.saved, &after.saved)); + assert_eq!(before.revision, after.revision); + assert_eq!(before.selection, after.selection); + assert_eq!(before.previous_selection, after.previous_selection); + assert_eq!(before.is_dirty(), after.is_dirty()); + for (a, b) in [(&before.undo, &after.undo), (&before.redo, &after.redo)] { + assert_eq!(a.len(), b.len()); + for (a, b) in a.iter().zip(b) { + assert_eq!(a.label, b.label); + assert_eq!(a.selection, b.selection); + assert!(std::sync::Arc::ptr_eq(&a.doc, &b.doc)); + } + } +} + +#[test] +fn selection_is_empty_by_default_and_limited_to_active_space() { + use cadcraft_doc::{Handle, Space}; + let mut s = Session::new(); + s.execute("circle", &json!({"center": [0, 0], "radius": 2})).unwrap(); + let model = s.doc().unwrap().model.iter().next().unwrap().handle; + assert_eq!(s.execute("entities", &json!({"selected": true})).unwrap()["count"], 0); + s.execute("layout.set", &json!({"name": "Layout1"})).unwrap(); + s.execute("circle", &json!({"center": [10, 10], "radius": 2})).unwrap(); + let paper = s.doc().unwrap().space(&Space::Paper("Layout1".into())).unwrap().iter().last().unwrap().handle; + // Even stale, duplicate and foreign-space selection handles cannot leak entities. + s.state_mut().unwrap().selection = vec![model, paper, paper, Handle(u64::MAX)]; + let page = s.execute("entities", &json!({"selected": true})).unwrap(); + assert_eq!(page["count"], 1); + assert_eq!(page["entities"][0]["handle"], paper.hex()); + s.state_mut().unwrap().space = Space::Model; + let page = s.execute("entities", &json!({"selected": true})).unwrap(); + assert_eq!(page["count"], 1); + assert_eq!(page["entities"][0]["handle"], model.hex()); + s.state_mut().unwrap().selection.clear(); + assert_eq!(s.execute("entities", &json!({"selected": true})).unwrap()["entities"], json!([])); +} diff --git a/crates/mcp/src/tests.rs b/crates/mcp/src/tests.rs index 946cfbbc..f0c66517 100644 --- a/crates/mcp/src/tests.rs +++ b/crates/mcp/src/tests.rs @@ -98,6 +98,8 @@ fn bad_tool_calls_are_invalid_params() { json!({"name": "query_entities", "arguments": {"type": 7}}), json!({"name": "query_entities", "arguments": {"layer": ["0"]}}), json!({"name": "query_entities", "arguments": {"offset": -1}}), + json!({"name": "query_entities", "arguments": {"selected": "true"}}), + json!({"name": "query_entities", "arguments": {"selected": 1}}), ]; for (i, params) in bad.into_iter().enumerate() { let r = call(&mut s, i as u64, "tools/call", params.clone()); @@ -109,6 +111,24 @@ fn bad_tool_calls_are_invalid_params() { assert_eq!(s.handle_line("[]").map(|r| serde_json::from_str::(&r).unwrap()["error"]["code"].clone()), Some(json!(-32600))); } +#[test] +fn query_entities_selection_filter_is_discoverable_and_forwarded() { + let mut s = Server::new(Box::new(Headless::default())); + let tools = call(&mut s, 1, "tools/list", json!({})); + let query = tools["result"]["tools"].as_array().unwrap().iter().find(|t| t["name"] == "query_entities").unwrap(); + assert_eq!(query["inputSchema"]["properties"]["selected"]["type"], "boolean"); + tool(&mut s, "execute", json!({"command": "circle", "params": {"center": [0, 0], "radius": 2}})); + tool(&mut s, "execute", json!({"command": "circle", "params": {"center": [10, 0], "radius": 2}})); + assert_eq!(tool(&mut s, "query_entities", json!({"selected": true}))["count"], 0); + tool(&mut s, "execute", json!({"command": "select", "params": {"window": [[-3, -3], [3, 3]]}})); + let before = tool(&mut s, "inspect_drawing", json!({})); + let selected = tool(&mut s, "query_entities", json!({"selected": true, "type": "Circle"})); + assert_eq!(selected["count"], 1); + assert_eq!(selected["entities"][0]["geometry"]["center"]["x"], 0.0); + assert_eq!(tool(&mut s, "query_entities", json!({"selected": false}))["count"], 2); + assert_eq!(tool(&mut s, "inspect_drawing", json!({})), before); +} + /// `count` is every match while `returned` is the page; a window matches geometry, not bounding boxes. #[test] fn query_entities_pages_and_windows_by_geometry() { diff --git a/crates/mcp/src/tools.rs b/crates/mcp/src/tools.rs index 257c6365..1739dd9c 100644 --- a/crates/mcp/src/tools.rs +++ b/crates/mcp/src/tools.rs @@ -16,8 +16,8 @@ pub fn tool_definitions(has_ui: bool) -> Value { "inputSchema": {"type": "object", "properties": {"filter": {"type": "string"}}}}), json!({"name": "inspect_drawing", "description": "Summary of the active drawing: entity counts, extents, layers, styles, blocks, layouts, selection, undo history and (optionally) entities with handles and geometry.", "inputSchema": {"type": "object", "properties": {"entities": {"type": "boolean"}, "limit": {"type": "integer"}}}}), - json!({"name": "query_entities", "description": "Entities filtered by type (Line, Circle, Polyline…), layer and/or a window [[x0,y0],[x1,y1]] (objects whose geometry is inside or crosses it; crossing:false = entirely inside). Returns count (all matches), returned (this page) and entities; page with offset and limit (default 500).", - "inputSchema": {"type": "object", "properties": {"type": {"type": "string"}, "layer": {"type": "string"}, + json!({"name": "query_entities", "description": "Entities filtered by type (Line, Circle, Polyline…), layer, current selection (selected:true; active space only) and/or a window [[x0,y0],[x1,y1]] (objects whose geometry is inside or crosses it; crossing:false = entirely inside). Returns count (all matches), returned (this page) and entities; page with offset and limit (default 500).", + "inputSchema": {"type": "object", "properties": {"type": {"type": "string"}, "layer": {"type": "string"}, "selected": {"type": "boolean"}, "window": {"type": "array", "items": {"type": "array", "items": {"type": "number"}, "minItems": 2, "maxItems": 2}, "minItems": 2, "maxItems": 2}, "crossing": {"type": "boolean"}, "limit": {"type": "integer", "minimum": 0}, "offset": {"type": "integer", "minimum": 0}}}}), json!({"name": "render", "description": "Render the active drawing to a PNG and return it as an image: fitted to the extents by default, or just the area `window` [[x0,y0],[x1,y1]] (drawing units), e.g. one sheet or one room of a large drawing. With a window and no size the image is 1600 px wide with the window's shape.", diff --git a/docs/control-protocol.md b/docs/control-protocol.md index 05288cc3..56cf4d99 100644 --- a/docs/control-protocol.md +++ b/docs/control-protocol.md @@ -86,6 +86,19 @@ Sending `open` or `document.bytes` as the top-level `method` returns an `unknown the protocol dispatches methods explicitly and does not fall through to the command registry. `app.open` is a top-level convenience method for opening a drawing with `{path}`. +To read selected objects as structured geometry in a single query, execute `entities` with +`selected: true` (a boolean; omitted/false keeps the unfiltered selection behavior): + +```json +{"id":7,"method":"engine.execute","params":{"command":"entities","params":{"selected":true,"limit":1000}}} +``` + +Only selected entities in the active space are returned; an empty selection returns an empty +page. The selection filter combines with `type`, `layer` and `window` before `offset`/`limit`. +The existing response is unchanged: `count` is all matches, `returned` is the page size and +`entities` contains handles, layers and geometry. This query does not change selection, drawing +contents or undo history. `entities` is a command, not a top-level protocol method. + The MCP server (`cadcraft-cli mcp --connect 127.0.0.1:PORT`) wraps this protocol; headless (`cadcraft-cli mcp`) it serves the same methods from an in-process session. MCP tool names are another interface: `execute` maps to `engine.execute`, `list_commands` to diff --git a/docs/gaps.md b/docs/gaps.md index eca0d4f1..10547a45 100644 --- a/docs/gaps.md +++ b/docs/gaps.md @@ -143,7 +143,7 @@ Detail: [localization-parity.md](localization-parity.md). | # | Gap | Est. | |---|---|---| | A1 | Smart Blocks equivalents (placement suggestions, replace, detect and convert, search), using local open models | 25–50 + model licensing | -| A2 | An in-app assistant (AutoCAD has Autodesk Assistant); CADCraft's MCP server already lets any agent drive it | 10–20 | +| A2 | An in-app assistant (AutoCAD has Autodesk Assistant); CADCraft's MCP server already lets any agent drive it, including read-only structured queries of the current selection (`query_entities selected:true`) | 10–20 | | A3 | Markup Assist (import markups from PDFs/images) | 10–20 | ## Revision history @@ -152,6 +152,7 @@ Detail: [localization-parity.md](localization-parity.md). |---|---|---| | 2026-10-13 | minor | Stabilized filled-object highlight dash anchors by canonicalizing boundary traversal order; renderer, CPU pointer-frame and GPU rebuild tests cover the fix (U6) | | 2026-10-14 | minor | HATCHEDIT adds boundary loops from internal points or closed objects and removes selected loops, in the dialog, command line and JSON command: F13 narrowed | +| 2026-10-15 | minor | `entities` / MCP `query_entities` accept `selected:true`, combined with existing filters and paging; selection, drawing and undo/redo stay unchanged. No parity estimate change. | | 2026-10-13 | minor | Selection window gesture preference (PICKDRAG 0/2) added to Options ▸ Selection and saved in the profile (U6), with Portuguese and Ukrainian labels | | 2026-10-12 | minor | Object transparency (entity, ByLayer, ByBlock) is drawn: on screen (TRANSPARENCYDISPLAY, on by default), in PNG/SVG exports, and in plots when the page setup's "Plot transparency" is on (PDF alpha, PNG blending, SVG opacity; PLOTTRANSPARENCYOVERRIDE): F14 narrowed | | 2026-10-12 | minor | Hatch and Gradient dialog (HATCH, GRADIENT; HPDLGMODE) and Hatch Edit dialog (HATCHEDIT, right-click, double-click a hatch): F13 narrowed to Preview, Inherit Properties and boundary add/remove in edit mode | diff --git a/docs/mcp.md b/docs/mcp.md index f72a5bfd..51e6107e 100644 --- a/docs/mcp.md +++ b/docs/mcp.md @@ -43,6 +43,7 @@ Example client configuration (Claude Code / Claude Desktop): | `inspect_drawing {entities?, limit?}` | Counts, extents, layers, styles, blocks, layouts, selection, undo history and entities. | | `query_entities {type?, layer?, window?, crossing?, limit?, offset?}` | Filtered entities with handles and geometry. `window` `[[x0,y0],[x1,y1]]` finds objects whose geometry is inside it or crosses it (`crossing: false`: entirely inside). `count` is every match, `returned` the page (`offset`, `limit`, default 500). | | `execute {"command": "shapes", "params": {type?, layer?, window?, crossing?, limit?, offset?, tolerance?}}` | Facts about the closed shapes — closed polylines, circles, full ellipses, closed splines: perimeter, area, centroid, winding, the first self-intersection and coincident vertices, exact with arcs and bulges. Other matches are counted under `skipped` with the reason. | +| `query_entities {type?, layer?, selected?, window?, crossing?, limit?, offset?}` | Filtered entities with handles and geometry. `selected: true` restricts results to the current selection in the active space; an empty selection returns no entities. Omitted/false queries all entities in that space. Filters combine before paging. `window` `[[x0,y0],[x1,y1]]` finds objects whose geometry is inside it or crosses it (`crossing: false`: entirely inside). `count` is every match, `returned` the page (`offset`, `limit`, default 500). | | `render {width?, height?, fit?, window?}` | A PNG image of the drawing, or of just the area `window` `[[x0,y0],[x1,y1]]` (one sheet or room of a large drawing; 1600 px wide with the window's shape unless sized). | | `find_text {contains?, layer?, window?, limit?, offset?}` | The readable texts — TEXT, MTEXT without formatting codes, block attributes, multileaders — with text, insertion point, height, rotation, style, layer and handle; filtered by content (any case), layer and a window around the insertion points. Read-only (the `texts` command). | | `new_drawing {metric?}`, `open {path}`, `save {path?}`, `cancel` | Files and Escape. `save` to a `.svg`, `.png` or `.pdf` path exports: the drawing keeps its name and unsaved changes. |