Files
vectorcraft/crates/plugins/src/effect.rs
T
Louise FoxandClaude Opus 5.5 24936cd006 Plug-ins: exporters write file formats of their own (G17)
A plug-in could filter objects or reshape them as a live effect, but
nothing it made could leave the app: pen plotters, cutting machines and
laser cutters read formats VectorCraft doesn't write. An exporter is a
third kind of plug-in ("kind": "exporter", with the file "extensions"
it writes), in the same sandbox and with the same ABI v1 exports.

- It adds a write-only format, plugin.<id>. document.formats lists it
  with its plug-in id, and document.export writes it by that id, or by
  a path ending in one of its extensions that no built-in format has;
  "params" carries its parameters, checked against its manifest as a
  filter's are.
- File › Export › Export As lists it after the built-in formats, with
  Use Artboards greyed out, since the plug-in writes the whole
  document. One with parameters asks for them in a dialog generated
  from its schema (pluginExport) once the file is picked.
- The plug-in is given the native file's JSON (format::json_part:
  images of 8 KiB or more without their data) and _context {mode:
  "export", extension, name}. It returns {"dataBase64", "warnings"?}
  or {"error"}. The host writes the file, and nothing when the plug-in
  declines or returns bad data.
- An export gets 10 G instructions plus 4,000 per byte and 300 s.
  Background Export's Cancel stops it between two slices of that
  budget (Error::Cancelled), and a cancelled live-effect run is not
  cached as a failure.
- plugin.run and Object › Plug-ins say where an exporter is instead of
  calling it a live effect.

engine/tests/plugin_export.rs reads back what an exporter is handed
with wat::echo_exporter, an exporter whose file is its input or its
parameters (its base64 encoder is written in WAT). The document it is
given is document.save's JSON, without the large image's data, and its
parameters arrive defaulted, clamped and with the file's name and
extension. It also covers listing and removal, built-in extensions
winning, selectedOnly, declines, bad base64, missing data, warnings,
and Cancel stopping a plug-in that never returns. It is a test binary
of its own: installed plug-ins are process-wide, and the format-list
unit tests check exact lists. dialogs/tests_plugin_export.rs drives
Export As headlessly; plugins/tests/abi.rs covers the budget, the
parameters and the echo exporter; format/tests/native_v4.rs covers
json_part.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01HhvnnnD5fTFupK8WWq35un
2026-10-20 08:17:48 +07:00

136 lines
5.0 KiB
Rust

