mirror of
https://github.com/storytold/vectorcraft.git
synced 2026-10-26 02:37:01 -06:00
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:
@@ -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;
|
||||
|
||||
@@ -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() });
|
||||
}
|
||||
}
|
||||
@@ -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));
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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 ----------
|
||||
|
||||
@@ -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)),
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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)]
|
||||
|
||||
@@ -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![];
|
||||
|
||||
@@ -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));
|
||||
}
|
||||
}
|
||||
@@ -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),
|
||||
|
||||
@@ -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.
|
@@ -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.
|
@@ -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.
|
@@ -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
|
||||
|
||||
|
@@ -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.
|
@@ -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.
|
@@ -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.
|
@@ -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.
|
@@ -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.
|
@@ -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.
|
@@ -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.
|
@@ -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.
|
@@ -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.
|
@@ -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.
|
@@ -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.
|
@@ -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.
|
@@ -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
|
||||
|
||||
|
@@ -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.
|
@@ -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.
|
@@ -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"),
|
||||
],
|
||||
|
||||
@@ -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
Reference in New Issue
Block a user