Viewer: the shape tools show the tool's own shape while dragging

The drag preview was a 1 px axis-aligned rectangle for every shape tool,
which read as no preview at all against most comps. Draw the tool's own
path instead (rectangle, rounded rectangle, ellipse, polygon, star), from
the engine's own geometry, filled with the Tools bar's Fill and outlined
in its Stroke, with a caption saying where the shape lands — a mask on
the selected layer, into the selected shape layer, or a new shape layer.

The ghost and the released shape go through one normalisation, so the
drag shows exactly what it commits: Alt (Option) draws from the press
point and Shift squares the box, both live. The drag also starts at the
press point now, not where the 6 px drag threshold was crossed, so the
shape is drawn from where the button went down, as in After Effects.
This commit is contained in:
Aavirash 2026-10-09 17:47:19 +02:00
parent 813c7c4650
commit 0d2264dbed
4 changed files with 329 additions and 37 deletions

View File

@ -205,8 +205,10 @@ pub(crate) fn shape_group(ids: &mut Ids, kind: &str, size: [f64; 2], paint: &Sha
Some(build::shape_group(ids, gname, items))
}
/// The mask a shape tool draws: `kind` of `size` centred at `centre` (layer space).
pub(crate) fn mask_path(kind: &str, centre: [f64; 2], size: [f64; 2]) -> Option<ShapePath> {
/// The path a shape tool draws: `kind` of `size` centred at `centre` (layer space). The geometry
/// of every one of the five kinds, so a caller that only wants to draw it (the viewer's
/// drag ghost) cannot drift from what `shape.newShape` / `layer.addMask` commit.
pub fn mask_path(kind: &str, centre: [f64; 2], size: [f64; 2]) -> Option<ShapePath> {
let bez = match kind {
"rect" | "rectangle" => return Some(ShapePath::rect(centre, size[0], size[1])),
"ellipse" => return Some(ShapePath::ellipse(centre, size[0], size[1])),
@ -219,12 +221,18 @@ pub(crate) fn mask_path(kind: &str, centre: [f64; 2], size: [f64; 2]) -> Option<
effectcraft_path::from_kurbo(&bez).into_iter().next()
}
/// The layer a shape tool draws into when no `layer` is given: the first selected unlocked shape
/// layer, else none (a new shape layer).
pub fn selected_shape_target(s: &Session, comp: &Comp) -> Option<LayerId> {
s.state.selected_layers.iter().copied().find(|l| comp.layer(*l).is_some_and(|l| matches!(l.source, LayerSource::Shape) && !l.switches.locked))
}
/// The shape layer a shape tool or the Pen draws into: `layer` (which must be a shape layer),
/// else the first selected unlocked shape layer, else none (a new shape layer).
pub(crate) fn draw_target(s: &Session, comp: &Comp, p: &Value, cmd: &str) -> Result<Option<LayerId>> {
let target = match p.get("layer") {
Some(l) => Some(super::resolve_layer(comp, l).ok_or_else(|| bad(cmd, format!("no layer {l}")))?),
None => s.state.selected_layers.iter().copied().find(|l| comp.layer(*l).is_some_and(|l| matches!(l.source, LayerSource::Shape) && !l.switches.locked)),
None => selected_shape_target(s, comp),
};
if target.and_then(|l| comp.layer(l)).is_some_and(|l| !matches!(l.source, LayerSource::Shape)) {
return Err(bad(cmd, "the layer is not a shape layer"));

View File

@ -4,9 +4,10 @@
//! 3D views (Active Camera / Front / … / Custom View 3), camera tools (orbit, pan, dolly) and
//! camera/light wireframes.
use effectcraft_engine::commands::shape_tool::PaintKind;
use effectcraft_engine::effects::puppet::PinKind;
use effectcraft_engine::geom::{Mat3, vec2 as gv2};
use effectcraft_engine::project::{Comp, Layer, LayerId};
use effectcraft_engine::project::{Comp, Layer, LayerId, LayerSource};
use effectcraft_engine::render::EvalCtx;
use effectcraft_engine::render::three_d::{self, CameraState, View3D};
use effectcraft_engine::time::Tick;
@ -1173,7 +1174,9 @@ pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) {
anchor_only: mods.alt,
})
}),
t if t.is_shape() => Some(Gesture::Create { tool: t, start: cpt }),
// From where the button went down: by the time the pointer has moved far enough to
// count as a drag it has already left the press point, and the shape is drawn there.
t if t.is_shape() => Some(Gesture::Create { tool: t, start: map.to_comp(press) }),
t if t.puppet_kind().is_some() && super::puppet_tool::hit(&pin_hits, press).is_none() => {
// Off the pins: Alt or a press outside the art drags a marquee that selects pins.
(mods.alt || puppet_layer(app, cpt).is_none()).then_some(Gesture::PinMarquee { start: press })
@ -1380,10 +1383,10 @@ pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) {
}
}
}
Gesture::Create { start, .. } => {
let a = map.to_screen(start);
let r = Rect::from_two_pos(a, pos);
painter.rect_stroke(r, 0.0, Stroke::new(1.0, t.accent), StrokeKind::Middle);
Gesture::Create { tool, start } => {
if let Some(d) = shape_drag(app, tool, start, cpt, mods) {
draw_shape_ghost(&painter, &map, app, &d, &t);
}
}
Gesture::Vertices { start, src, applied, inv, layer } => {
// The grabbed vertex snaps (to other layers' vertices and features, guides, grid).
@ -1522,7 +1525,7 @@ pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) {
Gesture::PuppetPin { layer, pin, .. } => super::puppet_tool::reveal_pin(app, layer, pin),
Gesture::Create { tool, start } => {
let end = map.to_comp(pos);
create_shape(app, tool, start, end, mods.shift);
create_shape(app, tool, start, end, mods);
}
Gesture::Overlay(ov::Drag::Roi { start, keep }) => {
let c = map.to_comp(pos);
@ -1896,38 +1899,159 @@ pub(crate) fn pick(app: &mut EffectcraftApp, ectx: &EvalCtx, cpt: [f64; 2], togg
Some(hit)
}
fn create_shape(app: &mut EffectcraftApp, tool: Tool, a: [f64; 2], b: [f64; 2], square: bool) {
let mut w = (b[0] - a[0]).abs();
let mut h = (b[1] - a[1]).abs();
if w < 2.0 && h < 2.0 {
return;
/// Where a shape drag is going to land: a mask on the selected layer, a group inside it, or a new
/// shape layer.
#[derive(Clone, Copy, PartialEq, Eq)]
enum ShapeTarget {
Mask,
IntoLayer,
NewLayer,
}
impl ShapeTarget {
/// What the drag will make and where, named the way the ghost captions it.
fn label(self, layer_name: &str) -> String {
match self {
ShapeTarget::Mask => format!("Mask on “{layer_name}”"),
ShapeTarget::IntoLayer => format!("Into “{layer_name}”"),
ShapeTarget::NewLayer => "New shape layer".to_string(),
}
}
if square {
let m = w.max(h);
w = m;
h = m;
}
let cx = a[0].min(b[0]) + w / 2.0;
let cy = a[1].min(b[1]) + h / 2.0;
let kind = match tool {
}
/// A shape drag as it stands, after the Tools' normalisations: the path it will make and where it
/// will land.
struct ShapeDrag {
/// The `shape.newShape` / `layer.addMask` kind for the tool.
kind: &'static str,
/// Comp-space centre and size, what the drag will make.
centre: [f64; 2],
size: [f64; 2],
target: ShapeTarget,
/// The selected layer's name and the index its next mask would take, for the ghost's caption
/// and colour.
layer_name: String,
mask_index: usize,
}
/// The kind `tool` draws, as `shape.newShape` / `layer.addMask` name it.
fn shape_kind(tool: Tool) -> &'static str {
match tool {
Tool::Rectangle => "rect",
Tool::RoundedRect => "rounded",
Tool::Ellipse => "ellipse",
Tool::Polygon => "polygon",
_ => "star",
};
}
}
/// What a shape tool drag from `start` to `end` makes, or `None` while it is too small to make
/// anything. Alt (Option) draws from the press point and Shift squares the box; the live ghost and
/// [`create_shape`] both go through this, so the drag always shows what it will commit.
fn shape_drag(app: &EffectcraftApp, tool: Tool, start: [f64; 2], end: [f64; 2], mods: egui::Modifiers) -> Option<ShapeDrag> {
let comp = app.session.active_comp()?;
let selected = app.session.state.selected_layers.first().copied();
let sel = selected.and_then(|id| comp.layer(id));
// A footage, solid or other non-shape layer selected, or a shape layer with Tool Creates
// Mask: a mask on it instead.
let creates_mask = app.session.state.shape_tool.creates_mask;
let sel = app.session.state.selected_layers.first().copied();
let sel_layer = sel.and_then(|id| app.session.active_comp().and_then(|c| c.layer(id)).cloned());
if let Some(l) = sel_layer
&& l.source.is_av()
&& (creates_mask || !matches!(l.source, effectcraft_engine::project::LayerSource::Shape))
{
// Mask in layer space: invert the layer transform.
let comp = app.session.active_comp_arc();
if let Some(comp) = comp {
// Mask: a mask on that layer. Otherwise the engine's own target: the first selected unlocked
// shape layer, else a new one.
let target = match sel {
Some(l) if l.source.is_av() && (app.session.state.shape_tool.creates_mask || !matches!(l.source, LayerSource::Shape)) => (ShapeTarget::Mask, Some(l)),
_ => match effectcraft_engine::commands::shape_tool::selected_shape_target(&app.session, comp).and_then(|id| comp.layer(id)) {
Some(l) => (ShapeTarget::IntoLayer, Some(l)),
None => (ShapeTarget::NewLayer, None),
},
};
let (target, layer) = target;
let mask_index = if target == ShapeTarget::Mask { layer.and_then(|l| l.masks()).map_or(0, |m| m.groups().count()) } else { 0 };
let (mut w, mut h) = ((end[0] - start[0]).abs(), (end[1] - start[1]).abs());
// Alt doubles the box about the press point, which is then its centre.
if mods.alt {
w *= 2.0;
h *= 2.0;
}
if w < 2.0 && h < 2.0 {
return None;
}
if mods.shift {
let m = w.max(h);
w = m;
h = m;
}
Some(ShapeDrag {
kind: shape_kind(tool),
centre: if mods.alt { start } else { [start[0].min(end[0]) + w / 2.0, start[1].min(end[1]) + h / 2.0] },
size: [w, h],
target,
layer_name: layer.map_or_else(String::new, |l| l.name.clone()),
mask_index,
})
}
/// The Tools bar's paint colour as bytes, dimmed by `alpha` so the comp shows through the ghost.
fn paint_rgb(c: [f32; 3], opacity: f64, alpha: f32) -> Color32 {
let f = |k: f32| (k.clamp(0.0, 1.0) * 255.0).round() as u8;
Color32::from_rgb(f(c[0]), f(c[1]), f(c[2])).gamma_multiply((opacity.clamp(0.0, 100.0) / 100.0) as f32 * alpha)
}
/// Draw a shape drag as it will land: the tool's own path, filled with the Tools bar's Fill and
/// outlined in its Stroke (or the mask colour, for a mask), captioned with where it goes.
fn draw_shape_ghost(painter: &egui::Painter, map: &ViewerMap, app: &EffectcraftApp, d: &ShapeDrag, t: &Tokens) {
// The engine's own geometry for the kind, so the ghost is the shape and not a stand-in for it.
let Some(sp) = effectcraft_engine::commands::shape_tool::mask_path(d.kind, d.centre, d.size) else { return };
let mut pts = ov::flatten(&sp, &Mat3::IDENTITY, map);
if pts.first() == pts.last() {
pts.pop();
}
if pts.len() < 3 {
return;
}
let tool = &app.session.state.shape_tool;
let (fill, line) = match d.target {
ShapeTarget::Mask => {
let [r, g, b] = effectcraft_engine::prefs::mask_color(app.session.prefs.appearance.cycle_mask_colors, d.mask_index);
(Color32::from_rgb(r, g, b).gamma_multiply(0.25), Color32::from_rgb(r, g, b))
}
_ => {
let stroke_on = tool.stroke.kind != PaintKind::None && tool.stroke_width > 0.0;
(
if tool.fill.kind == PaintKind::None { Color32::TRANSPARENT } else { paint_rgb(tool.fill.color, tool.fill.opacity, 0.45) },
if stroke_on { paint_rgb(tool.stroke.color, tool.stroke.opacity, 1.0) } else { t.accent },
)
}
};
let Some(bounds) = bounds_of(&pts) else { return };
// A screen-width outline reads at every zoom, where a comp-pixel hairline does not.
painter.add(egui::Shape::Path(egui::epaint::PathShape { points: pts, closed: true, fill, stroke: Stroke::new(1.5, line).into() }));
// Caption it with where the shape lands: a mask, a group in the selected shape layer or a
// new layer.
let font = Tokens::ui(11.0);
let galley = painter.layout_no_wrap(d.target.label(&d.layer_name), font, line);
let caption = Rect::from_min_size(pos2(bounds.left(), bounds.top() - 5.0 - galley.size().y), galley.size());
painter.rect_filled(caption.expand(3.0), 2.0, Color32::from_black_alpha(150));
painter.galley(caption.min, galley, line);
}
/// The bounds of points on screen, or `None` for an empty set.
fn bounds_of(pts: &[Pos2]) -> Option<Rect> {
let first = *pts.first()?;
let mut r = Rect::from_min_max(first, first);
for p in pts.iter().skip(1) {
r.extend_with(*p);
}
Some(r)
}
fn create_shape(app: &mut EffectcraftApp, tool: Tool, a: [f64; 2], b: [f64; 2], mods: egui::Modifiers) {
let Some(d) = shape_drag(app, tool, a, b, mods) else { return };
let (kind, (w, h), (cx, cy)) = (d.kind, (d.size[0], d.size[1]), (d.centre[0], d.centre[1]));
// A mask on the selected layer, as the ghost showed.
if d.target == ShapeTarget::Mask {
// In layer space: invert the layer transform.
let sel_layer = app.session.state.selected_layers.first().copied().and_then(|id| app.session.active_comp().and_then(|c| c.layer(id)).cloned());
if let Some(l) = sel_layer
&& let Some(comp) = app.session.active_comp_arc()
{
let ectx = EvalCtx {
project: &app.session.project,
comp_id: app.session.active_comp_id().unwrap_or_default(),
@ -1943,8 +2067,8 @@ fn create_shape(app: &mut EffectcraftApp, tool: Tool, a: [f64; 2], b: [f64; 2],
if let Err(e) = app.session.execute("layer.addMask", json!({"layer": l.id.0, "shape": kind, "rect": rect})) {
app.ui.status = e.to_string();
}
return;
}
return;
}
// Into the selected shape layer's Contents as a new group (as in After Effects), else a new
// shape layer; painted with the Tools bar's Fill and Stroke.

View File

@ -98,7 +98,9 @@ pub(crate) fn path_of(ectx: &EvalCtx, layer: LayerId, uid: u64) -> Option<PathIn
Some(PathInfo { layer, uid, m, sp, is_mask, feather: Vec::new() })
}
fn flatten(sp: &ShapePath, m: &Mat3, map: &ViewerMap) -> Vec<Pos2> {
/// The path on screen: `sp` through `m` and the viewer map, flattened, with the closing point
/// repeated for a closed path.
pub(crate) fn flatten(sp: &ShapePath, m: &Mat3, map: &ViewerMap) -> Vec<Pos2> {
let k = effectcraft_engine::render::kurbo_path(sp);
let mut pts: Vec<Pos2> = vec![];
kurbo::flatten(k.iter(), 0.5, |el| match el {

View File

@ -368,6 +368,164 @@ fn tool_creates_mask_draws_a_mask_on_the_selected_shape_layer() {
assert!(!h.state().session.state.shape_tool.creates_mask);
}
// ---- the shape tools' drag ghost
/// Run frames until the viewer's background frame has landed, so the comp is painted (and not
/// still black) before the drag begins: the ghost is then the only thing that changes.
fn settle_viewer(h: &mut Harness<'_, EffectcraftApp>) {
for _ in 0..600 {
h.step();
if h.state().frames.inflight() == 0 && h.state().frames.last_ms.lock().map(|v| *v > 0.0).unwrap_or(false) {
break;
}
std::thread::sleep(std::time::Duration::from_millis(10));
}
h.run_steps(4);
}
/// Press at `from`, drag to `to` with `mods` held and return the frame drawn with the button still
/// down — what the user sees mid-drag, before anything is committed. egui only takes modifiers
/// from [`egui::Event::ModifiersChanged`], so they are set before the press and stay set.
fn mid_drag(h: &mut Harness<'_, EffectcraftApp>, from: Pos2, to: Pos2, mods: egui::Modifiers) -> image::RgbaImage {
h.input_mut().events.push(Event::ModifiersChanged(mods));
h.input_mut().events.push(Event::PointerMoved(from));
h.input_mut().events.push(Event::PointerButton { pos: from, button: egui::PointerButton::Primary, pressed: true, modifiers: mods });
h.step();
for i in 1..=8 {
h.input_mut().events.push(Event::PointerMoved(from + (to - from) * (i as f32 / 8.0)));
h.step();
}
h.render().expect("the mid-drag frame")
}
/// Release the button where the drag left it.
fn release(h: &mut Harness<'_, EffectcraftApp>, at: Pos2, mods: egui::Modifiers) {
h.input_mut().events.push(Event::PointerButton { pos: at, button: egui::PointerButton::Primary, pressed: false, modifiers: mods });
h.run_steps(2);
}
/// The pixel at a comp point of a rendered frame.
fn px_at(img: &image::RgbaImage, h: &Harness<'_, EffectcraftApp>, p: [f32; 2]) -> [u8; 3] {
let s = screen(h, p);
let p = img.get_pixel(s.x.round() as u32, s.y.round() as u32).0;
[p[0], p[1], p[2]]
}
/// How different two pixels are, summed over the channels.
fn delta(a: [u8; 3], b: [u8; 3]) -> u32 {
(0..3).map(|i| (a[i] as i32 - b[i] as i32).unsigned_abs()).sum()
}
/// The Tools-bar Fill as bytes, the colour a filled ghost carries.
fn fill_rgb(h: &Harness<'_, EffectcraftApp>) -> [u8; 3] {
let c = h.state().session.state.shape_tool.fill.color;
std::array::from_fn(|i| (c[i].clamp(0.0, 1.0) * 255.0).round() as u8)
}
/// The shape tools show the shape they will draw under the cursor while the drag is still going:
/// filled with the Tools bar's Fill, so it reads at any zoom where the 1 px outline it replaces was
/// invisible, and in the tool's own form — only the Rectangle fills the corner of the box.
#[test]
fn shape_drag_ghost_shows_the_tools_own_shape_while_dragging() {
let (from, to) = ([140.0, 100.0], [400.0, 300.0]);
let centre = [270.0, 200.0];
// 6 comp px inside the box's top-left corner: inside a rectangle, outside every other kind.
let corner = [146.0, 106.0];
for (tool, fills_corner) in [(Tool::Rectangle, true), (Tool::RoundedRect, false), (Tool::Ellipse, false), (Tool::Polygon, false), (Tool::Star, false)] {
let mut h = harness();
h.state_mut().ui.tool = tool;
settle_viewer(&mut h);
let (a, b) = (screen(&h, from), screen(&h, to));
let before = h.render().expect("the frame before the drag");
let mid = mid_drag(&mut h, a, b, egui::Modifiers::default());
let fill = fill_rgb(&h);
// Drawn while the button is down, and painted with the Fill rather than left to a hairline.
let was = px_at(&before, &h, centre);
let now = px_at(&mid, &h, centre);
assert!(delta(was, now) > 30, "{tool:?}: no ghost drawn mid-drag ({was:?} → {now:?})");
assert!(delta(now, fill) < delta(was, fill), "{tool:?}: the ghost is not the Fill colour ({now:?} vs {was:?})");
// The tool's own form: only the Rectangle reaches the corner of its box.
let was_c = px_at(&before, &h, corner);
let now_c = px_at(&mid, &h, corner);
assert_eq!(delta(was_c, now_c) > 30, fills_corner, "{tool:?}: the ghost is not drawn in its own shape");
release(&mut h, b, egui::Modifiers::default());
}
}
/// The ghost and the shape the release commits share one normalisation, so nothing it shows is a
/// lie: Shift squares the box and Alt (Option) draws it from the press point, both live.
#[test]
fn shape_drag_ghost_normalises_the_same_way_as_the_release() {
let from = [140.0f64, 100.0];
let to = [400.0f64, 260.0];
// (mods, centre, size): Shift squares the box from its top-left, Alt centres it on the press.
for (mods, centre, size) in [
(egui::Modifiers::default(), [270.0, 180.0], [260.0, 160.0]),
(egui::Modifiers::SHIFT, [270.0, 230.0], [260.0, 260.0]),
(egui::Modifiers::ALT, [140.0, 100.0], [520.0, 320.0]),
(egui::Modifiers { alt: true, shift: true, ..Default::default() }, [140.0, 100.0], [520.0, 520.0]),
] {
let mut h = harness();
h.state_mut().ui.tool = Tool::Ellipse;
settle_viewer(&mut h);
let (a, b) = (screen(&h, from.map(|v| v as f32)), screen(&h, to.map(|v| v as f32)));
let before = h.render().expect("the frame before the drag");
// Measured with the button down, so this is the ghost and not the committed shape.
let mid = mid_drag(&mut h, a, b, mods);
let on = [centre[0] as f32 + size[0] as f32 / 4.0, centre[1] as f32 + size[1] as f32 / 4.0];
assert!(delta(px_at(&before, &h, on), px_at(&mid, &h, on)) > 30, "{mods:?}: the ghost is not where it will land");
release(&mut h, b, mods);
// And the shape it commits is that box: a new shape layer carries the position, its
// group sits at its own centre's origin.
let comp = h.state().session.active_comp().unwrap().clone();
let layer = comp.layers.first().expect("a new shape layer").clone();
assert!(matches!(layer.source, effectcraft_engine::project::LayerSource::Shape), "{mods:?}: no shape layer");
let at = layer.transform().unwrap().get("position").unwrap().value.components();
assert!((at[0] - centre[0]).abs() < 1.0 && (at[1] - centre[1]).abs() < 1.0, "{mods:?}: at {at:?}, not {centre:?}");
let group = layer.props.sub("contents").unwrap().groups().next().unwrap().clone();
let path = group.sub("contents").unwrap().sub("ellipse").unwrap();
let s = path.get("size").unwrap().value.components();
assert!((s[0] - size[0]).abs() < 1.0 && (s[1] - size[1]).abs() < 1.0, "{mods:?}: sized {s:?}, not {size:?}");
}
}
/// The ghost says where the shape lands: a mask on the selected layer is drawn in the mask colour
/// and not painted with the Fill, where a shape drawn into the selected shape layer is.
#[test]
fn shape_drag_ghost_shows_where_the_shape_will_land() {
let (from, to) = ([140.0, 100.0], [400.0, 300.0]);
let centre = [270.0, 200.0];
// Nothing selected: the shape becomes a new layer, painted with the Fill.
let mut h = harness();
h.state_mut().ui.tool = Tool::Ellipse;
settle_viewer(&mut h);
let (a, b) = (screen(&h, from), screen(&h, to));
let shape_mid = mid_drag(&mut h, a, b, egui::Modifiers::default());
let shape_px = px_at(&shape_mid, &h, centre);
let fill = fill_rgb(&h);
assert!(delta(shape_px, fill) < delta(shape_px, [0xff, 0xc0, 0x00]), "a shape ghost is painted with the Fill, not the mask colour");
release(&mut h, b, egui::Modifiers::default());
// A solid selected: the same drag is a mask on it, in the mask colour.
let mut h = harness();
let plate = h.state().session.active_comp().unwrap().layers.iter().find(|l| l.name == "Box").unwrap().id;
h.state_mut().session.execute("layer.select", json!({"layers": [plate.0]})).unwrap();
h.state_mut().ui.tool = Tool::Ellipse;
settle_viewer(&mut h);
let (a, b) = (screen(&h, from), screen(&h, to));
let mask_mid = mid_drag(&mut h, a, b, egui::Modifiers::default());
let mask_px = px_at(&mask_mid, &h, centre);
assert!(delta(mask_px, shape_px) > 20, "a mask ghost must not look like the shape ghost ({mask_px:?} vs {shape_px:?})");
release(&mut h, b, egui::Modifiers::default());
let comp = h.state().session.active_comp().unwrap();
assert_eq!(comp.layer(plate).unwrap().props.sub("masks").unwrap().groups().count(), 1, "the drag drew a mask");
}
/// Clicking the word "Fill" opens Fill Options: a radial gradient in Multiply at 40% paints the
/// next shape drawn (#227).
#[test]