Merge remote-tracking branch 'origin/main' into land/1031

This commit is contained in:
Tanish Manku
2026-11-05 13:08:56 -07:00
16 changed files with 612 additions and 140 deletions
Generated
+11
View File
@@ -776,6 +776,7 @@ dependencies = [
"getrandom 0.4.3",
"log",
"serde_json",
"tiny-skia",
]
[[package]]
@@ -827,6 +828,7 @@ dependencies = [
"cadcraft-fonts",
"cadcraft-io",
"cadcraft-ui-egui",
"craft-web-host",
"eframe",
"egui",
"log",
@@ -1230,6 +1232,15 @@ dependencies = [
"zip",
]
[[package]]
name = "craft-web-host"
version = "0.1.0"
source = "git+https://github.com/storytold/craft-libs?rev=2e2312ddaf21bc3d85cd3395d0955c7d239306f6#2e2312ddaf21bc3d85cd3395d0955c7d239306f6"
dependencies = [
"wasm-bindgen",
"web-sys",
]
[[package]]
name = "craft-window"
version = "0.1.0"
+3 -1
View File
@@ -19,6 +19,8 @@ cadcraft-engine = { workspace = true }
cadcraft-ui-egui = { workspace = true }
cadcraft-io = { workspace = true }
cadcraft-fonts = { workspace = true }
# The host messages (storytold/craft-libs): one contract and one bridge for the web builds.
craft-web-host = { git = "https://github.com/storytold/craft-libs", rev = "2e2312ddaf21bc3d85cd3395d0955c7d239306f6", version = "0.1.0" }
egui = { workspace = true }
log = { workspace = true }
serde_json = { workspace = true }
@@ -31,4 +33,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"] }
+62 -102
View File
@@ -1,28 +1,37 @@
//! The `postMessage` bridge to a same-origin parent page (`?host=parent`), for pages that embed
//! CADCraft in an `<iframe>` (such as a Nextcloud app). Messages are plain objects with a `type`:
//! Host modes: a page that runs CADCraft opens drawings in it and stores what it saves instead
//! of the browser downloading. The messages and the two transports are `craft-web-host`'s
//! (storytold/craft-libs), the same contract as the other Craft apps' bridges, with the
//! `cadcraft:` prefix. This module is CADCraft's part of it.
//!
//! - `cadcraft:ready` (to the parent): `{ version }`, the app is listening; send a drawing now.
//! - `cadcraft:open` (from the parent): `{ name, bytes }`, bytes an `ArrayBuffer` or
//! `Uint8Array`. It opens like a file picked with File › Open or dropped on the window.
//! - `cadcraft:save` (to the parent): `{ name, bytes }` (a transferred `ArrayBuffer`) for every
//! file CADCraft would otherwise download (SAVE, SAVEAS, exports): it replaces the engine's
//! delivery hook. The parent stores the file and reports failures itself.
//! - `cadcraft:dirty` (to the parent): `{ dirty }` whenever unsaved changes appear or go away.
//! - `cadcraft:failed` (to the parent): `{ error }` when the app couldn't start.
//! - `?host=parent` (an `<iframe>`, such as a Nextcloud app) talks to the same-origin parent over
//! `postMessage`; `data-host="page"` on the canvas (the page's own document, no iframe) uses
//! DOM `CustomEvent`s on the canvas instead. `?host=parent` wins when both are present. With no
//! parent, or a parent on another origin, there is no bridge: the normal app with downloads.
//! - `cadcraft:ready` once the app is listening; send a drawing now. CADCraft reads bytes, so it
//! says `file: false` and a host sends `bytes`.
//! - `cadcraft:open` (from the host): `{ name, bytes }`. It opens like a file picked with
//! File › Open or dropped on the window; an oversized drawing is refused before it is copied.
//! - `cadcraft:save` (to the host): `{ name, saveAs, bytes }` for every file CADCraft would
//! otherwise download (SAVE, SAVEAS, exports): it replaces the engine's delivery hook. The host
//! stores the file and reports failures itself. CADCraft doesn't tell the host which saves
//! make a file the document, so `saveAs` is always `false`.
//! - `cadcraft:dirty` whenever unsaved changes appear or go away, `cadcraft:failed` when the app
//! couldn't start.
//!
//! Only the parent window on the page's own origin is heard or answered. In host mode the app
//! starts on the Start tab rather than an empty Drawing1, and the parent guards leaving the page.
//! In both host modes the app starts on the Start tab rather than an empty Drawing1, and the
//! host guards leaving the page.
use std::cell::{Cell, RefCell};
use std::cell::RefCell;
use std::rc::Rc;
use cadcraft_ui_egui::CadApp;
use wasm_bindgen::JsCast as _;
use wasm_bindgen::JsValue;
use wasm_bindgen::closure::Closure;
use web_sys::js_sys;
use craft_web_host::web::{FileData, Host, Message};
/// Longest file name taken from the parent page.
const MAX_NAME_CHARS: usize = 255;
/// The app id in the host messages (`cadcraft:ready`).
const APP: &str = "cadcraft";
/// The name a drawing gets when the host sent none (the crate says `document` for a blank name).
const DEFAULT_NAME: &str = "Drawing.dxf";
thread_local! {
/// The bridge [`deliver`] posts through (the engine's hook is a plain `fn`).
@@ -31,25 +40,15 @@ thread_local! {
#[derive(Clone)]
pub struct Bridge {
window: web_sys::Window,
parent: web_sys::Window,
origin: String,
host: Rc<Host>,
}
/// The unsaved-changes state last reported to the parent.
#[derive(Default)]
pub struct DirtyReport(Cell<Option<bool>>);
impl Bridge {
/// `None` when the page isn't framed (there is no parent to talk to).
pub fn new() -> Option<Self> {
let window = web_sys::window()?;
let parent = window.parent().ok().flatten()?;
if js_sys::Object::is(&parent, &window) {
return None;
}
let origin = window.location().origin().ok()?;
Some(Self { window, parent, origin })
/// The bridge for this page, or `None` when CADCraft isn't hosted: no `?host=parent` and no
/// `data-host="page"` on the canvas, an unframed `?host=parent` page (it stays the normal app
/// with downloads), or a parent on another origin.
pub fn connect(canvas: &web_sys::HtmlCanvasElement) -> Option<Self> {
Some(Self { host: Rc::new(Host::connect(APP, canvas)?) })
}
/// Make this the bridge [`deliver`] uses.
@@ -58,94 +57,55 @@ impl Bridge {
}
pub fn post_ready(&self) {
self.send(&[("type", "cadcraft:ready".into()), ("version", env!("CARGO_PKG_VERSION").into())]);
let _ = self.host.post_ready(env!("CARGO_PKG_VERSION"), false);
}
pub fn post_failed(&self, error: &str) {
self.send(&[("type", "cadcraft:failed".into()), ("error", error.into())]);
let _ = self.host.post_failed(error, None);
}
/// Hear drawings from the parent; they arrive through the upload inbox.
/// Hear drawings from the host; they arrive through the upload inbox.
pub fn listen(&self, inbox: crate::upload::Inbox) {
let parent = self.parent.clone();
let origin = self.origin.clone();
let on_message = Closure::<dyn FnMut(web_sys::MessageEvent)>::new(move |event: web_sys::MessageEvent| {
if event.origin() != origin || !event.source().is_some_and(|s| js_sys::Object::is(&s, &parent)) {
return;
self.host.listen(move |message| {
if let Message::Open { name, data } = message {
receive_open(&name, &data, &inbox);
}
let data = event.data();
if string(&data, "type").as_deref() != Some("cadcraft:open") {
return;
}
let name = string(&data, "name")
.filter(|n| !n.trim().is_empty())
.map(|n| n.chars().take(MAX_NAME_CHARS).collect())
.unwrap_or_else(|| "Drawing.dxf".to_string());
// Refuse an oversized drawing before copying it into the app's memory.
let Some(array) = byte_array(&data) else { return };
if let Some(e) = CadApp::open_size_error(&name, u64::from(array.length())) {
inbox.refuse(e);
return;
}
inbox.receive(name, array.to_vec());
});
if self.window.add_event_listener_with_callback("message", on_message.as_ref().unchecked_ref()).is_ok() {
// The listener lives as long as the page.
on_message.forget();
}
}
/// After each logic callback (including a hidden tab's): tell the parent when unsaved changes
/// appear or go away. `DirtyReport` suppresses duplicates, so this is idempotent.
pub fn report_dirty(&self, app: &CadApp, reported: &DirtyReport) {
let dirty = app.session.docs.iter().any(|d| d.is_dirty());
if reported.0.get() != Some(dirty) && self.send(&[("type", "cadcraft:dirty".into()), ("dirty", dirty.into())]) {
reported.0.set(Some(dirty));
}
/// After each logic callback (including a hidden tab's): tell the host when unsaved changes
/// appear or go away. The crate does not repeat a state it already reported, so this is
/// idempotent.
pub fn report_dirty(&self, app: &CadApp) {
self.host.post_dirty(app.session.docs.iter().any(|d| d.is_dirty()));
}
fn post_save(&self, name: &str, bytes: &[u8]) -> Result<(), String> {
let buffer = js_sys::Uint8Array::from(bytes).buffer();
let message = message(&[("type", "cadcraft:save".into()), ("name", name.into()), ("bytes", buffer.clone().into())])
.ok_or_else(|| "couldn't build the message".to_string())?;
self.parent
.post_message_with_transfer(&message, &self.origin, &js_sys::Array::of1(&buffer))
.map_err(|e| e.as_string().unwrap_or_else(|| format!("{e:?}")))
}
/// Post a message to the parent; false if it couldn't be sent.
fn send(&self, fields: &[(&str, JsValue)]) -> bool {
message(fields).is_some_and(|m| self.parent.post_message(&m, &self.origin).is_ok())
self.host.post_save(name, bytes, None, false)
}
}
/// The engine's delivery hook in host mode ([`cadcraft_engine::cmd::file::set_deliver`]): hand
/// the file to the parent page. `Ok` once it was posted; the engine then counts a drawing as saved.
/// the file to the host page. `Ok` once it was sent; the engine then counts a drawing as saved.
pub fn deliver(name: &str, _mime: &str, bytes: &[u8]) -> Result<(), String> {
let bridge = BRIDGE.with(|b| b.borrow().clone()).ok_or("the page that embeds CADCraft is gone")?;
bridge.post_save(name, bytes)
}
fn message(fields: &[(&str, JsValue)]) -> Option<js_sys::Object> {
let m = js_sys::Object::new();
for (key, value) in fields {
js_sys::Reflect::set(&m, &JsValue::from_str(key), value).ok()?;
}
Some(m)
}
fn string(data: &JsValue, key: &str) -> Option<String> {
js_sys::Reflect::get(data, &JsValue::from_str(key)).ok()?.as_string()
}
/// `bytes` (an `ArrayBuffer` or `Uint8Array`) as a view, not yet copied.
fn byte_array(data: &JsValue) -> Option<js_sys::Uint8Array> {
let bytes = js_sys::Reflect::get(data, &JsValue::from_str("bytes")).ok()?;
if let Some(array) = bytes.dyn_ref::<js_sys::Uint8Array>() {
Some(array.clone())
} else if bytes.is_instance_of::<js_sys::ArrayBuffer>() {
Some(js_sys::Uint8Array::new(&bytes))
} else {
None
/// The `cadcraft:open` payload, `{ name, bytes }` where `bytes` holds a drawing: it opens like a
/// file picked with File › Open or dropped on the window. The crate has cleaned the name (and cut
/// it to 255 characters); a blank one comes as `document` and becomes `Drawing.dxf`. An oversized
/// drawing is refused before it is copied.
fn receive_open(name: &str, data: &FileData, inbox: &crate::upload::Inbox) {
let name = if name == "document" { DEFAULT_NAME } else { name }.to_string();
let FileData::Bytes(array) = data else {
inbox.refuse(format!("Open: {name} came as a File; CADCraft reads bytes"));
return;
};
// Refuse an oversized drawing before copying it into the app's memory (the data's own length).
if let Some(e) = CadApp::open_size_error(&name, data.size() as u64) {
inbox.refuse(e);
return;
}
inbox.receive(name, array.to_vec());
}
+24 -24
View File
@@ -6,7 +6,8 @@
//! with egui shapes instead of the GPU canvas, `?sample` opens the sample drawing. Saving downloads
//! the drawing through the browser (`download.rs`). With `?host=parent` (inside an `<iframe>`), a
//! same-origin parent page sends the drawing to open and stores what CADCraft saves instead
//! (`host.rs`).
//! (`host.rs`); a page can also host CADCraft in its own document (no iframe) with
//! `data-host="page"` on the canvas, talking over DOM `CustomEvent`s on it (`host.rs`).
#![deny(clippy::unwrap_used, clippy::expect_used, clippy::panic, clippy::unimplemented, clippy::todo, clippy::unreachable)]
#[cfg(target_arch = "wasm32")]
@@ -27,16 +28,11 @@ mod web {
const LOADING_ID: &str = "cadcraft_loading";
/// The app, the files File > Open… and drag and drop read for it in the background, the
/// page embedding it (`?host=parent`), the browser's `prefers-reduced-motion` (it stays
/// host page (`?host=parent`, or `data-host="page"` on the canvas), the browser's
/// `prefers-reduced-motion` (it stays
/// live, so View ▸ Accessibility ▸ Animation Effects ▸ Use System Setting follows a change),
/// and the document title last written to the page.
struct Shell(
CadApp,
crate::upload::Inbox,
Option<(crate::host::Bridge, crate::host::DirtyReport)>,
Option<web_sys::MediaQueryList>,
Option<String>,
);
struct Shell(CadApp, crate::upload::Inbox, Option<crate::host::Bridge>, Option<web_sys::MediaQueryList>, Option<String>);
impl eframe::App for Shell {
/// Runs one frame: takes in files dropped on or picked for the page, drains the background
@@ -51,10 +47,10 @@ mod web {
self.0.logic(ctx);
let drawing = self.0.session.state().ok();
crate::push_title(&mut self.4, drawing.map(|d| d.title.as_str()), drawing.is_some_and(|d| d.is_dirty()), set_page_title);
// `DirtyReport` suppresses duplicates, so reporting after every logic callback
// The crate suppresses duplicates, so reporting after every logic callback
// (including a hidden tab's) is idempotent and keeps the parent's guard current.
if let Some((bridge, reported)) = &self.2 {
bridge.report_dirty(&self.0, reported);
if let Some(bridge) = &self.2 {
bridge.report_dirty(&self.0);
}
}
/// Forward raw input to the web `CadApp` and capture Tab at the command line.
@@ -100,8 +96,10 @@ mod web {
web_sys::window().and_then(|w| w.location().search().ok()).unwrap_or_default()
}
fn query_param(key: &str) -> Option<String> {
web_sys::UrlSearchParams::new_with_str(&query()).ok()?.get(key)
/// The app's canvas: the one element `index.html` and every embedding page must provide,
/// with the id the app looks up.
fn app_canvas() -> Option<web_sys::HtmlCanvasElement> {
web_sys::window()?.document()?.get_element_by_id(CANVAS_ID).and_then(|e| e.dyn_into::<web_sys::HtmlCanvasElement>().ok())
}
/// No file picker on the web: Save / Save As from the menu (and the close prompt) download the
@@ -128,10 +126,17 @@ mod web {
groups: cadcraft_io::entity_groups,
entity: cadcraft_io::entity_from_groups,
});
let Some(canvas) = app_canvas() else {
log::error!("missing <canvas id=\"{CANVAS_ID}\">");
return;
};
// `?host=parent`: a same-origin page framing CADCraft (such as a Nextcloud app) opens
// drawings in it and stores what it saves. Otherwise there is no file system here: saved
// files reach the user as browser downloads.
let bridge = if query_param("host").as_deref() == Some("parent") { crate::host::Bridge::new() } else { None };
// drawings in it and stores what it saves. Otherwise `data-host="page"` on the canvas
// opts in a page that runs CADCraft in its own document (no iframe) and talks over DOM
// `CustomEvent`s on the canvas (`host`). With neither, there is no file system here:
// saved files reach the user as browser downloads; an unframed `?host=parent` stays
// that normal app.
let bridge = crate::host::Bridge::connect(&canvas);
match &bridge {
Some(bridge) => {
bridge.install();
@@ -142,10 +147,6 @@ mod web {
let app_bridge = bridge.clone();
wasm_bindgen_futures::spawn_local(async move {
let Some(document) = web_sys::window().and_then(|w| w.document()) else { return };
let Some(canvas) = document.get_element_by_id(CANVAS_ID).and_then(|e| e.dyn_into::<web_sys::HtmlCanvasElement>().ok()) else {
log::error!("missing <canvas id=\"{CANVAS_ID}\">");
return;
};
let mut options = eframe::WebOptions::default();
if query().contains("webgl")
&& let eframe::egui_wgpu::WgpuSetup::CreateNew(create) = &mut options.wgpu_options.wgpu_setup
@@ -157,7 +158,7 @@ mod web {
canvas,
options,
Box::new(move |cc| {
// Embedded, the parent sends the drawing: start on the Start tab, not Drawing1.
// Embedded, the host sends the drawing: start on the Start tab, not Drawing1.
let session = if app_bridge.is_some() { Session::empty() } else { Session::new() };
let mut app = CadApp::new(session, services());
app.system_languages =
@@ -192,10 +193,9 @@ mod web {
picker.pick();
None
}));
let host = app_bridge.map(|bridge| {
let host = app_bridge.inspect(|bridge| {
bridge.listen(inbox.clone());
bridge.post_ready();
(bridge, crate::host::DirtyReport::default())
});
let reduced_motion = web_sys::window().and_then(|w| w.match_media("(prefers-reduced-motion: reduce)").ok().flatten());
Ok(Box::new(Shell(app, inbox, host, reduced_motion, None)))
+3
View File
@@ -109,3 +109,6 @@ real_name = "Andrea Brandi"
[people.qinshah]
sync_github_name = true
[people.helenkwok]
real_name = "Helen Kwok"
+1
View File
@@ -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;
+196
View File
@@ -0,0 +1,196 @@
//! 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`]).
//!
//! 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;
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 = RasterOptions::DARK_BACKGROUND;
/// 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 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 MAX_LISTED_TABLES: usize = 10;
/// Longest part of a refused value repeated in its error message, in characters.
const MAX_ECHO_CHARS: 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). `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<Arc<PlotStyleTable>>,
}
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<RenderLook, String> {
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(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)))?;
}
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<Rgb> {
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<Option<PlotStyleTable>, String> {
if cadcraft_doc::no_plot_style_table(name) {
return Ok(None);
}
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
/// [`MAX_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<String> = names.iter().filter(|n| !builtin(n)).map(|n| echo(n)).collect();
let mut list: Vec<String> = cadcraft_doc::BUILTIN_PLOT_STYLE_TABLES.iter().map(|b| (*b).to_string()).collect();
list.extend(own.iter().take(MAX_LISTED_TABLES).cloned());
let mut text = list.join(", ");
if own.len() > MAX_LISTED_TABLES {
text.push_str(&format!(" and {} more of the drawing's", own.len() - MAX_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.
fn echo(v: &str) -> String {
v.chars().take(MAX_ECHO_CHARS).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());
// "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.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());
}
}
+4
View File
@@ -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 }
+10 -4
View File
@@ -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") {
+132
View File
@@ -259,3 +259,135 @@ 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: 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]
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());
}
+3 -1
View File
@@ -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\" (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 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"},
+6 -1
View File
@@ -64,9 +64,14 @@ pub struct RasterOptions {
pub antialias: bool,
}
impl RasterOptions {
/// The dark canvas a render draws on by default.
pub const DARK_BACKGROUND: Rgb = Rgb(33, 40, 48);
}
impl Default for RasterOptions {
fn default() -> Self {
RasterOptions { background: Rgb(33, 40, 48), hairline: 1.0, px_per_mm: 0.0, antialias: true }
RasterOptions { background: Self::DARK_BACKGROUND, hairline: 1.0, px_per_mm: 0.0, antialias: true }
}
}
+81 -5
View File
@@ -15,8 +15,11 @@
//! - `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, 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"`
@@ -211,7 +214,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 +478,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 +497,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 +847,67 @@ 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 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) {
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::<u32>());
(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, 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 render = |red: bool, mut p: Value| {
p["window"] = window.clone();
p["width"] = json!(120);
p["height"] = json!(40);
let r = render_line(red, p.clone());
assert_eq!(r["ok"], true, "{p}: {}", r["error"]);
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(false, p.clone());
assert_eq!(r["ok"], false, "{p} must be refused");
assert!(r.get("result").is_none(), "{p}");
}
}
}
+1 -1
View File
@@ -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"` (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. |
+1 -1
View File
@@ -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"` (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. |
+74
View File
@@ -85,6 +85,80 @@ host's existing eframe persistence; clearing site data removes the saved layout.
downloads) are blocked.
- Don't send `X-Frame-Options: DENY` or a `frame-ancestors` CSP that excludes the embedding page.
## Host mode: let the page open and store drawings
A page can take over the file side of the app instead of the browser's downloads. The iframe
embed above opts in by adding `?host=parent` to the iframe's URL
(`https://example.com/cadcraft/?host=parent`); the app then talks to its same-origin framing
page over `postMessage`, with plain window messages that carry a `type`:
| Message | Direction | Fields |
|---|---|---|
| `cadcraft:ready` | app → page | `version`, `appVersion`: the app is listening; send a drawing now |
| `cadcraft:open` | page → app | `name`, `bytes` (an `ArrayBuffer` or `Uint8Array`): open like File › Open |
| `cadcraft:save` | app → page | `name`, `bytes` (a transferred `ArrayBuffer`): every file the app would otherwise download (SAVE, SAVEAS, exports). The page stores it and reports failures itself. |
| `cadcraft:dirty` | app → page | `dirty`: unsaved changes appeared or went away |
| `cadcraft:failed` | app → page | `error`: the app couldn't start |
In host mode the app starts on the Start tab instead of an empty Drawing1, so the page should
guard leaving it while changes are unsaved (that is what `cadcraft:dirty` reports).
### Same-document host mode (no iframe)
A page can also run the app in its own document — its own `<canvas id="cadcraft_canvas">`
inside a component, no iframe — by loading the wasm-bindgen module itself. It opts in with
`data-host="page"` on that canvas — the exact value `page`; anything else, and an unframed
`?host=parent`, keeps the normal app with downloads. The behaviour is the same as the parent
bridge, except the transport is DOM `CustomEvent`s on the canvas instead of `postMessage`: the
app dispatches `cadcraft:ready`, `cadcraft:save`, `cadcraft:dirty` and `cadcraft:failed` on the
canvas with the message fields (without the `type`) as the event's `detail`, and the page
dispatches a `cadcraft:open` event with `detail: { name, bytes }` to open a drawing. Events
don't bubble, so two editors on one page can't hear each other; listen on the canvas itself.
```html
<canvas id="cadcraft_canvas" data-host="page" style="width: 100%; height: 600px; border: 0;"></canvas>
```
```js
import init from "./cadcraft-web-<hash>.js"; // the wasm-bindgen glue shipped with the app
const canvas = document.getElementById("cadcraft_canvas");
// The app announces itself while `init` runs, so add the listeners first.
canvas.addEventListener("cadcraft:ready", () => {
// The app is listening; hand it a drawing of the page's own now, if you have one.
canvas.dispatchEvent(new CustomEvent("cadcraft:open", {
detail: { name: "plan.dxf", bytes: drawingBytes }, // ArrayBuffer or Uint8Array
}));
});
// Every file the app would otherwise download (SAVE, SAVEAS, exports) arrives here.
canvas.addEventListener("cadcraft:save", (event) => {
const { name, bytes } = event.detail; // bytes is an ArrayBuffer
storeFile(name, bytes);
});
canvas.addEventListener("cadcraft:dirty", (event) => {
warnBeforeLeaving(event.detail.dirty);
});
canvas.addEventListener("cadcraft:failed", (event) => {
showError(event.detail.error);
});
// If the wasm cannot be fetched or instantiated, `init` rejects before the app can post
// `cadcraft:failed`, so report that here too.
try {
await init({ module_or_path: "./cadcraft-web-<hash>_bg.wasm" });
} catch (error) {
showError(String(error));
}
```
The messages and both transports are the shared contract of [`craft-web-host`](https://github.com/storytold/craft-libs/tree/main/crates/web-host) (storytold/craft-libs#420), the same for every Craft app: its README lists every message, including `saved`, with the compatibility notes. In `cadcraft:ready`, `version` is the contract's (`"1"`) and `appVersion` is this app's release. CADCraft reads bytes, so a host sends `bytes`, not a `File`.
### Trust and limits
- **Who is trusted.** In page host mode the page and CADCraft share one document, so any script on the page can drive the app: open files in it and receive what it saves. Use this mode only for a page you control. The iframe mode (`?host=parent`) answers only the parent window, on the page's own origin.
- **Files from the host are untrusted input.** An opened file goes through the same reading as one picked with File ▸ Open or dropped on the window, with the same format limits; before it is copied into the app's memory, its size is checked against the app's open size limit and a larger drawing is refused, and a name is cut to 255 characters.
- **The event and message names are the interface.** `cadcraft:ready`, `cadcraft:open`, `cadcraft:save`, `cadcraft:dirty` and `cadcraft:failed`, and the fields they carry (the same as the `?host=parent` messages), stay stable; changing one is a breaking change.
## Renderer selection and fallback flags
CADCraft renders with wgpu. It uses **WebGPU** when the browser has it and falls back to