diff --git a/crates/engine/src/cmd/shape.rs b/crates/engine/src/cmd/shape.rs index b4c6aa4e..aabac43f 100644 --- a/crates/engine/src/cmd/shape.rs +++ b/crates/engine/src/cmd/shape.rs @@ -29,6 +29,15 @@ pub fn specs() -> Vec { has_slide, freeform ), + cmd!( + "draw.inkToShape", + "Ink to Shape", + ["Draw", "Convert"], + None, + "{} → {shapes: [ids], kept: n} turns recognisable ink strokes on the slide into lines, rectangles, ellipses and triangles", + has_slide, + ink_to_shape + ), 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), @@ -860,3 +869,79 @@ fn freeform(s: &mut Session, p: &Value) -> Result { })?; Ok(json!({"id": id.0})) } + +/// Draw › Ink to Shape: each recognisable stroke of the slide's ink becomes a preset shape +/// outlined in the stroke's colour and width; the rest stays ink. One undo step. +fn ink_to_shape(s: &mut Session, _p: &Value) -> Result { + use crate::inkshape::{Recognized, recognize}; + use deckcraft_model::{Shape, ShapeId, ShapeKind}; + let shapes = s.doc()?.shapes(); + let Some(ink) = shapes.iter().find(|sh| matches!(sh.kind, ShapeKind::Ink { .. })) else { + return Ok(json!({"shapes": [], "kept": 0})); + }; + let (ink_id, off) = (ink.id, ink.xfrm.map(|x| (x.x, x.y)).unwrap_or((0.0, 0.0))); + let ShapeKind::Ink { strokes } = &ink.kind else { return Ok(json!({"shapes": [], "kept": 0})) }; + let found: Vec> = strokes + .iter() + .map(|st| if st.highlighter { None } else { recognize(&st.points.iter().map(|(x, y, _)| (x + off.0, y + off.1)).collect::>()) }) + .collect(); + if found.iter().all(Option::is_none) { + return Ok(json!({"shapes": [], "kept": strokes.len()})); + } + s.edit(|doc, sel| { + let mut new = vec![]; + let list = crate::shapes_mut(doc, sel).ok_or_else(|| bad("draw.inkToShape", "no slide"))?; + let Some(pos) = list.iter().position(|sh| sh.id == ink_id) else { return Err(bad("draw.inkToShape", "ink gone")) }; + let ShapeKind::Ink { strokes } = &mut list[pos].kind else { return Err(bad("draw.inkToShape", "not ink")) }; + let all = std::mem::take(strokes); + let mut kept = vec![]; + let mut made = vec![]; + for (st, r) in all.into_iter().zip(found.iter()) { + let Some(r) = r else { + kept.push(st); + continue; + }; + let (preset, xfrm) = match *r { + Recognized::Line { x0, y0, x1, y1 } => { + ("line", crate::tools::line_xfrm(deckcraft_geom::Point::new(x0, y0), deckcraft_geom::Point::new(x1, y1), 0.0)) + } + Recognized::Rect { x, y, w, h } => ("rect", deckcraft_geom::Xfrm::new(x, y, w, h)), + Recognized::Ellipse { x, y, w, h } => ("ellipse", deckcraft_geom::Xfrm::new(x, y, w, h)), + Recognized::Triangle { x, y, w, h, flip_v } => ("triangle", deckcraft_geom::Xfrm { flip_v, ..deckcraft_geom::Xfrm::new(x, y, w, h) }), + }; + made.push((preset, xfrm, st.color, st.width)); + } + let remaining = kept.len(); + *strokes = kept; + if remaining == 0 { + list.remove(pos); + } + for (preset, xfrm, color, width) in made { + let id = ShapeId(doc.alloc_id()); + let name = format!( + "{} {}", + match preset { + "line" => "Straight Connector", + "rect" => "Rectangle", + "ellipse" => "Oval", + _ => "Isosceles Triangle", + }, + id.0 + ); + let list = crate::shapes_mut(doc, sel).ok_or_else(|| bad("draw.inkToShape", "no slide"))?; + list.push(Shape { + id, + name, + xfrm: Some(xfrm), + geom: Geom::preset(preset), + fill: Some(Fill::None), + line: Some(Line::solid(ColorRef::rgb(color), width.clamp(0.25, 100.0))), + kind: ShapeKind::Shape, + ..Default::default() + }); + new.push(id); + } + sel.shapes = new.clone(); + Ok(json!({"shapes": new.iter().map(|i| i.0).collect::>(), "kept": remaining})) + }) +} diff --git a/crates/engine/src/inkshape.rs b/crates/engine/src/inkshape.rs new file mode 100644 index 00000000..593bcbc1 --- /dev/null +++ b/crates/engine/src/inkshape.rs @@ -0,0 +1,192 @@ +//! Draw › Ink to Shape: recognise a hand-drawn stroke as a line, rectangle, ellipse or triangle. +//! +//! Pure geometry so it is unit-tested with synthetic strokes. A stroke is closed when its ends +//! meet; a closed stroke is classified by how much of its bounding box it covers (rectangle ≈ 1, +//! ellipse ≈ π/4, triangle ≈ ½) and, for ellipses, by how evenly it keeps its distance from the +//! centre. + +/// What a stroke was recognised as, in its bounding box `(x, y, w, h)`. +#[derive(Clone, Copy, Debug, PartialEq)] +pub enum Recognized { + /// From `(x0, y0)` to `(x1, y1)`. + Line { + x0: f64, + y0: f64, + x1: f64, + y1: f64, + }, + Rect { + x: f64, + y: f64, + w: f64, + h: f64, + }, + Ellipse { + x: f64, + y: f64, + w: f64, + h: f64, + }, + /// Apex at the top (`flip_v` false) or at the bottom. + Triangle { + x: f64, + y: f64, + w: f64, + h: f64, + flip_v: bool, + }, +} + +/// Strokes smaller than this (points) stay ink. +const MIN_SIZE: f64 = 6.0; +const MAX_POINTS: usize = 100_000; + +pub fn recognize(points: &[(f64, f64)]) -> Option { + let pts: Vec<(f64, f64)> = points.iter().copied().filter(|(x, y)| x.is_finite() && y.is_finite()).take(MAX_POINTS).collect(); + if pts.len() < 2 { + return None; + } + let (mut x0, mut y0, mut x1, mut y1) = (f64::MAX, f64::MAX, f64::MIN, f64::MIN); + for (x, y) in &pts { + x0 = x0.min(*x); + y0 = y0.min(*y); + x1 = x1.max(*x); + y1 = y1.max(*y); + } + let (w, h) = (x1 - x0, y1 - y0); + let size = w.max(h); + if !size.is_finite() || size < MIN_SIZE { + return None; + } + let length: f64 = pts.windows(2).map(|p| (p[1].0 - p[0].0).hypot(p[1].1 - p[0].1)).sum(); + let (first, last) = (pts[0], pts[pts.len() - 1]); + let gap = (last.0 - first.0).hypot(last.1 - first.1); + if length <= 0.0 { + return None; + } + // Open stroke: a line if nearly straight, otherwise not a shape. + if gap > 0.25 * size { + return (gap / length > 0.92).then_some(Recognized::Line { x0: first.0, y0: first.1, x1: last.0, y1: last.1 }); + } + // Closed but flat: a line drawn back and forth isn't a shape. + if w.min(h) < 0.15 * size { + return None; + } + // Covered area (shoelace, the gap closed) over the bounding box. + let mut a2 = 0.0; + for k in 0..pts.len() { + let (p, q) = (pts[k], pts[(k + 1) % pts.len()]); + a2 += p.0 * q.1 - q.0 * p.1; + } + let cover = (a2.abs() / 2.0) / (w * h); + // Ellipse fit: distance from the centre in box-normalised units should stay near 1. + let (cx, cy) = (x0 + w / 2.0, y0 + h / 2.0); + let radial: Vec = pts.iter().map(|(x, y)| ((x - cx) / (w / 2.0)).hypot((y - cy) / (h / 2.0))).collect(); + let spread = radial.iter().map(|r| (r - 1.0).abs()).sum::() / radial.len() as f64; + if cover > 0.86 { + Some(Recognized::Rect { x: x0, y: y0, w, h }) + } else if cover > 0.66 && spread < 0.12 { + Some(Recognized::Ellipse { x: x0, y: y0, w, h }) + } else if (0.35..0.66).contains(&cover) { + // Apex: the side of the box with fewer points near it is where the triangle narrows. + let near_top = pts.iter().filter(|(_, y)| *y < y0 + h * 0.15).count(); + let near_bottom = pts.iter().filter(|(_, y)| *y > y1 - h * 0.15).count(); + Some(Recognized::Triangle { x: x0, y: y0, w, h, flip_v: near_bottom < near_top }) + } else { + None + } +} + +#[cfg(test)] +mod tests { + use super::*; + + fn poly(corners: &[(f64, f64)], per_side: usize) -> Vec<(f64, f64)> { + let mut v = vec![]; + for k in 0..corners.len() { + let (a, b) = (corners[k], corners[(k + 1) % corners.len()]); + for i in 0..per_side { + let t = i as f64 / per_side as f64; + // A little hand wobble. + let wob = ((i * 7 + k * 3) % 5) as f64 * 0.4 - 0.8; + v.push((a.0 + (b.0 - a.0) * t + wob, a.1 + (b.1 - a.1) * t - wob)); + } + } + v.push((corners[0].0 + 2.0, corners[0].1 + 1.5)); + v + } + + #[test] + fn rectangles_ellipses_triangles_and_lines() { + let r = recognize(&poly(&[(100.0, 100.0), (300.0, 100.0), (300.0, 200.0), (100.0, 200.0)], 30)).unwrap(); + assert!(matches!(r, Recognized::Rect { w, h, .. } if (w - 200.0).abs() < 4.0 && (h - 100.0).abs() < 4.0), "{r:?}"); + let ell: Vec<(f64, f64)> = (0..=80) + .map(|i| { + let a = i as f64 / 80.0 * std::f64::consts::TAU; + (200.0 + 120.0 * a.cos() + (i % 3) as f64 * 0.5, 150.0 + 60.0 * a.sin()) + }) + .collect(); + assert!(matches!(recognize(&ell), Some(Recognized::Ellipse { .. })), "{:?}", recognize(&ell)); + let up = recognize(&poly(&[(200.0, 50.0), (300.0, 250.0), (100.0, 250.0)], 30)).unwrap(); + assert!(matches!(up, Recognized::Triangle { flip_v: false, .. }), "{up:?}"); + let down = recognize(&poly(&[(100.0, 50.0), (300.0, 50.0), (200.0, 250.0)], 30)).unwrap(); + assert!(matches!(down, Recognized::Triangle { flip_v: true, .. }), "{down:?}"); + let line: Vec<(f64, f64)> = (0..=40).map(|i| (10.0 + i as f64 * 5.0, 20.0 + i as f64 * 2.0 + (i % 2) as f64 * 0.6)).collect(); + assert!(matches!(recognize(&line), Some(Recognized::Line { x0, x1, .. }) if x0 < x1), "{:?}", recognize(&line)); + } + + #[test] + fn scribbles_dots_and_garbage_stay_ink() { + assert_eq!(recognize(&[]), None); + assert_eq!(recognize(&[(1.0, 1.0)]), None); + assert_eq!(recognize(&[(0.0, 0.0), (2.0, 2.0), (1.0, 3.0)]), None, "too small"); + let zig: Vec<(f64, f64)> = (0..40).map(|i| (i as f64 * 5.0, if i % 2 == 0 { 0.0 } else { 60.0 })).collect(); + assert_eq!(recognize(&zig), None, "an open zigzag is no line"); + assert_eq!(recognize(&[(f64::NAN, 0.0), (f64::INFINITY, 1.0)]), None); + let there_and_back: Vec<(f64, f64)> = (0..=20).chain((0..=20).rev()).map(|i| (i as f64 * 10.0, 5.0)).collect(); + assert_eq!(recognize(&there_and_back), None); + } + + #[test] + fn ink_to_shape_command_converts_and_undoes() { + use deckcraft_model::{InkStroke, Shape, ShapeId, ShapeKind}; + use serde_json::json; + let mut s = crate::Session::with_new(); + s.execute("slide.new", &json!({"layout": "blank"})).unwrap(); + let rect = poly(&[(100.0, 100.0), (300.0, 100.0), (300.0, 200.0), (100.0, 200.0)], 30); + let zig: Vec<(f64, f64)> = (0..40).map(|i| (i as f64 * 5.0, if i % 2 == 0 { 300.0 } else { 360.0 })).collect(); + let mk = |v: &[(f64, f64)]| InkStroke { + points: v.iter().map(|(x, y)| (*x, *y, 0.5)).collect(), + color: deckcraft_color::Rgba::rgb(200, 0, 0), + width: 3.0, + highlighter: false, + }; + let strokes = vec![mk(&rect), mk(&zig)]; + s.edit(|doc, sel| { + let id = ShapeId(doc.alloc_id()); + crate::shapes_mut(doc, sel).unwrap().push(Shape { + id, + name: "Ink".into(), + xfrm: Some(deckcraft_geom::Xfrm::new(0.0, 0.0, 960.0, 540.0)), + kind: ShapeKind::Ink { strokes }, + ..Default::default() + }); + Ok(serde_json::Value::Null) + }) + .unwrap(); + let n0 = s.doc().unwrap().shapes().len(); + let r = s.execute("draw.inkToShape", &json!({})).unwrap(); + assert_eq!(r["kept"], 1); + let sh = s.doc().unwrap().shapes(); + assert_eq!(sh.len(), n0 + 1, "ink keeps the zigzag, one rectangle added"); + let made = sh.last().unwrap(); + assert!(matches!(&made.geom, deckcraft_model::Geom::Preset { name, .. } if name == "rect")); + assert_eq!(made.line.as_ref().and_then(|l| l.width), Some(3.0)); + s.execute("edit.undo", &json!({})).unwrap(); + assert_eq!(s.doc().unwrap().shapes().len(), n0); + // Nothing recognisable: a no-op, not an error. Empty slide too. + s.execute("draw.inkToShape", &json!({})).unwrap(); + s.execute("slide.new", &json!({"layout": "blank"})).unwrap(); + assert_eq!(s.execute("draw.inkToShape", &json!({})).unwrap()["kept"], 0); + } +} diff --git a/crates/engine/src/lib.rs b/crates/engine/src/lib.rs index c2b56c7f..83c0a510 100644 --- a/crates/engine/src/lib.rs +++ b/crates/engine/src/lib.rs @@ -11,6 +11,7 @@ pub mod cmd; pub mod connect; pub mod fonts; pub mod guard; +pub mod inkshape; pub mod keys; pub mod links; pub mod recovery; diff --git a/crates/ui-egui/src/inktoshape_tests.rs b/crates/ui-egui/src/inktoshape_tests.rs new file mode 100644 index 00000000..7a7ebafc --- /dev/null +++ b/crates/ui-egui/src/inktoshape_tests.rs @@ -0,0 +1,60 @@ +//! Draw › Ink to Shape driven through the ribbon: pen a rough rectangle on the slide with the +//! mouse, press the button, get a rectangle shape. + +use deckcraft_engine::Session; +use egui::{Modifiers, PointerButton, Pos2}; +use egui_kittest::Harness; +use egui_kittest::kittest::Queryable; +use serde_json::json; + +use crate::{Services, SlideApp}; + +#[test] +fn pen_a_rectangle_then_ink_to_shape() { + let mut s = Session::with_new(); + s.execute("slide.new", &json!({"layout": "blank"})).unwrap(); + let mut app = SlideApp::new(s, Services::default()); + app.ui.tab = "Draw".into(); + let mut h = Harness::builder().with_size(egui::vec2(1400.0, 900.0)).with_step_dt(1.0 / 60.0).build_ui_state( + |ui, app: &mut SlideApp| { + app.logic(ui.ctx()); + app.ui(ui); + }, + app, + ); + h.run_steps(3); + h.get_by_label("Black Pen").click(); + h.run_steps(2); + let (slide, k) = (h.state().slide_rect.unwrap(), h.state().canvas_scale); + let at = |x: f32, y: f32| slide.min + egui::vec2(x * k, y * k); + let corners = [(200.0, 150.0), (600.0, 150.0), (600.0, 400.0), (200.0, 400.0), (204.0, 152.0)]; + h.hover_at(at(200.0, 150.0)); + h.run_steps(1); + h.event(egui::Event::PointerButton { pos: at(200.0, 150.0), button: PointerButton::Primary, pressed: true, modifiers: Modifiers::NONE }); + for w in corners.windows(2) { + for i in 1..=12 { + let t = i as f32 / 12.0; + let p: Pos2 = at(w[0].0 + (w[1].0 - w[0].0) * t, w[0].1 + (w[1].1 - w[0].1) * t); + h.event(egui::Event::PointerMoved(p)); + h.run_steps(1); + } + } + h.event(egui::Event::PointerButton { pos: at(204.0, 152.0), button: PointerButton::Primary, pressed: false, modifiers: Modifiers::NONE }); + h.run_steps(3); + let has_ink = |h: &Harness<'_, SlideApp>| { + h.state().session.active().unwrap().shapes().iter().any(|s| matches!(s.kind, deckcraft_model::ShapeKind::Ink { .. })) + }; + assert!(has_ink(&h), "the pen drew a stroke"); + h.get_by_label("Ink to Shape").click(); + h.run_steps(3); + assert!(!has_ink(&h), "the only stroke became a shape"); + let st = h.state().session.active().unwrap(); + let sh = st.shapes().last().unwrap(); + assert!(matches!(&sh.geom, deckcraft_model::Geom::Preset { name, .. } if name == "rect"), "{:?}", sh.geom); + let x = sh.xfrm.unwrap(); + assert!((x.x - 200.0).abs() < 10.0 && (x.w - 400.0).abs() < 15.0 && (x.h - 250.0).abs() < 15.0, "{x:?}"); + assert_eq!(st.selection.shapes, vec![sh.id]); + if let Ok(img) = h.render() { + let _ = img.save("/tmp/ink_to_shape.png"); + } +} diff --git a/crates/ui-egui/src/ribbon.rs b/crates/ui-egui/src/ribbon.rs index 428eff0b..80cf0375 100644 --- a/crates/ui-egui/src/ribbon.rs +++ b/crates/ui-egui/src/ribbon.rs @@ -1200,8 +1200,12 @@ fn draw(app: &mut SlideApp, ui: &mut Ui) { } }); group(ui, |ui| { - let r = big_button(ui, Icon::InkToShape, "Ink to\nShape", false); - let _ = r.on_hover_text("Ink to Shape (coming soon)"); + let has_ink = app.session.doc().is_ok_and(|d| d.shapes().iter().any(|s| matches!(s.kind, deckcraft_model::ShapeKind::Ink { .. }))); + let r = big_button(ui, Icon::InkToShape, "Ink to\nShape", has_ink); + if r.on_hover_text("Ink to Shape: turn drawn lines, rectangles, ovals and triangles into shapes").clicked() { + app.session.set_tool(ToolKind::Select); + run(app, "draw.inkToShape", json!({})); + } let r = big_button(ui, Icon::InkToText, "Ink to\nText", false); let _ = r.on_hover_text("Ink to Text (coming soon)"); let r = big_button(ui, Icon::InkToMath, "Ink to\nMath", false); @@ -2420,3 +2424,7 @@ fn paint_freeform_icon(p: &egui::Painter, r: Rect, name: &str, ink: Color32) { }; p.add(egui::Shape::line(pts, egui::Stroke::new(1.3, ink))); } + +#[cfg(test)] +#[path = "inktoshape_tests.rs"] +mod inktoshape_tests; diff --git a/crates/ui-egui/src/widgets.rs b/crates/ui-egui/src/widgets.rs index 5827b396..97480599 100644 --- a/crates/ui-egui/src/widgets.rs +++ b/crates/ui-egui/src/widgets.rs @@ -39,6 +39,9 @@ pub fn big_button_ex(ui: &mut Ui, icon: Icon, label: &str, enabled: bool, split: let text_w = lines.iter().map(|l| ui.fonts_mut(|f| f.layout_no_wrap(l.to_string(), font.clone(), t.text).size().x)).fold(0.0f32, f32::max); let w = (text_w + 10.0 + if split && lines.len() == 1 { 10.0 } else { 0.0 }).max(40.0); let (rect, resp) = ui.allocate_exact_size(vec2(w, RIBBON_H - 2.0), if enabled { Sense::click() } else { Sense::hover() }); + // Named for screen readers and UI tests ("Ink to\nShape" reads "Ink to Shape"). + let spoken = label.replace('\n', " "); + resp.widget_info(|| egui::WidgetInfo::labeled(egui::WidgetType::Button, enabled, &spoken)); let painter = ui.painter_at(rect.expand(1.0)); let (main_rect, arrow_rect) = if split { (Rect::from_min_max(rect.min, pos2(rect.max.x, rect.min.y + 38.0)), Some(Rect::from_min_max(pos2(rect.min.x, rect.min.y + 38.0), rect.max))) diff --git a/docs/parity-checklist.md b/docs/parity-checklist.md index 2a3d450c..bd4a36ce 100644 --- a/docs/parity-checklist.md +++ b/docs/parity-checklist.md @@ -9,7 +9,7 @@ The summary between ` |---|---|---|---|---|---| | P0 | 72 | 61 | 11 | 0 | 92% | | P1 | 65 | 38 | 21 | 6 | 75% | -| P2 | 54 | 13 | 15 | 26 | 38% | +| P2 | 54 | 13 | 16 | 25 | 39% | | Area | Parity | |---|---| @@ -31,7 +31,7 @@ The summary between ` | Animations | 79% | | Slide show | 66% | | Review | 60% | -| Draw | 33% | +| Draw | 50% | | Undo & clipboard | 88% | | Automation (✱) | 100% | | Platforms (✱) | 67% | @@ -224,7 +224,7 @@ Open P0 items: Contextual tabs (Shape Format, Picture Format, Table Design/Layou | Review | Compare presentations | P2 | M | | Review | Track changes / version history | P2 | M | | Draw | Ink pens, highlighter, eraser | P1 | P | -| Draw | Ink to shape / text | P2 | M | +| Draw | Ink to shape / text | P2 | P | | Undo & clipboard | Unlimited undo/redo | P0 | D | | Undo & clipboard | Cut, copy, paste shapes and slides | P0 | D | | Undo & clipboard | Paste special (keep formatting, picture, text only) | P1 | P |