Effects: a plug-in before a fill's or stroke's own 3D effect is told its fill or stroke

A 3D effect on a fill or stroke worked on copies of the object's fills
and strokes, and the copies were looked up among the object's items by
identity, so a live-effect plug-in before that 3D effect was given
`_context.item` "object" and a deferred stage there was listed with no
item. The drawn art, the 3D meshes and Expand as Wireframes now pass the
original item, so the plug-in is given `{"fill": i}` or `{"stroke": i}`
as docs/plugins.md describes. The paints of a type's first run still have
no item.
This commit is contained in:
dotCooCoo
2026-11-13 13:33:01 -08:00
parent f8751a8c0c
commit c8177dc7e8
5 changed files with 99 additions and 10 deletions
+19 -6
View File
@@ -209,6 +209,19 @@ pub(crate) fn solid_paints(n: &Node) -> Vec<AppearanceItem> {
runs
}
/// [`solid_paints`] with, for each paint, the item of `n` it copies: the item a live-effect plug-in
/// before the paint's own 3D effect is told it sits on. The paints of a type's first run copy no
/// item of `n` and have none.
pub(crate) fn solid_paint_sources(n: &Node) -> (Vec<Option<&AppearanceItem>>, Vec<AppearanceItem>) {
let own: Vec<&AppearanceItem> = n.appearance.items.iter().filter(|i| crate::paints(i)).collect();
if own.is_empty() {
let paints = solid_paints(n);
return (vec![None; paints.len()], paints);
}
let paints = own.iter().map(|i| (*i).clone()).collect();
(own.into_iter().map(Some).collect(), paints)
}
fn facets(id: &str, path: &PathData, rule: FillRule, bounds: Rect, p: &Value) -> Result<Vec<Facet>, String> {
let view = view_options(p);
match id {
@@ -1771,7 +1784,7 @@ pub fn solid_art_in(doc: Option<&Document>, n: &Node) -> Option<Node> {
}
let (path, rule) = profile(n)?;
let ctx = GeomContext::of(n);
let paints = solid_paints(n);
let (sources, paints) = solid_paint_sources(n);
let mut children = vec![];
let remaining: Vec<Effect>;
if let Some((index, fx)) = solid(&n.appearance.effects) {
@@ -1789,9 +1802,9 @@ pub fn solid_art_in(doc: Option<&Document>, n: &Node) -> Option<Node> {
crate::raster::follow_paints(&mut paints, &n.appearance.effects);
// Every item's shadow goes behind every item's art.
let mut shadows = vec![];
for item in &paints {
for (source, item) in sources.into_iter().zip(&paints) {
if let Some((index, fx)) = solid(item.effects()) {
let p = geometry(&path, item.effects().get(..index)?, &ctx.item(item));
let p = geometry(&path, item.effects().get(..index)?, &ctx.item(source.unwrap_or(item)));
let (shadow, surfaces) = art_for(doc, n, fx, &[Profile { path: p, rule, items: vec![item.clone()], opacity: 1.0, scopes: vec![] }])?;
let later: Vec<Effect> = item.effects().iter().skip(index + 1).filter(|e| !is_three_d(&e.id)).cloned().collect();
let with_later = |mut art: Arc<Node>| {
@@ -2086,16 +2099,16 @@ fn meshes_within(doc: Option<&Document>, n: &Node, total: &mut usize, limit: usi
} else {
let (path, rule) = object_profile(n, label)?;
let ctx = GeomContext::of(n);
let paints = solid_paints(n);
let (sources, paints) = solid_paint_sources(n);
if let Some((index, fx)) = solid(&n.appearance.effects) {
let path = geometry(&path, n.appearance.effects.get(..index).unwrap_or_default(), &ctx);
meshes_for(fx, &[Profile { path, rule, items: paints, opacity: 1.0, scopes: vec![] }], total, limit)?
} else {
let path = geometry(&path, &n.appearance.effects, &ctx);
let mut out = vec![];
for item in &paints {
for (source, item) in sources.into_iter().zip(&paints) {
if let Some((index, fx)) = solid(item.effects()) {
let p = geometry(&path, item.effects().get(..index).unwrap_or_default(), &ctx.item(item));
let p = geometry(&path, item.effects().get(..index).unwrap_or_default(), &ctx.item(source.unwrap_or(item)));
out.extend(meshes_for(fx, &[Profile { path: p, rule, items: vec![item.clone()], opacity: 1.0, scopes: vec![] }], total, limit)?);
}
}
+4 -4
View File
@@ -10,7 +10,7 @@ use vectorcraft_three_d::Facet;
use super::{
Profile, ROTATE, built_solids, facets, geometry, has_solid, is_container, is_plane, is_three_d, label_of, members, object_profile, region,
reshape_members, resolve, resolve_for, solid, solid_paints, solid_parts, union_bounds,
reshape_members, resolve, resolve_for, solid, solid_paint_sources, solid_parts, union_bounds,
};
use crate::{GeomContext, is_geometry};
@@ -105,16 +105,16 @@ fn solid_face_paths(doc: Option<&Document>, n: &Node) -> Result<Vec<PathData>, S
let label = n.appearance.effects.iter().find(|e| e.visible && is_three_d(&e.id)).map_or("This effect", |e| label_of(&e.id));
let (path, rule) = object_profile(n, label)?;
let ctx = GeomContext::of(n);
let paints = solid_paints(n);
let (sources, paints) = solid_paint_sources(n);
if let Some((index, fx)) = solid(&n.appearance.effects) {
let path = geometry(&path, n.appearance.effects.get(..index).unwrap_or_default(), &ctx);
return facet_paths(fx, &[Profile { path, rule, items: paints, opacity: 1.0, scopes: vec![] }]);
}
let path = geometry(&path, &n.appearance.effects, &ctx);
let mut out = vec![];
for item in &paints {
for (source, item) in sources.into_iter().zip(&paints) {
if let Some((index, fx)) = solid(item.effects()) {
let p = geometry(&path, item.effects().get(..index).unwrap_or_default(), &ctx.item(item));
let p = geometry(&path, item.effects().get(..index).unwrap_or_default(), &ctx.item(source.unwrap_or(item)));
let faces = facet_paths(fx, &[Profile { path: p, rule, items: vec![item.clone()], opacity: 1.0, scopes: vec![] }])?;
out.extend(reshaped(faces, item.effects().get(index + 1..).unwrap_or_default(), &GeomContext { doc, ..ctx }));
}
+1
View File
@@ -8,6 +8,7 @@ mod imageoutline;
mod painted;
mod rasterfollow;
mod scale;
mod solidcontext;
mod transformfx;
fn fx(id: &str, params: Value) -> Effect {
+74
View File
@@ -0,0 +1,74 @@
//! A live-effect plug-in before a fill's or stroke's own 3D effect is told which fill or stroke it
//! is on, in the drawn art, the exported meshes and the wireframe.
use serde_json::json;
use vectorcraft_color::{Color, Paint};
use vectorcraft_doc::{Appearance, AppearanceItem, Effect, FillLayer, Node, NodeId};
use vectorcraft_geom::{PathData, Rect, shapes};
use vectorcraft_plugins::wat as tpl;
use crate::{EXTRUDE, solid_art, three_d_meshes_in, three_d_wireframe_in};
/// Widths the plug-in's squares are drawn at: `LARGE` when it is told it is on the item it looks
/// for, `SMALL` otherwise.
const LARGE: f64 = 600.0;
const SMALL: f64 = 6.0;
/// A rectangle with a fill, a 1 pt stroke and a second fill on top. The item at `at` (an index in
/// its appearance items) has, in its own effects, live-effect plug-in `name` and then Extrude &
/// Bevel. The plug-in returns a `LARGE` square when it is given `item`, and a `SMALL` one otherwise.
fn plugin_then_extrude(name: &str, at: usize, item: &str) -> Node {
let square = |w: f64| shapes::rectangle(Rect::new(0.0, 0.0, w, w));
let id = format!("test.effects.solidcontext.{name}");
let manifest = format!(r#"{{"id":"{id}","name":"Test {id}","version":"1","kind":"effect","params":{{}}}}"#);
let (large, small) = (json!({"objects": [{"path": square(LARGE)}]}).to_string(), json!({"objects": [{"path": square(SMALL)}]}).to_string());
let module = wat::parse_str(tpl::when_given_else(&manifest, tpl::Given::Params, item, &large, &small)).unwrap();
vectorcraft_plugins::registry::install_bytes(&module).unwrap();
let mut appearance = Appearance::basic(Paint::solid(Color::rgb(1.0, 0.0, 0.0)), Paint::solid(Color::BLACK), 1.0);
appearance.items.push(AppearanceItem::Fill(FillLayer::new(Paint::solid(Color::rgb(0.0, 0.0, 1.0)))));
let effects = appearance.items[at].effects_mut();
effects.push(Effect { id: format!("plugin.{id}"), params: json!({}), visible: true });
effects.push(crate::new_effect(EXTRUDE, &json!({})).unwrap());
Node::path(NodeId(7), shapes::rectangle(Rect::new(0.0, 0.0, 20.0, 20.0)), appearance)
}
/// The width of the union of `paths`' bounds.
fn width(paths: impl IntoIterator<Item = PathData>) -> f64 {
paths.into_iter().filter_map(|p| p.bounds()).reduce(|a, b| a.union(b)).map_or(0.0, |b| b.width())
}
/// Whether each of the art, the meshes and the wireframe `n`'s 3D effect draws is wider than half
/// of `LARGE`, with the widths.
fn drawn_large(n: &Node) -> ([bool; 3], [f64; 3]) {
let art = solid_art(n).unwrap();
let mut paths = vec![];
art.walk(&mut |c| paths.extend(c.path_data().cloned()));
let meshes = three_d_meshes_in(None, n).unwrap();
let xs = meshes.iter().flat_map(|m| m.mesh.positions.iter().map(|p| p[0]));
let mesh_width = xs.clone().fold(f64::NEG_INFINITY, f64::max) - xs.fold(f64::INFINITY, f64::min);
let widths = [width(paths), mesh_width, width(three_d_wireframe_in(None, n).unwrap())];
(widths.map(|w| w > LARGE / 2.0), widths)
}
/// A plug-in before the second fill's Extrude & Bevel is given `{"fill": 1}`, not `"object"`, so
/// the art, meshes and wireframe are all built from the square it returns for that fill.
#[test]
fn a_plugin_before_a_fills_extrude_is_told_its_fill() {
let (large, widths) = drawn_large(&plugin_then_extrude("fill", 2, r#""item":{"fill":1}"#));
assert_eq!(large, [true; 3], "the art, meshes and wireframe are {widths:?} wide");
}
/// A plug-in before the stroke's Extrude & Bevel is given `{"stroke": 0}`.
#[test]
fn a_plugin_before_a_strokes_extrude_is_told_its_stroke() {
let (large, widths) = drawn_large(&plugin_then_extrude("stroke", 1, r#""item":{"stroke":0}"#));
assert_eq!(large, [true; 3], "the art, meshes and wireframe are {widths:?} wide");
}
/// A plug-in on a fill is not told it is on the object: the square it returns otherwise is the one
/// extruded.
#[test]
fn a_plugin_on_a_fill_is_not_told_it_is_on_the_object() {
let (large, widths) = drawn_large(&plugin_then_extrude("object", 2, r#""item":"object""#));
assert_eq!(large, [false; 3], "the art, meshes and wireframe are {widths:?} wide");
}
+1
View File
@@ -1873,6 +1873,7 @@ Moved from the ROADMAP's "Shipped so far" on 2026-10-10. Update it when a featur
- **Plug-in parameters as their manifest presents them (G17):** each parameter may have a `label`, a `unit` shown after its value, `help` on its label and a `helpUrl` page opened from a button, a `group` (sections that fold, after the parameters outside any group) and a `visibleWhen` that shows it only while the checkboxes and choices it names have one of the listed values (a hidden parameter still goes to the plug-in). `string` and `numberList` parameters join number, int, bool and choice, and a plug-in declares up to 256 parameters, at most 64 outside groups. The filter, live-effect and exporter dialogs use these keys, and the Appearance panel's effect editor the labels, units, help and conditions; hosts from before them ignore them. Regressions: `crates/plugins/src/dialog.rs`, `crates/plugins/src/manifest.rs`, `crates/ui-egui/src/dialogs/tests_plugin_fields.rs`, `crates/ui-egui/src/panels/appearance.rs`. Scores and hours unchanged.
- **Live-effect plug-ins read their object's paints (G17):** a live effect is given the object's `fills` and `strokes` as a filter gets them, read only, and `_context.item` (`"object"`, or the fill or stroke whose own effects hold it); the result is cached by paint too, so a change of paint runs it again. Regressions: `crates/engine/src/tests/plugins.rs`, `crates/plugins/src/effect.rs`. Scores and hours unchanged.
- **Live-effect plug-ins before a fill's or stroke's own 3D effect:** a live effect listed before a fill's or stroke's own Extrude & Bevel, Inflate or Rotate effect is given `_context.item` for that fill or stroke (`{"fill": i}` or `{"stroke": i}`), not `"object"`, in the drawn art, in the 3D meshes and in Expand as Wireframes; a deferred stage there is listed at that fill's or stroke's place. Regressions: `crates/effects/src/tests/solidcontext.rs`. Scores and hours unchanged.
- **Filter plug-ins set effects and stroke styles (G17):** a filter is given each object's effect records and its strokes' `join`, `cap` and `miterLimit`, and may return them; the records are admitted as `effect.apply` admits them (a known effect or installed live-effect plug-in, parameters it takes, one 3D effect that fits, no texture read from a file), and one refused refuses the output. Regressions: `crates/engine/src/tests/plugins.rs`, `crates/plugins/src/objects/tests.rs`. Scores and hours unchanged.
- **Gradient stop plates from filter plug-ins are clamped (G17):** the four process `plates` of a gradient stop in a filter's or importer's output are clamped to 0–1, as its other colors are; a value that is not a number becomes 0, and an infinite one becomes 0 or 1 by its sign. Painted live effects still refuse such a stop. Regressions: `crates/plugins/src/objects/tests.rs`. Scores and hours unchanged.
- **Plug-in data kept with the document (G17):** a filter writes JSON of at most 1 MiB with `documentData` (one undo step with its objects; `null` removes it) and reads it back in `_context.documentData`, which no other filter is given. It is the document's foreign data (`unknown.pluginData`, by plug-in id), as the perspective grid is, so the native file keeps it with no section of its own; an exporter is given the whole document, so it can read every plug-in's. Regressions: `crates/engine/src/tests/plugins.rs`, `crates/engine/tests/plugin_export.rs` (the exporter), `crates/doc/src/plugin_data.rs`, `crates/plugins/src/objects/tests.rs`. Scores and hours unchanged.