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.
This commit is contained in:
dotCooCoo
2026-10-17 15:14:43 +07:00
committed by bflatastic
parent be553fa3cf
commit f9759afa30
35 changed files with 1328 additions and 17 deletions
+2
View File
@@ -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;
+130
View File
@@ -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<Self> {
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<f64> {
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() });
}
}
+1
View File
@@ -250,6 +250,7 @@ fn node_bytes(n: &Node, other: Option<&Node>) -> usize {
if let Some(e) = n.extras_box() {
b += ALLOC + size_of::<NodeExtras>();
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::<Value>() + json_bytes(t));
b += e.intertwine.as_deref().map_or(0, |a| ALLOC + size_of::<PathData>() + path_bytes(a));
}
+10
View File
@@ -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<Box<PathData>>,
/// Live Paint group: its Gap Options (Object › Live Paint › Gap Options; `None`: the defaults).
pub live_paint_gaps: Option<Box<crate::live_paint::GapOptions>>,
}
/// 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<Box<crate::dimension::Dimension>>,
#[serde(default)]
intertwine: Option<Box<PathData>>,
#[serde(default)]
live_paint_gaps: Option<Box<crate::live_paint::GapOptions>>,
}
impl From<NodeWire> for Node {
@@ -832,6 +839,7 @@ impl From<NodeWire> 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<String>);
@@ -2096,6 +2105,7 @@ mod tests {
"slice",
"bbox_angle",
"perspective",
"live_paint_gaps",
];
assert_eq!(keys, order);
assert_eq!(serde_json::from_value::<Node>(v).unwrap(), n);
+168 -10
View File
@@ -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<CommandSpec> {
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<CommandSpec> {
"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<Arc<Node>> {
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<Arc<Node>>) -> Result<(Vec<Arc<Node>>, 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<Node>]) -> (Vec<po::Shape>, 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<Arc<Node>>) -> Result<(Vec<Arc<
styles.push((a.fill_paint(), a.stroke_paint(), a.stroke().map(|s| s.width).unwrap_or(1.0)));
}
}
(shapes, styles)
}
/// Planar map children (sources group, faces, edges) for `sources`; with `max_gap` (Gap
/// Detection), paint stops at the gaps up to that wide.
fn build_children(d: &mut Document, sources: Vec<Arc<Node>>, max_gap: Option<f64>) -> Result<(Vec<Arc<Node>>, 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<Arc<Node>>) -> Result<(Vec<Arc<
src_group.visible = false;
children.push(Arc::new(src_group));
// Every path is an edge, open ones included; faces are the areas they enclose.
let (regs, edges) = po::live_paint(&shapes);
let (regs, edges) = match max_gap {
Some(gap) => {
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<Arc<Node>>) -> Result<(Vec<Arc<
Ok((children, nfaces, nedges))
}
/// Give the faces and edges of `g`, a Live Paint group made again from the sources of `old`, the
/// look of `old`'s face or edge where they lie: each face takes everything but the outline (its
/// appearance, none included, its transparency and the rest) of the old face its inside point is
/// in, and each edge that of the old edge its middle lies on. Faces and edges where `old` had none
/// keep the paint of their sources.
fn repaint_from(g: &mut Node, old: &Node) {
/// How far an edge's middle may be from the old edge it lies on.
const ON_EDGE: f64 = 1e-3;
let Some(ch) = g.children_mut() else { return };
for c in ch.iter_mut() {
let Some(pd) = c.path_data() else { continue };
let was = match c.name.as_deref() {
Some(FACE_NAME) => 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<Box<GapOptions>> {
(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<Value> {
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<GapOptions> = 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<Value> {
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<Value> {
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::<Vec<_>>())
}
/// 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<Value> {
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<Value> {
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<Value> {
.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 ----------
+1 -1
View File
@@ -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<LayerColor, String> {
pub(super) fn layer_color_param(v: &Value) -> std::result::Result<LayerColor, String> {
match v {
Value::Number(n) => match n.as_u64() {
Some(i) if (i as usize) < LAYER_COLORS.len() => Ok(LayerColor::Preset(i as u8)),
+226
View File
@@ -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<Vec<f64>> {
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<NodeId> = [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();
+4 -1
View File
@@ -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)]
+369 -1
View File
@@ -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<Region>, Vec<Shape>) {
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<Region>, Vec<Shape>, 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<PathSeg>,
/// The length at the start of each segment, then the whole length.
at: Vec<f64>,
closed: bool,
bounds: kurbo::Rect,
}
impl Track {
fn new(sp: &SubPath) -> Option<Self> {
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<PathSeg> = 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<Item = usize>) -> 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<Point> {
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<Vec<(usize, usize)>> = None;
let mut crossed: HashMap<usize, Vec<(f64, f64)>> = 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<Track> = 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<usize>> = 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<Point> = 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![];
+3 -1
View File
@@ -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 { .. })));
@@ -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<Value, String> {
let groups: Vec<u64> = 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<Value, String> {
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<Overlay> {
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<f64> = 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));
}
}
+2
View File
@@ -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,
@@ -25,7 +25,7 @@ pub fn open(app: &mut VectorcraftApp) -> Result<Value, String> {
}
/// `color` (a preset's name or a hex colour) as a colour.
fn resolve(color: &str) -> Option<Color> {
pub(super) fn resolve(color: &str) -> Option<Color> {
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),
+9 -1
View File
@@ -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
Can't render this file because it contains an unexpected character in line 2 and column 73.
+8
View File
@@ -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
Can't render this file because it contains an unexpected character in line 2 and column 73.
+8
View File
@@ -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
Can't render this file because it contains an unexpected character in line 2 and column 73.
+8
View File
@@ -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
1 # Ελληνικά (el) — VectorCraft
1126
1127
1128
1129
1130
1131
1132
1133
1134
1135
1136
1137
1138
1139
+8
View File
@@ -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
Can't render this file because it contains an unexpected character in line 2 and column 73.
+8
View File
@@ -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
Can't render this file because it contains an unexpected character in line 2 and column 73.
+8
View File
@@ -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
Can't render this file because it contains an unexpected character in line 2 and column 73.
+8
View File
@@ -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
Can't render this file because it contains an unexpected character in line 2 and column 73.
+8
View File
@@ -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
Can't render this file because it contains an unexpected character in line 2 and column 73.
+8
View File
@@ -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 トレースプリセット
Can't render this file because it contains an unexpected character in line 2 and column 73.
+8
View File
@@ -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 추적 프리셋
Can't render this file because it contains an unexpected character in line 2 and column 73.
+8
View File
@@ -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…
Can't render this file because it contains an unexpected character in line 2 and column 73.
+8
View File
@@ -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
Can't render this file because it contains an unexpected character in line 6 and column 47.
+8
View File
@@ -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
Can't render this file because it contains an unexpected character in line 2 and column 73.
+8
View File
@@ -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
Can't render this file because it contains an unexpected character in line 5 and column 25.
+8
View File
@@ -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
Can't render this file because it contains an unexpected character in line 722 and column 40.
+8
View File
@@ -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
1 # Vietnamese (vi) catalog. Same format as zh-hant.tsv: context <TAB> source <TAB> translation
1367
1368
1369
1370
1371
1372
1373
1374
1375
1376
1377
1378
1379
1380
+8
View File
@@ -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
Can't render this file because it contains an unexpected character in line 2 and column 73.
+8
View File
@@ -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
Can't render this file because it contains an unexpected character in line 6 and column 47.
+8 -1
View File
@@ -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<R
None => 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<Item>)> {
c("Merge", "livePaint.merge"),
c("Release", "livePaint.release"),
Sep,
todo("Gap Options…"),
c("Gap Options…", "ui.livePaintGapOptions"),
Sep,
c("Expand", "livePaint.expand"),
],
+8
View File
@@ -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
File diff suppressed because one or more lines are too long