Flare: the rays turn a step for each drag update up or down, and Shift holds the turn

While the center was dragged out, the rays pointed at the pointer, as a
polygon's first corner does, and Shift put them back in their original
orientation. In the reference app they turn by a step for each update
of the drag as the pointer moves up or down, and Shift stops the turn
where it is.

Each update of the center drag in which the pointer's document y grows
now adds 0.1 radian (about 5.73°) to the rays' turn, one in which it
falls takes 0.1 radian away, and one at the same y leaves the turn as
it is. Shift held keeps the turn, and turning goes on from there once
Shift is let go. Space held moves the flare without turning the rays.
General › Constrain Angle no longer sets the turn under Shift.

Docs: docs/mcp.md, docs/target-app-parity.md.
This commit is contained in:
dotCooCoo
2026-10-29 20:47:53 +07:00
committed by bflatastic
parent 997826c400
commit ec2744f844
3 changed files with 72 additions and 40 deletions
+67 -37
View File
@@ -1,7 +1,7 @@
//! Flare, Reshape, Shaper and Graph tools.
//!
//! Flare: drag sets the center and its size and turns the rays with the pointer (Shift keeps them in
//! their original orientation; click without dragging opens the Flare Tool Options), then a click
//! Flare: drag sets the center and its size and turns the rays as the pointer moves up or down
//! (Shift holds their turn; click without dragging opens the Flare Tool Options), then a click
//! sets the end point of the rings; Space held during either drag moves the flare. The rest comes
//! from the tool's options (the Flare Tool Options, which last). Reshape: drag a point of a
//! selected path; the engine's `path.reshape` adds an anchor there if needed and moves the
@@ -109,12 +109,17 @@ pub fn flare_params(o: &Value, c: Point) -> Value {
v
}
/// The rays' turn (degrees, clockwise) for a center drag along `v`: as a polygon's first corner,
/// the rays follow the pointer, and a drag straight up leaves them unturned. Shift keeps them in
/// their original orientation: unturned, or turned by General › Constrain Angle `base` (degrees,
/// counter-clockwise) as a polygon drawn with Shift is.
fn ray_turn(v: Vec2, shift: bool, base: f64) -> f64 {
if shift { vectorcraft_geom::constrain_rotation(base).unwrap_or(0.0) } else { (v.atan2().to_degrees() + 90.0).rem_euclid(360.0) }
/// How far one update of the center drag turns the rays, in radians.
const RAY_STEP: f64 = 0.1;
/// The rays' turn (degrees, as `shape.flare`'s `rotation`) after a center drag update whose
/// pointer moved `dy` in document y from the last one: a greater y adds [`RAY_STEP`], a smaller y
/// takes it away, and the same y leaves the turn as it is. Shift holds the turn.
fn ray_turn(turn: f64, dy: f64, shift: bool) -> f64 {
if shift || dy == 0.0 {
return turn;
}
(turn + RAY_STEP.to_degrees().copysign(dy)).rem_euclid(360.0)
}
impl FlareTool {
@@ -157,9 +162,13 @@ impl Tool for FlareTool {
PointerKind::Drag => {
if let Some((mut c, _)) = self.drag {
// Space held moves the center with the pointer, so the size and the turn stay.
let dy = ev.pos.y - self.last.y;
let moving = ev.mods.space && self.began;
crate::shape::space_moves(&mut c, &mut self.last, ev, self.began);
self.drag = Some((c, ev.pos));
self.turn = ray_turn(ev.pos - c, ev.mods.shift, cx.constrain_angle);
if !moving {
self.turn = ray_turn(self.turn, dy, ev.mods.shift);
}
if !self.began && ev.pos.distance(c) >= cx.tol(3.0) {
self.began = true;
return vec![
@@ -572,10 +581,11 @@ mod tests {
}
}
/// The center drag turns the rays with the pointer, and Shift keeps them in their original
/// orientation; the second drag keeps the turn the first one left.
/// Each update of the center drag turns the rays by 0.1 radian as the pointer's y grows or
/// falls, and none as it stays; Shift holds the turn, and the second drag keeps the turn the
/// first one left.
#[test]
fn flare_drag_turns_the_rays_and_shift_keeps_them_unturned() {
fn flare_drag_turns_the_rays_and_shift_holds_their_turn() {
use crate::testutil::*;
use vectorcraft_doc::Selection;
@@ -584,34 +594,48 @@ mod tests {
let p = paint();
let cx = cx(&d, &s, &p);
let shift = Mods { shift: true, ..Default::default() };
let step = 0.1_f64.to_degrees();
let mut t = FlareTool::default();
t.pointer(&cx, &PointerEvent::new(PointerKind::Down, 100.0, 100.0));
let turn = |t: &mut FlareTool, x: f64, y: f64, m: Mods| {
flare_preview(&t.pointer(&cx, &PointerEvent::new(PointerKind::Drag, x, y).with_mods(m)))["rotation"].as_f64()
flare_preview(&t.pointer(&cx, &PointerEvent::new(PointerKind::Drag, x, y).with_mods(m)))["rotation"].as_f64().unwrap_or(f64::NAN)
};
assert_eq!(turn(&mut t, 140.0, 100.0, Mods::default()), Some(90.0));
assert_eq!(turn(&mut t, 100.0, 60.0, Mods::default()), Some(0.0));
assert_eq!(turn(&mut t, 100.0, 140.0, Mods::default()), Some(180.0));
assert_eq!(turn(&mut t, 60.0, 100.0, Mods::default()), Some(270.0));
// Shift: the rays as they are without a turn, at the size the pointer gives.
let v = flare_preview(&t.pointer(&cx, &PointerEvent::new(PointerKind::Drag, 130.0, 140.0).with_mods(shift)));
assert_eq!((v["rotation"].as_f64(), v["diameter"].as_f64()), (Some(0.0), Some(100.0)));
let near = |a: f64, b: f64| {
let d = (a - b).rem_euclid(360.0);
d < 1e-9 || 360.0 - d < 1e-9
};
// Sideways: no turn, however far the pointer goes round the center.
assert!(near(turn(&mut t, 140.0, 100.0, Mods::default()), 0.0));
// Each update with a greater y adds a step, whatever the distance moved.
assert!(near(turn(&mut t, 140.0, 101.0, Mods::default()), step));
assert!(near(turn(&mut t, 140.0, 160.0, Mods::default()), 2.0 * step));
// A smaller y takes a step away, past zero.
assert!(near(turn(&mut t, 140.0, 150.0, Mods::default()), step));
assert!(near(turn(&mut t, 140.0, 100.0, Mods::default()), 0.0));
assert!(near(turn(&mut t, 140.0, 90.0, Mods::default()), 360.0 - step));
// Shift holds the turn while the pointer still sizes the flare; let go, turning goes on.
let v = flare_preview(&t.pointer(&cx, &PointerEvent::new(PointerKind::Drag, 100.0, 150.0).with_mods(shift)));
assert!(near(v["rotation"].as_f64().unwrap_or(f64::NAN), 360.0 - step));
assert_eq!(v["diameter"].as_f64(), Some(100.0));
assert!(near(turn(&mut t, 100.0, 40.0, shift), 360.0 - step));
assert!(near(turn(&mut t, 140.0, 100.0, Mods::default()), 0.0));
// ↑ adds a ray and keeps the turn.
assert_eq!(turn(&mut t, 140.0, 100.0, Mods::default()), Some(90.0));
assert_eq!(flare_preview(&t.key(&cx, ToolKey::Up, Mods::default()))["rotation"].as_f64(), Some(90.0));
t.pointer(&cx, &PointerEvent::new(PointerKind::Up, 140.0, 100.0));
assert!(near(turn(&mut t, 140.0, 110.0, Mods::default()), step));
assert!(near(flare_preview(&t.key(&cx, ToolKey::Up, Mods::default()))["rotation"].as_f64().unwrap_or(f64::NAN), step));
t.pointer(&cx, &PointerEvent::new(PointerKind::Up, 140.0, 110.0));
// Placing the rings' end point keeps the turn the center drag left.
t.pointer(&cx, &PointerEvent::new(PointerKind::Down, 300.0, 200.0));
let a = t.pointer(&cx, &PointerEvent::new(PointerKind::Up, 300.0, 200.0).with_mods(shift));
t.pointer(&cx, &PointerEvent::new(PointerKind::Drag, 300.0, 260.0));
let a = t.pointer(&cx, &PointerEvent::new(PointerKind::Up, 300.0, 260.0).with_mods(shift));
assert_eq!(a.last(), Some(&Action::Commit));
assert_eq!(flare_preview(&a)["rotation"].as_f64(), Some(90.0));
// With a Constrain Angle, Shift keeps the rays turned by it, as a polygon drawn with Shift.
assert!(near(flare_preview(&a)["rotation"].as_f64().unwrap_or(f64::NAN), step));
// A nonzero Constrain Angle does not turn the rays under Shift.
let mut cx = cx;
cx.constrain_angle = 30.0;
let mut t = FlareTool::default();
t.pointer(&cx, &PointerEvent::new(PointerKind::Down, 100.0, 100.0));
let v = flare_preview(&t.pointer(&cx, &PointerEvent::new(PointerKind::Drag, 140.0, 100.0).with_mods(shift)));
assert_eq!(v["rotation"].as_f64(), Some(-30.0));
let v = flare_preview(&t.pointer(&cx, &PointerEvent::new(PointerKind::Drag, 140.0, 120.0).with_mods(shift)));
assert_eq!(v["rotation"].as_f64(), Some(0.0));
}
/// Space held while dragging out a flare moves it at its current size and turn, in the center
@@ -626,21 +650,27 @@ mod tests {
let p = paint();
let cx = cx(&d, &s, &p);
let space = Mods { space: true, ..Default::default() };
let step = 0.1_f64.to_degrees();
let mut t = FlareTool::default();
t.pointer(&cx, &PointerEvent::new(PointerKind::Down, 100.0, 100.0));
t.pointer(&cx, &PointerEvent::new(PointerKind::Drag, 130.0, 100.0));
let v = flare_preview(&t.pointer(&cx, &PointerEvent::new(PointerKind::Drag, 180.0, 150.0).with_mods(space)));
let at = |v: &Value| (v["cx"].as_f64(), v["cy"].as_f64(), v["diameter"].as_f64(), v["rotation"].as_f64());
assert_eq!(at(&v), (Some(150.0), Some(150.0), Some(60.0), Some(90.0)));
// Space let go: the size follows the pointer again, from the moved center.
let v = flare_preview(&t.pointer(&cx, &PointerEvent::new(PointerKind::Drag, 190.0, 150.0)));
assert_eq!(at(&v), (Some(150.0), Some(150.0), Some(80.0), Some(90.0)));
t.pointer(&cx, &PointerEvent::new(PointerKind::Up, 190.0, 150.0));
t.pointer(&cx, &PointerEvent::new(PointerKind::Drag, 100.0, 130.0));
// Space held: the pointer's y grows, but the flare moves without turning.
let v = flare_preview(&t.pointer(&cx, &PointerEvent::new(PointerKind::Drag, 150.0, 180.0).with_mods(space)));
let at = |v: &Value| (v["cx"].as_f64(), v["cy"].as_f64(), v["diameter"].as_f64());
let turned = |v: &Value, by: f64| v["rotation"].as_f64().is_some_and(|r| (r - by).abs() < 1e-9);
assert_eq!(at(&v), (Some(150.0), Some(150.0), Some(60.0)));
assert!(turned(&v, step));
// Space let go: the size follows the pointer again, from the moved center, and so does the turn.
let v = flare_preview(&t.pointer(&cx, &PointerEvent::new(PointerKind::Drag, 150.0, 190.0)));
assert_eq!(at(&v), (Some(150.0), Some(150.0), Some(80.0)));
assert!(turned(&v, 2.0 * step));
t.pointer(&cx, &PointerEvent::new(PointerKind::Up, 150.0, 190.0));
// The second drag: Space held moves the flare, its rings' end point with it.
t.pointer(&cx, &PointerEvent::new(PointerKind::Move, 300.0, 300.0));
t.pointer(&cx, &PointerEvent::new(PointerKind::Down, 300.0, 300.0));
let v = flare_preview(&t.pointer(&cx, &PointerEvent::new(PointerKind::Drag, 320.0, 310.0).with_mods(space)));
assert_eq!((at(&v), v["x2"].as_f64(), v["y2"].as_f64()), ((Some(170.0), Some(160.0), Some(80.0), Some(90.0)), Some(320.0), Some(310.0)));
assert_eq!((at(&v), v["x2"].as_f64(), v["y2"].as_f64()), ((Some(170.0), Some(160.0), Some(80.0)), Some(320.0), Some(310.0)));
assert!(turned(&v, 2.0 * step));
let a = t.pointer(&cx, &PointerEvent::new(PointerKind::Up, 340.0, 310.0));
assert_eq!(a.last(), Some(&Action::Commit));
let v = flare_preview(&a);
+4 -3
View File
@@ -5133,9 +5133,10 @@ desktop app. The Flare tool's are its Flare Tool Options (dialog `flareOptions`)
(Option on macOS) is held, the dialog's Cancel button reads Reset. Reset puts every option back to the tool's default,
leaves a click's `x` and `y` unchanged and keeps the dialog open; `ui.dialogReset` does the same. OK keeps the options
(`tool.setOption {tool: "flare", values}`) and the next flare dragged out uses them; a click with the tool opens the
same dialog, and its OK also draws a flare there. Dragging out the center turns the rays with the pointer (a drag
straight up leaves them unturned; `shape.flare`'s `rotation`, degrees clockwise, turns only the rays), and Shift
keeps them unturned, or turned by General › Constrain Angle. Space held during the center drag or the drag that
same dialog, and its OK also draws a flare there. While the center is dragged out, each drag update in which the
pointer's document y grows adds 0.1 radian (about 5.73°) to `shape.flare`'s `rotation` (degrees clockwise, it turns
only the rays), each one in which it falls takes 0.1 radian away, and one at the same y leaves it; Shift held keeps
the turn as it is. Space held during the center drag or the drag that
places the rings' end point moves the flare at its current size. The Artboard tool's are the active artboard's Artboard Options
(dialog `artboardOptions`; the Artboard tool's active artboard while it is in use, else the window's; its `x` and `y`
are the artboard's top-left corner in document points, while the dialog shows X and Y at the reference point the
+1
View File
@@ -662,6 +662,7 @@ Moved from the ROADMAP's "Shipped so far" on 2026-10-10. Update it when a featur
Curvature point markers are circular, with no direction handles. While drawing, its own point feedback replaces generic selection markers; existing paths use the selection overlay, including partial selections and Hide Edges. Anchor Size applies, and Direct Selection and Pen retain their handles. The real egui-frame regressions in `crates/ui-egui/src/canvas.rs` verify marker positions, counts and unchanged documents (#1236, in part); the existing-path interpolation request remains open. The curve through the points (`curvature_spline` in `crates/tools/src/draw2.rs`) makes each stretch of three points follow the circle through them: a smooth point's handles lie along that circle's tangent, each `chord / (3 cos²(α/2))` long (α between handle and chord, capped at 90°), and the ends of an open path mirror their neighbour's tangent in their chord, so a J or a U curves evenly, a turn tighter than 30° comes to a point, and uneven spacing doesn't loop; the rubber band, a dragged or inserted point and `path.convertAnchor` to smooth use the same rule, while the Pencil's fitting keeps its own; `tests/draw2.rs` checks that the rubber band is the path the click makes (#1236, in part). The Smooth tool smooths only the stretch of path under the drag (#1547, fixes #1512).
While Alt is held, the Paintbrush (before a press and while drawing) shows a closing cursor (a crosshair with a ring), and so does the Pencil with Alt key toggles to Smooth Tool off when Alt is pressed after the drag starts; releasing the mouse button with Alt still held closes the path. A Pencil drag started with Shift or Alt held draws a straight line instead, which stays open.
With Shift held at the press, the Pencil draws a straight line from the press point to the pointer at 45° steps from the Constrain Angle, as [Draw straight lines with the Pencil tool](https://helpx.adobe.com/illustrator/desktop/draw-shapes-and-paths/draw-shapes/draw-straight-lines-with-the-pencil-tool.html) describes; pressed on an end of a selected open path, the line continues the path from that end (part of #3799). With Option/Alt held at the press and Alt key toggles to Smooth Tool off, the Pencil draws a straight line at any angle, whose ends snap to Smart Guides, and it stays open when Alt is still held at the release; Shift and Alt together keep the 45° steps (#3799). While Shift or Alt is held before the press, the Pencil shows a straight-line cursor (a crosshair with a short line), and over an end of a selected open path, where the line continues the path, a path-continuation cursor (the same line leaving a small square), as the page describes.
- **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 by 0.1 radian at each drag update in which the pointer's document y grows, and back by as much at each one in which it falls, Shift held keeps their turn, 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 (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).
- **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. 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).