M2.6: Flare tool (centre drag, then click for the rings; click → Flare Tool Options; shape.flare builds centre/halo/rays/rings from radial gradients in Screen mode) and Reshape tool (path.reshape: grab a point of a selected path, neighbourhood follows with a smooth falloff)

Co-Authored-By: Claude Opus 5.5 <[email protected]>
This commit is contained in:
Brandon ThomasandClaude Opus 5.5 committed 2026-10-01 20:57:02 -04:00
1 parent 7d95f146ce
commit 71d43421f5
6 files changed
+415

No files matched your search

+108
View File
@@ -12,6 +12,15 @@ pub fn specs() -> Vec<CommandSpec> {
cmd!("shape.ellipse", "Ellipse", [], None, "{x, y, width, height} → {id}", has_doc, ellipse),
cmd!("shape.polygon", "Polygon", [], None, "{cx, cy, radius, sides=6, rotation?: deg} → {id}", has_doc, polygon),
cmd!("shape.star", "Star", [], None, "{cx, cy, radius1, radius2, points=5, rotation?: deg} → {id}", has_doc, star),
cmd!(
"shape.flare",
"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}",
has_doc,
flare
),
cmd!("shape.line", "Line Segment", [], None, "{x1, y1, x2, y2} → {id}", has_doc, line),
cmd!("shape.spiral", "Spiral", [], None, "{cx, cy, radius, decay=80 (%), segments=10, clockwise?} → {id}", has_doc, spiral),
cmd!("shape.arc", "Arc", [], None, "{x1, y1, x2, y2, closed?} → {id}", has_doc, arc),
@@ -178,6 +187,105 @@ fn star(s: &mut Session, p: &Value) -> Result<Value> {
add_art(s, "Star", path_kind(path, None), None)
}
/// Flare tool: a lens flare built from radial-gradient paths in Screen mode — a bright centre, a halo
/// ring, rays of varying length and rings along the path to the end point.
fn flare(s: &mut Session, p: &Value) -> Result<Value> {
use vectorcraft_color::{BlendMode, Gradient, GradientGeom, GradientKind, GradientPaint, GradientStop, Paint};
let c = Point::new(f64_req(p, "cx", "shape.flare")?, f64_req(p, "cy", "shape.flare")?);
let pct = |k: &str, d: f64| (f64_or(p, k, d) / 100.0).clamp(0.0, 10.0);
let r = (f64_or(p, "diameter", 100.0) / 2.0).max(0.5);
let opacity = pct("opacity", 50.0).min(1.0) as f32;
let brightness = pct("brightness", 30.0).min(1.0) as f32;
let growth = pct("growth", 20.0);
let fuzz = pct("fuzziness", 50.0).min(1.0);
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 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)) {
(Some(x), Some(y)) => Point::new(x, y),
_ => {
let a = f64_or(p, "direction", 45.0).to_radians();
c + vectorcraft_geom::Vec2::new(a.cos(), -a.sin()) * f64_or(p, "pathLength", 300.0)
}
};
// Deterministic pseudo-random sizes (same parameters → same flare).
let mut seed = p.get("seed").and_then(Value::as_u64).unwrap_or(0x2545_f491_4f6c_dd1d) | 1;
let mut rnd = move || {
seed ^= seed << 13;
seed ^= seed >> 7;
seed ^= seed << 17;
(seed >> 11) as f64 / (1u64 << 53) as f64
};
let stop = |offset: f32, opacity: f32| GradientStop { offset, color: vectorcraft_color::Color::WHITE, opacity, midpoint: 0.5 };
let radial = |centre: Point, radius: f64, stops: Vec<GradientStop>| {
let mut g = GradientPaint::new(Gradient { kind: GradientKind::Radial, stops });
g.geom = Some(GradientGeom { start: centre, end: centre + vectorcraft_geom::Vec2::new(radius, 0.0), aspect: 1.0 });
Paint::Gradient(Box::new(g))
};
let circle = |centre: Point, radius: f64| shapes::ellipse(Rect::from_center_size(centre, (radius * 2.0, radius * 2.0)));
let mut parts: Vec<(&str, PathData, Paint, f32)> = vec![];
// Halo: a soft ring just outside the centre.
let halo_r = r * (1.0 + growth) * 1.6;
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.
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 len = r * longest * (1.0 - ray_fuzz * 0.75 * rnd()).max(0.1);
reach = reach.max(len);
let w = r * 0.035;
let dir = vectorcraft_geom::Vec2::new(a.cos(), a.sin());
let n = vectorcraft_geom::Vec2::new(-dir.y, dir.x) * w;
bp.move_to(c + n);
bp.line_to(c + dir * len);
bp.line_to(c - n);
bp.line_to(c - dir * (w * 4.0));
bp.close_path();
}
parts.push(("Rays", PathData::from_bezpath(&bp), radial(c, reach, vec![stop(0.0, 0.9), stop(0.25, 0.35), stop(1.0, 0.0)]), 1.0));
}
// Centre: bright core fading to transparent.
parts.push(("Center", circle(c, r), radial(c, r, vec![stop(0.0, 1.0), stop(0.25 + brightness * 0.5, 0.6), stop(1.0, 0.0)]), opacity));
// Rings along the path to the end point (Largest = % of the average ring size).
for _ in 0..rings {
let t = rnd();
let at = c + (end - c) * t;
let rr = (r * largest * (0.25 + rnd() * 0.75)).max(0.5);
let solid = rnd() < 0.5;
let stops = if solid {
vec![stop(0.0, 0.25), stop(0.8, 0.2), stop(1.0, 0.0)]
} else {
vec![stop(0.0, 0.0), stop(0.7, 0.05), stop(0.92, 0.35), stop(1.0, 0.0)]
};
parts.push(("Ring", circle(at, rr), radial(at, rr, stops), 1.0));
}
let parent = s.doc()?.insertion_parent();
let id = s.edit("Flare", |d, sel| {
let children = parts
.into_iter()
.map(|(name, pd, paint, op)| {
let mut n = Node::path(d.alloc_id(), pd, Appearance::basic(paint, Paint::None, 0.0));
n.name = Some(name.into());
n.blend = BlendMode::Screen;
n.opacity = op;
std::sync::Arc::new(n)
})
.collect();
let gid = d.alloc_id();
let mut g = Node::group(gid, children);
g.name = Some("Flare".into());
d.insert(parent, usize::MAX, g)?;
sel.set([gid]);
Ok(gid)
})?;
Ok(json!({ "id": id.0 }))
}
fn line(s: &mut Session, p: &Value) -> Result<Value> {
let a = Point::new(f64_req(p, "x1", "shape.line")?, f64_req(p, "y1", "shape.line")?);
let b = Point::new(f64_req(p, "x2", "shape.line")?, f64_req(p, "y2", "shape.line")?);
+47
View File
@@ -86,6 +86,15 @@ pub fn specs() -> Vec<CommandSpec> {
has_selection,
delete_anchors
),
cmd!(
"path.reshape",
"Reshape",
[],
None,
"{id, x, y, dx, dy, tol=3, radius?} Reshape tool: grab the path at (x, y) (adding an anchor there unless one is within tol) and drag it by (dx, dy); nearby anchors follow with a smooth falloff over radius (default: half the subpath's size)",
has_doc,
reshape
),
cmd!("path.insertAnchor", "Add Anchor Point", [], None, "{id, subpath, segment, t: 0..1}", has_doc, insert_anchor),
cmd!("path.cutAtAnchors", "Cut Path at Selected Anchor Points", [], None, "{}", has_selection, cut_at_anchors),
]
@@ -416,6 +425,44 @@ pub(crate) fn delete_anchors(s: &mut Session, _: &Value) -> Result<Value> {
ok()
}
fn reshape(s: &mut Session, p: &Value) -> Result<Value> {
let id = id_param(p, "id").ok_or_else(|| bad("path.reshape", "missing id"))?;
let at = Point::new(f64_req(p, "x", "path.reshape")?, f64_req(p, "y", "path.reshape")?);
let delta = Vec2::new(f64_or(p, "dx", 0.0), f64_or(p, "dy", 0.0));
let tol = f64_or(p, "tol", 3.0).max(0.0);
let radius = p.get("radius").and_then(Value::as_f64);
s.edit("Reshape", |d, _| {
let path = path_mut(d, id)?;
let (si, seg, t, _, _) = path.nearest(at).ok_or_else(|| EngineError::Other("empty path".into()))?;
let sp = &mut path.subpaths[si];
let grabbed = match sp.anchors.iter().enumerate().map(|(i, a)| (i, a.p.distance(at))).min_by(|a, b| a.1.total_cmp(&b.1)) {
Some((i, dist)) if dist <= tol => i,
_ => sp.insert_anchor(seg, t),
};
let origin = sp.anchors[grabbed].p;
let r = radius.unwrap_or_else(|| {
let mut bp = vectorcraft_geom::BezPath::new();
sp.to_bezpath_into(&mut bp);
let b = vectorcraft_geom::Shape::bounding_box(&bp);
(b.width().hypot(b.height()) / 2.0).max(1.0)
});
for (i, a) in sp.anchors.iter_mut().enumerate() {
let w = if i == grabbed {
1.0
} else {
let q = (a.p.distance(origin) / r).min(1.0);
(1.0 - q * q).powi(2)
};
let v = delta * w;
a.p += v;
a.h_in += v;
a.h_out += v;
}
Ok(())
})?;
ok()
}
fn insert_anchor(s: &mut Session, p: &Value) -> Result<Value> {
let id = id_param(p, "id").ok_or_else(|| bad("path.insertAnchor", "missing id"))?;
let si = p.get("subpath").and_then(Value::as_u64).unwrap_or(0) as usize;
+42
View File
@@ -310,3 +310,45 @@ fn every_command_survives_empty_params() {
}
}
}
#[test]
fn flare_tool_two_step_gesture_is_one_undo() {
let mut s = session();
let v = ViewInfo::default();
let undo_before = s.doc().unwrap().history.undo.len();
s.select_tool("flare", v).unwrap();
s.pointer(&PointerEvent::new(PointerKind::Down, 200.0, 200.0), v).unwrap();
s.pointer(&PointerEvent::new(PointerKind::Drag, 240.0, 200.0), v).unwrap();
s.pointer(&PointerEvent::new(PointerKind::Up, 240.0, 200.0), v).unwrap();
s.pointer(&PointerEvent::new(PointerKind::Move, 500.0, 400.0), v).unwrap();
s.pointer(&PointerEvent::new(PointerKind::Down, 500.0, 400.0), v).unwrap();
s.pointer(&PointerEvent::new(PointerKind::Up, 500.0, 400.0), v).unwrap();
let st = s.doc().unwrap();
assert_eq!(st.history.undo.len(), undo_before + 1);
let g = st.doc.node(st.selection.objects[0]).unwrap();
assert_eq!(g.name.as_deref(), Some("Flare"));
// Halo + rays + centre + 10 rings; the rings reach towards the end point.
let NodeKind::Group { children, .. } = &g.kind else { panic!("flare is a group") };
assert_eq!(children.len(), 13);
let b = g.geometric_bounds().unwrap();
assert!(b.x1 > 400.0 && b.y1 > 300.0, "{b:?}");
let centre = children.iter().find(|c| c.name.as_deref() == Some("Center")).unwrap().geometric_bounds().unwrap();
assert!((centre.width() - 80.0).abs() < 0.5);
}
#[test]
fn reshape_moves_the_grabbed_point_and_its_neighbourhood() {
let mut s = session();
let r = rect(&mut s, 0.0, 0.0, 100.0, 100.0);
// Grab the middle of the top edge: a new anchor appears there and moves the full delta.
s.execute("path.reshape", &json!({"id": r.0, "x": 50.0, "y": 0.0, "dx": 0.0, "dy": -40.0})).unwrap();
let st = s.doc().unwrap();
let pd = st.doc.node(r).unwrap().path_data().unwrap().clone();
assert_eq!(pd.anchor_count(), 5);
let pts: Vec<_> = pd.anchors().map(|(_, _, a)| a.p).collect();
assert!(pts.iter().any(|p| (p.x - 50.0).abs() < 1e-6 && (p.y + 40.0).abs() < 1e-6), "{pts:?}");
// Top corners follow partially, bottom corners (far away) stay put.
let tl = pts.iter().find(|p| p.x < 1.0 && p.y < 0.0).expect("top-left moved up");
assert!(tl.y > -40.0);
assert!(pts.iter().filter(|p| (p.y - 100.0).abs() < 1e-9).count() == 2);
}
+210
View File
@@ -0,0 +1,210 @@
//! Flare and Reshape 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. 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};
use crate::distort::{BLUE, ellipse_path};
use crate::{Action, Cursor, Mods, Overlay, PointerEvent, PointerKind, Tool, ToolContext, ToolKey};
pub fn create(id: &str) -> Option<Box<dyn Tool>> {
Some(match id {
"flare" => Box::new(FlareTool::default()),
"reshape" => Box::new(ReshapeTool::default()),
_ => return None,
})
}
/// Flare Tool: centre drag, then a click for the rings' end point.
#[derive(Default)]
pub struct FlareTool {
/// Pressed at (centre) and the current drag point.
drag: Option<(Point, Point)>,
/// Centre placed, waiting for the end-point click: (centre, diameter).
placed: Option<(Point, f64)>,
hover: Point,
began: bool,
}
impl FlareTool {
fn params(c: Point, diameter: f64, end: Option<Point>) -> Value {
let mut v = json!({ "cx": c.x, "cy": c.y, "diameter": diameter.max(1.0) });
if let Some(e) = end {
v["x2"] = json!(e.x);
v["y2"] = json!(e.y);
}
v
}
}
impl Tool for FlareTool {
fn id(&self) -> &'static str {
"flare"
}
fn busy(&self) -> bool {
self.drag.is_some() || self.placed.is_some()
}
fn pointer(&mut self, cx: &ToolContext, ev: &PointerEvent) -> Vec<Action> {
self.hover = ev.pos;
match ev.kind {
PointerKind::Down => {
if self.placed.is_none() {
self.drag = Some((ev.pos, ev.pos));
}
vec![]
}
PointerKind::Drag => {
if let Some((c, _)) = self.drag {
self.drag = Some((c, ev.pos));
if !self.began && ev.pos.distance(c) >= cx.tol(3.0) {
self.began = true;
return vec![
Action::Begin("Flare".into()),
Action::Preview("shape.flare".into(), Self::params(c, 2.0 * ev.pos.distance(c), None)),
];
}
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 {
return vec![Action::Preview("shape.flare".into(), Self::params(c, d, Some(ev.pos)))];
}
vec![]
}
PointerKind::Up => {
if let Some((c, d)) = self.placed.take() {
self.began = false;
return vec![Action::Preview("shape.flare".into(), Self::params(c, d, Some(ev.pos))), Action::Commit];
}
let Some((c, p)) = self.drag.take() else { return vec![] };
if self.began {
// Keep the interaction open: the next click places the end point.
self.placed = Some((c, 2.0 * p.distance(c)));
vec![]
} else {
vec![Action::Dialog("flare".into(), json!({ "x": c.x, "y": c.y }))]
}
}
PointerKind::Move => {
if let Some((c, d)) = self.placed {
return vec![Action::Preview("shape.flare".into(), Self::params(c, d, Some(ev.pos)))];
}
vec![]
}
_ => vec![],
}
}
fn key(&mut self, _cx: &ToolContext, key: ToolKey, _mods: Mods) -> Vec<Action> {
if key == ToolKey::Escape && self.busy() {
self.drag = None;
self.placed = None;
let began = std::mem::take(&mut self.began);
return if began { vec![Action::Cancel] } else { vec![] };
}
vec![]
}
fn deactivate(&mut self, _cx: &ToolContext) -> Vec<Action> {
// Switching away after the centre drag keeps the flare with its default rings.
self.drag = None;
if self.placed.take().is_some() && std::mem::take(&mut self.began) { vec![Action::Commit] } else { vec![] }
}
fn overlays(&self, _cx: &ToolContext) -> Vec<Overlay> {
match (self.drag, self.placed) {
(Some((c, p)), _) => {
vec![Overlay::Path { path: ellipse_path(c, p.distance(c), p.distance(c), 0.0), color: BLUE, width: 1.0, dashed: false }]
}
(_, Some((c, _))) => vec![Overlay::Line { a: c, b: self.hover, color: BLUE, dashed: true }],
_ => vec![],
}
}
fn cursor(&self, _cx: &ToolContext, _p: Point, _mods: Mods) -> Cursor {
Cursor::Crosshair
}
}
/// Reshape Tool: drag a point on a selected path.
#[derive(Default)]
pub struct ReshapeTool {
/// (path, grab point) while dragging.
grab: Option<(vectorcraft_doc::NodeId, Point)>,
began: bool,
}
impl ReshapeTool {
/// The selected path under `p` (Reshape works on selected paths only, like Illustrator).
fn hit(cx: &ToolContext, p: Point) -> Option<(vectorcraft_doc::NodeId, Point)> {
let tol = cx.tol(4.0);
cx.selection.objects.iter().copied().filter(|id| cx.doc.is_editable(*id)).find_map(|id| {
let pd = cx.doc.node(id)?.path_data()?;
let (_, _, _, q, d) = pd.nearest(p)?;
(d <= tol).then_some((id, q))
})
}
}
impl Tool for ReshapeTool {
fn id(&self) -> &'static str {
"reshape"
}
fn busy(&self) -> bool {
self.grab.is_some()
}
fn pointer(&mut self, cx: &ToolContext, ev: &PointerEvent) -> Vec<Action> {
match ev.kind {
PointerKind::Down => {
self.grab = Self::hit(cx, ev.pos);
self.began = false;
vec![]
}
PointerKind::Drag => {
let Some((id, at)) = self.grab else { return vec![] };
let mut d: Vec2 = ev.pos - at;
if ev.mods.shift {
d = vectorcraft_geom::constrain_angle(d, 45.0);
}
let mut out = vec![];
if !self.began {
if d.hypot() < cx.tol(2.0) {
return out;
}
self.began = true;
out.push(Action::Begin("Reshape".into()));
}
out.push(Action::Preview(
"path.reshape".into(),
json!({ "id": id.0, "x": at.x, "y": at.y, "dx": d.x, "dy": d.y, "tol": cx.tol(4.0) }),
));
out
}
PointerKind::Up => {
self.grab = None;
if std::mem::take(&mut self.began) { vec![Action::Commit] } else { vec![] }
}
_ => vec![],
}
}
fn key(&mut self, _cx: &ToolContext, key: ToolKey, _mods: Mods) -> Vec<Action> {
if key == ToolKey::Escape && self.grab.take().is_some() && std::mem::take(&mut self.began) {
return vec![Action::Cancel];
}
vec![]
}
fn cursor(&self, cx: &ToolContext, p: Point, _mods: Mods) -> Cursor {
if self.grab.is_some() || Self::hit(cx, p).is_some() { Cursor::Move } else { Cursor::Arrow }
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn create_covers_both() {
assert_eq!(create("flare").unwrap().id(), "flare");
assert_eq!(create("reshape").unwrap().id(), "reshape");
assert!(create("pen").is_none());
}
}
+2
View File
@@ -13,6 +13,7 @@ pub mod catalog;
pub mod direct;
pub mod distort;
pub mod draw2;
pub mod extra;
pub mod guides;
pub mod meshblend;
pub mod pen;
@@ -257,6 +258,7 @@ pub fn create(id: &str) -> Box<dyn Tool> {
.or_else(|| xform::create(other))
.or_else(|| meshblend::create(other))
.or_else(|| distort::create(other))
.or_else(|| extra::create(other))
.unwrap_or_else(|| Box::new(NoopTool(tool_info(other).map(|t| t.id).unwrap_or("selection")))),
}
}
+6
View File
@@ -17,6 +17,12 @@ pub fn open_tool_dialog(app: &mut VectorcraftApp, kind: &str, p: Value) {
"polygon" => Dialog::new(kind, json!({"x": x, "y": y, "radius": "50 pt", "sides": 6})),
"star" => Dialog::new(kind, json!({"x": x, "y": y, "radius1": "50 pt", "radius2": "25 pt", "points": 5})),
"lineSegment" => Dialog::new(kind, json!({"x": x, "y": y, "length": "100 pt", "angle": 0})),
// Flare Tool Options (Center / Halo / Rays / Rings), applied through the generic command dialog.
"flare" => Dialog::new(
"command",
json!({"__command": "shape.flare", "__label": "Flare Tool Options", "cx": x, "cy": y, "diameter": 100, "opacity": 50, "brightness": 30,
"growth": 20, "fuzziness": 50, "rays": 15, "longest": 300, "rayFuzziness": 100, "pathLength": 300, "rings": 10, "largest": 50, "direction": 45}),
),
"rotate" | "reflect" | "scale" | "shear" | "artboardOptions" => {
let mut base = match kind {
"rotate" => json!({"angle": 0}),