diff --git a/apps/vectorcraft/src/app.rs b/apps/vectorcraft/src/app.rs index c093a1bac..3161d1a37 100644 --- a/apps/vectorcraft/src/app.rs +++ b/apps/vectorcraft/src/app.rs @@ -46,6 +46,10 @@ impl eframe::App for App { #[cfg(windows)] windows_stylus::raw_input_hook(raw); file_access::unconfined(|| self.app.raw_input_hook(ctx, raw)); + // A mouse-up that never reached the window (#4326) is delivered once AppKit reports + // that no mouse button is down. + #[cfg(target_os = "macos")] + self.app.release_lost_buttons(ctx, raw, objc2_app_kit::NSEvent::pressedMouseButtons() != 0); } #[cfg_attr(not(target_os = "macos"), allow(unused_variables))] fn ui(&mut self, ui: &mut egui::Ui, frame: &mut eframe::Frame) { diff --git a/crates/cad/src/scene.rs b/crates/cad/src/scene.rs index 1e7baac26..7ab1d17f8 100644 --- a/crates/cad/src/scene.rs +++ b/crates/cad/src/scene.rs @@ -126,6 +126,61 @@ fn circular_subpath(sub: &Sub) -> Option { if samples.iter().any(|p| (centre.distance(*p) - radius).abs() > tolerance) { return None; } + // Verify that the curve progresses monotonically around the circle without reversing, + // both along each cubic segment's angular derivative and across sampled intervals, + // and that closed subpaths make exactly one full turn while open subpaths do not exceed one turn. + let mut total_turn = 0.0; + let mut dir: Option = None; + let ang_threshold = radius * radius * 1e-9; + let mut seg_from = sub.start; + for seg in &sub.segs { + let Seg::Cubic(a, b, to) = *seg else { return None }; + for step in 0..=32 { + let t = f64::from(step) / 32.0; + let p = cubic_point(seg_from, a, b, to, t); + let u = 1.0 - t; + let dx = 3.0 * u * u * (a.x - seg_from.x) + 6.0 * u * t * (b.x - a.x) + 3.0 * t * t * (to.x - b.x); + let dy = 3.0 * u * u * (a.y - seg_from.y) + 6.0 * u * t * (b.y - a.y) + 3.0 * t * t * (to.y - b.y); + let cross = (p.x - centre.x) * dy - (p.y - centre.y) * dx; + if cross.abs() > ang_threshold { + let is_ccw = cross > 0.0; + if let Some(expected_ccw) = dir { + if is_ccw != expected_ccw { + return None; + } + } else { + dir = Some(is_ccw); + } + } + } + seg_from = to; + } + for w in samples.windows(2) { + let (p0, p1) = (w[0], w[1]); + let (v0x, v0y) = (p0.x - centre.x, p0.y - centre.y); + let (v1x, v1y) = (p1.x - centre.x, p1.y - centre.y); + let cross = v0x * v1y - v0y * v1x; + let dot = v0x * v1x + v0y * v1y; + let d_theta = cross.atan2(dot); + if d_theta.abs() > 1e-9 { + let is_ccw = d_theta > 0.0; + if let Some(expected_ccw) = dir { + if is_ccw != expected_ccw { + return None; + } + } else { + dir = Some(is_ccw); + } + total_turn += d_theta; + } + } + if sub.closed { + if (total_turn.abs() - std::f64::consts::TAU).abs() > 1e-3 { + return None; + } + } else if total_turn.abs() < 1e-6 || total_turn.abs() >= std::f64::consts::TAU - 1e-3 { + return None; + } Some(CircularSub { centre, radius, samples }) } diff --git a/crates/cad/src/tests.rs b/crates/cad/src/tests.rs index 46b09f5ff..30a8d1a81 100644 --- a/crates/cad/src/tests.rs +++ b/crates/cad/src/tests.rs @@ -246,6 +246,52 @@ fn closed_circular_arc_with_a_closing_chord_is_not_a_circle() { assert!(!kinds(&r12).iter().any(|k| k == "CIRCLE")); } +#[test] +fn a_closed_sliver_along_a_circle_is_not_a_circle() { + let d = doc_with(|d| { + let (cx, cy, r) = (60.0, 250.0, 200.0); + let at = |deg: f64| { + let rad = deg.to_radians(); + Point::new(cx + r * rad.cos(), cy + r * rad.sin()) + }; + let along = |sliver: &mut vectorcraft_geom::BezPath, deg0: f64, deg1: f64| { + let p0 = at(deg0); + let p3 = at(deg1); + let step = (deg1 - deg0).to_radians(); + let k = 4.0 / 3.0 * (step / 4.0).tan(); + let r0 = deg0.to_radians(); + let r1 = deg1.to_radians(); + let p1 = Point::new(p0.x - r * r0.sin() * k, p0.y + r * r0.cos() * k); + let p2 = Point::new(p3.x + r * r1.sin() * k, p3.y - r * r1.cos() * k); + sliver.curve_to(p1, p2, p3); + }; + let mut sliver = vectorcraft_geom::BezPath::new(); + sliver.move_to(at(-110.0)); + along(&mut sliver, -110.0, -90.0); + along(&mut sliver, -90.0, -70.0); + along(&mut sliver, -70.0, -110.0); + sliver.close_path(); + vec![path(d, PathData::from_bezpath(&sliver), Paint::None, Paint::solid(Color::BLACK), 1.0)] + }); + + let modern = export(&d, &opts(&d)).unwrap(); + assert_ne!(kinds(&modern), ["CIRCLE"]); + assert_eq!(kinds(&modern), ["SPLINE"]); +} + +#[test] +fn cubic_with_hidden_angular_reversal_is_not_promoted_to_arc() { + let d = doc_with(|d| { + let mut b = vectorcraft_geom::BezPath::new(); + b.move_to(Point::new(-8.404028665134, 62.061475842818)); + b.curve_to(Point::new(-13.285253116737, 62.103123153545), Point::new(52.811163576591, 52.471253544459), Point::new(60.0, 50.0)); + vec![path(d, PathData::from_bezpath(&b), Paint::None, Paint::solid(Color::BLACK), 1.0)] + }); + let dxf = export(&d, &opts(&d)).unwrap(); + assert_ne!(kinds(&dxf), ["ARC"]); + assert_eq!(kinds(&dxf), ["SPLINE"]); +} + #[test] fn spline_closed_flags_follow_path_intent_across_versions_without_changing_geometry() { let cases = [(true, true), (true, false), (false, false), (false, true)]; diff --git a/crates/doc/src/blend.rs b/crates/doc/src/blend.rs index 7bf88ac16..69ad12781 100644 --- a/crates/doc/src/blend.rs +++ b/crates/doc/src/blend.rs @@ -1059,13 +1059,146 @@ pub fn steps_knockout_shows(steps: &[Arc]) -> bool { !steps.iter().all(|c| paints_opaquely(c)) } -/// Evaluate a blend: the keys and generated steps in paint order. +/// Most steps a blend makes in all, the steps of the blends inside its keys and steps included. +/// Each step of a blend whose keys are blends is a copy of one of them, which makes its own steps +/// when drawn, so nesting multiplies the step counts capped pair by pair: five levels of 15 steps +/// are 1.3 million steps from a 23 KB file, 3.4 GB to draw (#3579). Past it, a blend makes +/// proportionally fewer steps between each pair of keys ([`fit_steps`]). +pub const MAX_BLEND_STEPS: usize = 100_000; + +/// The steps between each pair of keys, as the blend's spacing asks for them. +fn requested_steps(keys: &[Arc], spec: &BlendSpec, rail: Option<&Rail>) -> Vec { + keys.windows(2) + .enumerate() + .map(|(i, w)| match w { + [a, b] => blend_step_count(a, b, spec.spacing, rail.map_or_else(|| center_of(a).distance(center_of(b)), |r| r.len(i))), + _ => 0, + }) + .collect() +} + +/// Most steps `spacing` makes between two keys, whatever their size and distance +/// ([`blend_step_count`]'s clamps). +fn most_steps(spacing: BlendSpacing) -> usize { + match spacing { + BlendSpacing::Steps(n) => n.clamp(1, 1000) as usize, + BlendSpacing::Distance(_) => 1000, + BlendSpacing::SmoothColor => 256, + } +} + +/// `want` steps between each pair of keys, fewer where they would make more than +/// [`MAX_BLEND_STEPS`] in all with `keys` steps already made by the keys and `each[i]` made by +/// one step of pair `i` (itself and what the blends in it make): every pair keeps the same share +/// of its steps, rounded down, and none when the keys alone fill the budget. +fn fit_steps(want: &[usize], each: &[usize], keys: usize) -> Vec { + let room = MAX_BLEND_STEPS.saturating_sub(keys) as u128; + let demand = want.iter().zip(each).map(|(&n, &c)| n as u128 * c as u128).fold(0u128, u128::saturating_add); + if demand <= room { + return want.to_vec(); + } + // `demand` is more than `room`, so it isn't 0, and each share is below `n`. + want.iter().map(|&n| usize::try_from(n as u128 * room / demand).unwrap_or(0)).collect() +} + +/// Counts the steps blends make in all ([`MAX_BLEND_STEPS`]), each object counted once by its +/// address while the count runs. +#[derive(Default)] +struct StepCount { + known: std::collections::HashMap<(usize, bool), usize>, +} + +impl StepCount { + /// Steps the blends in `n` make in all when drawn, each keeping to [`MAX_BLEND_STEPS`]. + /// `copy`: `n` is part of a blend's step, a copy moved and scaled into place. A blend spaced + /// by distance (or by distance where the colours match) makes more steps scaled up, so a + /// copy counts the most its spacing can make between each pair, an upper bound for any scale. + fn node(&mut self, n: &Node, copy: bool) -> usize { + let at = (std::ptr::from_ref(n) as usize, copy); + if let Some(&known) = self.known.get(&at) { + return known; + } + let count = match &n.kind { + NodeKind::Blend { children, spec } if copy => self.most(children, spec), + NodeKind::Blend { children, spec } => self.blend(children, spec, Rail::new(children, spec).as_ref()).1, + // Each instance draws the source again. + NodeKind::Repeat(r) => r.instances(None).saturating_mul(self.nodes(&r.source, copy)), + _ => self.nodes(n.children().map_or(&[], Vec::as_slice), copy), + }; + self.known.insert(at, count); + count + } + + fn nodes(&mut self, nodes: &[Arc], copy: bool) -> usize { + nodes.iter().map(|c| self.node(c, copy)).fold(0, usize::saturating_add) + } + + /// What one step between keys `a` and `b` makes: itself and the steps of the blends in it. + /// Groups pair their children as [`Lerp::new`] does; anything else is a copy of the nearer key. + fn step(&mut self, a: &Node, b: &Node) -> usize { + let inner = match (&a.kind, &b.kind) { + (NodeKind::Group { children: ca, clip: k1 }, NodeKind::Group { children: cb, clip: k2 }) + if k1 == k2 && !(*k1 && ca.len() != cb.len()) => + { + (0..ca.len().max(cb.len())) + .map(|i| match (ca.get(i), cb.get(i)) { + (Some(x), Some(y)) => self.step(x, y).saturating_sub(1), + (Some(x), None) | (None, Some(x)) => self.node(x, true), + (None, None) => 0, + }) + .fold(0, usize::saturating_add) + } + _ => self.node(a, true).max(self.node(b, true)), + }; + inner.saturating_add(1) + } + + /// What each step between each pair of `keys` makes ([`Self::step`]). + fn steps(&mut self, keys: &[Arc]) -> Vec { + keys.windows(2) + .map(|w| match w { + [a, b] => self.step(a, b), + _ => 1, + }) + .collect() + } + + /// The steps between each pair of keys a blend makes ([`fit_steps`]), and the steps it makes + /// in all, its keys' included. + fn blend(&mut self, keys: &[Arc], spec: &BlendSpec, rail: Option<&Rail>) -> (Vec, usize) { + let inside = self.nodes(keys, false); + let each = self.steps(keys); + let kept = fit_steps(&requested_steps(keys, spec, rail), &each, inside); + let total = kept.iter().zip(&each).map(|(&n, &c)| n.saturating_mul(c)).fold(inside, usize::saturating_add); + (kept, total) + } + + /// The most steps a copy of a blend makes in all, at any scale: what [`Self::blend`] counts + /// with [`most_steps`] between each pair, before rounding. + fn most(&mut self, keys: &[Arc], spec: &BlendSpec) -> usize { + let inside = self.nodes(keys, true); + let each = self.steps(keys); + let demand = each.iter().map(|&c| c.saturating_mul(most_steps(spec.spacing))).fold(0, usize::saturating_add); + inside.saturating_add(demand.min(MAX_BLEND_STEPS.saturating_sub(inside))) + } +} + +/// How many steps `keys` blended with `spec` make between each pair of keys: as many as the +/// spacing asks for, fewer when they and the steps of the blends nested in them would pass +/// [`MAX_BLEND_STEPS`] ([`blend_expand`] makes these). +pub fn blend_steps(keys: &[Arc], spec: &BlendSpec) -> Vec { + StepCount::default().blend(keys, spec, Rail::new(keys, spec).as_ref()).0 +} + +/// Evaluate a blend: the keys and generated steps in paint order. The steps keep to +/// [`MAX_BLEND_STEPS`] in all ([`blend_steps`]). pub fn blend_expand(keys: &[Arc], spec: &BlendSpec) -> Vec { let k = keys.len(); if k < 2 { return keys.iter().map(|n| (**n).clone()).collect(); } let rail = Rail::new(keys, spec); + let (counts, _) = StepCount::default().blend(keys, spec, rail.as_ref()); let mut out = Vec::new(); for (i, a) in keys.iter().enumerate() { let mut key = (**a).clone(); @@ -1074,8 +1207,7 @@ pub fn blend_expand(keys: &[Arc], spec: &BlendSpec) -> Vec { } out.push(key); let Some(b) = keys.get(i + 1) else { break }; - let len = rail.as_ref().map_or_else(|| center_of(a).distance(center_of(b)), |r| r.len(i)); - let n = blend_step_count(a, b, spec.spacing, len); + let n = counts.get(i).copied().unwrap_or(0); let lerp = Lerp::new(a, b, (spec.start(i), spec.start(i + 1))); for j in 1..=n { // Even position of the step, then where the easing puts its shape and its colours. @@ -1301,4 +1433,128 @@ mod tests { mid.walk(&mut |n| ids.push(n.id)); assert!(ids.iter().all(|i| *i == NodeId(0)), "{ids:?}"); } + + /// A blend of `a` and `b` spaced by `spacing`. + fn blend_of(a: Node, b: Node, spacing: BlendSpacing) -> Node { + let spec = BlendSpec { spacing, ..Default::default() }; + Node::new(NodeId(1), NodeKind::Blend { children: vec![Arc::new(a), Arc::new(b)], spec: Box::new(spec) }) + } + + /// A blend's keys and options. + fn blend_parts(n: &Node) -> Option<(&Vec>, &BlendSpec)> { + match &n.kind { + NodeKind::Blend { children, spec } => Some((children, spec)), + _ => None, + } + } + + /// A square of side `side` at (`x`, `y`). + fn square(x: f64, y: f64, side: f64) -> Node { + Node::path(NodeId(1), shapes::rectangle(Rect::new(x, y, x + side, y + side)), Appearance::default()) + } + + /// `levels` of blends of 15 steps whose keys are the blends a level down (#3579's input). + fn nested(levels: u32, x: f64) -> Node { + if levels == 0 { + return square(x, 0.0, 4.0); + } + let w = 40.0 * f64::from(levels); + blend_of(nested(levels - 1, x), nested(levels - 1, x + w), BlendSpacing::Steps(15)) + } + + /// The steps drawing `n` makes in all, each blend evaluated as the renderer does: one level at + /// a time, the blends in its keys and steps evaluated again. + fn drawn_steps(n: &Node) -> usize { + match &n.kind { + NodeKind::Blend { children, spec } => { + let out = blend_expand(children, spec); + out.len() - children.len() + out.iter().map(drawn_steps).sum::() + } + _ => n.children().map_or(0, |c| c.iter().map(|c| drawn_steps(c)).sum()), + } + } + + /// Steps `levels` of [`nested`] blends of 15 steps make in all: each level its 15 steps, + /// and again the steps of the level below in each key and each step. + fn nested_steps(levels: u32) -> usize { + (17usize.pow(levels) - 1) * 15 / 16 + } + + /// #3579: blends whose keys are blends draw their steps again in every step above them. Five + /// levels of 15 steps made 1.3 million steps from a 23 KB file. Now a blend makes fewer steps + /// where they would pass `MAX_BLEND_STEPS`, and none where its keys already fill it, so each + /// level past that adds only what its keys draw. + #[test] + fn nested_blends_keep_to_the_step_budget() { + assert_eq!(nested_steps(5), 1_331_115); + let four = nested_steps(4); + assert!(four < MAX_BLEND_STEPS && 2 * four > MAX_BLEND_STEPS); + let five = nested(5, 0.0); + let (children, spec) = blend_parts(&five).unwrap(); + assert_eq!(blend_steps(children, spec), vec![0]); + assert_eq!(drawn_steps(&five), 2 * four); + assert_eq!(drawn_steps(&nested(6, 0.0)), 4 * four); + // A blend whose keys leave room keeps the same share of its steps between each pair. + let mut three = nested(3, 0.0); + if let NodeKind::Blend { spec, .. } = &mut three.kind { + spec.spacing = BlendSpacing::Steps(1000); + } + let (children, spec) = blend_parts(&three).unwrap(); + let inside = 2 * nested_steps(2); + let each = 1 + nested_steps(2); + let kept = (MAX_BLEND_STEPS - inside) * 1000 / (1000 * each); + assert_eq!(blend_steps(children, spec), vec![kept]); + let drawn = drawn_steps(&three); + assert_eq!(drawn, inside + kept * each); + assert!(drawn <= MAX_BLEND_STEPS && drawn > MAX_BLEND_STEPS - each, "{drawn}"); + } + + /// Nested blends within the budget make every step their spacing asks for, as before. + #[test] + fn nested_blends_within_the_budget_draw_every_step() { + for levels in 1..=4 { + let n = nested(levels, 0.0); + let (children, spec) = blend_parts(&n).unwrap(); + assert_eq!(blend_steps(children, spec), vec![15]); + assert_eq!(drawn_steps(&n), nested_steps(levels), "{levels}"); + } + // The keys, then 15 steps, then the last key, each a blend whose keys moved with it. + let two = nested(2, 0.0); + let (children, spec) = blend_parts(&two).unwrap(); + let out = blend_expand(children, spec); + assert_eq!(out.len(), 17); + assert_eq!(out[0], *children[0]); + assert!(out.iter().all(|s| matches!(s.kind, NodeKind::Blend { .. }))); + assert_eq!(out[8].geometric_bounds(), children[0].geometric_bounds().map(|b| b + Vec2::new(40.0, 0.0))); + } + + /// A blend spaced by distance makes more steps scaled up, so a step's copy of one counts the + /// most its spacing can make. Here a tiny blend of one step is blended with a large blend of + /// one step: copies of the tiny one, scaled up toward the large one's size, make up to 1000 + /// steps each, which neither key's own count shows. + #[test] + fn scaled_copies_of_blends_spaced_by_distance_keep_to_the_budget() { + let tiny = blend_of(square(0.0, 0.0, 1.0), square(2.0, 0.0, 1.0), BlendSpacing::Distance(1.0)); + let large = blend_of(square(0.0, 100.0, 1000.0), square(2000.0, 100.0, 1000.0), BlendSpacing::Steps(1)); + assert_eq!((drawn_steps(&tiny), drawn_steps(&large)), (1, 1)); + let n = blend_of(tiny, large, BlendSpacing::Steps(1000)); + let (children, spec) = blend_parts(&n).unwrap(); + let steps = blend_steps(children, spec); + assert!(steps[0] < 1000, "{steps:?}"); + let drawn = drawn_steps(&n); + assert!(drawn <= MAX_BLEND_STEPS && drawn > 1000, "{drawn}"); + } + + /// A flat blend of many keys shares the budget evenly between its pairs. + #[test] + fn many_keys_share_the_step_budget() { + let keys: Vec> = (0..300).map(|i| key(f64::from(i) * 20.0)).collect(); + let spec = BlendSpec { spacing: BlendSpacing::Steps(1000), ..Default::default() }; + let steps = blend_steps(&keys, &spec); + assert!(steps.iter().all(|&n| n == MAX_BLEND_STEPS * 1000 / (299 * 1000)), "{:?}", &steps[..3]); + assert_eq!(blend_expand(&keys, &spec).len(), 300 + steps.iter().sum::()); + // Within the budget, every step stays. + assert_eq!(blend_steps(&keys[..50], &spec), vec![1000; 49]); + assert_eq!(fit_steps(&[10, 20], &[1, 1], MAX_BLEND_STEPS), vec![0, 0]); + } } diff --git a/crates/engine/src/cmd.rs b/crates/engine/src/cmd.rs index 411f2004a..6b658da0d 100644 --- a/crates/engine/src/cmd.rs +++ b/crates/engine/src/cmd.rs @@ -179,6 +179,21 @@ pub fn has_multi(s: &Session) -> std::result::Result<(), String> { let st = s.active().ok_or("no document open")?; if st.selection.len() < 2 { Err("select at least two objects".into()) } else { Ok(()) } } +/// A document is open and it is not in isolation mode: Object › Lock's and Object › Hide's items, +/// Unlock All and Show All are off in isolation mode. Pattern editing mode and opacity mask +/// editing isolate their art too, and do not count as isolation mode here while their own layer +/// is the isolated one. An object isolated inside them counts. +pub fn outside_isolation(s: &Session) -> std::result::Result<(), String> { + let st = s.active().ok_or("no document open")?; + let editing_layer = |l| st.doc.pattern_edit.as_ref().is_some_and(|e| e.layer == l) || st.doc.mask_edit.as_ref().is_some_and(|e| e.layer == l); + if st.isolation.is_some_and(|l| !editing_layer(l)) { Err("exit isolation mode first".into()) } else { Ok(()) } +} +/// Objects are selected ([`has_selection`]) and the document is not in isolation mode +/// ([`outside_isolation`]). +pub fn has_selection_outside_isolation(s: &Session) -> std::result::Result<(), String> { + outside_isolation(s)?; + has_selection(s) +} pub fn can_undo(s: &Session) -> std::result::Result<(), String> { s.active().filter(|d| !d.history.undo.is_empty()).map(|_| ()).ok_or_else(|| "nothing to undo".into()) } diff --git a/crates/engine/src/cmd/colorcmds.rs b/crates/engine/src/cmd/colorcmds.rs index 539785ab1..49b729c8a 100644 --- a/crates/engine/src/cmd/colorcmds.rs +++ b/crates/engine/src/cmd/colorcmds.rs @@ -19,7 +19,8 @@ use vectorcraft_doc::{Document, ImageBlob, Node, NodeId, NodeKind}; use super::edit::selected_roots; use super::{ - CommandSpec, EngineError, Result, Session, always, bad, bool_or, cmd, color_value, f64_or, has_selection, ids_param, str_param, unique_name, + CommandSpec, EngineError, Result, Session, always, bad, bool_or, check_numbers, cmd, color_value, f64_or, f64_req, has_selection, ids_param, + str_param, unique_name, }; pub fn specs() -> Vec { @@ -70,7 +71,7 @@ pub fn specs() -> Vec { "Saturate…", ["Edit", "Edit Colors"], None, - "{intensity: -100..100 (%), fill?, stroke?, includeImages?, includePatterns?, ids?} scale saturation by (1 + intensity/100), reaching what edit.colors.invert does → {changed}", + "{intensity: -100..100 (%; a number outside the range counts as its nearest end), fill?, stroke?, includeImages?, includePatterns?, ids?} scale saturation by (1 + intensity/100), reaching what edit.colors.invert does → {changed}", has_selection, saturate ), @@ -345,18 +346,21 @@ fn run_pass(s: &mut Session, p: &Value, label: &str, pass: Recolor) -> Result Result { - let i = (f64_or(p, "intensity", 0.0).clamp(-100.0, 100.0) / 100.0) as f32; + let i = (f64_req(p, "intensity", "edit.colors.saturate")?.clamp(-100.0, 100.0) / 100.0) as f32; recolor(s, p, "Saturate", &move |c| { let [h, sat, v] = c.to_hsb(); - if sat <= 0.0 { + let to = (sat * (1.0 + i)).clamp(0.0, 1.0); + // A color whose saturation stays the same is returned as it is, so it keeps its swatch link. + if sat <= 0.0 || to == sat { return c; } - keep_model(c, Color::from_hsb(h, (sat * (1.0 + i)).clamp(0.0, 1.0), v)) + keep_model(c, Color::from_hsb(h, to, v)) }) } fn adjust_balance(s: &mut Session, p: &Value) -> Result { const C: &str = "edit.colors.adjustBalance"; + check_numbers(p, &["r", "g", "b", "c", "m", "y", "k", "gray", "tint"], C)?; let given = |keys: &[&str]| keys.iter().any(|k| p.get(*k).is_some()); let mode = match str_param(p, "mode") { Some(m) => m.to_ascii_lowercase(), @@ -513,6 +517,7 @@ fn harmony(s: &mut Session, p: &Value) -> Result { let ids: Vec = Harmony::ALL.iter().map(|h| h.id()).collect(); bad(C, format!("unknown rule `{rule}` ({})", ids.join("|"))) })?; + check_numbers(p, &["steps", "amount"], C)?; let mut opts = GuideOptions::default(); if let Some(n) = p.get("steps").and_then(Value::as_u64) { opts.steps = n.min(u32::MAX as u64) as u32; diff --git a/crates/engine/src/cmd/create.rs b/crates/engine/src/cmd/create.rs index 11c479023..9249a48f4 100644 --- a/crates/engine/src/cmd/create.rs +++ b/crates/engine/src/cmd/create.rs @@ -591,11 +591,12 @@ fn grid_group(s: &mut Session, label: &str, (paths, bbox_angle): (Vec, } /// A grid's divider count `count` (an error unless it is a number or null; 5 unless it is a JSON -/// integer of 0 or more, so 4.0 and 2.5 give 5; at most 999) with its skew `key` in percent (0 when +/// whole number of 0 or more, so 4.0 gives 4 and 2.5 gives 5; at most 999) with its skew `key` in percent (0 when /// absent, clamped to ±[`shapes::GRID_SKEW_MAX`]). fn dividers(p: &Value, count: &str, key: &str, cmd: &str) -> Result<(u32, f64)> { check_numbers(p, &[count], cmd)?; - let n = p.get(count).and_then(Value::as_u64).unwrap_or(5).min(999) as u32; + let whole = |v: &Value| v.as_u64().or_else(|| v.as_f64().filter(|f| f.is_finite() && *f >= 0.0 && f.fract() == 0.0).map(|f| f as u64)); + let n = p.get(count).and_then(whole).unwrap_or(5).min(999) as u32; let skew = match p.get(key) { None | Some(Value::Null) => 0.0, Some(v) => v.as_f64().filter(|v| v.is_finite()).ok_or_else(|| bad(cmd, format!("`{key}` must be a number")))?, diff --git a/crates/engine/src/cmd/draw2/anchors.rs b/crates/engine/src/cmd/draw2/anchors.rs index 25a403cfb..df55e5207 100644 --- a/crates/engine/src/cmd/draw2/anchors.rs +++ b/crates/engine/src/cmd/draw2/anchors.rs @@ -109,13 +109,14 @@ pub(super) fn remove_anchor(s: &mut Session, p: &Value) -> Result { let id = id_param(p, "id").ok_or_else(|| bad(C, "missing id"))?; let si = usize_opt(p, "subpath", C)?.unwrap_or(0); let ai = usize_req(p, "anchor", C)?; + let curvature = switch_or(p, "curvature", false, C)?; let removed = s.edit("Delete Anchor Point", |d, sel| { let sp = path_mut(d, id)?.subpaths.get(si).ok_or_else(|| EngineError::Other("no such subpath".into()))?; if ai >= sp.anchors.len() { return Err(EngineError::Other("no such anchor".into())); } let mut emptied = Beside::replacing(d, &[id]); - let removed = remove_anchors_of(d, sel, &mut emptied, id, [(si, ai)])?; + let removed = remove_anchors_of(d, sel, &mut emptied, id, [(si, ai)], curvature)?; emptied.finish(d)?; Ok(removed) })?; @@ -141,7 +142,7 @@ pub(super) fn remove_anchors(s: &mut Session, _: &Value) -> Result { let shape: Vec<(usize, bool)> = d.node(id).and_then(Node::path_data).map(|p| p.subpaths.iter().map(|sp| (sp.anchors.len(), sp.closed)).collect()).unwrap_or_default(); let next = following_anchors(&shape, &anchors); - if remove_anchors_of(d, sel, &mut emptied, id, anchors)? { + if remove_anchors_of(d, sel, &mut emptied, id, anchors, false)? { removed += 1; } else if !next.is_empty() { // The next anchor along is selected, so the next Remove Anchor Points goes on from @@ -180,14 +181,16 @@ fn following_anchors(shape: &[(usize, bool)], removed: &[(usize, usize)]) -> BTr } /// Remove `anchors` (subpath, anchor) of path `id` without opening it, refitting the curve round -/// each one. A subpath left with one anchor is removed, and a path left with no subpath is removed -/// through `emptied`. True when the path went. +/// each one, or with `curvature` re-curving it as the Curvature tool curves its points +/// ([`vectorcraft_tools::draw2::curvature_remove`]). A subpath left with one anchor is removed, +/// and a path left with no subpath is removed through `emptied`. True when the path went. fn remove_anchors_of( d: &mut Document, sel: &mut Selection, emptied: &mut Beside, id: NodeId, anchors: impl IntoIterator, + curvature: bool, ) -> Result { let mut anchors: Vec<(usize, usize)> = anchors.into_iter().collect(); // The last first, so the indexes still to come stay valid. @@ -196,7 +199,11 @@ fn remove_anchors_of( let path = path_mut(d, id)?; for (si, ai) in anchors { if let Some(sp) = path.subpaths.get_mut(si) { - po::remove_anchor(sp, ai); + if curvature { + vectorcraft_tools::draw2::curvature_remove(sp, ai); + } else { + po::remove_anchor(sp, ai); + } } } path.subpaths.retain(|sp| sp.anchors.len() >= 2); diff --git a/crates/engine/src/cmd/draw2/commands.rs b/crates/engine/src/cmd/draw2/commands.rs index 95959f35f..57066e3b9 100644 --- a/crates/engine/src/cmd/draw2/commands.rs +++ b/crates/engine/src/cmd/draw2/commands.rs @@ -38,7 +38,7 @@ pub fn specs() -> Vec { "Delete Anchor Point", [], None, - "{id, subpath?, anchor} remove one anchor, re-fitting the curve", + "{id, subpath?, anchor, curvature?} remove one anchor, re-fitting the curve (with curvature: true, the curve where it was follows the remaining anchors, as the Curvature tool curves its points)", has_doc, remove_anchor ), @@ -128,7 +128,7 @@ pub fn specs() -> Vec { "Blob Brush", [], None, - "{points: [[x,y]…], size?: pt (10), merge?: true, mergeOnlySelection?: bool (false: merge with any touching same-color blob; true: only with selected ones), keepSelected?: bool (false: the drawn or merged path is not selected, and the rest of the selection stays; true: it is selected)} filled brush shape, merged with touching same-colour blobs → {id}", + "{points: [[x,y]…], size?: pt (10), fidelity?: pt (0.01–100; the brush follows the stroke fitted at this tolerance as path.freehand fits a Pencil stroke, and a higher value gives a smoother shape; without it the brush follows every point), merge?: true, mergeOnlySelection?: bool (false: merge with any touching same-color blob; true: only with selected ones), keepSelected?: bool (false: the drawn or merged path is not selected, and the rest of the selection stays; true: it is selected)} filled brush shape, merged with the topmost touching same-color blob and the touching ones next to it in stacking order in its group or layer → {id}", has_doc, blob ), diff --git a/crates/engine/src/cmd/draw2/common.rs b/crates/engine/src/cmd/draw2/common.rs index 2812b2fdc..b8c598a2d 100644 --- a/crates/engine/src/cmd/draw2/common.rs +++ b/crates/engine/src/cmd/draw2/common.rs @@ -168,6 +168,33 @@ pub(super) fn target_paths(doc: &Document, sel: &Selection, area: Rect) -> Vec Result> { + let closed_compound = |n: &Node| { + matches!(&n.kind, NodeKind::Compound { children, .. } + if children.iter().all(|c| matches!(&c.kind, NodeKind::Path { path, .. } if path.is_closed()))) + }; + let editable = doc.editable(); + let mut out: Vec<(NodeId, PathData, FillRule)> = vec![]; + for id in target_paths(doc, sel, area) { + let compound = doc.parent_of(id).and_then(|p| doc.node(p)).filter(|p| closed_compound(p) && editable(p.id)); + let target = compound.map_or(id, |c| c.id); + if out.iter().any(|t| t.0 == target) { + continue; + } + let (pd, rule) = match compound.map(|c| &c.kind) { + Some(NodeKind::Compound { children, rule }) => { + (PathData::new(children.iter().filter_map(|c| c.path_data()).flat_map(|p| p.subpaths.clone()).collect()), *rule) + } + _ => path_of(doc, id)?, + }; + out.push((target, pd, rule)); + } + Ok(out) +} + /// The members of a compound path with geometry `pd`: one path per subpath, with fresh ids. fn compound_members(d: &mut Document, pd: PathData) -> Vec> { pd.subpaths.into_iter().map(|sp| Arc::new(Node::path(d.alloc_id(), PathData::single(sp), Appearance::default()))).collect() diff --git a/crates/engine/src/cmd/draw2/cut.rs b/crates/engine/src/cmd/draw2/cut.rs index 4747ef783..e3a566f17 100644 --- a/crates/engine/src/cmd/draw2/cut.rs +++ b/crates/engine/src/cmd/draw2/cut.rs @@ -1,5 +1,6 @@ use super::common::{ - dist_to_polyline, ids_json, path_mut, path_of, points_param, poly_bounds, replace_with_pieces, sibling_with, target_paths, usize_opt, + dist_to_polyline, fill_targets, ids_json, path_mut, path_of, points_param, poly_bounds, replace_with_pieces, sibling_with, target_paths, + usize_opt, }; use super::{ Anchor, AnchorKind, Beside, BezPath, BoolOp, Cap, EngineError, FillRule, Join, NodeId, ParamCurve, PathData, Point, Result, Session, SubPath, @@ -236,8 +237,7 @@ pub(super) fn knife(s: &mut Session, p: &Value) -> Result { let st = s.doc()?; let strip = po::outline_stroke(&PathData::single(SubPath::polyline(&pts, false)), KNIFE_GAP, Cap::Butt, Join::Round, 4.0); let mut plan: Vec<(NodeId, Vec)> = vec![]; - for id in target_paths(&st.doc, &st.selection, poly_bounds(&pts, 1.0)) { - let (pd, rule) = path_of(&st.doc, id)?; + for (id, pd, rule) in fill_targets(&st.doc, &st.selection, poly_bounds(&pts, 1.0))? { if !pd.is_closed() { continue; } @@ -281,8 +281,7 @@ pub(super) fn erase_region(s: &mut Session, p: &Value) -> Result { let squash = eraser_tip(angle, roundness).inverse(); let round: Vec = pts.iter().map(|q| squash * *q).collect(); let mut plan: Vec<(NodeId, Vec)> = vec![]; - for id in target_paths(&st.doc, &st.selection, poly_bounds(&pts, r)) { - let (pd, rule) = path_of(&st.doc, id)?; + for (id, pd, rule) in fill_targets(&st.doc, &st.selection, poly_bounds(&pts, r))? { if pd.is_closed() { if po::area(&po::boolean(&pd, rule, ®ion, FillRule::NonZero, BoolOp::Intersect), FillRule::NonZero) <= 1e-6 { continue; diff --git a/crates/engine/src/cmd/draw2/freehand.rs b/crates/engine/src/cmd/draw2/freehand.rs index aeda2106d..e037f0cc1 100644 --- a/crates/engine/src/cmd/draw2/freehand.rs +++ b/crates/engine/src/cmd/draw2/freehand.rs @@ -3,8 +3,9 @@ use super::common::{ within_input_bound, }; use super::{ - Anchor, Appearance, BTreeSet, BoolOp, Color, EngineError, FillRule, NewArt, NodeId, NodeKind, Paint, PathData, Point, PressureProfile, Result, - Selection, Session, SimplifyOptions, SubPath, Value, bad, id_param, insert_look, json, number_or, po, remove_each, str_param, switch_or, + Anchor, Appearance, BTreeSet, BoolOp, Cap, Color, Document, EngineError, FillRule, Join, NewArt, Node, NodeId, NodeKind, Paint, PathData, Point, + PressureProfile, Result, Selection, Session, SimplifyOptions, SubPath, Value, bad, id_param, insert_look, json, number_or, po, remove_each, + str_param, switch_or, }; // ---------- Pencil / Paintbrush ---------- @@ -336,6 +337,35 @@ pub(super) fn freehand(s: &mut Session, p: &Value) -> Result { // ---------- Blob Brush ---------- +/// Whether `n` is a path a Blob Brush stroke of `paint` can merge with: the same fill and no stroke. +fn blob_match(n: &Node, paint: &Paint) -> bool { + matches!(n.kind, NodeKind::Path { guide: false, .. }) && n.appearance.fill_paint() == *paint && n.appearance.stroke_paint().is_none() +} + +/// The paths of `touched` that a Blob Brush stroke of `paint` merges with: the topmost of them, and +/// the others in the same group or layer that are adjacent to it in stacking order, with no object +/// between them other than a path of the same fill and no stroke. +fn adjacent_to_topmost(doc: &Document, touched: &[NodeId], paint: &Paint) -> Vec { + let Some(top) = doc.paint_order(touched.iter().copied()).last().copied() else { + return vec![]; + }; + let Some((parent, at, _)) = doc.position(top) else { + return vec![top]; + }; + let Some(siblings) = doc.children(parent) else { + return vec![top]; + }; + let mut out = vec![top]; + for n in siblings.iter().take(at).rev() { + if touched.contains(&n.id) { + out.push(n.id); + } else if !blob_match(n, paint) { + break; + } + } + out +} + pub(super) fn blob(s: &mut Session, p: &Value) -> Result { const C: &str = "path.blob"; let pts = points_param(p, "points", C)?; @@ -343,7 +373,18 @@ pub(super) fn blob(s: &mut Session, p: &Value) -> Result { if !(size.is_finite() && size > 0.0) { return Err(bad(C, "size must be positive")); } - let region = brush_region(&pts, size); + // With `fidelity` (pt), the brush follows the stroke fitted by [`fit_freehand`], as a Pencil + // stroke is fitted. Without it, or when the fitted stroke has no length (a short stroke back to + // its start), the brush follows every point. + let fidelity = match p.get("fidelity") { + None | Some(Value::Null) => None, + Some(_) => Some(number_or(p, "fidelity", 0.0, C)?.clamp(vectorcraft_tools::draw2::MIN_FIDELITY, 100.0)), + }; + let fitted = fidelity + .filter(|_| pts.len() > 1) + .map(|tol| po::outline_stroke(&PathData::single(fit_freehand(&pts, tol, false)), size, Cap::Round, Join::Round, 4.0)) + .filter(|r| !r.is_empty()); + let region = fitted.unwrap_or_else(|| brush_region(&pts, size)); if region.is_empty() { return Err(EngineError::Other("empty brush stroke".into())); } @@ -362,9 +403,11 @@ pub(super) fn blob(s: &mut Session, p: &Value) -> Result { if merge && !(only_selection && st.selection.is_empty()) { let all = Selection::default(); let from = if only_selection { &st.selection } else { &all }; - for id in target_paths(&st.doc, from, poly_bounds(&pts, size)) { + // A fitted stroke can curve outside the points' bounds, so the area also takes in the shape. + let area = region.control_bounds().map_or_else(|| poly_bounds(&pts, size), |b| b.union(poly_bounds(&pts, size))); + for id in target_paths(&st.doc, from, area) { let Some(n) = st.doc.node(id) else { continue }; - if n.appearance.fill_paint() != paint || !n.appearance.stroke_paint().is_none() { + if !blob_match(n, &paint) { continue; } let (pd, rule) = path_of(&st.doc, id)?; @@ -372,6 +415,8 @@ pub(super) fn blob(s: &mut Session, p: &Value) -> Result { merge_ids.push((id, pd, rule)); } } + let joining = adjacent_to_topmost(&st.doc, &merge_ids.iter().map(|m| m.0).collect::>(), &paint); + merge_ids.retain(|m| joining.contains(&m.0)); } let ap = NewArt::plain(Appearance::basic(paint, Paint::None, 1.0)); if merge_ids.is_empty() { @@ -380,11 +425,10 @@ pub(super) fn blob(s: &mut Session, p: &Value) -> Result { let mut inputs: Vec<(&PathData, FillRule)> = vec![(®ion, FillRule::NonZero)]; inputs.extend(merge_ids.iter().map(|(_, pd, r)| (pd, *r))); let merged = po::try_unite_all(&inputs).map_err(|e| EngineError::Other(e.to_string()))?; - let Some(((keep, _, _), rest)) = merge_ids.split_last() else { + let Some(keep) = st.doc.paint_order(merge_ids.iter().map(|m| m.0)).last().copied() else { return insert_look(s, "Blob Brush", path_kind(region), ap, None, keep_selected); }; - let keep = *keep; - let others: Vec = rest.iter().map(|m| m.0).collect(); + let others: Vec = merge_ids.iter().map(|m| m.0).filter(|id| *id != keep).collect(); s.edit("Blob Brush", |d, sel| { *path_mut(d, keep)? = merged; remove_each(d, &others)?; diff --git a/crates/engine/src/cmd/edit.rs b/crates/engine/src/cmd/edit.rs index 9a3b296fc..a2cab84a8 100644 --- a/crates/engine/src/cmd/edit.rs +++ b/crates/engine/src/cmd/edit.rs @@ -42,6 +42,15 @@ pub fn specs() -> Vec { has_doc, set_units ), + cmd!( + "document.switchUnits", + "Switch Units", + [], + Some("Cmd+Alt+Shift+U"), + "{} the document's units become the next of Points, Picas, Inches, Millimeters, Centimeters, Pixels, Feet & Inches, Meters, Yards and Feet (the order a right-click on a ruler lists them), Feet going back to Points; one undo step → {units}", + has_doc, + switch_units + ), cmd!( query "document.rulerOrigin", "Ruler Origin", @@ -332,6 +341,14 @@ fn set_units(s: &mut Session, p: &Value) -> Result { ok() } +/// Switch Units: the unit after the document's in [`Unit::ALL`], wrapping around. +fn switch_units(s: &mut Session, _: &Value) -> Result { + let now = s.doc()?.doc.units; + let next = Unit::ALL.iter().position(|u| *u == now).and_then(|i| Unit::ALL.get((i + 1) % Unit::ALL.len())).copied().unwrap_or_default(); + s.set_document_units(next)?; + Ok(json!({ "units": next.label() })) +} + /// The `artboard` param of the ruler origin commands: an artboard index, 0 when not given. fn artboard_param(s: &Session, p: &Value, c: &str) -> Result { let index = match p.get("artboard") { diff --git a/crates/engine/src/cmd/expand.rs b/crates/engine/src/cmd/expand.rs index b0df586cf..a62d5b18f 100644 --- a/crates/engine/src/cmd/expand.rs +++ b/crates/engine/src/cmd/expand.rs @@ -131,7 +131,11 @@ impl Expandable { e.placed |= matches!(c.kind, NodeKind::PlacedDocument(_)); e.repeats |= matches!(c.kind, NodeKind::Repeat(_)); e.intertwines |= matches!(c.kind, NodeKind::Intertwine { .. }); - e.stroke |= shape && !c.appearance.stroke_paint().is_none(); + e.stroke |= match &c.kind { + // Type's outlines take its characters' stroke (see `type.createOutlines`). + NodeKind::Text(t) => t.runs.iter().any(|r| r.style.has_stroke()), + _ => shape && !c.appearance.stroke_paint().is_none(), + }; e.gradient |= match &c.kind { NodeKind::Text(t) => t.runs.iter().any(|r| matches!(r.style.fill, Paint::Gradient(_))), _ => shape && c.appearance.items.iter().any(|i| gradient_fill(i).is_some()), diff --git a/crates/engine/src/cmd/fileio/batch.rs b/crates/engine/src/cmd/fileio/batch.rs index d5aa6d144..3dc3a51d9 100644 --- a/crates/engine/src/cmd/fileio/batch.rs +++ b/crates/engine/src/cmd/fileio/batch.rs @@ -27,15 +27,19 @@ pub(super) fn batch(s: &mut Session, p: &Value) -> Result { let active = s.active; s.batch_stash = Some(vec![]); let mut grouped = Vec::with_capacity(cmds.len()); + // The Type tool keeps what the steps' Undo and Redo change in it, to put back on a rollback. + s.notify_text_tool("batch.begin"); let r = run_steps(s, &label, artboard, &mut cmds, &mut grouped); let stash = s.batch_stash.take().unwrap_or_default(); let results = match r { Ok(results) => { + s.notify_text_tool("batch.end"); s.batch_steps = Some(grouped); results } Err(e) => { roll_back(s, start, &stash); + s.notify_text_tool("batch.rollBack"); s.active = active; (s.paint, s.fill_active, s.draw_mode, s.draw_inside, s.clipboard, s.untitled_counter, s.explicit_undo_group) = saved; s.pending_paint = None; diff --git a/crates/engine/src/cmd/fileio/commands.rs b/crates/engine/src/cmd/fileio/commands.rs index 4be6d8ccb..0c38079df 100644 --- a/crates/engine/src/cmd/fileio/commands.rs +++ b/crates/engine/src/cmd/fileio/commands.rs @@ -54,7 +54,7 @@ pub fn specs() -> Vec { "Export for Screens", ["File", "Export"], None, - "{folder?, zip?: false (one store-only .zip of every file: no folder → {name, dataBase64, bytes, files: [name…]}; with a folder it is written there → {path, bytes, files}), artboards?: [index…] | range?: \"1-3\" (default all), assets?: [asset id…] (instead of artboards: each asset's art alone, cropped to its bounds and named after the asset; see assets.list), layers?: [layer id…] (instead of artboards: each layer's art alone, sublayers included, named after the layer), selection?: one|each (instead of artboards: the selected art as one file, or a file per selected object, named after it), fullDocument?: false (one file per format instead: a PDF of every artboard, other formats the bounds of all visible art, named after the document), includeBleed?: false (artboards grown by the document's bleed), exportPreset?: (a preset of document.exportPresets.list: its settings, under these params), subfolders?: false (each row's files in a sub-folder: its folder, else its size for raster (1x, 2x, 100w…) or its format (SVG, PDF)), folders?: none|rows (as subfolders)|format (a folder per extension: png, svg, pdf…)|item (a folder per artboard, asset, layer or object)|itemFormat (item/extension) (default: rows with subfolders or a preset, else none), name?: (a file name pattern instead of the item's name, with for the document, for the artboard, asset, layer or object, from 1, #### for the counter zero-padded to the run of #, , , (else the row's suffix ends the name), YYYYMMDD, YYYY-MM-DD, YYYY, ,