diff --git a/README.md b/README.md
index e17cd74c1..75328de15 100644
--- a/README.md
+++ b/README.md
@@ -208,7 +208,7 @@ exposes to agents, and exported at 2x by FigCraft's own renderer. The source fil
Prototype and hand off
- Connections, triggers, actions and Smart Animate-style transitions, variables with conditionals (if / else if / else built in the interaction editor, expressions like count + 1), flows, a presentation view with device frames, hotspot hints and an Inspect panel, and a live preview pane. Alt to measure distances and padding on the canvas, Inspect mode with measurements, annotations in colour-coded categories, a ready-for-dev list with a focus view and Compare changes (before / after with property-level diffs since a frame was marked ready), and code (CSS, HTML, React, Vue, Leptos, SwiftUI, Compose) where components take their properties as parameters, comment threads pinned to frames (replies, @mentions, reactions, read state) and named version history. Export any layer at any scale to PNG, JPG, WebP, SVG or PDF.
+ Connections, triggers, actions and Smart Animate-style transitions, variables with conditionals (if / else if / else built in the interaction editor, expressions like count + 1), flows, a presentation view with device frames, hotspot hints and an Inspect panel, and a live preview pane. Alt to measure distances and padding on the canvas, Inspect mode with measurements, annotations in colour-coded categories, a ready-for-dev list with a focus view and Compare changes (before / after with property-level diffs since a frame was marked ready), and code (CSS, HTML, React, Vue, Leptos, SwiftUI, Compose, ArkUI, Flutter) where components take their properties as parameters, comment threads pinned to frames (replies, @mentions, reactions, read state) and named version history. Export any layer at any scale to PNG, JPG, WebP, SVG or PDF.
diff --git a/ROADMAP.md b/ROADMAP.md
index bf7ad08c0..5677e22f4 100644
--- a/ROADMAP.md
+++ b/ROADMAP.md
@@ -2,7 +2,7 @@
**Stage: alpha** · next: beta, ~26 points and ~162–292 h away (one Opus 5.5 agent; ~33–67 h wall-clock with 5 agents)
-> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-28 · **Change:** minor (FigJam outer shape outlines and sticky body fallback beside author text (#1341, #1397); M4: zero-length round-capped dashes draw dots; dash expansion uses the existing safety budget; extracted from #1143; Arabic draft catalog; scaled instances keep their scale when their main is rebuilt (#922); .fig embedded images are copied once per blob, not once per hash (#1059); page ruler guides survive save and reopen (#1476); colour picker LCH and OKLCH models; non-ASCII layer names transliterate in code export (#1235); branches in live sessions and branch history entries, gap 17; plugin widgets take text input and a property menu (plugin API 4); boards: templates and tables; boards: a shared timer and voting sessions; boards: the dot grid, connector ends dragged to re-attach, curved connectors; Installed weights of an embedded font family (#1246); `.sketch` import, best effort (Sketch 48 and newer); CJK line breaking: JIS X 4051's line-start and line-end sets completed with the halfwidth small kana, the halfwidth and spacing (semi-)voiced sound marks, the iteration marks 〱–〵 and the halfwidth parentheses, with behaviour cases for each; Adobe Fonts found on macOS (#298); egui 0.37 hidden-capture deadline contract; Dev Mode measurements kept on the canvas and a read-only layers panel; boards: a board area row and progress entry; community fixes in #507, #510, #529 and #530: auto-save, a text box by dragging, batch step references, removing someone from a live session, Russian and Turkish catalogs reviewed, Czech and Indonesian drafts, the Japanese draft completed, zoom previews, the web clipboard, Alt-drag snapping and Cmd+D repeat, recovery copies, crash and rollback fixes, colour emoji, `.fig` import fixes; earlier changes are in the revision history below) · **Target:** Figma Design (desktop app 126.10.7)
+> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-29 · **Change:** minor (the toolbar on one 32 pt grid, without its Resources button (#1210); M4: zero-length round-capped dashes draw dots; dash expansion uses the existing safety budget; extracted from #1143; Arabic draft catalog; scaled instances keep their scale when their main is rebuilt (#922); .fig embedded images are copied once per blob, not once per hash (#1059); page ruler guides survive save and reopen (#1476); colour picker LCH and OKLCH models; non-ASCII layer names transliterate in code export (#1235); branches in live sessions and branch history entries, gap 17; plugin widgets take text input and a property menu (plugin API 4); boards: templates and tables; boards: a shared timer and voting sessions; boards: the dot grid, connector ends dragged to re-attach, curved connectors; Installed weights of an embedded font family (#1246); `.sketch` import, best effort (Sketch 48 and newer); CJK line breaking: JIS X 4051's line-start and line-end sets completed with the halfwidth small kana, the halfwidth and spacing (semi-)voiced sound marks, the iteration marks 〱–〵 and the halfwidth parentheses, with behaviour cases for each; Adobe Fonts found on macOS (#298); egui 0.37 hidden-capture deadline contract; Dev Mode measurements kept on the canvas and a read-only layers panel; boards: a board area row and progress entry; community fixes in #507, #510, #529 and #530: auto-save, a text box by dragging, batch step references, removing someone from a live session, Russian and Turkish catalogs reviewed, Czech and Indonesian drafts, the Japanese draft completed, zoom previews, the web clipboard, Alt-drag snapping and Cmd+D repeat, recovery copies, crash and rollback fixes, colour emoji, `.fig` import fixes; earlier changes are in the revision history below) · **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
@@ -136,7 +136,6 @@ Ranked; detail and milestones in [`docs/roadmap.md`](docs/roadmap.md).
- **2026-10-28** · M6, #1059: `.fig` import copies each image carried inside the message once per blob, shared by every hash that names it, instead of once per hash: a file pointing many hashes at one large blob no longer multiplies it in memory (100 hashes on a 250 MB blob were 25 GB). Regression test. Numbers unchanged.
- **2026-10-28** · M1, #1476: page ruler guides survive save and reopen. A page kept its guides in the page kind while the layer's own `guides` field (frame guides, #70) took that key when a file was read, so a reopened page's guides vanished, and saving it wrote `guides` twice, which then refused to open. Every layer now keeps its guides in the one field, and a page with two `guides` lists opens with both. Regressions in the doc and format crates. Numbers unchanged.
- **2026-10-27** · M4: zero-length round-capped dashes draw dots; dash expansion uses the existing safety budget; extracted from #1143. Numbers unchanged.
-- **2026-10-27** · M6, #1341: FigJam sticky notes and shapes with text import their generated layers (#1397): the background (its saved vector network, or the outer fill or stroke outline when the generated geometry is damaged, before a rectangle), text with its typography and paints, saved bounds and sticky author lines, as editable frame children; missing or invalid generated text falls back to the outer text within the layer budget. Synthetic regressions and a local check of the report sample; native board metadata and direct `.jam` opening remain outside this fix. Numbers unchanged.
- **2026-10-26** · Non-ASCII layer names transliterate in code export (M6, #1235): `class_name` runs the name through `deunicode` before its ASCII filter, so 로그인 화면 → `rogeuin-hwamyeon`, кнопка → `knopka`, über → `uber` in the HTML/CSS/React/Leptos class names, component roots and `data-*` variant attributes (SwiftUI/Compose type names unchanged); a name left with nothing (symbols only) takes the node's kind (`.text`, `.frame`) instead of `layer-`, and the 40-character cut lands on a word boundary. New dependency: deunicode (pure Rust tables, zero deps, BSD-3-Clause, the `slug` crate's base). `html_codegen_tests`. Numbers unchanged.
- **2026-10-25** · egui 0.37 capture lifecycle: screenshots with hidden or unknown visibility queue, request a hidden paint, and expire after 60 seconds if no capture arrives (headless-tested contract). Logic-only edits, saves and inspection continue while hidden. Native visible GUI and forced hidden-window paint, browser and Wayland behavior remain unqualified; parity percentages are unchanged.
- **2026-10-24** · Open, Save and the folder dialogs start in the folder of the last file or folder picked in the run (#1290): the Linux portal does not remember it between dialogs as the macOS and Windows panels do. A folder deleted since falls back to the system's choice. No numbers change.
@@ -353,12 +352,12 @@ Ranked; detail and milestones in [`docs/roadmap.md`](docs/roadmap.md).
| Date | Change | Summary |
|---|---|---|
+| 2026-10-29 | minor | The toolbar on one 32 pt grid (M4, #1210): the pill's cells, padding, gaps and divider share one grid in every state, and the Resources button is gone, as in UI3; the Assets panel opens from the left rail, Shift+I, Alt+2 and View ▸ Panels ▸ Assets. Numbers unchanged |
| 2026-10-28 | minor | Arabic draft: 1,857 UI strings, 620 command labels, 31 menu wordings; count-neutral plurals and web font gate. Native review and panel mirroring remain open; grade and estimates unchanged. Evidence: `crates/ui-egui/locales/ar.toml`, i18n catalog tests and [translation notes](docs/translating.md#arabic-draft) |
| 2026-10-28 | minor | M3, #922: instances scaled with the Scale tool keep their layers' scaled size and points when their main is rebuilt (`size`/`position` overrides recorded on scale). Numbers unchanged. |
| 2026-10-28 | minor | M6, #1059: `.fig` import copies embedded image blobs once per blob, not once per hash naming them. Numbers unchanged. |
| 2026-10-28 | minor | M1, #1476: page ruler guides survive save and reopen; files written with a page's `guides` key twice open again with both lists. Numbers unchanged. |
| 2026-10-27 | minor | M4: zero-length round-capped dashes draw dots; dash expansion uses the existing safety budget; extracted from #1143. Numbers unchanged. |
-| 2026-10-27 | minor | M6, #1341: FigJam sticky notes and shapes with text import their generated layers (#1397): the background (its saved vector network, or the outer fill or stroke outline when the generated geometry is damaged, before a rectangle), text with its typography and paints, saved bounds and sticky author lines, as editable frame children; missing or invalid generated text falls back to the outer text within the layer budget. Synthetic regressions and a local check of the report sample; native board metadata and direct `.jam` opening remain outside this fix. Numbers unchanged. |
| 2026-10-26 | minor | Non-ASCII layer names transliterate in code export (M6, #1235): `class_name` transliterates through the new `deunicode` dependency (pure Rust tables, zero deps, BSD-3-Clause), so Korean, Cyrillic, Latin-diacritic and CJK names become readable ASCII classes (`rogeuin-hwamyeon`, `knopka`, `uber`, `an-niu`) in HTML/CSS/React/Leptos output, component roots and `data-*` variant attributes; symbols-only names take the node's kind (`.text`) instead of `layer-`, and the 40-character cut lands on a word boundary. `html_codegen_tests` updated and extended. Numbers unchanged |
| 2026-10-25 | minor | egui 0.37 capture lifecycle: screenshots with hidden or unknown visibility queue, request a hidden paint, and expire after 60 seconds if no capture arrives (headless-tested contract). Logic-only edits, saves and inspection continue while hidden. Native visible GUI and forced hidden-window paint, browser and Wayland behavior remain unqualified; parity percentages are unchanged. |
| 2026-10-24 | minor | Server memory (#1134): when the last person leaves a file nobody edited, figcraft-server keeps the client ids it issued in the log instead of rewriting the snapshot (compaction still follows `compact_after`/`compact_mb` and edits), and a snapshot is encoded straight into its file buffer, without a second copy of the body; the file format is unchanged. Numbers unchanged |
@@ -435,6 +434,7 @@ Ranked; detail and milestones in [`docs/roadmap.md`](docs/roadmap.md).
| 2026-10-17 | minor | Community fixes from #524 up (batches #727, #728, #733, #735, #736 and #729; 55 PRs) and a crash guard. `.fig` import: explicit instance identity, overrides by key, text style precedence, grid track sizes, slot content, connector labels, skip ink, paragraph options and hanging lists, list start numbers, vertical trim, per-point stroke joins, text on a path, image adjustments and embedded images; large files open within budgets from the file's own size; an import report after opening; imported bytes stay out of the command journal. Files and paste: save a copy as `.fig`; Cmd/Ctrl+V pastes images copied in other apps and layers copied from another design app (`edit.pasteDesignLayers`); paste over and paste properties; clipboard reads on Linux and the BSDs time out after 2 s. Editor: live font preview on hover; Shift+I and Resources open Assets; Shift-drag moves the selection; thin strokes take a nearby double-click; dropped points merge; Flatten keeps open paths; the Actions palette gets tabs, ranked search, a keyboard highlight and recents; the colour picker opens on a new fill or stroke; text strokes follow their alignment; selection highlights after line breaks; a draggable pages/layers divider; variant properties reorder by drag; submenus keep their hover with a safe triangle; menu actions that need a value ask for it; File ▸ Rename edits the name in place. Engine: bad paint indexes, non-finite geometry and pages in copy/cut are refused; a param naming a layer says so when it isn't one; a CLI script is one undo step and resolves `$step`. Server and collaboration: message limits follow `max_file_mb` (client capped at 1 GiB); a damaged generation field no longer drops flushed edits; the web build joins a session from a `#join=` link. Also Swedish command labels, Nix CI through the shared workflow, paint bounds once per render, and a guard that turns a panic inside the boolean-op library into bad input (the stroke draws unclipped). Numbers unchanged. |
| 2026-10-17 | minor | Number variables on text content (M3, #629): the Typography section's variable picker also lists number variables for a text layer's content, as the engine already accepted (the text shows the number, e.g. 42.5, and follows the variable). Other text fields keep their own types. Numbers unchanged. |
| 2026-10-17 | minor | Frames and sections convert into each other (M1, #719): `object.convertToSection` turns a frame on the page or in a section into a section in place (same box, name, fill and layers, which stay where they are; what only a frame has goes: auto layout, clipping, layout grids, guides, scrolling), and `object.convertToFrame` now also turns a section into a frame that keeps its fill and doesn't clip, so the look stays. Nested frames, instances, main components and sections holding sections are refused with the reason; one undo step each. Both are in the canvas context menu (Convert to section / Convert to frame) and in Spanish. Numbers unchanged. |
+| 2026-10-16 | minor | Flutter code (ours; the incumbent generates CSS, iOS and Android only): `inspect.code`, `inspect.componentCode` and `codeConnect.set` take `language: flutter` (`dart` is an alias): a `StatelessWidget` for a component (properties as constructor parameters with their defaults, instances as calls, variants as a Dart enum, swaps a `Widget`, slots a `List?`) and a Dart widget tree for a layer (`Container` with a `BoxDecoration`, `Row`/`Column` with `spacing`, `Expanded`, `Stack` + `Positioned`, `Text` with a `TextStyle`, `Image.asset`, `Opacity`), plus Dart `import '…';` lines in Code Connect. The Dev Mode language list includes it. Numbers unchanged |
| 2026-10-16 | minor | Adobe Fonts found on macOS (M7, #298): fonts activated through Adobe Fonts rendered in the fallback font without a warning, because Creative Cloud keeps them in its own folder as hidden files named by number, which the font scan never read. The macOS scan now includes `~/Library/Application Support/Adobe/CoreSync/plugins/livetype`, and files without an extension are indexed when they start with a font signature. Fonts are read in place. Regression test in the font database tests; docs/cli.md lists the folder. Numbers unchanged |
| 2026-10-16 | minor | Community fixes from #478 up (batches #591, #598, #602; 69 PRs) and a control-channel fix: a line that isn't JSON now ends a `--control` connection, so a web page can't drive the app through a browser request (#589). Highlights: page copies keep navigation, flows, prototype settings and guides; Find and Replace keeps line endings and case-insensitive matches; text hyperlinks survive import and export; recent colours record only what was picked; MCP screenshots respect `maxSize`; Swedish catalog (UI strings), Czech plurals; bounded canvas overscan while panning; Korean fallback at the run's weight; many vector-edit, guide, page and .fig round-trip fixes. Numbers unchanged |
| 2026-10-15 | minor | Drop files anywhere to open them (M7): dragging files over the window shows a "Drop to open" (home) or "Drop to import" (canvas) overlay. On the home screen each supported file opens as a document of its own and other files are turned away with a toast; on the canvas the files import at the drop point. Both wait behind a small card that is on screen before a large file blocks the window. A canvas import stays with the document and page that received the drop even if the tab or page changes meanwhile, and is cancelled with a message if that document or page is gone. Numbers unchanged. |
diff --git a/crates/doc/src/boolean_hit_tests.rs b/crates/doc/src/boolean_hit_tests.rs
index b826f2020..5a8a01128 100644
--- a/crates/doc/src/boolean_hit_tests.rs
+++ b/crates/doc/src/boolean_hit_tests.rs
@@ -2,7 +2,7 @@
//! picks the group, and a double click goes one level deeper than the selection.
use crate::geometry::{enter_target, select_target};
-use crate::{BoolOp, Document, NodeId, NodeKind};
+use crate::{BoolOp, Document, NodeId, NodeKind, Region, Segment, VectorNetwork, Vertex, WindingRule};
use figcraft_geom::Point;
fn page(d: &Document) -> NodeId {
@@ -40,6 +40,61 @@ fn hits_reach_operands_but_clicks_pick_the_group() {
assert_eq!(listed, vec![f, g, b]);
}
+#[test]
+fn ordinary_hit_uses_region_paints_without_layer_fill() {
+ let mut d = Document::new("region hit");
+ let p = page(&d);
+ let network = VectorNetwork {
+ vertices: vec![Vertex::new(0.0, 0.0), Vertex::new(10.0, 0.0), Vertex::new(10.0, 10.0), Vertex::new(0.0, 10.0)],
+ segments: (0..4).map(|i| Segment::line(i, (i + 1) % 4)).collect(),
+ regions: vec![
+ Region {
+ loops: vec![vec![0, 1, 2, 3]],
+ winding: WindingRule::NonZero,
+ fills: Some(vec![figcraft_color::Paint::solid(figcraft_color::Color::BLACK)]),
+ ..Default::default()
+ },
+ Region { loops: vec![vec![0, 1, 2, 3]], winding: WindingRule::NonZero, fills: Some(Vec::new()), ..Default::default() },
+ ],
+ };
+ let id = d.create(p, NodeKind::Vector { network }, "V", 0.0, 0.0, 10.0, 10.0).unwrap();
+ d.get_mut(id).unwrap().fills.clear();
+ assert!(d.node(id).unwrap().fills.is_empty());
+ assert_eq!(d.hit_test(p, Point::new(5.0, 5.0), 0.0, None), Some(id));
+ assert_eq!(d.hit_test(p, Point::new(15.0, 5.0), 0.0, None), None);
+}
+
+#[test]
+fn inherited_paint_frames_use_per_region_hit_testing() {
+ let mut d = Document::new("inherited region hit");
+ let p = page(&d);
+ let mut network = VectorNetwork::from_points(&[Point::new(0.0, 0.0), Point::new(10.0, 0.0), Point::new(10.0, 10.0), Point::new(0.0, 10.0)], true);
+ network.regions[0].winding = WindingRule::EvenOdd;
+ network.regions.push(network.regions[0].clone());
+ let id = d.create(p, NodeKind::Vector { network }, "V", 0.0, 0.0, 10.0, 10.0).unwrap();
+ assert_eq!(d.hit_test(p, Point::new(5.0, 5.0), 0.0, None), None, "legacy even-odd loops cancel");
+ let NodeKind::Vector { network } = &mut d.get_mut(id).unwrap().kind else { panic!("expected vector") };
+ network.regions[0].paint_space = Some(crate::RegionPaintSpace { width: 10.0, height: 10.0, transform: figcraft_geom::Affine::IDENTITY });
+ assert!(network.regions.iter().all(|region| region.fills.is_none()));
+ assert_eq!(d.hit_test(p, Point::new(5.0, 5.0), 0.0, None), Some(id), "paint-framed regions inherit the node fill independently");
+ assert_eq!(d.hit_test(p, Point::new(15.0, 5.0), 0.0, None), None);
+}
+
+#[test]
+fn explicitly_unfilled_regions_keep_the_stroke_only_selection_band() {
+ let mut d = Document::new("stroke selection");
+ let p = page(&d);
+ let mut network =
+ VectorNetwork::from_points(&[Point::new(0.0, 0.0), Point::new(100.0, 0.0), Point::new(100.0, 100.0), Point::new(0.0, 100.0)], true);
+ network.regions[0].fills = Some(Vec::new());
+ let id = d.create(p, NodeKind::Vector { network }, "V", 0.0, 0.0, 100.0, 100.0).unwrap();
+ let n = d.get_mut(id).unwrap();
+ n.fills.clear();
+ n.strokes = vec![figcraft_color::Paint::solid(figcraft_color::Color::BLACK)];
+ n.stroke.weight = 1.0;
+ assert_eq!(d.hit_test(p, Point::new(50.0, -4.0), 2.0, None), Some(id));
+}
+
#[test]
fn double_clicks_go_one_level_deeper() {
let (d, f, g, b, r1, _) = setup();
diff --git a/crates/doc/src/colors.rs b/crates/doc/src/colors.rs
index 93d32f0bd..a3168f8da 100644
--- a/crates/doc/src/colors.rs
+++ b/crates/doc/src/colors.rs
@@ -60,6 +60,9 @@ impl Node {
f(&mut prototype.background);
}
NodeKind::Text(t) => t.for_each_color_mut(f),
+ NodeKind::Vector { network } => {
+ network.regions.iter_mut().filter_map(|r| r.fills.as_mut()).flatten().for_each(|p| p.for_each_color_mut(f))
+ }
_ => {}
}
for it in &mut self.interactions {
diff --git a/crates/doc/src/geometry.rs b/crates/doc/src/geometry.rs
index 1ee885f56..0e06d2941 100644
--- a/crates/doc/src/geometry.rs
+++ b/crates/doc/src/geometry.rs
@@ -74,6 +74,23 @@ fn hits_node_itself(doc: &Document, id: NodeId, n: &Node, pt: Point, tol: f64, o
NodeKind::Frame | NodeKind::Component { .. } | NodeKind::ComponentSet { .. } | NodeKind::Instance { .. } | NodeKind::Section { .. } => {
n.local_rect().inflate(tol_l, tol_l).contains(lp)
}
+ NodeKind::Vector { network } if network.regions.iter().any(|r| r.fills.is_some() || r.paint_space.is_some()) => {
+ let rounded = network.rounded();
+ let net = rounded.as_ref().unwrap_or(network);
+ if !outline {
+ for (i, region) in net.regions.iter().enumerate().rev() {
+ let paints = region.fills.as_deref().unwrap_or(&n.fills);
+ if paints.iter().any(|p| p.visible) && net.region_contains(i, lp) {
+ return true;
+ }
+ }
+ }
+ let path = net.stroke_path();
+ let has_fill = net.regions.iter().any(|region| region.fills.as_deref().unwrap_or(&n.fills).iter().any(|paint| paint.visible));
+ let stroke_only = n.has_visible_stroke() && !has_fill;
+ let reach = (n.stroke.max_weight() / 2.0).max(if stroke_only { tol_l * STROKE_REACH } else { tol_l });
+ path.bounding_box().inflate(reach, reach).contains(lp) && near_path(&path, lp, reach)
+ }
_ => shape_path(n).is_some_and(|path| hits_path(n, &path, lp, tol_l, !outline && is_closed(n) && fills_point(n, &path, lp))),
}
}
diff --git a/crates/doc/src/lib.rs b/crates/doc/src/lib.rs
index d19ef0c71..88851ff54 100644
--- a/crates/doc/src/lib.rs
+++ b/crates/doc/src/lib.rs
@@ -915,6 +915,15 @@ impl Document {
pt.source = m.0;
}
}
+ if let NodeKind::Vector { network } = &mut c.kind {
+ for p in network.regions.iter_mut().filter_map(|r| r.fills.as_mut()).flatten() {
+ if let figcraft_color::PaintKind::Pattern(pt) = &mut p.kind
+ && let Some(m) = map.get(&NodeId(pt.source))
+ {
+ pt.source = m.0;
+ }
+ }
+ }
self.reserve_ids(*new_id);
self.nodes.insert(*new_id, Arc::new(c));
self.dirty.touch(*new_id);
diff --git a/crates/doc/src/library.rs b/crates/doc/src/library.rs
index fe53ec29f..348c4544f 100644
--- a/crates/doc/src/library.rs
+++ b/crates/doc/src/library.rs
@@ -300,6 +300,11 @@ pub fn paint_images<'a>(paints: impl IntoIterator, out: &mut B
/// Image hashes a node paints (fills, strokes, text and text run fills).
pub fn node_images(n: &Node, out: &mut BTreeSet) {
paint_images(n.fills.iter().chain(&n.strokes), out);
+ if let NodeKind::Vector { network } = &n.kind {
+ for r in &network.regions {
+ paint_images(r.fills.iter().flatten(), out);
+ }
+ }
if let NodeKind::Text(t) = &n.kind {
paint_images(t.style.fills.iter().flatten(), out);
for r in &t.runs {
diff --git a/crates/doc/src/node.rs b/crates/doc/src/node.rs
index bcf8f09d4..0942e3f87 100644
--- a/crates/doc/src/node.rs
+++ b/crates/doc/src/node.rs
@@ -222,7 +222,7 @@ pub struct Annotation {
/// (`figcraft_engine::code_connect`).
#[derive(Clone, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
pub struct CodeLink {
- /// `react`, `swiftui` or `compose`.
+ /// `react`, `leptos`, `vue`, `swiftui`, `compose`, `arkui` or `flutter`.
pub language: String,
/// What the snippet imports from (`@acme/ui`, a Swift module, a Kotlin name).
#[serde(default, skip_serializing_if = "String::is_empty")]
diff --git a/crates/doc/src/tests.rs b/crates/doc/src/tests.rs
index ebec2383a..70dcd25b0 100644
--- a/crates/doc/src/tests.rs
+++ b/crates/doc/src/tests.rs
@@ -125,6 +125,34 @@ fn hit_test_respects_z_and_clip() {
assert_eq!(d.hit_test(p, Point::new(5.0, 95.0), 0.0, None), Some(f));
}
+#[test]
+fn region_paints_participate_in_colors_images_and_pattern_remapping() {
+ let mut d = Document::new("region resources");
+ let p = page(&d);
+ let group = d.create(p, NodeKind::Group, "Group", 0.0, 0.0, 40.0, 20.0).unwrap();
+ let source = d.create(group, NodeKind::Rectangle, "Source", 0.0, 0.0, 4.0, 4.0).unwrap();
+ let mut network = 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);
+ network.regions[0].fills = Some(vec![
+ figcraft_color::Paint::solid(figcraft_color::Color::from_hex("FF0000").unwrap()),
+ figcraft_color::Paint::image("region-image", figcraft_color::ImageScaleMode::Fill),
+ figcraft_color::Paint::pattern(source.0),
+ ]);
+ let vector = d.create(group, NodeKind::Vector { network }, "Vector", 0.0, 0.0, 20.0, 20.0).unwrap();
+ assert!(used_images(&d).contains("region-image"));
+ let dup = d.duplicate_into(group, p, None).unwrap();
+ let copied_children = d.node(dup).unwrap().children.clone();
+ let copied_source = copied_children[0];
+ let copied_vector = d.node(copied_children[1]).unwrap();
+ let NodeKind::Vector { network } = &copied_vector.kind else { panic!() };
+ let figcraft_color::PaintKind::Pattern(pattern) = &network.regions[0].fills.as_ref().unwrap()[2].kind else { panic!() };
+ assert_eq!(pattern.source, copied_source.0);
+
+ let red = figcraft_color::Color::from_hex("FF0000").unwrap();
+ d.map_colors(|c| if c == red { figcraft_color::Color::BLACK } else { c });
+ let NodeKind::Vector { network } = &d.node(vector).unwrap().kind else { panic!() };
+ assert_eq!(network.regions[0].fills.as_ref().unwrap()[0].solid_color(), Some(figcraft_color::Color::BLACK));
+}
+
#[test]
fn click_target_rules() {
let mut d = Document::new("x");
@@ -212,7 +240,7 @@ fn vector_network_paths() {
let mut bad = VectorNetwork {
vertices: vec![Vertex::new(0.0, 0.0)],
segments: vec![Segment::line(0, 5)],
- regions: vec![Region { loops: vec![vec![0, 9]], winding: WindingRule::EvenOdd }],
+ regions: vec![Region { loops: vec![vec![0, 9]], winding: WindingRule::EvenOdd, fills: None, ..Default::default() }],
};
bad.sanitize();
assert!(bad.segments.is_empty());
@@ -534,7 +562,7 @@ fn hit_test_even_odd_vector_hole_misses() {
VectorNetwork {
vertices: v1.into_iter().chain(v2).collect(),
segments: s1.into_iter().chain(s2).collect(),
- regions: vec![Region { loops: vec![vec![0, 1, 2, 3], vec![4, 5, 6, 7]], winding: rule }],
+ regions: vec![Region { loops: vec![vec![0, 1, 2, 3], vec![4, 5, 6, 7]], winding: rule, ..Default::default() }],
}
};
let mut d = Document::new("x");
@@ -637,7 +665,7 @@ fn two_segment_closed_loop_rounded_corners() {
let net = VectorNetwork {
vertices: vec![v0, v1],
segments: vec![Segment { a: 0, b: 1, ta: (0.0, 0.0), tb: (0.0, 0.0) }, Segment { a: 1, b: 0, ta: (0.0, 5.0), tb: (0.0, -5.0) }],
- regions: vec![Region { loops: vec![vec![0, 1]], winding: WindingRule::NonZero }],
+ regions: vec![Region { loops: vec![vec![0, 1]], winding: WindingRule::NonZero, ..Default::default() }],
};
let r = net.rounded().unwrap();
assert_eq!(r.regions[0].loops[0].len(), 4, "both arcs are in the loop");
diff --git a/crates/doc/src/vector.rs b/crates/doc/src/vector.rs
index b3040b1e1..5ae01a089 100644
--- a/crates/doc/src/vector.rs
+++ b/crates/doc/src/vector.rs
@@ -1,8 +1,9 @@
//! Vector networks: vertices joined by curved segments, any number of segments per vertex, plus
//! filled regions made of closed loops.
+use figcraft_color::Paint;
use figcraft_geom::kurbo::{CubicBez, ParamCurve, ParamCurveArclen, ParamCurveDeriv};
-use figcraft_geom::{BezPath, Point, Vec2};
+use figcraft_geom::{Affine, BezPath, Point, Vec2};
use serde::{Deserialize, Serialize};
use crate::{StrokeCap, StrokeJoin};
@@ -88,12 +89,44 @@ pub enum WindingRule {
EvenOdd,
}
+/// The paint coordinate frame retained when a region moves or its vector is refit.
+#[derive(Clone, Copy, Debug, PartialEq, Serialize, Deserialize)]
+pub struct RegionPaintSpace {
+ /// Width of the box that defines paint coordinates.
+ pub width: f64,
+ /// Height of the box that defines paint coordinates.
+ pub height: f64,
+ /// Maps paint-local coordinates into this network's local coordinates.
+ pub transform: Affine,
+}
+
/// A filled region: loops of segment indices.
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize, Default)]
pub struct Region {
pub loops: Vec>,
#[serde(default)]
pub winding: WindingRule,
+ #[serde(default, skip_serializing_if = "Option::is_none")]
+ pub fills: Option>,
+ #[serde(default, skip_serializing_if = "Option::is_none")]
+ pub paint_space: Option,
+}
+
+impl Region {
+ /// Paint box and transform, falling back to the current node box for old or invalid metadata.
+ pub fn paint_frame(&self, width: f64, height: f64) -> (f64, f64, Affine) {
+ let Some(frame) = self.paint_space else { return (width, height, Affine::IDENTITY) };
+ let coefficients = frame.transform.as_coeffs();
+ let valid = frame.width.is_finite()
+ && frame.height.is_finite()
+ && frame.width > 0.0
+ && frame.height > 0.0
+ && coefficients.iter().all(|value| value.is_finite())
+ && frame.transform.determinant().is_finite()
+ && frame.transform.determinant() != 0.0
+ && frame.transform.inverse().as_coeffs().iter().all(|value| value.is_finite());
+ if valid { (frame.width, frame.height, frame.transform) } else { (width, height, Affine::IDENTITY) }
+ }
}
/// A rounded point as drawn: point `v` moves to `a` (on segment `s1`), segment `s2` starts again
@@ -153,7 +186,7 @@ impl VectorNetwork {
let mut regions = Vec::new();
if closed && n > 2 {
segments.push(Segment::line(n - 1, 0));
- regions.push(Region { loops: vec![(0..segments.len()).collect()], winding: WindingRule::NonZero });
+ regions.push(Region { loops: vec![(0..segments.len()).collect()], winding: WindingRule::NonZero, fills: None, paint_space: None });
}
Self { vertices, segments, regions }
}
@@ -242,7 +275,7 @@ impl VectorNetwork {
}
close_open(&mut net, &mut loop_segs, &mut all_loops, first_v, cur_v);
if !all_loops.is_empty() {
- net.regions.push(Region { loops: all_loops, winding: WindingRule::NonZero });
+ net.regions.push(Region { loops: all_loops, winding: WindingRule::NonZero, fills: None, paint_space: None });
}
net
}
@@ -637,6 +670,12 @@ impl VectorNetwork {
s.ta = (s.ta.0 * sx, s.ta.1 * sy);
s.tb = (s.tb.0 * sx, s.tb.1 * sy);
}
+ let scale = Affine::scale_non_uniform(sx, sy);
+ for region in &mut self.regions {
+ if let Some(frame) = &mut region.paint_space {
+ frame.transform = scale * frame.transform;
+ }
+ }
}
pub fn translate(&mut self, dx: f64, dy: f64) {
@@ -644,6 +683,12 @@ impl VectorNetwork {
v.x += dx;
v.y += dy;
}
+ let translate = Affine::translate((dx, dy));
+ for region in &mut self.regions {
+ if let Some(frame) = &mut region.paint_space {
+ frame.transform = translate * frame.transform;
+ }
+ }
}
/// Bounds of the curves.
diff --git a/crates/doc/src/vector_faces.rs b/crates/doc/src/vector_faces.rs
index 050ab821c..634039d06 100644
--- a/crates/doc/src/vector_faces.rs
+++ b/crates/doc/src/vector_faces.rs
@@ -30,7 +30,12 @@ impl Face {
/// The fill region for this face.
pub fn region(&self) -> Region {
- Region { loops: self.loops.clone(), winding: if self.loops.len() > 1 { WindingRule::EvenOdd } else { WindingRule::NonZero } }
+ Region {
+ loops: self.loops.clone(),
+ winding: if self.loops.len() > 1 { WindingRule::EvenOdd } else { WindingRule::NonZero },
+ fills: None,
+ paint_space: None,
+ }
}
/// Some point strictly inside the face (not in a hole).
@@ -115,7 +120,13 @@ impl VectorNetwork {
/// Like [`Self::paint`], but with `want` the face ends up filled (`Some(true)`) or unfilled
/// (`Some(false)`) whatever it was: a paint bucket drag keeps doing what its first click did.
pub fn paint_to(&mut self, p: Point, want: Option) -> Option {
+ self.paint_to_with(p, want, None)
+ }
+
+ /// Like [`Self::paint_to`], with the layer fill stack used for inherited regions.
+ pub fn paint_to_with(&mut self, p: Point, want: Option, inherited: Option<&[figcraft_color::Paint]>) -> Option {
let face = self.face_at(p)?;
+ let legacy = self.regions.iter().all(|region| region.fills.is_none() && region.paint_space.is_none());
let key = |loops: &[Vec]| -> Vec> {
let mut k: Vec> = loops
.iter()
@@ -131,18 +142,52 @@ impl VectorNetwork {
let faces = self.faces();
let face_keys: Vec>> = faces.iter().map(|f| key(&f.loops)).collect();
if !self.regions.iter().all(|r| face_keys.contains(&key(&r.loops))) {
- let filled: Vec = faces.iter().filter(|f| f.interior_point().is_some_and(|q| self.fill_contains(q))).map(Face::region).collect();
- self.regions = filled;
+ if legacy {
+ // Legacy vectors use one combined fill rule across all regions.
+ self.regions =
+ faces.iter().filter(|face| face.interior_point().is_some_and(|point| self.fill_contains(point))).map(Face::region).collect();
+ } else {
+ // Split each styled source region independently to retain its stack and frame.
+ let source_regions = self.regions.clone();
+ let mut filled = Vec::new();
+ for (source_index, source) in source_regions.into_iter().enumerate() {
+ let source_path = self.region_path(source_index);
+ for face in &faces {
+ if face.interior_point().is_some_and(|q| {
+ source_path.as_ref().is_some_and(|path| match source.winding {
+ WindingRule::NonZero => path.winding(q) != 0,
+ WindingRule::EvenOdd => path.winding(q) % 2 != 0,
+ })
+ }) {
+ let mut region = face.region();
+ region.fills = source.fills.clone();
+ region.paint_space = source.paint_space;
+ filled.push(region);
+ }
+ }
+ }
+ self.regions = filled;
+ }
}
let k = key(&face.loops);
- match (self.regions.iter().position(|r| key(&r.loops) == k), want) {
- (Some(_), None | Some(false)) => {
+ let filled = if legacy {
+ self.fill_contains(p) && inherited.is_none_or(|fills| fills.iter().any(|paint| paint.visible))
+ } else {
+ self.regions.iter().filter(|r| key(&r.loops) == k).any(|region| match ®ion.fills {
+ Some(fills) => fills.iter().any(|paint| paint.visible),
+ None => inherited.is_none_or(|fills| fills.iter().any(|paint| paint.visible)),
+ })
+ };
+ let want = want.unwrap_or(!filled);
+ match (filled, want) {
+ (true, false) => {
self.regions.retain(|r| key(&r.loops) != k);
Some(false)
}
- (Some(_), Some(true)) => Some(true),
- (None, Some(false)) => Some(false),
- (None, _) => {
+ (true, true) => Some(true),
+ (false, false) => Some(false),
+ (false, true) => {
+ self.regions.retain(|r| key(&r.loops) != k);
self.regions.push(face.region());
Some(true)
}
diff --git a/crates/doc/src/vector_regions_tests.rs b/crates/doc/src/vector_regions_tests.rs
index c09664b4a..75814f9fd 100644
--- a/crates/doc/src/vector_regions_tests.rs
+++ b/crates/doc/src/vector_regions_tests.rs
@@ -30,7 +30,32 @@ fn nested() -> VectorNetwork {
}
fn region(loops: Vec>, winding: WindingRule) -> Region {
- Region { loops, winding }
+ Region { loops, winding, fills: None, ..Default::default() }
+}
+
+#[test]
+fn region_fills_default_to_inheritance_and_keep_explicit_empty() {
+ let legacy: Region = serde_json::from_value(serde_json::json!({"loops": []})).unwrap();
+ assert_eq!(legacy.fills, None);
+ let empty = Region { fills: Some(Vec::new()), ..legacy };
+ let value = serde_json::to_value(empty).unwrap();
+ assert!(value["fills"].as_array().is_some_and(Vec::is_empty));
+ let inherited = serde_json::to_value(region(Vec::new(), WindingRule::NonZero)).unwrap();
+ assert!(inherited.get("fills").is_none());
+}
+
+#[test]
+fn region_paint_space_roundtrips_and_rejects_nonfinite_inverse() {
+ use crate::RegionPaintSpace;
+ use figcraft_geom::Affine;
+ let mut r = region(Vec::new(), WindingRule::EvenOdd);
+ assert_eq!(r.paint_frame(100.0, 40.0), (100.0, 40.0, Affine::IDENTITY));
+ r.paint_space = Some(RegionPaintSpace { width: 30.0, height: 20.0, transform: Affine::translate((4.0, 8.0)) });
+ let decoded: Region = serde_json::from_str(&serde_json::to_string(&r).unwrap()).unwrap();
+ assert_eq!(decoded, r);
+ assert_eq!(decoded.paint_frame(100.0, 40.0), (30.0, 20.0, Affine::translate((4.0, 8.0))));
+ r.paint_space.as_mut().unwrap().transform = Affine::scale_non_uniform(1e-320, 1.0);
+ assert_eq!(r.paint_frame(100.0, 40.0), (100.0, 40.0, Affine::IDENTITY));
}
fn p(x: f64, y: f64) -> Point {
diff --git a/crates/engine/src/al_behaviour_tests.rs b/crates/engine/src/al_behaviour_tests.rs
index a7a4d1d74..d36b35b8f 100644
--- a/crates/engine/src/al_behaviour_tests.rs
+++ b/crates/engine/src/al_behaviour_tests.rs
@@ -6,7 +6,7 @@ use serde_json::{Value, json};
use crate::Session;
use crate::params::id_of;
-use figcraft_doc::{CellAlign, MainAlign, NodeId, Sizing};
+use figcraft_doc::{CellAlign, CrossAlign, LayoutMode, MainAlign, NodeId, Sizing};
fn id(v: Value) -> NodeId {
id_of(&v["id"]).unwrap()
@@ -233,3 +233,46 @@ fn a_size_given_with_a_new_path_fixes_that_axis() {
assert_eq!(sizing(&s, v).0, Sizing::Fixed);
assert!((size(&s, v).0 - 30.0).abs() < 1e-6, "{:?}", size(&s, v));
}
+
+/// What one call's auto layout keys give, whichever way they are spelled: the mode (a string, or
+/// an object that replaces the whole auto layout) applies first and the other keys tune it.
+#[test]
+fn auto_layout_keys_in_one_call_tune_the_mode_given_beside_them() {
+ for (key, mode) in ["layout", "layoutMode"].into_iter().flat_map(|k| [(k, json!("vertical")), (k, json!({"mode": "vertical"}))]) {
+ let mut props = json!({"gap": 8, "padding": 4, "crossAlign": "center"});
+ props[key] = mode;
+ let mut s = Session::new();
+ let created = id(s.execute("node.create", &json!({"type": "frame", "props": props})).unwrap());
+ let set_on = id(s.execute("frame.create", &json!({"x": 0, "y": 0, "width": 100, "height": 100})).unwrap());
+ set(&mut s, set_on, props.clone());
+ for f in [created, set_on] {
+ let l = s.doc().unwrap().node(f).unwrap().layout.clone().unwrap();
+ assert_eq!((l.mode, l.gap, l.padding, l.cross_align), (LayoutMode::Vertical, 8.0, [4.0; 4], CrossAlign::Center), "{props}");
+ }
+ }
+}
+
+/// Auto layout keys beside a `layout` that turns auto layout off would turn it back on: the call
+/// is refused and the layer stays as it was.
+#[test]
+fn auto_layout_keys_beside_no_auto_layout_are_refused() {
+ let mut s = Session::new();
+ let f = id(s.execute("frame.create", &json!({"x": 0, "y": 0, "width": 100, "height": 100})).unwrap());
+ for props in
+ [json!({"layout": null, "padding": 4}), json!({"layout": "none", "gap": 8, "padding": 4}), json!({"layoutMode": "NONE", "wrap": true})]
+ {
+ let e = s.execute("node.set", &json!({"id": f, "props": props})).unwrap_err().to_string();
+ assert!(e.contains("turns auto layout off"), "{props}: {e}");
+ assert!(s.doc().unwrap().node(f).unwrap().layout.is_none(), "{props}");
+ let e = s.execute("node.create", &json!({"type": "frame", "props": props})).unwrap_err().to_string();
+ assert!(e.contains("turns auto layout off"), "{props}: {e}");
+ }
+ let e = s.execute("node.set", &json!({"id": f, "props": {"layout": null, "gap": 8, "padding": 4}})).unwrap_err().to_string();
+ assert!(e.contains("`gap`, `padding` are auto layout properties, and `layout` in the same call"), "{e}");
+ let e = s.execute("node.set", &json!({"id": f, "props": {"layoutMode": "NONE", "wrap": true}})).unwrap_err().to_string();
+ assert!(e.contains("`wrap` is an auto layout property, and `layoutMode` in the same call"), "{e}");
+ // Without other auto layout keys, `layout: null` still removes it.
+ set(&mut s, f, json!({"layout": "vertical", "gap": 8}));
+ set(&mut s, f, json!({"layout": null, "name": "Plain"}));
+ assert!(s.doc().unwrap().node(f).unwrap().layout.is_none());
+}
diff --git a/crates/engine/src/assets/playground.rs b/crates/engine/src/assets/playground.rs
index 5c05c3b94..ddb969c25 100644
--- a/crates/engine/src/assets/playground.rs
+++ b/crates/engine/src/assets/playground.rs
@@ -52,7 +52,7 @@ pub fn specs() -> Vec {
vec![spec!(
"component.playground",
"Component playground",
- "{id? (an instance, a component or a component set; default the selection), properties? {name: value} (variant, boolean, text and instance-swap properties, as instance.setProperties takes them), language? css|html|react|leptos|vue|swiftui|compose|arkui (default react), path? (write the preview there as a PNG), scale? (the PNG's, default 2)} → {component, properties: [{name, type, options?, value}], language, code, width, height, path?} the component with those property values, tried on a copy: its code and its look. Nothing in the document changes",
+ "{id? (an instance, a component or a component set; default the selection), properties? {name: value} (variant, boolean, text and instance-swap properties, as instance.setProperties takes them), language? css|html|react|leptos|vue|swiftui|compose|arkui|flutter (default react), path? (write the preview there as a PNG), scale? (the PNG's, default 2)} → {component, properties: [{name, type, options?, value}], language, code, width, height, path?} the component with those property values, tried on a copy: its code and its look. Nothing in the document changes",
false,
|s, p| {
let cmd = "component.playground";
@@ -63,7 +63,7 @@ pub fn specs() -> Vec {
Some(_) => return Err(bad(cmd, "properties: {name: value}")),
};
let lang = match string(p, "language") {
- Some(l) => CodeLang::from_id(&l).ok_or_else(|| bad(cmd, "language: css|html|react|leptos|vue|swiftui|compose|arkui"))?,
+ Some(l) => CodeLang::from_id(&l).ok_or_else(|| bad(cmd, "language: css|html|react|leptos|vue|swiftui|compose|arkui|flutter"))?,
None => CodeLang::React,
};
let (copy, inst) = build(s.doc()?, target, &values)?;
diff --git a/crates/engine/src/canvas/shapebuilder.rs b/crates/engine/src/canvas/shapebuilder.rs
index 74a4a9d12..b68f1e71d 100644
--- a/crates/engine/src/canvas/shapebuilder.rs
+++ b/crates/engine/src/canvas/shapebuilder.rs
@@ -2,9 +2,10 @@
//!
//! The selected shapes' outlines split the plane into **regions** (the faces of their arrangement):
//! connected areas covered by the same shapes. Merging regions makes one new vector from them,
-//! styled like the top-most shape covering the first region; removing regions deletes them. Either
-//! way every shape covering a picked region loses that area (it becomes a vector, or is deleted
-//! when nothing is left), so the rest of the drawing stays exactly where it was.
+//! styled like the top-most fill or stroke covering the first selected region (using that vector
+//! region's paints when applicable); removing regions deletes them. Either way every shape covering
+//! a picked region loses that area (it becomes a vector, or is deleted when nothing is left), so
+//! the rest of the drawing stays exactly where it was.
//!
//! **Strokes and open paths.** A visible stroke takes part as its outlined area (a [`Part`] of its
//! own, above the shape's fill), so stroked lines and open paths split the regions they cross and
@@ -37,6 +38,9 @@ use crate::{EngineError, Result, Session, bad, spec};
pub struct Part {
pub id: NodeId,
pub stroke: bool,
+ /// The source vector region for a fill (`None` for ordinary fills and strokes).
+ #[serde(skip)]
+ pub region: Option,
}
/// One region of the arrangement, in page coordinates.
@@ -126,7 +130,16 @@ fn world_area(doc: &Document, part: Part) -> Option<(BezPath, bool)> {
if !(figcraft_doc::geometry::is_closed(n) || matches!(n.kind, NodeKind::BooleanOp { .. } | NodeKind::Text(_))) {
return None;
}
- figcraft_render::node_outline(doc, n)?
+ match (&n.kind, part.region) {
+ (NodeKind::Vector { network }, Some(i)) => {
+ let rounded = network.rounded();
+ let net = rounded.as_ref().unwrap_or(network);
+ let region = net.regions.get(i)?;
+ let path = net.region_path(i)?;
+ (path, if region.winding == WindingRule::EvenOdd { figcraft_render::FillRule::EvenOdd } else { figcraft_render::FillRule::Winding })
+ }
+ _ => figcraft_render::node_outline(doc, n)?,
+ }
};
p.apply_affine(doc.world_transform(part.id));
(!p.elements().is_empty() && figcraft_pathops::finite(&p)).then_some((p, rule == figcraft_render::FillRule::EvenOdd))
@@ -155,10 +168,17 @@ fn compute(doc: Arc, uid: u64, ids: &[NodeId]) -> Result
let mut inputs = Vec::new();
for id in eligible(&doc, ids) {
let before = parts.len();
- for part in [Part { id, stroke: false }, Part { id, stroke: true }] {
- if inputs.len() < figcraft_pathops::MAX_ARRANGEMENT_INPUTS
- && let Some(o) = world_area(&doc, part)
- {
+ let fill_parts: Vec = match &doc.get(id)?.kind {
+ NodeKind::Vector { network } if network.regions.iter().any(|r| r.fills.is_some() || r.paint_space.is_some()) => {
+ network.regions.iter().enumerate().map(|(region, _)| Part { id, stroke: false, region: Some(region) }).collect()
+ }
+ _ => vec![Part { id, stroke: false, region: None }],
+ };
+ for part in fill_parts.into_iter().chain([Part { id, stroke: true, region: None }]) {
+ if let Some(o) = world_area(&doc, part) {
+ if inputs.len() >= figcraft_pathops::MAX_ARRANGEMENT_INPUTS {
+ return Err(EngineError::Other("shape builder: too many fill regions and strokes to process safely".into()));
+ }
parts.push(part);
inputs.push(o);
}
@@ -253,6 +273,68 @@ fn network_from(path: &BezPath, to_local: Affine) -> Option {
Some(net)
}
+fn append_network(into: &mut VectorNetwork, mut part: VectorNetwork) {
+ let (vertices, segments) = (into.vertices.len(), into.segments.len());
+ for segment in &mut part.segments {
+ segment.a += vertices;
+ segment.b += vertices;
+ }
+ for region in &mut part.regions {
+ for loop_segments in &mut region.loops {
+ for segment in loop_segments {
+ *segment += segments;
+ }
+ }
+ }
+ into.vertices.extend(part.vertices);
+ into.segments.extend(part.segments);
+ into.regions.extend(part.regions);
+}
+
+fn paint_frame(region: Option<&figcraft_doc::Region>, node: &Node) -> (f64, f64, Affine) {
+ region.map_or((node.width, node.height, Affine::IDENTITY), |r| r.paint_frame(node.width, node.height))
+}
+
+/// Rebuild `id` from each remaining source region, retaining region order, paint stacks and frames.
+fn reshape_vector_regions(doc: &mut Document, id: NodeId, areas: &[(usize, BezPath)]) -> Result<()> {
+ let old = doc.get(id)?.clone();
+ let (world, network) = match &old.kind {
+ NodeKind::Vector { network } => (doc.world_transform(id), network),
+ _ => return Err(EngineError::Other("expected a vector with regions".into())),
+ };
+ if !(world.determinant().is_finite() && world.determinant().abs() > 1e-12) {
+ return Err(EngineError::Other("can't reshape a layer scaled to nothing".into()));
+ }
+ let mut rebuilt = VectorNetwork::default();
+ for (index, area) in areas {
+ let Some(source) = network.regions.get(*index) else { continue };
+ let Some(mut part) = network_from(area, world.inverse()) else { return Err(EngineError::Other("invalid geometry".into())) };
+ if part.regions.is_empty() {
+ continue;
+ }
+ let (width, height, frame) = paint_frame(Some(source), &old);
+ for region in &mut part.regions {
+ region.fills = source.fills.clone();
+ region.paint_space = Some(figcraft_doc::RegionPaintSpace { width, height, transform: frame });
+ }
+ append_network(&mut rebuilt, part);
+ }
+ let Some(bounds) = rebuilt.bounds() else {
+ let n = doc.get_mut(id)?;
+ n.kind = NodeKind::Vector { network: rebuilt };
+ return Ok(());
+ };
+ rebuilt.translate(-bounds.x0, -bounds.y0);
+ let n = doc.get_mut(id)?;
+ n.kind = NodeKind::Vector { network: rebuilt };
+ n.children.clear();
+ n.corners = Corners::default();
+ n.transform *= Affine::translate((bounds.x0, bounds.y0));
+ n.width = bounds.width();
+ n.height = bounds.height();
+ Ok(())
+}
+
/// Replace shape `id`'s geometry with the page-space area `area`, keeping its transform and style.
fn reshape(doc: &mut Document, id: NodeId, area: &BezPath) -> Result<()> {
let w = doc.world_transform(id);
@@ -273,10 +355,16 @@ fn reshape(doc: &mut Document, id: NodeId, area: &BezPath) -> Result<()> {
crate::vectoredit::refit(doc, id)
}
-/// A new vector for the page-space area `area`, styled like part `style` (a stroke part: filled
-/// with the stroke's paints), just above its shape.
+/// A new vector for page-space `area`, styled like `style` (a stroke part: filled with stroke
+/// paints), just above its shape. Vector region paints and their original frame stay attached.
fn create_merged(doc: &mut Document, style: Part, area: &BezPath) -> Result {
let mut src = doc.get(style.id)?.clone();
+ let source_region = match (&src.kind, style.region) {
+ (NodeKind::Vector { network }, Some(i)) => network.regions.get(i),
+ _ => None,
+ };
+ let (paint_width, paint_height, source_paint_frame) = paint_frame(source_region, &src);
+ let region_fills = source_region.and_then(|r| r.fills.clone());
if style.stroke {
src.fills = std::mem::take(&mut src.strokes);
src.stroke = StrokeProps::default();
@@ -291,6 +379,12 @@ fn create_merged(doc: &mut Document, style: Part, area: &BezPath) -> Result Result {
// Merging every region of a single shape would only replace it with a copy of itself.
let whole_shape =
touched.len() == 1 && a.regions.iter().enumerate().all(|(i, r)| pick.contains(&i) || !r.sources.iter().any(|x| touched.contains(x)));
- if merge && whole_shape {
+ // A vector's distinct region paints are separate fill parts that merging does unify.
+ let fill_parts = touched_parts.iter().filter(|part| !part.stroke).count();
+ if merge && whole_shape && fill_parts <= 1 {
return Ok(json!({"id": null, "changed": [], "removed": [], "outlines": [], "regions": pick}));
}
let doc = s.doc_mut()?;
@@ -347,7 +443,8 @@ fn apply(s: &mut Session, cmd: &str, p: &Value, merge: bool) -> Result {
let mut changed = Vec::new();
let mut outlined: Vec<(NodeId, NodeId)> = Vec::new();
for src in a.sources.iter().copied().filter(|i| touched.contains(i)) {
- let (fill, stroke) = (Part { id: src, stroke: false }, Part { id: src, stroke: true });
+ let stroke = Part { id: src, stroke: true, region: None };
+ let fill_parts: Vec = a.parts.iter().copied().filter(|part| part.id == src && !part.stroke).collect();
// The stroke becomes an outline of its own (what's left of it), the shape loses it.
if a.parts.contains(&stroke) {
if let Some(area) = kept(stroke)? {
@@ -357,16 +454,35 @@ fn apply(s: &mut Session, cmd: &str, p: &Value, merge: bool) -> Result {
n.strokes.clear();
n.styles.stroke = None;
}
- let left = if !a.parts.contains(&fill) {
- None
- } else if touched_parts.contains(&fill) {
- kept(fill)?
- } else {
+ if fill_parts.is_empty() {
+ doc.remove(src)?;
+ removed.push(src);
+ continue;
+ }
+ let touched_fills: Vec = fill_parts.iter().copied().filter(|part| touched_parts.contains(part)).collect();
+ if touched_fills.is_empty() {
// The fill wasn't picked: the shape stays as it is.
changed.push(src);
continue;
- };
- match left {
+ }
+ if fill_parts.iter().any(|part| part.region.is_some()) {
+ let mut areas = Vec::new();
+ for part in &fill_parts {
+ let Some(region) = part.region else { continue };
+ if let Some(area) = kept(*part)? {
+ areas.push((region, area));
+ }
+ }
+ if areas.is_empty() {
+ doc.remove(src)?;
+ removed.push(src);
+ } else {
+ reshape_vector_regions(doc, src, &areas)?;
+ changed.push(src);
+ }
+ continue;
+ }
+ match kept(fill_parts[0])? {
Some(area) => {
reshape(doc, src, &area)?;
changed.push(src);
@@ -427,7 +543,7 @@ pub fn specs() -> Vec {
spec!(
"shapeBuilder.merge",
"Merge regions",
- "{ids?, regions?: [i, ...], points?: [[x, y], ...] (page)} → {id, changed, removed, outlines, regions} — one new shape from the regions, styled like the top-most fill or stroke over the first; the shapes lose those areas (strokes of the shapes involved become outlined vectors: `outlines`)",
+ "{ids?, regions?: [i, ...], points?: [[x, y], ...] (page)} → {id, changed, removed, outlines, regions} — one new shape from the regions, styled like the top-most fill or stroke over the first selected region (using its vector-region paints); the shapes lose those areas (strokes of the shapes involved become outlined vectors: `outlines`)",
true,
|s, p| apply(s, "shapeBuilder.merge", p, true)
),
@@ -464,6 +580,10 @@ pub fn key(s: &mut Session, name: &str) -> Result {
Ok(true)
}
+#[cfg(test)]
+#[path = "shapebuilder_region_paints_tests.rs"]
+mod region_paints_tests;
+
impl Session {
/// The Shape builder tool: hover highlights a region, click/drag collects regions, release
/// merges (or removes, with Alt or in Remove mode) them as one undo step. Clicking outside
diff --git a/crates/engine/src/canvas/shapebuilder_region_paints_tests.rs b/crates/engine/src/canvas/shapebuilder_region_paints_tests.rs
new file mode 100644
index 000000000..74e450930
--- /dev/null
+++ b/crates/engine/src/canvas/shapebuilder_region_paints_tests.rs
@@ -0,0 +1,307 @@
+use figcraft_color::{Color, Gradient, GradientKind, Paint};
+use figcraft_doc::{NodeId, NodeKind, Region, VectorNetwork, WindingRule};
+use figcraft_geom::{Affine, Point, Shape};
+use serde_json::{Value, json};
+
+use crate::Session;
+use crate::params::id_of;
+use crate::shapebuilder::arrangement;
+
+fn ex(s: &mut Session, cmd: &str, p: Value) -> Value {
+ s.execute(cmd, &p).unwrap_or_else(|e| panic!("{cmd}: {e}"))
+}
+
+fn id(v: &Value) -> NodeId {
+ id_of(&v["id"]).unwrap()
+}
+
+fn rect(s: &mut Session, x: f64, y: f64, width: f64, height: f64, fill: &str) -> NodeId {
+ id(&ex(s, "shape.rectangle", json!({"x": x, "y": y, "width": width, "height": height, "props": {"fill": fill}})))
+}
+
+fn append(into: &mut VectorNetwork, mut part: VectorNetwork) {
+ let (vertices, segments) = (into.vertices.len(), into.segments.len());
+ for segment in &mut part.segments {
+ segment.a += vertices;
+ segment.b += vertices;
+ }
+ for region in &mut part.regions {
+ for lp in &mut region.loops {
+ for segment in lp {
+ *segment += segments;
+ }
+ }
+ }
+ into.vertices.extend(part.vertices);
+ into.segments.extend(part.segments);
+ into.regions.extend(part.regions);
+}
+
+fn box_path(x0: f64, y0: f64, x1: f64, y1: f64) -> VectorNetwork {
+ VectorNetwork::from_points(&[Point::new(x0, y0), Point::new(x1, y0), Point::new(x1, y1), Point::new(x0, y1)], true)
+}
+
+fn split_vector(s: &mut Session) -> NodeId {
+ let id = rect(s, 0.0, 0.0, 100.0, 100.0, "#00ff00");
+ let mut network = VectorNetwork::default();
+ append(&mut network, box_path(0.0, 0.0, 50.0, 100.0));
+ append(&mut network, box_path(50.0, 0.0, 100.0, 100.0));
+ network.regions[0].fills = Some(vec![Paint::solid(Color::from_hex("#ff0000").unwrap())]);
+ network.regions[1].fills = Some(vec![Paint::solid(Color::from_hex("#0000ff").unwrap())]);
+ s.doc_mut().unwrap().get_mut(id).unwrap().kind = NodeKind::Vector { network };
+ id
+}
+
+fn network(s: &Session, id: NodeId) -> &VectorNetwork {
+ let NodeKind::Vector { network } = &s.doc().unwrap().node(id).unwrap().kind else { panic!("expected vector") };
+ // This helper's borrow is tied to the session document.
+ network
+}
+
+fn world_region_area(s: &Session, id: NodeId, index: usize) -> f64 {
+ let doc = s.doc().unwrap();
+ let mut path = network(s, id).region_path(index).unwrap();
+ path.apply_affine(doc.world_transform(id));
+ path.area().abs()
+}
+
+fn normalized_paint_world(s: &Session, id: NodeId, index: usize) -> Affine {
+ let doc = s.doc().unwrap();
+ let node = doc.node(id).unwrap();
+ let (width, height, transform) = network(s, id).regions[index].paint_frame(node.width, node.height);
+ doc.world_transform(id) * transform * Affine::scale_non_uniform(width, height)
+}
+
+fn assert_same_transform(a: Affine, b: Affine) {
+ for (x, y) in a.as_coeffs().into_iter().zip(b.as_coeffs()) {
+ assert!((x - y).abs() < 1e-8, "paint frame changed: {a:?} vs {b:?}");
+ }
+}
+
+#[test]
+fn arrangement_keeps_internal_paint_boundaries_when_another_shape_crosses_them() {
+ let mut s = Session::new();
+ let vector = split_vector(&mut s);
+ let crossing = rect(&mut s, 25.0, -10.0, 50.0, 120.0, "#ffffff");
+ s.set_selection(vec![vector, crossing]);
+ let a = arrangement(&mut s, &[vector, crossing]).unwrap();
+ let left = a.region_at(Point::new(35.0, 50.0)).unwrap();
+ let right = a.region_at(Point::new(65.0, 50.0)).unwrap();
+ assert_ne!(left, right, "the old paint boundary must split the crossing shape's overlap");
+ assert!(a.regions[left].parts.iter().any(|p| p.id == vector && p.region == Some(0)));
+ assert!(a.regions[right].parts.iter().any(|p| p.id == vector && p.region == Some(1)));
+}
+
+#[test]
+fn legacy_unstyled_nested_evenodd_regions_keep_the_hole_unpainted() {
+ let mut s = Session::new();
+ let vector = rect(&mut s, 0.0, 0.0, 100.0, 100.0, "#00ff00");
+ let mut net = box_path(0.0, 0.0, 100.0, 100.0);
+ let inner = VectorNetwork::from_points(&[Point::new(25.0, 25.0), Point::new(25.0, 75.0), Point::new(75.0, 75.0), Point::new(75.0, 25.0)], true);
+ append(&mut net, inner);
+ for region in &mut net.regions {
+ region.winding = WindingRule::EvenOdd;
+ region.fills = None;
+ region.paint_space = None;
+ }
+ s.doc_mut().unwrap().get_mut(vector).unwrap().kind = NodeKind::Vector { network: net };
+ let crossing = rect(&mut s, -5.0, 45.0, 15.0, 10.0, "#ffffff");
+ s.set_selection(vec![vector, crossing]);
+ let a = arrangement(&mut s, &[vector, crossing]).unwrap();
+ assert!(a.region_at(Point::new(50.0, 50.0)).is_none(), "the legacy combined even-odd fill leaves its nested center empty");
+
+ ex(&mut s, "shapeBuilder.remove", json!({"points": [[5, 50]]}));
+ let NodeKind::Vector { network } = &s.doc().unwrap().node(vector).unwrap().kind else { panic!("expected vector") };
+ assert!(!network.fill_contains(Point::new(50.0, 50.0)), "the nested hole survives removal");
+ assert!(network.fill_contains(Point::new(85.0, 50.0)), "unselected outer area remains filled");
+}
+
+#[test]
+fn merge_all_painted_regions_uses_the_first_picked_region_style() {
+ let mut s = Session::new();
+ let vector = split_vector(&mut s);
+ s.set_selection(vec![vector]);
+ let result = ex(&mut s, "shapeBuilder.merge", json!({"points": [[25, 50], [75, 50]]}));
+ let merged = id(&result);
+ assert!(!s.doc().unwrap().contains(vector));
+ assert_eq!(network(&s, merged).regions[0].fills.as_ref().unwrap()[0].solid_color().unwrap().to_hex(), "FF0000");
+ assert!((world_region_area(&s, merged, 0) - 10_000.0).abs() < 0.1);
+ s.undo().unwrap();
+ assert_eq!(network(&s, vector).regions.len(), 2);
+}
+
+#[test]
+fn merge_all_inherited_gradient_regions_with_distinct_frames_is_not_a_noop() {
+ let mut s = Session::new();
+ let vector = split_vector(&mut s);
+ {
+ let doc = s.doc_mut().unwrap();
+ let node = doc.get_mut(vector).unwrap();
+ node.fills = vec![Paint::gradient(Gradient::default_for(GradientKind::Linear, Color::from_hex("#ff0000").unwrap()))];
+ let NodeKind::Vector { network } = &mut node.kind else { panic!("expected vector") };
+ for (region, offset) in network.regions.iter_mut().zip([0.0, 12.0]) {
+ region.fills = None;
+ region.paint_space = Some(figcraft_doc::RegionPaintSpace { width: 100.0, height: 100.0, transform: Affine::translate((offset, 0.0)) });
+ }
+ }
+ let before_a = normalized_paint_world(&s, vector, 0);
+ let before_b = normalized_paint_world(&s, vector, 1);
+ assert_ne!(before_a, before_b, "source regions use distinct inherited-gradient frames");
+
+ s.set_selection(vec![vector]);
+ let result = ex(&mut s, "shapeBuilder.merge", json!({"points": [[25, 50], [75, 50]]}));
+ let merged = id(&result);
+ assert!(!s.doc().unwrap().contains(vector), "the selected regions are merged into a new vector");
+ assert_eq!(network(&s, merged).regions.len(), 1);
+ assert_eq!(network(&s, merged).regions[0].fills, None, "node gradient remains inherited");
+ assert!(matches!(s.doc().unwrap().node(merged).unwrap().fills[0].kind, figcraft_color::PaintKind::Gradient(_)));
+ assert_same_transform(normalized_paint_world(&s, merged, 0), before_a);
+}
+
+#[test]
+fn exceeding_arrangement_capacity_never_drops_unprocessed_regions() {
+ let mut s = Session::new();
+ let vector = split_vector(&mut s);
+ let mut net = VectorNetwork::default();
+ for i in 0..=figcraft_pathops::MAX_ARRANGEMENT_INPUTS {
+ let mut part = box_path(i as f64 * 12.0, 0.0, i as f64 * 12.0 + 10.0, 10.0);
+ part.regions[0].fills = Some(vec![Paint::solid(Color::BLACK)]);
+ append(&mut net, part);
+ }
+ s.doc_mut().unwrap().get_mut(vector).unwrap().kind = NodeKind::Vector { network: net.clone() };
+ s.set_selection(vec![vector]);
+ let error = s.execute("shapeBuilder.remove", &json!({"points": [[5, 5]]})).unwrap_err();
+ assert!(error.to_string().contains("too many fill regions"));
+ assert_eq!(network(&s, vector), &net);
+}
+
+#[test]
+fn remove_changes_only_the_picked_painted_face() {
+ let mut s = Session::new();
+ let vector = split_vector(&mut s);
+ let crossing = rect(&mut s, 25.0, -10.0, 50.0, 120.0, "#ffffff");
+ s.set_selection(vec![vector, crossing]);
+ ex(&mut s, "shapeBuilder.remove", json!({"points": [[35, 50]]}));
+ assert!((world_region_area(&s, vector, 0) - 2500.0).abs() < 0.1);
+ assert!((world_region_area(&s, vector, 1) - 5000.0).abs() < 0.1, "the untouched blue region keeps its area");
+ let net = network(&s, vector);
+ assert_eq!(net.regions[0].fills.as_ref().unwrap()[0].solid_color().unwrap().to_hex(), "FF0000");
+ assert_eq!(net.regions[1].fills.as_ref().unwrap()[0].solid_color().unwrap().to_hex(), "0000FF");
+}
+
+#[test]
+fn reshape_keeps_inherited_and_explicitly_empty_region_fills_distinct() {
+ let mut s = Session::new();
+ let vector = split_vector(&mut s);
+ {
+ let doc = s.doc_mut().unwrap();
+ let NodeKind::Vector { network } = &mut doc.get_mut(vector).unwrap().kind else { panic!("expected vector") };
+ network.regions[0].fills = Some(Vec::new());
+ network.regions[1].fills = None;
+ }
+ let crossing = rect(&mut s, 25.0, -10.0, 50.0, 120.0, "#ffffff");
+ s.set_selection(vec![vector, crossing]);
+ ex(&mut s, "shapeBuilder.remove", json!({"points": [[35, 50]]}));
+ assert_eq!(network(&s, vector).regions[0].fills, Some(Vec::new()));
+ assert_eq!(network(&s, vector).regions[1].fills, None);
+ assert_eq!(s.doc().unwrap().node(vector).unwrap().fills[0].solid_color().unwrap().to_hex(), "00FF00");
+}
+
+#[test]
+fn merge_uses_selected_region_paints_and_retains_other_region_stack() {
+ let mut s = Session::new();
+ // Create crossing first, then vector above it so vector region supplies merge appearance.
+ let crossing = rect(&mut s, 25.0, -10.0, 50.0, 120.0, "#ffffff");
+ let vector = split_vector(&mut s);
+ s.set_selection(vec![crossing, vector]);
+ let result = ex(&mut s, "shapeBuilder.merge", json!({"points": [[35, 50]]}));
+ let merged = id(&result);
+ let merged_net = network(&s, merged);
+ assert_eq!(merged_net.regions[0].fills.as_ref().unwrap()[0].solid_color().unwrap().to_hex(), "FF0000");
+ assert!((world_region_area(&s, vector, 1) - 5000.0).abs() < 0.1, "unselected blue region survives");
+ assert_eq!(network(&s, vector).regions[1].fills.as_ref().unwrap()[0].solid_color().unwrap().to_hex(), "0000FF");
+ assert!((world_region_area(&s, merged, 0) - 2500.0).abs() < 0.1);
+ assert!(s.undo().unwrap());
+ assert_eq!(network(&s, vector).regions[0].fills.as_ref().unwrap()[0].solid_color().unwrap().to_hex(), "FF0000");
+ assert!(s.redo().unwrap());
+ assert_eq!(network(&s, merged).regions[0].fills.as_ref().unwrap()[0].solid_color().unwrap().to_hex(), "FF0000");
+}
+
+#[test]
+fn merged_region_keeps_a_transformed_source_gradient_frame() {
+ let mut s = Session::new();
+ let crossing = rect(&mut s, 0.0, 0.0, 300.0, 200.0, "#ffffff");
+ let vector = split_vector(&mut s);
+ let mut transform = Affine::translate((100.0, 20.0)) * Affine::rotate(0.2);
+ transform *= Affine::scale_non_uniform(1.2, 0.8);
+ {
+ let doc = s.doc_mut().unwrap();
+ let node = doc.get_mut(vector).unwrap();
+ node.transform = transform;
+ let NodeKind::Vector { network } = &mut node.kind else { panic!("expected vector") };
+ network.regions[0].fills = Some(vec![Paint::gradient(Gradient::default_for(GradientKind::Linear, Color::from_hex("#ff0000").unwrap()))]);
+ }
+ let before = normalized_paint_world(&s, vector, 0);
+ let point = s.doc().unwrap().world_transform(vector) * Point::new(35.0, 50.0);
+ s.set_selection(vec![crossing, vector]);
+ let result = ex(&mut s, "shapeBuilder.merge", json!({"points": [[point.x, point.y]]}));
+ let merged = id(&result);
+ assert!(matches!(&network(&s, merged).regions[0].fills.as_ref().unwrap()[0].kind, figcraft_color::PaintKind::Gradient(_)));
+ assert_same_transform(before, normalized_paint_world(&s, merged, 0));
+}
+
+#[test]
+fn removal_preserves_holes_and_transformed_gradient_frames() {
+ let mut s = Session::new();
+ let vector = rect(&mut s, 0.0, 0.0, 100.0, 100.0, "#00ff00");
+ let mut outer = box_path(0.0, 0.0, 100.0, 100.0);
+ let inner = VectorNetwork::from_points(&[Point::new(25.0, 25.0), Point::new(25.0, 75.0), Point::new(75.0, 75.0), Point::new(75.0, 25.0)], true);
+ let outer_loop = outer.regions[0].loops[0].clone();
+ let inner_loop: Vec = inner.regions[0].loops[0].iter().map(|i| i + outer.segments.len()).collect();
+ append(&mut outer, inner);
+ outer.regions = vec![Region {
+ loops: vec![outer_loop, inner_loop],
+ winding: WindingRule::EvenOdd,
+ fills: Some(vec![Paint::gradient(Gradient::default_for(GradientKind::Linear, Color::from_hex("#ff0000").unwrap()))]),
+ paint_space: None,
+ }];
+ let transform = Affine::translate((120.0, 20.0)) * Affine::rotate(0.2);
+ {
+ let doc = s.doc_mut().unwrap();
+ let node = doc.get_mut(vector).unwrap();
+ node.kind = NodeKind::Vector { network: outer };
+ node.transform = transform;
+ }
+ let before = normalized_paint_world(&s, vector, 0);
+ let world = s.doc().unwrap().world_transform(vector) * Point::new(10.0, 50.0);
+ let overlay = rect(&mut s, world.x - 5.0, world.y - 5.0, 10.0, 10.0, "#ffffff");
+ s.set_selection(vec![overlay, vector]);
+ ex(&mut s, "shapeBuilder.remove", json!({"points": [[world.x, world.y]]}));
+ let net = network(&s, vector);
+ assert_eq!(net.regions.len(), 1);
+ assert_eq!(net.regions[0].loops.len(), 3, "the original hole and removed face stay separate loops");
+ assert!(!net.region_contains(0, Point::new(50.0, 50.0)), "the hole remains empty");
+ assert!(!net.region_contains(0, Point::new(10.0, 50.0)), "the removed face remains empty");
+ assert!(net.region_contains(0, Point::new(90.0, 50.0)), "the unselected ring remains filled");
+ assert_same_transform(before, normalized_paint_world(&s, vector, 0));
+}
+
+#[test]
+fn removing_the_left_region_keeps_the_right_region_paint_frame() {
+ let mut s = Session::new();
+ let vector = split_vector(&mut s);
+ {
+ let NodeKind::Vector { network } = &mut s.doc_mut().unwrap().get_mut(vector).unwrap().kind else { panic!("expected vector") };
+ network.regions[1].fills = Some(vec![Paint::gradient(Gradient::default_for(GradientKind::Linear, Color::from_hex("#0000ff").unwrap()))]);
+ }
+ let before = normalized_paint_world(&s, vector, 1);
+ s.set_selection(vec![vector]);
+ ex(&mut s, "shapeBuilder.remove", json!({"points": [[25, 50]]}));
+ let node = s.doc().unwrap().node(vector).unwrap();
+ assert!((node.width - 50.0).abs() < 1e-6, "the layer is reboxed to the surviving region: {}", node.width);
+ let net = network(&s, vector);
+ assert_eq!(net.regions.len(), 1);
+ assert!(matches!(&net.regions[0].fills.as_ref().unwrap()[0].kind, figcraft_color::PaintKind::Gradient(_)));
+ assert!((world_region_area(&s, vector, 0) - 5000.0).abs() < 1e-6);
+ assert_same_transform(before, normalized_paint_world(&s, vector, 0));
+}
diff --git a/crates/engine/src/canvas/vectoredit.rs b/crates/engine/src/canvas/vectoredit.rs
index fc263b4f2..963e9dd9b 100644
--- a/crates/engine/src/canvas/vectoredit.rs
+++ b/crates/engine/src/canvas/vectoredit.rs
@@ -1219,17 +1219,31 @@ pub(crate) fn cut_across(s: &mut Session, a: Point, b: Point) -> Result Result {
+ let inherited = doc.get(id)?.fills.clone();
let filled = edit(doc, id, |net| {
let mut want = None;
for p in pts {
- want = net.paint_to(*p, want).or(want);
+ want = net.paint_to_with(*p, want, Some(&inherited)).or(want);
}
want
})?
.ok_or_else(|| bad("vector.edit.paint", "click inside a closed area"))?;
let n = doc.get_mut(id)?;
if filled && n.fills.iter().all(|f| !f.visible) {
- n.fills = vec![figcraft_color::Paint::solid(figcraft_color::Color::DEFAULT_SHAPE)];
+ let default = figcraft_color::Paint::solid(figcraft_color::Color::DEFAULT_SHAPE);
+ let targets: std::collections::BTreeSet = match &n.kind {
+ NodeKind::Vector { network } => pts.iter().filter_map(|point| network.region_at(*point)).collect(),
+ _ => std::collections::BTreeSet::new(),
+ };
+ if let NodeKind::Vector { network } = &mut n.kind {
+ for (index, region) in network.regions.iter_mut().enumerate() {
+ if region.fills.is_none() && !targets.contains(&index) {
+ // Freeze an untouched inherited region before adding a node-wide default.
+ region.fills = Some(Vec::new());
+ }
+ }
+ }
+ n.fills = vec![default];
}
Ok(filled)
}
diff --git a/crates/engine/src/canvas/vectoredit/ops.rs b/crates/engine/src/canvas/vectoredit/ops.rs
index 8832e05dd..b44ee941d 100644
--- a/crates/engine/src/canvas/vectoredit/ops.rs
+++ b/crates/engine/src/canvas/vectoredit/ops.rs
@@ -19,15 +19,21 @@ pub fn move_vertices(net: &mut VectorNetwork, idx: &[usize], d: Vec2) {
if !finite(d) {
return;
}
- let mut seen = std::collections::BTreeSet::new();
- for i in idx {
- if seen.insert(*i)
- && let Some(v) = net.vertices.get_mut(*i)
- {
+ let seen: std::collections::BTreeSet = idx.iter().copied().filter(|i| *i < net.vertices.len()).collect();
+ for i in &seen {
+ if let Some(v) = net.vertices.get_mut(*i) {
v.x += d.x;
v.y += d.y;
}
}
+ if seen.len() == net.vertices.len() {
+ let translate = Affine::translate(d);
+ for region in &mut net.regions {
+ if let Some(frame) = &mut region.paint_space {
+ frame.transform = translate * frame.transform;
+ }
+ }
+ }
}
/// Map vertices `idx` through `m` (scale, rotate, flip…); the handles at them follow its linear
@@ -65,6 +71,13 @@ pub fn transform_vertices(net: &mut VectorNetwork, idx: &[usize], m: Affine) {
}
}
}
+ if set.len() == net.vertices.len() {
+ for region in &mut net.regions {
+ if let Some(frame) = &mut region.paint_space {
+ frame.transform = m * frame.transform;
+ }
+ }
+ }
}
/// Set a handle to `rel` (relative to its vertex). The vertex's other handle follows its mirroring
@@ -586,7 +599,7 @@ pub fn part(net: &VectorNetwork, verts: &[usize]) -> VectorNetwork {
let loops: Vec> =
r.loops.iter().map(|l| l.iter().filter_map(|i| smap.get(*i).copied().flatten()).collect::>()).filter(|l| !l.is_empty()).collect();
if !loops.is_empty() {
- out.regions.push(Region { loops, winding: r.winding });
+ out.regions.push(Region { loops, ..r.clone() });
}
}
reopen_loops(&mut out);
@@ -648,7 +661,7 @@ pub fn connect(net: &mut VectorNetwork, a: usize, b: usize, ta: (f64, f64), tb:
let unfilled = !net.regions.iter().any(|r| r.loops.iter().flatten().any(|s| lp.contains(s)));
if unfilled {
lp.insert(0, si);
- net.regions.push(Region { loops: vec![lp], winding: WindingRule::NonZero });
+ net.regions.push(Region { loops: vec![lp], winding: WindingRule::NonZero, fills: None, ..Default::default() });
}
}
Some(si)
@@ -862,6 +875,14 @@ mod tests {
VectorNetwork::from_points(&[Point::new(0.0, 0.0), Point::new(50.0, 0.0), Point::new(100.0, 0.0)], false)
}
+ #[test]
+ fn split_part_keeps_region_paint_metadata() {
+ let mut n = square();
+ n.regions[0].fills = Some(vec![figcraft_color::Paint::solid(figcraft_color::Color::BLACK)]);
+ let part = part(&n, &[0, 1, 2, 3]);
+ assert_eq!(part.regions[0].fills, n.regions[0].fills);
+ }
+
#[test]
fn handles_follow_mirroring() {
let mut n = open_line();
@@ -899,7 +920,7 @@ mod tests {
// Triangle closed 0→2: its loop [2, 1, 0] walks segment 1 backwards.
let mut n = VectorNetwork::from_points(&[Point::new(0.0, 0.0), Point::new(100.0, 0.0), Point::new(100.0, 100.0)], false);
n.segments.push(Segment::line(0, 2));
- n.regions.push(Region { loops: vec![vec![2, 1, 0]], winding: WindingRule::NonZero });
+ n.regions.push(Region { loops: vec![vec![2, 1, 0]], winding: WindingRule::NonZero, fills: None, ..Default::default() });
let p = Point::new(25.0, 10.0);
assert!(n.fill_contains(p));
for (seg, lp) in [(1, vec![2, 1, 0]), (2, vec![2, 1, 0]), (1, vec![0, 1, 2])] {
diff --git a/crates/engine/src/canvas/vectoredit/regions.rs b/crates/engine/src/canvas/vectoredit/regions.rs
index 111c8e115..91763d3aa 100644
--- a/crates/engine/src/canvas/vectoredit/regions.rs
+++ b/crates/engine/src/canvas/vectoredit/regions.rs
@@ -52,9 +52,8 @@ pub fn hatch_spacing(zoom: f64) -> Option {
(s.is_finite() && s > 0.0).then_some(s)
}
-/// Whether vector `n` shows a fill (only filled areas are regions).
-fn filled(n: &figcraft_doc::Node) -> bool {
- n.fills.iter().any(|f| f.visible)
+fn region_filled(n: &figcraft_doc::Node, net: &VectorNetwork, i: usize) -> bool {
+ net.regions.get(i).is_some_and(|r| r.fills.as_deref().unwrap_or(&n.fills).iter().any(|f| f.visible))
}
/// The network as its fill draws it: rounded points drawn in (region indexes are kept).
@@ -73,7 +72,8 @@ pub fn region_hit(doc: &Document, ve: &VectorEdit, p: Point, zoom: f64) -> Optio
return None;
}
let net = network(doc, ve.id)?;
- if net.regions.is_empty() || !doc.node(ve.id).is_some_and(filled) {
+ let node = doc.node(ve.id)?;
+ if net.regions.is_empty() || !net.regions.iter().enumerate().any(|(i, _)| region_filled(node, net, i)) {
return None;
}
let w = doc.world_transform(ve.id);
@@ -90,7 +90,8 @@ pub fn region_hit(doc: &Document, ve: &VectorEdit, p: Point, zoom: f64) -> Optio
if (0..net.segments.len()).filter_map(|i| net.cubic(i)).any(|c| c.nearest(lp, 0.01).distance_sq.sqrt() <= sc) {
return None;
}
- drawn(net).region_at(lp)
+ let drawn = drawn(net);
+ (0..drawn.regions.len()).rev().find(|i| region_filled(node, &drawn, *i) && drawn.region_contains(*i, lp))
}
/// The regions to hatch: the selected ones and the hovered one (not while a drag is on), each once.
diff --git a/crates/engine/src/canvas/vectoredit/regions_tests.rs b/crates/engine/src/canvas/vectoredit/regions_tests.rs
index f0e5768e5..6787a59f9 100644
--- a/crates/engine/src/canvas/vectoredit/regions_tests.rs
+++ b/crates/engine/src/canvas/vectoredit/regions_tests.rs
@@ -31,7 +31,7 @@ fn nested(regions: Vec) -> VectorNetwork {
}
fn region(loops: Vec>, winding: WindingRule) -> Region {
- Region { loops, winding }
+ Region { loops, winding, fills: None, ..Default::default() }
}
/// The 7.1 region: both squares as one region.
@@ -118,6 +118,17 @@ fn only_filled_areas_are_regions() {
assert!(ve(&s).regions.is_empty());
}
+#[test]
+fn styled_region_hit_ignores_empty_override_and_needs_no_layer_fill() {
+ let mut n = overlapping();
+ n.regions[0].fills = Some(vec![figcraft_color::Paint::solid(figcraft_color::Color::BLACK)]);
+ n.regions[1].fills = Some(Vec::new());
+ let mut s = Session::new();
+ let id = editing_vector(&mut s, n);
+ s.doc_mut().unwrap().get_mut(id).unwrap().fills.clear();
+ assert_eq!(hover(&mut s, 100.0, 100.0, 1.0), Some(0));
+}
+
#[test]
fn hover_and_press_select_a_region_with_all_its_points() {
// 20.
diff --git a/crates/engine/src/cmd/props.rs b/crates/engine/src/cmd/props.rs
index c8aeef30e..30f3761bc 100644
--- a/crates/engine/src/cmd/props.rs
+++ b/crates/engine/src/cmd/props.rs
@@ -387,20 +387,34 @@ fn paint_none(v: &Value) -> bool {
v.is_null() || v.as_str().is_some_and(|s| s.eq_ignore_ascii_case("none"))
}
-/// The order [`apply`] takes keys in: geometry first so rotation applies to the final size, style
-/// links after the values so a link wins over values its style sets, variable bindings last so
-/// paints set in the same call don't detach them.
+/// The order [`apply`] takes keys in: geometry first so rotation applies to the final size, the
+/// auto layout mode before the keys that tune it (an object `layout` replaces the whole auto
+/// layout), style links after the values so a link wins over values its style sets, variable
+/// bindings last so paints set in the same call don't detach them.
fn apply_order(k: &str) -> u8 {
match k {
"path" | "width" | "height" | "w" | "h" => 0,
- "rotation" => 2,
- "x" | "y" => 3,
- "fillStyle" | "strokeStyle" | "effectStyle" | "textStyle" | "gridStyle" => 4,
- "boundVariables" => 5,
- _ => 1,
+ "layout" | "layoutMode" => 1,
+ "rotation" => 3,
+ "x" | "y" => 4,
+ "fillStyle" | "strokeStyle" | "effectStyle" | "textStyle" | "gridStyle" => 5,
+ "boundVariables" => 6,
+ _ => 2,
}
}
+/// Auto layout keys given beside a `layout` (or `layoutMode`) that turns auto layout off: they
+/// would turn it back on (`layout_mut`), so the call is refused rather than one of them losing.
+fn layout_keys_beside_none(cmd: &str, props: &Map) -> Result<()> {
+ let off = ["layout", "layoutMode"]
+ .into_iter()
+ .find(|k| props.get(*k).is_some_and(|v| v.is_null() || v.as_str().is_some_and(|s| s.eq_ignore_ascii_case("none"))));
+ let tuning: Vec = props.keys().filter(|k| LAYOUT_KEYS.contains(&k.as_str())).map(|k| format!("`{k}`")).collect();
+ let Some(off) = off.filter(|_| !tuning.is_empty()) else { return Ok(()) };
+ let (what, are) = if tuning.len() == 1 { ("an auto layout property", "is") } else { ("auto layout properties", "are") };
+ Err(bad(cmd, format!("{} {are} {what}, and `{off}` in the same call turns auto layout off", tuning.join(", "))))
+}
+
/// An explicit width (`is_w`) or height makes hugging or filling on that axis fixed, like typing
/// into W or H.
fn fix_axis(doc: &mut Document, id: NodeId, is_w: bool) -> Result<()> {
@@ -995,6 +1009,7 @@ fn apply_shape(cmd: &str, doc: &mut Document, id: NodeId, k: &str, v: &Value) ->
/// Apply a property map to one node. `x`/`y` are relative to the parent.
pub fn apply(cmd: &str, doc: &mut Document, id: NodeId, props: &Map) -> Result<()> {
+ layout_keys_beside_none(cmd, props)?;
let mut unknown = Vec::new();
let mut keys: Vec<(&String, &Value)> = props.iter().collect();
keys.sort_by_key(|(k, _)| apply_order(k));
diff --git a/crates/engine/src/cmd/structure.rs b/crates/engine/src/cmd/structure.rs
index 05823cabf..5bfb088a4 100644
--- a/crates/engine/src/cmd/structure.rs
+++ b/crates/engine/src/cmd/structure.rs
@@ -2,8 +2,8 @@
use figcraft_color::{Color, Paint};
use figcraft_doc::{
- AutoLayout, BoolOp, Corners, Document, LayoutMode, Node, NodeId, NodeKind, Region, Segment, Sizing, StrokeAlign, StrokeJoin, StrokeProps,
- VectorNetwork, WindingRule, proto,
+ AutoLayout, BoolOp, Corners, Document, LayoutMode, Node, NodeId, NodeKind, Region, RegionPaintSpace, Segment, Sizing, StrokeAlign, StrokeJoin,
+ StrokeProps, VectorNetwork, WindingRule, proto,
};
use figcraft_geom::{Affine, BezPath, Rect, Shape};
use serde_json::{Value, json};
@@ -385,56 +385,35 @@ fn flatten(s: &mut Session, p: &Value) -> Result {
let parent = doc.get(top)?.parent.ok_or_else(|| bad("vector.flatten", "can't flatten a page"))?;
let index = doc.index_in_parent(top).map(|i| i + 1);
let pw = doc.world_transform(parent);
- let mut net = VectorNetwork::default();
- for i in &ids {
- let Some(n) = doc.node(*i) else { continue };
- // Vectors keep their own network (open paths and unfilled loops have no fill outline);
- // booleans flatten to their result; strokes don't get outlined.
- let mut part = match &n.kind {
- NodeKind::Vector { network } => network.clone(),
- _ => {
- let Some((op, rule)) = (match &n.kind {
- NodeKind::Group | NodeKind::Frame | NodeKind::Component { .. } | NodeKind::Instance { .. } => {
- figcraft_render::subtree_outline(doc, n, 0)
- }
- _ => figcraft_render::node_outline(doc, n),
- }) else {
- continue;
- };
- let mut part = VectorNetwork::from_path(&op);
- if rule == figcraft_render::FillRule::EvenOdd {
- for r in &mut part.regions {
- r.winding = WindingRule::EvenOdd;
- }
- }
- part
+ let first = ids.first().and_then(|i| doc.node(*i)).cloned();
+ let fallback_fills = first
+ .as_ref()
+ .filter(|node| matches!(node.kind, NodeKind::Group | NodeKind::Line) && node.fills.is_empty())
+ .map(|_| vec![Paint::solid(Color::DEFAULT_SHAPE)]);
+ let output_fills = first
+ .as_ref()
+ .map(|node| {
+ if matches!(node.kind, NodeKind::Group | NodeKind::Line) && node.fills.is_empty() {
+ fallback_fills.clone().unwrap_or_default()
+ } else {
+ node.fills.clone()
}
- };
- // Into the common parent's space.
- let all: Vec = (0..part.vertices.len()).collect();
- crate::vectoredit::ops::transform_vertices(&mut part, &all, pw.inverse() * doc.world_transform(*i));
- let (nv, ns) = (net.vertices.len(), net.segments.len());
- net.vertices.extend(part.vertices);
- net.segments.extend(part.segments.into_iter().map(|s| Segment { a: s.a + nv, b: s.b + nv, ..s }));
- net.regions.extend(
- part.regions.into_iter().map(|r| Region { loops: r.loops.into_iter().map(|l| l.into_iter().map(|s| s + ns).collect()).collect(), ..r }),
- );
+ })
+ .unwrap_or_default();
+ let mut net = VectorNetwork::default();
+ for id in &ids {
+ collect_flatten_parts(doc, *id, pw, fallback_fills.as_deref(), &output_fills, &mut net, 0)?;
}
let Some(bb) = net.bounds() else {
return Err(EngineError::Disabled("vector.flatten".into(), "nothing to flatten".into()));
};
- let first = ids.first().and_then(|i| doc.node(*i)).cloned();
net.translate(-bb.x0, -bb.y0);
let vid = doc.alloc_id();
let name = first.as_ref().map(|f| f.name.clone()).unwrap_or_else(|| "Vector".into());
let mut v = Node::new(vid, NodeKind::Vector { network: net }, name, bb.width(), bb.height());
v.transform = Affine::translate((bb.x0, bb.y0));
if let Some(f) = first {
- v.fills = if matches!(f.kind, NodeKind::Group | NodeKind::Line) && f.fills.is_empty() {
- vec![Paint::solid(Color::DEFAULT_SHAPE)]
- } else {
- f.fills.clone()
- };
+ v.fills = output_fills;
v.strokes = f.strokes.clone();
v.stroke = StrokeProps { align: StrokeAlign::Center, ..f.stroke.clone() };
v.effects = f.effects.clone();
@@ -449,6 +428,99 @@ fn flatten(s: &mut Session, p: &Value) -> Result {
Ok(json!({"id": vid}))
}
+fn collect_flatten_parts(
+ doc: &Document,
+ id: NodeId,
+ parent_world: Affine,
+ fallback_fills: Option<&[Paint]>,
+ output_fills: &[Paint],
+ net: &mut VectorNetwork,
+ depth: usize,
+) -> Result<()> {
+ if depth > 64 {
+ return Err(bad("vector.flatten", "group nesting exceeds 64"));
+ }
+ let Some(node) = doc.node(id) else { return Ok(()) };
+ if matches!(node.kind, NodeKind::Group | NodeKind::Frame | NodeKind::Component { .. } | NodeKind::Instance { .. }) && !node.children.is_empty() {
+ // Match subtree_outline: a nonempty frame contributes its own visible fill, then
+ // visible descendants in stacking order. Child clipping remains the container's outline rule.
+ if node.kind.is_frame_like()
+ && node.fills.iter().any(|paint| paint.visible)
+ && let Some(path) = figcraft_doc::geometry::shape_path(node)
+ {
+ let frame = VectorNetwork::from_path(&path);
+ let into_parent = parent_world.inverse() * doc.world_transform(id);
+ append_flatten_part(node, fallback_fills, output_fills, into_parent, frame, net);
+ }
+ for child in &node.children {
+ if !doc.node(*child).is_some_and(|child| child.visible) {
+ continue;
+ }
+ collect_flatten_parts(doc, *child, parent_world, fallback_fills, output_fills, net, depth + 1)?;
+ }
+ return Ok(());
+ }
+ let part = match &node.kind {
+ NodeKind::Vector { network } => {
+ let mut part = network.clone();
+ if !part.regions.is_empty() && part.regions.iter().all(|region| region.fills.is_none() && region.paint_space.is_none()) {
+ let winding = if part.regions.iter().any(|region| region.winding == WindingRule::EvenOdd) {
+ WindingRule::EvenOdd
+ } else {
+ WindingRule::NonZero
+ };
+ let loops = std::mem::take(&mut part.regions).into_iter().flat_map(|region| region.loops).collect();
+ part.regions = vec![Region { loops, winding, ..Default::default() }];
+ }
+ part
+ }
+ _ => {
+ let Some((path, rule)) = figcraft_render::node_outline(doc, node) else { return Ok(()) };
+ let mut part = VectorNetwork::from_path(&path);
+ for region in &mut part.regions {
+ if rule == figcraft_render::FillRule::EvenOdd {
+ region.winding = WindingRule::EvenOdd;
+ }
+ }
+ part
+ }
+ };
+ let into_parent = parent_world.inverse() * doc.world_transform(id);
+ append_flatten_part(node, fallback_fills, output_fills, into_parent, part, net);
+ Ok(())
+}
+
+fn append_flatten_part(
+ node: &Node,
+ fallback_fills: Option<&[Paint]>,
+ output_fills: &[Paint],
+ into_parent: Affine,
+ mut part: VectorNetwork,
+ net: &mut VectorNetwork,
+) {
+ for region in &mut part.regions {
+ let (width, height, paint_transform) = region.paint_frame(node.width, node.height);
+ if region.fills.is_none() {
+ let effective = if node.fills.is_empty() { fallback_fills.unwrap_or(&node.fills) } else { &node.fills };
+ if effective != output_fills {
+ region.fills = Some(effective.to_vec());
+ }
+ }
+ region.paint_space = Some(RegionPaintSpace { width, height, transform: paint_transform });
+ }
+ let vertices: Vec = (0..part.vertices.len()).collect();
+ crate::vectoredit::ops::transform_vertices(&mut part, &vertices, into_parent);
+ let (nv, ns) = (net.vertices.len(), net.segments.len());
+ net.vertices.extend(part.vertices);
+ net.segments.extend(part.segments.into_iter().map(|segment| Segment { a: segment.a + nv, b: segment.b + nv, ..segment }));
+ net.regions.extend(
+ part.regions.into_iter().map(|region| Region {
+ loops: region.loops.into_iter().map(|lp| lp.into_iter().map(|segment| segment + ns).collect()).collect(),
+ ..region
+ }),
+ );
+}
+
fn outline_stroke(s: &mut Session, p: &Value) -> Result {
let ids = req_targets("vector.outlineStroke", s, p)?;
let mut out = Vec::new();
@@ -489,7 +561,8 @@ fn outline_stroke(s: &mut Session, p: &Value) -> Result {
v.fills = n.strokes.clone();
v.strokes.clear();
v.styles.fill = n.styles.stroke;
- if n.fills.iter().any(|f| f.visible) && closed {
+ let region_fill = matches!(&n.kind, NodeKind::Vector { network } if network.regions.iter().any(|r| r.fills.as_ref().is_some_and(|fills| fills.iter().any(|f| f.visible))));
+ if (n.fills.iter().any(|f| f.visible) || region_fill) && closed {
// Keep the fill as its own layer under the outline, grouped.
doc.insert(v, parent, idx.map(|x| x + 1))?;
let fill_id = i;
@@ -634,12 +707,53 @@ fn simplify(s: &mut Session, p: &Value) -> Result {
let doc = s.doc_mut()?;
let n = doc.get_mut(i)?;
if let NodeKind::Vector { network } = &mut n.kind {
- let fill = network.fill_path();
- let src = if fill.elements().is_empty() { network.stroke_path() } else { fill };
- let simplified = figcraft_geom::kurbo::simplify::simplify_bezpath(src.iter(), tol, &Default::default());
- let regions = !network.regions.is_empty();
- let mut net = VectorNetwork::from_path(&simplified);
- if !regions {
+ let source = network.clone();
+ let mut net = VectorNetwork::default();
+ let legacy = source.regions.iter().all(|region| region.fills.is_none() && region.paint_space.is_none());
+ if legacy && !source.regions.is_empty() {
+ let winding = if source.regions.iter().any(|region| region.winding == WindingRule::EvenOdd) {
+ WindingRule::EvenOdd
+ } else {
+ WindingRule::NonZero
+ };
+ let path = source.fill_path();
+ let path = figcraft_geom::kurbo::simplify::simplify_bezpath(path.iter(), tol, &Default::default());
+ let (path, output_winding) = match figcraft_pathops::simplify_with_rule(&path, winding == WindingRule::EvenOdd) {
+ Ok(path) => (path, WindingRule::EvenOdd),
+ Err(_) => (path, winding),
+ };
+ let combined = Region { winding: output_winding, ..Default::default() };
+ append_region_path(&mut net, &path, &combined, output_winding);
+ } else {
+ for (index, region) in source.regions.iter().enumerate() {
+ let Some(path) = source.region_path(index) else { continue };
+ let path = figcraft_geom::kurbo::simplify::simplify_bezpath(path.iter(), tol, &Default::default());
+ let path = figcraft_pathops::simplify_with_rule(&path, region.winding == WindingRule::EvenOdd).unwrap_or(path);
+ append_region_path(&mut net, &path, region, region.winding);
+ }
+ }
+ if !source.regions.is_empty() {
+ let mut used = vec![false; source.segments.len()];
+ for segment in source.regions.iter().flat_map(|region| region.loops.iter().flatten()) {
+ if let Some(used) = used.get_mut(*segment) {
+ *used = true;
+ }
+ }
+ let mut open = source.clone();
+ open.segments = source.segments.iter().enumerate().filter_map(|(index, segment)| (!used[index]).then_some(*segment)).collect();
+ open.regions.clear();
+ if !open.segments.is_empty() {
+ let path = open.stroke_path();
+ let simplified = figcraft_geom::kurbo::simplify::simplify_bezpath(path.iter(), tol, &Default::default());
+ let mut loose = VectorNetwork::from_path(&simplified);
+ loose.regions.clear();
+ append_network(&mut net, loose);
+ }
+ }
+ if source.regions.is_empty() {
+ let path = source.stroke_path();
+ let simplified = figcraft_geom::kurbo::simplify::simplify_bezpath(path.iter(), tol, &Default::default());
+ net = VectorNetwork::from_path(&simplified);
net.regions.clear();
}
*network = net;
@@ -657,22 +771,99 @@ fn offset_path(s: &mut Session, p: &Value) -> Result {
if !n.children.is_empty() {
continue;
}
- let Some((path, _)) = figcraft_render::node_outline(doc, &n) else { continue };
- let props = StrokeProps { weight: d.abs() * 2.0, join: StrokeJoin::Round, align: StrokeAlign::Center, ..Default::default() };
- let ring = figcraft_pathops::stroke_center(&path, d.abs() * 2.0, &props);
- let res = if d >= 0.0 {
- figcraft_pathops::boolean(&[path, ring], BoolOp::Union)
- } else {
- figcraft_pathops::boolean(&[path, ring], BoolOp::Subtract)
- };
- let Ok(mut res) = res else { continue };
- let bb = res.bounding_box();
- res.apply_affine(Affine::translate((-bb.x0, -bb.y0)));
- let nm = doc.get_mut(i)?;
- let mut net = VectorNetwork::from_path(&res);
- for r in &mut net.regions {
- r.winding = WindingRule::EvenOdd;
+ if d == 0.0 {
+ continue;
}
+ let props = StrokeProps { weight: d.abs() * 2.0, join: StrokeJoin::Round, align: StrokeAlign::Center, ..Default::default() };
+ let NodeKind::Vector { network: source } = &n.kind else {
+ let Some((path, _)) = figcraft_render::node_outline(doc, &n) else { continue };
+ let ring = figcraft_pathops::stroke_center(&path, d.abs() * 2.0, &props);
+ let result = if d >= 0.0 {
+ figcraft_pathops::boolean(&[path, ring], BoolOp::Union)
+ } else {
+ figcraft_pathops::boolean(&[path, ring], BoolOp::Subtract)
+ }
+ .map_err(|error| bad("vector.offset", error.to_string()))?;
+ let mut result = result;
+ let bounds = result.bounding_box();
+ result.apply_affine(Affine::translate((-bounds.x0, -bounds.y0)));
+ let node = doc.get_mut(i)?;
+ node.kind = NodeKind::Vector { network: VectorNetwork::from_path(&result) };
+ node.transform *= Affine::translate((bounds.x0, bounds.y0));
+ node.width = bounds.width();
+ node.height = bounds.height();
+ continue;
+ };
+ let mut net = VectorNetwork::default();
+ let legacy = source.regions.iter().all(|region| region.fills.is_none() && region.paint_space.is_none());
+ if source.regions.is_empty() {
+ let Some((path, _)) = figcraft_render::node_outline(doc, &n) else { continue };
+ let ring = figcraft_pathops::stroke_center(&path, d.abs() * 2.0, &props);
+ let mut res = if d >= 0.0 {
+ figcraft_pathops::boolean(&[path.clone(), ring], BoolOp::Union)
+ } else {
+ figcraft_pathops::boolean(&[path.clone(), ring], BoolOp::Subtract)
+ }
+ .map_err(|error| bad("vector.offset", error.to_string()))?;
+ let bb = res.bounding_box();
+ res.apply_affine(Affine::translate((-bb.x0, -bb.y0)));
+ let nm = doc.get_mut(i)?;
+ nm.kind = NodeKind::Vector { network: VectorNetwork::from_path(&res) };
+ nm.transform *= Affine::translate((bb.x0, bb.y0));
+ nm.width = bb.width();
+ nm.height = bb.height();
+ continue;
+ }
+ if legacy {
+ let path = source.fill_path();
+ let even_odd = source.regions.iter().any(|region| region.winding == WindingRule::EvenOdd);
+ let path = figcraft_pathops::simplify_with_rule(&path, even_odd).map_err(|error| bad("vector.offset", error.to_string()))?;
+ let ring = figcraft_pathops::stroke_center(&path, d.abs() * 2.0, &props);
+ let result = if d >= 0.0 {
+ figcraft_pathops::boolean(&[path.clone(), ring], BoolOp::Union)
+ } else {
+ figcraft_pathops::boolean(&[path, ring], BoolOp::Subtract)
+ }
+ .map_err(|error| bad("vector.offset", error.to_string()))?;
+ let mut part = VectorNetwork::from_path(&result);
+ let (paint_width, paint_height, paint_transform) = source.regions[0].paint_frame(n.width, n.height);
+ for region in &mut part.regions {
+ region.winding = WindingRule::EvenOdd;
+ region.fills = source.regions[0].fills.clone();
+ region.paint_space = Some(RegionPaintSpace { width: paint_width, height: paint_height, transform: paint_transform });
+ }
+ append_network(&mut net, part);
+ } else {
+ for (index, region) in source.regions.iter().enumerate() {
+ let Some(path) = source.region_path(index) else { continue };
+ let mut paint_region = region.clone();
+ let (paint_width, paint_height, paint_transform) = region.paint_frame(n.width, n.height);
+ paint_region.paint_space = Some(RegionPaintSpace { width: paint_width, height: paint_height, transform: paint_transform });
+ let path = figcraft_pathops::simplify_with_rule(&path, region.winding == WindingRule::EvenOdd)
+ .map_err(|error| bad("vector.offset", error.to_string()))?;
+ let ring = figcraft_pathops::stroke_center(&path, d.abs() * 2.0, &props);
+ let res = if d >= 0.0 {
+ figcraft_pathops::boolean(&[path.clone(), ring], BoolOp::Union)
+ } else {
+ figcraft_pathops::boolean(&[path.clone(), ring], BoolOp::Subtract)
+ }
+ .map_err(|error| bad("vector.offset", error.to_string()))?;
+ // A collapsed negative offset removes this region, while other source regions stay.
+ if res.elements().is_empty() {
+ continue;
+ }
+ append_region_path(&mut net, &res, &paint_region, WindingRule::EvenOdd);
+ }
+ }
+ let Some(bb) = net.bounds() else {
+ let nm = doc.get_mut(i)?;
+ nm.kind = NodeKind::Vector { network: net };
+ nm.width = 0.0;
+ nm.height = 0.0;
+ continue;
+ };
+ net.translate(-bb.x0, -bb.y0);
+ let nm = doc.get_mut(i)?;
nm.kind = NodeKind::Vector { network: net };
nm.transform *= Affine::translate((bb.x0, bb.y0));
nm.width = bb.width();
@@ -681,6 +872,25 @@ fn offset_path(s: &mut Session, p: &Value) -> Result {
ok()
}
+fn append_region_path(net: &mut VectorNetwork, path: &BezPath, source: &Region, winding: WindingRule) {
+ let mut part = VectorNetwork::from_path(path);
+ for region in &mut part.regions {
+ region.winding = winding;
+ region.fills = source.fills.clone();
+ region.paint_space = source.paint_space;
+ }
+ append_network(net, part);
+}
+
+fn append_network(net: &mut VectorNetwork, part: VectorNetwork) {
+ let (nv, ns) = (net.vertices.len(), net.segments.len());
+ net.vertices.extend(part.vertices);
+ net.segments.extend(part.segments.into_iter().map(|s| Segment { a: s.a + nv, b: s.b + nv, ..s }));
+ net.regions.extend(
+ part.regions.into_iter().map(|r| Region { loops: r.loops.into_iter().map(|l| l.into_iter().map(|s| s + ns).collect()).collect(), ..r }),
+ );
+}
+
/// Join open vectors whose end points touch into one vector.
fn join(s: &mut Session, p: &Value) -> Result {
let ids = req_targets("vector.join", s, p)?;
@@ -695,7 +905,7 @@ fn join(s: &mut Session, p: &Value) -> Result {
let last = network.vertices.len() - 1;
network.segments.push(Segment::line(last, 0));
let all: Vec = (0..network.segments.len()).collect();
- network.regions.push(Region { loops: vec![all], winding: WindingRule::NonZero });
+ network.regions.push(Region { loops: vec![all], winding: WindingRule::NonZero, fills: None, paint_space: None });
}
}
return ok();
diff --git a/crates/engine/src/component_codegen_tests.rs b/crates/engine/src/component_codegen_tests.rs
index 22bb53c4d..2c3706f8f 100644
--- a/crates/engine/src/component_codegen_tests.rs
+++ b/crates/engine/src/component_codegen_tests.rs
@@ -106,6 +106,17 @@ fn components_take_their_properties_as_parameters() {
] {
assert!(leptos.contains(want), "Leptos has {want:?}:\n{leptos}");
}
+ let dart = code(&mut s, c, "flutter");
+ for want in [
+ "import 'package:flutter/material.dart';\n\nclass Card extends StatelessWidget {\n",
+ " const Card({\n super.key,\n this.showDot = true,\n this.title = \"Hello\",\n });\n",
+ " final bool showDot;\n final String title;\n",
+ " Widget build(BuildContext context) {\n return Container(",
+ "if (showDot)\n",
+ "Text(\n title,",
+ ] {
+ assert!(dart.contains(want), "Flutter has {want:?}:\n{dart}");
+ }
// The command describes the properties.
let v = component_code(&mut s, c, "react");
assert_eq!(v["name"], "Card");
@@ -140,6 +151,7 @@ fn instances_are_calls_passing_the_values_they_set() {
assert!(react.contains("export function Card({"));
assert_eq!(component_code(&mut s, i, "swiftui")["usage"], "Card(showDot: false, title: \"Buy\")\n");
assert_eq!(component_code(&mut s, i, "compose")["usage"], "Card(showDot = false, title = \"Buy\")\n");
+ assert_eq!(component_code(&mut s, i, "flutter")["usage"], "Card(showDot: false, title: \"Buy\")\n");
assert_eq!(component_code(&mut s, i, "arkui")["usage"], "Card({ showDot: false, title: \"Buy\" })\n");
assert_eq!(component_code(&mut s, i, "leptos")["usage"], "\n");
// Text that needs escaping is an expression in JSX and escaped per language.
@@ -147,6 +159,7 @@ fn instances_are_calls_passing_the_values_they_set() {
assert_eq!(component_code(&mut s, i, "react")["usage"], "\n");
assert_eq!(component_code(&mut s, i, "swiftui")["usage"], "Card(showDot: false, title: \"Say \\\"hi\\\" $5\\nnow\")\n");
assert_eq!(component_code(&mut s, i, "compose")["usage"], "Card(showDot = false, title = \"Say \\\"hi\\\" \\$5\\nnow\")\n");
+ assert_eq!(component_code(&mut s, i, "flutter")["usage"], "Card(showDot: false, title: \"Say \\\"hi\\\" \\$5\\nnow\")\n");
assert_eq!(component_code(&mut s, i, "arkui")["usage"], "Card({ showDot: false, title: \"Say \\\"hi\\\" $5\\nnow\" })\n");
assert_eq!(component_code(&mut s, i, "leptos")["usage"], "\n");
// A quoted title becomes a binding whose attribute value is escaped: the expression itself
@@ -175,6 +188,8 @@ fn nested_instances_in_a_layer_tree_are_calls() {
assert!(!react.contains("Hello"), "the instance's layers aren't spelled out: {react}");
assert!(code(&mut s, screen, "swiftui").contains("Card(title: \"Inbox\")"));
assert!(code(&mut s, screen, "compose").contains("Card(title = \"Inbox\")"));
+ let dart = code(&mut s, screen, "flutter");
+ assert!(dart.contains("Card(title: \"Inbox\")") && !dart.contains("Hello"), "{dart}");
let leptos = code(&mut s, screen, "leptos");
assert!(leptos.contains("pub fn HomeScreen() -> impl IntoView {") && leptos.contains(""), "{leptos}");
assert!(!leptos.contains("Hello"), "{leptos}");
@@ -232,6 +247,19 @@ fn variant_sets_share_one_definition() {
assert_eq!(component_code(&mut s, i, "leptos")["usage"], "\n");
}
+#[test]
+fn flutter_variant_sets_share_one_enum() {
+ let mut s = Session::new();
+ let (set, _, _) = button_set(&mut s);
+ let dart = code(&mut s, set, "flutter");
+ assert!(dart.contains("enum ButtonState { defaultValue, hover }\n"), "{dart}");
+ assert!(dart.contains(" this.state = ButtonState.defaultValue,\n"), "{dart}");
+ assert!(dart.contains(" final ButtonState state;\n"), "{dart}");
+ let i = idv(&ex(&mut s, "instance.create", json!({"component": set, "x": 0, "y": 600})));
+ ex(&mut s, "instance.setProperties", json!({"ids": [i], "properties": {"State": "Hover"}}));
+ assert_eq!(component_code(&mut s, i, "flutter")["usage"], "Button(state: ButtonState.hover)\n");
+}
+
#[test]
fn instance_swap_properties_take_views() {
let mut s = Session::new();
@@ -252,6 +280,10 @@ fn instance_swap_properties_take_views() {
assert!(swift.contains(" var icon: AnyView = AnyView(HeartIcon())"), "{swift}");
let kt = code(&mut s, chip, "compose");
assert!(kt.contains(" icon: @Composable () -> Unit = { HeartIcon() },") && kt.contains("icon()"), "{kt}");
+ let dart = code(&mut s, chip, "flutter");
+ assert!(dart.contains(" final Widget icon;\n") && dart.contains(" this.icon = const HeartIcon(),\n"), "{dart}");
+ assert!(dart.contains("child: icon\n"), "the nested instance is the parameter: {dart}");
+ assert!(!dart.contains("child: Container("), "the swap's widget tree isn't spelled out: {dart}");
let ark = code(&mut s, chip, "arkui");
for want in [
" @BuilderParam icon: () => void = this.defaultIconBuilder",
@@ -272,6 +304,7 @@ fn instance_swap_properties_take_views() {
assert_eq!(component_code(&mut s, i, "react")["usage"], "} />\n");
assert_eq!(component_code(&mut s, i, "swiftui")["usage"], "Chip(icon: AnyView(StarIcon()))\n");
assert_eq!(component_code(&mut s, i, "compose")["usage"], "Chip(icon = { StarIcon() })\n");
+ assert_eq!(component_code(&mut s, i, "flutter")["usage"], "Chip(icon: StarIcon())\n");
assert_eq!(component_code(&mut s, i, "arkui")["usage"], "Chip({ icon: () => { StarIcon() } })\n");
assert_eq!(component_code(&mut s, i, "leptos")["usage"], " }} />\n");
assert_eq!(component_code(&mut s, i, "vue")["usage"], "\n");
@@ -289,6 +322,9 @@ fn slots_take_optional_content() {
assert!(react.contains(" content?: React.ReactNode;") && react.contains("{content ?? ("), "{react}");
assert!(code(&mut s, panel, "swiftui").contains("var content: AnyView? = nil"));
assert!(code(&mut s, panel, "compose").contains("content: (@Composable () -> Unit)? = null,"));
+ let dart = code(&mut s, panel, "flutter");
+ assert!(dart.contains(" final List? content;\n") && dart.contains(" this.content,\n"), "{dart}");
+ assert!(dart.contains("...?content,\n") && dart.contains("if (content == null) ...[\n"), "{dart}");
let ark = code(&mut s, panel, "arkui");
for want in [
" @BuilderParam content: () => void = this.defaultContentBuilder",
@@ -305,11 +341,14 @@ fn slots_take_optional_content() {
let badge = idv(&ex(&mut s, "component.create", json!({"ids": [g]})));
let i = idv(&ex(&mut s, "instance.create", json!({"component": panel, "x": 400, "y": 0})));
assert_eq!(component_code(&mut s, i, "react")["usage"], "\n", "the main's content isn't passed");
+ assert_eq!(component_code(&mut s, i, "flutter")["usage"], "Panel()\n", "the main's content isn't passed");
ex(&mut s, "instance.slotInsert", json!({"id": i, "slot": "Content", "component": badge, "replace": true}));
let usage = component_code(&mut s, i, "react")["usage"].as_str().unwrap_or_default().to_string();
assert!(usage.starts_with(""), "{usage}");
let usage = component_code(&mut s, i, "leptos")["usage"].as_str().unwrap_or_default().to_string();
assert!(usage.starts_with("") && usage.ends_with("}} />\n"), "{usage}");
+ let usage = component_code(&mut s, i, "flutter")["usage"].as_str().unwrap_or_default().to_string();
+ assert!(usage.starts_with("Panel(content: [\n") && usage.contains("Badge()"), "{usage}");
let usage = component_code(&mut s, i, "arkui")["usage"].as_str().unwrap_or_default().to_string();
assert!(usage.starts_with("Panel({ content: () => {\n") && usage.contains("Badge()") && usage.ends_with("} })\n"), "{usage}");
}
@@ -359,10 +398,22 @@ fn identifiers_are_escaped_and_unique() {
] {
assert!(leptos.contains(want), "Leptos has {want:?}:\n{leptos}");
}
+ // Dart: lowerCamel members, keywords suffixed, never a type the widget tree names.
+ let dart = code_for(doc, c, CodeLang::Flutter);
+ for want in [
+ "class TextComponent extends StatelessWidget {",
+ "enum TextComponentColorVariant { red }\n",
+ " this.defaultValue = false,",
+ " this.p2Columns = false,",
+ " final String type;",
+ ] {
+ assert!(dart.contains(want), "Flutter has {want:?}:\n{dart}");
+ }
// Errors.
let r = idv(&ex(&mut s, "shape.rectangle", json!({"x": 0, "y": 0, "width": 10, "height": 10})));
assert!(s.execute("inspect.componentCode", &json!({"id": r})).is_err(), "not a component");
assert!(s.execute("inspect.componentCode", &json!({"id": c, "language": "html"})).is_err());
+ assert!(s.execute("inspect.componentCode", &json!({"id": c, "language": "flutter"})).is_ok());
assert!(s.execute("inspect.componentCode", &json!({"id": 999_999})).is_err());
}
diff --git a/crates/engine/src/dev/code_connect.rs b/crates/engine/src/dev/code_connect.rs
index 8524c25a9..33bb594b0 100644
--- a/crates/engine/src/dev/code_connect.rs
+++ b/crates/engine/src/dev/code_connect.rs
@@ -37,8 +37,9 @@ const MAX_WARNINGS: usize = 50;
/// The languages a component can be connected in.
pub fn language(s: &str) -> Option {
- CodeLang::from_id(s)
- .filter(|l| matches!(l, CodeLang::React | CodeLang::Leptos | CodeLang::Vue | CodeLang::SwiftUi | CodeLang::Compose | CodeLang::ArkUi))
+ CodeLang::from_id(s).filter(|l| {
+ matches!(l, CodeLang::React | CodeLang::Leptos | CodeLang::Vue | CodeLang::SwiftUi | CodeLang::Compose | CodeLang::ArkUi | CodeLang::Flutter)
+ })
}
/// The layer `id`'s links are stored on: its component set, else its component (an instance's
@@ -69,6 +70,7 @@ pub fn import_line(link: &CodeLink, lang: CodeLang) -> String {
CodeLang::Leptos => format!("use {path}::{};", link.name.split('.').next().unwrap_or_default()),
// A single-file component is a default export.
CodeLang::Vue => format!("import {} from {};", link.name.split('.').next().unwrap_or_default(), js_string(path)),
+ CodeLang::Flutter => format!("import '{}';", path.replace(['\'', '\\'], "")),
_ => format!("import {path}"),
}
}
@@ -273,6 +275,7 @@ fn expr(doc: &Document, c: &Component, p: &Param, v: &Value, lang: CodeLang) ->
(Some(l), CodeLang::Vue) => l.name.clone(),
(Some(l), CodeLang::SwiftUi) => format!("{}()", l.name),
(Some(l), CodeLang::ArkUi) => format!("() => {{ {}() }}", l.name),
+ (Some(l), CodeLang::Flutter) => format!("{}()", l.name),
(Some(l), _) => format!("{{ {}() }}", l.name),
(None, _) => value_expr(doc, c, p, v, lang),
}
@@ -456,7 +459,7 @@ fn default_template(c: &Component, lang: CodeLang, name: &str) -> String {
format!("<{name}{} />", params.iter().map(|p| format!(" {}={{{p}}}", crate::component_codegen::rust_ident(p))).collect::())
}
CodeLang::Vue => format!("<{name}{} />", params.iter().map(|p| format!(" :{p}={{{p}}}")).collect::()),
- CodeLang::SwiftUi => format!("{name}({})", params.iter().map(|p| format!("{p}: {{{p}}}")).collect::>().join(", ")),
+ CodeLang::SwiftUi | CodeLang::Flutter => format!("{name}({})", params.iter().map(|p| format!("{p}: {{{p}}}")).collect::>().join(", ")),
CodeLang::ArkUi => format!("{name}({{ {} }})", params.iter().map(|p| format!("{p}: {{{p}}}")).collect::>().join(", ")),
_ => format!("{name}({})", params.iter().map(|p| format!("{p} = {{{p}}}")).collect::>().join(", ")),
}
@@ -478,6 +481,7 @@ pub fn check(c: &Component, link: &CodeLink, lang: CodeLang) -> std::result::Res
CodeLang::React | CodeLang::ArkUi => !path.chars().any(|ch| ch.is_control() || ch == '"' || ch == '\\'),
CodeLang::Leptos => path.chars().all(|ch| ch.is_ascii_alphanumeric() || ch == '_' || ch == ':'),
CodeLang::Vue => path.chars().all(|ch| ch.is_ascii_alphanumeric() || "_./@-".contains(ch)),
+ CodeLang::Flutter => path.chars().all(|ch| ch.is_ascii_alphanumeric() || "_./:-".contains(ch)),
_ => path.chars().all(|ch| ch.is_ascii_alphanumeric() || ch == '_' || ch == '.'),
};
if path.chars().count() > MAX_IMPORT || !path_ok {
@@ -486,6 +490,9 @@ pub fn check(c: &Component, link: &CodeLink, lang: CodeLang) -> std::result::Res
CodeLang::ArkUi => format!("importPath: a module path without quotes, up to {MAX_IMPORT} characters"),
CodeLang::Leptos => format!("importPath: a Rust module path (letters, digits, _ and ::), up to {MAX_IMPORT} characters"),
CodeLang::Vue => format!("importPath: a module path (letters, digits, _ . / @ and -), up to {MAX_IMPORT} characters"),
+ CodeLang::Flutter => {
+ format!("importPath: a Dart import (package:acme/card.dart; letters, digits, _ . / : and -), up to {MAX_IMPORT} characters")
+ }
_ => format!("importPath: a module or package name (letters, digits, _ and .), up to {MAX_IMPORT} characters"),
});
}
@@ -521,7 +528,7 @@ fn text(cmd: &str, p: &Value, key: &str) -> Result