diff --git a/crates/engine/src/tests/draw2.rs b/crates/engine/src/tests/draw2.rs index 15d1b27f9..b4b241840 100644 --- a/crates/engine/src/tests/draw2.rs +++ b/crates/engine/src/tests/draw2.rs @@ -1275,6 +1275,38 @@ fn shape_arc_closed_along_y_closes_through_the_top_left_corner() { assert!((area - std::f64::consts::FRAC_PI_4).abs() < 0.01, "{area}"); } +/// A spiral dragged out with Alt pressed part way gets more segments as its outer end moves out, +/// and one dragged with Ctrl/Cmd pressed part way gets the decay that keeps its inner end's +/// distance from the center; the Spiral tool keeps both for the next spiral. +#[test] +fn spiral_drags_with_alt_and_ctrl_change_its_segments_and_decay() { + let mut s = session(); + let v = view(); + let spiral = |s: &mut Session, m: Mods, to: (f64, f64)| { + s.pointer(&PointerEvent::new(PointerKind::Down, 100.0, 100.0), v).unwrap(); + s.pointer(&PointerEvent::new(PointerKind::Drag, 200.0, 100.0), v).unwrap(); + s.pointer(&PointerEvent::new(PointerKind::Drag, 200.0, 100.0).with_mods(m), v).unwrap(); + s.pointer(&PointerEvent::new(PointerKind::Drag, to.0, to.1).with_mods(m), v).unwrap(); + s.pointer(&PointerEvent::new(PointerKind::Up, to.0, to.1).with_mods(m), v).unwrap(); + let sp = paths(s).last().unwrap().1.subpaths[0].clone(); + (sp.anchors.len(), sp.anchors.last().unwrap().p) + }; + s.select_tool("spiral", v).unwrap(); + let undo = undo_steps(&s); + // Alt from a radius of 100 to 200: 10 segments become 13, the outer end at the pointer. + let (anchors, outer) = spiral(&mut s, Mods { alt: true, ..Mods::default() }, (300.0, 100.0)); + assert_eq!(anchors, 14); + assert!(near(outer, Point::new(300.0, 100.0)), "{outer:?}"); + assert_eq!(s.tool_options()["segments"], json!(13)); + // Ctrl/Cmd from 100 to 50: the decay rises from 80 % to 80 × 2^(1/13) %. + let (anchors, outer) = spiral(&mut s, Mods { cmd: true, ..Mods::default() }, (150.0, 100.0)); + assert_eq!(anchors, 14); + assert!(near(outer, Point::new(150.0, 100.0)), "{outer:?}"); + let decay = s.tool_options()["decay"].as_f64().unwrap(); + assert!((decay - 80.0 * 2f64.powf(1.0 / 13.0)).abs() < 1e-9, "{decay}"); + assert_eq!(undo_steps(&s) - undo, 2, "one undo step a spiral"); +} + /// The children of the grid a `shape.rectangularGrid` / `shape.polarGrid` call made, bottom first. fn grid(s: &mut Session, cmd: &str, p: serde_json::Value) -> Vec> { let id = NodeId(s.execute(cmd, &p).unwrap()["id"].as_u64().unwrap()); diff --git a/crates/tools/src/draw2/family.rs b/crates/tools/src/draw2/family.rs index 9f8c08a59..b068e8772 100644 --- a/crates/tools/src/draw2/family.rs +++ b/crates/tools/src/draw2/family.rs @@ -5,7 +5,9 @@ //! start point and the dragged corner snap to Smart Guides ([`DrawSnap`]), hovering too. //! While dragging, ↑/↓ change the arc's slope, the spiral's segments, the grid rows or the //! concentric dividers; ←/→ change grid columns / radial dividers. C switches the arc between open -//! and closed, and F switches its base between the X and Y axes. +//! and closed, and F switches its base between the X and Y axes. While a spiral is dragged out, Alt +//! and Ctrl/Cmd keep its inner end at the distance from the center it had when the key went down as +//! the outer end follows the pointer: Alt adds or removes segments, and Ctrl/Cmd changes the decay. use serde_json::{Value, json}; use vectorcraft_geom::Point; @@ -33,6 +35,9 @@ pub struct FamilyTool { pub decay: f64, pub segments: u32, pub clockwise: bool, + /// The natural logarithm of the radius of the spiral's inner end, held while Alt or Ctrl/Cmd + /// is down in a drag. + spiral_end: Option, /// Rows / concentric dividers. pub rows: u32, /// Columns / radial dividers. @@ -75,6 +80,7 @@ impl FamilyTool { decay: 80.0, segments: 10, clockwise: true, + spiral_end: None, rows: 5, columns: 5, skew: [0.0; 2], @@ -107,6 +113,39 @@ impl FamilyTool { matches!(self.id, "rectangularGrid" | "polarGrid") } + /// The natural logarithm of the radius of the inner end of a spiral of outer radius `r` drawn + /// with the tool's decay and segments (a logarithm, so that a low decay with many segments does + /// not round to 0). The decay ratio is clamped to 0.05…0.9999 as `shapes::spiral` clamps it. + fn spiral_inner_end_ln(&self, r: f64) -> f64 { + r.ln() + f64::from(self.segments) * (self.decay / 100.0).clamp(0.05, 0.9999).ln() + } + + /// Alt or Ctrl/Cmd in a spiral drag from `start` to `p`: the inner end keeps the distance from + /// the center it had when the key went down while the outer end follows the pointer. With + /// Ctrl/Cmd the decay changes to fit (within 5…99.99 %, the decays `shapes::spiral` draws); + /// with Alt alone the segments change to the nearest whole number that fits (within 2…1000). + /// The new values stay once the key is let go. + fn hold_spiral_end(&mut self, start: Point, p: Point, m: Mods) { + let r = p.distance(start).max(0.01); + match (m.cmd || m.alt, self.spiral_end) { + (true, None) => self.spiral_end = Some(self.spiral_inner_end_ln(r)), + (false, Some(_)) => self.spiral_end = None, + _ => {} + } + let Some(end) = self.spiral_end.filter(|e| e.is_finite()) else { return }; + if m.cmd { + let ratio = ((end - r.ln()) / f64::from(self.segments)).exp(); + if ratio.is_finite() { + self.decay = (ratio * 100.0).clamp(5.0, 99.99); + } + } else { + let winds = (r.ln() - end) / -(self.decay / 100.0).clamp(0.05, 0.9999).ln(); + if winds.is_finite() { + self.segments = winds.round().clamp(2.0, 1000.0) as u32; + } + } + } + /// A grid's options a drag draws with: its dividers, skews and switches (the Default Size and /// Reference Point are for a click's dialog). fn grid_options(&self) -> Value { @@ -187,6 +226,7 @@ impl Tool for FamilyTool { self.start = Some(p); self.last = p; self.began = false; + self.spiral_end = None; vec![] } PointerKind::Drag => { @@ -204,6 +244,9 @@ impl Tool for FamilyTool { self.began = true; out.push(Action::Begin(self.label().into())); } + if self.id == "spiral" { + self.hold_spiral_end(s, ev.pos, ev.mods); + } let (c, v) = self.command_at(s, ev.pos, ev.mods, cx.constrain_angle); out.push(Action::Preview(c, v)); out @@ -211,6 +254,7 @@ impl Tool for FamilyTool { PointerKind::Up => { let Some(s) = self.start.take() else { return vec![] }; self.snap.clear(); + self.spiral_end = None; if std::mem::take(&mut self.began) { vec![Action::Commit] } else { vec![Action::Dialog(self.id.into(), json!({"x": s.x, "y": s.y}))] } } _ => vec![], @@ -262,11 +306,16 @@ impl Tool for FamilyTool { _ => false, }; if changed { + // An arrow with Alt or Ctrl/Cmd still held: the held inner end moves with the new segment count. + if self.spiral_end.is_some() { + self.spiral_end = Some(self.spiral_inner_end_ln(self.last.distance(s).max(0.01))); + } let (c, v) = self.command_at(s, self.last, self.mods, cx.constrain_angle); vec![Action::Preview(c, v)] } else if key == ToolKey::Escape { self.start = None; self.began = false; + self.spiral_end = None; self.snap.clear(); vec![Action::Cancel] } else { @@ -461,6 +510,69 @@ mod tests { ); } + /// While a spiral is dragged out, Alt and Ctrl/Cmd keep its inner end at the distance from the + /// center it had when the key went down as the outer end follows the pointer: Alt adds or + /// removes segments and Ctrl/Cmd changes the decay. The new values stay once the key is let go, + /// and for the next spiral. Both keys work with the lowest decay and many segments. + #[test] + fn spiral_drag_alt_changes_its_segments_and_ctrl_its_decay() { + let (d, _) = doc_with_rect(); + let s = Selection::default(); + let p = paint(); + let mut cx = cx(&d, &s, &p); + cx.smart_guides = false; + let alt = Mods { alt: true, ..Mods::default() }; + let ctrl = Mods { cmd: true, ..Mods::default() }; + let spiral = |a: Vec| match a.last() { + Some(Action::Preview(c, v)) if c == "shape.spiral" => { + (v["radius"].as_f64().unwrap(), v["decay"].as_f64().unwrap(), v["segments"].as_u64().unwrap()) + } + _ => panic!("{a:?}"), + }; + let mut t = FamilyTool::new("spiral"); + t.pointer(&cx, &PointerEvent::new(PointerKind::Down, 100.0, 100.0)); + assert_eq!(spiral(t.pointer(&cx, &PointerEvent::new(PointerKind::Drag, 200.0, 100.0))), (100.0, 80.0, 10)); + // Alt: twice the radius adds ln 2 / ln 1.25 ≈ 3.1 segments, half of it takes them away. + assert_eq!(spiral(t.pointer(&cx, &PointerEvent::new(PointerKind::Drag, 200.0, 100.0).with_mods(alt))), (100.0, 80.0, 10)); + assert_eq!(spiral(t.pointer(&cx, &PointerEvent::new(PointerKind::Drag, 300.0, 100.0).with_mods(alt))), (200.0, 80.0, 13)); + // ↑ with Alt held adds one, and the held inner end moves with it. + assert_eq!(spiral(t.key(&cx, ToolKey::Up, alt)).2, 14); + assert_eq!(spiral(t.pointer(&cx, &PointerEvent::new(PointerKind::Drag, 300.0, 100.0).with_mods(alt))).2, 14); + assert_eq!(spiral(t.pointer(&cx, &PointerEvent::new(PointerKind::Drag, 150.0, 100.0).with_mods(alt))), (50.0, 80.0, 8)); + assert_eq!(spiral(t.pointer(&cx, &PointerEvent::new(PointerKind::Drag, 101.0, 100.0).with_mods(alt))).2, 2, "at least two"); + assert_eq!(spiral(t.pointer(&cx, &PointerEvent::new(PointerKind::Drag, 150.0, 100.0).with_mods(alt))).2, 8); + // Let go: the eight segments stay as the radius follows the pointer again. + assert_eq!(spiral(t.pointer(&cx, &PointerEvent::new(PointerKind::Drag, 200.0, 100.0))), (100.0, 80.0, 8)); + // Ctrl/Cmd: the decay fits the held inner end, 80 % × (100 / r)^(1/8). + t.pointer(&cx, &PointerEvent::new(PointerKind::Drag, 200.0, 100.0).with_mods(ctrl)); + let (r, decay, n) = spiral(t.pointer(&cx, &PointerEvent::new(PointerKind::Drag, 150.0, 100.0).with_mods(ctrl))); + assert!((r, n) == (50.0, 8) && (decay - 80.0 * 2f64.powf(1.0 / 8.0)).abs() < 1e-9, "{r} {decay} {n}"); + let (_, decay, _) = spiral(t.pointer(&cx, &PointerEvent::new(PointerKind::Drag, 400.0, 100.0).with_mods(ctrl))); + assert!((decay - 80.0 * (1.0f64 / 3.0).powf(1.0 / 8.0)).abs() < 1e-9, "{decay}"); + assert_eq!(spiral(t.pointer(&cx, &PointerEvent::new(PointerKind::Drag, 100.5, 100.0).with_mods(ctrl))).1, 99.99, "at most 99.99 %"); + let (_, decay, _) = spiral(t.pointer(&cx, &PointerEvent::new(PointerKind::Drag, 300.0, 100.0).with_mods(ctrl))); + assert!((decay - 80.0 * 0.5f64.powf(1.0 / 8.0)).abs() < 1e-9, "{decay}"); + // Let go: the decay stays, and the spiral drawn next keeps both. + assert_eq!(spiral(t.pointer(&cx, &PointerEvent::new(PointerKind::Drag, 250.0, 100.0))), (150.0, decay, 8)); + assert_eq!(t.pointer(&cx, &PointerEvent::new(PointerKind::Up, 250.0, 100.0)), vec![Action::Commit]); + assert_eq!((t.options()["decay"].as_f64(), t.options()["segments"].clone()), (Some(decay), json!(8))); + t.pointer(&cx, &PointerEvent::new(PointerKind::Down, 100.0, 100.0)); + let (_, d2, n2) = spiral(t.pointer(&cx, &PointerEvent::new(PointerKind::Drag, 100.0, 140.0).with_mods(alt))); + assert_eq!((d2, n2), (decay, 8), "a new drag holds the end anew"); + // 5 % and 300 segments: an inner radius of 100 × 0.05^300 is below the smallest f64. + let mut t = FamilyTool::new("spiral"); + t.set_option("decay", &json!(5.0)); + t.set_option("segments", &json!(300)); + t.pointer(&cx, &PointerEvent::new(PointerKind::Down, 100.0, 100.0)); + t.pointer(&cx, &PointerEvent::new(PointerKind::Drag, 200.0, 100.0).with_mods(ctrl)); + let (_, decay, _) = spiral(t.pointer(&cx, &PointerEvent::new(PointerKind::Drag, 150.0, 100.0).with_mods(ctrl))); + assert!((decay - 5.0 * 2f64.powf(1.0 / 300.0)).abs() < 1e-9, "{decay}"); + t.pointer(&cx, &PointerEvent::new(PointerKind::Drag, 150.0, 100.0)); + // Alt: 200 times the radius adds ln 200 / ln (100 / decay) ≈ 1.8 segments. + t.pointer(&cx, &PointerEvent::new(PointerKind::Drag, 120.0, 100.0).with_mods(alt)); + assert_eq!(spiral(t.pointer(&cx, &PointerEvent::new(PointerKind::Drag, 4100.0, 100.0).with_mods(alt))).2, 302); + } + #[test] fn arc_drag_uses_stored_segment_options() { let mut t = FamilyTool::new("arc"); diff --git a/docs/mcp.md b/docs/mcp.md index 8f6c84a94..b907decc2 100644 --- a/docs/mcp.md +++ b/docs/mcp.md @@ -486,6 +486,17 @@ values, and they stay as the Arc tool's `closed`, `baseAlong` and `slope` option dragged, `c` chooses the tool bound to C, and `f` runs the shortcut bound to F in remote mode (headless has no binding for it). +While a spiral is being dragged out with the Spiral tool, a `drag` event with `mods.alt` or `mods.cmd` keeps the +spiral's inner end at the distance from the center it had at the first such event, and the outer end follows the +pointer. With `mods.alt` the number of segments changes to the nearest whole number of segments that fits (within +2…1000), so dragging outward adds segments and dragging inward removes them; with `mods.cmd` the decay changes to fit +(within 5…99.99 %). The spiral drawn on release uses the new values, and they stay as the Spiral tool's `segments` and +`decay` options for the next spiral: + +```json +{"name":"pointer_gesture","arguments":{"tool":"spiral","events":[{"kind":"down","x":100,"y":100},{"kind":"drag","x":200,"y":100,"mods":{"alt":true}},{"kind":"drag","x":300,"y":100,"mods":{"alt":true}},{"kind":"up","x":300,"y":100}]}} +``` + Appearance stacks: an object can carry several fills and strokes (`appearance.addFill`, `appearance.addStroke`), indexed in paint order (0 is painted first, the bottom row of the Appearance panel). Cmd+/ runs `appearance.addFill` and Cmd+Alt+/ runs `appearance.addStroke` (`press_key {key: "/", mods: {cmd: true, alt: true}}`). `paint.setFill`, `paint.setStroke`, diff --git a/docs/target-app-parity.md b/docs/target-app-parity.md index a81954400..6387f91d3 100644 --- a/docs/target-app-parity.md +++ b/docs/target-app-parity.md @@ -673,6 +673,7 @@ Moved from the ROADMAP's "Shipped so far" on 2026-10-10. Update it when a featur - **Shapes:** all shape tools (including Flare) and the line, arc, spiral and grid tools. Space held while dragging out a shape, line, arc, spiral or grid moves it at its current size. The Flare tool's center drag turns the rays with the pointer, Shift keeps them in their original orientation (unturned, or turned by a nonzero General › Constrain Angle), and Space held during either of its drags moves the flare. While a grid is dragged out, Up/Down change its rows (concentric dividers) and Left/Right its columns (radial dividers). A click with a grid tool asks for the size, the divider counts and the grid options (#1377, in part): a skew for each set of dividers, Use Outside Rectangle As Frame or Create Compound Path From Ellipses, and Fill Grid; they are parameters of `shape.rectangularGrid` and `shape.polarGrid`, stay as the tool's options and apply to drags too. The curve a skew percentage follows and the direction of the radial skew are our own (the public documentation gives neither), and the dialogs have no reference point locator. An ellipse's Pie Start and End Angle and Invert Pie (Transform and Properties panels' Ellipse Properties, `object.setLiveShape {pieStart, pieEnd, invertPie}`) cut it as a pie (#812). With the bounding box showing, the Selection tool shows a selected ellipse's two pie widgets just outside it; dragging one sets its angle in one undo step and double-clicking one makes the ellipse whole again. A live polygon's Polygon Properties there: side count, angle, corner type and radius, radius, side length and Make Sides Equal once an uneven scale has made its sides differ (`object.setLiveShape {sides, polygonAngle, polygonRadius, sideLength, makeSidesEqual}`, part of #812); a star is a plain path, as in Illustrator. The Flare Tool Options last as the tool's options: the next flare dragged out uses them, a click's dialog shows them and double-clicking the tool opens them (part of #812). - **Shapes:** all shape tools (including Flare) and the line, arc, spiral and grid tools. Space held while dragging out a shape, line, arc, spiral or grid moves it at its current size. The Flare tool's center drag turns the rays with the pointer, Shift keeps them in their original orientation, and Space held during either of its drags moves the flare. While a grid is dragged out, Up/Down change its rows (concentric dividers) and Left/Right its columns (radial dividers). A click with a grid tool asks for the size, the divider counts and the grid options (#1377, in part): a skew for each set of dividers, Use Outside Rectangle As Frame or Create Compound Path From Ellipses, and Fill Grid; they are parameters of `shape.rectangularGrid` and `shape.polarGrid`, stay as the tool's options and apply to drags too. The curve a skew percentage follows and the direction of the radial skew are our own (the public documentation gives neither), and the dialogs have no reference point locator. An ellipse's Pie Start and End Angle and Invert Pie (Transform and Properties panels' Ellipse Properties, `object.setLiveShape {pieStart, pieEnd, invertPie}`) cut it as a pie (#812). With the bounding box showing, the Selection tool shows a selected ellipse's two pie widgets just outside it; dragging one sets its angle in one undo step and double-clicking one makes the ellipse whole again. A live polygon's Polygon Properties there: side count, angle, corner type and radius, radius, side length and Make Sides Equal once an uneven scale has made its sides differ (`object.setLiveShape {sides, polygonAngle, polygonRadius, sideLength, makeSidesEqual}`, part of #812); a star is a plain path, as in Illustrator. The Flare Tool Options last as the tool's options: the next flare dragged out uses them, a click's dialog shows them and double-clicking the tool opens them (part of #812). While an arc is dragged out, C switches it between open and closed, F switches its base between the X and Y axes in the same box, and Up/Down raise and lower its slope by 10 within −100…100; each press switches once, and the values stay as the Arc tool's options. The [default keyboard shortcuts](https://web.archive.org/web/20260726060015/https://helpx.adobe.com/illustrator/using/default-keyboard-shortcuts.html) (archived copy) list C, F and Up/Down for an arc being dragged out. F as the base switch, the arrows changing the slope, the step of 10 and one switch per press are our own rules. + While a spiral is dragged out, Alt adds or removes segments and Ctrl/Cmd changes the decay; both keep the spiral's inner end at the distance from the center it had when the key went down while the outer end follows the pointer, and the values stay as the Spiral tool's options. The [default keyboard shortcuts](https://web.archive.org/web/20260726060015/https://helpx.adobe.com/illustrator/using/default-keyboard-shortcuts.html) (archived copy) list Alt-drag to add or subtract winds and Ctrl-drag to change the decay rate. The held inner radius, rounding to whole segments, the ranges (2…1000 segments, 5…99.99 % decay) and the new values staying are our own rules. - **Cutting:** Eraser, Scissors, Knife, Mirror & Cut, Line Cut and Rectangle Cut (real geometry, compound paths keep their holes). While Alt is held, the Scissors tool works as the Add Anchor Point tool ([Default keyboard shortcuts](https://web.archive.org/web/20260726060015/https://helpx.adobe.com/illustrator/using/default-keyboard-shortcuts.html)): a click on a segment adds an anchor point without cutting (`path.insertAnchor`), and the pointer shows the Add Anchor Point cursor. When Alt is let go, a click cuts again. Eraser Tool Options has Angle and Roundness besides Size, as [Erase paths using the Eraser tool](https://helpx.adobe.com/illustrator/desktop/draw-shapes-and-paths/modify-paths/erase-paths-using-eraser-tool.html) describes: the Eraser's tip is an ellipse Size across, its long axis at Angle and its short axis Roundness percent of the long one, and a drag with a tip that is not round shows the outline of the area it erases (`path.eraseRegion` `angle` and `roundness`).