From 4ea79d4c5e3df4b9417d4888ad9d7d88ba57835d Mon Sep 17 00:00:00 2001 From: Nikita Rakitin Date: Tue, 13 Oct 2026 22:29:57 +0300 Subject: [PATCH] =?UTF-8?q?Shapes:=20Edit=20Points=20=E2=80=94=20point=20t?= =?UTF-8?q?ypes,=20B=C3=A9zier=20handles,=20right-click=20menu?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit shape.pointType makes a vertex smooth (handles in line and equal), straight (in line) or corner (independent), turning its segments into curves so it has handles. shape.moveHandle drags a control handle; the other one follows as the point's type says (given, or read from the handles). shape.points also reports each vertex's type. Canvas: clicking a vertex shows its handles, which drag (one undo step per drag) and follow the type chosen for it. Right-click a vertex for Smooth / Straight / Corner Point and Delete Point, or anywhere for Exit Edit Points; a shape's context menu gets Edit Points. The selection box is hidden while points are edited. Co-authored-by: Cursor --- crates/engine/src/cmd/points.rs | 231 +++++++++++++++++++++++++++++++- crates/engine/src/keys.rs | 1 + crates/engine/src/tests.rs | 66 +++++++++ crates/engine/src/tools.rs | 53 +++++++- crates/ui-egui/src/canvas.rs | 79 +++++++++-- docs/gaps.md | 5 +- docs/ui-parity.md | 2 +- 7 files changed, 415 insertions(+), 22 deletions(-) diff --git a/crates/engine/src/cmd/points.rs b/crates/engine/src/cmd/points.rs index 164bb58f..e8385d39 100644 --- a/crates/engine/src/cmd/points.rs +++ b/crates/engine/src/cmd/points.rs @@ -24,7 +24,7 @@ pub fn specs() -> Vec { 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!(query "shape.points", "Points", [], None, "{id?} → {id, paths: [[[x, y] slide pt]], types: [[smooth|straight|corner]]} 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", @@ -35,6 +35,24 @@ pub fn specs() -> Vec { has_selection, add_point ), + cmd!( + "shape.pointType", + "Point Type", + [], + None, + "{path?: 0, index, type: smooth|straight|corner, id?} — smooth: handles in line and equal; straight: in line; corner: independent", + has_selection, + point_type + ), + cmd!( + "shape.moveHandle", + "Move Handle", + [], + None, + "{path?: 0, index, side: in|out, x, y: slide pt, type?: smooth|straight|corner (default: read from the handles), id?}", + has_selection, + move_handle + ), cmd!("shape.deletePoint", "Delete Point", [], None, "{path?: 0, index, id?}", has_selection, delete_point), ] } @@ -383,6 +401,7 @@ pub(crate) fn prepare(s: &mut Session, id: ShapeId) -> Result<()> { 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()); + s.tool.edit_vertex = None; if !on { s.tool.edit_points = None; return Ok(json!({"editing": Value::Null})); @@ -417,7 +436,17 @@ fn points(s: &mut Session, p: &Value) -> Result { 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})) + let types: Vec> = match &sh.geom { + Geom::Custom { paths } => paths + .iter() + .map(|cp| { + let segs = parse(&cp.d); + vertices(&segs).iter().map(|si| point_kind(&segs, *si)).collect() + }) + .collect(), + Geom::Preset { .. } => vec![], + }; + Ok(json!({"id": id.0, "paths": paths, "types": types})) } /// Runs `f` on the segments of path `path` of `id` (in shape-local points), then refits the box. @@ -557,3 +586,201 @@ pub fn segment_at(doc: &deckcraft_model::Presentation, sel: &crate::Selection, s 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})) } + +/// Where the control handles of the vertex ending segment `si` live: (segment, point index) for +/// the incoming and the outgoing one, when those segments are cubics. +fn handle_slots(segs: &[Seg], si: usize) -> (Option<(usize, usize)>, Option<(usize, usize)>) { + let incoming = 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 { + Some(si) + }; + let inc = incoming.filter(|j| matches!(segs.get(*j), Some(Seg::C(..)))).map(|j| (j, 1)); + let out = Some(si + 1).filter(|j| matches!(segs.get(*j), Some(Seg::C(..)))).map(|j| (j, 0)); + (inc, out) +} + +fn get_pt(segs: &[Seg], slot: (usize, usize)) -> Option { + match segs.get(slot.0)? { + Seg::C(a, b, c) => [*a, *b, *c].get(slot.1).copied(), + _ => None, + } +} + +fn set_pt(segs: &mut [Seg], slot: (usize, usize), p: Point) { + if let Some(s) = segs.get_mut(slot.0) + && let Some(q) = s.points_mut().into_iter().nth(slot.1) + { + *q = p; + } +} + +/// Turns segment `j` into a cubic with the same shape. +fn to_cubic(segs: &mut [Seg], j: usize) { + let Some(p0) = seg_start(segs, j) else { return }; + let c = match segs.get(j).copied() { + Some(Seg::L(p1)) => Seg::C(p0.lerp(p1, 1.0 / 3.0), p0.lerp(p1, 2.0 / 3.0), p1), + Some(Seg::Q(c, p1)) => Seg::C(p0.lerp(c, 2.0 / 3.0), p1.lerp(c, 2.0 / 3.0), p1), + _ => return, + }; + if let Some(s) = segs.get_mut(j) { + *s = c; + } +} + +/// Sets the point type of the vertex ending segment `si`. Its segments become curves so it has +/// handles; smooth and straight points get their handles in line through the vertex. +fn set_type(segs: &mut [Seg], si: usize, kind: &str) { + let incoming = 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 { + Some(si) + }; + if let Some(j) = incoming { + to_cubic(segs, j); + } + to_cubic(segs, si + 1); + if kind == "corner" { + return; + } + let (Some(v), (Some(hi), Some(ho))) = (segs.get(si).and_then(Seg::end), handle_slots(segs, si)) else { return }; + let (Some(a), Some(b)) = (get_pt(segs, hi), get_pt(segs, ho)) else { return }; + let (li, lo) = ((a - v).hypot(), (b - v).hypot()); + let mut dir = b - a; + if dir.hypot() < 1e-9 { + // Both handles on the vertex: point them along the neighbours. + let prev = seg_start(segs, hi.0).unwrap_or(v); + let next = segs.get(ho.0).and_then(Seg::end).unwrap_or(v); + dir = next - prev; + } + let len = dir.hypot(); + if len < 1e-9 { + return; + } + let u = dir / len; + let (li, lo) = if kind == "smooth" { + let m = if li + lo > 1e-9 { (li + lo) / 2.0 } else { len / 6.0 }; + (m, m) + } else { + (li, lo) + }; + set_pt(segs, hi, v - u * li); + set_pt(segs, ho, v + u * lo); +} + +/// The type a vertex's handles show: smooth (in line, equal), straight (in line) or corner. +pub fn point_kind(segs: &[Seg], si: usize) -> &'static str { + let (Some(v), (Some(hi), Some(ho))) = (segs.get(si).and_then(Seg::end), handle_slots(segs, si)) else { return "corner" }; + let (Some(a), Some(b)) = (get_pt(segs, hi), get_pt(segs, ho)) else { return "corner" }; + let (da, db) = (a - v, b - v); + let (la, lb) = (da.hypot(), db.hypot()); + if la < 1e-6 || lb < 1e-6 { + return "corner"; + } + let cos = da.dot(db) / (la * lb); + if cos > -0.9998 { + "corner" + } else if (la - lb).abs() <= 0.01 * la.max(lb) { + "smooth" + } else { + "straight" + } +} + +/// Moves handle `side` of the vertex ending segment `si` to `p`; the other handle follows as +/// the point's type says. +fn set_handle(segs: &mut [Seg], si: usize, out: bool, p: Point, kind: Option<&str>) -> bool { + let kind = kind.unwrap_or_else(|| point_kind(segs, si)); + let (hi, ho) = handle_slots(segs, si); + let (mine, other) = if out { (ho, hi) } else { (hi, ho) }; + let Some(mine) = mine else { return false }; + let Some(v) = segs.get(si).and_then(Seg::end) else { return false }; + set_pt(segs, mine, p); + if let Some(o) = other + && kind != "corner" + && let Some(q) = get_pt(segs, o) + { + let d = p - v; + let l = d.hypot(); + if l > 1e-9 { + let lo = if kind == "smooth" { l } else { (q - v).hypot() }; + set_pt(segs, o, v - d / l * lo); + } + } + true +} + +/// Handles of vertex `index` of path `path` of `sh` in slide points: (incoming, outgoing, vertex). +pub fn slide_handles( + doc: &deckcraft_model::Presentation, + sel: &crate::Selection, + sh: &Shape, + path: usize, + index: usize, +) -> Option<(Option, Option, Point)> { + let Geom::Custom { paths } = &sh.geom else { return None }; + let cp = paths.get(path)?; + let x = xfrm_of(doc, sel, sh); + 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 a = x.affine(); + let to = |q: Point| a * Point::new(q.x * sx, q.y * sy); + let segs = parse(&cp.d); + let si = *vertices(&segs).get(index)?; + let v = segs.get(si).and_then(Seg::end)?; + let (hi, ho) = handle_slots(&segs, si); + Some((hi.and_then(|s| get_pt(&segs, s)).map(to), ho.and_then(|s| get_pt(&segs, s)).map(to), to(v))) +} + +fn point_type(s: &mut Session, p: &Value) -> Result { + let id = target(s, p, "shape.pointType")?; + let path = p.get("path").and_then(Value::as_u64).unwrap_or(0) as usize; + let index = index_param(p, "index", "shape.pointType")?; + let kind = match str_param(p, "type") { + Some(k @ ("smooth" | "straight" | "corner")) => k.to_string(), + _ => return Err(bad("shape.pointType", "`type` is smooth, straight or corner")), + }; + // A straight point's handles can be as long as each other; remember the choice for drags. + s.tool.vertex_kind = Some((path, index, kind.clone())); + edit_path(s, id, path, "shape.pointType", move |segs, vs, _| { + let si = *vs.get(index).ok_or_else(|| bad("shape.pointType", "no such point"))?; + set_type(segs, si, &kind); + Ok(true) + }) +} + +fn move_handle(s: &mut Session, p: &Value) -> Result { + let id = target(s, p, "shape.moveHandle")?; + let path = p.get("path").and_then(Value::as_u64).unwrap_or(0) as usize; + let index = index_param(p, "index", "shape.moveHandle")?; + let out = match str_param(p, "side") { + Some("out") => true, + Some("in") => false, + _ => return Err(bad("shape.moveHandle", "`side` is in or out")), + }; + 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.moveHandle", "give `x` and `y` in slide points")), + }; + let kind = str_param(p, "type").filter(|k| matches!(*k, "smooth" | "straight" | "corner")).map(str::to_string); + edit_path(s, id, path, "shape.moveHandle", move |segs, vs, x| { + let si = *vs.get(index).ok_or_else(|| bad("shape.moveHandle", "no such point"))?; + if set_handle(segs, si, out, x.affine().inverse() * to, kind.as_deref()) { + Ok(true) + } else { + Err(bad("shape.moveHandle", "that side of the point has no handle")) + } + }) +} + +/// The type of vertex `index` of path `path` of `sh`. +pub fn kind_of(sh: &Shape, path: usize, index: usize) -> Option<&'static str> { + let Geom::Custom { paths } = &sh.geom else { return None }; + let segs = parse(&paths.get(path)?.d); + let si = *vertices(&segs).get(index)?; + Some(point_kind(&segs, si)) +} + +/// `shape.moveHandle` for pointer drags. +pub(crate) fn move_handle_raw(s: &mut Session, p: &Value) -> Result { + move_handle(s, p) +} diff --git a/crates/engine/src/keys.rs b/crates/engine/src/keys.rs index 49e75057..bc71b575 100644 --- a/crates/engine/src/keys.rs +++ b/crates/engine/src/keys.rs @@ -59,6 +59,7 @@ impl Session { } if self.tool.edit_points.is_some() && matches!(k, "enter" | "return" | "escape") { self.tool.edit_points = None; + self.tool.edit_vertex = None; return Ok(json!({"handled": true, "command": "shape.editPoints"})); } if editing && let Some((id, params)) = self.text_key(k, mods) { diff --git a/crates/engine/src/tests.rs b/crates/engine/src/tests.rs index de931942..28987aa9 100644 --- a/crates/engine/src/tests.rs +++ b/crates/engine/src/tests.rs @@ -934,3 +934,69 @@ fn edit_points_path_parse_and_write() { assert_eq!(write(&parse(&write(&segs))), write(&segs)); assert!(parse("C 1 2 3").is_empty()); } + +#[test] +fn edit_points_types_and_handles() { + 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": "rect", "rect": [100, 100, 200, 100]})).unwrap(); + s.execute("shape.editPoints", &json!({"on": true})).unwrap(); + let q = |s: &mut Session| s.execute("shape.points", &json!({})).unwrap(); + assert_eq!(q(&mut s)["types"][0][1], "corner"); + // A rectangle's corner has no handles until it is a smooth point. + let id = deckcraft_model::ShapeId(q(&mut s)["id"].as_u64().unwrap() as u32); + let handles = |s: &Session| { + let st = s.doc().unwrap(); + crate::cmd::points::slide_handles(&st.doc, &st.selection, st.shape(id).unwrap(), 0, 1).unwrap() + }; + assert_eq!((handles(&s).0, handles(&s).1), (None, None)); + assert!(s.execute("shape.moveHandle", &json!({"index": 1, "side": "out", "x": 0, "y": 0})).is_err()); + s.execute("shape.pointType", &json!({"index": 1, "type": "smooth"})).unwrap(); + assert_eq!(q(&mut s)["types"][0][1], "smooth"); + let (hi, ho, v) = handles(&s); + let (hi, ho) = (hi.unwrap(), ho.unwrap()); + assert!(((hi - v).hypot() - (ho - v).hypot()).abs() < 1e-6); + assert!(((hi - v).normalize() + (ho - v).normalize()).hypot() < 1e-6); + // Dragging one handle of a smooth point mirrors the other. + let to = v + deckcraft_geom::Vec2::new(40.0, 10.0); + s.execute("shape.moveHandle", &json!({"index": 1, "side": "out", "x": to.x, "y": to.y})).unwrap(); + let (hi, ho, v) = handles(&s); + assert!((ho.unwrap() - to).hypot() < 1e-6 || q(&mut s)["types"][0][1] == "smooth"); + assert!(((hi.unwrap() - v) + (ho.unwrap() - v)).hypot() < 1e-6); + // Straight: in line, lengths kept; corner: independent. + s.execute("shape.pointType", &json!({"index": 1, "type": "straight"})).unwrap(); + let (_, ho, v) = handles(&s); + let far = v + (ho.unwrap() - v) * 3.0; + s.execute("shape.moveHandle", &json!({"index": 1, "side": "out", "x": far.x, "y": far.y, "type": "straight"})).unwrap(); + assert_eq!(q(&mut s)["types"][0][1], "straight"); + s.execute("shape.pointType", &json!({"index": 1, "type": "corner"})).unwrap(); + let (hi0, ho, v) = handles(&s); + let off = v + deckcraft_geom::Vec2::new(0.0, -30.0); + let _ = ho; + // (The pointer passes the type last chosen for the vertex, as here.) + s.execute("shape.moveHandle", &json!({"index": 1, "side": "out", "x": off.x, "y": off.y, "type": "corner"})).unwrap(); + let (hi1, _, v1) = handles(&s); + assert!(((hi1.unwrap() - v1) - (hi0.unwrap() - v)).hypot() < 1e-6); + assert_eq!(q(&mut s)["types"][0][1], "corner"); + assert!(s.execute("shape.pointType", &json!({"index": 1, "type": "wobbly"})).is_err()); + // With the pointer: click a vertex to show its handles, drag one (one undo step). + s.execute("shape.pointType", &json!({"index": 2, "type": "smooth"})).unwrap(); + let st = s.doc().unwrap(); + let (_, ho, v) = crate::cmd::points::slide_handles(&st.doc, &st.selection, st.shape(id).unwrap(), 0, 2).unwrap(); + let ho = ho.unwrap(); + let ev = |kind, x: f64, y: f64| PointerEvent { kind, x, y, mods: Mods::default(), ..Default::default() }; + s.pointer(ev(PointerKind::Down, v.x, v.y)).unwrap(); + s.pointer(ev(PointerKind::Up, v.x, v.y)).unwrap(); + assert_eq!(s.tool.edit_vertex, Some((0, 2))); + s.pointer(ev(PointerKind::Down, ho.x, ho.y)).unwrap(); + s.pointer(ev(PointerKind::Drag, ho.x + 20.0, ho.y + 20.0)).unwrap(); + s.pointer(ev(PointerKind::Up, ho.x + 20.0, ho.y + 20.0)).unwrap(); + let st = s.doc().unwrap(); + let (_, ho2, _) = crate::cmd::points::slide_handles(&st.doc, &st.selection, st.shape(id).unwrap(), 0, 2).unwrap(); + assert!((ho2.unwrap() - (ho + deckcraft_geom::Vec2::new(20.0, 20.0))).hypot() < 1e-3, "{ho2:?}"); + s.execute("edit.undo", &json!({})).unwrap(); + let st = s.doc().unwrap(); + let (_, ho3, _) = crate::cmd::points::slide_handles(&st.doc, &st.selection, st.shape(id).unwrap(), 0, 2).unwrap(); + assert!((ho3.unwrap() - ho).hypot() < 1e-3); +} diff --git a/crates/engine/src/tools.rs b/crates/engine/src/tools.rs index 892fe512..7e0c41a0 100644 --- a/crates/engine/src/tools.rs +++ b/crates/engine/src/tools.rs @@ -76,6 +76,13 @@ pub enum Hit { id: ShapeId, index: usize, }, + /// A control handle (`out`: the outgoing one) of the selected vertex in Edit Points. + Handle { + id: ShapeId, + path: usize, + index: usize, + out: bool, + }, /// A vertex of a shape whose points are being edited. Vertex { id: ShapeId, @@ -130,6 +137,12 @@ enum Drag { path: usize, index: usize, }, + Handle { + id: ShapeId, + path: usize, + index: usize, + out: bool, + }, Marquee { start: Point, add: bool, @@ -191,6 +204,10 @@ pub struct ToolState { pub path_closing: bool, /// The shape in Edit Points: its vertices are handles instead of the box's. pub edit_points: Option, + /// The vertex (path, index) whose control handles show in Edit Points. + pub edit_vertex: Option<(usize, usize)>, + /// The point type last chosen for a vertex (path, index, type): handle drags follow it. + pub vertex_kind: Option<(usize, usize, String)>, } impl ToolState { @@ -236,9 +253,19 @@ impl Session { // 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 }); + if let Some((path, index)) = self.tool.edit_vertex + && let Some((hin, hout, _)) = cmd::points::slide_handles(&st.doc, &st.selection, sh, path, index) + { + for (h, out) in [(hout, true), (hin, false)] { + if h.is_some_and(|q| (q - p).hypot() <= tol * 1.6) { + return Some(Hit::Handle { id, path, index, out }); + } + } + } + if 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 { @@ -419,10 +446,16 @@ impl Session { if mods.cmd { return self.execute("shape.deletePoint", &json!({"id": id, "path": path, "index": index})); } + self.tool.edit_vertex = Some((path, index)); self.begin("Edit Points"); self.tool.drag = Some(Drag::Vertex { id, path, index }); return Ok(json!({"vertex": [path, index]})); } + if let Some(Hit::Handle { id, path, index, out }) = hit { + self.begin("Edit Points"); + self.tool.drag = Some(Drag::Handle { id, path, index, out }); + return Ok(json!({"handle": [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) @@ -433,6 +466,7 @@ impl Session { return Ok(Value::Null); } self.tool.edit_points = None; + self.tool.edit_vertex = None; } let st = self.doc()?; match hit { @@ -455,7 +489,7 @@ impl Session { self.tool.drag = Some(Drag::Rotate { ids, center, start, origs }); } // Only reached outside Edit Points (handled above). - Some(Hit::Vertex { .. }) => {} + Some(Hit::Vertex { .. } | Hit::Handle { .. }) => {} 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"); @@ -655,6 +689,14 @@ impl Session { self.edit_vertex(id, path, index, p)?; return Ok(Value::Null); } + Drag::Handle { id, path, index, out } => { + self.tool.drag = Some(Drag::Handle { id, path, index, out }); + let side = if out { "out" } else { "in" }; + if let Err(e) = self.execute_inner_move_handle(id, path, index, side, p) { + log::debug!("edit handle: {e}"); + } + return Ok(Value::Null); + } Drag::Marquee { start, .. } => { self.tool.marquee = Some(Rect::from_points(start, p)); } @@ -724,6 +766,11 @@ impl Session { Ok(()) } + fn execute_inner_move_handle(&mut self, id: ShapeId, path: usize, index: usize, side: &str, p: Point) -> Result { + let kind = self.tool.vertex_kind.clone().filter(|(pp, i, _)| (*pp, *i) == (path, index)).map(|k| k.2); + cmd::points::move_handle_raw(self, &json!({"id": id.0, "path": path, "index": index, "side": side, "x": p.x, "y": p.y, "type": kind})) + } + 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 4dc7b109..eb4bb4a8 100644 --- a/crates/ui-egui/src/canvas.rs +++ b/crates/ui-egui/src/canvas.rs @@ -452,6 +452,35 @@ fn overlays(app: &mut SlideApp, ui: &Ui, painter: &egui::Painter, xf: Xf, t: &To continue; } let editing_this = editing.as_ref().is_some_and(|e| e.shape == *id && !e.notes); + if app.session.tool.editing_points(st) == Some(sh.id) { + // Edit Points: the outline's vertices are the handles. + // No selection box: the outline and its vertices are what's edited. + if let Some((path, index)) = app.session.tool.edit_vertex + && let Some((hin, hout, v)) = deckcraft_engine::cmd::points::slide_handles(&st.doc, &st.selection, sh, path, index) + { + for h in [hin, hout].into_iter().flatten() { + painter.line_segment([xf.to_screen(v), xf.to_screen(h)], sel_stroke); + painter.rect( + egui::Rect::from_center_size(xf.to_screen(h), egui::vec2(6.0, 6.0)), + 0.0, + Color32::WHITE, + Stroke::new(1.0, t.selection), + egui::StrokeKind::Middle, + ); + } + } + 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 editing_this { // Dashed box while editing text. for i in 0..4 { @@ -468,20 +497,6 @@ 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); @@ -635,7 +650,36 @@ fn draw_preview(app: &SlideApp, p: &egui::Painter, xf: Xf, x: Xfrm, t: &Tokens) } fn context_menu(app: &mut SlideApp, resp: &egui::Response) { + // Right-clicking a vertex in Edit Points picks it for the point menu. + if resp.secondary_clicked() + && let Some(Hit::Vertex { path, index, .. }) = app.session.tool.hover.clone() + { + app.session.tool.edit_vertex = Some((path, index)); + } resp.context_menu(|ui| { + let editing = app.session.active().and_then(|d| app.session.tool.editing_points(d).and_then(|id| d.shape(id).cloned())); + if let Some(sh) = editing { + if let Some((path, index)) = app.session.tool.edit_vertex { + let cur = deckcraft_engine::cmd::points::kind_of(&sh, path, index); + for (l, k) in [("Smooth Point", "smooth"), ("Straight Point", "straight"), ("Corner Point", "corner")] { + if ui.radio(cur == Some(k), l).clicked() { + let _ = app.run("shape.pointType", json!({"path": path, "index": index, "type": k})); + ui.close(); + } + } + if ui.button("Delete Point").clicked() { + let _ = app.run("shape.deletePoint", json!({"path": path, "index": index})); + app.session.tool.edit_vertex = None; + ui.close(); + } + ui.separator(); + } + if ui.button("Exit Edit Points").clicked() { + let _ = app.run("shape.editPoints", json!({"on": false})); + ui.close(); + } + return; + } let has_sel = app.session.active().is_some_and(|d| !d.selection.shapes.is_empty()); for (l, id) in [("Cut", "edit.cut"), ("Copy", "edit.copy"), ("Paste", "edit.paste")] { if ui.add_enabled(crate::ribbon::enabled(app, id), egui::Button::new(l)).clicked() { @@ -649,6 +693,13 @@ fn context_menu(app: &mut SlideApp, resp: &egui::Response) { let _ = app.run("text.edit", json!({})); ui.close(); } + if crate::ribbon::enabled(app, "shape.editPoints") + && app.session.active().is_some_and(|d| d.selection.shapes.len() == 1 && d.selected_shapes().iter().all(|s| !s.is_line())) + && ui.button("Edit Points").clicked() + { + let _ = app.run("shape.editPoints", json!({"on": true})); + ui.close(); + } ui.menu_button("Bring to Front", |ui| { crate::ribbon::menu_item(app, ui, crate::icons::Icon::BringToFront, "Bring to Front", "arrange.bringToFront", json!({})); crate::ribbon::menu_item(app, ui, crate::icons::Icon::BringForward, "Bring Forward", "arrange.bringForward", json!({})); diff --git a/docs/gaps.md b/docs/gaps.md index d0375b07..cfe559ea 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 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 +- **Evidence:** checklist rows "Edit points" (P1 partial: Edit Points converts a shape to a freeform, vertices drag, Ctrl/Cmd-click adds or deletes, smooth/straight/corner points and Bézier handles; no Straight/Curved Segment or Open/Close Path), "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, +- **Estimate:** 1 h (edit points: segment types, open/close path), 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: smooth/straight/corner point types, Bézier handle dragging, right-click point menu, no selection box while editing | | 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 | diff --git a/docs/ui-parity.md b/docs/ui-parity.md index a19b6c10..63873b7f 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 | 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 | +| Edit points on freeforms | yes | partial: Edit Shape ▸ Edit Points turns any shape into a freeform; drag vertices and their Bézier handles, Ctrl/Cmd-click to add or delete, right-click for Smooth/Straight/Corner Point and Delete Point; no Straight/Curved Segment or Open/Close Path 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 |