diff --git a/README.md b/README.md index e17cd74c..75328de1 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 9604db66..5677e22f 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 (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,6 +352,7 @@ 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. | @@ -433,6 +434,7 @@ Ranked; detail and milestones in [`docs/roadmap.md`](docs/roadmap.md). | 2026-10-17 | minor | Community fixes from #524 up (batches #727, #728, #733, #735, #736 and #729; 55 PRs) and a crash guard. `.fig` import: explicit instance identity, overrides by key, text style precedence, grid track sizes, slot content, connector labels, skip ink, paragraph options and hanging lists, list start numbers, vertical trim, per-point stroke joins, text on a path, image adjustments and embedded images; large files open within budgets from the file's own size; an import report after opening; imported bytes stay out of the command journal. Files and paste: save a copy as `.fig`; Cmd/Ctrl+V pastes images copied in other apps and layers copied from another design app (`edit.pasteDesignLayers`); paste over and paste properties; clipboard reads on Linux and the BSDs time out after 2 s. Editor: live font preview on hover; Shift+I and Resources open Assets; Shift-drag moves the selection; thin strokes take a nearby double-click; dropped points merge; Flatten keeps open paths; the Actions palette gets tabs, ranked search, a keyboard highlight and recents; the colour picker opens on a new fill or stroke; text strokes follow their alignment; selection highlights after line breaks; a draggable pages/layers divider; variant properties reorder by drag; submenus keep their hover with a safe triangle; menu actions that need a value ask for it; File ▸ Rename edits the name in place. Engine: bad paint indexes, non-finite geometry and pages in copy/cut are refused; a param naming a layer says so when it isn't one; a CLI script is one undo step and resolves `$step`. Server and collaboration: message limits follow `max_file_mb` (client capped at 1 GiB); a damaged generation field no longer drops flushed edits; the web build joins a session from a `#join=` link. Also Swedish command labels, Nix CI through the shared workflow, paint bounds once per render, and a guard that turns a panic inside the boolean-op library into bad input (the stroke draws unclipped). Numbers unchanged. | | 2026-10-17 | minor | Number variables on text content (M3, #629): the Typography section's variable picker also lists number variables for a text layer's content, as the engine already accepted (the text shows the number, e.g. 42.5, and follows the variable). Other text fields keep their own types. Numbers unchanged. | | 2026-10-17 | minor | Frames and sections convert into each other (M1, #719): `object.convertToSection` turns a frame on the page or in a section into a section in place (same box, name, fill and layers, which stay where they are; what only a frame has goes: auto layout, clipping, layout grids, guides, scrolling), and `object.convertToFrame` now also turns a section into a frame that keeps its fill and doesn't clip, so the look stays. Nested frames, instances, main components and sections holding sections are refused with the reason; one undo step each. Both are in the canvas context menu (Convert to section / Convert to frame) and in Spanish. Numbers unchanged. | +| 2026-10-16 | minor | Flutter code (ours; the incumbent generates CSS, iOS and Android only): `inspect.code`, `inspect.componentCode` and `codeConnect.set` take `language: flutter` (`dart` is an alias): a `StatelessWidget` for a component (properties as constructor parameters with their defaults, instances as calls, variants as a Dart enum, swaps a `Widget`, slots a `List?`) and a Dart widget tree for a layer (`Container` with a `BoxDecoration`, `Row`/`Column` with `spacing`, `Expanded`, `Stack` + `Positioned`, `Text` with a `TextStyle`, `Image.asset`, `Opacity`), plus Dart `import '…';` lines in Code Connect. The Dev Mode language list includes it. Numbers unchanged | | 2026-10-16 | minor | Adobe Fonts found on macOS (M7, #298): fonts activated through Adobe Fonts rendered in the fallback font without a warning, because Creative Cloud keeps them in its own folder as hidden files named by number, which the font scan never read. The macOS scan now includes `~/Library/Application Support/Adobe/CoreSync/plugins/livetype`, and files without an extension are indexed when they start with a font signature. Fonts are read in place. Regression test in the font database tests; docs/cli.md lists the folder. Numbers unchanged | | 2026-10-16 | minor | Community fixes from #478 up (batches #591, #598, #602; 69 PRs) and a control-channel fix: a line that isn't JSON now ends a `--control` connection, so a web page can't drive the app through a browser request (#589). Highlights: page copies keep navigation, flows, prototype settings and guides; Find and Replace keeps line endings and case-insensitive matches; text hyperlinks survive import and export; recent colours record only what was picked; MCP screenshots respect `maxSize`; Swedish catalog (UI strings), Czech plurals; bounded canvas overscan while panning; Korean fallback at the run's weight; many vector-edit, guide, page and .fig round-trip fixes. Numbers unchanged | | 2026-10-15 | minor | Drop files anywhere to open them (M7): dragging files over the window shows a "Drop to open" (home) or "Drop to import" (canvas) overlay. On the home screen each supported file opens as a document of its own and other files are turned away with a toast; on the canvas the files import at the drop point. Both wait behind a small card that is on screen before a large file blocks the window. A canvas import stays with the document and page that received the drop even if the tab or page changes meanwhile, and is cancelled with a message if that document or page is gone. Numbers unchanged. | diff --git a/apps/figcraft-cli/src/files.rs b/apps/figcraft-cli/src/files.rs index 1ba9d60a..8e689ec0 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/crates/doc/src/boolean_hit_tests.rs b/crates/doc/src/boolean_hit_tests.rs index b826f202..5a8a0112 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 93d32f0b..a3168f8d 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 1ee885f5..0e06d294 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 d19ef0c7..88851ff5 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 fe53ec29..348c4544 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 bcf8f09d..0942e3f8 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 ebec2383..70dcd25b 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 b3040b1e..5ae01a08 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 050ab821..634039d0 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 c09664b4..75814f9f 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 a7a4d1d7..d36b35b8 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 5c05c3b9..ddb969c2 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 5c4550ec..aa11a2bb 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 74a4a9d1..b68f1e71 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 00000000..74e45093 --- /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 fc263b4f..963e9dd9 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 8832e05d..b44ee941 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 111c8e11..91763d3a 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 f0e5768e..6787a59f 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 13c7be0c..0e565cc9 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 a247ed52..c046cd19 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 3fde2b09..baac9ab3 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 2e64ce04..c4c97252 100644 --- a/crates/engine/src/cmd/create.rs +++ b/crates/engine/src/cmd/create.rs @@ -53,37 +53,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 ), diff --git a/crates/engine/src/cmd/edit.rs b/crates/engine/src/cmd/edit.rs index d24600c9..9679e19b 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 576452c5..173507a6 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/open.rs b/crates/engine/src/cmd/file/open.rs index 6b724ced..169d71fd 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 2474ec23..ee5ba4f6 100644 --- a/crates/engine/src/cmd/props.rs +++ b/crates/engine/src/cmd/props.rs @@ -405,23 +405,37 @@ pub(crate) fn variable_prop<'a>(cmd: &str, key: &str, value: &'a Value) -> Resul Ok(Some(name)) } -/// 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. `$var` wrappers also win over style links. +/// 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. `$var` wrappers also win over style links. fn apply_order(k: &str, v: &Value) -> u8 { if v.get("$var").is_some() { - return 6; + return 7; } 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<()> { @@ -1034,6 +1048,7 @@ fn apply_variable(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, v)| apply_order(k, v)); diff --git a/crates/engine/src/cmd/proto.rs b/crates/engine/src/cmd/proto.rs index d0822db9..3f51bb5e 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 05823cab..6b203c59 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 4672583f..0f3fdef6 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 5f00fbe7..ec219db3 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 97b947a5..f8cde2e3 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 22bb53c4..2c3706f8 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"], "