From 4745f39c6c45986ffd8957b6abc40fc56a4144ae Mon Sep 17 00:00:00 2001 From: Brandon Thomas Date: Thu, 1 Oct 2026 22:48:49 -0400 Subject: [PATCH] =?UTF-8?q?M7.4:=20Object=20=E2=86=92=20Text=20Wrap=20(Mak?= =?UTF-8?q?e,=20Release,=20Text=20Wrap=20Options:=20offset,=20invert):=20a?= =?UTF-8?q?rea=20type=20flows=20around=20wrap=20objects=20above=20it=20in?= =?UTF-8?q?=20the=20same=20layer,=20re-resolved=20after=20every=20edit?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 5.5 --- ROADMAP.md | 4 +- crates/doc/src/lib.rs | 4 +- crates/doc/src/node.rs | 4 + crates/doc/src/text.rs | 28 ++++ crates/effects/src/bake.rs | 1 + crates/engine/src/cmd/mod.rs | 2 + crates/engine/src/cmd/textedit.rs | 2 + crates/engine/src/cmd/textwrap.rs | 251 ++++++++++++++++++++++++++++++ crates/engine/src/lib.rs | 2 + crates/svg/src/import.rs | 1 + crates/text/src/layout.rs | 132 ++++++++++++---- crates/ui-egui/src/menus.rs | 19 ++- 12 files changed, 417 insertions(+), 33 deletions(-) create mode 100644 crates/engine/src/cmd/textwrap.rs diff --git a/ROADMAP.md b/ROADMAP.md index 41e20ee5..338d3ded 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -40,7 +40,7 @@ _Last updated: 2026-10-01._ - **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, 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. +- **Type:** Text Wrap, Type on a Path effects (Rainbow/Skew/3D Ribbon/Stair Step/Gravity), 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). - **Colour, type and file workflows:** Recolor Artwork (dialog with harmonies), Edit Colors, Find & Replace, Change Case, Smart Punctuation, Guides, Lock/Hide Above, Transform Each, Rasterize. @@ -61,7 +61,7 @@ _Last updated: 2026-10-01._ | 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, OpenType panel, Find Font done; Tabs panel, text wrap, spell check, vertical type pending | 10–17 | +| 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, Text Wrap (offset, invert; follows edits), Type on a Path effects done; Tabs panel, spell check, vertical type, wrap on both sides of an object pending | 9–15 | | 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 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 | diff --git a/crates/doc/src/lib.rs b/crates/doc/src/lib.rs index 91c304e3..95e4c472 100644 --- a/crates/doc/src/lib.rs +++ b/crates/doc/src/lib.rs @@ -35,7 +35,9 @@ pub use live::{BlendOrientation, BlendSpacing, BlendSpec, EnvelopeKind, Gradient pub use node::{ImageObject, LAYER_COLORS, LayerColor, LiveShape, Node, NodeId, NodeKind, OpacityMask}; pub use pattern::{Overlap, PatternDef, PatternEdit, RepeatKind, RepeatSpec, TileType}; pub use selection::{AnchorRef, Selection}; -pub use text::{AreaOptions, CharStyle, FirstBaseline, Justify, ParaStyle, PathEffect, TextKind, TextObject, TextRun, TextStyleDef}; +pub use text::{ + AreaOptions, CharStyle, FirstBaseline, Justify, ParaStyle, PathEffect, TextKind, TextObject, TextRun, TextStyleDef, TextWrap, WrapShape, +}; pub use vectorcraft_color as color; pub use vectorcraft_geom as geom; diff --git a/crates/doc/src/node.rs b/crates/doc/src/node.rs index 1f060529..039de376 100644 --- a/crates/doc/src/node.rs +++ b/crates/doc/src/node.rs @@ -263,6 +263,9 @@ pub struct Node { /// Image Trace object: `{preset, params}` it was traced with (the Image Trace panel shows them). #[serde(default, skip_serializing_if = "Option::is_none")] pub trace: Option>, + /// Object → Text Wrap: area type below this object (in the same layer) flows around it. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub wrap: Option, pub kind: NodeKind, } @@ -305,6 +308,7 @@ impl Node { appearance: Appearance::default(), mask: None, trace: None, + wrap: None, kind, } } diff --git a/crates/doc/src/text.rs b/crates/doc/src/text.rs index 13a6149e..ed67b31c 100644 --- a/crates/doc/src/text.rs +++ b/crates/doc/src/text.rs @@ -136,6 +136,30 @@ pub enum FirstBaseline { Fixed, } +/// Text Wrap Options of a wrap object (Object → Text Wrap). +#[derive(Clone, Copy, Debug, PartialEq, Serialize, Deserialize)] +#[serde(default)] +pub struct TextWrap { + /// Distance kept between the text and the object, in points (Illustrator's default 6 pt). + pub offset: f64, + /// Invert Wrap: text flows inside the object instead of around it. + pub invert: bool, +} + +impl Default for TextWrap { + fn default() -> Self { + Self { offset: 6.0, invert: false } + } +} + +/// A resolved wrap shape on an area text object: the outline of a wrap object in text space. +#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] +pub struct WrapShape { + pub path: PathData, + #[serde(flatten)] + pub wrap: TextWrap, +} + /// Type on a Path effect: how each glyph is oriented on the path (Type → Type on a Path). #[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)] #[serde(rename_all = "camelCase")] @@ -234,6 +258,9 @@ pub struct TextObject { /// Type on a Path effect (type on a path only). #[serde(default, skip_serializing_if = "crate::skip::is_default")] pub path_effect: PathEffect, + /// Wrap objects above this area type, resolved by the engine after each edit (text space). + #[serde(default, skip_serializing_if = "Vec::is_empty")] + pub wrap: Vec, /// Cached layout bounds in text space, filled in by the layout engine (not serialized). #[serde(skip)] pub cached_bounds: Option, @@ -248,6 +275,7 @@ impl TextObject { para: ParaStyle::default(), area: AreaOptions::default(), path_effect: PathEffect::default(), + wrap: Vec::new(), cached_bounds: None, } } diff --git a/crates/effects/src/bake.rs b/crates/effects/src/bake.rs index 5dbaf1e2..816458de 100644 --- a/crates/effects/src/bake.rs +++ b/crates/effects/src/bake.rs @@ -132,6 +132,7 @@ fn bake_leaf(d: &mut Document, n: &Node) -> Option { knockout: false, mask: None, trace: None, + wrap: None, kind, ..n.clone() }) diff --git a/crates/engine/src/cmd/mod.rs b/crates/engine/src/cmd/mod.rs index 2fda8942..c51f067b 100644 --- a/crates/engine/src/cmd/mod.rs +++ b/crates/engine/src/cmd/mod.rs @@ -30,6 +30,7 @@ mod recolor; mod select; mod textedit; pub mod textstyles; +pub(crate) mod textwrap; pub(crate) mod threads; pub(crate) mod typecmd; mod typemenu; @@ -159,6 +160,7 @@ pub fn command_specs() -> &'static [CommandSpec] { v.extend(fonts::specs()); v.extend(help::specs()); v.extend(threads::specs()); + v.extend(textwrap::specs()); v.extend(docmenu::specs()); v.extend(docinfo::specs()); v.extend(panelcmds::specs()); diff --git a/crates/engine/src/cmd/textedit.rs b/crates/engine/src/cmd/textedit.rs index 245ab297..571c74df 100644 --- a/crates/engine/src/cmd/textedit.rs +++ b/crates/engine/src/cmd/textedit.rs @@ -338,6 +338,7 @@ fn create_in_path(s: &mut Session, p: &Value) -> Result { para: Default::default(), area: Default::default(), path_effect: Default::default(), + wrap: Vec::new(), cached_bounds: None, }; refresh_bounds(&mut t); @@ -373,6 +374,7 @@ fn headline_tracking(t: &TextObject, target: f64) -> Option { para: Default::default(), area: Default::default(), path_effect: Default::default(), + wrap: Vec::new(), cached_bounds: None, }; for r in &mut h.runs { diff --git a/crates/engine/src/cmd/textwrap.rs b/crates/engine/src/cmd/textwrap.rs new file mode 100644 index 00000000..76ca49b4 --- /dev/null +++ b/crates/engine/src/cmd/textwrap.rs @@ -0,0 +1,251 @@ +//! Object → Text Wrap: area type flows around wrap objects stacked above it in the same layer. +//! +//! A wrap object carries [`TextWrap`] options (`Node::wrap`). After every edit [`refresh`] +//! resolves, for each area text object, the outlines of the wrap objects above it into +//! `TextObject::wrap` (text space); the layout engine subtracts them from each line's span. + +use serde_json::{Value, json}; +use vectorcraft_doc::{Document, Node, NodeId, NodeKind, TextKind, TextWrap, WrapShape}; +use vectorcraft_geom::{PathData, shapes}; + +use super::edit::selected_roots; +use super::typecmd::refresh_bounds; +use super::*; + +pub fn specs() -> Vec { + vec![ + cmd!( + "object.textWrap.make", + "Make", + ["Object", "Text Wrap"], + None, + "{offset?: pt (6), invert?: bool, ids?} area type below the selected objects (same layer) wraps around them → {ids}", + has_selection, + make + ), + cmd!("object.textWrap.release", "Release", ["Object", "Text Wrap"], None, "{ids?} → {ids}", has_selection, release), + cmd!( + "object.textWrap.options", + "Text Wrap Options…", + ["Object", "Text Wrap"], + None, + "{offset?: pt, invert?: bool, ids?} set the options of the selected wrap objects; no options → the current {offset, invert}", + has_selection, + options + ), + ] +} + +fn targets(s: &Session, p: &Value) -> Result> { + match p.get("ids").and_then(Value::as_array) { + Some(a) => Ok(a.iter().filter_map(Value::as_u64).map(NodeId).collect()), + None => selected_roots(s), + } +} + +fn wrap_param(p: &Value, base: TextWrap) -> TextWrap { + TextWrap { offset: f64_or(p, "offset", base.offset).clamp(-1000.0, 1000.0), invert: bool_or(p, "invert", base.invert) } +} + +fn make(s: &mut Session, p: &Value) -> Result { + let ids = targets(s, p)?; + let w = wrap_param(p, TextWrap::default()); + s.edit("Make Text Wrap", |d, _| { + for id in &ids { + let n = d.node_mut(*id).ok_or(EngineError::NoNode(*id))?; + if matches!(n.kind, NodeKind::Layer { .. }) { + return Err(EngineError::Other("layers can't be wrap objects".into())); + } + n.wrap = Some(w); + } + Ok(()) + })?; + Ok(json!({ "ids": ids.iter().map(|i| i.0).collect::>() })) +} + +fn release(s: &mut Session, p: &Value) -> Result { + let ids: Vec = { + let d = &s.doc()?.doc; + targets(s, p)?.into_iter().filter(|i| d.node(*i).is_some_and(|n| n.wrap.is_some())).collect() + }; + if ids.is_empty() { + return Err(EngineError::Other("Text Wrap: select a wrap object".into())); + } + s.edit("Release Text Wrap", |d, _| { + for id in &ids { + if let Some(n) = d.node_mut(*id) { + n.wrap = None; + } + } + Ok(()) + })?; + Ok(json!({ "ids": ids.iter().map(|i| i.0).collect::>() })) +} + +fn options(s: &mut Session, p: &Value) -> Result { + let ids = targets(s, p)?; + let current = { + let d = &s.doc()?.doc; + ids.iter().find_map(|i| d.node(*i).and_then(|n| n.wrap)).unwrap_or_default() + }; + if p.get("offset").is_none() && p.get("invert").is_none() { + return Ok(json!({ "offset": current.offset, "invert": current.invert })); + } + let w = wrap_param(p, current); + s.edit("Text Wrap Options", |d, _| { + for id in &ids { + if let Some(n) = d.node_mut(*id) + && !matches!(n.kind, NodeKind::Layer { .. }) + { + n.wrap = Some(w); + } + } + Ok(()) + })?; + Ok(json!({ "offset": w.offset, "invert": w.invert })) +} + +/// Outline of a wrap object in document coordinates: its paths, or its bounds for other art. +fn outline(n: &Node, out: &mut PathData) { + match &n.kind { + NodeKind::Path { path, guide: false, .. } => { + out.subpaths.extend(path.subpaths.iter().filter(|sp| sp.closed || sp.anchors.len() > 2).cloned()) + } + NodeKind::Compound { children, .. } | NodeKind::Group { children, clip: false } => { + for c in children.iter().filter(|c| c.visible) { + outline(c, out); + } + } + // A clip group wraps by its clipping path. + NodeKind::Group { children, clip: true } => { + if let Some(c) = children.first() { + outline(c, out); + } + } + _ => { + if let Some(b) = n.geometric_bounds() { + out.subpaths.extend(shapes::rectangle(b).subpaths); + } + } + } +} + +enum Item<'a> { + Wrap(&'a Node), + Text(NodeId), +} + +fn collect<'a>(n: &'a Node, out: &mut Vec>) { + if !n.visible { + return; + } + if n.wrap.is_some() && !matches!(n.kind, NodeKind::Layer { .. }) { + out.push(Item::Wrap(n)); + return; + } + match &n.kind { + NodeKind::Text(t) if matches!(t.kind, TextKind::Area { .. }) => out.push(Item::Text(n.id)), + NodeKind::Layer { children, .. } | NodeKind::Group { children, .. } => { + for c in children { + collect(c, out); + } + } + _ => {} + } +} + +fn any_wrap_state(n: &Node) -> bool { + n.wrap.is_some() + || matches!(&n.kind, NodeKind::Text(t) if !t.wrap.is_empty()) + || n.children().is_some_and(|ch| ch.iter().any(|c| any_wrap_state(c))) +} + +/// Re-resolve the wrap shapes of every area text object (called after each edit). +pub(crate) fn refresh(doc: &mut Document) { + if !doc.layers.iter().any(|l| any_wrap_state(l)) { + return; + } + let mut updates: Vec<(NodeId, Vec)> = vec![]; + for layer in &doc.layers { + let mut items = vec![]; + collect(layer, &mut items); + for (i, it) in items.iter().enumerate() { + let Item::Text(id) = it else { continue }; + let Some(NodeKind::Text(t)) = doc.node(*id).map(|n| &n.kind) else { continue }; + let inv = t.xf.inverse(); + let shapes: Vec = items[i + 1..] + .iter() + .filter_map(|w| match w { + Item::Wrap(n) => { + let mut pd = PathData::default(); + outline(n, &mut pd); + (!pd.is_empty()).then(|| { + pd.transform(inv); + WrapShape { path: pd, wrap: n.wrap.unwrap_or_default() } + }) + } + Item::Text(_) => None, + }) + .collect(); + if shapes != t.wrap { + updates.push((*id, shapes)); + } + } + } + for (id, shapes) in updates { + if let Some(NodeKind::Text(t)) = doc.node_mut(id).map(|n| &mut n.kind) { + t.wrap = shapes; + refresh_bounds(t); + } + } +} + +#[cfg(test)] +mod tests { + use serde_json::json; + use vectorcraft_doc::NodeKind; + + use crate::{NodeId, Session}; + + fn lines(s: &Session, id: u64) -> Vec<(f64, f64)> { + let Some(NodeKind::Text(t)) = s.doc().unwrap().doc.node(NodeId(id)).map(|n| n.kind.clone()) else { panic!() }; + vectorcraft_text::layout(vectorcraft_text::FontDb::global(), &t).lines.iter().map(|l| l.avail).collect() + } + + #[test] + fn area_type_wraps_around_objects_above_and_follows_edits() { + let mut s = Session::new(); + s.execute("file.new", &json!({"width": 600, "height": 600})).unwrap(); + let text = "word ".repeat(200); + let t = s.execute("text.create", &json!({"x": 0, "y": 0, "text": text, "size": 12, "area": {"width": 400, "height": 400}})).unwrap()["id"] + .as_u64() + .unwrap(); + let plain = lines(&s, t); + assert!(plain.iter().all(|l| l.1 > 300.0)); + // A square over the left half of the frame: lines next to it start right of it (+6 pt offset). + let r = s.execute("shape.rectangle", &json!({"x": 0, "y": 50, "width": 200, "height": 100})).unwrap()["id"].as_u64().unwrap(); + s.execute("select.set", &json!({"ids": [r]})).unwrap(); + s.execute("object.textWrap.make", &json!({})).unwrap(); + let wrapped = lines(&s, t); + assert!(wrapped.iter().any(|l| l.0 >= 205.0), "{wrapped:?}"); + assert!(wrapped.iter().filter(|l| l.0 < 1.0).count() >= 2); + // Moving the wrap object away re-flows the text. + s.execute("object.move", &json!({"dx": 500, "dy": 0})).unwrap(); + assert!(lines(&s, t).iter().all(|l| l.0 < 1.0)); + s.execute("object.move", &json!({"dx": -500, "dy": 0})).unwrap(); + // Options round-trip; Invert Wrap keeps text inside the object only. + assert_eq!(s.execute("object.textWrap.options", &json!({})).unwrap()["offset"], json!(6.0)); + s.execute("object.textWrap.options", &json!({"invert": true, "offset": 0})).unwrap(); + let inside = lines(&s, t); + assert!(inside.iter().all(|l| l.1 <= 200.0 + 1e-6), "{inside:?}"); + // Release: back to the plain layout. + s.execute("object.textWrap.release", &json!({})).unwrap(); + assert_eq!(lines(&s, t), plain); + // Objects below the text don't wrap it. + let below = s.execute("shape.rectangle", &json!({"x": 0, "y": 50, "width": 200, "height": 100})).unwrap()["id"].as_u64().unwrap(); + s.execute("select.set", &json!({"ids": [below]})).unwrap(); + s.execute("object.textWrap.make", &json!({})).unwrap(); + s.execute("object.arrange.sendToBack", &json!({})).unwrap(); + assert_eq!(lines(&s, t), plain); + } +} diff --git a/crates/engine/src/lib.rs b/crates/engine/src/lib.rs index ebb54fb4..0aa710f1 100644 --- a/crates/engine/src/lib.rs +++ b/crates/engine/src/lib.rs @@ -573,6 +573,8 @@ impl Session { let doc = Arc::make_mut(&mut st.doc); let result = match f(doc, &mut st.selection) { Ok(v) => { + // Text Wrap: area type follows its wrap objects; then threads re-flow. + cmd::textwrap::refresh(Arc::make_mut(&mut st.doc)); // Threaded text re-flows when any of its frames changed. if !st.doc.text_threads.is_empty() { cmd::threads::reflow(&before, Arc::make_mut(&mut st.doc)); diff --git a/crates/svg/src/import.rs b/crates/svg/src/import.rs index 239aed31..8bc4dcee 100644 --- a/crates/svg/src/import.rs +++ b/crates/svg/src/import.rs @@ -697,6 +697,7 @@ fn text_fallback(im: &mut Importer, svg: &str, w: f64, h: f64, layer_mode: bool) para: Default::default(), area: Default::default(), path_effect: Default::default(), + wrap: Vec::new(), cached_bounds: None, }; obj.para.justify = justify; diff --git a/crates/text/src/layout.rs b/crates/text/src/layout.rs index 65746017..23e8e4bb 100644 --- a/crates/text/src/layout.rs +++ b/crates/text/src/layout.rs @@ -89,7 +89,7 @@ pub fn layout_with(db: &FontDb, t: &TextObject, opts: &LayoutOptions) -> TextLay match &t.kind { TextKind::Point => flow(&mut cx, ¶s, &t.para, None), TextKind::Area { frame } => { - let regions = Region::cells(&frame.to_bezpath(), opts); + let regions = Region::cells(&frame.to_bezpath(), opts, &t.wrap); cx.out.frames = regions.iter().map(|r| r.cell).collect(); flow(&mut cx, ¶s, &t.para, Some(®ions)); } @@ -124,21 +124,94 @@ struct Region { polys: Vec>, /// The frame is its own bounding rectangle (spans need no polygon intersection). rect: bool, + /// Text Wrap shapes: (polygons, offset, invert). + wraps: Vec<(Vec>, f64, bool)>, +} + +/// Flattened closed polygons of a path. +fn polygons(path: &BezPath) -> Vec> { + let mut polys: Vec> = Vec::new(); + kurbo::flatten(path, 0.1, |el| match el { + PathEl::MoveTo(p) => polys.push(vec![p]), + PathEl::LineTo(p) => { + if let Some(v) = polys.last_mut() { + v.push(p); + } + } + _ => {} + }); + polys.retain(|p| p.len() >= 3); + polys +} + +/// Inside intervals (even-odd) of the horizontal line at `y` through `polys`. +fn poly_intervals(polys: &[Vec], y: f64) -> Vec<(f64, f64)> { + let mut xs = Vec::new(); + for poly in polys { + for i in 0..poly.len() { + let a = poly[i]; + let b = poly[(i + 1) % poly.len()]; + if (a.y <= y) != (b.y <= y) { + xs.push(a.x + (y - a.y) / (b.y - a.y) * (b.x - a.x)); + } + } + } + xs.sort_by(f64::total_cmp); + xs.chunks_exact(2).map(|c| (c[0], c[1])).collect() +} + +/// Union of sorted-or-not intervals. +fn union_intervals(mut v: Vec<(f64, f64)>) -> Vec<(f64, f64)> { + v.sort_by(|a, b| a.0.total_cmp(&b.0)); + let mut out: Vec<(f64, f64)> = Vec::with_capacity(v.len()); + for (a, b) in v { + match out.last_mut() { + Some(l) if a <= l.1 => l.1 = l.1.max(b), + _ => out.push((a, b)), + } + } + out +} + +/// `a` minus `b` (both unions of disjoint intervals). +fn subtract_intervals(a: &[(f64, f64)], b: &[(f64, f64)]) -> Vec<(f64, f64)> { + let mut out = vec![]; + for &(mut s, e) in a { + for &(c, d) in b { + if d <= s || c >= e { + continue; + } + if c > s { + out.push((s, c)); + } + s = s.max(d); + } + if e > s { + out.push((s, e)); + } + } + out +} + +/// `a` ∩ `b`. +fn intersect_intervals(a: &[(f64, f64)], b: &[(f64, f64)]) -> Vec<(f64, f64)> { + let mut out = vec![]; + for &(s, e) in a { + for &(c, d) in b { + let (x, y) = (s.max(c), e.min(d)); + if y > x { + out.push((x, y)); + } + } + } + out } impl Region { - fn cells(path: &BezPath, opts: &LayoutOptions) -> Vec { - let mut polys: Vec> = Vec::new(); - kurbo::flatten(path, 0.1, |el| match el { - PathEl::MoveTo(p) => polys.push(vec![p]), - PathEl::LineTo(p) => { - if let Some(v) = polys.last_mut() { - v.push(p); - } - } - _ => {} - }); - polys.retain(|p| p.len() >= 3); + fn cells(path: &BezPath, opts: &LayoutOptions, wrap: &[vectorcraft_doc::WrapShape]) -> Vec { + let polys = polygons(path); + let wraps: Vec<(Vec>, f64, bool)> = + wrap.iter().map(|w| (polygons(&w.path.to_bezpath()), w.wrap.offset.max(0.0), w.wrap.invert)).filter(|w| !w.0.is_empty()).collect(); let bbox = path.bounding_box(); let rect = polys.len() == 1 && { let area: f64 = polys[0].iter().zip(polys[0].iter().cycle().skip(1)).map(|(a, b)| a.x * b.y - b.x * a.y).sum::().abs() * 0.5; @@ -155,7 +228,7 @@ impl Region { let x0 = bbox.x0 + c as f64 * (cw + gutter); let y0 = bbox.y0 + r as f64 * (rh + gutter); let cell = if rows * cols == 1 { bbox } else { Rect::new(x0, y0, x0 + cw, y0 + rh) }; - out.push(Region { cell, inset: opts.inset.max(0.0), polys: polys.clone(), rect }); + out.push(Region { cell, inset: opts.inset.max(0.0), polys: polys.clone(), rect, wraps: wraps.clone() }); } } out @@ -168,20 +241,16 @@ impl Region { self.cell.y1 - self.inset } - /// Inside intervals (even-odd) of the horizontal line at `y`. + /// Inside intervals (even-odd) of the horizontal line at `y`, minus the wrap objects (or + /// inside them, for Invert Wrap). Offsets grow each wrap shape vertically and horizontally. fn intervals(&self, y: f64) -> Vec<(f64, f64)> { - let mut xs = Vec::new(); - for poly in &self.polys { - for i in 0..poly.len() { - let a = poly[i]; - let b = poly[(i + 1) % poly.len()]; - if (a.y <= y) != (b.y <= y) { - xs.push(a.x + (y - a.y) / (b.y - a.y) * (b.x - a.x)); - } - } + let mut iv = if self.polys.is_empty() || self.rect { vec![(self.cell.x0, self.cell.x1)] } else { poly_intervals(&self.polys, y) }; + for (polys, off, invert) in &self.wraps { + let ys = if *off > 0.0 { vec![y - off, y - off * 0.5, y, y + off * 0.5, y + off] } else { vec![y] }; + let w = union_intervals(ys.into_iter().flat_map(|y| poly_intervals(polys, y)).map(|(a, b)| (a - off, b + off)).collect()); + iv = if *invert { intersect_intervals(&iv, &w) } else { subtract_intervals(&iv, &w) }; } - xs.sort_by(f64::total_cmp); - xs.chunks_exact(2).map(|c| (c[0], c[1])).collect() + iv } /// Widest horizontal span inside the frame (and the cell) over the band `top..bottom`. @@ -190,11 +259,16 @@ impl Region { let (a, b) = (a.max(self.cell.x0) + self.inset, b.min(self.cell.x1) - self.inset); (b > a).then_some((a, b)) }; - if self.polys.is_empty() || self.rect { + let plain = self.polys.is_empty() || self.rect; + if plain && self.wraps.is_empty() { return (self.cell.width() > 0.0).then_some((self.cell.x0, self.cell.x1)).and_then(clip); } - let fb = self.polys.iter().flatten().fold(Rect::new(f64::MAX, f64::MAX, f64::MIN, f64::MIN), |r, p| r.union_pt(*p)); - let clamp = |y: f64| y.clamp(fb.y0 + 1e-4, fb.y1 - 1e-4); + let fb = if plain { + self.cell + } else { + self.polys.iter().flatten().fold(Rect::new(f64::MAX, f64::MAX, f64::MIN, f64::MIN), |r, p| r.union_pt(*p)) + }; + let clamp = |y: f64| if plain { y } else { y.clamp(fb.y0 + 1e-4, fb.y1 - 1e-4) }; // Sample the band densely enough for curved frames (circles, blobs). let samples = 5; let mut rows: Vec> = diff --git a/crates/ui-egui/src/menus.rs b/crates/ui-egui/src/menus.rs index 115cb5b5..d2aec1dd 100644 --- a/crates/ui-egui/src/menus.rs +++ b/crates/ui-egui/src/menus.rs @@ -792,7 +792,14 @@ pub fn menu_tree() -> Vec<(&'static str, Vec)> { c("Expand", "imageTrace.expand"), ], ), - sub("Text Wrap", vec![todo("Make"), todo("Release"), todo("Text Wrap Options…")]), + sub( + "Text Wrap", + vec![ + c("Make", "object.textWrap.make"), + c("Release", "object.textWrap.release"), + c("Text Wrap Options…", "object.textWrap.options"), + ], + ), Sep, sub( "Clipping Mask", @@ -1220,6 +1227,16 @@ pub fn invoke(app: &mut VectorcraftApp, id: &str, p: Value) { } return; } + // Text Wrap Options: a dialog with the selected wrap object's current values. + if id == "object.textWrap.options" && p.as_object().is_none_or(|o| o.is_empty()) { + match app.session.execute(id, &json!({})) { + Ok(fields) => { + let _ = app.run("ui.paramDialog", json!({"command": id, "label": "Text Wrap Options", "params": fields})); + } + Err(e) => app.status(e.to_string()), + } + return; + } // Help links: the command returns the URL; a menu click opens it (agents just get the URL). if matches!(id, "help.discord" | "help.website" | "help.appPage" | "help.github") { app.open_link(id);