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:
14 files changed
+191
-7
No files matched your search
@@ -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)]
|
||||
|
||||
@@ -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") {
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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
|
||||
|
@@ -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
|
||||
|
@@ -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
|
||||
|
@@ -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
|
||||
|
@@ -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}
|
||||
|
@@ -1005,3 +1005,4 @@
|
||||
Search for pattern Поиск образца
|
||||
Sheet Books to PDF... Книги листов в PDF...
|
||||
Not a number or distance: {text} Не число и не расстояние: {text}
|
||||
Query Texts Запрос текстов
|
||||
|
@@ -1246,6 +1246,7 @@
|
||||
Hatch Pattern Зразок штрихування
|
||||
Search for pattern Пошук зразка
|
||||
Run Script... Виконати сценарій...
|
||||
Query Texts Запит текстів
|
||||
Hatch Pick Вибір для штрихування
|
||||
# Hatch and Gradient dialog
|
||||
Hatch and Gradient Штрихування та градієнт
|
||||
|
||||
|
+2
-1
@@ -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. |
|
||||
|
||||
|
||||
Reference in new issue
Block a user