diff --git a/Cargo.lock b/Cargo.lock index bb082d96..1828b397 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -4186,6 +4186,7 @@ dependencies = [ "thiserror 2.0.21", "vectorcraft-color", "vectorcraft-doc", + "vectorcraft-effects", "vectorcraft-geom", "vectorcraft-text", ] @@ -4216,11 +4217,13 @@ dependencies = [ "image", "kurbo 0.13.1", "proptest", + "serde_json", "svgtypes 0.16.1", "thiserror 2.0.21", "usvg 0.48.1", "vectorcraft-color", "vectorcraft-doc", + "vectorcraft-effects", "vectorcraft-geom", "vectorcraft-testkit", ] diff --git a/ROADMAP.md b/ROADMAP.md index 67e3f576..41e20ee5 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -32,14 +32,14 @@ _Last updated: 2026-10-01._ - **Tools:** - **Selection:** Selection, Direct/Group Selection, Magic Wand, Lasso. - **Drawing:** Pen, Curvature, anchor tools, Pencil, Paintbrush, Blob Brush, Smooth, Path Eraser, Join. - - **Shapes:** all shape tools and the line, arc, spiral and grid tools. + - **Shapes:** all shape tools (including Flare) and the line, arc, spiral and grid tools. - **Cutting:** Eraser, Scissors, Knife. - - **Transform:** Rotate, Reflect, Scale, Shear, Free Transform (distort/perspective). + - **Transform:** Rotate, Reflect, Scale, Shear, Reshape, Free Transform (distort/perspective). - **Other:** Eyedropper, Gradient annotator, Artboard, Measure, Type, Hand, Zoom, Rotate View. - **Drawing aids:** Smart Guides and snapping, and Draw Normal / Behind / Inside modes. - **Geometry and effects:** - Pathfinder (10 exact curve booleans), Offset, Outline Stroke, Simplify, Clean Up, Split Into Grid, Divide Objects Below. - - Live effects with previewing dialogs: Distort & Transform, Path, Convert to Shape, 15 Warp styles, Round Corners, Scribble, and raster drop shadow, glows and feather. + - Live effects with previewing dialogs: Distort & Transform, Path, Convert to Shape, 15 Warp styles, Round Corners, Scribble, Effect → Pathfinder (all 10 operations, live on groups), and raster drop shadow, glows and feather. SVG and PDF export keep live effects (geometry baked; SVG raster effects as filters). - **Type:** Character and Paragraph Styles (override-preserving redefine), Area Type Options (rows/columns/inset/first baseline), threaded text across any closed shapes, Fit Headline. - **Transparency:** opacity masks (clip/invert/disable/link), exported as SVG `` and PDF soft masks. - **Advanced art:** live Blends (steps/distance/smooth colour, spine), Envelope Distort (warp/mesh/top object), Gradient Mesh, Shape Builder, Live Paint, Image Trace (12 presets), pattern swatches with pattern editing mode, live Repeat (radial/grid/mirror). @@ -56,16 +56,16 @@ _Last updated: 2026-10-01._ |---|---|---|---| | M0 | Skeleton + vertical slice | ✅ done | — | | M1 | Selection, transform, layers, MCP | ✅ mostly done (rotated persistent bbox pending) | 3–5 | -| M2 | Drawing tools + smart guides | ✅ mostly done (Shaper, Pen modifier nuances) | 5–10 | +| M2 | Drawing tools + smart guides | ✅ mostly done (Flare, Reshape landed; Shaper, Pen modifier nuances) | 4–8 | | M3 | Paint & appearance (swatches, color, gradient, stroke, appearance, transparency, styles) | 🟡 panels done; opacity masks done (make/release, clip, invert, disable, link; render + SVG `` in/out + PDF soft mask); mask-editing mode, freeform gradients pending | 6–10 | -| M4 | Files (native, SVG, PDF, raster, Export for Screens, clipboard interop) | 🟡 Export for Screens (PNG/JPG/WebP/SVG/PDF × scales) done; headless CLI/MCP export every format; system clipboard: copy puts SVG markup on it, paste takes SVG from other apps (Ctrl/Cmd+C/X/V now also work off macOS); PNG/PDF clipboard flavours, EPS/DXF pending | 8–12 | +| M4 | Files (native, SVG, PDF, raster, Export for Screens, clipboard interop) | 🟡 Export for Screens (PNG/JPG/WebP/SVG/PDF × scales) done; headless CLI/MCP export every format; system clipboard: copy puts SVG markup on it, paste takes SVG from other apps (Ctrl/Cmd+C/X/V now also work off macOS); live effects now survive SVG/PDF/clipboard export (geometry baked, SVG filters for shadows/glows/blur/feather); PDF raster effects, PNG/PDF clipboard flavours, EPS/DXF pending | 7–11 | | M5 | Performance | 🟡 background render + caches + MT done; `vectorcraft-cli bench` and `vectorcraft-cli perf` (budget suite); file format v2 opens 3× faster (50k paths: 722 → 244 ms); raster effects (glows, shadows, blur, feather) no longer force the whole frame single-threaded (filtered offscreen per effect, verified equal to the single-threaded reference); effect-heavy demos need a clean-machine benchmark; dirty-region rendering, GPU backend spike pending | 10–20 | | M6 | Path operations (Pathfinder, Shape Builder, offset…) | ✅ mostly done (Shape Builder edge erase, large-offset bug open) | 3–6 | -| M7 | Type (point/area/path, editing, styles, OpenType, threading, glyphs) | 🟡 Character/Paragraph Styles, Area Type Options, threaded text, Fit Headline, Glyphs done; OpenType/Tabs panels, text wrap, Find Font, spell check pending | 12–20 | +| M7 | Type (point/area/path, editing, styles, OpenType, threading, glyphs) | 🟡 Character/Paragraph Styles, Area Type Options, threaded text, Fit Headline, Glyphs, OpenType panel, Find Font done; Tabs panel, text wrap, spell check, vertical type pending | 10–17 | | M8 | Transform & distort (Puppet Warp, Liquify tools, Envelopes, Blends, Perspective Grid) | 🟡 live Blends, Envelopes (warp/mesh/top object), Width tool, Liquify tools, Puppet Warp and Perspective Grid landed; fidelity pass pending | 8–12 | -| M9 | Live effects (+ 3D & Materials) | 🟡 2D effects done; 3D pending | 25–40 | +| M9 | Live effects (+ 3D & Materials) | 🟡 2D effects done incl. Effect → Pathfinder; SVG Filters, Document Raster Effects Settings, 3D pending | 24–38 | | M10 | Brushes, symbols, patterns, Repeat | 🟡 pattern swatches (5 tile types, Pattern Options, editing mode, SVG ``/PDF export) and live Repeat (radial/grid/mirror) done; brushes/symbols in progress | 8–15 | -| M11 | Artboards & views (artboard panel/tool done; print tiling, multiple windows, presentation polish) | 🟡 | 10–15 | +| M11 | Artboards & views (artboard panel/tool done, Trim View; print tiling, multiple windows, presentation polish) | 🟡 | 9–14 | | M12 | Advanced color & art (CMYK/ICC, separations, Gradient Mesh, Live Paint, Image Trace, Graphs) | 🟡 Gradient Mesh, Live Paint, Image Trace (12 presets, 18 ms/1k² image), Recolor Artwork, colour management (ICC, soft proofing, separations preview) done; Graphs pending | 12–20 | | M13 | Automation (Actions ✅ record/playback, persisted; variables, scripting, batch) | 🟡 | 8–12 | | M14 | 1.0 polish (preferences, shortcut editor, workspaces, accessibility, packaging for all OSes) | 🟡 Preferences, shortcut editor, workspaces done; accessibility, Windows/Linux packaging pending | 12–20 | diff --git a/crates/effects/src/bake.rs b/crates/effects/src/bake.rs new file mode 100644 index 00000000..5dbaf1e2 --- /dev/null +++ b/crates/effects/src/bake.rs @@ -0,0 +1,210 @@ +//! Baking live geometry effects for export. +//! +//! SVG, PDF and the clipboard have no notion of Illustrator's live effects, so exporters receive a +//! copy of the document in which every geometry effect — object level, per fill/stroke and +//! Effect → Pathfinder — is evaluated exactly like the renderer does. Raster effects (shadows, +//! glows, blur) stay on the objects for the exporter to translate (e.g. SVG filters). + +use std::sync::Arc; + +use vectorcraft_doc::{Appearance, AppearanceItem, Document, Node, NodeKind}; +use vectorcraft_geom::{FillRule, PathData}; + +use crate::{GeomContext, apply_geometry_with, has_geometry, has_pathfinder, is_geometry, is_pathfinder, pathfinder_children}; + +fn item_effects(item: &AppearanceItem) -> &[vectorcraft_doc::Effect] { + match item { + AppearanceItem::Fill(f) => &f.effects, + AppearanceItem::Stroke(s) => &s.effects, + } +} + +fn clear_item_effects(item: &mut AppearanceItem) { + match item { + AppearanceItem::Fill(f) => f.effects.retain(|e| !is_geometry(&e.id)), + AppearanceItem::Stroke(s) => s.effects.retain(|e| !is_geometry(&e.id)), + } +} + +/// Does anything in `n`'s subtree need baking? +pub fn needs_bake(n: &Node) -> bool { + has_pathfinder(n) + || has_geometry(&n.appearance.effects) + || n.appearance.items.iter().any(|i| has_geometry(item_effects(i))) + || n.children().is_some_and(|ch| ch.iter().any(|c| needs_bake(c))) +} + +fn geometry(n: &Node) -> Option<(PathData, FillRule)> { + match &n.kind { + NodeKind::Path { path, rule, guide: false, .. } => Some((path.clone(), *rule)), + NodeKind::Compound { children, rule } => { + Some((PathData::new(children.iter().filter_map(|c| c.path_data()).flat_map(|p| p.subpaths.iter().cloned()).collect()), *rule)) + } + _ => None, + } +} + +fn apply(effects: &[vectorcraft_doc::Effect], path: &PathData, ctx: &GeomContext) -> PathData { + if !has_geometry(effects) || path.is_empty() { + return path.clone(); + } + let b = vectorcraft_geom::Shape::bounding_box(&path.to_bezpath()); + apply_geometry_with(effects, path, b, ctx) +} + +/// A path node of `kind`'s flavour (compound when the result has several subpaths). +fn path_kind(d: &mut Document, path: PathData, rule: FillRule) -> NodeKind { + if path.subpaths.len() > 1 { + let children = path.subpaths.into_iter().map(|sp| Arc::new(Node::path(d.alloc_id(), PathData::single(sp), Appearance::default()))).collect(); + NodeKind::Compound { children, rule } + } else { + NodeKind::Path { path, rule, live: None, clipping: false, guide: false } + } +} + +/// `n` with its geometry effects evaluated (`None` = unchanged). +fn bake_node(d: &mut Document, n: &Node) -> Option { + if !needs_bake(n) { + return None; + } + if let Some(result) = pathfinder_children(n, None) { + let mut m = n.clone(); + m.appearance.effects.retain(|e| !is_pathfinder(&e.id)); + let children = result + .into_iter() + .map(|c| { + let mut c = Arc::unwrap_or_clone(c); + c.id = d.alloc_id(); + Arc::new(bake_node(d, &c).unwrap_or(c)) + }) + .collect(); + if let Some(ch) = m.children_mut() { + *ch = children; + } + return Some(m); + } + if let Some(ch) = n.children() { + if matches!(n.kind, NodeKind::Compound { .. }) + && (has_geometry(&n.appearance.effects) || n.appearance.items.iter().any(|i| has_geometry(item_effects(i)))) + { + return bake_leaf(d, n); + } + let mut m = n.clone(); + let baked: Vec> = ch.iter().map(|c| bake_node(d, c).map(Arc::new).unwrap_or_else(|| c.clone())).collect(); + if let Some(ch) = m.children_mut() { + *ch = baked; + } + return Some(m); + } + bake_leaf(d, n) +} + +fn bake_leaf(d: &mut Document, n: &Node) -> Option { + let (base, rule) = geometry(n)?; + let w = n.appearance.stroke_width(); + let ctx = GeomContext { stroke_width: if w > 0.0 { w } else { 1.0 } }; + let g = apply(&n.appearance.effects, &base, &ctx); + let mut m = n.clone(); + m.appearance.effects.retain(|e| !is_geometry(&e.id)); + if !n.appearance.items.iter().any(|i| has_geometry(item_effects(i))) { + m.kind = path_kind(d, g, rule); + return Some(m); + } + // Per-item geometry: one path per fill/stroke, grouped under the object's transparency and + // raster effects (what Expand Appearance produces). + let children = n + .appearance + .items + .iter() + .map(|item| { + let ig = apply(item_effects(item), &g, &ctx); + let mut it = item.clone(); + clear_item_effects(&mut it); + let id = d.alloc_id(); + let kind = path_kind(d, ig, rule); + Arc::new(Node { + id, + name: None, + appearance: Appearance { items: vec![it], effects: vec![] }, + opacity: 1.0, + blend: Default::default(), + isolate: false, + knockout: false, + mask: None, + trace: None, + kind, + ..n.clone() + }) + }) + .collect(); + m.appearance.items.clear(); + m.kind = NodeKind::Group { children, clip: false }; + Some(m) +} + +/// A copy of `doc` with every live geometry effect baked into plain paths, or `None` when the +/// document has none (export it as is). +pub fn bake_document(doc: &Document) -> Option { + if !doc.layers.iter().any(|l| needs_bake(l)) { + return None; + } + let mut d = doc.clone(); + let layers = doc.layers.clone(); + d.layers = layers.iter().map(|l| bake_node(&mut d, l).map(Arc::new).unwrap_or_else(|| l.clone())).collect(); + Some(d) +} + +#[cfg(test)] +mod tests { + use super::*; + use serde_json::json; + use vectorcraft_doc::{Effect, NodeId}; + use vectorcraft_geom::{Rect, shapes}; + + fn fx(id: &str, params: serde_json::Value) -> Effect { + Effect { id: id.into(), params, visible: true } + } + + fn doc_with(n: Node) -> Document { + let mut d = Document::new(400.0, 400.0); + let l = d.layers[0].id; + d.insert(Some(l), 0, n).unwrap(); + d + } + + #[test] + fn plain_documents_are_not_copied() { + let mut d = Document::new(100.0, 100.0); + let id = d.alloc_id(); + let d = doc_with(Node::path(id, shapes::rectangle(Rect::new(0.0, 0.0, 10.0, 10.0)), Appearance::default_art())); + assert!(bake_document(&d).is_none()); + } + + #[test] + fn object_effects_become_geometry_and_raster_effects_stay() { + let mut n = Node::path(NodeId(50), shapes::rectangle(Rect::new(0.0, 0.0, 100.0, 100.0)), Appearance::default_art()); + n.appearance.effects.push(fx("path.offsetPath", json!({"offset": 10.0}))); + n.appearance.effects.push(fx("stylize.dropShadow", json!({}))); + let d = bake_document(&doc_with(n)).unwrap(); + let m = d.node(NodeId(50)).unwrap(); + let b = m.geometric_bounds().unwrap(); + assert!((b.width() - 120.0).abs() < 0.5, "{b:?}"); + assert_eq!(m.appearance.effects.len(), 1); + assert_eq!(m.appearance.effects[0].id, "stylize.dropShadow"); + } + + #[test] + fn item_effects_split_into_one_path_per_item() { + let mut n = Node::path(NodeId(50), shapes::rectangle(Rect::new(0.0, 0.0, 100.0, 100.0)), Appearance::default_art()); + if let AppearanceItem::Stroke(s) = &mut n.appearance.items[1] { + s.effects.push(fx("path.offsetPath", json!({"offset": 5.0}))); + } + let d = bake_document(&doc_with(n)).unwrap(); + let m = d.node(NodeId(50)).unwrap(); + let ch = m.children().unwrap(); + assert_eq!(ch.len(), 2); + assert!((ch[0].geometric_bounds().unwrap().width() - 100.0).abs() < 1e-6); + assert!((ch[1].geometric_bounds().unwrap().width() - 110.0).abs() < 0.5); + assert!(ch[0].id != ch[1].id && ch[0].id != NodeId(50)); + } +} diff --git a/crates/effects/src/lib.rs b/crates/effects/src/lib.rs index c0ccd7ba..494e2cbe 100644 --- a/crates/effects/src/lib.rs +++ b/crates/effects/src/lib.rs @@ -15,6 +15,7 @@ //! noise (`seed` parameter, default 0). #![forbid(unsafe_code)] +mod bake; mod distort; mod group; mod raster; @@ -29,6 +30,7 @@ use serde_json::{Map, Value, json}; use vectorcraft_doc::Effect; use vectorcraft_geom::{BezPath, PathData, Rect}; +pub use bake::{bake_document, needs_bake}; pub use group::{OutlineHook, PATHFINDER_EFFECTS, has_pathfinder, is_pathfinder, pathfinder_children}; pub use raster::{RasterFx, outset, raster_effects}; pub use warp::{WarpStyle, warp_point}; diff --git a/crates/pdf/Cargo.toml b/crates/pdf/Cargo.toml index b960417f..35143d4e 100644 --- a/crates/pdf/Cargo.toml +++ b/crates/pdf/Cargo.toml @@ -11,6 +11,7 @@ workspace = true [dependencies] vectorcraft-geom = { workspace = true } vectorcraft-doc = { workspace = true } +vectorcraft-effects = { workspace = true } vectorcraft-color = { workspace = true } vectorcraft-text = { workspace = true } kurbo = { workspace = true } diff --git a/crates/pdf/src/export.rs b/crates/pdf/src/export.rs index dd539984..20cea8e3 100644 --- a/crates/pdf/src/export.rs +++ b/crates/pdf/src/export.rs @@ -25,6 +25,9 @@ pub fn export(doc: &Document, opts: &PdfOptions) -> Result, PdfError> { /// Like [`export`], also returning warnings about approximated or dropped features. pub fn export_with_report(doc: &Document, opts: &PdfOptions) -> Result { + // Live geometry effects export as their result; raster effects are reported below. + let baked = vectorcraft_effects::bake_document(doc); + let doc = baked.as_ref().unwrap_or(doc); if doc.artboards.is_empty() { return Err(PdfError::NoArtboards); } @@ -358,8 +361,8 @@ impl Exporter<'_> { None if !n.is_container() => return, _ => {} } - if !n.appearance.effects.is_empty() { - self.warn("live effects are not exported to PDF yet"); + if n.appearance.effects.iter().any(|e| e.visible && vectorcraft_effects::is_raster(&e.id)) { + self.warn("raster effects (shadows, glows, blur, feather) are not exported to PDF yet"); } let container = n.is_container() && !matches!(n.kind, NodeKind::Compound { .. }); let mut pushes = 0; @@ -464,8 +467,8 @@ impl Exporter<'_> { if !fl.visible || fl.paint.is_none() { continue; } - if !fl.effects.is_empty() { - self.warn("live effects are not exported to PDF yet"); + if fl.effects.iter().any(|e| e.visible && vectorcraft_effects::is_raster(&e.id)) { + self.warn("raster effects (shadows, glows, blur, feather) are not exported to PDF yet"); } // Pattern fills: the tile instances covering the shape, clipped to it. let doc = self.doc; diff --git a/crates/svg/Cargo.toml b/crates/svg/Cargo.toml index 093bf3fc..d0663ae1 100644 --- a/crates/svg/Cargo.toml +++ b/crates/svg/Cargo.toml @@ -12,6 +12,7 @@ workspace = true vectorcraft-geom = { workspace = true } vectorcraft-color = { workspace = true } vectorcraft-doc = { workspace = true } +vectorcraft-effects = { workspace = true } kurbo = { workspace = true } usvg = { workspace = true } thiserror = { workspace = true } @@ -23,3 +24,4 @@ svgtypes = "0.16" vectorcraft-testkit = { workspace = true } proptest = { workspace = true } image = { workspace = true } +serde_json = { workspace = true } diff --git a/crates/svg/src/export.rs b/crates/svg/src/export.rs index 7e99db97..ef35d136 100644 --- a/crates/svg/src/export.rs +++ b/crates/svg/src/export.rs @@ -14,6 +14,9 @@ use crate::{ExportOptions, Styling, base64_encode, fmt_num, xml_escape}; type Props = Vec<(&'static str, String)>; pub(crate) fn export(doc: &Document, opts: &ExportOptions) -> String { + // Live geometry effects (Roughen, Warp, Offset Path, Effect → Pathfinder…) export as their result. + let baked = vectorcraft_effects::bake_document(doc); + let doc = baked.as_ref().unwrap_or(doc); let rect = opts .artboard .and_then(|i| doc.artboards.get(i)) @@ -589,6 +592,9 @@ impl Writer<'_> { if !n.visible { return; } + if n.appearance.effects.iter().any(|e| e.visible && vectorcraft_effects::is_raster(&e.id)) { + return self.filtered(n); + } if let Some(m) = n.mask.as_deref() && !m.disabled { @@ -689,6 +695,78 @@ impl Writer<'_> { } } + /// Raster effects (drop shadow, glows, Gaussian blur, feather) as SVG filters: one `` + /// per effect, the first effect innermost (Illustrator's stacking order). + fn filtered(&mut self, n: &Node) { + use vectorcraft_effects::RasterFx; + let fx = vectorcraft_effects::raster_effects(&n.appearance.effects); + let mut inner = n.clone(); + inner.appearance.effects.retain(|e| !vectorcraft_effects::is_raster(&e.id)); + let reach: f64 = fx.iter().map(RasterFx::outset).sum::() + 2.0; + let region = n.visual_bounds().map(|b| self.xf.transform_rect_bbox(b).inflate(reach, reach)); + let mut opened = 0; + for f in fx.iter().rev() { + let fid = self.fresh_id("filter"); + let region_attr = match region { + Some(r) => format!( + " filterUnits=\"userSpaceOnUse\" x=\"{}\" y=\"{}\" width=\"{}\" height=\"{}\"", + self.num(r.x0), + self.num(r.y0), + self.num(r.width()), + self.num(r.height()) + ), + None => String::new(), + }; + let sd = |blur: f64| fmt_num((blur / 2.0).max(0.0), 3); + let flood = |c: &vectorcraft_color::Color, o: f32| { + format!("", c.to_hex(), fmt_num(o as f64, 3)) + }; + let body = match f { + RasterFx::DropShadow { opacity, dx, dy, blur, color, .. } => format!( + "{}", + sd(*blur), + self.num(*dx), + self.num(*dy), + flood(color, *opacity) + ), + RasterFx::OuterGlow { opacity, blur, color, .. } => format!( + "{}", + sd(*blur), + flood(color, *opacity) + ), + RasterFx::InnerGlow { opacity, blur, color, center, .. } => { + // Edge: the blurred inverse silhouette; Center: the blurred silhouette; both clipped to the shape. + let src = if *center { + "".replace("SD", &sd(*blur)) + } else { + format!( + "", + sd(*blur) + ) + }; + format!( + "{src}{}", + flood(color, *opacity) + ) + } + RasterFx::Feather { radius } => format!( + "", + sd(*radius) + ), + RasterFx::GaussianBlur { radius } => format!("", sd(*radius)), + }; + self.def(1, &format!("{body}")); + self.line(&format!("")); + self.depth += 1; + opened += 1; + } + self.node(&inner); + for _ in 0..opened { + self.depth -= 1; + self.line(""); + } + } + fn char_props(&mut self, st: &CharStyle) -> Props { let mut p = Props::new(); let fam = diff --git a/crates/svg/tests/roundtrip.rs b/crates/svg/tests/roundtrip.rs index f4136a8e..4fac33c9 100644 --- a/crates/svg/tests/roundtrip.rs +++ b/crates/svg/tests/roundtrip.rs @@ -693,3 +693,21 @@ fn opentype_features_export_as_css() { let s = export(&d, &ExportOptions::default()); assert!(s.contains("font-feature-settings:"liga" 0, "dlig" 1"), "{s}"); } + +#[test] +fn live_effects_export_as_geometry_and_filters() { + let mut d = Document::new(400.0, 400.0); + let mut n = rect_node(&mut d, Rect::new(100.0, 100.0, 200.0, 200.0), Appearance::default_art()); + let fx = |id: &str, p: serde_json::Value| vectorcraft_doc::Effect { id: id.into(), params: p, visible: true }; + n.appearance.effects.push(fx("path.offsetPath", serde_json::json!({"offset": 10.0}))); + n.appearance.effects.push(fx("stylize.dropShadow", serde_json::json!({"x": 7.0, "y": 7.0, "blur": 5.0}))); + let l = d.layers[0].id; + d.insert(Some(l), 0, n).unwrap(); + let s = export(&d, &ExportOptions { artboard: Some(0), ..Default::default() }); + assert!(s.contains(", b| Some(a.map_or(b, |a| a.union(b)))).unwrap(); + assert!((b.width() - 120.0).abs() < 1.0, "{b:?}"); +}