From 017cc9b1fcc1ee50df965036340e1cc6bb1c6356 Mon Sep 17 00:00:00 2001 From: Zinadin Zidan Date: Thu, 5 Nov 2026 11:20:52 +0600 Subject: [PATCH 1/9] fix(mcp): render takes a background and a plot style (#803) The MCP render tool and ui.render, in the app and headless, always drew on the dark canvas with no plot style, so a PNG placed on a page needed the SVG export instead. They now take `background` ("dark", the default; "white", "paper" or "#rrggbb") and `plot_style` (a built-in plot style table such as monochrome.ctb or grayscale.ctb, or one the drawing holds). Colour 7 is drawn black on a light background and white on a dark one, as on a plot. An unknown value is refused with what is accepted. Both copies of ui.render read the options through one function in the engine (render_look). The render tool's description also says it returns an image block, not text. Co-Authored-By: Claude Opus 5.5 --- Cargo.lock | 1 + crates/engine/src/lib.rs | 1 + crates/engine/src/render_look.rs | 129 +++++++++++++++++++++++++++++++ crates/mcp/Cargo.toml | 4 + crates/mcp/src/backend.rs | 14 +++- crates/mcp/src/tests.rs | 107 +++++++++++++++++++++++++ crates/mcp/src/tools.rs | 4 +- crates/ui-egui/src/control.rs | 73 +++++++++++++++-- docs/control-protocol.md | 2 +- docs/mcp.md | 2 +- 10 files changed, 325 insertions(+), 12 deletions(-) create mode 100644 crates/engine/src/render_look.rs diff --git a/Cargo.lock b/Cargo.lock index c08b8f4bd..ac733f45d 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -776,6 +776,7 @@ dependencies = [ "getrandom 0.4.3", "log", "serde_json", + "tiny-skia", ] [[package]] diff --git a/crates/engine/src/lib.rs b/crates/engine/src/lib.rs index ccb93ef4f..2e02831dd 100644 --- a/crates/engine/src/lib.rs +++ b/crates/engine/src/lib.rs @@ -27,6 +27,7 @@ pub mod mtext_edit; pub mod pointmod; pub mod prompt; mod recent; +pub mod render_look; pub mod sample; pub mod select; pub mod settings_dir; diff --git a/crates/engine/src/render_look.rs b/crates/engine/src/render_look.rs new file mode 100644 index 000000000..531807953 --- /dev/null +++ b/crates/engine/src/render_look.rs @@ -0,0 +1,129 @@ +//! How a render request draws: its `background` and `plot_style` (#803). The app's control +//! channel and the headless MCP backend both answer `ui.render`, and both read these here. +//! +//! Colour 7 (white/black) follows the background, as on a plot: the rasteriser draws it white on +//! a dark background and black on a light one ([`cadcraft_color::display_rgb`]). + +use std::sync::Arc; + +use cadcraft_color::Rgb; +use cadcraft_doc::{Drawing, PlotStyleTable}; +use cadcraft_render::raster::RasterOptions; +use serde_json::Value; + +/// The background a render draws on without `background`: the dark canvas. +pub const DARK: Rgb = Rgb(33, 40, 48); +/// The background of `"white"` and `"paper"`. +pub const WHITE: Rgb = Rgb(255, 255, 255); + +/// What `background` accepts, for the error message and the docs. +pub const BACKGROUNDS: &str = "\"dark\" (the default), \"white\", \"paper\" or \"#rrggbb\""; + +/// Longest part of a refused value repeated in its error message. +const ECHO: usize = 40; + +/// The look of a render: what it draws on and with which plot style table. +#[derive(Clone, Debug)] +pub struct RenderLook { + pub background: Rgb, + /// `None` = draw with the objects' own properties (or, in a layout showing plot styles, its + /// page setup's table). + pub plot_style_table: Option>, +} + +impl Default for RenderLook { + fn default() -> Self { + RenderLook { background: DARK, plot_style_table: None } + } +} + +impl RenderLook { + /// `background` and `plot_style` of a request's params for drawing `d`; absent or `null` + /// keeps the default. `Err` says what is accepted. + pub fn from_params(d: &Drawing, p: &Value) -> Result { + let text = |key: &str| match p.get(key) { + None | Some(Value::Null) => Ok(None), + Some(Value::String(s)) => Ok(Some(s.as_str())), + Some(_) => Err(format!("`{key}` must be a string")), + }; + let mut look = RenderLook::default(); + if let Some(b) = text("background")? { + look.background = background(b).ok_or_else(|| format!("unknown background `{}`: use {BACKGROUNDS}", echo(b)))?; + } + if let Some(name) = text("plot_style")? { + look.plot_style_table = plot_style(d, name)?.map(Arc::new); + } + Ok(look) + } + + /// The raster options of a render with this look. + pub fn raster(&self) -> RasterOptions { + RasterOptions { background: self.background, ..RasterOptions::default() } + } +} + +/// The colour of a `background` value (any case): `dark`, `white`, `paper` or `#rrggbb`. +pub fn background(v: &str) -> Option { + let v = v.trim(); + match v.to_ascii_lowercase().as_str() { + "dark" => Some(DARK), + "white" | "paper" => Some(WHITE), + hex if hex.starts_with('#') => Rgb::parse_hex(hex), + _ => None, + } +} + +/// The plot style table `name` names: one the drawing holds, or a built-in one (`.ctb` may be +/// left out); `none` or an empty name is no table. `Err` lists the tables there are. +fn plot_style(d: &Drawing, name: &str) -> Result, String> { + if cadcraft_doc::no_plot_style_table(name) { + return Ok(None); + } + match cadcraft_doc::plot_style_table(d, name.trim()) { + Some(t) => Ok(Some(t)), + None => Err(format!("unknown plot style table `{}`: use one of {}", echo(name), cadcraft_doc::plot_style_table_names(d).join(", "))), + } +} + +/// The start of a refused value, to repeat in its error message. +fn echo(v: &str) -> String { + v.chars().take(ECHO).collect() +} + +#[cfg(test)] +mod tests { + use super::*; + use serde_json::json; + + #[test] + fn backgrounds() { + assert_eq!(background("dark"), Some(DARK)); + assert_eq!(DARK, RasterOptions::default().background, "the default is the rasteriser's canvas"); + assert_eq!(background(" Paper "), Some(WHITE)); + assert_eq!(background("WHITE"), Some(WHITE)); + assert_eq!(background("#336699"), Some(Rgb(0x33, 0x66, 0x99))); + for bad in ["", "purple", "336699", "#12345", "#1234567", "##336699", "#33669g"] { + assert_eq!(background(bad), None, "{bad}"); + } + } + + #[test] + fn from_params() { + let d = Drawing::new_imperial(); + let look = RenderLook::from_params(&d, &json!({})).unwrap(); + assert_eq!(look.background, DARK); + assert!(look.plot_style_table.is_none()); + let look = RenderLook::from_params(&d, &json!({"background": null, "plot_style": "none"})).unwrap(); + assert_eq!(look.background, DARK); + assert!(look.plot_style_table.is_none()); + let look = RenderLook::from_params(&d, &json!({"background": "white", "plot_style": "Monochrome"})).unwrap(); + assert_eq!(look.raster().background, WHITE); + assert_eq!(look.plot_style_table.map(|t| t.name.clone()).as_deref(), Some("monochrome.ctb")); + let e = RenderLook::from_params(&d, &json!({"plot_style": "nope.ctb"})).unwrap_err(); + assert!(e.contains("nope.ctb") && e.contains("grayscale.ctb"), "{e}"); + let e = RenderLook::from_params(&d, &json!({"background": "x".repeat(10_000)})).unwrap_err(); + assert!(e.len() < 200 && e.contains("#rrggbb"), "{e}"); + assert!(RenderLook::from_params(&d, &json!({"background": 1})).is_err()); + assert!(RenderLook::from_params(&d, &json!({"plot_style": {}})).is_err()); + } +} diff --git a/crates/mcp/Cargo.toml b/crates/mcp/Cargo.toml index 1359fbdda..dc5d8ca2a 100644 --- a/crates/mcp/Cargo.toml +++ b/crates/mcp/Cargo.toml @@ -17,3 +17,7 @@ serde_json = { workspace = true } log = { workspace = true } # Secure randomness for the per-session control token (MIT OR Apache-2.0). getrandom = "0.4" + +[dev-dependencies] +# Reads the pixels of rendered PNGs in the tests. +tiny-skia = { workspace = true } diff --git a/crates/mcp/src/backend.rs b/crates/mcp/src/backend.rs index 913569e82..d1ac6727d 100644 --- a/crates/mcp/src/backend.rs +++ b/crates/mcp/src/backend.rs @@ -300,7 +300,7 @@ const HEADLESS_METHODS: &[(&str, &str, &str)] = &[ ("engine.execute", "{command, params}", "Run any command with JSON parameters. Never opens a dialog."), ("engine.commands", "", "Every command: id, label, menu path, shortcut, aliases, params doc, enabled."), ("drawing.inspect", "{entities?, limit?}", "Drawing summary and entities with handles and geometry."), - ("ui.render", "{path?, width?, height?, fit?}", "Render the drawing; replies pngBase64."), + ("ui.render", "{path?, width?, height?, fit?, window?, background?, plot_style?}", "Render the drawing; replies pngBase64."), ("app.open", "{path}", "Open a drawing."), ("app.save", "{path?}", "Save the drawing (Save As with a path)."), ("control.methods", "", "This list."), @@ -400,7 +400,14 @@ impl Backend for Headless { 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 window = render_window(&p); - let opts = cadcraft_render::Options { view: window, transparency: s.settings.transparency_display, ..Default::default() }; + // `background` and `plot_style` (#803), read as the app reads them. + let look = cadcraft_engine::render_look::RenderLook::from_params(&st.doc, &p)?; + let opts = cadcraft_render::Options { + view: window, + transparency: s.settings.transparency_display, + plot_style_table: look.plot_style_table.clone(), + ..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 side = |k: &str| p.get(k).and_then(Value::as_f64).map(|x| x as u32); @@ -413,8 +420,7 @@ impl Backend for Headless { 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")?; + let png = cadcraft_render::raster::render_png(&list, &view, &look.raster()).ok_or("render failed")?; let mut r = json!({"pngBase64": cadcraft_engine::cmd::file::base64_encode(&png), "width": w, "height": h, "truncated": list.truncated}); if let Some(path) = str_p("path") { diff --git a/crates/mcp/src/tests.rs b/crates/mcp/src/tests.rs index 9a437b076..aec224baf 100644 --- a/crates/mcp/src/tests.rs +++ b/crates/mcp/src/tests.rs @@ -259,3 +259,110 @@ fn a_lazy_bridge_starts_without_the_app_and_says_how_to_open_it() { assert_ne!(r["result"]["isError"], json!(true), "{r}"); app.join().unwrap(); } + +/// The pixels of a `render` tool result. +fn render_pixels(s: &mut Server, args: Value) -> tiny_skia::Pixmap { + let img = tool(s, "render", args); + assert_eq!(img["type"], "image", "{img}"); + let png = cadcraft_engine::cmd::file::base64_decode(img["data"].as_str().unwrap()).unwrap(); + tiny_skia::Pixmap::decode_png(&png).unwrap() +} + +/// The RGB of pixel (x, y). +fn rgb(pm: &tiny_skia::Pixmap, x: u32, y: u32) -> (u8, u8, u8) { + let c = pm.pixel(x, y).unwrap(); + (c.red(), c.green(), c.blue()) +} + +/// The pixel of the middle column that differs most from the corner: what the line along the +/// middle of the image is drawn with. +fn ink(pm: &tiny_skia::Pixmap) -> (u8, u8, u8) { + let bg = rgb(pm, 0, 0); + let far = |c: &(u8, u8, u8)| bg.0.abs_diff(c.0) as u32 + bg.1.abs_diff(c.1) as u32 + bg.2.abs_diff(c.2) as u32; + (0..pm.height()).map(|y| rgb(pm, pm.width() / 2, y)).max_by_key(far).unwrap() +} + +/// A drawing with one line along y = 0: colour 7 (layer 0's colour), or red on a red layer. +fn line_drawing(red: bool) -> Server { + let mut s = Server::new(Box::new(Headless::default())); + if red { + tool(&mut s, "execute", json!({"command": "layer.new", "params": {"name": "Red", "color": "red", "current": true}})); + } + tool(&mut s, "execute", json!({"command": "line", "params": {"points": [[0, 0], [10, 0]]}})); + s +} + +/// Around the line, which runs along the middle of pixel row 20 of a 120 x 40 image (not on a +/// pixel edge, so it isn't blended across two rows). +const WINDOW: [[f64; 2]; 2] = [[-1.0, -1.95], [11.0, 2.05]]; + +/// Guards #803: `render` draws on the background asked for (`white`, `paper`, `#rrggbb`) and a +/// colour-7 line follows it, as on a plot: dark on white, light on the dark canvas, which stays +/// the default. +#[test] +fn render_draws_on_the_background_asked_for() { + let mut s = line_drawing(false); + let dark = render_pixels(&mut s, json!({"window": WINDOW, "width": 120, "height": 40})); + assert_eq!(rgb(&dark, 0, 0), (33, 40, 48), "the default stays the dark canvas"); + let (r, g, b) = ink(&dark); + assert!(r > 200 && g > 200 && b > 200, "colour 7 is light on the dark canvas: {:?}", (r, g, b)); + for bg in ["white", "paper", "WHITE"] { + let pm = render_pixels(&mut s, json!({"window": WINDOW, "width": 120, "height": 40, "background": bg})); + let (w, h) = (pm.width(), pm.height()); + for (x, y) in [(0, 0), (w - 1, 0), (0, h - 1), (w - 1, h - 1)] { + assert_eq!(rgb(&pm, x, y), (255, 255, 255), "{bg}: corner ({x}, {y})"); + } + let (r, g, b) = ink(&pm); + assert!(r < 128 && g < 128 && b < 128, "{bg}: colour 7 is drawn dark on white: {:?}", (r, g, b)); + } + let pm = render_pixels(&mut s, json!({"window": WINDOW, "width": 120, "height": 40, "background": "#336699"})); + assert_eq!(rgb(&pm, 0, 0), (0x33, 0x66, 0x99)); + let pm = render_pixels(&mut s, json!({"window": WINDOW, "width": 120, "height": 40, "background": "dark"})); + assert_eq!(rgb(&pm, 0, 0), (33, 40, 48)); +} + +/// Guards #803: `plot_style` draws with a built-in plot style table: `monochrome.ctb` turns a red +/// line black, which without one stays red. +#[test] +fn render_draws_with_the_plot_style_asked_for() { + let mut s = line_drawing(true); + let plain = render_pixels(&mut s, json!({"window": WINDOW, "width": 120, "height": 40, "background": "white"})); + let (r, g, b) = ink(&plain); + assert!(r > 200 && g < 100 && b < 100, "red without a plot style: {:?}", (r, g, b)); + for table in ["monochrome.ctb", "Monochrome", "monochrome.CTB"] { + let pm = render_pixels(&mut s, json!({"window": WINDOW, "width": 120, "height": 40, "background": "white", "plot_style": table})); + let (r, g, b) = ink(&pm); + assert!(r < 100 && r == g && g == b, "{table}: red plots black: {:?}", (r, g, b)); + } + let pm = render_pixels(&mut s, json!({"window": WINDOW, "width": 120, "height": 40, "background": "white", "plot_style": "grayscale.ctb"})); + let (r, g, b) = ink(&pm); + assert!(r == g && g == b, "grayscale.ctb draws red grey: {:?}", (r, g, b)); +} + +/// Guards #803: an unknown `background` or `plot_style` is refused with what is accepted, not +/// drawn some other way; a value of the wrong type is invalid params. +#[test] +fn render_refuses_unknown_backgrounds_and_plot_styles() { + let mut s = line_drawing(false); + for (args, wants) in [ + (json!({"background": "purple"}), "#rrggbb"), + (json!({"background": "#12345"}), "#rrggbb"), + (json!({"background": "336699"}), "#rrggbb"), + (json!({"background": ""}), "#rrggbb"), + (json!({"plot_style": "nope.ctb"}), "monochrome.ctb"), + (json!({"plot_style": "default.ctx"}), "monochrome.ctb"), + ] { + let r = call(&mut s, 1, "tools/call", json!({"name": "render", "arguments": args.clone()})); + assert_eq!(r["result"]["isError"], json!(true), "{args} must be refused, not drawn"); + let msg = r["result"]["content"][0]["text"].as_str().unwrap(); + assert!(msg.contains(wants), "{args}: {msg}"); + } + for args in [json!({"background": 7}), json!({"plot_style": ["monochrome.ctb"]})] { + let r = call(&mut s, 2, "tools/call", json!({"name": "render", "arguments": args.clone()})); + assert_eq!(r["error"]["code"], -32602, "{args} must be invalid params"); + } + // The headless control method refuses them too. + let mut h = Headless::default(); + assert!(crate::Backend::call(&mut h, "ui.render", json!({"background": "purple"})).is_err()); + assert!(crate::Backend::call(&mut h, "ui.render", json!({"plot_style": 3})).is_err()); +} diff --git a/crates/mcp/src/tools.rs b/crates/mcp/src/tools.rs index 2c432d608..c6560218b 100644 --- a/crates/mcp/src/tools.rs +++ b/crates/mcp/src/tools.rs @@ -22,9 +22,11 @@ pub fn tool_definitions(has_ui: bool) -> Value { "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.", + json!({"name": "render", "description": "Render the active drawing to a PNG and return it as an image content block (content[0].type is \"image\", not text): 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. `background`: \"dark\" (default, the canvas), \"white\", \"paper\" or \"#rrggbb\"; colour 7 is drawn white on a dark background and black on a light one, as on a plot. `plot_style`: a plot style table, e.g. \"monochrome.ctb\" (every colour black, as most plots) or \"grayscale.ctb\"; with a white background it makes light colours readable. An unknown background or plot style is refused.", "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}, + "background": {"type": "string", "description": "\"dark\" (default), \"white\", \"paper\" or \"#rrggbb\""}, + "plot_style": {"type": "string", "description": "a plot style table: monochrome.ctb, grayscale.ctb, default.ctb, default.stb, monochrome.stb, or one the drawing holds"}, "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"}, diff --git a/crates/ui-egui/src/control.rs b/crates/ui-egui/src/control.rs index 9c806a96f..ffa1b7d1a 100644 --- a/crates/ui-egui/src/control.rs +++ b/crates/ui-egui/src/control.rs @@ -15,8 +15,10 @@ //! - `ui.click {x, y, shift?, ctrl?}`, `ui.move {x, y}`: real egui pointer input in screen points //! - `ui.key {key, cmd?, shift?, alt?}`, `ui.text {text}`: synthetic keyboard input //! - `ui.set {...UiState fields}`, `ui.resize {width, height}` -//! - `ui.screenshot {path?}`: PNG of the window; `ui.render {path?, width?, height?, fit?}`: headless -//! render of the drawing (no window needed; fits the drawing unless `fit` is false; replies `pngBase64`) +//! - `ui.screenshot {path?}`: PNG of the window; `ui.render {path?, width?, height?, fit?, window?, +//! background?, plot_style?}`: headless render of the drawing (no window needed; fits the drawing +//! unless `fit` is false or a `window` is given; on the dark canvas unless `background` is +//! `white`, `paper` or `#rrggbb`; `plot_style` names a plot style table; replies `pngBase64`) //! - `app.open {path}`, `app.save {path?}`, `app.quit` //! - `control.methods`: this list, with params and a description; `ping`: replies `"pong"` @@ -211,7 +213,7 @@ const METHODS: &[(&str, &str, &str)] = &[ ("ui.resize", "{width, height}", "Resize the window."), ("ui.focus", "", "Activate the native window."), ("ui.screenshot", "{path?}", "PNG of the window (needs a presented frame)."), - ("ui.render", "{path?, width?, height?, fit?}", "Headless render of the drawing; replies pngBase64."), + ("ui.render", "{path?, width?, height?, fit?, window?, background?, plot_style?}", "Headless render of the drawing; replies pngBase64."), ("app.open", "{path}", "Open a drawing."), ("app.save", "{path?}", "Save the drawing (Save As with a path)."), ("app.quit", "", "Quit without the unsaved-changes prompt."), @@ -475,7 +477,17 @@ pub fn handle(app: &mut CadApp, ctx: &egui::Context, req: &ControlRequest) -> Ou let Ok(st) = app.session.state() else { return err("no drawing") }; // `window`: just that area, at the size given or matching its shape. let window = render_window(p); - let opts = cadcraft_render::Options { view: window, transparency: app.session.settings.transparency_display, ..Default::default() }; + // `background` and `plot_style` (#803), read as the headless MCP backend reads them. + let look = match cadcraft_engine::render_look::RenderLook::from_params(&st.doc, p) { + Ok(look) => look, + Err(e) => return err(e), + }; + let opts = cadcraft_render::Options { + view: window, + transparency: app.session.settings.transparency_display, + plot_style_table: look.plot_style_table.clone(), + ..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); @@ -484,7 +496,7 @@ pub fn handle(app: &mut CadApp, ctx: &egui::Context, req: &ControlRequest) -> Ou 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 { + let Some(png) = cadcraft_render::raster::render_png(&list, &view, &look.raster()) else { return err("render failed"); }; #[cfg(not(target_arch = "wasm32"))] @@ -834,4 +846,55 @@ mod tests { assert!(render_reply(b"abc", 7, 5, None, true).get("path").is_none()); assert_eq!(render_reply(b"abc", 7, 5, None, true)["truncated"], true); } + + /// The reply to a `ui.render` request in an app showing one colour-7 line along y = 0. + fn render_line(params: Value) -> Value { + let mut app = CadApp::new(cadcraft_engine::Session::empty(), crate::Services::default()); + app.session.new_drawing(false); + app.session.execute("line", &json!({"points": [[0, 0], [10, 0]]})).unwrap(); + let (req, _rx) = ControlRequest::new("ui.render", params); + match handle(&mut app, &egui::Context::default(), &req) { + Outcome::Done(v) => v, + Outcome::Screenshot { .. } => panic!("ui.render must not take a screenshot"), + } + } + + /// The RGB of a reply's corner pixel and of its middle column's darkest pixel. + fn corner_and_darkest(reply: &Value) -> ([u8; 3], [u8; 3]) { + let png = cadcraft_engine::cmd::file::base64_decode(reply["result"]["pngBase64"].as_str().unwrap_or_default()).unwrap(); + let img = image::load_from_memory(&png).unwrap().to_rgb8(); + let corner = img.get_pixel(0, 0).0; + let darkest = (0..img.height()).map(|y| img.get_pixel(img.width() / 2, y).0).min_by_key(|p| p.iter().map(|&c| u32::from(c)).sum::()); + (corner, darkest.unwrap_or_default()) + } + + /// Guards #803 in the app: `ui.render` takes `background` and `plot_style` as the headless MCP + /// backend does (colour 7 drawn dark on white), keeps the dark canvas by default and refuses + /// unknown values. + #[test] + fn render_takes_a_background_and_a_plot_style() { + // The line runs along the middle of pixel row 20 of the 120 x 40 image. + let window = json!([[-1.0, -1.95], [11.0, 2.05]]); + let sized = |mut p: Value| { + p["window"] = window.clone(); + p["width"] = json!(120); + p["height"] = json!(40); + p + }; + let r = render_line(sized(json!({}))); + assert_eq!(r["ok"], true, "{}", r["error"]); + assert_eq!(corner_and_darkest(&r).0, [33, 40, 48], "the default stays the dark canvas"); + for p in [json!({"background": "white"}), json!({"background": "paper", "plot_style": "monochrome.ctb"})] { + let r = render_line(sized(p.clone())); + assert_eq!(r["ok"], true, "{p}: {}", r["error"]); + let (corner, ink) = corner_and_darkest(&r); + assert_eq!(corner, [255, 255, 255], "{p}"); + assert!(ink.iter().all(|&c| c < 128), "{p}: colour 7 is drawn dark on white: {ink:?}"); + } + for p in [json!({"background": "purple"}), json!({"background": 7}), json!({"plot_style": "nope.ctb"})] { + let r = render_line(p.clone()); + assert_eq!(r["ok"], false, "{p} must be refused"); + assert!(r.get("result").is_none(), "{p}"); + } + } } diff --git a/docs/control-protocol.md b/docs/control-protocol.md index d10d43abb..8b6927e62 100644 --- a/docs/control-protocol.md +++ b/docs/control-protocol.md @@ -61,7 +61,7 @@ field. `engine.commands` returns a separate command catalog, not a list of addit | `ui.resize` | `{width, height}` | Resize the window. | | `ui.focus` | | Activate the native window. Does not assign focus to a widget. | | `ui.screenshot` | `{path?}` | PNG of the window (needs a presented frame). Bounded: at most 4 queued/in-flight/completed requests, 64 MiB per image and 64 MiB total (completed but undrained images still count), an 8 s deadline, and cancellation when the document or keyboard focus changes while the request is outstanding. A queued request adopts the settled window size when issued. After issue, a change in window size, scale, OS window focus, minimization or occlusion cancels it. Every reply carries `request`; failures include `ok:false,error`. Files are written only after the deadline and identity checks pass. Without `path`, each request gets a unique PNG in a private owner-only per-process directory under the temp dir (created exclusively; an existing symlink is never followed), so concurrent app instances cannot collide; the caller owns those files and should remove them when done. | -| `ui.render` | `{path?, width?, height?, fit?}` | Headless render. `fit` (default true) frames the whole drawing; `false` uses the current view. Replies `{pngBase64, width, height, bytes, path?}`. | +| `ui.render` | `{path?, width?, height?, fit?, window?, background?, plot_style?}` | Headless render. `fit` (default true) frames the whole drawing; `false` uses the current view; `window` `[[x0, y0], [x1, y1]]` shows just that area (drawing units). `background`: `"dark"` (default, the canvas), `"white"`, `"paper"` or `"#rrggbb"`; colour 7 is drawn white on a dark background and black on a light one, as on a plot. `plot_style`: a plot style table, built-in (`monochrome.ctb` prints every colour black, `grayscale.ctb` grey; `default.ctb`, `default.stb`, `monochrome.stb`) or one the drawing holds. An unknown `background` or `plot_style` is refused. Replies `{pngBase64, width, height, bytes, path?}`. | | `app.open` / `app.save` / `app.quit` | `{path}` | File operations. | | `control.methods` | | Every method this server answers: `{method, params, description}`. Headless (`cadcraft-cli mcp`) lists only the methods it serves. | | `ping` | | Replies `"pong"`. A cheap health check. | diff --git a/docs/mcp.md b/docs/mcp.md index ecdc53d09..dcb63e7db 100644 --- a/docs/mcp.md +++ b/docs/mcp.md @@ -47,7 +47,7 @@ Example client configuration (Claude Code / Claude Desktop): | `execute {"command": "usage", "params": {type?, limit?, offset?}}` | Read-only cross-reference of the layers, blocks, text styles and linetypes: for each, the objects in model space and layouts that use it, the objects in block definitions that use it, whether it is used only inside block definitions, and whether it is empty or unused. `summary` counts each kind and `unused` lists what PURGE would offer, in table order. One scan reads at most 5,000,000 objects; a larger drawing sets `incomplete: true` with an `incompleteReason`, because counts, statuses and summaries then omit the objects past the limit. | | `execute {"command": "loops", "params": {tolerance?, maxPairs?, limit?, offset?}}` | Implied closed loops from loose LINE and ARC chains: each closed loop with its member handles, perimeter, area and winding, exact with arcs; each open chain with the endpoint gap, its location (`at`, `ends`) and length; and branch junctions (three or more ends meeting), reported but not resolved. `tolerance` defaults to 1e-9 × the extents diagonal; the quadratic endpoint pairing stops at `maxPairs` (default 1,000,000) and reports `capped` with `pairTestsNeeded` instead of guessing from a partial pairing. | | `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). | +| `render {width?, height?, fit?, window?, background?, plot_style?}` | 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). `background`: `"dark"` (default, the canvas), `"white"`, `"paper"` or `"#rrggbb"`; colour 7 is drawn white on a dark background and black on a light one, as on a plot. `plot_style`: a plot style table, e.g. `monochrome.ctb` (every colour black) or `grayscale.ctb`. An unknown value is refused. The result is an image content block (`content[0].type` is `"image"`), not text. | | `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. | From 04a279cff08e32406e8300ffde1c60174a9eb119 Mon Sep 17 00:00:00 2001 From: Zinadin Zidan Date: Thu, 5 Nov 2026 12:09:31 +0600 Subject: [PATCH 2/9] fix(mcp): a render with a plot style plots on white; "none" turns plot styles off (#803) A plot style alone drew black lines on the dark canvas: the tables that change colours print colour 7 (and, in monochrome.ctb, every colour) black. With `plot_style` and no `background`, the render now draws on white, as a plot prints on paper; a given background still wins. `plot_style: "none"` (or "") draws every object with its own properties, also in a layout that shows its own plot styles. An unknown table's error lists the built-in tables and at most ten of the drawing's own. The app's ui.render test now draws a red line, so it fails if the plot style isn't passed on. Co-Authored-By: Claude Opus 5.5 --- crates/engine/src/render_look.rs | 91 +++++++++++++++++++++++++++----- crates/mcp/src/tests.rs | 25 +++++++++ crates/mcp/src/tools.rs | 6 +-- crates/ui-egui/src/control.rs | 47 +++++++++++------ docs/control-protocol.md | 2 +- docs/mcp.md | 2 +- 6 files changed, 139 insertions(+), 34 deletions(-) diff --git a/crates/engine/src/render_look.rs b/crates/engine/src/render_look.rs index 531807953..ba4dbc8f4 100644 --- a/crates/engine/src/render_look.rs +++ b/crates/engine/src/render_look.rs @@ -3,6 +3,14 @@ //! //! Colour 7 (white/black) follows the background, as on a plot: the rasteriser draws it white on //! a dark background and black on a light one ([`cadcraft_color::display_rgb`]). +//! +//! A render with a plot style table draws on white unless `background` says otherwise, as a plot +//! prints on paper: the tables that change colours print colour 7 (and, in `monochrome.ctb`, +//! every colour) black, which can hardly be seen on the dark canvas. +//! +//! `plot_style: "none"` (or `""`) draws every object with its own properties, also in a layout +//! whose page setup has "Display plot styles" on; without `plot_style` such a layout draws with +//! its own table, as on screen. use std::sync::Arc; @@ -17,7 +25,11 @@ pub const DARK: Rgb = Rgb(33, 40, 48); pub const WHITE: Rgb = Rgb(255, 255, 255); /// What `background` accepts, for the error message and the docs. -pub const BACKGROUNDS: &str = "\"dark\" (the default), \"white\", \"paper\" or \"#rrggbb\""; +pub const BACKGROUNDS: &str = "\"dark\" (the default without a plot style), \"white\" (the default with one), \"paper\" or \"#rrggbb\""; + +/// Most table names an unknown `plot_style` error lists from the drawing's own tables (the +/// built-in ones are always listed). +const LISTED_TABLES: usize = 10; /// Longest part of a refused value repeated in its error message. const ECHO: usize = 40; @@ -27,7 +39,8 @@ const ECHO: usize = 40; pub struct RenderLook { pub background: Rgb, /// `None` = draw with the objects' own properties (or, in a layout showing plot styles, its - /// page setup's table). + /// page setup's table). `plot_style: "none"` is a table that keeps every property, so such a + /// layout's table doesn't apply either. pub plot_style_table: Option>, } @@ -47,12 +60,19 @@ impl RenderLook { Some(_) => Err(format!("`{key}` must be a string")), }; let mut look = RenderLook::default(); + if let Some(name) = text("plot_style")? { + look.plot_style_table = Some(Arc::new(match plot_style(d, name)? { + Some(t) => { + // Plotted on paper, unless `background` says otherwise. + look.background = WHITE; + t + } + None => object_properties(), + })); + } if let Some(b) = text("background")? { look.background = background(b).ok_or_else(|| format!("unknown background `{}`: use {BACKGROUNDS}", echo(b)))?; } - if let Some(name) = text("plot_style")? { - look.plot_style_table = plot_style(d, name)?.map(Arc::new); - } Ok(look) } @@ -79,10 +99,30 @@ fn plot_style(d: &Drawing, name: &str) -> Result, String> if cadcraft_doc::no_plot_style_table(name) { return Ok(None); } - match cadcraft_doc::plot_style_table(d, name.trim()) { - Some(t) => Ok(Some(t)), - None => Err(format!("unknown plot style table `{}`: use one of {}", echo(name), cadcraft_doc::plot_style_table_names(d).join(", "))), + cadcraft_doc::plot_style_table(d, name.trim()) + .map(Some) + .ok_or_else(|| format!("unknown plot style table `{}`: use none or one of {}", echo(name), table_list(d))) +} + +/// The tables a drawing can use, for an error message: the built-in ones, then at most +/// [`LISTED_TABLES`] of the drawing's own and how many more there are. +fn table_list(d: &Drawing) -> String { + let builtin = |n: &String| cadcraft_doc::BUILTIN_PLOT_STYLE_TABLES.iter().any(|b| b.eq_ignore_ascii_case(n)); + let names = cadcraft_doc::plot_style_table_names(d); + let own: Vec = names.iter().filter(|n| !builtin(n)).map(|n| echo(n)).collect(); + let mut list: Vec = cadcraft_doc::BUILTIN_PLOT_STYLE_TABLES.iter().map(|b| (*b).to_string()).collect(); + list.extend(own.iter().take(LISTED_TABLES).cloned()); + let mut text = list.join(", "); + if own.len() > LISTED_TABLES { + text.push_str(&format!(" and {} more of the drawing's", own.len() - LISTED_TABLES)); } + text +} + +/// A table that prints every object with its own properties: what `plot_style: "none"` draws +/// with, so a layout's own table doesn't apply either. +fn object_properties() -> PlotStyleTable { + PlotStyleTable::color_dependent("None", "Object properties", |_| None) } /// The start of a refused value, to repeat in its error message. @@ -113,17 +153,44 @@ mod tests { let look = RenderLook::from_params(&d, &json!({})).unwrap(); assert_eq!(look.background, DARK); assert!(look.plot_style_table.is_none()); - let look = RenderLook::from_params(&d, &json!({"background": null, "plot_style": "none"})).unwrap(); - assert_eq!(look.background, DARK); - assert!(look.plot_style_table.is_none()); + // "none" keeps the dark canvas and draws every object with its own properties, also in a + // layout that shows its own plot styles. + for none in ["none", "", " None "] { + let look = RenderLook::from_params(&d, &json!({"background": null, "plot_style": none})).unwrap(); + assert_eq!(look.background, DARK, "{none:?}"); + let t = look.plot_style_table.unwrap(); + assert!(t.is_color_dependent() && t.styles.iter().all(|s| s.keeps_color() && s.lineweight.is_none()), "{none:?}"); + } let look = RenderLook::from_params(&d, &json!({"background": "white", "plot_style": "Monochrome"})).unwrap(); assert_eq!(look.raster().background, WHITE); assert_eq!(look.plot_style_table.map(|t| t.name.clone()).as_deref(), Some("monochrome.ctb")); + // A plot style plots on paper unless `background` says otherwise. + for table in ["monochrome.ctb", "grayscale.ctb", "default.stb"] { + assert_eq!(RenderLook::from_params(&d, &json!({"plot_style": table})).unwrap().background, WHITE, "{table}"); + let look = RenderLook::from_params(&d, &json!({"plot_style": table, "background": "dark"})).unwrap(); + assert_eq!(look.background, DARK, "{table}"); + } let e = RenderLook::from_params(&d, &json!({"plot_style": "nope.ctb"})).unwrap_err(); - assert!(e.contains("nope.ctb") && e.contains("grayscale.ctb"), "{e}"); + assert!(e.contains("nope.ctb") && e.contains("grayscale.ctb") && e.contains("none"), "{e}"); let e = RenderLook::from_params(&d, &json!({"background": "x".repeat(10_000)})).unwrap_err(); assert!(e.len() < 200 && e.contains("#rrggbb"), "{e}"); assert!(RenderLook::from_params(&d, &json!({"background": 1})).is_err()); assert!(RenderLook::from_params(&d, &json!({"plot_style": {}})).is_err()); } + + /// An unknown plot style lists the built-in tables and only some of the drawing's own. + #[test] + fn unknown_plot_style_lists_a_few_tables() { + let mut d = Drawing::new_imperial(); + for i in 0..500 { + d.plot_style_tables.push(PlotStyleTable::color_dependent(&format!("{}{i}.ctb", "own".repeat(30)), "", |_| None)); + } + let e = RenderLook::from_params(&d, &json!({"plot_style": "nope.ctb"})).unwrap_err(); + for b in cadcraft_doc::BUILTIN_PLOT_STYLE_TABLES { + assert!(e.contains(b), "{b}: {e}"); + } + assert!(e.contains("and 490 more"), "{e}"); + assert!(e.len() < 1000, "{} bytes: {e}", e.len()); + assert!(RenderLook::from_params(&d, &json!({"plot_style": d.plot_style_tables[7].name.clone()})).is_ok()); + } } diff --git a/crates/mcp/src/tests.rs b/crates/mcp/src/tests.rs index aec224baf..b7eee854e 100644 --- a/crates/mcp/src/tests.rs +++ b/crates/mcp/src/tests.rs @@ -339,6 +339,31 @@ fn render_draws_with_the_plot_style_asked_for() { assert!(r == g && g == b, "grayscale.ctb draws red grey: {:?}", (r, g, b)); } +/// Guards #803: a plot style alone plots on white, as on paper, so `monochrome.ctb` doesn't draw +/// black on the dark canvas; `background` still wins, and `plot_style: "none"` keeps the canvas +/// and the objects' colours. +#[test] +fn render_with_a_plot_style_plots_on_white() { + for red in [false, true] { + let mut s = line_drawing(red); + for table in ["monochrome.ctb", "grayscale.ctb"] { + let pm = render_pixels(&mut s, json!({"window": WINDOW, "width": 120, "height": 40, "plot_style": table})); + assert_eq!(rgb(&pm, 0, 0), (255, 255, 255), "{table} without a background plots on white"); + let (r, g, b) = ink(&pm); + assert!(r == g && g == b && r < 128, "{table}, red {red}: drawn dark grey or black on white: {:?}", (r, g, b)); + } + let pm = render_pixels(&mut s, json!({"window": WINDOW, "width": 120, "height": 40, "plot_style": "monochrome.ctb", "background": "dark"})); + assert_eq!(rgb(&pm, 0, 0), (33, 40, 48), "an explicit background wins"); + for none in ["none", ""] { + let pm = render_pixels(&mut s, json!({"window": WINDOW, "width": 120, "height": 40, "plot_style": none})); + assert_eq!(rgb(&pm, 0, 0), (33, 40, 48), "{none:?} keeps the dark canvas"); + let (r, g, b) = ink(&pm); + let light = r > 200 && g > 200 && b > 200; + assert!(if red { r > 200 && g < 100 && b < 100 } else { light }, "{none:?}, red {red}: own colour {:?}", (r, g, b)); + } + } +} + /// Guards #803: an unknown `background` or `plot_style` is refused with what is accepted, not /// drawn some other way; a value of the wrong type is invalid params. #[test] diff --git a/crates/mcp/src/tools.rs b/crates/mcp/src/tools.rs index c6560218b..8518a9aff 100644 --- a/crates/mcp/src/tools.rs +++ b/crates/mcp/src/tools.rs @@ -22,11 +22,11 @@ pub fn tool_definitions(has_ui: bool) -> Value { "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 content block (content[0].type is \"image\", not text): 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. `background`: \"dark\" (default, the canvas), \"white\", \"paper\" or \"#rrggbb\"; colour 7 is drawn white on a dark background and black on a light one, as on a plot. `plot_style`: a plot style table, e.g. \"monochrome.ctb\" (every colour black, as most plots) or \"grayscale.ctb\"; with a white background it makes light colours readable. An unknown background or plot style is refused.", + json!({"name": "render", "description": "Render the active drawing to a PNG and return it as an image content block (content[0].type is \"image\", not text): 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. `background`: \"dark\" (the canvas; the default without a plot style), \"white\" (the default with one, as a plot prints on paper), \"paper\" or \"#rrggbb\"; colour 7 is drawn white on a dark background and black on a light one, as on a plot. `plot_style`: a plot style table, e.g. \"monochrome.ctb\" (every colour black, as most plots) or \"grayscale.ctb\", which make light colours readable on white; \"none\" (or \"\") draws every object with its own properties, also in a layout that shows its own plot styles (without `plot_style` such a layout draws with its table, as on screen). An unknown background or plot style is refused.", "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}, - "background": {"type": "string", "description": "\"dark\" (default), \"white\", \"paper\" or \"#rrggbb\""}, - "plot_style": {"type": "string", "description": "a plot style table: monochrome.ctb, grayscale.ctb, default.ctb, default.stb, monochrome.stb, or one the drawing holds"}, + "background": {"type": "string", "description": "\"dark\" (default without plot_style), \"white\" (default with one), \"paper\" or \"#rrggbb\""}, + "plot_style": {"type": "string", "description": "a plot style table: monochrome.ctb, grayscale.ctb, default.ctb, default.stb, monochrome.stb, or one the drawing holds; \"none\" or \"\" for the objects' own properties"}, "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"}, diff --git a/crates/ui-egui/src/control.rs b/crates/ui-egui/src/control.rs index ffa1b7d1a..2637c26f8 100644 --- a/crates/ui-egui/src/control.rs +++ b/crates/ui-egui/src/control.rs @@ -18,7 +18,8 @@ //! - `ui.screenshot {path?}`: PNG of the window; `ui.render {path?, width?, height?, fit?, window?, //! background?, plot_style?}`: headless render of the drawing (no window needed; fits the drawing //! unless `fit` is false or a `window` is given; on the dark canvas unless `background` is -//! `white`, `paper` or `#rrggbb`; `plot_style` names a plot style table; replies `pngBase64`) +//! `white`, `paper` or `#rrggbb`; `plot_style` names a plot style table, which plots on white +//! unless `background` is given, or is `none`; replies `pngBase64`) //! - `app.open {path}`, `app.save {path?}`, `app.quit` //! - `control.methods`: this list, with params and a description; `ping`: replies `"pong"` @@ -847,10 +848,14 @@ mod tests { assert_eq!(render_reply(b"abc", 7, 5, None, true)["truncated"], true); } - /// The reply to a `ui.render` request in an app showing one colour-7 line along y = 0. - fn render_line(params: Value) -> Value { + /// The reply to a `ui.render` request in an app showing one line along y = 0: colour 7, or + /// red on a red layer. + fn render_line(red: bool, params: Value) -> Value { let mut app = CadApp::new(cadcraft_engine::Session::empty(), crate::Services::default()); app.session.new_drawing(false); + if red { + app.session.execute("layer.new", &json!({"name": "Red", "color": "red", "current": true})).unwrap(); + } app.session.execute("line", &json!({"points": [[0, 0], [10, 0]]})).unwrap(); let (req, _rx) = ControlRequest::new("ui.render", params); match handle(&mut app, &egui::Context::default(), &req) { @@ -869,30 +874,38 @@ mod tests { } /// Guards #803 in the app: `ui.render` takes `background` and `plot_style` as the headless MCP - /// backend does (colour 7 drawn dark on white), keeps the dark canvas by default and refuses - /// unknown values. + /// backend does (colour 7 drawn dark on white, a red line black with `monochrome.ctb`, white + /// by default with a plot style), keeps the dark canvas by default and refuses unknown values. #[test] fn render_takes_a_background_and_a_plot_style() { // The line runs along the middle of pixel row 20 of the 120 x 40 image. let window = json!([[-1.0, -1.95], [11.0, 2.05]]); - let sized = |mut p: Value| { + let render = |red: bool, mut p: Value| { p["window"] = window.clone(); p["width"] = json!(120); p["height"] = json!(40); - p - }; - let r = render_line(sized(json!({}))); - assert_eq!(r["ok"], true, "{}", r["error"]); - assert_eq!(corner_and_darkest(&r).0, [33, 40, 48], "the default stays the dark canvas"); - for p in [json!({"background": "white"}), json!({"background": "paper", "plot_style": "monochrome.ctb"})] { - let r = render_line(sized(p.clone())); + let r = render_line(red, p.clone()); assert_eq!(r["ok"], true, "{p}: {}", r["error"]); - let (corner, ink) = corner_and_darkest(&r); - assert_eq!(corner, [255, 255, 255], "{p}"); - assert!(ink.iter().all(|&c| c < 128), "{p}: colour 7 is drawn dark on white: {ink:?}"); + corner_and_darkest(&r) + }; + assert_eq!(render(false, json!({})).0, [33, 40, 48], "the default stays the dark canvas"); + let (corner, ink) = render(false, json!({"background": "white"})); + assert_eq!(corner, [255, 255, 255]); + assert!(ink.iter().all(|&c| c < 128), "colour 7 is drawn dark on white: {ink:?}"); + // A red line stays red without a plot style and plots black with monochrome.ctb, on white + // unless `background` says otherwise. + let (_, [r, g, b]) = render(true, json!({"background": "paper"})); + assert!(r > 200 && g < 100 && b < 100, "red without a plot style: {:?}", [r, g, b]); + for (p, bg) in [ + (json!({"plot_style": "monochrome.ctb"}), [255, 255, 255]), + (json!({"plot_style": "monochrome.ctb", "background": "#336699"}), [0x33, 0x66, 0x99]), + ] { + let (corner, [r, g, b]) = render(true, p.clone()); + assert_eq!(corner, bg, "{p}"); + assert!(r < 100 && r == g && g == b, "{p}: red plots black: {:?}", [r, g, b]); } for p in [json!({"background": "purple"}), json!({"background": 7}), json!({"plot_style": "nope.ctb"})] { - let r = render_line(p.clone()); + let r = render_line(false, p.clone()); assert_eq!(r["ok"], false, "{p} must be refused"); assert!(r.get("result").is_none(), "{p}"); } diff --git a/docs/control-protocol.md b/docs/control-protocol.md index 8b6927e62..f85731e37 100644 --- a/docs/control-protocol.md +++ b/docs/control-protocol.md @@ -61,7 +61,7 @@ field. `engine.commands` returns a separate command catalog, not a list of addit | `ui.resize` | `{width, height}` | Resize the window. | | `ui.focus` | | Activate the native window. Does not assign focus to a widget. | | `ui.screenshot` | `{path?}` | PNG of the window (needs a presented frame). Bounded: at most 4 queued/in-flight/completed requests, 64 MiB per image and 64 MiB total (completed but undrained images still count), an 8 s deadline, and cancellation when the document or keyboard focus changes while the request is outstanding. A queued request adopts the settled window size when issued. After issue, a change in window size, scale, OS window focus, minimization or occlusion cancels it. Every reply carries `request`; failures include `ok:false,error`. Files are written only after the deadline and identity checks pass. Without `path`, each request gets a unique PNG in a private owner-only per-process directory under the temp dir (created exclusively; an existing symlink is never followed), so concurrent app instances cannot collide; the caller owns those files and should remove them when done. | -| `ui.render` | `{path?, width?, height?, fit?, window?, background?, plot_style?}` | Headless render. `fit` (default true) frames the whole drawing; `false` uses the current view; `window` `[[x0, y0], [x1, y1]]` shows just that area (drawing units). `background`: `"dark"` (default, the canvas), `"white"`, `"paper"` or `"#rrggbb"`; colour 7 is drawn white on a dark background and black on a light one, as on a plot. `plot_style`: a plot style table, built-in (`monochrome.ctb` prints every colour black, `grayscale.ctb` grey; `default.ctb`, `default.stb`, `monochrome.stb`) or one the drawing holds. An unknown `background` or `plot_style` is refused. Replies `{pngBase64, width, height, bytes, path?}`. | +| `ui.render` | `{path?, width?, height?, fit?, window?, background?, plot_style?}` | Headless render. `fit` (default true) frames the whole drawing; `false` uses the current view; `window` `[[x0, y0], [x1, y1]]` shows just that area (drawing units). `background`: `"dark"` (the canvas; the default without a plot style), `"white"` (the default with one, as a plot prints on paper), `"paper"` or `"#rrggbb"`; colour 7 is drawn white on a dark background and black on a light one, as on a plot. `plot_style`: a plot style table, built-in (`monochrome.ctb` prints every colour black, `grayscale.ctb` grey; `default.ctb`, `default.stb`, `monochrome.stb`) or one the drawing holds; `"none"` (or `""`) draws every object with its own properties, also in a layout whose page setup shows plot styles (without `plot_style` such a layout draws with its table, as on screen). An unknown `background` or `plot_style` is refused. Replies `{pngBase64, width, height, bytes, path?}`. | | `app.open` / `app.save` / `app.quit` | `{path}` | File operations. | | `control.methods` | | Every method this server answers: `{method, params, description}`. Headless (`cadcraft-cli mcp`) lists only the methods it serves. | | `ping` | | Replies `"pong"`. A cheap health check. | diff --git a/docs/mcp.md b/docs/mcp.md index dcb63e7db..504ca2c15 100644 --- a/docs/mcp.md +++ b/docs/mcp.md @@ -47,7 +47,7 @@ Example client configuration (Claude Code / Claude Desktop): | `execute {"command": "usage", "params": {type?, limit?, offset?}}` | Read-only cross-reference of the layers, blocks, text styles and linetypes: for each, the objects in model space and layouts that use it, the objects in block definitions that use it, whether it is used only inside block definitions, and whether it is empty or unused. `summary` counts each kind and `unused` lists what PURGE would offer, in table order. One scan reads at most 5,000,000 objects; a larger drawing sets `incomplete: true` with an `incompleteReason`, because counts, statuses and summaries then omit the objects past the limit. | | `execute {"command": "loops", "params": {tolerance?, maxPairs?, limit?, offset?}}` | Implied closed loops from loose LINE and ARC chains: each closed loop with its member handles, perimeter, area and winding, exact with arcs; each open chain with the endpoint gap, its location (`at`, `ends`) and length; and branch junctions (three or more ends meeting), reported but not resolved. `tolerance` defaults to 1e-9 × the extents diagonal; the quadratic endpoint pairing stops at `maxPairs` (default 1,000,000) and reports `capped` with `pairTestsNeeded` instead of guessing from a partial pairing. | | `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?, background?, plot_style?}` | 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). `background`: `"dark"` (default, the canvas), `"white"`, `"paper"` or `"#rrggbb"`; colour 7 is drawn white on a dark background and black on a light one, as on a plot. `plot_style`: a plot style table, e.g. `monochrome.ctb` (every colour black) or `grayscale.ctb`. An unknown value is refused. The result is an image content block (`content[0].type` is `"image"`), not text. | +| `render {width?, height?, fit?, window?, background?, plot_style?}` | 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). `background`: `"dark"` (the canvas; the default without a plot style), `"white"` (the default with one, as a plot prints on paper), `"paper"` or `"#rrggbb"`; colour 7 is drawn white on a dark background and black on a light one, as on a plot. `plot_style`: a plot style table, e.g. `monochrome.ctb` (every colour black) or `grayscale.ctb`; `"none"` (or `""`) draws every object with its own properties, also in a layout that shows its own plot styles (without `plot_style` such a layout draws with its table, as on screen). An unknown value is refused. The result is an image content block (`content[0].type` is `"image"`), not text. | | `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. | From 4b47a04a09b8ecd7e1e71869630fd8e925f06006 Mon Sep 17 00:00:00 2001 From: Helen Kwok <107752876+helenkwok@users.noreply.github.com> Date: Mon, 26 Oct 2026 11:05:28 +1030 Subject: [PATCH 3/9] Contributors: credit helenkwok as Helen Kwok --- contributors/people.toml | 3 +++ 1 file changed, 3 insertions(+) diff --git a/contributors/people.toml b/contributors/people.toml index 8bb73b9ea..cee143efd 100644 --- a/contributors/people.toml +++ b/contributors/people.toml @@ -109,3 +109,6 @@ real_name = "Andrea Brandi" [people.qinshah] sync_github_name = true + +[people.helenkwok] +real_name = "Helen Kwok" From 1c3975523d1fa7dd685ab9ffe96ab99aaaed5d1d Mon Sep 17 00:00:00 2001 From: Helen Kwok <107752876+helenkwok@users.noreply.github.com> Date: Mon, 26 Oct 2026 11:05:28 +1030 Subject: [PATCH 4/9] feat(web): page host mode, so a page can host CADCraft in its own document (#2178) data-host="page" on the canvas opts a page that loads the module into its own document into the host bridge. The ?host=parent messages travel as non-bubbling CustomEvents on the canvas; the bridge holds a Transport (Parent or Page) with one send, and the open, save, dirty, ready and failure handling is shared. ?host=parent is checked first and unchanged, including no bridge for an unframed page. The hosting guide documents both modes. --- ROADMAP.md | 4 +- apps/cadcraft-web/Cargo.toml | 2 +- apps/cadcraft-web/src/host.rs | 186 ++++++++++++++++++++++++---------- apps/cadcraft-web/src/main.rs | 58 +++++++++-- packaging/web/README.md | 60 +++++++++++ 5 files changed, 246 insertions(+), 64 deletions(-) diff --git a/ROADMAP.md b/ROADMAP.md index 48bd33032..bf89343a6 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -2,7 +2,7 @@ **Stage: pre-alpha** · next: alpha, ~2 points (38% → 40% ready for real work) and ~125–190 Opus 5.5 hours away (the four failing core workflows of the [alpha gate](docs/roadmap.md#alpha-gate)) -> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-21 · **Change:** trivial (progress log: proxy objects keep their class through a save, #1812; previously minor: HATCHEDIT boundary add/remove progress) · **Target:** Autodesk AutoCAD 2027 +> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-26 · **Change:** minor (web: same-document host mode, `data-host="page"`; previously trivial: proxy objects keep their class through a save, #1812) · **Target:** Autodesk AutoCAD 2027 CADCraft targets full parity with AutoCAD (2D drafting first, then annotation, layouts and plotting, DWG, parametrics and 3D), plus what AutoCAD doesn't have: agent control over MCP, a @@ -119,6 +119,7 @@ Ranked; detail and the full beta checklist in [docs/roadmap.md](docs/roadmap.md) | Date | Entry | |---|---| +| 2026-10-26 | Web: a page can host CADCraft in its own document (no iframe) with `data-host="page"` on the canvas, talking over DOM `CustomEvent`s with the same messages as the `?host=parent` parent bridge, which is now documented in the hosting guide | | 2026-10-21 | DXF save keeps each proxy object's and proxy entity's class: its class number (group 91) is renumbered to the rewritten CLASSES instead of naming another class, so AutoCAD's visual styles in an R14 file no longer come back as SCALE or MLEADERSTYLE (#1812) | | 2026-10-14 | HATCHEDIT adds boundaries from internal points or closed objects and removes selected loops in the dialog, command line and JSON command; associative links and undo are covered by tests. | | 2026-10-12 | TRIM: a pick after a closed polyline's last cut keeps only the complementary run instead of an extra circuit of the outline (#726) | @@ -136,6 +137,7 @@ Ranked; detail and the full beta checklist in [docs/roadmap.md](docs/roadmap.md) | Date | Change | Summary | |---|---|---| +| 2026-10-26 | minor | Web: same-document host mode (`data-host="page"` on the canvas); the parent bridge documented in the hosting guide | | 2026-10-21 | trivial | Progress log: proxy objects keep their class through a save (#1812) | | 2026-10-14 | minor | Progress log: HATCHEDIT boundary add/remove | | 2026-10-12 | trivial | Progress log: closed-polyline TRIM (#726) | diff --git a/apps/cadcraft-web/Cargo.toml b/apps/cadcraft-web/Cargo.toml index cb9ddb81b..2ff055366 100644 --- a/apps/cadcraft-web/Cargo.toml +++ b/apps/cadcraft-web/Cargo.toml @@ -31,4 +31,4 @@ web-sys = { version = "0.3", features = ["HtmlInputElement", "FileList", "File"] eframe = { workspace = true, features = ["persistence", "wgpu", "accesskit"] } wasm-bindgen = "0.2" wasm-bindgen-futures = "0.4" -web-sys = { version = "0.3", features = ["Window", "Document", "Element", "HtmlElement", "HtmlCanvasElement", "HtmlAnchorElement", "Location", "Navigator", "Blob", "BlobPropertyBag", "Url", "MessageEvent", "UrlSearchParams", "MediaQueryList"] } +web-sys = { version = "0.3", features = ["Window", "Document", "Element", "HtmlElement", "HtmlCanvasElement", "HtmlAnchorElement", "Location", "Navigator", "Blob", "BlobPropertyBag", "Url", "MessageEvent", "UrlSearchParams", "MediaQueryList", "Event", "EventTarget", "CustomEvent", "CustomEventInit"] } diff --git a/apps/cadcraft-web/src/host.rs b/apps/cadcraft-web/src/host.rs index c8d6380c6..2d6c9e4cf 100644 --- a/apps/cadcraft-web/src/host.rs +++ b/apps/cadcraft-web/src/host.rs @@ -1,5 +1,10 @@ -//! The `postMessage` bridge to a same-origin parent page (`?host=parent`), for pages that embed -//! CADCraft in an `