diff --git a/crates/engine/src/tests/draw2.rs b/crates/engine/src/tests/draw2.rs index 30b1c4a3f..ab039b9c3 100644 --- a/crates/engine/src/tests/draw2.rs +++ b/crates/engine/src/tests/draw2.rs @@ -1391,17 +1391,17 @@ fn spiral_drags_with_alt_and_ctrl_change_its_segments_and_decay() { }; s.select_tool("spiral", v).unwrap(); let undo = undo_steps(&s); - // Alt from a radius of 100 to 200: 10 segments become 14, one full wind of four quarter turns. + // Alt from a radius of 100 to 200: preserving the inner end adds three full winds. let (anchors, outer) = spiral(&mut s, Mods { alt: true, ..Mods::default() }, (300.0, 100.0)); - assert_eq!(anchors, 15); + assert_eq!(anchors, 23); assert!(near(outer, Point::new(300.0, 100.0)), "{outer:?}"); - assert_eq!(s.tool_options()["segments"], json!(14)); - // Ctrl/Cmd from 100 to 50: the decay rises from 80 % to 80 × 2^(1/14) %. + assert_eq!(s.tool_options()["segments"], json!(22)); + // Ctrl/Cmd from 100 to 50: the decay rises from 80 % to 80 × 2^(4/22) %. let (anchors, outer) = spiral(&mut s, Mods { cmd: true, ..Mods::default() }, (150.0, 100.0)); - assert_eq!(anchors, 15); + assert_eq!(anchors, 23); 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 / 14.0)).abs() < 1e-9, "{decay}"); + assert!((decay - 80.0 * 2f64.powf(4.0 / 22.0)).abs() < 1e-9, "{decay}"); assert_eq!(undo_steps(&s) - undo, 2, "one undo step a spiral"); } diff --git a/crates/geom/src/shapes.rs b/crates/geom/src/shapes.rs index 2747fdf45..8ba46c780 100644 --- a/crates/geom/src/shapes.rs +++ b/crates/geom/src/shapes.rs @@ -219,17 +219,18 @@ pub fn arc(a: Point, b: Point, slope: f64, closed: bool, _base_along_x: bool) -> PathData::single(SubPath::new(anchors, closed)) } -/// Logarithmic spiral approximating the reference app's Spiral tool (decay-based, `segments` quarter turns). +/// Logarithmic spiral with decay per full wind (`segments` quarter turns). pub fn spiral(center: Point, radius: f64, decay_percent: f64, segments: u32, clockwise: bool) -> PathData { let decay = (decay_percent / 100.0).clamp(0.05, 0.9999); + let step_decay = decay.powf(0.25); let dir = if clockwise { 1.0 } else { -1.0 }; - // The radius shrinks by `decay` each quarter turn: r(φ) = radius · e^(kφ) after turning φ. - let k = decay.ln() / (PI / 2.0); + // The radius shrinks by `decay` each full wind: r(φ) = radius · e^(kφ). + let k = decay.ln() / TAU; // Handle lengths per unit of the derivative below, at a segment's outer and inner end: √r // changes evenly with the curve's parameter along each quarter turn. Both are KAPPA when the // radius does not shrink, and the curve stays within 0.8% of the spiral's radius at 20% decay. - let outer = 2.0 * KAPPA * (decay.sqrt() - 1.0) / decay.ln(); - let inner = outer / decay.sqrt(); + let outer = 2.0 * KAPPA * (step_decay.sqrt() - 1.0) / step_decay.ln(); + let inner = outer / step_decay.sqrt(); let mut anchors = Vec::new(); let mut r = radius; for i in 0..=segments { @@ -241,7 +242,7 @@ pub fn spiral(center: Point, radius: f64, decay_percent: f64, segments: u32, clo let h_in = p - d * inner; let h_out = p + d * outer; anchors.push(Anchor { p, h_in: if i == 0 { p } else { h_in }, h_out: if i == segments { p } else { h_out }, kind: AnchorKind::Smooth }); - r *= decay; + r *= step_decay; } // Illustrator spirals wind inward from the outer point; reverse so the path starts at the centre end. let mut sp = SubPath::new(anchors, false); @@ -475,8 +476,17 @@ mod tests { assert!(!p.subpaths[0].closed); } + #[test] + fn spiral_decay_is_per_full_wind() { + let p = spiral(Point::ZERO, 100.0, 80.0, 8, true); + let radii: Vec<_> = p.subpaths[0].anchors.iter().map(|a| (a.p - Point::ZERO).hypot()).collect(); + assert!((radii[8] - 100.0).abs() < 1e-9); + assert!((radii[4] - 80.0).abs() < 1e-9); + assert!((radii[0] - 64.0).abs() < 1e-9); + } + /// #3437: every segment stays on the spiral through its anchors, whose radius shrinks by - /// `decay` each quarter turn, for strong and weak decay and both directions. + /// `decay` each full wind, for strong and weak decay and both directions. #[test] fn spiral_segments_follow_the_spiral() { let center = Point::new(40.0, -25.0); @@ -484,7 +494,7 @@ mod tests { for clockwise in [true, false] { let p = spiral(center, 100.0, decay, 8, clockwise); let anchors = &p.subpaths[0].anchors; - let k = (decay / 100.0_f64).ln() / (PI / 2.0); + let k = (decay / 100.0_f64).ln() / TAU; let mut worst = 0.0_f64; for pair in anchors.windows(2) { let c = kurbo::CubicBez::new(pair[0].p, pair[0].h_out, pair[1].h_in, pair[1].p); diff --git a/crates/tools/src/draw2/family.rs b/crates/tools/src/draw2/family.rs index 0bbdcf79b..6d1082971 100644 --- a/crates/tools/src/draw2/family.rs +++ b/crates/tools/src/draw2/family.rs @@ -127,7 +127,7 @@ impl FamilyTool { /// 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() + r.ln() + f64::from(self.segments) / 4.0 * (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 @@ -150,12 +150,12 @@ impl FamilyTool { } 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(); + let ratio = ((end - r.ln()) * 4.0 / f64::from(self.segments)).exp(); if ratio.is_finite() { self.decay = (ratio * 100.0).clamp(5.0, 99.99); } } else { - let fitted = (r.ln() - end) / -(self.decay / 100.0).clamp(0.05, 0.9999).ln(); + let fitted = (r.ln() - end) * 4.0 / -(self.decay / 100.0).clamp(0.05, 0.9999).ln(); if fitted.is_finite() { let base = f64::from(self.spiral_alt_base.unwrap_or(self.segments)); self.segments = (base + 4.0 * ((fitted - base) / 4.0).round()).clamp(2.0, 1000.0) as u32; @@ -631,44 +631,44 @@ mod tests { assert_eq!(spiral(t.pointer(&cx, &PointerEvent::new(PointerKind::Drag, 200.0, 100.0))), (100.0, 80.0, 10)); // Alt rounds the fitted count to whole winds from the count when Alt goes down. 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, 14)); + assert_eq!(spiral(t.pointer(&cx, &PointerEvent::new(PointerKind::Drag, 300.0, 100.0).with_mods(alt))), (200.0, 80.0, 22)); // ↑ with Alt held adds one, and the held inner end moves with it. - assert_eq!(spiral(t.key(&cx, ToolKey::Up, alt)).2, 15); - assert_eq!(spiral(t.pointer(&cx, &PointerEvent::new(PointerKind::Drag, 300.0, 100.0).with_mods(alt))).2, 15); - assert_eq!(spiral(t.pointer(&cx, &PointerEvent::new(PointerKind::Drag, 150.0, 100.0).with_mods(alt))), (50.0, 80.0, 7)); + assert_eq!(spiral(t.key(&cx, ToolKey::Up, alt)).2, 23); + assert_eq!(spiral(t.pointer(&cx, &PointerEvent::new(PointerKind::Drag, 300.0, 100.0).with_mods(alt))).2, 23); + assert_eq!(spiral(t.pointer(&cx, &PointerEvent::new(PointerKind::Drag, 150.0, 100.0).with_mods(alt))), (50.0, 80.0, 2)); 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, 7); + assert_eq!(spiral(t.pointer(&cx, &PointerEvent::new(PointerKind::Drag, 150.0, 100.0).with_mods(alt))).2, 2); // The count stays 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, 7)); + assert_eq!(spiral(t.pointer(&cx, &PointerEvent::new(PointerKind::Drag, 200.0, 100.0))), (100.0, 80.0, 2)); // Ctrl/Cmd fits the decay to the held inner end. 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, 7) && (decay - 80.0 * 2f64.powf(1.0 / 7.0)).abs() < 1e-9, "{r} {decay} {n}"); + assert_eq!((r, decay, n), (50.0, 99.99, 2), "{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 / 7.0)).abs() < 1e-9, "{decay}"); + assert!((decay - 80.0 / 9.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 %"); assert_eq!(spiral(t.pointer(&cx, &PointerEvent::new(PointerKind::Drag, 1e20, 100.0).with_mods(ctrl))).1, 5.0, "at least 5 %"); 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 / 7.0)).abs() < 1e-9, "{decay}"); + assert!((decay - 20.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, 7)); + assert_eq!(spiral(t.pointer(&cx, &PointerEvent::new(PointerKind::Drag, 250.0, 100.0))), (150.0, decay, 2)); 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!(7))); + assert_eq!((t.options()["decay"].as_f64(), t.options()["segments"].clone()), (Some(decay), json!(2))); 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, 7), "a new drag holds the end anew"); - // 5 % and 300 segments: an inner radius of 100 × 0.05^300 is below the smallest f64. + assert_eq!((d2, n2), (decay, 2), "a new drag holds the end anew"); + // Low decay over many winds still fits using logarithms. 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}"); + assert!((decay - 5.0 * 2f64.powf(4.0 / 300.0)).abs() < 1e-9, "{decay}"); t.pointer(&cx, &PointerEvent::new(PointerKind::Drag, 150.0, 100.0)); // Alt: a large radius increase adds a whole wind. t.pointer(&cx, &PointerEvent::new(PointerKind::Drag, 120.0, 100.0).with_mods(alt)); - assert_eq!(spiral(t.pointer(&cx, &PointerEvent::new(PointerKind::Drag, 200100.0, 100.0).with_mods(alt))).2, 304); + assert_eq!(spiral(t.pointer(&cx, &PointerEvent::new(PointerKind::Drag, 200100.0, 100.0).with_mods(alt))).2, 312); } /// Switching between Alt and Ctrl/Cmd keeps the held inner end and uses the current segment @@ -687,21 +687,21 @@ mod tests { t.pointer(&cx, &PointerEvent::new(PointerKind::Down, 100.0, 100.0)); t.pointer(&cx, &PointerEvent::new(PointerKind::Drag, 200.0, 100.0).with_mods(alt)); t.pointer(&cx, &PointerEvent::new(PointerKind::Drag, 300.0, 100.0).with_mods(alt)); - assert_eq!(t.segments, 14); + assert_eq!(t.segments, 22); t.pointer(&cx, &PointerEvent::new(PointerKind::Drag, 200.0, 100.0).with_mods(both)); - assert_eq!(t.segments, 14, "Ctrl/Cmd takes precedence"); + assert_eq!(t.segments, 22, "Ctrl/Cmd takes precedence"); let decay = t.decay; assert!(decay > 80.0); t.pointer(&cx, &PointerEvent::new(PointerKind::Drag, 200.0, 100.0).with_mods(ctrl)); t.pointer(&cx, &PointerEvent::new(PointerKind::Drag, 300.0, 100.0).with_mods(alt)); - assert_eq!(t.segments, 18, "Alt resumes from the current count with the held end"); + assert_eq!(t.segments, 50, "Alt resumes from the current count with the held end"); assert_eq!(t.decay, decay); t.key(&cx, ToolKey::Escape, alt); assert_eq!(t.spiral_end, None); assert_eq!(t.spiral_alt_base, None); t.pointer(&cx, &PointerEvent::new(PointerKind::Down, 100.0, 100.0)); t.pointer(&cx, &PointerEvent::new(PointerKind::Drag, 200.0, 100.0).with_mods(alt)); - assert_eq!(t.segments, 18, "a new drag holds a new inner end"); + assert_eq!(t.segments, 50, "a new drag holds a new inner end"); } /// Alt adjusts the segment count in whole winds while keeping it within 2 to 1000. @@ -712,7 +712,7 @@ mod tests { let start = Point::new(0.0, 0.0); t.hold_spiral_end(start, Point::new(100.0, 0.0), alt); t.hold_spiral_end(start, Point::new(200.0, 0.0), alt); - assert_eq!(t.segments, 14); + assert_eq!(t.segments, 22); t.hold_spiral_end(start, Point::new(0.01, 0.0), alt); assert_eq!(t.segments, 2); t.hold_spiral_end(start, Point::new(1e150, 0.0), alt); diff --git a/docs/mcp.md b/docs/mcp.md index 0ca4e3235..f8474e09d 100644 --- a/docs/mcp.md +++ b/docs/mcp.md @@ -492,7 +492,8 @@ While a spiral is being dragged out with the Spiral tool, a `drag` event with `m spiral's inner end at the distance from the center it had on the first modified `drag` event, and the outer end follows the pointer. With `mods.alt` alone, dragging outward adds whole winds of four segments and dragging inward removes them. The fitted segment count rounds to the nearest whole wind from the count on the first Alt-only `drag`, within 2…1000. -With `mods.cmd`, decay changes to fit within 5…99.99 %; it takes precedence when both modifiers are held. Switching +Decay is the percentage retained after each full wind of four segments. With `mods.cmd`, decay changes to fit within +5…99.99 %; it takes precedence when both modifiers are held. Switching back to Alt uses the current count as its new base while keeping the held inner end. 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: diff --git a/docs/target-app-parity.md b/docs/target-app-parity.md index b4370a3a0..1481596d5 100644 --- a/docs/target-app-parity.md +++ b/docs/target-app-parity.md @@ -1,9 +1,11 @@ # Parity with Adobe Illustrator -> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-11-03 · **Change:** minor (new gradients follow document color mode; scores and estimates unchanged) · **Target:** Adobe Illustrator 2026 (30.x; public release notes through 30.7, August 2026; Units › East Asian Type acts, with Q and Ha units; scores unchanged) · **Target:** Adobe Illustrator 2026 (30.x; public release notes through 30.7, August 2026) +> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-11-03 · **Change:** minor (spiral decay applies per full wind; scores and estimates unchanged) · **Target:** Adobe Illustrator 2026 (30.x; public release notes through 30.7, August 2026; Units › East Asian Type acts, with Q and Ha units; scores unchanged) · **Target:** Adobe Illustrator 2026 (30.x; public release notes through 30.7, August 2026) ## Feature submission evidence +Spiral decay now applies after each full wind of four segments. A newly drawn spiral at 80% decay keeps 80% of its radius after one wind. Existing saved paths retain their geometry; newly drawn paths with the same settings differ from earlier VectorCraft builds. Scores and estimates are unchanged. + Creating a gradient from solid or empty paint uses the document's color model for its default stops, including drawing its vector with the Gradient tool. Explicit `keepModel: true` retains RGB defaults, and existing gradients keep their own colors. Scores and estimates are unchanged. **Feature evidence last updated:** 2026-11-03. @@ -21,6 +23,7 @@ Artboard Options forwards the active Artboard tool's Scale Artwork with Artboard | Date | Change | Summary | |---|---|---| +| 2026-11-03 | minor | Spiral decay applies once per full wind of four segments; newly drawn spirals with the same settings wind more broadly than earlier builds. Existing paths are unchanged. Scores and estimates unchanged. | | 2026-11-03 | minor | New gradients use CMYK default stops in CMYK documents, including Gradient tool creation. Scores and estimates unchanged. | | 2026-11-03 | minor | Type Tools add-on: Text Tweaker (a VectorCraft addition, #4924): evidence added; scores unchanged. | | 2026-11-03 | minor | Transform previews valid typed values while numeric fields remain focused. Effect dialogs use the object, group, or layer targeted in Appearance. | @@ -739,7 +742,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, 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 a grid is dragged out, F lowers `rowSkew` (rectangular) or `radialSkew` (polar) by 10 percentage points and V raises it by 10; X lowers `columnSkew` (rectangular) or `concentricSkew` (polar) by 10 and C raises it by 10. Plain and Shift presses work; with Alt, Ctrl or Cmd held they do not, as for the other letters a tool takes while dragging. Each press changes the skew once and updates the preview; values stay within ±500% and remain in the tool options after release. The [default keyboard shortcuts](https://web.archive.org/web/20260726060015/https://helpx.adobe.com/illustrator/using/default-keyboard-shortcuts.html) (archived copy) list the four drag keys and a 10% change. The signs (positive values follow VectorCraft's existing skew convention, so F and X lower the stored value) and one change per press with key repeat ignored are our own rules. 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 whole winds of four segments and Ctrl/Cmd changes the decay. The spiral's inner end keeps the distance from the center it had on the first modified drag event, while the outer end follows the pointer. 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; [Draw spirals](https://helpx.adobe.com/illustrator/desktop/draw-shapes-and-paths/draw-shapes/draw-spirals.html) defines four segments per full wind. Rounding the fitted count to whole winds from the count on the first Alt-only drag event, the held inner radius, the ranges (2…1000 segments, 5…99.99 % decay), Ctrl/Cmd taking precedence when both are held, and the new values staying as Spiral tool options are our own rules. Switching back to Alt uses the current count as its new base while keeping the held inner end. + While a spiral is dragged out, Alt adds or removes whole winds of four segments and Ctrl/Cmd changes the decay. The spiral's inner end keeps the distance from the center it had on the first modified drag event, while the outer end follows the pointer. 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; [Draw spirals](https://helpx.adobe.com/illustrator/desktop/draw-shapes-and-paths/draw-shapes/draw-spirals.html) defines four segments per full wind and Decay as the amount each wind decreases. An 80% spiral has 80% of its outer radius after four segments; newly drawn spirals with the same settings wind more broadly than in earlier VectorCraft builds. Rounding the fitted count to whole winds from the count on the first Alt-only drag event, the held inner radius, the ranges (2…1000 segments, 5…99.99 % decay), Ctrl/Cmd taking precedence when both are held, and the new values staying as Spiral tool options are our own rules. Switching back to Alt uses the current count as its new base while keeping the held inner end. - **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. Alt-drag with the Knife cuts in a straight line, and Shift+Alt-drag cuts at 45° or 90°, as [Cut objects using the Knife and Scissors tools](https://web.archive.org/web/20260926095638/https://helpx.adobe.com/illustrator/desktop/manage-objects/edit-objects/cut-objects.html) and [Default keyboard shortcuts](https://web.archive.org/web/20260726060015/https://helpx.adobe.com/illustrator/using/default-keyboard-shortcuts.html) describe. Alt counts only when held at the press; the dashed line runs from the press point to the pointer, stays straight after Alt is let go, and release sends `path.knife` with those two points. Alt pressed after the press leaves the cut freehand. Shift+Alt keeps the endpoint at 45° steps from the horizontal and vertical, whatever the Constrain Angle.