mirror of
https://github.com/storytold/figcraft.git
synced 2026-10-31 15:49:48 +00:00
Merge main into #1735
This commit is contained in:
commit
00a54c9ee9
48 files changed
+393
-51
No files matched your search
+2
-1
@@ -2,7 +2,7 @@
|
||||
|
||||
**Stage: alpha** · next: beta, ~26 points and ~162–292 h away (one Opus 5.5 agent; ~33–67 h wall-clock with 5 agents)
|
||||
|
||||
> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-30 · **Change:** minor (library analytics count insertions and detachments of styles and variables; library analytics list how many layers and files use each style and variable; the Controller menu names each button as PlayStation and Switch controllers do; widgets draw lines and SVG pictures (plugin API 6); a connector end dropped along a side of a shape attaches to that side (#1708); regression tests pin a rounded shape's one-sided stroke and SVG ids and font families without XML-forbidden characters (#1351, #1124); a damaged image is refused with a reason instead of showing as an empty box (#1474); an image copied in another app pastes again on the desktop, and in the browser for the first time (#492); SwiftUI and Compose code names a bound variable by its iOS or Android code syntax; the colour picker's Image tab shows a placeholder and Upload from computer, an asset preview, and a file dropped on it replaces the image, keeping the fill mode, crop and position (#1682); the Frame tool's Phone presets have the iPhone 17 family (#1691); Show in folder works in the Flatpak build and selects the file on Linux (#1701); a dashed line starts and ends with a half-length dash, on the canvas and in exports; the Pages, Layers and Assets search fields keep their text in the middle of the field (#1703); a board connector's path can be reshaped by dragging its path handles (#1710); boards have 16 more shapes (arrows, polygons, star, plus and flowchart symbols), in pickers that lay them out in a grid; library analytics count insertions and show insertions and detachments over the past 30 days, 90 days or year; a stroke's Custom style takes a dash pattern of any length as a comma-separated list (#1681); a control request can set its own timeout, up to 10 minutes (`timeoutMs`; `figcraft-cli mcp --attach PORT --timeout SECONDS`) (#1696); gap 6: a widget opens its plugin's panel (plugin API 5), whose callbacks redraw it; the keyboard's Menu key opens the context menu, as Shift+F10 does; an Open link action can open in place of the prototype, with "Open in new tab" off (gap 15); a collection of this file can be extended (Extend collection on its row, `variable.extendCollection`), its extension following its edits at once, and extended collections extend again in the file; the eyedropper with Cmd/Ctrl+Shift makes a colour style or variable of what it picks; a live session's welcome sends the document compressed to clients that read it, about seven times smaller (#1665); a control request that times out says truthfully whether it was dropped or is still running and will take effect, and only screenshots and input blame the window (#1673); a board keeps earlier votes' results, listed newest first in Results and deleted one at a time; the object a stamp will stick to is outlined while the stamp tool is over it or a stamp is dragged over it, and board objects take no drops (gap 24); a server can list the files shared from it now for everyone who reaches it, Home ▸ Online shows them and their links carry no secret (`team_files`, part of #742); team library analytics count detached instances per component and file (gap 10); skipping the Open link notice by command is saved with the preferences at once (gap 15); a selected connector's bar on a board sets its start and end points, solid or dashed, its weight and any colour (#1596); cells copied from a spreadsheet paste onto a board as a table; a long layer name stays on one line in the Export button and the Dev Mode header, ending with an ellipsis, and no longer covers the Design panel (#1650); with hundreds of layers selected, the Design panel and the selection overlays stop going over the whole selection on every frame, so dragging 648 layers averages about 1.1 ms of UI per frame (#1571); plugin panels show pictures: an `image` control, base64 within 256 KB and 4,096 px a side, named by its label (gap 6); `.fig` connector labels without a saved snapshot are kept, centred on their line (gap 1); a drag moving many layers re-runs only what sits around them, not each layer's bindings, sizing, text and instance checks, on every move (#1571); damaged font tracking table offsets are checked instead of wrapping on 32-bit targets, and font indexing makes its existing offset bounds explicit (#1125); Bosnian (`bs`) draft catalog; text style dedupe is O(spans) via a last-two cache and fingerprint buckets (#1102); 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); progress docs cleaned of what merges left twice: rows merged into one, repeated status items removed, and `cargo xtask docs` (in `ci`) fails on a repeated table row or status item; a name typed for a variable or collection finds the one with that exact id before one named like it, and stored mode ids, fLine truncated
|
||||
> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-30 · **Change:** minor (library analytics count insertions and detachments of styles and variables; a Dash cap setting (none, round or square) on Dash and Custom strokes, drawn on both ends of every dash; library analytics list how many layers and files use each style and variable; the Controller menu names each button as PlayStation and Switch controllers do; widgets draw lines and SVG pictures (plugin API 6); a connector end dropped along a side of a shape attaches to that side (#1708); regression tests pin a rounded shape's one-sided stroke and SVG ids and font families without XML-forbidden characters (#1351, #1124); a damaged image is refused with a reason instead of showing as an empty box (#1474); an image copied in another app pastes again on the desktop, and in the browser for the first time (#492); SwiftUI and Compose code names a bound variable by its iOS or Android code syntax; the colour picker's Image tab shows a placeholder and Upload from computer, an asset preview, and a file dropped on it replaces the image, keeping the fill mode, crop and position (#1682); the Frame tool's Phone presets have the iPhone 17 family (#1691); Show in folder works in the Flatpak build and selects the file on Linux (#1701); a dashed line starts and ends with a half-length dash, on the canvas and in exports; the Pages, Layers and Assets search fields keep their text in the middle of the field (#1703); a board connector's path can be reshaped by dragging its path handles (#1710); boards have 16 more shapes (arrows, polygons, star, plus and flowchart symbols), in pickers that lay them out in a grid; library analytics count insertions and show insertions and detachments over the past 30 days, 90 days or year; a stroke's Custom style takes a dash pattern of any length as a comma-separated list (#1681); a control request can set its own timeout, up to 10 minutes (`timeoutMs`; `figcraft-cli mcp --attach PORT --timeout SECONDS`) (#1696); gap 6: a widget opens its plugin's panel (plugin API 5), whose callbacks redraw it; the keyboard's Menu key opens the context menu, as Shift+F10 does; an Open link action can open in place of the prototype, with "Open in new tab" off (gap 15); a collection of this file can be extended (Extend collection on its row, `variable.extendCollection`), its extension following its edits at once, and extended collections extend again in the file; the eyedropper with Cmd/Ctrl+Shift makes a colour style or variable of what it picks; a live session's welcome sends the document compressed to clients that read it, about seven times smaller (#1665); a control request that times out says truthfully whether it was dropped or is still running and will take effect, and only screenshots and input blame the window (#1673); a board keeps earlier votes' results, listed newest first in Results and deleted one at a time; the object a stamp will stick to is outlined while the stamp tool is over it or a stamp is dragged over it, and board objects take no drops (gap 24); a server can list the files shared from it now for everyone who reaches it, Home ▸ Online shows them and their links carry no secret (`team_files`, part of #742); team library analytics count detached instances per component and file (gap 10); skipping the Open link notice by command is saved with the preferences at once (gap 15); a selected connector's bar on a board sets its start and end points, solid or dashed, its weight and any colour (#1596); cells copied from a spreadsheet paste onto a board as a table; a long layer name stays on one line in the Export button and the Dev Mode header, ending with an ellipsis, and no longer covers the Design panel (#1650); with hundreds of layers selected, the Design panel and the selection overlays stop going over the whole selection on every frame, so dragging 648 layers averages about 1.1 ms of UI per frame (#1571); plugin panels show pictures: an `image` control, base64 within 256 KB and 4,096 px a side, named by its label (gap 6); `.fig` connector labels without a saved snapshot are kept, centred on their line (gap 1); a drag moving many layers re-runs only what sits around them, not each layer's bindings, sizing, text and instance checks, on every move (#1571); damaged font tracking table offsets are checked instead of wrapping on 32-bit targets, and font indexing makes its existing offset bounds explicit (#1125); Bosnian (`bs`) draft catalog; text style dedupe is O(spans) via a last-two cache and fingerprint buckets (#1102); 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); progress docs cleaned of what merges left twice: rows merged into one, repeated status items removed, and `cargo xtask docs` (in `ci`) fails on a repeated table row or status item; a name typed for aLine truncated
|
||||
|
||||
FigCraft is a clean-room, open-source, pure-Rust reimplementation of the Figma design workflow,
|
||||
native on macOS, Windows, Linux and FreeBSD and in the browser, fully drivable by agents through
|
||||
@@ -359,6 +359,7 @@ Ranked; detail and milestones in [`docs/roadmap.md`](docs/roadmap.md).
|
||||
| Date | Change | Summary |
|
||||
|---|---|---|
|
||||
| 2026-10-31 | minor | M3, gap 10: library analytics count insertions and detachments of styles and variables. Applying one, or copying or duplicating content that uses it, inserts it; the Detach button detaches it; an instance counts as its component alone. Show usage and `library.usage` give them per style and variable over the chosen period. Modes aren't counted. Numbers unchanged. Tests: `library_asset_counts_tests`, `library_assets_tests`. |
|
||||
| 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-31 | minor | M3, gap 10: library analytics cover styles and variables. A file reports how many of its layers use each of a library's styles and variables, and Show usage (`library.usage`) lists them beside the components, by a Type choice, with their layers and files. Layers inside instances count for their component. Insertions, detachments and modes aren't counted for them yet. Numbers unchanged. Tests: `library_assets_tests`. |
|
||||
| 2026-10-31 | minor | M5: a key/gamepad trigger's Controller menu shows, beside each button, what PlayStation and Nintendo Switch controllers call it ("Gamepad A" is ✕ and B). Triggers are stored by the button's place, as before. |
|
||||
| 2026-10-31 | minor | M11, gap 6: widgets draw lines and SVG pictures, as a Figma widget's `Line` and `SVG` do (plugin API 6; older plugins unchanged). A line is 100 long, black and 1 thick unless told otherwise, and turns around its top-left corner. An SVG comes in as locked layers, scaled to fit a size, within the widget's 256 layers. The Counter example uses both. Numbers unchanged. Tests: `widget_shapes_tests`, `widget_tests`. |
|
||||
|
||||
+15
-5
@@ -157,7 +157,7 @@ impl Color {
|
||||
return Self::from_hex(&t);
|
||||
}
|
||||
if let Some(inner) = t.strip_prefix("rgba(").or_else(|| t.strip_prefix("rgb(")).and_then(|r| r.strip_suffix(')')) {
|
||||
let parts = css_args(inner);
|
||||
let parts = css_args(inner)?;
|
||||
let num = |i: usize| -> Option<f32> {
|
||||
let p = parts.get(i)?;
|
||||
if let Some(pc) = p.strip_suffix('%') {
|
||||
@@ -176,7 +176,7 @@ impl Color {
|
||||
return Some(Self::new(num(0)?, num(1)?, num(2)?, alpha));
|
||||
}
|
||||
if let Some(inner) = t.strip_prefix("hsla(").or_else(|| t.strip_prefix("hsl(")).and_then(|r| r.strip_suffix(')')) {
|
||||
let parts = css_args(inner);
|
||||
let parts = css_args(inner)?;
|
||||
let h = parts.first()?.trim_end_matches("deg").parse::<f32>().ok()?;
|
||||
let s = parts.get(1)?.trim_end_matches('%').parse::<f32>().ok()? / 100.0;
|
||||
let l = parts.get(2)?.trim_end_matches('%').parse::<f32>().ok()? / 100.0;
|
||||
@@ -244,14 +244,24 @@ impl Color {
|
||||
}
|
||||
}
|
||||
|
||||
fn css_args(inner: &str) -> Vec<String> {
|
||||
inner.replace('/', ",").split([',', ' ']).map(str::trim).filter(|p| !p.is_empty()).map(str::to_string).collect()
|
||||
/// Split CSS function arguments on `,` and `/` (each may be surrounded by spaces) and on runs of
|
||||
/// whitespace. An empty argument between two separators makes the whole list invalid.
|
||||
fn css_args(inner: &str) -> Option<Vec<String>> {
|
||||
let mut out = Vec::new();
|
||||
for seg in inner.replace('/', ",").split(',') {
|
||||
let before = out.len();
|
||||
out.extend(seg.split_whitespace().map(str::to_string));
|
||||
if out.len() == before {
|
||||
return None;
|
||||
}
|
||||
}
|
||||
Some(out)
|
||||
}
|
||||
|
||||
/// The arguments of `lch()` / `oklch()`: lightness, chroma, hue and alpha. Percentages are of
|
||||
/// `l_full` / `c_full`; `none` is 0.
|
||||
fn css_polar(inner: &str, l_full: f32, c_full: f32) -> Option<([f32; 3], f32)> {
|
||||
let parts = css_args(inner);
|
||||
let parts = css_args(inner)?;
|
||||
if !(3..=4).contains(&parts.len()) {
|
||||
return None;
|
||||
}
|
||||
|
||||
@@ -148,3 +148,12 @@ fn css_hsl_rejects_non_finite() {
|
||||
assert_eq!(Color::parse_css(s), None, "{s}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn css_rejects_empty_arguments() {
|
||||
for s in ["rgb(255,,0,0)", "rgb(,1,2,3)", "rgb(1,2,3,)", "hsl(10,,50%,50%)", "rgb(1,2,,)", "oklch(0.5,,0.1,20)"] {
|
||||
assert_eq!(Color::parse_css(s), None, "{s}");
|
||||
}
|
||||
assert!(Color::parse_css("rgb(255 0 0 / 50%)").is_some());
|
||||
assert!(Color::parse_css("hsl(120deg 50% 50%)").is_some());
|
||||
}
|
||||
@@ -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())
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -59,7 +59,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
spec!(
|
||||
"node.create",
|
||||
"Create layer",
|
||||
"{type: rectangle|ellipse|polygon|star|line|arrow|vector|text|frame|section|component|slice, x?, y? (relative to the parent frame; groups don't count), width?, height?, parent? (id; default: the page), index?, name?, text? | characters? (for text), path? | points? (vector: SVG path data | [[x,y],..]), closed? (vector), props? {node.set keys}, select? (default true)}",
|
||||
"{type: rectangle|ellipse|polygon|star|line|arrow|vector|text|frame|section|component|slice, x?, y? (relative to the parent frame; groups don't count), width?, height?, parent? (id; default: the page), index?, name?, text? | characters? (for text), path? | points? (vector: SVG path data | [[x,y],..]; x/y move their own origin, so the layer's left/top is x/y plus their box's unscaled offset), closed? (vector), props? {node.set keys}, select? (default true)}",
|
||||
true,
|
||||
create
|
||||
),
|
||||
@@ -106,7 +106,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
spec!(
|
||||
"vector.create",
|
||||
"Vector",
|
||||
"{path (SVG path data) | points [[x,y],..], closed?, x?, y?, width?, height? (scale the path; one keeps its proportion), parent?, index?, name?, props?, select? (default true)}",
|
||||
"{path (SVG path data) | points [[x,y],..], closed?, x?, y? (move the path's own origin: the layer's left/top is x/y plus the box's unscaled offset), width?, height? (scale the path; one keeps its proportion), parent?, index?, name?, props?, select? (default true)}",
|
||||
true,
|
||||
|s, p| create_kind(s, p, "vector")
|
||||
),
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
@@ -187,15 +187,15 @@ fn tools() -> Vec<Value> {
|
||||
obj(
|
||||
json!({
|
||||
"type": {"type": "string", "enum": ["frame", "rectangle", "ellipse", "polygon", "star", "line", "arrow", "text", "vector", "section", "component", "slice"], "description": "Layer type"},
|
||||
"x": num("Left, relative to the parent frame (default 0)"),
|
||||
"y": num("Top, relative to the parent frame (default 0)"),
|
||||
"x": num("Left, relative to the parent frame (default 0). For a vector with `path` or `points`, x/y move their own origin instead: data whose box starts at (30, 0) makes a layer whose left is x + 30. Start the path at 0,0 to place its left edge at x, or set x afterwards with set_properties (which moves the left edge)"),
|
||||
"y": num("Top, relative to the parent frame (default 0). For a vector with `path` or `points`, their own top is added, as for x"),
|
||||
"width": num("Width (default 100; text hugs its content unless width is set, which makes it wrap; for a vector with path, width/height scale the path, one alone keeping its proportion, and without them the path's own box is used)"),
|
||||
"height": num("Height (default 100; ignored for lines/arrows and auto-height text)"),
|
||||
"parent": id_schema("Parent frame/group/component id (default: the current page)"),
|
||||
"index": int("Z-index among the parent's children (default: on top)"),
|
||||
"name": string("Layer name"),
|
||||
"text": string("Characters, for type text"),
|
||||
"path": string("SVG path data, for type vector (e.g. \"M0 0 L100 0 L50 80 Z\")"),
|
||||
"path": string("SVG path data, for type vector (e.g. \"M0 0 L100 0 L50 80 Z\"); its coordinates are kept relative to x/y, so a path that doesn't start at 0,0 is offset by its own box"),
|
||||
"points": {"type": "array", "items": {"type": "array", "items": {"type": "number"}}, "description": "For type vector, instead of path: the points [[x, y], ...]"},
|
||||
"closed": boolean("For type vector: close the path"),
|
||||
"select": boolean("Select the new layer (default true)"),
|
||||
|
||||
@@ -325,3 +325,28 @@ fn set_properties_passes_on_what_it_skipped() {
|
||||
assert!(c.ok("set_properties", json!({"ids": [f], "props": {"gap": 4}})).get("warnings").is_none());
|
||||
assert!(c.err("set_properties", json!({"ids": [t], "props": {"gap": 4}})).contains("auto layout"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn create_node_x_y_offset_a_vector_path_by_its_own_box_as_documented() {
|
||||
// An agent placed `M95 0 L225 0 ...` (box starting at x 30) with x 830 expecting its left at
|
||||
// 830: x/y move the path's own origin, so the layer lands at 860. The schema has to say so.
|
||||
let mut c = Client::new();
|
||||
let lid = "M95 0 L225 0 L290 130 L232 470 L88 470 L30 130 Z";
|
||||
let offset = c.create(json!({"type": "vector", "path": lid, "x": 830, "y": 445, "width": 260, "height": 470}));
|
||||
let n = c.node(&offset);
|
||||
assert_eq!((n["x"].as_f64(), n["y"].as_f64(), n["width"].as_f64()), (Some(860.0), Some(445.0), Some(260.0)), "{n}");
|
||||
// The same shape drawn from 0,0 lands at x/y.
|
||||
let at_origin = c.create(json!({"type": "vector", "path": "M65 0 L195 0 L260 130 L202 470 L58 470 L0 130 Z", "x": 830, "y": 445}));
|
||||
assert_eq!(c.node(&at_origin)["x"].as_f64(), Some(830.0));
|
||||
// Points are placed the same way.
|
||||
let points = c.create(json!({"type": "vector", "points": [[30, 0], [290, 130], [88, 470]], "closed": true, "x": 830, "y": 445}));
|
||||
assert_eq!(c.node(&points)["x"].as_f64(), Some(860.0));
|
||||
// set_properties x moves the left edge.
|
||||
c.ok("set_properties", json!({"ids": [offset], "props": {"x": 830}}));
|
||||
assert_eq!(c.node(&offset)["x"].as_f64(), Some(830.0));
|
||||
let r = c.rpc("tools/list", json!({}));
|
||||
let tool = r["result"]["tools"].as_array().unwrap().iter().find(|t| t["name"] == "create_node").unwrap().clone();
|
||||
let x_doc = tool["inputSchema"]["properties"]["x"]["description"].as_str().unwrap();
|
||||
assert!(x_doc.contains("path") && x_doc.contains("x + 30"), "{x_doc}");
|
||||
assert!(tool["inputSchema"]["properties"]["path"]["description"].as_str().unwrap().contains("offset by its own box"));
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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);
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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());
|
||||
|
||||
@@ -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\"",
|
||||
_ => "",
|
||||
|
||||
@@ -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() {
|
||||
|
||||
@@ -392,6 +392,7 @@ status = "draft — needs native review"
|
||||
"Dark" = "داكن"
|
||||
"Darken" = "تعتيم"
|
||||
"Dash" = "شرطة"
|
||||
"Dash cap" = "طرف الشرطة"
|
||||
"Dashes" = "الشرطات"
|
||||
"Dashes take numbers separated by commas" = "تقبل الشرطات أرقامًا مفصولة بفواصل"
|
||||
"Dashboard" = "لوحة المعلومات"
|
||||
|
||||
@@ -372,6 +372,7 @@ status = "draft — needs native review"
|
||||
"Dark" = "ডার্ক"
|
||||
"Darken" = "ডার্কেন"
|
||||
"Dash" = "ড্যাশ"
|
||||
"Dash cap" = "ড্যাশ ক্যাপ"
|
||||
"Dashes" = "ড্যাশগুলো"
|
||||
"Dashes take numbers separated by commas" = "ড্যাশগুলো কমা দিয়ে আলাদা করা সংখ্যা হবে"
|
||||
"Dashboard" = "ড্যাশবোর্ড"
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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ä"
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -370,6 +370,7 @@ status = "draft — needs native review"
|
||||
"Dark" = "ダーク"
|
||||
"Darken" = "比較(暗)"
|
||||
"Dash" = "破線"
|
||||
"Dash cap" = "破線の端"
|
||||
"Dashes" = "ダッシュ"
|
||||
"Dashes take numbers separated by commas" = "ダッシュはカンマ区切りの数値で入力します"
|
||||
"Dashboard" = "ダッシュボード"
|
||||
|
||||
@@ -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" = "სამართავი დაფა"
|
||||
|
||||
@@ -358,6 +358,7 @@ status = "draft — needs native review"
|
||||
"Dark" = "다크"
|
||||
"Darken" = "어둡게 하기"
|
||||
"Dash" = "대시"
|
||||
"Dash cap" = "대시 끝 모양"
|
||||
"Dashes" = "대시"
|
||||
"Dashes take numbers separated by commas" = "대시는 쉼표로 구분한 숫자로 입력하세요"
|
||||
"Decoration style" = "장식 스타일"
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -373,6 +373,7 @@ name = "Русский"
|
||||
"Dark" = "Тёмная"
|
||||
"Darken" = "Затемнение"
|
||||
"Dash" = "Штрих"
|
||||
"Dash cap" = "Конец штриха"
|
||||
"Dashes" = "Штрихи"
|
||||
"Dashes take numbers separated by commas" = "Штрихи задаются числами через запятую"
|
||||
"Dashboard" = "Панель аналитики"
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -371,6 +371,7 @@ status = "draft — needs native review"
|
||||
"Dark" = "มืด"
|
||||
"Darken" = "Darken"
|
||||
"Dash" = "เส้นประ"
|
||||
"Dash cap" = "ปลายเส้นประ"
|
||||
"Dashes" = "เส้นประ"
|
||||
"Dashes take numbers separated by commas" = "ใส่เส้นประเป็นตัวเลขคั่นด้วยจุลภาค"
|
||||
"Dashboard" = "แดชบอร์ด"
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -362,6 +362,7 @@ status = "draft — needs native review"
|
||||
"Dark" = "深色"
|
||||
"Darken" = "变暗"
|
||||
"Dash" = "虚线"
|
||||
"Dash cap" = "虚线端点"
|
||||
"Dashes" = "虚线"
|
||||
"Dashes take numbers separated by commas" = "虚线为以逗号分隔的数字"
|
||||
"Decoration style" = "装饰线样式"
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
@@ -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 |
|
||||
|
||||
@@ -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
@@ -1,6 +1,6 @@
|
||||
# FigCraft roadmap: milestones and what's next
|
||||
|
||||
> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-30 · **Change:** minor (library analytics count insertions and detachments of styles and variables; library analytics list how many layers and files use each style and variable; the Controller menu names each button as PlayStation and Switch controllers do; widgets draw lines and SVG pictures (plugin API 6); a connector end dropped along a side of a shape attaches to that side (#1708); regression tests pin a rounded shape's one-sided stroke and SVG ids and font families without XML-forbidden characters (#1351, #1124); a damaged image is refused with a reason instead of showing as an empty box (#1474); SwiftUI and Compose code names a bound variable by its iOS or Android code syntax; the colour picker's Image tab shows a placeholder and Upload from computer, an asset preview, and a file dropped on it replaces the image, keeping the fill mode, crop and position (#1682); the Frame tool's Phone presets have the iPhone 17 family (#1691); Show in folder works in the Flatpak build and selects the file on Linux (#1701); a dashed line starts and ends with a half-length dash, on the canvas and in exports; the Pages, Layers and Assets search fields keep their text in the middle of the field (#1703); a board connector's path can be reshaped by dragging its path handles (#1710); boards have 16 more shapes (arrows, polygons, star, plus and flowchart symbols), in pickers that lay them out in a grid; library analytics count insertions and show insertions and detachments over the past 30 days, 90 days or year; a stroke's Custom style takes a dash pattern of any length as a comma-separated list (#1681); a control request can set its own timeout, up to 10 minutes (`timeoutMs`; `figcraft-cli mcp --attach PORT --timeout SECONDS`) (#1696); gap 6: a widget opens its plugin's panel (plugin API 5), whose callbacks redraw it; the keyboard's Menu key opens the context menu, as Shift+F10 does; an Open link action can open in place of the prototype, with "Open in new tab" off (gap 15); a collection of this file can be extended (Extend collection on its row, `variable.extendCollection`), its extension following its edits at once, and extended collections extend again in the file; the eyedropper with Cmd/Ctrl+Shift makes a colour style or variable of what it picks; a live session's welcome sends the document compressed to clients that read it, about seven times smaller (#1665); a control request that times out says truthfully whether it was dropped or is still running and will take effect, and only screenshots and input blame the window (#1673); a board keeps earlier votes' results, listed newest first in Results and deleted one at a time; a server can list the files shared from it now for everyone who reaches it, Home ▸ Online shows them and their links carry no secret (`team_files`, part of #742); a selected connector's bar on a board sets its start and end points, solid or dashed, its weight and any colour (#1596); cells copied from a spreadsheet paste onto a board as a table; a long layer name stays on one line in the Export button and the Dev Mode header, ending with an ellipsis, and no longer covers the Design panel (#1650); with hundreds of layers selected, the Design panel and the selection overlays stop going over the whole selection on every frame, so dragging 648 layers averages about 1.1 ms of UI per frame (#1571); plugin panels show pictures: an `image` control, base64 within 256 KB and 4,096 px a side, named by its label (gap 6); a drag moving many layers re-runs only what sits around them, not each layer's bindings, sizing, text and instance checks, on every move (#1571); damaged font tracking table offsets are checked instead of wrapping on 32-bit targets, and font indexing makes its existing offset bounds explicit (#1125); 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); Shift+Cmd/Ctrl+Enter on a selected board shape adds the next shape already joined to it, and goes on typing (#1597); a name typed for a variable or collection finds the one with that exact id before one named like it, and stored mode ids, font family variables and the panels' labels read exact ids (#1613); plugin widgets have hover styles: a layer's `hover` fill, stroke, opacity or text colour shows while the pointer is over it (gap 6); 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; compact layer trees with fields and outline (M0.9, part of #996); while a drag only moves a large selection, its outlines, box, slots, spacing and Design panel values are worked out once and shifted, not rebuilt on every move (#1571); an imported or pasted file finds an installed family under the other names its fonts give it, and with other casLine truncated
|
||||
> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-30 · **Change:** minor (library analytics count insertions and detachments of styles and variables; a Dash cap setting (none, round or square) on Dash and Custom strokes, drawn on both ends of every dash; library analytics list how many layers and files use each style and variable; the Controller menu names each button as PlayStation and Switch controllers do; widgets draw lines and SVG pictures (plugin API 6); a connector end dropped along a side of a shape attaches to that side (#1708); regression tests pin a rounded shape's one-sided stroke and SVG ids and font families without XML-forbidden characters (#1351, #1124); a damaged image is refused with a reason instead of showing as an empty box (#1474); SwiftUI and Compose code names a bound variable by its iOS or Android code syntax; the colour picker's Image tab shows a placeholder and Upload from computer, an asset preview, and a file dropped on it replaces the image, keeping the fill mode, crop and position (#1682); the Frame tool's Phone presets have the iPhone 17 family (#1691); Show in folder works in the Flatpak build and selects the file on Linux (#1701); a dashed line starts and ends with a half-length dash, on the canvas and in exports; the Pages, Layers and Assets search fields keep their text in the middle of the field (#1703); a board connector's path can be reshaped by dragging its path handles (#1710); boards have 16 more shapes (arrows, polygons, star, plus and flowchart symbols), in pickers that lay them out in a grid; library analytics count insertions and show insertions and detachments over the past 30 days, 90 days or year; a stroke's Custom style takes a dash pattern of any length as a comma-separated list (#1681); a control request can set its own timeout, up to 10 minutes (`timeoutMs`; `figcraft-cli mcp --attach PORT --timeout SECONDS`) (#1696); gap 6: a widget opens its plugin's panel (plugin API 5), whose callbacks redraw it; the keyboard's Menu key opens the context menu, as Shift+F10 does; an Open link action can open in place of the prototype, with "Open in new tab" off (gap 15); a collection of this file can be extended (Extend collection on its row, `variable.extendCollection`), its extension following its edits at once, and extended collections extend again in the file; the eyedropper with Cmd/Ctrl+Shift makes a colour style or variable of what it picks; a live session's welcome sends the document compressed to clients that read it, about seven times smaller (#1665); a control request that times out says truthfully whether it was dropped or is still running and will take effect, and only screenshots and input blame the window (#1673); a board keeps earlier votes' results, listed newest first in Results and deleted one at a time; a server can list the files shared from it now for everyone who reaches it, Home ▸ Online shows them and their links carry no secret (`team_files`, part of #742); a selected connector's bar on a board sets its start and end points, solid or dashed, its weight and any colour (#1596); cells copied from a spreadsheet paste onto a board as a table; a long layer name stays on one line in the Export button and the Dev Mode header, ending with an ellipsis, and no longer covers the Design panel (#1650); with hundreds of layers selected, the Design panel and the selection overlays stop going over the whole selection on every frame, so dragging 648 layers averages about 1.1 ms of UI per frame (#1571); plugin panels show pictures: an `image` control, base64 within 256 KB and 4,096 px a side, named by its label (gap 6); a drag moving many layers re-runs only what sits around them, not each layer's bindings, sizing, text and instance checks, on every move (#1571); damaged font tracking table offsets are checked instead of wrapping on 32-bit targets, and font indexing makes its existing offset bounds explicit (#1125); 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); Shift+Cmd/Ctrl+Enter on a selected board shape adds the next shape already joined to it, and goes on typing (#1597); a name typed for a variable or collection finds the one with that exact id before one named like it, and stored mode ids, font family variables and the panels' labels read exact ids (#1613); plugin widgets have hover styles: a layer's `hover` fill, stroke, opacity or text colour shows while the pointer is over it (gap 6); 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; compact layer trees with fields and outline (M0.9, part of #996); while a drag only moves a large selection, its outlines, box, slots, spacing and Design panel values are worked out once and shifted, not rebuilt on every move (#1571); an impoLine truncated
|
||||
|
||||
Forward-looking plan. Where FigCraft stands today: [`ROADMAP.md`](../ROADMAP.md) and
|
||||
[`target-app-parity.md`](target-app-parity.md); the full ranked list: [`gaps.md`](gaps.md). Hours
|
||||
@@ -76,6 +76,7 @@ blockers.
|
||||
| Date | Change | Summary |
|
||||
|---|---|---|
|
||||
| 2026-10-31 | minor | M3, gap 10: library analytics count insertions and detachments of styles and variables. Applying one, or copying or duplicating content that uses it, inserts it; the Detach button detaches it; an instance counts as its component alone. Show usage and `library.usage` give them per style and variable over the chosen period. Modes aren't counted. Numbers unchanged. Tests: `library_asset_counts_tests`, `library_assets_tests`. |
|
||||
| 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-31 | minor | M3, gap 10: library analytics cover styles and variables. A file reports how many of its layers use each of a library's styles and variables, and Show usage (`library.usage`) lists them beside the components, by a Type choice, with their layers and files. Layers inside instances count for their component. Insertions, detachments and modes aren't counted for them yet. Numbers unchanged. Tests: `library_assets_tests`. |
|
||||
| 2026-10-31 | minor | M5: a key/gamepad trigger's Controller menu shows, beside each button, what PlayStation and Nintendo Switch controllers call it ("Gamepad A" is ✕ and B). Triggers are stored by the button's place, as before. |
|
||||
| 2026-10-31 | minor | M11, gap 6: widgets draw lines and SVG pictures, as a Figma widget's `Line` and `SVG` do (plugin API 6; older plugins unchanged). A line is 100 long, black and 1 thick unless told otherwise, and turns around its top-left corner. An SVG comes in as locked layers, scaled to fit a size, within the widget's 256 layers. The Counter example uses both. Numbers unchanged. Tests: `widget_shapes_tests`, `widget_tests`. |
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
# FigCraft parity with Figma
|
||||
|
||||
> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-31 · **Change:** minor (gap 6: widgets draw lines and SVG pictures (plugin API 6); gap 6: a widget opens its plugin's panel (plugin API 5), whose callbacks redraw it; the browser build uses the fonts installed on the computer once asked, each read when text uses it (#1051); plugin widgets have hover styles: a layer's `hover` fill, stroke, opacity or text colour shows while the pointer is over it (gap 6); Thai, Lao, Khmer and Burmese lines break between words (#1219); MCP and control calls name their document (#998); shadows of images with transparent pixels follow their alpha (#1289); Arabic draft catalog; truncated text fills its last line before the ellipsis and keeps truncating when resized; auto-height text with max lines (#1375, #1376, #1377), numbers unchanged; plugin widgets take text input and a property menu (plugin API 4); boards: templates and tables; boards: a shared timer and voting sessions; boards: the dot grid, connector ends dragged to re-attach, curved connectors; `.sketch` import, best effort: import/export breadth 86% → 87%, file formats 60% → 61%, headline numbers unchanged; Canvas & navigation's remaining work no longer lists outline mode's Include hidden layers and Include object bounds; boards: area 22 (weight 0, outside the weighted sum); CJK line breaking: JIS X 4051's line-start and line-end sets completed with the halfwidth small kana, the halfwidth and spacing (semi-)voiced sound marks, iteration marks 〱–〵 and the halfwidth parentheses; Turkish reviewed, Czech and Indonesian drafts, the Japanese draft completed; reviewed Russian catalog and integer plurals; vector edit mode regions: hover hatch, selection and drag; plugin log byte/count limits (#191); core editor leftovers: component playground, auto gap handles, variables inside styles, inner shadows and background blur on groups, command-made text readable on dark pages, a one-step frame/group convert, delays on mouse triggers and manual overlay offsets, wrapped-line alignment; starter templates, a Templates tab and Getting started tips on the home screen: essentials ~57% → ~59%; everyday editing partials: number fields, outline options, Hide when publishing, cursors, panel widths, dark pages, numbers unchanged but menu breadth 211/211; text box sizes measured against documents re-saved by other software: whole-pixel line heights, advances on a 1024-per-em grid, letter spacing between glyphs only, numbers unchanged; .fig export checked by opening written files in other software, numbers unchanged; a public figcraft-server: open hosting, owner tokens, per-address limits, idle expiry, moderation, install.sh; experimental .fig export; keyboard shortcut parity suite and bindings; Simplified Chinese draft catalog; Latin American Spanish overlay; prototyping tail: gamepad triggers, widgets in prototypes, mode lookups; plugin directory on figcraft-server; Japanese and Korean draft catalogs and CJK UI fonts; plugin widgets and the plugin panel tail; French, German and Brazilian Portuguese draft catalogs; cursor chat and spotlight in live sessions; layers and assets panels behaviour suite and fixes; vector multi-edit and edit tail; dev resources links; extended collections tail; branch version history and finer list merges; headline percentages recomputed from the area table: feature breadth ~84%, core ~94%, mainstream ~50%; text lists, hanging punctuation and decoration styles; comments and version history behaviour suite and fixes; canvas navigation, guides and grids behaviour suite and fixes; export and inspect behaviour suite and fixes; .fig import: editable vector networks and more layer kinds; shapes and vector behaviour suite and fixes; paint and effects behaviour suite and fixes; frames, groups, sections and masks behaviour suite and fixes; styles and variables behaviour suite and fixes; prototyping behaviour suite and fixes; components behaviour suite and fixes; embedded image colour profiles; layers panel keyboard: F2 rename, Shift+F10 context menu, keyboard box selection, 10k-layer QA; per-side stroke weight variables; text behaviour suite: CJK line breaking, metrics, vertical trim; selection and transform behaviour suite and fixes; `.fig` import, best effort; auto layout behaviour suite and fixes; Display P3: gamut warning and soft-proof (true P3 blocked upstream); several windows; localization layer with a draft Spanish catalog; hosting figcraft-server: wss in the desktop app, server releases, deployment guide; plugin panels and settings; canvas fixes: boolean hit testing on the result, group drags, frame outlines and slices; code connect; stretch and scatter brushes, pen pressure; text on a path and pattern fills; team libraries on the server and swap library; branches: create, update from main, review and merge; animated GIF fills and TIFF images; Open recent, outlines for the selection and mask outlines: menu breadth 100%; extended collections, vLine truncated
|
||||
> **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; gap 6: widgets draw lines and SVG pictures (plugin API 6); gap 6: a widget opens its plugin's panel (plugin API 5), whose callbacks redraw it; the browser build uses the fonts installed on the computer once asked, each read when text uses it (#1051); plugin widgets have hover styles: a layer's `hover` fill, stroke, opacity or text colour shows while the pointer is over it (gap 6); Thai, Lao, Khmer and Burmese lines break between words (#1219); MCP and control calls name their document (#998); shadows of images with transparent pixels follow their alpha (#1289); Arabic draft catalog; truncated text fills its last line before the ellipsis and keeps truncating when resized; auto-height text with max lines (#1375, #1376, #1377), numbers unchanged; plugin widgets take text input and a property menu (plugin API 4); boards: templates and tables; boards: a shared timer and voting sessions; boards: the dot grid, connector ends dragged to re-attach, curved connectors; `.sketch` import, best effort: import/export breadth 86% → 87%, file formats 60% → 61%, headline numbers unchanged; Canvas & navigation's remaining work no longer lists outline mode's Include hidden layers and Include object bounds; boards: area 22 (weight 0, outside the weighted sum); CJK line breaking: JIS X 4051's line-start and line-end sets completed with the halfwidth small kana, the halfwidth and spacing (semi-)voiced sound marks, iteration marks 〱–〵 and the halfwidth parentheses; Turkish reviewed, Czech and Indonesian drafts, the Japanese draft completed; reviewed Russian catalog and integer plurals; vector edit mode regions: hover hatch, selection and drag; plugin log byte/count limits (#191); core editor leftovers: component playground, auto gap handles, variables inside styles, inner shadows and background blur on groups, command-made text readable on dark pages, a one-step frame/group convert, delays on mouse triggers and manual overlay offsets, wrapped-line alignment; starter templates, a Templates tab and Getting started tips on the home screen: essentials ~57% → ~59%; everyday editing partials: number fields, outline options, Hide when publishing, cursors, panel widths, dark pages, numbers unchanged but menu breadth 211/211; text box sizes measured against documents re-saved by other software: whole-pixel line heights, advances on a 1024-per-em grid, letter spacing between glyphs only, numbers unchanged; .fig export checked by opening written files in other software, numbers unchanged; a public figcraft-server: open hosting, owner tokens, per-address limits, idle expiry, moderation, install.sh; experimental .fig export; keyboard shortcut parity suite and bindings; Simplified Chinese draft catalog; Latin American Spanish overlay; prototyping tail: gamepad triggers, widgets in prototypes, mode lookups; plugin directory on figcraft-server; Japanese and Korean draft catalogs and CJK UI fonts; plugin widgets and the plugin panel tail; French, German and Brazilian Portuguese draft catalogs; cursor chat and spotlight in live sessions; layers and assets panels behaviour suite and fixes; vector multi-edit and edit tail; dev resources links; extended collections tail; branch version history and finer list merges; headline percentages recomputed from the area table: feature breadth ~84%, core ~94%, mainstream ~50%; text lists, hanging punctuation and decoration styles; comments and version history behaviour suite and fixes; canvas navigation, guides and grids behaviour suite and fixes; export and inspect behaviour suite and fixes; .fig import: editable vector networks and more layer kinds; shapes and vector behaviour suite and fixes; paint and effects behaviour suite and fixes; frames, groups, sections and masks behaviour suite and fixes; styles and variables behaviour suite and fixes; prototyping behaviour suite and fixes; components behaviour suite and fixes; embedded image colour profiles; layers panel keyboard: F2 rename, Shift+F10 context menu, keyboard box selection, 10k-layer QA; per-side stroke weight variables; text behaviour suite: CJK line breaking, metrics, vertical trim; selection and transform behaviour suite and fixes; `.fig` import, best effort; auto layout behaviour suite and fixes; Display P3: gamut warning and soft-proof (true P3 blocked upstream); several windows; localization layer with a draft Spanish catalog; hosting figcraft-server: wss in the desktop app, server releases, deployment guide; plugin panels and settings; canvas fixes: boolean hit testing on the result, group drags, frame outlines and slices; code connect; stretch and scatter brushes, pen pressure; text on a path and pattern fills; team libraries on the server and swap library; branches: create, update from main, review and merge; animated GIF fills and TIFF imagLine truncated
|
||||
|
||||
The authoritative assessment of how close FigCraft is to Figma Design. [`ROADMAP.md`](../ROADMAP.md)
|
||||
summarizes it; [`gaps.md`](gaps.md) is the ranked work list; the measured menu checklist is
|
||||
@@ -588,7 +588,7 @@ Carried over from the ROADMAP's "Shipped so far" (2026-10-10). The test count th
|
||||
stars, lines/arrows, vector networks, text with styled runs, boolean groups, components, component
|
||||
sets (variants), instances with per-layer overrides, slices; fills (solid, linear/radial/angular/
|
||||
diamond gradients, images with fill/fit/crop/tile + adjustments), multiple strokes with
|
||||
inside/center/outside alignment, per-side weights, dashes, caps/arrowheads, joins; effects (drop
|
||||
inside/center/outside alignment, per-side weights, dashes with a dash cap, caps/arrowheads, joins; effects (drop
|
||||
and inner shadow with spread that offsets the outline exactly: ellipses, stars, vectors, text;
|
||||
inner shadows over the fills and under the children and strokes; drop shadows hidden behind what
|
||||
the layer covers, translucent fills included, unless shown behind transparent areas, and images
|
||||
@@ -648,6 +648,7 @@ Carried over from the ROADMAP's "Shipped so far" (2026-10-10). The test count th
|
||||
|
||||
| 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-31 | minor | M11, gap 6: widgets draw lines and SVG pictures, as a Figma widget's `Line` and `SVG` do (plugin API 6; older plugins unchanged). A line is 100 long, black and 1 thick unless told otherwise, and turns around its top-left corner. An SVG comes in as locked layers, scaled to fit a size, within the widget's 256 layers. The Counter example uses both. Numbers unchanged. Tests: `widget_shapes_tests`, `widget_tests`. |
|
||||
| 2026-10-30 | minor | M11, gap 6: a widget can open its plugin's panel, as a Figma widget's click handler shows a plugin window (plugin API 5). A widget callback's `ui_show` (click, field edit or menu item) opens the plugin's one panel and keeps the module loaded; each panel callback gets the widget's id and current state as well as the panel's event and values, and its `widget_render` redraws the widget in the same undo step. A widget removed meanwhile isn't redrawn (the callback rolls back, the panel stays); an API 3 or 4 plugin's `ui_show` from a widget still opens nothing; a presented prototype opens no panel. The Counter example (API 5) gets Set count… in its menu: a panel whose Set button redraws it with the count entered. Tests: `widget_panel_tests` (engine), the Counter's unit test. Numbers unchanged |
|
||||
| 2026-10-30 | minor | Gap 6: plugin widgets have hover styles (fill, stroke, opacity, text colour while the pointer is over a layer); timers, panels from widgets and more layer kinds remain. Numbers unchanged. |
|
||||
|
||||
Reference in new issue
Block a user