mirror of
https://github.com/storytold/vectorcraft.git
synced 2026-11-10 10:02:38 +00:00
Holding backquote while dragging with the Selection tool (move, bounding-box scale or rotate) or the Rotate, Reflect, Scale or Shear tool applies the drag to the selected objects' pattern fills and strokes and leaves their geometry in place. The key state at release decides the result: the key may be pressed after the mouse button goes down, and releasing it before the button comes up commits an ordinary transform. Objects without pattern paint do not move. PointerEvent gains a `backquote` flag (false when absent). The desktop canvas reads it from the held key on every pointer event, and `ui.pointer` and the `pointer_gesture` tool accept it per event or as a default for the gesture.
460 lines
22 KiB
Rust
460 lines
22 KiB
Rust
use super::definitions::{INSPECT_KEYS, SAVE_FILE_OPTIONS, tool_definitions};
|
|
use super::handlers::{
|
|
Args, absolute_path, add_text, answer_dialog, apply_paint, draw_path, draw_shape, exec, image_result, max_side, need_ui, req_str, screenshot,
|
|
select_ids, transform,
|
|
};
|
|
use super::{ARTBOARD_PARAMS, Backend, Map, ToolResult, Value, format, json, save_reads_option, writable_format};
|
|
|
|
/// The `menu` argument of the command listings.
|
|
pub(super) const MENU_FILTER: &str =
|
|
"Case-insensitive substring of the menu path, its levels joined with \" > \" (e.g. \"Object\", \"Object > Path\")";
|
|
|
|
/// The `brief` argument of the command listings.
|
|
pub(super) const BRIEF: &str =
|
|
"Leave out each command's params description (the bulk of the catalogue); run command_list with the id as filter to read it";
|
|
|
|
/// The `max_side` argument of the window captures.
|
|
pub(super) const MAX_SIDE_WINDOW: &str =
|
|
"Window capture only: longest side of the returned image in pixels; a larger capture is scaled down to it (default: the full capture)";
|
|
|
|
/// The `traceViews` argument of the artboard renders.
|
|
pub(super) const TRACE_VIEWS: &str = "Draw Image Trace objects in the view set with imageTrace.setView (outlines, the source image…), as the app's window does; by default they draw their tracing result, as exports do";
|
|
|
|
/// `traceViews`: false when absent.
|
|
pub(super) fn trace_views(a: &Args) -> Result<bool, String> {
|
|
match a.get("traceViews") {
|
|
None => Ok(false),
|
|
Some(v) => v.as_bool().ok_or_else(|| "`traceViews` must be a boolean".into()),
|
|
}
|
|
}
|
|
|
|
/// The command catalogue narrowed by the listing tools' arguments: `filter` (or `query`) on the
|
|
/// id, label and menu path, `menu` on the menu path, `enabledOnly`, and `brief` (no `params`
|
|
/// text: the full catalogue is too large for many clients, #2173).
|
|
fn filter_commands(all: Value, a: &Args) -> Value {
|
|
let text = |k: &str| a.get(k).and_then(Value::as_str).map(str::to_lowercase).filter(|s| !s.is_empty());
|
|
let needle = text("filter").or_else(|| text("query"));
|
|
let in_menu = text("menu");
|
|
let enabled_only = a.get("enabledOnly").and_then(Value::as_bool).unwrap_or(false);
|
|
let brief = a.get("brief").and_then(Value::as_bool).unwrap_or(false);
|
|
let Value::Array(list) = all else { return all };
|
|
let menu_path = |c: &Value, sep: &str| {
|
|
c.get("menu")
|
|
.and_then(Value::as_array)
|
|
.map(|m| m.iter().filter_map(Value::as_str).collect::<Vec<_>>().join(sep))
|
|
.unwrap_or_default()
|
|
.to_lowercase()
|
|
};
|
|
let hit = |c: &Value| {
|
|
if enabled_only && c.get("enabled").and_then(Value::as_bool) == Some(false) {
|
|
return false;
|
|
}
|
|
if let Some(m) = &in_menu
|
|
&& !menu_path(c, " > ").contains(m.as_str())
|
|
{
|
|
return false;
|
|
}
|
|
let Some(n) = &needle else { return true };
|
|
let field = |k: &str| c.get(k).and_then(Value::as_str).unwrap_or("").to_lowercase();
|
|
field("id").contains(n.as_str()) || field("label").contains(n.as_str()) || menu_path(c, " ").contains(n.as_str())
|
|
};
|
|
Value::Array(
|
|
list.into_iter()
|
|
.filter(hit)
|
|
.map(|mut c| {
|
|
if brief && let Some(o) = c.as_object_mut() {
|
|
o.remove("params");
|
|
}
|
|
c
|
|
})
|
|
.collect(),
|
|
)
|
|
}
|
|
|
|
fn dispatch(b: &mut dyn Backend, name: &str, a: &Args) -> Result<ToolResult, String> {
|
|
let j = |v: Value| Ok(ToolResult::json(&v));
|
|
match name {
|
|
"command_list" => {
|
|
let all = b.call("engine.commands", json!({}))?;
|
|
let mut filters = a.clone();
|
|
if let Some(v) = filters.remove("enabled_only") {
|
|
filters.insert("enabledOnly".into(), v);
|
|
}
|
|
j(filter_commands(all, &filters))
|
|
}
|
|
"command_batch" => command_batch(b, a),
|
|
"render_preview" => render_preview(b, a),
|
|
"ui_screenshot" => {
|
|
let mut args = a.clone();
|
|
args.insert("window".into(), json!(true));
|
|
screenshot(b, &args)
|
|
}
|
|
"list_commands" => {
|
|
let all = b.call("engine.commands", json!({}))?;
|
|
let list = filter_commands(all, a);
|
|
j(json!({"count": list.as_array().map_or(0, Vec::len), "commands": list}))
|
|
}
|
|
"command_run" | "run_command" | "invoke_menu" => {
|
|
let cmd = req_str(a, if name == "command_run" { "id" } else { "command" })?;
|
|
let params = match a.get("params") {
|
|
None | Some(Value::Null) => json!({}),
|
|
Some(v @ Value::Object(_)) => v.clone(),
|
|
Some(_) => return Err("`params` must be an object".into()),
|
|
};
|
|
let method = if name == "invoke_menu" && b.has_ui() { "ui.menu.invoke" } else { "engine.execute" };
|
|
j(b.call(method, json!({"command": cmd, "params": params}))?)
|
|
}
|
|
"doc_inspect" | "inspect_document" => {
|
|
// Only the slicing and scope options reach the command, which validates them.
|
|
let slice: Map<String, Value> =
|
|
a.iter().filter(|(k, _)| INSPECT_KEYS.contains(&k.as_str())).map(|(k, v)| (k.clone(), v.clone())).collect();
|
|
j(b.call("document.inspect", Value::Object(slice))?)
|
|
}
|
|
"ui_inspect" | "inspect_ui" => {
|
|
need_ui(b, name)?;
|
|
j(b.call("ui.inspect", json!({}))?)
|
|
}
|
|
"select_tool" => j(b.call("ui.tool.select", json!({"tool": req_str(a, "tool")?}))?),
|
|
"pointer_gesture" => {
|
|
let events = a.get("events").and_then(Value::as_array).filter(|e| !e.is_empty()).ok_or("`events` must be a non-empty array")?;
|
|
for (i, e) in events.iter().enumerate() {
|
|
let kind = e.get("kind").and_then(Value::as_str).unwrap_or("");
|
|
if crate::headless::pointer_kind(kind).is_none() {
|
|
return Err(format!("event {i}: unknown kind `{kind}` (down, drag, up, move, doubleclick)"));
|
|
}
|
|
if !e.get("x").is_some_and(Value::is_number) || !e.get("y").is_some_and(Value::is_number) {
|
|
return Err(format!("event {i}: numeric `x` and `y` required"));
|
|
}
|
|
}
|
|
if let Some(t) = a.get("tool").and_then(Value::as_str) {
|
|
b.call("ui.tool.select", json!({"tool": t}))?;
|
|
}
|
|
let events: Vec<Value> = events
|
|
.iter()
|
|
.map(|e| {
|
|
let mut e = e.clone();
|
|
e["space"] = json!("doc");
|
|
e
|
|
})
|
|
.collect();
|
|
let mut p = json!({"events": events});
|
|
if let Some(m) = a.get("mods") {
|
|
p["mods"] = m.clone();
|
|
}
|
|
if let Some(backquote) = a.get("backquote") {
|
|
p["backquote"] = backquote.clone();
|
|
}
|
|
j(b.call("ui.pointer", p)?)
|
|
}
|
|
"draw_path" => j(draw_path(b, a)?),
|
|
"draw_shape" => j(draw_shape(b, a)?),
|
|
"set_paint" => {
|
|
if !["fill", "stroke", "strokeWidth"].iter().any(|k| a.contains_key(*k)) {
|
|
return Err("give at least one of `fill`, `stroke`, `strokeWidth`".into());
|
|
}
|
|
let (applied, objects) = apply_paint(b, a, a.get("ids").cloned())?;
|
|
let mut reply = json!({"applied": applied, "objects": objects});
|
|
if objects == 0 {
|
|
reply["note"] = json!(
|
|
"no object was painted (nothing selected, or `ids` is empty or holds no object this paint applies to): only the paint for new art changed"
|
|
);
|
|
}
|
|
j(reply)
|
|
}
|
|
"press_key" => {
|
|
let mut p = json!({"key": req_str(a, "key")?});
|
|
if let Some(Value::Object(m)) = a.get("mods") {
|
|
for (k, v) in m {
|
|
p[k.as_str()] = v.clone();
|
|
}
|
|
}
|
|
j(b.call("ui.key", p)?)
|
|
}
|
|
"type_text" => {
|
|
need_ui(b, name)?;
|
|
j(b.call("ui.text", json!({"text": req_str(a, "text")?}))?)
|
|
}
|
|
"answer_dialog" => {
|
|
need_ui(b, name)?;
|
|
j(answer_dialog(b, a)?)
|
|
}
|
|
"open_panel" => {
|
|
need_ui(b, name)?;
|
|
j(b.call("ui.set", json!({"panel": req_str(a, "panel")?}))?)
|
|
}
|
|
"screenshot" => screenshot(b, a),
|
|
"open_file" => {
|
|
let path = absolute_path(b, req_str(a, "path")?)?;
|
|
j(b.call("app.open", json!({"path": path}))?)
|
|
}
|
|
"save_file" => {
|
|
let mut p = json!({"path": a.get("path").and_then(Value::as_str).map(|p| absolute_path(b, p)).transpose()?});
|
|
for k in SAVE_FILE_OPTIONS {
|
|
if let Some(v) = a.get(k).filter(|v| !v.is_null()) {
|
|
p[k] = v.clone();
|
|
}
|
|
}
|
|
// The app writes the file before it replies, even with Background Save on, so a failed
|
|
// save is this call's error.
|
|
if b.has_ui() {
|
|
p["wait"] = json!(true);
|
|
}
|
|
let mut r = b.call("app.save", p)?;
|
|
let notes = save_file_warnings(a, r.get("format").and_then(Value::as_str));
|
|
if let Some(o) = r.as_object_mut().filter(|_| !notes.is_empty()) {
|
|
let mut all = o.get("warnings").and_then(Value::as_array).cloned().unwrap_or_default();
|
|
all.extend(notes.into_iter().map(Value::from));
|
|
o.insert("warnings".into(), Value::Array(all));
|
|
}
|
|
j(r)
|
|
}
|
|
"export" => {
|
|
// One params object for every backend; the named arguments win over `options` (a named
|
|
// artboard or range replaces every artboard choice in `options`).
|
|
let mut params = a.get("options").and_then(Value::as_object).cloned().unwrap_or_default();
|
|
if ["artboard", "range"].iter().any(|k| a.get(*k).is_some_and(|v| !v.is_null())) {
|
|
for k in ARTBOARD_PARAMS {
|
|
params.remove(k);
|
|
}
|
|
}
|
|
for k in named_export_params(a) {
|
|
if let Some(v) = a.get(*k).filter(|v| !v.is_null()) {
|
|
params.insert((*k).into(), v.clone());
|
|
}
|
|
}
|
|
if let Some(path) = params.get("path").and_then(Value::as_str) {
|
|
let path = absolute_path(b, path)?;
|
|
params.insert("path".into(), json!(path));
|
|
}
|
|
let cmd = if a.get("selection").and_then(Value::as_bool) == Some(true) { "document.exportSelection" } else { "document.export" };
|
|
j(exec(b, cmd, Value::Object(params))?)
|
|
}
|
|
"add_text" => j(add_text(b, a)?),
|
|
"apply_effect" => {
|
|
let Some(effect) = a.get("effect").and_then(Value::as_str) else {
|
|
let all = exec(b, "effect.list", json!({}))?;
|
|
return j(json!({"catalog": all.get("catalog")}));
|
|
};
|
|
let mut p = json!({"effect": effect, "params": a.get("params").cloned().unwrap_or(json!({}))});
|
|
if let Some(ids) = a.get("ids") {
|
|
p["ids"] = ids.clone();
|
|
}
|
|
j(exec(b, "effect.apply", p)?)
|
|
}
|
|
"pathfinder" => {
|
|
select_ids(b, a)?;
|
|
j(exec(b, &format!("object.pathfinder.{}", req_str(a, "operation")?), json!({}))?)
|
|
}
|
|
"transform" => j(transform(b, a)?),
|
|
"create_graph" => {
|
|
let mut p = Value::Object(a.clone());
|
|
if p.get("type").is_none() {
|
|
p["type"] = json!("column");
|
|
}
|
|
j(exec(b, "graph.create", p)?)
|
|
}
|
|
"text_wrap" => {
|
|
select_ids(b, a)?;
|
|
if a.get("release").and_then(Value::as_bool) == Some(true) {
|
|
return j(exec(b, "object.textWrap.release", json!({}))?);
|
|
}
|
|
let mut p = json!({});
|
|
for k in ["offset", "invert"] {
|
|
if let Some(v) = a.get(k) {
|
|
p[k] = v.clone();
|
|
}
|
|
}
|
|
j(exec(b, "object.textWrap.make", p)?)
|
|
}
|
|
"undo" => j(exec(b, "edit.undo", json!({}))?),
|
|
"redo" => j(exec(b, "edit.redo", json!({}))?),
|
|
other => Err(format!("unknown tool `{other}` (see tools/list)")),
|
|
}
|
|
}
|
|
|
|
/// The named `export` arguments passed on to `document.export`. `scale` is raster pixels per
|
|
/// point, so only a raster format gets it: DXF's own (drawing units per unit, given in `options`)
|
|
/// would resize the drawing and leave its units unset.
|
|
fn named_export_params(a: &Args) -> &'static [&'static str] {
|
|
let format = writable_format(a.get("format").and_then(Value::as_str), a.get("path").and_then(Value::as_str));
|
|
if format.is_ok_and(|f| f.raster) {
|
|
&["path", "format", "scale", "artboard", "range", "outlineText"]
|
|
} else {
|
|
&["path", "format", "artboard", "range", "outlineText"]
|
|
}
|
|
}
|
|
|
|
/// What `save_file` with arguments `a` adds to the save's `warnings`, for a file written in format
|
|
/// `written` (none when nothing was written yet): each key in `options` that the format doesn't
|
|
/// read. The save itself warns about `compress`, `version` and `preview` given at the top level.
|
|
fn save_file_warnings(a: &Args, written: Option<&str>) -> Vec<String> {
|
|
let Some(f) = written.and_then(format) else { return vec![] };
|
|
let nested = a.get("options").and_then(Value::as_object);
|
|
nested
|
|
.into_iter()
|
|
.flatten()
|
|
.filter(|(key, v)| !v.is_null() && !save_reads_option(f, key))
|
|
.map(|(key, _)| format!("`options.{key}` was not used: {} files don't read it", f.label))
|
|
.collect()
|
|
}
|
|
|
|
/// Run one tool. Failures (unknown tool, bad arguments, command errors) come back as an
|
|
/// `isError` result so the model can read and correct them.
|
|
pub fn call_tool(b: &mut dyn Backend, name: &str, args: &Value) -> ToolResult {
|
|
let empty = Map::new();
|
|
let a = match args {
|
|
Value::Object(o) => o,
|
|
Value::Null => &empty,
|
|
_ => return ToolResult::error("tool arguments must be a JSON object"),
|
|
};
|
|
match vectorcraft_engine::guard::catch_panic(|| dispatch(b, name, a)) {
|
|
Ok(Ok(r)) => r,
|
|
Ok(Err(e)) => ToolResult::error(format!("{name}: {e}")),
|
|
Err(e) => ToolResult::error(format!("internal error in `{name}`: {e} (please report this bug)")),
|
|
}
|
|
}
|
|
|
|
/// Most steps one `command_batch` may hold, and most bytes its kept step results may total.
|
|
/// Batches run sequentially and every result is kept for later steps' `"$N.path"` references, so
|
|
/// without caps one batch could do unbounded work and pile up an unbounded reply (#1727).
|
|
pub(crate) const MAX_COMMAND_BATCH_STEPS: usize = 256;
|
|
|
|
pub(crate) const MAX_COMMAND_BATCH_BYTES: usize = 8 * 1024 * 1024;
|
|
|
|
/// Refuses an argument the tool's schema doesn't list, as `tools/call` does (#713).
|
|
pub(crate) fn check_tool_arguments(name: &str, args: &Args) -> Result<(), String> {
|
|
let defs = tool_definitions();
|
|
let props =
|
|
defs.iter().find(|t| t.get("name").and_then(Value::as_str) == Some(name)).and_then(|t| t.get("inputSchema")?.get("properties")?.as_object());
|
|
match props.and_then(|p| args.keys().find(|k| !p.contains_key(*k))) {
|
|
Some(key) => Err(format!("unknown argument `{key}` for `{name}`")),
|
|
None => Ok(()),
|
|
}
|
|
}
|
|
|
|
fn command_batch(b: &mut dyn Backend, a: &Args) -> Result<ToolResult, String> {
|
|
let steps = a.get("steps").and_then(Value::as_array).ok_or("`steps` must be an array")?;
|
|
if steps.len() > MAX_COMMAND_BATCH_STEPS {
|
|
return Err(format!("`steps` holds {} steps; at most {MAX_COMMAND_BATCH_STEPS}", steps.len()));
|
|
}
|
|
let stop = match a.get("stop_on_error") {
|
|
None => true,
|
|
Some(v) => v.as_bool().ok_or("`stop_on_error` must be a boolean")?,
|
|
};
|
|
let atomic = match a.get("atomic") {
|
|
None => false,
|
|
Some(v) => v.as_bool().ok_or("`atomic` must be a boolean")?,
|
|
};
|
|
if atomic {
|
|
let label = a.get("label").cloned().unwrap_or(Value::Null);
|
|
b.call("engine.execute", json!({"command":"edit.undoGroup.begin","params":{"label":label}}))?;
|
|
} else if a.get("label").is_some() {
|
|
return Err("`label` names the undo step of an atomic batch: give atomic:true too".into());
|
|
}
|
|
let (mut completed, mut failed, mut results) = (0, 0, Vec::new());
|
|
// Each step's result (null for a failed one), for later steps' `"$N.path"` references.
|
|
let mut values: Vec<Value> = vec![];
|
|
// The kept results all go back in the reply, so their total size is capped: one batch can't
|
|
// pile up an unbounded answer (#1727).
|
|
let mut kept_bytes = 0usize;
|
|
// The objects the drawing steps made (#2113).
|
|
let mut created: Vec<Value> = vec![];
|
|
for step in steps {
|
|
let result = vectorcraft_engine::guard::catch_panic(|| {
|
|
let args = step.as_object().ok_or("each step must be an object")?;
|
|
if args.keys().any(|k| k != "id" && k != "params") {
|
|
return Err("unknown step argument (expected id, params)".into());
|
|
}
|
|
let id = req_str(args, "id")?;
|
|
let params = match args.get("params") {
|
|
None | Some(Value::Null) => json!({}),
|
|
Some(v @ Value::Object(_)) => vectorcraft_engine::steps::resolve(v, &values)?,
|
|
Some(_) => return Err("`params` must be an object".into()),
|
|
};
|
|
match id {
|
|
// The drawing tools, with the arguments `tools/call` takes (#2113).
|
|
"draw_path" | "draw_shape" => {
|
|
let args = params.as_object().cloned().unwrap_or_default();
|
|
check_tool_arguments(id, &args)?;
|
|
let made = if id == "draw_path" { draw_path(b, &args)? } else { draw_shape(b, &args)? };
|
|
if let Some(new) = made.get("id").filter(|v| v.is_u64()) {
|
|
created.push(new.clone());
|
|
}
|
|
Ok(made)
|
|
}
|
|
_ => b.call("engine.execute", json!({"command":id,"params":params})),
|
|
}
|
|
})
|
|
.unwrap_or_else(|e| Err(format!("internal error: {e}")));
|
|
match result {
|
|
Ok(v) => {
|
|
let entry = json!({"ok": true, "result": v});
|
|
let size = entry.to_string().len();
|
|
if kept_bytes.saturating_add(size) > MAX_COMMAND_BATCH_BYTES {
|
|
// The results already kept stay; this step ran, and its result is replaced by
|
|
// the reason the batch stops (saying it ran, so nobody runs it again). The loop breaks either way — carrying on would defeat the cap —
|
|
// and the atomic group below still closes, as for any stopped batch.
|
|
failed += 1;
|
|
values.push(Value::Null);
|
|
results.push(json!({"ok": false, "error": format!("this step ran, but the batch's results exceed {MAX_COMMAND_BATCH_BYTES} bytes: its result is dropped and the batch stops here")}));
|
|
break;
|
|
}
|
|
kept_bytes += size;
|
|
completed += 1;
|
|
values.push(entry["result"].clone());
|
|
results.push(entry);
|
|
}
|
|
Err(e) => {
|
|
failed += 1;
|
|
values.push(Value::Null);
|
|
results.push(json!({"ok":false,"error":e}));
|
|
if stop {
|
|
break;
|
|
}
|
|
}
|
|
}
|
|
}
|
|
// The steps that ran stay, as one undo step.
|
|
if atomic {
|
|
b.call("engine.execute", json!({"command":"edit.undoGroup.end","params":{}}))?;
|
|
}
|
|
let mut r = ToolResult::json(&json!({"completed":completed,"failed":failed,"results":results,"created":created}));
|
|
r.is_error = failed > 0;
|
|
Ok(r)
|
|
}
|
|
|
|
fn render_preview(b: &mut dyn Backend, a: &Args) -> Result<ToolResult, String> {
|
|
let max = max_side(a)?.unwrap_or(1024);
|
|
let index = match a.get("artboard") {
|
|
None => 0,
|
|
Some(v) => v.as_u64().ok_or("`artboard` must be a 0-based artboard index")?,
|
|
};
|
|
let trace_views = trace_views(a)?;
|
|
let doc = b.call("document.inspect", json!({"depth":0}))?;
|
|
let board =
|
|
doc.get("artboards").and_then(Value::as_array).and_then(|v| v.get(usize::try_from(index).ok()?)).ok_or(format!("no artboard {index}"))?;
|
|
let width = board.get("width").and_then(Value::as_f64).ok_or("artboard has no width")?;
|
|
let height = board.get("height").and_then(Value::as_f64).ok_or("artboard has no height")?;
|
|
if !width.is_finite() || !height.is_finite() || width <= 0.0 || height <= 0.0 {
|
|
return Err("invalid artboard dimensions".into());
|
|
}
|
|
// The headless renderer clamps scale at 0.01. Bound its allocation even for
|
|
// a huge artboard, then downsize to the requested dimensions.
|
|
let scale = (f64::from(max) / width.max(height)).clamp(0.01, 1.0);
|
|
if width.max(height) * scale > 4096.0 {
|
|
return Err("artboard is too large for a bounded preview".into());
|
|
}
|
|
let mut p = json!({"scale":scale, "artboard": index});
|
|
if trace_views {
|
|
p["traceViews"] = json!(true);
|
|
}
|
|
let r = b.call("ui.render", p)?;
|
|
let encoded = r.get("pngBase64").and_then(Value::as_str).ok_or("renderer returned no image")?;
|
|
let png = vectorcraft_format::base64_decode(encoded).ok_or("renderer returned bad base64")?;
|
|
let img = image::load_from_memory_with_format(&png, image::ImageFormat::Png).map_err(|e| format!("read preview: {e}"))?;
|
|
let img = if img.width() > max || img.height() > max { img.thumbnail(max, max) } else { img };
|
|
let mut out = std::io::Cursor::new(Vec::new());
|
|
img.write_to(&mut out, image::ImageFormat::Png).map_err(|e| format!("encode preview: {e}"))?;
|
|
Ok(image_result(out.get_ref(), json!({"width":img.width(),"height":img.height()})))
|
|
}
|