diff --git a/ROADMAP.md b/ROADMAP.md index d7b87c3..42126c1 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -30,6 +30,7 @@ DesignCraft aims at full Adobe InDesign parity — and to be better: faster, ope - Edit › Paste Into (copied objects become a frame's content, clipped by it; rendered, PDF and IDML both ways — verified in InDesign 2026); Polygon Settings (sides, star inset; double-click the Polygon tool). - Color panel (fill/stroke proxy, CMYK/RGB/Lab sliders with channel ramps, tint slider for swatches, spectrum ramp, Add to Swatches) and Color Picker (double-click the proxy); mixed colours are unnamed (not listed in Swatches until added); `object.color`, `swatch.addToSwatches`, `swatch.addUnnamed`. - Place text files: Word (.docx: styles by name with their attributes, bold/italic/underline/size/font, footnotes, tables, tabs, breaks), RTF and plain text, into the insertion point, the selected frame or a new frame; autoflow adds pages and threaded frames until the text fits; Remove Styles option (`designcraft-textimport`). +- Add Anchor Point (=), Delete Anchor Point (-) and Convert Direction Point (⇧C) tools (click toggles smooth/corner, drag pulls out handles), with `path.addAnchor`, `path.deleteAnchor`, `path.convertAnchor`. - Gradients: the Gradient Swatch tool (G) drags a gradient's start and end across objects (Shift: 45°); the Gradient panel sets type, angle, reverse and edits stops on a ramp (drag, add, drag off to remove, location, colour). Edited gradients are unnamed swatches; `object.gradient`; the vector round-trips through IDML. - Document Setup (⌥⌘P): intent, number of pages, start page # (an even start begins with a left page), facing pages, size presets and orientation, bleed and slug per edge; `layout.documentSetup {}` reports the setup. - Stroke panel: cap, join, miter limit, alignment, type, Start/End arrowheads (12 kinds, drawn on screen and in PDF; the path is shortened under pointed heads) and gap colour under dashes and dots. diff --git a/crates/engine/src/cmd/anchors.rs b/crates/engine/src/cmd/anchors.rs new file mode 100644 index 0000000..254486c --- /dev/null +++ b/crates/engine/src/cmd/anchors.rs @@ -0,0 +1,220 @@ +//! Anchor-point editing (the Pen tool group): Add Anchor Point, Delete Anchor Point and Convert +//! Direction Point. Positions are in spread coordinates; anchors are `(subpath, index)`. + +use designcraft_doc::{Document, ItemId, Shape}; +use designcraft_geom::{Affine, Anchor, AnchorKind, Point}; +use serde_json::{Value, json}; + +use super::{CommandSpec, bad, cmd, has_doc, point_param}; +use crate::{Result, Session}; + +pub fn specs() -> Vec { + vec![ + cmd!( + "path.addAnchor", + "Add Anchor Point", + [], + None, + "{id, at: [x,y] (spread coords; the nearest point on the path), tolerance?: pt (default 4)} → {subpath, anchor}", + has_doc, + add_anchor + ), + cmd!( + "path.deleteAnchor", + "Delete Anchor Point", + [], + None, + "{id, subpath, anchor} — removes the point (and the object when no path is left)", + has_doc, + delete_anchor + ), + cmd!( + "path.convertAnchor", + "Convert Direction Point", + [], + None, + "{id, subpath, anchor, to?: [x,y] (spread coords: drag out symmetric handles to there), corner?: bool} — without `to`, a smooth point becomes a corner and a corner gets smooth handles", + has_doc, + convert_anchor + ), + ] +} + +/// Spread → item-space transform of `id`. +fn to_inner(d: &Document, id: ItemId) -> Option { + let loc = d.find(id)?; + Some((d.parent_xf(&loc) * d.item_at(&loc)?.xf).inverse()) +} + +fn anchor_param(p: &Value, cmd: &str) -> Result<(ItemId, usize, usize)> { + let id = super::id_param(p, "id").ok_or_else(|| bad(cmd, "missing id"))?; + let si = p.get("subpath").and_then(Value::as_u64).unwrap_or(0) as usize; + let ai = p.get("anchor").and_then(Value::as_u64).ok_or_else(|| bad(cmd, "missing anchor"))? as usize; + Ok((id, si, ai)) +} + +/// Editing points makes a drawn rectangle/ellipse/polygon an ordinary path. +fn as_path(shape: &mut Shape) { + if matches!(shape, Shape::Rectangle | Shape::Oval | Shape::Polygon) { + *shape = Shape::Path; + } +} + +fn add_anchor(s: &mut Session, p: &Value) -> Result { + let id = super::id_param(p, "id").ok_or_else(|| bad("path.addAnchor", "missing id"))?; + let at = point_param(p, "at").ok_or_else(|| bad("path.addAnchor", "missing at"))?; + let tol = p.get("tolerance").and_then(Value::as_f64).unwrap_or(4.0); + s.edit(|d, _| { + let inv = to_inner(d, id).ok_or(designcraft_doc::DocError::NoItem(id))?; + let it = d.item_mut(id).ok_or(designcraft_doc::DocError::NoItem(id))?; + let q = inv * at; + let (si, seg, t, _, dist) = it.path.nearest(q).ok_or_else(|| bad("path.addAnchor", "the object has no path"))?; + // Tolerance is in spread units; the item space may be scaled. + let scale = inv.determinant().abs().sqrt().max(1e-9); + if dist > tol * scale { + return Err(bad("path.addAnchor", "not on the path")); + } + let t = t.clamp(1e-4, 1.0 - 1e-4); + let ai = it.path.subpaths[si].insert_anchor(seg, t); + as_path(&mut it.shape); + Ok(json!({"subpath": si, "anchor": ai})) + }) +} + +fn delete_anchor(s: &mut Session, p: &Value) -> Result { + let (id, si, ai) = anchor_param(p, "path.deleteAnchor")?; + s.edit(|d, sel| { + let it = d.item_mut(id).ok_or(designcraft_doc::DocError::NoItem(id))?; + let sp = it.path.subpaths.get_mut(si).ok_or_else(|| bad("path.deleteAnchor", "no such subpath"))?; + if ai >= sp.anchors.len() { + return Err(bad("path.deleteAnchor", "no such anchor")); + } + sp.anchors.remove(ai); + // A subpath needs two points (three to stay closed). + if sp.anchors.len() < 2 { + it.path.subpaths.remove(si); + } else if sp.closed && sp.anchors.len() < 3 { + sp.closed = false; + } + as_path(&mut it.shape); + if it.path.subpaths.is_empty() { + d.remove_item(id)?; + sel.items.retain(|i| *i != id); + return Ok(json!({"deleted": true})); + } + Ok(json!({"deleted": false})) + }) +} + +fn convert_anchor(s: &mut Session, p: &Value) -> Result { + let (id, si, ai) = anchor_param(p, "path.convertAnchor")?; + let to = point_param(p, "to"); + let corner = p.get("corner").and_then(Value::as_bool); + s.edit(|d, _| { + let inv = to_inner(d, id).ok_or(designcraft_doc::DocError::NoItem(id))?; + let it = d.item_mut(id).ok_or(designcraft_doc::DocError::NoItem(id))?; + let sp = it.path.subpaths.get_mut(si).ok_or_else(|| bad("path.convertAnchor", "no such subpath"))?; + let n = sp.anchors.len(); + if ai >= n { + return Err(bad("path.convertAnchor", "no such anchor")); + } + let closed = sp.closed; + let a = sp.anchors[ai]; + let has_handles = a.has_in() || a.has_out(); + let new = match (to, corner) { + (Some(t), _) => Anchor::smooth(a.p, inv * t), + (None, Some(true)) => Anchor::corner(a.p), + (None, Some(false)) => smooth_auto(&sp.anchors, ai, closed), + (None, None) if has_handles => Anchor::corner(a.p), + (None, None) => smooth_auto(&sp.anchors, ai, closed), + }; + sp.anchors[ai] = new; + as_path(&mut it.shape); + Ok(json!({"kind": if new.kind == AnchorKind::Smooth { "smooth" } else { "corner" }})) + }) +} + +/// Smooth handles for a corner: parallel to the line through its neighbours, a third of the way +/// to each. +fn smooth_auto(anchors: &[Anchor], i: usize, closed: bool) -> Anchor { + let n = anchors.len(); + let p = anchors[i].p; + let prev = if i > 0 { + Some(anchors[i - 1].p) + } else if closed { + Some(anchors[n - 1].p) + } else { + None + }; + let next = if i + 1 < n { + Some(anchors[i + 1].p) + } else if closed { + Some(anchors[0].p) + } else { + None + }; + let (a, b) = (prev.unwrap_or(p), next.unwrap_or(p)); + let dir = b - a; + let len = dir.hypot(); + if len < 1e-9 { + return anchors[i]; + } + let u = dir / len; + let (lin, lout) = ((p - a).hypot() / 3.0, (b - p).hypot() / 3.0); + let lin = if prev.is_some() { lin } else { lout }; + let lout = if next.is_some() { lout } else { lin }; + Anchor { p, h_in: p - u * lin, h_out: p + u * lout, kind: AnchorKind::Smooth } +} + +/// The anchor of item `id` nearest to `at` (spread coords), within `tol`. +pub fn anchor_near(d: &Document, id: ItemId, at: Point, tol: f64) -> Option<(usize, usize)> { + let loc = d.find(id)?; + let m = d.parent_xf(&loc) * d.item_at(&loc)?.xf; + d.item(id)? + .path + .anchors() + .map(|(si, ai, a)| (si, ai, ((m * a.p) - at).hypot())) + .filter(|x| x.2 <= tol) + .min_by(|a, b| a.2.total_cmp(&b.2)) + .map(|x| (x.0, x.1)) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn add_convert_delete_anchor_points() { + let mut s = Session::new(); + s.execute("file.new", &json!({})).unwrap(); + let id = ItemId(s.execute("frame.create", &json!({"rect": [100, 100, 200, 200]})).unwrap()["id"].as_u64().unwrap()); + let count = |s: &Session| s.doc().unwrap().doc.item(id).unwrap().path.anchor_count(); + assert_eq!(count(&s), 4); + assert!(s.execute("path.addAnchor", &json!({"id": id.0, "at": [150, 150]})).is_err(), "not on the path"); + let r = s.execute("path.addAnchor", &json!({"id": id.0, "at": [150, 101]})).unwrap(); + assert_eq!(count(&s), 5); + let ai = r["anchor"].as_u64().unwrap() as usize; + let d = s.doc().unwrap().doc.clone(); + let it = d.item(id).unwrap(); + assert_eq!(it.shape, Shape::Path); + assert!((it.path.subpaths[0].anchors[ai].p.y - 100.0).abs() < 1e-6); + assert_eq!(anchor_near(&d, id, Point::new(150.5, 100.5), 3.0), Some((0, ai))); + // Drag out handles, then click again to make it a corner. + s.execute("path.convertAnchor", &json!({"id": id.0, "anchor": ai, "to": [170, 90]})).unwrap(); + let a = s.doc().unwrap().doc.item(id).unwrap().path.subpaths[0].anchors[ai]; + assert_eq!(a.kind, AnchorKind::Smooth); + assert!((a.h_out - Point::new(170.0, 90.0)).hypot() < 1e-9 && (a.h_in - Point::new(130.0, 110.0)).hypot() < 1e-9); + assert_eq!(s.execute("path.convertAnchor", &json!({"id": id.0, "anchor": ai})).unwrap()["kind"], "corner"); + assert_eq!(s.execute("path.convertAnchor", &json!({"id": id.0, "anchor": 0})).unwrap()["kind"], "smooth"); + s.execute("path.deleteAnchor", &json!({"id": id.0, "anchor": ai})).unwrap(); + assert_eq!(count(&s), 4); + // Deleting down to one point removes the object. + for _ in 0..2 { + s.execute("path.deleteAnchor", &json!({"id": id.0, "anchor": 0})).unwrap(); + } + assert!(!s.doc().unwrap().doc.item(id).unwrap().path.is_closed(), "two points can't stay closed"); + let r = s.execute("path.deleteAnchor", &json!({"id": id.0, "anchor": 0})).unwrap(); + assert_eq!(r["deleted"], true); + assert!(s.doc().unwrap().doc.item(id).is_none()); + } +} diff --git a/crates/engine/src/cmd/mod.rs b/crates/engine/src/cmd/mod.rs index 3c8ea80..603218b 100644 --- a/crates/engine/src/cmd/mod.rs +++ b/crates/engine/src/cmd/mod.rs @@ -1,6 +1,7 @@ //! The command registry. Ids follow InDesign's menu structure. mod anchored; +pub mod anchors; mod datamerge; mod edit; mod export; @@ -124,6 +125,7 @@ pub fn command_specs() -> &'static [CommandSpec] { v.extend(export::specs()); v.extend(edit::specs()); v.extend(object::specs()); + v.extend(anchors::specs()); v.extend(text::specs()); v.extend(style::specs()); v.extend(table::specs()); diff --git a/crates/tools/src/anchors.rs b/crates/tools/src/anchors.rs new file mode 100644 index 0000000..453eed7 --- /dev/null +++ b/crates/tools/src/anchors.rs @@ -0,0 +1,102 @@ +//! Add Anchor Point (=), Delete Anchor Point (-) and Convert Direction Point (Shift+C): they act on +//! the selected paths, or the path under the pointer when nothing is selected. + +use designcraft_doc::ItemId; +use designcraft_geom::Point; +use serde_json::json; + +use crate::select::anchor_at_in; +use crate::{Action, Cursor, Mods, PointerEvent, PointerKind, Tool, ToolContext}; + +#[derive(Clone, Copy, PartialEq, Eq)] +pub enum Kind { + Add, + Delete, + Convert, +} + +pub struct AnchorTool { + kind: Kind, + /// Convert: the anchor pressed on (id, subpath, anchor), the press point, whether it dragged. + press: Option<(u64, usize, usize, Point, bool)>, +} + +impl AnchorTool { + pub fn new(kind: Kind) -> Self { + Self { kind, press: None } + } +} + +/// The paths the tool works on. +fn targets(cx: &ToolContext, p: Point) -> Vec { + if cx.selection.items.is_empty() { cx.hit(p).map(|(_, id)| vec![id]).unwrap_or_default() } else { cx.selection.items.clone() } +} + +impl Tool for AnchorTool { + fn id(&self) -> &'static str { + match self.kind { + Kind::Add => "addAnchor", + Kind::Delete => "deleteAnchor", + Kind::Convert => "convertDirection", + } + } + + fn pointer(&mut self, cx: &ToolContext, ev: &PointerEvent) -> Vec { + let p = ev.pos; + match (self.kind, ev.kind) { + (Kind::Add, PointerKind::Down) => { + let Some((_, sp)) = cx.layout.spread_at(p) else { return vec![] }; + // The first target whose outline passes near the pointer. + for id in targets(cx, p) { + let Some(it) = cx.doc.item(id) else { continue }; + let Some(xf) = cx.item_canvas_xf(id) else { continue }; + let inner = (xf * it.xf).inverse() * p; + let scale = (xf * it.xf).inverse().determinant().abs().sqrt(); + if it.path.nearest(inner).is_some_and(|n| n.4 <= cx.tol(5.0) * scale) { + return vec![Action::Exec("path.addAnchor".into(), json!({"id": id.0, "at": [sp.x, sp.y], "tolerance": cx.tol(5.0)}))]; + } + } + vec![] + } + (Kind::Delete, PointerKind::Down) => match anchor_at_in(cx, &targets(cx, p), p) { + Some((id, si, ai, None)) => vec![Action::Exec("path.deleteAnchor".into(), json!({"id": id, "subpath": si, "anchor": ai}))], + _ => vec![], + }, + (Kind::Convert, PointerKind::Down) => { + if let Some((id, si, ai, None)) = anchor_at_in(cx, &targets(cx, p), p) { + self.press = Some((id, si, ai, p, false)); + } + vec![] + } + (Kind::Convert, PointerKind::Drag) => { + let Some((id, si, ai, start, dragged)) = self.press else { return vec![] }; + if !dragged && (p - start).hypot() < cx.tol(3.0) { + return vec![]; + } + let Some((_, sp)) = cx.layout.spread_at(p) else { return vec![] }; + let mut out = vec![]; + if !dragged { + out.push(Action::Begin("Convert Direction Point".into())); + self.press = Some((id, si, ai, start, true)); + } + out.push(Action::Preview("path.convertAnchor".into(), json!({"id": id, "subpath": si, "anchor": ai, "to": [sp.x, sp.y]}))); + out + } + (Kind::Convert, PointerKind::Up) => match self.press.take() { + Some((_, _, _, _, true)) => vec![Action::Commit], + // A click toggles smooth ↔ corner. + Some((id, si, ai, _, false)) => vec![Action::Exec("path.convertAnchor".into(), json!({"id": id, "subpath": si, "anchor": ai}))], + None => vec![], + }, + _ => vec![], + } + } + + fn cursor(&self, _cx: &ToolContext, _p: Point, _m: Mods) -> Cursor { + Cursor::Pen + } + + fn busy(&self) -> bool { + self.press.is_some_and(|p| p.4) + } +} diff --git a/crates/tools/src/lib.rs b/crates/tools/src/lib.rs index 2fcb5f5..8d817a6 100644 --- a/crates/tools/src/lib.rs +++ b/crates/tools/src/lib.rs @@ -8,6 +8,7 @@ //! Pointer positions are **canvas** coordinates (see [`layout::CanvasLayout`]). #![forbid(unsafe_code)] +mod anchors; pub mod catalog; mod frame; mod gradient; @@ -259,6 +260,9 @@ pub fn create(id: &str) -> Box { "type" => Box::new(text::TypeTool::default()), "pen" => Box::new(pen::PenTool::default()), "gradientSwatch" => Box::new(gradient::GradientTool::default()), + "addAnchor" => Box::new(anchors::AnchorTool::new(anchors::Kind::Add)), + "deleteAnchor" => Box::new(anchors::AnchorTool::new(anchors::Kind::Delete)), + "convertDirection" => Box::new(anchors::AnchorTool::new(anchors::Kind::Convert)), "rotate" => Box::new(xform::XformTool::new("rotate")), "scale" => Box::new(xform::XformTool::new("scale")), "shear" => Box::new(xform::XformTool::new("shear")), diff --git a/crates/tools/src/select.rs b/crates/tools/src/select.rs index 141223c..f877c15 100644 --- a/crates/tools/src/select.rs +++ b/crates/tools/src/select.rs @@ -23,8 +23,13 @@ enum Drag { /// Anchor or handle of a selected item under `p` (canvas): (item, subpath, anchor, handle). pub fn anchor_at(cx: &ToolContext, p: Point) -> Option<(u64, usize, usize, Option<&'static str>)> { + anchor_at_in(cx, &cx.selection.items, p) +} + +/// [`anchor_at`] over the given items. +pub fn anchor_at_in(cx: &ToolContext, ids: &[designcraft_doc::ItemId], p: Point) -> Option<(u64, usize, usize, Option<&'static str>)> { let tol = cx.tol(5.0); - for id in &cx.selection.items { + for id in ids { let (Some(it), Some(xf)) = (cx.doc.item(*id), cx.item_canvas_xf(*id)) else { continue }; let m = xf * it.xf; for (si, sp) in it.path.subpaths.iter().enumerate() { diff --git a/crates/tools/src/tests.rs b/crates/tools/src/tests.rs index c944f4d..6cac644 100644 --- a/crates/tools/src/tests.rs +++ b/crates/tools/src/tests.rs @@ -104,3 +104,41 @@ fn gradient_tool_drag_sets_the_vector() { } assert_eq!(t.pointer(&cx, &PointerEvent::new(PointerKind::Up, 220.0 + off.x, 153.0 + off.y)), vec![Action::Commit]); } + +#[test] +fn anchor_tools_emit_path_commands() { + let mut d = Document::new(&NewDocument::default()); + let lid = d.default_layer(); + let (fid, _) = d.add_text_frame(SpreadRef::Doc(0), Rect::new(100.0, 100.0, 200.0, 200.0), lid, "", ParaFormat::default()).unwrap(); + let (s, c, l) = (Selection::items(vec![fid]), Cache::new(), CanvasLayout::new(&d, false)); + let cx = ctx(&d, &s, &c, &l); + let off = l.offset(SpreadRef::Doc(0)); + let at = |x: f64, y: f64| (x + off.x, y + off.y); + let ev = |k, (x, y): (f64, f64)| PointerEvent::new(k, x, y); + let mut add = create("addAnchor"); + match &add.pointer(&cx, &ev(PointerKind::Down, at(150.0, 101.0)))[..] { + [Action::Exec(cmd, p)] => { + assert_eq!(cmd, "path.addAnchor"); + assert_eq!(p["at"], serde_json::json!([150.0, 101.0])); + } + other => panic!("{other:?}"), + } + assert!(add.pointer(&cx, &ev(PointerKind::Down, at(150.0, 150.0))).is_empty(), "inside, away from the edge"); + let mut del = create("deleteAnchor"); + assert_eq!( + del.pointer(&cx, &ev(PointerKind::Down, at(200.0, 100.0))), + vec![Action::Exec("path.deleteAnchor".into(), serde_json::json!({"id": fid.0, "subpath": 0, "anchor": 1}))] + ); + // Convert: click toggles, drag pulls out handles. + let mut conv = create("convertDirection"); + conv.pointer(&cx, &ev(PointerKind::Down, at(100.0, 100.0))); + assert_eq!( + conv.pointer(&cx, &ev(PointerKind::Up, at(100.0, 100.0))), + vec![Action::Exec("path.convertAnchor".into(), serde_json::json!({"id": fid.0, "subpath": 0, "anchor": 0}))] + ); + conv.pointer(&cx, &ev(PointerKind::Down, at(100.0, 100.0))); + let a = conv.pointer(&cx, &ev(PointerKind::Drag, at(120.0, 90.0))); + assert_eq!(a[0], Action::Begin("Convert Direction Point".into())); + assert!(matches!(&a[1], Action::Preview(c, p) if c == "path.convertAnchor" && p["to"] == serde_json::json!([120.0, 90.0]))); + assert_eq!(conv.pointer(&cx, &ev(PointerKind::Up, at(120.0, 90.0))), vec![Action::Commit]); +} diff --git a/crates/ui-egui/src/canvas.rs b/crates/ui-egui/src/canvas.rs index 64a3d80..0c87bf3 100644 --- a/crates/ui-egui/src/canvas.rs +++ b/crates/ui-egui/src/canvas.rs @@ -656,7 +656,7 @@ fn draw_selection(app: &DesignApp, painter: &egui::Painter, xf: &Xf, doc: &Docum painter.rect_stroke(gb, 0.0, Stroke::new(1.0, Color32::from_rgb(196, 111, 43)), StrokeKind::Middle); } } - let direct = matches!(app.session.tool_id(), "directSelection" | "pen"); + let direct = matches!(app.session.tool_id(), "directSelection" | "pen" | "addAnchor" | "deleteAnchor" | "convertDirection"); if direct { for id in &sel.items { let (Some(it), Some((a, _))) = (doc.item(*id), item_canvas_xf(doc, layout, *id)) else { continue }; diff --git a/docs/parity.md b/docs/parity.md index 4df52e6..c38ed5e 100644 --- a/docs/parity.md +++ b/docs/parity.md @@ -9,7 +9,7 @@ Each row of the InDesign feature catalogue is scored **D** (done), **P** (partia | Priority | Features | Done | Partial | Missing | Parity | |---|---|---|---|---|---| -| P0 | 102 | 93 | 9 | 0 | 96% | +| P0 | 102 | 94 | 8 | 0 | 96% | | P1 | 97 | 33 | 23 | 41 | 46% | | P2 | 76 | 8 | 7 | 61 | 15% | @@ -18,7 +18,7 @@ Each row of the InDesign feature catalogue is scored **D** (done), **P** (partia | Application shell & workspace | 65% | | Documents, pages, spreads | 71% | | Layers | 74% | -| Frames, shapes & paths | 74% | +| Frames, shapes & paths | 76% | | Transform | 78% | | Fill, stroke, colour | 69% | | Effects & transparency | 50% | @@ -35,7 +35,7 @@ Each row of the InDesign feature catalogue is scored **D** (done), **P** (partia | Undo, history, saving | 100% | | Accessibility | 20% | -Open P0 items: Dockable panels, Preferences, Pen, add/delete anchor, convert direction, Raster formats, Vector/page formats (PDF/AI/SVG/EPS place), Display performance, OpenType features, Export to PDF (print), Display performance. +Open P0 items: Dockable panels, Preferences, Raster formats, Vector/page formats (PDF/AI/SVG/EPS place), Display performance, OpenType features, Export to PDF (print), Display performance. ## Rows @@ -98,7 +98,7 @@ Open P0 items: Dockable panels, Preferences, Pen, add/delete anchor, convert dir | Frames, shapes & paths | Rectangle/Ellipse/Polygon shapes | P0 | D | | Frames, shapes & paths | Polygon settings | P0 | D | | Frames, shapes & paths | Line tool | P0 | D | -| Frames, shapes & paths | Pen, add/delete anchor, convert direction | P0 | P | +| Frames, shapes & paths | Pen, add/delete anchor, convert direction | P0 | D | | Frames, shapes & paths | Pencil, Smooth, Erase | P1 | M | | Frames, shapes & paths | Scissors | P1 | M | | Frames, shapes & paths | Content type | P0 | D |