diff --git a/crates/engine/tests/color_settings.rs b/crates/engine/tests/color_settings.rs index ba9539de8..74ccfc0b4 100644 --- a/crates/engine/tests/color_settings.rs +++ b/crates/engine/tests/color_settings.rs @@ -227,6 +227,43 @@ fn live_objects_draw_through_new_color_settings() { } } +/// The art drawn for 3D effects and color adjustments follows the Color Settings: in the next +/// frame, a CMYK cyan rectangle with Revolve, with Extrude & Bevel, with Inflate, with Rotate, with +/// Brightness/Contrast, and a group holding it with Brightness/Contrast, draw through the new +/// working CMYK profile. +#[test] +fn effect_art_draws_through_new_color_settings() { + let _g = settings(); + let old = cms::active_settings(); + let cyan = json!({"c": 100, "m": 0, "y": 0, "k": 0}); + let brighter = json!({"brightness": 30}); + let cases = [ + ("the revolved rectangle", "threeD.revolve", json!({}), false), + ("the extruded rectangle", "threeD.extrude", json!({}), false), + ("the inflated rectangle", "threeD.inflate", json!({}), false), + ("the rotated rectangle", "threeD.rotate", json!({}), false), + ("the adjusted rectangle", "adjust.brightnessContrast", brighter.clone(), false), + ("the adjusted group", "adjust.brightnessContrast", brighter, true), + ]; + let opts = RenderOptions { background: Some([255, 255, 255, 255]), ..Default::default() }; + let frame = |r: &mut Renderer, s: &Session| r.render(&s.doc().unwrap().doc, 400, 300, Affine::IDENTITY, &opts).pixels; + for (what, effect, params, grouped) in cases { + let mut s = session(); + let shape = rect(&mut s, 150.0, 60.0, 60.0, 120.0); + exec(&mut s, "paint.setFill", json!({"ids": [shape.0], "color": cyan})); + let target = if grouped { id_of(&exec(&mut s, "object.group", json!({"ids": [shape.0]}))) } else { shape }; + exec(&mut s, "effect.apply", json!({"ids": [target.0], "effect": effect, "params": params})); + let mut r = Renderer::new(); + let before = frame(&mut r, &s); + s.execute("edit.colorSettings", &json!({"cmyk": cms::DEVICE_CMYK})).unwrap(); + let fresh = frame(&mut Renderer::new(), &s); + let warm = frame(&mut r, &s); + cms::set_active(&old).unwrap(); + assert!(fresh != before, "{what}: the new profile changes the frame"); + assert!(warm == fresh, "{what}: drawn as a new renderer draws it"); + } +} + /// #2276: with a working RGB space other than sRGB, the Eyedropper takes an image's sRGB pixel /// into the working space, so the sampled fill shows as the pixel it was picked from. #[test] diff --git a/crates/render/src/fx.rs b/crates/render/src/fx.rs index 0d0adf794..8e615d773 100644 --- a/crates/render/src/fx.rs +++ b/crates/render/src/fx.rs @@ -108,6 +108,23 @@ fn evaluates_live(n: &Node) -> bool { is_live(n) || (is_container(n) && to_members && !effects::has_pathfinder(n) && subtree_has_live(n)) } +/// Can the art [`Renderer::fx_art`] makes for `n` change while `n` stays the same, other than +/// through live objects it evaluates? It can when `n` holds type (laid out in the fonts installed), +/// a symbol instance (made from its symbol's art), or an object with a 3D effect or a color +/// adjustment (shaded or recolored through the color settings), in it or in the opacity masks of +/// the objects in it. +fn reads_outside(n: &Node) -> bool { + let mut reads = false; + n.walk(&mut |c| { + reads |= matches!(c.kind, NodeKind::Text(_) | NodeKind::SymbolInstance { .. }) + || effects::has_revolve(c) + || effects::has_solid(c) + || effects::has_adjustment(c) + || c.extras().mask.as_ref().is_some_and(|m| reads_outside(&m.art)) + }); + reads +} + /// What [`Renderer::raster_fx`] paints effects around. struct Content<'a> { /// The object the effects belong to (cached shadows are checked against it). @@ -1358,8 +1375,9 @@ impl Renderer { /// as their evaluated art (groups and layers: [`effects::evaluate_container`], keeping their /// knockout setting), recoloured by the colour adjustments in it ([`Self::adjusted_art`]); /// without the object's transparency and effects (a path keeps its geometry effects). Art that - /// is made from live objects it evaluated ([`evaluates_live`]) is kept only while the inputs of - /// their evaluation stay the same ([`Self::live_found`]). + /// is made from live objects it evaluated ([`evaluates_live`]), or from type, symbol art or + /// colors shaded through the color settings ([`reads_outside`]), is kept only while what it + /// reads from outside `a` stays the same ([`Self::live_found`]). fn fx_art(&mut self, doc: &vectorcraft_doc::Document, a: &Arc, cache: bool) -> Arc { let key = Arc::as_ptr(a) as usize; let mut kept = None; @@ -1372,7 +1390,7 @@ impl Renderer { Some(was) => kept = Some((art.clone(), was.0)), } } - let found = (kept.is_some() || (cache && evaluates_live(a))).then(|| self.live_found(doc, a)); + let found = (kept.is_some() || (cache && (evaluates_live(a) || reads_outside(a)))).then(|| self.live_found(doc, a)); if let Some((art, was)) = kept && found.as_ref().is_some_and(|f| f.0 == was) { diff --git a/crates/render/src/lib.rs b/crates/render/src/lib.rs index 0169a3593..e5c1300e2 100644 --- a/crates/render/src/lib.rs +++ b/crates/render/src/lib.rs @@ -410,8 +410,9 @@ type ClipEntry = (Arc, Option>); /// A clipping path (kept alive) and what it paints: its fills and its strokes ([`Node::clip_paint`]). type ClipPaintEntry = (Arc, Option>, Option>); /// An object with effects (kept alive), the art drawn for it ([`Renderer::fx_art`]), and, when that -/// art can be made from evaluated live objects (`fx::evaluates_live`), the inputs of their -/// evaluation ([`live::Found`]). +/// art can be made from evaluated live objects (`fx::evaluates_live`) or from type, symbol art or +/// colors shaded through the color settings (`fx::reads_outside`), the inputs it was made with +/// ([`live::Found`]). type FxArtEntry = (Arc, Arc, Option); /// What symbol art placed by [`Renderer::placed_art`] is kept under: the art's address, the bits /// of the transform's coefficients, whether strokes scale with it, and the stain ([`stain_bits`]). diff --git a/crates/render/src/live.rs b/crates/render/src/live.rs index 285aaab5f..ad25aeb8a 100644 --- a/crates/render/src/live.rs +++ b/crates/render/src/live.rs @@ -68,8 +68,8 @@ pub fn expand_live(n: &Node) -> Vec { crate::effects::expand_live(None, n) } -/// What the evaluation of `n` (a live node, or an object whose effect art can be made from -/// evaluated live objects, see [`Renderer::live_found`]) reads from the document, placed files, +/// What the evaluation of `n` (a live node, or an object whose effect art the renderer keeps with +/// its inputs, see [`Renderer::live_found`]) reads from the document, placed files, /// the fonts, the plug-ins and the color settings, hashed: the art of the symbols, the tilings of /// the patterns and the reads of the placed files it shows ([`hash_resources`]), the font /// generation ([`vectorcraft_text::FontDb::generation`]) when it shows type or a placed file, the @@ -343,7 +343,7 @@ fn spread_to_edges(px: &mut [vello_cpu::color::PremulRgba8], filled: Vec, impl Renderer { /// The [`resources_key`] of `n` in `doc`, with the frame's [`DocInputs`] ([`Found`]): `n` is a /// live node, or an object with effects whose art ([`Renderer::fx_art`]) can be made from - /// evaluated live objects. + /// evaluated live objects, type, symbol art or colors shaded through the color settings. pub(crate) fn live_found(&mut self, doc: &Document, n: &Node) -> Found { let inputs = self.doc_inputs(doc); let (key, reads) = resources_key(doc, inputs.cms, n); diff --git a/crates/render/src/tests_fontchange.rs b/crates/render/src/tests_fontchange.rs index 26af4830a..9f7b543b7 100644 --- a/crates/render/src/tests_fontchange.rs +++ b/crates/render/src/tests_fontchange.rs @@ -155,6 +155,60 @@ fn symbol_type_redraws_when_its_font_becomes_available() { assert_eq!(after, Renderer::new().render(&d, 300, 150, Affine::IDENTITY, &opts).pixels, "as a new renderer draws it"); } +/// Type that a geometry effect outlines redraws in a font that becomes available: the art drawn for +/// the effects of the type, of a group that holds it and of a symbol instance whose art holds it +/// is made again when the fonts change. +#[test] +fn type_with_geometry_effects_redraws_when_its_font_becomes_available() { + let _adding = adding_fonts(); + use vectorcraft_doc::Effect; + let moved = || Effect { id: "distort.transform".into(), params: serde_json::json!({"moveH": 0.0}), visible: true }; + let cases = [ + ("Moving Serif 4", "the type with a Transform effect"), + ("Shadow Serif 5", "the type with a Transform effect and a drop shadow"), + ("Massed Serif 4", "the group with a Transform effect"), + ("Stamps Serif 4", "the symbol instance with a Transform effect"), + ("Darker Serif 4", "the type with a Transform effect in Multiply"), + ]; + for (i, (family, what)) in cases.into_iter().enumerate() { + let mut d = Document::new(300.0, 150.0); + let style = CharStyle { size: 50.0, font_family: family.into(), ..Default::default() }; + let at = if i == 3 { Point::new(-140.0, 20.0) } else { Point::new(10.0, 80.0) }; + let mut object = Node::new(d.alloc_id(), NodeKind::Text(Box::new(TextObject::point(at, "Hamburg", style)))); + match i { + 0 => object.appearance.effects.push(moved()), + 1 => { + object.appearance.effects.push(moved()); + let params = serde_json::json!({"x": 4, "y": 4, "blur": 1, "opacity": 100}); + object.appearance.effects.push(Effect { id: "stylize.dropShadow".into(), params, visible: true }); + } + 2 => { + object = Node::group(d.alloc_id(), vec![Arc::new(object)]); + object.appearance.effects.push(moved()); + } + 3 => { + let art = Node::group(d.alloc_id(), vec![Arc::new(object)]); + d.symbols.push(vectorcraft_doc::Symbol { name: "Word".into(), art: Arc::new(art) }); + object = Node::new(d.alloc_id(), NodeKind::SymbolInstance { symbol: "Word".into(), xf: Affine::translate((150.0, 75.0)) }); + object.appearance.effects.push(moved()); + } + _ => { + object.appearance.effects.push(moved()); + object.blend = vectorcraft_color::BlendMode::Multiply; + } + } + let l = d.layers[0].id; + d.insert(Some(l), 0, object).unwrap(); + let opts = RenderOptions { background: Some([255, 255, 255, 255]), ..Default::default() }; + let mut r = Renderer::new(); + let before = r.render(&d, 300, 150, Affine::IDENTITY, &opts).pixels; + assert!(vectorcraft_text::FontDb::global().add_font(serif_as(family)) > 0); + let after = r.render(&d, 300, 150, Affine::IDENTITY, &opts).pixels; + assert_ne!(before, after, "{what}: drawn in the font that arrived"); + assert_eq!(after, Renderer::new().render(&d, 300, 150, Affine::IDENTITY, &opts).pixels, "{what}: as a new renderer draws it"); + } +} + /// Type that an envelope bends redraws in a font that becomes available, in the envelope and in /// the art of a symbol whose instance an envelope bends: the envelopes are evaluated again when the /// fonts change. diff --git a/docs/gaps.md b/docs/gaps.md index 3c4f678eb..df8d4ee9a 100644 --- a/docs/gaps.md +++ b/docs/gaps.md @@ -313,8 +313,6 @@ from G14 and the budget run of G15: **~170–270 h** one agent, ~50–80 h wall on 2026-10-13, missed all three render budgets at 20k paths), a spatial index for zoomed-in frames, dirty-region rendering, the GPU backend spike (M5). Large-selection edits, cached live effects and Pathfinder Trim, Merge, Crop and Outline landed in #1173. -- **Not redrawn:** the canvas keeps drawing these objects as they were: - in the web build, type with a geometry effect keeps a substitute font's outlines after its own font has loaded. - **Estimate:** 15–25 h (the budget run and fixes block beta). ## G16 Localization: the three missing key languages diff --git a/docs/target-app-parity.md b/docs/target-app-parity.md index b2ef4434f..78d3f9c44 100644 --- a/docs/target-app-parity.md +++ b/docs/target-app-parity.md @@ -818,6 +818,8 @@ Moved from the ROADMAP's "Shipped so far" on 2026-10-10. Update it when a featur - **Plug-in effects after a plug-in changes:** when a plug-in is installed for the first time, installed again or removed (`plugin.install`, `plugin.reload`, `plugin.remove`, File › Open of a `.wasm` file, the Additional Plug-ins Folder preference), the next frame the canvas draws uses the plug-ins then installed for objects with a plug-in effect and their raster effects, for the blends, envelopes and repeats that show such objects, and for pattern tiles and symbol instances whose art holds one. +- **Effect art after font and color settings changes:** when a font becomes available (fonts added, Refresh Font List, `text.rescanFonts`), the next frame the canvas draws outlines type in it where a geometry effect reshapes the type, a group that holds it or a symbol instance whose art holds it. When the color settings change (Edit › Color Settings, `edit.colorSettings`), the next frame shades objects with Revolve, Extrude & Bevel, Inflate or Rotate, and recolors objects and groups with a color adjustment, through the new settings. + - **Full font names (#1258):** OpenType name ID 4 identifies its face alongside family/legacy/PostScript names, including localized names, for loaded fonts, cataloged files and Missing Fonts searches. Synthetic name-table regressions cover full names distinct from their PostScript name, real-family precedence and variable fonts' default faces. Windows font-service activation remains unverified; scores and hours are unchanged. ### Corpus integrity evidence