From 733522d5286e8fb41ea49903d023fbb7aab2226f Mon Sep 17 00:00:00 2001 From: Nikita Rakitin Date: Tue, 13 Oct 2026 22:22:57 +0300 Subject: [PATCH] =?UTF-8?q?Shapes:=20Edit=20Points=20=E2=80=94=20drag,=20a?= =?UTF-8?q?dd=20and=20delete=20a=20shape's=20vertices?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit New cmd::points with shape.editPoints (enter/leave; a preset becomes a freeform in shape-local points), shape.points (vertices in slide points), shape.movePoint, shape.addPoint (splits a line, quadratic or cubic segment) and shape.deletePoint. After each change the box is fitted around the outline and kept in place on the slide, rotation and flips included. Closing edges are made explicit so they take points too. On the canvas the edited shape shows its vertices as handles: drag one (one undo step per drag), Cmd/Ctrl-click a vertex to delete it or the outline to add one; Escape, Enter or a click elsewhere leaves Edit Points. Shape Format ▸ Edit Shape gets an Edit Points item. Not yet: smooth/straight/corner point types and Bézier control handles. Co-authored-by: Cursor --- crates/engine/src/cmd/mod.rs | 2 + crates/engine/src/cmd/points.rs | 559 ++++++++++++++++++++++++++++++++ crates/engine/src/keys.rs | 4 + crates/engine/src/tests.rs | 99 ++++++ crates/engine/src/tools.rs | 63 ++++ crates/ui-egui/src/canvas.rs | 14 + crates/ui-egui/src/ribbon.rs | 5 + docs/gaps.md | 5 +- docs/parity-checklist.md | 2 +- docs/ui-parity.md | 2 +- 10 files changed, 751 insertions(+), 4 deletions(-) create mode 100644 crates/engine/src/cmd/points.rs diff --git a/crates/engine/src/cmd/mod.rs b/crates/engine/src/cmd/mod.rs index ce909b02..ca3ae8be 100644 --- a/crates/engine/src/cmd/mod.rs +++ b/crates/engine/src/cmd/mod.rs @@ -10,6 +10,7 @@ pub mod insert; pub mod inspect; pub mod media; pub mod merge; +pub mod points; pub mod review; pub mod selection; pub mod shape; @@ -133,6 +134,7 @@ pub fn command_specs() -> &'static [CommandSpec] { v.extend(arrange::specs()); v.extend(selection::specs()); v.extend(merge::specs()); + v.extend(points::specs()); v.extend(text::specs()); v.extend(format::specs()); v.extend(design::specs()); diff --git a/crates/engine/src/cmd/points.rs b/crates/engine/src/cmd/points.rs new file mode 100644 index 00000000..164bb58f --- /dev/null +++ b/crates/engine/src/cmd/points.rs @@ -0,0 +1,559 @@ +//! Edit Points (Shape Format ▸ Edit Shape ▸ Edit Points): turn a shape into a freeform and move, +//! add or delete the vertices of its outline. +//! +//! While points are edited every custom path of the shape is kept in shape-local points (its +//! `w`/`h` equal the shape's size), so a vertex's path coordinates are its offset in the shape. +//! After each change the shape's box is fitted around the outline again, keeping it in place on +//! the slide (rotation and flips included). + +use deckcraft_geom::{PathEl, Point, Xfrm}; +use deckcraft_model::{CustomPath, Geom, Shape, ShapeId, ShapeKind}; +use serde_json::{Value, json}; + +use super::*; +use crate::{Result, Session}; + +pub fn specs() -> Vec { + vec![ + cmd!( + noundo "shape.editPoints", + "Edit Points", + ["Shape Format", "Insert Shapes", "Edit Shape"], + None, + "{on?: bool (default: toggle), id?} — enter or leave Edit Points for one selected shape; entering turns a preset into a freeform", + has_selection, + edit_points + ), + cmd!(query "shape.points", "Points", [], None, "{id?} → {id, paths: [[[x, y] slide pt]]} vertices of a freeform", has_selection, points), + cmd!("shape.movePoint", "Move Point", [], None, "{path?: 0, index, x, y: slide pt, id?}", has_selection, move_point), + cmd!( + "shape.addPoint", + "Add Point", + [], + None, + "{path?: 0, segment: index of the vertex ending it, t?: 0..1 (0.5), id?}", + has_selection, + add_point + ), + cmd!("shape.deletePoint", "Delete Point", [], None, "{path?: 0, index, id?}", has_selection, delete_point), + ] +} + +/// One path command, in path units. +#[derive(Clone, Copy, Debug, PartialEq)] +pub enum Seg { + M(Point), + L(Point), + C(Point, Point, Point), + Q(Point, Point), + Z, +} + +impl Seg { + pub fn end(&self) -> Option { + match self { + Seg::M(p) | Seg::L(p) | Seg::C(_, _, p) | Seg::Q(_, p) => Some(*p), + Seg::Z => None, + } + } + fn points_mut(&mut self) -> Vec<&mut Point> { + match self { + Seg::M(p) | Seg::L(p) => vec![p], + Seg::C(a, b, p) => vec![a, b, p], + Seg::Q(a, p) => vec![a, p], + Seg::Z => vec![], + } + } +} + +/// Parses path data (`M x y L x y C … Q … Z`); malformed commands are skipped. +pub fn parse(d: &str) -> Vec { + let toks: Vec<&str> = d.split(|c: char| c.is_whitespace() || c == ',').filter(|t| !t.is_empty()).collect(); + let num = |i: usize| toks.get(i).and_then(|t| t.parse::().ok()).filter(|v| v.is_finite()); + let pt = |i: usize| Some(Point::new(num(i)?, num(i + 1)?)); + let mut out = Vec::new(); + let mut i = 0; + while i < toks.len() && out.len() < 100_000 { + let (seg, n) = match toks.get(i).copied().unwrap_or("") { + "M" => (pt(i + 1).map(Seg::M), 3), + "L" => (pt(i + 1).map(Seg::L), 3), + "C" => ((|| Some(Seg::C(pt(i + 1)?, pt(i + 3)?, pt(i + 5)?)))(), 7), + "Q" => ((|| Some(Seg::Q(pt(i + 1)?, pt(i + 3)?)))(), 5), + "Z" => (Some(Seg::Z), 1), + _ => (None, 1), + }; + // Make a closing edge explicit, so it is a segment that can take points. + if matches!(seg, Some(Seg::Z)) { + let start = out.iter().rev().find_map(|x| if let Seg::M(p) = x { Some(*p) } else { None }); + let end = out.last().and_then(Seg::end); + if let (Some(a), Some(b)) = (start, end) + && (a - b).hypot() > 1e-6 + { + out.push(Seg::L(a)); + } + } + if let Some(s) = seg { + // A path starts with a move. + let s = match (out.is_empty(), s) { + (true, Seg::L(p)) => Seg::M(p), + (true, Seg::C(..) | Seg::Q(..) | Seg::Z) => { + i += n; + continue; + } + (_, s) => s, + }; + out.push(s); + } + i += n; + } + out +} + +/// Path data for `segs`. +pub fn write(segs: &[Seg]) -> String { + let f = |v: f64| { + let s = format!("{:.3}", v); + let s = s.trim_end_matches('0').trim_end_matches('.'); + if s == "-0" { "0".to_string() } else { s.to_string() } + }; + let p = |q: &Point| format!("{} {}", f(q.x), f(q.y)); + segs.iter() + .map(|s| match s { + Seg::M(a) => format!("M {}", p(a)), + Seg::L(a) => format!("L {}", p(a)), + Seg::C(a, b, c) => format!("C {} {} {}", p(a), p(b), p(c)), + Seg::Q(a, b) => format!("Q {} {}", p(a), p(b)), + Seg::Z => "Z".into(), + }) + .collect::>() + .join(" ") +} + +/// Whether segment `i` ends back on its sub-path's start right before a `Z` (the closing edge of +/// a closed outline). Its end is the same vertex as the start, not a vertex of its own. +fn closes_onto_start(segs: &[Seg], i: usize) -> bool { + if !matches!(segs.get(i + 1), Some(Seg::Z)) { + return false; + } + let start = segs.get(..=i).and_then(|s| s.iter().rev().find_map(|x| if let Seg::M(p) = x { Some(*p) } else { None })); + match (start, segs.get(i).and_then(Seg::end)) { + (Some(a), Some(b)) => !matches!(segs.get(i), Some(Seg::M(_))) && (a - b).hypot() < 1e-6, + _ => false, + } +} + +/// The segments whose end is a vertex, in order. +pub fn vertices(segs: &[Seg]) -> Vec { + (0..segs.len()).filter(|i| segs.get(*i).and_then(Seg::end).is_some() && !closes_onto_start(segs, *i)).collect() +} + +fn shift(segs: &mut [Seg], i: usize, which: &[usize], d: deckcraft_geom::Vec2) { + if let Some(s) = segs.get_mut(i) { + let mut pts = s.points_mut(); + for w in which { + if let Some(p) = pts.get_mut(*w) { + **p += d; + } + } + } +} + +/// The indices of the points of segment `i` that sit on its end vertex (the end and, for a +/// cubic, its incoming control point). +fn end_points(seg: Option<&Seg>) -> &'static [usize] { + match seg { + Some(Seg::C(..)) => &[1, 2], + Some(Seg::Q(..)) => &[1], + Some(Seg::M(_) | Seg::L(_)) => &[0], + _ => &[], + } +} + +/// Moves the vertex ending segment `si` by `d`, with the control points attached to it. +fn move_vertex(segs: &mut [Seg], si: usize, d: deckcraft_geom::Vec2) { + // A closed sub-path's start is also its closing edge's end. + let close = if matches!(segs.get(si), Some(Seg::M(_))) { + (si + 1..segs.len()).take_while(|j| !matches!(segs.get(*j), Some(Seg::M(_)))).find(|j| closes_onto_start(segs, *j)) + } else { + None + }; + let own = end_points(segs.get(si)); + shift(segs, si, own, d); + // The outgoing curve's first control point follows the vertex. + if matches!(segs.get(si + 1), Some(Seg::C(..))) { + shift(segs, si + 1, &[0], d); + } + if let Some(j) = close { + let w = end_points(segs.get(j)); + shift(segs, j, w, d); + } +} + +/// The point where segment `si` starts. +fn seg_start(segs: &[Seg], si: usize) -> Option { + let prev = segs.get(..si)?; + match prev.last()? { + Seg::Z => prev.iter().rev().find_map(|x| if let Seg::M(p) = x { Some(*p) } else { None }), + s => s.end(), + } +} + +/// Splits segment `si` at `t`, adding a vertex there. Returns false for a move or close. +fn split(segs: &mut Vec, si: usize, t: f64) -> bool { + let Some(p0) = seg_start(segs, si) else { return false }; + let lerp = |a: Point, b: Point| a.lerp(b, t); + let (a, b) = match segs.get(si).copied() { + Some(Seg::L(p1)) => (Seg::L(lerp(p0, p1)), Seg::L(p1)), + Some(Seg::Q(c, p1)) => { + let (m0, m1) = (lerp(p0, c), lerp(c, p1)); + let m = m0.lerp(m1, t); + (Seg::Q(m0, m), Seg::Q(m1, p1)) + } + Some(Seg::C(c1, c2, p1)) => { + let (a1, a2, a3) = (lerp(p0, c1), lerp(c1, c2), lerp(c2, p1)); + let (b1, b2) = (a1.lerp(a2, t), a2.lerp(a3, t)); + let m = b1.lerp(b2, t); + (Seg::C(a1, b1, m), Seg::C(b2, a3, p1)) + } + _ => return false, + }; + if let Some(s) = segs.get_mut(si) { + *s = a; + } + segs.insert(si + 1, b); + true +} + +/// Removes the vertex ending segment `si`. A sub-path keeps at least two vertices. +fn remove_vertex(segs: &mut Vec, si: usize) -> bool { + // The vertices of `si`'s sub-path. + let from = segs.get(..=si).and_then(|s| s.iter().rposition(|x| matches!(x, Seg::M(_)))).unwrap_or(0); + let to = (si + 1..segs.len()).find(|j| matches!(segs.get(*j), Some(Seg::M(_)))).unwrap_or(segs.len()); + let count = vertices(segs).iter().filter(|v| (from..to).contains(*v)).count(); + if count <= 2 { + return false; + } + if matches!(segs.get(si), Some(Seg::M(_))) { + // The next vertex becomes the start; a closed outline's closing edge now ends there. + let Some(next) = segs.get(si + 1).and_then(Seg::end) else { return false }; + segs.remove(si + 1); + if let Some(s) = segs.get_mut(si) { + *s = Seg::M(next); + } + let close = (si + 1..segs.len()).take_while(|j| !matches!(segs.get(*j), Some(Seg::M(_)))).find(|j| matches!(segs.get(j + 1), Some(Seg::Z))); + if let Some(j) = close + && let Some(Seg::L(p) | Seg::C(_, _, p) | Seg::Q(_, p)) = segs.get_mut(j) + { + *p = next; + } + return true; + } + // Merge with the next segment: it now starts where the removed vertex's segment started. + match (segs.get(si).copied(), segs.get(si + 1).copied()) { + (Some(Seg::C(c1, _, _)), Some(Seg::C(_, c2, p))) => { + segs.remove(si + 1); + if let Some(s) = segs.get_mut(si) { + *s = Seg::C(c1, c2, p); + } + } + _ => { + segs.remove(si); + } + } + true +} + +/// Custom paths of a preset shape, in shape-local points. +fn to_custom(sh: &Shape, w: f64, h: f64) -> Vec { + let geo = deckcraft_render::shape_geometry(sh, w, h); + geo.paths + .iter() + .map(|sp| { + let segs: Vec = sp + .path + .elements() + .iter() + .map(|e| match *e { + PathEl::MoveTo(p) => Seg::M(p), + PathEl::LineTo(p) => Seg::L(p), + PathEl::QuadTo(a, p) => Seg::Q(a, p), + PathEl::CurveTo(a, b, p) => Seg::C(a, b, p), + PathEl::ClosePath => Seg::Z, + }) + .collect(); + CustomPath { w, h, d: write(&segs), fill: sp.fill, stroke: sp.stroke } + }) + .collect() +} + +/// Rescales every path of a custom geometry to shape-local points. +fn normalize(paths: &mut [CustomPath], w: f64, h: f64) { + for p in paths { + let (sx, sy) = (if p.w > 0.0 { w / p.w } else { 1.0 }, if p.h > 0.0 { h / p.h } else { 1.0 }); + if (sx - 1.0).abs() > 1e-12 || (sy - 1.0).abs() > 1e-12 { + let mut segs = parse(&p.d); + for s in &mut segs { + for q in s.points_mut() { + *q = Point::new(q.x * sx, q.y * sy); + } + } + p.d = write(&segs); + } + p.w = w; + p.h = h; + } +} + +/// Fits the box around the outline, keeping the drawing where it is on the slide. +fn refit(sh: &mut Shape) { + let (Some(x), Geom::Custom { paths }) = (sh.xfrm, &mut sh.geom) else { return }; + let mut all: Vec> = paths.iter().map(|p| parse(&p.d)).collect(); + let mut bb: Option = None; + for s in all.iter_mut().flatten() { + for q in s.points_mut() { + bb = Some(bb.map_or(deckcraft_geom::Rect::from_points(*q, *q), |b| b.union_pt(*q))); + } + } + let Some(bb) = bb else { return }; + let (w, h) = (bb.width().max(1.0), bb.height().max(1.0)); + if bb.x0.abs() < 1e-6 && bb.y0.abs() < 1e-6 && (w - x.w).abs() < 1e-6 && (h - x.h).abs() < 1e-6 { + return; + } + let c = x.affine() * Point::new(bb.x0 + w / 2.0, bb.y0 + h / 2.0); + let off = deckcraft_geom::Vec2::new(bb.x0, bb.y0); + for (p, segs) in paths.iter_mut().zip(&mut all) { + for s in segs.iter_mut() { + for q in s.points_mut() { + *q -= off; + } + } + p.d = write(segs); + p.w = w; + p.h = h; + } + sh.xfrm = Some(Xfrm { x: c.x - w / 2.0, y: c.y - h / 2.0, w, h, ..x }); +} + +/// The one shape whose points are edited: `id`, or the single selected shape. +fn target(s: &Session, p: &Value, cmd: &str) -> Result { + let st = s.doc()?; + let id = match p.get("id").and_then(Value::as_u64) { + Some(i) => ShapeId(i as u32), + None => match st.selection.shapes.as_slice() { + [one] => *one, + _ => return Err(bad(cmd, "select one shape")), + }, + }; + let sh = st.shape(id).ok_or_else(|| bad(cmd, "no such shape"))?; + if !st.shapes().iter().any(|t| t.id == id) { + return Err(bad(cmd, "a shape in a group can't have its points edited; ungroup it first")); + } + if !matches!(sh.kind, ShapeKind::Shape) || sh.is_line() || sh.xfrm.is_none() { + return Err(bad(cmd, "only a shape with an outline has points to edit")); + } + if sh.locked { + return Err(bad(cmd, "the shape is locked")); + } + Ok(id) +} + +/// Makes `id` a freeform in shape-local points (the state Edit Points works in). +pub(crate) fn prepare(s: &mut Session, id: ShapeId) -> Result<()> { + let st = s.doc()?; + let Some(sh) = st.shape(id) else { return Ok(()) }; + let Some(x) = sh.xfrm else { return Ok(()) }; + let ready = matches!(&sh.geom, Geom::Custom { paths } if paths.iter().all(|p| p.w == x.w && p.h == x.h)); + if ready { + return Ok(()); + } + s.edit(|doc, sel| { + let list = crate::shapes_mut(doc, sel).ok_or_else(|| bad("shape.editPoints", "no slide"))?; + if let Some(sh) = deckcraft_model::find_shape_mut(list, id) { + match &mut sh.geom { + Geom::Custom { paths } => normalize(paths, x.w, x.h), + Geom::Preset { .. } => { + let paths = to_custom(sh, x.w, x.h); + sh.geom = Geom::Custom { paths }; + } + } + } + Ok(()) + }) +} + +fn edit_points(s: &mut Session, p: &Value) -> Result { + let on = p.get("on").and_then(Value::as_bool).unwrap_or(s.tool.edit_points.is_none()); + if !on { + s.tool.edit_points = None; + return Ok(json!({"editing": Value::Null})); + } + let id = target(s, p, "shape.editPoints")?; + prepare(s, id)?; + s.tool.edit_points = Some(id); + Ok(json!({"editing": id.0})) +} + +/// Vertices of every path of `sh` in slide points. +pub fn slide_vertices(doc: &deckcraft_model::Presentation, sel: &crate::Selection, sh: &Shape) -> Vec> { + let Geom::Custom { paths } = &sh.geom else { return vec![] }; + let x = xfrm_of(doc, sel, sh); + let a = x.affine(); + paths + .iter() + .map(|p| { + let (sx, sy) = (if p.w > 0.0 { x.w / p.w } else { 1.0 }, if p.h > 0.0 { x.h / p.h } else { 1.0 }); + let segs = parse(&p.d); + vertices(&segs).iter().filter_map(|i| segs.get(*i).and_then(Seg::end)).map(|q| a * Point::new(q.x * sx, q.y * sy)).collect() + }) + .collect() +} + +fn points(s: &mut Session, p: &Value) -> Result { + let st = s.doc()?; + let id = match p.get("id").and_then(Value::as_u64) { + Some(i) => ShapeId(i as u32), + None => *st.selection.shapes.first().ok_or_else(|| bad("shape.points", "select a shape"))?, + }; + let sh = st.shape(id).ok_or_else(|| bad("shape.points", "no such shape"))?; + let paths: Vec> = + slide_vertices(&st.doc, &st.selection, sh).into_iter().map(|v| v.into_iter().map(|q| [q.x, q.y]).collect()).collect(); + Ok(json!({"id": id.0, "paths": paths})) +} + +/// Runs `f` on the segments of path `path` of `id` (in shape-local points), then refits the box. +/// `f` gets the segment indices of the vertices and returns whether it changed anything. +pub(crate) fn edit_path( + s: &mut Session, + id: ShapeId, + path: usize, + cmd: &'static str, + f: impl Fn(&mut Vec, &[usize], &Xfrm) -> Result, +) -> Result { + prepare(s, id)?; + s.edit(|doc, sel| { + let list = crate::shapes_mut(doc, sel).ok_or_else(|| bad(cmd, "no slide"))?; + let sh = deckcraft_model::find_shape_mut(list, id).ok_or_else(|| bad(cmd, "no such shape"))?; + let x = sh.xfrm.ok_or_else(|| bad(cmd, "the shape has no position"))?; + let Geom::Custom { paths } = &mut sh.geom else { return Err(bad(cmd, "not a freeform")) }; + let cp = paths.get_mut(path).ok_or_else(|| bad(cmd, "no such path"))?; + let mut segs = parse(&cp.d); + let vs = vertices(&segs); + if f(&mut segs, &vs, &x)? { + cp.d = write(&segs); + refit(sh); + } + Ok(()) + })?; + ok() +} + +fn index_param(p: &Value, key: &str, cmd: &str) -> Result { + p.get(key).and_then(Value::as_u64).map(|v| v as usize).ok_or_else(|| bad(cmd, format!("give `{key}`"))) +} + +fn move_point(s: &mut Session, p: &Value) -> Result { + let id = target(s, p, "shape.movePoint")?; + let path = p.get("path").and_then(Value::as_u64).unwrap_or(0) as usize; + let index = index_param(p, "index", "shape.movePoint")?; + let to = match (f64_param(p, "x"), f64_param(p, "y")) { + (Some(x), Some(y)) if x.is_finite() && y.is_finite() => Point::new(x.clamp(-1e5, 1e5), y.clamp(-1e5, 1e5)), + _ => return Err(bad("shape.movePoint", "give `x` and `y` in slide points")), + }; + edit_path(s, id, path, "shape.movePoint", move |segs, vs, x| { + let si = *vs.get(index).ok_or_else(|| bad("shape.movePoint", "no such point"))?; + let local = x.affine().inverse() * to; + let Some(cur) = segs.get(si).and_then(Seg::end) else { return Ok(false) }; + move_vertex(segs, si, local - cur); + Ok(true) + }) +} + +fn add_point(s: &mut Session, p: &Value) -> Result { + let id = target(s, p, "shape.addPoint")?; + let path = p.get("path").and_then(Value::as_u64).unwrap_or(0) as usize; + let seg = index_param(p, "segment", "shape.addPoint")?; + let t = f64_param(p, "t").filter(|t| t.is_finite()).unwrap_or(0.5).clamp(0.01, 0.99); + edit_path(s, id, path, "shape.addPoint", move |segs, vs, _| { + let si = *vs.get(seg).ok_or_else(|| bad("shape.addPoint", "no such segment"))?; + // The closing edge of a closed outline ends at the start: split it, not the start. + let si = if matches!(segs.get(si), Some(Seg::M(_))) { + (si + 1..segs.len()).find(|j| closes_onto_start(segs, *j)).ok_or_else(|| bad("shape.addPoint", "the first point ends no segment"))? + } else { + si + }; + if split(segs, si, t) { Ok(true) } else { Err(bad("shape.addPoint", "no segment ends at that point")) } + }) +} + +fn delete_point(s: &mut Session, p: &Value) -> Result { + let id = target(s, p, "shape.deletePoint")?; + let path = p.get("path").and_then(Value::as_u64).unwrap_or(0) as usize; + let index = index_param(p, "index", "shape.deletePoint")?; + edit_path(s, id, path, "shape.deletePoint", move |segs, vs, _| { + let si = *vs.get(index).ok_or_else(|| bad("shape.deletePoint", "no such point"))?; + if remove_vertex(segs, si) { Ok(true) } else { Err(bad("shape.deletePoint", "an outline keeps at least two points")) } + }) +} + +/// The vertex of `sh` near slide point `p`: (path, index). +pub fn vertex_at(doc: &deckcraft_model::Presentation, sel: &crate::Selection, sh: &Shape, p: Point, tol: f64) -> Option<(usize, usize)> { + for (pi, vs) in slide_vertices(doc, sel, sh).iter().enumerate() { + for (i, q) in vs.iter().enumerate() { + if (*q - p).hypot() <= tol { + return Some((pi, i)); + } + } + } + None +} + +/// The segment of `sh`'s outline near slide point `p`: (path, index of the vertex ending it, t). +pub fn segment_at(doc: &deckcraft_model::Presentation, sel: &crate::Selection, sh: &Shape, p: Point, tol: f64) -> Option<(usize, usize, f64)> { + let Geom::Custom { paths } = &sh.geom else { return None }; + let x = xfrm_of(doc, sel, sh); + let local = x.affine().inverse() * p; + let mut best: Option<(f64, usize, usize, f64)> = None; + for (pi, cp) in paths.iter().enumerate() { + let (sx, sy) = (if cp.w > 0.0 { x.w / cp.w } else { 1.0 }, if cp.h > 0.0 { x.h / cp.h } else { 1.0 }); + let segs = parse(&cp.d); + let vs = vertices(&segs); + for si in 0..segs.len() { + let Some(p0) = seg_start(&segs, si) else { continue }; + let at = |t: f64| -> Option { + let q = match segs.get(si)? { + Seg::L(p1) => p0.lerp(*p1, t), + Seg::Q(c, p1) => p0.lerp(*c, t).lerp(c.lerp(*p1, t), t), + Seg::C(c1, c2, p1) => { + let (a, b, c) = (p0.lerp(*c1, t), c1.lerp(*c2, t), c2.lerp(*p1, t)); + a.lerp(b, t).lerp(b.lerp(c, t), t) + } + _ => return None, + }; + Some(Point::new(q.x * sx, q.y * sy)) + }; + // The vertex that ends this segment (a closing edge ends on the start). + let end_vertex = if closes_onto_start(&segs, si) { + let start = segs.get(..si).and_then(|s| s.iter().rposition(|x| matches!(x, Seg::M(_)))); + start.and_then(|m| vs.iter().position(|v| *v == m)) + } else { + vs.iter().position(|v| *v == si) + }; + let Some(ev) = end_vertex else { continue }; + for k in 1..32 { + let t = k as f64 / 32.0; + if let Some(q) = at(t) { + let d = (q - local).hypot(); + if d <= tol && best.is_none_or(|b| d < b.0) { + best = Some((d, pi, ev, t)); + } + } + } + } + } + best.map(|(_, pi, ev, t)| (pi, ev, t)) +} + +/// Moves vertex `index` of path `path` of `id` to slide point `to` (pointer drags). +pub(crate) fn move_point_raw(s: &mut Session, id: ShapeId, path: usize, index: usize, to: Point) -> Result { + move_point(s, &json!({"id": id.0, "path": path, "index": index, "x": to.x, "y": to.y})) +} diff --git a/crates/engine/src/keys.rs b/crates/engine/src/keys.rs index 665cc22e..49e75057 100644 --- a/crates/engine/src/keys.rs +++ b/crates/engine/src/keys.rs @@ -57,6 +57,10 @@ impl Session { if self.tool.drawing_freeform() && matches!(k, "enter" | "return" | "escape") { return self.finish_freeform(false); } + if self.tool.edit_points.is_some() && matches!(k, "enter" | "return" | "escape") { + self.tool.edit_points = None; + return Ok(json!({"handled": true, "command": "shape.editPoints"})); + } if editing && let Some((id, params)) = self.text_key(k, mods) { let r = self.execute(id, ¶ms)?; return Ok(json!({"handled": true, "command": id, "result": r})); diff --git a/crates/engine/src/tests.rs b/crates/engine/src/tests.rs index e83f72ef..de931942 100644 --- a/crates/engine/src/tests.rs +++ b/crates/engine/src/tests.rs @@ -835,3 +835,102 @@ fn slide_ranges_parse_like_powerpoints_print_box() { let opts = cmd::file::pdf_options(&json!({"slides": [0, 7, 1]}), 2).unwrap(); assert_eq!(opts.slides, Some(vec![0, 1]), "indices past the end are dropped"); } + +#[test] +fn edit_points_turns_a_preset_into_a_freeform_and_moves_adds_deletes() { + use deckcraft_model::Geom; + let mut s = Session::with_new(); + s.execute("slide.new", &json!({"layout": "blank"})).unwrap(); + s.execute("shape.insert", &json!({"preset": "rect", "rect": [100, 100, 200, 100]})).unwrap(); + let r = s.execute("shape.editPoints", &json!({})).unwrap(); + let id = r["editing"].as_u64().unwrap(); + assert!(matches!(s.doc().unwrap().selected_shapes()[0].geom, Geom::Custom { .. })); + let pts = + |s: &mut Session| -> Vec<[f64; 2]> { serde_json::from_value(s.execute("shape.points", &json!({})).unwrap()["paths"][0].clone()).unwrap() }; + let near = |a: [f64; 2], b: [f64; 2]| (a[0] - b[0]).abs() < 1e-6 && (a[1] - b[1]).abs() < 1e-6; + let v = pts(&mut s); + assert_eq!(v.len(), 4, "{v:?}"); + assert!(near(v[0], [100.0, 100.0]) && near(v[2], [300.0, 200.0])); + // Drag the bottom-right corner out: the box grows to fit, other points stay on the slide. + s.execute("shape.movePoint", &json!({"index": 2, "x": 350, "y": 260})).unwrap(); + let v = pts(&mut s); + assert!(near(v[0], [100.0, 100.0]) && near(v[2], [350.0, 260.0]) && near(v[3], [100.0, 200.0]), "{v:?}"); + let x = s.doc().unwrap().selected_shapes()[0].xfrm.unwrap(); + assert!((x.w - 250.0).abs() < 1e-6 && (x.h - 160.0).abs() < 1e-6); + // Move the first point up-left: the box moves with it. + s.execute("shape.movePoint", &json!({"index": 0, "x": 80, "y": 90})).unwrap(); + let x = s.doc().unwrap().selected_shapes()[0].xfrm.unwrap(); + assert!((x.x - 80.0).abs() < 1e-6 && (x.y - 90.0).abs() < 1e-6); + assert!(near(pts(&mut s)[0], [80.0, 90.0])); + // Add a point in the middle of the closing edge (from the last point back to the first) and + // of the top edge, then delete one. + s.execute("shape.addPoint", &json!({"segment": 0})).unwrap(); + assert_eq!(pts(&mut s).len(), 5); + s.execute("shape.addPoint", &json!({"segment": 1, "t": 0.5})).unwrap(); + let v = pts(&mut s); + assert_eq!(v.len(), 6); + assert!(near(v[1], [(80.0 + 300.0) / 2.0, (90.0 + 100.0) / 2.0]), "{v:?}"); + s.execute("shape.deletePoint", &json!({"index": 1})).unwrap(); + s.execute("shape.deletePoint", &json!({"index": 0})).unwrap(); + assert_eq!(pts(&mut s).len(), 4); + // Bad input is an error, not a panic; an outline keeps two points. + assert!(s.execute("shape.movePoint", &json!({"index": 99, "x": 1, "y": 1})).is_err()); + assert!(s.execute("shape.movePoint", &json!({"index": 0, "x": f64::NAN, "y": 1})).is_err()); + s.execute("shape.deletePoint", &json!({"index": 0})).unwrap(); + s.execute("shape.deletePoint", &json!({"index": 0})).unwrap(); + assert!(s.execute("shape.deletePoint", &json!({"index": 0})).is_err()); + // Each edit is one undo step. + s.execute("edit.undo", &json!({})).unwrap(); + assert_eq!(pts(&mut s).len(), 3); + // It saves as custom geometry. + let bytes = cmd::file::save_bytes(&s.doc().unwrap().doc, "pptx").unwrap(); + s.execute("file.openBytes", &json!({"name": "e.pptx", "data": cmd::base64_encode(&bytes)})).unwrap(); + let back = s.doc().unwrap().doc.slides.iter().flat_map(|sl| sl.shapes.iter()).find(|sh| sh.id.0 == id as u32).map(|sh| sh.geom.clone()); + assert!(matches!(back, Some(Geom::Custom { .. })), "{back:?}"); +} + +#[test] +fn edit_points_with_the_pointer_and_curves() { + use crate::tools::{Mods, PointerEvent, PointerKind}; + let mut s = Session::with_new(); + s.execute("slide.new", &json!({"layout": "blank"})).unwrap(); + s.execute("shape.insert", &json!({"preset": "ellipse", "rect": [100, 100, 200, 100]})).unwrap(); + s.execute("shape.editPoints", &json!({"on": true})).unwrap(); + let pts = + |s: &mut Session| -> Vec<[f64; 2]> { serde_json::from_value(s.execute("shape.points", &json!({})).unwrap()["paths"][0].clone()).unwrap() }; + let v = pts(&mut s); + assert!(v.len() >= 4, "{v:?}"); + let ev = |kind, x: f64, y: f64, cmd: bool| PointerEvent { kind, x, y, mods: Mods { cmd, ..Default::default() }, ..Default::default() }; + // Drag the first vertex 30 pt left: one undo step. + let [x0, y0] = v[0]; + s.pointer(ev(PointerKind::Down, x0, y0, false)).unwrap(); + s.pointer(ev(PointerKind::Drag, x0 - 15.0, y0, false)).unwrap(); + s.pointer(ev(PointerKind::Drag, x0 - 30.0, y0, false)).unwrap(); + s.pointer(ev(PointerKind::Up, x0 - 30.0, y0, false)).unwrap(); + let w = pts(&mut s); + assert!((w[0][0] - (x0 - 30.0)).abs() < 1e-3 && (w[0][1] - y0).abs() < 1e-3, "{w:?}"); + assert!(w[1..].iter().zip(&v[1..]).all(|(a, b)| (a[0] - b[0]).abs() < 1e-3 && (a[1] - b[1]).abs() < 1e-3)); + s.execute("edit.undo", &json!({})).unwrap(); + assert!((pts(&mut s)[0][0] - x0).abs() < 1e-3); + // Ctrl-click a vertex deletes it. + let n = pts(&mut s).len(); + let [x1, y1] = pts(&mut s)[1]; + s.pointer(ev(PointerKind::Down, x1, y1, true)).unwrap(); + s.pointer(ev(PointerKind::Up, x1, y1, true)).unwrap(); + assert_eq!(pts(&mut s).len(), n - 1); + // Escape leaves Edit Points; then a click on empty slide doesn't touch the shape. + s.key("Escape", Mods::default()).unwrap(); + assert!(s.tool.edit_points.is_none()); +} + +#[test] +fn edit_points_path_parse_and_write() { + use crate::cmd::points::{Seg, parse, vertices, write}; + let segs = parse("M 0 0 L 10 0 C 10 5 5 10 0 10 Z junk L"); + // The closing edge is made explicit; its end is not a vertex of its own. + assert_eq!(segs.len(), 5); + assert!(matches!(segs[3], Seg::L(_))); + assert_eq!(vertices(&segs), vec![0, 1, 2]); + assert_eq!(write(&parse(&write(&segs))), write(&segs)); + assert!(parse("C 1 2 3").is_empty()); +} diff --git a/crates/engine/src/tools.rs b/crates/engine/src/tools.rs index bf8b5c31..892fe512 100644 --- a/crates/engine/src/tools.rs +++ b/crates/engine/src/tools.rs @@ -76,6 +76,12 @@ pub enum Hit { id: ShapeId, index: usize, }, + /// A vertex of a shape whose points are being edited. + Vertex { + id: ShapeId, + path: usize, + index: usize, + }, /// Line end 0/1 of a line-like shape. LineEnd { id: ShapeId, @@ -119,6 +125,11 @@ enum Drag { index: usize, orig: Xfrm, }, + Vertex { + id: ShapeId, + path: usize, + index: usize, + }, Marquee { start: Point, add: bool, @@ -178,6 +189,8 @@ pub struct ToolState { pub path_preview: Vec, pub path_smooth: bool, pub path_closing: bool, + /// The shape in Edit Points: its vertices are handles instead of the box's. + pub edit_points: Option, } impl ToolState { @@ -185,6 +198,11 @@ impl ToolState { self.drag.is_some() } + /// The shape in Edit Points, while it is still the one selected shape. + pub fn editing_points(&self, st: &crate::DocState) -> Option { + self.edit_points.filter(|id| st.selection.shapes.as_slice() == [*id] && st.selection.text.is_none() && st.shape(*id).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 { .. })) @@ -215,6 +233,13 @@ impl Session { /// What's under `p` on the current slide. pub fn hit_test(&self, p: Point, tol: f64) -> Option { let st = self.active()?; + // In Edit Points, the edited shape's vertices come first. + if let Some(id) = self.tool.editing_points(st) + && let Some(sh) = st.shape(id) + && let Some((path, index)) = cmd::points::vertex_at(&st.doc, &st.selection, sh, p, tol * 1.6) + { + return Some(Hit::Vertex { id, path, index }); + } // Handles of selected shapes first. if st.selection.text.is_none() || st.selection.shapes.len() == 1 { for id in st.selection.shapes.iter().rev() { @@ -387,6 +412,28 @@ impl Session { } } let hit = self.hit_test(p, tol); + // Edit Points: drag a vertex, Ctrl-click one to delete it or the outline to add one; + // a click anywhere else leaves Edit Points. + if let Some(eid) = self.doc().ok().and_then(|st| self.tool.editing_points(st)) { + if let Some(Hit::Vertex { id, path, index }) = hit { + if mods.cmd { + return self.execute("shape.deletePoint", &json!({"id": id, "path": path, "index": index})); + } + self.begin("Edit Points"); + self.tool.drag = Some(Drag::Vertex { id, path, index }); + return Ok(json!({"vertex": [path, index]})); + } + let st = self.doc()?; + if let Some(sh) = st.shape(eid) + && let Some((path, seg, t)) = cmd::points::segment_at(&st.doc, &st.selection, sh, p, tol * 1.6) + { + if mods.cmd { + return self.execute("shape.addPoint", &json!({"id": eid, "path": path, "segment": seg, "t": t})); + } + return Ok(Value::Null); + } + self.tool.edit_points = None; + } let st = self.doc()?; match hit { Some(Hit::Resize { id, handle }) => { @@ -407,6 +454,8 @@ impl Session { self.begin("Rotate"); self.tool.drag = Some(Drag::Rotate { ids, center, start, origs }); } + // Only reached outside Edit Points (handled above). + Some(Hit::Vertex { .. }) => {} Some(Hit::Adjust { id, index }) => { let orig = st.shape(id).map(|s| xfrm_of(&st.doc, &st.selection, s)).unwrap_or_default(); self.begin("Adjust Shape"); @@ -601,6 +650,11 @@ impl Session { Ok(()) })?; } + Drag::Vertex { id, path, index } => { + self.tool.drag = Some(Drag::Vertex { id, path, index }); + self.edit_vertex(id, path, index, p)?; + return Ok(Value::Null); + } Drag::Marquee { start, .. } => { self.tool.marquee = Some(Rect::from_points(start, p)); } @@ -661,6 +715,15 @@ impl Session { Ok(Value::Null) } + /// Moves a vertex during a drag (one undo step for the whole drag). + fn edit_vertex(&mut self, id: ShapeId, path: usize, index: usize, p: Point) -> Result<()> { + let r = cmd::points::move_point_raw(self, id, path, index, p); + if let Err(e) = r { + log::debug!("edit point: {e}"); + } + Ok(()) + } + fn up(&mut self, p: Point, mods: Mods) -> Result { let drag = self.tool.drag.take(); self.tool.guides.clear(); diff --git a/crates/ui-egui/src/canvas.rs b/crates/ui-egui/src/canvas.rs index 253765bf..4dc7b109 100644 --- a/crates/ui-egui/src/canvas.rs +++ b/crates/ui-egui/src/canvas.rs @@ -468,6 +468,20 @@ fn overlays(app: &mut SlideApp, ui: &Ui, painter: &egui::Painter, xf: Xf, t: &To } continue; } + if app.session.tool.editing_points(st) == Some(sh.id) { + // Edit Points: the outline's vertices are the handles. + for q in deckcraft_engine::cmd::points::slide_vertices(&st.doc, &st.selection, sh).into_iter().flatten() { + let c = xf.to_screen(q); + painter.rect( + egui::Rect::from_center_size(c, egui::vec2(7.0, 7.0)), + 0.0, + Color32::BLACK, + Stroke::new(1.0, Color32::WHITE), + egui::StrokeKind::Middle, + ); + } + continue; + } if st.selection.shapes.len() == 1 || !editing_this { let (hs, rot) = box_handles(&x); let rot_s = xf.to_screen(rot); diff --git a/crates/ui-egui/src/ribbon.rs b/crates/ui-egui/src/ribbon.rs index 428eff0b..3106f473 100644 --- a/crates/ui-egui/src/ribbon.rs +++ b/crates/ui-egui/src/ribbon.rs @@ -1891,6 +1891,11 @@ fn shape_format(app: &mut SlideApp, ui: &mut Ui) { if let Some(p) = shapes_gallery(ui) { run(app, "shape.change", json!({"preset": p})); } + ui.separator(); + if ui.add_enabled(enabled(app, "shape.editPoints"), egui::Button::new("Edit Points")).clicked() { + run(app, "shape.editPoints", json!({"on": true})); + ui.close(); + } }); let can_merge = enabled(app, "shape.merge"); let r = big_button(ui, Icon::MergeShapes, "Merge\nShapes", can_merge).on_hover_text("Merge Shapes"); diff --git a/docs/gaps.md b/docs/gaps.md index a2894b42..d0375b07 100644 --- a/docs/gaps.md +++ b/docs/gaps.md @@ -165,13 +165,13 @@ Detail in [animation-parity.md](animation-parity.md). 10–16 h; the pane is a * - **Estimate:** 4–6 h. ### Edit points, text warp, 3-D -- **Evidence:** checklist rows "Edit points" (P1 missing), "Text warp transforms" (partial: all 40 +- **Evidence:** checklist rows "Edit points" (P1 partial: Edit Points converts a shape to a freeform, vertices drag, Ctrl/Cmd-click adds or deletes; no point types or Bézier control handles), "Text warp transforms" (partial: all 40 `prstTxWarp` presets render and their adjusts round-trip, but there is no Transform gallery or command to apply one, no adjust handles, and arc/circle placement can differ from PowerPoint by 1–3 % of the path length), "Bevel and 3-D rotation" (partial: bevels, camera presets and rotations and face lighting render as approximations and `scene3d`/`sp3d` round-trip as raw XML; no extrusion or side faces, only the three-point rig, and no command or pane to apply 3-D). -- **Estimate:** 4–6 h (edit points), 2–4 h (warp gallery, command and handles), 6–10 h (3-D extrusion, +- **Estimate:** 2–3 h (edit points: point types, control handles), 2–4 h (warp gallery, command and handles), 6–10 h (3-D extrusion, rigs and materials, Format Shape page). ### Proofing: spelling, thesaurus, AutoCorrect @@ -256,6 +256,7 @@ Detail in [localization-parity.md](localization-parity.md). | Date | Change | Summary | |---|---|---| +| 2026-10-13 | minor | Edit Points: convert to freeform, drag, add and delete vertices (`shape.editPoints`, `shape.movePoint`, `shape.addPoint`, `shape.deletePoint`); left: point types and control handles | | 2026-10-13 | minor | Shape, picture and table rendering checked against PowerPoint on ~150 synthetic edge-case slides; 3-D bevels, cameras and face lighting now render approximately (gap narrowed to extrusion, rigs and the 3-D pane) | | 2026-10-12 | minor | IME composition in slide text and notes, and CJK interface text with real glyphs (#101): the IME gap is now Korean on the web and table-cell underlines | | 2026-10-12 | minor | EMF/WMF text, pattern and hatch brushes drawn; legacy OLE and ActiveX previews shown; the SVG gap keeps only EMF clip regions | diff --git a/docs/parity-checklist.md b/docs/parity-checklist.md index 2a3d450c..4c5f1793 100644 --- a/docs/parity-checklist.md +++ b/docs/parity-checklist.md @@ -110,7 +110,7 @@ Open P0 items: Contextual tabs (Shape Format, Picture Format, Table Design/Layou | Shapes | Connectors (straight, elbow, curved) | P0 | D | | Shapes | Connector glue to connection sites | P1 | D | | Shapes | Freeform, curve and scribble tools | P1 | D | -| Shapes | Edit points | P1 | M | +| Shapes | Edit points | P1 | P | | Shapes | Merge shapes (union, combine, fragment, intersect, subtract) | P1 | D | | Shapes | Change shape | P1 | D | | Shapes | Text boxes | P0 | D | diff --git a/docs/ui-parity.md b/docs/ui-parity.md index c403f05e..a19b6c10 100644 --- a/docs/ui-parity.md +++ b/docs/ui-parity.md @@ -78,7 +78,7 @@ menu colours taken from the theme tokens (menu `chrome`, page `card`). | Nudge (arrows), fine nudge (Option+arrows) | yes | yes | | Shift-constrained move, Option-drag to duplicate | yes | yes | | Connector glue and rerouting | yes | yes | -| Edit points on freeforms | yes | **no** | +| Edit points on freeforms | yes | partial: Edit Shape ▸ Edit Points turns any shape into a freeform; drag vertices, Ctrl/Cmd-click to add or delete; no smooth/straight/corner point types or Bézier handles yet | | Crop handles, crop to shape, aspect ratio | yes | partial | | Text caret, selection, double/triple click, drag-select | yes | yes; Tab in text boxes ends editing (#78) | | Exact size/position, lock aspect | yes | yes |