M6: .fig import: editable vector networks and more layer kinds

The `.fig` import's tail (gap 1):

- **Editable vector networks.** Vector layers now open as vector networks instead of their outlines: points, curved segments with their tangents, filled areas (loops) with their winding rule, per-point corner radius and handle mirroring, and end caps taken from the end points' own styles (an arrow drawn on one end keeps its arrowhead on that end only). The network blob is decoded defensively (`crates/format/src/fig/network.rs`: bounds-checked reads, capped counts, indices checked, floats finite) and scaled from its normalized size to the layer's size. When the blob is missing or damaged, or an open path shows a fill that regions can't hold, the outline import is used as before (damage is reported in the warnings).
- **Layer kinds that used to be dropped** now import as the closest editable layers, each kind with one warning: sticky notes, shapes with text, code blocks, tables and table cells, stamps, media, widgets and slide containers become frames with their paints and children, their text added as a text layer (notes top-left, shapes centred); a non-rectangular shape with text gets its saved outline as a vector under the frame; connectors, highlights and washi tape become vectors (the network or outline when saved, else a straight line between the connector's ends: free points or a point of a sibling layer) with the connector's end caps; text on a path becomes plain text.
- **Variable prototype actions:** set-variable (literal, variable or expression values), set-variable-mode and conditional actions (if / else blocks with expression conditions) are mapped onto the REST importer's existing support. A condition that can't be read makes the whole conditional skip with a warning rather than turning into an "else".
- The REST importer gained two small extensions the `.fig` mapping writes: `vectorNetwork` (FigCraft's own network JSON, validated: sizes capped, coordinates finite and in range, references sanitized, else the outline with a warning) and per-end `strokeCapStart` / `strokeCapEnd`.
- Docs: gaps entry 1, file-format parity, target-app parity, ROADMAP and the MCP guide's `.fig` section.
This commit is contained in:
bflatastic
2026-10-12 01:32:04 +07:00
committed by BFlat
parent 2a54adb762
commit df6bc0a319
12 changed files with 1024 additions and 104 deletions
+16 -11
View File
@@ -1,8 +1,8 @@
# FigCraft Roadmap
**Stage: alpha** · next: beta, ~26 points and ~173–310 h away (one Opus 5.5 agent; ~35–70 h wall-clock with 5 agents)
**Stage: alpha** · next: beta, ~26 points and ~170–305 h away (one Opus 5.5 agent; ~35–70 h wall-clock with 5 agents)
> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-11 · **Change:** minor (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; Windows QA pass with eight fixes; 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, variables import/export, gradient stop and variant bindings; feature breadth ~80%; ready ~49%) · **Target:** Figma Design (desktop app 126.10.7)
> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-11 · **Change:** minor (.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; Windows QA pass with eight fixes; 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, variables import/export, gradient stop and variant bindings; feature breadth ~80%; ready ~49%) · **Target:** Figma Design (desktop app 126.10.7)
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
@@ -18,16 +18,16 @@ start with [`docs/gaps.md`](docs/gaps.md).
| Ready for real work | **~51%** | estimated, same doc (written dimension weights and scores) |
| Essentials user | **~57%** | estimated: core features 77.8% × 0.92 launch/stability × 0.92 discoverability × 0.88 opening files people send ([method](docs/target-app-parity.md#essentials-user)) |
| Mainstream practitioner | **~49%** | estimated: weekly areas 70.7% × 0.9 interaction × 0.9 stability × 0.85 file exchange with Figma users ([method](docs/target-app-parity.md#mainstream-practitioner)) |
| Remaining to beta | **~173–310 h** one agent | estimated |
| Remaining to full parity | **~310–601 h** one agent (~86–150 h wall-clock with 5 agents) | estimated |
| Remaining to beta | **~170–305 h** one agent | estimated |
| Remaining to full parity | **~307–596 h** one agent (~86–150 h wall-clock with 5 agents) | estimated |
### Readiness by audience
| Audience | Ready % | Opus 5.5 agent wall-clock hours to ~95% | Work that dominates |
|---|---|---|---|
| Full target (ready for real work) | ~51% | ~299–575 h | multiplayer (45–90), localization (45–75), plugins (15–40), the interaction pass (40–60), GPU canvas (30–60), AI (30–60), the `.fig` import's tail (8–15) |
| Mainstream practitioner | ~49% | ~134–250 h | core editor depth incl. the `.fig` import's tail (52–103), the interaction pass (40–60), real-file corpus and platform QA (25–50), `.fig` export for handing work back (15–30) |
| Essentials user | ~57% | ~63–115 h | the `.fig` import's tail so the files people send open well (8–15), core tool polish (25–45), interaction pass on the core tools (15–25), stability QA (10–20), onboarding and starter templates (5–10) |
| Full target (ready for real work) | ~51% | ~296–570 h | multiplayer (45–90), localization (45–75), plugins (15–40), the interaction pass (40–60), GPU canvas (30–60), AI (30–60), the `.fig` import's tail (5–10) |
| Mainstream practitioner | ~49% | ~131–245 h | core editor depth incl. the `.fig` import's tail (49–98), the interaction pass (40–60), real-file corpus and platform QA (25–50), `.fig` export for handing work back (15–30) |
| Essentials user | ~57% | ~60–110 h | the `.fig` import's tail so the files people send open well (5–10), core tool polish (25–45), interaction pass on the core tools (15–25), stability QA (10–20), onboarding and starter templates (5–10) |
Hours are one agent working sequentially, calibrated from this repo's history (about 1 agent-hour
per 1,000 added lines, 1–2.5 h per feature PR, ~7 h per honest depth point in the polish tail; see
@@ -47,7 +47,7 @@ lines, a typical feature PR 1–2.5 h). Details and what needs a human:
with variants and overrides, styles, variables, prototyping, Dev Mode, SVG/PNG/JPG/PDF), but
FigCraft opens `.fig` files only best effort, has only a preview of multiplayer, and nothing has been checked
against real Figma documents. Ready-for-real-work is ~51%, inside alpha's ~40–75% band, and the alpha gate passes: every daily core workflow (design screens, build a design system, prototype, hand off to developers) works end to end on FigCraft's own files, which save and reopen; review works only asynchronously and `.fig` import is unproven on real files, both beta gaps ([alpha gate](docs/roadmap.md#alpha-gate)). Beta needs
~75% **and** reliable `.fig` import: the `.fig` import's tail (8–15 h), a real-file corpus with measured
~75% **and** reliable `.fig` import: the `.fig` import's tail (5–10 h), a real-file corpus with measured
fidelity (15–30 h), the interaction pass (25–40 h of the 40–60), core depth to ~80% (40–75 h),
basic multiplayer on the landed core (30–55 h), stability and platform QA (20–40 h), a partial GPU path (15–30 h) and
Figma's own languages on the landed localization layer (15–25 h).
@@ -58,7 +58,7 @@ Figma's own languages on the landed localization layer (15–25 h).
|---|---|---|---|
| Features (depth) | 61% (core editor 70%) | 47–95 | [target-app-parity](docs/target-app-parity.md) |
| UI/UX fidelity | 58% | 40–60 | [ui-parity](docs/ui-parity.md) |
| File formats | 55% (`.fig` read best effort, no write) | 8–15 | [file-format-parity](docs/file-format-parity.md) |
| File formats | 58% (`.fig` read best effort with editable vectors, no write) | 5–10 | [file-format-parity](docs/file-format-parity.md) |
| Collaboration | 13% (core in; live sessions with document sync, cursors and text carets, the browser joins, invite links open the app; a self-hosted server with edit and view links and team libraries; preview) | 49–98 | [collaboration-parity](docs/collaboration-parity.md) |
| Stability | 55% | 20–40 | [gaps](docs/gaps.md) |
| Performance | 60% | 30–60 | [hardware-parity](docs/hardware-parity.md) |
@@ -85,7 +85,7 @@ Figma's own languages on the landed localization layer (15–25 h).
| Components & variants | 96% | 72% | 4–9 | [components-parity](docs/components-parity.md) |
| Styles & variables | 94% | 76% | 3–5 | [components-parity](docs/components-parity.md) |
| Prototyping | 90% | 74% | 3–6 | [prototyping-parity](docs/prototyping-parity.md) |
| Import/export | 65% | 35% | 30–60 | [file-format-parity](docs/file-format-parity.md) |
| Import/export | 82% | 54% | 5–10 | [file-format-parity](docs/file-format-parity.md) |
| Dev Mode / inspect | 90% | 64% | 1–3 | [dev-mode-parity](docs/dev-mode-parity.md) |
| Comments, versions, misc | 80% | 58% | 4–8 | [collaboration-parity](docs/collaboration-parity.md) |
| Multiplayer & sharing | 15% | 5% | 45–90 | [collaboration-parity](docs/collaboration-parity.md) |
@@ -120,7 +120,7 @@ shaping, IME) already works. Detail: [`docs/localization-parity.md`](docs/locali
Ranked; detail and milestones in [`docs/roadmap.md`](docs/roadmap.md).
1. `.fig` import's tail: editable vector networks, the skipped layer kinds that matter, fixes from real files (the best-effort import landed 2026-10-11): 8–15 h.
1. `.fig` import's tail: fixes from real files, connector labels, table cells, filled areas with their own fills (the best-effort import, editable vector networks, the other layer kinds and variable prototype actions landed 2026-10-11): 5–10 h.
2. Real-file corpus and measured fidelity: 15–30 h.
3. Tool-by-tool interaction pass against Figma: 40–60 h.
4. Languages on the landed localization layer: ja, ko, pt-BR, fr, de, and a native review of es: 15–30 h.
@@ -128,6 +128,10 @@ Ranked; detail and milestones in [`docs/roadmap.md`](docs/roadmap.md).
## Progress log
- **2026-10-11** · `.fig` import's tail (gap 1): vectors open as editable vector networks with their
corner radii, handle mirroring and end caps (outlines when the network is missing or damaged); sticky notes, shapes
with text, connectors, tables and similar layers import as frames, text and vectors instead of being dropped;
set-variable and conditional prototype actions come across. File formats 58%, ready ~49%.
- **2026-10-11** · Paint and effects behaviour suite: every documented rule for fills, blend modes,
gradients, images, colour models, shadows, blurs and effect exports is a table-driven case. Fixes: Normal
groups isolate and clipping frames don't, plus darker is drawn, drop shadows hide behind translucent
@@ -200,6 +204,7 @@ Ranked; detail and milestones in [`docs/roadmap.md`](docs/roadmap.md).
| Date | Change | Summary |
|---|---|---|
| 2026-10-11 | minor | `.fig` import's tail (gap 1): vectors open as editable vector networks (points, curved segments, filled areas with their winding, per-point corner radius and handle mirroring, end caps from the end points), falling back to outlines when the network is missing or damaged; sticky notes, shapes with text, connectors, tables, code blocks, slides and similar layers import as the closest frames, text layers and vectors with a warning instead of being dropped; set-variable, set-variable-mode and conditional prototype actions come across. New parsers fuzzed on fixtures generated in code. File formats 55% → 58%, 8–15 h → 5–10 h; import/export breadth 80% → 82%, ready 50% → 54%; feature and area totals and ready for real work (~49%) unchanged; beta ~173–310 h → ~170–305 h; full parity ~321–618 h → ~318–613 h |
| 2026-10-11 | minor | Shapes and vector behaviour suite written from the public documentation and its fixes: rounded vector points are drawn and exported, a boolean group looks like its top layer (a subtraction like the bottom one), Alt draws lines from the centre, stars keep to 3–60 points, delete and heal follows the curve, Shift-click on a point at a corner of the points' box deselects it, outline stroke keeps the stroke's colour style, opacity and effects, the miter field shows degrees, individual corners only for rectangles and frames, Option/Alt+Shift+F flattens, MCP `key` reaches the edit tools. Added: corner radius and star ratio handles, Count and Ratio fields, cut drags that divide shapes into layers, paint bucket drags. Shapes & vector 97%/68% → 98%/72%, 1–3 h → 2–4 h. Totals recomputed from the area table, which earlier rows had left stale: feature breadth ~80% → ~82% (core ~91% → ~93%), Features (depth) 57% → 61% (core 68% → 69%), 60–115 h → 56–109 h, ready for real work ~49% → ~51%, mainstream ~46% → ~48%; full parity ~321–618 h → ~319–615 h (the selection suite's −3/−5 h hadn't reached the totals) |
| 2026-10-11 | minor | Paint and effects behaviour suite written from the public documentation and its fixes: Normal groups and frames isolate their children and clipping frames don't (canvas, SVG, PDF); plus darker drawn on the canvas and in PNG exports; drop shadows hidden behind what the layer covers unless shown behind transparent areas, SVG included, with filter regions that reach as far as the effects; Fill images cover the box to its edges; "Normal" names Normal in commands and the blend menu. Paint 85%/66% → 87%/70% (breadth from the GIF/TIFF row), 5–9 h → 3–6 h; effects 72% → 75%, 3–6 h → 2–5 h; percentages unchanged at this rounding; full parity ~321–618 h → ~319–616 h |
| 2026-10-11 | minor | Frames, groups, sections and masks behaviour suite written from the public documentation (15 frame, group and section tests, 6 mask tests) and its fixes: a layer a mask hides can't be clicked there (clicks reach what's behind; Select layer agrees), a mask group's box and exports are the mask's (plus any layers under it), sections stay on the page or in sections (moves, drags, pastes, grouping and new sections in frames refused or kept on the page), drawing a frame or section around layers puts those fully inside it in it, resizing a section leaves its contents where they are (and a frame resized with Cmd/Ctrl held keeps its layers' sizes too), and X/Y of layers in groups are measured in the frame (groups have no coordinate space). Frames, groups, sections, masks 95%/75% → 97%/80%, 2–4 h → 1–3 h; percentages unchanged at this rounding; full parity ~323–621 h → ~322–620 h |
+330 -41
View File
@@ -10,9 +10,11 @@ use std::collections::BTreeMap;
use std::rc::Rc;
use figcraft_doc::geom::{Affine, Point};
use figcraft_doc::proto::{MAX_ACTION_DEPTH, MAX_EXPR_DEPTH};
use serde_json::{Map, Value, json};
use super::kiwi::Kv;
use super::network;
type Obj = Map<String, Value>;
@@ -283,14 +285,19 @@ impl<'a> Mapper<'a> {
(Some(i), Some(p)) => format!("I{};{}", i.root, p.join(";")),
_ => id.to_string(),
};
let ty = match view.str("type").unwrap_or("") {
let own = view.str("type").unwrap_or("");
let stand = stand_in(own);
let ty = match own {
"ROUNDED_RECTANGLE" => "RECTANGLE",
"SYMBOL" => "COMPONENT",
"FRAME" if view.flag("isStateGroup") == Some(true) => "COMPONENT_SET",
"FRAME" if view.flag("resizeToFit") == Some(true) => "GROUP",
"VARIABLE" | "VARIABLE_SET" => return None,
t => t,
t => stand.map_or(t, |s| s.0),
};
if let Some((_, what)) = stand {
self.warn(format!("{} layers import as {what}", own.to_ascii_lowercase().replace('_', " ")));
}
self.count += 1;
let mut o = Obj::new();
o.insert("id".into(), json!(rest_id));
@@ -302,6 +309,9 @@ impl<'a> Mapper<'a> {
"VECTOR" | "STAR" | "REGULAR_POLYGON" => self.geometry(&mut o, &view),
_ => {}
}
if own == "CONNECTOR" {
self.connector(&mut o, &view, space);
}
if ty == "COMPONENT" || ty == "COMPONENT_SET" {
self.component(&mut o, &view, &rest_id);
}
@@ -323,12 +333,124 @@ impl<'a> Mapper<'a> {
kids.extend(self.layer(&c, inst, child_space, Some(&view), depth + 1));
}
}
if stand.is_some_and(|s| s.0 == "FRAME") {
kids.splice(0..0, self.shape_with_text(&mut o, &view, own, &rest_id));
kids.extend(self.inner_text(&o, &view, &rest_id, own == "STICKY"));
}
if !kids.is_empty() {
o.insert("children".into(), Value::Array(kids));
}
Some(Value::Object(o))
}
/// A non-rectangular shape-with-text's shape, as a vector under its frame (which gives up its
/// paints to it).
fn shape_with_text(&mut self, o: &mut Obj, v: &View<'a>, own: &str, rest_id: &str) -> Option<Value> {
let shape = v.str("shapeWithTextType").unwrap_or("SQUARE");
let outlined = !v.list("fillGeometry").is_empty() || !v.list("strokeGeometry").is_empty();
if own != "SHAPE_WITH_TEXT" || matches!(shape, "SQUARE" | "ROUNDED_RECTANGLE") || !outlined || self.count >= MAX_NODES {
return None;
}
let mut s = Obj::new();
s.insert("id".into(), json!(format!("{rest_id};shape")));
s.insert("type".into(), json!("VECTOR"));
s.insert("name".into(), json!(shape.to_ascii_lowercase().replace('_', " ")));
for k in ["size", "strokeWeight", "strokeAlign", "strokeJoin", "strokeDashes"] {
if let Some(x) = o.get(k) {
s.insert(k.into(), x.clone());
}
}
for k in ["fills", "strokes"] {
s.insert(k.into(), o.insert(k.into(), json!([])).unwrap_or_else(|| json!([])));
}
s.insert("relativeTransform".into(), rows(Affine::IDENTITY));
self.geometry(&mut s, v);
if !s.contains_key("fillGeometry") && !s.contains_key("strokeGeometry") {
// Damaged outlines: the frame keeps its paints and box.
for k in ["fills", "strokes"] {
o.insert(k.into(), s.remove(k).unwrap_or_else(|| json!([])));
}
return None;
}
self.count += 1;
Some(Value::Object(s))
}
/// The text a stand-in frame holds (a sticky note's, a shape's, a table cell's) as a text
/// layer filling it, inset a little; centred unless it's a note.
fn inner_text(&mut self, o: &Obj, v: &View<'a>, rest_id: &str, note: bool) -> Option<Value> {
let chars = own_text(v)?;
if self.count >= MAX_NODES {
return None;
}
self.count += 1;
let size = |k: &str| o.get("size").and_then(|s| s.get(k)).and_then(Value::as_f64).unwrap_or(0.0);
let (w, h) = (size("x"), size("y"));
let pad = (w.min(h) / 4.0).clamp(0.0, 16.0);
let mut t = Obj::new();
t.insert("id".into(), json!(format!("{rest_id};text")));
t.insert("type".into(), json!("TEXT"));
t.insert("name".into(), json!(chars.chars().take(40).collect::<String>()));
t.insert("size".into(), json!({"x": (w - 2.0 * pad).max(1.0), "y": (h - 2.0 * pad).max(1.0)}));
t.insert("relativeTransform".into(), rows(Affine::translate((pad, pad))));
self.text(&mut t, v);
if let Some(Value::Object(st)) = t.get_mut("style") {
let (hor, ver) = if note { ("LEFT", "TOP") } else { ("CENTER", "CENTER") };
st.entry("textAlignHorizontal").or_insert_with(|| json!(hor));
st.entry("textAlignVertical").or_insert_with(|| json!(ver));
st.insert("textAutoResize".into(), json!("NONE"));
}
Some(Value::Object(t))
}
/// A connector's end caps, and when it has no saved outline or network a straight line
/// between its ends: free points, or a point (the centre unless given) of a sibling layer.
fn connector(&mut self, o: &mut Obj, v: &View<'a>, space: Affine) {
for (from, to) in [("connectorStartCap", "strokeCapStart"), ("connectorEndCap", "strokeCapEnd")] {
if let Some(c) = v.str(from) {
o.insert(to.into(), json!(cap_name(c)));
}
}
if own_text(v).is_some() {
self.warn("connector labels were skipped");
}
if ["vectorNetwork", "fillGeometry", "strokeGeometry"].iter().any(|k| o.contains_key(*k)) {
return;
}
let parent = v.get("parentIndex").and_then(|p| p.get("guid")).and_then(guid);
let end = |k: &str| -> Option<Point> {
let e = v.get(k)?;
let p = match e.get("endpointNodeID").and_then(guid).filter(|g| g != NO_STYLE) {
Some(id) => {
let n = *self.nodes.get(&id)?;
// Only siblings share the connector's coordinates.
if n.get("parentIndex").and_then(|p| p.get("guid")).and_then(guid) != parent {
return None;
}
let (w, h) = n.get("size").map_or((0.0, 0.0), |s| (num(s, "x").unwrap_or(0.0), num(s, "y").unwrap_or(0.0)));
let (rx, ry) = e.get("relativePosition").and_then(|r| Some((num(r, "x")?, num(r, "y")?))).unwrap_or((0.5, 0.5));
n.get("transform").and_then(matrix).unwrap_or_default() * Point::new(w * rx, h * ry)
}
None => e.get("position").and_then(|p| Some(Point::new(num(p, "x")?, num(p, "y")?)))?,
};
(p.x.is_finite() && p.y.is_finite()).then_some(p)
};
let (Some(a), Some(b)) = (end("connectorStart"), end("connectorEnd")) else {
self.warn("connectors whose ends can't be placed are empty");
return;
};
if v.str("connectorLineStyle").is_some_and(|s| s != "STRAIGHT") {
self.warn("elbowed and curved connectors without a saved path import as straight lines");
}
let (x0, y0) = (a.x.min(b.x), a.y.min(b.y));
o.insert("relativeTransform".into(), rows(space * Affine::translate((x0, y0))));
o.insert("size".into(), json!({"x": (a.x - b.x).abs(), "y": (a.y - b.y).abs()}));
o.insert(
"vectorNetwork".into(),
json!({"vertices": [{"x": a.x - x0, "y": a.y - y0}, {"x": b.x - x0, "y": b.y - y0}], "segments": [{"a": 0, "b": 1}]}),
);
}
/// Fields every layer type shares.
fn common(&mut self, o: &mut Obj, v: &View<'a>, ty: &str, space: Affine, parent: Option<&View<'a>>) {
for (from, to) in [("visible", "visible"), ("locked", "locked"), ("mask", "isMask")] {
@@ -556,7 +678,8 @@ impl<'a> Mapper<'a> {
}
}
let vector = o.get("type").and_then(Value::as_str) == Some("VECTOR");
if vector && !o.contains_key("fillGeometry") && !o.contains_key("strokeGeometry") {
if vector && !self.network(o, v) && !o.contains_key("fillGeometry") && !o.contains_key("strokeGeometry") && v.str("type") != Some("CONNECTOR")
{
self.warn("vectors without geometry are empty");
}
if !vector {
@@ -565,6 +688,81 @@ impl<'a> Mapper<'a> {
}
}
/// The editable network of `vectorData` as `vectorNetwork` (the REST importer's extension),
/// scaled to the layer's size, with per-point corner radii, handle mirroring and end caps.
/// False when there's none or it's damaged (the outlines stand in).
fn network(&mut self, o: &mut Obj, v: &View<'a>) -> bool {
let Some(vd) = v.get("vectorData") else { return false };
let Some(blob) = num(vd, "vectorNetworkBlob").and_then(|i| self.blobs.get(i as usize)).copied() else { return false };
let Some(net) = network::decode(blob) else {
self.warn("damaged vector networks import as outlines");
return false;
};
// An open path shows its fill, which regions can't hold: the outline keeps the look.
let fills_shown =
o.get("fills").and_then(Value::as_array).is_some_and(|f| f.iter().any(|p| p.get("visible").and_then(Value::as_bool) != Some(false)));
if net.segments.is_empty() || (net.regions.is_empty() && fills_shown && o.contains_key("fillGeometry")) {
return false;
}
let styles: BTreeMap<u64, &Kv> =
vd.get("styleOverrideTable").map(Kv::list).unwrap_or_default().iter().filter_map(|s| Some((num(s, "styleID")? as u64, s))).collect();
let style = |id: u32| styles.get(&u64::from(id)).copied().filter(|_| id != 0);
let scale = |k: &str| {
let (size, norm) = (v.get("size").and_then(|s| num(s, k)), vd.get("normalizedSize").and_then(|s| num(s, k)));
size.zip(norm).filter(|(s, n)| s.is_finite() && *n > 1e-9).map_or(1.0, |(s, n)| s / n)
};
let (sx, sy) = (scale("x"), scale("y"));
let vertices: Vec<Value> = net
.vertices
.iter()
.map(|p| {
let mut out = json!({"x": p.x * sx, "y": p.y * sy});
let st = style(p.style);
if let Some(r) = st.and_then(|s| num(s, "cornerRadius")).filter(|r| *r > 0.0) {
out["radius"] = json!(r);
}
match st.and_then(|s| s.get("handleMirroring")).and_then(Kv::str) {
Some("ANGLE") => out["mirror"] = json!("angle"),
Some("ANGLE_AND_LENGTH") => out["mirror"] = json!("angleAndLength"),
Some("NONE") => out["mirror"] = json!("none"),
_ => {}
}
out
})
.collect();
let segments: Vec<Value> =
net.segments.iter().map(|s| json!({"a": s.a, "b": s.b, "ta": [s.ta.0 * sx, s.ta.1 * sy], "tb": [s.tb.0 * sx, s.tb.1 * sy]})).collect();
let regions: Vec<Value> =
net.regions.iter().map(|r| json!({"loops": r.loops, "winding": if r.non_zero { "nonZero" } else { "evenOdd" }})).collect();
if net.regions.iter().any(|r| style(r.style).is_some_and(|s| s.get("fillPaints").is_some())) {
self.warn("filled areas of a vector with their own fill use the layer's fill");
}
let join = v.str("strokeJoin");
if net.vertices.iter().any(|p| style(p.style).and_then(|s| s.get("strokeJoin")).and_then(Kv::str).is_some_and(|j| Some(j) != join)) {
self.warn("points with their own stroke join use the layer's");
}
// The stroke starts at the first end point and stops at the last (as the editor draws it).
let mut degree = vec![0usize; net.vertices.len()];
for s in &net.segments {
for i in [s.a, s.b] {
if let Some(d) = degree.get_mut(i) {
*d += 1;
}
}
}
let ends: Vec<usize> = (0..degree.len()).filter(|i| degree.get(*i) == Some(&1)).collect();
let cap =
|i: Option<&usize>| i.and_then(|i| net.vertices.get(*i)).and_then(|p| style(p.style)).and_then(|s| s.get("strokeCap")).and_then(Kv::str);
let (first, last) = (cap(ends.first()), cap(ends.last()));
if first.is_some() || last.is_some() {
let node = v.str("strokeCap").unwrap_or("NONE");
o.insert("strokeCapStart".into(), json!(cap_name(first.unwrap_or(node))));
o.insert("strokeCapEnd".into(), json!(cap_name(last.unwrap_or(node))));
}
o.insert("vectorNetwork".into(), json!({"vertices": vertices, "segments": segments, "regions": regions}));
true
}
/// Component property definitions and description.
fn component(&mut self, o: &mut Obj, v: &View<'a>, id: &str) {
let mut defs = Obj::new();
@@ -765,41 +963,68 @@ impl<'a> Mapper<'a> {
trigger["device"] = json!(if d.contains("KEYBOARD") { "KEYBOARD" } else { d });
}
}
let mut actions = Vec::new();
for a in it.get("actions").map(Kv::list).unwrap_or_default() {
match a.get("connectionType").and_then(Kv::str) {
Some("INTERNAL_NODE") | None => {
let Some(dest) = a.get("transitionNodeID").and_then(guid) else { continue };
let nav = match a.get("navigationType").and_then(Kv::str) {
Some("SWAP_STATE") => "CHANGE_TO",
Some(n) => n,
None => "NAVIGATE",
};
let mut act = json!({"type": "NODE", "destinationId": dest, "navigation": nav});
if let Some(t) = transition(a) {
act["transition"] = t;
}
if let Some(b) = a.get("transitionPreserveScroll").and_then(Kv::bool) {
act["preserveScrollPosition"] = json!(b);
}
actions.push(act);
}
Some("URL") => {
if let Some(u) = a.get("connectionURL").and_then(Kv::str) {
actions.push(json!({"type": "URL", "url": u}));
}
}
Some("BACK") => actions.push(json!({"type": "BACK"})),
Some("CLOSE") => actions.push(json!({"type": "CLOSE"})),
Some("NONE") => {}
Some(other) => {
self.warn(format!("{} prototype actions are not imported from .fig files yet", other.to_ascii_lowercase().replace('_', " ")))
}
}
}
let actions: Vec<Value> = it.get("actions").map(Kv::list).unwrap_or_default().iter().filter_map(|a| self.action(a, 0)).collect();
(!actions.is_empty()).then(|| json!({"trigger": trigger, "actions": actions}))
}
/// One prototype action in the REST shape (`depth`: conditionals it's nested in).
fn action(&mut self, a: &Kv, depth: usize) -> Option<Value> {
Some(match a.get("connectionType").and_then(Kv::str) {
Some("INTERNAL_NODE") | None => {
let dest = a.get("transitionNodeID").and_then(guid)?;
let nav = match a.get("navigationType").and_then(Kv::str) {
Some("SWAP_STATE") => "CHANGE_TO",
Some(n) => n,
None => "NAVIGATE",
};
let mut act = json!({"type": "NODE", "destinationId": dest, "navigation": nav});
if let Some(t) = transition(a) {
act["transition"] = t;
}
if let Some(b) = a.get("transitionPreserveScroll").and_then(Kv::bool) {
act["preserveScrollPosition"] = json!(b);
}
act
}
Some("URL") => json!({"type": "URL", "url": a.get("connectionURL").and_then(Kv::str)?}),
Some("BACK") => json!({"type": "BACK"}),
Some("CLOSE") => json!({"type": "CLOSE"}),
Some("NONE") => return None,
Some("SET_VARIABLE") => {
let var = a.get("targetVariable").and_then(|t| t.get("id")).and_then(ref_guid).or_else(|| a.get("targetVariableID").and_then(guid));
// Missing parts are left out: the REST importer skips the action with a warning.
let mut act = json!({"type": "SET_VARIABLE", "variableId": var});
if let Some(d) = a.get("targetVariableData").and_then(|d| variable_data(d, 0)) {
act["variableValue"] = d;
}
act
}
Some("SET_VARIABLE_MODE") => json!({
"type": "SET_VARIABLE_MODE",
"variableCollectionId": a.get("targetVariableSetID").and_then(ref_guid),
"variableModeId": a.get("targetVariableModeID").and_then(guid),
}),
Some("CONDITIONAL") if depth < MAX_ACTION_DEPTH => {
let mut blocks = Vec::new();
for b in a.get("conditionalActions").map(Kv::list).unwrap_or_default() {
let actions: Vec<Value> =
b.get("actions").map(Kv::list).unwrap_or_default().iter().filter_map(|x| self.action(x, depth + 1)).collect();
let mut block = json!({"actions": actions});
// A condition that can't be read must not turn the block into an "else".
if let Some(c) = b.get("condition") {
block["condition"] = variable_data(c, 0).unwrap_or_else(|| json!({}));
}
blocks.push(block);
}
json!({"type": "CONDITIONAL", "conditionalBlocks": blocks})
}
Some(other) => {
self.warn(format!("{} prototype actions are not imported from .fig files yet", other.to_ascii_lowercase().replace('_', " ")));
return None;
}
})
}
fn variable_bindings(&mut self, o: &mut Obj, v: &View<'a>) {
let mut bound = Obj::new();
let mut text_bound = Obj::new();
@@ -941,12 +1166,7 @@ fn stroke(o: &mut Obj, v: &View) {
}
copy_str(o, v, "strokeJoin", "strokeJoin");
if let Some(cap) = v.str("strokeCap") {
let cap = match cap {
"ARROW_LINES" => "LINE_ARROW",
"ARROW_EQUILATERAL" => "TRIANGLE_ARROW",
c => c,
};
o.insert("strokeCap".into(), json!(cap));
o.insert("strokeCap".into(), json!(cap_name(cap)));
}
let dashes: Vec<f64> = v.list("dashPattern").iter().filter_map(Kv::f64).collect();
if !dashes.is_empty() {
@@ -966,6 +1186,75 @@ fn stroke(o: &mut Obj, v: &View) {
}
}
/// A `VariableData` (literal, variable reference or expression) in the REST shape; `None` for
/// kinds a prototype can't evaluate.
fn variable_data(d: &Kv, depth: usize) -> Option<Value> {
let v = d.get("value")?;
let ty = d.get("dataType").and_then(Kv::str)?;
let value = match ty {
"ALIAS" => json!({"type": "VARIABLE_ALIAS", "id": v.get("alias").and_then(ref_guid)?}),
"EXPRESSION" if depth < MAX_EXPR_DEPTH => {
let e = v.get("expressionValue")?;
let function = match e.get("expressionFunction").and_then(Kv::str)? {
"MULTIPLY" => "MULTIPLICATION",
"DIVIDE" => "DIVISION",
f => f,
};
let args = e.get("expressionArguments").map(Kv::list).unwrap_or_default();
let args = args.iter().map(|x| variable_data(x, depth + 1)).collect::<Option<Vec<Value>>>()?;
json!({"expressionFunction": function, "expressionArguments": args})
}
"BOOLEAN" => json!(v.get("boolValue").and_then(Kv::bool)?),
"FLOAT" => json!(num(v, "floatValue").filter(|x| x.is_finite())?),
"STRING" => json!(v.get("textValue").and_then(Kv::str)?),
"COLOR" => color(v.get("colorValue")?),
_ => return None,
};
let mut out = json!({"type": ty, "value": value});
if let Some(r) = d.get("resolvedDataType").and_then(Kv::str) {
out["resolvedType"] = json!(r);
}
Some(out)
}
/// The text a layer that isn't a text layer carries (a note's, a shape's), unless blank.
fn own_text<'a>(v: &View<'a>) -> Option<&'a str> {
v.get("textData").and_then(|t| t.get("characters")).and_then(Kv::str).filter(|c| !c.trim().is_empty())
}
/// The REST name of a stroke cap.
fn cap_name(cap: &str) -> &str {
match cap {
"ARROW_LINES" => "LINE_ARROW",
"ARROW_EQUILATERAL" => "TRIANGLE_ARROW",
c => c,
}
}
/// The layer kind (and how the warning names it) that a layer the editor has no counterpart for
/// imports as: frames for notes, shapes, tables, slides and the like (their text as a text layer),
/// vectors for connectors and marker strokes, plain text for text on a path.
fn stand_in(ty: &str) -> Option<(&'static str, &'static str)> {
Some(match ty {
"STICKY"
| "SHAPE_WITH_TEXT"
| "CODE_BLOCK"
| "TABLE"
| "TABLE_CELL"
| "STAMP"
| "MEDIA"
| "WIDGET"
| "SLIDE"
| "SLIDE_GRID"
| "SLIDE_ROW"
| "INTERACTIVE_SLIDE_ELEMENT"
| "MODULE" => ("FRAME", "frames with their text"),
"CONNECTOR" | "HIGHLIGHT" | "WASHI_TAPE" => ("VECTOR", "vectors"),
"TEXT_PATH" => ("TEXT", "text off its path"),
_ => return None,
})
}
/// How a layer sizes and sits in an auto layout parent.
fn child_layout(o: &mut Obj, v: &View, parent: Option<&View>) {
copy_num(o, v, "stackChildPrimaryGrow", "layoutGrow");
+6 -3
View File
@@ -6,12 +6,15 @@
//! zstd: a binary schema in the kiwi format ([`kiwi`]), then a message encoded with it. Every file
//! carries its own schema, so the message is decoded generically and read by field name: a list
//! of node changes (each with its id, parent id and position among its siblings) that make up the
//! layer tree, plus blobs (vector geometry). [`map`] turns that into the REST API file JSON shape
//! and the REST importer builds the document, so both imports share one mapping. Anything not
//! understood is skipped with a warning; malformed input never panics.
//! layer tree, plus blobs (vector outlines and editable vector networks, [`network`]). [`map`]
//! turns that into the REST API file JSON shape and the REST importer builds the document, so both
//! imports share one mapping. Layer kinds the editor doesn't have (sticky notes, connectors,
//! tables, …) come in as the closest frames, text and vectors. Anything not understood is skipped
//! with a warning; malformed input never panics.
pub mod kiwi;
mod map;
mod network;
#[cfg(test)]
mod tests;
+133
View File
@@ -0,0 +1,133 @@
//! Vector network blobs: the editable form of a vector layer (`vectorData.vectorNetworkBlob`).
//!
//! Little-endian throughout: `u32` counts of vertices, segments and regions; each vertex a style
//! id (`u32`) and its position (`f32` x, y); each segment a style id, its start vertex (`u32`) and
//! tangent (`f32` x, y), then its end vertex and tangent; each region a `u32` holding its style id
//! shifted left once with the winding rule in the low bit (set: non-zero, clear: even-odd), a loop
//! count, and per loop a segment count and the segment indices. Positions are in the space of
//! `vectorData.normalizedSize`. Style ids point into `vectorData.styleOverrideTable` (0: none).
/// Most vertices, segments, regions or loop entries read from one blob.
const MAX_ITEMS: usize = 200_000;
pub(super) struct Vertex {
pub style: u32,
pub x: f64,
pub y: f64,
}
pub(super) struct Segment {
pub a: usize,
pub ta: (f64, f64),
pub b: usize,
pub tb: (f64, f64),
}
pub(super) struct Region {
pub style: u32,
pub non_zero: bool,
pub loops: Vec<Vec<usize>>,
}
#[derive(Default)]
pub(super) struct Network {
pub vertices: Vec<Vertex>,
pub segments: Vec<Segment>,
pub regions: Vec<Region>,
}
struct Reader<'b> {
b: &'b [u8],
pos: usize,
}
impl Reader<'_> {
fn u32(&mut self) -> Option<u32> {
let v = u32::from_le_bytes(self.b.get(self.pos..self.pos + 4)?.try_into().ok()?);
self.pos += 4;
Some(v)
}
fn f32(&mut self) -> Option<f64> {
Some(f64::from(f32::from_bits(self.u32()?))).filter(|x| x.is_finite())
}
/// An index below `len`.
fn index(&mut self, len: usize) -> Option<usize> {
Some(self.u32()? as usize).filter(|i| *i < len)
}
/// A count of items of at least `size` bytes each that the rest of the blob can hold.
fn count(&mut self, size: usize) -> Option<usize> {
let n = self.u32()? as usize;
(n <= MAX_ITEMS && n * size <= self.b.len().saturating_sub(self.pos)).then_some(n)
}
}
/// The network in blob `b`; `None` when it's truncated, out of range or not finite.
pub(super) fn decode(b: &[u8]) -> Option<Network> {
let mut r = Reader { b, pos: 0 };
let (nv, ns, nr) = (r.u32()? as usize, r.u32()? as usize, r.u32()? as usize);
if nv > MAX_ITEMS || ns > MAX_ITEMS || nr > MAX_ITEMS || nv * 12 + ns * 28 + nr * 8 > b.len().saturating_sub(12) {
return None;
}
let mut net = Network::default();
for _ in 0..nv {
net.vertices.push(Vertex { style: r.u32()?, x: r.f32()?, y: r.f32()? });
}
for _ in 0..ns {
r.u32()?;
net.segments.push(Segment { a: r.index(nv)?, ta: (r.f32()?, r.f32()?), b: r.index(nv)?, tb: (r.f32()?, r.f32()?) });
}
let mut entries = 0usize;
for _ in 0..nr {
let sw = r.u32()?;
let mut loops = Vec::new();
for _ in 0..r.count(4)? {
let n = r.count(4)?;
entries += n;
if entries > MAX_ITEMS {
return None;
}
loops.push((0..n).map(|_| r.index(ns)).collect::<Option<Vec<usize>>>()?);
}
net.regions.push(Region { style: sw >> 1, non_zero: sw & 1 == 1, loops });
}
Some(net)
}
/// The blob of `net` (for tests: fixtures are written with it).
#[cfg(test)]
pub(super) fn encode(net: &Network) -> Vec<u8> {
let mut out = Vec::new();
let u = |v: u32, out: &mut Vec<u8>| out.extend_from_slice(&v.to_le_bytes());
let fl = |v: f64, out: &mut Vec<u8>| out.extend_from_slice(&(v as f32).to_le_bytes());
for n in [net.vertices.len(), net.segments.len(), net.regions.len()] {
u(n as u32, &mut out);
}
for v in &net.vertices {
u(v.style, &mut out);
fl(v.x, &mut out);
fl(v.y, &mut out);
}
for s in &net.segments {
u(0, &mut out);
u(s.a as u32, &mut out);
fl(s.ta.0, &mut out);
fl(s.ta.1, &mut out);
u(s.b as u32, &mut out);
fl(s.tb.0, &mut out);
fl(s.tb.1, &mut out);
}
for r in &net.regions {
u((r.style << 1) | u32::from(r.non_zero), &mut out);
u(r.loops.len() as u32, &mut out);
for l in &r.loops {
u(l.len() as u32, &mut out);
for i in l {
u(*i as u32, &mut out);
}
}
}
out
}
+434
View File
@@ -0,0 +1,434 @@
//! The import's tail: editable vector networks, the closest layers for kinds the editor doesn't
//! have (sticky notes, shapes with text, connectors, tables) and variable prototype actions, on
//! fixtures built in code with the parent module's helpers.
use figcraft_doc::proto::Action;
use figcraft_doc::{HandleMirroring, StrokeCap, WindingRule};
use super::super::network::{self, Network, Region, Segment, Vertex};
use super::*;
fn seg(a: usize, b: usize, ta: (f64, f64), tb: (f64, f64)) -> Segment {
Segment { a, ta, b, tb }
}
fn vtx(style: u32, x: f64, y: f64) -> Vertex {
Vertex { style, x, y }
}
/// A square with one curved side and a rounded, mirrored corner, filled even-odd.
fn square_blob() -> Vec<u8> {
network::encode(&Network {
vertices: vec![vtx(0, 0.0, 0.0), vtx(1, 50.0, 0.0), vtx(0, 50.0, 50.0), vtx(0, 0.0, 50.0)],
segments: vec![
seg(0, 1, (10.0, 0.0), (-10.0, 0.0)),
seg(1, 2, (0.0, 0.0), (0.0, 0.0)),
seg(2, 3, (0.0, 0.0), (0.0, 0.0)),
seg(3, 0, (0.0, 0.0), (0.0, 0.0)),
],
regions: vec![Region { style: 0, non_zero: false, loops: vec![vec![0, 1, 2, 3]] }],
})
}
/// An open line whose end point has its own (arrow) cap.
fn arrow_blob() -> Vec<u8> {
network::encode(&Network {
vertices: vec![vtx(0, 0.0, 0.0), vtx(2, 100.0, 0.0)],
segments: vec![seg(0, 1, (0.0, 0.0), (0.0, 0.0))],
regions: vec![],
})
}
fn vector_data(blob: u64, nx: f64, ny: f64, styles: Vec<Kv<'static>>) -> Kv<'static> {
o(vec![("vectorNetworkBlob", u(blob)), ("normalizedSize", xy(nx, ny)), ("styleOverrideTable", arr(styles))])
}
fn outline(blob: u64) -> Kv<'static> {
arr(vec![o(vec![("windingRule", e("NONZERO")), ("commandsBlob", u(blob))])])
}
fn text_data(t: &str) -> Kv<'static> {
o(vec![("characters", s(t))])
}
fn data(ty: &'static str, value: Kv<'static>) -> Kv<'static> {
o(vec![("value", value), ("dataType", e(ty))])
}
fn action(ty: &'static str, mut more: Vec<(&'static str, Kv<'static>)>) -> Kv<'static> {
more.push(("connectionType", e(ty)));
o(more)
}
/// Blobs: 0 a triangle outline, 1 the square network, 2 the arrow network, 3 a truncated network.
fn tail_message() -> Kv<'static> {
let modes = arr(vec![
o(vec![("id", g(92)), ("name", s("Off")), ("sortPosition", s("!"))]),
o(vec![("id", g(93)), ("name", s("On")), ("sortPosition", s("\""))]),
]);
let bool_value = |b| data("BOOLEAN", o(vec![("boolValue", Kv::Bool(b))]));
let values = o(vec![(
"entries",
arr(vec![o(vec![("modeID", g(92)), ("variableData", bool_value(false))]), o(vec![("modeID", g(93)), ("variableData", bool_value(true))])]),
)]);
let is_on = data(
"EXPRESSION",
o(vec![(
"expressionValue",
o(vec![("expressionFunction", e("EQUALS")), ("expressionArguments", arr(vec![alias_data(41), bool_value(true)]))]),
)]),
);
let click = o(vec![
("event", o(vec![("interactionType", e("ON_CLICK"))])),
(
"actions",
arr(vec![
action("SET_VARIABLE", vec![("targetVariable", o(vec![("id", o(vec![("guid", g(41))]))])), ("targetVariableData", bool_value(true))]),
action(
"CONDITIONAL",
vec![(
"conditionalActions",
arr(vec![
o(vec![("actions", arr(vec![action("BACK", vec![])])), ("condition", is_on)]),
o(vec![("actions", arr(vec![action("CLOSE", vec![])]))]),
]),
)],
),
action("SET_VARIABLE_MODE", vec![("targetVariableSetID", o(vec![("guid", g(40))])), ("targetVariableModeID", g(93))]),
]),
),
]);
let stroke = || ("strokePaints", arr(vec![solid(0.0, 0.0, 0.0)]));
let nodes = vec![
node(0, None, "DOCUMENT", "Document", vec![]),
node(1, Some((0, "!")), "CANVAS", "Board", vec![]),
node(2, Some((0, "~")), "CANVAS", "Internal", vec![("internalOnly", Kv::Bool(true))]),
node(40, Some((2, "!")), "VARIABLE_SET", "Flags", vec![("variableSetModes", modes)]),
node(
41,
Some((2, "\"")),
"VARIABLE",
"on",
vec![("variableSetID", o(vec![("guid", g(40))])), ("variableResolvedType", e("BOOLEAN")), ("variableDataValues", values)],
),
node(
30,
Some((1, "!")),
"VECTOR",
"Wave",
vec![
("size", xy(200.0, 100.0)),
("transform", at(700.0, 0.0)),
("fillPaints", arr(vec![solid(1.0, 0.0, 0.0)])),
("fillGeometry", outline(0)),
(
"vectorData",
vector_data(
1,
100.0,
50.0,
vec![o(vec![("styleID", u(1)), ("cornerRadius", f(4.0)), ("handleMirroring", e("ANGLE_AND_LENGTH"))])],
),
),
],
),
node(
31,
Some((1, "\"")),
"VECTOR",
"Arrow",
vec![
("size", xy(300.0, 0.0)),
("transform", at(0.0, 230.0)),
stroke(),
("strokeWeight", f(2.0)),
("strokeCap", e("NONE")),
("vectorData", vector_data(2, 100.0, 0.0, vec![o(vec![("styleID", u(2)), ("strokeCap", e("ARROW_LINES"))])])),
],
),
node(
32,
Some((1, "#")),
"VECTOR",
"Broken",
vec![
("size", xy(10.0, 8.0)),
("transform", at(700.0, 200.0)),
("fillPaints", arr(vec![solid(0.0, 0.0, 1.0)])),
("fillGeometry", outline(0)),
("vectorData", vector_data(3, 10.0, 8.0, vec![])),
],
),
node(
33,
Some((1, "$")),
"STICKY",
"Idea",
vec![
("size", xy(200.0, 200.0)),
("fillPaints", arr(vec![solid(1.0, 0.9, 0.4)])),
("fontSize", f(24.0)),
("textData", text_data("Ship it")),
],
),
node(
34,
Some((1, "%")),
"SHAPE_WITH_TEXT",
"Bubble",
vec![
("size", xy(100.0, 60.0)),
("transform", at(0.0, 400.0)),
("fillPaints", arr(vec![solid(0.2, 0.6, 1.0)])),
("shapeWithTextType", e("ELLIPSE")),
("fillGeometry", outline(0)),
("textData", text_data("Hi")),
],
),
node(
35,
Some((1, "&")),
"RECTANGLE",
"Box",
vec![("size", xy(100.0, 100.0)), ("transform", at(400.0, 0.0)), ("fillPaints", arr(vec![solid(0.8, 0.8, 0.8)]))],
),
node(
36,
Some((1, "'")),
"CONNECTOR",
"Link",
vec![
stroke(),
("strokeWeight", f(2.0)),
("connectorStart", o(vec![("position", xy(0.0, 300.0))])),
("connectorEnd", o(vec![("endpointNodeID", g(35))])),
("connectorEndCap", e("ARROW_EQUILATERAL")),
("connectorLineStyle", e("ELBOWED")),
],
),
node(
37,
Some((1, "(")),
"TABLE",
"Grid",
vec![("size", xy(100.0, 50.0)), ("transform", at(300.0, 400.0)), ("strokePaints", arr(vec![solid(0.0, 0.0, 0.0)]))],
),
node(
39,
Some((1, ")")),
"FRAME",
"Proto",
vec![("size", xy(50.0, 50.0)), ("transform", at(500.0, 400.0)), ("prototypeInteractions", arr(vec![click]))],
),
];
let mut broken = square_blob();
broken.truncate(30);
let blobs = [triangle_blob(), square_blob(), arrow_blob(), broken];
o(vec![("nodeChanges", arr(nodes)), ("blobs", arr(blobs.into_iter().map(|b| o(vec![("bytes", Kv::Bytes(b))])).collect()))])
}
fn tail_canvas() -> Vec<u8> {
canvas(&schema(), &tail_message())
}
fn network_of<'d>(doc: &'d figcraft_doc::Document, name: &str) -> &'d figcraft_doc::VectorNetwork {
match &named(doc, name).kind {
NodeKind::Vector { network } => network,
k => panic!("{name} is {k:?}"),
}
}
#[test]
fn vectors_import_as_editable_networks() {
let (doc, warnings) = import(&tail_canvas(), "Tail").unwrap();
// Scaled from the normalized size (100 × 50) to the layer's (200 × 100), corner and handles kept.
let wave = network_of(&doc, "Wave");
let pts: Vec<(f64, f64)> = wave.vertices.iter().map(|v| (v.x, v.y)).collect();
assert_eq!(pts, [(0.0, 0.0), (100.0, 0.0), (100.0, 100.0), (0.0, 100.0)]);
assert_eq!((wave.vertices[1].radius, wave.vertices[1].mirror), (4.0, Some(HandleMirroring::AngleAndLength)));
assert_eq!(wave.vertices[0].mirror, None);
assert_eq!((wave.segments[0].ta, wave.segments[0].tb), ((20.0, 0.0), (-20.0, 0.0)));
assert_eq!(wave.regions.len(), 1);
assert_eq!((wave.regions[0].loops.clone(), wave.regions[0].winding), (vec![vec![0, 1, 2, 3]], WindingRule::EvenOdd));
assert_eq!(named(&doc, "Wave").fills.len(), 1);
// An open line stays a stroke; its end point's own cap is the end cap.
let arrow = named(&doc, "Arrow");
assert_eq!(network_of(&doc, "Arrow").vertices.iter().map(|v| v.x).collect::<Vec<_>>(), [0.0, 300.0]);
assert!(network_of(&doc, "Arrow").regions.is_empty());
assert_eq!((arrow.stroke.cap_start, arrow.stroke.cap_end), (StrokeCap::None, StrokeCap::LineArrow));
assert_eq!((arrow.strokes.len(), arrow.fills.len()), (1, 0));
// A damaged network falls back to the outline.
assert_eq!(network_of(&doc, "Broken").regions.len(), 1);
assert!(warnings.iter().any(|w| w.contains("damaged vector networks import as outlines")), "{warnings:?}");
}
#[test]
fn other_layer_kinds_import_as_the_closest_layers() {
let (doc, warnings) = import(&tail_canvas(), "Tail").unwrap();
let text_of = |id| match &doc.node(id).unwrap().kind {
NodeKind::Text(t) => t.characters.clone(),
k => panic!("{k:?}"),
};
// A sticky note: a frame with its paint, holding its text.
let idea = named(&doc, "Idea");
assert!(matches!(idea.kind, NodeKind::Frame));
assert_eq!(idea.fills.len(), 1);
assert_eq!(idea.children.len(), 1);
assert_eq!(text_of(idea.children[0]), "Ship it");
let NodeKind::Text(t) = &doc.node(idea.children[0]).unwrap().kind else { panic!() };
assert_eq!(t.style.font_size, 24.0);
// A shape with text: its outline as a vector (with the paint), then the text.
let bubble = named(&doc, "Bubble");
assert!(bubble.fills.is_empty());
let kids: Vec<&figcraft_doc::Node> = bubble.children.iter().map(|c| doc.node(*c).unwrap()).collect();
assert!(matches!(kids[0].kind, NodeKind::Vector { .. }) && kids[0].fills.len() == 1);
assert_eq!(text_of(bubble.children[1]), "Hi");
// A connector from a free point to the centre of a sibling, with its end cap.
let link = named(&doc, "Link");
let world = doc.world_transform(link.id);
let ends: Vec<(f64, f64)> =
network_of(&doc, "Link").vertices.iter().map(|v| world * figcraft_doc::geom::Point::new(v.x, v.y)).map(|p| (p.x, p.y)).collect();
assert_eq!(ends, [(0.0, 300.0), (450.0, 50.0)]);
assert_eq!(link.stroke.cap_end, StrokeCap::TriangleArrow);
// A table: a frame.
assert!(matches!(named(&doc, "Grid").kind, NodeKind::Frame));
for w in ["sticky layers import as frames", "shape with text layers", "connector layers import as vectors", "table layers", "elbowed"] {
assert!(warnings.iter().any(|x| x.contains(w)), "{w}: {warnings:?}");
}
}
#[test]
fn variable_prototype_actions_import() {
let (doc, warnings) = import(&tail_canvas(), "Tail").unwrap();
let actions = &named(&doc, "Proto").interactions[0].actions;
assert_eq!(actions.len(), 3, "{actions:?} {warnings:?}");
assert!(matches!(&actions[0], Action::SetVariable { variable, value, .. } if variable == "1:41" && *value == Value::Bool(true)));
let Action::Conditional { blocks } = &actions[1] else { panic!("{:?}", actions[1]) };
assert_eq!(blocks.len(), 2);
assert!(blocks[0].condition.is_some() && matches!(blocks[0].actions[..], [Action::Back]));
assert!(blocks[1].condition.is_none() && matches!(blocks[1].actions[..], [Action::CloseOverlay]));
assert!(matches!(&actions[2], Action::SetVariableMode { collection, mode } if collection == "1:40" && mode == "1:93"));
assert!(!warnings.iter().any(|w| w.contains("prototype")), "{warnings:?}");
}
#[test]
fn unreadable_conditions_skip_the_conditional() {
// A condition of a kind that can't be read must not become an "else".
let bad = data("MAP", o(vec![]));
let a =
action("CONDITIONAL", vec![("conditionalActions", arr(vec![o(vec![("actions", arr(vec![action("BACK", vec![])])), ("condition", bad)])]))]);
let it = o(vec![("event", o(vec![("interactionType", e("ON_CLICK"))])), ("actions", arr(vec![a]))]);
let nodes = vec![
node(0, None, "DOCUMENT", "D", vec![]),
node(1, Some((0, "!")), "CANVAS", "P", vec![]),
node(2, Some((1, "!")), "FRAME", "F", vec![("size", xy(10.0, 10.0)), ("prototypeInteractions", arr(vec![it]))]),
];
let mut sc = schema();
// `MAP` isn't in the fixture's enum: add it.
let dt = sc.defs.iter_mut().find(|d| d.name == "DataType").unwrap();
let value = dt.fields.len() as u32;
dt.fields.push(Field { name: "MAP".into(), ty: None, array: false, value });
let (doc, warnings) = import(&canvas(&sc, &o(vec![("nodeChanges", arr(nodes))])), "x").unwrap();
assert!(named(&doc, "F").interactions.is_empty());
assert!(warnings.iter().any(|w| w.contains("conditions that can't be evaluated")), "{warnings:?}");
}
#[test]
fn damaged_network_blobs_never_panic() {
let good = square_blob();
assert!(network::decode(&good).is_some());
for cut in 0..good.len() {
assert!(network::decode(&good[..cut]).is_none(), "{cut}");
}
let mut r = Lcg(5);
for _ in 0..5000 {
let mut m = good.clone();
for _ in 0..1 + r.next() % 4 {
let i = (r.next() as usize) % m.len();
m[i] ^= 1 << (r.next() % 8);
}
if let Some(net) = network::decode(&m) {
assert!(net.segments.iter().all(|s| s.a < net.vertices.len() && s.b < net.vertices.len()));
assert!(net.regions.iter().flat_map(|r| &r.loops).flatten().all(|i| *i < net.segments.len()));
assert!(net.vertices.iter().all(|v| v.x.is_finite() && v.y.is_finite()));
}
}
// Counts larger than the blob, and indices out of range.
let mut huge = vec![0xff; 12];
huge.extend_from_slice(&[0; 64]);
assert!(network::decode(&huge).is_none());
let mut bad = arrow_blob();
bad[12 + 24 + 4..12 + 24 + 8].copy_from_slice(&7u32.to_le_bytes());
assert!(network::decode(&bad).is_none());
for _ in 0..2000 {
let junk: Vec<u8> = (0..(r.next() % 120)).map(|_| r.next() as u8).collect();
let _ = network::decode(&junk);
}
}
#[test]
fn mangled_tail_files_never_panic() {
let c = tail_canvas();
let mut r = Lcg(13);
for cut in (0..c.len()).step_by(29) {
let _ = import(&c[..cut], "x");
}
for _ in 0..300 {
let mut m = c.clone();
for _ in 0..1 + r.next() % 8 {
let i = (r.next() as usize) % m.len();
m[i] ^= 1 << (r.next() % 8);
}
let _ = import(&m, "x");
}
// Mangled messages decode and map without panicking.
let sc = schema();
let root = sc.find("Message").unwrap();
let msg = encode::message(&sc, root, &tail_message());
for _ in 0..2000 {
let mut m = msg.clone();
for _ in 0..1 + r.next() % 4 {
let i = (r.next() as usize) % m.len();
m[i] = r.next() as u8;
}
if let Ok(kv) = sc.decode_message(root, &m) {
let (json, _) = map::to_rest(&kv, "x");
let _ = crate::rest::import(&json, &crate::rest::no_images);
}
}
}
#[test]
fn junk_vector_networks_in_json_fall_back() {
let vector = |net: Value| {
serde_json::json!({"document": {"id": "0:0", "type": "DOCUMENT", "children": [{"id": "0:1", "type": "CANVAS", "children": [{
"id": "1:1", "type": "VECTOR", "name": "V", "size": {"x": 10, "y": 10}, "relativeTransform": [[1, 0, 0], [0, 1, 0]],
"fills": [], "strokes": [], "fillGeometry": [{"path": "M0 0L10 0L10 10Z"}], "vectorNetwork": net}]}]}})
};
let cases = [
serde_json::json!({"vertices": [{"x": 0, "y": 0}], "segments": [{"a": 0, "b": 5}]}),
serde_json::json!({"vertices": [{"x": 1e300, "y": 0}, {"x": 0, "y": 0}], "segments": [{"a": 0, "b": 1}]}),
serde_json::json!({"vertices": "no"}),
serde_json::json!({"vertices": [{"x": 0, "y": 0}, {"x": 1, "y": 1}], "segments": [{"a": 0, "b": 1}], "regions": [{"loops": [[9]]}]}),
];
for (i, net) in cases.into_iter().enumerate() {
let (doc, w) = crate::rest::import(&vector(net), &crate::rest::no_images).unwrap();
let NodeKind::Vector { network } = &named(&doc, "V").kind else { panic!() };
assert!(!network.segments.is_empty(), "{i}");
if i == 3 {
// Usable apart from a loop that refers to nothing: kept, the loop dropped.
assert!(network.regions.iter().all(|r| r.loops.is_empty()));
} else {
assert!(w.iter().any(|w| w.contains("vector networks import as outlines")), "{i}: {w:?}");
}
}
}
/// Local only: writes this module's fixture as a `.fig` file to the path `FIG_FIXTURE_OUT` names
/// (for opening it in the app).
#[test]
fn write_fixture() {
if let Ok(out) = std::env::var("FIG_FIXTURE_OUT") {
std::fs::write(out, zip_file(&tail_canvas())).unwrap();
}
}
+25 -9
View File
@@ -12,17 +12,23 @@ use super::*;
/// The fixture schema: `enum Name A B …`, `struct|message Name type field, type field[] …`.
/// Message field ids count from 1; enum values from 0.
const SCHEMA: &str = "
enum NodeType NONE DOCUMENT CANVAS GROUP FRAME BOOLEAN_OPERATION VECTOR STAR LINE ELLIPSE RECTANGLE REGULAR_POLYGON ROUNDED_RECTANGLE TEXT SLICE SYMBOL INSTANCE STICKY VARIABLE VARIABLE_SET
enum NodeType NONE DOCUMENT CANVAS GROUP FRAME BOOLEAN_OPERATION VECTOR STAR LINE ELLIPSE RECTANGLE REGULAR_POLYGON ROUNDED_RECTANGLE TEXT SLICE SYMBOL INSTANCE STICKY VARIABLE VARIABLE_SET SHAPE_WITH_TEXT CONNECTOR TABLE
enum PaintType SOLID GRADIENT_LINEAR IMAGE
enum WindingRule NONZERO ODD
enum StyleType NONE FILL TEXT
enum StackMode NONE HORIZONTAL VERTICAL
enum PropType BOOL TEXT
enum VariableField MISSING VISIBLE TEXT_DATA
enum DataType BOOLEAN FLOAT STRING ALIAS COLOR PROP_REF
enum DataType BOOLEAN FLOAT STRING ALIAS COLOR PROP_REF EXPRESSION
enum ResolvedType BOOLEAN FLOAT STRING COLOR
enum InteractionType ON_CLICK ON_HOVER
enum ConnectionType NONE INTERNAL_NODE URL
enum ConnectionType NONE INTERNAL_NODE URL BACK CLOSE SET_VARIABLE CONDITIONAL SET_VARIABLE_MODE
enum StrokeCap NONE ROUND SQUARE ARROW_LINES ARROW_EQUILATERAL
enum StrokeJoin MITER BEVEL ROUND
enum VectorMirror NONE ANGLE ANGLE_AND_LENGTH
enum ShapeWithTextType SQUARE ELLIPSE
enum ConnectorLineStyle ELBOWED STRAIGHT CURVED
enum ExpressionFunction ADDITION SUBTRACTION RESOLVE_VARIANT MULTIPLY DIVIDE EQUALS
enum NavigationType NAVIGATE OVERLAY
enum TransitionType INSTANT_TRANSITION DISSOLVE SMART_ANIMATE
enum EasingType IN_CUBIC OUT_CUBIC
@@ -34,7 +40,13 @@ struct ColorStop Color color, float position
message Image byte[] hash, string name
message VariableID GUID guid
message PropRefValue GUID defId
message AnyValue bool boolValue, float floatValue, VariableID alias, Color colorValue, PropRefValue propRefValue, TextData textDataValue
message AnyValue bool boolValue, float floatValue, VariableID alias, Color colorValue, PropRefValue propRefValue, TextData textDataValue, string textValue, Expression expressionValue
message Expression ExpressionFunction expressionFunction, VariableData[] expressionArguments
message VariableTarget VariableID id
message VariableSetID GUID guid
message ConditionalActions Action[] actions, VariableData condition
message VectorData uint vectorNetworkBlob, Vector normalizedSize, NodeChange[] styleOverrideTable
message ConnectorEndpoint GUID endpointNodeID, Vector position, Vector relativePosition
message VariableData AnyValue value, DataType dataType
message Paint PaintType type, Color color, float opacity, bool visible, Image image, Matrix transform, ColorStop[] stops, VariableData colorVar
message ParentIndex GUID guid, string position
@@ -55,10 +67,10 @@ message Values ValuesEntry[] entries
message ModeEntry VariableID variableSetID, GUID variableModeID
message ModeMap ModeEntry[] entries
message Event InteractionType interactionType
message Action GUID transitionNodeID, TransitionType transitionType, float transitionDuration, EasingType easingType, ConnectionType connectionType, NavigationType navigationType
message Action GUID transitionNodeID, TransitionType transitionType, float transitionDuration, EasingType easingType, ConnectionType connectionType, NavigationType navigationType, VariableTarget targetVariable, VariableData targetVariableData, VariableSetID targetVariableSetID, GUID targetVariableModeID, ConditionalActions[] conditionalActions
message Interaction Event event, Action[] actions
message StartingPoint string name, string position
message NodeChange GUID guid, ParentIndex parentIndex, NodeType type, string name, bool visible, float opacity, Vector size, Matrix transform, Paint[] fillPaints, Paint[] strokePaints, float strokeWeight, float cornerRadius, bool resizeToFit, bool internalOnly, float fontSize, FontName fontName, TextData textData, uint styleID, Path[] fillGeometry, Path[] strokeGeometry, StyleType styleType, StyleId styleIdForFill, SymbolData symbolData, PropDef[] componentPropDefs, PropAssignment[] componentPropAssignments, VariableDataMap parameterConsumptionMap, SetMode[] variableSetModes, VariableID variableSetID, ResolvedType variableResolvedType, Values variableDataValues, ModeMap variableModeBySetMap, Interaction[] prototypeInteractions, StartingPoint prototypeStartingPoint, GUIDPath guidPath, StackMode stackMode, float stackSpacing, float stackHorizontalPadding, float stackVerticalPadding, uint count, float starInnerScale
message NodeChange GUID guid, ParentIndex parentIndex, NodeType type, string name, bool visible, float opacity, Vector size, Matrix transform, Paint[] fillPaints, Paint[] strokePaints, float strokeWeight, float cornerRadius, bool resizeToFit, bool internalOnly, float fontSize, FontName fontName, TextData textData, uint styleID, Path[] fillGeometry, Path[] strokeGeometry, StyleType styleType, StyleId styleIdForFill, SymbolData symbolData, PropDef[] componentPropDefs, PropAssignment[] componentPropAssignments, VariableDataMap parameterConsumptionMap, SetMode[] variableSetModes, VariableID variableSetID, ResolvedType variableResolvedType, Values variableDataValues, ModeMap variableModeBySetMap, Interaction[] prototypeInteractions, StartingPoint prototypeStartingPoint, GUIDPath guidPath, StackMode stackMode, float stackSpacing, float stackHorizontalPadding, float stackVerticalPadding, uint count, float starInnerScale, VectorData vectorData, StrokeCap strokeCap, StrokeJoin strokeJoin, VectorMirror handleMirroring, ShapeWithTextType shapeWithTextType, ConnectorEndpoint connectorStart, ConnectorEndpoint connectorEnd, StrokeCap connectorStartCap, StrokeCap connectorEndCap, ConnectorLineStyle connectorLineStyle
message Blob byte[] bytes
message Message NodeChange[] nodeChanges, Blob[] blobs
";
@@ -493,7 +505,7 @@ fn imports_the_fixture() {
assert_eq!(doc.node(page.children[0]).unwrap().name, "Screen");
let screen = named(&doc, "Screen");
let names: Vec<&str> = screen.children.iter().map(|c| doc.node(*c).unwrap().name.as_str()).collect();
assert_eq!(names, ["Photo", "Title", "Group", "Styled", "Card", "Button copy"]);
assert_eq!(names, ["Photo", "Title", "Group", "Styled", "Card", "Button copy", "Note"]);
// Image fill from the archive.
let photo = named(&doc, "Photo");
let figcraft_doc::color::PaintKind::Image(img) = &photo.fills[0].kind else { panic!("not an image fill") };
@@ -539,8 +551,9 @@ fn imports_the_fixture() {
assert_eq!(it.duration_ms, 250);
let Some(NodeKind::Page { flows, .. }) = doc.node(doc.pages[0]).map(|p| &p.kind) else { panic!("no page") };
assert_eq!((flows[0].name.as_str(), flows[0].start), ("Main flow", screen.id));
// The sticky note isn't supported: a warning.
assert!(warnings.iter().any(|w| w.contains("STICKY")), "{warnings:?}");
// The sticky note comes in as a frame, with a warning.
assert!(matches!(named(&doc, "Note").kind, NodeKind::Frame));
assert!(warnings.iter().any(|w| w.contains("sticky layers import as frames")), "{warnings:?}");
}
#[test]
@@ -692,6 +705,9 @@ fn instances_inside_their_own_main_stop() {
assert!(doc.nodes().any(|n| n.name == "Self"));
}
#[path = "tail_tests.rs"]
mod tail;
/// `.fig` files in the folder `FIGCRAFT_FIG_SAMPLES` names (none when it's unset).
fn samples() -> Vec<std::path::PathBuf> {
let Some(dir) = std::env::var_os("FIGCRAFT_FIG_SAMPLES") else { return Vec::new() };
+31 -2
View File
@@ -520,9 +520,19 @@ impl<'a> Ctx<'a> {
self.bindings(n, j);
}
/// A vector's network from its geometry. A stroke-only vector uses the stroke outline,
/// painted with the stroke paints (exact look, editable as a shape).
/// A vector's network: the editable one when the JSON carries it (`vectorNetwork`, our
/// extension, written by the `.fig` import), else from its geometry. A stroke-only vector
/// uses the stroke outline, painted with the stroke paints (exact look, editable as a shape).
fn vector(&mut self, n: &mut Node, j: &Value) {
if let Some(net) = j.get("vectorNetwork") {
match network(net) {
Some(network) => {
n.kind = NodeKind::Vector { network };
return;
}
None => self.warn("invalid or oversized vector networks import as outlines"),
}
}
let fills_shown = n.fills.iter().any(|p| p.visible);
let strokes_shown = n.strokes.iter().any(|p| p.visible) && n.stroke.weight > 0.0;
let fill = self.geometry(j, "fillGeometry");
@@ -945,6 +955,25 @@ fn local(parent: Affine, world: Affine) -> Affine {
}
/// Finite path data within the coordinate cap.
/// A vector network in our own JSON shape, when it's within bounds and refers only to what it has.
fn network(v: &Value) -> Option<VectorNetwork> {
use serde::Deserialize;
let mut net = VectorNetwork::deserialize(v).ok()?;
let entries: usize = net.regions.iter().flat_map(|r| &r.loops).map(Vec::len).sum();
let coord = |x: f64| x.is_finite() && x.abs() <= MAX_COORD;
let fits = net.vertices.len() + net.segments.len() + entries <= MAX_PATH_ELEMENTS
&& net.vertices.iter().all(|p| coord(p.x) && coord(p.y) && coord(p.radius))
&& net.segments.iter().all(|s| [s.ta.0, s.ta.1, s.tb.0, s.tb.1].into_iter().all(coord));
if !fits {
return None;
}
for p in &mut net.vertices {
p.radius = p.radius.clamp(0.0, MAX_SIZE);
}
net.sanitize();
(!net.segments.is_empty()).then_some(net)
}
fn sane(p: &BezPath) -> bool {
let b = p.bounding_box();
[b.x0, b.y0, b.x1, b.y1].iter().all(|v| v.is_finite() && v.abs() <= MAX_COORD)
+18 -10
View File
@@ -309,20 +309,28 @@ pub(super) fn stroke_props(v: &Value, sp: &mut StrokeProps) {
Some("OUTSIDE") => sp.align = StrokeAlign::Outside,
_ => {}
}
let cap = match text(v, "strokeCap") {
Some("ROUND") => Some(StrokeCap::Round),
Some("SQUARE") => Some(StrokeCap::Square),
Some("LINE_ARROW") => Some(StrokeCap::LineArrow),
Some("TRIANGLE_ARROW" | "TRIANGLE_FILLED") => Some(StrokeCap::TriangleArrow),
Some("DIAMOND_FILLED") => Some(StrokeCap::DiamondFilled),
Some("CIRCLE_FILLED") => Some(StrokeCap::CircleFilled),
Some(_) => Some(StrokeCap::None),
None => None,
let cap = |k: &str| {
text(v, k).map(|c| match c {
"ROUND" => StrokeCap::Round,
"SQUARE" => StrokeCap::Square,
"LINE_ARROW" => StrokeCap::LineArrow,
"TRIANGLE_ARROW" | "TRIANGLE_FILLED" => StrokeCap::TriangleArrow,
"DIAMOND_FILLED" => StrokeCap::DiamondFilled,
"CIRCLE_FILLED" => StrokeCap::CircleFilled,
_ => StrokeCap::None,
})
};
if let Some(c) = cap {
if let Some(c) = cap("strokeCap") {
sp.cap_start = c;
sp.cap_end = c;
}
// Each end on its own (our extension, written by the `.fig` import).
if let Some(c) = cap("strokeCapStart") {
sp.cap_start = c;
}
if let Some(c) = cap("strokeCapEnd") {
sp.cap_end = c;
}
match text(v, "strokeJoin") {
Some("MITER") => sp.join = StrokeJoin::Miter,
Some("BEVEL") => sp.join = StrokeJoin::Bevel,
+4 -3
View File
@@ -1,12 +1,12 @@
# File format parity
> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-11 · **Change:** minor (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) · **Target:** Figma Design (desktop app 126.10.7)
> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-11 · **Change:** minor (.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) · **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
help center (image and video types, export formats), and FigCraft's source.
**Summary (estimated):** ~55% ready. The core interchange formats (SVG, PNG, JPG, PDF) are solid
**Summary (estimated):** ~58% ready. The core interchange formats (SVG, PNG, JPG, PDF) are solid
and tested; Figma's own document, `.fig`, opens (best effort, not yet checked against real-world
files) but can't be written.
@@ -14,7 +14,7 @@ files) but can't be written.
| Format | Figma | FigCraft read | FigCraft write | Fidelity / testing | Hours |
|---|---|---|---|---|---|
| `.fig` (Figma Design, `com.figma.document`, `application/x-figma`) | native (cloud; "Save local copy") | **yes, best effort** (`file.open`, `file.import`, drop, CLI, MCP) | **no** | `.fig` files open (best effort): the zip, its deflate/zstd chunks and the kiwi schema are decoded generically and mapped by field name onto the REST importer, so pages, the layer tree, geometry, paints (styles resolved), strokes, effects, text with styled runs, vectors (as outlines), auto layout (grid included), constraints, components, sets, instances (expanded from their main with overrides, derived layout and property values), styles, variables with modes and bindings, prototyping and image fills from the archive come across; anything else is a warning. Vectors are outlines, not editable networks; sticky notes, connectors, tables and other non-design layers, set-variable and conditional actions and video fills are skipped with warnings. Tested on fixtures generated in code (`crates/format/src/fig/tests.rs`, fuzzed); no real-world files yet | read tail 8–15, write 15–30 |
| `.fig` (Figma Design, `com.figma.document`, `application/x-figma`) | native (cloud; "Save local copy") | **yes, best effort** (`file.open`, `file.import`, drop, CLI, MCP) | **no** | `.fig` files open (best effort): the zip, its deflate/zstd chunks and the kiwi schema are decoded generically and mapped by field name onto the REST importer, so pages, the layer tree, geometry, paints (styles resolved), strokes, effects, text with styled runs, vectors as editable vector networks (points, curved segments, filled areas with their winding, per-point corner radius and handle mirroring, end caps; outlines when the network is missing or damaged), auto layout (grid included), constraints, components, sets, instances (expanded from their main with overrides, derived layout and property values), styles, variables with modes and bindings, prototyping (set-variable, set-variable-mode and conditional actions included) and image fills from the archive come across; sticky notes, shapes with text, connectors, tables, code blocks, slides and similar layers come in as the closest frames, text layers and vectors, with a warning; anything else is a warning. Not read: connector labels, table cells beyond their own layers, filled areas with their own fills, per-point stroke joins, variables bound to fields the editor can't bind, fixed or content-sized grid tracks, video fills. Tested on fixtures generated in code (`crates/format/src/fig/tests.rs`, `tail_tests.rs`, fuzzed); no real-world files yet | read tail 5–10, write 15–30 |
| `.jam` (FigJam), `.deck` (Slides) | native to other Figma products | no | no | out of scope | — |
| REST API file JSON (`GET /v1/files/:key`) + local variables JSON + `images/` folder | the API's output | **yes** (`file.open`, `file.import`, CLI) | no | pages, layer tree, geometry, paints, strokes, effects, text runs, vectors, auto layout, constraints, grids, components, sets, instances with overrides, component properties, styles, prototyping (incl. conditionals and variable modes), variables with modes, aliases, scopes and bindings; warnings for what's skipped (video triggers and actions, modifier keys). Unit tests in `crates/format/src/rest/*_tests.rs` | — |
| `.figcraft` (ours: zip of `document.json` + images, versions inside) | — | yes | yes | streamed JSON, 50k layers save ~0.09 s / load ~0.16 s (`cargo xtask bench`); old files keep opening (`large_tests.rs`) | — |
@@ -61,6 +61,7 @@ files) but can't be written.
| Date | Change | Summary |
|---|---|---|
| 2026-10-11 | minor | `.fig` import's tail (gap 1): vectors open as editable vector networks (points, curved segments, filled areas with their winding, per-point corner radius and handle mirroring, end caps from the end points), falling back to outlines when the network is missing or damaged; sticky notes, shapes with text, connectors, tables, code blocks, slides and similar layers import as the closest frames, text layers and vectors with a warning instead of being dropped; set-variable, set-variable-mode and conditional prototype actions come across. New parsers fuzzed on fixtures generated in code. File formats 55% → 58%, 8–15 h → 5–10 h; import/export breadth 80% → 82%, ready 50% → 54%; feature and area totals and ready for real work (~49%) unchanged; beta ~173–310 h → ~170–305 h; full parity ~321–618 h → ~318–613 h |
| 2026-10-11 | minor | Embedded image colour profiles: placed and opened images are converted once, when decoded, from their own colour space into the file's (sRGB or Display P3): ICC profiles in PNG (`iCCP`), JPG (APP2), WebP and TIFF, matrix/curve and table profiles in RGB or grey (moxcms, already built for the `image` crate), and a PNG's `sRGB`, `gAMA` and `cHRM` chunks; images without a profile, or with one that can't be used (CMYK, damaged, over 4 MB), are taken as sRGB as before. The original file is kept (PDF exports still embed it); pixel exports carry the converted pixels with the file's profile. The image fill's details show the source profile (Unknown — treated as sRGB when there's none) and `image.place` reports it as `imageProfile`. |
| 2026-10-11 | minor | `.fig` import, best effort (gap 1): `.fig` files open (best effort): the zip, its deflate/zstd chunks and the kiwi schema are decoded generically and mapped by field name onto the REST importer, so pages, the layer tree, geometry, paints (styles resolved), strokes, effects, text with styled runs, vectors (as outlines), auto layout (grid included), constraints, components, sets, instances (expanded from their main with overrides, derived layout and property values), styles, variables with modes and bindings, prototyping and image fills from the archive come across; anything else is a warning. Summary ~40% → ~55%; `.fig` read 30–60 h → 8–15 h (write 15–30 h unchanged) |
| 2026-10-11 | minor | Animated GIF fills and TIFF images (gap 12, without video and HEIC): GIF fills keep the original file, show the first frame on the canvas and in exports and play in the presenter and the preview (frames decoded on first play, at most 500 frames and 128 MB; always looping; the window wakes only for a GIF's next frame); TIFF images open, import, place and paste. Paint breadth 85% → 87%, 5–9 h → 4–7 h; gap 12 6–12 h → 5–10 h (video playback and HEIC left, both needing non-Rust decoders); feature breadth (~79%), ready for real work (~47%) and full parity hours unchanged at this rounding |
+11 -11
View File
@@ -1,6 +1,6 @@
# Where FigCraft falls short of Figma
> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-11 · **Change:** minor (`.fig` import, best effort: gap 1 down to its tail; Windows QA pass: everyday workflows on Windows 11, eight fixes incl. unsaved work kept after a crash or a forced quit; 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, variables import/export, gradient stop and variant bindings: gap 14 down to its tail) · **Target:** Figma Design (desktop app 126.10.7)
> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-11 · **Change:** minor (`.fig` import: editable vector networks, more layer kinds, variable prototype actions; `.fig` import, best effort: gap 1 down to its tail; Windows QA pass: everyday workflows on Windows 11, eight fixes incl. unsaved work kept after a crash or a forced quit; 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, variables import/export, gradient stop and variant bindings: gap 14 down to its tail) · **Target:** Figma Design (desktop app 126.10.7)
Every known shortfall against Figma, one entry each, ranked by how much it stops a Figma user from
doing real work in FigCraft. Agents: pick work from the top unless the owner says otherwise. The
@@ -14,7 +14,7 @@ No alpha blockers: the alpha gate in [`roadmap.md`](roadmap.md#alpha-gate) passe
| # | Gap | Kind | Hours | Doc |
|---|---|---|---|---|
| 1 | `.fig` files: best-effort import landed; editable vector networks, more layer kinds, real-file checks and export left (**beta blocker** until checked on real files, gap 3) | File format | 8–15 | [file-format-parity](file-format-parity.md) |
| 1 | `.fig` files: best-effort import with editable vector networks and stand-ins for non-design layers landed; real-file checks, the small tail and export left (**beta blocker** until checked on real files, gap 3) | File format | 5–10 | [file-format-parity](file-format-parity.md) |
| 2 | Multiplayer and sharing are a preview, without accounts (**beta blocker**) | Feature | 45–90 | [collaboration-parity](collaboration-parity.md) |
| 3 | Nothing checked against real Figma files | Fidelity | 15–30 | [target-app-parity](target-app-parity.md) |
| 4 | Interaction fidelity not compared tool by tool | UI/UX | 40–60 | [ui-parity](ui-parity.md) |
@@ -41,20 +41,19 @@ No alpha blockers: the alpha gate in [`roadmap.md`](roadmap.md#alpha-gate) passe
## Entries
### 1. `.fig` files: the import's tail
- **Landed (2026-10-11):** `.fig` files open (best effort): the zip, its deflate/zstd chunks and the kiwi schema are decoded generically and mapped by field name onto the REST importer, so pages, the layer tree, geometry, paints (styles resolved), strokes, effects, text with styled runs, vectors (as outlines), auto layout (grid included), constraints, components, sets, instances (expanded from their main with overrides, derived layout and property values), styles, variables with modes and bindings, prototyping and image fills from the archive come across; anything else is a warning (`crates/format/src/fig`). `file.open`,
- **Landed (2026-10-11):** `.fig` files open (best effort): the zip, its deflate/zstd chunks and the kiwi schema are decoded generically and mapped by field name onto the REST importer, so pages, the layer tree, geometry, paints (styles resolved), strokes, effects, text with styled runs, vectors, auto layout (grid included), constraints, components, sets, instances (expanded from their main with overrides, derived layout and property values), styles, variables with modes and bindings, prototyping and image fills from the archive come across; anything else is a warning (`crates/format/src/fig`). Then (2026-10-11): vectors open as editable vector networks (per-point corner radius, handle mirroring, end caps; outlines when the network blob is missing or damaged), sticky notes, shapes with text, connectors, tables, code blocks, slides and similar layers import as the closest frames, text layers and vectors with a warning, and set-variable, set-variable-mode and conditional prototype actions come across. `file.open`,
`file.import`, drag and drop, the CLI and MCP's `open_file` read `.fig`; the file is never saved
over.
- **Missing:** vectors come in as their outlines (fill or stroke geometry), not editable vector
networks with per-vertex caps; sticky notes, connectors, tables, slides and other non-design layer
kinds are skipped; set-variable and conditional prototype actions, variables bound to fields other
than the common ones, non-flexible grid tracks and video fills aren't read; text lays out again with
- **Missing:** connector labels, elbowed or curved connectors without a saved path (drawn straight),
table cells beyond their own layers, filled areas of a vector with their own fills, per-point stroke
joins, variables bound to fields the editor can't bind, fixed or content-sized grid tracks and video
fills aren't read; text lays out again with
local fonts. Nothing has been checked against real-world files yet (gap 3). Export to `.fig` is
not started.
- **Evidence:** fixtures generated in code (`crates/format/src/fig/tests.rs`: kiwi round trips,
every mapped feature, truncation, bit flips and junk never panic); `crates/engine/src/fig_import_tests.rs`.
- **Evidence:** fixtures generated in code (`crates/format/src/fig/tests.rs` and `tail_tests.rs`: kiwi
round trips, every mapped feature, vector network blobs, truncation, bit flips and junk never panic); `crates/engine/src/fig_import_tests.rs`.
- **Impact:** existing work can be brought across, but only real files will show how much of it.
- **Estimate:** 8–15 h for the read tail (vector networks, the skipped layer kinds that matter,
real-file fixes); export to `.fig` a further 15–30 h and lower priority.
- **Estimate:** 5–10 h for the read tail (real-file fixes, connector labels, table cells); export to `.fig` a further 15–30 h and lower priority.
### 2. Multiplayer and sharing are a preview, without accounts
- **Missing:** accounts, teams and projects, per-person permissions, spotlight, cursor chat, audio,
@@ -291,6 +290,7 @@ No alpha blockers: the alpha gate in [`roadmap.md`](roadmap.md#alpha-gate) passe
| Date | Change | Summary |
|---|---|---|
| 2026-10-11 | minor | `.fig` import's tail (gap 1): vectors open as editable vector networks (points, curved segments, filled areas with their winding, per-point corner radius and handle mirroring, end caps from the end points), falling back to outlines when the network is missing or damaged; sticky notes, shapes with text, connectors, tables, code blocks, slides and similar layers import as the closest frames, text layers and vectors with a warning instead of being dropped; set-variable, set-variable-mode and conditional prototype actions come across. New parsers fuzzed on fixtures generated in code. File formats 55% → 58%, 8–15 h → 5–10 h; import/export breadth 80% → 82%, ready 50% → 54%; feature and area totals and ready for real work (~49%) unchanged; beta ~173–310 h → ~170–305 h; full parity ~321–618 h → ~318–613 h |
| 2026-10-11 | minor | `.fig` import, best effort (gap 1): `.fig` files open (best effort): the zip, its deflate/zstd chunks and the kiwi schema are decoded generically and mapped by field name onto the REST importer, so pages, the layer tree, geometry, paints (styles resolved), strokes, effects, text with styled runs, vectors (as outlines), auto layout (grid included), constraints, components, sets, instances (expanded from their main with overrides, derived layout and property values), styles, variables with modes and bindings, prototyping and image fills from the archive come across; anything else is a warning. Gap 1 30–60 h → 8–15 h (vector networks, other layer kinds, real-file checks; export 15–30 h on top, as before) |
| 2026-10-11 | minor | Display P3 honest minimum (gap 18): the colour picker of a Display P3 file warns when a colour is outside sRGB (Outside sRGB, with what the canvas and exports do), and View ▸ Soft-proof sRGB (`view.proofSrgb`) stripes the canvas pixels sRGB can't show. Why the canvas still shows sRGB: wgpu 30 can present Display P3 or extended-range sRGB surfaces (Metal, DX12, Vulkan, WebGPU canvases), but eframe/egui-wgpu 0.36 configure an 8-bit sRGB surface with no option for the format or colour space, and egui's renderer writes sRGB-encoded UI colours; showing P3 needs those two upstream (or a patched egui-wgpu). Hardware 13–27 h → 12–26 h; gap 18 6–12 h → 5–11 h; percentages, feature breadth (~79%) and ready for real work (~47%) unchanged at this rounding; full parity ~363–697 h → ~362–696 h |
| 2026-10-11 | minor | Several windows (gap 18): File ▸ New window, a tab's Open in new window or a tab dragged off the tab strip opens files in another window with its own tabs, panels and canvas, sharing the files, preferences, libraries and clipboard; each window draws only when it needs to (idle: ~0% CPU), closing one closes its files after asking about unsaved changes (the main window too, which takes over another window instead of quitting; the last window quits), and the control channel and MCP open, list, focus and capture windows (`window.new`, `window.list`, `window.focus`, `window` on any request); the screen-reader tree covers the main window only (eframe 0.36), so with a screen reader running files stay tabs; one window in the browser. Hardware 55% → 60%, 13–27 h → 10–21 h; gap 18 6–12 h → 4–8 h (P3 on screen left); feature breadth (~79%) and ready for real work (~47%) unchanged at this rounding; full parity ~363–697 h → ~360–691 h |
+5 -4
View File
@@ -611,10 +611,11 @@ refused.
with, so the data is decoded generically and mapped by field name onto the REST import below: the
same layers, paints, text, auto layout, components, styles, variables and prototyping come across,
with image fills from the file's own `images/` folder. Instances are rebuilt from their main
component with the overrides and property values the file records. Vectors come in as their
outlines (not editable vector networks); sticky notes, connectors, tables and other non-design
layers, set-variable and conditional actions and video fills are skipped. The result's `warnings`
list what was skipped.
component with the overrides and property values the file records. Vectors come in as editable
vector networks (their outlines when the network is missing or damaged); sticky notes, shapes with
text, connectors, tables and similar layers come in as the closest frames, text layers and
vectors; set-variable, set-variable-mode and conditional actions come across; video fills are
skipped. The result's `opened.warnings` list what was approximated or skipped.
### Importing REST API file JSON
+11 -10
View File
@@ -1,6 +1,6 @@
# FigCraft parity with Figma
> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-11 · **Change:** minor (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, variables import/export, gradient stop and variant bindings; feature breadth ~80%; ready ~49%; last major: full re-measure against Figma desktop 126.10.7) · **Target:** Figma Design (desktop app 126.10.7, macOS)
> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-11 · **Change:** minor (.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, variables import/export, gradient stop and variant bindings; feature breadth ~80%; ready ~49%; last major: full re-measure against Figma desktop 126.10.7) · **Target:** Figma Design (desktop app 126.10.7, macOS)
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
@@ -20,8 +20,8 @@ summarizes it; [`gaps.md`](gaps.md) is the ranked work list; the measured menu c
| Ready for real work | **~51%** | estimated (dimension table below: written weights × written per-dimension scores) |
| Essentials user | **~57%** | estimated ([below](#essentials-user): core features 77.8% × discounts 0.92 × 0.92 × 0.88) |
| Mainstream practitioner | **~49%** | estimated ([below](#mainstream-practitioner): weekly areas 70.7% × discounts 0.9 × 0.9 × 0.85) |
| Remaining to beta | **~173–310 h** one Opus 5.5 agent (~35–70 h wall-clock with 5 parallel agents) | estimated |
| Remaining to full parity | **~310–601 h** one agent (~86–150 h wall-clock with 5 parallel agents) | estimated |
| Remaining to beta | **~170–305 h** one Opus 5.5 agent (~35–70 h wall-clock with 5 parallel agents) | estimated |
| Remaining to full parity | **~307–596 h** one agent (~86–150 h wall-clock with 5 parallel agents) | estimated |
| Stage | **alpha**: the alpha gate passes ([roadmap](roadmap.md#alpha-gate)); multiplayer and checking the best-effort `.fig` import on real files are beta gaps (see [ROADMAP](../ROADMAP.md#stage)) | |
The previous figures (ROADMAP of 2026-10-10, "feature depth ~79%", "full parity 115–195 h") were
@@ -35,9 +35,9 @@ tool; presence was being graded as behaviour in places.
| Audience | Ready % | Opus 5.5 agent wall-clock hours to ~95% | Work that dominates |
|---|---|---|---|
| Full target (ready for real work) | ~51% | ~299–575 h | multiplayer (45–90), localization (45–75), plugins (15–40), the interaction pass (40–60), GPU canvas (30–60), AI (30–60), the `.fig` import's tail (8–15) |
| Mainstream practitioner | ~49% | ~134–250 h | core editor depth incl. the `.fig` import's tail (52–103), the interaction pass (40–60), real-file corpus and platform QA (25–50), `.fig` export for handing work back (15–30) |
| Essentials user | ~57% | ~63–115 h | the `.fig` import's tail so the files people send open well (8–15), core tool polish (25–45), interaction pass on the core tools (15–25), stability QA (10–20), onboarding and starter templates (5–10) |
| Full target (ready for real work) | ~51% | ~296–570 h | multiplayer (45–90), localization (45–75), plugins (15–40), the interaction pass (40–60), GPU canvas (30–60), AI (30–60), the `.fig` import's tail (5–10) |
| Mainstream practitioner | ~49% | ~131–245 h | core editor depth incl. the `.fig` import's tail (49–98), the interaction pass (40–60), real-file corpus and platform QA (25–50), `.fig` export for handing work back (15–30) |
| Essentials user | ~57% | ~60–110 h | the `.fig` import's tail so the files people send open well (5–10), core tool polish (25–45), interaction pass on the core tools (15–25), stability QA (10–20), onboarding and starter templates (5–10) |
Hours are one agent working sequentially, calibrated from this repo's history (about 1 agent-hour
per 1,000 added lines, 1–2.5 h per feature PR, ~7 h per honest depth point in the polish tail; see
@@ -107,7 +107,7 @@ Breadth: does it exist. Ready: depth, correctness and fidelity for real projects
| 11 | Components & variants | 9 | 96% | 72% | Real (not synthetic) libraries, `.fig` libraries, component playground (behaviour suite from the documentation, overrides of padding, gap, grids, exports and interactions, refused geometry overrides, variant-switch and Assets-swap override rules, top-left default variant landed) ([components-parity](components-parity.md)) | 4–9 |
| 12 | Styles & variables | 5 | 94% | 76% | Variables inside styles, new library variables reaching extended collections on their own, extending an extended collection, real-library QA (behaviour suite from the documentation, style folders and order, mode order, code syntax, extended collections and variables import/export landed) ([components-parity](components-parity.md)) | 3–5 |
| 13 | Prototyping | 6 | 90% | 74% | Video triggers and media actions, gamepad triggers, delays on mouse triggers, manual overlay offsets, real prototype QA (behaviour suite from the documentation, key combinations, interactive components playing their variants, drag-driven animations, physical springs landed) ([prototyping-parity](prototyping-parity.md)) | 3–6 |
| 14 | Import/export | 6 | 80% | 50% | `.fig` import's tail (best-effort import landed: vectors as outlines, non-design layers skipped, no real-file checks yet), `.fig` export, HEIC/video import, CMYK output intent ([file-format-parity](file-format-parity.md)) | 8–15 |
| 14 | Import/export | 6 | 82% | 54% | `.fig` import's tail (best-effort import, editable vector networks, stand-ins for non-design layers and variable prototype actions landed; no real-file checks yet), `.fig` export, HEIC/video import, CMYK output intent ([file-format-parity](file-format-parity.md)) | 5–10 |
| 15 | Dev Mode / inspect | 2 | 90% | 64% | Dev resources links, import lines for connected instances inside generated layer trees; an editor extension is left to the MCP server (code connect and plugins in Dev Mode landed) ([dev-mode-parity](dev-mode-parity.md)) | 1–3 |
| 16 | Comments, versions, find, misc | 3 | 85% | 60% | Comments are local only; a branch's own version history (branches with review and merge landed) | 2–5 |
| 17 | Multiplayer & sharing | 8 | 15% | 5% | Core landed (`crates/collab`: ids, change sets, server ordering, convergence tested); live sessions (preview): WebSocket transport, hosting from the desktop app, joining from the app or the browser, invite links that open the app, cursors, selections and follow, per-person undo, offline resume; a self-hosted `figcraft-server` with persistence and share links to edit or view. Left: accounts and teams, cursor chat ([collaboration-parity](collaboration-parity.md)) | 45–90 |
@@ -115,7 +115,7 @@ Breadth: does it exist. Ready: depth, correctness and fidelity for real projects
| 19 | Team libraries & organization | 2 | 70% | 40% | Libraries kept on `figcraft-server` under their file and enabled by a share link (per-asset versions, background update checks, overrides kept), usage counts per component and file, swapping a library by names. Left: team-wide library lists, accounts and permissions, analytics over time, pushed update notices | 4–8 |
| 20 | Figma Draw extras | 2 | 80% | 55% | Have: variable width, lasso, shape builder, noise/texture, text on a path, pattern fills, stretch and scatter brushes, pen pressure (Windows pens, browser styluses). Missing: multi-edit, pressure from macOS/Linux tablets | 2–5 |
| 21 | AI features | 3 | 0% | 0% | First draft, rename layers, image edit/remove background, visual search, Make: needs local models (owner decision) | 30–60 |
| | **Weighted (÷113)** | 113 | **~82%** | **~61%** | | **152–315** |
| | **Weighted (÷113)** | 113 | **~82%** | **~61%** | | **149–310** |
| | Core editor only (1–16, ÷94) | 94 | ~93% | ~70% | | 47–96 |
## By dimension (ready for real work)
@@ -124,7 +124,7 @@ Breadth: does it exist. Ready: depth, correctness and fidelity for real projects
|---|---|---|---|---|---|
| Features (depth) | 35 | 61% | 47–95 (areas 1–13, 15, 16, 20) | The table above; core editor ~70% | this file |
| UI/UX fidelity | 15 | 58% | 40–60 | Look & feel compared side by side with UI3 (~65% by the builders); tool-by-tool interaction (cursors, modifiers while dragging, hover states, popovers) not yet compared; accessibility names and keyboard navigation landed | [ui-parity](ui-parity.md) |
| File formats | 12 | 55% | 8–15 | `.fig` read, best effort (tested on generated fixtures, not real files), no `.fig` write; REST JSON import, SVG in/out, PNG/JPG/WebP/PDF export solid | [file-format-parity](file-format-parity.md) |
| File formats | 12 | 58% | 5–10 | `.fig` read, best effort, with editable vector networks (tested on generated fixtures, not real files), no `.fig` write; REST JSON import, SVG in/out, PNG/JPG/WebP/PDF export solid | [file-format-parity](file-format-parity.md) |
| Collaboration | 10 | 13% | 55–110 | Local comments, version history and branches with review and merge; the multiplayer core (sync model, server ordering, client reconciliation, convergence tests) landed in #103, and live sessions (preview) co-edit a file hosted by a desktop app or kept on a self-hosted `figcraft-server` with edit and view links, joined from the app or the browser (`docs/collab-protocol.md`) | [collaboration-parity](collaboration-parity.md) |
| Stability | 8 | 55% | 20–40 | Never-crash lints, command guard with rollback, recovery copies, 963 tests incl. junk-parameter sweeps and a seeded retained-render walk; but two days old, one release (v0.2.0), no field reports, QA mostly on macOS | [gaps](gaps.md) |
| Performance | 6 | 60% | 30–60 | `cargo xtask bench`: 50k-layer page ~8 ms zoomed out, 10k-layer edits ~0.2 ms, 2,000-instance design system edits 4–64 ms; CPU (tiny-skia) canvas where Figma renders on the GPU; nothing timed against Figma on the same file | [hardware-parity](hardware-parity.md) |
@@ -133,7 +133,7 @@ Breadth: does it exist. Ready: depth, correctness and fidelity for real projects
| Platforms | 3 | 75% | 10–20 | macOS, Windows (x64/x86/arm64), Linux, FreeBSD, web all packaged (v0.2.0); Figma also has iOS/Android apps; non-macOS QA thin | [hardware-parity](hardware-parity.md) |
| Ecosystem/plugins | 3 | 28% | 15–40 | Own WASM plugins with a Rust SDK and two examples (#101), plugin panels and settings (ABI v2); no Figma plugin compatibility, no widgets, no distribution or community files; the CLI, control channel and MCP server are ahead of Figma for agents | [gaps](gaps.md) |
| AI features | 2 | 0% | 30–60 | None | [gaps](gaps.md) |
| **Ready for real work** | 100 | **~51%** | **~310–601** | | |
| **Ready for real work** | 100 | **~51%** | **~307–596** | | |
Dimension hours add up to the full-parity total without double counting: the feature-area rows
for import/export (14), multiplayer and team libraries (17, 19), plugins (18) and AI (21) are
@@ -601,6 +601,7 @@ Carried over from the ROADMAP's "Shipped so far" (2026-10-10). The test count th
| Date | Change | Summary |
|---|---|---|
| 2026-10-11 | minor | `.fig` import's tail (gap 1): vectors open as editable vector networks (points, curved segments, filled areas with their winding, per-point corner radius and handle mirroring, end caps from the end points), falling back to outlines when the network is missing or damaged; sticky notes, shapes with text, connectors, tables, code blocks, slides and similar layers import as the closest frames, text layers and vectors with a warning instead of being dropped; set-variable, set-variable-mode and conditional prototype actions come across. New parsers fuzzed on fixtures generated in code. File formats 55% → 58%, 8–15 h → 5–10 h; import/export breadth 80% → 82%, ready 50% → 54%; feature and area totals and ready for real work (~49%) unchanged; beta ~173–310 h → ~170–305 h; full parity ~321–618 h → ~318–613 h |
| 2026-10-11 | minor | Shapes and vector behaviour suite written from the public documentation (10 shape tests, 6 vector tests and 3 handle tests: about 70 table rows plus stepwise checks) and its fixes: rounded vector points are drawn and exported (the radius was stored only; a vector's corner radius now rounds every point); a boolean group looks like its top layer, a subtraction like the bottom one (always the bottom); Alt draws a line from its centre; stars keep to 3–60 points; delete and heal fits the curve through the deleted point; Shift-clicking a selected point at a corner of the points' box takes it out (it scaled); outline stroke turns the stroke's colour style into the fill's and keeps opacity and effects; the miter field shows the miter angle (it showed the ratio under that name); individual corners only for rectangles and frames; Option/Alt+Shift+F flattens; edit-mode keys reach the edit tools through MCP `key` (Delete removed the layer). Added: corner radius and star ratio handles on the canvas, Count and Ratio fields for polygons and stars, cut tool drags that divide a shape into layers, paint bucket drags across areas. Shapes & vector 97%/68% → 98%/72%, 1–2 h → 2–4 h (the suite found gaps the estimate didn't count: corner smoothing beyond rectangles, a join or cap per point). Totals recomputed by the doc's method, which earlier rows had left stale: the area table's weighted figures are breadth ~82% (core ~93%) and ready ~61% (core ~69%), not the ~80%/~58% (~91%/~68%) carried since #130; Features (depth) follows the weighted ready, 57% → 61%; ready for real work ~49% → ~51%; mainstream ~46% → ~48% (weekly areas 67.8% → 69.4%); the selection suite's area 2 hours (−3/−5) hadn't reached the totals, and neither it nor the text suite had reached the Features (depth) row: Features 60–115 h → 56–109 h, full parity ~321–618 h → ~319–615 h (−3/−5 for area 2, +1/+2 for area 4) |
| 2026-10-11 | minor | Paint and effects behaviour suite written from the public documentation (14 paint and 9 effects tests with table rows for fills, opacity, every blend mode by its formula, pass-through and Normal containers, gradient handles and stops, image scale modes and adjustments, colour models, the colour profile, shadows, blurs, effect order and exports; 3 command tests) and its fixes: groups and frames set to Normal blend their children among themselves first, and clipping frames no longer isolate their children (blend modes inside a clipping frame met only the frame's children, not its fill or what is behind it) — canvas, SVG (`isolation`; clips put on what blends, since a clipped group isolates) and PDF (isolated groups); plus darker is drawn (it was multiply) on the canvas and in PNG exports; drop shadows hide behind what the layer covers, translucent fills included, unless shown behind transparent areas, in SVG too (it never cut them out) and the SVG filter region reaches as far as the effects; Fill images cover the box to its edges (upscaled ones faded out at the edge); "Normal" names Normal, not pass-through, in commands and the blend menu (the menu's Normal set pass-through). Added: a blend menu for each paint in the colour picker. Paint 66% → 70%, 4–7 h → 3–6 h; effects 72% → 75%, 3–6 h → 2–5 h; percentages unchanged at this rounding; full parity ~321–618 h → ~319–616 h |
| 2026-10-11 | minor | Frames, groups, sections and masks behaviour suite written from the public documentation (15 frame, group and section tests, 6 mask tests) and its fixes: a layer a mask hides can't be clicked there (clicks reach what's behind; Select layer agrees), a mask group's box and exports are the mask's (plus any layers under it), sections stay on the page or in sections (moves, drags, pastes, grouping and new sections in frames refused or kept on the page), drawing a frame or section around layers puts those fully inside it in it, resizing a section leaves its contents where they are (and a frame resized with Cmd/Ctrl held keeps its layers' sizes too), and X/Y of layers in groups are measured in the frame (groups have no coordinate space). Frames, groups, sections, masks 95%/75% → 97%/80%, 2–4 h → 1–3 h; percentages unchanged at this rounding; full parity ~323–621 h → ~322–620 h |