diff --git a/Cargo.lock b/Cargo.lock index 8901fd6cc..122fac456 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1653,6 +1653,8 @@ dependencies = [ "fmv-macos-events", "image", "log", + "objc2-app-kit 0.3.2", + "objc2-foundation 0.3.2", "rfd", "serde_json", "wat", 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 9604db662..75c807ffe 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 (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 (images placed at their own size, 2x screenshots in points (#1360); 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 @@ -352,11 +352,13 @@ 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 | Placed images keep their own size (M7, #1360): dropping, Place image, paste and `image.place` put an image at its pixel size and fit only a side past 4096 px (they fitted it into 1000 px); a PNG whose `pHYs` records a whole 2x–4x multiple of 72 dpi (macOS screenshots record 144 dpi) is placed at its point size, so an 800 × 600 px capture comes in at 400 × 300; other densities, such as 300 dpi scans, keep their pixel size; the image data stays at full resolution and an explicit width or height still wins. On macOS, paste reads the clipboard's PNG as is (its density and colour profile kept) and falls back to the decoded image. 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 | @@ -433,6 +435,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/apps/figcraft-cli/src/files.rs b/apps/figcraft-cli/src/files.rs index 1ba9d60a9..8e689ec04 100644 --- a/apps/figcraft-cli/src/files.rs +++ b/apps/figcraft-cli/src/files.rs @@ -366,6 +366,7 @@ pub fn parse_script(text: &str) -> Result, String> { .enumerate() .map(|(i, s)| { let cmd = s.get("command").and_then(Value::as_str).ok_or_else(|| format!("step {i}: `command` (a string) is required"))?; + figcraft_engine::batch::refuse_unknown_step_keys(i, &s).map_err(|e| e.to_string())?; let params = match s.get("params") { None | Some(Value::Null) => json!({}), Some(p @ Value::Object(_)) => p.clone(), @@ -443,6 +444,9 @@ mod tests { assert!(parse_script("[{\"command\":\"a\",\"params\":3}]").is_err()); assert!(parse_script("not json\n").is_err()); assert!(parse_script("42").is_err()); + // `args` for `params` would run the step without them (#1413). + let e = parse_script(r#"[{"command":"a"},{"command":"b","args":{"x":1}}]"#).unwrap_err().to_string(); + assert!(e.contains("step 1: unknown key `args`"), "{e}"); } #[test] diff --git a/apps/figcraft/Cargo.toml b/apps/figcraft/Cargo.toml index 2b5a3bd14..093c431d7 100644 --- a/apps/figcraft/Cargo.toml +++ b/apps/figcraft/Cargo.toml @@ -48,6 +48,9 @@ zbus = { version = "5", default-features = false, features = ["async-io", "block # unsafe, which the workspace forbids). Pinned exactly: review the diff before bumping. [target.'cfg(target_os = "macos")'.dependencies] fmv-macos-events = "=0.1.0" +# Paste reads the clipboard's public.png as is, keeping the screen density a screenshot records. +objc2-app-kit = { version = "0.3", default-features = false, features = ["std", "NSPasteboard"] } +objc2-foundation = { version = "0.3", default-features = false, features = ["std", "NSData", "NSString"] } # A macOS app menu whose Quit (⌘Q) asks the app first (sends the root window a close request, the # same guarded path as the close button); winit's default Quit ends the process through # `terminate:`, losing unsaved changes without a word (issue #857). diff --git a/apps/figcraft/src/services.rs b/apps/figcraft/src/services.rs index b83a2664e..ca8810673 100644 --- a/apps/figcraft/src/services.rs +++ b/apps/figcraft/src/services.rs @@ -327,6 +327,9 @@ fn picked(pick: impl Future> + 'static) -> Dial Box::pin(async move { pick.await.map(|f| f.path().to_path_buf()) }) } +#[cfg(target_os = "macos")] +const MACOS_PASTEBOARD_PNG: &str = "public.png"; + /// How long a clipboard read may wait on the app that copied (Linux and the BSDs). #[cfg(all(unix, not(target_os = "macos")))] const CLIPBOARD_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(2); @@ -438,6 +441,12 @@ impl Services for Desktop { } Some((w, h, img.bytes.into_owned())) } + #[cfg(target_os = "macos")] + fn clipboard_get_png(&mut self) -> Option> { + let board = objc2_app_kit::NSPasteboard::generalPasteboard(); + let png = board.dataForType(&objc2_foundation::NSString::from_str(MACOS_PASTEBOARD_PNG))?; + (png.len() <= figcraft_ui_egui::MAX_CLIPBOARD_PNG_BYTES).then(|| png.to_vec()) + } fn open_url(&mut self, url: &str) { let _ = webbrowser::open(url); } diff --git a/contributors/people.toml b/contributors/people.toml index 89366cb62..14c2ad436 100644 --- a/contributors/people.toml +++ b/contributors/people.toml @@ -92,3 +92,6 @@ sync_github_name = true [people.culiulemon] sync_github_name = true + +[people.StrajnarFilip] +display_name = "Filip Strajnar" 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/automation/batch.rs b/crates/engine/src/automation/batch.rs index 5c4550ecb..aa11a2bb1 100644 --- a/crates/engine/src/automation/batch.rs +++ b/crates/engine/src/automation/batch.rs @@ -39,6 +39,18 @@ pub fn step_name(step: usize, json: &Value) -> Result> { } } +/// Refuse a step key other than `command`, `params` and `as` (`step` is its index, for the +/// error): `args` in place of `params` would run the command without them. +pub fn refuse_unknown_step_keys(step: usize, json: &Value) -> Result<()> { + let Some(o) = json.as_object() else { return Ok(()) }; + let unknown: Vec = o.keys().filter(|k| !matches!(k.as_str(), "command" | "params" | "as")).map(|k| format!("`{k}`")).collect(); + if unknown.is_empty() { + return Ok(()); + } + let s = if unknown.len() == 1 { "" } else { "s" }; + Err(bad("command.batch", format!("step {step}: unknown key{s} {} (a step takes command, params and as)", unknown.join(", ")))) +} + /// The steps of one batch run so far: their results, and which step each `as` name belongs to. /// A nested `command.batch` step gets its own scope, so its references are to its own steps. #[derive(Clone, Debug, Default)] 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/canvas/vectoredit/width.rs b/crates/engine/src/canvas/vectoredit/width.rs index 13c7be0c3..0e565cc92 100644 --- a/crates/engine/src/canvas/vectoredit/width.rs +++ b/crates/engine/src/canvas/vectoredit/width.rs @@ -244,7 +244,7 @@ pub(crate) fn specs() -> Vec { vec![spec!( "vector.edit.widthPoint", "Width point", - "{action?: add|set|remove, index?, t? (0-1 along the path), width? (px) | scale? (× stroke weight)} — add needs t (width defaults to the current one); set moves or resizes point `index`; with `side` only that side of the path changes (looking along it): `width` is then the distance from the centre line to that edge and `scale` that side's scale (× stroke weight, as if both sides were that wide)", + "{action?: add|set|remove, index?, t? (0-1 along the path), width? (px) | scale? (× stroke weight), side? (left|right)} — add needs t (width defaults to the current one); set moves or resizes point `index`; with `side` only that side of the path changes (looking along it): `width` is then the distance from the centre line to that edge and `scale` that side's scale (× stroke weight, as if both sides were that wide)", true, width_point )] diff --git a/crates/engine/src/cmd.rs b/crates/engine/src/cmd.rs index a247ed523..c046cd19f 100644 --- a/crates/engine/src/cmd.rs +++ b/crates/engine/src/cmd.rs @@ -63,6 +63,8 @@ macro_rules! spec { struct Registry { list: Vec, index: HashMap<&'static str, usize>, + /// The parameter names each command's doc lists (`list` order; see [`doc_param_names`]). + names: Vec>>, } fn registry() -> &'static Registry { @@ -123,7 +125,8 @@ fn registry() -> &'static Registry { log::warn!("duplicate command id {}", s.id); } } - Registry { list, index } + let names = list.iter().map(|s| doc_param_names(s.params)).collect(); + Registry { list, index, names } }) } @@ -136,6 +139,113 @@ pub fn find_command(id: &str) -> Option<&'static CommandSpec> { r.index.get(id).and_then(|i| r.list.get(*i)) } +/// The parameter names command `id` takes, from its doc; `None` when the doc isn't a `{…}` list +/// (`node.set` documents its props, and refuses the ones it doesn't know itself). +pub fn param_names(id: &str) -> Option<&'static [&'static str]> { + let r = registry(); + r.index.get(id).and_then(|i| r.names.get(*i)).and_then(|n| n.as_deref()) +} + +/// Refuse params naming something the command doesn't take: a misspelt name would otherwise be +/// ignored and the command answer as if it hadn't been given. `ids` and `id` are always taken: +/// the layers a command acts on (`params::targets` reads either). +pub fn refuse_unknown_params(id: &str, params: &Value) -> Result<()> { + let (Some(names), Some(given)) = (param_names(id), params.as_object()) else { return Ok(()) }; + let taken = |k: &str| matches!(k, "ids" | "id") || names.contains(&k); + let unknown: Vec = given.keys().filter(|k| !taken(k)).map(|k| format!("`{k}`")).collect(); + if unknown.is_empty() { + return Ok(()); + } + let s = if unknown.len() == 1 { "" } else { "s" }; + let takes = if names.is_empty() { "none".to_string() } else { names.join(", ") }; + Err(crate::bad(id, format!("unknown parameter{s} {} (it takes {takes})", unknown.join(", ")))) +} + +/// The top-level items of a `{…}` params doc (`{ids?, direction? (horizontal|vertical)}` → +/// `ids?`, ` direction? (horizontal|vertical)`), with those of the alternatives after it +/// (`{x, y} | {points}`); `None` when the doc doesn't start with one. +pub(crate) fn doc_items(doc: &'static str) -> Option> { + let mut body = doc.trim_start().strip_prefix('{')?; + let (mut items, mut depth, mut start) = (Vec::new(), 0usize, 0); + loop { + let mut rest = None; + for (i, c) in body.char_indices() { + match c { + '{' | '[' | '(' => depth += 1, + '}' | ']' | ')' if depth > 0 => depth -= 1, + '}' | ',' if depth == 0 => { + items.push(body.get(start..i).unwrap_or_default()); + start = i + c.len_utf8(); + if c == '}' { + rest = body.get(start..); + break; + } + } + _ => {} + } + } + let rest = rest?; + match rest.trim_start().strip_prefix('|').and_then(|r| r.trim_start().strip_prefix('{')) { + Some(next) => (body, start) = (next, 0), + None => return Some(items), + } + } +} + +/// The names one doc item gives: its first word and the words joined to it by `|`, `/` or `+` +/// (`id?/ids?`, `path | data (base64) + name`, `value? + newValue?`), without the `?` of optional +/// ones. Bracketed text describes a name, and what follows a `:`, `=` or `→` its value. +pub(crate) fn item_names(item: &'static str) -> Vec<&'static str> { + let mut outside = Vec::new(); + let (mut depth, mut start) = (0usize, None); + for (i, c) in item.char_indices() { + let value_follows = depth == 0 && matches!(c, ':' | '=' | '→'); + let in_word = depth == 0 && !value_follows && !c.is_whitespace() && !matches!(c, '(' | '[' | '{' | ')' | ']' | '}'); + match c { + '(' | '[' | '{' => depth += 1, + ')' | ']' | '}' => depth = depth.saturating_sub(1), + _ => {} + } + match (in_word, start) { + (true, None) => start = Some(i), + (false, Some(s)) => { + outside.push(item.get(s..i).unwrap_or_default()); + start = None; + } + _ => {} + } + if value_follows { + break; + } + } + if let Some(s) = start { + outside.push(item.get(s..).unwrap_or_default()); + } + let is_name = + |n: &&str| n.chars().next().is_some_and(|c| c.is_ascii_alphabetic() || c == '_') && n.chars().all(|c| c.is_ascii_alphanumeric() || c == '_'); + let mut words = outside.into_iter(); + let (mut names, mut word) = (Vec::new(), words.next()); + while let Some(w) = word { + names.extend(w.split(['/', '|']).map(|n| n.trim_end_matches('?')).filter(is_name)); + word = match words.next() { + Some("|" | "/" | "+") => words.next(), + _ => None, + }; + } + names +} + +/// Every name a `{…}` params doc lists, once each (see [`item_names`]). +fn doc_param_names(doc: &'static str) -> Option> { + let mut names = Vec::new(); + for n in doc_items(doc)?.into_iter().flat_map(item_names) { + if !names.contains(&n) { + names.push(n); + } + } + Some(names) +} + /// `{"ok": true}` shorthand. pub fn ok() -> Result { Ok(serde_json::json!({"ok": true})) diff --git a/crates/engine/src/cmd/comment.rs b/crates/engine/src/cmd/comment.rs index 3fde2b095..baac9ab3d 100644 --- a/crates/engine/src/cmd/comment.rs +++ b/crates/engine/src/cmd/comment.rs @@ -576,7 +576,7 @@ pub fn specs() -> Vec { Ok(json!({"id": id, "link": link(id)})) } ), - spec!("comment.reply", "Reply to comment", "{id, text, author?}", true, |s, p| { + spec!("comment.reply", "Reply to comment", "{id | link, text, author?}", true, |s, p| { let id = req_comment("comment.reply", s, p)?; let who = author(s, p); let r = Message::new(who.clone(), text("comment.reply", p)?, now_secs()); @@ -586,7 +586,7 @@ pub fn specs() -> Vec { c.touched_by(&who); Ok(json!({"reply": c.replies.len() - 1})) }), - spec!("comment.edit", "Edit comment", "{id, text, reply? (index; omit for the first message), author?}", true, |s, p| { + spec!("comment.edit", "Edit comment", "{id | link, text, reply? (index; omit for the first message), author?}", true, |s, p| { let id = req_comment("comment.edit", s, p)?; let t = text("comment.edit", p)?; let who = author(s, p); @@ -607,7 +607,7 @@ pub fn specs() -> Vec { spec!( "comment.react", "React to comment", - "{id, emoji, reply? (index; omit for the first message), on? (default: toggle), author?}", + "{id | link, emoji, reply? (index; omit for the first message), on? (default: toggle), author?}", true, |s, p| { let id = req_comment("comment.react", s, p)?; @@ -629,7 +629,7 @@ pub fn specs() -> Vec { Ok(json!({"on": on})) } ), - spec!("comment.resolve", "Resolve comment", "{id, resolved? (default true)}", true, |s, p| { + spec!("comment.resolve", "Resolve comment", "{id | link, resolved? (default true)}", true, |s, p| { let id = req_comment("comment.resolve", s, p)?; let v = boolean(p, "resolved").unwrap_or(true); if let Some(c) = s.doc_mut()?.comment_mut(id) { @@ -640,7 +640,7 @@ pub fn specs() -> Vec { spec!( "comment.move", "Move comment", - "{id, x, y, anchor? (default true: pin to the innermost frame under the point; false: to the page; or a node id)}", + "{id | link, x, y, anchor? (default true: pin to the innermost frame under the point; false: to the page; or a node id)}", true, |s, p| { let id = req_comment("comment.move", s, p)?; @@ -655,24 +655,30 @@ pub fn specs() -> Vec { ok() } ), - spec!("comment.delete", "Delete comment", "{id, reply? (index: delete one reply; omit to delete the thread), author?}", true, |s, p| { - let id = req_comment("comment.delete", s, p)?; - let who = author(s, p); - let doc = s.doc_mut()?; - let c = doc.comment_mut(id).ok_or_else(|| bad("comment.delete", "no comment"))?; - match message_index("comment.delete", c, p)? { - 0 => { - own("comment.delete", &c.first, &who, "delete this thread")?; - doc.comments.retain(|c| c.id != id); - } - i => { - let m = c.message(i).ok_or_else(|| bad("comment.delete", "no message"))?; - own("comment.delete", m, &who, "delete this reply")?; - c.replies.remove(i - 1); + spec!( + "comment.delete", + "Delete comment", + "{id | link, reply? (index: delete one reply; omit to delete the thread), author?}", + true, + |s, p| { + let id = req_comment("comment.delete", s, p)?; + let who = author(s, p); + let doc = s.doc_mut()?; + let c = doc.comment_mut(id).ok_or_else(|| bad("comment.delete", "no comment"))?; + match message_index("comment.delete", c, p)? { + 0 => { + own("comment.delete", &c.first, &who, "delete this thread")?; + doc.comments.retain(|c| c.id != id); + } + i => { + let m = c.message(i).ok_or_else(|| bad("comment.delete", "no message"))?; + own("comment.delete", m, &who, "delete this reply")?; + c.replies.remove(i - 1); + } } + ok() } - ok() - }), + ), spec!( "comment.markRead", "Mark comment as read", @@ -711,7 +717,7 @@ pub fn specs() -> Vec { spec!( "comment.next", "Next comment", - "{id? (the thread to step from; omitted: the first or last), dir? (1 next, -1 previous; default 1), page?, filter?, sort?, query? (the list, as comment.list), author?}", + "{id? (the thread to step from; omitted: the first or last), dir? (1 next, -1 previous; default 1), page?, filter?, sort?, query?, frame?, resolved? (the list, as comment.list), author?}", false, |s, p| { let dir = num(p, "dir").unwrap_or(1.0); @@ -748,7 +754,7 @@ pub fn specs() -> Vec { } Ok(json!({"zoom": v.zoom})) }), - spec!("comment.link", "Copy link to comment", "{id}", false, |s, p| { + spec!("comment.link", "Copy link to comment", "{id | link}", false, |s, p| { let id = req_comment("comment.link", s, p)?; Ok(json!({"link": link(id)})) }), diff --git a/crates/engine/src/cmd/create.rs b/crates/engine/src/cmd/create.rs index 2e64ce042..c03d2f826 100644 --- a/crates/engine/src/cmd/create.rs +++ b/crates/engine/src/cmd/create.rs @@ -9,6 +9,8 @@ use super::{CommandSpec, ok}; use crate::params::{boolean, f64_of, get, id, num, num_or, opt_id, req_targets, string}; use crate::{Result, Session, bad, spec}; +pub const PLACED_IMAGE_MAX_SIDE: f64 = 4096.0; + /// Frame presets shown by the Frame tool (category, name, width, height). pub const FRAME_PRESETS: &[(&str, &str, f64, f64)] = &[ ("Phone", "iPhone 16", 393.0, 852.0), @@ -53,37 +55,61 @@ pub fn specs() -> Vec { spec!( "node.create", "Create layer", - "{type: rectangle|ellipse|polygon|star|line|arrow|vector|text|frame|section|component|slice, x?, y? (relative to the parent frame; groups don't count), width?, height?, parent? (id; default: the page), index?, name?, text? (for text), path? (SVG path data for vector), props? {node.set keys}, select? (default true)}", + "{type: rectangle|ellipse|polygon|star|line|arrow|vector|text|frame|section|component|slice, x?, y? (relative to the parent frame; groups don't count), width?, height?, parent? (id; default: the page), index?, name?, text? | characters? (for text), path? | points? (vector: SVG path data | [[x,y],..]), closed? (vector), props? {node.set keys}, select? (default true)}", true, create ), spec!( "frame.create", "Create frame", - "{preset? (name from FRAME_PRESETS), x?, y?, width?, height?, parent?, name?, props?} (a preset without x/y lands to the right of the page's frames)", + "{preset? (name from FRAME_PRESETS), x?, y?, width?, height?, parent?, index?, name?, props?, select? (default true)} (a preset without x/y lands to the right of the page's frames)", true, frame_create ), - spec!("shape.rectangle", "Rectangle", "{x, y, width, height, parent?, props?}", true, |s, p| create_kind(s, p, "rectangle")), - spec!("shape.ellipse", "Ellipse", "{x, y, width, height, parent?, props?}", true, |s, p| create_kind(s, p, "ellipse")), - spec!("shape.polygon", "Polygon", "{x, y, width, height, parent?, props? {pointCount}}", true, |s, p| create_kind(s, p, "polygon")), - spec!("shape.star", "Star", "{x, y, width, height, parent?, props? {pointCount, innerRadius}}", true, |s, p| create_kind(s, p, "star")), - spec!("shape.line", "Line", "{x, y, width (length), parent?, props? {rotation}}", true, |s, p| create_kind(s, p, "line")), - spec!("shape.arrow", "Arrow", "{x, y, width (length), parent?, props?}", true, |s, p| create_kind(s, p, "arrow")), - spec!("text.create", "Text", "{text, x, y, parent?, width? (fixed width wraps), props? (fontSize, fontFamily, …)}", true, |s, p| { - create_kind(s, p, "text") + spec!("shape.rectangle", "Rectangle", "{x, y, width, height, parent?, index?, name?, props?, select? (default true)}", true, |s, p| { + create_kind(s, p, "rectangle") }), + spec!("shape.ellipse", "Ellipse", "{x, y, width, height, parent?, index?, name?, props?, select? (default true)}", true, |s, p| create_kind( + s, p, "ellipse" + )), + spec!( + "shape.polygon", + "Polygon", + "{x, y, width, height, parent?, index?, name?, props? {pointCount}, select? (default true)}", + true, + |s, p| create_kind(s, p, "polygon") + ), + spec!( + "shape.star", + "Star", + "{x, y, width, height, parent?, index?, name?, props? {pointCount, innerRadius}, select? (default true)}", + true, + |s, p| create_kind(s, p, "star") + ), + spec!("shape.line", "Line", "{x, y, width (length), parent?, index?, name?, props? {rotation}, select? (default true)}", true, |s, p| { + create_kind(s, p, "line") + }), + spec!("shape.arrow", "Arrow", "{x, y, width (length), parent?, index?, name?, props?, select? (default true)}", true, |s, p| create_kind( + s, p, "arrow" + )), + spec!( + "text.create", + "Text", + "{text | characters, x, y, parent?, index?, name? (default: the first line), width? (fixed width wraps), props? (fontSize, fontFamily, …), select? (default true)}", + true, + |s, p| { create_kind(s, p, "text") } + ), spec!( "vector.create", "Vector", - "{path (SVG path data) | points [[x,y],..], closed?, x?, y?, width?, height? (scale the path; one keeps its proportion), parent?, props?}", + "{path (SVG path data) | points [[x,y],..], closed?, x?, y?, width?, height? (scale the path; one keeps its proportion), parent?, index?, name?, props?, select? (default true)}", true, |s, p| create_kind(s, p, "vector") ), spec!( "image.place", "Place image", - "{data (base64) | path, x?, y?, width?, height?, parent?, target? (an existing layer: the image is added on top of its fills instead of making a layer), mode? (fill|fit|crop|tile)} → {id, image, width, height, imageProfile (its embedded colour profile; null: taken as sRGB)}", + "{data (base64) | path, x?, y?, width?, height?, parent?, name? (default Image), target? (an existing layer: the image is added on top of its fills instead of making a layer), mode? (fill|fit|crop|tile)} → {id, image, width, height, imageProfile (its embedded colour profile; null: taken as sRGB)}", true, place_image ), @@ -443,13 +469,12 @@ pub fn place_image(s: &mut Session, p: &Value) -> Result { let parent = crate::params::opt_id(cmd, p, "parent")?.unwrap_or(page); let ds = s.ds_mut()?; let doc = ds.doc_mut(); + let density = figcraft_format::pixel_density(&bytes); let (hash, iw, ih) = figcraft_format::add_image(doc, bytes).map_err(|e| bad(cmd, e.to_string()))?; let profile = figcraft_render::images::profile_name(&hash, doc.images.get(&hash)); - let (iw, ih) = (f64::from(iw), f64::from(ih)); - // Fit large images into 1000 px like Figma does when placing. - let k = (1000.0 / iw.max(ih)).min(1.0); - let w = num(p, "width").unwrap_or(iw * k); - let h = num(p, "height").unwrap_or(if num(p, "width").is_some() { w * ih / iw.max(1.0) } else { ih * k }); + let (placed_w, placed_h) = figcraft_format::placed_size(iw, ih, density, PLACED_IMAGE_MAX_SIDE); + let w = num(p, "width").unwrap_or(placed_w); + let h = num(p, "height").unwrap_or(if num(p, "width").is_some() { w * f64::from(ih) / f64::from(iw).max(1.0) } else { placed_h }); let nid = doc.alloc_id(); let name = string(p, "name").unwrap_or_else(|| "Image".into()); let mut node = Node::new(nid, NodeKind::Rectangle, name, w, h); diff --git a/crates/engine/src/cmd/edit.rs b/crates/engine/src/cmd/edit.rs index d24600c97..9679e19be 100644 --- a/crates/engine/src/cmd/edit.rs +++ b/crates/engine/src/cmd/edit.rs @@ -8,8 +8,8 @@ use serde_json::{Map, Value, json}; use super::{CommandSpec, ok}; use crate::params::{ - blend, boolean, color, fraction_of, get, id, id_of, num, num_or, opt_index, paint_of, parse, req_id, req_index, req_num, req_string, req_targets, - string, targets, + blend, boolean, color, fraction_of, get, id, id_of, num, num_or, opt_id, opt_index, paint_of, parse, req_id, req_index, req_num, req_string, + req_targets, string, targets, }; use crate::{BatchStep, EngineError, Result, Session, bad, spec}; @@ -29,6 +29,80 @@ use select::{ select_parent, select_same, select_sibling, toggle, }; +/// The layer types `node.find` takes, as `NodeKind::type_name` spells them. +const LAYER_TYPES: [&str; 16] = [ + "PAGE", + "FRAME", + "GROUP", + "SECTION", + "RECTANGLE", + "ELLIPSE", + "POLYGON", + "STAR", + "LINE", + "VECTOR", + "TEXT", + "BOOLEAN_OPERATION", + "COMPONENT", + "COMPONENT_SET", + "INSTANCE", + "SLICE", +]; + +/// Lowercase for `node.find`'s substring filters. Greek final sigma becomes σ on both sides, so a +/// query lowercased on its own (`Σ` to `σ`) still matches a name ending in it (`ΟΣ` to `ος`). +fn fold(s: &str) -> String { + s.to_lowercase().replace('ς', "σ") +} + +fn node_find(s: &mut Session, p: &Value) -> Result { + let cmd = "node.find"; + let filter = |key| -> Result> { + get(p, key).map(|v| v.as_str().map(fold).ok_or_else(|| bad(cmd, format!("`{key}` must be a string")))).transpose() + }; + let name = filter("name")?; + let text = filter("text")?; + let component = filter("component")?; + let kind = filter("type")?; + if let Some(kind) = &kind + && !LAYER_TYPES.iter().any(|t| t.eq_ignore_ascii_case(kind)) + { + return Err(bad(cmd, format!("unknown type; use one of {}", LAYER_TYPES.join(", ")))); + } + let limit = match get(p, "limit") { + None => 100, + Some(v) => v.as_u64().filter(|n| *n <= 10000).ok_or_else(|| bad(cmd, "`limit` must be an integer from 0 to 10000"))? as usize, + }; + let root = opt_id(cmd, p, "root")?.map_or_else(|| s.page(), Ok)?; + let doc = s.doc()?; + // Keep one child iterator per ancestor, not a materialised subtree or all pending siblings. + let mut stack = vec![doc.get(root)?.children.iter()]; + let mut ids = Vec::new(); + let mut total = 0usize; + while let Some(children) = stack.last_mut() { + let Some(&id) = children.next() else { + stack.pop(); + continue; + }; + let n = doc.get(id)?; + stack.push(n.children.iter()); + if name.as_ref().is_some_and(|q| !fold(&n.name).contains(q)) + || kind.as_ref().is_some_and(|q| !n.kind.type_name().eq_ignore_ascii_case(q)) + || text.as_ref().is_some_and(|q| !matches!(&n.kind, NodeKind::Text(t) if fold(&t.characters).contains(q))) + || component.as_ref().is_some_and( + |q| !matches!(&n.kind, NodeKind::Instance { component, .. } if doc.node(*component).is_some_and(|c| fold(&c.name).contains(q))), + ) + { + continue; + } + total += 1; + if ids.len() < limit { + ids.push(id); + } + } + Ok(json!({"ids": ids, "total": total})) +} + pub fn specs() -> Vec { vec![ spec!("edit.undo", "Undo", "{}", false, |s, _| Ok(json!({"undone": s.undo()?}))), @@ -235,6 +309,13 @@ pub fn specs() -> Vec { let i = req_id("node.get", p, "id")?; Ok(crate::inspect::node_json(s.doc()?, i, 1)) }), + spec!( + "node.find", + "Find layers", + "{root? (strict descendants; default current page), name?, type? (canonical layer type), text?, component? (main component name), limit? (0-10000; default 100)}: every filter must match; name, text and component are case-insensitive substrings, type is exact (any case); returns {ids, total} and does not select", + false, + node_find + ), spec!( "node.reparent", "Move layer into", @@ -248,8 +329,8 @@ pub fn specs() -> Vec { s.doc_mut()?.reorder(i, idx)?; ok() }), - spec!("fill.add", "Add fill", "{ids?, paint? (default #D9D9D9 or white for frames)}", true, |s, p| paint_list(s, p, true, true)), - spec!("stroke.add", "Add stroke", "{ids?, paint? (default black)}", true, |s, p| paint_list(s, p, false, true)), + spec!("fill.add", "Add fill", "{ids?, paint? | color? (default #D9D9D9 or white for frames)}", true, |s, p| paint_list(s, p, true, true)), + spec!("stroke.add", "Add stroke", "{ids?, paint? | color? (default black)}", true, |s, p| paint_list(s, p, false, true)), spec!("fill.remove", "Remove fill", "{ids?, index}", true, |s, p| paint_list(s, p, true, false)), spec!("stroke.remove", "Remove stroke", "{ids?, index}", true, |s, p| paint_list(s, p, false, false)), spec!("fill.set", "Set fill", "{ids?, index?, paint | color, opacity?, visible?, blend?}", true, |s, p| paint_set(s, p, true)), @@ -273,7 +354,7 @@ pub fn specs() -> Vec { spec!( "fill.pattern", "Pattern fill", - "{source (layer id), ids?, index? (default the top fill), tile? (grid|horizontalHex|verticalHex), scale? (1 = the source's size), spacing? [x, y], align? (start|center|end), opacity?} the source layer tiled; it updates as the source changes", + "{source (layer id), ids?, index? (default the top fill), tile? (grid|horizontalHex|verticalHex), scale? (1 = the source's size), spacing? [x, y], align? (start|center|end), opacity?, visible?, blend?} the source layer tiled; it updates as the source changes", true, pattern_fill ), @@ -354,7 +435,7 @@ pub fn specs() -> Vec { spec!( "export.update", "Edit export setting", - "{ids?, index, size? (2x|512w|512h|{by, value}), suffix?, format? (png|jpg|svg|pdf|webp|xml: Android Vector Drawable), bleed? (PDF: points, or with a unit: \"3mm\", \"0.125in\", \"9pt\"), cropMarks?, bleedMarks?, cmyk? (PDF print options), vdCompat? (XML: bestEffort|exact|ignore)}", + "{ids?, index, size? (2x|512w|512h|{by, value}), suffix?, format? (png|jpg|svg|pdf|webp|xml: Android Vector Drawable), bleed? (PDF: points, or with a unit: \"3mm\", \"0.125in\", \"9pt\"), cropMarks?, bleedMarks?, cmyk? (PDF print options), vdCompat? (XML: bestEffort|exact|ignore), contentsOnly?, includeBoundingBox?, svgOutlineText?, svgIdAttribute?, svgSimplifyStroke?}", true, |s, p| { let cmd = "export.update"; @@ -387,6 +468,7 @@ pub fn specs() -> Vec { for (i, c) in a.iter().enumerate() { let cmd = c.get("command").and_then(Value::as_str).ok_or_else(|| bad("command.batch", format!("step {i} has no `command`")))?; + crate::batch::refuse_unknown_step_keys(i, c)?; let params = c.get("params").cloned().unwrap_or(json!({})); out.push(BatchStep { name: crate::batch::step_name(i, c)?, ..BatchStep::new(cmd, params) }); } diff --git a/crates/engine/src/cmd/file.rs b/crates/engine/src/cmd/file.rs index 576452c5b..173507a60 100644 --- a/crates/engine/src/cmd/file.rs +++ b/crates/engine/src/cmd/file.rs @@ -36,7 +36,7 @@ pub(crate) use load::{load_fig, read_capped, recovered, variables_param, write}; pub use open::MAX_RECENT; #[cfg(test)] use open::append_pages; -use open::{import, open, open_recent}; +use open::{import, open, open_recent, revert}; pub(crate) use save::save_ends_preview; pub use save::{FileSnapshot, file_bytes, thumbnail}; use save::{color_profile, mark_saved, save, save_as}; @@ -75,7 +75,7 @@ pub fn specs() -> Vec { spec!( "file.open", "Open", - "{path | data (base64) + name (tells the format; opens as a new unsaved document), variables? (REST: the local variables JSON as an object, JSON text or a path; default `.variables.json` or `variables.json` next to the file)} (.figcraft, .json: FigCraft or REST API file JSON, .fig (best effort), .sketch (Sketch 48 or newer, best effort), .svg, .png, .jpg …); REST, .fig and .sketch imports return `warnings`; a file opened anew returns `summary` {name, pages, layers, images} (one that was already open just returns its `index`)", + "{path | data (base64) + name (tells the format; opens as a new unsaved document), variables? (REST: the local variables JSON as an object, JSON text or a path; default `.variables.json` or `variables.json` next to the file), reload? (a file already open: read it again into its tab, which must have no unsaved changes)} (.figcraft, .json: FigCraft or REST API file JSON, .fig (best effort), .sketch (Sketch 48 or newer, best effort), .svg, .png, .jpg …); REST, .fig and .sketch imports return `warnings`; a file opened anew returns `summary` {name, pages, layers, images} (one that was already open returns its `index`, with `stale: true` when the file changed on disk since the tab read or saved it, or `reloaded: true` after `reload`)", false, open ), @@ -110,7 +110,7 @@ pub fn specs() -> Vec { spec!( "file.export", "Export", - "{ids? (default selection, else the page), format? (png|jpg|svg|pdf|webp|xml (an Android Vector Drawable, with `warnings` for what it changed or left out), or fig: the whole file as .fig, experimental, with `warnings` for what was left out; other apps may not open every file), scale? (default 1) | size? (2x|512w|512h or {by: scale|width|height, value}: a scale, or a width or height in pixels), path? (else returns base64 `data`), bleed? (PDF: points, or with a unit: \"3mm\", \"0.125in\", \"9pt\"), cropMarks?, bleedMarks?, cmyk? (PDF print options), svgIdAttribute? (SVG ids, XML android:name), vdCompat? (XML: bestEffort (default: approximate with a warning)|exact (leave out what can't be exact, with a warning)|ignore (no warnings))}", + "{ids? (default selection, else the page), format? (png|jpg|svg|pdf|webp|xml (an Android Vector Drawable, with `warnings` for what it changed or left out), or fig: the whole file as .fig, experimental, with `warnings` for what was left out; other apps may not open every file), scale? (default 1) | size? (2x|512w|512h or {by: scale|width|height, value}: a scale, or a width or height in pixels), path? (else returns base64 `data`), bleed? (PDF: points, or with a unit: \"3mm\", \"0.125in\", \"9pt\"), cropMarks?, bleedMarks?, cmyk? (PDF print options), contentsOnly? (default true; false also draws the layers that show in their area), includeBoundingBox? (true: only the layers' own boxes, cutting off shadows and outside strokes), svgOutlineText? (default true), svgSimplifyStroke? (default true), svgIdAttribute? (SVG ids, XML android:name), vdCompat? (XML: bestEffort (default: approximate with a warning)|exact (leave out what can't be exact, with a warning)|ignore (no warnings))}", false, export ), @@ -121,13 +121,19 @@ pub fn specs() -> Vec { false, export_layers ), - spec!("file.exportSvg", "Export selection as SVG", "{ids?, path?}", false, |s, p| { - let mut q = p.clone(); - if let Some(o) = q.as_object_mut() { - o.insert("format".into(), json!("svg")); + spec!( + "file.exportSvg", + "Export selection as SVG", + "{ids?, path?, scale? | size?, contentsOnly?, includeBoundingBox?, svgOutlineText?, svgIdAttribute?, svgSimplifyStroke? (file.export's SVG options)}", + false, + |s, p| { + let mut q = p.clone(); + if let Some(o) = q.as_object_mut() { + o.insert("format".into(), json!("svg")); + } + export(s, &q) } - export(s, &q) - }), + ), spec!( "file.exportFramesPdf", "Export frames to PDF", @@ -142,7 +148,13 @@ pub fn specs() -> Vec { true, import ), - spec!("file.placeImage", "Place image", "{path | data, x?, y?}", true, super::create::place_image), + spec!( + "file.placeImage", + "Place image", + "{path | data, x?, y?, width?, height?, parent?, name?, target?, mode? (as image.place)}", + true, + super::create::place_image + ), spec!("file.close", "Close tab", "{index?, force? (discard unsaved changes)}", false, close), spec!("file.moveTab", "Move tab", "{from, to} (indices among open documents)", false, |s, p| { let from = req_index("file.moveTab", p, "from")?; @@ -191,21 +203,7 @@ pub fn specs() -> Vec { Ok(json!({"index": s.active})) }), spec!("file.revert", "Revert to saved", "{} leave the live session first, if any", false, |s, _| { - let path = s.ds()?.path.clone().ok_or_else(|| EngineError::Disabled("file.revert".into(), "the file was never saved".into()))?; - // Stamped before reading: a change made during the read makes the next save refuse. - let on_disk = crate::library::stat(&path).ok(); - let (loaded, _) = load_path(&path, None)?; - let i = s.active; - let uid = s.ds()?.uid; - if let Some(slot) = s.docs.get_mut(i) { - *slot = DocState::new(loaded.doc, Some(path.clone())) - .with_versions(loaded.versions) - .with_branches(loaded.branches) - .with_images_left_in_file(&path, loaded.images_past_budget); - slot.on_disk = on_disk; - } - // Edits on its branch tabs are reverted too. - crate::branches::close_tabs(s, uid, None); + revert(s)?; ok() }), spec!( diff --git a/crates/engine/src/cmd/file/load.rs b/crates/engine/src/cmd/file/load.rs index 8c15783ce..54542eecd 100644 --- a/crates/engine/src/cmd/file/load.rs +++ b/crates/engine/src/cmd/file/load.rs @@ -165,7 +165,23 @@ fn reflow_overridden_text(doc: &mut Document) { } for id in changed { if let Some((w, h)) = doc.node(id).and_then(figcraft_text::auto_size) { + // Sketch grows auto-width text from its aligned edge: a centred one stays centred, a + // right-aligned one keeps its right edge. + let shift = doc.node(id).map_or(0.0, |n| match &n.kind { + NodeKind::Text(t) if t.auto_resize == figcraft_doc::TextAutoResize::WidthAndHeight => match t.align_h { + figcraft_doc::TextAlignH::Center => (n.width - w) / 2.0, + figcraft_doc::TextAlignH::Right => n.width - w, + _ => 0.0, + }, + _ => 0.0, + }); figcraft_layout::set_size(doc, id, w, h); + if shift.is_finite() + && shift != 0.0 + && let Some(n) = doc.node_mut(id) + { + n.transform *= figcraft_doc::geom::Affine::translate((shift, 0.0)); + } } figcraft_layout::layout_up(doc, id, &m); } diff --git a/crates/engine/src/cmd/file/open.rs b/crates/engine/src/cmd/file/open.rs index 6b724ced5..169d71fd0 100644 --- a/crates/engine/src/cmd/file/open.rs +++ b/crates/engine/src/cmd/file/open.rs @@ -62,11 +62,26 @@ pub(super) fn open(s: &mut Session, p: &Value) -> Result { return open_data(s, &d, p, variables.as_ref()); } let path = req_string("file.open", p, "path")?; - // Already open: switch to it. + // Already open: switch to it, and say when the file changed on disk since this tab read or + // saved it (`stale`), or read it again (`reload`). if let Some(i) = s.docs.iter().position(|d| d.path.as_deref() == Some(path.as_str())) { s.active = i; remember(s, &path); - return Ok(json!({"index": i})); + let stale = s.ds()?.on_disk.as_ref().is_some_and(|read| crate::library::stat(&path).ok().as_ref() != Some(read)); + if !boolean(p, "reload").unwrap_or(false) { + return Ok(if stale { json!({"index": i, "stale": true}) } else { json!({"index": i}) }); + } + if s.ds()?.is_dirty() { + return Err(EngineError::Disabled( + "file.open".into(), + "the open copy has unsaved changes: save them, or Revert to saved to drop them".into(), + )); + } + if s.live().doc_uid.is_some() { + return Err(EngineError::Disabled("file.open".into(), "leave the live session before reading the file again".into())); + } + revert(s)?; + return Ok(json!({"index": i, "reloaded": true})); } // Stamped before reading: a change made during the read makes the next save refuse. let on_disk = crate::library::stat(&path).ok(); @@ -95,6 +110,26 @@ pub(super) fn open(s: &mut Session, p: &Value) -> Result { Ok(out) } +/// Replace the active tab's document with its file as saved (`file.revert`, `file.open {reload}`). +pub(super) fn revert(s: &mut Session) -> Result<()> { + let path = s.ds()?.path.clone().ok_or_else(|| EngineError::Disabled("file.revert".into(), "the file was never saved".into()))?; + // Stamped before reading: a change made during the read makes the next save refuse. + let on_disk = crate::library::stat(&path).ok(); + let (loaded, _) = load_path(&path, None)?; + let i = s.active; + let uid = s.ds()?.uid; + if let Some(slot) = s.docs.get_mut(i) { + *slot = DocState::new(loaded.doc, Some(path.clone())) + .with_versions(loaded.versions) + .with_branches(loaded.branches) + .with_images_left_in_file(&path, loaded.images_past_budget); + slot.on_disk = on_disk; + } + // Edits on its branch tabs are reverted too. + crate::branches::close_tabs(s, uid, None); + Ok(()) +} + /// [`open`] for a file given as base64 `data` (a file dropped without a path on disk): `name` tells /// the format. It opens as a new document with no path, so the first save asks where to put it. fn open_data(s: &mut Session, data: &str, p: &Value, variables: Option<&Value>) -> Result { 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/proto.rs b/crates/engine/src/cmd/proto.rs index d0822db9e..3f51bb5e5 100644 --- a/crates/engine/src/cmd/proto.rs +++ b/crates/engine/src/cmd/proto.rs @@ -16,7 +16,7 @@ pub fn specs() -> Vec { spec!( "proto.interaction.add", "Add interaction", - "{id?, trigger? {type: onClick|onHover|onPress|onDrag|mouseEnter|mouseLeave|mouseDown|mouseUp|afterDelay {ms}|keyPress {key (a key or a combination: Shift+K, Ctrl+Alt+Delete; or a controller button: Gamepad A|B|X|Y|LB|RB|LT|RT|View|Menu|LS|RS|Up|Down|Left|Right|Home)}}, action {type: navigate {preserveScroll?}|back|scrollTo {offset? [x, y] (where the layer ends up from the top-left)}|openOverlay {position?, anchor? screen|triggerInside|triggerOutside (default screen; the trigger ones measure the position preset on the layer that opens the overlay: outside puts top presets above it and bottom presets below it, edges lined up), offset? [x, y] (position manual: the overlay's top-left from the top-left of the layer that opens it; without it, right below that layer), close_on_outside? (default true), background?}|swapOverlay|closeOverlay|openUrl {url}|changeTo|setVariable {variable, value | expression (text, e.g. \"count + 1\")}|setVariableMode {collection, mode}|conditional {blocks [{condition? | expression? (text, e.g. \"count >= 3\"), actions}]}, dest?}, actions? [..], transition? {type: instant|dissolve|smartAnimate|moveIn|moveOut|push|slideIn|slideOut, direction?: left|right|top|bottom}, durationMs?, easing? linear|easeIn|easeOut|easeInOut|easeInBack|easeOutBack|easeInOutBack|gentle|quick|bouncy|slow|{customBezier {x1, y1, x2, y2}}|{customSpring {mass, stiffness, damping}}}, delayMs? (mouse enter, leave, down and up: how long after the gesture the actions run, up to 600000)} — or {id, dest} for a click-navigate. Returns {index}", + "{id?, trigger? {type: onClick|onHover|onPress|onDrag|mouseEnter|mouseLeave|mouseDown|mouseUp|afterDelay {ms}|keyPress {key (a key or a combination: Shift+K, Ctrl+Alt+Delete; or a controller button: Gamepad A|B|X|Y|LB|RB|LT|RT|View|Menu|LS|RS|Up|Down|Left|Right|Home)}}, action {type: navigate {preserveScroll?}|back|scrollTo {offset? [x, y] (where the layer ends up from the top-left)}|openOverlay {position?, anchor? screen|triggerInside|triggerOutside (default screen; the trigger ones measure the position preset on the layer that opens the overlay: outside puts top presets above it and bottom presets below it, edges lined up), offset? [x, y] (position manual: the overlay's top-left from the top-left of the layer that opens it; without it, right below that layer), close_on_outside? (default true), background?}|swapOverlay|closeOverlay|openUrl {url}|changeTo|setVariable {variable, value | expression (text, e.g. \"count + 1\")}|setVariableMode {collection, mode}|conditional {blocks [{condition? | expression? (text, e.g. \"count >= 3\"), actions}]}, dest?}, actions? [..], dest? (without action: a click-navigate to it), transition? {type: instant|dissolve|smartAnimate|moveIn|moveOut|push|slideIn|slideOut, direction?: left|right|top|bottom}, durationMs? | duration_ms?, easing? linear|easeIn|easeOut|easeInOut|easeInBack|easeOutBack|easeInOutBack|gentle|quick|bouncy|slow|{customBezier {x1, y1, x2, y2}}|{customSpring {mass, stiffness, damping}}, delayMs? | delay_ms? (mouse enter, leave, down and up: how long after the gesture the actions run, up to 600000)} — or {id, dest} for a click-navigate. Returns {index}", true, add ), @@ -51,7 +51,7 @@ pub fn specs() -> Vec { spec!( "proto.interaction.set", "Edit interaction", - "{id?, index, interaction {..}} replaces it; or patch fields: {id?, index, trigger?, action? (first action), actions?, transition?, durationMs?, easing?, delayMs?}", + "{id?, index, interaction? {..} (replaces it whole), trigger?, action? (first action), actions?, transition?, durationMs? | duration_ms?, easing?, delayMs? | delay_ms?} patch fields on top of the current interaction (or of interaction)", true, set ), diff --git a/crates/engine/src/cmd/structure.rs b/crates/engine/src/cmd/structure.rs index 05823cabf..6b203c593 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}; @@ -83,7 +83,7 @@ pub fn specs() -> Vec { } ok() }), - spec!("layout.suggest", "Suggest auto layout", "{ids?}", true, layout_add), + spec!("layout.suggest", "Suggest auto layout", "{ids?, direction? (horizontal|vertical|grid)}", true, layout_add), spec!("object.moveToPage", "Move to page", "{ids?, page}", true, move_to_page), spec!("object.setThumbnail", "Set as thumbnail", "{id?}", true, |s, p| { let id = targets(s, p).first().copied().ok_or_else(|| bad("object.setThumbnail", "select a frame"))?; @@ -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/cmd/styles.rs b/crates/engine/src/cmd/styles.rs index 0066602ae..b99abc2bc 100644 --- a/crates/engine/src/cmd/styles.rs +++ b/crates/engine/src/cmd/styles.rs @@ -164,7 +164,7 @@ pub fn specs() -> Vec { spec!( "variable.create", "Create variable", - "{collection, name, type: color|number|string|boolean, value (default mode) | values {modeName: value}}", + "{collection, name, type: color|number|string|boolean, value (default mode) | values {modeName: value}, description?}", true, var_create ), diff --git a/crates/engine/src/cmd/text.rs b/crates/engine/src/cmd/text.rs index 5f00fbe7b..ec219db36 100644 --- a/crates/engine/src/cmd/text.rs +++ b/crates/engine/src/cmd/text.rs @@ -279,13 +279,19 @@ pub fn specs() -> Vec { .ok_or_else(|| bad("text.decoration", "`decoration`: none|underline|strikethrough"))?; style(s, p, |st| st.decoration = d) }), - spec!("text.listStyle", "List style", "{style: none|bulleted|numbered, ids?} while editing: the selected paragraphs", true, |s, p| { - let l: ListStyle = get(p, "style") - .or_else(|| get(p, "list")) - .and_then(super::props::enum_of) - .ok_or_else(|| bad("text.listStyle", "`style`: none|bulleted|numbered"))?; - set_list(s, p, l, false) - }), + spec!( + "text.listStyle", + "List style", + "{style | list: none|bulleted|numbered, ids?} while editing: the selected paragraphs", + true, + |s, p| { + let l: ListStyle = get(p, "style") + .or_else(|| get(p, "list")) + .and_then(super::props::enum_of) + .ok_or_else(|| bad("text.listStyle", "`style`: none|bulleted|numbered"))?; + set_list(s, p, l, false) + } + ), spec!("text.paragraphIndent", "Paragraph indent", "{indent (px), ids?}", true, |s, p| { let v = finite(req_num("text.paragraphIndent", p, "indent")?).clamp(0.0, 10_000.0); para(s, p, |t| t.paragraph_indent = v) @@ -374,7 +380,7 @@ pub fn specs() -> Vec { spec!("text.endPreview", "End text preview", "{} puts the document back as it was before text.preview → {ended}", false, |s, _| { Ok(json!({"ended": crate::textpreview::end(s)})) }), - spec!("text.set", "Set text", "{id?, characters}", true, |s, p| { + spec!("text.set", "Set text", "{id?, characters | text}", true, |s, p| { let c = string(p, "characters").or_else(|| string(p, "text")).ok_or_else(|| bad("text.set", "`characters` required"))?; let ids = req_targets("text.set", s, p)?; let doc = s.doc_mut()?; @@ -390,7 +396,7 @@ pub fn specs() -> Vec { spec!( "text.style", "Style text range", - "{id?, start?, end?, fontFamily?, fontStyle?, fontWeight?, fontSize?, lineHeight?, letterSpacing?, textDecoration?, textCase?, hyperlink?, textFills?}", + "{id?, start?, end?, fontFamily?, fontStyle?, fontWeight?, fontVariations? | variationSettings?, fontSize?, lineHeight?, letterSpacing?, textDecoration?, textDecorationStyle?, textDecorationThickness?, textDecorationOffset?, textDecorationSkipInk?, textDecorationColor?, textCase?, hyperlink?, textFills?}", true, |s, p| { let ids = req_targets("text.style", s, p)?; diff --git a/crates/engine/src/cmd/view.rs b/crates/engine/src/cmd/view.rs index 97b947a59..f8cde2e3c 100644 --- a/crates/engine/src/cmd/view.rs +++ b/crates/engine/src/cmd/view.rs @@ -373,7 +373,7 @@ pub fn specs() -> Vec { spec!( "pref.uiScale", "Interface size", - "{scale?: a size from 0.75 to 3 relative to the system's display scale, 1 being the system's own size, so 1.25 is 125 %, not 125 (outside the range it stops at the nearest end; \"auto\" is 1; omit: just read it)} → {scale}: how large the whole interface is drawn; the canvas zoom is separate", + "{scale? (a size from 0.75 to 3 relative to the system's display scale: 1 is the system's own size, so 1.25 is 125 %, not 125; outside the range it stops at the nearest end; \"auto\" is 1; omit: just read it)} → {scale}: how large the whole interface is drawn; the canvas zoom is separate", false, |s, p| { match get(p, "scale") { 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"], "