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"], "