MCP: render one area of the drawing, and read its texts (find_text) (#757)

A render of a large drawing (a set of sheets in model space) fits every
sheet in one image, too small to read. `render` takes `window`
[[x0,y0],[x1,y1]]: just that area, 1600 px wide with the window's shape
unless sized (the app's and the headless `ui.render` both; the display list
is built for that view only). `View::of_window` sizes it.

`find_text` (engine command `texts`, read-only) returns the readable text of
TEXT, MTEXT (formatting removed), block attributes and multileaders with
insertion point, height, rotation, style, layer and handle, filtered by
content, layer and a window. FIND selects and can replace; this only
reports, so an agent can find room names, sheet numbers and titles without
reading all the geometry.

On a 39,600-object permit set: 5,861 texts in 0.02 s; one 90 x 59.4 sheet
rendered at 2400 x 1584 in 0.5 s.

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
Co-authored-by: Brandon Thomas <echelon@gmail.com>
This commit is contained in:
authored and GitHub committed 2026-10-13 05:34:34 -04:00
1 parent 8166e4a809
commit 8abc0909f3
14 files changed
+191 -7

No files matched your search

+75
View File
@@ -43,6 +43,9 @@ pub fn specs() -> Vec<CommandSpec> {
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}")
.noundo(),
CommandSpec::new("texts", "Query Texts", run_texts)
.params("{contains?: text (any case), layer?, window?: [[x,y],[x,y]] (by insertion point), limit? (default 200), offset?} → {count (all matches), returned, offset, texts: [{handle, type, layer, text, at, height, rotation (degrees), style, tag? (attributes)}]}: the readable text of TEXT, MTEXT (formatting removed), block attributes and multileaders")
.noundo(),
CommandSpec::new("countlist", "Count List", count::run_countlist)
.params("{space?: all (default)|current|model|<layout>, nested?: bool} → per-block counts, as COUNT")
.noundo(),
@@ -348,6 +351,78 @@ fn run_entities(s: &mut Session, p: &Value) -> Result<Value> {
Ok(json!({ "count": matches.len(), "returned": page.len(), "offset": offset, "entities": page }))
}
/// TEXTS: the readable text of the space's texts, multiline texts, block attributes and
/// multileaders, with where they are, filtered by content, layer and a window around their
/// insertion points. Read-only (FIND selects and can replace; this only reports).
fn run_texts(s: &mut Session, p: &Value) -> Result<Value> {
const CMD: &str = "texts";
let given = |k: &str| p.get(k).filter(|v| !v.is_null());
let text = |k: &str| -> Result<Option<String>> {
given(k).map(|v| v.as_str().map(str::to_lowercase).ok_or_else(|| bad(CMD, format!("`{k}` must be a string")))).transpose()
};
let count = |k: &str, default: usize| -> Result<usize> {
given(k).map_or(Ok(default), |v| {
v.as_u64().map(|n| usize::try_from(n).unwrap_or(usize::MAX)).ok_or_else(|| bad(CMD, format!("`{k}` must be a whole number, 0 or more")))
})
};
let contains = text("contains")?;
let layer = text("layer")?;
let window = given("window")
.map(|w| {
let corners: Option<Vec<Vec2>> = w.as_array().filter(|a| a.len() == 2).and_then(|a| a.iter().map(point_value).collect());
match corners.as_deref() {
Some([a, b]) => Ok(cadcraft_geom::Bounds2::new(*a, *b)),
_ => Err(bad(CMD, "`window` must be two corner points [[x0, y0], [x1, y1]]")),
}
})
.transpose()?;
let limit = count("limit", 200)?;
let offset = count("offset", 0)?;
let st = s.state()?;
let Some(store) = st.doc.space(&st.space) else { return Ok(json!({ "count": 0, "returned": 0, "offset": offset, "texts": [] })) };
let mut found: Vec<Value> = Vec::new();
for e in store.iter() {
if layer.as_ref().is_some_and(|l| e.common.layer.to_lowercase() != *l) {
continue;
}
let mut push = |ty: &str, value: String, at: cadcraft_geom::Vec3, height: f64, rotation: f64, style: &str, tag: Option<&str>| {
let value = value.trim().to_string();
if value.is_empty()
|| contains.as_ref().is_some_and(|c| !value.to_lowercase().contains(c.as_str()))
|| window.as_ref().is_some_and(|w| !w.contains(Vec2::new(at.x, at.y)))
{
return;
}
let mut v = json!({
"handle": e.handle.hex(), "type": ty, "layer": e.common.layer, "text": value,
"at": [at.x, at.y], "height": height, "rotation": rotation.to_degrees(), "style": style,
});
if let (Some(t), Some(o)) = (tag, v.as_object_mut()) {
o.insert("tag".into(), json!(t));
}
found.push(v);
};
match &e.kind {
EntityKind::Text(t) => push("Text", t.value.clone(), t.insert, t.height, t.rotation, &t.style, None),
EntityKind::MText(t) => push("MText", cadcraft_fonts::plain_mtext(&t.contents), t.insert, t.height, t.rotation, &t.style, None),
EntityKind::Insert(i) => {
for a in i.attribs.iter().filter(|a| !a.invisible) {
push("Attribute", a.text.value.clone(), a.text.insert, a.text.height, a.text.rotation, &a.text.style, Some(&a.tag));
}
}
EntityKind::MLeader(m) => {
if let Some(t) = &m.text {
push("MLeader", cadcraft_fonts::plain_mtext(&t.contents), t.insert, t.height, t.rotation, &t.style, None);
}
}
_ => {}
}
}
let n = found.len();
let page: Vec<Value> = found.into_iter().skip(offset).take(limit).collect();
Ok(json!({ "count": n, "returned": page.len(), "offset": offset, "texts": page }))
}
mod count;
#[derive(Default)]
+21 -2
View File
@@ -108,6 +108,19 @@ impl Remote {
}
}
/// The `window` of a `ui.render` request: two finite corners [[x0, y0], [x1, y1]] around an area
/// (`None` when absent or unusable).
fn render_window(p: &Value) -> Option<cadcraft_engine::geom::Bounds2> {
let a = p.get("window")?.as_array().filter(|a| a.len() == 2)?;
let pt = |v: &Value| -> Option<cadcraft_engine::geom::Vec2> {
let c = v.as_array().filter(|c| c.len() == 2)?;
let (x, y) = (c[0].as_f64()?, c[1].as_f64()?);
(x.is_finite() && y.is_finite()).then(|| cadcraft_engine::geom::Vec2::new(x, y))
};
let b = cadcraft_engine::geom::Bounds2::new(pt(&a[0])?, pt(&a[1])?);
(b.width() > 0.0 && b.height() > 0.0).then_some(b)
}
/// The error for an app that doesn't answer: what to do about it.
fn not_open(addr: &str, e: std::io::Error) -> std::io::Error {
std::io::Error::new(
@@ -292,14 +305,20 @@ impl Backend for Headless {
let w = p.get("width").and_then(Value::as_f64).unwrap_or(1600.0).clamp(16.0, 8192.0) as u32;
let h = p.get("height").and_then(Value::as_f64).unwrap_or(1000.0).clamp(16.0, 8192.0) as u32;
let st = s.state().map_err(|e| e.to_string())?;
let list = cadcraft_render::build(&st.doc, &st.space, &cadcraft_render::Options::default());
let window = render_window(&p);
let opts = cadcraft_render::Options { view: window, ..Default::default() };
let list = cadcraft_render::build(&st.doc, &st.space, &opts);
let fit = p.get("fit").and_then(Value::as_bool).unwrap_or(true);
let view = if fit {
let side = |k: &str| p.get(k).and_then(Value::as_f64).map(|x| x as u32);
let view = if let Some(win) = &window {
cadcraft_render::raster::View::of_window(win, side("width"), side("height"))
} else if fit {
cadcraft_render::raster::View::fit(&list.bounds, w, h, 0.05)
} else {
let v = st.view();
cadcraft_render::raster::View { center: v.center, scale: f64::from(h) / v.height.max(1e-12), width: w, height: h }
};
let (w, h) = (view.width, view.height);
let png =
cadcraft_render::raster::render_png(&list, &view, &cadcraft_render::raster::RasterOptions::default()).ok_or("render failed")?;
if let Some(path) = str_p("path") {
+39
View File
@@ -140,6 +140,45 @@ fn list_commands_filter_matches_names_not_docs() {
assert!(r.as_array().unwrap().iter().any(|c| c["id"] == "pline"), "aliases and ids match");
}
#[test]
fn find_text_reads_texts_and_render_shows_one_area() {
let mut s = Server::new(Box::new(Headless::default()));
call(&mut s, 1, "initialize", json!({"protocolVersion": "2025-06-18"}));
tool(&mut s, "execute", json!({"command": "layer.new", "params": {"name": "Texto"}}));
tool(&mut s, "execute", json!({"command": "text", "params": {"at": [1, 1], "height": 0.15, "text": "RECAMARA 1"}}));
tool(&mut s, "execute", json!({"command": "text", "params": {"at": [50, 1], "height": 0.15, "text": "COCINA"}}));
tool(
&mut s,
"execute",
json!({"command": "mtext", "params": {"at": [1, 5], "height": 0.2, "width": 5, "text": "{\\fArial|b1;PLANTA} BAJA\\PNIVEL 0.00"}}),
);
tool(&mut s, "execute", json!({"command": "rectang", "params": {"p1": [0, 0], "p2": [10, 5]}}));
let all = tool(&mut s, "find_text", json!({}));
assert_eq!(all["count"], 3);
let r = tool(&mut s, "find_text", json!({"contains": "recamara"}));
assert_eq!(r["count"], 1);
assert_eq!(r["texts"][0]["text"], "RECAMARA 1");
assert_eq!(r["texts"][0]["at"], json!([1.0, 1.0]));
assert_eq!(r["texts"][0]["height"], 0.15);
// Multiline text comes back readable, without its formatting codes.
let m = tool(&mut s, "find_text", json!({"contains": "planta"}));
let t = m["texts"][0]["text"].as_str().unwrap();
assert!(t.contains("PLANTA BAJA") && t.contains("NIVEL 0.00") && !t.contains('\\') && !t.contains('{'), "{t}");
// By area: only what is inside the window.
let w = tool(&mut s, "find_text", json!({"window": [[-1, -1], [20, 20]]}));
assert_eq!(w["count"], 2);
assert_eq!(tool(&mut s, "find_text", json!({"layer": "nope"}))["count"], 0);
// A render of one area takes the area's shape.
let r = call(&mut s, 9, "tools/call", json!({"name": "render", "arguments": {"window": [[0, 0], [10, 5]]}}));
let c = &r["result"]["content"][0];
assert_eq!(c["type"], "image", "{r}");
let png = cadcraft_engine::cmd::file::base64_decode(c["data"].as_str().unwrap()).unwrap();
let (w, h) = (u32::from_be_bytes(png[16..20].try_into().unwrap()), u32::from_be_bytes(png[20..24].try_into().unwrap()));
assert_eq!((w, h), (1600, 800));
}
#[test]
fn a_lazy_bridge_starts_without_the_app_and_says_how_to_open_it() {
// Nothing listens on a port that was just freed.
+9 -2
View File
@@ -20,8 +20,14 @@ pub fn tool_definitions(has_ui: bool) -> Value {
"inputSchema": {"type": "object", "properties": {"type": {"type": "string"}, "layer": {"type": "string"},
"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 (fitted to extents by default) and return it as an image.",
"inputSchema": {"type": "object", "properties": {"width": {"type": "integer"}, "height": {"type": "integer"}, "fit": {"type": "boolean"}, "path": {"type": "string"}}}}),
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.",
"inputSchema": {"type": "object", "properties": {"width": {"type": "integer", "minimum": 16}, "height": {"type": "integer", "minimum": 16}, "fit": {"type": "boolean"},
"window": {"type": "array", "items": {"type": "array", "items": {"type": "number"}, "minItems": 2, "maxItems": 2}, "minItems": 2, "maxItems": 2},
"path": {"type": "string"}}}}),
json!({"name": "find_text", "description": "Read the drawing's texts: TEXT, MTEXT (formatting removed), block attributes and multileaders, each with its text, insertion point `at`, height, rotation, style, layer and handle. Filter by `contains` (any case), `layer` and/or `window` [[x0,y0],[x1,y1]] around the insertion points; page with offset and limit (default 200). Use it to find room names, sheet titles and notes without reading all the geometry.",
"inputSchema": {"type": "object", "properties": {"contains": {"type": "string"}, "layer": {"type": "string"},
"window": {"type": "array", "items": {"type": "array", "items": {"type": "number"}, "minItems": 2, "maxItems": 2}, "minItems": 2, "maxItems": 2},
"limit": {"type": "integer", "minimum": 0}, "offset": {"type": "integer", "minimum": 0}}}}),
json!({"name": "new_drawing", "description": "Create a new drawing (imperial by default, or metric).",
"inputSchema": {"type": "object", "properties": {"metric": {"type": "boolean"}}}}),
json!({"name": "open", "description": "Open a DXF file.", "inputSchema": {"type": "object", "properties": {"path": {"type": "string"}}, "required": ["path"]}}),
@@ -119,6 +125,7 @@ pub fn call_tool(b: &mut dyn Backend, name: &str, args: &Value) -> Result<Value,
}),
"inspect_drawing" => b.call("drawing.inspect", a),
"query_entities" => b.call("engine.execute", json!({"command": "entities", "params": a})),
"find_text" => b.call("engine.execute", json!({"command": "texts", "params": a})),
"new_drawing" => b.call("engine.execute", json!({"command": "new", "params": a})),
"open" => b.call("app.open", a),
"save" => b.call("app.save", a),
+16
View File
@@ -31,6 +31,22 @@ impl View {
let s = (f64::from(width) / w).min(f64::from(height) / h) * (1.0 - margin);
View { center: b.center(), scale: if s.is_finite() && s > 0.0 { s } else { 1.0 }, width, height }
}
/// The view of `window` (drawing units), filling the image: `width` × `height` pixels when both
/// are given; otherwise the side given (default 1600 wide) with the other matching the window's
/// shape, each side 16..=8192.
pub fn of_window(window: &Bounds2, width: Option<u32>, height: Option<u32>) -> View {
let aspect = if window.width() > 1e-12 && window.height() > 1e-12 { window.width() / window.height() } else { 1.0 };
let side = |v: f64| v.round().clamp(16.0, 8192.0) as u32;
let (w, h) = match (width, height) {
(Some(w), Some(h)) => (side(f64::from(w)), side(f64::from(h))),
(None, Some(h)) => (side(f64::from(h) * aspect), side(f64::from(h))),
(w, None) => {
let w = f64::from(w.unwrap_or(1600));
(side(w), side(w / aspect))
}
};
View::fit(window, w, h, 0.0)
}
pub fn world_bounds(&self) -> Bounds2 {
let hw = f64::from(self.width) / 2.0 / self.scale;
let hh = f64::from(self.height) / 2.0 / self.scale;
+22 -2
View File
@@ -470,10 +470,17 @@ pub fn handle(app: &mut CadApp, ctx: &egui::Context, req: &ControlRequest) -> Ou
let w = f("width").unwrap_or(1600.0).clamp(16.0, 8192.0) as u32;
let h = f("height").unwrap_or(1000.0).clamp(16.0, 8192.0) as u32;
let Ok(st) = app.session.state() else { return err("no drawing") };
let list = cadcraft_render::build(&st.doc, &st.space, &cadcraft_render::Options::default());
// `window`: just that area, at the size given or matching its shape.
let window = render_window(p);
let opts = cadcraft_render::Options { view: window, ..Default::default() };
let list = cadcraft_render::build(&st.doc, &st.space, &opts);
let v = st.view();
let fit = p.get("fit").and_then(Value::as_bool).unwrap_or(true);
let view = render_view(&list.bounds, v.center, v.height, fit, w, h);
let view = match &window {
Some(win) => cadcraft_render::raster::View::of_window(win, f("width").map(|x| x as u32), f("height").map(|x| x as u32)),
None => render_view(&list.bounds, v.center, v.height, fit, w, h),
};
let (w, h) = (view.width, view.height);
let Some(png) = cadcraft_render::raster::render_png(&list, &view, &cadcraft_render::raster::RasterOptions::default()) else {
return err("render failed");
};
@@ -504,6 +511,19 @@ pub fn handle(app: &mut CadApp, ctx: &egui::Context, req: &ControlRequest) -> Ou
}
}
/// The `window` of a `ui.render` request: two finite corners [[x0, y0], [x1, y1]] around an area
/// (`None` when absent or unusable).
pub fn render_window(p: &Value) -> Option<cadcraft_geom::Bounds2> {
let a = p.get("window")?.as_array().filter(|a| a.len() == 2)?;
let pt = |v: &Value| -> Option<cadcraft_geom::Vec2> {
let c = v.as_array().filter(|c| c.len() == 2)?;
let (x, y) = (c[0].as_f64()?, c[1].as_f64()?);
(x.is_finite() && y.is_finite()).then(|| cadcraft_geom::Vec2::new(x, y))
};
let b = cadcraft_geom::Bounds2::new(pt(&a[0])?, pt(&a[1])?);
(b.width() > 0.0 && b.height() > 0.0).then_some(b)
}
/// The raster view for `ui.render`: fitted to the drawing's bounds, or the current screen view.
fn render_view(bounds: &cadcraft_geom::Bounds2, center: Vec2, height: f64, fit: bool, w: u32, h: u32) -> cadcraft_render::raster::View {
if fit {
+1
View File
@@ -1214,3 +1214,4 @@ menu Modify Modifikace
Hatch Pattern Vzor šrafy
Search for pattern Hledat vzor
Not a number or distance: {text} Není číslo ani vzdálenost: {text}
Query Texts Dotaz na texty
1 # CADCraft Czech interface catalog — original translations by @Losminek (AI-assisted).
1214
1215
1216
1217
+1
View File
@@ -1025,3 +1025,4 @@
Hatch Pattern Schraffurmuster
Search for pattern Muster suchen
Not a number or distance: {text} Keine Zahl und keine Entfernung: {text}
Query Texts Texte abfragen
1 # CADCraft German interface catalog — original translations by the CADCraft contributors.
1025
1026
1027
1028
+1
View File
@@ -1240,3 +1240,4 @@
Hatch Pattern Patrón de sombreado
Search for pattern Buscar patrón
Not a number or distance: {text} No es un número ni una distancia: {text}
Query Texts Consultar textos
1 # CADCraft Spanish interface catalog — original translations.
1240
1241
1242
1243
+1
View File
@@ -1004,3 +1004,4 @@
Hatch Pattern Motif de hachure
Search for pattern Rechercher un motif
Not a number or distance: {text} Ni un nombre ni une distance : {text}
Query Texts Interroger les textes
1 # CADCraft French interface catalog — original translations by @pechnikov2026-star.
1004
1005
1006
1007
+1
View File
@@ -1239,5 +1239,6 @@
Left device button: zoom extents. Right device button: calibrate. Botão esquerdo do dispositivo: zoom à extensão. Botão direito do dispositivo: calibrar.
Hatch Pattern Padrão de hachura
Search for pattern Pesquisar padrão
Query Texts Consultar textos
Run Script... Executar script...
Not a number or distance: {text} Não é um número nem uma distância: {text}
1 # CADCraft Brazilian Portuguese (pt-BR) interface catalog.
1239
1240
1241
1242
1243
1244
+1
View File
@@ -1005,3 +1005,4 @@
Search for pattern Поиск образца
Sheet Books to PDF... Книги листов в PDF...
Not a number or distance: {text} Не число и не расстояние: {text}
Query Texts Запрос текстов
1 # CADCraft Russian interface catalog — original translations by @pechnikov2026-star.
1005
1006
1007
1008
+1
View File
@@ -1246,6 +1246,7 @@
Hatch Pattern Зразок штрихування
Search for pattern Пошук зразка
Run Script... Виконати сценарій...
Query Texts Запит текстів
Hatch Pick Вибір для штрихування
# Hatch and Gradient dialog
Hatch and Gradient Штрихування та градієнт
1 # CADCraft Ukrainian interface catalog — original translations by @dmatviichuk.
1246
1247
1248
1249
1250
1251 # Hatch and Gradient dialog
1252
+2 -1
View File
@@ -42,7 +42,8 @@ Example client configuration (Claude Code / Claude Desktop):
| `list_commands {filter?}` | The command catalog; `filter` matches part of a command's id, label or an alias. |
| `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). |
| `render {width?, height?, fit?}` | A PNG image of the drawing. |
| `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. |
| `screenshot`, `ui_inspect`, `ui_click {x, y}` | Only when connected to the app. |