선택 객체 조회 필터와 MCP 연동 추가 (#1253)

## 변경 내용
- entities 명령에 selected 불리언 필터를 추가해 현재 공간의 선택 객체만 조회
- 기존 종류·레이어·영역 필터와 결합하고 필터 적용 후 페이지 처리
- MCP query_entities 스키마와 명령·제어 프로토콜 문서에 옵션 공개
- 빈 선택, 중복·오래된 핸들, 다른 공간 객체, 잘못된 입력 및 조회 전후 상태 보존 회귀 테스트 추가

## 검증
- cargo xtask ci 6단계 통과: fmt, clippy, 테스트 2,012개, assets, layers, wasm
- 별도 .NET 클라이언트와 실제 헤드리스 엔진의 localhost 통합 검증 통과
- 실제 데스크톱 UI 조작 및 고객 DWG 왕복 검증은 수행하지 않음

Co-authored-by: Brandon Thomas <echelon@gmail.com>
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
이상익
2026-10-19 02:55:02 -04:00
committed by GitHub
co-authored by Brandon Thomas Claude Opus 5.5
parent a76775bf2b
commit 30db7296c4
7 changed files with 118 additions and 4 deletions
+4 -1
View File
@@ -42,7 +42,7 @@ pub fn specs() -> Vec<CommandSpec> {
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<Value> {
})
.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<Handle> = if selected { st.selection.iter().copied().collect() } else { Default::default() };
let matches: Vec<&std::sync::Arc<cadcraft_doc::Entity>> = 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)))
+76
View File
@@ -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!([]));
}
+20
View File
@@ -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::<Value>(&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() {
+2 -2
View File
@@ -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.",
+13
View File
@@ -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
+2 -1
View File
@@ -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 |
+1
View File
@@ -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. |