Merge PR #514 from NikitkaRa: Shapes: Edit Points — drag, add and delete a shape's vertices

# Conflicts:
#	crates/engine/src/tests.rs
This commit is contained in:
Brandon Thomas
2026-10-14 13:08:48 -04:00
10 changed files with 756 additions and 2 deletions
+2
View File
@@ -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());
+559
View File
@@ -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<CommandSpec> {
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<Point> {
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<Seg> {
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::<f64>().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::<Vec<_>>()
.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<usize> {
(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<Point> {
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<Seg>, 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<Seg>, 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<CustomPath> {
let geo = deckcraft_render::shape_geometry(sh, w, h);
geo.paths
.iter()
.map(|sp| {
let segs: Vec<Seg> = 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<Vec<Seg>> = paths.iter().map(|p| parse(&p.d)).collect();
let mut bb: Option<deckcraft_geom::Rect> = 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<ShapeId> {
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<Value> {
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<Vec<Point>> {
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<Value> {
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<Vec<[f64; 2]>> =
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<Seg>, &[usize], &Xfrm) -> Result<bool>,
) -> Result<Value> {
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<usize> {
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<Value> {
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<Value> {
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<Value> {
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<Point> {
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<Value> {
move_point(s, &json!({"id": id.0, "path": path, "index": index, "x": to.x, "y": to.y}))
}
+4
View File
@@ -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, &params)?;
return Ok(json!({"handled": true, "command": id, "result": r}));
+99
View File
@@ -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());
}
+63
View File
@@ -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<Point>,
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<ShapeId>,
}
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<ShapeId> {
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<Hit> {
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<Value> {
let drag = self.tool.drag.take();
self.tool.guides.clear();
+14
View File
@@ -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);
+5
View File
@@ -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");
+8
View File
@@ -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 |
+1 -1
View File
@@ -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 |
+1 -1
View File
@@ -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 |