From 323b178ea777e0bbc27b3dec65f813330b2e710f Mon Sep 17 00:00:00 2001 From: bflatastic Date: Mon, 5 Oct 2026 00:32:14 +0700 Subject: [PATCH 1/2] =?UTF-8?q?M3.40:=20stroke=20on=20type=20=E2=80=94=20t?= =?UTF-8?q?he=20Stroke=20panel=20edits=20the=20characters'=20stroke?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit With type selected, stroke.set (without an item) gives every run the weight, cap, join, miter limit and dashes instead of adding an object stroke; while the Type tool edits text, the Stroke panel styles the selected characters through text.setRangeStyle `strokeOptions`, and the panel reads the first run (or the selection's style). CharStyle gains stroke_cap, stroke_join, stroke_miter_limit and stroke_dash (defaulted, skipped when default). Character strokes and object strokes on type now draw through the shared stroke geometry on the canvas, in PDF and in SVG (live text writes cap/join/miter/dash and tspans reset what the first run sets; object strokes are drawn on the glyph outlines), so every stroke option, opacity and blend applies. Outlines, the Eyedropper and graphic styles keep the options. Co-Authored-By: Claude Opus 5.5 --- crates/doc/src/text.rs | 73 +++++++++-- crates/effects/src/reshape.rs | 10 +- crates/engine/src/cmd/appearance.rs | 8 +- crates/engine/src/cmd/paint.rs | 2 +- crates/engine/src/cmd/stroke.rs | 173 ++++++++++++++++++------- crates/engine/src/cmd/style.rs | 2 +- crates/engine/src/cmd/textedit.rs | 22 +++- crates/engine/src/inspect.rs | 20 ++- crates/engine/src/lib.rs | 2 + crates/engine/src/tests_charstroke.rs | 135 +++++++++++++++++++ crates/pdf/src/export.rs | 46 +++---- crates/pdf/src/lib.rs | 2 + crates/pdf/src/tests_charstroke.rs | 40 ++++++ crates/render/src/fx.rs | 2 +- crates/render/src/lib.rs | 69 +++++----- crates/render/src/tests_charstroke.rs | 83 ++++++++++++ crates/svg/src/export.rs | 93 ++++++++++--- crates/svg/src/import.rs | 15 +++ crates/svg/tests/text_stroke.rs | 78 +++++++++++ crates/ui-egui/src/panels/character.rs | 11 +- crates/ui-egui/src/panels/mod.rs | 9 +- crates/ui-egui/src/panels/stroke.rs | 34 ++++- docs/mcp.md | 9 ++ 23 files changed, 777 insertions(+), 161 deletions(-) create mode 100644 crates/engine/src/tests_charstroke.rs create mode 100644 crates/pdf/src/tests_charstroke.rs create mode 100644 crates/render/src/tests_charstroke.rs create mode 100644 crates/svg/tests/text_stroke.rs diff --git a/crates/doc/src/text.rs b/crates/doc/src/text.rs index 994dff13..077d00e4 100644 --- a/crates/doc/src/text.rs +++ b/crates/doc/src/text.rs @@ -4,6 +4,8 @@ use serde::{Deserialize, Serialize}; use vectorcraft_color::{Color, Paint}; use vectorcraft_geom::{Affine, PathData, Point, Rect}; +use crate::appearance::{Appearance, AppearanceItem, Dash, FillLayer, LineCap, LineJoin, StrokeLayer}; + #[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)] pub enum Justify { #[default] @@ -64,8 +66,24 @@ pub struct CharStyle { pub overprint_fill: bool, #[serde(default, skip_serializing_if = "crate::skip::is_default")] pub overprint_stroke: bool, + /// Cap, join, miter limit and dashes of the character stroke (Stroke panel, with type + /// selected); defaults as for object strokes. + #[serde(default, skip_serializing_if = "crate::skip::is_default")] + pub stroke_cap: LineCap, + #[serde(default, skip_serializing_if = "crate::skip::is_default")] + pub stroke_join: LineJoin, + #[serde(default = "ten", skip_serializing_if = "is_ten")] + pub stroke_miter_limit: f64, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub stroke_dash: Option, } +fn ten() -> f64 { + 10.0 +} +fn is_ten(v: &f64) -> bool { + *v == 10.0 +} fn regular() -> String { "Regular".into() } @@ -96,6 +114,10 @@ impl Default for CharStyle { style_name: None, overprint_fill: false, overprint_stroke: false, + stroke_cap: LineCap::Butt, + stroke_join: LineJoin::Miter, + stroke_miter_limit: 10.0, + stroke_dash: None, } } } @@ -104,19 +126,50 @@ impl CharStyle { pub fn effective_leading(&self) -> f64 { self.leading.unwrap_or(self.size * 1.2) } - /// These characters' paint as an object appearance (their outlines'): the fill, plus the - /// stroke when it is painted, overprinting as the characters do. - pub fn appearance(&self) -> crate::Appearance { - let mut fill = crate::FillLayer::new(self.fill.clone()); - fill.overprint = self.overprint_fill; - let mut a = crate::Appearance { items: vec![crate::AppearanceItem::Fill(fill)], ..Default::default() }; - if !self.stroke.is_none() && self.stroke_width > 0.0 { - let mut st = crate::StrokeLayer::new(self.stroke.clone(), self.stroke_width); - st.overprint = self.overprint_stroke; - a.items.push(crate::AppearanceItem::Stroke(st)); + /// Do the characters draw a stroke (a paint and a positive weight)? + pub fn has_stroke(&self) -> bool { + !self.stroke.is_none() && self.stroke_width > 0.0 + } + /// The character stroke as a stroke layer (paint, weight, cap, join, miter limit, dashes and + /// overprint), so type strokes share the object strokes' geometry, rendering and export. + pub fn stroke_layer(&self) -> StrokeLayer { + StrokeLayer { + overprint: self.overprint_stroke, + cap: self.stroke_cap, + join: self.stroke_join, + miter_limit: self.stroke_miter_limit, + dash: self.stroke_dash.clone(), + ..StrokeLayer::new(self.stroke.clone(), self.stroke_width) + } + } + /// Take the paint, weight, cap, join, miter limit and dashes of `st` as the character stroke + /// (the options characters have no use for, such as alignment and arrowheads, are dropped). + pub fn set_stroke_layer(&mut self, st: &StrokeLayer) { + self.stroke = st.paint.clone(); + self.stroke_width = st.width; + self.stroke_cap = st.cap; + self.stroke_join = st.join; + self.stroke_miter_limit = st.miter_limit; + self.stroke_dash = st.dash.clone(); + } + /// The character fill as a fill layer, overprinting as the characters do. + fn fill_layer(&self) -> FillLayer { + FillLayer { overprint: self.overprint_fill, ..FillLayer::new(self.fill.clone()) } + } + /// The characters' paint as an object appearance (their outlines'): the fill, plus the + /// stroke when it draws one, overprinting as the characters do. + pub fn appearance(&self) -> Appearance { + let mut a = Appearance { items: vec![AppearanceItem::Fill(self.fill_layer())], ..Default::default() }; + if self.has_stroke() { + a.items.push(AppearanceItem::Stroke(self.stroke_layer())); } a } + /// The characters' paint as the basic fill and stroke rows (the stroke row even when it has + /// no paint): the appearance the Eyedropper and graphic styles take from type. + pub fn basic_appearance(&self) -> Appearance { + Appearance { items: vec![AppearanceItem::Fill(self.fill_layer()), AppearanceItem::Stroke(self.stroke_layer())], ..Default::default() } + } } /// Tab stop alignment (Tabs panel). diff --git a/crates/effects/src/reshape.rs b/crates/effects/src/reshape.rs index 6de86c5a..12cad9b7 100644 --- a/crates/effects/src/reshape.rs +++ b/crates/effects/src/reshape.rs @@ -9,7 +9,7 @@ use std::sync::Arc; -use vectorcraft_doc::{Appearance, AppearanceItem, Effect, FillLayer, Node, NodeKind, StrokeLayer}; +use vectorcraft_doc::{Appearance, AppearanceItem, Effect, Node, NodeKind}; use vectorcraft_geom::{FillRule, PathData, Rect, shapes}; use crate::{GeomContext, apply_one, is_geometry, merged_params}; @@ -54,13 +54,7 @@ pub fn outline_text(n: &Node) -> Option { .iter() .zip(&t.runs) .filter(|(bp, _)| !bp.elements().is_empty()) - .map(|(bp, run)| { - let mut ap = Appearance { items: vec![AppearanceItem::Fill(FillLayer::new(run.style.fill.clone()))], ..Default::default() }; - if !run.style.stroke.is_none() && run.style.stroke_width > 0.0 { - ap.items.push(AppearanceItem::Stroke(StrokeLayer::new(run.style.stroke.clone(), run.style.stroke_width))); - } - Arc::new(Node::path(n.id, path(bp), ap)) - }) + .map(|(bp, run)| Arc::new(Node::path(n.id, path(bp), run.style.appearance()))) .collect(); if !n.appearance.items.is_empty() && !all.elements().is_empty() { let (below, above) = n.appearance.split_contents(); diff --git a/crates/engine/src/cmd/appearance.rs b/crates/engine/src/cmd/appearance.rs index b5ae5dcc..6d86eec3 100644 --- a/crates/engine/src/cmd/appearance.rs +++ b/crates/engine/src/cmd/appearance.rs @@ -637,7 +637,7 @@ fn copy_from(s: &mut Session, p: &Value) -> Result { let (appearance, src_box) = match &src.kind { NodeKind::Text(t) if !t.runs.is_empty() => { let r = &t.runs[0]; - (Appearance::basic(r.style.fill.clone(), r.style.stroke.clone(), r.style.stroke_width), Some(t.local_bounds())) + (r.style.basic_appearance(), Some(t.local_bounds())) } _ => (src.appearance.clone(), src.geometric_bounds()), }; @@ -680,10 +680,12 @@ fn copy_from(s: &mut Session, p: &Value) -> Result { } if let NodeKind::Text(t) = &mut n.kind { let ap = placed(Some(t.local_bounds())); + // Characters without a painted stroke get no weight. + let stroke = + ap.stroke().map_or_else(|| StrokeLayer::new(Paint::None, 0.0), |s| StrokeLayer { width: ap.stroke_width(), ..s.clone() }); for r in &mut t.runs { r.style.fill = ap.fill_paint(); - r.style.stroke = ap.stroke_paint(); - r.style.stroke_width = ap.stroke_width(); + r.style.set_stroke_layer(&stroke); } continue; } diff --git a/crates/engine/src/cmd/paint.rs b/crates/engine/src/cmd/paint.rs index 2ada9291..3763c20f 100644 --- a/crates/engine/src/cmd/paint.rs +++ b/crates/engine/src/cmd/paint.rs @@ -208,7 +208,7 @@ fn same_in_proxy(a: &Paint, b: &Paint) -> bool { /// The objects whose paints the proxies show for a selected `n`, the ones the paint commands /// change ([`leaf_targets`]): groups and layers stand for their contents (and so do blends and /// other containers inside them), a compound path for its parts. -fn painted<'a>(n: &'a Node, top: bool, out: &mut Vec<&'a Node>) { +pub(crate) fn painted<'a>(n: &'a Node, top: bool, out: &mut Vec<&'a Node>) { let expand = match n.kind { NodeKind::Group { .. } | NodeKind::Layer { .. } => true, NodeKind::Compound { .. } => false, diff --git a/crates/engine/src/cmd/stroke.rs b/crates/engine/src/cmd/stroke.rs index 79296a27..c1ec48d1 100644 --- a/crates/engine/src/cmd/stroke.rs +++ b/crates/engine/src/cmd/stroke.rs @@ -1,11 +1,14 @@ //! The Stroke panel: weight, cap, join, alignment, dashes, arrowheads, width profiles and brushes. +//! With type selected it edits the characters' stroke (weight, cap, join, miter limit, dashes). use serde_json::Value; use vectorcraft_color::{Color, Paint}; -use vectorcraft_doc::{ArrowAlign, Arrowhead, Dash, LineCap, LineJoin, StrokeAlign, WidthProfile}; +use vectorcraft_doc::{ArrowAlign, Arrowhead, CharStyle, Dash, LineCap, LineJoin, NodeKind, StrokeLayer, Unit, WidthProfile}; use super::appearance::{edit_items, item_target}; +use super::paint::painted; use super::*; +use crate::inspect::{ALIGNS, CAPS, JOINS, node_stroke}; pub fn specs() -> Vec { vec![ @@ -14,7 +17,7 @@ pub fn specs() -> Vec { "Stroke Options", ["Window", "Stroke"], None, - "{weight?, cap?: butt|round|square, join?: miter|round|bevel, miterLimit?, align?: center|inside|outside, dash?: [d,g,…]|null (a 0 dash with a round or projecting cap draws dots or squares; a new pattern keeps the current offset and alignment), dashOffset? (exact dashes only), alignDashes?: bool (true: dashes fitted to corners and path ends, every run between them holding whole periods with a dash centred on each corner and end; false, the default for a new pattern: exact lengths), startArrow?, endArrow?: Arrow|ArrowOpen|Triangle|TriangleOpen|Circle|CircleOpen|Square|SquareOpen|Diamond|Bar|null, arrowAlign?: \"extend\" (tip past the end point, default)|\"tip\" (tip on the end point; the stroke is shortened), profile?: \"uniform\"|\"lens\"|\"taperStart\"|\"taperEnd\", item?: stroke item index|null (omitted: the Appearance panel's active item if it is a stroke, else the top stroke, created when missing), ids?}", + "{weight?: pt, cap?: butt|round|square, join?: miter|round|bevel, miterLimit?, align?: center|inside|outside, dash?: [d,g,…]|null (a 0 dash with a round or projecting cap draws dots or squares; a new pattern keeps the current offset and alignment), dashOffset? (exact dashes only), alignDashes?: bool (true: dashes fitted to corners and path ends, every run between them holding whole periods with a dash centred on each corner and end; false, the default for a new pattern: exact lengths), startArrow?, endArrow?: Arrow|ArrowOpen|Triangle|TriangleOpen|Circle|CircleOpen|Square|SquareOpen|Diamond|Bar|null, arrowAlign?: \"extend\" (tip past the end point, default)|\"tip\" (tip on the end point; the stroke is shortened), profile?: \"uniform\"|\"lens\"|\"taperStart\"|\"taperEnd\", item?: stroke item index|null (omitted: the Appearance panel's active item if it is a stroke, else the top stroke, created when missing), ids?} Without a stroke item, type takes weight, cap, join, miterLimit and the dash options as its characters' stroke (every run; text.setRangeStyle `strokeOptions` styles a range)", has_doc, stroke_set ), @@ -30,10 +33,100 @@ pub fn specs() -> Vec { ] } +/// The stroke attributes object and character strokes share, parsed from `stroke.set` params +/// (also the `strokeOptions` of `text.setRangeStyle`). +#[derive(Clone, Debug, Default)] +pub(crate) struct StrokeChange { + pub(crate) weight: Option, + cap: Option, + join: Option, + miter_limit: Option, + /// `Some(None)`: solid. + dash: Option>>, + dash_offset: Option, + align_dashes: Option, +} + +/// The value param `key` names in `table` (see [`crate::inspect::CAPS`]); `None` when absent. +fn named(table: &[(T, &str)], p: &Value, key: &str, cmd: &str) -> Result> { + let Some(s) = str_param(p, key) else { return Ok(None) }; + let s = if key == "cap" && s == "projecting" { "square" } else { s }; + match table.iter().find(|(_, n)| *n == s) { + Some((v, _)) => Ok(Some(*v)), + None => Err(bad(cmd, format!("{key} must be {}, got {s}", table.iter().map(|(_, n)| *n).collect::>().join("|")))), + } +} + +impl StrokeChange { + pub(crate) fn parse(p: &Value, cmd: &str) -> Result { + let num = |k: &str| p.get(k).and_then(Value::as_f64); + let dash = match p.get("dash") { + None => None, + Some(Value::Null) => Some(None), + Some(Value::Array(a)) => Some(Some(a.iter().filter_map(Value::as_f64).collect::>()).filter(|d| !d.is_empty())), + Some(v) => return Err(bad(cmd, format!("dash must be a list of lengths or null, got {v}"))), + }; + Ok(Self { + weight: num("weight").map(|w| w.clamp(0.0, 1000.0)), + cap: named(&CAPS, p, "cap", cmd)?, + join: named(&JOINS, p, "join", cmd)?, + miter_limit: num("miterLimit").map(|m| m.clamp(1.0, 500.0)), + dash, + dash_offset: num("dashOffset"), + align_dashes: p.get("alignDashes").and_then(Value::as_bool), + }) + } + + pub(crate) fn is_empty(&self) -> bool { + self.weight.is_none() + && self.cap.is_none() + && self.join.is_none() + && self.miter_limit.is_none() + && self.dash.is_none() + && self.dash_offset.is_none() + && self.align_dashes.is_none() + } + + pub(crate) fn apply(&self, st: &mut StrokeLayer) { + if let Some(w) = self.weight { + st.width = w; + } + if let Some(c) = self.cap { + st.cap = c; + } + if let Some(j) = self.join { + st.join = j; + } + if let Some(m) = self.miter_limit { + st.miter_limit = m; + } + if let Some(pattern) = &self.dash { + // A new pattern keeps the offset and alignment of the one it replaces. + st.dash = pattern.clone().map(|pattern| Dash { pattern, ..st.dash.take().unwrap_or_default() }); + } + if let Some(d) = st.dash.as_mut() { + if let Some(o) = self.dash_offset { + d.offset = o; + } + if let Some(a) = self.align_dashes { + d.align_corners = a; + } + } + } + + /// [`Self::apply`] to a character stroke. + pub(crate) fn apply_char(&self, style: &mut CharStyle) { + let mut st = style.stroke_layer(); + self.apply(&mut st); + style.set_stroke_layer(&st); + } +} + fn stroke_set(s: &mut Session, p: &Value) -> Result { const C: &str = "stroke.set"; - if let Some(w) = p.get("weight").and_then(Value::as_f64) { - s.paint.stroke_width = w.max(0.0); + let change = StrokeChange::parse(p, C)?; + if let Some(w) = change.weight { + s.paint.stroke_width = w; } let item = item_target(s, p, C)?.of_kind(s, false); let ids = item.targets(s, p)?; @@ -46,6 +139,7 @@ fn stroke_set(s: &mut Session, p: &Value) -> Result { } }; let (sa, ea) = (arrow("startArrow")?, arrow("endArrow")?); + let align = named(&ALIGNS, p, "align", C)?; let arrow_align = match str_param(p, "arrowAlign") { None => None, Some("extend") => Some(ArrowAlign::Extend), @@ -58,50 +152,19 @@ fn stroke_set(s: &mut Session, p: &Value) -> Result { Some(id) => Some(Some(WidthProfile::preset(id).ok_or_else(|| bad(C, format!("unknown profile {id}")))?)), }; edit_items(s, &ids, item, C, "Stroke", false, |n, index| { + if index.is_none() + && let NodeKind::Text(t) = &mut n.kind + { + t.runs.iter_mut().for_each(|r| change.apply_char(&mut r.style)); + return Ok(()); + } if index.is_none() && n.appearance.stroke().is_none() { n.appearance.set_stroke(Paint::solid(Color::BLACK)); } let Some(st) = n.appearance.stroke_at_mut(index) else { return Ok(()) }; - if let Some(w) = p.get("weight").and_then(Value::as_f64) { - st.width = w.max(0.0); - } - match str_param(p, "cap") { - Some("round") => st.cap = LineCap::Round, - Some("square") | Some("projecting") => st.cap = LineCap::Square, - Some("butt") => st.cap = LineCap::Butt, - _ => {} - } - match str_param(p, "join") { - Some("round") => st.join = LineJoin::Round, - Some("bevel") => st.join = LineJoin::Bevel, - Some("miter") => st.join = LineJoin::Miter, - _ => {} - } - if let Some(m) = p.get("miterLimit").and_then(Value::as_f64) { - st.miter_limit = m.clamp(1.0, 500.0); - } - match str_param(p, "align") { - Some("inside") => st.align = StrokeAlign::Inside, - Some("outside") => st.align = StrokeAlign::Outside, - Some("center") => st.align = StrokeAlign::Center, - _ => {} - } - match p.get("dash") { - Some(Value::Null) => st.dash = None, - Some(Value::Array(a)) => { - let pattern: Vec = a.iter().filter_map(Value::as_f64).collect(); - // A new pattern keeps the offset and alignment of the one it replaces. - st.dash = (!pattern.is_empty()).then(|| Dash { pattern, ..st.dash.take().unwrap_or_default() }); - } - _ => {} - } - if let Some(d) = st.dash.as_mut() { - if let Some(o) = p.get("dashOffset").and_then(Value::as_f64) { - d.offset = o; - } - if let Some(a) = p.get("alignDashes").and_then(Value::as_bool) { - d.align_corners = a; - } + change.apply(st); + if let Some(a) = align { + st.align = a; } if let Some(a) = sa { st.start_arrow = a; @@ -120,6 +183,28 @@ fn stroke_set(s: &mut Session, p: &Value) -> Result { ok() } +impl Session { + /// Units > Stroke (Preferences): the unit stroke weights and dash lengths show in. + pub fn stroke_unit(&self) -> Unit { + super::edit::parse_unit(&self.prefs.units_stroke).unwrap_or_default() + } + + /// The stroke the Stroke panel, the Control bar and the Properties panel show, picked as the + /// Stroke proxy picks its paint: the Appearance panel's active item when it is a stroke, else + /// the first selected object's (a group's first painted object's) top stroke, type's first + /// run's character stroke. None without a selection or a stroke. + pub fn shown_stroke(&self) -> Option { + let st = self.active()?; + let first = st.doc.node(*st.selection.objects.first()?)?; + if let Some(i) = first.appearance.item_of_kind(self.appearance_item(), false) { + return first.appearance.stroke_at(Some(i)).cloned(); + } + let mut nodes = vec![]; + painted(first, true, &mut nodes); + node_stroke(nodes.first()?) + } +} + /// Mirror a width profile along the path (t → 1 − t) or across it (swap left/right widths). pub(crate) fn flip_profile(p: &WidthProfile, along: bool) -> WidthProfile { let mut pts: Vec<(f64, f64, f64)> = diff --git a/crates/engine/src/cmd/style.rs b/crates/engine/src/cmd/style.rs index c35b8a5c..09d0b482 100644 --- a/crates/engine/src/cmd/style.rs +++ b/crates/engine/src/cmd/style.rs @@ -192,7 +192,7 @@ fn captured(n: &Node) -> Appearance { match &n.kind { NodeKind::Text(t) => { if let Some(r) = t.runs.first() { - ap.items = Appearance::basic(r.style.fill.clone(), r.style.stroke.clone(), r.style.stroke_width).items; + ap.items = r.style.basic_appearance().items; // Type paints its characters in text space. ap = unitized(ap, Some(t.local_bounds())); } diff --git a/crates/engine/src/cmd/textedit.rs b/crates/engine/src/cmd/textedit.rs index 02ef3cff..82ef9a9b 100644 --- a/crates/engine/src/cmd/textedit.rs +++ b/crates/engine/src/cmd/textedit.rs @@ -7,6 +7,7 @@ use vectorcraft_doc::{Appearance, CharStyle, Node, NodeId, NodeKind, TextKind, T use vectorcraft_geom::Affine; use vectorcraft_text::edit; +use super::stroke::StrokeChange; use super::typecmd::refresh_bounds; use super::*; @@ -26,7 +27,7 @@ pub fn specs() -> Vec { "Character", [], None, - "{id, start?: byte, end?: byte (default: all text), font?, style?, size?: pt, leading?: pt|\"auto\", tracking?, kerning?: 1/1000 em|\"auto\", baselineShift?: pt, hScale?: %, vScale?: %, rotation?: deg, fill?: colour|\"none\", stroke?: colour|\"none\", strokeWidth?: pt, underline?, strikethrough?, allCaps?: bool, features?: [\"dlig\", \"-liga\", …]} style a character range (runs are split at the range ends) → {id, runs}", + "{id, start?: byte, end?: byte (default: all text), font?, style?, size?: pt, leading?: pt|\"auto\", tracking?, kerning?: 1/1000 em|\"auto\", baselineShift?: pt, hScale?: %, vScale?: %, rotation?: deg, fill?: colour|\"none\", stroke?: colour|\"none\", strokeWidth?: pt, strokeOptions?: {weight?, cap?, join?, miterLimit?, dash?, dashOffset?, alignDashes?} (as stroke.set: the character stroke), underline?, strikethrough?, allCaps?: bool, features?: [\"dlig\", \"-liga\", …]} style a character range (runs are split at the range ends) → {id, runs}", has_doc, set_range_style ), @@ -133,7 +134,8 @@ pub(crate) struct CharChange { rotation: Option, fill: Option, stroke: Option, - stroke_width: Option, + /// Weight (`strokeWidth`), cap, join, miter limit and dashes of the character stroke. + stroke_opts: StrokeChange, underline: Option, strikethrough: Option, all_caps: Option, @@ -170,6 +172,14 @@ impl CharChange { pub(crate) fn parse(p: &Value, cmd: &str) -> Result { let num = |k: &str| p.get(k).and_then(Value::as_f64); let flag = |k: &str| p.get(k).and_then(Value::as_bool); + let mut stroke_opts = match p.get("strokeOptions") { + None | Some(Value::Null) => StrokeChange::default(), + Some(o) if o.is_object() => StrokeChange::parse(o, cmd)?, + Some(_) => return Err(bad(cmd, "strokeOptions must be an object of stroke.set options")), + }; + if let Some(w) = num("strokeWidth") { + stroke_opts.weight = Some(w.clamp(0.0, 1000.0)); + } let c = Self { font: str_param(p, "font").map(str::to_string), style: str_param(p, "style").map(str::to_string), @@ -183,7 +193,7 @@ impl CharChange { rotation: num("rotation"), fill: paint_param(p, "fill", cmd)?, stroke: paint_param(p, "stroke", cmd)?, - stroke_width: num("strokeWidth"), + stroke_opts, underline: flag("underline"), strikethrough: flag("strikethrough"), all_caps: flag("allCaps"), @@ -208,7 +218,7 @@ impl CharChange { && self.rotation.is_none() && self.fill.is_none() && self.stroke.is_none() - && self.stroke_width.is_none() + && self.stroke_opts.is_empty() && self.underline.is_none() && self.strikethrough.is_none() && self.all_caps.is_none() @@ -259,8 +269,8 @@ impl CharChange { if let Some(v) = &self.stroke { st.stroke = v.clone(); } - if let Some(v) = self.stroke_width { - st.stroke_width = v.clamp(0.0, 1000.0); + if !self.stroke_opts.is_empty() { + self.stroke_opts.apply_char(st); } if let Some(v) = self.underline { st.underline = v; diff --git a/crates/engine/src/inspect.rs b/crates/engine/src/inspect.rs index bddfaa4f..34fcbf47 100644 --- a/crates/engine/src/inspect.rs +++ b/crates/engine/src/inspect.rs @@ -1,7 +1,7 @@ //! Agent-friendly document summaries and view-models for panels. use serde_json::{Value, json}; -use vectorcraft_doc::{Node, NodeKind}; +use vectorcraft_doc::{LineCap, LineJoin, Node, NodeKind, StrokeAlign, StrokeLayer}; use crate::Session; @@ -30,6 +30,7 @@ pub fn node_summary(n: &Node) -> Value { v["stroke"] = json!(n.appearance.stroke_paint().label()); v["strokeWidth"] = json!(n.appearance.stroke_width()); } + match &n.kind { NodeKind::Path { path, .. } => { v["anchors"] = json!(path.anchor_count()); @@ -68,3 +69,20 @@ pub fn document(s: &Session) -> Value { "paint": {"fill": s.paint.fill.label(), "stroke": s.paint.stroke.label(), "strokeWidth": s.paint.stroke_width, "fillActive": s.fill_active, "appearanceItem": s.appearance_item()}, }) } + +/// Cap names as `stroke.set` takes them. +pub const CAPS: [(LineCap, &str); 3] = [(LineCap::Butt, "butt"), (LineCap::Round, "round"), (LineCap::Square, "square")]; +/// Join names as `stroke.set` takes them. +pub const JOINS: [(LineJoin, &str); 3] = [(LineJoin::Miter, "miter"), (LineJoin::Round, "round"), (LineJoin::Bevel, "bevel")]; +/// Stroke alignment names as `stroke.set` takes them. +pub const ALIGNS: [(StrokeAlign, &str); 3] = [(StrokeAlign::Center, "center"), (StrokeAlign::Inside, "inside"), (StrokeAlign::Outside, "outside")]; + +/// The stroke an object's Stroke panel options describe: type's first run's character stroke, +/// else the topmost stroke. None for containers and objects without a stroke. +pub fn node_stroke(n: &Node) -> Option { + match &n.kind { + NodeKind::Text(t) => Some(t.runs.first().map_or_else(|| vectorcraft_doc::CharStyle::default().stroke_layer(), |r| r.style.stroke_layer())), + _ if n.is_container() => None, + _ => n.appearance.stroke().cloned(), + } +} diff --git a/crates/engine/src/lib.rs b/crates/engine/src/lib.rs index c51424d2..43a30faa 100644 --- a/crates/engine/src/lib.rs +++ b/crates/engine/src/lib.rs @@ -747,6 +747,8 @@ mod tests_brushsym; #[cfg(test)] mod tests_build; #[cfg(test)] +mod tests_charstroke; +#[cfg(test)] mod tests_clip; #[cfg(test)] mod tests_cmdsplit; diff --git a/crates/engine/src/tests_charstroke.rs b/crates/engine/src/tests_charstroke.rs new file mode 100644 index 00000000..41af4f1a --- /dev/null +++ b/crates/engine/src/tests_charstroke.rs @@ -0,0 +1,135 @@ +//! Stroke on type: the Stroke panel's options on the characters' stroke (every run, or a range +//! through `text.setRangeStyle`), and the character stroke kept by the Eyedropper, outlines and +//! the native format. + +use serde_json::{Value, json}; +use vectorcraft_color::{Color, Paint}; +use vectorcraft_doc::{CharStyle, Dash, LineCap, LineJoin, NodeId, NodeKind, TextObject}; + +use super::*; + +fn session() -> Session { + let mut s = Session::new(); + s.execute("file.new", &json!({"width": 400, "height": 300})).unwrap(); + s +} + +fn run(s: &mut Session, id: &str, p: Value) -> Value { + s.execute(id, &p).unwrap_or_else(|e| panic!("{id}: {e}")) +} + +/// Selected point type "Stroke" with a 1 pt red character stroke. +fn stroked_text(s: &mut Session) -> NodeId { + let id = NodeId(run(s, "text.create", json!({"x": 20, "y": 100, "text": "Stroke", "size": 48}))["id"].as_u64().unwrap()); + run(s, "select.set", json!({ "ids": [id.0] })); + run(s, "paint.setStroke", json!({"color": "#ff0000"})); + id +} + +fn text(s: &Session, id: NodeId) -> TextObject { + match &s.doc().unwrap().doc.node(id).unwrap().kind { + NodeKind::Text(t) => (**t).clone(), + _ => panic!("not type"), + } +} + +#[test] +fn stroke_options_on_type_style_every_run_and_add_no_object_stroke() { + let mut s = session(); + let id = stroked_text(&mut s); + run(&mut s, "text.setRangeStyle", json!({"id": id.0, "start": 0, "end": 2, "size": 60})); + assert_eq!(text(&s, id).runs.len(), 2); + let undo = s.doc().unwrap().history.undo.len(); + run(&mut s, "stroke.set", json!({"weight": 3, "join": "round", "cap": "round", "miterLimit": 4, "dash": [6, 2]})); + let t = text(&s, id); + for r in &t.runs { + let st = &r.style; + assert_eq!((st.stroke_width, st.stroke_join, st.stroke_cap, st.stroke_miter_limit), (3.0, LineJoin::Round, LineCap::Round, 4.0)); + assert_eq!(st.stroke_dash.as_ref().map(|d| d.pattern.clone()), Some(vec![6.0, 2.0])); + assert_eq!(st.stroke, Paint::solid(Color::rgb8(255, 0, 0)), "the stroke colour stays"); + } + assert!(s.doc().unwrap().doc.node(id).unwrap().appearance.items.is_empty(), "no object-level stroke is added"); + assert_eq!(s.doc().unwrap().history.undo.len(), undo + 1, "one undo step"); + // The shown stroke (Stroke panel, Control bar) is the first run's. + let shown = s.shown_stroke().unwrap(); + assert_eq!((shown.width, shown.join), (3.0, LineJoin::Round)); + // Dash options keep working on the character dash; null turns it solid. + run(&mut s, "stroke.set", json!({"alignDashes": true, "dashOffset": 1})); + assert_eq!(text(&s, id).runs[0].style.stroke_dash, Some(Dash { pattern: vec![6.0, 2.0], offset: 1.0, align_corners: true })); + run(&mut s, "stroke.set", json!({"dash": null})); + assert!(text(&s, id).runs.iter().all(|r| r.style.stroke_dash.is_none())); + assert!(s.execute("stroke.set", &json!({"join": "pointy"})).is_err(), "unknown joins are rejected"); +} + +#[test] +fn an_object_stroke_on_type_is_still_reached_through_its_item() { + let mut s = session(); + let id = stroked_text(&mut s); + run(&mut s, "appearance.addStroke", json!({})); + let item = s.doc().unwrap().doc.node(id).unwrap().appearance.items.len() - 1; + run(&mut s, "stroke.set", json!({"item": item, "weight": 5, "dash": [3, 3]})); + let n = s.doc().unwrap().doc.node(id).unwrap().clone(); + let st = n.appearance.stroke_at(Some(item)).unwrap(); + assert_eq!((st.width, st.dash.as_ref().map(|d| d.pattern.clone())), (5.0, Some(vec![3.0, 3.0]))); + assert_eq!(text(&s, id).runs[0].style.stroke_width, 1.0, "the characters keep their stroke"); +} + +#[test] +fn range_stroke_options_split_runs() { + let mut s = session(); + let id = stroked_text(&mut s); + run( + &mut s, + "text.setRangeStyle", + json!({"id": id.0, "start": 0, "end": 3, "strokeWidth": 2, "strokeOptions": {"join": "bevel", "dash": [1, 1]}}), + ); + let t = text(&s, id); + assert_eq!(t.runs.len(), 2); + assert_eq!((t.runs[0].style.stroke_width, t.runs[0].style.stroke_join), (2.0, LineJoin::Bevel)); + assert!(t.runs[0].style.stroke_dash.is_some()); + assert_eq!((t.runs[1].style.stroke_width, t.runs[1].style.stroke_join), (1.0, LineJoin::Miter)); + assert!(s.execute("text.setRangeStyle", &json!({"id": id.0, "strokeOptions": 3})).is_err()); +} + +#[test] +fn eyedropper_and_outlines_carry_the_character_stroke() { + let mut s = session(); + let id = stroked_text(&mut s); + run(&mut s, "stroke.set", json!({"weight": 2, "join": "round", "dash": [4, 2]})); + // Type → path: the path takes the characters' stroke options. + let rect = run(&mut s, "shape.rectangle", json!({"x": 10, "y": 150, "width": 50, "height": 50}))["id"].as_u64().unwrap(); + run(&mut s, "appearance.copyFrom", json!({"source": id.0, "ids": [rect]})); + let r = s.doc().unwrap().doc.node(NodeId(rect)).unwrap().appearance.stroke().unwrap().clone(); + assert_eq!((r.width, r.join, r.dash.map(|d| d.pattern)), (2.0, LineJoin::Round, Some(vec![4.0, 2.0]))); + // Path → type. + run(&mut s, "stroke.set", json!({"ids": [rect], "cap": "square", "dash": null})); + run(&mut s, "appearance.copyFrom", json!({"source": rect, "ids": [id.0]})); + let st = text(&s, id).runs[0].style.clone(); + assert_eq!((st.stroke_cap, st.stroke_join, st.stroke_dash), (LineCap::Square, LineJoin::Round, None)); + // Create Outlines keeps them on every glyph. + run(&mut s, "select.set", json!({ "ids": [id.0] })); + let g = run(&mut s, "type.createOutlines", json!({}))["ids"][0].as_u64().unwrap(); + let d = &s.doc().unwrap().doc; + let glyphs = d.node(NodeId(g)).unwrap().children().unwrap().to_vec(); + assert!(!glyphs.is_empty()); + assert!(glyphs.iter().all(|c| c.appearance.stroke().is_some_and(|st| st.cap == LineCap::Square && st.join == LineJoin::Round))); +} + +#[test] +fn character_stroke_options_round_trip_natively_and_old_files_load() { + let mut s = session(); + let id = stroked_text(&mut s); + run(&mut s, "stroke.set", json!({"cap": "round", "join": "bevel", "miterLimit": 3, "dash": [5, 1]})); + let d = &s.doc().unwrap().doc; + let back = vectorcraft_format::load(&vectorcraft_format::save(d, false)).unwrap(); + let NodeKind::Text(t) = &back.node(id).unwrap().kind else { panic!("not type") }; + assert_eq!(t.runs, text(&s, id).runs); + // A character style written before stroke options existed takes the defaults. + let mut old = serde_json::to_value(CharStyle::default()).unwrap(); + for k in ["stroke_cap", "stroke_join", "stroke_miter_limit", "stroke_dash"] { + old.as_object_mut().unwrap().remove(k); + } + let st: CharStyle = serde_json::from_value(old).unwrap(); + assert_eq!((st.stroke_cap, st.stroke_join, st.stroke_miter_limit, st.stroke_dash), (LineCap::Butt, LineJoin::Miter, 10.0, None)); + assert!(serde_json::to_value(CharStyle::default()).unwrap().get("stroke_miter_limit").is_none(), "defaults aren't written"); +} diff --git a/crates/pdf/src/export.rs b/crates/pdf/src/export.rs index c7aa3882..e456c8c7 100644 --- a/crates/pdf/src/export.rs +++ b/crates/pdf/src/export.rs @@ -13,7 +13,6 @@ use krilla::paint::{Fill, LinearGradient, RadialGradient, SpreadMethod, Stop, St use krilla::surface::Surface; use kurbo::{PathEl, Shape, Vec2}; use vectorcraft_color::{BlendMode, Color, GradientKind, Paint}; -use vectorcraft_doc::appearance::stroke_paint_bounds; use vectorcraft_doc::{AppearanceItem, Document, LineCap, LineJoin, Node, NodeKind, StrokeAlign, StrokeLayer, TextObject}; use vectorcraft_effects::stroke::{self, WrittenShape}; use vectorcraft_geom::{Affine, BezPath, FillRule, Rect}; @@ -494,7 +493,7 @@ impl Exporter<'_> { } self.shape(s, n, &bp, *rule, page); } - NodeKind::Text(t) => self.text(s, n, t), + NodeKind::Text(t) => self.text(s, n, t, page), NodeKind::Image(im) => self.image(s, im), NodeKind::SymbolInstance { symbol, xf } => { if let Some(sym) = self.doc.symbols.iter().find(|x| &x.name == symbol) { @@ -562,7 +561,7 @@ impl Exporter<'_> { continue; } self.warn_raster(&st.effects); - self.stroke(s, bp, &path, r, st, page); + self.stroke(s, bp, &path, r, st, page, bounds); } } } @@ -570,11 +569,13 @@ impl Exporter<'_> { s.set_stroke(None); } - fn stroke(&mut self, s: &mut Surface, bp: &BezPath, path: &Path, r: FillRule, st: &StrokeLayer, page: Rect) { + /// Paint stroke `st` of the shape `bp` (`path`), whose geometric bounds `bounds` place its + /// unplaced gradients. + #[allow(clippy::too_many_arguments)] + fn stroke(&mut self, s: &mut Surface, bp: &BezPath, path: &Path, r: FillRule, st: &StrokeLayer, page: Rect, bounds: Rect) { if !stroke::is_plain(st) && self.brush_art(s, bp, st, page) { return; } - let bounds = bp.bounding_box(); let Some(paint) = self.paint(&st.paint, st.paint_bounds(bounds)) else { return }; let w = stroke::for_writer(bp, st); let mut pushes = 0; @@ -675,7 +676,7 @@ impl Exporter<'_> { true } - fn text(&mut self, s: &mut Surface, n: &Node, t: &TextObject) { + fn text(&mut self, s: &mut Surface, n: &Node, t: &TextObject, page: Rect) { let layout = vectorcraft_text::layout(vectorcraft_text::FontDb::global(), t); let tb = t.xf.transform_rect_bbox(layout.bounds); // The object's own fills and strokes paint the whole outline: those below the Characters @@ -684,11 +685,11 @@ impl Exporter<'_> { let all = (!n.appearance.items.is_empty()).then(|| { let mut all = layout.to_bezpath(); all.apply_affine(t.xf); - to_path(&all) + to_path(&all).map(|p| (all, p)) }); let all = all.flatten(); - if let Some(path) = &all { - self.text_items(s, below, path, tb); + if let Some((bp, path)) = &all { + self.text_items(s, below, bp, path, page, tb); } s.push_transform(&xf(t.xf)); for (i, run) in t.runs.iter().enumerate() { @@ -702,24 +703,23 @@ impl Exporter<'_> { s.set_fill(Some(Fill { paint, opacity: NormalizedF32::ONE, rule: krilla::paint::FillRule::NonZero })); s.draw_path(&path); } - if run.style.stroke_width > 0.0 - && let Some(paint) = self.paint(&run.style.stroke, stroke_paint_bounds(layout.bounds, run.style.stroke_width)) - { - s.set_fill(None); - s.set_stroke(Some(Stroke { paint, width: run.style.stroke_width as f32, ..Default::default() })); - s.draw_path(&path); + if run.style.has_stroke() { + // Character strokes are drawn in text space, with their cap, join and dashes. + self.stroke(s, &bp, &path, FillRule::NonZero, &run.style.stroke_layer(), page, layout.bounds); } } s.set_fill(None); s.set_stroke(None); s.pop(); - if let Some(path) = &all { - self.text_items(s, above, path, tb); + if let Some((bp, path)) = &all { + self.text_items(s, above, bp, path, page, tb); } } - /// A type object's own fills and strokes `items` on its glyph outlines `path` (`tb`: their bounds). - fn text_items(&mut self, s: &mut Surface, items: &[AppearanceItem], path: &Path, tb: Rect) { + /// Some of a type object's own fills and strokes (`items`) on its glyph outlines `bp` (`path`; + /// `tb`: their bounds). Strokes take every stroke option, as on paths. + #[allow(clippy::too_many_arguments)] + fn text_items(&mut self, s: &mut Surface, items: &[AppearanceItem], bp: &BezPath, path: &Path, page: Rect, tb: Rect) { for item in items { match item { AppearanceItem::Fill(fl) if fl.visible => { @@ -729,13 +729,7 @@ impl Exporter<'_> { s.draw_path(path); } } - AppearanceItem::Stroke(st) if st.visible && st.width > 0.0 => { - if let Some(paint) = self.paint(&st.paint, st.paint_bounds(tb)) { - s.set_fill(None); - s.set_stroke(Some(Stroke { paint, width: st.width as f32, opacity: norm(st.opacity), ..Default::default() })); - s.draw_path(path); - } - } + AppearanceItem::Stroke(st) if st.visible && st.width > 0.0 => self.stroke(s, bp, path, FillRule::NonZero, st, page, tb), _ => {} } } diff --git a/crates/pdf/src/lib.rs b/crates/pdf/src/lib.rs index 1e6ec125..ef400b37 100644 --- a/crates/pdf/src/lib.rs +++ b/crates/pdf/src/lib.rs @@ -110,6 +110,8 @@ mod tests; #[cfg(test)] mod tests_blend; #[cfg(test)] +mod tests_charstroke; +#[cfg(test)] mod tests_dashalign; #[cfg(test)] mod tests_fx; diff --git a/crates/pdf/src/tests_charstroke.rs b/crates/pdf/src/tests_charstroke.rs new file mode 100644 index 00000000..fd51389d --- /dev/null +++ b/crates/pdf/src/tests_charstroke.rs @@ -0,0 +1,40 @@ +//! Strokes on type in PDF: the characters' and the object's strokes keep their join and dashes. + +use vectorcraft_color::{Color, Paint}; +use vectorcraft_doc::{AppearanceItem, CharStyle, Dash, Document, LineJoin, Node, NodeKind, StrokeLayer, TextObject}; +use vectorcraft_geom::Point; + +use crate::*; + +/// The strokes of a PDF of `d` read back. +fn strokes_back(d: &Document) -> Vec { + let back = import(&export(d, &PdfOptions::default()).unwrap()).unwrap(); + let mut out = vec![]; + back.walk(|n| out.extend(n.appearance.items.iter().filter_map(|i| if let AppearanceItem::Stroke(s) = i { Some(s.clone()) } else { None }))); + out +} + +#[test] +fn character_and_object_strokes_on_type_keep_join_and_dashes() { + let dash = Some(Dash { pattern: vec![3.0, 2.0], ..Default::default() }); + let style = CharStyle { + size: 40.0, + stroke: Paint::solid(Color::BLACK), + stroke_width: 2.0, + stroke_join: LineJoin::Round, + stroke_dash: dash.clone(), + ..Default::default() + }; + let mut d = Document::new(200.0, 100.0); + let mut n = Node::new(d.alloc_id(), NodeKind::Text(Box::new(TextObject::point(Point::new(10.0, 60.0), "Type", style)))); + let mut own = StrokeLayer::new(Paint::solid(Color::rgb8(255, 0, 0)), 1.0); + own.join = LineJoin::Bevel; + own.dash = Some(Dash { pattern: vec![5.0, 5.0], ..Default::default() }); + n.appearance.items.push(AppearanceItem::Stroke(own)); + let l = d.layers[0].id; + d.insert(Some(l), 0, n).unwrap(); + let strokes = strokes_back(&d); + let pattern = |s: &StrokeLayer| s.dash.as_ref().map(|d| d.pattern.iter().map(|v| (v * 100.0).round() / 100.0).collect::>()); + assert!(strokes.iter().any(|s| s.join == LineJoin::Round && pattern(s) == Some(vec![3.0, 2.0])), "character stroke: {strokes:?}"); + assert!(strokes.iter().any(|s| s.join == LineJoin::Bevel && pattern(s) == Some(vec![5.0, 5.0])), "object stroke: {strokes:?}"); +} diff --git a/crates/render/src/fx.rs b/crates/render/src/fx.rs index 6329da4d..d8a57237 100644 --- a/crates/render/src/fx.rs +++ b/crates/render/src/fx.rs @@ -652,7 +652,7 @@ impl Renderer { if st.brush.is_some() && self.draw_brush(ctx, f, n, ig, st) { return; } - self.draw_stroke(ctx, f, ig, rule, st, ib); + self.draw_stroke(ctx, f, Affine::IDENTITY, ig, rule, st, ib); } } } diff --git a/crates/render/src/lib.rs b/crates/render/src/lib.rs index 73c1b109..1fbb639d 100644 --- a/crates/render/src/lib.rs +++ b/crates/render/src/lib.rs @@ -17,7 +17,6 @@ pub mod proof; use std::collections::HashMap; use std::sync::Arc; -use vectorcraft_doc::appearance::stroke_paint_bounds; use vectorcraft_doc::{AppearanceItem, Document, Node, NodeId, NodeKind, StrokeAlign, StrokeLayer, TextObject}; use vectorcraft_geom::{Affine, BezPath, FillRule, Rect, Shape}; use vello_cpu::kurbo; @@ -792,7 +791,7 @@ impl Renderer { if st.brush.is_some() && self.draw_brush(ctx, f, n, bp, st) { continue; } - self.draw_stroke(ctx, f, bp, rule, st, bounds); + self.draw_stroke(ctx, f, Affine::IDENTITY, bp, rule, st, bounds); } } } @@ -808,7 +807,10 @@ impl Renderer { } } - fn draw_stroke(&mut self, ctx: &mut RenderContext, f: &Frame, bp: &BezPath, rule: FillRule, st: &StrokeLayer, bounds: Rect) { + /// Paint stroke `st` of the shape `bp` (in the space `xf` maps to the document; `bounds`: its + /// geometric bounds there) with its alignment, dashes, profile, arrowheads, opacity and blend. + #[allow(clippy::too_many_arguments)] + fn draw_stroke(&mut self, ctx: &mut RenderContext, f: &Frame, xf: Affine, bp: &BezPath, rule: FillRule, st: &StrokeLayer, bounds: Rect) { let pieces = effects::stroke::stroke_pieces(bp, st); // The line and its heads overlap: they take the opacity once, in one layer, instead of // each folding it into its paint. @@ -819,10 +821,10 @@ impl Renderer { ctx.set_transform(Affine::IDENTITY); ctx.push_layer(None, Some(blend_mode(st.blend)), Some(opacity), None, None); } - ctx.set_transform(f.view); + ctx.set_transform(f.view * xf); let closed = effects::stroke::is_closed(bp); // Keep hairlines visible when zoomed far out (at least ~1 device pixel). - let width = effects::stroke::aligned_width(st, closed).max(f.px * 0.5); + let width = effects::stroke::aligned_width(st, closed).max(f.px * 0.5 / xf.determinant().abs().sqrt().max(1e-9)); let inside = st.align == StrokeAlign::Inside && closed; if inside { ctx.set_fill_rule(fill_rule(rule)); @@ -929,7 +931,7 @@ impl Renderer { all }); if let Some(all) = &all { - self.draw_text_items(ctx, f, below, all, tb); + self.draw_text_items(ctx, f, n, below, all, tb); } for (i, run) in t.runs.iter().enumerate() { let Some(path) = g.runs.get(i) else { continue }; @@ -943,44 +945,45 @@ impl Renderer { overprint(ctx, run.style.overprint_fill); ctx.fill_path(path); } - if !run.style.stroke.is_none() - && run.style.stroke_width > 0.0 - && paint::set_paint(ctx, &run.style.stroke, stroke_paint_bounds(g.bounds, run.style.stroke_width), f.doc) - { + if run.style.has_stroke() { + // Character strokes are drawn in text space, with their cap, join and dashes. overprint(ctx, run.style.overprint_stroke); - ctx.set_stroke(kurbo::Stroke::new(run.style.stroke_width)); - ctx.stroke_path(path); + self.draw_stroke(ctx, f, t.xf, path, FillRule::NonZero, &run.style.stroke_layer(), g.bounds); } } overprint(ctx, false); if let Some(all) = &all { - self.draw_text_items(ctx, f, above, all, tb); + self.draw_text_items(ctx, f, n, above, all, tb); } } - /// Paint a type object's own fills and strokes `items` on its glyph outlines `all` (document - /// space; `tb` their bounds), each with its own opacity and blend mode. - fn draw_text_items(&mut self, ctx: &mut RenderContext, f: &Frame, items: &[AppearanceItem], all: &BezPath, tb: Rect) { + /// Paint some of type `n`'s own fills and strokes (`items`) on its glyph outlines `all` + /// (document space; `tb` their bounds). Fills composite with their own opacity and blend mode; + /// strokes take every stroke option, as on paths. + fn draw_text_items(&mut self, ctx: &mut RenderContext, f: &Frame, n: &Node, items: &[AppearanceItem], all: &BezPath, tb: Rect) { for item in items.iter().filter(|i| i.visible() && !i.paint().is_none()) { - let layered = item.opacity() < 1.0 || item.blend() != vectorcraft_color::BlendMode::Normal; - if layered { - ctx.set_transform(Affine::IDENTITY); - ctx.push_layer(None, Some(blend_mode(item.blend())), Some(item.opacity()), None, None); - } - ctx.set_transform(f.view); match item { - AppearanceItem::Fill(fl) if paint::set_paint(ctx, &fl.paint, tb, f.doc) => { - ctx.set_fill_rule(peniko::Fill::NonZero); - ctx.fill_path(all); + AppearanceItem::Fill(fl) => { + let layered = fl.opacity < 1.0 || fl.blend != vectorcraft_color::BlendMode::Normal; + if layered { + ctx.set_transform(Affine::IDENTITY); + ctx.push_layer(None, Some(blend_mode(fl.blend)), Some(fl.opacity), None, None); + } + ctx.set_transform(f.view); + if paint::set_paint(ctx, &fl.paint, tb, f.doc) { + ctx.set_fill_rule(peniko::Fill::NonZero); + ctx.fill_path(all); + } + if layered { + ctx.pop_layer(); + } } - AppearanceItem::Stroke(st) if st.width > 0.0 && paint::set_paint(ctx, &st.paint, st.paint_bounds(tb), f.doc) => { - ctx.set_stroke(kurbo::Stroke::new(st.width)); - ctx.stroke_path(all); + AppearanceItem::Stroke(st) if st.width > 0.0 => { + if st.brush.is_none() || !self.draw_brush(ctx, f, n, all, st) { + self.draw_stroke(ctx, f, Affine::IDENTITY, all, FillRule::NonZero, st, tb); + } } - _ => {} - } - if layered { - ctx.pop_layer(); + AppearanceItem::Stroke(_) => {} } } } @@ -1152,6 +1155,8 @@ mod tests; #[cfg(test)] mod tests_blend; #[cfg(test)] +mod tests_charstroke; +#[cfg(test)] mod tests_clip; #[cfg(test)] mod tests_container; diff --git a/crates/render/src/tests_charstroke.rs b/crates/render/src/tests_charstroke.rs new file mode 100644 index 00000000..ce44e8e9 --- /dev/null +++ b/crates/render/src/tests_charstroke.rs @@ -0,0 +1,83 @@ +//! Strokes on type: character strokes take their join, cap and dashes, and object strokes on type +//! take every stroke option, as path strokes do. + +use vectorcraft_color::{Color, Paint}; +use vectorcraft_doc::text::{CharStyle, TextObject}; +use vectorcraft_doc::{AppearanceItem, Dash, LineJoin, Node, NodeKind}; +use vectorcraft_geom::Point; + +use super::*; + +const SIZE: u32 = 200; + +/// 120 pt `text` with its baseline at (40, 150), on a white 200×200 page. +fn render_text(text: &str, style: CharStyle, items: Vec) -> Rendered { + let mut d = Document::new(SIZE as f64, SIZE as f64); + let mut n = Node::new(NodeId(0), NodeKind::Text(Box::new(TextObject::point(Point::new(40.0, 150.0), text, CharStyle { size: 120.0, ..style })))); + n.appearance.items = items; + n.id = d.alloc_id(); + let l = d.layers[0].id; + d.insert(Some(l), 0, n).unwrap(); + Renderer::new().render(&d, SIZE, SIZE, Affine::IDENTITY, &RenderOptions { background: Some([255, 255, 255, 255]), ..Default::default() }) +} + +fn dark(img: &Rendered, x: u32, y: u32) -> bool { + let p = img.pixel(x, y); + (p[0] as u32 + p[1] as u32 + p[2] as u32) < 384 +} + +/// The box of the dark pixels: (x0, y0, x1, y1). +fn ink(img: &Rendered) -> (u32, u32, u32, u32) { + let mut b = (u32::MAX, u32::MAX, 0, 0); + for y in 0..SIZE { + for x in 0..SIZE { + if dark(img, x, y) { + b = (b.0.min(x), b.1.min(y), b.2.max(x), b.3.max(y)); + } + } + } + b +} + +fn stroked(join: LineJoin) -> CharStyle { + CharStyle { stroke: Paint::solid(Color::BLACK), stroke_width: 16.0, stroke_join: join, ..Default::default() } +} + +#[test] +fn round_joined_character_strokes_have_no_miter_spikes() { + // The glyph alone, then stroked 16 pt: a round join reaches at most half the weight past it, + // the default miter join spikes out further at the sharp corners of the A. + let glyph = ink(&render_text("A", CharStyle::default(), vec![])); + let round = ink(&render_text("A", stroked(LineJoin::Round), vec![])); + let miter = ink(&render_text("A", stroked(LineJoin::Miter), vec![])); + let reach = |b: (u32, u32, u32, u32)| (glyph.0 - b.0).max(glyph.1 - b.1).max(b.2 - glyph.2).max(b.3 - glyph.3); + assert!(reach(round) <= 9, "round joins stay within half the weight: {glyph:?} → {round:?}"); + assert!(reach(miter) > reach(round) + 2, "miter joins spike: {miter:?} vs {round:?}"); +} + +#[test] +fn character_and_object_strokes_on_type_are_dashed() { + // An "I" without a fill: its outline drawn with 8/8 dashes breaks up along the left stem edge. + let none = CharStyle { fill: Paint::None, ..Default::default() }; + let dash = Some(Dash { pattern: vec![8.0, 8.0], ..Default::default() }); + let transitions = |img: &Rendered| { + let (x0, y0, _, y1) = ink(img); + let col = x0 + 1; + (y0..y1).filter(|y| dark(img, col, *y) != dark(img, col, y + 1)).count() + }; + let solid = CharStyle { stroke: Paint::solid(Color::BLACK), stroke_width: 3.0, ..none.clone() }; + assert!(transitions(&render_text("I", solid.clone(), vec![])) <= 2, "a solid stroke is continuous"); + let chars = CharStyle { stroke_dash: dash.clone(), ..solid }; + assert!(transitions(&render_text("I", chars, vec![])) >= 6, "character dashes"); + let mut st = vectorcraft_doc::StrokeLayer::new(Paint::solid(Color::BLACK), 3.0); + st.dash = dash; + let object = render_text("I", none, vec![AppearanceItem::Stroke(st.clone())]); + assert!(transitions(&object) >= 6, "object dashes"); + // And its opacity. + st.opacity = 0.5; + st.dash = None; + let half = render_text("I", CharStyle { fill: Paint::None, ..Default::default() }, vec![AppearanceItem::Stroke(st)]); + let (x0, y0, _, y1) = ink(&render_text("I", CharStyle::default(), vec![])); + let p = half.pixel(x0, (y0 + y1) / 2); + assert!(p[0] > 90 && p[0] < 200, "a half-opaque stroke is grey: {p:?}"); +} diff --git a/crates/svg/src/export.rs b/crates/svg/src/export.rs index 4832eb7f..fcce2df5 100644 --- a/crates/svg/src/export.rs +++ b/crates/svg/src/export.rs @@ -822,8 +822,7 @@ impl Writer<'_> { let d: Vec = paths.iter().map(|p| self.path_d(p, self.xf)).collect(); self.shape(n, &d.join(" "), &paths, *rule, n.geometric_bounds()); } - NodeKind::Text(t) if self.opts.outline_text => self.text_outlines(n, t), - NodeKind::Text(t) => self.text(n, t), + NodeKind::Text(t) => self.text_node(n, t), NodeKind::Image(im) => { let href = match self.doc.images.get(&im.key) { Some(b) if !b.bytes.is_empty() => format!("data:{};base64,{}", b.mime, base64_encode(&b.bytes)), @@ -965,13 +964,14 @@ impl Writer<'_> { p => self.paint(p, None), }; p.push(("fill", fill)); - if !st.stroke.is_none() && st.stroke_width > 0.0 { - let s = match &st.stroke { - Paint::Gradient(g) => g.gradient.stops.first().map(|s| s.color.to_hex()).unwrap_or_else(|| "none".into()), - p => self.paint(p, None), - }; - p.push(("stroke", s)); - p.push(("stroke-width", self.num(st.stroke_width))); + if st.has_stroke() { + // Weight, cap, join, miter limit and dashes as for object strokes (a gradient: its + // first colour, as for the fill). + let mut layer = st.stroke_layer(); + if let Paint::Gradient(g) = &layer.paint { + layer.paint = g.gradient.stops.first().map_or(Paint::None, |s| Paint::solid(s.color)); + } + self.stroke_props(&layer, layer.width, None, &mut p); } if st.tracking != 0.0 { p.push(("letter-spacing", self.num(st.tracking / 1000.0 * st.size))); @@ -996,10 +996,43 @@ impl Writer<'_> { p } + /// Type: its characters (live text, or glyph outlines when text is exported as outlines) and, + /// as the canvas paints them, the object's own fills and strokes on the glyph outlines: those + /// below the Characters row under the characters, the others over them. + fn text_node(&mut self, n: &Node, t: &TextObject) { + let chars = |w: &mut Self, n: &Node| if w.opts.outline_text { w.text_outlines(n, t) } else { w.text(n, t) }; + if !n.appearance.items.iter().any(|i| i.visible() && !i.paint().is_none()) { + return chars(self, n); + } + let id = self.id_attr(n); + let a = self.attrs(&Self::node_props(n)); + self.line(&format!("")); + self.depth += 1; + let lay = vectorcraft_text::layout(vectorcraft_text::FontDb::global(), t); + let pd = PathData::from_bezpath(&lay.to_bezpath()).transformed(t.xf); + let d = self.path_d(&pd, self.xf); + let tb = Some(t.xf.transform_rect_bbox(lay.bounds)); + let (below, above) = n.appearance.split_contents(); + let glyphs = |w: &mut Self, items: &[AppearanceItem]| { + if !items.is_empty() { + let ap = vectorcraft_doc::Appearance { items: items.to_vec(), ..Default::default() }; + w.shape(&Node::path(NodeId(u64::MAX), pd.clone(), ap), &d, &[&pd], FillRule::NonZero, tb); + } + }; + glyphs(self, below); + // The group carries the id and the object's transparency. + let bare = Node { opacity: 1.0, blend: BlendMode::Normal, isolate: false, ..n.clone() }; + let anonymous = std::mem::replace(&mut self.anonymous, true); + chars(self, &bare); + glyphs(self, above); + self.anonymous = anonymous; + self.depth -= 1; + self.line(""); + } + /// Text as glyph outlines: one compound path per run, painted like the run. fn text_outlines(&mut self, n: &Node, t: &TextObject) { let lay = vectorcraft_text::layout(vectorcraft_text::FontDb::global(), t); - let m = self.xf * t.xf; let mut runs: Vec<(usize, kurbo::BezPath)> = vec![]; for g in &lay.glyphs { match runs.last_mut() { @@ -1013,16 +1046,12 @@ impl Writer<'_> { self.depth += 1; for (r, mut bp) in runs { let Some(run) = t.runs.get(r) else { continue }; - bp.apply_affine(m); + bp.apply_affine(t.xf); let pd = PathData::from_bezpath(&bp); - let st = &run.style; // An unnamed id: the pieces carry no id attribute (the group has it). - let glyphs = - Node::path(NodeId(u64::MAX), pd.clone(), vectorcraft_doc::Appearance::basic(st.fill.clone(), st.stroke.clone(), st.stroke_width)); - let d = self.path_d(&pd, Affine::IDENTITY); - let bounds = pd.bounds(); - // Run strokes are plain: they need no document-space geometry. - self.shape(&glyphs, &d, &[], FillRule::NonZero, bounds); + let glyphs = Node::path(NodeId(u64::MAX), pd.clone(), run.style.appearance()); + let d = self.path_d(&pd, self.xf); + self.shape(&glyphs, &d, &[&pd], FillRule::NonZero, pd.bounds()); } self.depth -= 1; self.line(""); @@ -1068,7 +1097,7 @@ impl Writer<'_> { let last = l.pieces.len() - 1; for (k, (ri, text)) in l.pieces.into_iter().enumerate() { let pos = if k == 0 { format!(" x=\"{}\" y=\"{}\"", self.num(l.x), self.num(l.y)) } else { String::new() }; - let diff: Props = self.char_props(&t.runs[ri].style).into_iter().filter(|kv| !base.contains(kv)).collect(); + let diff = run_diff(&base, self.char_props(&t.runs[ri].style)); let a = self.attrs(&diff); let hy = if k == last && l.hyphenated { "-" } else { "" }; s.push_str(&format!("{}{hy}", xml_escape(text))); @@ -1078,8 +1107,7 @@ impl Writer<'_> { let mut line = 0usize; let mut pending = false; for run in &t.runs { - let rp = self.char_props(&run.style); - let diff: Props = rp.into_iter().filter(|kv| !base.contains(kv)).collect(); + let diff = run_diff(&base, self.char_props(&run.style)); for (i, piece) in run.text.split('\n').enumerate() { if i > 0 { line += 1; @@ -1147,6 +1175,29 @@ impl Writer<'_> { } } +/// What the `` of a run whose properties are `run` sets over its ``'s `base`: the +/// properties that differ, and the initial value of each one `base` sets and the run doesn't. +fn run_diff(base: &Props, run: Props) -> Props { + let reset: Props = + base.iter().filter(|(k, _)| !run.iter().any(|(r, _)| r == k)).filter_map(|(k, _)| initial_value(k).map(|v| (*k, v.to_string()))).collect(); + run.into_iter().filter(|kv| !base.contains(kv)).chain(reset).collect() +} + +/// The initial value of an inherited property a run may leave unset (decorations can't be undone +/// on a ``). +fn initial_value(k: &str) -> Option<&'static str> { + Some(match k { + "font-weight" | "font-style" | "font-feature-settings" => "normal", + "stroke" | "stroke-dasharray" => "none", + "stroke-width" | "stroke-opacity" => "1", + "stroke-linecap" => "butt", + "stroke-linejoin" => "miter", + "stroke-miterlimit" => "4", + "stroke-dashoffset" | "letter-spacing" => "0", + _ => return None, + }) +} + /// Any visible raster effect (shadow, glow, blur, feather) in `effects`? fn has_raster(effects: &[vectorcraft_doc::Effect]) -> bool { effects.iter().any(|e| e.visible && vectorcraft_effects::is_raster(&e.id)) diff --git a/crates/svg/src/import.rs b/crates/svg/src/import.rs index 4a6a300e..92e0d2f3 100644 --- a/crates/svg/src/import.rs +++ b/crates/svg/src/import.rs @@ -539,6 +539,21 @@ fn char_style(ctx: &TextCtx, n: XNode) -> CharStyle { if let Some(s) = ctx.prop(n, "stroke") { st.stroke = parse_color_paint(&s); st.stroke_width = ctx.prop(n, "stroke-width").map(|w| first_number(Some(&w))).unwrap_or(1.0); + st.stroke_cap = match ctx.prop(n, "stroke-linecap").as_deref() { + Some("round") => LineCap::Round, + Some("square") => LineCap::Square, + _ => LineCap::Butt, + }; + st.stroke_join = match ctx.prop(n, "stroke-linejoin").as_deref() { + Some("round") => LineJoin::Round, + Some("bevel") => LineJoin::Bevel, + _ => LineJoin::Miter, + }; + st.stroke_miter_limit = ctx.prop(n, "stroke-miterlimit").map_or(4.0, |m| first_number(Some(&m)).max(1.0)); + let pattern: Vec = + ctx.prop(n, "stroke-dasharray").map_or(vec![], |d| d.split([' ', ',']).filter_map(|v| v.trim().parse().ok()).collect()); + let offset = ctx.prop(n, "stroke-dashoffset").map_or(0.0, |o| first_number(Some(&o))); + st.stroke_dash = Some(Dash { pattern, offset, align_corners: false }).filter(Dash::is_dashed); } if let Some(ls) = ctx.prop(n, "letter-spacing") { let v = first_number(Some(&ls)); diff --git a/crates/svg/tests/text_stroke.rs b/crates/svg/tests/text_stroke.rs new file mode 100644 index 00000000..81293f8e --- /dev/null +++ b/crates/svg/tests/text_stroke.rs @@ -0,0 +1,78 @@ +//! Strokes on type in SVG: character stroke options on live text (and back), runs that differ +//! from the first, and the object's own strokes drawn over the glyph outlines. + +use vectorcraft_color::{Color, Paint}; +use vectorcraft_doc::{AppearanceItem, CharStyle, Dash, Document, LineCap, LineJoin, Node, NodeKind, StrokeLayer, TextObject, TextRun}; +use vectorcraft_geom::Point; +use vectorcraft_svg::{ExportOptions, export, import}; + +fn text_doc(t: TextObject, items: Vec) -> Document { + let mut d = Document::new(300.0, 200.0); + let mut n = Node::new(d.alloc_id(), NodeKind::Text(Box::new(t))); + n.appearance.items = items; + let l = d.layers[0].id; + d.insert(Some(l), 0, n).unwrap(); + d +} + +fn stroked() -> CharStyle { + CharStyle { + size: 40.0, + stroke: Paint::solid(Color::BLACK), + stroke_width: 2.0, + stroke_cap: LineCap::Round, + stroke_join: LineJoin::Round, + stroke_dash: Some(Dash { pattern: vec![3.0, 1.0], ..Default::default() }), + ..Default::default() + } +} + +#[test] +fn an_object_stroke_on_type_is_written_with_its_dashes() { + let mut st = StrokeLayer::new(Paint::solid(Color::rgb8(255, 0, 0)), 3.0); + st.dash = Some(Dash { pattern: vec![4.0, 2.0], ..Default::default() }); + st.opacity = 0.5; + let t = TextObject::point(Point::new(20.0, 100.0), "Dashed", CharStyle { size: 40.0, ..Default::default() }); + for outline_text in [false, true] { + let svg = export(&text_doc(t.clone(), vec![AppearanceItem::Stroke(st.clone())]), &ExportOptions { outline_text, ..Default::default() }); + assert!(svg.contains("stroke-dasharray=\"4 2\""), "{svg}"); + assert!(svg.contains("stroke-opacity=\"0.5\""), "{svg}"); + assert_eq!(svg.contains(" (bool, } } -/// The stroke the Stroke panel, Control bar and Properties show: the Appearance panel's active -/// item when it is a stroke, else the first selected object's top stroke. +/// The stroke the Stroke panel, Control bar and Properties show: while the Type tool edits text, +/// the selected characters' stroke, else [`vectorcraft_engine::Session::shown_stroke`]. pub(crate) fn current_stroke(app: &VectorcraftApp) -> Option { - first_node(app)?.appearance.stroke_for(app.session.appearance_item()).cloned() + if character::text_editing(app).is_some() { + return character::text_style(app).map(|(c, _)| c.stroke_layer()); + } + app.session.shown_stroke() } /// Opacity and blend mode as the Transparency panel and Control bar show them: the Appearance diff --git a/crates/ui-egui/src/panels/stroke.rs b/crates/ui-egui/src/panels/stroke.rs index 4b6de5e3..5e6aa68c 100644 --- a/crates/ui-egui/src/panels/stroke.rs +++ b/crates/ui-egui/src/panels/stroke.rs @@ -5,7 +5,7 @@ use egui::{Color32, Pos2, Rect, Sense, Stroke, StrokeKind, Ui, pos2, vec2}; use serde_json::{Value, json}; use vectorcraft_doc::{ArrowAlign, Arrowhead, Dash, LineCap, LineJoin, ProfilePreset, StrokeAlign, StrokeLayer, Unit, WidthProfile}; -use super::{current_stroke, pstate, set_pstate}; +use super::{character, current_stroke, pstate, set_pstate}; use crate::theme::Tokens; use crate::widgets::{self, menu_item}; use crate::{VectorcraftApp, icons}; @@ -73,8 +73,14 @@ fn arrow_label(a: Option) -> String { } } +/// Apply Stroke panel options (`stroke.set` params): while the Type tool edits text, to the +/// selected characters' stroke. fn set(app: &mut VectorcraftApp, p: Value) { - app.run("stroke.set", p).ok(); + if character::text_editing(app).is_some() { + character::range_style(app, json!({ "strokeOptions": p })); + } else { + app.run("stroke.set", p).ok(); + } } pub fn show(app: &mut VectorcraftApp, ui: &mut Ui) { @@ -450,4 +456,28 @@ mod tests { assert_eq!((st.end_arrow, st.arrow_align), (Some(Arrowhead::ArrowOpen), ArrowAlign::Tip)); assert_eq!(WidthProfile::id_of(st.profile.as_ref()), "lens"); } + + #[test] + fn while_typing_the_panel_strokes_the_selected_characters() { + use vectorcraft_doc::NodeKind; + use vectorcraft_engine::{Session, ViewInfo}; + use vectorcraft_tools::{PointerEvent, PointerKind}; + let mut app = VectorcraftApp::new(Session::new(), Default::default()); + app.session.execute("file.new", &json!({"width": 400, "height": 300})).unwrap(); + let v = ViewInfo::default(); + app.session.select_tool("type", v).unwrap(); + for k in [PointerKind::Down, PointerKind::Up] { + app.session.pointer(&PointerEvent::new(k, 50.0, 200.0), v).unwrap(); + } + app.session.tool_text("Make this bold", v).unwrap(); + app.session.set_tool_option("select", &json!({"start": 5, "end": 9})); + set(&mut app, json!({"weight": 4, "join": "round"})); + let id = app.session.active().unwrap().selection.objects[0]; + let NodeKind::Text(t) = &app.session.active().unwrap().doc.node(id).unwrap().kind else { panic!("type") }; + let joins: Vec<(&str, LineJoin, f64)> = t.runs.iter().map(|r| (r.text.as_str(), r.style.stroke_join, r.style.stroke_width)).collect(); + assert_eq!(joins, [("Make ", LineJoin::Miter, 0.0), ("this", LineJoin::Round, 4.0), (" bold", LineJoin::Miter, 0.0)]); + // The panel shows the selected characters' stroke. + assert_eq!(current_stroke(&app).map(|s| (s.join, s.width)), Some((LineJoin::Round, 4.0))); + frame(&mut app); + } } diff --git a/docs/mcp.md b/docs/mcp.md index f92cb9fc..c40eb95c 100644 --- a/docs/mcp.md +++ b/docs/mcp.md @@ -324,3 +324,12 @@ as a library (`.vcswatches` keeps colour models, global, spot, gradients and col CSS writes custom properties); without `path` it returns `{data}`, and `user: true` saves into the user library folder of the desktop app (listed as category `user`, User Defined). `swatch.library.load {path? | data? | dataBase64?, name?}` loads a `.vcswatches` or `.gpl` file, or another document's swatches, as a library to add from. + +## Strokes on type + +`stroke.set` without an `item` gives type its characters' stroke: weight, cap, join, miter limit and the dash +options go to every run (the object keeps no stroke of its own); an `item` still edits an object-level stroke added +with `appearance.addStroke`, which takes every stroke option (dashes, profile, opacity, blend) like a path's and paints under or over the +characters as its place relative to the Characters row says. To stroke +some characters, use `text.setRangeStyle {id, start, end, strokeOptions: {weight?, cap?, join?, miterLimit?, dash?, +dashOffset?, alignDashes?}}`. From 64d366e2aea733a2d5f281cda206b2c80ee42e70 Mon Sep 17 00:00:00 2001 From: bflatastic Date: Mon, 5 Oct 2026 00:41:47 +0700 Subject: [PATCH 2/2] M3.41: Stroke panel follows Units > Stroke, shows mixed values blank and reaches the Control bar - Weights and dash lengths show in Units > Stroke in the Stroke panel, the Control bar and the Properties panel (Session::stroke_unit). - Values the selected objects don't share show blank (weight, miter limit, dashes) or light no button (cap, join, align), from a per-revision cached DocState::stroke_mixed. - Align Stroke Inside/Outside are disabled for open paths and type. - The Control bar weight is a preset spinner with a width profile dropdown, and its Stroke link (and the Properties panel's) opens the Stroke panel as an anchored popover instead of docking it. - The panels show a group's first painted object's stroke, as the Stroke proxy does (Session::shown_stroke). - stroke.set on a group skips images and symbol instances. - document.inspect reports each object's strokeOptions (cap, join, miterLimit, align, dash, dashOffset, alignDashes, arrows, arrowAlign, profile) in stroke.set terms. Co-Authored-By: Claude Opus 5.5 --- crates/engine/src/cmd/stroke.rs | 66 ++++++++++-- crates/engine/src/inspect.rs | 44 +++++++- crates/engine/src/lib.rs | 2 + crates/engine/src/tests_strokeux.rs | 110 ++++++++++++++++++++ crates/ui-egui/src/chrome.rs | 24 +++-- crates/ui-egui/src/panels/mod.rs | 19 ++++ crates/ui-egui/src/panels/properties.rs | 12 +-- crates/ui-egui/src/panels/stroke.rs | 63 +++++++---- crates/ui-egui/src/panels/tests_strokeux.rs | 88 ++++++++++++++++ crates/ui-egui/src/widgets.rs | 13 +-- docs/mcp.md | 3 +- 11 files changed, 390 insertions(+), 54 deletions(-) create mode 100644 crates/engine/src/tests_strokeux.rs create mode 100644 crates/ui-egui/src/panels/tests_strokeux.rs diff --git a/crates/engine/src/cmd/stroke.rs b/crates/engine/src/cmd/stroke.rs index c1ec48d1..b926fd4d 100644 --- a/crates/engine/src/cmd/stroke.rs +++ b/crates/engine/src/cmd/stroke.rs @@ -3,12 +3,13 @@ use serde_json::Value; use vectorcraft_color::{Color, Paint}; -use vectorcraft_doc::{ArrowAlign, Arrowhead, CharStyle, Dash, LineCap, LineJoin, NodeKind, StrokeLayer, Unit, WidthProfile}; +use vectorcraft_doc::{ArrowAlign, Arrowhead, CharStyle, Dash, LineCap, LineJoin, Node, NodeKind, StrokeLayer, Unit, WidthProfile}; -use super::appearance::{edit_items, item_target}; +use super::appearance::{ItemTarget, edit_items, item_target}; use super::paint::painted; use super::*; -use crate::inspect::{ALIGNS, CAPS, JOINS, node_stroke}; +use crate::DocState; +use crate::inspect::{ALIGNS, CAPS, JOINS, StrokeMixed, node_stroke}; pub fn specs() -> Vec { vec![ @@ -17,7 +18,7 @@ pub fn specs() -> Vec { "Stroke Options", ["Window", "Stroke"], None, - "{weight?: pt, cap?: butt|round|square, join?: miter|round|bevel, miterLimit?, align?: center|inside|outside, dash?: [d,g,…]|null (a 0 dash with a round or projecting cap draws dots or squares; a new pattern keeps the current offset and alignment), dashOffset? (exact dashes only), alignDashes?: bool (true: dashes fitted to corners and path ends, every run between them holding whole periods with a dash centred on each corner and end; false, the default for a new pattern: exact lengths), startArrow?, endArrow?: Arrow|ArrowOpen|Triangle|TriangleOpen|Circle|CircleOpen|Square|SquareOpen|Diamond|Bar|null, arrowAlign?: \"extend\" (tip past the end point, default)|\"tip\" (tip on the end point; the stroke is shortened), profile?: \"uniform\"|\"lens\"|\"taperStart\"|\"taperEnd\", item?: stroke item index|null (omitted: the Appearance panel's active item if it is a stroke, else the top stroke, created when missing), ids?} Without a stroke item, type takes weight, cap, join, miterLimit and the dash options as its characters' stroke (every run; text.setRangeStyle `strokeOptions` styles a range)", + "{weight?: pt, cap?: butt|round|square, join?: miter|round|bevel, miterLimit?, align?: center|inside|outside, dash?: [d,g,…]|null (a 0 dash with a round or projecting cap draws dots or squares; a new pattern keeps the current offset and alignment), dashOffset? (exact dashes only), alignDashes?: bool (true: dashes fitted to corners and path ends, every run between them holding whole periods with a dash centred on each corner and end; false, the default for a new pattern: exact lengths), startArrow?, endArrow?: Arrow|ArrowOpen|Triangle|TriangleOpen|Circle|CircleOpen|Square|SquareOpen|Diamond|Bar|null, arrowAlign?: \"extend\" (tip past the end point, default)|\"tip\" (tip on the end point; the stroke is shortened), profile?: \"uniform\"|\"lens\"|\"taperStart\"|\"taperEnd\", item?: stroke item index|null (omitted: the Appearance panel's active item if it is a stroke, else the top stroke, created when missing), ids?} Without a stroke item, type takes weight, cap, join, miterLimit and the dash options as its characters' stroke (every run; text.setRangeStyle `strokeOptions` styles a range), and images and symbol instances (also in groups) are left alone", has_doc, stroke_set ), @@ -35,7 +36,7 @@ pub fn specs() -> Vec { /// The stroke attributes object and character strokes share, parsed from `stroke.set` params /// (also the `strokeOptions` of `text.setRangeStyle`). -#[derive(Clone, Debug, Default)] +#[derive(Debug, Default)] pub(crate) struct StrokeChange { pub(crate) weight: Option, cap: Option, @@ -129,7 +130,12 @@ fn stroke_set(s: &mut Session, p: &Value) -> Result { s.paint.stroke_width = w; } let item = item_target(s, p, C)?.of_kind(s, false); - let ids = item.targets(s, p)?; + let mut ids = item.targets(s, p)?; + if item == ItemTarget::Top { + // Images and symbol instances take no stroke from the panel. + let d = &s.doc()?.doc; + ids.retain(|id| !matches!(d.node(*id).map(|n| &n.kind), Some(NodeKind::Image(_) | NodeKind::SymbolInstance { .. }))); + } let arrow = |k: &str| -> Result>> { match p.get(k) { None => Ok(None), @@ -205,6 +211,54 @@ impl Session { } } +impl DocState { + /// Which Stroke panel values the selected objects' strokes (type: every run's) don't share, + /// and whether Align Stroke applies to them. One walk of the selection, so callers that ask + /// every frame cache it by revision. + pub fn stroke_mixed(&self) -> StrokeMixed { + let mut nodes = vec![]; + for id in &self.selection.objects { + if let Some(n) = self.doc.node(*id) { + painted(n, true, &mut nodes); + } + } + let mut strokes = vec![]; + let mut can_align = true; + for n in &nodes { + match &n.kind { + NodeKind::Text(t) => { + can_align = false; + strokes.extend(t.runs.iter().map(|r| r.style.stroke_layer())); + } + _ => { + can_align &= encloses(n); + strokes.extend(n.appearance.stroke().cloned()); + } + } + } + let Some((first, rest)) = strokes.split_first() else { return StrokeMixed { can_align, ..Default::default() } }; + let differs = |same: fn(&StrokeLayer, &StrokeLayer) -> bool| rest.iter().any(|s| !same(first, s)); + StrokeMixed { + weight: differs(|a, b| a.width == b.width), + cap: differs(|a, b| a.cap == b.cap), + join: differs(|a, b| a.join == b.join), + miter_limit: differs(|a, b| a.miter_limit == b.miter_limit), + align: differs(|a, b| a.align == b.align), + dash: differs(|a, b| a.dash == b.dash), + can_align, + } + } +} + +/// Does `n` enclose an area a stroke can be aligned inside or outside of (no open path)? +fn encloses(n: &Node) -> bool { + match &n.kind { + NodeKind::Path { path, .. } => path.is_closed(), + NodeKind::Compound { children, .. } => children.iter().all(|c| encloses(c)), + _ => true, + } +} + /// Mirror a width profile along the path (t → 1 − t) or across it (swap left/right widths). pub(crate) fn flip_profile(p: &WidthProfile, along: bool) -> WidthProfile { let mut pts: Vec<(f64, f64, f64)> = diff --git a/crates/engine/src/inspect.rs b/crates/engine/src/inspect.rs index 34fcbf47..90c43273 100644 --- a/crates/engine/src/inspect.rs +++ b/crates/engine/src/inspect.rs @@ -1,7 +1,7 @@ //! Agent-friendly document summaries and view-models for panels. use serde_json::{Value, json}; -use vectorcraft_doc::{LineCap, LineJoin, Node, NodeKind, StrokeAlign, StrokeLayer}; +use vectorcraft_doc::{ArrowAlign, LineCap, LineJoin, Node, NodeKind, StrokeAlign, StrokeLayer, WidthProfile}; use crate::Session; @@ -30,7 +30,9 @@ pub fn node_summary(n: &Node) -> Value { v["stroke"] = json!(n.appearance.stroke_paint().label()); v["strokeWidth"] = json!(n.appearance.stroke_width()); } - + if let Some(st) = node_stroke(n) { + v["strokeOptions"] = stroke_options(&st); + } match &n.kind { NodeKind::Path { path, .. } => { v["anchors"] = json!(path.anchor_count()); @@ -77,12 +79,46 @@ pub const JOINS: [(LineJoin, &str); 3] = [(LineJoin::Miter, "miter"), (LineJoin: /// Stroke alignment names as `stroke.set` takes them. pub const ALIGNS: [(StrokeAlign, &str); 3] = [(StrokeAlign::Center, "center"), (StrokeAlign::Inside, "inside"), (StrokeAlign::Outside, "outside")]; +fn name_of(table: &[(T, &'static str)], v: T) -> &'static str { + table.iter().find(|(k, _)| *k == v).map_or("", |(_, n)| n) +} + /// The stroke an object's Stroke panel options describe: type's first run's character stroke, -/// else the topmost stroke. None for containers and objects without a stroke. +/// else the topmost stroke of its own (groups and layers too). None without a stroke. pub fn node_stroke(n: &Node) -> Option { match &n.kind { NodeKind::Text(t) => Some(t.runs.first().map_or_else(|| vectorcraft_doc::CharStyle::default().stroke_layer(), |r| r.style.stroke_layer())), - _ if n.is_container() => None, _ => n.appearance.stroke().cloned(), } } + +/// A stroke's options in `stroke.set` terms: cap, join, miterLimit, align, dash (null when +/// solid), dashOffset, alignDashes, startArrow/endArrow (null for none), arrowAlign and profile. +pub fn stroke_options(st: &StrokeLayer) -> Value { + json!({ + "cap": name_of(&CAPS, st.cap), + "join": name_of(&JOINS, st.join), + "miterLimit": st.miter_limit, + "align": name_of(&ALIGNS, st.align), + "dash": st.dash.as_ref().map(|d| &d.pattern), + "dashOffset": st.dash.as_ref().map_or(0.0, |d| d.offset), + "alignDashes": st.dash.as_ref().is_some_and(|d| d.align_corners), + "startArrow": st.start_arrow, + "endArrow": st.end_arrow, + "arrowAlign": if st.arrow_align == ArrowAlign::Tip { "tip" } else { "extend" }, + "profile": WidthProfile::id_of(st.profile.as_ref()), + }) +} + +/// Which Stroke panel values the selected objects don't share (their fields show blank), and +/// whether Align Stroke applies to them (no open path or type among them). +#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)] +pub struct StrokeMixed { + pub weight: bool, + pub cap: bool, + pub join: bool, + pub miter_limit: bool, + pub align: bool, + pub dash: bool, + pub can_align: bool, +} diff --git a/crates/engine/src/lib.rs b/crates/engine/src/lib.rs index 43a30faa..0e9fa29f 100644 --- a/crates/engine/src/lib.rs +++ b/crates/engine/src/lib.rs @@ -801,6 +801,8 @@ mod tests_prefs; #[cfg(test)] mod tests_strokegeom; #[cfg(test)] +mod tests_strokeux; +#[cfg(test)] mod tests_styles; #[cfg(test)] mod tests_swatchcmds; diff --git a/crates/engine/src/tests_strokeux.rs b/crates/engine/src/tests_strokeux.rs new file mode 100644 index 00000000..16f81617 --- /dev/null +++ b/crates/engine/src/tests_strokeux.rs @@ -0,0 +1,110 @@ +//! Stroke panel reach: `stroke.set` on groups, what the panel shows for a selection (mixed values, +//! Align Stroke), Units > Stroke, and the stroke options `document.inspect` reports. + +use serde_json::{Value, json}; +use vectorcraft_doc::{NodeId, NodeKind, Unit}; + +use super::*; + +fn session() -> Session { + let mut s = Session::new(); + s.execute("file.new", &json!({"width": 400, "height": 300})).unwrap(); + s +} + +fn run(s: &mut Session, id: &str, p: Value) -> Value { + s.execute(id, &p).unwrap_or_else(|e| panic!("{id}: {e}")) +} + +fn rect(s: &mut Session, x: f64) -> NodeId { + NodeId(run(s, "shape.rectangle", json!({"x": x, "y": 10, "width": 40, "height": 40}))["id"].as_u64().unwrap()) +} + +fn select(s: &mut Session, ids: &[NodeId]) { + run(s, "select.set", json!({ "ids": ids.iter().map(|i| i.0).collect::>() })); +} + +#[test] +fn stroke_set_on_a_group_skips_images_and_symbol_instances() { + let mut s = session(); + let (a, b, c) = (rect(&mut s, 10.0), rect(&mut s, 60.0), rect(&mut s, 110.0)); + select(&mut s, &[b]); + let image = NodeId(run(&mut s, "object.rasterize", json!({}))["id"].as_u64().unwrap()); + select(&mut s, &[c]); + let instance = NodeId(run(&mut s, "symbol.new", json!({"name": "Box"}))["id"].as_u64().unwrap()); + select(&mut s, &[a, image, instance]); + run(&mut s, "object.group", json!({})); + run(&mut s, "stroke.set", json!({"weight": 5})); + let d = &s.doc().unwrap().doc; + assert_eq!(d.node(a).unwrap().appearance.stroke_width(), 5.0); + for id in [image, instance] { + let n = d.node(id).unwrap(); + assert!(matches!(n.kind, NodeKind::Image(_) | NodeKind::SymbolInstance { .. })); + assert!(n.appearance.stroke().is_none(), "{:?} gains no stroke", n.kind_label()); + } +} + +#[test] +fn mixed_values_and_align_stroke_follow_the_selection() { + let mut s = session(); + let (a, b) = (rect(&mut s, 10.0), rect(&mut s, 60.0)); + run(&mut s, "stroke.set", json!({"ids": [b.0], "weight": 3, "join": "round"})); + select(&mut s, &[a, b]); + let m = s.active().unwrap().stroke_mixed(); + assert!(m.weight && m.join && !m.cap && !m.miter_limit && !m.align && !m.dash && m.can_align, "{m:?}"); + // An open path or type in the selection can't take an inside or outside stroke. + let line = NodeId(run(&mut s, "shape.line", json!({"x1": 10, "y1": 100, "x2": 90, "y2": 100}))["id"].as_u64().unwrap()); + select(&mut s, &[a, line]); + assert!(!s.active().unwrap().stroke_mixed().can_align); + let text = NodeId(run(&mut s, "text.create", json!({"x": 10, "y": 200, "text": "Hi"}))["id"].as_u64().unwrap()); + select(&mut s, &[text]); + assert!(!s.active().unwrap().stroke_mixed().can_align); + // Grouped closed paths can. + select(&mut s, &[a, b]); + run(&mut s, "object.group", json!({})); + let m = s.active().unwrap().stroke_mixed(); + assert!(m.can_align && m.weight); + assert_eq!(s.shown_stroke().map(|st| st.width), Some(1.0), "a group shows its first painted object's stroke"); +} + +#[test] +fn stroke_unit_follows_the_preference() { + let mut s = session(); + assert_eq!(s.stroke_unit(), Unit::Points); + s.prefs.units_stroke = "millimeters".into(); + assert_eq!(s.stroke_unit(), Unit::Millimeters); + assert_eq!(s.stroke_unit().format(1.0), "0.353 mm"); +} + +#[test] +fn inspect_reports_stroke_options() { + let mut s = session(); + let line = run(&mut s, "shape.line", json!({"x1": 10, "y1": 100, "x2": 90, "y2": 100}))["id"].as_u64().unwrap(); + run( + &mut s, + "stroke.set", + json!({"ids": [line], "cap": "round", "join": "bevel", "miterLimit": 4, "dash": [6, 3], "alignDashes": true, "endArrow": "Triangle", "arrowAlign": "tip", "profile": "lens"}), + ); + let doc = run(&mut s, "document.inspect", json!({})); + let o = &doc["layers"][0]["children"][0]["strokeOptions"]; + assert_eq!(o["cap"], "round"); + assert_eq!(o["join"], "bevel"); + assert_eq!(o["miterLimit"], 4.0); + assert_eq!(o["align"], "center"); + assert_eq!(o["dash"], json!([6.0, 3.0])); + assert_eq!(o["alignDashes"], true); + assert_eq!((o["startArrow"].clone(), o["endArrow"].clone(), o["arrowAlign"].clone()), (Value::Null, json!("Triangle"), json!("tip"))); + assert_eq!(o["profile"], "lens"); + // The options read back into stroke.set unchanged. + let mut q = o.as_object().unwrap().clone(); + q.insert("ids".into(), json!([line])); + let before = s.doc().unwrap().doc.node(NodeId(line)).unwrap().appearance.clone(); + run(&mut s, "stroke.set", Value::Object(q)); + assert_eq!(s.doc().unwrap().doc.node(NodeId(line)).unwrap().appearance, before); + // Type reports its characters' stroke. + run(&mut s, "text.create", json!({"x": 10, "y": 200, "text": "Hi"})); + run(&mut s, "stroke.set", json!({"join": "round"})); + let doc = run(&mut s, "document.inspect", json!({})); + let text = doc["layers"][0]["children"].as_array().unwrap().iter().find(|c| c["kind"] == "Text" || c.get("text").is_some()).unwrap().clone(); + assert_eq!(text["strokeOptions"]["join"], "round", "{text}"); +} diff --git a/crates/ui-egui/src/chrome.rs b/crates/ui-egui/src/chrome.rs index 8be42e7d..87180ec4 100644 --- a/crates/ui-egui/src/chrome.rs +++ b/crates/ui-egui/src/chrome.rs @@ -5,6 +5,7 @@ use serde_json::json; use vectorcraft_color::Paint; use vectorcraft_doc::NodeKind; +use crate::panels::stroke as stroke_panel; use crate::state::{ZOOM_STOPS, zoom_label}; use crate::theme::{self, Tokens}; use crate::widgets::{self, paint_chip}; @@ -100,12 +101,10 @@ pub fn control_bar(app: &mut VectorcraftApp, ui: &mut Ui) { ui.label(egui::RichText::new(label).font(theme::semibold(12.0)).color(t.text)); ui.add_space(6.0); let (fill, stroke) = crate::panels::current_paints(app); - let (weight, opacity) = match &first { - Some(_) => { - (crate::panels::current_stroke(app).map_or(0.0, |s| s.width), crate::panels::current_transparency(app).map_or(1.0, |t| t.0)) - } - None => (app.session.paint.stroke_width, 1.0), - }; + let shown_stroke = crate::panels::current_stroke(app); + let mixed = crate::panels::stroke_mixed(app, ui.ctx()); + let weight = stroke_panel::shown_weight(app, shown_stroke.as_ref(), &mixed); + let opacity = if first.is_some() { crate::panels::current_transparency(app).map_or(1.0, |t| t.0) } else { 1.0 }; if chip_button(ui, &fill, false, "Fill").clicked() { app.run("paint.toggleActive", json!({})).ok(); app.session.fill_active = true; @@ -115,11 +114,14 @@ pub fn control_bar(app: &mut VectorcraftApp, ui: &mut Ui) { app.session.fill_active = false; app.ui.open_panel = Some("swatches".into()); } - if ui.link(egui::RichText::new("Stroke:").size(12.0).color(t.text).underline()).clicked() { - app.ui.open_panel = Some("stroke".into()); - } - if let Some(w) = widgets::num_field(ui, "cb-stroke", Some(weight), vectorcraft_doc::Unit::Points, 64.0) { - app.run("stroke.set", json!({"weight": w})).ok(); + // The link opens the Stroke panel as a popover under it; then the weight spinner + // (with presets) and the width profile. + let link = ui.link(egui::RichText::new("Stroke:").size(12.0).color(t.text).underline()).on_hover_text("Stroke options"); + stroke_panel::popover(app, &link); + stroke_panel::weight_field(app, ui, "cb-stroke", weight, 100.0); + let profile = vectorcraft_doc::WidthProfile::id_of(shown_stroke.as_ref().and_then(|s| s.profile.as_ref())); + if let Some(id) = stroke_panel::profile_dropdown(ui, profile) { + app.run("stroke.set", json!({"profile": id})).ok(); } ui.add_space(4.0); ui.separator(); diff --git a/crates/ui-egui/src/panels/mod.rs b/crates/ui-egui/src/panels/mod.rs index dd094901..343b8efd 100644 --- a/crates/ui-egui/src/panels/mod.rs +++ b/crates/ui-egui/src/panels/mod.rs @@ -40,6 +40,7 @@ use egui::{Rect, Sense, Ui, vec2}; use serde_json::{Value, json}; use vectorcraft_color::{BlendMode, Color, Paint}; use vectorcraft_doc::{Node, StrokeLayer}; +use vectorcraft_engine::inspect::StrokeMixed; use crate::theme::Tokens; use crate::widgets::{Live, dim_label}; @@ -214,6 +215,22 @@ pub(crate) fn current_stroke(app: &VectorcraftApp) -> Option { app.session.shown_stroke() } +/// Which Stroke panel values the selection doesn't share and whether Align Stroke applies +/// ([`vectorcraft_engine::DocState::stroke_mixed`]), cached per document revision. While the Type +/// tool edits text, the selected characters' stroke shows as it is. +pub(crate) fn stroke_mixed(app: &VectorcraftApp, ctx: &egui::Context) -> StrokeMixed { + let Some(st) = app.session.active().filter(|_| character::text_editing(app).is_none()) else { return StrokeMixed::default() }; + let key = (st.uid, st.revision); + match pstate::>(ctx, "stroke-mixed") { + Some((k, mixed)) if k == key => mixed, + _ => { + let mixed = st.stroke_mixed(); + set_pstate(ctx, "stroke-mixed", Some((key, mixed))); + mixed + } + } +} + /// Opacity and blend mode as the Transparency panel and Control bar show them: the Appearance /// panel's active item's, else the first selected object's. pub(crate) fn current_transparency(app: &VectorcraftApp) -> Option<(f32, BlendMode)> { @@ -364,3 +381,5 @@ mod tests_appearance; mod tests_effectedit; #[cfg(test)] mod tests_stroke; +#[cfg(test)] +mod tests_strokeux; diff --git a/crates/ui-egui/src/panels/properties.rs b/crates/ui-egui/src/panels/properties.rs index 36a8b857..3322e984 100644 --- a/crates/ui-egui/src/panels/properties.rs +++ b/crates/ui-egui/src/panels/properties.rs @@ -266,10 +266,7 @@ fn appearance_section(app: &mut VectorcraftApp, ui: &mut Ui) { let Some(n) = first_selected(app) else { return }; section_header(ui, "Appearance"); let (fill, stroke) = super::current_paints(app); - let weight = match &n.kind { - NodeKind::Text(tx) if app.session.appearance_item().is_none() => tx.first_style().stroke_width, - _ => super::current_stroke(app).map_or(0.0, |s| s.width), - }; + let weight = super::stroke::shown_weight(app, super::current_stroke(app).as_ref(), &super::stroke_mixed(app, ui.ctx())); for (label, paint, is_fill) in [("Fill", fill, true), ("Stroke", stroke, false)] { ui.horizontal(|ui| { let (r, resp) = ui.allocate_exact_size(vec2(22.0, 22.0), Sense::click()); @@ -287,12 +284,11 @@ fn appearance_section(app: &mut VectorcraftApp, ui: &mut Ui) { ui.label(egui::RichText::new(label).size(12.0)); if !is_fill { ui.add_space(8.0); - if let Some(w) = widgets::num_field(ui, "ap-w", Some(weight), Unit::Points, 70.0) { + if let Some(w) = widgets::num_field(ui, "ap-w", weight, app.session.stroke_unit(), 70.0) { app.run("stroke.set", json!({"weight": w})).ok(); } - if widgets::icon_button(ui, "ellipsis", "Stroke options", false, 22.0).clicked() { - app.ui.open_panel = Some("stroke".into()); - } + let more = widgets::icon_button(ui, "ellipsis", "Stroke options", false, 22.0); + super::stroke::popover(app, &more); } }); } diff --git a/crates/ui-egui/src/panels/stroke.rs b/crates/ui-egui/src/panels/stroke.rs index 5e6aa68c..c52019b0 100644 --- a/crates/ui-egui/src/panels/stroke.rs +++ b/crates/ui-egui/src/panels/stroke.rs @@ -3,9 +3,10 @@ use egui::{Color32, Pos2, Rect, Sense, Stroke, StrokeKind, Ui, pos2, vec2}; use serde_json::{Value, json}; -use vectorcraft_doc::{ArrowAlign, Arrowhead, Dash, LineCap, LineJoin, ProfilePreset, StrokeAlign, StrokeLayer, Unit, WidthProfile}; +use vectorcraft_doc::{ArrowAlign, Arrowhead, Dash, LineCap, LineJoin, ProfilePreset, StrokeAlign, StrokeLayer, WidthProfile}; +use vectorcraft_engine::inspect::StrokeMixed; -use super::{character, current_stroke, pstate, set_pstate}; +use super::{character, current_stroke, pstate, set_pstate, stroke_mixed}; use crate::theme::Tokens; use crate::widgets::{self, menu_item}; use crate::{VectorcraftApp, icons}; @@ -83,10 +84,34 @@ fn set(app: &mut VectorcraftApp, p: Value) { } } +/// The weight the Stroke panel, Control bar and Properties panel show: blank where the selected +/// objects differ, the default for new art without a stroke. +pub(crate) fn shown_weight(app: &VectorcraftApp, st: Option<&StrokeLayer>, mixed: &StrokeMixed) -> Option { + (!mixed.weight).then(|| st.map_or(app.session.paint.stroke_width, |s| s.width)) +} + +/// The weight spinner (Stroke panel, Control bar): Units > Stroke, presets, blank when mixed. +pub(crate) fn weight_field(app: &mut VectorcraftApp, ui: &mut Ui, id: &str, weight: Option, width: f32) { + if let Some(w) = widgets::spin_field(ui, id, weight, app.session.stroke_unit(), width, 1.0, 0.0, &WEIGHT_PRESETS) { + set(app, json!({"weight": w})); + } +} + +/// The Stroke panel in a popover anchored to `resp` (the Control bar's and the Properties +/// panel's Stroke links), which a click on it toggles. +pub(crate) fn popover(app: &mut VectorcraftApp, resp: &egui::Response) { + egui::Popup::menu(resp).close_behavior(egui::PopupCloseBehavior::CloseOnClickOutside).show(|ui| { + ui.set_width(260.0); + show(app, ui); + }); +} + pub fn show(app: &mut VectorcraftApp, ui: &mut Ui) { let t = Tokens::get(ui.ctx()); let st = current_stroke(app); - let weight = st.as_ref().map(|s| s.width).unwrap_or(app.session.paint.stroke_width); + let mixed = stroke_mixed(app, ui.ctx()); + let unit = app.session.stroke_unit(); + let weight = shown_weight(app, st.as_ref(), &mixed); let hidden: bool = pstate(ui.ctx(), "stroke-hide-options"); let label_w = 64.0; let row_label = |ui: &mut Ui, s: &str| { @@ -94,16 +119,15 @@ pub fn show(app: &mut VectorcraftApp, ui: &mut Ui) { }; ui.horizontal(|ui| { row_label(ui, "Weight:"); - if let Some(w) = widgets::spin_field(ui, "stroke-weight", Some(weight), Unit::Points, 120.0, 1.0, 0.0, &WEIGHT_PRESETS) { - set(app, json!({"weight": w})); - } + weight_field(app, ui, "stroke-weight", weight, 120.0); }); if hidden { return; } - let cap = st.as_ref().map(|s| s.cap).unwrap_or_default(); - let join = st.as_ref().map(|s| s.join).unwrap_or_default(); - let align = st.as_ref().map(|s| s.align).unwrap_or_default(); + // Mixed values light no button. + let cap = st.as_ref().map(|s| s.cap).filter(|_| !mixed.cap); + let join = st.as_ref().map(|s| s.join).filter(|_| !mixed.join); + let align = st.as_ref().map(|s| s.align).filter(|_| !mixed.align); ui.horizontal(|ui| { row_label(ui, "Cap:"); for (v, icon, tip, name) in [ @@ -111,7 +135,7 @@ pub fn show(app: &mut VectorcraftApp, ui: &mut Ui) { (LineCap::Round, "dc-cap-round", "Round Cap", "round"), (LineCap::Square, "dc-cap-square", "Projecting Cap", "square"), ] { - if widgets::icon_button(ui, icon, tip, cap == v, 24.0).clicked() { + if widgets::icon_button(ui, icon, tip, cap == Some(v), 24.0).clicked() { set(app, json!({"cap": name})); } } @@ -123,14 +147,14 @@ pub fn show(app: &mut VectorcraftApp, ui: &mut Ui) { (LineJoin::Round, "dc-join-round", "Round Join", "round"), (LineJoin::Bevel, "dc-join-bevel", "Bevel Join", "bevel"), ] { - if widgets::icon_button(ui, icon, tip, join == v, 24.0).clicked() { + if widgets::icon_button(ui, icon, tip, join == Some(v), 24.0).clicked() { set(app, json!({"join": name})); } } widgets::dim_label(ui, "Limit:"); let lim = st.as_ref().map(|s| s.miter_limit).unwrap_or(10.0); - if join == LineJoin::Miter { - if let Some(v) = widgets::plain_field(ui, "stroke-limit", lim, " x", 0, 50.0) { + if !matches!(join, Some(LineJoin::Round | LineJoin::Bevel)) { + if let Some(v) = widgets::mixed_field(ui, "stroke-limit", (!mixed.miter_limit).then_some(lim), " x", 0, 50.0) { set(app, json!({"miterLimit": v.clamp(1.0, 500.0)})); } } else { @@ -144,7 +168,9 @@ pub fn show(app: &mut VectorcraftApp, ui: &mut Ui) { (StrokeAlign::Inside, "dc-stroke-inside", "Align Stroke to Inside", "inside"), (StrokeAlign::Outside, "dc-stroke-outside", "Align Stroke to Outside", "outside"), ] { - if widgets::icon_button(ui, icon, tip, align == v, 24.0).clicked() { + // Only closed paths take a stroke inside or outside (not open paths, not type). + let enabled = v == StrokeAlign::Center || mixed.can_align; + if widgets::icon_button_enabled(ui, icon, tip, align == Some(v), enabled, 24.0).clicked() { set(app, json!({"align": name})); } } @@ -153,6 +179,7 @@ pub fn show(app: &mut VectorcraftApp, ui: &mut Ui) { // Dashed line. let dash = st.as_ref().and_then(|s| s.dash.as_ref()); let (fields, align_corners) = dash_state(dash, pstate(ui.ctx(), "stroke-dash-last")); + let shown = if mixed.dash { [None; 6] } else { fields }; ui.horizontal(|ui| { if widgets::check(ui, "Dashed Line", dash.is_some(), st.is_some()) { if dash.is_some() { @@ -174,10 +201,10 @@ pub fn show(app: &mut VectorcraftApp, ui: &mut Ui) { ui.horizontal(|ui| { ui.spacing_mut().item_spacing.x = 3.0; let fw = ((ui.available_width() - 15.0) / 6.0).clamp(28.0, 40.0); - let mut nf = fields; + let mut nf = shown; let mut changed = false; - for (i, f) in fields.iter().enumerate() { - let r = ui.add_enabled_ui(dash.is_some(), |ui| widgets::opt_field(ui, ("dash", i), *f, fw)).inner; + for (i, f) in shown.iter().enumerate() { + let r = ui.add_enabled_ui(dash.is_some(), |ui| widgets::opt_field(ui, ("dash", i), *f, unit, fw)).inner; if let Some(x) = r { nf[i] = x.map(|v| v.max(0.0)); changed = true; @@ -349,7 +376,7 @@ pub fn paint_profile(ui: &Ui, r: Rect, prof: Option<&WidthProfile>, color: Color ui.painter().add(egui::Shape::mesh(mesh)); } -fn profile_dropdown(ui: &mut Ui, cur: &str) -> Option<&'static str> { +pub(crate) fn profile_dropdown(ui: &mut Ui, cur: &str) -> Option<&'static str> { let t = Tokens::get(ui.ctx()); let (r, resp) = ui.allocate_exact_size(vec2(100.0, 24.0), Sense::click()); ui.painter().rect_filled(r, 2, t.input); diff --git a/crates/ui-egui/src/panels/tests_strokeux.rs b/crates/ui-egui/src/panels/tests_strokeux.rs new file mode 100644 index 00000000..a6ecc918 --- /dev/null +++ b/crates/ui-egui/src/panels/tests_strokeux.rs @@ -0,0 +1,88 @@ +//! Headless frames of the Stroke panel's reach: Units > Stroke in the panel, the Control bar and +//! the Properties panel, mixed values shown blank, Align Stroke for open paths and type, and the +//! weight presets. + +use serde_json::json; + +use super::tests_appearance::{app_with_rect, click, frame_events, run, text_rect}; +use super::*; + +type Texts = Vec<(String, egui::Rect)>; + +fn texts(ctx: &egui::Context, app: &mut VectorcraftApp, f: impl FnMut(&mut VectorcraftApp, &mut Ui)) -> Texts { + frame_events(ctx, app, vec![], f) +} + +fn shows(t: &Texts, s: &str) -> bool { + t.iter().any(|(x, _)| x == s) +} + +/// The centre of the `n`th (0-based) 24 px button right of a Stroke panel row label. +fn row_button(ctx: &egui::Context, t: &Texts, label: &str, n: usize) -> egui::Pos2 { + let r = text_rect(t, label); + let sp = ctx.global_style().spacing.item_spacing.x; + egui::pos2(r.right() + sp + n as f32 * (24.0 + sp) + 12.0, r.center().y) +} + +#[test] +fn weights_and_dashes_show_in_the_stroke_unit_everywhere() { + let ctx = egui::Context::default(); + crate::theme::install_fonts(&ctx); + let mut app = app_with_rect(); + app.session.prefs.units_stroke = "millimeters".into(); + run(&mut app, "stroke.set", json!({"dash": [12, 6]})); + let mm = vectorcraft_doc::Unit::Millimeters.format(1.0); + assert_eq!(mm, "0.353 mm"); + let t = texts(&ctx, &mut app, stroke::show); + assert!(shows(&t, &mm), "Stroke panel: {t:?}"); + assert!(shows(&t, "4.233") && shows(&t, "2.117"), "dash and gap in mm: {t:?}"); + assert!(shows(&texts(&ctx, &mut app, properties::show), &mm), "Properties panel"); + assert!(shows(&texts(&ctx, &mut app, crate::chrome::control_bar), &mm), "Control bar"); +} + +#[test] +fn mixed_weights_show_blank() { + let ctx = egui::Context::default(); + let mut app = app_with_rect(); + let a = app.session.active().unwrap().selection.objects[0]; + let b = run(&mut app, "shape.rectangle", json!({"x": 120, "y": 10, "width": 50, "height": 50}))["id"].as_u64().unwrap(); + run(&mut app, "stroke.set", json!({"ids": [b], "weight": 3})); + run(&mut app, "select.set", json!({ "ids": [a.0, b] })); + let t = texts(&ctx, &mut app, stroke::show); + assert!(!shows(&t, "1 pt") && !shows(&t, "3 pt"), "{t:?}"); + run(&mut app, "select.set", json!({ "ids": [b] })); + assert!(shows(&texts(&ctx, &mut app, stroke::show), "3 pt"), "the cache follows the selection"); +} + +#[test] +fn inside_and_outside_are_off_for_open_paths_and_type() { + let ctx = egui::Context::default(); + let mut app = app_with_rect(); + let align = |app: &VectorcraftApp| current_stroke(app).unwrap().align; + let at = row_button(&ctx, &texts(&ctx, &mut app, stroke::show), "Align Stroke:", 1); + click(&ctx, &mut app, at, stroke::show); + assert_eq!(align(&app), vectorcraft_doc::StrokeAlign::Inside, "a closed path takes an inside stroke"); + run(&mut app, "shape.line", json!({"x1": 10, "y1": 150, "x2": 150, "y2": 150})); + click(&ctx, &mut app, at, stroke::show); + assert_eq!(align(&app), vectorcraft_doc::StrokeAlign::Center, "an open path doesn't"); + let id = run(&mut app, "text.create", json!({"x": 10, "y": 190, "text": "Hi"}))["id"].as_u64().unwrap(); + run(&mut app, "select.set", json!({ "ids": [id] })); + run(&mut app, "paint.setStroke", json!({"color": "#000000"})); + let undo = app.session.active().unwrap().history.undo.len(); + click(&ctx, &mut app, at, stroke::show); + assert_eq!(app.session.active().unwrap().history.undo.len(), undo, "type doesn't"); +} + +#[test] +fn the_weight_presets_run_from_a_quarter_point_to_a_hundred() { + assert!(stroke::WEIGHT_PRESETS.contains(&0.25) && stroke::WEIGHT_PRESETS.contains(&100.0)); + let ctx = egui::Context::default(); + let mut app = app_with_rect(); + // The chevron at the right end of the 120 px weight spinner opens them. + let t = texts(&ctx, &mut app, stroke::show); + let r = text_rect(&t, "Weight:"); + let sp = ctx.global_style().spacing.item_spacing.x; + click(&ctx, &mut app, egui::pos2(r.right() + sp + 110.0, r.center().y), stroke::show); + let t = texts(&ctx, &mut app, stroke::show); + assert!(shows(&t, "0.25 pt") && shows(&t, "100 pt"), "{t:?}"); +} diff --git a/crates/ui-egui/src/widgets.rs b/crates/ui-egui/src/widgets.rs index 70b89b21..8c76851d 100644 --- a/crates/ui-egui/src/widgets.rs +++ b/crates/ui-egui/src/widgets.rs @@ -911,12 +911,13 @@ pub fn menu_item(ui: &mut Ui, label: &str, enabled: bool, checked: bool) -> bool ui.add_enabled(enabled, egui::Button::new(egui::RichText::new(text).size(12.5)).frame(false)).clicked() } -/// A number field that may be empty (Stroke dash/gap). Returns `Some(new)` on commit, where -/// `new` is `None` when the field was cleared. -pub fn opt_field(ui: &mut Ui, id: impl std::hash::Hash + std::fmt::Debug, value: Option, width: f32) -> Option> { +/// A number field that may be empty (Stroke dash/gap): `value` (points) shows in `unit` without +/// its suffix, to fit narrow fields. Returns `Some(new)` (points) on commit, where `new` is `None` +/// when the field was cleared; typed units and arithmetic work as in [`num_field`]. +pub fn opt_field(ui: &mut Ui, id: impl std::hash::Hash + std::fmt::Debug, value: Option, unit: Unit, width: f32) -> Option> { let t = Tokens::get(ui.ctx()); let id = ui.id().with(id); - let shown = value.map(|v| format!("{v}")).unwrap_or_default(); + let shown = value.map(|v| unit.format(v).trim_end_matches(unit.suffix()).trim_end().to_string()).unwrap_or_default(); let editing = ui.memory(|m| m.has_focus(id)); let mut buf: String = if editing { ui.data_mut(|d| d.get_temp::(id)).unwrap_or_else(|| shown.clone()) } else { shown.clone() }; let enabled = ui.is_enabled(); @@ -938,8 +939,8 @@ pub fn opt_field(ui: &mut Ui, id: impl std::hash::Hash + std::fmt::Debug, value: .inner; ui.data_mut(|d| d.insert_temp(id, buf.clone())); if resp.lost_focus() && buf != shown { - let s = buf.trim().trim_end_matches("pt").trim(); - if s.is_empty() { Some(None) } else { s.parse::().ok().map(Some) } + let s = buf.trim(); + if s.is_empty() { Some(None) } else { unit.parse(s).map(Some) } } else { None } diff --git a/docs/mcp.md b/docs/mcp.md index c40eb95c..1896f944 100644 --- a/docs/mcp.md +++ b/docs/mcp.md @@ -332,4 +332,5 @@ options go to every run (the object keeps no stroke of its own); an `item` still with `appearance.addStroke`, which takes every stroke option (dashes, profile, opacity, blend) like a path's and paints under or over the characters as its place relative to the Characters row says. To stroke some characters, use `text.setRangeStyle {id, start, end, strokeOptions: {weight?, cap?, join?, miterLimit?, dash?, -dashOffset?, alignDashes?}}`. +dashOffset?, alignDashes?}}`. `inspect_document` reports each object's stroke as `strokeOptions` (type: its first +run's) in `stroke.set` terms. `stroke.set` on a group leaves the images and symbol instances in it alone.