Skip to main content

ravenrs_sheet/engine/cmd/
draw.rs

1//! Draw tab: pen and eraser tools, ink strokes, Ink to Shape.
2
3use crate::base::CellRef;
4use crate::model::{Anchor, Color, Shape, ShapeKind};
5use serde_json::{Value as Json, json};
6
7use super::{CommandSpec, EngineError, Result, Session, bad, bool_param, cmd, edit, f64_param, has_doc, ok, str_param, u32_param};
8
9pub fn specs() -> Vec<CommandSpec> {
10    vec![
11        cmd!(noundo "draw.pen", "Pen", ["Draw", "Tools"], None, "{on?: bool, color?: hex, width?: 2}", has_doc, pen),
12        cmd!(noundo "draw.eraser", "Eraser", ["Draw", "Tools"], None, "{on?: bool}", has_doc, eraser),
13        cmd!(noundo "draw.lasso", "Lasso Select", ["Draw", "Tools"], None, "{} (selects ink by dragging; same as Select Objects)", has_doc, |s, _| {
14            s.draw_tool = String::new();
15            ok()
16        }),
17        cmd!("draw.stroke", "Ink Stroke", [], None, "{points: [[x,y],…] in sheet points, color?: hex, width?: 2}", has_doc, stroke),
18        cmd!(
19            "draw.inkToShape",
20            "Ink to Shape",
21            ["Draw", "Convert"],
22            None,
23            "{id?} converts an ink stroke (default: the last) into a rectangle, ellipse, triangle or line",
24            has_doc,
25            ink_to_shape
26        ),
27    ]
28}
29
30fn pen(s: &mut Session, p: &Json) -> Result<Json> {
31    let on = bool_param(p, "on").unwrap_or(s.draw_tool != "pen");
32    s.draw_tool = if on { "pen".into() } else { String::new() };
33    if let Some(c) = str_param(p, "color") {
34        s.draw_color = c.to_string();
35    }
36    if let Some(w) = f64_param(p, "width") {
37        s.draw_width = w.clamp(0.5, 40.0) as f32;
38    }
39    Ok(json!({"tool": s.draw_tool, "color": s.draw_color, "width": s.draw_width}))
40}
41
42fn eraser(s: &mut Session, p: &Json) -> Result<Json> {
43    let on = bool_param(p, "on").unwrap_or(s.draw_tool != "eraser");
44    s.draw_tool = if on { "eraser".into() } else { String::new() };
45    Ok(json!({"tool": s.draw_tool}))
46}
47
48fn parse_points(t: &str) -> Vec<[f32; 2]> {
49    t.split(';').filter_map(|pt| pt.split_once(',').and_then(|(x, y)| Some([x.parse().ok()?, y.parse().ok()?]))).collect()
50}
51
52fn stroke(s: &mut Session, p: &Json) -> Result<Json> {
53    let pts: Vec<[f64; 2]> = p
54        .get("points")
55        .and_then(Json::as_array)
56        .map(|a| {
57            a.iter()
58                .filter_map(|q| Some([q.get(0)?.as_f64()?, q.get(1)?.as_f64()?]))
59                .filter(|q| q[0].is_finite() && q[1].is_finite())
60                .take(20_000)
61                .collect()
62        })
63        .unwrap_or_default();
64    if pts.len() < 2 {
65        return Err(bad("draw.stroke", "a stroke needs at least two points"));
66    }
67    let minx = pts.iter().map(|q| q[0]).fold(f64::INFINITY, f64::min).max(0.0);
68    let miny = pts.iter().map(|q| q[1]).fold(f64::INFINITY, f64::min).max(0.0);
69    let maxx = pts.iter().map(|q| q[0]).fold(f64::NEG_INFINITY, f64::max);
70    let maxy = pts.iter().map(|q| q[1]).fold(f64::NEG_INFINITY, f64::max);
71    let color =
72        str_param(p, "color").map(str::to_string).unwrap_or_else(|| if s.draw_color.is_empty() { "#1F5FC9".into() } else { s.draw_color.clone() });
73    let width = f64_param(p, "width").map(|w| w as f32).unwrap_or(if s.draw_width <= 0.0 { 2.0 } else { s.draw_width }).clamp(0.5, 40.0);
74    let d = s.doc()?;
75    let si = d.wb.active_sheet();
76    let sh = d.wb.sheet(si).ok_or(EngineError::NoDocument)?;
77    let cell = CellRef::new(sh.row_at(miny), sh.col_at(minx));
78    let dx = (minx - sh.col_left(cell.col)) as f32;
79    let dy = (miny - sh.row_top(cell.row)) as f32;
80    let text = pts.iter().map(|q| format!("{:.1},{:.1}", q[0] - minx, q[1] - miny)).collect::<Vec<_>>().join(";");
81    let id = d.wb.next_object_id();
82    let shape = Shape {
83        math: Default::default(),
84        id,
85        kind: ShapeKind::Ink,
86        anchor: Anchor {
87            cell,
88            dx,
89            dy,
90            width: (maxx - minx).max(1.0) as f32,
91            height: (maxy - miny).max(1.0) as f32,
92            // Ink is a freehand mark: pinned where it was drawn, it does not follow cells.
93            mode: crate::model::AnchorMode::Absolute,
94        },
95        fill: Color::from_hex(&format!("#{:02X}0000", width.round().clamp(0.0, 255.0) as u8)).unwrap_or_default(),
96        line: Color::from_hex(&color).unwrap_or(Color::rgb(0x1F, 0x5F, 0xC9)),
97        text,
98        format: Default::default(),
99    };
100    edit(s, |cx| {
101        cx.sheet_mut(si)?.shapes.push(shape);
102        Ok(json!({"shape": id}))
103    })
104}
105
106/// Ink width is stored in the red channel of `fill` (shapes have no width field).
107pub fn ink_width(sh: &Shape) -> f32 {
108    match sh.fill {
109        Color::Rgb(v) => ((v >> 16) & 0xFF).max(1) as f32,
110        _ => 2.0,
111    }
112}
113
114pub fn ink_points(sh: &Shape) -> Vec<[f32; 2]> {
115    parse_points(&sh.text)
116}
117
118fn ink_to_shape(s: &mut Session, p: &Json) -> Result<Json> {
119    let si = s.doc()?.wb.active_sheet();
120    let id = u32_param(p, "id");
121    edit(s, |cx| {
122        let sh = cx.sheet_mut(si)?;
123        let idx = match id {
124            Some(id) => sh.shapes.iter().position(|x| x.id == id && x.kind == ShapeKind::Ink),
125            None => sh.shapes.iter().rposition(|x| x.kind == ShapeKind::Ink),
126        }
127        .ok_or_else(|| bad("draw.inkToShape", "no ink stroke to convert"))?;
128        let ink = sh.shapes.get_mut(idx).ok_or_else(|| bad("draw.inkToShape", "gone"))?;
129        let pts = parse_points(&ink.text);
130        let (w, h) = (ink.anchor.width.max(1.0), ink.anchor.height.max(1.0));
131        let closed = match (pts.first(), pts.last()) {
132            (Some(a), Some(b)) => ((a[0] - b[0]).powi(2) + (a[1] - b[1]).powi(2)).sqrt() < 0.25 * w.max(h),
133            _ => false,
134        };
135        let kind = if !closed {
136            ShapeKind::Line
137        } else {
138            // Corners hugging the bounding box mean a rectangle; one apex at the top a triangle.
139            let near = |cx: f32, cy: f32| pts.iter().any(|q| ((q[0] - cx).powi(2) + (q[1] - cy).powi(2)).sqrt() < 0.15 * w.min(h).max(8.0));
140            let corners = [near(0.0, 0.0), near(w, 0.0), near(0.0, h), near(w, h)].iter().filter(|b| **b).count();
141            let apex = near(w / 2.0, 0.0);
142            if corners >= 3 {
143                ShapeKind::Rectangle
144            } else if apex && near(0.0, h) && near(w, h) {
145                ShapeKind::Triangle
146            } else {
147                ShapeKind::Ellipse
148            }
149        };
150        let color = ink.line;
151        ink.kind = kind;
152        ink.fill = if kind == ShapeKind::Line { color } else { Color::Auto };
153        ink.text = String::new();
154        if kind == ShapeKind::Line && pts.first().zip(pts.last()).is_some_and(|(a, b)| a[1] > b[1]) {
155            // Lines are drawn top-left to bottom-right; keep the stroke direction simple.
156        }
157        Ok(json!({"kind": format!("{kind:?}")}))
158    })
159}