Freeform, Curve and Scribble drawing tools; shape.freeform command; composite commands are one undo step
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
parent
8598c41085
commit
f0d230bdcd
|
|
@ -20,6 +20,15 @@ pub fn specs() -> Vec<CommandSpec> {
|
|||
has_slide,
|
||||
connect
|
||||
),
|
||||
cmd!(
|
||||
"shape.freeform",
|
||||
"Freeform",
|
||||
["Insert", "Shapes"],
|
||||
None,
|
||||
"{points: [[x, y], …] slide pt (≥ 2), closed?: bool (filled shape), smooth?: bool (curve through the points)} → {id}",
|
||||
has_slide,
|
||||
freeform
|
||||
),
|
||||
cmd!(query "shape.sites", "Connection Sites", [], None, "{id} → [[x, y]] connection sites in slide points", has_slide, sites),
|
||||
cmd!("shape.move", "Move", [], None, "{dx?, dy?: pt (relative) | x?, y?: pt (absolute), ids?}", has_selection, move_by),
|
||||
cmd!("shape.resize", "Size", ["Shape Format", "Size"], None, "{w?, h?: pt, lockAspect?: bool, ids?}", has_selection, resize),
|
||||
|
|
@ -731,3 +740,80 @@ fn connect(s: &mut Session, p: &Value) -> Result<Value> {
|
|||
})?;
|
||||
Ok(json!({"id": id.0, "fromSite": ia, "toSite": ib}))
|
||||
}
|
||||
|
||||
/// Path data through `pts` (shape-local): straight segments, or a Catmull-Rom curve as cubics.
|
||||
pub fn freeform_path(pts: &[slidecraft_geom::Point], closed: bool, smooth: bool) -> String {
|
||||
let f = |v: f64| {
|
||||
let s = format!("{:.2}", v);
|
||||
s.trim_end_matches('0').trim_end_matches('.').to_string()
|
||||
};
|
||||
let mut d = format!("M {} {}", f(pts[0].x), f(pts[0].y));
|
||||
let n = pts.len();
|
||||
if smooth && n > 2 {
|
||||
let at = |i: isize| -> slidecraft_geom::Point {
|
||||
if closed { pts[i.rem_euclid(n as isize) as usize] } else { pts[i.clamp(0, n as isize - 1) as usize] }
|
||||
};
|
||||
let segs = if closed { n } else { n - 1 };
|
||||
for i in 0..segs as isize {
|
||||
let (p0, p1, p2, p3) = (at(i - 1), at(i), at(i + 1), at(i + 2));
|
||||
let c1 = p1 + (p2 - p0) / 6.0;
|
||||
let c2 = p2 - (p3 - p1) / 6.0;
|
||||
d += &format!(" C {} {} {} {} {} {}", f(c1.x), f(c1.y), f(c2.x), f(c2.y), f(p2.x), f(p2.y));
|
||||
}
|
||||
} else {
|
||||
for q in &pts[1..] {
|
||||
d += &format!(" L {} {}", f(q.x), f(q.y));
|
||||
}
|
||||
}
|
||||
if closed {
|
||||
d += " Z";
|
||||
}
|
||||
d
|
||||
}
|
||||
|
||||
fn freeform(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let pts: Vec<slidecraft_geom::Point> = p
|
||||
.get("points")
|
||||
.and_then(Value::as_array)
|
||||
.map(|a| {
|
||||
a.iter()
|
||||
.filter_map(|v| Some(slidecraft_geom::Point::new(v.get(0)?.as_f64()?, v.get(1)?.as_f64()?)))
|
||||
.filter(|q| q.x.is_finite() && q.y.is_finite())
|
||||
.take(20_000)
|
||||
.collect()
|
||||
})
|
||||
.unwrap_or_default();
|
||||
if pts.len() < 2 {
|
||||
return Err(bad("shape.freeform", "need at least two points"));
|
||||
}
|
||||
let closed = bool_or(p, "closed", false) && pts.len() > 2;
|
||||
let smooth = bool_or(p, "smooth", false);
|
||||
let mut bb = slidecraft_geom::Rect::from_points(pts[0], pts[0]);
|
||||
for q in &pts {
|
||||
bb = bb.union_pt(*q);
|
||||
}
|
||||
let (w, h) = (bb.width().max(1.0), bb.height().max(1.0));
|
||||
let local: Vec<slidecraft_geom::Point> = pts.iter().map(|q| slidecraft_geom::Point::new(q.x - bb.x0, q.y - bb.y0)).collect();
|
||||
let d = freeform_path(&local, closed, smooth);
|
||||
// Closed shapes take the default shape style, open paths the line style.
|
||||
let r = s.execute("shape.insert", &json!({"preset": if closed { "rect" } else { "line" }, "rect": [bb.x0, bb.y0, w, h]}))?;
|
||||
let id = slidecraft_model::ShapeId(r.get("id").and_then(Value::as_u64).ok_or_else(|| bad("shape.freeform", "insert failed"))? as u32);
|
||||
s.edit(|doc, sel| {
|
||||
let list = crate::shapes_mut(doc, sel).ok_or_else(|| bad("shape.freeform", "no slide"))?;
|
||||
let sh = slidecraft_model::find_shape_mut(list, id).ok_or_else(|| bad("shape.freeform", "insert failed"))?;
|
||||
sh.geom = Geom::Custom {
|
||||
paths: vec![slidecraft_model::CustomPath {
|
||||
w,
|
||||
h,
|
||||
d: d.clone(),
|
||||
fill: if closed { slidecraft_geom::preset::FillMode::Norm } else { slidecraft_geom::preset::FillMode::None },
|
||||
stroke: true,
|
||||
}],
|
||||
};
|
||||
sh.xfrm = Some(slidecraft_geom::Xfrm::new(bb.x0, bb.y0, w, h));
|
||||
sh.kind = slidecraft_model::ShapeKind::Shape;
|
||||
sh.name = format!("Freeform {}", id.0);
|
||||
Ok(())
|
||||
})?;
|
||||
Ok(json!({"id": id.0}))
|
||||
}
|
||||
|
|
|
|||
|
|
@ -36,9 +36,11 @@ impl Session {
|
|||
/// Run command `id` (`f`) inside the guard.
|
||||
pub(crate) fn guarded(&mut self, id: &str, f: impl FnOnce(&mut Session) -> Result<Value>) -> Result<Value> {
|
||||
let before = self.active().map(|d| (d.uid, d.doc.clone(), d.selection.clone(), d.revision));
|
||||
let depth = self.depth;
|
||||
match catch_unwind(AssertUnwindSafe(|| f(self))) {
|
||||
Ok(r) => r,
|
||||
Err(payload) => {
|
||||
self.depth = depth;
|
||||
let msg = panic_message(payload.as_ref());
|
||||
// Keep the document as it was before the command.
|
||||
if let (Some((uid, doc, selection, revision)), Some(st)) = (before, self.active_mut())
|
||||
|
|
|
|||
|
|
@ -29,6 +29,9 @@ impl Session {
|
|||
let has_shapes = self.active().is_some_and(|d| !d.selection.shapes.is_empty());
|
||||
let k = key.to_ascii_lowercase();
|
||||
let k = k.trim_start_matches("arrow");
|
||||
if self.tool.drawing_freeform() && matches!(k, "enter" | "return" | "escape") {
|
||||
return self.finish_freeform(false);
|
||||
}
|
||||
if editing && !mods.cmd {
|
||||
let r = match k {
|
||||
"left" => Some(self.execute("text.move", &json!({"to": if mods.alt { "wordLeft" } else { "left" }, "extend": mods.shift}))),
|
||||
|
|
|
|||
|
|
@ -267,6 +267,9 @@ pub struct Session {
|
|||
pub painter: Option<(cmd::format::Painted, bool)>,
|
||||
/// Set as Default Shape: the look new shapes get.
|
||||
pub default_look: Option<cmd::format::Painted>,
|
||||
/// Nesting of commands run by commands: only the outermost one is an undo step and a journal
|
||||
/// entry.
|
||||
depth: u32,
|
||||
}
|
||||
|
||||
impl Default for Session {
|
||||
|
|
@ -289,6 +292,7 @@ impl Session {
|
|||
untitled: 0,
|
||||
default_look: None,
|
||||
painter: None,
|
||||
depth: 0,
|
||||
}
|
||||
}
|
||||
/// A session with one new presentation open.
|
||||
|
|
@ -351,7 +355,11 @@ impl Session {
|
|||
}
|
||||
}
|
||||
let before = self.active().map(|d| (d.uid, d.doc.clone(), d.selection.clone()));
|
||||
let r = (spec.run)(self, params)?;
|
||||
let outer = self.depth == 0;
|
||||
self.depth += 1;
|
||||
let r = (spec.run)(self, params);
|
||||
self.depth -= 1;
|
||||
let r = r?;
|
||||
if let Some(st) = self.active_mut() {
|
||||
clamp_selection(st);
|
||||
}
|
||||
|
|
@ -360,10 +368,11 @@ impl Session {
|
|||
&& !Arc::ptr_eq(&old, &st.doc)
|
||||
&& st.interaction.is_none()
|
||||
&& spec.undoable
|
||||
&& outer
|
||||
{
|
||||
push_undo(st, HistoryEntry { label: spec.label.to_string(), doc: old, selection: sel });
|
||||
}
|
||||
if spec.journal {
|
||||
if spec.journal && outer {
|
||||
self.journal.push((id.to_string(), params.clone()));
|
||||
if self.journal.len() > 10_000 {
|
||||
self.journal.drain(..1000);
|
||||
|
|
|
|||
|
|
@ -310,3 +310,59 @@ fn drawing_a_line_between_shapes_glues_it() {
|
|||
let (p0, p1) = line_ends(&s, id);
|
||||
assert!((p0.x - 200.0).abs() < 1e-6 && (p1.x - 400.0).abs() < 1e-6, "{p0:?} {p1:?}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn freeform_tools_and_command() {
|
||||
let mut s = session();
|
||||
let before = s.doc().unwrap().shapes().len();
|
||||
// Composite command: one undo step.
|
||||
let r = s.execute("shape.freeform", &json!({"points": [[100, 100], [200, 120], [150, 220]], "closed": true})).unwrap();
|
||||
assert!(r["id"].as_u64().is_some());
|
||||
s.execute("edit.undo", &json!({})).unwrap();
|
||||
assert_eq!(s.doc().unwrap().shapes().len(), before);
|
||||
s.execute("edit.redo", &json!({})).unwrap();
|
||||
assert_eq!(s.doc().unwrap().shapes().len(), before + 1);
|
||||
|
||||
let ev = |kind, x: f64, y: f64| tools::PointerEvent { kind, x, y, mods: Default::default(), tol: 3.0 };
|
||||
// Polygon: click, click, click, click on the start → closed, filled.
|
||||
s.set_tool(tools::ToolKind::Shape { preset: "freeform".into() });
|
||||
for (x, y) in [(300.0, 300.0), (400.0, 300.0), (400.0, 400.0)] {
|
||||
s.pointer(ev(tools::PointerKind::Down, x, y)).unwrap();
|
||||
s.pointer(ev(tools::PointerKind::Up, x, y)).unwrap();
|
||||
}
|
||||
assert!(s.tool.drawing_freeform());
|
||||
s.pointer(ev(tools::PointerKind::Down, 301.0, 301.0)).unwrap();
|
||||
assert!(!s.tool.drawing_freeform());
|
||||
let st = s.doc().unwrap();
|
||||
let sh = st.shapes().last().unwrap();
|
||||
match &sh.geom {
|
||||
slidecraft_model::Geom::Custom { paths } => assert!(paths[0].d.ends_with('Z') && paths[0].d.matches(" L ").count() == 2, "{}", paths[0].d),
|
||||
g => panic!("{g:?}"),
|
||||
}
|
||||
let x = sh.xfrm.unwrap();
|
||||
assert_eq!((x.x, x.y, x.w, x.h), (300.0, 300.0, 100.0, 100.0));
|
||||
|
||||
// Scribble: one drag, open path.
|
||||
s.set_tool(tools::ToolKind::Shape { preset: "scribble".into() });
|
||||
s.pointer(ev(tools::PointerKind::Down, 10.0, 10.0)).unwrap();
|
||||
for i in 1..20 {
|
||||
s.pointer(ev(tools::PointerKind::Drag, 10.0 + i as f64 * 5.0, 10.0 + (i as f64).sin() * 10.0)).unwrap();
|
||||
}
|
||||
let r = s.pointer(ev(tools::PointerKind::Up, 110.0, 10.0)).unwrap();
|
||||
assert!(r["id"].as_u64().is_some());
|
||||
assert_eq!(s.tool.kind, tools::ToolKind::Select);
|
||||
|
||||
// Curve: clicks then Enter → smooth open path.
|
||||
s.set_tool(tools::ToolKind::Shape { preset: "curve".into() });
|
||||
for (x, y) in [(500.0, 100.0), (550.0, 50.0), (600.0, 100.0), (650.0, 50.0)] {
|
||||
s.pointer(ev(tools::PointerKind::Down, x, y)).unwrap();
|
||||
s.pointer(ev(tools::PointerKind::Up, x, y)).unwrap();
|
||||
}
|
||||
let r = s.key("Enter", Default::default()).unwrap();
|
||||
let id = slidecraft_model::ShapeId(r["id"].as_u64().unwrap() as u32);
|
||||
let st = s.doc().unwrap();
|
||||
match &st.shape(id).unwrap().geom {
|
||||
slidecraft_model::Geom::Custom { paths } => assert_eq!(paths[0].d.matches(" C ").count(), 3),
|
||||
g => panic!("{g:?}"),
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -89,15 +89,63 @@ pub enum Hit {
|
|||
|
||||
#[derive(Clone, Debug)]
|
||||
enum Drag {
|
||||
Move { ids: Vec<ShapeId>, start: Point, origs: Vec<(ShapeId, Xfrm)>, moved: bool, duplicate: bool, enter_text: Option<(ShapeId, Point)> },
|
||||
Resize { id: ShapeId, handle: u8, orig: Xfrm, start: Point },
|
||||
LineEnd { id: ShapeId, end: u8, orig: Xfrm },
|
||||
Rotate { ids: Vec<ShapeId>, center: Point, start: f64, origs: Vec<(ShapeId, Xfrm)> },
|
||||
Adjust { id: ShapeId, index: usize, orig: Xfrm },
|
||||
Marquee { start: Point, add: bool },
|
||||
Create { start: Point },
|
||||
TextSelect { shape: ShapeId },
|
||||
Ink { points: Vec<(f64, f64, f32)> },
|
||||
Move {
|
||||
ids: Vec<ShapeId>,
|
||||
start: Point,
|
||||
origs: Vec<(ShapeId, Xfrm)>,
|
||||
moved: bool,
|
||||
duplicate: bool,
|
||||
enter_text: Option<(ShapeId, Point)>,
|
||||
},
|
||||
Resize {
|
||||
id: ShapeId,
|
||||
handle: u8,
|
||||
orig: Xfrm,
|
||||
start: Point,
|
||||
},
|
||||
LineEnd {
|
||||
id: ShapeId,
|
||||
end: u8,
|
||||
orig: Xfrm,
|
||||
},
|
||||
Rotate {
|
||||
ids: Vec<ShapeId>,
|
||||
center: Point,
|
||||
start: f64,
|
||||
origs: Vec<(ShapeId, Xfrm)>,
|
||||
},
|
||||
Adjust {
|
||||
id: ShapeId,
|
||||
index: usize,
|
||||
orig: Xfrm,
|
||||
},
|
||||
Marquee {
|
||||
start: Point,
|
||||
add: bool,
|
||||
},
|
||||
Create {
|
||||
start: Point,
|
||||
},
|
||||
TextSelect {
|
||||
shape: ShapeId,
|
||||
},
|
||||
Ink {
|
||||
points: Vec<(f64, f64, f32)>,
|
||||
},
|
||||
/// Freeform drawing (`freeform` polygon, `curve`, `scribble`): placed points; `pressed` while
|
||||
/// the button is down (dragging adds scribbled points).
|
||||
Freeform {
|
||||
mode: String,
|
||||
points: Vec<Point>,
|
||||
pressed: bool,
|
||||
},
|
||||
}
|
||||
|
||||
/// Shape-gallery names that draw a freeform path instead of a preset.
|
||||
pub const FREEFORM_TOOLS: [(&str, &str); 3] = [("curve", "Curve"), ("freeform", "Freeform: Shape"), ("scribble", "Freeform: Scribble")];
|
||||
|
||||
pub fn is_freeform_tool(name: &str) -> bool {
|
||||
FREEFORM_TOOLS.iter().any(|f| f.0 == name)
|
||||
}
|
||||
|
||||
/// A snapping guide line to draw (slide coordinates): vertical (x) or horizontal (y).
|
||||
|
|
@ -125,12 +173,22 @@ pub struct ToolState {
|
|||
/// site the end would glue to.
|
||||
pub sites: Vec<Point>,
|
||||
pub glue: Option<Point>,
|
||||
/// Freeform being drawn: points so far (plus the rubber-band point), smoothed, and whether
|
||||
/// the pointer is over the start (closing the shape).
|
||||
pub path_preview: Vec<Point>,
|
||||
pub path_smooth: bool,
|
||||
pub path_closing: bool,
|
||||
}
|
||||
|
||||
impl ToolState {
|
||||
pub fn dragging(&self) -> bool {
|
||||
self.drag.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 { .. }))
|
||||
}
|
||||
}
|
||||
|
||||
/// Handle positions in slide coordinates for a box (8 resize + rotate).
|
||||
|
|
@ -209,6 +267,15 @@ impl Session {
|
|||
}
|
||||
match ev.kind {
|
||||
PointerKind::Move => {
|
||||
if let Some(Drag::Freeform { points, mode, .. }) = &self.tool.drag {
|
||||
let closing = points.len() > 2 && points.first().is_some_and(|s| (*s - p).hypot() <= tol * 2.0);
|
||||
let mut v = points.clone();
|
||||
v.push(p);
|
||||
self.tool.path_preview = v;
|
||||
self.tool.path_smooth = mode == "curve";
|
||||
self.tool.path_closing = closing;
|
||||
return Ok(Value::Null);
|
||||
}
|
||||
self.tool.hover = self.hit_test(p, tol);
|
||||
let line_tool = matches!(&self.tool.kind, ToolKind::Shape { preset } if slidecraft_geom::preset::is_line_like(preset));
|
||||
if line_tool {
|
||||
|
|
@ -251,7 +318,25 @@ impl Session {
|
|||
}
|
||||
|
||||
fn down(&mut self, p: Point, mods: Mods, tol: f64) -> Result<Value> {
|
||||
if let Some(Drag::Freeform { mode, mut points, .. }) = self.tool.drag.clone() {
|
||||
// Next click of a freeform: close on the start point, else add a vertex.
|
||||
if points.len() > 2 && points.first().is_some_and(|s| (*s - p).hypot() <= tol * 2.0) {
|
||||
self.tool.drag = Some(Drag::Freeform { mode, points, pressed: false });
|
||||
return self.finish_freeform(true);
|
||||
}
|
||||
points.push(p);
|
||||
self.tool.path_preview = points.clone();
|
||||
self.tool.drag = Some(Drag::Freeform { mode, points, pressed: true });
|
||||
return Ok(Value::Null);
|
||||
}
|
||||
match self.tool.kind.clone() {
|
||||
ToolKind::Shape { preset } if is_freeform_tool(&preset) => {
|
||||
self.tool.drag = Some(Drag::Freeform { mode: preset.clone(), points: vec![p], pressed: true });
|
||||
self.tool.path_preview = vec![p];
|
||||
self.tool.path_smooth = preset == "curve";
|
||||
self.tool.path_closing = false;
|
||||
return Ok(Value::Null);
|
||||
}
|
||||
ToolKind::Shape { .. } | ToolKind::TextBox => {
|
||||
self.tool.drag = Some(Drag::Create { start: p });
|
||||
self.tool.preview = Some(Xfrm::new(p.x, p.y, 0.0, 0.0));
|
||||
|
|
@ -539,6 +624,18 @@ impl Session {
|
|||
})?;
|
||||
}
|
||||
}
|
||||
Drag::Freeform { mode, mut points, pressed } => {
|
||||
// Dragging scribbles (except for curves, which only take clicked points).
|
||||
if pressed && mode != "curve" && points.last().is_none_or(|l| (*l - p).hypot() >= 1.5) {
|
||||
points.push(p);
|
||||
}
|
||||
let mut v = points.clone();
|
||||
if mode == "curve" {
|
||||
v.push(p);
|
||||
}
|
||||
self.tool.path_preview = v;
|
||||
self.tool.drag = Some(Drag::Freeform { mode, points, pressed });
|
||||
}
|
||||
Drag::Ink { mut points } => {
|
||||
points.push((p.x, p.y, 0.5));
|
||||
self.tool.ink_preview = points.clone();
|
||||
|
|
@ -633,6 +730,17 @@ impl Session {
|
|||
}
|
||||
out = res;
|
||||
}
|
||||
Some(Drag::Freeform { mode, points, .. }) => {
|
||||
if mode == "scribble" {
|
||||
// A scribble ending back at its start closes into a filled shape.
|
||||
let closed = points.len() > 3 && points.first().zip(points.last()).is_some_and(|(a, b)| (*a - *b).hypot() <= 6.0);
|
||||
self.tool.drag = Some(Drag::Freeform { mode, points, pressed: false });
|
||||
return self.finish_freeform(closed);
|
||||
}
|
||||
// Polygon / curve: keep going until double-click, Enter or a click on the start.
|
||||
self.tool.drag = Some(Drag::Freeform { mode, points, pressed: false });
|
||||
return Ok(Value::Null);
|
||||
}
|
||||
Some(Drag::Ink { points }) => {
|
||||
self.tool.ink_preview.clear();
|
||||
if points.len() > 1 {
|
||||
|
|
@ -655,6 +763,14 @@ impl Session {
|
|||
}
|
||||
|
||||
fn double_click(&mut self, p: Point, tol: f64) -> Result<Value> {
|
||||
if let Some(Drag::Freeform { mode, mut points, .. }) = self.tool.drag.clone() {
|
||||
// The double-click's own click added a duplicate vertex.
|
||||
while points.len() > 2 && points.last().zip(points.get(points.len() - 2)).is_some_and(|(a, b)| (*a - *b).hypot() <= tol) {
|
||||
points.pop();
|
||||
}
|
||||
self.tool.drag = Some(Drag::Freeform { mode, points, pressed: false });
|
||||
return self.finish_freeform(false);
|
||||
}
|
||||
if !matches!(self.tool.kind, ToolKind::Select) {
|
||||
// Double-click while drawing: lock drawing mode off.
|
||||
self.tool.sticky = false;
|
||||
|
|
@ -698,6 +814,21 @@ impl Session {
|
|||
self.active().and_then(|d| d.shape(id)).is_some_and(|s| matches!(s.kind, ShapeKind::Picture { .. } | ShapeKind::Media(_)))
|
||||
}
|
||||
|
||||
/// End the freeform being drawn: create the shape (if it has at least two points).
|
||||
pub fn finish_freeform(&mut self, closed: bool) -> Result<Value> {
|
||||
let Some(Drag::Freeform { mode, points, .. }) = self.tool.drag.take() else { return Ok(Value::Null) };
|
||||
self.tool.path_preview.clear();
|
||||
self.tool.path_closing = false;
|
||||
if !self.tool.sticky {
|
||||
self.tool.kind = ToolKind::Select;
|
||||
}
|
||||
if points.len() < 2 {
|
||||
return Ok(Value::Null);
|
||||
}
|
||||
let pts: Vec<[f64; 2]> = points.iter().map(|q| [q.x, q.y]).collect();
|
||||
self.execute("shape.freeform", &json!({"points": pts, "closed": closed, "smooth": mode == "curve"}))
|
||||
}
|
||||
|
||||
/// The connection site within glue range of `p` (excluding shape `exclude`).
|
||||
fn glue_at(&self, p: Point, tol: f64) -> Option<(ShapeId, u32, Point)> {
|
||||
let st = self.active()?;
|
||||
|
|
|
|||
|
|
@ -51,6 +51,7 @@ pub fn tool_kind(name: &str, preset: Option<&str>) -> Option<ToolKind> {
|
|||
"highlighter" => ToolKind::Ink { mode: "highlighter".into(), color: slidecraft_engine::model::Rgba::rgb(255, 230, 0), width: 10.0 },
|
||||
"eraser" => ToolKind::Ink { mode: "eraser".into(), color: slidecraft_engine::model::Rgba::BLACK, width: 2.0 },
|
||||
other if slidecraft_engine::geom::preset::info(other).is_some() => ToolKind::Shape { preset: other.to_string() },
|
||||
other if slidecraft_engine::tools::is_freeform_tool(other) => ToolKind::Shape { preset: other.to_string() },
|
||||
_ => return None,
|
||||
})
|
||||
}
|
||||
|
|
|
|||
|
|
@ -381,6 +381,28 @@ fn overlays(app: &mut SlideApp, ui: &Ui, painter: &egui::Painter, xf: Xf, t: &To
|
|||
if let Some(pv) = app.session.tool.preview {
|
||||
draw_preview(app, painter, xf, pv, t);
|
||||
}
|
||||
// Freeform being drawn.
|
||||
let path = &app.session.tool.path_preview;
|
||||
if path.len() > 1 {
|
||||
let pts: Vec<Point> = if app.session.tool.path_smooth && path.len() > 2 {
|
||||
let d = slidecraft_engine::cmd::shape::freeform_path(path, false, true);
|
||||
let mut v = vec![];
|
||||
let bp = slidecraft_render::parse_path(&d);
|
||||
slidecraft_geom::preset_flatten(&bp, &mut |el| match el {
|
||||
slidecraft_geom::PathEl::MoveTo(q) | slidecraft_geom::PathEl::LineTo(q) => v.push(q),
|
||||
_ => {}
|
||||
});
|
||||
v
|
||||
} else {
|
||||
path.clone()
|
||||
};
|
||||
painter.add(egui::Shape::line(pts.iter().map(|q| xf.to_screen(*q)).collect(), Stroke::new(1.5, t.accent)));
|
||||
if app.session.tool.path_closing
|
||||
&& let Some(s0) = path.first()
|
||||
{
|
||||
painter.circle(xf.to_screen(*s0), 5.0, t.accent, Stroke::new(1.5, t.handle_fill));
|
||||
}
|
||||
}
|
||||
// Connection sites of the shape under a connector end, and the site it would glue to.
|
||||
for q in &app.session.tool.sites {
|
||||
let c = xf.to_screen(*q);
|
||||
|
|
|
|||
|
|
@ -325,6 +325,7 @@ pub fn tool_kind(name: &str, preset: Option<&str>) -> Option<slidecraft_engine::
|
|||
"highlighter" => ToolKind::Ink { mode: "highlighter".into(), color: slidecraft_color::Rgba::rgb(255, 230, 0), width: 10.0 },
|
||||
"eraser" => ToolKind::Ink { mode: "eraser".into(), color: slidecraft_color::Rgba::BLACK, width: 2.0 },
|
||||
other if slidecraft_geom::preset::info(other).is_some() => ToolKind::Shape { preset: other.into() },
|
||||
other if slidecraft_engine::tools::is_freeform_tool(other) => ToolKind::Shape { preset: other.into() },
|
||||
_ => return None,
|
||||
})
|
||||
}
|
||||
|
|
|
|||
|
|
@ -570,7 +570,7 @@ impl SlideApp {
|
|||
}
|
||||
egui::Event::Key { key, pressed: true, modifiers, .. } => {
|
||||
let mods = Mods { shift: modifiers.shift, alt: modifiers.alt, cmd: modifiers.command || modifiers.ctrl };
|
||||
if menus::ui_shortcut(self, key, mods) {
|
||||
if !self.session.tool.drawing_freeform() && menus::ui_shortcut(self, key, mods) {
|
||||
continue;
|
||||
}
|
||||
let name = key_name(key);
|
||||
|
|
|
|||
|
|
@ -633,6 +633,18 @@ pub fn shapes_gallery(ui: &mut Ui) -> Option<&'static str> {
|
|||
out = Some(p.name);
|
||||
}
|
||||
}
|
||||
if cat == slidecraft_geom::preset::Category::Lines {
|
||||
for (name, label) in slidecraft_engine::tools::FREEFORM_TOOLS {
|
||||
let (r, resp) = ui.allocate_exact_size(vec2(26.0, 26.0), Sense::click());
|
||||
if resp.hovered() {
|
||||
ui.painter().rect_filled(r, CornerRadius::same(3), t.hover);
|
||||
}
|
||||
paint_freeform_icon(ui.painter(), r.shrink(5.0), name, t.text);
|
||||
if resp.on_hover_text(label).clicked() {
|
||||
out = Some(name);
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
ui.add_space(4.0);
|
||||
}
|
||||
|
|
@ -2342,3 +2354,16 @@ fn slide_master(app: &mut SlideApp, ui: &mut Ui) {
|
|||
}
|
||||
});
|
||||
}
|
||||
|
||||
/// Gallery glyphs for the freeform tools: a wave (curve), a polygon (freeform), a squiggle (scribble).
|
||||
fn paint_freeform_icon(p: &egui::Painter, r: Rect, name: &str, ink: Color32) {
|
||||
let at = |x: f32, y: f32| egui::pos2(r.min.x + x * r.width(), r.min.y + y * r.height());
|
||||
let pts: Vec<egui::Pos2> = match name {
|
||||
"curve" => (0..=24).map(|i| i as f32 / 24.0).map(|u| at(u, 0.5 - 0.38 * (u * std::f32::consts::TAU).sin())).collect(),
|
||||
"freeform" => {
|
||||
[(0.0, 0.85), (0.15, 0.2), (0.55, 0.45), (0.8, 0.05), (1.0, 0.75), (0.45, 1.0), (0.0, 0.85)].iter().map(|(x, y)| at(*x, *y)).collect()
|
||||
}
|
||||
_ => (0..=40).map(|i| i as f32 / 40.0).map(|u| at(u, 0.5 + 0.35 * (u * 19.0).sin() * (1.0 - u * 0.4))).collect(),
|
||||
};
|
||||
p.add(egui::Shape::line(pts, egui::Stroke::new(1.3, ink)));
|
||||
}
|
||||
|
|
|
|||
|
|
@ -16,7 +16,7 @@ Live Presentations), add-ins and VBA macros.
|
|||
| Priority | Features | Done | Partial | Missing | Parity |
|
||||
|---|---|---|---|---|---|
|
||||
| P0 | 72 | 58 | 13 | 1 | 90% |
|
||||
| P1 | 65 | 33 | 23 | 9 | 68% |
|
||||
| P1 | 65 | 34 | 23 | 8 | 70% |
|
||||
| P2 | 50 | 11 | 11 | 28 | 33% |
|
||||
|
||||
| Area | Parity |
|
||||
|
|
@ -26,7 +26,7 @@ Live Presentations), add-ins and VBA macros.
|
|||
| Slides | 95% |
|
||||
| Views | 90% |
|
||||
| Masters & themes | 80% |
|
||||
| Shapes | 72% |
|
||||
| Shapes | 79% |
|
||||
| Format | 75% |
|
||||
| Arrange | 100% |
|
||||
| Text | 83% |
|
||||
|
|
@ -115,7 +115,7 @@ Open P0 items: Start screen (new, templates/themes, recent), Contextual tabs (Sh
|
|||
| Shapes | Adjustment handles | P0 | D |
|
||||
| Shapes | Connectors (straight, elbow, curved) | P0 | D |
|
||||
| Shapes | Connector glue to connection sites | P1 | D |
|
||||
| Shapes | Freeform, curve and scribble tools | P1 | M |
|
||||
| Shapes | Freeform, curve and scribble tools | P1 | D |
|
||||
| Shapes | Edit points | P1 | M |
|
||||
| Shapes | Merge shapes (union, combine, fragment, intersect, subtract) | P1 | M |
|
||||
| Shapes | Change shape | P1 | D |
|
||||
|
|
|
|||
Loading…
Reference in New Issue