From 924310c124a6662d8e54c8b6acd45e443dab6b76 Mon Sep 17 00:00:00 2001 From: Skyfuller1303 Date: Thu, 15 Oct 2026 23:14:59 +0200 Subject: [PATCH 1/4] M1: keep per-point stroke joins on .fig import Vector points carry their own stroke join in a .fig file. Import now keeps each point's join as the point's own override instead of dropping it for the layer's join. Joins equal to the layer join no longer force styled stroke runs, and an absent layer join counts as miter. Unresolved point styles, unknown joins, and joins that dashed or variable-width strokes cannot draw each produce a warning. --- crates/format/src/fig/map.rs | 28 ++++++++++++++-- crates/format/src/fig/tail_tests.rs | 52 ++++++++++++++++++++++++++--- crates/format/src/fig/tests.rs | 2 +- crates/pathops/src/lib.rs | 44 ++++++++++++++++++++++++ crates/pathops/src/points.rs | 11 ++++-- docs/file-format-parity.md | 2 +- 6 files changed, 128 insertions(+), 11 deletions(-) diff --git a/crates/format/src/fig/map.rs b/crates/format/src/fig/map.rs index 66a00129..914843d3 100644 --- a/crates/format/src/fig/map.rs +++ b/crates/format/src/fig/map.rs @@ -899,12 +899,20 @@ impl<'a> Mapper<'a> { 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")); + // An absent layer join is the source default. + let layer_join = v.str("strokeJoin").unwrap_or("MITER"); + let mut unsupported_join = false; + let mut unresolved_style = false; + let mut unknown_join = false; let vertices: Vec = net .vertices .iter() .map(|p| { let mut out = json!({"x": p.x * sx, "y": p.y * sy}); let st = style(p.style); + if p.style != 0 && st.is_none() { + unresolved_style = true; + } if let Some(r) = st.and_then(|s| num(s, "cornerRadius")).filter(|r| *r > 0.0) { out["radius"] = json!(r); } @@ -914,6 +922,15 @@ impl<'a> Mapper<'a> { Some("NONE") => out["mirror"] = json!("none"), _ => {} } + if let Some(raw) = st.and_then(|s| s.get("strokeJoin")) { + match raw.str() { + Some(join @ ("MITER" | "BEVEL" | "ROUND")) => { + out["join"] = json!(join.to_ascii_lowercase()); + unsupported_join |= join != layer_join; + } + _ => unknown_join = true, + } + } out }) .collect(); @@ -924,9 +941,14 @@ impl<'a> Mapper<'a> { 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"); + if unresolved_style { + self.warn("vector points with unresolved style IDs lost their point styles"); + } + if unknown_join { + self.warn("vector points with unknown stroke joins use the layer join"); + } + if unsupported_join && (o.contains_key("strokeDashes") || o.contains_key("strokeWidthProfile")) { + self.warn("point stroke joins were preserved; dashed or variable-width strokes render with the layer join"); } // 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()]; diff --git a/crates/format/src/fig/tail_tests.rs b/crates/format/src/fig/tail_tests.rs index fc0c5815..cd27300d 100644 --- a/crates/format/src/fig/tail_tests.rs +++ b/crates/format/src/fig/tail_tests.rs @@ -3,7 +3,7 @@ //! fixtures built in code with the parent module's helpers. use figcraft_doc::proto::Action; -use figcraft_doc::{HandleMirroring, StrokeCap, WindingRule}; +use figcraft_doc::{HandleMirroring, StrokeCap, StrokeJoin, WindingRule}; use super::super::network::{self, Network, Region, Segment, Vertex}; use super::*; @@ -19,7 +19,7 @@ fn vtx(style: u32, x: f64, y: f64) -> Vertex { /// A square with one curved side and a rounded, mirrored corner, filled even-odd. fn square_blob() -> Vec { 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)], + vertices: vec![vtx(0, 0.0, 0.0), vtx(1, 50.0, 0.0), vtx(2, 50.0, 50.0), vtx(3, 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)), @@ -39,6 +39,15 @@ fn arrow_blob() -> Vec { }) } +/// An open path with one missing style ID and one unsupported point join. +fn join_fallback_blob() -> Vec { + network::encode(&Network { + vertices: vec![vtx(4, 0.0, 0.0), vtx(5, 50.0, 0.0), vtx(0, 100.0, 0.0)], + segments: vec![seg(0, 1, (0.0, 0.0), (0.0, 0.0)), seg(1, 2, (0.0, 0.0), (0.0, 0.0))], + regions: vec![], + }) +} + fn vector_data(blob: u64, nx: f64, ny: f64, styles: Vec>) -> Kv<'static> { o(vec![("vectorNetworkBlob", u(blob)), ("normalizedSize", xy(nx, ny)), ("styleOverrideTable", arr(styles))]) } @@ -121,13 +130,24 @@ fn tail_message() -> Kv<'static> { ("transform", at(700.0, 0.0)), ("fillPaints", arr(vec![solid(1.0, 0.0, 0.0)])), ("fillGeometry", outline(0)), + ("strokeJoin", e("MITER")), + ("dashPattern", arr(vec![f(4.0), f(2.0)])), ( "vectorData", vector_data( 1, 100.0, 50.0, - vec![o(vec![("styleID", u(1)), ("cornerRadius", f(4.0)), ("handleMirroring", e("ANGLE_AND_LENGTH"))])], + vec![ + o(vec![ + ("styleID", u(1)), + ("cornerRadius", f(4.0)), + ("handleMirroring", e("ANGLE_AND_LENGTH")), + ("strokeJoin", e("BEVEL")), + ]), + o(vec![("styleID", u(2)), ("strokeJoin", e("MITER"))]), + o(vec![("styleID", u(3)), ("strokeJoin", e("ROUND"))]), + ], ), ), ], @@ -146,6 +166,19 @@ fn tail_message() -> Kv<'static> { ("vectorData", vector_data(2, 100.0, 0.0, vec![o(vec![("styleID", u(2)), ("strokeCap", e("ARROW_LINES"))])])), ], ), + node( + 42, + Some((1, "#")), + "VECTOR", + "Join Fallbacks", + vec![ + ("size", xy(100.0, 10.0)), + ("transform", at(0.0, 330.0)), + stroke(), + ("strokeJoin", e("MITER")), + ("vectorData", vector_data(4, 100.0, 10.0, vec![o(vec![("styleID", u(5)), ("strokeJoin", u(999))])])), + ], + ), node( 32, Some((1, "#")), @@ -223,7 +256,7 @@ fn tail_message() -> Kv<'static> { ]; let mut broken = square_blob(); broken.truncate(30); - let blobs = [triangle_blob(), square_blob(), arrow_blob(), broken]; + let blobs = [triangle_blob(), square_blob(), arrow_blob(), broken, join_fallback_blob()]; o(vec![("nodeChanges", arr(nodes)), ("blobs", arr(blobs.into_iter().map(|b| o(vec![("bytes", Kv::Bytes(b))])).collect()))]) } @@ -247,6 +280,11 @@ fn vectors_import_as_editable_networks() { 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.vertices.iter().map(|v| v.join).collect::>(), + [None, Some(StrokeJoin::Bevel), Some(StrokeJoin::Miter), Some(StrokeJoin::Round)] + ); + assert_eq!(named(&doc, "Wave").stroke.join, StrokeJoin::Miter); 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)); @@ -260,6 +298,12 @@ fn vectors_import_as_editable_networks() { // 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:?}"); + let fallback = network_of(&doc, "Join Fallbacks"); + assert!(fallback.vertices.iter().all(|v| v.join.is_none())); + assert_eq!(named(&doc, "Join Fallbacks").stroke.join, StrokeJoin::Miter); + assert!(warnings.iter().any(|w| w.contains("unresolved style IDs lost their point styles")), "{warnings:?}"); + assert!(warnings.iter().any(|w| w.contains("unknown stroke joins use the layer join")), "{warnings:?}"); + assert!(warnings.iter().any(|w| w.contains("dashed or variable-width strokes render with the layer join")), "{warnings:?}"); } #[test] diff --git a/crates/format/src/fig/tests.rs b/crates/format/src/fig/tests.rs index 19c4fc15..8d17cb4a 100644 --- a/crates/format/src/fig/tests.rs +++ b/crates/format/src/fig/tests.rs @@ -72,7 +72,7 @@ message Event InteractionType interactionType 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, VectorData vectorData, StrokeCap strokeCap, StrokeJoin strokeJoin, VectorMirror handleMirroring, ShapeWithTextType shapeWithTextType, ConnectorEndpoint connectorStart, ConnectorEndpoint connectorEnd, StrokeCap connectorStartCap, StrokeCap connectorEndCap, ConnectorLineStyle connectorLineStyle, ConstraintType horizontalConstraint, ConstraintType verticalConstraint, NodeChange[] derivedSymbolData, string key, string version +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, ConstraintType horizontalConstraint, ConstraintType verticalConstraint, NodeChange[] derivedSymbolData, string key, string version, float[] dashPattern message Blob byte[] bytes message Message NodeChange[] nodeChanges, Blob[] blobs "; diff --git a/crates/pathops/src/lib.rs b/crates/pathops/src/lib.rs index 553fce04..9d92892a 100644 --- a/crates/pathops/src/lib.rs +++ b/crates/pathops/src/lib.rs @@ -525,6 +525,50 @@ mod tests { assert!(arrowhead(&BezPath::new(), StrokeCap::LineArrow, 1.0, true).is_none()); } + #[test] + fn equal_point_joins_keep_the_layer_stroke_and_differing_ones_apply() { + let mut path = BezPath::new(); + path.move_to((0.0, 0.0)); + path.line_to((10.0, 10.0)); + path.line_to((20.0, 0.0)); + let net = VectorNetwork { + vertices: vec![ + figcraft_doc::Vertex { cap: Some(StrokeCap::Square), ..figcraft_doc::Vertex::new(0.0, 0.0) }, + figcraft_doc::Vertex { x: 10.0, y: 10.0, radius: 2.0, join: Some(StrokeJoin::Miter), ..figcraft_doc::Vertex::new(10.0, 10.0) }, + figcraft_doc::Vertex { cap: Some(StrokeCap::Square), ..figcraft_doc::Vertex::new(20.0, 0.0) }, + ], + segments: vec![figcraft_doc::Segment::line(0, 1), figcraft_doc::Segment::line(1, 2)], + ..Default::default() + }; + let props = StrokeProps { weight: 4.0, ..Default::default() }; + let inherited = stroke_outline_of(&path, &props, false, Some(&net)); + let mut without_join = net.clone(); + without_join.vertices[1].join = None; + let baseline = stroke_outline_of(&path, &props, false, Some(&without_join)); + assert_eq!(inherited, baseline); + + let different_layer = StrokeProps { join: StrokeJoin::Bevel, ..props }; + let explicit = stroke_outline_of(&path, &different_layer, false, Some(&net)); + let layer_only = stroke_outline_of(&path, &different_layer, false, Some(&without_join)); + assert_ne!(explicit, layer_only); + } + + #[test] + fn equal_joins_do_not_break_closed_stroke_runs() { + let path = sq(0.0, 0.0, 20.0); + let mut net = VectorNetwork::from_points(&[Point::new(0.0, 0.0), Point::new(20.0, 0.0), Point::new(20.0, 20.0), Point::new(0.0, 20.0)], true); + for vertex in &mut net.vertices { + vertex.join = Some(StrokeJoin::Round); + } + let props = StrokeProps { join: StrokeJoin::Round, weight: 4.0, ..Default::default() }; + let styled = stroke_outline_of(&path, &props, true, Some(&net)); + for vertex in &mut net.vertices { + vertex.join = None; + } + let inherited = stroke_outline_of(&path, &props, true, Some(&net)); + assert_eq!(styled, inherited); + } + #[test] fn dashes() { let mut line = BezPath::new(); diff --git a/crates/pathops/src/points.rs b/crates/pathops/src/points.rs index d432da05..9c161b7c 100644 --- a/crates/pathops/src/points.rs +++ b/crates/pathops/src/points.rs @@ -18,11 +18,18 @@ pub(crate) struct Styled { impl Styled { /// `None` when no point has a style of its own, or the stroke is dashed, variable or a brush. /// shortcut: dashed, variable-width and brush strokes keep the layer's caps and joins at every point; stroke them run by run too if asked for. + /// Joins equal to the layer do not require styled runs; stored overrides remain unchanged. pub(crate) fn new(net: &VectorNetwork, props: &StrokeProps) -> Option { - if !net.has_point_styles() || !props.dashes.is_empty() || props.profile().is_some() || props.brush.is_some() { + let styled = net.vertices.iter().any(|v| v.cap.is_some() || v.join.is_some_and(|join| join != props.join)); + if !styled || !props.dashes.is_empty() || props.profile().is_some() || props.brush.is_some() { return None; } - let net = net.rounded().unwrap_or_else(|| net.clone()); + let mut net = net.rounded().unwrap_or_else(|| net.clone()); + for vertex in &mut net.vertices { + if vertex.join == Some(props.join) { + vertex.join = None; + } + } let runs = net.stroke_runs(); let mut degrees = vec![0usize; net.vertices.len()]; for s in &net.segments { diff --git a/docs/file-format-parity.md b/docs/file-format-parity.md index d8e51711..dcd75691 100644 --- a/docs/file-format-parity.md +++ b/docs/file-format-parity.md @@ -17,7 +17,7 @@ and prototype interactions are still unchecked there. | 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) | **experimental** ([below](#export-to-fig-experimental)) | `.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, ellipse arcs, boolean operations, components, sets (with their descriptions), 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) and by reading back what the export writes; no real-world files yet | read tail 5–10, write: checks against real files 5–15 | +| `.fig` (Figma Design, `com.figma.document`, `application/x-figma`) | native (cloud; "Save local copy") | **yes, best effort** (`file.open`, `file.import`, drop, CLI, MCP) | **experimental** ([below](#export-to-fig-experimental)) | `.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, per-point stroke joins (strokes that are dashed or variable-width still draw with the layer's join, with a warning), end caps; outlines when the network is missing or damaged), auto layout (grid included), constraints, ellipse arcs, boolean operations, components, sets (with their descriptions), 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, 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) and by reading back what the export writes; no real-world files yet | read tail 5–10, write: checks against real files 5–15 | | `.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`) | — | From 771f7e869f669f478e0b9277a2123307269272fc Mon Sep 17 00:00:00 2001 From: Skyfuller1303 Date: Fri, 16 Oct 2026 09:15:25 +0200 Subject: [PATCH 2/4] M1: reword the unresolved point style import note --- crates/format/src/fig/map.rs | 2 +- crates/format/src/fig/tail_tests.rs | 7 ++++++- 2 files changed, 7 insertions(+), 2 deletions(-) diff --git a/crates/format/src/fig/map.rs b/crates/format/src/fig/map.rs index 914843d3..0760e889 100644 --- a/crates/format/src/fig/map.rs +++ b/crates/format/src/fig/map.rs @@ -942,7 +942,7 @@ impl<'a> Mapper<'a> { self.warn("filled areas of a vector with their own fill use the layer's fill"); } if unresolved_style { - self.warn("vector points with unresolved style IDs lost their point styles"); + self.warn("some vector points refer to point settings missing from the file; they use the layer's joins and plain corners"); } if unknown_join { self.warn("vector points with unknown stroke joins use the layer join"); diff --git a/crates/format/src/fig/tail_tests.rs b/crates/format/src/fig/tail_tests.rs index cd27300d..51871ccd 100644 --- a/crates/format/src/fig/tail_tests.rs +++ b/crates/format/src/fig/tail_tests.rs @@ -301,7 +301,12 @@ fn vectors_import_as_editable_networks() { let fallback = network_of(&doc, "Join Fallbacks"); assert!(fallback.vertices.iter().all(|v| v.join.is_none())); assert_eq!(named(&doc, "Join Fallbacks").stroke.join, StrokeJoin::Miter); - assert!(warnings.iter().any(|w| w.contains("unresolved style IDs lost their point styles")), "{warnings:?}"); + assert!( + warnings + .iter() + .any(|w| w.contains("vector points refer to point settings missing from the file; they use the layer's joins and plain corners")), + "{warnings:?}" + ); assert!(warnings.iter().any(|w| w.contains("unknown stroke joins use the layer join")), "{warnings:?}"); assert!(warnings.iter().any(|w| w.contains("dashed or variable-width strokes render with the layer join")), "{warnings:?}"); } From 6023d3547a2a8b056e2f3c0c68cefc45607cbdc9 Mon Sep 17 00:00:00 2001 From: bflatastic Date: Sat, 17 Oct 2026 13:45:15 +0700 Subject: [PATCH 3/4] #617: leave progress docs to the maintainer's docs PR --- docs/file-format-parity.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/file-format-parity.md b/docs/file-format-parity.md index dcd75691..d8e51711 100644 --- a/docs/file-format-parity.md +++ b/docs/file-format-parity.md @@ -17,7 +17,7 @@ and prototype interactions are still unchecked there. | 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) | **experimental** ([below](#export-to-fig-experimental)) | `.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, per-point stroke joins (strokes that are dashed or variable-width still draw with the layer's join, with a warning), end caps; outlines when the network is missing or damaged), auto layout (grid included), constraints, ellipse arcs, boolean operations, components, sets (with their descriptions), 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, 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) and by reading back what the export writes; no real-world files yet | read tail 5–10, write: checks against real files 5–15 | +| `.fig` (Figma Design, `com.figma.document`, `application/x-figma`) | native (cloud; "Save local copy") | **yes, best effort** (`file.open`, `file.import`, drop, CLI, MCP) | **experimental** ([below](#export-to-fig-experimental)) | `.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, ellipse arcs, boolean operations, components, sets (with their descriptions), 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) and by reading back what the export writes; no real-world files yet | read tail 5–10, write: checks against real files 5–15 | | `.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`) | — | From 7fe0770b9d74a072bdccae449edc347819617231 Mon Sep 17 00:00:00 2001 From: bflatastic Date: Sat, 17 Oct 2026 13:45:16 +0700 Subject: [PATCH 4/4] #617: point joins equal to the layer's no longer count as point styles (SVG keeps a plain stroke) --- crates/doc/src/vector.rs | 6 +++--- crates/pathops/src/points.rs | 3 +-- crates/render/src/draw.rs | 2 +- crates/svg/src/lib.rs | 2 +- crates/svg/src/tests.rs | 25 +++++++++++++++++++++++++ 5 files changed, 31 insertions(+), 7 deletions(-) diff --git a/crates/doc/src/vector.rs b/crates/doc/src/vector.rs index fd0a0f7e..1165bd60 100644 --- a/crates/doc/src/vector.rs +++ b/crates/doc/src/vector.rs @@ -468,9 +468,9 @@ impl VectorNetwork { start_dir(&self.outward(seg, at_a)?) } - /// Whether any point has a stroke cap or join of its own. - pub fn has_point_styles(&self) -> bool { - self.vertices.iter().any(|v| v.cap.is_some() || v.join.is_some()) + /// Whether any point has a stroke cap of its own or a join other than the layer's `layer_join`. + pub fn has_point_styles(&self, layer_join: StrokeJoin) -> bool { + self.vertices.iter().any(|v| v.cap.is_some() || v.join.is_some_and(|j| j != layer_join)) } /// The fill path: every region's loops. diff --git a/crates/pathops/src/points.rs b/crates/pathops/src/points.rs index 9c161b7c..05f58216 100644 --- a/crates/pathops/src/points.rs +++ b/crates/pathops/src/points.rs @@ -20,8 +20,7 @@ impl Styled { /// shortcut: dashed, variable-width and brush strokes keep the layer's caps and joins at every point; stroke them run by run too if asked for. /// Joins equal to the layer do not require styled runs; stored overrides remain unchanged. pub(crate) fn new(net: &VectorNetwork, props: &StrokeProps) -> Option { - let styled = net.vertices.iter().any(|v| v.cap.is_some() || v.join.is_some_and(|join| join != props.join)); - if !styled || !props.dashes.is_empty() || props.profile().is_some() || props.brush.is_some() { + if !net.has_point_styles(props.join) || !props.dashes.is_empty() || props.profile().is_some() || props.brush.is_some() { return None; } let mut net = net.rounded().unwrap_or_else(|| net.clone()); diff --git a/crates/render/src/draw.rs b/crates/render/src/draw.rs index 7bfafd33..021af92d 100644 --- a/crates/render/src/draw.rs +++ b/crates/render/src/draw.rs @@ -155,7 +155,7 @@ pub fn stroke_area(n: &Node, path: &BezPath, closed: bool) -> Arc { props.dashes.len().hash(&mut h); // Points' own caps and joins (vector networks). let net = match &n.kind { - NodeKind::Vector { network } if network.has_point_styles() => Some(network), + NodeKind::Vector { network } if network.has_point_styles(props.join) => Some(network), _ => None, }; for v in net.map(|n| n.vertices.as_slice()).unwrap_or_default() { diff --git a/crates/svg/src/lib.rs b/crates/svg/src/lib.rs index a6813017..1433b942 100644 --- a/crates/svg/src/lib.rs +++ b/crates/svg/src/lib.rs @@ -638,7 +638,7 @@ impl W<'_> { || k.brush.is_some() || k.cap_start.is_arrow() || k.cap_end.is_arrow() - || matches!(&n.kind, NodeKind::Vector { network } if network.has_point_styles()) + || matches!(&n.kind, NodeKind::Vector { network } if network.has_point_styles(k.join)) || !(w.is_finite() && w > 0.0) { return None; diff --git a/crates/svg/src/tests.rs b/crates/svg/src/tests.rs index b15cd999..5a333d07 100644 --- a/crates/svg/src/tests.rs +++ b/crates/svg/src/tests.rs @@ -115,3 +115,28 @@ fn text_run_fill_survives_live_text_export() { assert!(svg.contains("#000000"), "{svg}"); assert!(svg.contains("fill=\"#00FF00\"") && svg.contains(">b") && svg.contains(">a"), "{svg}"); } + +#[test] +fn point_joins_equal_to_the_layer_join_keep_a_plain_stroke() { + use figcraft_doc::{StrokeJoin, VectorNetwork}; + use figcraft_geom::kurbo::Point; + let svg_with = |join: StrokeJoin| { + let mut network = VectorNetwork::from_points(&[Point::new(0.0, 0.0), Point::new(50.0, 50.0), Point::new(100.0, 0.0)], false); + for v in &mut network.vertices { + v.join = Some(StrokeJoin::Miter); + } + if let Some(v) = network.vertices.get_mut(1) { + v.join = Some(join); + } + let mut d = Document::new("t"); + let p = d.first_page().unwrap(); + let v = d.create(p, NodeKind::Vector { network }, "V", 0.0, 0.0, 100.0, 50.0).unwrap(); + let n = d.get_mut(v).unwrap(); + n.strokes = vec![Paint::solid(Color::from_hex("000000").unwrap())]; + n.stroke.weight = 2.0; + n.stroke.join = StrokeJoin::Miter; + export_svg(&d, &[v], &SvgOptions::default()).unwrap() + }; + assert!(svg_with(StrokeJoin::Miter).contains("stroke-width=\"2\""), "equal joins stay a stroke"); + assert!(!svg_with(StrokeJoin::Round).contains("stroke-width="), "a differing join is drawn as an outline"); +}