//! Live-effect plug-ins: a plug-in of kind `effect` rewrites one object's geometry each time it is
//! drawn, from its appearance stack (an `Effect` record with id `plugin.<plug-in id>`).
//!
//! Drawing evaluates effects often (every frame, hit tests, bounds), so results are cached by
//! plug-in installation, parameters and input geometry: the plug-in runs again only when one of
//! them changes. Failures are cached too (the geometry is then drawn unchanged) and reported by
//! [`last_error`].
use std::collections::HashMap;
use std::hash::{Hash, Hasher};
use std::sync::Mutex;
use serde_json::{Value, json};
use vectorcraft_geom::{FillRule, PathData, Rect};
use crate::objects::{decode, output_geometry};
use crate::{Error, Kind, Limits, Result, registry};
/// The effect id prefix of live-effect plug-ins: `plugin.<plug-in id>`.
pub const PREFIX: &str = crate::PREFIX;
/// Wall-clock budget of one live-effect run (drawing waits for it), in milliseconds.
pub const WALL_TIME_MS: u64 = 5_000;
/// Cached results kept before the cache starts over.
const CACHE_SIZE: usize = 512;
/// The plug-in id of effect id `id` (`plugin.<id>`).
pub fn plugin_id(effect_id: &str) -> Option<&str> {
effect_id.strip_prefix(PREFIX)
}
/// The effect id of plug-in `id`.
pub fn effect_id(plugin_id: &str) -> String {
format!("{PREFIX}{plugin_id}")
}
/// An installed live-effect plug-in by its effect id.
pub fn installed(effect_id: &str) -> Option<std::sync::Arc<crate::Plugin>> {
registry::get(plugin_id(effect_id)?).filter(|p| p.manifest().kind == Kind::Effect)
}
#[derive(PartialEq)]
struct Key {
serial: u64,
params: String,
bounds: [u64; 4],
rule: FillRule,
path: PathData,
}
impl Key {
fn hash64(&self) -> u64 {
let mut h = std::collections::hash_map::DefaultHasher::new();
(self.serial, &self.params, self.bounds, self.rule).hash(&mut h);
for sp in &self.path.subpaths {
sp.closed.hash(&mut h);
for a in &sp.anchors {
for p in [a.p, a.h_in, a.h_out] {
(p.x.to_bits(), p.y.to_bits()).hash(&mut h);
}
}
}
h.finish()
}
}
type Cache = HashMap<u64, (Key, Option<PathData>)>;
static CACHE: Mutex<Option<Cache>> = Mutex::new(None);
static ERRORS: Mutex<Option<HashMap<String, String>>> = Mutex::new(None);
/// The last error of live-effect plug-in `id` (cleared when it next succeeds).
pub fn last_error(id: &str) -> Option<String> {
ERRORS.lock().unwrap_or_else(|e| e.into_inner()).as_ref()?.get(id).cloned()
}
fn note(id: &str, r: &Result<PathData>) {
let mut errors = ERRORS.lock().unwrap_or_else(|e| e.into_inner());
let errors = errors.get_or_insert_with(HashMap::new);
match r {
Ok(_) => errors.remove(id),
Err(e) => errors.insert(id.to_string(), e.to_string()),
};
}
/// Runs live-effect plug-in `id` on `path` (whose reference box is `bounds`): the new geometry,
/// or `None` when the plug-in isn't installed, isn't an effect, or fails (the caller then keeps
/// the geometry).
pub fn apply(id: &str, params: &Value, path: &PathData, bounds: Rect, rule: FillRule) -> Option<PathData> {
let plugin = registry::get(id).filter(|p| p.manifest().kind == Kind::Effect)?;
let resolved = Value::Object(plugin.manifest().resolve_params(params).ok()?);
let key = Key {
serial: plugin.serial(),
params: resolved.to_string(),
bounds: [bounds.x0, bounds.y0, bounds.x1, bounds.y1].map(f64::to_bits),
rule,
path: path.clone(),
};
let h = key.hash64();
if let Some((k, out)) = CACHE.lock().unwrap_or_else(|e| e.into_inner()).as_ref().and_then(|c| c.get(&h))
&& *k == key
{
return out.clone();
}
let r = run(&plugin, resolved, path, bounds, rule);
// A drawing that was cancelled (an export stopped half way) says nothing about the plug-in:
// the next drawing runs it again.
if matches!(r, Err(Error::Cancelled)) {
return None;
}
note(id, &r);
let out = r.ok();
let mut cache = CACHE.lock().unwrap_or_else(|e| e.into_inner());
let cache = cache.get_or_insert_with(HashMap::new);
if cache.len() >= CACHE_SIZE {
cache.clear();
}
cache.insert(h, (key, out.clone()));
out
}
fn run(plugin: &crate::Plugin, mut params: Value, path: &PathData, bounds: Rect, rule: FillRule) -> Result<PathData> {
let b = json!([bounds.x0, bounds.y0, bounds.x1, bounds.y1]);
params["_context"] = json!({"mode": "effect", "bounds": b});
let rule = match rule {
FillRule::NonZero => "nonzero",
FillRule::EvenOdd => "evenodd",
};
let input = json!({"objects": [{"type": "path", "path": path, "fillRule": rule, "bounds": b}]});
let (input, params) = (serde_json::to_vec(&input), serde_json::to_vec(&params));
let (Ok(input), Ok(params)) = (input, params) else { return Err(Error::Params("can't encode the input".into())) };
let limits = Limits { wall_time_ms: WALL_TIME_MS, ..plugin.limits().clone() };
Ok(output_geometry(decode(&plugin.run_limited(&input, &params, &limits)?)?))
}