Merge upstream main into pencil-no-guides

# Conflicts:
#	docs/target-app-parity.md
This commit is contained in:
ncic
2026-11-13 23:52:26 +01:00
142 changed files with 5868 additions and 783 deletions
+34 -1
View File
@@ -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 {
+36
View File
@@ -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"
+9 -1
View File
@@ -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 {
+10 -7
View File
@@ -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}))],
+32 -1
View File
@@ -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() {