mirror of
https://github.com/storytold/deckcraft.git
synced 2026-10-24 18:46:57 -06:00
shape.editPoints was a noundo command, so the conversion from a preset to custom geometry was never recorded and Ctrl+Z took back the step before it. It is undoable now (only an actual change records a step), and Edit Points ends when Undo turns the shape back into a preset. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
787 lines
31 KiB
Rust
787 lines
31 KiB
Rust
//! 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!(
|
|
"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]], 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",
|
|
"Add Point",
|
|
[],
|
|
None,
|
|
"{path?: 0, segment: index of the vertex ending it, t?: 0..1 (0.5), id?}",
|
|
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),
|
|
]
|
|
}
|
|
|
|
/// 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 id_param(p, "id", cmd)? {
|
|
Some(i) => i,
|
|
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());
|
|
s.tool.edit_vertex = 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 id_param(p, "id", "shape.points")? {
|
|
Some(i) => i,
|
|
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();
|
|
let types: Vec<Vec<&str>> = 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.
|
|
/// `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}))
|
|
}
|
|
|
|
/// 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<Point> {
|
|
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<Point>, Option<Point>, 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<Value> {
|
|
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<Value> {
|
|
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<Value> {
|
|
move_handle(s, p)
|
|
}
|