Draw: Ink to Shape converts pen strokes into shapes

Draw > Ink to Shape recognises each pen stroke on the slide's ink layer
as a line, rectangle, oval or isosceles triangle (apex up or down) and
replaces it with that preset shape, outlined in the stroke's colour and
width with no fill. Strokes it can't recognise, and highlighter strokes,
stay ink. The ink shape is removed once it has no strokes left. The
whole conversion is one undo step.

The recogniser (engine/src/inkshape.rs) is pure geometry: ends meeting,
area covered within the bounding box, and spread from the fitted
ellipse. Ribbon big buttons now carry an accessibility label.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
Nikita Rakitin
2026-10-13 23:16:49 +03:00
co-authored by Cursor
parent 457406a7a5
commit 4658042de8
7 changed files with 354 additions and 5 deletions
+85
View File
@@ -29,6 +29,15 @@ pub fn specs() -> Vec<CommandSpec> {
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<Value> {
})?;
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<Value> {
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<Option<Recognized>> = strokes
.iter()
.map(|st| if st.highlighter { None } else { recognize(&st.points.iter().map(|(x, y, _)| (x + off.0, y + off.1)).collect::<Vec<_>>()) })
.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::<Vec<_>>(), "kept": remaining}))
})
}
+192
View File
@@ -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<Recognized> {
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<f64> = 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::<f64>() / 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);
}
}
+1
View File
@@ -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;
+60
View File
@@ -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");
}
}
+10 -2
View File
@@ -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;
+3
View File
@@ -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)))
+3 -3
View File
@@ -9,7 +9,7 @@ The summary between `<!-- SUMMARY -->
|---|---|---|---|---|---|
| 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 `<!-- SUMMARY -->
| 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 |