diff --git a/crates/engine/src/cmd/create.rs b/crates/engine/src/cmd/create.rs index d473be08..4087c9fd 100644 --- a/crates/engine/src/cmd/create.rs +++ b/crates/engine/src/cmd/create.rs @@ -12,6 +12,15 @@ pub fn specs() -> Vec { 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 { 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 { + 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| { + 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 { 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")?); diff --git a/crates/engine/src/cmd/path.rs b/crates/engine/src/cmd/path.rs index 1d8cedde..6bc09d35 100644 --- a/crates/engine/src/cmd/path.rs +++ b/crates/engine/src/cmd/path.rs @@ -86,6 +86,15 @@ pub fn specs() -> Vec { 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 { ok() } +fn reshape(s: &mut Session, p: &Value) -> Result { + 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 { 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; diff --git a/crates/engine/src/tests.rs b/crates/engine/src/tests.rs index 10a2ded2..3402c935 100644 --- a/crates/engine/src/tests.rs +++ b/crates/engine/src/tests.rs @@ -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); +} diff --git a/crates/tools/src/extra.rs b/crates/tools/src/extra.rs new file mode 100644 index 00000000..3cee14ce --- /dev/null +++ b/crates/tools/src/extra.rs @@ -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> { + 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) -> 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 { + 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 { + 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 { + // 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 { + 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 { + 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 { + 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()); + } +} diff --git a/crates/tools/src/lib.rs b/crates/tools/src/lib.rs index 800d480f..3153a117 100644 --- a/crates/tools/src/lib.rs +++ b/crates/tools/src/lib.rs @@ -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 { .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")))), } } diff --git a/crates/ui-egui/src/dialogs.rs b/crates/ui-egui/src/dialogs.rs index 497f79fd..09ae9ed9 100644 --- a/crates/ui-egui/src/dialogs.rs +++ b/crates/ui-egui/src/dialogs.rs @@ -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}),