diff --git a/crates/engine/src/cmd/mod.rs b/crates/engine/src/cmd/mod.rs index 8d93254f..80262779 100644 --- a/crates/engine/src/cmd/mod.rs +++ b/crates/engine/src/cmd/mod.rs @@ -11,6 +11,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; @@ -134,6 +135,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 03c9e937..aa8efa2b 100644 --- a/crates/engine/src/tests.rs +++ b/crates/engine/src/tests.rs @@ -1326,3 +1326,102 @@ fn text_transparency_keeps_the_colour_and_sets_alpha() { assert!((color.alpha() - 0.5).abs() < 1e-9); assert!(s.execute("format.textTransparency", &json!({})).is_err()); } + +#[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 d86b8798..947fda7f 100644 --- a/crates/engine/src/tools.rs +++ b/crates/engine/src/tools.rs @@ -79,6 +79,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, @@ -122,6 +128,11 @@ enum Drag { index: usize, orig: Xfrm, }, + Vertex { + id: ShapeId, + path: usize, + index: usize, + }, Marquee { start: Point, add: bool, @@ -181,6 +192,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 { @@ -188,6 +201,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 { .. })) @@ -218,6 +236,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() { @@ -391,6 +416,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 }) => { @@ -411,6 +458,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"); @@ -605,6 +654,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)); } @@ -665,6 +719,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 70925171..8fe03dc5 100644 --- a/crates/ui-egui/src/canvas.rs +++ b/crates/ui-egui/src/canvas.rs @@ -490,6 +490,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 c5b2c7c9..f9d8dad9 100644 --- a/crates/ui-egui/src/ribbon.rs +++ b/crates/ui-egui/src/ribbon.rs @@ -2023,6 +2023,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 cbe41489..9d762483 100644 --- a/docs/gaps.md +++ b/docs/gaps.md @@ -203,6 +203,13 @@ Detail in [animation-parity.md](animation-parity.md). 10–16 h; the pane is a * `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), 1–2 h (warp adjust handles), 6–10 h (3-D extrusion, +- **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:** 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 @@ -290,6 +297,7 @@ Detail in [localization-parity.md](localization-parity.md). | 2026-10-13 | minor | EMF/WMF rectangle clip regions (`INTERSECTCLIPRECT`, `EXCLUDECLIPRECT`, `EXTSELECTCLIPRGN`) limit later drawing. EMF+ records remain | | 2026-10-13 | minor | Embedded fonts are read, written and drawn (TrueType, OpenType, Embedded OpenType). The gap is now MicroType Express subsets, which are kept but not drawn | | 2026-10-13 | minor | WordArt Transform gallery: Shape Format ▸ Text Effects lists the 40 presets and No Transform; `format.textWarp` applies one (adjust handles still open) | +| 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-13 | minor | Interactive decks: triggers, return-to-slide state and click rules landed in the slide show; the remaining pieces are one new entry under Features | | 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 | diff --git a/docs/parity-checklist.md b/docs/parity-checklist.md index 5906ee5a..747242f9 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 54203f32..c1a0d435 100644 --- a/docs/ui-parity.md +++ b/docs/ui-parity.md @@ -83,7 +83,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 |