From f9759afa3081941fdbd96d8960257023466d30bd Mon Sep 17 00:00:00 2001 From: dotCooCoo Date: Thu, 15 Oct 2026 16:07:04 -0700 Subject: [PATCH] Live Paint: Gap Options find and close gaps in Live Paint groups Object > Live Paint > Gap Options was a stub, and paint leaked through any gap between the paths of a Live Paint group. The reference app's Gap Options stop paint at small gaps, close them with unpainted paths and preview them on the canvas. livePaint.gapOptions (Gap Options) sets a group's options: detect (Gap Detection), size (small 3 pt, medium 6 pt, large 12 pt, or a custom size over 0 up to 72 pt), color (the Gap Preview Color, #rrggbb or a layer color's name) and closeGaps, which adds an unpainted path across each gap to the group's paths. A gap runs from a loose end of a path to the nearest point of any path within the size, unless the paths already join the two close by. With detection on, the faces are made again as if a line closed each gap, and each face or edge keeps the look of the old one it lies in or on. The options are stored on the group, so they are saved, copied and undone with it, and Expand drops them. Merge keeps the bottom group's options when its Gap Detection is on, or else turns Gap Detection on with Small when another merged group has it on. When Gap Detection is off in every merged group, Merge keeps it off. livePaint.info lists the options and the gaps up to the size wide that no path closes. The menu item opens a Gap Options dialog. With Preview on, the options apply live and the gaps show on the canvas in the preview color, and OK keeps them as one undo step. vectorcraft-pathops gains find_gaps and live_paint_with_gaps. Docs: docs/mcp.md, docs/target-app-parity.md. i18n: the 19 complete catalogs have rows for the dialog's labels, and bg's Gap Options menu label now names gaps instead of line spacing. --- crates/doc/src/lib.rs | 2 + crates/doc/src/live_paint.rs | 130 ++++++ crates/doc/src/mem.rs | 1 + crates/doc/src/node.rs | 10 + crates/engine/src/cmd/buildcmds.rs | 178 ++++++++- crates/engine/src/cmd/layer.rs | 2 +- crates/engine/src/tests_build.rs | 226 +++++++++++ crates/pathops/src/lib.rs | 5 +- crates/pathops/src/planar.rs | 370 +++++++++++++++++- crates/ui-egui/src/canvas.rs | 4 +- crates/ui-egui/src/dialogs/live_paint_gaps.rs | 241 ++++++++++++ crates/ui-egui/src/dialogs/mod.rs | 2 + crates/ui-egui/src/dialogs/tile_edge_color.rs | 2 +- crates/ui-egui/src/i18n/bg.tsv | 10 +- crates/ui-egui/src/i18n/cs.tsv | 8 + crates/ui-egui/src/i18n/de.tsv | 8 + crates/ui-egui/src/i18n/el.tsv | 8 + crates/ui-egui/src/i18n/es.tsv | 8 + crates/ui-egui/src/i18n/fi.tsv | 8 + crates/ui-egui/src/i18n/fr.tsv | 8 + crates/ui-egui/src/i18n/hu.tsv | 8 + crates/ui-egui/src/i18n/it.tsv | 8 + crates/ui-egui/src/i18n/ja.tsv | 8 + crates/ui-egui/src/i18n/ko.tsv | 8 + crates/ui-egui/src/i18n/pl.tsv | 8 + crates/ui-egui/src/i18n/pt-br.tsv | 8 + crates/ui-egui/src/i18n/ru.tsv | 8 + crates/ui-egui/src/i18n/sv.tsv | 8 + crates/ui-egui/src/i18n/uk.tsv | 8 + crates/ui-egui/src/i18n/vi.tsv | 8 + crates/ui-egui/src/i18n/zh-hans.tsv | 8 + crates/ui-egui/src/i18n/zh-hant.tsv | 8 + crates/ui-egui/src/menus.rs | 9 +- docs/mcp.md | 8 + docs/target-app-parity.md | 1 + 35 files changed, 1328 insertions(+), 17 deletions(-) create mode 100644 crates/doc/src/live_paint.rs create mode 100644 crates/ui-egui/src/dialogs/live_paint_gaps.rs diff --git a/crates/doc/src/lib.rs b/crates/doc/src/lib.rs index c4180f490..b4ebce946 100644 --- a/crates/doc/src/lib.rs +++ b/crates/doc/src/lib.rs @@ -29,6 +29,7 @@ pub use symbol_export_type::{SYMBOL_EXPORT_TYPE, SymbolExportType}; pub use symbol_registration::{SYMBOL_REGISTRATION, SymbolRegistration}; pub mod links; pub mod live; +pub mod live_paint; pub mod marks; mod mem; pub mod metadata; @@ -96,6 +97,7 @@ pub use graph::{ pub use hit::{Hit, HitKind}; pub use links::{LinkInfo, PlacementOptions}; pub use live::{BlendOrientation, BlendSpacing, BlendSpec, EnvelopeKind, GradientMesh, MeshPoint}; +pub use live_paint::{GapOptions, GapSize}; pub use metadata::{CopyrightStatus, DocMetadata}; pub use node::Knockout; pub use node::Scaling; diff --git a/crates/doc/src/live_paint.rs b/crates/doc/src/live_paint.rs new file mode 100644 index 000000000..dfcfd24b8 --- /dev/null +++ b/crates/doc/src/live_paint.rs @@ -0,0 +1,130 @@ +//! Live Paint groups' Gap Options (Object › Live Paint › Gap Options): whether paint stops at small +//! gaps between the group's paths, how wide a gap it stops at, and the color gaps preview in. +//! A group without options has Gap Detection off. + +use serde::{Deserialize, Serialize}; + +use crate::node::{LAYER_COLORS, Node}; + +/// Paint Stops At: the widest gap Gap Detection closes. +#[derive(Clone, Copy, Debug, Default, PartialEq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub enum GapSize { + /// Small Gaps: [`GapSize::SMALL`] points. + #[default] + Small, + /// Medium Gaps: [`GapSize::MEDIUM`] points. + Medium, + /// Large Gaps: [`GapSize::LARGE`] points. + Large, + /// Custom: this many points, over 0 up to [`GapSize::MAX`]. + Custom(f64), +} + +impl GapSize { + pub const SMALL: f64 = 3.0; + pub const MEDIUM: f64 = 6.0; + pub const LARGE: f64 = 12.0; + /// The widest custom gap. + pub const MAX: f64 = 72.0; + + /// The preset sizes with their ids, smallest first. + pub const PRESETS: [(Self, &'static str); 3] = [(Self::Small, "small"), (Self::Medium, "medium"), (Self::Large, "large")]; + + /// The widest gap it closes, in points. + pub fn points(self) -> f64 { + match self { + Self::Small => Self::SMALL, + Self::Medium => Self::MEDIUM, + Self::Large => Self::LARGE, + Self::Custom(pt) => pt, + } + } + + /// The id of a preset size (`small`, `medium` or `large`); None for a custom size. + pub fn id(self) -> Option<&'static str> { + Self::PRESETS.iter().find(|(s, _)| *s == self).map(|(_, id)| *id) + } + + /// A preset size by its id (any case), or a custom size from a number of points over 0 up to + /// [`GapSize::MAX`]. + pub fn parse(v: &serde_json::Value) -> Option { + match v { + serde_json::Value::String(id) => Self::PRESETS.iter().find(|(_, i)| i.eq_ignore_ascii_case(id.trim())).map(|(s, _)| *s), + v => v.as_f64().filter(|pt| *pt > 0.0 && *pt <= Self::MAX).map(Self::Custom), + } + } +} + +/// Gap Preview Color for a group that has none set: the layer color Red. +pub const DEFAULT_GAP_COLOR: [u8; 3] = LAYER_COLORS[1].1; + +/// A Live Paint group's Gap Options. +#[derive(Clone, Copy, Debug, PartialEq, Serialize, Deserialize)] +#[serde(default, rename_all = "camelCase")] +pub struct GapOptions { + /// Gap Detection: paint stops at gaps up to `size` wide. + pub detect: bool, + /// Paint Stops At. + pub size: GapSize, + /// Gap Preview Color: the color gaps show in. + pub color: [u8; 3], +} + +impl Default for GapOptions { + fn default() -> Self { + Self { detect: false, size: GapSize::Small, color: DEFAULT_GAP_COLOR } + } +} + +impl GapOptions { + /// The widest gap paint stops at, in points; None with Gap Detection off. + pub fn max_gap(&self) -> Option { + self.detect.then(|| self.size.points()) + } +} + +impl Node { + /// The Gap Options of a Live Paint group (the defaults when it has none set). + pub fn gap_options(&self) -> GapOptions { + self.extras().live_paint_gaps.as_deref().copied().unwrap_or_default() + } +} + +#[cfg(test)] +mod tests { + use super::*; + use serde_json::json; + + #[test] + fn sizes_parse_from_ids_and_points() { + for (size, id) in GapSize::PRESETS { + assert_eq!(GapSize::parse(&json!(id)), Some(size)); + assert_eq!(GapSize::parse(&json!(id.to_ascii_uppercase())), Some(size)); + assert_eq!(size.id(), Some(id)); + } + assert_eq!(GapSize::parse(&json!("Medium")).map(GapSize::points), Some(6.0)); + assert_eq!(GapSize::parse(&json!("large")).map(GapSize::points), Some(12.0)); + assert_eq!(GapSize::parse(&json!(4.5)), Some(GapSize::Custom(4.5))); + assert_eq!(GapSize::Custom(4.5).points(), 4.5); + assert_eq!(GapSize::Custom(4.5).id(), None); + for bad in [json!(0), json!(-1), json!(72.5), json!("tiny"), json!(null), json!(true)] { + assert_eq!(GapSize::parse(&bad), None, "{bad}"); + } + } + + #[test] + fn options_round_trip_and_default_to_no_detection() { + let mut g = Node::group(crate::NodeId(1), vec![]); + assert_eq!(g.gap_options(), GapOptions::default()); + assert_eq!(g.gap_options().max_gap(), None); + let set = GapOptions { detect: true, size: GapSize::Custom(5.0), color: [1, 2, 3] }; + g.extras_mut().live_paint_gaps = Some(Box::new(set)); + assert_eq!(g.gap_options().max_gap(), Some(5.0)); + let text = serde_json::to_string(&g).unwrap(); + let back: Node = serde_json::from_str(&text).unwrap(); + assert_eq!(back.gap_options(), set); + let bare: GapOptions = serde_json::from_value(json!({"detect": true})).unwrap(); + assert_eq!(bare, GapOptions { detect: true, ..GapOptions::default() }); + } +} diff --git a/crates/doc/src/mem.rs b/crates/doc/src/mem.rs index 810745163..3109fa4cf 100644 --- a/crates/doc/src/mem.rs +++ b/crates/doc/src/mem.rs @@ -250,6 +250,7 @@ fn node_bytes(n: &Node, other: Option<&Node>) -> usize { if let Some(e) = n.extras_box() { b += ALLOC + size_of::(); b += boxed(&e.mask) + boxed(&e.graph) + boxed(&e.shaper) + boxed(&e.attrs) + boxed(&e.slice) + boxed(&e.perspective); + b += boxed(&e.dimension) + boxed(&e.live_paint_gaps); b += e.trace.as_ref().map_or(0, |t| ALLOC + size_of::() + json_bytes(t)); b += e.intertwine.as_deref().map_or(0, |a| ALLOC + size_of::() + path_bytes(a)); } diff --git a/crates/doc/src/node.rs b/crates/doc/src/node.rs index 7d1645ddb..25acfd088 100644 --- a/crates/doc/src/node.rs +++ b/crates/doc/src/node.rs @@ -570,6 +570,8 @@ pub struct NodeExtras { /// Object › Intertwine: as a member of an Intertwine group, the area (document coordinates, /// filled non-zero) where this object is drawn over the group's other members. pub intertwine: Option>, + /// Live Paint group: its Gap Options (Object › Live Paint › Gap Options; `None`: the defaults). + pub live_paint_gaps: Option>, } /// No extras: what [`Node::extras`] gives for a node without any. @@ -587,6 +589,7 @@ static NO_EXTRAS: NodeExtras = NodeExtras { perspective: None, dimension: None, intertwine: None, + live_paint_gaps: None, }; /// A node's [`NodeExtras`], if it has any ([`Node::extras`]). Extras that are all unset equal @@ -720,6 +723,7 @@ impl Serialize for Node { e.perspective.is_none(), e.dimension.is_none(), e.intertwine.is_none(), + e.live_paint_gaps.is_none(), ]; let mut st = s.serialize_struct("Node", skips.iter().filter(|s| !**s).count())?; let mut skip = skips.into_iter(); @@ -757,6 +761,7 @@ impl Serialize for Node { field!("perspective", &e.perspective); field!("dimension", &e.dimension); field!("intertwine", &e.intertwine); + field!("live_paint_gaps", &e.live_paint_gaps); st.end() } } @@ -814,6 +819,8 @@ struct NodeWire { dimension: Option>, #[serde(default)] intertwine: Option>, + #[serde(default)] + live_paint_gaps: Option>, } impl From for Node { @@ -832,6 +839,7 @@ impl From for Node { perspective: w.perspective, dimension: w.dimension, intertwine: w.intertwine, + live_paint_gaps: w.live_paint_gaps, }; Node { id: w.id, @@ -2055,6 +2063,7 @@ mod tests { e.attrs = Some(Box::default()); e.slice = Some(Box::default()); e.perspective = Some(Box::new(crate::PerspectiveAttachment::new("left", 1.0))); + e.live_paint_gaps = Some(Box::default()); } /// The top-level keys of a JSON object, in the order written. struct Keys(Vec); @@ -2096,6 +2105,7 @@ mod tests { "slice", "bbox_angle", "perspective", + "live_paint_gaps", ]; assert_eq!(keys, order); assert_eq!(serde_json::from_value::(v).unwrap(), n); diff --git a/crates/engine/src/cmd/buildcmds.rs b/crates/engine/src/cmd/buildcmds.rs index b3b232b34..f00e96e9c 100644 --- a/crates/engine/src/cmd/buildcmds.rs +++ b/crates/engine/src/cmd/buildcmds.rs @@ -15,7 +15,7 @@ use std::sync::Arc; use serde_json::{Value, json}; use vectorcraft_color::{Color, Paint}; use vectorcraft_doc::appearance::{AppearanceItem, FillLayer, LineCap, LineJoin, StrokeLayer}; -use vectorcraft_doc::{Appearance, Document, LiveShape, Node, NodeId, NodeKind, Selection, TraceView}; +use vectorcraft_doc::{Appearance, Document, GapOptions, GapSize, LiveShape, Node, NodeId, NodeKind, Selection, TraceView}; use vectorcraft_geom::{FillRule, PathData, Point, Rect, SubPath}; use vectorcraft_pathops as po; use vectorcraft_tools::builder::{ @@ -88,6 +88,15 @@ pub fn specs() -> Vec { has_selection_or_ids, lp_expand ), + cmd!( + "livePaint.gapOptions", + "Gap Options", + [], + None, + "{id?, ids?: [id…] (instead of id; default: the selected Live Paint groups), detect?: bool (Gap Detection: paint stops at gaps up to size wide), size?: \"small\" (3 pt) | \"medium\" (6 pt) | \"large\" (12 pt) | pt (custom, over 0 up to 72) (Paint Stops At), color?: #rrggbb or a layer color's name such as \"Red\" (Gap Preview Color), closeGaps?: bool (add an unpainted path across each gap up to size wide to the group's paths)} set the Gap Options of Live Paint groups (what is not given stays as it was; a group starts with detect false, small and Red) and make their faces again, keeping their paint → {ids, faces, gaps: [[x0,y0,x1,y1]…] (the gaps up to size wide that no path closes, from a loose end of a path to where it closes to; detect closes them for paint), closed (paths added)}", + has_selection_or_ids, + lp_gap_options + ), cmd!( "livePaint.fill", "Live Paint Bucket", @@ -111,7 +120,7 @@ pub fn specs() -> Vec { "Live Paint Info", [], None, - "{group, id?: object id (default: the selection)} → {faces: [{id, fill, bounds}], edges: [{id, stroke, width}], sources}", + "{group, id?: object id (default: the selection)} → {faces: [{id, fill, bounds}], edges: [{id, stroke, width}], sources, gapOptions: {detect, size: \"small\"|\"medium\"|\"large\"|pt, points, color: #rrggbb} (as livePaint.gapOptions sets them), gaps: [[x0,y0,x1,y1]…] (as livePaint.gapOptions returns them)}", has_doc, lp_info ), @@ -383,10 +392,11 @@ fn source_nodes(n: &Node) -> Vec> { if is_live_paint(n) { b::sources(n).and_then(|g| g.children().cloned()).unwrap_or_default() } else { vec![Arc::new(n.clone())] } } -/// Planar map children (sources group, faces, edges) for `sources`. -fn build_children(d: &mut Document, sources: Vec>) -> Result<(Vec>, usize, usize)> { +/// The planar map's inputs from `sources` (paths and compound paths, back → front), with the +/// fill, stroke and stroke width of each. +fn source_shapes(sources: &[Arc]) -> (Vec, Vec<(Paint, Paint, f64)>) { let mut lv = vec![]; - for n in &sources { + for n in sources { b::leaf_shapes(n, &mut lv); } let mut shapes = vec![]; @@ -400,6 +410,13 @@ fn build_children(d: &mut Document, sources: Vec>) -> Result<(Vec>, max_gap: Option) -> Result<(Vec>, usize, usize)> { + let (shapes, styles) = source_shapes(&sources); if shapes.is_empty() { return Err(EngineError::Other("Live Paint: select paths or compound paths".into())); } @@ -410,7 +427,13 @@ fn build_children(d: &mut Document, sources: Vec>) -> Result<(Vec { + let (regs, edges, _) = po::live_paint_with_gaps(&shapes, gap); + (regs, edges) + } + None => po::live_paint(&shapes), + }; if regs.is_empty() && edges.is_empty() { return Err(EngineError::Other("Live Paint: these paths are too degenerate to paint".into())); } @@ -433,6 +456,51 @@ fn build_children(d: &mut Document, sources: Vec>) -> Result<(Vec po::interior_point(pd).and_then(|pt| face_at(old, pt)), + Some(EDGE_NAME) => pd + .subpaths + .first() + .filter(|sp| sp.segment_count() > 0) + .and_then(|sp| edge_near(old, kurbo::ParamCurve::eval(&sp.segment(0), 0.5), ON_EDGE)), + _ => None, + }; + if let Some(was) = was { + let mut n = (**was).clone(); + // Gradients fitted to the old outline keep that fit on the new one. + n.pin_gradients(); + n.id = c.id; + n.kind = c.kind.clone(); + n.bbox_angle = c.bbox_angle; + *c = Arc::new(n); + } + } +} + +/// `options` as stored on a Live Paint group: none for the defaults. +fn stored_gap_options(options: GapOptions) -> Option> { + (options != GapOptions::default()).then(|| Box::new(options)) +} + +/// The Gap Options of Live Paint groups merged into one, given bottom-most first: the bottom-most +/// group's when its Gap Detection is on; else Gap Detection on with Small Gaps when another +/// group's is on; else the bottom-most group's. +fn merged_gap_options(groups: &[GapOptions]) -> GapOptions { + let Some(&bottom) = groups.first() else { return GapOptions::default() }; + if !bottom.detect && groups.iter().any(|g| g.detect) { GapOptions { detect: true, size: GapSize::Small, ..bottom } } else { bottom } +} + /// Make a Live Paint group from `roots`; returns (group, faces, edges). fn make_live(d: &mut Document, sel: &mut Selection, roots: &[NodeId]) -> Result<(NodeId, usize, usize)> { let mut sources = vec![]; @@ -440,7 +508,7 @@ fn make_live(d: &mut Document, sel: &mut Selection, roots: &[NodeId]) -> Result< let n = d.node(*r).ok_or(EngineError::NoNode(*r))?; sources.extend(source_nodes(n)); } - let (children, nf, ne) = build_children(d, sources)?; + let (children, nf, ne) = build_children(d, sources, None)?; let gid = d.alloc_id(); let mut g = Node::group(gid, children); g.name = Some(LIVE_PAINT_NAME.into()); @@ -484,11 +552,15 @@ fn lp_merge(s: &mut Session, p: &Value) -> Result { sources.extend(source_nodes(d.node(*r).ok_or(EngineError::NoNode(*r))?)); } } - let (children, nf, ne) = build_children(d, sources)?; + // Merge applies the Gap Options' merge rules to the groups' options, bottom-most first. + let groups: Vec = roots.iter().filter_map(|r| d.node(*r)).filter(|n| is_live_paint(n)).map(Node::gap_options).collect(); + let options = merged_gap_options(&groups); + let (children, nf, ne) = build_children(d, sources, options.max_gap())?; let mut g = Node::group(target, children); g.name = old.name.clone(); g.opacity = old.opacity; g.blend = old.blend; + g.extras_mut().live_paint_gaps = stored_gap_options(options); for (pt, paint) in keep_faces { if let Some(fid) = face_at(&g, pt).map(|f| f.id) && let Some(ch) = g.children_mut() @@ -553,7 +625,11 @@ fn lp_expand(s: &mut Session, p: &Value) -> Result { for g in &targets { let n = d.node_mut(*g).ok_or(EngineError::NoNode(*g))?; n.name = None; - n.extras_mut().trace = None; + { + let mut e = n.extras_mut(); + e.trace = None; + e.live_paint_gaps = None; + } if let Some(ch) = n.children_mut() { ch.retain(|c| match c.name.as_deref() { Some(SOURCES_NAME) => false, @@ -569,6 +645,81 @@ fn lp_expand(s: &mut Session, p: &Value) -> Result { Ok(json!({ "ids": ids_json(&targets) })) } +/// Gaps as `[x0, y0, x1, y1]` (the loose end, then where it closes to). +fn gaps_json(gaps: &[[Point; 2]]) -> Value { + json!(gaps.iter().map(|[a, b]| [a.x, a.y, b.x, b.y]).collect::>()) +} + +/// A group's Gap Options as `livePaint.info` gives them. +fn gap_options_json(o: GapOptions) -> Value { + let [r, g, b] = o.color; + let size = o.size.id().map_or_else(|| json!(o.size.points()), |id| json!(id)); + json!({ "detect": o.detect, "size": size, "points": o.size.points(), "color": format!("#{r:02x}{g:02x}{b:02x}") }) +} + +/// The gaps up to `options`' size wide among a Live Paint group's sources. +fn open_gaps(group: &Node, options: GapOptions) -> Vec<[Point; 2]> { + po::find_gaps(&source_shapes(&source_nodes(group)).0, options.size.points()) +} + +fn lp_gap_options(s: &mut Session, p: &Value) -> Result { + const C: &str = "livePaint.gapOptions"; + let detect = match p.get("detect").filter(|v| !v.is_null()) { + Some(v) => Some(v.as_bool().ok_or_else(|| bad(C, "detect must be true or false"))?), + None => None, + }; + let size = + match p.get("size").filter(|v| !v.is_null()) { + Some(v) => Some(GapSize::parse(v).ok_or_else(|| { + bad(C, format!("size must be \"small\", \"medium\", \"large\" or a number of points over 0 up to {}", GapSize::MAX)) + })?), + None => None, + }; + let color = match p.get("color").filter(|v| !v.is_null()) { + Some(v) => Some(super::layer::layer_color_param(v).map_err(|e| bad(C, e))?.rgb()), + None => None, + }; + let close = bool_or(p, "closeGaps", false); + let targets = live_targets(s, p)?; + let (faces, gaps, closed) = s.edit("Gap Options", |d, sel| { + let (mut faces, mut gaps, mut closed) = (0, vec![], 0); + let mut changed = Beside::replacing(d, &targets); + for g in &targets { + let old = d.node(*g).cloned().ok_or(EngineError::NoNode(*g))?; + let mut options = old.gap_options(); + options.detect = detect.unwrap_or(options.detect); + options.size = size.unwrap_or(options.size); + options.color = color.unwrap_or(options.color); + let mut sources = source_nodes(&old); + if close { + // Close Gaps With Paths adds an unpainted line across each gap to the group's paths. + for gap in po::find_gaps(&source_shapes(&sources).0, options.size.points()) { + let line = PathData::single(SubPath::polyline(&gap, false)); + sources.push(Arc::new(Node::path(d.alloc_id(), line, Appearance::default()))); + closed += 1; + } + } + let mut n = old.clone(); + // The faces change only with what paint stops at, or with new paths. + if close || options.max_gap() != old.gap_options().max_gap() { + let (children, _, _) = build_children(d, sources, options.max_gap())?; + if let Some(ch) = n.children_mut() { + *ch = children; + } + repaint_from(&mut n, &old); + } + n.extras_mut().live_paint_gaps = stored_gap_options(options); + faces += b::faces(&n).len(); + gaps.extend(open_gaps(&n, options)); + changed.put(d, *g, vec![n])?; + } + changed.finish(d)?; + sel.set(targets.iter().copied()); + Ok((faces, gaps, closed)) + })?; + Ok(json!({ "ids": ids_json(&targets), "faces": faces, "gaps": gaps_json(&gaps), "closed": closed })) +} + fn lp_fill(s: &mut Session, p: &Value) -> Result { const C: &str = "livePaint.fill"; let pt = point_param(p, "point").filter(|q| finite(*q)).ok_or_else(|| bad(C, "missing point [x,y]"))?; @@ -637,7 +788,14 @@ fn lp_info(s: &mut Session, p: &Value) -> Result { .map(|e| json!({ "id": e.id.0, "stroke": e.appearance.stroke_paint().label(), "width": e.appearance.stroke_width() })) .collect(); let sources = b::sources(n).and_then(|g| g.children()).map(|c| c.len()).unwrap_or(0); - Ok(json!({ "faces": faces, "edges": edges, "sources": sources })) + let options = n.gap_options(); + Ok(json!({ + "faces": faces, + "edges": edges, + "sources": sources, + "gapOptions": gap_options_json(options), + "gaps": gaps_json(&open_gaps(n, options)), + })) } // ---------- Image Trace ---------- diff --git a/crates/engine/src/cmd/layer.rs b/crates/engine/src/cmd/layer.rs index 8ff970171..55a58a59f 100644 --- a/crates/engine/src/cmd/layer.rs +++ b/crates/engine/src/cmd/layer.rs @@ -286,7 +286,7 @@ pub(crate) fn make_layer(d: &mut Document, name: Option<&str>) -> Node { } /// A layer colour: a preset index (0–26), `#rrggbb` or a preset's name (any case). -fn layer_color_param(v: &Value) -> std::result::Result { +pub(super) fn layer_color_param(v: &Value) -> std::result::Result { match v { Value::Number(n) => match n.as_u64() { Some(i) if (i as usize) < LAYER_COLORS.len() => Ok(LayerColor::Preset(i as u8)), diff --git a/crates/engine/src/tests_build.rs b/crates/engine/src/tests_build.rs index 981d809aa..336464804 100644 --- a/crates/engine/src/tests_build.rs +++ b/crates/engine/src/tests_build.rs @@ -577,6 +577,232 @@ fn live_paint_merge_keeps_paint_and_adds_faces() { assert_eq!(layer_children(&s), 1); } +/// A Live Paint group of one open path round the square 0..100, its end 2 pt short of its start. +fn live_square_with_a_gap(s: &mut Session) -> NodeId { + let anchors = [(0, 0), (100, 0), (100, 100), (0, 100), (0, 2)].map(|(x, y)| json!({"x": x, "y": y})); + let p = s.execute("path.create", &json!({ "anchors": anchors })).unwrap(); + s.execute("select.set", &json!({"ids": [p["id"]]})).unwrap(); + live_group_only(s) +} + +fn gap_options(s: &Session, g: NodeId) -> vectorcraft_doc::GapOptions { + node(s, g).gap_options() +} + +/// The gaps of a `livePaint.*` reply, rounded to 1e-6. +fn gaps_of(r: &Value) -> Vec> { + r["gaps"].as_array().unwrap().iter().map(|g| g.as_array().unwrap().iter().map(|v| (v.as_f64().unwrap() * 1e6).round() / 1e6).collect()).collect() +} + +#[test] +fn live_paint_gap_detection_stops_paint_at_a_gap() { + let mut s = session(); + let g = live_square_with_a_gap(&mut s); + // Paint leaks through the gap: there is no face to fill. + assert!(vectorcraft_tools::builder::faces(&node(&s, g)).is_empty()); + assert!(s.execute("livePaint.fill", &json!({"group": g.0, "point": [50, 50]})).is_err()); + let info = s.execute("livePaint.info", &json!({"group": g.0})).unwrap(); + assert_eq!(info["gapOptions"], json!({"detect": false, "size": "small", "points": 3.0, "color": "#ff4f4f"})); + assert_eq!(gaps_of(&info), [[0.0, 0.0, 0.0, 2.0]], "Small Gaps (3 pt) finds the gap"); + // Gap Detection with Small Gaps: the square is one face, filled in one undo step. + let depth = s.doc().unwrap().history.undo.len(); + let r = s.execute("livePaint.gapOptions", &json!({"detect": true, "size": "small"})).unwrap(); + assert_eq!(s.doc().unwrap().history.undo.len(), depth + 1); + assert_eq!((ids(&r), &r["faces"], &r["closed"]), (vec![g], &json!(1), &json!(0))); + assert_eq!(gaps_of(&r), [[0.0, 0.0, 0.0, 2.0]]); + let f = s.execute("livePaint.fill", &json!({"group": g.0, "point": [50, 50], "color": "#00ff00"})).unwrap(); + let face = node(&s, NodeId(f["face"].as_u64().unwrap())); + assert!((vectorcraft_pathops::area(face.path_data().unwrap(), FillRule::NonZero).abs() - 10000.0).abs() < 1e-6); + assert_eq!(vectorcraft_tools::builder::edges(&node(&s, g)).len(), 1, "the gap is no edge"); + // A color change keeps the face and its paint. + s.execute("livePaint.gapOptions", &json!({"id": g.0, "color": "Green"})).unwrap(); + let opts = gap_options(&s, g); + assert_eq!((opts.detect, opts.size, opts.color), (true, vectorcraft_doc::GapSize::Small, [0x4f, 0xff, 0x4f])); + let gn = node(&s, g); + let kept = vectorcraft_tools::builder::face_at(&gn, vectorcraft_geom::Point::new(50.0, 50.0)).unwrap(); + assert_eq!(kept.appearance.fill_paint(), Paint::solid(Color::from_hex("#00ff00").unwrap())); + // A custom size narrower than the gap lets paint leak again, and so does Gap Detection off. + let r = s.execute("livePaint.gapOptions", &json!({"size": 1.5})).unwrap(); + assert_eq!((&r["faces"], gaps_of(&r).len()), (&json!(0), 0)); + s.execute("livePaint.gapOptions", &json!({"size": 12})).unwrap(); + assert_eq!(vectorcraft_tools::builder::faces(&node(&s, g)).len(), 1); + let r = s.execute("livePaint.gapOptions", &json!({"detect": false})).unwrap(); + assert_eq!((&r["faces"], gaps_of(&r).len()), (&json!(0), 1)); + // Bad values change nothing. + for bad in [json!({"size": 0}), json!({"size": 73}), json!({"size": "huge"}), json!({"detect": "yes"}), json!({"color": "#12"})] { + assert!(s.execute("livePaint.gapOptions", &bad).is_err(), "{bad}"); + } + assert_eq!(gap_options(&s, g).size, vectorcraft_doc::GapSize::Custom(12.0)); + // Plain paths are no Live Paint group. + let r = rect(&mut s, 200.0, 0.0, 10.0, 10.0); + assert!(s.execute("livePaint.gapOptions", &json!({"id": r.0, "detect": true})).is_err()); + // Expand leaves a plain group, without the options. + s.execute("livePaint.expand", &json!({"id": g.0})).unwrap(); + assert!(node(&s, g).extras().live_paint_gaps.is_none()); +} + +#[test] +fn medium_and_large_gaps_stop_paint_at_wider_gaps() { + // Squares whose ends stop 5 pt and 9 pt short of their starts: each size closes the gaps up + // to its width (Small 3 pt, Medium 6 pt, Large 12 pt). + for (gap, closed_by) in [(5, ["medium", "large"]), (9, ["large", "large"])] { + let mut s = session(); + let anchors = [(0, 0), (100, 0), (100, 100), (0, 100), (0, gap)].map(|(x, y)| json!({"x": x, "y": y})); + let p = s.execute("path.create", &json!({ "anchors": anchors })).unwrap(); + s.execute("select.set", &json!({"ids": [p["id"]]})).unwrap(); + let g = live_group_only(&mut s); + for size in ["small", "medium", "large"] { + let r = s.execute("livePaint.gapOptions", &json!({"detect": true, "size": size})).unwrap(); + let faces = if closed_by.contains(&size) { 1 } else { 0 }; + assert_eq!(r["faces"], json!(faces), "a {gap} pt gap with {size}"); + } + let info = s.execute("livePaint.info", &json!({"group": g.0})).unwrap(); + assert_eq!(info["gapOptions"], json!({"detect": true, "size": "large", "points": 12.0, "color": "#ff4f4f"})); + s.execute("livePaint.gapOptions", &json!({"size": "Medium"})).unwrap(); + assert_eq!(gap_options(&s, g).size, vectorcraft_doc::GapSize::Medium); + } +} + +#[test] +fn gap_options_keep_the_paint_of_split_faces_and_of_unpainted_faces() { + let mut s = session(); + // A red square with a divider that stops 2 pt short of its top edge. + let r = rect(&mut s, 0.0, 0.0, 100.0, 100.0); + set_fill(&mut s, r, Color::rgb(1.0, 0.0, 0.0)); + let l = line(&mut s, (50.0, 2.0), (50.0, 100.0)); + s.execute("select.set", &json!({"ids": [r.0, l.0]})).unwrap(); + let g = live_group_only(&mut s); + assert_eq!(vectorcraft_tools::builder::faces(&node(&s, g)).len(), 1); + s.execute("livePaint.fill", &json!({"group": g.0, "point": [25, 50], "color": "#00ff00"})).unwrap(); + let fill_at = |s: &Session, x: f64| { + let gn = node(s, g); + vectorcraft_tools::builder::face_at(&gn, vectorcraft_geom::Point::new(x, 50.0)).map(|f| f.appearance.fill_paint()) + }; + let green = Paint::solid(Color::from_hex("#00ff00").unwrap()); + // Closing the gap splits the face in two, and both halves keep its paint. + let r = s.execute("livePaint.gapOptions", &json!({"detect": true})).unwrap(); + assert_eq!(r["faces"], 2, "{r}"); + assert_eq!((fill_at(&s, 25.0), fill_at(&s, 75.0)), (Some(green.clone()), Some(green.clone()))); + // A face painted with none stays unpainted when the options change, and a face keeps its + // transparency and the rest of its look when the faces are made again. + s.execute("livePaint.fill", &json!({"group": g.0, "point": [75, 50], "none": true})).unwrap(); + s.execute("livePaint.gapOptions", &json!({"color": "Blue"})).unwrap(); + assert_eq!((fill_at(&s, 25.0), fill_at(&s, 75.0)), (Some(green.clone()), Some(Paint::None))); + let left = vectorcraft_tools::builder::face_at(&node(&s, g), vectorcraft_geom::Point::new(25.0, 50.0)).unwrap().id; + s.edit("t", |d, _| { + d.node_mut(left).unwrap().opacity = 0.5; + Ok(()) + }) + .unwrap(); + s.execute("livePaint.gapOptions", &json!({"size": 12})).unwrap(); + let gn = node(&s, g); + let f = vectorcraft_tools::builder::face_at(&gn, vectorcraft_geom::Point::new(25.0, 50.0)).unwrap(); + assert_eq!((f.opacity, f.appearance.fill_paint()), (0.5, green)); + assert_eq!(fill_at(&s, 75.0), Some(Paint::None)); +} + +#[test] +fn a_gradient_face_split_by_a_closed_gap_keeps_one_gradient_across_both_halves() { + use vectorcraft_color::{Gradient, GradientKind, GradientPaint, GradientStop}; + let mut s = session(); + let r = rect(&mut s, 0.0, 0.0, 100.0, 100.0); + let stops = vec![GradientStop::new(0.0, Color::rgb(1.0, 0.0, 0.0)), GradientStop::new(1.0, Color::rgb(0.0, 0.0, 1.0))]; + let gradient = Paint::Gradient(Box::new(GradientPaint::new(Gradient { kind: GradientKind::Linear, stops }))); + s.edit("t", |d, _| { + d.node_mut(r).unwrap().appearance.set_fill(gradient); + Ok(()) + }) + .unwrap(); + let l = line(&mut s, (50.0, 2.0), (50.0, 100.0)); + s.execute("select.set", &json!({"ids": [r.0, l.0]})).unwrap(); + let g = live_group_only(&mut s); + s.execute("livePaint.gapOptions", &json!({"detect": true})).unwrap(); + let gn = node(&s, g); + let geoms: Vec<_> = vectorcraft_tools::builder::faces(&gn) + .iter() + .map(|f| match f.appearance.fill_paint() { + Paint::Gradient(gp) => gp.geom, + other => panic!("{other:?}"), + }) + .collect(); + assert_eq!(geoms.len(), 2); + assert!(geoms[0].is_some() && geoms[0] == geoms[1], "both halves keep the square's fit: {geoms:?}"); +} + +#[test] +fn close_gaps_with_paths_adds_an_unpainted_path_across_each_gap() { + let mut s = session(); + let g = live_square_with_a_gap(&mut s); + let r = s.execute("livePaint.gapOptions", &json!({"closeGaps": true})).unwrap(); + assert_eq!((&r["faces"], &r["closed"], gaps_of(&r).len()), (&json!(1), &json!(1), 0), "{r}"); + assert!(!gap_options(&s, g).detect, "Gap Detection stays off: the path closes the gap"); + let gn = node(&s, g); + let src = vectorcraft_tools::builder::sources(&gn).unwrap(); + let added = src.children().unwrap().last().unwrap(); + assert!(added.appearance.fill_paint().is_none() && added.appearance.stroke_paint().is_none(), "unpainted"); + let bb = added.geometric_bounds().unwrap(); + assert!(bb.x0.abs() < 1e-9 && bb.x1.abs() < 1e-9 && bb.y0.abs() < 1e-9 && (bb.y1 - 2.0).abs() < 1e-9, "{bb:?}"); + assert!(s.execute("livePaint.fill", &json!({"group": g.0, "point": [50, 50]})).is_ok()); + // Release gives the closing path back with the others. + assert_eq!(ids(&s.execute("livePaint.release", &json!({"id": g.0})).unwrap()).len(), 2); +} + +#[test] +fn gap_options_survive_save_copy_and_undo() { + let mut s = session(); + let g = live_square_with_a_gap(&mut s); + s.execute("livePaint.gapOptions", &json!({"detect": true, "size": 4.5, "color": "#123456"})).unwrap(); + let want = vectorcraft_doc::GapOptions { detect: true, size: vectorcraft_doc::GapSize::Custom(4.5), color: [0x12, 0x34, 0x56] }; + assert_eq!(gap_options(&s, g), want); + let doc = s.doc().unwrap().doc.clone(); + let back = vectorcraft_format::load(&vectorcraft_format::save(&doc, false)).unwrap(); + assert_eq!(back.node(g).unwrap().gap_options(), want); + s.execute("edit.copy", &json!({})).unwrap(); + s.execute("edit.paste", &json!({})).unwrap(); + let pasted = s.doc().unwrap().selection.objects[0]; + assert_ne!(pasted, g); + assert_eq!(gap_options(&s, pasted), want); + assert_eq!(vectorcraft_tools::builder::faces(&node(&s, pasted)).len(), 1); + s.execute("edit.undo", &json!({})).unwrap(); + s.execute("edit.undo", &json!({})).unwrap(); + assert_eq!(gap_options(&s, g), vectorcraft_doc::GapOptions::default()); + assert!(vectorcraft_tools::builder::faces(&node(&s, g)).is_empty()); +} + +#[test] +fn merged_live_paint_groups_keep_the_bottom_groups_gap_options() { + use vectorcraft_doc::{GapOptions, GapSize}; + // Two groups side by side, the left one at the bottom, merged with each Gap Detection setting. + let merged = |bottom: Value, top: Value| { + let mut s = session(); + let groups: Vec = [0.0, 200.0] + .into_iter() + .zip([bottom, top]) + .map(|(x, opts)| { + let r = rect(&mut s, x, 0.0, 100.0, 100.0); + s.execute("select.set", &json!({"ids": [r.0]})).unwrap(); + let g = live_group_only(&mut s); + s.execute("livePaint.gapOptions", &opts).unwrap(); + g + }) + .collect(); + s.execute("select.set", &json!({"ids": [groups[0].0, groups[1].0]})).unwrap(); + let r = s.execute("livePaint.merge", &json!({})).unwrap(); + assert_eq!(NodeId(r["id"].as_u64().unwrap()), groups[0]); + gap_options(&s, groups[0]) + }; + let six = json!({"detect": true, "size": 6, "color": "Blue"}); + let twelve = json!({"detect": true, "size": 12}); + let off = json!({"detect": false, "size": 12, "color": "Blue"}); + let blue = [0x4f, 0x4f, 0xff]; + // The bottom group's options, with its Gap Detection on. + assert_eq!(merged(six.clone(), twelve), GapOptions { detect: true, size: GapSize::Custom(6.0), color: blue }); + // Detection on with Small Gaps when only a group above has it on. + assert_eq!(merged(off.clone(), six), GapOptions { detect: true, size: GapSize::Small, color: blue }); + // With detection off in every group, the bottom group's options. + assert_eq!(merged(off, json!({"detect": false})), GapOptions { detect: false, size: GapSize::Custom(12.0), color: blue }); +} + #[test] fn live_paint_bucket_tool_fills_face() { let mut s = session(); diff --git a/crates/pathops/src/lib.rs b/crates/pathops/src/lib.rs index d531b2cb8..9cfc0bd5c 100644 --- a/crates/pathops/src/lib.rs +++ b/crates/pathops/src/lib.rs @@ -20,7 +20,10 @@ pub use edit::{ }; pub use offset::{Cap, Join, offset_path, outline_stroke, stroke_region}; pub use pathfinder::{FaceMerger, PathfinderOp, Region, Shape, merge_regions, pathfinder, region_at, regions}; -pub use planar::{BuilderArrangement, SHAPE_BUILDER_MAX_SEGMENTS, cut_out, encloses_area, has_open_path, interior_point, live_paint, shape_builder}; +pub use planar::{ + BuilderArrangement, SHAPE_BUILDER_MAX_SEGMENTS, cut_out, encloses_area, find_gaps, has_open_path, interior_point, live_paint, + live_paint_with_gaps, shape_builder, +}; /// Errors from fallible operations. #[derive(Clone, Copy, Debug, PartialEq, Eq, thiserror::Error)] diff --git a/crates/pathops/src/planar.rs b/crates/pathops/src/planar.rs index 4aa828fcb..f9f0940b5 100644 --- a/crates/pathops/src/planar.rs +++ b/crates/pathops/src/planar.rs @@ -19,7 +19,7 @@ use std::collections::HashMap; -use kurbo::{Affine, BezPath, CubicBez, ParamCurve, PathSeg, Point, Shape as _}; +use kurbo::{Affine, BezPath, CubicBez, ParamCurve, ParamCurveArclen, PathSeg, Point, Shape as _}; use linesweeper::topology::{Topology, WindingNumber}; use vectorcraft_geom::{FillRule, PathData, SubPath}; @@ -307,6 +307,279 @@ pub fn live_paint(shapes: &[Shape]) -> (Vec, Vec) { build(shapes).unwrap_or_default() } +/// [`live_paint`] with Gap Detection: the faces are those of `shapes` with a line across each gap +/// [`find_gaps`] finds up to `max_gap`, so paint stops at the gaps, and the edges are those of +/// `shapes` alone (the lines add none). Also returns the gaps. +pub fn live_paint_with_gaps(shapes: &[Shape], max_gap: f64) -> (Vec, Vec, Vec<[Point; 2]>) { + let gaps = find_gaps(shapes, max_gap); + if gaps.is_empty() { + let (faces, edges) = live_paint(shapes); + return (faces, edges, gaps); + } + let mut closed = shapes.to_vec(); + let lines = PathData::new(gaps.iter().map(|g| SubPath::polyline(g, false)).collect()); + closed.push(Shape::new(lines, FillRule::NonZero, u64::MAX)); + let (faces, _) = live_paint(&closed); + let (_, edges) = live_paint(shapes); + (faces, edges, gaps) +} + +/// How many times longer than a gap the way between its two ends along the paths must be for it to +/// count: a loose end that crosses a path just before it (an overshoot), or a path that curls back +/// close to its own end (a hook), leaves no gap there. +const GAP_DETOUR: f64 = 8.0; + +/// How exactly [`find_gaps`] measures lengths along the paths. +const GAP_ARC_ACCURACY: f64 = 1e-4; + +/// How many points along a loose end's own segment [`find_gaps`] tries. +const GAP_SAMPLES: u32 = 64; + +/// One subpath's segments for [`find_gaps`], with the length along it where each starts. +struct Track { + segs: Vec, + /// The length at the start of each segment, then the whole length. + at: Vec, + closed: bool, + bounds: kurbo::Rect, +} + +impl Track { + fn new(sp: &SubPath) -> Option { + if sp.anchors.len() < 2 || !sp.anchors.iter().all(|a| a.p.is_finite() && a.h_in.is_finite() && a.h_out.is_finite()) { + return None; + } + let mut bp = BezPath::new(); + sp.to_bezpath_into(&mut bp); + let segs: Vec = bp.segments().collect(); + let mut at = Vec::with_capacity(segs.len() + 1); + let mut len = 0.0; + for s in &segs { + at.push(len); + len += s.arclen(GAP_ARC_ACCURACY); + } + at.push(len); + (!segs.is_empty()).then(|| Self { bounds: bp.bounding_box(), segs, at, closed: sp.closed }) + } + + fn len(&self) -> f64 { + self.at.last().copied().unwrap_or(0.0) + } + + /// The length along the subpath to parameter `t` of segment `k`. + fn pos(&self, k: usize, t: f64) -> f64 { + let Some(s) = self.segs.get(k) else { return self.len() }; + self.at.get(k).copied().unwrap_or(0.0) + s.subsegment(0.0..t).arclen(GAP_ARC_ACCURACY) + } + + /// The length along the subpath between two places on it (the shorter way round a closed one). + fn along(&self, a: f64, b: f64) -> f64 { + let d = (a - b).abs(); + if self.closed { d.min(self.len() - d) } else { d } + } +} + +/// Do two boxes (lines' boxes included, which have no area) touch or overlap? +fn boxes_meet(a: kurbo::Rect, b: kurbo::Rect) -> bool { + a.x0 <= b.x1 && b.x0 <= a.x1 && a.y0 <= b.y1 && b.y0 <= a.y1 +} + +/// Where track `o`, within `reach` along it of `from`, crosses the segments `near` of track `q`: +/// (length along `o`, length along `q`) of each crossing. +fn crossings(o: &Track, from: f64, reach: f64, q: &Track, near: impl Iterator) -> Vec<(f64, f64)> { + let near: Vec<(usize, &PathSeg)> = near.filter_map(|j| Some((j, q.segs.get(j)?))).collect(); + let mut out = vec![]; + // `first` is the first segment that ends within reach. + let first = o.at.partition_point(|&a| a < from - reach).saturating_sub(1); + for (k, s) in o.segs.iter().enumerate().skip(first) { + let (Some(&a0), Some(&a1)) = (o.at.get(k), o.at.get(k + 1)) else { continue }; + if a0 > from + reach { + break; + } + if a1 < from - reach || !boxes_meet(s.bounding_box(), q.bounds) { + continue; + } + // A curve is tried as 16 chords. + let n = if matches!(s, PathSeg::Line(_)) { 1 } else { 16 }; + let mut prev = s.start(); + for i in 1..=n { + let (t0, t1) = (f64::from(i - 1) / f64::from(n), f64::from(i) / f64::from(n)); + let p = s.eval(t1); + let chord = kurbo::Line::new(prev, p); + let cb = chord.bounding_box(); + for &(j, qs) in &near { + if !boxes_meet(qs.bounding_box(), cb) { + continue; + } + for x in qs.intersect_line(chord) { + out.push((o.pos(k, t0 + x.line_t * (t1 - t0)), q.pos(j, x.segment_t))); + } + } + prev = p; + } + } + out +} + +/// Most grid cells one segment is listed in by [`SegGrid`]; a longer one is tried for every point. +const GAP_GRID_SPAN: i64 = 1024; + +/// The farthest cell from the grid's origin, on each axis, that [`SegGrid`] numbers a point in. +const GAP_GRID_LIMIT: f64 = 1_048_576.0; + +/// The segments of [`find_gaps`]' tracks by the cells of a square grid that their bounds cover. +struct SegGrid { + origin: Point, + cell: f64, + /// (track, segment) by cell. + cells: HashMap<(i64, i64), Vec<(usize, usize)>>, + /// Segments whose bounds cover more than [`GAP_GRID_SPAN`] cells, near every point. + wide: Vec<(usize, usize)>, +} + +impl SegGrid { + fn new(tracks: &[Track], origin: Point, cell: f64) -> Self { + let mut grid = Self { origin, cell, cells: HashMap::new(), wide: vec![] }; + for (j, t) in tracks.iter().enumerate() { + for (k, s) in t.segs.iter().enumerate() { + let b = s.bounding_box(); + let ((x0, y0), (x1, y1)) = (grid.cell_of(Point::new(b.x0, b.y0)), grid.cell_of(Point::new(b.x1, b.y1))); + if (x1 - x0 + 1).saturating_mul(y1 - y0 + 1) > GAP_GRID_SPAN { + grid.wide.push((j, k)); + continue; + } + for x in x0..=x1 { + for y in y0..=y1 { + grid.cells.entry((x, y)).or_default().push((j, k)); + } + } + } + } + grid + } + + /// The cell `p` is in, counted from the origin (at most [`GAP_GRID_LIMIT`] cells away on each + /// axis). + fn cell_of(&self, p: Point) -> (i64, i64) { + let at = |v: f64, o: f64| ((v - o) / self.cell).floor().clamp(-GAP_GRID_LIMIT, GAP_GRID_LIMIT) as i64; + (at(p.x, self.origin.x), at(p.y, self.origin.y)) + } + + /// The segments whose bounds come within `r` of `p` (and some farther ones), each once, in + /// track order. + fn near(&self, p: Point, r: f64) -> Vec<(usize, usize)> { + let mut out = self.wide.clone(); + let ((x0, y0), (x1, y1)) = (self.cell_of(Point::new(p.x - r, p.y - r)), self.cell_of(Point::new(p.x + r, p.y + r))); + for x in x0..=x1 { + for y in y0..=y1 { + out.extend(self.cells.get(&(x, y)).into_iter().flatten()); + } + } + out.sort_unstable(); + out.dedup(); + out + } +} + +/// The point a gap from the loose end `e` of track `own` (at `from` along it) closes to: the +/// nearest point of any path within `max_gap` of it, leaving out points the paths already join to +/// `e` along a way less than [`GAP_DETOUR`] times as long as the gap. None when there is no such +/// point, or when `e` lies on a path (it is no loose end). +fn gap_from(tracks: &[Track], grid: &SegGrid, own: usize, e: Point, from: f64, max_gap: f64, eps: f64) -> Option { + use kurbo::ParamCurveNearest; + let o = tracks.get(own)?; + let reach = GAP_DETOUR * max_gap; + let mut best: Option<(f64, Point)> = None; + // `around` holds the segments a way of up to `reach` from `e` can cross, and `crossed` the + // crossings with each track. + let mut around: Option> = None; + let mut crossed: HashMap> = HashMap::new(); + for (j, k) in grid.near(e, max_gap) { + let Some(q) = tracks.get(j) else { continue }; + let Some(s) = q.segs.get(k) else { continue }; + let b = s.bounding_box(); + if e.x < b.x0 - max_gap || e.x > b.x1 + max_gap || e.y < b.y0 - max_gap || e.y > b.y1 + max_gap { + continue; + } + let near = s.nearest(e, 1e-9); + let d = near.distance_sq.sqrt(); + if d > max_gap { + continue; + } + let at = q.pos(k, near.t); + let way = if j == own { + o.along(from, at) + } else { + let xs = crossed.entry(j).or_insert_with(|| { + let segs = around.get_or_insert_with(|| grid.near(e, reach + max_gap)); + crossings(o, from, reach, q, segs.iter().filter(|c| c.0 == j).map(|c| c.1)) + }); + xs.iter().map(|&(a, b)| o.along(from, a) + q.along(b, at)).fold(f64::INFINITY, f64::min) + }; + if d <= eps { + // On another path, or on its own away from the end: the end is joined there. + if j != own || way > 4.0 * eps.max(GAP_ARC_ACCURACY) { + return None; + } + // The end's own segment: a curve that comes back round can still pass near it. + for i in 0..=GAP_SAMPLES { + let t = f64::from(i) / f64::from(GAP_SAMPLES); + let p = s.eval(t); + let d = p.distance(e); + if d > eps && d <= max_gap && o.along(from, q.pos(k, t)) > GAP_DETOUR * d && best.is_none_or(|b| d < b.0) { + best = Some((d, p)); + } + } + continue; + } + if way > GAP_DETOUR * d && best.is_none_or(|b| d < b.0) { + best = Some((d, s.eval(near.t))); + } + } + best.map(|b| b.1) +} + +/// The gaps Live Paint's Gap Detection closes in `shapes`: from each loose end of an open subpath +/// (an end that lies on no path) to the nearest point of any path, its own included, at most +/// `max_gap` away, unless the paths already join the two along a way less than [`GAP_DETOUR`] times +/// the gap's length. Each gap is a pair of points: the loose end, then where it closes to. A gap +/// between two loose ends that close to each other is listed once. +pub fn find_gaps(shapes: &[Shape], max_gap: f64) -> Vec<[Point; 2]> { + if !(max_gap.is_finite() && max_gap > 0.0) { + return vec![]; + } + let tracks: Vec = shapes.iter().flat_map(|s| &s.path.subpaths).filter_map(Track::new).collect(); + let eps = (max_gap * 1e-4).max(1e-6); + // The grid cells are as wide as the gap, or 1/4096 of the art where that is wider. + let bounds = tracks.iter().map(|t| t.bounds).reduce(|a, b| a.union(b)).unwrap_or_default(); + let grid = SegGrid::new(&tracks, bounds.origin(), max_gap.max(bounds.width().max(bounds.height()) / 4096.0)); + let mut gaps: Vec<[Point; 2]> = vec![]; + // `by_end` lists the gaps by the cell of their loose end. + let mut by_end: HashMap<(i64, i64), Vec> = HashMap::new(); + for (i, o) in tracks.iter().enumerate() { + let (Some(first), Some(last)) = (o.segs.first(), o.segs.last()) else { continue }; + // An open subpath that ends where it starts encloses its area already. + if o.closed || first.start().distance(last.end()) <= eps { + continue; + } + for (e, from) in [(first.start(), 0.0), (last.end(), o.len())] { + let Some(to) = gap_from(&tracks, &grid, i, e, from, max_gap, eps) else { continue }; + let (cx, cy) = grid.cell_of(to); + let listed = (cx - 1..=cx + 1) + .flat_map(|x| (cy - 1..=cy + 1).map(move |y| (x, y))) + .filter_map(|c| by_end.get(&c)) + .flatten() + .filter_map(|&g| gaps.get(g)) + .any(|g| g[0].distance(to) <= eps && g[1].distance(e) <= eps); + if !listed { + by_end.entry(grid.cell_of(e)).or_default().push(gaps.len()); + gaps.push([e, to]); + } + } + } + gaps +} + /// Most segments the Shape Builder sweeps into its planar map (open paths as edges, the gaps closed /// paths enclose, outline edges): the map costs far more than the filled areas' arrangement, so past /// this only the filled areas count. @@ -961,6 +1234,101 @@ mod tests { assert_eq!(edges.iter().filter(|e| e.key == 2).count(), 3); } + fn open(points: &[(f64, f64)], key: u64) -> Shape { + let pts: Vec = points.iter().map(|&(x, y)| Point::new(x, y)).collect(); + Shape::new(PathData::single(SubPath::polyline(&pts, false)), FillRule::NonZero, key) + } + + #[test] + fn gap_detection_closes_a_square_drawn_with_a_gap() { + // One open path round a square, its end 2 pt short of its start. + let s = [open(&[(0.0, 0.0), (100.0, 0.0), (100.0, 100.0), (0.0, 100.0), (0.0, 2.0)], 0)]; + assert!(live_paint(&s).0.is_empty(), "paint leaks through the gap"); + let (faces, edges, gaps) = live_paint_with_gaps(&s, 3.0); + assert_eq!(gaps.len(), 1, "{gaps:?}"); + let g = gaps[0]; + assert!(g[0].distance(Point::new(0.0, 0.0)) < 1e-9 && g[1].distance(Point::new(0.0, 2.0)) < 1e-6, "{g:?}"); + assert_eq!(faces.len(), 1, "{faces:?}"); + assert!((area(&faces[0]) - 10000.0).abs() < 1e-6, "{}", area(&faces[0])); + assert_eq!(edges, live_paint(&s).1, "the gap adds no edge"); + // A gap wider than the size stays open. + let (faces, _, gaps) = live_paint_with_gaps(&s, 1.5); + assert!(faces.is_empty() && gaps.is_empty()); + } + + #[test] + fn gaps_close_to_curves_and_between_paths() { + // A line across a circle, each end 2 pt short of it: the circle splits in two. + let circle = Shape::new(shapes::ellipse(Rect::new(50.0, 0.0, 150.0, 100.0)), FillRule::NonZero, 0); + let s = [circle, line((52.0, 50.0), (148.0, 50.0), 1)]; + assert_eq!(live_paint(&s).0.len(), 1); + let (faces, _, gaps) = live_paint_with_gaps(&s, 3.0); + assert_eq!(gaps.len(), 2, "{gaps:?}"); + for g in &gaps { + assert!((g[0].distance(g[1]) - 2.0).abs() < 1e-6, "{g:?}"); + } + assert_eq!(faces.len(), 2, "{faces:?}"); + assert_eq!(at(&faces, 100.0, 25.0).len(), 1); + assert_eq!(at(&faces, 100.0, 75.0).len(), 1); + // Four lines round a square whose corners miss by 1 pt: the gaps are listed once each. + let s = [ + line((0.0, 0.0), (99.0, 0.0), 0), + line((100.0, 1.0), (100.0, 99.0), 1), + line((99.0, 100.0), (1.0, 100.0), 2), + line((0.0, 99.0), (0.0, 1.0), 3), + ]; + let (faces, _, gaps) = live_paint_with_gaps(&s, 3.0); + assert_eq!(gaps.len(), 4, "{gaps:?}"); + assert_eq!(faces.len(), 1, "{faces:?}"); + // One curve that comes back round to 2 pt from its start. + let mut bp = BezPath::new(); + bp.move_to((0.0, 0.0)); + bp.curve_to((100.0, 0.0), (100.0, 100.0), (0.0, 2.0)); + let s = [Shape::new(PathData::from_bezpath(&bp), FillRule::NonZero, 0)]; + let (faces, _, gaps) = live_paint_with_gaps(&s, 3.0); + assert_eq!(gaps.len(), 1, "{gaps:?}"); + assert!((gaps[0][0].distance(gaps[0][1]) - 2.0).abs() < 1e-9, "{gaps:?}"); + assert_eq!(faces.len(), 1, "{faces:?}"); + // A line far longer than the gap, and many lines far from each other. + let mut s = vec![line((0.0, 0.0), (100000.0, 0.0), 0), line((50000.0, 2.0), (50000.0, 100.0), 1)]; + s.extend((0..2000).map(|k| { + let (x, y) = (f64::from(k % 50) * 40.0, 1000.0 + f64::from(k / 50) * 40.0); + line((x, y), (x + 10.0, y), 2) + })); + let gaps = find_gaps(&s, 3.0); + assert_eq!(gaps.len(), 1, "{gaps:?}"); + assert!(gaps[0][0].distance(Point::new(50000.0, 2.0)) < 1e-9 && gaps[0][1].distance(Point::new(50000.0, 0.0)) < 1e-9, "{gaps:?}"); + // Paths far from the origin: one gap between two loose ends, listed once. + let s = [line((1e20, 0.0), (1e20, 100.0), 0), line((1e20, 102.0), (1e20, 200.0), 1)]; + assert_eq!(find_gaps(&s, 3.0).len(), 1, "{:?}", find_gaps(&s, 3.0)); + } + + #[test] + fn overshoots_and_joined_ends_are_no_gaps() { + // Ends that cross a line just before they stop, square to it and at 30 degrees. + let t = 30f64.to_radians(); + let s = [ + line((-10.0, 0.0), (110.0, 0.0), 0), + line((20.0, 100.0), (20.0, -1.0), 1), + line((80.0 + 100.0 * t.cos(), 100.0 * t.sin()), (80.0 - t.cos(), -t.sin()), 2), + ]; + assert!(find_gaps(&s, 3.0).is_empty(), "{:?}", find_gaps(&s, 3.0)); + // An end on another path, or on its own, is joined there. + let s = [line((0.0, 0.0), (100.0, 0.0), 0), line((50.0, 0.0), (50.0, 100.0), 1)]; + assert!(find_gaps(&s, 3.0).is_empty(), "{:?}", find_gaps(&s, 3.0)); + // Another path 2 pt away does not make a gap from the joined end. + let s = [line((0.0, 0.0), (100.0, 0.0), 0), line((50.0, 0.0), (50.0, 100.0), 1), line((40.0, -2.0), (60.0, -2.0), 2)]; + let gaps = find_gaps(&s, 3.0); + assert!(!gaps.is_empty() && gaps.iter().all(|g| g[0].distance(Point::new(50.0, 0.0)) > 1e-9), "{gaps:?}"); + let six = [open(&[(0.0, 0.0), (0.0, 100.0), (50.0, 100.0), (50.0, 50.0), (0.0, 50.0)], 0)]; + assert!(find_gaps(&six, 3.0).is_empty(), "{:?}", find_gaps(&six, 3.0)); + // Sizes that are not a length find nothing. + let square = [open(&[(0.0, 0.0), (100.0, 0.0), (100.0, 100.0), (0.0, 100.0), (0.0, 2.0)], 0)]; + for size in [0.0, -3.0, f64::NAN, f64::INFINITY] { + assert!(find_gaps(&square, size).is_empty(), "{size}"); + } + } + #[test] fn a_grid_of_lines_makes_its_cells() { let mut s = vec![]; diff --git a/crates/ui-egui/src/canvas.rs b/crates/ui-egui/src/canvas.rs index 88e9bf265..cbfa68d91 100644 --- a/crates/ui-egui/src/canvas.rs +++ b/crates/ui-egui/src/canvas.rs @@ -487,11 +487,13 @@ pub fn show(app: &mut VectorcraftApp, ui: &mut Ui) { let revolve_edit = app.ui.dialog.as_ref().is_some_and(crate::dialogs::revolve_gizmo::active); let view_info = app.view_info(); // View → Hide Gradient Annotator hides the Gradient tool's annotator. - let overlays = if !revolve_edit && (app.session.tool_id() != "gradient" || app.ui.view.gradient_annotator) { + let mut overlays = if !revolve_edit && (app.session.tool_id() != "gradient" || app.ui.view.gradient_annotator) { app.session.overlays(view_info) } else { vec![] }; + // Gap Options' Preview shows the gaps it finds. + overlays.extend(crate::dialogs::live_paint_gaps::overlays(app)); if app.ui.view.edges && !revolve_edit { hover_highlight(app, &painter, &xf); selection_overlay(app, &painter, &xf, overlays.iter().any(|o| matches!(o, Overlay::CurvePoint { .. }))); diff --git a/crates/ui-egui/src/dialogs/live_paint_gaps.rs b/crates/ui-egui/src/dialogs/live_paint_gaps.rs new file mode 100644 index 000000000..3d99287f7 --- /dev/null +++ b/crates/ui-egui/src/dialogs/live_paint_gaps.rs @@ -0,0 +1,241 @@ +//! Object › Live Paint › Gap Options: Gap Detection, Paint Stops At (Small, Medium or Large Gaps, +//! or Custom with its size), Gap Preview Color (a layer color or a custom color) and Close Gaps +//! With Paths for the selected Live Paint groups, opened on the first group's options. With Preview +//! on, the options apply to the groups live and the gaps they find draw on the canvas in the preview +//! color; OK keeps them as one undo step (`livePaint.gapOptions`). +//! +//! Fields: `detect`, `size` (`small` | `medium` | `large` | `custom`), `custom` (pt), `color` +//! (`#rrggbb`, or a preset's name such as "Red"), `closeGaps`, `preview`, `__ids` (the groups) and +//! `__gaps` (the gaps the last preview found, as `livePaint.gapOptions` returns them). + +use serde_json::{Value, json}; +use vectorcraft_color::Color; +use vectorcraft_doc::{GapSize, LAYER_COLORS}; +use vectorcraft_geom::{BezPath, Point}; +use vectorcraft_tools::Overlay; + +use super::tile_edge_color::resolve; +use super::{DialogSpec, form}; +use crate::state::Dialog; +use crate::{VectorcraftApp, widgets}; + +/// The dialog kind of Gap Options. +pub const KIND: &str = "livePaintGapOptions"; + +const CMD: &str = "livePaint.gapOptions"; + +/// Width of the label column. +const LABEL_W: f32 = 120.0; + +/// Paint Stops At: (value, label). +const SIZES: [(&str, &str); 4] = [("small", "Small Gaps"), ("medium", "Medium Gaps"), ("large", "Large Gaps"), ("custom", "Custom")]; + +/// The private field holding the gaps of the last preview. +const GAPS: &str = "__gaps"; + +pub(super) const SPEC: DialogSpec = + DialogSpec { heading: |_| tl!("Gap Options").into(), body, confirm, preview: true, min_width: 340.0, ..DialogSpec::FORM }; + +/// Open Gap Options on the selected Live Paint groups, filled in from the first one's options. +pub fn open(app: &mut VectorcraftApp) -> Result { + let groups: Vec = app + .session + .doc() + .map(|d| { + let doc = &d.doc; + d.selection.objects.iter().filter(|id| doc.node(**id).is_some_and(vectorcraft_tools::builder::is_live_paint)).map(|id| id.0).collect() + }) + .unwrap_or_default(); + let Some(&first) = groups.first() else { return Err("select a Live Paint group".into()) }; + let info = app.session.execute("livePaint.info", &json!({ "group": first })).map_err(|e| e.to_string())?; + let o = &info["gapOptions"]; + let (size, custom) = match o["size"].as_str() { + Some(id) => (id.to_string(), o["points"].clone()), + None => ("custom".to_string(), o["size"].clone()), + }; + app.ui.dialog = Some(Dialog::new( + KIND, + json!({ + "detect": o["detect"], + "size": size, + "custom": custom, + "color": o["color"], + "closeGaps": false, + "preview": false, + "__ids": groups, + }), + )); + Ok(Value::Null) +} + +/// `livePaint.gapOptions` parameters from the fields. +fn params(d: &Dialog) -> Value { + let size = match d.str("size").as_str() { + "custom" => json!(d.f64("custom", GapSize::SMALL)), + id => json!(id), + }; + json!({ + "ids": d.fields.get("__ids").cloned().unwrap_or_default(), + "detect": d.bool("detect"), + "size": size, + "color": d.str("color"), + "closeGaps": d.bool("closeGaps"), + }) +} + +fn body(app: &mut VectorcraftApp, ui: &mut egui::Ui, d: &mut Dialog) -> bool { + form::check(ui, d, "detect", tl!("Gap Detection")); + ui.add_space(6.0); + let size = d.str("size"); + let shown = SIZES.iter().find(|(v, _)| *v == size).map_or(SIZES[0].1, |(_, l)| l); + let labels = SIZES.map(|(_, l)| l); + widgets::label_row(ui, tl!("Paint Stops At:"), LABEL_W, |ui| { + if let Some((value, _)) = widgets::dropdown(ui, "gap-size", shown, &labels, 130.0).and_then(|i| SIZES.get(i)) { + d.fields.insert("size".into(), json!(value)); + } + ui.add_space(6.0); + // The custom size field is enabled while Custom is chosen. + ui.add_enabled_ui(size == "custom", |ui| form::length(ui, d, "custom", app.session.general_unit(), 72.0)); + }); + let color = resolve(&d.str("color")).unwrap_or(Color::BLACK); + let [r, g, b, _] = color.to_rgba8(1.0); + let preset = LAYER_COLORS.iter().position(|(_, c)| *c == [r, g, b]); + let names: Vec<&str> = LAYER_COLORS.iter().map(|(n, _)| *n).chain(["Custom"]).collect(); + widgets::label_row(ui, tl!("Gap Preview Color:"), LABEL_W, |ui| { + if let Some((_, [r, g, b])) = + widgets::dropdown(ui, "gap-color", names[preset.unwrap_or(LAYER_COLORS.len())], &names, 130.0).and_then(|i| LAYER_COLORS.get(i)) + { + d.fields.insert("color".into(), json!(Color::rgb8(*r, *g, *b).to_hex())); + } + // The color button sets any other color; the dropdown then shows Custom. + let mut rgb = [r, g, b]; + if ui.color_edit_button_srgb(&mut rgb).changed() { + d.fields.insert("color".into(), json!(Color::rgb8(rgb[0], rgb[1], rgb[2]).to_hex())); + } + }); + ui.add_space(6.0); + form::check(ui, d, "closeGaps", tl!("Close Gaps With Paths")); + let p = params(d); + if let Some(r) = form::preview_result(app, ui, d, "Gap Options", CMD, p) { + d.fields.insert(GAPS.into(), r["gaps"].clone()); + } + if !d.bool("preview") { + d.fields.remove(GAPS); + } + false +} + +fn confirm(app: &mut VectorcraftApp, d: &Dialog) -> Result { + form::commit_preview(app, CMD, params(d)) +} + +/// The gaps the open Gap Options previews, as lines in its preview color (none while Preview is +/// off or another dialog is open). +pub(crate) fn overlays(app: &VectorcraftApp) -> Vec { + let Some(d) = app.ui.dialog.as_ref().filter(|d| d.kind == KIND && d.bool("preview")) else { return vec![] }; + let [r, g, b, _] = resolve(&d.str("color")).unwrap_or(Color::BLACK).to_rgba8(1.0); + let gaps = d.fields.get(GAPS).and_then(Value::as_array).map(Vec::as_slice).unwrap_or_default(); + gaps.iter() + .filter_map(|gap| { + let v: Vec = gap.as_array()?.iter().filter_map(Value::as_f64).collect(); + let [x0, y0, x1, y1] = v.as_slice() else { return None }; + let mut path = BezPath::new(); + path.move_to(Point::new(*x0, *y0)); + path.line_to(Point::new(*x1, *y1)); + Some(Overlay::Path { path, color: [r, g, b], width: 2.0, dashed: false }) + }) + .collect() +} + +#[cfg(test)] +mod tests { + use super::*; + use vectorcraft_doc::{GapOptions, NodeId}; + use vectorcraft_engine::Session; + + fn frame(app: &mut VectorcraftApp) { + let ctx = egui::Context::default(); + crate::theme::install_fonts(&ctx); + let mut out = ctx.run_ui(egui::RawInput::default(), |ui| super::super::show(app, ui.ctx())); + out.textures_delta.clear(); + } + + fn options(app: &VectorcraftApp, g: u64) -> GapOptions { + app.session.doc().unwrap().doc.node(NodeId(g)).unwrap().gap_options() + } + + fn faces(app: &VectorcraftApp, g: u64) -> usize { + vectorcraft_tools::builder::faces(app.session.doc().unwrap().doc.node(NodeId(g)).unwrap()).len() + } + + #[test] + fn the_menu_item_opens_gap_options_which_preview_the_gaps_and_ok_keeps_them() { + let mut app = VectorcraftApp::new(Session::new(), Default::default()); + app.run("file.new", json!({"width": 300, "height": 300})).unwrap(); + // An open path round a square, its end 2 pt short of its start. + let anchors = [(0, 0), (100, 0), (100, 100), (0, 100), (0, 2)].map(|(x, y)| json!({"x": x, "y": y})); + let p = app.run("path.create", json!({ "anchors": anchors })).unwrap(); + app.run("select.set", json!({"ids": [p["id"]]})).unwrap(); + // Without a Live Paint group the dialog does not open. + assert!(app.run("ui.livePaintGapOptions", json!({})).is_err()); + let g = app.run("livePaint.make", json!({})).unwrap()["id"].as_u64().unwrap(); + // Object › Live Paint › Gap Options… runs the command that opens the dialog. + let tree = crate::menus::menu_tree(); + let (_, object) = tree.iter().find(|(t, _)| *t == "Object").unwrap(); + let live = object.iter().find_map(|i| if let crate::menus::Item::Sub("Live Paint", items) = i { Some(items) } else { None }).unwrap(); + assert!(live.iter().any(|i| matches!(i, crate::menus::Item::Cmd("Gap Options…", "ui.livePaintGapOptions", _))), "{live:?}"); + crate::menus::invoke(&mut app, "ui.livePaintGapOptions", json!({})); + let d = app.ui.dialog.as_ref().expect("Object › Live Paint › Gap Options… opens the dialog"); + assert_eq!(d.kind, KIND); + assert_eq!((d.bool("detect"), d.str("size"), d.str("color")), (false, "small".to_string(), "#ff4f4f".to_string())); + frame(&mut app); + assert!(overlays(&app).is_empty(), "no preview yet"); + // Preview with Gap Detection and Small Gaps: the square fills as one face and its gap shows. + let d = app.ui.dialog.as_mut().unwrap(); + d.fields.insert("detect".into(), json!(true)); + d.fields.insert("size".into(), json!("small")); + d.fields.insert("color".into(), json!("Green")); + d.fields.insert("preview".into(), json!(true)); + frame(&mut app); + assert_eq!(faces(&app, g), 1, "previewed"); + let shown = overlays(&app); + assert_eq!(shown.len(), 1); + assert!(matches!(&shown[0], Overlay::Path { color: [0x4f, 0xff, 0x4f], .. }), "{shown:?}"); + // Cancel rolls the preview back. + crate::dialogs::cancel(&mut app); + assert_eq!((faces(&app, g), options(&app, g)), (0, GapOptions::default())); + assert!(overlays(&app).is_empty()); + // OK keeps a custom size as one undo step. + app.run("ui.livePaintGapOptions", json!({})).unwrap(); + let d = app.ui.dialog.as_mut().unwrap(); + d.fields.insert("detect".into(), json!(true)); + d.fields.insert("size".into(), json!("custom")); + d.fields.insert("custom".into(), json!(2.5)); + frame(&mut app); + let depth = app.session.doc().unwrap().history.undo.len(); + crate::dialogs::confirm(&mut app).unwrap(); + assert!(app.ui.dialog.is_none()); + assert_eq!(app.session.doc().unwrap().history.undo.len(), depth + 1); + assert_eq!(options(&app, g).size, GapSize::Custom(2.5)); + assert_eq!(faces(&app, g), 1); + // The dialog opens again on those options; Close Gaps With Paths adds the closing path. + app.run("ui.livePaintGapOptions", json!({})).unwrap(); + let d = app.ui.dialog.as_mut().unwrap(); + assert_eq!((d.str("size"), d.f64("custom", 0.0)), ("custom".to_string(), 2.5)); + d.fields.insert("detect".into(), json!(false)); + d.fields.insert("closeGaps".into(), json!(true)); + crate::dialogs::confirm(&mut app).unwrap(); + assert_eq!(faces(&app, g), 1, "closed by a path"); + let info = app.run("livePaint.info", json!({"group": g})).unwrap(); + assert_eq!((info["sources"].as_u64(), info["gaps"].as_array().map(Vec::len)), (Some(2), Some(0))); + // OK keeps Medium Gaps, and the dialog opens again on Large Gaps. + app.run("ui.livePaintGapOptions", json!({})).unwrap(); + app.ui.dialog.as_mut().unwrap().fields.insert("size".into(), json!("medium")); + crate::dialogs::confirm(&mut app).unwrap(); + assert_eq!(options(&app, g).size, GapSize::Medium); + app.run("livePaint.gapOptions", json!({"id": g, "size": "large"})).unwrap(); + app.run("ui.livePaintGapOptions", json!({})).unwrap(); + let d = app.ui.dialog.as_ref().unwrap(); + assert_eq!((d.str("size"), d.f64("custom", 0.0)), ("large".to_string(), GapSize::LARGE)); + } +} diff --git a/crates/ui-egui/src/dialogs/mod.rs b/crates/ui-egui/src/dialogs/mod.rs index 754dd9b4e..27b923cfe 100644 --- a/crates/ui-egui/src/dialogs/mod.rs +++ b/crates/ui-egui/src/dialogs/mod.rs @@ -49,6 +49,7 @@ pub mod javascript_files; pub mod layer_options; pub mod layers_panel_options; pub mod liquify; +pub mod live_paint_gaps; pub mod missing_fonts; pub mod missing_links; pub(crate) mod modal; @@ -314,6 +315,7 @@ registry! { PerspectiveGridPresets: [perspective_presets::KIND] => perspective_presets::SPEC, PerspectiveGridOptions: [perspective_options::KIND] => perspective_options::SPEC, BlendOptions: [blend_options::KIND] => blend_options::SPEC, + LivePaintGaps: [live_paint_gaps::KIND] => live_paint_gaps::SPEC, BrushOptions: [brush_options::KIND] => brush_options::SPEC, EditSelection: [edit_selection::KIND] => edit_selection::SPEC, PerspectivePlane: [perspective_plane::KIND] => perspective_plane::SPEC, diff --git a/crates/ui-egui/src/dialogs/tile_edge_color.rs b/crates/ui-egui/src/dialogs/tile_edge_color.rs index 785fafbcd..3c7854346 100644 --- a/crates/ui-egui/src/dialogs/tile_edge_color.rs +++ b/crates/ui-egui/src/dialogs/tile_edge_color.rs @@ -25,7 +25,7 @@ pub fn open(app: &mut VectorcraftApp) -> Result { } /// `color` (a preset's name or a hex colour) as a colour. -fn resolve(color: &str) -> Option { +pub(super) fn resolve(color: &str) -> Option { match LAYER_COLORS.iter().find(|(name, _)| name.eq_ignore_ascii_case(color.trim())) { Some((_, [r, g, b])) => Some(Color::rgb8(*r, *g, *b)), None => Color::from_hex(color), diff --git a/crates/ui-egui/src/i18n/bg.tsv b/crates/ui-egui/src/i18n/bg.tsv index e45753a1d..7529e75fe 100644 --- a/crates/ui-egui/src/i18n/bg.tsv +++ b/crates/ui-egui/src/i18n/bg.tsv @@ -1160,7 +1160,15 @@ Gamut Check Проверка за цветова гама Gamut Warning Предупреждение за цветова гама gap междуредрие - Gap Options… Опции за междуредрие… + Gap Options… Опции за пролуки… + Gap Options Опции за пролуки + Gap Detection Откриване на пролуки + Paint Stops At: Оцветяването спира при: + Small Gaps Малки пролуки + Medium Gaps Средни пролуки + Large Gaps Големи пролуки + Gap Preview Color: Цвят за преглед на пролуките: + Close Gaps With Paths Затваряне на пролуките с пътища Gaussian Blur… Гаусово размазване… General Общи General › Placement › X Общи › Разположение › X diff --git a/crates/ui-egui/src/i18n/cs.tsv b/crates/ui-egui/src/i18n/cs.tsv index d976565b4..364b04547 100644 --- a/crates/ui-egui/src/i18n/cs.tsv +++ b/crates/ui-egui/src/i18n/cs.tsv @@ -255,6 +255,14 @@ Live Paint Živá malba Merge Sloučit Gap Options… Možnosti mezer… + Gap Options Možnosti mezer + Gap Detection Detekce mezer + Paint Stops At: Barva se zastaví u: + Small Gaps Malé mezery + Medium Gaps Střední mezery + Large Gaps Velké mezery + Gap Preview Color: Barva náhledu mezer: + Close Gaps With Paths Uzavřít mezery cestami Image Trace Vektorizace obrazu Image Trace (Make and Expand) Vektorizace obrazu (vytvořit a rozložit) Release Image Trace Uvolnit vektorizaci obrazu diff --git a/crates/ui-egui/src/i18n/de.tsv b/crates/ui-egui/src/i18n/de.tsv index b89256771..5e60de9d6 100644 --- a/crates/ui-egui/src/i18n/de.tsv +++ b/crates/ui-egui/src/i18n/de.tsv @@ -1179,6 +1179,14 @@ file-filter Templates Vorlagen Gamut Warning Farbumfang-Warnung gap Lücke Gap Options… Lückenoptionen… + Gap Options Lückenoptionen + Gap Detection Lückenerkennung + Paint Stops At: Farbe stoppt bei: + Small Gaps Kleine Lücken + Medium Gaps Mittlere Lücken + Large Gaps Große Lücken + Gap Preview Color: Vorschaufarbe für Lücken: + Close Gaps With Paths Lücken mit Pfaden schließen Gaussian Blur… Gaußscher Weichzeichner… General Allgemein General › Placement › X Allgemein › Platzierung › X diff --git a/crates/ui-egui/src/i18n/el.tsv b/crates/ui-egui/src/i18n/el.tsv index 0a0b5b126..9974d0586 100644 --- a/crates/ui-egui/src/i18n/el.tsv +++ b/crates/ui-egui/src/i18n/el.tsv @@ -1126,6 +1126,14 @@ Gamut Warning Προειδοποίηση χρωματικής γκάμας gap διάκενο Gap Options… Επιλογές διακένου… + Gap Options Επιλογές διακένου + Gap Detection Εντοπισμός διακένων + Paint Stops At: Η βαφή σταματά σε: + Small Gaps Μικρά διάκενα + Medium Gaps Μεσαία διάκενα + Large Gaps Μεγάλα διάκενα + Gap Preview Color: Χρώμα προεπισκόπησης διακένων: + Close Gaps With Paths Κλείσιμο διακένων με διαδρομές Gaussian Blur… Θόλωση Γκάους… General Γενικά General › Placement › X Γενικά › Τοποθέτηση › X diff --git a/crates/ui-egui/src/i18n/es.tsv b/crates/ui-egui/src/i18n/es.tsv index 43a670760..3dba235d1 100644 --- a/crates/ui-egui/src/i18n/es.tsv +++ b/crates/ui-egui/src/i18n/es.tsv @@ -1186,6 +1186,14 @@ Gamut Warning Aviso de gama gap hueco Gap Options… Opciones de huecos… + Gap Options Opciones de huecos + Gap Detection Detección de huecos + Paint Stops At: La pintura se detiene en: + Small Gaps Huecos pequeños + Medium Gaps Huecos medianos + Large Gaps Huecos grandes + Gap Preview Color: Color de previsualización de huecos: + Close Gaps With Paths Cerrar huecos con trazados Gaussian Blur… Desenfoque gaussiano… General General General › Placement › X General › Colocación › X diff --git a/crates/ui-egui/src/i18n/fi.tsv b/crates/ui-egui/src/i18n/fi.tsv index b99e7f5e7..38a09bdc6 100644 --- a/crates/ui-egui/src/i18n/fi.tsv +++ b/crates/ui-egui/src/i18n/fi.tsv @@ -252,6 +252,14 @@ Live Paint Elävä maalaus Merge Yhdistä Gap Options… Aukkojen asetukset… + Gap Options Aukkojen asetukset + Gap Detection Aukkojen tunnistus + Paint Stops At: Maalaus pysähtyy: + Small Gaps Pienet aukot + Medium Gaps Keskikokoiset aukot + Large Gaps Suuret aukot + Gap Preview Color: Aukkojen esikatseluväri: + Close Gaps With Paths Sulje aukot poluilla Image Trace Kuvan jäljitys Image Trace (Make and Expand) Kuvan jäljitys (luo ja laajenna) Release Image Trace Vapauta kuvan jäljitys diff --git a/crates/ui-egui/src/i18n/fr.tsv b/crates/ui-egui/src/i18n/fr.tsv index 8acb072bb..153606d9b 100644 --- a/crates/ui-egui/src/i18n/fr.tsv +++ b/crates/ui-egui/src/i18n/fr.tsv @@ -1185,6 +1185,14 @@ Gamut Warning Alerte de gamme gap espace Gap Options… Options d’espacement… + Gap Options Options d’espacement + Gap Detection Détection des espaces + Paint Stops At: Arrêt de la peinture à : + Small Gaps Petits espaces + Medium Gaps Espaces moyens + Large Gaps Grands espaces + Gap Preview Color: Couleur d’aperçu des espaces : + Close Gaps With Paths Fermer les espaces avec des tracés Gaussian Blur… Flou gaussien… General Général General › Placement › X Général › Placement › X diff --git a/crates/ui-egui/src/i18n/hu.tsv b/crates/ui-egui/src/i18n/hu.tsv index 871457f0c..2dd0a06e3 100644 --- a/crates/ui-egui/src/i18n/hu.tsv +++ b/crates/ui-egui/src/i18n/hu.tsv @@ -1491,6 +1491,14 @@ file-filter Templates Sablonok Gamut Warning Színtartomány-figyelmeztetés gap hézag Gap Options… Hézagbeállítások… + Gap Options Hézagbeállítások + Gap Detection Hézagészlelés + Paint Stops At: Festés határa: + Small Gaps Kis hézagok + Medium Gaps Közepes hézagok + Large Gaps Nagy hézagok + Gap Preview Color: Hézagok előnézeti színe: + Close Gaps With Paths Hézagok lezárása útvonalakkal Gaussian Blur Gauss-elmosás Gaussian Blur… Gauss-elmosás… General Általános diff --git a/crates/ui-egui/src/i18n/it.tsv b/crates/ui-egui/src/i18n/it.tsv index 5e81340bf..935b9fbfc 100644 --- a/crates/ui-egui/src/i18n/it.tsv +++ b/crates/ui-egui/src/i18n/it.tsv @@ -1195,6 +1195,14 @@ file-filter Templates Modelli Gamut Warning Avviso gamut gap spazio Gap Options… Opzioni spazi vuoti… + Gap Options Opzioni spazi vuoti + Gap Detection Rilevamento spazi vuoti + Paint Stops At: La pittura si ferma a: + Small Gaps Spazi vuoti piccoli + Medium Gaps Spazi vuoti medi + Large Gaps Spazi vuoti grandi + Gap Preview Color: Colore anteprima spazi vuoti: + Close Gaps With Paths Chiudi spazi vuoti con tracciati Gaussian Blur… Sfocatura gaussiana… General Generale General › Placement › X Generale › Posizionamento › X diff --git a/crates/ui-egui/src/i18n/ja.tsv b/crates/ui-egui/src/i18n/ja.tsv index 152499ee9..3f0467f19 100644 --- a/crates/ui-egui/src/i18n/ja.tsv +++ b/crates/ui-egui/src/i18n/ja.tsv @@ -252,6 +252,14 @@ Live Paint ライブペイント Merge 結合 Gap Options… 間隔オプション… + Gap Options 間隔オプション + Gap Detection 間隔の検出 + Paint Stops At: ペイントが止まる間隔: + Small Gaps 小さい間隔 + Medium Gaps 中程度の間隔 + Large Gaps 大きい間隔 + Gap Preview Color: 間隔のプレビューカラー: + Close Gaps With Paths パスで間隔を閉じる Image Trace 画像トレース Image Tracing 画像トレースオブジェクト Tracing Presets トレースプリセット diff --git a/crates/ui-egui/src/i18n/ko.tsv b/crates/ui-egui/src/i18n/ko.tsv index 19d427e53..aad786f05 100644 --- a/crates/ui-egui/src/i18n/ko.tsv +++ b/crates/ui-egui/src/i18n/ko.tsv @@ -248,6 +248,14 @@ Live Paint 라이브 페인트 Merge 병합 Gap Options… 간격 옵션… + Gap Options 간격 옵션 + Gap Detection 간격 감지 + Paint Stops At: 페인트 중지 간격: + Small Gaps 작은 간격 + Medium Gaps 중간 간격 + Large Gaps 큰 간격 + Gap Preview Color: 간격 미리 보기 색상: + Close Gaps With Paths 패스로 간격 닫기 Image Trace 이미지 추적 Image Tracing 이미지 추적 Tracing Presets 추적 프리셋 diff --git a/crates/ui-egui/src/i18n/pl.tsv b/crates/ui-egui/src/i18n/pl.tsv index 0d45f90df..4e1b330c5 100644 --- a/crates/ui-egui/src/i18n/pl.tsv +++ b/crates/ui-egui/src/i18n/pl.tsv @@ -220,6 +220,14 @@ Live Paint Aktywne malowanie Merge Scal Gap Options… Opcje przerw… + Gap Options Opcje przerw + Gap Detection Wykrywanie przerw + Paint Stops At: Farba zatrzymuje się na: + Small Gaps Małe przerwy + Medium Gaps Średnie przerwy + Large Gaps Duże przerwy + Gap Preview Color: Kolor podglądu przerw: + Close Gaps With Paths Zamknij przerwy ścieżkami Image Trace Obrys obrazu Text Wrap Oblewanie tekstem Text Wrap Options… Opcje oblewania tekstem… diff --git a/crates/ui-egui/src/i18n/pt-br.tsv b/crates/ui-egui/src/i18n/pt-br.tsv index 1e89afa99..40637503d 100644 --- a/crates/ui-egui/src/i18n/pt-br.tsv +++ b/crates/ui-egui/src/i18n/pt-br.tsv @@ -1044,6 +1044,14 @@ Gamut Check Verificação de Gamut Gamut Warning Aviso de Gamut Gap Options… Opções de Lacuna… + Gap Options Opções de Lacuna + Gap Detection Detecção de Lacunas + Paint Stops At: Pintura Para Em: + Small Gaps Lacunas Pequenas + Medium Gaps Lacunas Médias + Large Gaps Lacunas Grandes + Gap Preview Color: Cor de Visualização das Lacunas: + Close Gaps With Paths Fechar Lacunas com Caminhos Gaussian Blur… Desfoque Gaussiano… General Geral Generate CSS Gerar CSS diff --git a/crates/ui-egui/src/i18n/ru.tsv b/crates/ui-egui/src/i18n/ru.tsv index b8fffe5cd..0492be082 100644 --- a/crates/ui-egui/src/i18n/ru.tsv +++ b/crates/ui-egui/src/i18n/ru.tsv @@ -1193,6 +1193,14 @@ file-filter Templates Шаблоны Gamut Warning Предупреждение о цветовом охвате gap зазор Gap Options… Параметры зазоров… + Gap Options Параметры зазоров + Gap Detection Обнаружение зазоров + Paint Stops At: Заливка останавливается на: + Small Gaps Малые зазоры + Medium Gaps Средние зазоры + Large Gaps Большие зазоры + Gap Preview Color: Цвет просмотра зазоров: + Close Gaps With Paths Закрыть зазоры контурами Gaussian Blur… Размытие по Гауссу… General Общие General › Placement › X Общие › Размещение › X diff --git a/crates/ui-egui/src/i18n/sv.tsv b/crates/ui-egui/src/i18n/sv.tsv index ce50f4240..f39561df6 100644 --- a/crates/ui-egui/src/i18n/sv.tsv +++ b/crates/ui-egui/src/i18n/sv.tsv @@ -1163,6 +1163,14 @@ Gamut Warning Färgomfångsvarning gap mellanrum Gap Options… Alternativ för mellanrum… + Gap Options Alternativ för mellanrum + Gap Detection Identifiering av mellanrum + Paint Stops At: Målningen stannar vid: + Small Gaps Små mellanrum + Medium Gaps Medelstora mellanrum + Large Gaps Stora mellanrum + Gap Preview Color: Förhandsgranskningsfärg för mellanrum: + Close Gaps With Paths Stäng mellanrum med banor Gaussian Blur… Gaussisk oskärpa… General Allmänt General › Placement › X Allmänt › Placering › X diff --git a/crates/ui-egui/src/i18n/uk.tsv b/crates/ui-egui/src/i18n/uk.tsv index 8849c3d83..ec6d7143f 100644 --- a/crates/ui-egui/src/i18n/uk.tsv +++ b/crates/ui-egui/src/i18n/uk.tsv @@ -1189,6 +1189,14 @@ file-filter Templates Шаблони Gamut Warning Попередження про колірне охоплення gap зазор Gap Options… Параметри проміжків… + Gap Options Параметри проміжків + Gap Detection Виявлення проміжків + Paint Stops At: Заливка зупиняється на: + Small Gaps Малі проміжки + Medium Gaps Середні проміжки + Large Gaps Великі проміжки + Gap Preview Color: Колір перегляду проміжків: + Close Gaps With Paths Закрити проміжки контурами Gaussian Blur… Розмиття за Гауссом… General Загальні General › Placement › X Загальні › Розміщення › X diff --git a/crates/ui-egui/src/i18n/vi.tsv b/crates/ui-egui/src/i18n/vi.tsv index f17434f84..79a1adf6e 100644 --- a/crates/ui-egui/src/i18n/vi.tsv +++ b/crates/ui-egui/src/i18n/vi.tsv @@ -1367,6 +1367,14 @@ file-filter Templates Mẫu Gamut Warning Cảnh báo ngoài dải màu gap khoảng cách Gap Options… Tùy chọn khoảng cách… + Gap Options Tùy chọn khoảng cách + Gap Detection Phát hiện khoảng cách + Paint Stops At: Ngừng tô tại: + Small Gaps Khoảng cách nhỏ + Medium Gaps Khoảng cách vừa + Large Gaps Khoảng cách lớn + Gap Preview Color: Màu xem trước khoảng cách: + Close Gaps With Paths Đóng khoảng cách bằng đường dẫn Gaussian Blur Mờ Gaussian Gaussian Blur… Làm mờ Gaussian… General Chung diff --git a/crates/ui-egui/src/i18n/zh-hans.tsv b/crates/ui-egui/src/i18n/zh-hans.tsv index d646dc88f..37794c29c 100644 --- a/crates/ui-egui/src/i18n/zh-hans.tsv +++ b/crates/ui-egui/src/i18n/zh-hans.tsv @@ -1142,6 +1142,14 @@ file-filter Templates 模板 Gamut Warning 色域警告 gap 间隔 Gap Options… 间隙选项… + Gap Options 间隙选项 + Gap Detection 间隙检测 + Paint Stops At: 上色停止于: + Small Gaps 小间隙 + Medium Gaps 中等间隙 + Large Gaps 大间隙 + Gap Preview Color: 间隙预览颜色: + Close Gaps With Paths 用路径封闭间隙 Gaussian Blur… 高斯模糊… General 常规 General › Placement › X 一般 › 位置 › X diff --git a/crates/ui-egui/src/i18n/zh-hant.tsv b/crates/ui-egui/src/i18n/zh-hant.tsv index 2e31ae3a0..9abd3ed5a 100644 --- a/crates/ui-egui/src/i18n/zh-hant.tsv +++ b/crates/ui-egui/src/i18n/zh-hant.tsv @@ -1166,6 +1166,14 @@ file-filter Templates 範本 Gamut Warning 色域警告 gap 間隙 Gap Options… 間隙選項… + Gap Options 間隙選項 + Gap Detection 間隙偵測 + Paint Stops At: 上色停止於: + Small Gaps 小間隙 + Medium Gaps 中等間隙 + Large Gaps 大間隙 + Gap Preview Color: 間隙預覽顏色: + Close Gaps With Paths 以路徑封閉間隙 Gaussian Blur… 高斯模糊… General 一般 General › Placement › X 一般 › 位置 › X diff --git a/crates/ui-egui/src/menus.rs b/crates/ui-egui/src/menus.rs index 4c090c90a..74ebdab01 100644 --- a/crates/ui-egui/src/menus.rs +++ b/crates/ui-egui/src/menus.rs @@ -977,6 +977,12 @@ pub const UI_COMMANDS: &[(&str, &str, &str, &str)] = &[ "", "{} open Blend Options (Object › Blend › Blend Options…, the Blend tool's double-click, Alt-click and toolbar button; dialog `blendOptions`: spacing smooth|steps|distance, steps, distance (pt), orientation page|path, preview) on the selected blend's options, previewed live: OK runs object.blend.options as one undo step; with no blend selected it sets what new blends start with", ), + ( + "ui.livePaintGapOptions", + "Gap Options…", + "", + "{} open Gap Options (Object › Live Paint › Gap Options…; dialog `livePaintGapOptions`: detect, size small|medium|large|custom, custom (pt), color (#rrggbb or a layer color's name), closeGaps, preview) on the selected Live Paint groups, filled in from the first; with preview on, the options apply live and the gaps found draw on the canvas in the preview color: OK runs livePaint.gapOptions as one undo step", + ), ( "ui.perspectivePlane", "Perspective Plane Options…", @@ -1647,6 +1653,7 @@ pub fn run_ui_command(app: &mut VectorcraftApp, id: &str, p: &Value) -> Option Err("no such perspective grid preset".into()), }, "ui.blendOptions" => crate::dialogs::blend_options::open(app), + "ui.livePaintGapOptions" => crate::dialogs::live_paint_gaps::open(app), "ui.perspectivePlane" => crate::dialogs::perspective_plane::open(app, p), _ => return None, }; @@ -2559,7 +2566,7 @@ pub fn menu_tree_named(english_names: bool) -> Vec<(&'static str, Vec)> { c("Merge", "livePaint.merge"), c("Release", "livePaint.release"), Sep, - todo("Gap Options…"), + c("Gap Options…", "ui.livePaintGapOptions"), Sep, c("Expand", "livePaint.expand"), ], diff --git a/docs/mcp.md b/docs/mcp.md index 39dc97156..9d634ed1c 100644 --- a/docs/mcp.md +++ b/docs/mcp.md @@ -1116,6 +1116,14 @@ already walks Image Trace. Short agent entry points: action on every file in a folder or every data set (the Actions panel menu's Batch…). - Image Trace: `imageTrace.presets`, `imageTrace.make {id?, preset?, params?, view?}` (or `imageTrace.makeAndExpand`), `imageTrace.setView`, `imageTrace.expand`, `imageTrace.release`. +- Live Paint: `livePaint.make`, `livePaint.merge`, `livePaint.fill {group?, point}`, `livePaint.strokeEdge`, + `livePaint.info {group}`, `livePaint.release`, `livePaint.expand`, and `livePaint.gapOptions {ids?, detect?, size?, + color?, closeGaps?}` (Object › Live Paint › Gap Options). With `detect: true`, paint stops at the gaps that run + from a loose end of a path to the nearest path, up to `size` wide (`small` 3 pt, `medium` 6 pt, `large` 12 pt, or a + number of points up to 72); `closeGaps: true` adds an unpainted path across each of them. The options are saved with the group, and the reply + and `livePaint.info` list the gaps up to `size` wide that no path closes, as `[x0, y0, x1, y1]`. `livePaint.merge` + keeps the bottom group's options when its Gap Detection is on, and otherwise turns Gap Detection on with `small` + when another merged group has it on. When Gap Detection is off in every merged group, it stays off. `dataset.select`, `dataset.next` and `dataset.prev` reply `{name, applied, skipped}`. They add `overflow: [id…]` when area type or type on a path whose text they rewrote doesn't fit, as `text.setText` does (see diff --git a/docs/target-app-parity.md b/docs/target-app-parity.md index 6a5b9af9f..27ffe48b8 100644 --- a/docs/target-app-parity.md +++ b/docs/target-app-parity.md @@ -593,6 +593,7 @@ Moved from the ROADMAP's "Shipped so far" on 2026-10-10. Update it when a featur - **Image Trace, measured:** `crates/trace/tests/quality` traces drawings made from known vector art with every preset and [`image-trace-quality.md`](image-trace-quality.md) records the numbers against the baseline. Black and White, Grayscale and Color place each edge by the blend of the pixels across it (to a fraction of a pixel on anti-aliased and blurred images; hard-edged images are read as staircases) and fit outlines as Flat Logo does. *Measured* on each preset's own inputs, all sizes and variants: Default's outlines went from 0.31 px off on average to 0.09 (largest 1.51 to 1.02), Black and White Logo's from 0.27 to 0.06 with 0.3 kinks per trace where it had 15, and the gaps between abutting paths from 64 px to 22. On clean curved shapes Black and White Logo is 0.02 px off (the open tracer measured beside it: 0.20). The palette is made of the picture's own colours (blends along edges take none of it, colours that noise made of one colour merge, a blend pixel goes to one of the two colours it lies between; #1504): 16 Colors on flat art, all variants, went from 70 paths to 4.2 and from 5.1 px off to 0.13, with no path left in a colour the art doesn't have (5.9 before). Photos are smoothed within their tones before they are quantised and stacked layers go by lightness (#1516): High Fidelity Photo went from 3,653 paths to 121 with its colour error down from 2.30 to 1.75, and a 16 megapixel photo-like image is traced in 3.5 s where 4 megapixels were refused. Since then, by the same harness: outlines are cut at their junctions and each shared piece is fitted once (#1535: gaps and overlaps between abutting paths 22 to 50 px of area down to under 2); layers are traced within the box of their pixels and a few at a time (#1546: a 16 megapixel photo 3.9 GB to 0.9 GB); black and white are read against the image's own black and the paper near each edge (#1563, for thin lines in blurred and scanned art); Ignore Color and the Auto Color and Outline presets (#1574); Snap Curves To Lines levels lines within 2.5 degrees of level or upright (#1593, the angle our choice); Create: Strokes puts centre lines in the middle of their ink and makes them as wide as it (#1595: Outline 0.295 to 0.052 px from the true centre line, 18.4 to 2.7 anchors a stroke); sharp corners whose pixel is mostly outside the shape stay corners (#1609: Default 69% to 76% kept). Three options, each off unless asked for as the legacy presets have them: Shapes (#1609: 57 to 62% of plain circles, ellipses, rectangles and regular polygons come out as live shapes, rounded rectangles rarely), Auto Grouping (#1618: paths grouped with what lies inside them, the same picture) and Gradients (#1627: linear gradients traced as one gradient-filled path; on gradient inputs 6 Colors' colour error 2.12 to 0.62 with 6.4 to 3.4 paths, none found in flat art). Snap Curves To Lines moves no end of a line by more than 1.6 px, so long lines a degree or two off stay as the image has them (#1647: Technical Drawing's largest distance 4.21 px to 1.46). Flat Logo takes the tones a scan's uneven paper makes of one colour for one colour (#1657: on scans 470 paths and 21 px off to 8.5 paths and 0.09 px). Shapes finds rounded rectangles and shapes in aliased, blurred and scanned art (#1845: 57 to 62% of the plain shapes up to 98 to 100%, no false shape but one line 2 px wide). Stroked lines get square caps where their ink is cut square, and a wide end that turned towards a corner goes back on its line (#1846: Outline's largest distance 2.94 px to 0.88, 14 anchors to 10). Each of the 14 documented presets has an enhanced version with Auto Grouping and, in all but High Fidelity Photo, Shapes (#1847), measured beside its preset: equal or better within 0.005 px and 1% of edge ΔE on every preset's own inputs (Default 0.091 to 0.083 px); Auto Grouping keeps traced order in its groups, which removed a light seam along the counters of letters. Gradients grows a gradient over the pixels noise and soft rims took off it (#1848: blurred and noisy linear gradients 3 to 11 paths beside the gradient down to 0.5 with 6 Colors, 24 to 56 down to 0.5 to 1.5 with High Fidelity Photo). Silhouettes is 0.52 px off because its threshold is 200 (0.11 px at 128). Still weak, by the same numbers: rounded rectangles and other line-and-arc outlines take 12 to 16 anchors (the fitter does not split lines from arcs), blurred and scanned polygons lose corners, hairlines under half a pixel are lost to the threshold, aliased art is about 0.25 px off, a scan's uneven paper splits into tones when more colours are asked for than the art has, stroke junctions stay at their skeleton pixels, a three-stop gradient in a small or scanned disc is not found, radial gradients stay bands (a draft, #1849, traces them; the documented option is for linear ones, so it waits for a decision), near-regular polygons a few dozen pixels across are made regular with Shapes (up to 0.5 px off), and aliased stroked lines are 0.8 px off at worst. - **Image Trace Flat Logo mode:** a fourth mode beside Black and White, Grayscale and Color, for flat-colour logos. Each pixel is read as a blend of the logo's colours (found automatically or named), the blend is interpolated smoothly and every outline is placed where two colours are equally present, so edges are accurate to a fraction of a pixel. Outlines are then cleaned: corners the blur rounded are put back, near-straight runs and near-circular arcs become exact lines and arcs, the rest is fitted with as few Béziers as the tolerance allows, touching colours overlap by a hair so no gap opens, and an outline that strays from the raw contour is fitted plainly instead. Gradients and photos are refused with advice to use Color. Line art whose only solid area is white paper (strokes a pixel or two wide) takes its colours from the strokes' cores, with the lighter shades along a stroke's anti-aliased edge folded into its ink; a page whose ink (a channel at least 48 below white) covers under 1% of it traces to nothing, as blank paper does. *Measured* (a local experiment, not run in CI): a flat-colour logo's vector original was rendered down to a soft 199 × 100 px transparent PNG, traced, and the result compared with the original at 1600 px wide. The built-in colour presets (6 Colors, 16 Colors, High Fidelity Photo, Low Fidelity Photo) had a mean edge error of 2.5 to 3.4 px and region overlap of 0.88 to 0.91; Flat Logo had 0.78 px and 0.966 (0.48 px and 0.975 from a crisp source), in about 0.1 s. Limits: flat-colour art only; needle-thin sharp tips are shortened by about 1.7 source pixels because the blur erases them; it works at no more than 1024 px on the long side (larger images are averaged down first); only one logo family has been scored against a vector original. - **Advanced art:** live Blends (steps/distance/smooth colour, editable spine, anchor-targeted Blend tool, faithful interpolation, knockout), Envelope Distort (warp/mesh/top object, Reset, full Envelope Options, mesh handles, Edit Contents; type, images, symbols, gradients, patterns and appearance distorted everywhere), Liquify tools with options and pen pressure, Puppet Warp with rotating pins, Perspective Grid with presets, movable planes and projected type (choosing a perspective tool shows the grid; Hide Grid hides it with the tool still chosen), Gradient Mesh, Shape Builder (stroke-only open paths and lines cut the regions they cross and enclose regions of their own, and regions they bound merge without seams where the lines cross at rounded coordinates (#893); filled open paths count as closed; erase mode while Alt/Option is held: a minus pointer, red highlights of what will go, a click or drag deletes the regions touched, the pieces of open lines and the edges (outline pieces between the points where paths meet, opening their paths there) it passes over, one undo step, `shapeBuilder.merge {erase}` and `shapeBuilder.regions` edges over MCP), Live Paint (every path an edge, open lines included), Image Trace (12 presets, colour layers traced in parallel; a trace past two million anchor points is refused with advice rather than running out of memory, an image over 64 megapixels is traced scaled down to that size (aspect kept, paths at the image's own size) and one over 192 megapixels is refused, and a selection too large to outline shows its bounds (#525); Object › Image Trace › Make and Make and Expand trace at once with the Default preset, #543; with an image selected the Control bar and the Properties panel show Image Trace, whose arrow traces with any preset, Mask (`object.maskImage`: a clipping path on the image's outline, selected so its handles crop), Crop Image (a crop box on the image, starting on its part over the artboard, dragged by its handles or inside it, the outside dimmed, with X/Y/W/H, Apply and Cancel in the Control bar, Enter and Escape, #734; `object.cropImage {rect}` for agents, `{trim: true}` cuts an image to its pixels that aren't fully transparent, pixel for pixel) and Embed or Unembed in place of Fill and Stroke; with an Image Trace object selected, its Preset (choosing another traces again), View, the Image Trace panel and Expand, plus Release in Properties; the five View modes (Tracing Result, with Outlines, Outlines, Outlines with Source Image, Source Image) redraw a trace on screen without tracing again, are saved with it and `imageTrace.setView` sets them, while exports and Expand keep the result; the panel's Advanced section is a header that stays open or closed as it was left (part of #641); Color mode's Palette: Limited, Full Tone (as many colours as the tones need, Less … More), Automatic (flat art with its own colours, else Full Tone; a heuristic) and Document Library (the most used colours of the document's swatches or a swatch library, filled with the swatch colours as they are), part of #641; Advanced › Create: Fills and/or Strokes, where lines up to the Stroke width trace as stroked centre lines (distance transform, thinning, a skeleton graph with spurs pruned, curve fitting; one width per line; without Fills, wider areas become stroked outlines), part of #641), pattern swatches with pattern editing mode, live Repeat (radial/grid/mirror). Object › Expand turns radial, grid and mirror repeats into groups of their instances (#1765, #1756). + Live Paint Gap Options (Object › Live Paint › Gap Options…, `livePaint.gapOptions`): with Gap Detection on, paint stops at the gaps that run from a loose end of a path to the nearest path, up to Small (3 pt), Medium (6 pt), Large (12 pt) or a custom size; Close Gaps With Paths adds an unpainted path across each gap; Preview applies the options live and draws the gaps in the Gap Preview Color. The options are saved, copied and undone with the group, and Merge keeps the bottom group's options when its Gap Detection is on, or else turns Gap Detection on with Small when another merged group has it on. When Gap Detection is off in every merged group, it stays off. Window › Blend opens the Blend panel: Make, the selected blend's Spacing and Orientation (with no blend selected, the options new blends start with), Reverse Front to Back, Reverse Spine, Replace Spine, Expand and Release, each running its Object › Blend command. Make is dimmed until two or more objects are selected. Spacing and Orientation are dimmed until a blend or two or more objects are selected, and the other buttons are dimmed until a blend is selected. - **Paint and appearance (M3):** - **Swatches:** names follow the colour model and are unique; Swatch Options (process/spot, Global, Gray/RGB/HSB/CMYK/Web, live preview); editing a global or spot swatch recolours every linked fill, stroke and text run in one undo step; multi-select and delete with confirmation; New Swatch and New Color Group dialogs (a group from the selected artwork's colours); swatches in colour groups convert, separate and count like any other. The Swatches panel selects ranges and whole groups, has a find field, and drags swatches to reorder, regroup or paint art; Add Used Colors, Select All Unused, Merge, Ungroup and Sort by Kind; a CMYK document keeps applied colours in CMYK. Swatch libraries: nine generated colour libraries and five gradient libraries in a library panel, saved as `.vcswatches`, `.gpl`, swatch exchange (`.ase`) or CSS, and loaded from `.vcswatches`, `.gpl` and `.ase` files (`.ase` both ways: RGB, CMYK, Lab and Gray; global, spot and process; groups) and from color books (`.acb`, read only: the RGB, CMYK or Lab colors of a book such as a Pantone book a user owns, named with its prefix and suffix, spot unless it says process, #831); `swatch.library.copyToUser` copies a loaded library into the User Defined folder without replacing a file, and a file with the same extension and bytes as a User Defined library opens as that library. Tints of global and spot colours ("Name 40%") stay linked and separate as a percentage of their plate. Spot colours can be defined in Lab (Spot Colors options). A built-in [Registration] swatch prints on every plate.