mirror of
https://github.com/storytold/deckcraft.git
synced 2026-10-27 03:56:50 -06:00
Freeform, Curve and Scribble drawing tools; shape.freeform command; composite commands are one undo step
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 5.5
parent
8598c41085
commit
f0d230bdcd
@@ -20,6 +20,15 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
has_slide,
|
||||
connect
|
||||
),
|
||||
cmd!(
|
||||
"shape.freeform",
|
||||
"Freeform",
|
||||
["Insert", "Shapes"],
|
||||
None,
|
||||
"{points: [[x, y], …] slide pt (≥ 2), closed?: bool (filled shape), smooth?: bool (curve through the points)} → {id}",
|
||||
has_slide,
|
||||
freeform
|
||||
),
|
||||
cmd!(query "shape.sites", "Connection Sites", [], None, "{id} → [[x, y]] connection sites in slide points", has_slide, sites),
|
||||
cmd!("shape.move", "Move", [], None, "{dx?, dy?: pt (relative) | x?, y?: pt (absolute), ids?}", has_selection, move_by),
|
||||
cmd!("shape.resize", "Size", ["Shape Format", "Size"], None, "{w?, h?: pt, lockAspect?: bool, ids?}", has_selection, resize),
|
||||
@@ -731,3 +740,80 @@ fn connect(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
})?;
|
||||
Ok(json!({"id": id.0, "fromSite": ia, "toSite": ib}))
|
||||
}
|
||||
|
||||
/// Path data through `pts` (shape-local): straight segments, or a Catmull-Rom curve as cubics.
|
||||
pub fn freeform_path(pts: &[slidecraft_geom::Point], closed: bool, smooth: bool) -> String {
|
||||
let f = |v: f64| {
|
||||
let s = format!("{:.2}", v);
|
||||
s.trim_end_matches('0').trim_end_matches('.').to_string()
|
||||
};
|
||||
let mut d = format!("M {} {}", f(pts[0].x), f(pts[0].y));
|
||||
let n = pts.len();
|
||||
if smooth && n > 2 {
|
||||
let at = |i: isize| -> slidecraft_geom::Point {
|
||||
if closed { pts[i.rem_euclid(n as isize) as usize] } else { pts[i.clamp(0, n as isize - 1) as usize] }
|
||||
};
|
||||
let segs = if closed { n } else { n - 1 };
|
||||
for i in 0..segs as isize {
|
||||
let (p0, p1, p2, p3) = (at(i - 1), at(i), at(i + 1), at(i + 2));
|
||||
let c1 = p1 + (p2 - p0) / 6.0;
|
||||
let c2 = p2 - (p3 - p1) / 6.0;
|
||||
d += &format!(" C {} {} {} {} {} {}", f(c1.x), f(c1.y), f(c2.x), f(c2.y), f(p2.x), f(p2.y));
|
||||
}
|
||||
} else {
|
||||
for q in &pts[1..] {
|
||||
d += &format!(" L {} {}", f(q.x), f(q.y));
|
||||
}
|
||||
}
|
||||
if closed {
|
||||
d += " Z";
|
||||
}
|
||||
d
|
||||
}
|
||||
|
||||
fn freeform(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let pts: Vec<slidecraft_geom::Point> = p
|
||||
.get("points")
|
||||
.and_then(Value::as_array)
|
||||
.map(|a| {
|
||||
a.iter()
|
||||
.filter_map(|v| Some(slidecraft_geom::Point::new(v.get(0)?.as_f64()?, v.get(1)?.as_f64()?)))
|
||||
.filter(|q| q.x.is_finite() && q.y.is_finite())
|
||||
.take(20_000)
|
||||
.collect()
|
||||
})
|
||||
.unwrap_or_default();
|
||||
if pts.len() < 2 {
|
||||
return Err(bad("shape.freeform", "need at least two points"));
|
||||
}
|
||||
let closed = bool_or(p, "closed", false) && pts.len() > 2;
|
||||
let smooth = bool_or(p, "smooth", false);
|
||||
let mut bb = slidecraft_geom::Rect::from_points(pts[0], pts[0]);
|
||||
for q in &pts {
|
||||
bb = bb.union_pt(*q);
|
||||
}
|
||||
let (w, h) = (bb.width().max(1.0), bb.height().max(1.0));
|
||||
let local: Vec<slidecraft_geom::Point> = pts.iter().map(|q| slidecraft_geom::Point::new(q.x - bb.x0, q.y - bb.y0)).collect();
|
||||
let d = freeform_path(&local, closed, smooth);
|
||||
// Closed shapes take the default shape style, open paths the line style.
|
||||
let r = s.execute("shape.insert", &json!({"preset": if closed { "rect" } else { "line" }, "rect": [bb.x0, bb.y0, w, h]}))?;
|
||||
let id = slidecraft_model::ShapeId(r.get("id").and_then(Value::as_u64).ok_or_else(|| bad("shape.freeform", "insert failed"))? as u32);
|
||||
s.edit(|doc, sel| {
|
||||
let list = crate::shapes_mut(doc, sel).ok_or_else(|| bad("shape.freeform", "no slide"))?;
|
||||
let sh = slidecraft_model::find_shape_mut(list, id).ok_or_else(|| bad("shape.freeform", "insert failed"))?;
|
||||
sh.geom = Geom::Custom {
|
||||
paths: vec![slidecraft_model::CustomPath {
|
||||
w,
|
||||
h,
|
||||
d: d.clone(),
|
||||
fill: if closed { slidecraft_geom::preset::FillMode::Norm } else { slidecraft_geom::preset::FillMode::None },
|
||||
stroke: true,
|
||||
}],
|
||||
};
|
||||
sh.xfrm = Some(slidecraft_geom::Xfrm::new(bb.x0, bb.y0, w, h));
|
||||
sh.kind = slidecraft_model::ShapeKind::Shape;
|
||||
sh.name = format!("Freeform {}", id.0);
|
||||
Ok(())
|
||||
})?;
|
||||
Ok(json!({"id": id.0}))
|
||||
}
|
||||
|
||||
@@ -36,9 +36,11 @@ impl Session {
|
||||
/// Run command `id` (`f`) inside the guard.
|
||||
pub(crate) fn guarded(&mut self, id: &str, f: impl FnOnce(&mut Session) -> Result<Value>) -> Result<Value> {
|
||||
let before = self.active().map(|d| (d.uid, d.doc.clone(), d.selection.clone(), d.revision));
|
||||
let depth = self.depth;
|
||||
match catch_unwind(AssertUnwindSafe(|| f(self))) {
|
||||
Ok(r) => r,
|
||||
Err(payload) => {
|
||||
self.depth = depth;
|
||||
let msg = panic_message(payload.as_ref());
|
||||
// Keep the document as it was before the command.
|
||||
if let (Some((uid, doc, selection, revision)), Some(st)) = (before, self.active_mut())
|
||||
|
||||
@@ -29,6 +29,9 @@ impl Session {
|
||||
let has_shapes = self.active().is_some_and(|d| !d.selection.shapes.is_empty());
|
||||
let k = key.to_ascii_lowercase();
|
||||
let k = k.trim_start_matches("arrow");
|
||||
if self.tool.drawing_freeform() && matches!(k, "enter" | "return" | "escape") {
|
||||
return self.finish_freeform(false);
|
||||
}
|
||||
if editing && !mods.cmd {
|
||||
let r = match k {
|
||||
"left" => Some(self.execute("text.move", &json!({"to": if mods.alt { "wordLeft" } else { "left" }, "extend": mods.shift}))),
|
||||
|
||||
@@ -267,6 +267,9 @@ pub struct Session {
|
||||
pub painter: Option<(cmd::format::Painted, bool)>,
|
||||
/// Set as Default Shape: the look new shapes get.
|
||||
pub default_look: Option<cmd::format::Painted>,
|
||||
/// Nesting of commands run by commands: only the outermost one is an undo step and a journal
|
||||
/// entry.
|
||||
depth: u32,
|
||||
}
|
||||
|
||||
impl Default for Session {
|
||||
@@ -289,6 +292,7 @@ impl Session {
|
||||
untitled: 0,
|
||||
default_look: None,
|
||||
painter: None,
|
||||
depth: 0,
|
||||
}
|
||||
}
|
||||
/// A session with one new presentation open.
|
||||
@@ -351,7 +355,11 @@ impl Session {
|
||||
}
|
||||
}
|
||||
let before = self.active().map(|d| (d.uid, d.doc.clone(), d.selection.clone()));
|
||||
let r = (spec.run)(self, params)?;
|
||||
let outer = self.depth == 0;
|
||||
self.depth += 1;
|
||||
let r = (spec.run)(self, params);
|
||||
self.depth -= 1;
|
||||
let r = r?;
|
||||
if let Some(st) = self.active_mut() {
|
||||
clamp_selection(st);
|
||||
}
|
||||
@@ -360,10 +368,11 @@ impl Session {
|
||||
&& !Arc::ptr_eq(&old, &st.doc)
|
||||
&& st.interaction.is_none()
|
||||
&& spec.undoable
|
||||
&& outer
|
||||
{
|
||||
push_undo(st, HistoryEntry { label: spec.label.to_string(), doc: old, selection: sel });
|
||||
}
|
||||
if spec.journal {
|
||||
if spec.journal && outer {
|
||||
self.journal.push((id.to_string(), params.clone()));
|
||||
if self.journal.len() > 10_000 {
|
||||
self.journal.drain(..1000);
|
||||
|
||||
@@ -310,3 +310,59 @@ fn drawing_a_line_between_shapes_glues_it() {
|
||||
let (p0, p1) = line_ends(&s, id);
|
||||
assert!((p0.x - 200.0).abs() < 1e-6 && (p1.x - 400.0).abs() < 1e-6, "{p0:?} {p1:?}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn freeform_tools_and_command() {
|
||||
let mut s = session();
|
||||
let before = s.doc().unwrap().shapes().len();
|
||||
// Composite command: one undo step.
|
||||
let r = s.execute("shape.freeform", &json!({"points": [[100, 100], [200, 120], [150, 220]], "closed": true})).unwrap();
|
||||
assert!(r["id"].as_u64().is_some());
|
||||
s.execute("edit.undo", &json!({})).unwrap();
|
||||
assert_eq!(s.doc().unwrap().shapes().len(), before);
|
||||
s.execute("edit.redo", &json!({})).unwrap();
|
||||
assert_eq!(s.doc().unwrap().shapes().len(), before + 1);
|
||||
|
||||
let ev = |kind, x: f64, y: f64| tools::PointerEvent { kind, x, y, mods: Default::default(), tol: 3.0 };
|
||||
// Polygon: click, click, click, click on the start → closed, filled.
|
||||
s.set_tool(tools::ToolKind::Shape { preset: "freeform".into() });
|
||||
for (x, y) in [(300.0, 300.0), (400.0, 300.0), (400.0, 400.0)] {
|
||||
s.pointer(ev(tools::PointerKind::Down, x, y)).unwrap();
|
||||
s.pointer(ev(tools::PointerKind::Up, x, y)).unwrap();
|
||||
}
|
||||
assert!(s.tool.drawing_freeform());
|
||||
s.pointer(ev(tools::PointerKind::Down, 301.0, 301.0)).unwrap();
|
||||
assert!(!s.tool.drawing_freeform());
|
||||
let st = s.doc().unwrap();
|
||||
let sh = st.shapes().last().unwrap();
|
||||
match &sh.geom {
|
||||
slidecraft_model::Geom::Custom { paths } => assert!(paths[0].d.ends_with('Z') && paths[0].d.matches(" L ").count() == 2, "{}", paths[0].d),
|
||||
g => panic!("{g:?}"),
|
||||
}
|
||||
let x = sh.xfrm.unwrap();
|
||||
assert_eq!((x.x, x.y, x.w, x.h), (300.0, 300.0, 100.0, 100.0));
|
||||
|
||||
// Scribble: one drag, open path.
|
||||
s.set_tool(tools::ToolKind::Shape { preset: "scribble".into() });
|
||||
s.pointer(ev(tools::PointerKind::Down, 10.0, 10.0)).unwrap();
|
||||
for i in 1..20 {
|
||||
s.pointer(ev(tools::PointerKind::Drag, 10.0 + i as f64 * 5.0, 10.0 + (i as f64).sin() * 10.0)).unwrap();
|
||||
}
|
||||
let r = s.pointer(ev(tools::PointerKind::Up, 110.0, 10.0)).unwrap();
|
||||
assert!(r["id"].as_u64().is_some());
|
||||
assert_eq!(s.tool.kind, tools::ToolKind::Select);
|
||||
|
||||
// Curve: clicks then Enter → smooth open path.
|
||||
s.set_tool(tools::ToolKind::Shape { preset: "curve".into() });
|
||||
for (x, y) in [(500.0, 100.0), (550.0, 50.0), (600.0, 100.0), (650.0, 50.0)] {
|
||||
s.pointer(ev(tools::PointerKind::Down, x, y)).unwrap();
|
||||
s.pointer(ev(tools::PointerKind::Up, x, y)).unwrap();
|
||||
}
|
||||
let r = s.key("Enter", Default::default()).unwrap();
|
||||
let id = slidecraft_model::ShapeId(r["id"].as_u64().unwrap() as u32);
|
||||
let st = s.doc().unwrap();
|
||||
match &st.shape(id).unwrap().geom {
|
||||
slidecraft_model::Geom::Custom { paths } => assert_eq!(paths[0].d.matches(" C ").count(), 3),
|
||||
g => panic!("{g:?}"),
|
||||
}
|
||||
}
|
||||
|
||||
+140
-9
@@ -89,15 +89,63 @@ pub enum Hit {
|
||||
|
||||
#[derive(Clone, Debug)]
|
||||
enum Drag {
|
||||
Move { ids: Vec<ShapeId>, start: Point, origs: Vec<(ShapeId, Xfrm)>, moved: bool, duplicate: bool, enter_text: Option<(ShapeId, Point)> },
|
||||
Resize { id: ShapeId, handle: u8, orig: Xfrm, start: Point },
|
||||
LineEnd { id: ShapeId, end: u8, orig: Xfrm },
|
||||
Rotate { ids: Vec<ShapeId>, center: Point, start: f64, origs: Vec<(ShapeId, Xfrm)> },
|
||||
Adjust { id: ShapeId, index: usize, orig: Xfrm },
|
||||
Marquee { start: Point, add: bool },
|
||||
Create { start: Point },
|
||||
TextSelect { shape: ShapeId },
|
||||
Ink { points: Vec<(f64, f64, f32)> },
|
||||
Move {
|
||||
ids: Vec<ShapeId>,
|
||||
start: Point,
|
||||
origs: Vec<(ShapeId, Xfrm)>,
|
||||
moved: bool,
|
||||
duplicate: bool,
|
||||
enter_text: Option<(ShapeId, Point)>,
|
||||
},
|
||||
Resize {
|
||||
id: ShapeId,
|
||||
handle: u8,
|
||||
orig: Xfrm,
|
||||
start: Point,
|
||||
},
|
||||
LineEnd {
|
||||
id: ShapeId,
|
||||
end: u8,
|
||||
orig: Xfrm,
|
||||
},
|
||||
Rotate {
|
||||
ids: Vec<ShapeId>,
|
||||
center: Point,
|
||||
start: f64,
|
||||
origs: Vec<(ShapeId, Xfrm)>,
|
||||
},
|
||||
Adjust {
|
||||
id: ShapeId,
|
||||
index: usize,
|
||||
orig: Xfrm,
|
||||
},
|
||||
Marquee {
|
||||
start: Point,
|
||||
add: bool,
|
||||
},
|
||||
Create {
|
||||
start: Point,
|
||||
},
|
||||
TextSelect {
|
||||
shape: ShapeId,
|
||||
},
|
||||
Ink {
|
||||
points: Vec<(f64, f64, f32)>,
|
||||
},
|
||||
/// Freeform drawing (`freeform` polygon, `curve`, `scribble`): placed points; `pressed` while
|
||||
/// the button is down (dragging adds scribbled points).
|
||||
Freeform {
|
||||
mode: String,
|
||||
points: Vec<Point>,
|
||||
pressed: bool,
|
||||
},
|
||||
}
|
||||
|
||||
/// Shape-gallery names that draw a freeform path instead of a preset.
|
||||
pub const FREEFORM_TOOLS: [(&str, &str); 3] = [("curve", "Curve"), ("freeform", "Freeform: Shape"), ("scribble", "Freeform: Scribble")];
|
||||
|
||||
pub fn is_freeform_tool(name: &str) -> bool {
|
||||
FREEFORM_TOOLS.iter().any(|f| f.0 == name)
|
||||
}
|
||||
|
||||
/// A snapping guide line to draw (slide coordinates): vertical (x) or horizontal (y).
|
||||
@@ -125,12 +173,22 @@ pub struct ToolState {
|
||||
/// site the end would glue to.
|
||||
pub sites: Vec<Point>,
|
||||
pub glue: Option<Point>,
|
||||
/// Freeform being drawn: points so far (plus the rubber-band point), smoothed, and whether
|
||||
/// the pointer is over the start (closing the shape).
|
||||
pub path_preview: Vec<Point>,
|
||||
pub path_smooth: bool,
|
||||
pub path_closing: bool,
|
||||
}
|
||||
|
||||
impl ToolState {
|
||||
pub fn dragging(&self) -> bool {
|
||||
self.drag.is_some()
|
||||
}
|
||||
|
||||
/// A freeform path is being drawn (Enter/Escape/double-click finish it).
|
||||
pub fn drawing_freeform(&self) -> bool {
|
||||
matches!(self.drag, Some(Drag::Freeform { .. }))
|
||||
}
|
||||
}
|
||||
|
||||
/// Handle positions in slide coordinates for a box (8 resize + rotate).
|
||||
@@ -209,6 +267,15 @@ impl Session {
|
||||
}
|
||||
match ev.kind {
|
||||
PointerKind::Move => {
|
||||
if let Some(Drag::Freeform { points, mode, .. }) = &self.tool.drag {
|
||||
let closing = points.len() > 2 && points.first().is_some_and(|s| (*s - p).hypot() <= tol * 2.0);
|
||||
let mut v = points.clone();
|
||||
v.push(p);
|
||||
self.tool.path_preview = v;
|
||||
self.tool.path_smooth = mode == "curve";
|
||||
self.tool.path_closing = closing;
|
||||
return Ok(Value::Null);
|
||||
}
|
||||
self.tool.hover = self.hit_test(p, tol);
|
||||
let line_tool = matches!(&self.tool.kind, ToolKind::Shape { preset } if slidecraft_geom::preset::is_line_like(preset));
|
||||
if line_tool {
|
||||
@@ -251,7 +318,25 @@ impl Session {
|
||||
}
|
||||
|
||||
fn down(&mut self, p: Point, mods: Mods, tol: f64) -> Result<Value> {
|
||||
if let Some(Drag::Freeform { mode, mut points, .. }) = self.tool.drag.clone() {
|
||||
// Next click of a freeform: close on the start point, else add a vertex.
|
||||
if points.len() > 2 && points.first().is_some_and(|s| (*s - p).hypot() <= tol * 2.0) {
|
||||
self.tool.drag = Some(Drag::Freeform { mode, points, pressed: false });
|
||||
return self.finish_freeform(true);
|
||||
}
|
||||
points.push(p);
|
||||
self.tool.path_preview = points.clone();
|
||||
self.tool.drag = Some(Drag::Freeform { mode, points, pressed: true });
|
||||
return Ok(Value::Null);
|
||||
}
|
||||
match self.tool.kind.clone() {
|
||||
ToolKind::Shape { preset } if is_freeform_tool(&preset) => {
|
||||
self.tool.drag = Some(Drag::Freeform { mode: preset.clone(), points: vec![p], pressed: true });
|
||||
self.tool.path_preview = vec![p];
|
||||
self.tool.path_smooth = preset == "curve";
|
||||
self.tool.path_closing = false;
|
||||
return Ok(Value::Null);
|
||||
}
|
||||
ToolKind::Shape { .. } | ToolKind::TextBox => {
|
||||
self.tool.drag = Some(Drag::Create { start: p });
|
||||
self.tool.preview = Some(Xfrm::new(p.x, p.y, 0.0, 0.0));
|
||||
@@ -539,6 +624,18 @@ impl Session {
|
||||
})?;
|
||||
}
|
||||
}
|
||||
Drag::Freeform { mode, mut points, pressed } => {
|
||||
// Dragging scribbles (except for curves, which only take clicked points).
|
||||
if pressed && mode != "curve" && points.last().is_none_or(|l| (*l - p).hypot() >= 1.5) {
|
||||
points.push(p);
|
||||
}
|
||||
let mut v = points.clone();
|
||||
if mode == "curve" {
|
||||
v.push(p);
|
||||
}
|
||||
self.tool.path_preview = v;
|
||||
self.tool.drag = Some(Drag::Freeform { mode, points, pressed });
|
||||
}
|
||||
Drag::Ink { mut points } => {
|
||||
points.push((p.x, p.y, 0.5));
|
||||
self.tool.ink_preview = points.clone();
|
||||
@@ -633,6 +730,17 @@ impl Session {
|
||||
}
|
||||
out = res;
|
||||
}
|
||||
Some(Drag::Freeform { mode, points, .. }) => {
|
||||
if mode == "scribble" {
|
||||
// A scribble ending back at its start closes into a filled shape.
|
||||
let closed = points.len() > 3 && points.first().zip(points.last()).is_some_and(|(a, b)| (*a - *b).hypot() <= 6.0);
|
||||
self.tool.drag = Some(Drag::Freeform { mode, points, pressed: false });
|
||||
return self.finish_freeform(closed);
|
||||
}
|
||||
// Polygon / curve: keep going until double-click, Enter or a click on the start.
|
||||
self.tool.drag = Some(Drag::Freeform { mode, points, pressed: false });
|
||||
return Ok(Value::Null);
|
||||
}
|
||||
Some(Drag::Ink { points }) => {
|
||||
self.tool.ink_preview.clear();
|
||||
if points.len() > 1 {
|
||||
@@ -655,6 +763,14 @@ impl Session {
|
||||
}
|
||||
|
||||
fn double_click(&mut self, p: Point, tol: f64) -> Result<Value> {
|
||||
if let Some(Drag::Freeform { mode, mut points, .. }) = self.tool.drag.clone() {
|
||||
// The double-click's own click added a duplicate vertex.
|
||||
while points.len() > 2 && points.last().zip(points.get(points.len() - 2)).is_some_and(|(a, b)| (*a - *b).hypot() <= tol) {
|
||||
points.pop();
|
||||
}
|
||||
self.tool.drag = Some(Drag::Freeform { mode, points, pressed: false });
|
||||
return self.finish_freeform(false);
|
||||
}
|
||||
if !matches!(self.tool.kind, ToolKind::Select) {
|
||||
// Double-click while drawing: lock drawing mode off.
|
||||
self.tool.sticky = false;
|
||||
@@ -698,6 +814,21 @@ impl Session {
|
||||
self.active().and_then(|d| d.shape(id)).is_some_and(|s| matches!(s.kind, ShapeKind::Picture { .. } | ShapeKind::Media(_)))
|
||||
}
|
||||
|
||||
/// End the freeform being drawn: create the shape (if it has at least two points).
|
||||
pub fn finish_freeform(&mut self, closed: bool) -> Result<Value> {
|
||||
let Some(Drag::Freeform { mode, points, .. }) = self.tool.drag.take() else { return Ok(Value::Null) };
|
||||
self.tool.path_preview.clear();
|
||||
self.tool.path_closing = false;
|
||||
if !self.tool.sticky {
|
||||
self.tool.kind = ToolKind::Select;
|
||||
}
|
||||
if points.len() < 2 {
|
||||
return Ok(Value::Null);
|
||||
}
|
||||
let pts: Vec<[f64; 2]> = points.iter().map(|q| [q.x, q.y]).collect();
|
||||
self.execute("shape.freeform", &json!({"points": pts, "closed": closed, "smooth": mode == "curve"}))
|
||||
}
|
||||
|
||||
/// The connection site within glue range of `p` (excluding shape `exclude`).
|
||||
fn glue_at(&self, p: Point, tol: f64) -> Option<(ShapeId, u32, Point)> {
|
||||
let st = self.active()?;
|
||||
|
||||
Reference in New Issue
Block a user