diff --git a/crates/engine/src/cmd/shape/freeform.rs b/crates/engine/src/cmd/shape/freeform.rs index 5fd299c9e..f6886c75f 100644 --- a/crates/engine/src/cmd/shape/freeform.rs +++ b/crates/engine/src/cmd/shape/freeform.rs @@ -104,7 +104,7 @@ pub(super) fn freeform(s: &mut Session, p: &Value) -> Result { a.iter() .filter_map(|v| Some(deckcraft_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) + .take(crate::tools::MAX_GESTURE_POINTS) .collect() }) .unwrap_or_default(); diff --git a/crates/engine/src/tests.rs b/crates/engine/src/tests.rs index 1c9434f0e..8b1841675 100644 --- a/crates/engine/src/tests.rs +++ b/crates/engine/src/tests.rs @@ -5433,6 +5433,38 @@ fn pen_pressure_is_kept_and_a_mouse_draws_at_full_width() { assert_eq!(got, vec![vec![0.2, 1.0, 0.4], vec![1.0, 1.0]]); } +#[test] +fn a_long_ink_stroke_is_extended_in_place_and_capped() { + let mut s = session(); + s.set_tool(tools::ToolKind::Ink { mode: "pen".into(), color: deckcraft_model::Rgba::rgb(20, 20, 20), width: 4.0 }); + let ev = |kind, x: f64| tools::PointerEvent { kind, x, y: 80.0, mods: Mods::default(), tol: 3.0, pressure: Some(0.5) }; + s.pointer(ev(PointerKind::Down, 0.0)).unwrap(); + let n = tools::MAX_GESTURE_POINTS + 5_000; + for i in 1..n { + s.pointer(ev(PointerKind::Drag, (i % 500) as f64)).unwrap(); + } + assert_eq!(s.tool.ink_preview.len(), tools::MAX_GESTURE_POINTS); + s.pointer(ev(PointerKind::Up, 0.0)).unwrap(); + let kept: usize = ink_pressures(&s).iter().map(Vec::len).sum(); + assert!((2..=tools::MAX_GESTURE_POINTS).contains(&kept), "{kept}"); +} + +#[test] +fn double_clicking_a_capped_freeform_keeps_its_vertices() { + let mut s = session(); + let ev = |kind, x: f64, y: f64| tools::PointerEvent { kind, x, y, mods: Default::default(), tol: 3.0, pressure: None }; + s.set_tool(tools::ToolKind::Shape { preset: "freeform".into() }); + s.pointer(ev(tools::PointerKind::Down, 100.0, 100.0)).unwrap(); + // Densely clicked vertices, each within the double-click tolerance of the last. + for i in 0..tools::MAX_GESTURE_POINTS + 10 { + s.pointer(ev(tools::PointerKind::Down, 300.0 + i as f64 * 0.001, 100.0)).unwrap(); + } + s.pointer(ev(tools::PointerKind::DoubleClick, 300.0, 100.0)).unwrap(); + let st = s.doc().unwrap(); + let deckcraft_model::Geom::Custom { paths } = &st.shapes().last().unwrap().geom else { panic!("no freeform") }; + assert_eq!(paths[0].d.matches(" L ").count(), tools::MAX_GESTURE_POINTS - 1); +} + fn ink_pressures(s: &Session) -> Vec> { let st = s.doc().unwrap(); let Some(sh) = st.shapes().iter().rev().find(|sh| matches!(sh.kind, deckcraft_model::ShapeKind::Ink { .. })) else { diff --git a/crates/engine/src/tools.rs b/crates/engine/src/tools.rs index 82f53dfc3..2b7f78da9 100644 --- a/crates/engine/src/tools.rs +++ b/crates/engine/src/tools.rs @@ -17,6 +17,7 @@ mod geometry; mod hit; mod pointer; +pub(crate) use drag::MAX_GESTURE_POINTS; pub use hit::{line_xfrm, resize_box, table_rows, text_pos}; #[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)] @@ -275,6 +276,8 @@ pub struct ToolState { pub path_preview: Vec, pub path_smooth: bool, pub path_closing: bool, + /// Whether the last freeform press added a vertex (it adds none at the point cap). + pub press_added_vertex: bool, /// The shape in Edit Points: its vertices are handles instead of the box's. pub edit_points: Option, /// The vertex (path, index) whose control handles show in Edit Points. diff --git a/crates/engine/src/tools/click.rs b/crates/engine/src/tools/click.rs index 802eb8c5e..10bc03d98 100644 --- a/crates/engine/src/tools/click.rs +++ b/crates/engine/src/tools/click.rs @@ -4,8 +4,12 @@ use super::{Drag, Hit, Point, Result, Session, ShapeId, ShapeKind, TextSel, Tool impl Session { pub(super) fn double_click(&mut self, p: Point, tol: f64) -> Result { 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) { + // The double-click's own click added a duplicate vertex (none at the point cap, where + // the existing vertices are all kept). + while self.tool.press_added_vertex + && 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 }); diff --git a/crates/engine/src/tools/drag.rs b/crates/engine/src/tools/drag.rs index a1abcb93c..41ade3266 100644 --- a/crates/engine/src/tools/drag.rs +++ b/crates/engine/src/tools/drag.rs @@ -12,9 +12,16 @@ struct MoveDrag { enter_text: Option<(ShapeId, Point)>, } +/// Samples one ink or freeform gesture keeps; later pointer moves extend it no further. +pub(crate) const MAX_GESTURE_POINTS: usize = 100_000; + impl Session { /// Update the active drag interaction using the pointer position and modifiers. pub(super) fn drag(&mut self, p: Point, mods: Mods, tol: f64) -> Result { + if matches!(self.tool.drag, Some(Drag::Ink { .. })) { + self.extend_ink(p); + return Ok(Value::Null); + } let Some(drag) = self.tool.drag.clone() else { return Ok(Value::Null) }; match drag { Drag::Guide { index } => { @@ -181,7 +188,7 @@ 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) { + if pressed && mode != "curve" && points.len() < MAX_GESTURE_POINTS && points.last().is_none_or(|l| (*l - p).hypot() >= 1.5) { points.push(p); } let mut v = points.clone(); @@ -191,15 +198,24 @@ impl Session { self.tool.path_preview = v; self.tool.drag = Some(Drag::Freeform { mode, points, pressed }); } - Drag::Ink { mut points } => { - points.push(self.ink_sample(p)); - self.tool.ink_preview = points.clone(); - self.tool.drag = Some(Drag::Ink { points }); - } + // Handled above. + Drag::Ink { .. } => {} } Ok(Value::Null) } + /// Ink grows by one sample per move: extend it (and its preview) in place rather than + /// copying the whole stroke twice per event, up to [`MAX_GESTURE_POINTS`]. + fn extend_ink(&mut self, p: Point) { + let sample = self.ink_sample(p); + if let Some(Drag::Ink { points }) = self.tool.drag.as_mut() + && points.len() < MAX_GESTURE_POINTS + { + points.push(sample); + self.tool.ink_preview.push(sample); + } + } + fn drag_move(&mut self, drag: MoveDrag, p: Point, mods: Mods, tol: f64) -> Result { let MoveDrag { ids, start, origs, moved, duplicate, enter_text } = drag; let mut d = p - start; diff --git a/crates/engine/src/tools/pointer.rs b/crates/engine/src/tools/pointer.rs index d20ae9315..e289e5c70 100644 --- a/crates/engine/src/tools/pointer.rs +++ b/crates/engine/src/tools/pointer.rs @@ -228,7 +228,10 @@ impl Session { self.tool.drag = Some(Drag::Freeform { mode, points, pressed: false }); return self.finish_freeform(true); } - points.push(p); + self.tool.press_added_vertex = points.len() < super::MAX_GESTURE_POINTS; + if self.tool.press_added_vertex { + points.push(p); + } self.tool.path_preview = points.clone(); self.tool.drag = Some(Drag::Freeform { mode, points, pressed: true }); return Ok(Value::Null);