Keep stroke weight in document space on transformed type

Fixes #3241

Co-Authored-By: Claude Haiku 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
NetterSilver
2026-10-21 15:42:35 +07:00
committed by bflatastic
co-authored by Claude Haiku 5.5
parent 92a9b91255
commit 4444450a44
3 changed files with 73 additions and 4 deletions
+15 -3
View File
@@ -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<CommandSpec> {
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<Value> {
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() {
+16 -1
View File
@@ -347,11 +347,26 @@ fn name_of<T: PartialEq>(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<StrokeLayer> {
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)
+42
View File
@@ -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<f64> {
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::<Vec<_>>();
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);
}