From c8177dc7e8126abae6602b3c10cd21d37ec9e511 Mon Sep 17 00:00:00 2001 From: dotCooCoo Date: Fri, 13 Nov 2026 13:33:01 -0800 Subject: [PATCH] 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. --- crates/effects/src/solids.rs | 25 ++++++-- crates/effects/src/solids/wireframe.rs | 8 +-- crates/effects/src/tests.rs | 1 + crates/effects/src/tests/solidcontext.rs | 74 ++++++++++++++++++++++++ docs/target-app-parity.md | 1 + 5 files changed, 99 insertions(+), 10 deletions(-) create mode 100644 crates/effects/src/tests/solidcontext.rs diff --git a/crates/effects/src/solids.rs b/crates/effects/src/solids.rs index 2642fca878..a68ff9529f 100644 --- a/crates/effects/src/solids.rs +++ b/crates/effects/src/solids.rs @@ -209,6 +209,19 @@ pub(crate) fn solid_paints(n: &Node) -> Vec { 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>, Vec) { + 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, String> { let view = view_options(p); match id { @@ -1771,7 +1784,7 @@ pub fn solid_art_in(doc: Option<&Document>, n: &Node) -> Option { } 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; if let Some((index, fx)) = solid(&n.appearance.effects) { @@ -1789,9 +1802,9 @@ pub fn solid_art_in(doc: Option<&Document>, n: &Node) -> Option { 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 = item.effects().iter().skip(index + 1).filter(|e| !is_three_d(&e.id)).cloned().collect(); let with_later = |mut art: Arc| { @@ -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)?); } } diff --git a/crates/effects/src/solids/wireframe.rs b/crates/effects/src/solids/wireframe.rs index 00e741bfe6..e6e7d89ab5 100644 --- a/crates/effects/src/solids/wireframe.rs +++ b/crates/effects/src/solids/wireframe.rs @@ -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, 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 })); } diff --git a/crates/effects/src/tests.rs b/crates/effects/src/tests.rs index 48665539d9..627cc1be03 100644 --- a/crates/effects/src/tests.rs +++ b/crates/effects/src/tests.rs @@ -8,6 +8,7 @@ mod imageoutline; mod painted; mod rasterfollow; mod scale; +mod solidcontext; mod transformfx; fn fx(id: &str, params: Value) -> Effect { diff --git a/crates/effects/src/tests/solidcontext.rs b/crates/effects/src/tests/solidcontext.rs new file mode 100644 index 0000000000..2de212dba1 --- /dev/null +++ b/crates/effects/src/tests/solidcontext.rs @@ -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) -> 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"); +} diff --git a/docs/target-app-parity.md b/docs/target-app-parity.md index 167d99d3ab..4948ac869c 100644 --- a/docs/target-app-parity.md +++ b/docs/target-app-parity.md @@ -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.