mirror of
https://github.com/storytold/vectorcraft.git
synced 2026-11-15 12:24:19 +00:00
Merge upstream main into pencil-no-guides
# Conflicts: # docs/target-app-parity.md
This commit is contained in:
@@ -35,11 +35,11 @@ mod map;
|
||||
use cache::{BACKGROUND_SEGMENTS, CacheKey, RegionCache};
|
||||
#[cfg(test)]
|
||||
use live_paint::LpHover;
|
||||
use live_paint::lp_color;
|
||||
pub use live_paint::{
|
||||
LP_BUCKET_EDGE_FRACTION, LP_BUCKET_WIDTH, LP_BUCKET_WIDTH_RANGE, LP_FILL_CHUNK_POINTS, LP_SELECT_WIDTH, LP_SELECT_WIDTH_RANGE,
|
||||
LivePaintBucketTool, LivePaintSelectionTool,
|
||||
};
|
||||
use live_paint::{check_live_paint_options, lp_color, lp_rgb_color};
|
||||
pub use lookup::{
|
||||
EDGE_NAME, FACE_NAME, Face, LIVE_PAINT_NAME, LIVE_PAINT_PREFIX, SOURCES_NAME, builder_members, drawn_outlines, edge_near, edges, face_at, faces,
|
||||
is_live_paint, leaf_shapes, live_paint_at, live_paint_takes, node_outline, outline_snap, path_contains, shape_builder_shapes, shapes_for,
|
||||
@@ -777,6 +777,39 @@ impl Tool for ShapeBuilderTool {
|
||||
}
|
||||
}
|
||||
|
||||
/// Why `tool.setOption` cannot give the builder-family tool `tool` the options `values`, checked
|
||||
/// before any of them is set. For `shapeBuilder`: an option the tool does not have, a switch that
|
||||
/// is not true or false, a `pickColorFrom` other than `artwork` or `swatches`, a `selection` other
|
||||
/// than `freeform` or `straightLine`, a `highlightColor` that is not an RGB color without an alpha
|
||||
/// part and a `gapLength` that is not a finite number are errors. A finite gap length out of 0 to
|
||||
/// [`SHAPE_BUILDER_MAX_GAP`] is not an error: the tool clamps it. The Live Paint tools are checked
|
||||
/// by [`check_live_paint_options`]; Ok for every other tool. Saved settings go to the tools'
|
||||
/// `set_option` instead, which skips a value it cannot read.
|
||||
pub fn check_builder_options(tool: &str, values: &serde_json::Map<String, Value>) -> Result<(), String> {
|
||||
if tool != "shapeBuilder" {
|
||||
return check_live_paint_options(tool, values);
|
||||
}
|
||||
let one_of = |key: &str, v: &Value, names: &[&str]| {
|
||||
(!v.as_str().is_some_and(|s| names.contains(&s))).then(|| format!("`{key}` must be one of {}", names.join(", ")))
|
||||
};
|
||||
for (key, v) in values {
|
||||
let error = match key.as_str() {
|
||||
"openFilledAsClosed" | "cursorSwatchPreview" | "highlightFill" | "gapDetection" | "clickStrokeSplitsPath" | "highlightStroke" => {
|
||||
(!v.is_boolean()).then(|| format!("`{key}` must be true or false"))
|
||||
}
|
||||
"pickColorFrom" => one_of(key, v, &["artwork", "swatches"]),
|
||||
"selection" => one_of(key, v, &["freeform", "straightLine"]),
|
||||
"gapLength" => (!v.as_f64().is_some_and(f64::is_finite)).then(|| format!("`{key}` must be a number")),
|
||||
"highlightColor" => (!lp_rgb_color(v)).then(|| format!("`{key}` must be a #rrggbb color or a layer color name")),
|
||||
_ => Some(format!("unknown Shape Builder option `{key}`")),
|
||||
};
|
||||
if let Some(e) = error {
|
||||
return Err(e);
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Create a builder-family tool by id (None = not one of ours).
|
||||
pub fn create(id: &str) -> Option<Box<dyn Tool>> {
|
||||
Some(match id {
|
||||
|
||||
@@ -594,6 +594,42 @@ pub(super) fn lp_color(v: &Value) -> Option<[u8; 3]> {
|
||||
Some([r, g, b])
|
||||
}
|
||||
|
||||
/// Whether `v` is a highlight color [`lp_color`] reads without an alpha part: `#rrggbbaa` (with
|
||||
/// or without `#`) is not one.
|
||||
pub(super) fn lp_rgb_color(v: &Value) -> bool {
|
||||
let Some(s) = v.as_str() else { return false };
|
||||
let digits = s.trim().trim_start_matches('#');
|
||||
let alpha = digits.len() == 8 && digits.chars().all(|c| c.is_ascii_hexdigit());
|
||||
!alpha && lp_color(v).is_some()
|
||||
}
|
||||
|
||||
/// Why `tool.setOption` cannot give the Live Paint tool `tool` (`livePaintBucket` or
|
||||
/// `livePaintSelection`) the options `values`, checked before any of them is set: an option the
|
||||
/// tool does not have, a switch that is not true or false, a `color` that is not an RGB color
|
||||
/// ([`lp_rgb_color`]) and a `width` that is not a finite number are errors. A finite width out of
|
||||
/// [`LP_BUCKET_WIDTH_RANGE`] or [`LP_SELECT_WIDTH_RANGE`] is not an error: the tool clamps it.
|
||||
/// Ok for every other tool. Saved settings go to the tools' `set_option` instead, which skips a
|
||||
/// value it cannot read.
|
||||
pub fn check_live_paint_options(tool: &str, values: &serde_json::Map<String, Value>) -> Result<(), String> {
|
||||
let switches: &[&str] = match tool {
|
||||
"livePaintBucket" => &["paintFills", "paintStrokes", "highlight"],
|
||||
"livePaintSelection" => &["selectFills", "selectStrokes"],
|
||||
_ => return Ok(()),
|
||||
};
|
||||
for (key, v) in values {
|
||||
let error = match key.as_str() {
|
||||
"color" => (!lp_rgb_color(v)).then(|| format!("`{key}` must be a #rrggbb color or a layer color name")),
|
||||
"width" => (!v.as_f64().is_some_and(f64::is_finite)).then(|| format!("`{key}` must be a number")),
|
||||
k if switches.contains(&k) => (!v.is_boolean()).then(|| format!("`{key}` must be true or false")),
|
||||
_ => Some(format!("unknown Live Paint option `{key}`")),
|
||||
};
|
||||
if let Some(e) = error {
|
||||
return Err(e);
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
impl Tool for LivePaintSelectionTool {
|
||||
fn id(&self) -> &'static str {
|
||||
"livePaintSelection"
|
||||
|
||||
@@ -39,9 +39,17 @@ pub const MIN_FIT_TOLERANCE: f64 = MIN_FIDELITY / 640.0;
|
||||
/// A change to either range changes this bound with it.
|
||||
pub const MAX_FIT_TOLERANCE: f64 = 100.0 / 0.0313;
|
||||
|
||||
/// The lowest Fidelity (points) the Smooth tool takes.
|
||||
/// The lowest Fidelity (screen pixels) the Smooth tool takes.
|
||||
pub const SMOOTH_MIN_FIDELITY: f64 = 0.05;
|
||||
|
||||
/// The finest tolerance (points) `path.smoothRegion` refits at: the Smooth tool's lowest Fidelity at
|
||||
/// the highest zoom (64000 %), whose screen pixels the tools send in points.
|
||||
pub const SMOOTH_MIN_TOLERANCE: f64 = SMOOTH_MIN_FIDELITY / 640.0;
|
||||
|
||||
/// The coarsest tolerance (points) `path.smoothRegion` refits at: the highest Fidelity (100 screen
|
||||
/// pixels) at the lowest zoom (3.13 %).
|
||||
pub const SMOOTH_MAX_TOLERANCE: f64 = 100.0 / 0.0313;
|
||||
|
||||
/// Whether segment `i` of `sp` is straight, so the Smooth tool leaves it as it is: its handles lie
|
||||
/// on the line through its ends, and the segment runs from one end to the other without turning back.
|
||||
pub fn segment_is_straight(sp: &SubPath, i: usize) -> bool {
|
||||
|
||||
@@ -72,7 +72,7 @@ const BAND_WORK_PER_CALL: usize = 20_000;
|
||||
/// a fit's cost grows as its Fidelity shrinks (at 0.01 pt a long pen stroke lagged, #4565), and
|
||||
/// a preview within half a pixel of the stroke looks the same. The release fits it at its Fidelity.
|
||||
const PREVIEW_FIDELITY_PX: f64 = 0.5;
|
||||
/// The Smooth tool's Fidelity until Smooth Tool Options sets another.
|
||||
/// The Smooth tool's Fidelity (screen pixels) until Smooth Tool Options sets another.
|
||||
const SMOOTH_FIDELITY: f64 = 2.5;
|
||||
|
||||
/// The `fidelity` (points) the Pencil and the Paintbrush send `path.freehand` for `px` screen pixels
|
||||
@@ -107,8 +107,8 @@ pub struct GestureTool {
|
||||
/// Paths' distance in points); the stroke redraws the section it ends on (#2850).
|
||||
redraw: Option<(NodeId, f64)>,
|
||||
/// Fit tolerance (Pencil/Paintbrush/Smooth/Blob Brush "Fidelity"): in screen pixels for the
|
||||
/// Pencil and the Paintbrush, so a stroke keeps as many points at any zoom (#5729), in points
|
||||
/// for the others.
|
||||
/// Pencil and the Paintbrush, so a stroke keeps as many points at any zoom (#5729), and for the
|
||||
/// Smooth tool, so a drag smooths as much at any zoom; in points for the Blob Brush.
|
||||
pub fidelity: f64,
|
||||
/// Brush / eraser diameter in points (the Eraser's across its tip's long axis).
|
||||
pub size: f64,
|
||||
@@ -139,8 +139,8 @@ pub struct GestureTool {
|
||||
/// preview shows the points joined by straight segments and the release fits them (Pencil
|
||||
/// only, on by default).
|
||||
pub live_fit: bool,
|
||||
/// The Smooth tool's Fidelity, used when Alt switches the Pencil, Paintbrush or Blob Brush to Smooth.
|
||||
/// The engine sets it from Smooth Tool Options (`smoothFidelity`).
|
||||
/// The Smooth tool's Fidelity (screen pixels), used when Alt switches the Pencil, Paintbrush or
|
||||
/// Blob Brush to Smooth. The engine sets it from Smooth Tool Options (`smoothFidelity`).
|
||||
smooth_fidelity: f64,
|
||||
/// Active drag is smoothing with the Pencil, Paintbrush or Blob Brush (Alt held).
|
||||
smoothing: bool,
|
||||
@@ -1178,7 +1178,7 @@ impl GestureTool {
|
||||
if has_sel && pts.len() >= 2 {
|
||||
return vec![Action::Exec(
|
||||
"path.smoothRegion".into(),
|
||||
json!({"points": points_json(pts), "radius": radius, "fidelity": self.smooth_fidelity}),
|
||||
json!({"points": points_json(pts), "radius": radius, "fidelity": cx.tol(self.smooth_fidelity)}),
|
||||
)];
|
||||
}
|
||||
return vec![];
|
||||
@@ -1228,7 +1228,10 @@ impl GestureTool {
|
||||
)],
|
||||
"knife" if pts.len() >= 2 => vec![Action::Exec("path.knife".into(), json!({"points": points_json(pts)}))],
|
||||
"smooth" if has_sel && pts.len() >= 2 => {
|
||||
vec![Action::Exec("path.smoothRegion".into(), json!({"points": points_json(pts), "radius": radius, "fidelity": self.fidelity}))]
|
||||
vec![Action::Exec(
|
||||
"path.smoothRegion".into(),
|
||||
json!({"points": points_json(pts), "radius": radius, "fidelity": cx.tol(self.fidelity)}),
|
||||
)]
|
||||
}
|
||||
"pathEraser" if has_sel => vec![Action::Exec("path.eraseSegments".into(), json!({"points": points_json(pts), "width": cx.tol(8.0)}))],
|
||||
"join" if pts.len() >= 2 => vec![Action::Exec("path.joinScrub".into(), json!({"points": points_json(pts), "tolerance": tol}))],
|
||||
|
||||
@@ -627,7 +627,7 @@ fn blob_brush_alt_switches_to_the_smooth_tool() {
|
||||
let acts = drag_held(&mut t, &cx, &stroke, alt);
|
||||
assert!(acts[..acts.len() - 1].iter().all(|a| a.is_empty()), "{acts:?}");
|
||||
let v = smoothed(&acts).unwrap_or_else(|| panic!("{acts:?}"));
|
||||
assert_eq!(v, json!({"points": [[50.0, 150.0], [150.0, 150.0], [250.0, 160.0]], "radius": cx.tol(12.0), "fidelity": SMOOTH_FIDELITY}));
|
||||
assert_eq!(v, json!({"points": [[50.0, 150.0], [150.0, 150.0], [250.0, 160.0]], "radius": cx.tol(12.0), "fidelity": cx.tol(SMOOTH_FIDELITY)}));
|
||||
// Mid-drag the cursor stays the Smooth tool's when Alt is let go, and the line is thin with
|
||||
// no length label.
|
||||
t.pointer(&cx, &PointerEvent::new(PointerKind::Down, 50.0, 150.0).with_mods(alt));
|
||||
@@ -1541,6 +1541,37 @@ fn alt_smoothing_uses_the_smooth_tools_fidelity() {
|
||||
}
|
||||
}
|
||||
|
||||
/// The Smooth tool's Fidelity is in screen pixels: the Smooth tool and an Alt smoothing drag with
|
||||
/// the Pencil, Paintbrush or Blob Brush send it in points at Fidelity / zoom, so a drag smooths as
|
||||
/// much on screen at any zoom.
|
||||
#[test]
|
||||
fn smooth_fidelity_is_in_screen_pixels_at_any_zoom() {
|
||||
let (d, id) = doc_with_rect();
|
||||
let mut s = Selection::default();
|
||||
s.set([id]);
|
||||
let p = paint();
|
||||
let mut cx = cx(&d, &s, &p);
|
||||
cx.smart_guides = false;
|
||||
let pts = [(50.0, 150.0), (150.0, 150.0), (250.0, 160.0)];
|
||||
let fidelity = |acts: &[Vec<Action>]| match acts.last().and_then(|a| a.first()) {
|
||||
Some(Action::Exec(c, v)) if c == "path.smoothRegion" => v["fidelity"].as_f64().unwrap(),
|
||||
a => panic!("expected path.smoothRegion, got: {a:?}"),
|
||||
};
|
||||
let alt = Mods { alt: true, ..Default::default() };
|
||||
for zoom in [0.25, 1.0, 8.0] {
|
||||
cx.zoom = zoom;
|
||||
let mut t = GestureTool::new("smooth");
|
||||
t.set_option("fidelity", &json!(4.0));
|
||||
assert_eq!(fidelity(&drag(&mut t, &cx, &pts, Mods::default())), 4.0 / zoom, "smooth at {zoom}x");
|
||||
for (id, alt_smooth) in [("pencil", true), ("blobBrush", false), ("paintbrush", false)] {
|
||||
let mut t = GestureTool::new(id);
|
||||
t.set_option("altSmooth", &json!(alt_smooth));
|
||||
t.set_option("smoothFidelity", &json!(4.0));
|
||||
assert_eq!(fidelity(&drag_held(&mut t, &cx, &pts, alt)), 4.0 / zoom, "{id} at {zoom}x");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Alt pressed after a Paintbrush stroke begins closes it, and an empty selection still draws.
|
||||
#[test]
|
||||
fn paintbrush_alt_after_press_closes_without_smoothing_and_empty_selection_draws() {
|
||||
|
||||
Reference in New Issue
Block a user