From 11d65eb8a83ac968e2378225f3bd4ecc3bb66b09 Mon Sep 17 00:00:00 2001 From: dotCooCoo Date: Tue, 27 Oct 2026 17:21:06 -0700 Subject: [PATCH 1/6] Flare: the drag turns the rays, Shift keeps their orientation and Space moves the flare MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The reference app's Flare tool rotates the rays as the center is dragged out, Shift constrains them to their original orientation, and Space held moves the shape being drawn. VectorCraft's Flare tool drew the rays in one fixed orientation and ignored Shift and Space. shape.flare takes rotation (degrees, clockwise), which turns the rays about the center and leaves the halo, the center and the rings as they are. While the center is dragged out, the rays turn with the pointer, as a polygon's first corner does, and a drag straight up leaves them unturned. Shift keeps them unturned, or turned by General › Constrain Angle as a polygon drawn with Shift is. Space held during the center drag or the drag that places the rings' end point moves the flare at its current size, and the drag goes on from there when Space is let go, as with the shape tools. Docs: docs/mcp.md, docs/target-app-parity.md. --- crates/engine/src/cmd/create.rs | 7 +- crates/engine/src/tests.rs | 28 ++++++++ crates/tools/src/extra.rs | 124 +++++++++++++++++++++++++++++--- docs/mcp.md | 9 ++- docs/target-app-parity.md | 1 + 5 files changed, 156 insertions(+), 13 deletions(-) diff --git a/crates/engine/src/cmd/create.rs b/crates/engine/src/cmd/create.rs index 9249a48f4..a943ee10d 100644 --- a/crates/engine/src/cmd/create.rs +++ b/crates/engine/src/cmd/create.rs @@ -29,7 +29,7 @@ pub fn specs() -> Vec { "Flare", [], None, - "{cx, cy, diameter=100, opacity=50 (%), brightness=30 (%), growth=20 (%), fuzziness=50 (%), rays=15, longest=300 (%), rayFuzziness=100 (%), x2?, y2? (ring end; else pathLength at direction), pathLength=300, rings=10, largest=50 (%), direction=45 (deg), seed?} → {id}", + "{cx, cy, diameter=100, opacity=50 (%), brightness=30 (%), growth=20 (%), fuzziness=50 (%), rays=15, longest=300 (%), rayFuzziness=100 (%), rotation=0 (deg, turns the rays clockwise), x2?, y2? (ring end; else pathLength at direction), pathLength=300, rings=10, largest=50 (%), direction=45 (deg), seed?} → {id}", has_doc, flare ), @@ -395,6 +395,7 @@ fn flare(s: &mut Session, p: &Value) -> Result { "rays", "longest", "rayFuzziness", + "rotation", "rings", "largest", "x2", @@ -413,6 +414,7 @@ fn flare(s: &mut Session, p: &Value) -> Result { let rays = p.get("rays").and_then(Value::as_u64).unwrap_or(15).min(50) as usize; let longest = pct("longest", 300.0); let ray_fuzz = pct("rayFuzziness", 100.0).min(1.0); + let ray_turn = f64_or(p, "rotation", 0.0).to_radians(); let rings = p.get("rings").and_then(Value::as_u64).unwrap_or(10).min(50) as usize; let largest = pct("largest", 50.0); let end = match (p.get("x2").and_then(Value::as_f64), p.get("y2").and_then(Value::as_f64)) { @@ -443,11 +445,12 @@ fn flare(s: &mut Session, p: &Value) -> Result { let inner = (1.0 - fuzz as f32 * 0.6).clamp(0.05, 0.95); parts.push(("Halo", circle(c, halo_r), radial(c, halo_r, vec![stop(0.0, 0.0), stop(inner * 0.75, 0.0), stop(inner, 0.5), stop(1.0, 0.0)]), 1.0)); // Rays: thin spikes of random length (Longest = % of the centre radius), fading outwards. + // `rotation` turns them clockwise about the center. if rays > 0 { let mut bp = vectorcraft_geom::BezPath::new(); let mut reach: f64 = 0.0; for i in 0..rays { - let a = std::f64::consts::TAU * (i as f64 + rnd() * 0.6) / rays as f64; + let a = std::f64::consts::TAU * (i as f64 + rnd() * 0.6) / rays as f64 + ray_turn; let len = r * longest * (1.0 - ray_fuzz * 0.75 * rnd()).max(0.1); reach = reach.max(len); let w = r * 0.035; diff --git a/crates/engine/src/tests.rs b/crates/engine/src/tests.rs index 7d3064c92..3119d6278 100644 --- a/crates/engine/src/tests.rs +++ b/crates/engine/src/tests.rs @@ -1460,6 +1460,7 @@ fn shape_commands_refuse_a_number_or_boolean_given_as_text() { ("shape.flare", with(&flare, "opacity", json!("50")), "opacity"), ("shape.flare", with(&flare, "largest", json!(true)), "largest"), ("shape.flare", with(&flare, "rays", json!("15")), "rays"), + ("shape.flare", with(&flare, "rotation", json!("90")), "rotation"), ("shape.flare", json!({"cx": 100, "cy": 100, "x2": "300", "y2": 300}), "x2"), ("shape.flare", with(&flare, "direction", json!("45")), "direction"), ("shape.flare", with(&flare, "seed", json!("7")), "seed"), @@ -3160,6 +3161,33 @@ fn flare_tool_two_step_gesture_is_one_undo() { assert_eq!(rays.path_data().unwrap().subpaths.len(), 13); } +/// `shape.flare`'s `rotation` turns the rays clockwise about the center, and leaves the halo, the +/// center and the rings as they are. +#[test] +fn flare_rotation_turns_only_the_rays() { + use vectorcraft_geom::Affine; + let mut s = session(); + let parts = |s: &mut Session, rotation: f64| { + let p = json!({"cx": 200, "cy": 200, "diameter": 60, "rays": 9, "rings": 4, "x2": 400, "y2": 300, "seed": 3, "rotation": rotation}); + let id = NodeId(s.execute("shape.flare", &p).unwrap()["id"].as_u64().unwrap()); + let NodeKind::Group { children, .. } = &s.doc().unwrap().doc.node(id).unwrap().kind else { panic!("flare is a group") }; + children.iter().map(|c| (c.name.clone(), c.path_data().unwrap().clone())).collect::>() + }; + let plain = parts(&mut s, 0.0); + let turned = parts(&mut s, 90.0); + assert_eq!(plain.len(), turned.len()); + let quarter = Affine::translate((200.0, 200.0)) * Affine::rotate(std::f64::consts::FRAC_PI_2) * Affine::translate((-200.0, -200.0)); + for ((name, a), (_, b)) in plain.iter().zip(&turned) { + let pa: Vec = a.subpaths.iter().flat_map(|sp| sp.anchors.iter().map(|k| k.p)).collect(); + let pb: Vec = b.subpaths.iter().flat_map(|sp| sp.anchors.iter().map(|k| k.p)).collect(); + let want: Vec = if name.as_deref() == Some("Rays") { pa.iter().map(|&p| quarter * p).collect() } else { pa }; + assert_eq!(pb.len(), want.len(), "{name:?}"); + for (p, q) in pb.iter().zip(&want) { + assert!(p.distance(*q) < 1e-6, "{name:?}: {p:?} is not {q:?}"); + } + } +} + #[test] fn reshape_moves_the_grabbed_point_and_its_neighbourhood() { let mut s = session(); diff --git a/crates/tools/src/extra.rs b/crates/tools/src/extra.rs index 013578edc..afeb7b1c9 100644 --- a/crates/tools/src/extra.rs +++ b/crates/tools/src/extra.rs @@ -1,9 +1,11 @@ //! Flare, Reshape, Shaper and Graph tools. //! -//! Flare: drag sets the centre and its size (click without dragging opens the Flare Tool Options), -//! then a click sets the end point of the rings; 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 neighbourhood smoothly. +//! 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 +//! 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 +//! neighbourhood smoothly. use serde_json::{Value, json}; use vectorcraft_geom::{Point, Vec2}; @@ -51,9 +53,13 @@ pub struct FlareTool { /// Centre placed, waiting for the end-point click: (centre, diameter). placed: Option<(Point, f64)>, hover: Point, + /// The pointer at the last press or drag, from which Space held moves the flare. + last: Point, began: bool, /// Ray count changed with ↑/↓ during the gesture (None = the options' count). rays: Option, + /// The rays' turn (`shape.flare`'s `rotation`, degrees) the center drag set. + turn: f64, /// The options' values, in [`FLARE_OPTIONS`] order. values: [f64; 12], /// Whether the flare has rays and rings (the options' Rays and Rings checkboxes). @@ -67,8 +73,10 @@ impl Default for FlareTool { drag: None, placed: None, hover: Point::ZERO, + last: Point::ZERO, began: false, rays: None, + turn: 0.0, values: FLARE_OPTIONS.map(|(_, default, _, _)| default), rays_on: true, rings_on: true, @@ -101,12 +109,21 @@ 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) } +} + impl FlareTool { - /// `shape.flare`'s params: the options, with the dragged centre and diameter, the ray count - /// ↑/↓ chose and the rings' end point once clicked. + /// `shape.flare`'s params: the options, with the dragged center and diameter, the rays' turn, + /// the ray count ↑/↓ chose and the rings' end point once clicked. fn params(&self, c: Point, diameter: f64, end: Option) -> Value { let mut v = flare_params(&self.options(), c); v["diameter"] = json!(diameter.max(1.0)); + v["rotation"] = json!(self.turn); if let Some(r) = self.rays.filter(|_| self.rays_on) { v["rays"] = json!(r); } @@ -132,12 +149,17 @@ impl Tool for FlareTool { if self.placed.is_none() { self.drag = Some((ev.pos, ev.pos)); self.rays = None; + self.turn = 0.0; } + self.last = ev.pos; vec![] } PointerKind::Drag => { - if let Some((c, _)) = self.drag { + if let Some((mut c, _)) = self.drag { + // Space held moves the center with the pointer, so the size and the turn stay. + 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 !self.began && ev.pos.distance(c) >= cx.tol(3.0) { self.began = true; return vec![ @@ -148,7 +170,10 @@ impl Tool for FlareTool { if self.began { return vec![Action::Preview("shape.flare".into(), self.params(c, 2.0 * ev.pos.distance(c), None))]; } - } else if let Some((c, d)) = self.placed { + } else if let Some((mut c, d)) = self.placed { + // Space held moves the whole flare, rings' end point included. + crate::shape::space_moves(&mut c, &mut self.last, ev, true); + self.placed = Some((c, d)); return vec![Action::Preview("shape.flare".into(), self.params(c, d, Some(ev.pos)))]; } vec![] @@ -538,6 +563,89 @@ mod tests { assert_eq!(t.params(Point::ZERO, 1.0, None)["rays"].as_u64(), Some(0)); } + /// The last `shape.flare` preview of `a`. + fn flare_preview(a: &[Action]) -> Value { + match a.iter().rev().find(|a| matches!(a, Action::Preview(..))) { + Some(Action::Preview(c, v)) if c == "shape.flare" => v.clone(), + other => panic!("no flare preview: {other:?}"), + } + } + + /// 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. + #[test] + fn flare_drag_turns_the_rays_and_shift_keeps_them_unturned() { + use crate::testutil::*; + use vectorcraft_doc::Selection; + + let (d, _) = doc_with_rect(); + let s = Selection::default(); + let p = paint(); + let cx = cx(&d, &s, &p); + let shift = Mods { shift: true, ..Default::default() }; + 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() + }; + 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))); + // ↑ 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)); + // 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)); + 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. + 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)); + } + + /// Space held while dragging out a flare moves it at its current size and turn, in the center + /// drag and in the drag that places the rings' end point; let go, the drag goes on from there. + #[test] + fn space_moves_the_flare_being_drawn() { + use crate::testutil::*; + use vectorcraft_doc::Selection; + + let (d, _) = doc_with_rect(); + let s = Selection::default(); + let p = paint(); + let cx = cx(&d, &s, &p); + let space = Mods { space: true, ..Default::default() }; + 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)); + // 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))); + 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); + assert_eq!((v["cx"].as_f64(), v["cy"].as_f64(), v["x2"].as_f64()), (Some(170.0), Some(160.0), Some(340.0))); + } + #[test] fn reshape_drag_honors_measurement_labels() { use crate::testutil::*; diff --git a/docs/mcp.md b/docs/mcp.md index 85bb654a9..340f83615 100644 --- a/docs/mcp.md +++ b/docs/mcp.md @@ -1141,8 +1141,8 @@ and `type.fractionalWidths`; `remove` and `overhang` in `type.ruby`; `expandInst These commands refuse a string or another value that is not a number with `` `radius` must be a number, not "2" ``, naming the key, and change nothing: `radius` in `shape.rectangle`; `sides` and `rotation` in `shape.polygon`; `radius2`, `points` and `rotation` in `shape.star`; `diameter`, `opacity`, `brightness`, `growth`, `fuzziness`, -`rays`, `longest`, `rayFuzziness`, `rings`, `largest`, `x2`, `y2`, `direction`, `pathLength` and `seed` in -`shape.flare`; `decay` and `segments` in `shape.spiral`; `rows` and `columns` in `shape.rectangularGrid`; `concentric` +`rays`, `longest`, `rayFuzziness`, `rotation`, `rings`, `largest`, `x2`, `y2`, `direction`, `pathLength` and `seed` +in `shape.flare`; `decay` and `segments` in `shape.spiral`; `rows` and `columns` in `shape.rectangularGrid`; `concentric` and `radial` in `shape.polarGrid`; `size` and the `width`, `height` and `fitMinPercent` of `area` in `text.create`; `size` and `fitMinPercent` in `text.createInPath`; `start`, `end`, `size`, `tracking`, `baselineShift`, `hScale`, `vScale`, `rotation` and `strokeWidth` in `text.setRangeStyle`; `start` and `end` in @@ -5133,7 +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. The Artboard tool's are the active artboard's Artboard Options +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 +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 Transform panel uses, counted from the ruler origin), a graph tool's diff --git a/docs/target-app-parity.md b/docs/target-app-parity.md index 182086b2a..be099ca3d 100644 --- a/docs/target-app-parity.md +++ b/docs/target-app-parity.md @@ -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 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). 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. - **Cutting:** Eraser, Scissors, Knife, Mirror & Cut, Line Cut and Rectangle Cut (real geometry, compound paths keep their holes). From 997826c4003b1afb89cdb628a5d9fab43a232154 Mon Sep 17 00:00:00 2001 From: dotCooCoo Date: Tue, 27 Oct 2026 23:23:28 -0700 Subject: [PATCH 2/6] Flare: a release with Space held moves the flare to the release point The release that places the rings' end point applied the pointer's last movement to the end point only, so with Space held the flare changed shape instead of moving. The release now moves the center by that movement as well. The progress doc says how Shift and General > Constrain Angle set the rays' orientation. --- crates/tools/src/extra.rs | 12 +++++++++++- docs/target-app-parity.md | 1 + 2 files changed, 12 insertions(+), 1 deletion(-) diff --git a/crates/tools/src/extra.rs b/crates/tools/src/extra.rs index afeb7b1c9..433ff5ad6 100644 --- a/crates/tools/src/extra.rs +++ b/crates/tools/src/extra.rs @@ -179,7 +179,8 @@ impl Tool for FlareTool { vec![] } PointerKind::Up => { - if let Some((c, d)) = self.placed.take() { + if let Some((mut c, d)) = self.placed.take() { + crate::shape::space_moves(&mut c, &mut self.last, ev, true); self.began = false; return vec![Action::Preview("shape.flare".into(), self.params(c, d, Some(ev.pos))), Action::Commit]; } @@ -644,6 +645,15 @@ mod tests { assert_eq!(a.last(), Some(&Action::Commit)); let v = flare_preview(&a); assert_eq!((v["cx"].as_f64(), v["cy"].as_f64(), v["x2"].as_f64()), (Some(170.0), Some(160.0), Some(340.0))); + // A release with Space held away from the last drag moves the flare there too. + 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)); + t.pointer(&cx, &PointerEvent::new(PointerKind::Up, 130.0, 100.0)); + t.pointer(&cx, &PointerEvent::new(PointerKind::Down, 300.0, 300.0)); + t.pointer(&cx, &PointerEvent::new(PointerKind::Drag, 320.0, 300.0)); + let v = flare_preview(&t.pointer(&cx, &PointerEvent::new(PointerKind::Up, 340.0, 310.0).with_mods(space))); + assert_eq!((v["cx"].as_f64(), v["cy"].as_f64(), v["x2"].as_f64()), (Some(120.0), Some(110.0), Some(340.0))); } #[test] diff --git a/docs/target-app-parity.md b/docs/target-app-parity.md index be099ca3d..ace5ef606 100644 --- a/docs/target-app-parity.md +++ b/docs/target-app-parity.md @@ -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 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). 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. From ec2744f84411341cc792fae366c8957b3af3071f Mon Sep 17 00:00:00 2001 From: dotCooCoo Date: Wed, 28 Oct 2026 18:32:48 -0700 Subject: [PATCH 3/6] Flare: the rays turn a step for each drag update up or down, and Shift holds the turn MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- crates/tools/src/extra.rs | 104 ++++++++++++++++++++++++-------------- docs/mcp.md | 7 +-- docs/target-app-parity.md | 1 + 3 files changed, 72 insertions(+), 40 deletions(-) diff --git a/crates/tools/src/extra.rs b/crates/tools/src/extra.rs index 433ff5ad6..9144b5ba6 100644 --- a/crates/tools/src/extra.rs +++ b/crates/tools/src/extra.rs @@ -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); diff --git a/docs/mcp.md b/docs/mcp.md index 340f83615..e5df1b981 100644 --- a/docs/mcp.md +++ b/docs/mcp.md @@ -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 diff --git a/docs/target-app-parity.md b/docs/target-app-parity.md index ace5ef606..47713adb4 100644 --- a/docs/target-app-parity.md +++ b/docs/target-app-parity.md @@ -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). From c479d2eeaf92bb3e01bb0cf3008acc16948aa503 Mon Sep 17 00:00:00 2001 From: dotCooCoo Date: Wed, 28 Oct 2026 21:48:24 -0700 Subject: [PATCH 4/6] Flare: rotate rays by drag distance instead of event count --- crates/tools/src/extra.rs | 101 +++++++++++++++++--------------------- docs/gaps.md | 2 +- docs/mcp.md | 4 +- docs/roadmap.md | 2 +- docs/target-app-parity.md | 4 +- 5 files changed, 51 insertions(+), 62 deletions(-) diff --git a/crates/tools/src/extra.rs b/crates/tools/src/extra.rs index 9144b5ba6..123c4b1ef 100644 --- a/crates/tools/src/extra.rs +++ b/crates/tools/src/extra.rs @@ -109,17 +109,17 @@ pub fn flare_params(o: &Value, c: Point) -> Value { v } -/// How far one update of the center drag turns the rays, in radians. -const RAY_STEP: f64 = 0.1; +/// The chosen rotation rate: one degree per document point of vertical pointer travel. +/// This is independent of pointer event frequency and elapsed time. +const RAY_DEGREES_PER_POINT: f64 = 1.0; -/// 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. +/// Accumulate travel rather than an event-count step. Frozen/moving samples still update +/// the pointer baseline, so releasing a modifier never catches up skipped rotation. fn ray_turn(turn: f64, dy: f64, shift: bool) -> f64 { - if shift || dy == 0.0 { + if shift || !dy.is_finite() { return turn; } - (turn + RAY_STEP.to_degrees().copysign(dy)).rem_euclid(360.0) + (turn + dy * RAY_DEGREES_PER_POINT).rem_euclid(360.0) } impl FlareTool { @@ -163,10 +163,9 @@ impl Tool for FlareTool { 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)); - if !moving { + if !ev.mods.space { self.turn = ray_turn(self.turn, dy, ev.mods.shift); } if !self.began && ev.pos.distance(c) >= cx.tol(3.0) { @@ -581,61 +580,51 @@ mod tests { } } - /// 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. + /// Equal vertical travel gives equal rotation, regardless of event partitioning. #[test] fn flare_drag_turns_the_rays_and_shift_holds_their_turn() { use crate::testutil::*; use vectorcraft_doc::Selection; - let (d, _) = doc_with_rect(); let s = Selection::default(); let p = paint(); let cx = cx(&d, &s, &p); let shift = Mods { shift: true, ..Default::default() }; - let step = 0.1_f64.to_degrees(); + let space = Mods { space: true, ..Default::default() }; + let mut results = vec![]; + for steps in [1, 8, 64] { + let mut t = FlareTool::default(); + t.pointer(&cx, &PointerEvent::new(PointerKind::Down, 100.0, 100.0)); + for i in 1..=steps { + t.pointer(&cx, &PointerEvent::new(PointerKind::Drag, 140.0, 100.0 + 80.0 * i as f64 / steps as f64)); + } + assert_eq!(t.turn, 80.0); + t.pointer(&cx, &PointerEvent::new(PointerKind::Drag, 180.0, 180.0)); + assert_eq!(t.turn, 80.0, "horizontal movement doesn't turn"); + t.pointer(&cx, &PointerEvent::new(PointerKind::Drag, 180.0, 150.0)); + assert_eq!(t.turn, 50.0, "reverse travel subtracts its distance"); + let v = flare_preview(&t.pointer(&cx, &PointerEvent::new(PointerKind::Drag, 100.0, 200.0).with_mods(shift))); + assert_eq!(v["rotation"].as_f64(), Some(50.0)); + assert_eq!(v["diameter"].as_f64(), Some(200.0)); + t.pointer(&cx, &PointerEvent::new(PointerKind::Drag, 120.0, 220.0)); + assert_eq!(t.turn, 70.0, "release does not catch up frozen movement"); + t.key(&cx, ToolKey::Up, Mods::default()); + assert_eq!(t.turn, 70.0); + t.pointer(&cx, &PointerEvent::new(PointerKind::Up, 120.0, 220.0)); + t.pointer(&cx, &PointerEvent::new(PointerKind::Down, 300.0, 200.0)); + let a = t.pointer(&cx, &PointerEvent::new(PointerKind::Up, 300.0, 260.0)); + assert_eq!(a.last(), Some(&Action::Commit)); + assert_eq!(flare_preview(&a)["rotation"].as_f64(), Some(70.0)); + results.push(t.turn); + } + assert!(results.windows(2).all(|p| p[0] == p[1])); + // Space held before the first drag update freezes rotation too. 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().unwrap_or(f64::NAN) - }; - 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!(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)); - 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!(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, 120.0).with_mods(shift))); - assert_eq!(v["rotation"].as_f64(), Some(0.0)); + t.pointer(&cx, &PointerEvent::new(PointerKind::Drag, 140.0, 140.0).with_mods(space)); + assert_eq!(t.turn, 0.0); + t.pointer(&cx, &PointerEvent::new(PointerKind::Drag, 140.0, 150.0)); + assert_eq!(t.turn, 10.0); } /// Space held while dragging out a flare moves it at its current size and turn, in the center @@ -650,7 +639,7 @@ 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 step = 30.0; let mut t = FlareTool::default(); t.pointer(&cx, &PointerEvent::new(PointerKind::Down, 100.0, 100.0)); t.pointer(&cx, &PointerEvent::new(PointerKind::Drag, 100.0, 130.0)); @@ -663,14 +652,14 @@ mod tests { // 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)); + assert!(turned(&v, step + 10.0)); 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(320.0), Some(310.0))); - assert!(turned(&v, 2.0 * step)); + assert!(turned(&v, step + 10.0)); 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); diff --git a/docs/gaps.md b/docs/gaps.md index 0e017129e..d2e1d0bd2 100644 --- a/docs/gaps.md +++ b/docs/gaps.md @@ -842,6 +842,6 @@ trap, and the defaults (0.25 pt, 100%, 40%) are VectorCraft's own, the pages giv | 2026-10-22 | minor | Tab-drag follow-up: reconcile upstream progress updates and document drag lifecycle, insertion behavior and regression contracts; behavior, scores and estimates unchanged. | | 2026-10-22 | minor | Artwork dragged with Selection switches visible document tabs after a 400 ms hover, then copies editable artwork on canvas release; source and clipboard stay intact; scores and hours unchanged | +2026-10-28 behavior correction: Flare ray rotation follows vertical travel at one degree per document point, independent of pointer event count. Shift freezes the current orientation; Space also freezes rotation on the first update. This is the chosen application interaction rate. Scores and estimates unchanged. ## EPS Save evidence (2026-10-26) - Ordinary Save and Save As keep EPS as EPS, with the chosen page frame, transparent margins and the complete editable VectorCraft document. Existing import-loss confirmation and transparency warnings remain; the Save TIFF preview is capped at 1024 pixels, while Export retains 2048. See [file-format-parity.md](file-format-parity.md#eps-save-and-source-protection) for limits and regression evidence. This narrows G9 save-back workflow coverage; native Illustrator editing-data fidelity, parity scores and hour estimates are unchanged. diff --git a/docs/mcp.md b/docs/mcp.md index e5df1b981..689f27a9d 100644 --- a/docs/mcp.md +++ b/docs/mcp.md @@ -5134,8 +5134,8 @@ desktop app. The Flare tool's are its Flare Tool Options (dialog `flareOptions`) 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. 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 +pointer's document y grows adds one degree per document point to `shape.flare`'s `rotation` (it turns +only the rays), decreasing y subtracts the same rate, and unchanged y leaves it. This application rate is independent of event frequency; 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` diff --git a/docs/roadmap.md b/docs/roadmap.md index 8db632900..c879d15e7 100644 --- a/docs/roadmap.md +++ b/docs/roadmap.md @@ -291,6 +291,6 @@ separate. Scores and hours unchanged. [Behavior and command details](development | 2026-10-22 | minor | Tab-drag follow-up: reconcile upstream progress updates and document drag lifecycle, insertion behavior and regression contracts; behavior, scores and estimates unchanged. | | 2026-10-22 | minor | Artwork dragged with Selection switches visible document tabs after a 400 ms hover, then copies editable artwork on canvas release; source and clipboard stay intact; scores and hours unchanged | +2026-10-28 behavior correction: Flare ray rotation follows vertical travel at one degree per document point, independent of pointer event count. Shift freezes the current orientation; Space also freezes rotation on the first update. This is the chosen application interaction rate. Scores and estimates unchanged. ## EPS Save evidence (2026-10-26) - Ordinary Save and Save As keep EPS as EPS, with the chosen page frame, transparent margins and the complete editable VectorCraft document. Existing import-loss confirmation and transparency warnings remain; the Save TIFF preview is capped at 1024 pixels, while Export retains 2048. See [file-format-parity.md](file-format-parity.md#eps-save-and-source-protection) for limits and regression evidence. This narrows G9 save-back workflow coverage; native Illustrator editing-data fidelity, parity scores and hour estimates are unchanged. diff --git a/docs/target-app-parity.md b/docs/target-app-parity.md index 47713adb4..5403b23d7 100644 --- a/docs/target-app-parity.md +++ b/docs/target-app-parity.md @@ -662,10 +662,10 @@ 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. 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 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). 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. - **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. @@ -1460,6 +1460,6 @@ applies to fills). Tests: `color::gradient::tests` (perceptual midpoint, expansi | 2026-10-22 | minor | Tab-drag follow-up: reconcile upstream progress updates and document drag lifecycle, insertion behavior and regression contracts; behavior, scores and estimates unchanged. | | 2026-10-22 | minor | Artwork dragged with Selection switches visible document tabs after a 400 ms hover, then copies editable artwork on canvas release; source and clipboard stay intact; scores and hours unchanged | +2026-10-28 Flare interaction: the center drag turns only the rays by one degree per document point of increasing pointer y and back at the same rate for decreasing y. This is an application rule independent of event frequency. Shift keeps the current turn; Space during either drag moves the flare without changing its size or turn, including the first drag sample. Releasing a modifier resumes from the current pointer position without catching up skipped rotation. Scores and remaining estimates unchanged. ## EPS Save evidence (2026-10-26) - Ordinary Save and Save As keep EPS as EPS, with the chosen page frame, transparent margins and the complete editable VectorCraft document. Existing import-loss confirmation and transparency warnings remain; the Save TIFF preview is capped at 1024 pixels, while Export retains 2048. See [file-format-parity.md](file-format-parity.md#eps-save-and-source-protection) for limits and regression evidence. This narrows G9 save-back workflow coverage; native Illustrator editing-data fidelity, parity scores and hour estimates are unchanged. From 6f7a6b3e67605e38ee1f86f9727aba88e4026976 Mon Sep 17 00:00:00 2001 From: dotCooCoo Date: Wed, 28 Oct 2026 22:28:01 -0700 Subject: [PATCH 5/6] Docs: clarify initial Space sizing and record Flare history --- docs/gaps.md | 1 + docs/roadmap.md | 1 + docs/target-app-parity.md | 1 + 3 files changed, 3 insertions(+) diff --git a/docs/gaps.md b/docs/gaps.md index d2e1d0bd2..200bb8ca1 100644 --- a/docs/gaps.md +++ b/docs/gaps.md @@ -829,6 +829,7 @@ trap, and the defaults (0.25 pt, 100%, 40%) are VectorCraft's own, the pages giv | 2026-10-10 | minor | G9 evidence counted from the tracker (28 `.ai`/EPS issues, 20 fixed); impact narrowed to handing files back | | 2026-10-10 | minor | Alpha blockers checked against the core-workflow gate: none | | 2026-10-10 | major | Moved from the ROADMAP's "Where we're lacking"; kept ids G1–G8, added G9–G19 (file formats, stability, bugs, preferences, 30.x additions, pen hardware, performance, localization, ecosystem, AI, accessibility) and re-ranked | +| 2026-10-28 | minor | Flare ray rotation follows vertical travel at one degree per document point, independent of pointer event count. Shift freezes the current orientation; Space also freezes rotation on the first update. This is the chosen application interaction rate. Scores and estimates unchanged. | | 2026-10-28 | minor | Drop Shadow Darkness subtracts the normalized amount from RGB source channels or adds it to CMYK/Gray black before profile conversion and blur; near-100% becomes gray black, with shadow opacity separate. SVG keeps live filters for uniform sRGB sources and renders shadows for other RGB profiles, source models and gradients. Gradient-stop adjustment is an application rule; spot/custom colors and composite-source parity remain open. Scores and estimates unchanged. | | 2026-10-28 | minor | Blob Brush Shift locks after clear axial movement, keeps earlier samples fixed, and waits without an off-axis trail. Activation uses raw press displacement in logical screen pixels, with exact quarter-turn boundaries. Explicit application rule; scores and estimates unchanged. | | 2026-10-28 | minor | Flare Tool Options opened by a canvas click previews its settings at that click. Reset refreshes the enabled preview while keeping the dialog open. Cancel discards preview art and tentative options; OK accepts one flare in one Undo step. Editing existing flare artwork remains outside this dialog scope. Scores and estimates unchanged. | diff --git a/docs/roadmap.md b/docs/roadmap.md index c879d15e7..aa1153d76 100644 --- a/docs/roadmap.md +++ b/docs/roadmap.md @@ -278,6 +278,7 @@ separate. Scores and hours unchanged. [Behavior and command details](development | 2026-10-10 | minor | M9 row from main: initial Revolve | | 2026-10-10 | minor | Added the Alpha gate table (core-workflow gate): all six workflows work end to end, `.ai` exchange partial but not blocking; stage stays alpha | | 2026-10-10 | major | Created from ROADMAP.md's milestone table and "Where we're going"; added Current focus and the beta list | +| 2026-10-28 | minor | Flare ray rotation follows vertical travel at one degree per document point, independent of pointer event count. Shift freezes the current orientation; Space also freezes rotation on the first update. This is the chosen application interaction rate. Scores and estimates unchanged. | | 2026-10-28 | minor | Drop Shadow Darkness subtracts the normalized amount from RGB source channels or adds it to CMYK/Gray black before profile conversion and blur; near-100% becomes gray black, with shadow opacity separate. SVG keeps live filters for uniform sRGB sources and renders shadows for other RGB profiles, source models and gradients. Gradient-stop adjustment is an application rule; spot/custom colors and composite-source parity remain open. Scores and estimates unchanged. | | 2026-10-28 | minor | Blob Brush Shift locks after clear axial movement, keeps earlier samples fixed, and waits without an off-axis trail. Activation uses raw press displacement in logical screen pixels, with exact quarter-turn boundaries. Explicit application rule; scores and estimates unchanged. | | 2026-10-28 | minor | Flare Tool Options opened by a canvas click previews its settings at that click. Reset refreshes the enabled preview while keeping the dialog open. Cancel discards preview art and tentative options; OK accepts one flare in one Undo step. Editing existing flare artwork remains outside this dialog scope. Scores and estimates unchanged. | diff --git a/docs/target-app-parity.md b/docs/target-app-parity.md index 5403b23d7..17b48395b 100644 --- a/docs/target-app-parity.md +++ b/docs/target-app-parity.md @@ -1447,6 +1447,7 @@ applies to fills). Tests: `color::gradient::tests` (perceptual midpoint, expansi | 2026-10-10 | major | Full re-measure against Illustrator 2026 30.x from code counts, open issues and public docs; added breadth/depth/ready-for-real-work numbers, generative-AI row, calibration from PR timestamps; merged the ROADMAP's parity estimate, honest assessment and "Shipped so far" into this file | | 2026-10-05 | major | (in ROADMAP.md) M3 and M4 rows re-derived after the paint and files milestones | | 2026-10-02 | major | (in ROADMAP.md) First 22-area weighted estimate | +| 2026-10-28 | minor | The center drag turns only the rays by one degree per document point of increasing pointer y and back at the same rate for decreasing y. This is an application rule independent of event frequency. Shift keeps the current turn. Space freezes rotation on every sample, including the first; the initial samples establish size until the gesture begins, after which Space translates the flare without changing its size or turn. Releasing a modifier resumes from the current pointer position without catching up skipped rotation. Scores and remaining estimates unchanged. | | 2026-10-28 | minor | Drop Shadow Darkness subtracts the normalized amount from RGB source channels or adds it to CMYK/Gray black before profile conversion and blur; near-100% becomes gray black, with shadow opacity separate. SVG keeps live filters for uniform sRGB sources and renders shadows for other RGB profiles, source models and gradients. Gradient-stop adjustment is an application rule; spot/custom colors and composite-source parity remain open. Scores and estimates unchanged. | | 2026-10-28 | minor | Blob Brush Shift locks after clear axial movement, keeps earlier samples fixed, and waits without an off-axis trail. Activation uses raw press displacement in logical screen pixels, with exact quarter-turn boundaries. Explicit application rule; scores and estimates unchanged. | | 2026-10-28 | minor | Flare Tool Options opened by a canvas click previews its settings at that click. Reset refreshes the enabled preview while keeping the dialog open. Cancel discards preview art and tentative options; OK accepts one flare in one Undo step. Editing existing flare artwork remains outside this dialog scope. Scores and estimates unchanged. | From 5fa538ee1cf9cba82918b286bcec1d5c68bf4a14 Mon Sep 17 00:00:00 2001 From: dotCooCoo Date: Thu, 29 Oct 2026 06:36:09 -0700 Subject: [PATCH 6/6] Flare: the docs say Space keeps the turn and moves the flare only after the drag starts --- docs/mcp.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/docs/mcp.md b/docs/mcp.md index 689f27a9d..5dfcd6116 100644 --- a/docs/mcp.md +++ b/docs/mcp.md @@ -5136,8 +5136,8 @@ leaves a click's `x` and `y` unchanged and keeps the dialog open; `ui.dialogRese 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 one degree per document point to `shape.flare`'s `rotation` (it turns only the rays), decreasing y subtracts the same rate, and unchanged y leaves it. This application rate is independent of event frequency; 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 +the turn as it is. Space held keeps the turn as well. Once the center drag has passed its starting distance, and in the +drag that places the rings' end point, Space held 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 Transform panel uses, counted from the ruler origin), a graph tool's