From 4444450a445af22646bb8605edc27a17b51b9677 Mon Sep 17 00:00:00 2001 From: NetterSilver <133535268+Silvertree2010@users.noreply.github.com> Date: Tue, 20 Oct 2026 19:07:36 +0000 Subject: [PATCH] Keep stroke weight in document space on transformed type Fixes #3241 Co-Authored-By: Claude Haiku 5.5 Co-Authored-By: Claude Opus 5.5 --- crates/engine/src/cmd/stroke.rs | 18 ++++++++-- crates/engine/src/inspect.rs | 17 +++++++++- crates/engine/tests/text_stroke_scale.rs | 42 ++++++++++++++++++++++++ 3 files changed, 73 insertions(+), 4 deletions(-) create mode 100644 crates/engine/tests/text_stroke_scale.rs diff --git a/crates/engine/src/cmd/stroke.rs b/crates/engine/src/cmd/stroke.rs index 9c1c49a68..c346631c8 100644 --- a/crates/engine/src/cmd/stroke.rs +++ b/crates/engine/src/cmd/stroke.rs @@ -4,14 +4,14 @@ use serde_json::{Value, json}; use vectorcraft_color::{Color, Paint}; use vectorcraft_doc::{ - ArrowAlign, Arrowhead, CharStyle, Dash, LineCap, LineJoin, Node, NodeKind, SavedProfile, StrokeAlign, StrokeLayer, Unit, WidthProfile, + ArrowAlign, Arrowhead, CharStyle, Dash, LineCap, LineJoin, Node, NodeKind, SavedProfile, Scaling, StrokeAlign, StrokeLayer, Unit, WidthProfile, }; use super::appearance::{ItemTarget, edit_items, item_target}; use super::paint::painted; use super::*; use crate::DocState; -use crate::inspect::{ALIGNS, CAPS, JOINS, StrokeMixed, node_stroke}; +use crate::inspect::{ALIGNS, CAPS, JOINS, StrokeMixed, node_stroke, scale_layer}; pub fn specs() -> Vec { vec![ @@ -171,6 +171,16 @@ impl StrokeChange { self.apply(&mut st); style.set_stroke_layer(&st); } + + /// [`Self::apply_char`] to type drawn at mean scale `k` (its `xf`'s): the weight and dash + /// lengths are document-space, the character stroke is drawn in text space. + pub(crate) fn apply_char_scaled(&self, style: &mut CharStyle, k: f64) { + let mut st = style.stroke_layer(); + scale_layer(&mut st, k); + self.apply(&mut st); + scale_layer(&mut st, 1.0 / k); + style.set_stroke_layer(&st); + } } /// Does a Stroke panel edit find nothing selected (and no `ids`)? It then sets up new art. @@ -227,7 +237,9 @@ fn stroke_set(s: &mut Session, p: &Value) -> Result { if index.is_none() && let NodeKind::Text(t) = &mut n.kind { - t.runs.iter_mut().for_each(|r| change.apply_char(&mut r.style)); + // Character strokes are drawn in text space, scaled by the type's transform. + let k = Scaling::factor(t.xf).filter(|k| k.is_finite() && *k > 1e-12).unwrap_or(1.0); + t.runs.iter_mut().for_each(|r| change.apply_char_scaled(&mut r.style, k)); return Ok(()); } if index.is_none() && n.appearance.stroke().is_none() { diff --git a/crates/engine/src/inspect.rs b/crates/engine/src/inspect.rs index 20db02bce..0a61be706 100644 --- a/crates/engine/src/inspect.rs +++ b/crates/engine/src/inspect.rs @@ -347,11 +347,26 @@ fn name_of(table: &[(T, &'static str)], v: T) -> &'static str { /// 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())), + NodeKind::Text(t) => { + let mut st = t.runs.first().map_or_else(|| vectorcraft_doc::CharStyle::default().stroke_layer(), |r| r.style.stroke_layer()); + // Character strokes are drawn in text space: report the document-space weight. + if let Some(k) = vectorcraft_doc::Scaling::factor(t.xf).filter(|k| k.is_finite()) { + scale_layer(&mut st, k); + } + Some(st) + } _ => n.appearance.stroke().cloned(), } } +/// Scale a stroke's weight and dash lengths by `s`. +pub(crate) fn scale_layer(st: &mut StrokeLayer, s: f64) { + st.width *= s; + if let Some(d) = &mut st.dash { + d.scale(s); + } +} + /// 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, /// plus widthPoints: the profile's `[t, left, right]` points (width factors; null when uniform) diff --git a/crates/engine/tests/text_stroke_scale.rs b/crates/engine/tests/text_stroke_scale.rs new file mode 100644 index 000000000..10ab2bf0a --- /dev/null +++ b/crates/engine/tests/text_stroke_scale.rs @@ -0,0 +1,42 @@ +//! A stroke weight set on type is a document-space weight, whether the type was enlarged by its +//! font size or by a transform (issue #3241). +#![allow(clippy::unwrap_used, clippy::panic)] + +use serde_json::json; +use vectorcraft_doc::{NodeId, NodeKind}; +use vectorcraft_engine::Session; + +const MM: f64 = 72.0 / 25.4; + +fn text(s: &mut Session, x: f64) -> NodeId { + NodeId(s.execute("text.create", &json!({"x": x, "y": 100, "text": "Ab", "size": 30.0 * MM})).unwrap()["id"].as_u64().unwrap()) +} + +/// The weights the type's character strokes draw at in document space. +fn drawn(s: &Session, id: NodeId) -> Vec { + let NodeKind::Text(t) = &s.doc().unwrap().doc.node(id).unwrap().kind else { panic!("not type") }; + let k = t.xf.determinant().abs().sqrt(); + t.runs.iter().map(|r| r.style.stroke_width * k).collect() +} + +#[test] +fn stroke_weight_on_scaled_type_matches_type_resized_by_size() { + let mut s = Session::new(); + s.execute("file.new", &json!({"width": 800, "height": 600})).unwrap(); + let a = text(&mut s, 10.0); + let b = text(&mut s, 300.0); + s.execute("text.setStyle", &json!({"ids": [a.0], "size": 60.0 * MM})).unwrap(); + s.execute("object.scale", &json!({"ids": [b.0], "sx": 200, "sy": 200})).unwrap(); + s.execute("stroke.set", &json!({"ids": [a.0, b.0], "weight": MM, "dash": [MM, 2.0 * MM]})).unwrap(); + for w in drawn(&s, a).into_iter().chain(drawn(&s, b)) { + assert!((w - MM).abs() < 1e-9, "drawn stroke weight {w}, want {MM}"); + } + let NodeKind::Text(t) = &s.doc().unwrap().doc.node(b).unwrap().kind else { panic!("not type") }; + let dash = t.runs[0].style.stroke_dash.as_ref().unwrap().pattern.iter().map(|d| d * 2.0).collect::>(); + assert!((dash[0] - MM).abs() < 1e-9 && (dash[1] - 2.0 * MM).abs() < 1e-9, "drawn dash {dash:?}"); + + s.execute("select.set", &json!({"ids": [b.0]})).unwrap(); + let shown = s.shown_stroke().unwrap(); + assert!((shown.width - MM).abs() < 1e-9, "Stroke panel shows {}, want {MM}", shown.width); + assert!((shown.dash.unwrap().pattern[0] - MM).abs() < 1e-9); +}