MCP: effect.refreshDeferred takes objects in symbol, pattern, brush and mask art

effect.deferredStatus lists a stage in the art of a symbol, pattern, brush
or opacity mask by the id of the object in that art. effect.refreshDeferred
took such an id only while the object still had a deferred stage; an
existing object there without one was refused as no such object, while a
layer object without a stage was accepted. An id is now valid when it names
an object in the layers or in that art, an object without a stage adds
nothing to the counts, and an id of no object is an error.

The params text and docs/mcp.md said `id` names one object, but the params
text did not list the key, so `{id: N}` was refused as an unknown
parameter. It is now `{ids?|id?}`, and the command checks only the form of
the ids before it looks them up itself.

The requested ids are held in a set while the art is walked, and only those
with a stage are handed to the refresh.
This commit is contained in:
dotCooCoo
2026-11-11 22:45:06 -08:00
parent 5eefe7d1c5
commit f494578072
6 changed files with 148 additions and 10 deletions
+17
View File
@@ -507,6 +507,23 @@ pub(crate) fn given_objects(s: &Session, p: &Value, command: &str) -> Result<Opt
Some(v) => Ok(Some(vec![checked_id(s, v, command)?])),
}
}
/// The objects `p` gives `command`, as [`given_objects`] reads them, checked only for their form:
/// for a command that looks its objects up itself, outside the document's layers too.
pub(crate) fn given_object_ids(p: &Value, command: &str) -> Result<Option<Vec<NodeId>>> {
if p.get("ids").is_some_and(|v| !v.is_null()) {
return ids_param(p, "ids", command);
}
match p.get("id").filter(|v| !v.is_null()) {
None => Ok(None),
Some(Value::String(r)) if r.starts_with('$') => {
Err(bad(command, format!("`id` is the step reference {r:?}, which only `run` and `command_batch` resolve")))
}
Some(v) => {
let id = v.as_u64().map(NodeId).ok_or_else(|| bad(command, format!("invalid object id {v}: expected a non-negative integer")))?;
Ok(Some(vec![id]))
}
}
}
/// `v` as an index, a count or a byte offset: past `usize::MAX` reads as `usize::MAX`, past the end of any list.
pub(crate) fn index(v: u64) -> usize {
usize::try_from(v).unwrap_or(usize::MAX)
+27 -7
View File
@@ -123,7 +123,7 @@ pub fn specs() -> Vec<CommandSpec> {
"Refresh Deferred Effects",
["Effect"],
None,
"{ids?: [object id] (objects of the document; `id` names one; default: every object with a deferred stage)} run the deferred live-effect plug-in stages of those objects again (effect.deferredStatus), sharing a fresh 2,000 ms budget; a stage that gives a result is drawn with it; not an undo step. Enabled when a stage is deferred → {refreshed, stillDeferred (left out for want of time again, or not reached because the object, or a group or layer holding it, is hidden, or with a part of its input too small for the refresh's drawing), failed (timed out, failed or has no plug-in)}",
"{ids?|id?: [object id] (objects of the document, also in the art of a symbol, pattern, brush or opacity mask; an object without a deferred stage adds nothing; an id of no object is an error; `id` names one; default: every object with a deferred stage)} run the deferred live-effect plug-in stages of those objects again (effect.deferredStatus), sharing a fresh 2,000 ms budget; a stage that gives a result is drawn with it; not an undo step. Enabled when a stage is deferred → {refreshed, stillDeferred (left out for want of time again, or not reached because the object, or a group or layer holding it, is hidden, or with a part of its input too small for the refresh's drawing), failed (timed out, failed or has no plug-in)}",
has_deferred,
refresh_deferred
),
@@ -150,16 +150,27 @@ fn stage_objects(d: &Document, fx: &vectorcraft_plugins::deferred::Deferrals) ->
None => rest.push(object),
}
}
if rest.is_empty() {
for (id, n) in definition_objects(d, &rest) {
out.entry(id).or_insert((n, false));
}
out
}
/// The objects of `ids` found in the art `d` draws from outside its layers: the art of its
/// symbols, patterns and brushes, and opacity masks (in that art or in the layers).
fn definition_objects(d: &Document, ids: &[u64]) -> std::collections::HashMap<u64, Arc<Node>> {
let mut out = std::collections::HashMap::new();
if ids.is_empty() {
return out;
}
let ids: std::collections::HashSet<u64> = ids.iter().copied().collect();
let brushes = vectorcraft_brush::library(d);
let symbols = d.symbols.iter().map(|s| &*s.art);
let patterns = d.patterns.iter().flat_map(|p| p.art.iter().map(|a| &**a));
let layers = d.layers.iter().map(|a| &**a);
let mut look = |n: &Node| {
if rest.contains(&n.id.0) {
out.entry(n.id.0).or_insert_with(|| (Arc::new(n.clone()), false));
if ids.contains(&n.id.0) {
out.entry(n.id.0).or_insert_with(|| Arc::new(n.clone()));
}
};
for a in symbols.chain(patterns).chain(brushes.iter().flat_map(|b| b.art())).chain(layers) {
@@ -231,10 +242,19 @@ fn refresh_deferred(s: &mut Session, p: &Value) -> Result<Value> {
let (doc, fx) = (st.doc.clone(), st.plugin_effects.clone());
let ids = super::named_ids(p)?;
let objects = prune(&doc, &fx);
if let Some(id) = ids.iter().flatten().find(|id| doc.node(**id).is_none() && !objects.contains_key(&id.0)) {
return Err(EngineError::NoNode(*id));
// An object of the art drawn outside the layers is valid whether or not it has a stage.
let elsewhere: Vec<u64> = ids.iter().flatten().filter(|id| doc.node(**id).is_none() && !objects.contains_key(&id.0)).map(|id| id.0).collect();
let found = definition_objects(&doc, &elsewhere);
if let Some(id) = elsewhere.iter().find(|id| !found.contains_key(id)) {
return Err(EngineError::NoNode(NodeId(*id)));
}
let ids: Option<Vec<u64>> = ids.map(|ids| ids.iter().map(|id| id.0).collect());
// Only the objects with a stage have anything to refresh.
let ids: Option<Vec<u64>> = ids.map(|ids| {
let mut ids: Vec<u64> = ids.iter().map(|id| id.0).filter(|id| objects.contains_key(id)).collect();
ids.sort_unstable();
ids.dedup();
ids
});
let counts = fx.refresh(ids.as_deref(), |targets| {
let mut renderer = vectorcraft_render::Renderer::new();
let mut whole = Vec::new();
+7 -1
View File
@@ -82,7 +82,13 @@ impl Session {
&& params.get("ids").is_none_or(Value::is_null)
&& params.get("id").and_then(Value::as_str).is_some_and(|e| !e.starts_with('$'));
if cmd::params::id_stands_for_ids(spec.params) && !effect_named && self.active().is_some() {
cmd::given_objects(self, params, id)?;
// `effect.refreshDeferred` looks its objects up itself: they can also be in the art of
// a symbol, pattern, brush or opacity mask.
if id == "effect.refreshDeferred" {
cmd::given_object_ids(params, id)?;
} else {
cmd::given_objects(self, params, id)?;
}
}
if SELECTS_GIVEN_IDS.contains(&id) {
self.check_given_ids(id, params)?;
+92
View File
@@ -1153,6 +1153,98 @@ fn deferred_stages_in_symbol_art_are_listed_and_refreshed() {
assert_eq!(run(&mut s, "effect.deferredStatus", json!({}))["effects"], json!([]));
}
/// `effect.refreshDeferred` takes the id of an object in the art of a symbol, pattern, brush or
/// opacity mask, as `effect.deferredStatus` lists it: one with a deferred stage is refreshed, and
/// one without (or whose stage is gone) adds nothing to the counts. The id of art that is no
/// longer in the document is an error.
#[test]
fn refresh_takes_objects_in_definition_art() {
let mut s = session();
const FX: &str = "plugin.test.engine.deferred.nested";
let out = json!({"objects": [{"path": square_at(110.0)}]}).to_string();
install_wat(&mut s, &tpl::returning(&tpl::manifest("test.engine.deferred.nested", "effect", "{}"), &out));
// A symbol whose art holds an object with the effect and one without.
let staged = rect(&mut s, 10.0, "#ff0000");
let plain = rect(&mut s, 30.0, "#ff0000");
run(&mut s, "effect.apply", json!({"ids": [staged.0], "effect": FX}));
run(&mut s, "select.set", json!({"ids": [staged.0, plain.0]}));
run(&mut s, "symbol.new", json!({"name": "Nested"}));
// A pattern, an art brush (its source deleted) and an opacity mask, none with a stage.
let tile = rect(&mut s, 50.0, "#00ff00");
run(&mut s, "select.set", json!({"ids": [tile.0]}));
run(&mut s, "object.pattern.make", json!({"name": "Tile", "width": 20, "height": 20}));
run(&mut s, "object.pattern.done", json!({}));
let bar = rect(&mut s, 70.0, "#0000ff");
run(&mut s, "select.set", json!({"ids": [bar.0]}));
run(&mut s, "brush.new", json!({"type": "art", "name": "Bar"}));
run(&mut s, "edit.clear", json!({"ids": [bar.0]}));
let masked = rect(&mut s, 90.0, "#ff0000");
let mask = rect(&mut s, 90.0, "#ffffff");
run(&mut s, "select.set", json!({"ids": [masked.0, mask.0]}));
run(&mut s, "transparency.makeOpacityMask", json!({}));
// A layer object with a stage that stays deferred, and one without.
let kept = rect(&mut s, 130.0, "#ff0000");
run(&mut s, "effect.apply", json!({"ids": [kept.0], "effect": FX}));
let bare = rect(&mut s, 150.0, "#ff0000");
// The ids of the objects of `art` that are not in the layers.
let nested = |s: &Session, art: &vectorcraft_doc::Node| {
let d = &s.doc().unwrap().doc;
let mut ids = Vec::new();
vectorcraft_doc::swatches::walk_with_masks(art, &mut |n| {
if d.node(n.id).is_none() {
ids.push(n.id.0);
}
});
ids
};
let d = s.doc().unwrap().doc.clone();
let in_symbol = nested(&s, &d.symbols[0].art);
let in_pattern = d.patterns.iter().flat_map(|p| p.art.iter().flat_map(|a| nested(&s, a))).collect::<Vec<_>>();
let brushes = vectorcraft_brush::library(&d);
let in_brush = brushes.iter().flat_map(|b| b.art().into_iter().flat_map(|a| nested(&s, a)).collect::<Vec<_>>()).collect::<Vec<_>>();
let in_mask = d.node(masked).and_then(|n| n.extras().mask.as_ref()).map(|m| nested(&s, &m.art)).unwrap_or_default();
for (what, ids) in [("symbol", &in_symbol), ("pattern", &in_pattern), ("brush", &in_brush), ("opacity mask", &in_mask)] {
assert!(!ids.is_empty(), "the {what} art has an object outside the layers");
}
let step = std::sync::Arc::new(std::sync::atomic::AtomicU64::new(3_000));
let fx = vectorcraft_plugins::deferred::Deferrals::with_clock(stepping_clock(&step));
s.doc_mut().unwrap().plugin_effects = fx.clone();
fx.view(|| drawn(&s));
fx.first_view_done();
let listed = |s: &mut Session| {
run(s, "effect.deferredStatus", json!({}))["effects"].as_array().unwrap().iter().map(|e| e["objectId"].as_u64().unwrap()).collect::<Vec<_>>()
};
let deferred = listed(&mut s);
let [nested_staged, last] = deferred[..] else { panic!("two stages: {deferred:?}") };
assert!(in_symbol.contains(&nested_staged) && last == kept.0, "{deferred:?} {in_symbol:?}");
let Some(&nested_plain) = in_symbol.iter().find(|i| **i != nested_staged) else { panic!("{in_symbol:?}") };
// Objects without a stage, each on its own and together: nothing to refresh.
let none = json!({"refreshed": 0, "stillDeferred": 0, "failed": 0});
let mut plain_ids = vec![nested_plain, in_pattern[0], in_brush[0], in_mask[0], bare.0];
for id in &plain_ids {
assert_eq!(run(&mut s, "effect.refreshDeferred", json!({"id": id})), none, "object {id}");
}
assert_eq!(run(&mut s, "effect.refreshDeferred", json!({"ids": plain_ids})), none);
assert_eq!(listed(&mut s), [nested_staged, kept.0]);
// The staged object of the symbol's art, by its id, with the others.
step.store(0, std::sync::atomic::Ordering::Relaxed);
plain_ids.push(nested_staged);
assert_eq!(run(&mut s, "effect.refreshDeferred", json!({"ids": plain_ids})), json!({"refreshed": 1, "stillDeferred": 0, "failed": 0}));
assert_eq!(listed(&mut s), [kept.0]);
// Its stage is gone; the object is still there.
assert_eq!(run(&mut s, "effect.refreshDeferred", json!({"id": nested_staged})), none);
// Art no longer in the document, and an id of no object.
std::sync::Arc::make_mut(&mut s.doc_mut().unwrap().doc).patterns.clear();
assert!(s.execute("effect.refreshDeferred", &json!({"id": in_pattern[0]})).is_err(), "the pattern is gone");
assert!(s.execute("effect.refreshDeferred", &json!({"ids": [nested_plain, 999_999]})).is_err(), "not an object of the document");
assert_eq!(listed(&mut s), [kept.0]);
}
/// `CRAFT_PLUGIN_PAINTS_WASM=<folder>` saves a test live effect to try in the app
/// (`stroke-width-test-effect.wasm`): it draws a triangle over the area 100,80–300,230 while its
/// object's stroke is 5 pt wide, and hands the geometry back otherwise.
+4 -2
View File
@@ -7975,13 +7975,15 @@ drawings leave a deferred stage as it is until its input or parameters change, w
the active document (`reason`: `budget`, `timeout`, `missing`, `failed` or `safeMode`; `item` is the fill or stroke
whose effect it is; `objectId` can be an object in the art of a symbol, pattern, brush or opacity mask; an empty list
when none is deferred). A stage whose object is deleted, or whose plug-in effect is
removed, moved from its place in the list or hidden (or whose fill or stroke is hidden), is no longer listed. `effect.refreshDeferred {ids?}` (Effect › Refresh
removed, moved from its place in the list or hidden (or whose fill or stroke is hidden), is no longer listed. `effect.refreshDeferred {ids?|id?}` (Effect › Refresh
Deferred Effects, enabled while a stage is deferred) runs the deferred stages of those objects (default: all of them)
again with a fresh 2,000 ms budget and returns `{refreshed, stillDeferred, failed}`: `stillDeferred` counts stages left
out for want of time again, the stages of an object that is hidden or in a hidden group or layer, which is not drawn,
and the stages with a part of their input too small for the drawing the refresh makes, and `failed` those that
timed out, failed or have no plug-in. `id` names one object in place of
`ids`, and an entry that is not an object id of the document is an error. Each document has its own budget and its own deferred stages. Exports, Expand
`ids`. An entry can name an object in the document's layers or in the art of a symbol, pattern, brush or opacity mask;
an object without a deferred stage adds nothing to the counts, and an entry that is not an object of the document is
an error. Each document has its own budget and its own deferred stages. Exports, Expand
Appearance and commands run every stage under the per-call limit, so an export never leaves out a stage that was only
deferred for time, and the canvas then draws the result. The web build measures the budget and the per-call
limit with the page's `performance.now()`. An exporter adds a write-only file format `plugin.<id>` (listed by `document.formats`):
+1
View File
@@ -1708,6 +1708,7 @@ Moved from the ROADMAP's "Shipped so far" on 2026-10-10. Update it when a featur
- **Plug-in progress, Cancel and log lines (G17):** at plug-in API level 2 a plug-in with the `progress` permission imports `env.vc_progress`, `env.vc_cancelled` and `env.vc_log`. A heavy filter's and an export's progress windows show its progress as a percentage bar, their Cancel reaches it through `vc_cancelled`, a light filter with `progress` and no `commandAllowlist` runs in the background with the same progress window when it runs at once or OK runs it with Preview off, and its log lines go to the app's log with its id, capped per line and per run. Installing checks every import and names the one it refuses. Regressions: `crates/plugins/src/host.rs`, `crates/plugins/tests/abi.rs`, `crates/engine/src/tests/plugins.rs`, `crates/engine/tests/plugin_fuzz.rs`, `crates/ui-egui/src/tests/plugin_runs.rs`. Scores and hours unchanged.
- **Live-effect plug-ins paint their output (G17, #121):** a live effect whose manifest has `"api": 2` and `"paintOutput": true` returns pieces, each drawn by itself in order with fills and strokes of its own in the filter's paint forms (a piece without them keeps the object's; an empty list paints none; an effect on a fill returns only fills, one on a stroke only strokes). The whole result is checked first: limits, numbers in range with nothing clamped, known keys only, and the swatches and patterns it names in the document; one fault draws that step of the stack unchanged, keeps the effect record and puts the plug-in's name and the field in `plugin.info`'s `lastError`. The pieces are drawn, measured and exported to PDF and SVG from the appearance stack without changing the document, and Expand Appearance writes them as ordinary paths in one undo step. Effects without the key behave as before. Regressions: `crates/plugins/src/painted/tests.rs`, `crates/plugins/src/manifest.rs`, `crates/engine/src/tests/plugins.rs`, `crates/engine/tests/plugin_fuzz.rs`. Scores and hours unchanged.
- **Live-effect plug-ins while a document opens (G17):** the live-effect plug-in runs of a document's first view on screen share a 2,000 ms budget (VectorCraft's own value). A stage left out for time, or one that times out, fails, has no plug-in or meets Safe Mode, draws its input unchanged and is deferred; the status bar says how many, `effect.deferredStatus` lists them with their object, effect and reason, and Effect › Refresh Deferred Effects (`effect.refreshDeferred`) runs them again with a fresh budget. Each document has its own budget, an edit runs the stages it changes, and exports, Expand Appearance and commands run every stage under the 5 s limit of each run. The web build times the budget and each run's limit with the page's `performance.now()`. Regressions: `crates/plugins/src/deferred.rs`, `crates/plugins/src/effect.rs`, `crates/plugins/src/host.rs`, `crates/engine/src/tests/plugins.rs`, `crates/ui-egui/src/canvas/tests.rs`. Scores and hours unchanged.
- **Refresh Deferred Effects by object (G17):** `effect.refreshDeferred` takes `ids`, or `id` for one, naming objects in the layers or in the art of a symbol, pattern, brush or opacity mask, the objects `effect.deferredStatus` lists; an object without a deferred stage adds nothing to the counts, and an id of no object in the document is an error. Regressions: `crates/engine/src/tests/plugins.rs`. Scores and hours unchanged.
- **Plug-in settings and cache (G17, #2191):** at plug-in API level 2 a plug-in with the `settings` permission keeps settings of its own (`env.vc_settings_get`, `_set`, `_delete`) and one with `cache` a cache the user can clear (`env.vc_cache_read`, `_write`, `_clear`), under its id in the `Plug-ins` folder. Keys are plain names, never paths; VectorCraft's own caps refuse a write past 256 settings, 1 MiB of settings or 512 MiB of cache; a damaged settings file is moved aside and the settings start empty. `plugin.clearCache` empties a cache, `plugin.list` and `plugin.info` give `cacheBytes`, replacing a plug-in keeps its storage and `plugin.remove` deletes it. Another module under an id that is installed or has storage installs only with `replace: true`, which the app asks the user for; each storage call counts the whole settings file or cache entry it reads and writes toward the run's 1 GiB. Regressions: `crates/plugins/src/storage.rs`, `crates/plugins/tests/abi.rs`, `crates/engine/src/tests/plugins.rs`, `crates/engine/tests/plugin_clear_cache.rs`, `crates/engine/tests/plugin_fuzz.rs`. Scores and hours unchanged.
- **Plug-in settings and cache totals in memory (#6228):** the settings stay in memory while `settings.json` keeps its size and modified time, a set to the same value writes nothing, every other change is written to the file before the call returns, and `cacheBytes` comes from a total kept while the `cache` folder keeps its modified time. Regressions: `crates/engine/src/tests/plugins.rs`. Scores and hours unchanged.
- **Plug-in settings a failed removal can't move back:** when `plugin.remove` can't remove the grant and can't rename the plug-in's `.removed` folder back, its error names that folder, and the next load, install or removal of the plug-in renames it back instead of deleting it. Regressions: `crates/engine/src/tests/plugins.rs`. Scores and hours unchanged.