mirror of
https://github.com/storytold/vectorcraft.git
synced 2026-10-24 01:37:02 -06:00
M0.1/M0.9: xtask (layers, wasm, ci, stats), agent docs, NOTICE, README, control protocol doc
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 5.5
parent
bea0c16636
commit
8cd8f8f8a7
@@ -0,0 +1,2 @@
|
|||||||
|
[alias]
|
||||||
|
xtask = "run --package xtask --"
|
||||||
@@ -0,0 +1,25 @@
|
|||||||
|
# DrawCraft — instructions for agents
|
||||||
|
|
||||||
|
DrawCraft is a clean-room, open-source, Rust-native vector illustration app targeting Adobe Illustrator parity (and beyond). It runs natively on macOS, Windows and Linux, and on the web via WASM. Siblings: `../photocraft` (Photoshop-class) and `../printcraft` (Acrobat-class); same conventions.
|
||||||
|
|
||||||
|
## Start every session here
|
||||||
|
1. Read `plan/STATUS.md` (current milestone, next task), then the task in `plan/execution-plan.md` §3 and the relevant `plan/architecture.md` section. Behaviour reference: `plan/illustrator/*.md`.
|
||||||
|
2. Follow the autonomous operation protocol (`plan/execution-plan.md` §7): orient → plan → implement + test → verify → record → commit. Don't stop to ask unless §7 lists the decision as the user's.
|
||||||
|
|
||||||
|
`plan/` is gitignored (local only).
|
||||||
|
|
||||||
|
## Non-negotiables
|
||||||
|
- **Clean-room.** Never read, disassemble or copy anything inside the Illustrator bundle (names/listings only). Never copy Adobe icons, artwork, presets or wording beyond feature names. Behaviour comes from public docs and black-box observation of the running app with synthetic documents only (screenshots by window id, stored under `plan/illustrator/screenshots/`, never committed). Never copy GPL/AGPL code (Inkscape, lib2geom…).
|
||||||
|
- **Everything is a command.** User-visible behaviour = a command in `crates/engine/src/cmd/*` (id, label, menu path, shortcut, params doc, `enabled`, `run`) + tests. Tools emit commands (Begin/Preview/Commit). UI-only commands live in `crates/ui-egui/src/menus.rs` (`UI_COMMANDS`). The control channel and MCP reach all of them.
|
||||||
|
- **Layering** is enforced by `cargo xtask layers`. Nothing below L6 depends on egui/eframe/winit/rfd.
|
||||||
|
- **The UI is thin**: panels read engine state and act through `app.run(id, params)`. Colours come from `theme::Tokens`.
|
||||||
|
- **Rust only** (no handwritten JS/TS). **Never break wasm** (`cargo xtask wasm`).
|
||||||
|
- **Quality gates** before every commit: `cargo xtask ci` (fmt, clippy -D warnings, tests, layers, wasm). One task id per commit (`M2.1: pen tool`).
|
||||||
|
|
||||||
|
## Running and looking at the app
|
||||||
|
- `cargo run --release -p drawcraft -- --control 7979 [file.svg|file.drawcraft]`
|
||||||
|
- Drive it: JSON lines on `127.0.0.1:7979`, e.g. `{"id":1,"method":"engine.execute","params":{"command":"shape.rectangle","params":{"x":10,"y":10,"width":100,"height":50}}}` then `{"id":2,"method":"ui.screenshot","params":{"path":"/tmp/shot.png"}}`. Methods: `crates/ui-egui/src/control.rs`.
|
||||||
|
- **For UI work, look at the result** (take `ui.screenshot`, read the PNG) and compare with `plan/illustrator/02-ui-ux.md` / `10-observed-ui.md`.
|
||||||
|
- MCP: `drawcraft-cli mcp` (see `docs/mcp.md`).
|
||||||
|
- Shell gotcha: `mv`/`cp` are aliased interactive here — use `/bin/mv -f` / `/bin/cp -f`.
|
||||||
|
- Parallel agents: separate `CARGO_TARGET_DIR` per agent; edit only the crates you own; write manifests atomically.
|
||||||
@@ -0,0 +1,25 @@
|
|||||||
|
# DrawCraft — instructions for agents
|
||||||
|
|
||||||
|
DrawCraft is a clean-room, open-source, Rust-native vector illustration app targeting Adobe Illustrator parity (and beyond). It runs natively on macOS, Windows and Linux, and on the web via WASM. Siblings: `../photocraft` (Photoshop-class) and `../printcraft` (Acrobat-class); same conventions.
|
||||||
|
|
||||||
|
## Start every session here
|
||||||
|
1. Read `plan/STATUS.md` (current milestone, next task), then the task in `plan/execution-plan.md` §3 and the relevant `plan/architecture.md` section. Behaviour reference: `plan/illustrator/*.md`.
|
||||||
|
2. Follow the autonomous operation protocol (`plan/execution-plan.md` §7): orient → plan → implement + test → verify → record → commit. Don't stop to ask unless §7 lists the decision as the user's.
|
||||||
|
|
||||||
|
`plan/` is gitignored (local only).
|
||||||
|
|
||||||
|
## Non-negotiables
|
||||||
|
- **Clean-room.** Never read, disassemble or copy anything inside the Illustrator bundle (names/listings only). Never copy Adobe icons, artwork, presets or wording beyond feature names. Behaviour comes from public docs and black-box observation of the running app with synthetic documents only (screenshots by window id, stored under `plan/illustrator/screenshots/`, never committed). Never copy GPL/AGPL code (Inkscape, lib2geom…).
|
||||||
|
- **Everything is a command.** User-visible behaviour = a command in `crates/engine/src/cmd/*` (id, label, menu path, shortcut, params doc, `enabled`, `run`) + tests. Tools emit commands (Begin/Preview/Commit). UI-only commands live in `crates/ui-egui/src/menus.rs` (`UI_COMMANDS`). The control channel and MCP reach all of them.
|
||||||
|
- **Layering** is enforced by `cargo xtask layers`. Nothing below L6 depends on egui/eframe/winit/rfd.
|
||||||
|
- **The UI is thin**: panels read engine state and act through `app.run(id, params)`. Colours come from `theme::Tokens`.
|
||||||
|
- **Rust only** (no handwritten JS/TS). **Never break wasm** (`cargo xtask wasm`).
|
||||||
|
- **Quality gates** before every commit: `cargo xtask ci` (fmt, clippy -D warnings, tests, layers, wasm). One task id per commit (`M2.1: pen tool`).
|
||||||
|
|
||||||
|
## Running and looking at the app
|
||||||
|
- `cargo run --release -p drawcraft -- --control 7979 [file.svg|file.drawcraft]`
|
||||||
|
- Drive it: JSON lines on `127.0.0.1:7979`, e.g. `{"id":1,"method":"engine.execute","params":{"command":"shape.rectangle","params":{"x":10,"y":10,"width":100,"height":50}}}` then `{"id":2,"method":"ui.screenshot","params":{"path":"/tmp/shot.png"}}`. Methods: `crates/ui-egui/src/control.rs`.
|
||||||
|
- **For UI work, look at the result** (take `ui.screenshot`, read the PNG) and compare with `plan/illustrator/02-ui-ux.md` / `10-observed-ui.md`.
|
||||||
|
- MCP: `drawcraft-cli mcp` (see `docs/mcp.md`).
|
||||||
|
- Shell gotcha: `mv`/`cp` are aliased interactive here — use `/bin/mv -f` / `/bin/cp -f`.
|
||||||
|
- Parallel agents: separate `CARGO_TARGET_DIR` per agent; edit only the crates you own; write manifests atomically.
|
||||||
Generated
+27
@@ -802,6 +802,10 @@ dependencies = [
|
|||||||
[[package]]
|
[[package]]
|
||||||
name = "drawcraft-cli"
|
name = "drawcraft-cli"
|
||||||
version = "0.1.0"
|
version = "0.1.0"
|
||||||
|
dependencies = [
|
||||||
|
"drawcraft-mcp",
|
||||||
|
"serde_json",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "drawcraft-color"
|
name = "drawcraft-color"
|
||||||
@@ -883,6 +887,17 @@ dependencies = [
|
|||||||
[[package]]
|
[[package]]
|
||||||
name = "drawcraft-mcp"
|
name = "drawcraft-mcp"
|
||||||
version = "0.1.0"
|
version = "0.1.0"
|
||||||
|
dependencies = [
|
||||||
|
"drawcraft-doc",
|
||||||
|
"drawcraft-engine",
|
||||||
|
"drawcraft-format",
|
||||||
|
"drawcraft-geom",
|
||||||
|
"drawcraft-render",
|
||||||
|
"drawcraft-svg",
|
||||||
|
"drawcraft-tools",
|
||||||
|
"log",
|
||||||
|
"serde_json",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "drawcraft-pathops"
|
name = "drawcraft-pathops"
|
||||||
@@ -988,6 +1003,18 @@ dependencies = [
|
|||||||
[[package]]
|
[[package]]
|
||||||
name = "drawcraft-web"
|
name = "drawcraft-web"
|
||||||
version = "0.1.0"
|
version = "0.1.0"
|
||||||
|
dependencies = [
|
||||||
|
"drawcraft-engine",
|
||||||
|
"drawcraft-ui-egui",
|
||||||
|
"eframe",
|
||||||
|
"egui",
|
||||||
|
"js-sys",
|
||||||
|
"log",
|
||||||
|
"rfd",
|
||||||
|
"wasm-bindgen",
|
||||||
|
"wasm-bindgen-futures",
|
||||||
|
"web-sys",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "ecolor"
|
name = "ecolor"
|
||||||
|
|||||||
@@ -0,0 +1,13 @@
|
|||||||
|
DrawCraft
|
||||||
|
Copyright the DrawCraft contributors. Licensed under MIT OR Apache-2.0.
|
||||||
|
|
||||||
|
This product bundles third-party material under the following licences:
|
||||||
|
|
||||||
|
- Lucide icons (assets/icons/*.svg except dc-*.svg) — ISC License. See assets/icons/LICENSE-lucide.txt.
|
||||||
|
- DrawCraft icons (assets/icons/dc-*.svg) — original work, MIT OR Apache-2.0.
|
||||||
|
- Source Sans 3 and Source Serif 4 (assets/fonts/Source*) — SIL Open Font License 1.1. See assets/fonts/OFL-SourceSans3.txt, OFL-SourceSerif4.txt.
|
||||||
|
- Inter (assets/fonts/Inter-*.ttf) — SIL Open Font License 1.1. See assets/fonts/OFL-Inter.txt.
|
||||||
|
- JetBrains Mono (assets/fonts/JetBrainsMono-*.ttf) — SIL Open Font License 1.1. See assets/fonts/OFL-JetBrainsMono.txt.
|
||||||
|
|
||||||
|
Rust dependencies (vello_cpu, kurbo, peniko, skrifa, harfrust, usvg, linesweeper, egui, eframe, …) are
|
||||||
|
permissively licensed (MIT/Apache-2.0/BSD/ISC/Zlib) and fetched through crates.io.
|
||||||
@@ -1,4 +1,19 @@
|
|||||||
DrawCraft
|
DrawCraft
|
||||||
=========
|
=========
|
||||||
|
|
||||||
By the artcraft team
|
By the artcraft team.
|
||||||
|
|
||||||
|
A fast, open-source, cross-platform vector illustration app written in pure Rust — a clean-room
|
||||||
|
reimplementation of the Adobe Illustrator workflow (tools, panels, menus, shortcuts) that runs on
|
||||||
|
macOS, Windows, Linux and the web (WASM), and is fully drivable by agents over a control channel
|
||||||
|
and MCP.
|
||||||
|
|
||||||
|
```sh
|
||||||
|
cargo run --release -p drawcraft # desktop app
|
||||||
|
cargo run --release -p drawcraft -- --control 7979 # + JSON control channel
|
||||||
|
cargo run --release -p drawcraft-cli -- mcp # MCP server (stdio)
|
||||||
|
cargo xtask ci # fmt, clippy, tests, layering, wasm
|
||||||
|
```
|
||||||
|
|
||||||
|
Workspace: `crates/{geom,color,doc,pathops,text,effects,render,svg,pdf,format,tools,engine,ui-egui,mcp,testkit}`,
|
||||||
|
`apps/{drawcraft,drawcraft-cli,drawcraft-web}`. See `CLAUDE.md` and `docs/`.
|
||||||
|
|||||||
@@ -4,8 +4,15 @@ version.workspace = true
|
|||||||
edition.workspace = true
|
edition.workspace = true
|
||||||
license.workspace = true
|
license.workspace = true
|
||||||
publish = false
|
publish = false
|
||||||
|
description = "DrawCraft command line: MCP server (stdio) and headless batch runs"
|
||||||
|
|
||||||
|
[[bin]]
|
||||||
|
name = "drawcraft-cli"
|
||||||
|
path = "src/main.rs"
|
||||||
|
|
||||||
[lints]
|
[lints]
|
||||||
workspace = true
|
workspace = true
|
||||||
|
|
||||||
[dependencies]
|
[dependencies]
|
||||||
|
drawcraft-mcp = { workspace = true }
|
||||||
|
serde_json = { workspace = true }
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
[package]
|
[package]
|
||||||
name = "drawcraft-web"
|
name = "drawcraft-web"
|
||||||
|
description = "DrawCraft in the browser (eframe web runner, WebGPU with WebGL2 fallback)"
|
||||||
version.workspace = true
|
version.workspace = true
|
||||||
edition.workspace = true
|
edition.workspace = true
|
||||||
license.workspace = true
|
license.workspace = true
|
||||||
@@ -8,4 +9,21 @@ publish = false
|
|||||||
[lints]
|
[lints]
|
||||||
workspace = true
|
workspace = true
|
||||||
|
|
||||||
|
[[bin]]
|
||||||
|
name = "drawcraft-web"
|
||||||
|
path = "src/main.rs"
|
||||||
|
|
||||||
[dependencies]
|
[dependencies]
|
||||||
|
drawcraft-engine = { workspace = true }
|
||||||
|
drawcraft-ui-egui = { workspace = true }
|
||||||
|
egui = { workspace = true }
|
||||||
|
log = { workspace = true }
|
||||||
|
|
||||||
|
[target.'cfg(target_arch = "wasm32")'.dependencies]
|
||||||
|
# `wgpu` pulls egui-wgpu's defaults: WebGPU plus the WebGL2 fallback.
|
||||||
|
eframe = { workspace = true, features = ["default_fonts", "wgpu", "accesskit"] }
|
||||||
|
rfd = { workspace = true }
|
||||||
|
wasm-bindgen = "0.2"
|
||||||
|
wasm-bindgen-futures = "0.4"
|
||||||
|
js-sys = "0.3"
|
||||||
|
web-sys = { version = "0.3", features = ["Window", "Document", "Element", "HtmlElement", "HtmlCanvasElement", "HtmlAnchorElement", "Blob", "BlobPropertyBag", "Url", "Location", "CssStyleDeclaration"] }
|
||||||
|
|||||||
@@ -0,0 +1,10 @@
|
|||||||
|
# Build with: trunk build --release (from apps/drawcraft-web)
|
||||||
|
[build]
|
||||||
|
target = "index.html"
|
||||||
|
dist = "../../dist/web"
|
||||||
|
public_url = "./"
|
||||||
|
filehash = true
|
||||||
|
|
||||||
|
[serve]
|
||||||
|
addresses = ["127.0.0.1"]
|
||||||
|
port = 8766
|
||||||
@@ -0,0 +1,19 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="utf-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1, user-scalable=no">
|
||||||
|
<title>DrawCraft</title>
|
||||||
|
<link data-trunk rel="rust" data-bin="drawcraft-web" data-wasm-opt="z" data-weak-refs />
|
||||||
|
<style>
|
||||||
|
html, body { margin: 0; padding: 0; width: 100%; height: 100%; overflow: hidden; background: #262626; }
|
||||||
|
#drawcraft_canvas { position: absolute; inset: 0; width: 100%; height: 100%; display: block; }
|
||||||
|
#drawcraft_loading { position: absolute; inset: 0; display: flex; flex-direction: column; align-items: center; justify-content: center;
|
||||||
|
color: #b8b8b8; font: 14px system-ui, sans-serif; pointer-events: none; }
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<canvas id="drawcraft_canvas"></canvas>
|
||||||
|
<div id="drawcraft_loading">Loading DrawCraft…</div>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
@@ -1 +1,27 @@
|
|||||||
fn main() {}
|
//! DrawCraft in the browser.
|
||||||
|
//!
|
||||||
|
//! Runs the same [`drawcraft_ui_egui::DrawcraftApp`] as the desktop app through eframe's web
|
||||||
|
//! runner (wgpu: WebGPU where available, WebGL2 otherwise). Build with `trunk build --release`
|
||||||
|
//! from this directory; see `docs/development.md` ("Web build").
|
||||||
|
//!
|
||||||
|
//! Differences from the desktop app:
|
||||||
|
//! - no TCP control server (browsers can't listen on sockets);
|
||||||
|
//! - File → Open uses the browser file picker; bytes arrive asynchronously through
|
||||||
|
//! `Services::inbox`;
|
||||||
|
//! - Save / Export trigger a browser download;
|
||||||
|
//! - dropped files are read asynchronously by `web::WebShell` and delivered through the inbox.
|
||||||
|
//!
|
||||||
|
//! URL query flag: `?webgl` forces the WebGL2 backend instead of WebGPU.
|
||||||
|
|
||||||
|
#[cfg(target_arch = "wasm32")]
|
||||||
|
mod web;
|
||||||
|
|
||||||
|
#[cfg(target_arch = "wasm32")]
|
||||||
|
fn main() {
|
||||||
|
web::start();
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(not(target_arch = "wasm32"))]
|
||||||
|
fn main() {
|
||||||
|
eprintln!("drawcraft-web only runs in the browser: build it with `trunk build --release` in apps/drawcraft-web");
|
||||||
|
}
|
||||||
|
|||||||
@@ -0,0 +1,171 @@
|
|||||||
|
//! The browser shell: web `Services`, drag-and-drop, and the eframe web runner.
|
||||||
|
|
||||||
|
use std::sync::{Arc, Mutex};
|
||||||
|
|
||||||
|
use drawcraft_engine::Session;
|
||||||
|
use drawcraft_ui_egui::{DrawcraftApp, Services};
|
||||||
|
use wasm_bindgen::JsCast as _;
|
||||||
|
|
||||||
|
type Inbox = Arc<Mutex<Vec<(String, Vec<u8>)>>>;
|
||||||
|
|
||||||
|
const OPEN_EXTS: &[&str] = &["drawcraft", "svg", "png", "jpg", "jpeg", "gif", "webp"];
|
||||||
|
const CANVAS_ID: &str = "drawcraft_canvas";
|
||||||
|
const LOADING_ID: &str = "drawcraft_loading";
|
||||||
|
|
||||||
|
pub fn start() {
|
||||||
|
eframe::WebLogger::init(log::LevelFilter::Info).ok();
|
||||||
|
wasm_bindgen_futures::spawn_local(async {
|
||||||
|
let Some(document) = web_sys::window().and_then(|w| w.document()) else {
|
||||||
|
log::error!("no document");
|
||||||
|
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
|
||||||
|
{
|
||||||
|
create.instance_descriptor.backends = eframe::wgpu::Backends::GL;
|
||||||
|
}
|
||||||
|
let result = eframe::WebRunner::new()
|
||||||
|
.start(
|
||||||
|
canvas,
|
||||||
|
options,
|
||||||
|
Box::new(move |cc| {
|
||||||
|
if let Some(rs) = &cc.wgpu_render_state {
|
||||||
|
log::info!("drawcraft-web: wgpu backend {:?}", rs.adapter.get_info().backend);
|
||||||
|
}
|
||||||
|
let inbox: Inbox = Arc::default();
|
||||||
|
let app = DrawcraftApp::new(Session::new(), services(inbox.clone(), cc.egui_ctx.clone()));
|
||||||
|
Ok(Box::new(WebShell { app, inbox }))
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
.await;
|
||||||
|
if let Some(el) = document.get_element_by_id(LOADING_ID) {
|
||||||
|
match result {
|
||||||
|
Ok(()) => el.remove(),
|
||||||
|
Err(e) => el.set_inner_html(&format!(
|
||||||
|
"<p>DrawCraft failed to start: {e:?}</p><p>A browser with WebGPU or WebGL2 is required.</p>"
|
||||||
|
)),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
fn query() -> String {
|
||||||
|
web_sys::window().and_then(|w| w.location().search().ok()).unwrap_or_default()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Wraps the app to read dropped files asynchronously (browsers can't read them synchronously)
|
||||||
|
/// and feed them through the inbox.
|
||||||
|
struct WebShell {
|
||||||
|
app: DrawcraftApp,
|
||||||
|
inbox: Inbox,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl eframe::App for WebShell {
|
||||||
|
fn logic(&mut self, ctx: &egui::Context, _frame: &mut eframe::Frame) {
|
||||||
|
let dropped = ctx.input_mut(|i| std::mem::take(&mut i.raw.dropped_files));
|
||||||
|
for f in dropped {
|
||||||
|
let inbox = self.inbox.clone();
|
||||||
|
let ctx = ctx.clone();
|
||||||
|
wasm_bindgen_futures::spawn_local(async move {
|
||||||
|
let name = f
|
||||||
|
.path()
|
||||||
|
.file_name()
|
||||||
|
.map(|n| n.to_string_lossy().to_string())
|
||||||
|
.unwrap_or_else(|| "dropped".into());
|
||||||
|
match f.bytes_async().await {
|
||||||
|
Ok(bytes) => {
|
||||||
|
inbox.lock().unwrap_or_else(|e| e.into_inner()).push((name, bytes));
|
||||||
|
ctx.request_repaint();
|
||||||
|
}
|
||||||
|
Err(e) => log::error!("couldn't read dropped file {name}: {e}"),
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
self.app.logic(ctx);
|
||||||
|
}
|
||||||
|
|
||||||
|
fn raw_input_hook(&mut self, _ctx: &egui::Context, raw: &mut egui::RawInput) {
|
||||||
|
self.app.raw_input_hook(raw);
|
||||||
|
}
|
||||||
|
|
||||||
|
fn ui(&mut self, ui: &mut egui::Ui, _frame: &mut eframe::Frame) {
|
||||||
|
self.app.ui(ui);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn services(inbox: Inbox, ctx: egui::Context) -> Services {
|
||||||
|
let open_inbox = inbox.clone();
|
||||||
|
Services {
|
||||||
|
open_async: Some(Box::new(move || {
|
||||||
|
let inbox = open_inbox.clone();
|
||||||
|
let ctx = ctx.clone();
|
||||||
|
wasm_bindgen_futures::spawn_local(async move {
|
||||||
|
let Some(file) = rfd::AsyncFileDialog::new().add_filter("All supported", OPEN_EXTS).pick_file().await else {
|
||||||
|
return;
|
||||||
|
};
|
||||||
|
let bytes = file.read().await;
|
||||||
|
inbox.lock().unwrap_or_else(|e| e.into_inner()).push((file.file_name(), bytes));
|
||||||
|
ctx.request_repaint();
|
||||||
|
});
|
||||||
|
})),
|
||||||
|
download: Some(Box::new(|name: &str, bytes: &[u8]| {
|
||||||
|
if let Err(e) = download(name, bytes) {
|
||||||
|
log::error!("download of {name} failed: {e}");
|
||||||
|
}
|
||||||
|
})),
|
||||||
|
inbox: Some(inbox),
|
||||||
|
..Default::default()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Trigger a browser download of `bytes` named after the last component of `path`.
|
||||||
|
fn download(path: &str, bytes: &[u8]) -> Result<(), String> {
|
||||||
|
let js = |e: wasm_bindgen::JsValue| format!("{e:?}");
|
||||||
|
let name = std::path::Path::new(path)
|
||||||
|
.file_name()
|
||||||
|
.map(|n| n.to_string_lossy().to_string())
|
||||||
|
.unwrap_or_else(|| "drawcraft".into());
|
||||||
|
let window = web_sys::window().ok_or("no window")?;
|
||||||
|
let document = window.document().ok_or("no document")?;
|
||||||
|
let parts = js_sys::Array::of1(&js_sys::Uint8Array::from(bytes));
|
||||||
|
let opts = web_sys::BlobPropertyBag::new();
|
||||||
|
opts.set_type(mime_for(&name));
|
||||||
|
let blob = web_sys::Blob::new_with_u8_array_sequence_and_options(&parts, &opts).map_err(js)?;
|
||||||
|
let url = web_sys::Url::create_object_url_with_blob(&blob).map_err(js)?;
|
||||||
|
let a: web_sys::HtmlAnchorElement =
|
||||||
|
document.create_element("a").map_err(js)?.dyn_into().map_err(|_| "not an anchor")?;
|
||||||
|
a.set_href(&url);
|
||||||
|
a.set_download(&name);
|
||||||
|
a.style().set_property("display", "none").map_err(js)?;
|
||||||
|
let body = document.body().ok_or("no body")?;
|
||||||
|
body.append_child(&a).map_err(js)?;
|
||||||
|
a.click();
|
||||||
|
a.remove();
|
||||||
|
// Revoke after the click has been dispatched; the download keeps its own reference.
|
||||||
|
let revoke = wasm_bindgen::closure::Closure::once_into_js(move || {
|
||||||
|
web_sys::Url::revoke_object_url(&url).ok();
|
||||||
|
});
|
||||||
|
window
|
||||||
|
.set_timeout_with_callback_and_timeout_and_arguments_0(revoke.unchecked_ref(), 10_000)
|
||||||
|
.map_err(js)?;
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
fn mime_for(name: &str) -> &'static str {
|
||||||
|
match name.rsplit('.').next().map(str::to_ascii_lowercase).as_deref() {
|
||||||
|
Some("svg") => "image/svg+xml",
|
||||||
|
Some("png") => "image/png",
|
||||||
|
Some("jpg" | "jpeg") => "image/jpeg",
|
||||||
|
Some("pdf") => "application/pdf",
|
||||||
|
Some("drawcraft") => "application/json",
|
||||||
|
_ => "application/octet-stream",
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,710 @@
|
|||||||
|
//! Pathfinder panel (`object.pathfinder.*`) and Object → Path commands backed by
|
||||||
|
//! `drawcraft-pathops`: Offset Path, Outline Stroke, Simplify, Add Anchor Points, Split Into Grid,
|
||||||
|
//! Clean Up and Divide Objects Below.
|
||||||
|
|
||||||
|
use std::sync::Arc;
|
||||||
|
|
||||||
|
use drawcraft_color::{BlendMode, Paint};
|
||||||
|
use drawcraft_doc::appearance::{AppearanceItem, LineCap, LineJoin, StrokeAlign, StrokeLayer};
|
||||||
|
use drawcraft_doc::{Appearance, Document, Node, NodeId, NodeKind, TextObject};
|
||||||
|
use drawcraft_geom::{FillRule, PathData};
|
||||||
|
use drawcraft_pathops as po;
|
||||||
|
use drawcraft_pathops::{BoolOp, PathfinderOp};
|
||||||
|
use serde_json::{Value, json};
|
||||||
|
|
||||||
|
use super::edit::selected_roots;
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
pub fn specs() -> Vec<CommandSpec> {
|
||||||
|
const PF: &str = "{} operate on the selected objects (back → front) → {ids}";
|
||||||
|
vec![
|
||||||
|
cmd!("object.pathfinder.unite", "Unite", ["Window", "Pathfinder"], None, PF, has_selection, |s, _| run_pf(s, PathfinderOp::Unite)),
|
||||||
|
cmd!("object.pathfinder.minusFront", "Minus Front", ["Window", "Pathfinder"], None, PF, has_selection, |s, _| run_pf(
|
||||||
|
s,
|
||||||
|
PathfinderOp::MinusFront
|
||||||
|
)),
|
||||||
|
cmd!("object.pathfinder.intersect", "Intersect", ["Window", "Pathfinder"], None, PF, has_selection, |s, _| run_pf(
|
||||||
|
s,
|
||||||
|
PathfinderOp::Intersect
|
||||||
|
)),
|
||||||
|
cmd!("object.pathfinder.exclude", "Exclude", ["Window", "Pathfinder"], None, PF, has_selection, |s, _| run_pf(s, PathfinderOp::Exclude)),
|
||||||
|
cmd!("object.pathfinder.divide", "Divide", ["Window", "Pathfinder"], None, PF, has_selection, |s, _| run_pf(s, PathfinderOp::Divide)),
|
||||||
|
cmd!("object.pathfinder.trim", "Trim", ["Window", "Pathfinder"], None, PF, has_selection, |s, _| run_pf(s, PathfinderOp::Trim)),
|
||||||
|
cmd!("object.pathfinder.merge", "Merge", ["Window", "Pathfinder"], None, PF, has_selection, |s, _| run_pf(s, PathfinderOp::Merge)),
|
||||||
|
cmd!("object.pathfinder.crop", "Crop", ["Window", "Pathfinder"], None, PF, has_multi, |s, _| run_pf(s, PathfinderOp::Crop)),
|
||||||
|
cmd!("object.pathfinder.outline", "Outline", ["Window", "Pathfinder"], None, PF, has_selection, |s, _| run_pf(s, PathfinderOp::Outline)),
|
||||||
|
cmd!("object.pathfinder.minusBack", "Minus Back", ["Window", "Pathfinder"], None, PF, has_selection, |s, _| run_pf(
|
||||||
|
s,
|
||||||
|
PathfinderOp::MinusBack
|
||||||
|
)),
|
||||||
|
cmd!(
|
||||||
|
"object.path.outlineStroke",
|
||||||
|
"Outline Stroke",
|
||||||
|
["Object", "Path"],
|
||||||
|
None,
|
||||||
|
"{} replace stroked paths with filled outlines → {ids}",
|
||||||
|
has_selection,
|
||||||
|
outline_stroke
|
||||||
|
),
|
||||||
|
cmd!(
|
||||||
|
"object.path.offsetPath",
|
||||||
|
"Offset Path…",
|
||||||
|
["Object", "Path"],
|
||||||
|
None,
|
||||||
|
"{offset: pt (negative insets), joins?: \"miter\"|\"round\"|\"bevel\", miterLimit?: 4} → {ids} (new objects above the originals)",
|
||||||
|
has_selection,
|
||||||
|
offset_path
|
||||||
|
),
|
||||||
|
cmd!(
|
||||||
|
"object.path.simplify",
|
||||||
|
"Simplify…",
|
||||||
|
["Object", "Path"],
|
||||||
|
None,
|
||||||
|
"{tolerance?: pt (1), cornerAngle?: deg (30), straightLines?: bool} → {ids, before, after} anchor counts",
|
||||||
|
has_selection,
|
||||||
|
simplify
|
||||||
|
),
|
||||||
|
cmd!("object.path.addAnchorPoints", "Add Anchor Points", ["Object", "Path"], None, "{} → {ids}", has_selection, add_anchor_points),
|
||||||
|
cmd!(
|
||||||
|
"object.path.divideObjectsBelow",
|
||||||
|
"Divide Objects Below",
|
||||||
|
["Object", "Path"],
|
||||||
|
None,
|
||||||
|
"{} cut the objects below with the selected path (which is removed) → {ids}",
|
||||||
|
has_selection,
|
||||||
|
divide_objects_below
|
||||||
|
),
|
||||||
|
cmd!(
|
||||||
|
"object.path.splitIntoGrid",
|
||||||
|
"Split Into Grid…",
|
||||||
|
["Object", "Path"],
|
||||||
|
None,
|
||||||
|
"{rows?: 2, columns?: 2, gutter?: pt (12)} → {ids}",
|
||||||
|
has_selection,
|
||||||
|
split_into_grid
|
||||||
|
),
|
||||||
|
cmd!(
|
||||||
|
"object.path.cleanUp",
|
||||||
|
"Clean Up…",
|
||||||
|
["Object", "Path"],
|
||||||
|
None,
|
||||||
|
"{strayPoints?: true, unpaintedObjects?: true, emptyTextPaths?: true} → {removed}",
|
||||||
|
has_doc,
|
||||||
|
clean_up
|
||||||
|
),
|
||||||
|
]
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------- helpers ----------
|
||||||
|
|
||||||
|
/// Number param that also accepts strings like `"10 pt"`.
|
||||||
|
pub(crate) fn num_param(p: &Value, key: &str) -> Option<f64> {
|
||||||
|
match p.get(key)? {
|
||||||
|
Value::Number(n) => n.as_f64(),
|
||||||
|
Value::String(s) => {
|
||||||
|
let t: String = s.trim().chars().take_while(|c| c.is_ascii_digit() || matches!(c, '.' | '-' | '+' | 'e' | 'E')).collect();
|
||||||
|
t.parse().ok()
|
||||||
|
}
|
||||||
|
_ => None,
|
||||||
|
}
|
||||||
|
.filter(|v: &f64| v.is_finite())
|
||||||
|
}
|
||||||
|
|
||||||
|
fn ids_json(ids: &[NodeId]) -> Value {
|
||||||
|
json!({ "ids": ids.iter().map(|i| i.0).collect::<Vec<_>>() })
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Paint identity carried through a Pathfinder operation.
|
||||||
|
#[derive(Clone, Debug, PartialEq)]
|
||||||
|
struct Style {
|
||||||
|
appearance: Appearance,
|
||||||
|
opacity: f32,
|
||||||
|
blend: BlendMode,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Style {
|
||||||
|
fn of(n: &Node) -> Self {
|
||||||
|
Style { appearance: n.appearance.clone(), opacity: n.opacity, blend: n.blend }
|
||||||
|
}
|
||||||
|
fn apply(&self, n: &mut Node) {
|
||||||
|
n.appearance = self.appearance.clone();
|
||||||
|
n.opacity = self.opacity;
|
||||||
|
n.blend = self.blend;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// All subpaths of a path or compound path node, in document coordinates, with its fill rule.
|
||||||
|
pub(crate) fn node_path(n: &Node) -> Option<(PathData, FillRule)> {
|
||||||
|
match &n.kind {
|
||||||
|
NodeKind::Path { path, rule, .. } => Some((path.clone(), *rule)),
|
||||||
|
NodeKind::Compound { children, rule } => {
|
||||||
|
let mut subs = vec![];
|
||||||
|
for c in children {
|
||||||
|
if let Some((p, _)) = node_path(c) {
|
||||||
|
subs.extend(p.subpaths);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Some((PathData::new(subs), *rule))
|
||||||
|
}
|
||||||
|
NodeKind::Text(t) => Some((text_outline(t), FillRule::NonZero)),
|
||||||
|
_ => None,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Glyph outlines of a text object in document coordinates.
|
||||||
|
pub(crate) fn text_outline(t: &TextObject) -> PathData {
|
||||||
|
let lay = drawcraft_text::layout(drawcraft_text::FontDb::global(), t);
|
||||||
|
PathData::from_bezpath(&lay.to_bezpath()).transformed(t.xf)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Appearance a text object's outlines take: the first run's fill and stroke.
|
||||||
|
pub(crate) fn text_style_appearance(fill: &Paint, stroke: &Paint, width: f64) -> Appearance {
|
||||||
|
let mut a = Appearance { items: vec![AppearanceItem::Fill(drawcraft_doc::appearance::FillLayer::new(fill.clone()))], effects: vec![] };
|
||||||
|
if !stroke.is_none() && width > 0.0 {
|
||||||
|
a.items.push(AppearanceItem::Stroke(StrokeLayer::new(stroke.clone(), width)));
|
||||||
|
}
|
||||||
|
a
|
||||||
|
}
|
||||||
|
|
||||||
|
fn style_of(n: &Node) -> Style {
|
||||||
|
match &n.kind {
|
||||||
|
NodeKind::Text(t) => {
|
||||||
|
let st = t.first_style();
|
||||||
|
Style { appearance: text_style_appearance(&st.fill, &st.stroke, st.stroke_width), opacity: n.opacity, blend: n.blend }
|
||||||
|
}
|
||||||
|
_ => Style::of(n),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Leaf shapes (paths, compound paths, text) of a subtree in paint order; guides and clipping
|
||||||
|
/// paths are skipped.
|
||||||
|
fn leaves(n: &Node, out: &mut Vec<Node>) {
|
||||||
|
match &n.kind {
|
||||||
|
NodeKind::Path { guide: true, .. } | NodeKind::Path { clipping: true, .. } => {}
|
||||||
|
NodeKind::Path { .. } | NodeKind::Compound { .. } | NodeKind::Text(_) => out.push(n.clone()),
|
||||||
|
NodeKind::Group { children, .. } | NodeKind::Layer { children, .. } => {
|
||||||
|
for c in children {
|
||||||
|
leaves(c, out);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
_ => {}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A node for `path`: a plain path for one subpath, else a compound path.
|
||||||
|
pub(crate) fn shape_node(d: &mut Document, path: PathData, style_src: Option<&Node>) -> Node {
|
||||||
|
let id = d.alloc_id();
|
||||||
|
let mut n = if path.subpaths.len() <= 1 {
|
||||||
|
Node::path(id, path, Appearance::default())
|
||||||
|
} else {
|
||||||
|
let children = path
|
||||||
|
.subpaths
|
||||||
|
.into_iter()
|
||||||
|
.map(|sp| {
|
||||||
|
let cid = d.alloc_id();
|
||||||
|
Arc::new(Node::path(cid, PathData::single(sp), Appearance::default()))
|
||||||
|
})
|
||||||
|
.collect();
|
||||||
|
Node::new(id, NodeKind::Compound { children, rule: FillRule::NonZero })
|
||||||
|
};
|
||||||
|
if let Some(src) = style_src {
|
||||||
|
Style::of(src).apply(&mut n);
|
||||||
|
}
|
||||||
|
n
|
||||||
|
}
|
||||||
|
|
||||||
|
fn shape_node_styled(d: &mut Document, path: PathData, style: &Style) -> Node {
|
||||||
|
let mut n = shape_node(d, path, None);
|
||||||
|
style.apply(&mut n);
|
||||||
|
n
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------- Pathfinder ----------
|
||||||
|
|
||||||
|
fn pf_label(op: PathfinderOp) -> &'static str {
|
||||||
|
match op {
|
||||||
|
PathfinderOp::Unite => "Unite",
|
||||||
|
PathfinderOp::MinusFront => "Minus Front",
|
||||||
|
PathfinderOp::Intersect => "Intersect",
|
||||||
|
PathfinderOp::Exclude => "Exclude",
|
||||||
|
PathfinderOp::Divide => "Divide",
|
||||||
|
PathfinderOp::Trim => "Trim",
|
||||||
|
PathfinderOp::Merge => "Merge",
|
||||||
|
PathfinderOp::Crop => "Crop",
|
||||||
|
PathfinderOp::Outline => "Outline",
|
||||||
|
PathfinderOp::MinusBack => "Minus Back",
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn run_pf(s: &mut Session, op: PathfinderOp) -> Result<Value> {
|
||||||
|
let roots = selected_roots(s)?;
|
||||||
|
let Some(&top) = roots.last() else { return Err(EngineError::Other("nothing selected".into())) };
|
||||||
|
let shape_mode = matches!(op, PathfinderOp::Unite | PathfinderOp::MinusFront | PathfinderOp::Intersect | PathfinderOp::Exclude | PathfinderOp::MinusBack);
|
||||||
|
let label = pf_label(op);
|
||||||
|
let ids = s.edit(label, |d, sel| {
|
||||||
|
// Build the stack (back → front) with a style per entry.
|
||||||
|
let mut styles: Vec<Style> = vec![];
|
||||||
|
let mut shapes: Vec<po::Shape> = vec![];
|
||||||
|
let mut push = |path: PathData, rule: FillRule, st: Style| {
|
||||||
|
if path.is_empty() {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
let key = match styles.iter().position(|x| *x == st) {
|
||||||
|
Some(k) => k,
|
||||||
|
None => {
|
||||||
|
styles.push(st);
|
||||||
|
styles.len() - 1
|
||||||
|
}
|
||||||
|
};
|
||||||
|
shapes.push(po::Shape::new(path, rule, key as u64));
|
||||||
|
};
|
||||||
|
for id in &roots {
|
||||||
|
let n = d.node(*id).ok_or(EngineError::NoNode(*id))?;
|
||||||
|
let mut lv = vec![];
|
||||||
|
leaves(n, &mut lv);
|
||||||
|
if lv.is_empty() {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if shape_mode && lv.len() > 1 {
|
||||||
|
// A group acts as one shape (the union of its leaves) with its top leaf's style.
|
||||||
|
let parts: Vec<(PathData, FillRule)> = lv.iter().filter_map(node_path).collect();
|
||||||
|
let refs: Vec<(&PathData, FillRule)> = parts.iter().map(|(p, r)| (p, *r)).collect();
|
||||||
|
push(po::unite_all(&refs), FillRule::NonZero, style_of(lv.last().unwrap()));
|
||||||
|
} else {
|
||||||
|
for l in &lv {
|
||||||
|
if let Some((p, r)) = node_path(l) {
|
||||||
|
push(p, r, style_of(l));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if shapes.is_empty() {
|
||||||
|
return Err(EngineError::Other(format!("{label}: select paths, compound paths, groups or text")));
|
||||||
|
}
|
||||||
|
if shapes.iter().any(|s| s.path.subpaths.iter().any(|sp| sp.anchors.iter().any(|a| !(a.p.x.is_finite() && a.p.y.is_finite())))) {
|
||||||
|
return Err(EngineError::Other("paths contain invalid coordinates".into()));
|
||||||
|
}
|
||||||
|
let results = po::pathfinder(op, &shapes);
|
||||||
|
if results.iter().all(|r| r.path.is_empty()) {
|
||||||
|
return Err(EngineError::Other(format!("{label} produced an empty result")));
|
||||||
|
}
|
||||||
|
let (par, idx, _) = d.position(top).ok_or(EngineError::NoNode(top))?;
|
||||||
|
let new_node = if shape_mode {
|
||||||
|
let r = results.into_iter().find(|r| !r.path.is_empty()).unwrap();
|
||||||
|
shape_node_styled(d, r.path, &styles[r.key as usize])
|
||||||
|
} else {
|
||||||
|
let mut children = vec![];
|
||||||
|
for r in results {
|
||||||
|
if r.path.is_empty() {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
let st = &styles[r.key as usize];
|
||||||
|
let mut n = shape_node_styled(d, r.path, st);
|
||||||
|
if op == PathfinderOp::Outline {
|
||||||
|
// Outline: open edges stroked with the face's former fill.
|
||||||
|
let fill = st.appearance.fill_paint();
|
||||||
|
let paint = if fill.is_none() { st.appearance.stroke_paint() } else { fill };
|
||||||
|
n.appearance = Appearance { items: vec![AppearanceItem::Stroke(StrokeLayer::new(paint, 1.0))], effects: vec![] };
|
||||||
|
}
|
||||||
|
children.push(Arc::new(n));
|
||||||
|
}
|
||||||
|
let gid = d.alloc_id();
|
||||||
|
Node::group(gid, children)
|
||||||
|
};
|
||||||
|
let nid = new_node.id;
|
||||||
|
d.insert(par, idx + 1, new_node)?;
|
||||||
|
for id in &roots {
|
||||||
|
d.remove(*id)?;
|
||||||
|
}
|
||||||
|
sel.set([nid]);
|
||||||
|
Ok(vec![nid])
|
||||||
|
})?;
|
||||||
|
Ok(ids_json(&ids))
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------- Offset Path ----------
|
||||||
|
|
||||||
|
fn join_param(p: &Value, key: &str) -> po::Join {
|
||||||
|
match str_param(p, key).map(str::to_ascii_lowercase).as_deref() {
|
||||||
|
Some("round") => po::Join::Round,
|
||||||
|
Some("bevel") => po::Join::Bevel,
|
||||||
|
_ => po::Join::Miter,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn offset_path(s: &mut Session, p: &Value) -> Result<Value> {
|
||||||
|
let delta = num_param(p, "offset").unwrap_or(10.0);
|
||||||
|
if delta.abs() > 1e5 {
|
||||||
|
return Err(bad("object.path.offsetPath", "offset out of range"));
|
||||||
|
}
|
||||||
|
let join = join_param(p, "joins");
|
||||||
|
let miter = num_param(p, "miterLimit").unwrap_or(4.0).clamp(1.0, 500.0);
|
||||||
|
let roots = selected_roots(s)?;
|
||||||
|
let ids = s.edit("Offset Path", |d, sel| {
|
||||||
|
let mut out = vec![];
|
||||||
|
for id in &roots {
|
||||||
|
let Some(n) = d.node(*id).cloned() else { continue };
|
||||||
|
let mut lv = vec![];
|
||||||
|
leaves(&n, &mut lv);
|
||||||
|
let mut made = vec![];
|
||||||
|
for l in lv.iter().filter(|l| !matches!(l.kind, NodeKind::Text(_))) {
|
||||||
|
let Some((path, rule)) = node_path(l) else { continue };
|
||||||
|
let base = if matches!(l.kind, NodeKind::Compound { .. }) && path.is_closed() { po::normalize(&path, rule) } else { path };
|
||||||
|
let off = po::offset_path(&base, delta, join, miter);
|
||||||
|
if off.is_empty() {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
made.push(shape_node(d, off, Some(l)));
|
||||||
|
}
|
||||||
|
if made.is_empty() {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
let new = if made.len() == 1 && !matches!(n.kind, NodeKind::Group { .. }) {
|
||||||
|
made.pop().unwrap()
|
||||||
|
} else {
|
||||||
|
let gid = d.alloc_id();
|
||||||
|
Node::group(gid, made.into_iter().map(Arc::new).collect())
|
||||||
|
};
|
||||||
|
let (par, idx, _) = d.position(*id).ok_or(EngineError::NoNode(*id))?;
|
||||||
|
out.push(d.insert(par, idx + 1, new)?);
|
||||||
|
}
|
||||||
|
if out.is_empty() {
|
||||||
|
return Err(EngineError::Other("Offset Path: select paths".into()));
|
||||||
|
}
|
||||||
|
sel.set(out.iter().copied());
|
||||||
|
Ok(out)
|
||||||
|
})?;
|
||||||
|
Ok(ids_json(&ids))
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------- Outline Stroke ----------
|
||||||
|
|
||||||
|
fn cap_of(c: LineCap) -> po::Cap {
|
||||||
|
match c {
|
||||||
|
LineCap::Butt => po::Cap::Butt,
|
||||||
|
LineCap::Round => po::Cap::Round,
|
||||||
|
LineCap::Square => po::Cap::Square,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
fn join_of(j: LineJoin) -> po::Join {
|
||||||
|
match j {
|
||||||
|
LineJoin::Miter => po::Join::Miter,
|
||||||
|
LineJoin::Round => po::Join::Round,
|
||||||
|
LineJoin::Bevel => po::Join::Bevel,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The filled outline of `st` stroking `path` (honours inside/outside alignment on closed paths).
|
||||||
|
pub(crate) fn stroke_outline(path: &PathData, rule: FillRule, st: &StrokeLayer) -> PathData {
|
||||||
|
let (cap, join) = (cap_of(st.cap), join_of(st.join));
|
||||||
|
let w = st.width;
|
||||||
|
if !w.is_finite() || w <= 0.0 {
|
||||||
|
return PathData::default();
|
||||||
|
}
|
||||||
|
match st.align {
|
||||||
|
StrokeAlign::Inside | StrokeAlign::Outside if path.is_closed() => {
|
||||||
|
let ring = po::outline_stroke(path, 2.0 * w, cap, join, st.miter_limit);
|
||||||
|
let op = if st.align == StrokeAlign::Inside { BoolOp::Intersect } else { BoolOp::Difference };
|
||||||
|
po::boolean(&ring, FillRule::NonZero, path, rule, op)
|
||||||
|
}
|
||||||
|
_ => po::outline_stroke(path, w, cap, join, st.miter_limit),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn outline_stroke(s: &mut Session, _: &Value) -> Result<Value> {
|
||||||
|
let roots = selected_roots(s)?;
|
||||||
|
let ids = s.edit("Outline Stroke", |d, sel| {
|
||||||
|
let mut new_sel = vec![];
|
||||||
|
let mut changed = 0;
|
||||||
|
for root in &roots {
|
||||||
|
let Some(n) = d.node(*root).cloned() else { continue };
|
||||||
|
let mut lv = vec![];
|
||||||
|
leaves(&n, &mut lv);
|
||||||
|
let mut root_replacement = None;
|
||||||
|
for l in lv {
|
||||||
|
if matches!(l.kind, NodeKind::Text(_)) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
let Some(st) = l.appearance.stroke().filter(|s| s.visible && !s.paint.is_none() && s.width > 0.0).cloned() else { continue };
|
||||||
|
let Some((path, rule)) = node_path(&l) else { continue };
|
||||||
|
let outline = stroke_outline(&path, rule, &st);
|
||||||
|
if outline.is_empty() {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
let mut stroke_node = shape_node(d, outline, Some(&l));
|
||||||
|
stroke_node.appearance = Appearance { items: vec![AppearanceItem::Fill(drawcraft_doc::appearance::FillLayer::new(st.paint.clone()))], effects: vec![] };
|
||||||
|
let fill = l.appearance.fill().filter(|f| f.visible && !f.paint.is_none()).cloned();
|
||||||
|
let new = if let Some(f) = fill {
|
||||||
|
let mut fill_node = l.clone();
|
||||||
|
fill_node.id = d.alloc_id();
|
||||||
|
if let Some(ch) = fill_node.children_mut() {
|
||||||
|
for c in ch.iter_mut() {
|
||||||
|
Arc::make_mut(c).id = d.alloc_id();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
fill_node.appearance = Appearance { items: vec![AppearanceItem::Fill(f)], effects: vec![] };
|
||||||
|
if let NodeKind::Path { live, .. } = &mut fill_node.kind {
|
||||||
|
*live = None;
|
||||||
|
}
|
||||||
|
let gid = d.alloc_id();
|
||||||
|
let mut g = Node::group(gid, vec![Arc::new(fill_node), Arc::new(stroke_node)]);
|
||||||
|
g.opacity = l.opacity;
|
||||||
|
g.blend = l.blend;
|
||||||
|
if let Some(ch) = g.children_mut() {
|
||||||
|
for c in ch.iter_mut() {
|
||||||
|
let c = Arc::make_mut(c);
|
||||||
|
c.opacity = 1.0;
|
||||||
|
c.blend = BlendMode::Normal;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
g
|
||||||
|
} else {
|
||||||
|
stroke_node
|
||||||
|
};
|
||||||
|
let (par, idx, _) = d.position(l.id).ok_or(EngineError::NoNode(l.id))?;
|
||||||
|
let nid = d.insert(par, idx, new)?;
|
||||||
|
d.remove(l.id)?;
|
||||||
|
changed += 1;
|
||||||
|
if l.id == *root {
|
||||||
|
root_replacement = Some(nid);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
new_sel.push(root_replacement.unwrap_or(*root));
|
||||||
|
}
|
||||||
|
if changed == 0 {
|
||||||
|
return Err(EngineError::Other("Outline Stroke: no stroked paths selected".into()));
|
||||||
|
}
|
||||||
|
sel.set(new_sel.iter().copied());
|
||||||
|
Ok(new_sel)
|
||||||
|
})?;
|
||||||
|
Ok(ids_json(&ids))
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------- Simplify / Add Anchor Points ----------
|
||||||
|
|
||||||
|
/// Ids of every plain path node under the selected roots (compound children included).
|
||||||
|
fn path_ids(d: &Document, roots: &[NodeId]) -> Vec<NodeId> {
|
||||||
|
let mut out = vec![];
|
||||||
|
for r in roots {
|
||||||
|
if let Some(n) = d.node(*r) {
|
||||||
|
n.walk(&mut |c| {
|
||||||
|
if matches!(c.kind, NodeKind::Path { guide: false, .. }) {
|
||||||
|
out.push(c.id)
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
out
|
||||||
|
}
|
||||||
|
|
||||||
|
fn edit_paths(s: &mut Session, label: &str, f: impl Fn(&PathData) -> PathData) -> Result<(Vec<NodeId>, usize, usize)> {
|
||||||
|
let roots = selected_roots(s)?;
|
||||||
|
s.edit(label, |d, _| {
|
||||||
|
let ids = path_ids(d, &roots);
|
||||||
|
if ids.is_empty() {
|
||||||
|
return Err(EngineError::Other(format!("{label}: select paths")));
|
||||||
|
}
|
||||||
|
let (mut before, mut after) = (0, 0);
|
||||||
|
for id in &ids {
|
||||||
|
if let Some(NodeKind::Path { path, live, .. }) = d.node_mut(*id).map(|n| &mut n.kind) {
|
||||||
|
before += path.anchor_count();
|
||||||
|
let np = f(path);
|
||||||
|
if np.is_empty() && !path.is_empty() {
|
||||||
|
after += path.anchor_count();
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
after += np.anchor_count();
|
||||||
|
*path = np;
|
||||||
|
*live = None;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Ok((ids, before, after))
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
fn simplify(s: &mut Session, p: &Value) -> Result<Value> {
|
||||||
|
let opts = po::SimplifyOptions {
|
||||||
|
tolerance: num_param(p, "tolerance").unwrap_or(1.0).clamp(1e-4, 1e4),
|
||||||
|
corner_angle_deg: num_param(p, "cornerAngle").unwrap_or(30.0).clamp(0.0, 180.0),
|
||||||
|
straight_lines: bool_or(p, "straightLines", false),
|
||||||
|
};
|
||||||
|
let (ids, before, after) = edit_paths(s, "Simplify", |path| po::simplify_with(path, &opts))?;
|
||||||
|
Ok(json!({ "ids": ids.iter().map(|i| i.0).collect::<Vec<_>>(), "before": before, "after": after }))
|
||||||
|
}
|
||||||
|
|
||||||
|
fn add_anchor_points(s: &mut Session, _: &Value) -> Result<Value> {
|
||||||
|
let (ids, before, after) = edit_paths(s, "Add Anchor Points", po::add_anchor_points)?;
|
||||||
|
Ok(json!({ "ids": ids.iter().map(|i| i.0).collect::<Vec<_>>(), "before": before, "after": after }))
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------- Split Into Grid ----------
|
||||||
|
|
||||||
|
fn split_into_grid(s: &mut Session, p: &Value) -> Result<Value> {
|
||||||
|
let rows = num_param(p, "rows").unwrap_or(2.0).round();
|
||||||
|
let cols = num_param(p, "columns").unwrap_or(2.0).round();
|
||||||
|
if !(1.0..=500.0).contains(&rows) || !(1.0..=500.0).contains(&cols) {
|
||||||
|
return Err(bad("object.path.splitIntoGrid", "rows and columns must be 1..500"));
|
||||||
|
}
|
||||||
|
let gutter = num_param(p, "gutter").unwrap_or(12.0).max(0.0);
|
||||||
|
let roots = selected_roots(s)?;
|
||||||
|
let ids = s.edit("Split Into Grid", |d, sel| {
|
||||||
|
let mut out = vec![];
|
||||||
|
for id in &roots {
|
||||||
|
let Some(n) = d.node(*id).cloned() else { continue };
|
||||||
|
if !matches!(n.kind, NodeKind::Path { .. } | NodeKind::Compound { .. }) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
let Some(b) = n.geometric_bounds() else { continue };
|
||||||
|
let cells = po::split_into_grid(b, rows as usize, cols as usize, gutter);
|
||||||
|
if cells.is_empty() {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
let (par, idx, _) = d.position(*id).ok_or(EngineError::NoNode(*id))?;
|
||||||
|
d.remove(*id)?;
|
||||||
|
for (k, c) in cells.into_iter().enumerate() {
|
||||||
|
let node = shape_node(d, c, Some(&n));
|
||||||
|
out.push(d.insert(par, idx + k, node)?);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if out.is_empty() {
|
||||||
|
return Err(EngineError::Other("Split Into Grid: select paths large enough for the grid".into()));
|
||||||
|
}
|
||||||
|
sel.set(out.iter().copied());
|
||||||
|
Ok(out)
|
||||||
|
})?;
|
||||||
|
Ok(ids_json(&ids))
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------- Clean Up ----------
|
||||||
|
|
||||||
|
fn is_painted(a: &Appearance) -> bool {
|
||||||
|
a.items.iter().any(|i| match i {
|
||||||
|
AppearanceItem::Fill(f) => f.visible && !f.paint.is_none(),
|
||||||
|
AppearanceItem::Stroke(s) => s.visible && !s.paint.is_none() && s.width > 0.0,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
fn clean_up(s: &mut Session, p: &Value) -> Result<Value> {
|
||||||
|
let stray = bool_or(p, "strayPoints", true);
|
||||||
|
let unpainted = bool_or(p, "unpaintedObjects", true);
|
||||||
|
let empty_text = bool_or(p, "emptyTextPaths", true);
|
||||||
|
let found = {
|
||||||
|
let d = &s.doc()?.doc;
|
||||||
|
let mut v = vec![];
|
||||||
|
fn visit(n: &Node, parent_compound: bool, parent_clip: bool, first: bool, v: &mut Vec<NodeId>, opts: (bool, bool, bool)) {
|
||||||
|
let (stray, unpainted, empty_text) = opts;
|
||||||
|
match &n.kind {
|
||||||
|
NodeKind::Path { path, clipping, guide, .. } => {
|
||||||
|
if *guide || *clipping || (parent_clip && first) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if stray && path.anchor_count() <= 1 {
|
||||||
|
v.push(n.id);
|
||||||
|
} else if unpainted && !parent_compound && !is_painted(&n.appearance) {
|
||||||
|
v.push(n.id);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
NodeKind::Compound { children, .. } => {
|
||||||
|
if unpainted && !(parent_clip && first) && !is_painted(&n.appearance) {
|
||||||
|
v.push(n.id);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
for c in children {
|
||||||
|
visit(c, true, false, false, v, opts);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
NodeKind::Text(t) => {
|
||||||
|
if empty_text && t.plain_text().trim().is_empty() {
|
||||||
|
v.push(n.id);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
NodeKind::Layer { children, .. } => {
|
||||||
|
for c in children {
|
||||||
|
visit(c, false, false, false, v, opts);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
NodeKind::Group { children, clip } => {
|
||||||
|
for (i, c) in children.iter().enumerate() {
|
||||||
|
visit(c, false, *clip, i == 0, v, opts);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
_ => {}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for l in &d.layers {
|
||||||
|
visit(l, false, false, false, &mut v, (stray, unpainted, empty_text));
|
||||||
|
}
|
||||||
|
v.retain(|id| d.is_editable(*id));
|
||||||
|
v
|
||||||
|
};
|
||||||
|
if found.is_empty() {
|
||||||
|
return Ok(json!({ "removed": 0 }));
|
||||||
|
}
|
||||||
|
s.edit("Clean Up", |d, _| {
|
||||||
|
for id in &found {
|
||||||
|
if d.node(*id).is_some() {
|
||||||
|
d.remove(*id)?;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
|
})?;
|
||||||
|
Ok(json!({ "removed": found.len() }))
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------- Divide Objects Below ----------
|
||||||
|
|
||||||
|
fn divide_objects_below(s: &mut Session, _: &Value) -> Result<Value> {
|
||||||
|
let roots = selected_roots(s)?;
|
||||||
|
let [cutter_id] = roots[..] else { return Err(EngineError::Other("Divide Objects Below: select exactly one path".into())) };
|
||||||
|
let ids = s.edit("Divide Objects Below", |d, sel| {
|
||||||
|
let cutter = d.node(cutter_id).cloned().ok_or(EngineError::NoNode(cutter_id))?;
|
||||||
|
let Some((cpath, crule)) = node_path(&cutter).filter(|_| !matches!(cutter.kind, NodeKind::Text(_))) else {
|
||||||
|
return Err(EngineError::Other("Divide Objects Below: select a path".into()));
|
||||||
|
};
|
||||||
|
let Some(cb) = cpath.bounds() else { return Err(EngineError::Other("empty cutter".into())) };
|
||||||
|
let cutter_pos = d.index_path(cutter_id).unwrap_or_default();
|
||||||
|
let mut targets = vec![];
|
||||||
|
for l in &d.layers {
|
||||||
|
l.walk(&mut |n| {
|
||||||
|
if let NodeKind::Path { path, guide: false, clipping: false, .. } = &n.kind
|
||||||
|
&& path.is_closed()
|
||||||
|
{
|
||||||
|
targets.push(n.id);
|
||||||
|
}
|
||||||
|
if matches!(n.kind, NodeKind::Compound { .. }) {
|
||||||
|
targets.push(n.id);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
// Compound children are handled via their compound.
|
||||||
|
targets.retain(|id| !matches!(d.parent_of(*id).and_then(|p| d.node(p)).map(|p| &p.kind), Some(NodeKind::Compound { .. })));
|
||||||
|
targets.retain(|id| {
|
||||||
|
*id != cutter_id
|
||||||
|
&& d.is_editable(*id)
|
||||||
|
&& d.is_visible(*id)
|
||||||
|
&& d.index_path(*id).is_some_and(|ip| ip < cutter_pos)
|
||||||
|
&& d.node(*id).and_then(Node::geometric_bounds).is_some_and(|b| b.intersect(cb).area() > 0.0)
|
||||||
|
});
|
||||||
|
let mut out = vec![];
|
||||||
|
for t in targets {
|
||||||
|
let Some(n) = d.node(t).cloned() else { continue };
|
||||||
|
let Some((tp, tr)) = node_path(&n) else { continue };
|
||||||
|
let inside = po::boolean(&tp, tr, &cpath, crule, BoolOp::Intersect);
|
||||||
|
let outside = po::boolean(&tp, tr, &cpath, crule, BoolOp::Difference);
|
||||||
|
if inside.is_empty() || outside.is_empty() {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
let (par, idx, _) = d.position(t).ok_or(EngineError::NoNode(t))?;
|
||||||
|
d.remove(t)?;
|
||||||
|
for (k, piece) in [outside, inside].into_iter().enumerate() {
|
||||||
|
let node = shape_node(d, piece, Some(&n));
|
||||||
|
out.push(d.insert(par, idx + k, node)?);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
d.remove(cutter_id)?;
|
||||||
|
sel.set(out.iter().copied());
|
||||||
|
Ok(out)
|
||||||
|
})?;
|
||||||
|
Ok(ids_json(&ids))
|
||||||
|
}
|
||||||
|
|
||||||
@@ -3,8 +3,18 @@ name = "drawcraft-mcp"
|
|||||||
version.workspace = true
|
version.workspace = true
|
||||||
edition.workspace = true
|
edition.workspace = true
|
||||||
license.workspace = true
|
license.workspace = true
|
||||||
|
description = "DrawCraft MCP server: JSON-RPC 2.0 over stdio, driving a running app or a headless session"
|
||||||
|
|
||||||
[lints]
|
[lints]
|
||||||
workspace = true
|
workspace = true
|
||||||
|
|
||||||
[dependencies]
|
[dependencies]
|
||||||
|
drawcraft-engine = { workspace = true }
|
||||||
|
drawcraft-render = { workspace = true }
|
||||||
|
drawcraft-svg = { workspace = true }
|
||||||
|
drawcraft-format = { workspace = true }
|
||||||
|
drawcraft-tools = { workspace = true }
|
||||||
|
drawcraft-geom = { workspace = true }
|
||||||
|
drawcraft-doc = { workspace = true }
|
||||||
|
serde_json = { workspace = true }
|
||||||
|
log = { workspace = true }
|
||||||
|
|||||||
@@ -0,0 +1,105 @@
|
|||||||
|
//! Where MCP tool calls end up: a control-channel method call.
|
||||||
|
|
||||||
|
use std::io::{BufRead, BufReader, Write};
|
||||||
|
use std::net::{TcpStream, ToSocketAddrs};
|
||||||
|
use std::time::Duration;
|
||||||
|
|
||||||
|
use serde_json::{Value, json};
|
||||||
|
|
||||||
|
/// Something that answers control-channel methods (`engine.execute`, `document.inspect`,
|
||||||
|
/// `ui.pointer`, `ui.render`, `app.export`, …). See `drawcraft_ui_egui::control` for the list.
|
||||||
|
pub trait Backend {
|
||||||
|
/// Call one method. `Ok` carries the `result`, `Err` the error message.
|
||||||
|
fn call(&mut self, method: &str, params: Value) -> Result<Value, String>;
|
||||||
|
/// True when a real UI is attached (`ui.*` methods like `ui.key` / `ui.inspect` work).
|
||||||
|
fn has_ui(&self) -> bool;
|
||||||
|
/// Short human description ("headless", "remote 127.0.0.1:7979").
|
||||||
|
fn describe(&self) -> String;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A running DrawCraft app, reached through its loopback control port.
|
||||||
|
pub struct Remote {
|
||||||
|
addr: String,
|
||||||
|
conn: Option<(BufReader<TcpStream>, TcpStream)>,
|
||||||
|
next_id: u64,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Remote {
|
||||||
|
/// Connect to `addr` (e.g. `127.0.0.1:7979`), failing fast when nothing is listening.
|
||||||
|
pub fn connect(addr: &str) -> std::io::Result<Self> {
|
||||||
|
let mut r = Self { addr: addr.to_string(), conn: None, next_id: 1 };
|
||||||
|
r.reconnect()?;
|
||||||
|
Ok(r)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn addr(&self) -> &str {
|
||||||
|
&self.addr
|
||||||
|
}
|
||||||
|
|
||||||
|
fn reconnect(&mut self) -> std::io::Result<()> {
|
||||||
|
self.conn = None;
|
||||||
|
let mut last = std::io::Error::new(std::io::ErrorKind::NotFound, format!("cannot resolve {}", self.addr));
|
||||||
|
for sa in self.addr.to_socket_addrs()? {
|
||||||
|
match TcpStream::connect_timeout(&sa, Duration::from_millis(800)) {
|
||||||
|
Ok(s) => {
|
||||||
|
s.set_nodelay(true).ok();
|
||||||
|
// The app answers within 60 s (its own timeout); leave headroom.
|
||||||
|
s.set_read_timeout(Some(Duration::from_secs(90))).ok();
|
||||||
|
let read = s.try_clone()?;
|
||||||
|
self.conn = Some((BufReader::new(read), s));
|
||||||
|
return Ok(());
|
||||||
|
}
|
||||||
|
Err(e) => last = e,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Err(last)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn roundtrip(&mut self, line: &str) -> std::io::Result<String> {
|
||||||
|
if self.conn.is_none() {
|
||||||
|
self.reconnect()?;
|
||||||
|
}
|
||||||
|
let (reader, writer) = self.conn.as_mut().expect("connected");
|
||||||
|
writer.write_all(line.as_bytes())?;
|
||||||
|
writer.write_all(b"\n")?;
|
||||||
|
writer.flush()?;
|
||||||
|
let mut reply = String::new();
|
||||||
|
if reader.read_line(&mut reply)? == 0 {
|
||||||
|
return Err(std::io::Error::new(std::io::ErrorKind::UnexpectedEof, "control channel closed"));
|
||||||
|
}
|
||||||
|
Ok(reply)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Backend for Remote {
|
||||||
|
fn call(&mut self, method: &str, params: Value) -> Result<Value, String> {
|
||||||
|
let id = self.next_id;
|
||||||
|
self.next_id += 1;
|
||||||
|
let line = json!({"id": id, "method": method, "params": params}).to_string();
|
||||||
|
// One retry with a fresh connection (the app may have restarted).
|
||||||
|
let reply = match self.roundtrip(&line) {
|
||||||
|
Ok(r) => r,
|
||||||
|
Err(_) => {
|
||||||
|
self.conn = None;
|
||||||
|
self.roundtrip(&line).map_err(|e| {
|
||||||
|
self.conn = None;
|
||||||
|
format!("DrawCraft app at {} is not reachable: {e}", self.addr)
|
||||||
|
})?
|
||||||
|
}
|
||||||
|
};
|
||||||
|
let v: Value = serde_json::from_str(reply.trim()).map_err(|e| format!("bad reply from app: {e}"))?;
|
||||||
|
if v.get("ok").and_then(Value::as_bool) == Some(true) {
|
||||||
|
Ok(v.get("result").cloned().unwrap_or(Value::Null))
|
||||||
|
} else {
|
||||||
|
Err(v.get("error").and_then(Value::as_str).unwrap_or("unknown error").to_string())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn has_ui(&self) -> bool {
|
||||||
|
true
|
||||||
|
}
|
||||||
|
|
||||||
|
fn describe(&self) -> String {
|
||||||
|
format!("remote {}", self.addr)
|
||||||
|
}
|
||||||
|
}
|
||||||
+31
-1
@@ -1 +1,31 @@
|
|||||||
//! stub
|
//! DrawCraft's MCP server.
|
||||||
|
//!
|
||||||
|
//! [Model Context Protocol](https://modelcontextprotocol.io) over stdio: newline-delimited
|
||||||
|
//! JSON-RPC 2.0, hand-written (no async runtime). The server exposes DrawCraft as a set of MCP
|
||||||
|
//! tools and resources and forwards everything to a [`Backend`]:
|
||||||
|
//!
|
||||||
|
//! - [`Remote`] talks to a running desktop app through its loopback JSON-lines control channel
|
||||||
|
//! (`drawcraft --control 7979`): one `{"id","method","params"}` line in, one
|
||||||
|
//! `{"id","ok","result"|"error"}` line out.
|
||||||
|
//! - [`Headless`] hosts an in-process [`drawcraft_engine::Session`] and implements the same
|
||||||
|
//! control-channel method names itself (rendering screenshots with `drawcraft-render`), so agents
|
||||||
|
//! can draw and look at the result without a window.
|
||||||
|
//!
|
||||||
|
//! Entry points: [`Server::serve`] (stdio loop) and [`Server::handle_line`] (one message).
|
||||||
|
#![forbid(unsafe_code)]
|
||||||
|
|
||||||
|
mod backend;
|
||||||
|
mod headless;
|
||||||
|
mod server;
|
||||||
|
mod tools;
|
||||||
|
|
||||||
|
pub use backend::{Backend, Remote};
|
||||||
|
pub use headless::Headless;
|
||||||
|
pub use server::{PROTOCOL_VERSION, Server};
|
||||||
|
pub use tools::{ToolResult, call_tool, tool_definitions};
|
||||||
|
|
||||||
|
/// Default control-channel address of the desktop app.
|
||||||
|
pub const DEFAULT_ADDR: &str = "127.0.0.1:7979";
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests;
|
||||||
|
|||||||
@@ -308,8 +308,9 @@ impl DrawcraftApp {
|
|||||||
if self.fonts_ready {
|
if self.fonts_ready {
|
||||||
shortcuts::handle(self, ctx);
|
shortcuts::handle(self, ctx);
|
||||||
}
|
}
|
||||||
let dropped = ctx.input(|i| i.raw.dropped_files.clone());
|
// Native only: the web host reads dropped files asynchronously and feeds `Services::inbox`.
|
||||||
for f in dropped {
|
#[cfg(not(target_arch = "wasm32"))]
|
||||||
|
for f in ctx.input(|i| i.raw.dropped_files.clone()) {
|
||||||
let path = f.path().to_path_buf();
|
let path = f.path().to_path_buf();
|
||||||
let name = path.file_name().map(|n| n.to_string_lossy().to_string()).unwrap_or_else(|| "dropped".into());
|
let name = path.file_name().map(|n| n.to_string_lossy().to_string()).unwrap_or_else(|| "dropped".into());
|
||||||
match f.bytes() {
|
match f.bytes() {
|
||||||
|
|||||||
@@ -0,0 +1,21 @@
|
|||||||
|
# Control protocol
|
||||||
|
|
||||||
|
`drawcraft --control <port>` listens on `127.0.0.1:<port>` (loopback only). One JSON request per line:
|
||||||
|
`{"id": 1, "method": "ui.inspect", "params": {}}` → `{"id": 1, "ok": true, "result": {...}}` or `{"id":1,"ok":false,"error":"..."}`.
|
||||||
|
|
||||||
|
| Method | Params | |
|
||||||
|
|---|---|---|
|
||||||
|
| `engine.execute` | `{command, params}` | run any engine or UI command (see `engine.commands`) |
|
||||||
|
| `engine.commands` | | every command with label, shortcut, params doc, enablement |
|
||||||
|
| `document.inspect` | | layer tree, selection, history, paint defaults |
|
||||||
|
| `ui.inspect` | | tool, UI state, view, canvas rect, window size, perf |
|
||||||
|
| `ui.menu.list` / `ui.menu.invoke` | `{command, params}` | the full menu tree / invoke an item |
|
||||||
|
| `ui.tool.select` / `ui.tool.list` | `{tool}` | |
|
||||||
|
| `ui.pointer` | `{events:[{kind: down|drag|up|move|doubleclick, x, y, space?: "doc"|"screen", mods?}]}` | drive the active tool exactly like the mouse |
|
||||||
|
| `ui.key` / `ui.text` | `{key, shift?, alt?, cmd?}` / `{text}` | synthetic keyboard input |
|
||||||
|
| `ui.set` | `{brightness?, panel?, rulers?, outline?, grid?, smartGuides?, boundingBox?, controlBar?}` | |
|
||||||
|
| `ui.dialog.set` / `.confirm` / `.cancel` | `{field, value}` | fill and submit the open dialog |
|
||||||
|
| `ui.screenshot` | `{path?}` | capture the window (PNG) |
|
||||||
|
| `ui.render` | `{path?, scale?}` | render the artboard headlessly (PNG) |
|
||||||
|
| `ui.resize` / `ui.focus` | | |
|
||||||
|
| `app.open` / `app.save` / `app.export` / `app.quit` | `{path}` / `{path?}` / `{format, path, scale?}` | |
|
||||||
@@ -0,0 +1,489 @@
|
|||||||
|
//! Dependency layering rules (plan/architecture.md §3).
|
||||||
|
//!
|
||||||
|
//! The rule engine works on a small, metadata-independent model so it can be
|
||||||
|
//! unit-tested; `from_metadata` builds that model from `cargo metadata`.
|
||||||
|
|
||||||
|
use serde_json::Value;
|
||||||
|
|
||||||
|
/// Where a workspace crate sits.
|
||||||
|
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||||
|
pub enum Class {
|
||||||
|
/// Regular layered crate.
|
||||||
|
Layer(u8),
|
||||||
|
/// Layer 0, and additionally may depend on no workspace crate at all.
|
||||||
|
Standalone,
|
||||||
|
/// Test tooling: may depend on anything up to L5 (it sits at L6 for rule
|
||||||
|
/// purposes); other crates may use it only as a dev-dependency.
|
||||||
|
Testkit,
|
||||||
|
/// Binaries and build tooling: exempt from the rules.
|
||||||
|
Exempt,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Class {
|
||||||
|
fn layer(self) -> Option<u8> {
|
||||||
|
match self {
|
||||||
|
Class::Layer(l) => Some(l),
|
||||||
|
Class::Standalone => Some(0),
|
||||||
|
Class::Testkit => Some(6),
|
||||||
|
Class::Exempt => None,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The layering table. Names are package names without the `drawcraft-`
|
||||||
|
/// prefix.
|
||||||
|
pub const TABLE: &[(&str, Class)] = &[
|
||||||
|
("geom", Class::Layer(0)),
|
||||||
|
("color", Class::Layer(0)),
|
||||||
|
("doc", Class::Layer(1)),
|
||||||
|
("pathops", Class::Layer(2)),
|
||||||
|
("text", Class::Layer(2)),
|
||||||
|
("effects", Class::Layer(2)),
|
||||||
|
("render", Class::Layer(3)),
|
||||||
|
("svg", Class::Layer(3)),
|
||||||
|
("pdf", Class::Layer(3)),
|
||||||
|
("format", Class::Layer(3)),
|
||||||
|
("tools", Class::Layer(4)),
|
||||||
|
("engine", Class::Layer(5)),
|
||||||
|
("ui-egui", Class::Layer(6)),
|
||||||
|
("mcp", Class::Layer(6)),
|
||||||
|
("testkit", Class::Testkit),
|
||||||
|
// L7 apps and tooling
|
||||||
|
("drawcraft", Class::Exempt),
|
||||||
|
("cli", Class::Exempt),
|
||||||
|
("web", Class::Exempt),
|
||||||
|
("xtask", Class::Exempt),
|
||||||
|
];
|
||||||
|
|
||||||
|
/// Explicit orderings *within* a layer (earlier may be used by later).
|
||||||
|
/// The L0 foundation is a small chain: `raster` builds on `color` and
|
||||||
|
/// `geom`, which the §3 diagram draws on one line. The GPU backend (`gpu`)
|
||||||
|
/// reuses the CPU reference (`compose`) for LUTs and parity tests.
|
||||||
|
pub const INTRA_LAYER_ORDER: &[&[&str]] = &[&["geom", "color"], &["pathops", "effects"]];
|
||||||
|
|
||||||
|
fn intra_layer_allowed(from: &str, to: &str) -> bool {
|
||||||
|
let (from, to) = (short_name(from), short_name(to));
|
||||||
|
INTRA_LAYER_ORDER.iter().any(|chain| {
|
||||||
|
match (
|
||||||
|
chain.iter().position(|n| *n == from),
|
||||||
|
chain.iter().position(|n| *n == to),
|
||||||
|
) {
|
||||||
|
(Some(f), Some(t)) => t < f,
|
||||||
|
_ => false,
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/// External crates that constitute a UI toolkit / windowing dependency.
|
||||||
|
/// Entries ending in `*` are prefixes.
|
||||||
|
pub const UI_CRATES: &[&str] = &["egui", "eframe", "winit", "egui_kittest", "rfd", "bevy*"];
|
||||||
|
|
||||||
|
/// First layer allowed to use UI crates.
|
||||||
|
pub const UI_MIN_LAYER: u8 = 6;
|
||||||
|
|
||||||
|
pub fn short_name(pkg: &str) -> &str {
|
||||||
|
pkg.strip_prefix("drawcraft-").unwrap_or(pkg)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn classify(pkg: &str) -> Option<Class> {
|
||||||
|
let s = short_name(pkg);
|
||||||
|
TABLE.iter().find(|(n, _)| *n == s).map(|(_, c)| *c)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn is_ui_crate(name: &str) -> bool {
|
||||||
|
UI_CRATES.iter().any(|p| match p.strip_suffix('*') {
|
||||||
|
Some(prefix) => name.starts_with(prefix),
|
||||||
|
None => name == *p,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||||
|
pub enum DepKind {
|
||||||
|
Normal,
|
||||||
|
Dev,
|
||||||
|
Build,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone)]
|
||||||
|
pub struct Dep {
|
||||||
|
pub name: String,
|
||||||
|
pub kind: DepKind,
|
||||||
|
/// `true` if the dependency is a workspace member.
|
||||||
|
pub workspace: bool,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone)]
|
||||||
|
pub struct Crate {
|
||||||
|
pub name: String,
|
||||||
|
pub deps: Vec<Dep>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||||
|
pub enum Violation {
|
||||||
|
Unregistered {
|
||||||
|
krate: String,
|
||||||
|
},
|
||||||
|
Upward {
|
||||||
|
krate: String,
|
||||||
|
dep: String,
|
||||||
|
from: u8,
|
||||||
|
to: u8,
|
||||||
|
kind: DepKind,
|
||||||
|
},
|
||||||
|
StandaloneHasWorkspaceDep {
|
||||||
|
krate: String,
|
||||||
|
dep: String,
|
||||||
|
},
|
||||||
|
TestkitAsNormalDep {
|
||||||
|
krate: String,
|
||||||
|
},
|
||||||
|
UiBelowL6 {
|
||||||
|
krate: String,
|
||||||
|
dep: String,
|
||||||
|
layer: u8,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
impl std::fmt::Display for Violation {
|
||||||
|
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||||
|
match self {
|
||||||
|
Violation::Unregistered { krate } => write!(
|
||||||
|
f,
|
||||||
|
"{krate}: unknown workspace crate; register it in xtask/src/layers.rs TABLE (see plan/architecture.md §3)"
|
||||||
|
),
|
||||||
|
Violation::Upward {
|
||||||
|
krate,
|
||||||
|
dep,
|
||||||
|
from,
|
||||||
|
to,
|
||||||
|
kind,
|
||||||
|
} => write!(
|
||||||
|
f,
|
||||||
|
"{krate} (L{from}) -> {dep} (L{to}) [{kind:?}]: may only depend on strictly lower layers"
|
||||||
|
),
|
||||||
|
Violation::StandaloneHasWorkspaceDep { krate, dep } => {
|
||||||
|
write!(
|
||||||
|
f,
|
||||||
|
"{krate}: standalone crate must not depend on workspace crate {dep}"
|
||||||
|
)
|
||||||
|
}
|
||||||
|
Violation::TestkitAsNormalDep { krate } => {
|
||||||
|
write!(
|
||||||
|
f,
|
||||||
|
"{krate}: drawcraft-testkit may only be a dev-dependency"
|
||||||
|
)
|
||||||
|
}
|
||||||
|
Violation::UiBelowL6 { krate, dep, layer } => {
|
||||||
|
write!(
|
||||||
|
f,
|
||||||
|
"{krate} (L{layer}) depends on UI crate `{dep}`; UI toolkits are only allowed in L6+"
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Check all rules. Returns violations sorted for stable output.
|
||||||
|
pub fn check(crates: &[Crate]) -> Vec<Violation> {
|
||||||
|
let mut out = Vec::new();
|
||||||
|
for c in crates {
|
||||||
|
let Some(class) = classify(&c.name) else {
|
||||||
|
out.push(Violation::Unregistered {
|
||||||
|
krate: c.name.clone(),
|
||||||
|
});
|
||||||
|
continue;
|
||||||
|
};
|
||||||
|
if class == Class::Exempt {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
let layer = class.layer().unwrap_or(0);
|
||||||
|
for d in &c.deps {
|
||||||
|
// Self dev-dependencies (e.g. to enable features in tests) are fine.
|
||||||
|
if d.name == c.name {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if d.workspace {
|
||||||
|
if class == Class::Standalone {
|
||||||
|
out.push(Violation::StandaloneHasWorkspaceDep {
|
||||||
|
krate: c.name.clone(),
|
||||||
|
dep: d.name.clone(),
|
||||||
|
});
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
match classify(&d.name) {
|
||||||
|
// Unregistered deps are reported on their own entry.
|
||||||
|
None => {}
|
||||||
|
Some(Class::Testkit) => {
|
||||||
|
if d.kind != DepKind::Dev {
|
||||||
|
out.push(Violation::TestkitAsNormalDep {
|
||||||
|
krate: c.name.clone(),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Some(dc) => {
|
||||||
|
let to = dc.layer().unwrap_or(u8::MAX);
|
||||||
|
if to >= layer && !(to == layer && intra_layer_allowed(&c.name, &d.name)) {
|
||||||
|
out.push(Violation::Upward {
|
||||||
|
krate: c.name.clone(),
|
||||||
|
dep: d.name.clone(),
|
||||||
|
from: layer,
|
||||||
|
to: if to == u8::MAX { 7 } else { to },
|
||||||
|
kind: d.kind,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else if layer < UI_MIN_LAYER && is_ui_crate(&d.name) {
|
||||||
|
out.push(Violation::UiBelowL6 {
|
||||||
|
krate: c.name.clone(),
|
||||||
|
dep: d.name.clone(),
|
||||||
|
layer,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
out.sort_by_key(|v| v.to_string());
|
||||||
|
out.dedup();
|
||||||
|
out
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Build the model from `cargo metadata --format-version 1 --no-deps`.
|
||||||
|
pub fn from_metadata(meta: &Value) -> Result<Vec<Crate>, String> {
|
||||||
|
let pkgs = meta["packages"]
|
||||||
|
.as_array()
|
||||||
|
.ok_or("metadata: no packages array")?;
|
||||||
|
let members: Vec<&str> = pkgs.iter().filter_map(|p| p["name"].as_str()).collect();
|
||||||
|
let mut out = Vec::new();
|
||||||
|
for p in pkgs {
|
||||||
|
let name = p["name"].as_str().ok_or("package without name")?.to_owned();
|
||||||
|
let mut deps = Vec::new();
|
||||||
|
for d in p["dependencies"].as_array().into_iter().flatten() {
|
||||||
|
let dname = d["name"].as_str().unwrap_or_default().to_owned();
|
||||||
|
let kind = match d["kind"].as_str() {
|
||||||
|
Some("dev") => DepKind::Dev,
|
||||||
|
Some("build") => DepKind::Build,
|
||||||
|
_ => DepKind::Normal,
|
||||||
|
};
|
||||||
|
let workspace = members.contains(&dname.as_str()) || d["path"].is_string();
|
||||||
|
deps.push(Dep {
|
||||||
|
name: dname,
|
||||||
|
kind,
|
||||||
|
workspace,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
out.push(Crate { name, deps });
|
||||||
|
}
|
||||||
|
out.sort_by(|a, b| a.name.cmp(&b.name));
|
||||||
|
Ok(out)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn describe(class: Option<Class>) -> String {
|
||||||
|
match class {
|
||||||
|
Some(Class::Layer(l)) => format!("L{l}"),
|
||||||
|
Some(Class::Standalone) => "L0 standalone".into(),
|
||||||
|
Some(Class::Testkit) => "testkit".into(),
|
||||||
|
Some(Class::Exempt) => "exempt".into(),
|
||||||
|
None => "UNREGISTERED".into(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
fn c(name: &str, deps: &[(&str, DepKind, bool)]) -> Crate {
|
||||||
|
Crate {
|
||||||
|
name: name.into(),
|
||||||
|
deps: deps
|
||||||
|
.iter()
|
||||||
|
.map(|(n, k, w)| Dep {
|
||||||
|
name: (*n).into(),
|
||||||
|
kind: *k,
|
||||||
|
workspace: *w,
|
||||||
|
})
|
||||||
|
.collect(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
use DepKind::*;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn clean_downward_graph_passes() {
|
||||||
|
let g = [
|
||||||
|
c("drawcraft-geom", &[("kurbo", Normal, false)]),
|
||||||
|
c("drawcraft-doc", &[("drawcraft-geom", Normal, true)]),
|
||||||
|
c(
|
||||||
|
"drawcraft-engine",
|
||||||
|
&[
|
||||||
|
("drawcraft-doc", Normal, true),
|
||||||
|
("drawcraft-testkit", Dev, true),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
c(
|
||||||
|
"drawcraft-ui-egui",
|
||||||
|
&[("drawcraft-engine", Normal, true), ("egui", Normal, false)],
|
||||||
|
),
|
||||||
|
c("drawcraft-cli", &[("drawcraft-ui-egui", Normal, true)]),
|
||||||
|
];
|
||||||
|
assert!(check(&g).is_empty(), "{:?}", check(&g));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn upward_dependency_flagged() {
|
||||||
|
let v = check(&[c("drawcraft-doc", &[("drawcraft-engine", Normal, true)])]);
|
||||||
|
assert!(matches!(v[..], [Violation::Upward { from: 1, to: 5, .. }]));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn sideways_dependency_flagged() {
|
||||||
|
let v = check(&[c("drawcraft-ops", &[("drawcraft-algo", Normal, true)])]);
|
||||||
|
assert!(matches!(v[..], [Violation::Upward { from: 2, to: 2, .. }]));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn l0_foundation_chain_allowed_one_way() {
|
||||||
|
assert!(
|
||||||
|
check(&[c(
|
||||||
|
"drawcraft-raster",
|
||||||
|
&[
|
||||||
|
("drawcraft-color", Normal, true),
|
||||||
|
("drawcraft-geom", Normal, true)
|
||||||
|
]
|
||||||
|
)])
|
||||||
|
.is_empty()
|
||||||
|
);
|
||||||
|
assert!(check(&[c("drawcraft-color", &[("drawcraft-geom", Normal, true)])]).is_empty());
|
||||||
|
let v = check(&[c("drawcraft-geom", &[("drawcraft-raster", Normal, true)])]);
|
||||||
|
assert!(matches!(v[..], [Violation::Upward { from: 0, to: 0, .. }]));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn self_dev_dependency_ignored() {
|
||||||
|
assert!(check(&[c("drawcraft-psd", &[("drawcraft-psd", Dev, true)])]).is_empty());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn upward_dev_dependency_flagged() {
|
||||||
|
let v = check(&[c("drawcraft-geom", &[("drawcraft-doc", Dev, true)])]);
|
||||||
|
assert!(matches!(v[..], [Violation::Upward { kind: Dev, .. }]));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn standalone_crates_have_no_workspace_deps() {
|
||||||
|
for s in [
|
||||||
|
"drawcraft-psd",
|
||||||
|
"drawcraft-codecs",
|
||||||
|
"drawcraft-adobe-assets",
|
||||||
|
] {
|
||||||
|
let v = check(&[c(s, &[("drawcraft-geom", Normal, true)])]);
|
||||||
|
assert!(
|
||||||
|
matches!(v[..], [Violation::StandaloneHasWorkspaceDep { .. }]),
|
||||||
|
"{s}"
|
||||||
|
);
|
||||||
|
assert!(check(&[c(s, &[("image", Normal, false)])]).is_empty());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn standalone_is_usable_from_higher_layers() {
|
||||||
|
assert!(check(&[c("drawcraft-io", &[("drawcraft-psd", Normal, true)])]).is_empty());
|
||||||
|
let v = check(&[c("drawcraft-geom", &[("drawcraft-codecs", Normal, true)])]);
|
||||||
|
assert!(matches!(v[..], [Violation::Upward { from: 0, to: 0, .. }]));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn ui_crates_below_l6_flagged() {
|
||||||
|
for dep in [
|
||||||
|
"egui",
|
||||||
|
"eframe",
|
||||||
|
"winit",
|
||||||
|
"egui_kittest",
|
||||||
|
"rfd",
|
||||||
|
"bevy_ecs",
|
||||||
|
"bevy",
|
||||||
|
] {
|
||||||
|
let v = check(&[c("drawcraft-engine", &[(dep, Normal, false)])]);
|
||||||
|
assert!(
|
||||||
|
matches!(v[..], [Violation::UiBelowL6 { layer: 5, .. }]),
|
||||||
|
"{dep}"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
assert!(
|
||||||
|
check(&[c(
|
||||||
|
"drawcraft-engine",
|
||||||
|
&[("egui_extras_not", Normal, false)]
|
||||||
|
)])
|
||||||
|
.is_empty()
|
||||||
|
);
|
||||||
|
assert!(check(&[c("drawcraft-platform", &[("winit", Normal, false)])]).is_empty());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn unregistered_crate_is_error() {
|
||||||
|
let v = check(&[c("drawcraft-mystery", &[])]);
|
||||||
|
assert!(
|
||||||
|
matches!(&v[..], [Violation::Unregistered { krate }] if krate == "drawcraft-mystery")
|
||||||
|
);
|
||||||
|
assert!(v[0].to_string().contains("register"));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn testkit_only_as_dev_dependency() {
|
||||||
|
let v = check(&[c(
|
||||||
|
"drawcraft-raster",
|
||||||
|
&[("drawcraft-testkit", Normal, true)],
|
||||||
|
)]);
|
||||||
|
assert!(matches!(v[..], [Violation::TestkitAsNormalDep { .. }]));
|
||||||
|
assert!(check(&[c("drawcraft-raster", &[("drawcraft-testkit", Dev, true)])]).is_empty());
|
||||||
|
// testkit itself may use anything up to L5 but not L6 crates.
|
||||||
|
assert!(
|
||||||
|
check(&[c(
|
||||||
|
"drawcraft-testkit",
|
||||||
|
&[("drawcraft-engine", Normal, true)]
|
||||||
|
)])
|
||||||
|
.is_empty()
|
||||||
|
);
|
||||||
|
assert!(
|
||||||
|
!check(&[c(
|
||||||
|
"drawcraft-testkit",
|
||||||
|
&[("drawcraft-ui-egui", Normal, true)]
|
||||||
|
)])
|
||||||
|
.is_empty()
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn apps_and_xtask_exempt() {
|
||||||
|
for app in ["drawcraft", "drawcraft-cli", "drawcraft-web", "xtask"] {
|
||||||
|
assert!(
|
||||||
|
check(&[c(
|
||||||
|
app,
|
||||||
|
&[
|
||||||
|
("egui", Normal, false),
|
||||||
|
("drawcraft-ui-egui", Normal, true)
|
||||||
|
]
|
||||||
|
)])
|
||||||
|
.is_empty()
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn metadata_parsing() {
|
||||||
|
let meta: Value = serde_json::from_str(
|
||||||
|
r#"{"packages":[
|
||||||
|
{"name":"drawcraft-doc","dependencies":[
|
||||||
|
{"name":"drawcraft-geom","kind":null,"path":"/x/crates/geom"},
|
||||||
|
{"name":"serde","kind":null},
|
||||||
|
{"name":"proptest","kind":"dev"}]},
|
||||||
|
{"name":"drawcraft-geom","dependencies":[]}
|
||||||
|
]}"#,
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
let g = from_metadata(&meta).unwrap();
|
||||||
|
assert_eq!(g.len(), 2);
|
||||||
|
let doc = g.iter().find(|c| c.name == "drawcraft-doc").unwrap();
|
||||||
|
assert!(doc.deps[0].workspace && !doc.deps[1].workspace);
|
||||||
|
assert_eq!(doc.deps[2].kind, Dev);
|
||||||
|
assert!(check(&g).is_empty());
|
||||||
|
}
|
||||||
|
}
|
||||||
+272
-1
@@ -1 +1,272 @@
|
|||||||
fn main() {}
|
//! Workspace tooling: `cargo xtask <command>`.
|
||||||
|
//!
|
||||||
|
//! Pure Rust (std + serde_json). External tools (`cargo`, `curl`, `tar`) are
|
||||||
|
//! invoked through `std::process::Command`.
|
||||||
|
|
||||||
|
mod layers;
|
||||||
|
mod stats;
|
||||||
|
|
||||||
|
use std::path::{Path, PathBuf};
|
||||||
|
use std::process::{Command, ExitCode};
|
||||||
|
|
||||||
|
const USAGE: &str = "\
|
||||||
|
usage: cargo xtask <command>
|
||||||
|
|
||||||
|
commands:
|
||||||
|
layers enforce the crate dependency layering (plan/architecture.md §3)
|
||||||
|
wasm cargo check --target wasm32-unknown-unknown for the wasm-safe crates
|
||||||
|
ci fmt --check, clippy -D warnings, test, layers, wasm (stops at first failure)
|
||||||
|
corpus [--download]
|
||||||
|
show where test corpora live; --download fetches PngSuite into corpus/pngsuite
|
||||||
|
stats [--exact] count tests and lines per crate (--exact: ask the test harness via `-- --list`)
|
||||||
|
";
|
||||||
|
|
||||||
|
fn main() -> ExitCode {
|
||||||
|
let args: Vec<String> = std::env::args().skip(1).collect();
|
||||||
|
let rest: Vec<&str> = args.iter().skip(1).map(String::as_str).collect();
|
||||||
|
let result = match args.first().map(String::as_str) {
|
||||||
|
Some("layers") => cmd_layers(),
|
||||||
|
Some("wasm") => cmd_wasm(),
|
||||||
|
Some("ci") => cmd_ci(),
|
||||||
|
Some("corpus") => cmd_corpus(rest.contains(&"--download")),
|
||||||
|
Some("stats") => stats::run(&root(), rest.contains(&"--exact")),
|
||||||
|
Some("-h" | "--help" | "help") | None => {
|
||||||
|
print!("{USAGE}");
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
Some(other) => Err(format!("unknown command `{other}`\n\n{USAGE}")),
|
||||||
|
};
|
||||||
|
match result {
|
||||||
|
Ok(()) => ExitCode::SUCCESS,
|
||||||
|
Err(e) => {
|
||||||
|
eprintln!("error: {e}");
|
||||||
|
ExitCode::FAILURE
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Workspace root (parent of the xtask crate).
|
||||||
|
pub fn root() -> PathBuf {
|
||||||
|
Path::new(env!("CARGO_MANIFEST_DIR"))
|
||||||
|
.parent()
|
||||||
|
.expect("xtask has a parent dir")
|
||||||
|
.to_path_buf()
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn cargo() -> Command {
|
||||||
|
let mut c = Command::new(std::env::var("CARGO").unwrap_or_else(|_| "cargo".into()));
|
||||||
|
c.current_dir(root());
|
||||||
|
c
|
||||||
|
}
|
||||||
|
|
||||||
|
fn run(mut cmd: Command, what: &str) -> Result<(), String> {
|
||||||
|
eprintln!("$ {what}");
|
||||||
|
let status = cmd
|
||||||
|
.status()
|
||||||
|
.map_err(|e| format!("{what}: failed to spawn: {e}"))?;
|
||||||
|
if status.success() {
|
||||||
|
Ok(())
|
||||||
|
} else {
|
||||||
|
Err(format!("{what}: exited with {status}"))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn metadata() -> Result<serde_json::Value, String> {
|
||||||
|
let out = cargo()
|
||||||
|
.args(["metadata", "--format-version", "1", "--no-deps"])
|
||||||
|
.output()
|
||||||
|
.map_err(|e| format!("cargo metadata: {e}"))?;
|
||||||
|
if !out.status.success() {
|
||||||
|
return Err(format!(
|
||||||
|
"cargo metadata failed:\n{}",
|
||||||
|
String::from_utf8_lossy(&out.stderr)
|
||||||
|
));
|
||||||
|
}
|
||||||
|
serde_json::from_slice(&out.stdout).map_err(|e| format!("cargo metadata: bad JSON: {e}"))
|
||||||
|
}
|
||||||
|
|
||||||
|
fn cmd_layers() -> Result<(), String> {
|
||||||
|
let crates = layers::from_metadata(&metadata()?)?;
|
||||||
|
println!("Dependency layering (plan/architecture.md §3)\n");
|
||||||
|
println!("{:<28} {:<14} workspace deps", "crate", "layer");
|
||||||
|
for c in &crates {
|
||||||
|
let ws: Vec<String> = c
|
||||||
|
.deps
|
||||||
|
.iter()
|
||||||
|
.filter(|d| d.workspace)
|
||||||
|
.map(|d| {
|
||||||
|
let k = match d.kind {
|
||||||
|
layers::DepKind::Normal => "",
|
||||||
|
layers::DepKind::Dev => " (dev)",
|
||||||
|
layers::DepKind::Build => " (build)",
|
||||||
|
};
|
||||||
|
format!("{}{k}", layers::short_name(&d.name))
|
||||||
|
})
|
||||||
|
.collect();
|
||||||
|
println!(
|
||||||
|
"{:<28} {:<14} {}",
|
||||||
|
c.name,
|
||||||
|
layers::describe(layers::classify(&c.name)),
|
||||||
|
ws.join(", ")
|
||||||
|
);
|
||||||
|
}
|
||||||
|
let violations = layers::check(&crates);
|
||||||
|
println!();
|
||||||
|
if violations.is_empty() {
|
||||||
|
println!("OK: {} crates, no layering violations.", crates.len());
|
||||||
|
Ok(())
|
||||||
|
} else {
|
||||||
|
println!("{} violation(s):", violations.len());
|
||||||
|
for v in &violations {
|
||||||
|
println!(" - {v}");
|
||||||
|
}
|
||||||
|
Err(format!("{} layering violation(s)", violations.len()))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Workspace packages that must build for wasm32: all L0–L5 crates plus
|
||||||
|
/// the egui shell.
|
||||||
|
fn wasm_set() -> Result<Vec<String>, String> {
|
||||||
|
let crates = layers::from_metadata(&metadata()?)?;
|
||||||
|
Ok(crates
|
||||||
|
.into_iter()
|
||||||
|
.filter(|c| match layers::classify(&c.name) {
|
||||||
|
Some(layers::Class::Layer(l)) => l <= 5 || layers::short_name(&c.name) == "ui-egui",
|
||||||
|
Some(layers::Class::Standalone) => true,
|
||||||
|
_ => false,
|
||||||
|
})
|
||||||
|
.map(|c| c.name)
|
||||||
|
.collect())
|
||||||
|
}
|
||||||
|
|
||||||
|
fn cmd_wasm() -> Result<(), String> {
|
||||||
|
let set = wasm_set()?;
|
||||||
|
let mut results = Vec::new();
|
||||||
|
for pkg in &set {
|
||||||
|
let mut c = cargo();
|
||||||
|
c.args(["check", "--target", "wasm32-unknown-unknown", "-p", pkg]);
|
||||||
|
let ok = run(
|
||||||
|
c,
|
||||||
|
&format!("cargo check --target wasm32-unknown-unknown -p {pkg}"),
|
||||||
|
)
|
||||||
|
.is_ok();
|
||||||
|
results.push((pkg.clone(), ok));
|
||||||
|
}
|
||||||
|
println!("\nwasm32-unknown-unknown check:");
|
||||||
|
for (p, ok) in &results {
|
||||||
|
println!(" {:<6} {p}", if *ok { "ok" } else { "FAIL" });
|
||||||
|
}
|
||||||
|
let failed = results.iter().filter(|r| !r.1).count();
|
||||||
|
if failed == 0 {
|
||||||
|
Ok(())
|
||||||
|
} else {
|
||||||
|
Err(format!("{failed} crate(s) failed the wasm check"))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn cmd_ci() -> Result<(), String> {
|
||||||
|
type Step = (&'static str, Box<dyn Fn() -> Result<(), String>>);
|
||||||
|
let steps: Vec<Step> = vec![
|
||||||
|
(
|
||||||
|
"fmt",
|
||||||
|
Box::new(|| {
|
||||||
|
let mut c = cargo();
|
||||||
|
c.args(["fmt", "--all", "--", "--check"]);
|
||||||
|
run(c, "cargo fmt --all -- --check")
|
||||||
|
}),
|
||||||
|
),
|
||||||
|
(
|
||||||
|
"clippy",
|
||||||
|
Box::new(|| {
|
||||||
|
let mut c = cargo();
|
||||||
|
c.args([
|
||||||
|
"clippy",
|
||||||
|
"--workspace",
|
||||||
|
"--all-targets",
|
||||||
|
"--",
|
||||||
|
"-D",
|
||||||
|
"warnings",
|
||||||
|
]);
|
||||||
|
run(c, "cargo clippy --workspace --all-targets -- -D warnings")
|
||||||
|
}),
|
||||||
|
),
|
||||||
|
(
|
||||||
|
"test",
|
||||||
|
Box::new(|| {
|
||||||
|
let mut c = cargo();
|
||||||
|
c.args(["test", "--workspace"]);
|
||||||
|
run(c, "cargo test --workspace")
|
||||||
|
}),
|
||||||
|
),
|
||||||
|
("layers", Box::new(cmd_layers)),
|
||||||
|
("wasm", Box::new(cmd_wasm)),
|
||||||
|
];
|
||||||
|
let mut done = Vec::new();
|
||||||
|
for (name, f) in &steps {
|
||||||
|
eprintln!("\n=== ci: {name} ===");
|
||||||
|
if let Err(e) = f() {
|
||||||
|
println!("\nCI summary:");
|
||||||
|
for d in &done {
|
||||||
|
println!(" ok {d}");
|
||||||
|
}
|
||||||
|
println!(" FAIL {name}: {e}");
|
||||||
|
for (n, _) in steps.iter().skip(done.len() + 1) {
|
||||||
|
println!(" skip {n}");
|
||||||
|
}
|
||||||
|
return Err(format!("ci failed at `{name}`"));
|
||||||
|
}
|
||||||
|
done.push(*name);
|
||||||
|
}
|
||||||
|
println!(
|
||||||
|
"\nCI summary: all {} steps passed ({})",
|
||||||
|
done.len(),
|
||||||
|
done.join(", ")
|
||||||
|
);
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
const PNGSUITE_URL: &str = "http://www.schaik.com/pngsuite/PngSuite-2017jul19.tgz";
|
||||||
|
|
||||||
|
fn cmd_corpus(download: bool) -> Result<(), String> {
|
||||||
|
let corpus = root().join("corpus");
|
||||||
|
println!(
|
||||||
|
"Test corpora live under {} (git-ignored, never committed).
|
||||||
|
Tests that use a corpus skip cleanly when it is absent.
|
||||||
|
|
||||||
|
corpus/pngsuite/ PngSuite (public domain) — drawcraft-codecs compares every file
|
||||||
|
against the `image` crate. Fetch: cargo xtask corpus --download
|
||||||
|
corpus/psd/ PSD samples from MIT/BSD projects (ag-psd, psd-tools test data).
|
||||||
|
Copy files in manually; licences must be MIT/BSD/CC0.
|
||||||
|
corpus/tiff/ libtiff pics (optional)
|
||||||
|
corpus/exr/ OpenEXR sample images (optional)
|
||||||
|
corpus/raw/ raw.pixls.us samples, CC0 (optional)
|
||||||
|
",
|
||||||
|
corpus.display()
|
||||||
|
);
|
||||||
|
if !download {
|
||||||
|
return Ok(());
|
||||||
|
}
|
||||||
|
let dest = corpus.join("pngsuite");
|
||||||
|
std::fs::create_dir_all(&dest).map_err(|e| format!("create {}: {e}", dest.display()))?;
|
||||||
|
let tgz = corpus.join("PngSuite-2017jul19.tgz");
|
||||||
|
let mut curl = Command::new("curl");
|
||||||
|
curl.args(["-fsSL", "-o"]).arg(&tgz).arg(PNGSUITE_URL);
|
||||||
|
run(curl, &format!("curl {PNGSUITE_URL}"))?;
|
||||||
|
let mut tar = Command::new("tar");
|
||||||
|
tar.arg("-xzf").arg(&tgz).arg("-C").arg(&dest);
|
||||||
|
run(tar, "tar -xzf PngSuite-2017jul19.tgz")?;
|
||||||
|
let _ = std::fs::remove_file(&tgz);
|
||||||
|
let n = std::fs::read_dir(&dest)
|
||||||
|
.map(|d| {
|
||||||
|
d.flatten()
|
||||||
|
.filter(|e| e.path().extension().is_some_and(|x| x == "png"))
|
||||||
|
.count()
|
||||||
|
})
|
||||||
|
.unwrap_or(0);
|
||||||
|
println!("PngSuite: {n} PNG files in {}", dest.display());
|
||||||
|
if n == 0 {
|
||||||
|
Err("no PNG files extracted".into())
|
||||||
|
} else {
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -0,0 +1,149 @@
|
|||||||
|
//! `cargo xtask stats`: tests and lines of Rust per crate.
|
||||||
|
|
||||||
|
use std::path::Path;
|
||||||
|
|
||||||
|
#[derive(Default, Clone, Copy)]
|
||||||
|
struct Counts {
|
||||||
|
files: usize,
|
||||||
|
lines: usize,
|
||||||
|
tests: usize,
|
||||||
|
proptests: usize,
|
||||||
|
}
|
||||||
|
|
||||||
|
fn walk(dir: &Path, c: &mut Counts) {
|
||||||
|
let Ok(rd) = std::fs::read_dir(dir) else {
|
||||||
|
return;
|
||||||
|
};
|
||||||
|
for e in rd.flatten() {
|
||||||
|
let p = e.path();
|
||||||
|
let name = e.file_name();
|
||||||
|
let name = name.to_string_lossy();
|
||||||
|
if p.is_dir() {
|
||||||
|
// Nested fuzz workspaces and build output are not part of the crate.
|
||||||
|
if name != "target" && name != "fuzz" && !name.starts_with('.') {
|
||||||
|
walk(&p, c);
|
||||||
|
}
|
||||||
|
} else if name.ends_with(".rs")
|
||||||
|
&& let Ok(s) = std::fs::read_to_string(&p)
|
||||||
|
{
|
||||||
|
c.files += 1;
|
||||||
|
c.lines += s.lines().count();
|
||||||
|
let (t, pt) = count_tests(&s);
|
||||||
|
c.tests += t;
|
||||||
|
c.proptests += pt;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// `#[test]` attributes and `proptest!` blocks (each block may hold many
|
||||||
|
/// property tests, and macro-generated tests are only counted once — use
|
||||||
|
/// `--exact` for harness-accurate numbers).
|
||||||
|
fn count_tests(src: &str) -> (usize, usize) {
|
||||||
|
let mut tests = 0;
|
||||||
|
let mut props = 0;
|
||||||
|
for line in src.lines() {
|
||||||
|
let t = line.trim_start();
|
||||||
|
if t.starts_with("//") {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
tests += t.matches("#[test]").count();
|
||||||
|
if t.starts_with("proptest!") {
|
||||||
|
props += 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
(tests, props)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn exact_tests(pkg: &str) -> Option<usize> {
|
||||||
|
let out = crate::cargo()
|
||||||
|
.args(["test", "-q", "-p", pkg, "--", "--list"])
|
||||||
|
.output()
|
||||||
|
.ok()?;
|
||||||
|
if !out.status.success() {
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
Some(
|
||||||
|
String::from_utf8_lossy(&out.stdout)
|
||||||
|
.lines()
|
||||||
|
.filter(|l| l.ends_with(": test"))
|
||||||
|
.count(),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn run(_root: &Path, exact: bool) -> Result<(), String> {
|
||||||
|
let meta = crate::metadata()?;
|
||||||
|
let mut rows = Vec::new();
|
||||||
|
for p in meta["packages"].as_array().into_iter().flatten() {
|
||||||
|
let name = p["name"].as_str().unwrap_or("?").to_owned();
|
||||||
|
let Some(dir) = p["manifest_path"]
|
||||||
|
.as_str()
|
||||||
|
.and_then(|m| Path::new(m).parent().map(Path::to_path_buf))
|
||||||
|
else {
|
||||||
|
continue;
|
||||||
|
};
|
||||||
|
let mut c = Counts::default();
|
||||||
|
walk(&dir, &mut c);
|
||||||
|
let ex = if exact { exact_tests(&name) } else { None };
|
||||||
|
rows.push((name, c, ex));
|
||||||
|
}
|
||||||
|
rows.sort_by(|a, b| a.0.cmp(&b.0));
|
||||||
|
|
||||||
|
let exact_col = if exact {
|
||||||
|
format!(" {:>8}", "harness")
|
||||||
|
} else {
|
||||||
|
String::new()
|
||||||
|
};
|
||||||
|
println!(
|
||||||
|
"{:<26} {:>6} {:>8} {:>7} {:>10}{exact_col}",
|
||||||
|
"crate", "files", "lines", "#[test]", "proptest!"
|
||||||
|
);
|
||||||
|
println!("{}", "-".repeat(62 + if exact { 9 } else { 0 }));
|
||||||
|
let mut total = Counts::default();
|
||||||
|
let mut total_exact = 0;
|
||||||
|
for (name, c, ex) in &rows {
|
||||||
|
let ex_s = match (exact, ex) {
|
||||||
|
(false, _) => String::new(),
|
||||||
|
(true, Some(n)) => {
|
||||||
|
total_exact += n;
|
||||||
|
format!(" {n:>8}")
|
||||||
|
}
|
||||||
|
(true, None) => format!(" {:>8}", "error"),
|
||||||
|
};
|
||||||
|
println!(
|
||||||
|
"{name:<26} {:>6} {:>8} {:>7} {:>10}{ex_s}",
|
||||||
|
c.files, c.lines, c.tests, c.proptests
|
||||||
|
);
|
||||||
|
total.files += c.files;
|
||||||
|
total.lines += c.lines;
|
||||||
|
total.tests += c.tests;
|
||||||
|
total.proptests += c.proptests;
|
||||||
|
}
|
||||||
|
println!("{}", "-".repeat(62 + if exact { 9 } else { 0 }));
|
||||||
|
let ex_s = if exact {
|
||||||
|
format!(" {total_exact:>8}")
|
||||||
|
} else {
|
||||||
|
String::new()
|
||||||
|
};
|
||||||
|
println!(
|
||||||
|
"{:<26} {:>6} {:>8} {:>7} {:>10}{ex_s}",
|
||||||
|
"TOTAL", total.files, total.lines, total.tests, total.proptests
|
||||||
|
);
|
||||||
|
if !exact {
|
||||||
|
println!(
|
||||||
|
"\n(#[test] counts source attributes; macro-generated tests count once. Use --exact for harness counts.)"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::count_tests;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn counts_attributes_and_proptest_blocks() {
|
||||||
|
let src =
|
||||||
|
"#[test]\nfn a() {}\n// #[test] commented\n #[test]\nfn b() {}\nproptest! {\n}\n";
|
||||||
|
assert_eq!(count_tests(src), (2, 1));
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user