Merge pull request #7614 from dotCooCoo/fix/export-cull-brushed-path-art

PDF, EPS and print: a brushed path just off the page whose brush paints onto it is written
This commit is contained in:
BFlat
2026-11-15 06:17:15 +07:00
committed by GitHub
10 changed files with 241 additions and 15 deletions
+27
View File
@@ -1008,6 +1008,33 @@ pub fn has_brush(n: &Node) -> bool {
n.appearance.items.iter().any(|i| matches!(i, AppearanceItem::Stroke(s) if s.brush.is_some() && s.visible))
}
/// How far brush art can paint past the visual box of `n`: `16 × w + 8`, where `w` is the widest
/// stroke of `n` (at least 1), when `n` has a visible brushed stroke with a paint; 0 when it has
/// none, as a brushed stroke without a paint draws no brush art. This is the room the renderer and
/// the exports leave for brush art when they cull.
pub fn reach(n: &Node) -> f64 {
let paints = |i: &AppearanceItem| matches!(i, AppearanceItem::Stroke(s) if s.visible && s.brush.is_some() && !s.paint.is_none());
if !n.appearance.items.iter().any(paints) {
return 0.0;
}
let w = n.appearance.items.iter().filter_map(|i| if let AppearanceItem::Stroke(s) = i { Some(s.width) } else { None }).fold(1.0, f64::max);
16.0 * w + 8.0
}
/// The largest [`reach`] of `n` and of the objects in it: the members of its groups, layers and
/// live objects. Of a group or layer with a clipping path only the clipping path counts, as the
/// clip keeps its members' brush art inside it. A compound path counts its own strokes only, as
/// it is painted with its own appearance and not its paths'. Symbol art is not looked into
/// ([`vectorcraft_doc::SymbolBoxes::with_brush_art`] measures it).
pub fn reach_within(n: &Node) -> f64 {
let members = match &n.kind {
NodeKind::Group { children, clip: true } | NodeKind::Layer { children, clip: true, .. } => children.get(..1).unwrap_or_default(),
NodeKind::Compound { .. } => &[],
_ => n.children().map_or(&[][..], |c| &c[..]),
};
members.iter().map(|c| reach_within(c)).fold(reach(n), f64::max)
}
/// Expand a brushed path: a group of [the path with its fills and unbrushed strokes (if any
/// paint remains), the brush art…]. Ids are 0 (the caller re-ids). `None` if not brushed.
pub fn expand(doc: &Document, n: &Node) -> Option<Node> {
+4 -1
View File
@@ -489,8 +489,11 @@ impl<'a> Scene<'a> {
self.warn(TOO_LARGE);
return;
}
// Off the region, an object is still written when brush art painted along its brushed
// strokes, or those of the objects in it, can reach the region.
let off = |b: Rect, reach: f64| !overlaps(b.inflate(1.0 + reach, 1.0 + reach), self.o.region);
match n.visual_bounds_with(&self.symbols) {
Some(b) if !force && !overlaps(b.inflate(1.0, 1.0), self.o.region) => return,
Some(b) if !force && off(b, 0.0) && off(b, vectorcraft_brush::reach_within(n)) => return,
None if !n.is_container() => return,
_ => {}
}
+53
View File
@@ -1654,6 +1654,59 @@ fn an_instance_whose_brush_art_reaches_the_region_is_written() {
assert!(page.contains("1 0 0 rg"), "{page}");
}
#[test]
fn a_path_whose_brush_art_reaches_the_region_is_written() {
// A line from (-5, 40) to (-5, 60) with a 1 pt red stroke painted by a 20 pt round nib lies
// left of the artboard, and the nib paints 10 pt either side of it, onto the artboard: alone, in
// a group and as a compound path. Without its brush, or 100 pt left of the artboard, where the
// nib does not reach it, the line is left out.
let red = |x: f64, brushed: bool| {
let mut n = Node::path(
vectorcraft_doc::NodeId(0),
shapes::line((x, 40.0).into(), (x, 60.0).into()),
Appearance::basic(Paint::None, Paint::solid(Color::rgb(1.0, 0.0, 0.0)), 1.0),
);
if brushed {
n.appearance.stroke_mut().unwrap().brush = Some("3 pt. Round".into());
}
n
};
let cases: [(&str, f64, bool, bool); 5] = [
("path", -5.0, true, true),
("group", -5.0, true, true),
("compound path", -5.0, true, true),
("unbrushed path", -5.0, false, false),
("far path", -100.0, true, false),
];
for (what, x, brushed, written) in cases {
let mut d = Document::new(200.0, 200.0);
let mut lib = vectorcraft_brush::library(&d);
let Some(vectorcraft_brush::BrushKind::Calligraphic(c)) = lib.iter_mut().find(|b| b.name == "3 pt. Round").map(|b| &mut b.kind) else {
panic!("the 3 pt. Round brush")
};
c.size = 20.0;
vectorcraft_brush::store(&mut d, &lib);
let line = red(x, brushed);
let mut n = match what {
"group" => Node::group(vectorcraft_doc::NodeId(0), vec![Arc::new(line)]),
"compound path" => {
let mut c = Node::new(
vectorcraft_doc::NodeId(0),
NodeKind::Compound { children: vec![Arc::new(red(x, false))], rule: vectorcraft_geom::FillRule::NonZero },
);
c.appearance = line.appearance;
c
}
_ => line,
};
n.id = d.alloc_id();
let layer = d.layers[0].id;
d.insert(Some(layer), usize::MAX, n).unwrap();
let page = page_of(&d, &opts(&d));
assert_eq!(page.contains("1 0 0 rg") || page.contains("1 0 0 RG"), written, "{what}: {page}");
}
}
#[test]
fn symbol_art_effects_are_written_where_they_draw() {
// The symbol's square at (10, 40), which a Transform effect moves 100 pt right, is drawn from
+10 -3
View File
@@ -14,6 +14,11 @@ struct Measured {
members: Vec<Measured>,
}
/// Box `b` grown by `reach`, the room brush art can paint past it; `b` itself when `reach` is 0.
fn brushed(b: Option<Rect>, reach: f64) -> Option<Rect> {
if reach > 0.0 { b.map(|b| b.inflate(reach, reach)) } else { b }
}
#[cfg(test)]
std::thread_local! {
/// The objects this thread has measured for the walk and for the links (tests).
@@ -312,15 +317,17 @@ impl Exporter<'_> {
krilla::mask::Mask::new(sb.finish(), krilla::mask::MaskType::Luminosity)
}
/// The box of `n`, and of the members of a plain group or layer.
/// The box of `n`, and of the members of a plain group or layer. A box grows by the room brush
/// art can paint past the brushed strokes of `n` and of the objects in it
/// ([`vectorcraft_brush::reach_within`]), as the renderer culls it.
fn measure(&self, n: &Node) -> Measured {
if !matches!(n.kind, NodeKind::Layer { clip: false, .. } | NodeKind::Group { clip: false, .. }) {
#[cfg(test)]
MEASURES.with(|m| m.set(m.get() + 1));
return Measured { bounds: n.visual_bounds_with(&self.symbols), members: vec![] };
return Measured { bounds: brushed(n.visual_bounds_with(&self.symbols), vectorcraft_brush::reach_within(n)), members: vec![] };
}
let members: Vec<_> = n.editing_children().iter().map(|c| self.measure(c)).collect();
Measured { bounds: n.visual_of_members(members.iter().map(|m| m.bounds)), members }
Measured { bounds: brushed(n.visual_of_members(members.iter().map(|m| m.bounds)), vectorcraft_brush::reach(n)), members }
}
pub(super) fn node(&mut self, s: &mut Surface, n: &Node, page: Rect, force: bool) {
+8 -4
View File
@@ -71,7 +71,9 @@ pub struct TileGrid {
/// Calls `f` with the bounds of each object of `n` that prints: visible, not on a template
/// layer and not a guide (layers are already picked by the Print Layers option). Symbol
/// instances are measured by their art from `symbols`.
/// instances are measured by their art from `symbols`. A box grows by the room brush art can
/// paint past the brushed strokes of the object and of the objects in it
/// ([`vectorcraft_brush::reach_within`]).
fn printed(n: &Node, symbols: &SymbolBoxes, f: &mut impl FnMut(Rect)) {
if !n.visible {
return;
@@ -85,7 +87,8 @@ fn printed(n: &Node, symbols: &SymbolBoxes, f: &mut impl FnMut(Rect)) {
}
_ => {
if let Some(b) = n.visual_bounds_with(symbols) {
f(b);
let reach = vectorcraft_brush::reach_within(n);
f(if reach > 0.0 { b.inflate(reach, reach) } else { b });
}
}
}
@@ -102,9 +105,10 @@ fn has_art(doc: &Document, r: Rect) -> bool {
any
}
/// The bounds of all the art that prints.
/// The bounds of all the art that prints, with the brush art it paints. The box of a symbol
/// instance whose art holds brushed strokes grows as in [`has_art`].
fn art_bounds(doc: &Document) -> Option<Rect> {
let symbols = doc.symbol_boxes();
let symbols = doc.symbol_boxes().with_brush_art();
let mut all: Option<Rect> = None;
for l in &doc.layers {
printed(l, &symbols, &mut |b| all = Some(all.map_or(b, |a| a.union(b))));
+71
View File
@@ -246,6 +246,77 @@ fn an_instance_whose_brush_art_reaches_the_page_is_written() {
assert!(close(l[0].geometric_bounds().unwrap(), Rect::new(-15.0, 30.0, 5.0, 70.0), 0.01), "{:?}", l[0].geometric_bounds());
}
/// A 200 × 100 pt document whose 3 pt. Round brush paints a 20 pt round nib.
fn doc_with_wide_nib() -> Document {
let mut d = doc(200.0, 100.0);
let mut lib = vectorcraft_brush::library(&d);
let Some(vectorcraft_brush::BrushKind::Calligraphic(c)) = lib.iter_mut().find(|b| b.name == "3 pt. Round").map(|b| &mut b.kind) else {
panic!("the 3 pt. Round brush")
};
c.size = 20.0;
vectorcraft_brush::store(&mut d, &lib);
d
}
/// A line from (`x`, 40) to (`x`, 60) with a 1 pt black stroke, painted by the 3 pt. Round brush
/// when `brushed`.
fn nib_line(x: f64, brushed: bool) -> Node {
let mut stroke = Appearance::basic(Paint::None, Paint::solid(Color::BLACK), 1.0);
if brushed {
stroke.stroke_mut().unwrap().brush = Some("3 pt. Round".into());
}
Node::path(NodeId(0), shapes::line((x, 40.0).into(), (x, 60.0).into()), stroke)
}
#[test]
fn a_path_whose_brush_art_reaches_the_page_is_written() {
// The line at x = -5 lies left of the page, and the 20 pt nib paints 10 pt either side of it,
// onto the page: alone, in a group, in a knockout group and as a compound path.
let compound = |n: Node| {
let mut c = Node::new(NodeId(0), NodeKind::Compound { children: vec![Arc::new(nib_line(-5.0, false))], rule: FillRule::NonZero });
c.appearance = n.appearance;
c
};
let knockout = |n: Node| {
let mut g = Node::group(NodeId(0), vec![Arc::new(n), Arc::new(rect_node(Rect::new(150.0, 10.0, 170.0, 30.0), Color::rgb(0.0, 0.0, 1.0)))]);
g.knockout = vectorcraft_doc::Knockout::On;
g
};
let group = |n: Node| Node::group(NodeId(0), vec![Arc::new(n)]);
let wraps: [(&str, &dyn Fn(Node) -> Node); 4] =
[("path", &|n| n), ("group", &group), ("knockout group", &knockout), ("compound path", &compound)];
for (what, wrap) in wraps {
let mut d = doc_with_wide_nib();
add(&mut d, wrap(nib_line(-5.0, true)));
let out = roundtrip(&d);
let nib: Vec<Rect> = leaves(&out).iter().filter_map(|n| n.geometric_bounds()).filter(|b| b.x1 < 100.0).collect();
assert_eq!(nib.len(), 1, "{what}: the nib's art, {nib:?}");
assert!(close(nib[0], Rect::new(-15.0, 30.0, 5.0, 70.0), 0.01), "{what}: {nib:?}");
}
// Without its brush, or 100 pt left of the page, where the nib does not reach it, the line is
// left out.
for line in [nib_line(-5.0, false), nib_line(-100.0, true)] {
let mut d = doc_with_wide_nib();
add(&mut d, line);
assert!(leaves(&roundtrip(&d)).is_empty());
}
// A clipping path left of the page keeps the nib's art off it.
let mut d = doc_with_wide_nib();
let clip = Node::new(
NodeId(0),
NodeKind::Path {
path: shapes::rectangle(Rect::new(-30.0, 0.0, -2.0, 100.0)),
rule: FillRule::NonZero,
live: None,
clipping: true,
guide: false,
},
);
add(&mut d, Node::new(NodeId(0), NodeKind::Group { children: vec![Arc::new(clip), Arc::new(nib_line(-5.0, true))], clip: true }));
let out = roundtrip(&d);
assert!(leaves(&out).is_empty(), "{:?}", leaves(&out));
}
/// The live effects in a symbol's art apply where the instance places the art, as on the canvas:
/// the symbol's square at (10, 40), which a Transform effect moves 100 pt right, is written at
/// (110, 40).
+56
View File
@@ -451,6 +451,62 @@ fn a_symbol_instances_brush_art_keeps_its_artboard_printed() {
assert!(p[0].1.iter().any(|(_, paint)| rgb8(paint) == [255, 0, 0]), "{p:?}");
}
#[test]
fn a_brushed_paths_brush_art_keeps_its_artboard_printed_and_framed() {
// A line from (-5, 40) to (-5, 60) with a 1 pt red stroke painted by a 20 pt round nib, alone,
// in a group or as the art of a symbol instance: the line lies left of the artboard, and the nib
// paints 10 pt either side of it, onto the artboard.
let line = || {
let mut stroke = Appearance::basic(Paint::None, rgb(1.0, 0.0, 0.0), 1.0);
stroke.stroke_mut().unwrap().brush = Some("3 pt. Round".into());
Node::path(vectorcraft_doc::NodeId(0), shapes::line((-5.0, 40.0).into(), (-5.0, 60.0).into()), stroke)
};
for what in ["path", "group", "symbol instance"] {
let mut d = doc(100.0, 100.0, &[]);
let mut lib = vectorcraft_brush::library(&d);
let Some(vectorcraft_brush::BrushKind::Calligraphic(c)) = lib.iter_mut().find(|b| b.name == "3 pt. Round").map(|b| &mut b.kind) else {
panic!("the 3 pt. Round brush")
};
c.size = 20.0;
vectorcraft_brush::store(&mut d, &lib);
let mut n = match what {
"path" => line(),
"group" => Node::group(vectorcraft_doc::NodeId(0), vec![Arc::new(line())]),
_ => {
d.symbols.push(vectorcraft_doc::Symbol { dynamic: false, name: "Nib".into(), art: Arc::new(line()) });
Node::new(vectorcraft_doc::NodeId(0), NodeKind::SymbolInstance { symbol: "Nib".into(), xf: vectorcraft_geom::Affine::IDENTITY })
}
};
n.id = d.alloc_id();
let layer = d.default_layer().unwrap();
d.insert(Some(layer), 0, n).unwrap();
// Skip Blank Artboards keeps the artboard, and its page holds the nib's art.
let p = pages(&job(&d, json!({"skipBlank": true})).bytes);
assert_eq!(p.len(), 1, "{what}");
assert!(p[0].1.iter().any(|(_, paint)| rgb8(paint) == [255, 0, 0]), "{what}: {p:?}");
// Ignoring artboards, the page holds all the nib's art, 20 × 40 pt.
let pv = preview(&d, &settings(json!({"artboards": "ignore"}))).unwrap();
let t = pv.sheets[0].trim;
assert!(t[2] - t[0] >= 20.0 && t[3] - t[1] >= 40.0, "{what}: {t:?}");
}
// A compound path is painted with its own appearance: the brushed stroke left on its path
// paints nothing, and the artboard stays blank.
let mut d = doc(100.0, 100.0, &[]);
let kind = NodeKind::Compound { children: vec![Arc::new(line())], rule: vectorcraft_geom::FillRule::NonZero };
let mut compound = Node::new(d.alloc_id(), kind);
compound.appearance = Appearance::basic(Paint::None, rgb(1.0, 0.0, 0.0), 1.0).into();
let layer = d.default_layer().unwrap();
d.insert(Some(layer), 0, compound).unwrap();
assert!(preview(&d, &settings(json!({"skipBlank": true}))).is_err());
// A brushed stroke without a paint draws no brush art: the artboard stays blank.
let mut d = doc(100.0, 100.0, &[]);
let mut unpainted = line();
unpainted.id = d.alloc_id();
unpainted.appearance.stroke_mut().unwrap().paint = Paint::None;
d.insert(Some(layer), 0, unpainted).unwrap();
assert!(preview(&d, &settings(json!({"skipBlank": true}))).is_err());
}
#[test]
fn symbol_art_effects_print_where_they_draw() {
// A red square and the symbol's blue square, each moved 100 pt right by a Transform effect,
+5 -5
View File
@@ -155,19 +155,19 @@ pub(crate) fn path_cull_bounds(symbols: &SymbolBoxes, n: &Node, visual: impl FnO
/// How far brush art can paint past `n`'s brushed strokes, or past the brushed strokes in the art
/// of symbol instance `n`: `16 × w + 8`, where `w` is the widest stroke (at least 1) of `n` when it
/// has a visible brushed stroke and of the objects in that art that have one
/// has a visible brushed stroke with a paint and of the objects in that art that have one
/// ([`SymbolBoxes::brush_width`]) or the pieces live-effect plug-ins paint for them
/// ([`crate::fx::painted_brush_width`]), and 0 when neither has one.
/// ([`crate::fx::painted_brush_width`]), and 0 when neither has one. The room for `n`'s own strokes
/// is [`vectorcraft_brush::reach`], which the exports leave too.
pub(crate) fn pad(symbols: &SymbolBoxes, n: &Node) -> f64 {
let own = vectorcraft_brush::has_brush(n)
.then(|| n.appearance.items.iter().filter_map(|i| if let AppearanceItem::Stroke(s) = i { Some(s.width) } else { None }).fold(1.0, f64::max));
let own = vectorcraft_brush::reach(n);
let art = match &n.kind {
NodeKind::SymbolInstance { symbol, .. } => {
symbols.brush_width(symbol).into_iter().chain(crate::fx::painted_brush_width(symbols, symbol)).reduce(f64::max).map(|w| w.max(1.0))
}
_ => None,
};
own.into_iter().chain(art).reduce(f64::max).map_or(0.0, |w| 16.0 * w + 8.0)
art.map_or(own, |w| own.max(16.0 * w + 8.0))
}
pub use vectorcraft_brush::instance_art;
+6 -2
View File
@@ -5134,8 +5134,12 @@ when its symbol or a symbol nested in its art holds an instance of itself, and w
nest 32 deep in its art. PDF and EPS export and the SVG of an artboard leave out art outside the page or artboard, and
Skip Blank Artboards skips an artboard with no art on it. For both, the box of an instance whose art holds brushed
strokes grows by 16 times the widest stroke (at least 1 pt) of the objects in its art that have a brushed stroke,
plus 8 pt. The SVG of an artboard also keeps an object whose effects paint onto the artboard, and an object that holds
an instance when the effects in the symbol's art, or the instance's own, paint onto it.
plus 8 pt. PDF and EPS export and Skip Blank Artboards grow the box of an object with a visible, painted brushed stroke the
same way, by its own widest stroke, and the box of an object that holds one by the largest such room among them (of a
clip group, only its clipping path counts). Print with Ignore Artboards frames the page around the art with the same
room for brushed objects and for instances whose art holds brushed strokes. The SVG of an artboard also keeps an
object whose effects paint onto the artboard, and an object that holds an instance when the effects in the symbol's
art, or the instance's own, paint onto it.
## Expand
+1
View File
@@ -2073,6 +2073,7 @@ Moved from the ROADMAP's "Shipped so far" on 2026-10-10. Update it when a featur
- **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.
- **Later effects on every fill or stroke a painted effect returns (G17):** when a painted effect on a fill or stroke returns several fills or strokes, a later live-effect plug-in in that fill's or stroke's stack, and a later geometry effect on strokes, runs once on each of them in paint order, given its own paint and its own `_context.item` among them. Each run's pieces replace only that fill or stroke. A run that fails, is refused, is deferred or would pass the effects' budget leaves only its own fill or stroke as it was, and the others still run; a deferred stage is listed once, at the source fill's or stroke's place. Regressions: `crates/engine/src/tests/plugins.rs`, `crates/effects/src/tests/painted.rs`. Scores and hours unchanged.
- **Painted pieces with brushed strokes are culled and measured by their brush art (G17):** a piece that keeps the object's brushed stroke draws the brush along the piece's own path at the piece's stroke width. Culling leaves VectorCraft's usual room for brush art (16 × the stroke width + 8 pt) past each piece, from the piece's width rather than the object's, and so does culling of a group, layer, symbol instance, blend, envelope or repeat that holds the object. A group or layer also leaves that room for each brushed path among its members, painted or not. Object › Rasterize takes the brush art drawn along each piece and leaves out the object's own brush art, which is not drawn. Pieces without a brush are culled as before. Regressions: `crates/render/src/tests/paintedbrush.rs`. Scores and hours unchanged.
- **Brushed paths just off the page are exported and printed:** PDF and EPS export keep a path or compound path whose brush art reaches the page while the path lies just off it, alone or in a group, by leaving the canvas's room for brush art (16 × the widest stroke + 8 pt, VectorCraft's own value) past its box. Skip Blank Artboards keeps an artboard such brush art reaches, and Print with Ignore Artboards frames the page with that room around brushed paths and around instances whose art holds brushed strokes. A clip group leaves room only for its clipping path. Art without brushed strokes is written as before. Regressions: `crates/pdf/src/tests.rs`, `crates/pdf/src/tests/print.rs`, `crates/eps/src/tests.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.
- **Refreshing many deferred effects (G17):** Refresh Deferred Effects (`effect.refreshDeferred`) looks up the objects asked for, the stages reached, the objects drawn whole and the inputs that ran in hash sets, so its time no longer grows with the square of the number of deferred stages. The deferred records are indexed by stage and input, so asking whether a stage stays deferred, replacing a stage's records when it runs, and ending a drawing after the first view look at that stage's records only, and the records keep the order they were made in. With 40,000 deferred stages, one refresh in a release build took 38 ms, down from 1,036 ms, and asking about and running each stage once took 16 ms, down from 2,287 ms (wall-clock time on a computer busy with other builds, one run each). Regressions: `crates/plugins/src/deferred.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.