M4: Dash cap setting for Dash and Custom strokes (#1728)

This commit is contained in:
BFlat
2026-10-31 09:41:39 +07:00
committed by GitHub
parent b62ec4ef25
commit 6b28ef57dc
43 changed files with 339 additions and 41 deletions
+2 -1
View File
File diff suppressed because one or more lines are too long
+22
View File
@@ -28,6 +28,8 @@ pub enum StrokeCap {
}
impl StrokeCap {
/// The caps a dash can have.
pub const DASH: [StrokeCap; 3] = [StrokeCap::None, StrokeCap::Round, StrokeCap::Square];
pub const ALL: [StrokeCap; 8] = [
StrokeCap::None,
StrokeCap::Round,
@@ -53,6 +55,14 @@ impl StrokeCap {
pub fn is_arrow(self) -> bool {
!matches!(self, StrokeCap::None | StrokeCap::Round | StrokeCap::Square)
}
/// The cap shape this draws on the stroke's band (arrowheads are drawn on top of it).
pub fn shape(self) -> StrokeCap {
match self {
StrokeCap::Round | StrokeCap::CircleFilled => StrokeCap::Round,
StrokeCap::Square | StrokeCap::DiamondFilled => StrokeCap::Square,
_ => StrokeCap::None,
}
}
}
#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize, Default)]
@@ -82,6 +92,9 @@ pub struct StrokeProps {
pub miter_limit: f64,
/// Dash pattern (dash, gap, …); empty = solid.
pub dashes: Vec<f64>,
/// The cap of every dash, both ends (`None`: dashes take `cap_start` and `cap_end`).
#[serde(skip_serializing_if = "Option::is_none")]
pub dash_cap: Option<StrokeCap>,
/// Variable width: how the width changes along the path (`None` = uniform).
#[serde(skip_serializing_if = "Option::is_none")]
pub width_profile: Option<WidthProfile>,
@@ -151,6 +164,7 @@ impl Default for StrokeProps {
join: StrokeJoin::Miter,
miter_limit: 4.0,
dashes: Vec::new(),
dash_cap: None,
width_profile: None,
brush: None,
}
@@ -158,6 +172,14 @@ impl Default for StrokeProps {
}
impl StrokeProps {
/// The start and end cap the band is drawn with: a dashed stroke's dash cap when it has one.
pub fn band_caps(&self) -> [StrokeCap; 2] {
match self.dash_cap.filter(|_| !self.dashes.is_empty()) {
Some(c) => [c, c],
None => [self.cap_start, self.cap_end],
}
}
/// The variable width profile, when there is one with points.
pub fn profile(&self) -> Option<&WidthProfile> {
self.width_profile.as_ref().filter(|p| !p.points.is_empty())
+3
View File
@@ -251,6 +251,9 @@ fn paint_props(n: &Node, o: &mut serde_json::Map<String, Value>, options: &Inspe
if !n.stroke.dashes.is_empty() {
insert_prop(o, options, "dashes", || json!(n.stroke.dashes));
}
if let Some(cap) = n.stroke.dash_cap {
insert_prop(o, options, "dashCap", || json!(cap));
}
if n.stroke.join != StrokeJoin::Miter {
insert_prop(o, options, "strokeJoin", || json!(n.stroke.join));
} else if n.stroke.miter_limit != 4.0 {
+1
View File
@@ -328,6 +328,7 @@ pub(super) fn copy_props_of(doc: &Document, id: NodeId) -> Value {
"fills": n.fills, "strokes": n.strokes, "strokeWeight": n.stroke.weight, "strokeAlign": n.stroke.align,
"strokeSides": n.stroke.sides, "startCap": n.stroke.cap_start, "endCap": n.stroke.cap_end,
"strokeJoin": n.stroke.join, "miterLimit": n.stroke.miter_limit, "dashes": n.stroke.dashes,
"dashCap": n.stroke.dash_cap,
"widthProfile": super::super::props::width_points_json(n.stroke.width_profile.as_ref()), "brush": n.stroke.brush,
"effects": n.effects, "opacity": n.opacity, "blend": n.blend,
});
+10 -4
View File
@@ -18,7 +18,7 @@ pub const STROKE_SIDES: [&str; 4] = ["strokeTopWeight", "strokeRightWeight", "st
/// Keys `node.set` understands (documented for agents).
pub const KEYS: &str = "name, visible, locked, opacity (0-1 or \"50%\"), blend, x, y (relative to the parent frame; groups don't count), width, height, rotation (degrees), \
fills (paints or colours; gradients as {type: linearGradient|radialGradient|angularGradient|diamondGradient, stops: [\"#hex\", ...], from?, to?} too), fill (colour, {$var: variable id or name}, or \"none\"/null: no fill), strokes, stroke (colour, {$var: variable id or name}, or \"none\"/null: no stroke), strokeWeight, strokeAlign (inside|center|outside), strokeSides [t,r,b,l], strokeTopWeight / strokeRightWeight / strokeBottomWeight / strokeLeftWeight (one side; turns on individual strokes), \
strokeCap / startCap / endCap, strokeJoin, miterAngle (degrees: sharper joins bevel; or miterLimit as a ratio), dashes [..], widthProfile (preset uniform|wedge|taper|eye|rounded|pinch, [[t, scale], ...] or null), brush (preset id, {preset, spacing, sizeJitter, rotationJitter, scatter, seed} or null), cornerRadius (n or [tl,tr,br,bl]), cornerSmoothing (0-1), effects, \
strokeCap / startCap / endCap, strokeJoin, miterAngle (degrees: sharper joins bevel; or miterLimit as a ratio), dashes [..], dashCap (none|round|square; null: dashes take startCap and endCap), widthProfile (preset uniform|wedge|taper|eye|rounded|pinch, [[t, scale], ...] or null), brush (preset id, {preset, spacing, sizeJitter, rotationJitter, scatter, seed} or null), cornerRadius (n or [tl,tr,br,bl]), cornerSmoothing (0-1), effects, \
clip, isMask, layout (null|horizontal|vertical|grid|{...}), gap, crossGap, padding (n or [t,r,b,l]), paddingTop/Right/Bottom/Left, \
mainAlign (min|center|max, or the auto gaps spaceBetween|spaceAround|spaceEvenly), crossAlign (min|center|max|baseline: first text baselines, horizontal layouts), contentAlign (wrapped lines: auto = packed where crossAlign says | spaceBetween), wrap (rows of a horizontal layout, columns of a vertical one), strokesIncluded (the frame's inside stroke takes up space), reverseZ (canvas stacking: true = first on top), \
columnGap / rowGap (aliases of gap / crossGap), gridColumns, gridRows, columnTracks / rowTracks ([120 | \"1fr\" | \"auto\" | {sizing, value}]), \
@@ -581,6 +581,12 @@ fn apply_look(cmd: &str, doc: &mut Document, id: NodeId, k: &str, v: &Value) ->
let d: Vec<f64> = v.as_array().map(|a| a.iter().filter_map(f64_of).map(|x| x.max(0.0)).collect()).unwrap_or_default();
doc.get_mut(id)?.stroke.dashes = d;
}
"dashCap" => {
let cap = (!v.is_null())
.then(|| enum_of(v).filter(|c| StrokeCap::DASH.contains(c)).ok_or_else(|| bad(cmd, "dashCap: none|round|square, or null")))
.transpose()?;
doc.get_mut(id)?.stroke.dash_cap = cap;
}
"widthProfile" => doc.get_mut(id)?.stroke.width_profile = width_profile_of(v).ok_or_else(|| bad(cmd, WIDTH_PROFILE_HELP))?,
"brush" | "strokeBrush" => doc.get_mut(id)?.stroke.brush = brush_of(v).ok_or_else(|| bad(cmd, BRUSH_HELP))?,
_ => return Ok(false),
@@ -1120,9 +1126,9 @@ fn set_path(cmd: &str, doc: &mut Document, id: NodeId, v: &Value, props: &Map<St
pub fn override_group(k: &str) -> &'static str {
match k {
"fills" | "fill" => "fills",
"strokes" | "stroke" | "strokeWeight" | "strokeAlign" | "dashes" | "strokeCap" | "startCap" | "endCap" | "strokeJoin" | "strokeSides"
| "strokeTopWeight" | "strokeRightWeight" | "strokeBottomWeight" | "strokeLeftWeight" | "miterLimit" | "miterAngle" | "widthProfile"
| "brush" | "strokeBrush" => "strokes",
"strokes" | "stroke" | "strokeWeight" | "strokeAlign" | "dashes" | "dashCap" | "strokeCap" | "startCap" | "endCap" | "strokeJoin"
| "strokeSides" | "strokeTopWeight" | "strokeRightWeight" | "strokeBottomWeight" | "strokeLeftWeight" | "miterLimit" | "miterAngle"
| "widthProfile" | "brush" | "strokeBrush" => "strokes",
"effects" => "effects",
"opacity" | "blend" | "blendMode" => "opacity",
"visible" => "visible",
+71
View File
@@ -0,0 +1,71 @@
//! The Dash cap through the session: `node.set`, the saved file, inspection and copied properties.
use serde_json::{Value, json};
use crate::Session;
use crate::params::id_of;
use figcraft_doc::{NodeId, StrokeCap};
/// A 200-long vector with a dashed 10 px black stroke.
fn line(s: &mut Session) -> NodeId {
let v = id_of(&s.execute("vector.create", &json!({"path": "M0 0 L200 0", "x": 0, "y": 0})).unwrap()["id"]).unwrap();
s.execute("node.set", &json!({"id": v, "props": {"strokes": ["#000000"], "strokeWeight": 10, "strokeAlign": "center", "dashes": [20, 20]}}))
.unwrap();
v
}
fn set(s: &mut Session, id: NodeId, cap: Value) -> crate::Result<Value> {
s.execute("node.set", &json!({"id": id, "props": {"dashCap": cap}}))
}
fn cap(s: &Session, id: NodeId) -> Option<StrokeCap> {
s.doc().unwrap().node(id).unwrap().stroke.dash_cap
}
#[test]
fn node_set_takes_the_three_caps_and_null_clears_it() {
let mut s = Session::new();
let l = line(&mut s);
assert_eq!(cap(&s, l), None, "unset by default");
for (name, want) in [("round", StrokeCap::Round), ("square", StrokeCap::Square), ("none", StrokeCap::None)] {
set(&mut s, l, json!(name)).unwrap();
assert_eq!(cap(&s, l), Some(want));
}
for junk in [json!("lineArrow"), json!("triangleArrow"), json!("wobbly"), json!(3)] {
assert!(set(&mut s, l, junk).is_err());
}
assert_eq!(cap(&s, l), Some(StrokeCap::None), "a rejected value changes nothing");
set(&mut s, l, Value::Null).unwrap();
assert_eq!(cap(&s, l), None);
}
#[test]
fn it_saves_only_when_set_and_older_files_open_unset() {
let mut s = Session::new();
let l = line(&mut s);
let bytes = |s: &Session| figcraft_format::save_bytes(s.doc().unwrap(), None).unwrap();
let unset = figcraft_format::to_json(s.doc().unwrap()).unwrap().to_string();
assert!(!unset.contains("dash_cap") && !unset.contains("dashCap"), "not written when unset");
let (open, _) = figcraft_format::load_bytes(&bytes(&s)).unwrap();
assert_eq!(open.node(l).unwrap().stroke.dash_cap, None);
set(&mut s, l, json!("round")).unwrap();
let (open, _) = figcraft_format::load_bytes(&bytes(&s)).unwrap();
assert_eq!(open.node(l).unwrap().stroke.dash_cap, Some(StrokeCap::Round));
}
#[test]
fn inspect_shows_it_and_copied_properties_carry_it() {
let mut s = Session::new();
let (a, b) = (line(&mut s), line(&mut s));
set(&mut s, a, json!("square")).unwrap();
assert_eq!(s.execute("node.get", &json!({"id": a})).unwrap()["dashCap"], "square");
assert!(s.execute("node.get", &json!({"id": b})).unwrap().get("dashCap").is_none(), "only when set");
s.execute("edit.copyProperties", &json!({"id": a})).unwrap();
s.execute("edit.pasteProperties", &json!({"ids": [b]})).unwrap();
assert_eq!(cap(&s, b), Some(StrokeCap::Square));
// Copying an unset stroke clears what it pastes over.
set(&mut s, a, Value::Null).unwrap();
s.execute("edit.copyProperties", &json!({"id": a})).unwrap();
s.execute("edit.pasteProperties", &json!({"ids": [b]})).unwrap();
assert_eq!(cap(&s, b), None);
}
+2
View File
@@ -269,6 +269,8 @@ mod cursor_tests;
#[cfg(test)]
mod dark_page_text_tests;
#[cfg(test)]
mod dash_cap_tests;
#[cfg(test)]
mod datafill_tests;
#[cfg(test)]
mod design_paste_tests;
+77
View File
@@ -0,0 +1,77 @@
//! The Dash cap draws both ends of every dash, on Dash and Custom strokes alike. Unset, dashes keep
//! the end point caps they have always taken, so older files look the same.
use figcraft_doc::{StrokeAlign, StrokeCap, StrokeProps, WidthPreset};
use figcraft_geom::Point;
use figcraft_geom::kurbo::{BezPath, Shape};
use crate::{stroke_center, stroke_outline};
/// 200 long: a `10, 10` pattern fits it as it is, a half dash at each end.
fn line() -> BezPath {
let mut p = BezPath::new();
p.move_to((0.0, 0.0));
p.line_to((200.0, 0.0));
p
}
fn dashed(dash_cap: Option<StrokeCap>) -> StrokeProps {
StrokeProps { weight: 4.0, dashes: vec![10.0, 10.0], dash_cap, align: StrokeAlign::Center, ..Default::default() }
}
fn area(props: &StrokeProps) -> f64 {
stroke_center(&line(), 4.0, props).area().abs()
}
#[test]
fn the_cap_draws_both_ends_of_each_dash() {
let dashes = 11.0; // Nine whole ones and the two halves at the ends.
let band = 200.0 / 2.0 * 4.0;
assert!((area(&dashed(Some(StrokeCap::None))) - band).abs() < 0.01);
let square = area(&dashed(Some(StrokeCap::Square))) - band;
assert!((square - dashes * 2.0 * 2.0 * 4.0).abs() < 0.01, "{square}");
let round = area(&dashed(Some(StrokeCap::Round))) - band;
assert!((round - dashes * std::f64::consts::PI * 4.0).abs() < 1.0, "{round}");
}
#[test]
fn unset_keeps_the_end_point_caps_and_set_overrides_them() {
let caps = |dash_cap| StrokeProps { cap_start: StrokeCap::Round, cap_end: StrokeCap::Square, ..dashed(dash_cap) };
let old = stroke_center(&line(), 4.0, &caps(None));
assert!(old.area().abs() > area(&dashed(None)), "dashes still take them");
assert_eq!(caps(None).band_caps(), [StrokeCap::Round, StrokeCap::Square]);
assert_eq!(area(&caps(Some(StrokeCap::None))), area(&dashed(Some(StrokeCap::None))));
}
#[test]
fn a_solid_stroke_ignores_it() {
let solid = |dash_cap| StrokeProps { dashes: vec![], cap_end: StrokeCap::Square, ..dashed(dash_cap) };
assert_eq!(area(&solid(Some(StrokeCap::Round))), area(&solid(None)));
}
#[test]
fn zero_length_dashes_become_dots_of_the_cap() {
let dots = |cap| StrokeProps { dashes: vec![0.0, 20.0], ..dashed(Some(cap)) };
assert!(area(&dots(StrokeCap::Round)) > 100.0);
assert!((area(&dots(StrokeCap::Square)) - 11.0 * 16.0).abs() < 0.1);
assert!(area(&dots(StrokeCap::None)) < 1e-6);
}
#[test]
fn arrowheads_stay_on_the_path_ends() {
let arrow = |dash_cap| StrokeProps { cap_end: StrokeCap::TriangleArrow, ..dashed(dash_cap) };
let p = stroke_outline(&line(), &arrow(Some(StrokeCap::Round)), false);
let plain = stroke_outline(&line(), &dashed(Some(StrokeCap::Round)), false);
assert!(p.bounding_box().y1 > plain.bounding_box().y1, "the head is still drawn");
}
#[test]
fn a_variable_width_dash_takes_it_too() {
let wide = |cap| StrokeProps { width_profile: WidthPreset::Eye.profile(), ..dashed(cap) };
let (none, square) =
(stroke_outline(&line(), &wide(Some(StrokeCap::None)), false), stroke_outline(&line(), &wide(Some(StrokeCap::Square)), false));
// The dash at the middle ends at 105.
let past = Point::new(106.0, 0.0);
assert_eq!(none.winding(past), 0);
assert_ne!(square.winding(past), 0);
}
+11 -7
View File
@@ -16,6 +16,8 @@ mod budget;
#[cfg(test)]
mod budget_tests;
#[cfg(test)]
mod dash_cap_tests;
#[cfg(test)]
mod dash_ends_tests;
#[cfg(test)]
mod dot_dash_tests;
@@ -314,9 +316,9 @@ pub fn union_faces<'a>(faces: impl IntoIterator<Item = &'a BezPath>) -> Result<B
}
pub(crate) fn kcap(c: StrokeCap) -> Cap {
match c {
StrokeCap::Round | StrokeCap::CircleFilled => Cap::Round,
StrokeCap::Square | StrokeCap::DiamondFilled => Cap::Square,
match c.shape() {
StrokeCap::Round => Cap::Round,
StrokeCap::Square => Cap::Square,
_ => Cap::Butt,
}
}
@@ -339,15 +341,17 @@ pub fn stroke_center(path: &BezPath, width: f64, props: &StrokeProps) -> BezPath
return BezPath::new();
}
let width = width.min(MAX_STROKE_WIDTH);
let caps = props.band_caps();
let style = kurbo::Stroke::new(width)
.with_join(kjoin(props.join))
.with_miter_limit(props.miter_limit.clamp(1.0, 100.0))
.with_start_cap(kcap(props.cap_start))
.with_end_cap(kcap(props.cap_end));
.with_start_cap(kcap(caps[0]))
.with_end_cap(kcap(caps[1]));
// Each subpath restarts the pattern, so it can add up to one more pattern's worth of dashes.
let subpaths = path.elements().iter().filter(|e| matches!(e, PathEl::MoveTo(_))).count() as f64;
let round = [props.cap_start, props.cap_end].into_iter().any(|c| matches!(kcap(c), Cap::Round));
let pattern = width::dash_pattern(props).map(|p| p.into_iter().map(|d| if round && d == 0.0 { 1e-3 } else { d }).collect::<Vec<_>>());
// A zero-length dash is a dot of its cap, in viewers too; older squares stay undrawn here.
let dots = caps.into_iter().any(|c| matches!(kcap(c), Cap::Round)) || props.dash_cap == Some(StrokeCap::Square);
let pattern = width::dash_pattern(props).map(|p| p.into_iter().map(|d| if dots && d == 0.0 { 1e-3 } else { d }).collect::<Vec<_>>());
let opts = kurbo::StrokeOpts::default();
let Some(pat) = pattern.filter(|p| width::dashes_fit(path.perimeter(1.0) + subpaths * p.iter().sum::<f64>(), p)) else {
return kurbo::stroke(path.iter(), &style, &opts, 0.05);
+4 -13
View File
@@ -53,16 +53,6 @@ struct Style {
cap_end: StrokeCap,
}
/// The cap shape an end cap draws (arrowheads are added by the caller; their stroke ends round
/// or square like the uniform stroke does).
fn cap_shape(c: StrokeCap) -> StrokeCap {
match c {
StrokeCap::Round | StrokeCap::CircleFilled => StrokeCap::Round,
StrokeCap::Square | StrokeCap::DiamondFilled => StrokeCap::Square,
_ => StrokeCap::None,
}
}
/// Points of an arc around `c` from the direction of `from` turning by `sweep` radians, with the
/// radius going from `r0` to `r1` (ends excluded).
fn arc(out: &mut Vec<Point>, c: Point, from: Vec2, sweep: f64, r0: f64, r1: f64) {
@@ -134,7 +124,7 @@ fn side(segs: &[Seg], sigma: f64, closed: bool, style: Style) -> Vec<Point> {
fn cap(out: &mut Vec<Point>, c: Point, dir: Vec2, from: f64, to: f64, kind: StrokeCap) {
let n = dir.turn_90();
let (m, r) = (c + n * ((from - to) / 2.0), (from + to) / 2.0);
match cap_shape(kind) {
match kind.shape() {
StrokeCap::Square => {
out.push(m + n * r + dir * r);
out.push(m - n * r + dir * r);
@@ -172,7 +162,7 @@ fn outline_piece(path: &mut BezPath, pts: &[Point], hw: &[Half], closed: bool, s
// A zero-length piece is a dot with round caps.
let r = hw.first().map_or(0.0, |h| (h[0] + h[1]) / 2.0);
if let Some(p) = pts.first()
&& cap_shape(style.cap_start) == StrokeCap::Round
&& style.cap_start.shape() == StrokeCap::Round
&& r > 0.0
{
path.extend(kurbo::Shape::path_elements(&kurbo::Circle::new(*p, r), FLATTEN_TOL));
@@ -309,7 +299,8 @@ pub fn variable_outline(path: &BezPath, width: f64, props: &StrokeProps, profile
}
let profile = profile.sanitized();
let lines = polylines(path)?;
let style = Style { join: props.join, miter_limit: props.miter_limit.clamp(1.0, 100.0), cap_start: props.cap_start, cap_end: props.cap_end };
let [cap_start, cap_end] = props.band_caps();
let style = Style { join: props.join, miter_limit: props.miter_limit.clamp(1.0, 100.0), cap_start, cap_end };
let pattern = dash_pattern(props);
let mut budget = MAX_WIDTH_SAMPLES;
let mut out = BezPath::new();
+1
View File
@@ -254,6 +254,7 @@ fn cached_stroke_outline(n: &Node, path: &BezPath, closed: bool) -> Arc<BezPath>
d.to_bits().hash(&mut h);
}
props.dashes.len().hash(&mut h);
props.dash_cap.map(|c| c as u8).hash(&mut h);
// Points' own caps and joins and the fill rule Inside/Outside clip by (vector networks).
let net = match &n.kind {
NodeKind::Vector { network } => Some(network),
+15
View File
@@ -25,6 +25,21 @@ fn render(d: &figcraft_doc::Document, p: figcraft_doc::NodeId) -> Pixmap {
render_view(d, p, &View { origin: (0.0, 0.0), scale: 1.0, width: 100, height: 100 }, &Options::default()).unwrap()
}
/// The stroke outline is cached: changing the dash cap alone has to redraw it.
#[test]
fn changing_the_dash_cap_redraws_the_dashes() {
let (mut d, p) = line_doc(None);
let line = d.node(p).unwrap().children[0];
let n = d.get_mut(line).unwrap();
(n.stroke.weight, n.stroke.dashes) = (10.0, vec![20.0, 20.0]);
// Two 40-long patterns fit the 80-long line, so a whole dash ends at x = 60 on the page.
assert!(!dark(&render(&d, p), 62, 50), "butt: a gap");
d.get_mut(line).unwrap().stroke.dash_cap = Some(figcraft_doc::StrokeCap::Square);
assert!(dark(&render(&d, p), 62, 50), "square: 5 further");
d.get_mut(line).unwrap().stroke.dash_cap = Some(figcraft_doc::StrokeCap::None);
assert!(!dark(&render(&d, p), 62, 50));
}
#[test]
fn wedge_profile_tapers_the_stroke() {
let (d, p) = line_doc(WidthPreset::Wedge.profile());
+1 -1
View File
@@ -819,7 +819,7 @@ impl W<'_> {
}
_ => return None,
};
let cap = match k.cap_start {
let cap = match k.band_caps()[0] {
StrokeCap::Round => " stroke-linecap=\"round\"",
StrokeCap::Square => " stroke-linecap=\"square\"",
_ => "",
+24
View File
@@ -367,6 +367,30 @@ fn dashed(kind: NodeKind, dashes: Vec<f64>) -> String {
export_svg(&d, &[n], &SvgOptions { simplify_stroke: true, ..SvgOptions::default() }).unwrap()
}
/// The `stroke-linecap` a dashed or solid line with a round start cap and `dash_cap` writes.
fn linecap(dashes: Vec<f64>, dash_cap: Option<figcraft_doc::StrokeCap>) -> Option<String> {
let mut d = Document::new("dash cap");
let p = d.first_page().unwrap();
let n = d.create(p, NodeKind::Line, "Line", 0.0, 0.0, 100.0, 0.0).unwrap();
let node = d.get_mut(n).unwrap();
node.strokes = vec![Paint::solid(Color::from_hex("000000").unwrap())];
node.stroke.align = figcraft_doc::StrokeAlign::Center;
(node.stroke.cap_start, node.stroke.cap_end) = (figcraft_doc::StrokeCap::Round, figcraft_doc::StrokeCap::Round);
(node.stroke.dashes, node.stroke.dash_cap) = (dashes, dash_cap);
let svg = export_svg(&d, &[n], &SvgOptions { simplify_stroke: true, ..SvgOptions::default() }).unwrap();
assert!(svg.contains("stroke-width"), "a stroke, not an outline: {svg}");
svg.split(" stroke-linecap=\"").nth(1).and_then(|r| r.split('"').next()).map(str::to_string)
}
#[test]
fn a_dash_cap_writes_the_line_cap_of_a_dashed_stroke() {
use figcraft_doc::StrokeCap::{None as Butt, Square};
assert_eq!(linecap(vec![4.0, 4.0], None).as_deref(), Some("round"), "unset: the end point cap");
assert_eq!(linecap(vec![4.0, 4.0], Some(Square)).as_deref(), Some("square"));
assert_eq!(linecap(vec![4.0, 4.0], Some(Butt)), None);
assert_eq!(linecap(vec![], Some(Square)).as_deref(), Some("round"), "solid: it doesn't apply");
}
/// A viewer draws what the canvas does: an open line's dashes fitted and started half a dash in.
#[test]
fn a_dashed_line_exports_as_the_canvas_draws_it() {
+1
View File
@@ -392,6 +392,7 @@ status = "draft — needs native review"
"Dark" = "داكن"
"Darken" = "تعتيم"
"Dash" = "شرطة"
"Dash cap" = "طرف الشرطة"
"Dashes" = "الشرطات"
"Dashes take numbers separated by commas" = "تقبل الشرطات أرقامًا مفصولة بفواصل"
"Dashboard" = "لوحة المعلومات"
+1
View File
@@ -372,6 +372,7 @@ status = "draft — needs native review"
"Dark" = "ডার্ক"
"Darken" = "ডার্কেন"
"Dash" = "ড্যাশ"
"Dash cap" = "ড্যাশ ক্যাপ"
"Dashes" = "ড্যাশগুলো"
"Dashes take numbers separated by commas" = "ড্যাশগুলো কমা দিয়ে আলাদা করা সংখ্যা হবে"
"Dashboard" = "ড্যাশবোর্ড"
+1
View File
@@ -395,6 +395,7 @@ status = "draft — needs native review"
"Dark" = "Tamna"
"Darken" = "Zatamni"
"Dash" = "Crtica"
"Dash cap" = "Završetak crtice"
"Dashes" = "Crtice"
"Dashes take numbers separated by commas" = "Crtice su brojevi odvojeni zarezima"
"Dashboard" = "Kontrolna tabla"
+1
View File
@@ -362,6 +362,7 @@ status = "draft — needs native review"
"Dark" = "Tmavý"
"Darken" = "Ztmavit"
"Dash" = "Čárkování"
"Dash cap" = "Zakončení čárek"
"Dashes" = "Čárkování"
"Dashes take numbers separated by commas" = "Čárkování zadejte jako čísla oddělená čárkami"
"Dashboard" = "Přehled"
+1
View File
@@ -362,6 +362,7 @@ status = "draft — needs native review"
"Dark" = "Dunkel"
"Darken" = "Abdunkeln"
"Dash" = "Gestrichelt"
"Dash cap" = "Strichende"
"Dashes" = "Striche"
"Dashes take numbers separated by commas" = "Striche sind Zahlen, durch Kommas getrennt"
"Decoration style" = "Linienstil"
+1
View File
@@ -400,6 +400,7 @@ name = "English"
"Dark" = "Dark"
"Darken" = "Darken"
"Dash" = "Dash"
"Dash cap" = "Dash cap"
"Dashes" = "Dashes"
"Dashes take numbers separated by commas" = "Dashes take numbers separated by commas"
"Dashboard" = "Dashboard"
+1
View File
@@ -396,6 +396,7 @@ status = "draft — needs native review"
"Dark" = "Oscuro"
"Darken" = "Oscurecer"
"Dash" = "Discontinuo"
"Dash cap" = "Remate de guion"
"Dashes" = "Guiones"
"Dashes take numbers separated by commas" = "Los guiones son números separados por comas"
"Dashboard" = "Panel de control"
+1
View File
@@ -396,6 +396,7 @@ status = "draft — needs native review"
"Dark" = "Tumma"
"Darken" = "Tummenna"
"Dash" = "Katkoviiva"
"Dash cap" = "Katkoviivan pää"
"Dashes" = "Katkoviivat"
"Dashes take numbers separated by commas" = "Anna katkoviivat pilkuilla erotettuina lukuina"
"Dashboard" = "Koontinäkymä"
+1
View File
@@ -372,6 +372,7 @@ status = "draft — needs native review"
"Dark" = "Sombre"
"Darken" = "Assombrir"
"Dash" = "Tirets"
"Dash cap" = "Extrémité des tirets"
"Dashes" = "Tirets"
"Dashes take numbers separated by commas" = "Les tirets sont des nombres séparés par des virgules"
"Dashboard" = "Tableau de bord"
+1
View File
@@ -377,6 +377,7 @@ status = "draft — needs native review"
"Dark" = "Gelap"
"Darken" = "Gelapkan"
"Dash" = "Putus-putus"
"Dash cap" = "Ujung garis putus"
"Dashes" = "Garis putus"
"Dashes take numbers separated by commas" = "Garis putus berupa angka yang dipisahkan koma"
"Dashboard" = "Dasbor"
+1
View File
@@ -356,6 +356,7 @@ status = "draft — needs native review"
"Dark" = "Scuro"
"Darken" = "Scurisci"
"Dash" = "Tratteggio"
"Dash cap" = "Estremità tratto"
"Dashes" = "Trattini"
"Dashes take numbers separated by commas" = "I trattini sono numeri separati da virgole"
"Dashboard" = "Dashboard"
+1
View File
@@ -370,6 +370,7 @@ status = "draft — needs native review"
"Dark" = "ダーク"
"Darken" = "比較(暗)"
"Dash" = "破線"
"Dash cap" = "破線の端"
"Dashes" = "ダッシュ"
"Dashes take numbers separated by commas" = "ダッシュはカンマ区切りの数値で入力します"
"Dashboard" = "ダッシュボード"
+1
View File
@@ -377,6 +377,7 @@ status = "draft — reviewed by a native speaker, awaiting a maintainer's approv
"Dark" = "ბნელი"
"Darken" = "ჩამუქება"
"Dash" = "ტირე"
"Dash cap" = "ტირის ბოლო"
"Dashes" = "ტირეები"
"Dashes take numbers separated by commas" = "ტირეები მძიმით გამოყოფილი რიცხვებია"
"Dashboard" = "სამართავი დაფა"
+1
View File
@@ -358,6 +358,7 @@ status = "draft — needs native review"
"Dark" = "다크"
"Darken" = "어둡게 하기"
"Dash" = "대시"
"Dash cap" = "대시 끝 모양"
"Dashes" = "대시"
"Dashes take numbers separated by commas" = "대시는 쉼표로 구분한 숫자로 입력하세요"
"Decoration style" = "장식 스타일"
+1
View File
@@ -374,6 +374,7 @@ status = "draft — needs native review"
"Dark" = "Donker"
"Darken" = "Darken"
"Dash" = "Streep"
"Dash cap" = "Streepuiteinde"
"Dashes" = "Streepjes"
"Dashes take numbers separated by commas" = "Streepjes zijn getallen gescheiden door komma's"
"Dashboard" = "Dashboard"
+1
View File
@@ -373,6 +373,7 @@ name = "Polski"
"Dark" = "Ciemny"
"Darken" = "Ciemniej"
"Dash" = "Kreska"
"Dash cap" = "Zakończenie kreski"
"Dashes" = "Kreski"
"Dashes take numbers separated by commas" = "Kreski to liczby oddzielone przecinkami"
"Dashboard" = "Pulpit"
+1
View File
@@ -357,6 +357,7 @@ status = "draft — needs native review"
"Dark" = "Escuro"
"Darken" = "Escurecer"
"Dash" = "Tracejado"
"Dash cap" = "Terminação do traço"
"Dashes" = "Traços"
"Dashes take numbers separated by commas" = "Os traços são números separados por vírgulas"
"Decoration style" = "Estilo da decoração"
+1
View File
@@ -373,6 +373,7 @@ name = "Русский"
"Dark" = "Тёмная"
"Darken" = "Затемнение"
"Dash" = "Штрих"
"Dash cap" = "Конец штриха"
"Dashes" = "Штрихи"
"Dashes take numbers separated by commas" = "Штрихи задаются числами через запятую"
"Dashboard" = "Панель аналитики"
+1
View File
@@ -397,6 +397,7 @@ name = "Svenska"
"Dark" = "Mörk"
"Darken" = "Mörkare"
"Dash" = "Streck"
"Dash cap" = "Streckände"
"Dashes" = "Streck"
"Dashes take numbers separated by commas" = "Streck anges som tal åtskilda med kommatecken"
"Dashboard" = "Instrumentpanel"
+1
View File
@@ -371,6 +371,7 @@ status = "draft — needs native review"
"Dark" = "มืด"
"Darken" = "Darken"
"Dash" = "เส้นประ"
"Dash cap" = "ปลายเส้นประ"
"Dashes" = "เส้นประ"
"Dashes take numbers separated by commas" = "ใส่เส้นประเป็นตัวเลขคั่นด้วยจุลภาค"
"Dashboard" = "แดชบอร์ด"
+1
View File
@@ -366,6 +366,7 @@ name = "Türkçe"
"Dark" = "Koyu"
"Darken" = "Koyulaştır"
"Dash" = "Kesik çizgi"
"Dash cap" = "Kesik çizgi ucu"
"Dashes" = "Kesik çizgiler"
"Dashes take numbers separated by commas" = "Kesik çizgiler virgülle ayrılmış sayılardır"
"Dashboard" = "Pano"
+1
View File
@@ -357,6 +357,7 @@ status = "draft — needs native review"
"Dark" = "Tối"
"Darken" = "Làm tối"
"Dash" = "Nét đứt"
"Dash cap" = "Đầu nét đứt"
"Dashes" = "Các nét đứt"
"Dashes take numbers separated by commas" = "Nét đứt là các số cách nhau bằng dấu phẩy"
"Dashboard" = "Bảng điều khiển"
+1
View File
@@ -362,6 +362,7 @@ status = "draft — needs native review"
"Dark" = "深色"
"Darken" = "变暗"
"Dash" = "虚线"
"Dash cap" = "虚线端点"
"Dashes" = "虚线"
"Dashes take numbers separated by commas" = "虚线为以逗号分隔的数字"
"Decoration style" = "装饰线样式"
+36 -1
View File
@@ -3,7 +3,7 @@
//! imported `[4, 2, 1, 2]` stays whole.
use egui::{Event, Id, Key, Modifiers};
use figcraft_doc::NodeId;
use figcraft_doc::{NodeId, StrokeCap};
use figcraft_engine::Session;
use serde_json::json;
@@ -152,3 +152,38 @@ fn a_single_value_pattern_is_the_dash_and_the_gap() {
type_into(&mut u, "stroke_dash", "7");
assert_eq!(dashes(&u, r), [7.0, 2.0]);
}
/// Both dashed styles offer the Dash cap, and picking one sets it. Solid has no dashes to cap.
#[test]
fn the_dash_cap_shows_for_dash_and_custom_strokes() {
let has = |u: &mut Ui, text: &str| labelled(u).iter().any(|(t, _)| t == text);
let cap = |u: &Ui, r| u.app.session.doc().unwrap().node(r).unwrap().stroke.dash_cap;
let (mut u, _) = scene(json!([]));
assert!(!has(&mut u, "Dash cap"));
let (mut u, r) = scene(json!([6, 3]));
assert!(has(&mut u, "Dash cap"));
assert_eq!(cap(&u, r), None, "showing it changes nothing");
pick_style(&mut u, "None", "Round");
assert_eq!(cap(&u, r), Some(StrokeCap::Round));
pick_style(&mut u, "Dash", "Custom");
assert!(has(&mut u, "Dash cap"));
pick_style(&mut u, "Round", "Square");
assert_eq!(cap(&u, r), Some(StrokeCap::Square));
assert_eq!(dashes(&u, r), [6.0, 3.0]);
}
/// Unset, the setting shows the end point caps the dashes take.
#[test]
fn an_unset_dash_cap_shows_what_draws() {
let (mut u, r) = scene(json!([6, 3]));
let caps = |u: &mut Ui, start: &str, end: &str| {
u.app.run("node.set", json!({"ids": [r], "props": {"startCap": start, "endCap": end}}));
u.frame(vec![]);
u.frame(vec![]);
};
caps(&mut u, "round", "round");
assert!(labelled(&mut u).iter().any(|(t, _)| t == "Round"));
caps(&mut u, "round", "square");
assert!(labelled(&mut u).iter().any(|(t, _)| t == "Mixed"));
assert_eq!(u.app.session.doc().unwrap().node(r).unwrap().stroke.dash_cap, None);
}
+25 -9
View File
@@ -280,6 +280,27 @@ fn dash_settings(app: &mut FigApp, ui: &mut Ui, ids: &[NodeId], dashes: Option<&
}
}
/// A dropdown of `caps` setting the prop `key`, showing `shown` (`None`: mixed).
fn cap_dropdown(app: &mut FigApp, ui: &mut Ui, ids: &[NodeId], key: &str, shown: Option<StrokeCap>, caps: &[StrokeCap], width: f32) {
let resp = widgets::dropdown(ui, shown.map(|c| tr(c.label())).unwrap_or(tr("Mixed")), width, None);
menu_popup(&resp).show(|ui| {
for &c in caps {
if menu_item_with(ui, tr(c.label()), "", CAP_ART_W, |p, r, col| cap_art(p, r, col, c, key == "startCap")) {
app.set_props(ids, json!({key: format!("{c:?}")}));
ui.close();
}
}
});
}
/// The cap of a dashed stroke's dashes. Unset, dashes take the end point caps: it shows those.
fn dash_cap_row(app: &mut FigApp, ui: &mut Ui, ids: &[NodeId], stroke: &StrokeProps) {
let [start, end] = stroke.band_caps().map(StrokeCap::shape);
widgets::sublabels(ui, tr("Dash cap"), None);
let half = widgets::col_width(ui.available_width());
row(ui, 32.0, |ui| cap_dropdown(app, ui, ids, "dashCap", (start == end).then_some(start), &StrokeCap::DASH, half));
}
/// Advanced stroke settings: dash pattern, width profile, join and the end points of open paths.
pub(super) fn stroke_settings(app: &mut FigApp, ui: &mut Ui, doc: &Document, sel: &[NodeId]) {
let ids = sel.to_vec();
@@ -287,6 +308,9 @@ pub(super) fn stroke_settings(app: &mut FigApp, ui: &mut Ui, doc: &Document, sel
let st = common(doc, sel, |n| n.stroke.clone());
let weight = st.as_ref().map(|s| s.weight).unwrap_or(1.0).max(1.0);
dash_settings(app, ui, &ids, st.as_ref().map(|s| s.dashes.as_slice()), weight);
if let Some(s) = st.as_ref().filter(|s| !s.dashes.is_empty()) {
dash_cap_row(app, ui, &ids, s);
}
width_profile_row(app, ui, &ids, st.as_ref().map(|s| s.profile().cloned()));
brush_row(app, ui, &ids, st.as_ref().map(|s| s.brush.clone()), ui.available_width());
let join = st.as_ref().map(|s| s.join);
@@ -313,15 +337,7 @@ pub(super) fn stroke_settings(app: &mut FigApp, ui: &mut Ui, doc: &Document, sel
widgets::sublabels(ui, tr("Start point"), Some(tr("End point")));
row(ui, 32.0, |ui| {
for (key, cap) in [("startCap", st.as_ref().map(|s| s.cap_start)), ("endCap", st.as_ref().map(|s| s.cap_end))] {
let resp = widgets::dropdown(ui, cap.map(|c| tr(c.label())).unwrap_or(tr("Mixed")), half, None);
menu_popup(&resp).show(|ui| {
for c in StrokeCap::ALL {
if menu_item_with(ui, tr(c.label()), "", CAP_ART_W, |p, r, col| cap_art(p, r, col, c, key == "startCap")) {
app.set_props(&ids, json!({key: format!("{c:?}")}));
ui.close();
}
}
});
cap_dropdown(app, ui, &ids, key, cap, &StrokeCap::ALL, half);
}
});
}
+3 -2
View File
@@ -1,6 +1,6 @@
# File format parity
> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-30 · **Change:** minor (pasted layers whose image the clipboard lacks are named and show a placeholder that reads as missing (#1646); paint-style colours on layers pasted from another design app keep their style instead of turning black (#1638); imported and pasted text finds an installed family under the other names its fonts give it, and with other case and spacing (#1292); `.fig` export writes colour calculations as the colour they show in each mode and leaves out, with a warning, one that resolves to nothing (#1280); `.fig` import budgets four decoded values per compressed byte instead of one, still within 20M–128M, so dense files within that budget open instead of being refused as having too many values (#574); `.fig` ZIP image reads share a total byte budget and count skipped images in the import warnings (#1076); every file a command reads whole by path is a regular file within a cap that fits it: documents 16 GiB, placed images 256 MiB, CLI scripts 1 GiB; folders, devices and pipes are refused before anything is read (#1114); files nested 256 levels deep import on the browser's 1 MB stack (#1101); SVG and PDF exports count pattern sources and the renders they embed from the layer's depth, so one cap bounds a whole export (#1082); opening a `.figcraft` file unpacks at most 6 GiB in all (2 GiB in the browser), every area together (#1079); `.fig` instance expansion no longer walks variable nodes for every instance or copies every outer override row into each nested instance (#1077); a FigJam `.jam` file opens as a board (#1342); FigJam `.jam` files open like `.fig` files: File ▸ Open, drag and drop, `file.import` and the command line (#1342); SVG import reads `color()` and `color-mix()` colours, with a note and a grey for those it can't (#1550); SVG import draws each pattern tile once per scale, within the layer budget (#1080); a file's JPEG thumbnail can't crash the home screen (#1491's progressive JPEG check, now shared); `.fig` import: library text styles linked by asset key, and override rows restating a nested level (#1234); `.fig` import keeps the file's saved line breaks while the text and its box are unedited (#1236); Download a missing free font from the Missing fonts window (`font.download`, #754); offline Penpot 2.x import/export (#1143); `.sketch` import checked against Sketch's renders: master backgrounds, symbol clipping, tints before Sketch 64, mask opacity, bitmap fills, image overrides on shapes, stretching stacks, blur radii, colour adjustments, OKLab gradients, text growth, shape group borders (#1389); FigJam outer shape outlines and sticky body fallback beside author text (#1341, #1397); image member names are checked on read, and REST image placeholders get archive-safe keys (#1086); Android Vector Drawable (XML) export; `.fig` export: files the incumbent imports, with component set properties, and grid track sizes at its field ids; Save and auto-save don't replace changes another app made to the open file (#1330); `.sketch` import, best effort, Sketch 48 and newer; the PNG row names ignore overlapping layers and where include bounding box is offered; the Fonts table's missing-font row describes the Missing fonts window; `.figcraft` carries the file kind (boards); `.fig` import: resized instances and variable modes by asset key; atomic saves; auto-save; SVG export clip of nested frames; SVG/PDF export depth refusal (#208); `.fig` export: instance sizes, variables with modes, shared styles, variable-width strokes; `.fig` export: written files checked by opening them in other software; experimental `.fig` export; export and inspect behaviour suite and fixes; .fig import: editable vector networks and more layer kinds; embedded image colour profiles; `.fig` import, best effort; animated GIF fills and TIFF images; last major: created, formats from the installed Figma's Info.plist and help center, ours from source; REST import keeps slot layers and instances' slot content (#792); REST import reads grid track sizes (#465); `.fig` import fidelity from the community batches, budgets for large files, the import report, a `.fig` copy from Save as, paste from another design app; instance layers' size and resizing and Hug on import (#731, #732)) · **Target:** Figma Design (desktop app 126.10.7)
> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-31 · **Change:** minor (a Dash cap setting (none, round or square) on Dash and Custom strokes, drawn on both ends of every dash; pasted layers whose image the clipboard lacks are named and show a placeholder that reads as missing (#1646); paint-style colours on layers pasted from another design app keep their style instead of turning black (#1638); imported and pasted text finds an installed family under the other names its fonts give it, and with other case and spacing (#1292); `.fig` export writes colour calculations as the colour they show in each mode and leaves out, with a warning, one that resolves to nothing (#1280); `.fig` import budgets four decoded values per compressed byte instead of one, still within 20M–128M, so dense files within that budget open instead of being refused as having too many values (#574); `.fig` ZIP image reads share a total byte budget and count skipped images in the import warnings (#1076); every file a command reads whole by path is a regular file within a cap that fits it: documents 16 GiB, placed images 256 MiB, CLI scripts 1 GiB; folders, devices and pipes are refused before anything is read (#1114); files nested 256 levels deep import on the browser's 1 MB stack (#1101); SVG and PDF exports count pattern sources and the renders they embed from the layer's depth, so one cap bounds a whole export (#1082); opening a `.figcraft` file unpacks at most 6 GiB in all (2 GiB in the browser), every area together (#1079); `.fig` instance expansion no longer walks variable nodes for every instance or copies every outer override row into each nested instance (#1077); a FigJam `.jam` file opens as a board (#1342); FigJam `.jam` files open like `.fig` files: File ▸ Open, drag and drop, `file.import` and the command line (#1342); SVG import reads `color()` and `color-mix()` colours, with a note and a grey for those it can't (#1550); SVG import draws each pattern tile once per scale, within the layer budget (#1080); a file's JPEG thumbnail can't crash the home screen (#1491's progressive JPEG check, now shared); `.fig` import: library text styles linked by asset key, and override rows restating a nested level (#1234); `.fig` import keeps the file's saved line breaks while the text and its box are unedited (#1236); Download a missing free font from the Missing fonts window (`font.download`, #754); offline Penpot 2.x import/export (#1143); `.sketch` import checked against Sketch's renders: master backgrounds, symbol clipping, tints before Sketch 64, mask opacity, bitmap fills, image overrides on shapes, stretching stacks, blur radii, colour adjustments, OKLab gradients, text growth, shape group borders (#1389); FigJam outer shape outlines and sticky body fallback beside author text (#1341, #1397); image member names are checked on read, and REST image placeholders get archive-safe keys (#1086); Android Vector Drawable (XML) export; `.fig` export: files the incumbent imports, with component set properties, and grid track sizes at its field ids; Save and auto-save don't replace changes another app made to the open file (#1330); `.sketch` import, best effort, Sketch 48 and newer; the PNG row names ignore overlapping layers and where include bounding box is offered; the Fonts table's missing-font row describes the Missing fonts window; `.figcraft` carries the file kind (boards); `.fig` import: resized instances and variable modes by asset key; atomic saves; auto-save; SVG export clip of nested frames; SVG/PDF export depth refusal (#208); `.fig` export: instance sizes, variables with modes, shared styles, variable-width strokes; `.fig` export: written files checked by opening them in other software; experimental `.fig` export; export and inspect behaviour suite and fixes; .fig import: editable vector networks and more layer kinds; embedded image colour profiles; `.fig` import, best effort; animated GIF fills and TIFF images; last major: created, formats from the installed Figma's Info.plist and help center, ours from source; REST import keeps slot layers and instances' slot content (#792); REST import reads grid track sizes (#465); `.fig` import fidelity from the community batches, budgets for large files, the import report, a `.fig` copy from Save as, paste from another design app; instance layers' size and resizing and Hug on import (#731, #732)) · **Target:** Figma Design (desktop app 126.10.7)
Every format Figma reads or writes, with FigCraft's support. Sources: `Info.plist` of
`/Applications/Figma.app` (document types only; nothing inside the bundle's code was read), Figma's
@@ -135,7 +135,7 @@ source paint coordinates.
out), set-variable, set-variable-mode and conditional actions, scroll-to offsets, text decoration
looks, variable font axes, text on a path, nested list levels, list spacing, hanging punctuation,
leading trim, OpenType features, per-layer operations inside boolean groups, different stroke ends
on lines. Not written at all: stroke outlines of dashed and per-side strokes and of inside or
on lines, the Dash cap (no public name for it). Not written at all: stroke outlines of dashed and per-side strokes and of inside or
outside strokes on polygons, stars and vectors, glyph outlines of text (a reader lays text out
with its own fonts), version history and branches.
- **Checked by opening written files in other software:** the example documents and a small test
@@ -187,6 +187,7 @@ source paint coordinates.
| Date | Change | Summary |
|---|---|---|
| 2026-10-31 | patch | M4: Dash and Custom strokes have a Dash cap (None, Round, Square) in the advanced stroke settings and `node.set {dashCap}`, on both ends of every dash on the canvas and in PNG, PDF and SVG. Unset, dashes keep taking the end point caps, so older files look the same; the saved file writes it only when set. Numbers unchanged |
| 2026-10-30 | minor | `.fig` import: a connector's label without a saved snapshot was skipped; now the connector imports as a frame holding its line and the label, as a connector with a saved snapshot does, the label centred on the line's middle by length (in a 200-wide box, laid out again with the fonts at hand), in the connector's fill paints or black. Its background isn't known without the snapshot and is left out. Not checked on real files. Test: `fig::tests::tail::a_labelled_connector_without_a_snapshot_keeps_its_label` |
| 2026-10-30 | minor | Pasted content row: an image the clipboard doesn't carry keeps its name and shows a placeholder that reads as missing; the paste names those layers in its message and opens the import report with its notes (#1646). Numbers unchanged |
| 2026-10-30 | minor | Pasted content row: styles a pasted layer links by its library's guid are found by their library asset, so paint-style colours on pasted components and instances no longer import as black; a link to a style that isn't there keeps the stored colour, with a note (#1638). Numbers unchanged |
+3
View File
@@ -764,6 +764,9 @@ On the canvas (`pointer`), dragging points, handles and pen clicks snap to other
midpoints (this vector's and other vectors'), the usual layer targets and the pixel grid; `cmd`
held while dragging turns the object snapping off.
Dash caps: `node.set {dashCap}` takes `none`, `round` or `square` for both ends of every dash of a
dashed stroke, and `null` clears it (dashes then take `startCap` and `endCap`, the default).
Variable width strokes: `stroke.widthProfile {ids?, preset?: uniform|wedge|taper|eye|rounded|pinch,
points?: [[t, scale], ...]}` (also `node.set {widthProfile}`; `t` runs 0–1 along each subpath by
length, `scale` multiplies the stroke weight, widths in between are interpolated smoothly; no
+2 -1
View File
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long