mirror of
https://github.com/storytold/figcraft.git
synced 2026-10-24 10:27:00 -06:00
M4: vector edit mode tools: pen branching, bend, paint bucket, lasso, cut, mirroring, point panel (#2)
- Edit-mode toolbar (Move V, Pen P, Bend hold Cmd, Paint bucket Shift+B, Lasso Q, Cut X, Done) replaces the tools while a vector is edited. - Pen in edit mode continues open ends, branches from any point (vector networks), adds points on segments and closes paths into fill regions; dragging pulls out mirrored handles. - Paint bucket fills or unfills any closed area: faces of the network (half-edge walk, nested pieces become holes), with a hatched preview of the area under the pointer. - Bend tool / Cmd: bend segments, pull handles out of a point, click a smooth point to sharpen it. - Lasso and marquee point selection, segment selection (shift adds), cut at a segment or a point. - Per-point handle mirroring (none / angle / angle and length, inferred for existing files), Alt-drag breaks it; smooth/sharp points; join and merge selected points (Cmd+J, Shift+Cmd+J). - Delete opens the path, Shift+Delete heals it; arrows nudge points; Cmd+A selects all points. - Design panel shows the selected points' position, corner radius and mirroring. - Every gesture is one undo step; edit mode ends when its layer is undone away. - Paths that end on their start without a ClosePath (ellipse outlines) stay closed and filled. - New commands (vector.edit.tool/pen/paint/cut/lasso/mirroring/smooth/position/nudge/connect/...) for the CLI, control channel and MCP; docs updated. - Clippy: simplify a few boolean expressions flagged by the current toolchain. Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
@@ -88,7 +88,7 @@
|
||||
<tr>
|
||||
<td width="50%" valign="top">
|
||||
<h3>Vectors without compromise</h3>
|
||||
Real vector networks: the Pen and Pencil tools, vector edit mode with bend, split, heal-on-delete and handle mirroring, boolean groups (union, subtract, intersect, exclude), flatten, outline stroke, arrowheads, dashes and per-side strokes. Rectangles with smooth (squircle) corners, polygons, stars and arcs.
|
||||
Real vector networks: the Pen and Pencil tools, vector edit mode with its own tools (pen that extends and branches paths, bend, paint bucket fills for any closed area, lasso, cut), point panel with handle mirroring and per-point corner radius, join, split and heal-on-delete, boolean groups (union, subtract, intersect, exclude), flatten, outline stroke, arrowheads, dashes and per-side strokes. Rectangles with smooth (squircle) corners, polygons, stars and arcs.
|
||||
</td>
|
||||
<td width="50%" valign="top">
|
||||
<h3>Paints and effects</h3>
|
||||
|
||||
+12
-7
@@ -9,7 +9,7 @@ continuous Claude Opus 5.5 agent work (including builds and the CI gate), for **
|
||||
**4–6 parallel agents** on disjoint crates. They are counted from the remaining work in the parity
|
||||
table below and updated as work lands.
|
||||
|
||||
_Last updated: 2026-10-09 (end of day 1: foundation, renderer, engine, UI3 shell)._
|
||||
_Last updated: 2026-10-10 (vector edit mode tools)._
|
||||
|
||||
## Where we are
|
||||
|
||||
@@ -38,8 +38,12 @@ dimension measured side by side.
|
||||
|
||||
### Where we're lacking (priority order)
|
||||
|
||||
1. **Vector edit mode** (Enter on a vector): vertex/handle editing, bend, paint bucket, lasso,
|
||||
cut/erase, vector networks with branching. 25–35 h.
|
||||
1. **Vector edit mode**: *landed:* edit-mode toolbar (move, pen, bend, paint bucket, lasso, cut),
|
||||
pen that extends, branches and closes paths (vector networks), paint bucket fill/unfill of any
|
||||
closed area (faces with holes), point/segment selection, handle mirroring (none/angle/angle and
|
||||
length) with a Point panel (position, corner radius, mirroring), smooth/sharp points, join and
|
||||
merge, delete vs delete-and-heal, nudging, one undo step per gesture. *Left:* eraser, variable
|
||||
width strokes, shape builder, transform box for several points, point snapping. 12–18 h.
|
||||
2. **Text editing depth**: *landed:* OpenType features panel, small caps, lists, title case,
|
||||
truncation/max lines, font picker with previews and recents, word/line selection, IME commit.
|
||||
*Left:* inline IME preedit, variable font axes, visual QA. 8–12 h.
|
||||
@@ -62,7 +66,8 @@ dimension measured side by side.
|
||||
|
||||
### Where we're going
|
||||
|
||||
- **Next:** vector edit mode (1) and text depth (2) in parallel with the fidelity pass (7).
|
||||
- **Next:** text depth (2) and auto layout depth (3) in parallel with the fidelity pass (7); the
|
||||
rest of vector editing (1).
|
||||
- **Then:** auto layout/components/prototyping depth (3–5), interop (6).
|
||||
- **Alpha:** release builds on every push to `release` (packaging in progress), demo files, docs.
|
||||
|
||||
@@ -111,7 +116,7 @@ Scores by behaviour, 0–100%. Hours are what remains (one agent).
|
||||
| Canvas & navigation | 6 | 60% | rulers/guides dragging, pixel preview, minimap-free perf at scale | 8–12 |
|
||||
| Selection & transform | 8 | 55% | rotated multi-select handles, scale tool polish, measure hover polish, nudge-resize | 12–18 |
|
||||
| Layers panel | 5 | 55% | search/filter, multi-select ranges, collapse all, thumbnails, drag polish | 8–12 |
|
||||
| Shapes & vector | 8 | 30% | vector edit mode, bend/paint bucket, live boolean editing, arc handles on canvas | 30–40 |
|
||||
| Shapes & vector | 8 | 55% | eraser, variable width, shape builder, multi-point transform box, point snapping, live boolean editing, arc handles on canvas | 15–22 |
|
||||
| Paint | 7 | 55% | gradient handles on canvas, image crop mode, document colours, colour models | 12–18 |
|
||||
| Strokes | 3 | 55% | arrowhead set, dash/gap UI, per-side UI, stroke styles | 5–8 |
|
||||
| Effects | 4 | 60% | effect styles UI, glass/texture fidelity, shadow spread on non-rects exactness | 6–10 |
|
||||
@@ -125,7 +130,7 @@ Scores by behaviour, 0–100%. Hours are what remains (one agent).
|
||||
| Dev mode / inspect | 2 | 35% | measurements on canvas, code variants (Swift/Compose), assets | 6–10 |
|
||||
| Comments, versions, misc | 3 | 25% | comments, version history UI, plugins (WASM) | 15–25 |
|
||||
|
||||
Weighted feature depth ≈ **43%**.
|
||||
Weighted feature depth ≈ **45%**.
|
||||
|
||||
## Milestones
|
||||
|
||||
@@ -136,7 +141,7 @@ Weighted feature depth ≈ **43%**.
|
||||
| M1 Core editing completeness (tools, panels, menus, clipboard, files) | ~60% |
|
||||
| M2 Layout depth | ~45% |
|
||||
| M3 Components & styles depth | ~40% |
|
||||
| M4 Vector & text depth | ~30% |
|
||||
| M4 Vector & text depth | ~45% |
|
||||
| M5 Prototype & present | ~30% |
|
||||
| M6 Interop & dev mode | ~35% |
|
||||
| M7 Release (signed builds for all platforms, README screenshots) | started |
|
||||
|
||||
@@ -16,6 +16,7 @@ pub mod proto;
|
||||
pub mod styles;
|
||||
pub mod text;
|
||||
pub mod vector;
|
||||
pub mod vector_faces;
|
||||
|
||||
pub use comments::*;
|
||||
pub use figcraft_color as color;
|
||||
@@ -25,6 +26,7 @@ pub use props::*;
|
||||
pub use styles::*;
|
||||
pub use text::*;
|
||||
pub use vector::*;
|
||||
pub use vector_faces::Face;
|
||||
|
||||
use figcraft_color::Color;
|
||||
use figcraft_geom::{Affine, Point, Rect};
|
||||
|
||||
+132
-2
@@ -4,6 +4,32 @@
|
||||
use figcraft_geom::{BezPath, Point};
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
/// How a vertex's two bezier handles follow each other when one is dragged.
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize, Default)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub enum HandleMirroring {
|
||||
/// Handles move independently (a corner).
|
||||
#[default]
|
||||
None,
|
||||
/// The other handle stays opposite but keeps its own length.
|
||||
Angle,
|
||||
/// The other handle mirrors angle and length (a symmetric smooth point).
|
||||
AngleAndLength,
|
||||
}
|
||||
|
||||
impl HandleMirroring {
|
||||
pub fn id(self) -> &'static str {
|
||||
match self {
|
||||
HandleMirroring::None => "none",
|
||||
HandleMirroring::Angle => "angle",
|
||||
HandleMirroring::AngleAndLength => "angleAndLength",
|
||||
}
|
||||
}
|
||||
pub fn from_id(s: &str) -> Option<Self> {
|
||||
[HandleMirroring::None, HandleMirroring::Angle, HandleMirroring::AngleAndLength].into_iter().find(|m| m.id().eq_ignore_ascii_case(s))
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Serialize, Deserialize)]
|
||||
pub struct Vertex {
|
||||
pub x: f64,
|
||||
@@ -11,11 +37,14 @@ pub struct Vertex {
|
||||
/// Per-vertex corner radius (0 = sharp).
|
||||
#[serde(default)]
|
||||
pub radius: f64,
|
||||
/// Handle mirroring; `None` infers it from the handles (see [`VectorNetwork::mirroring`]).
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub mirror: Option<HandleMirroring>,
|
||||
}
|
||||
|
||||
impl Vertex {
|
||||
pub fn new(x: f64, y: f64) -> Self {
|
||||
Self { x, y, radius: 0.0 }
|
||||
Self { x, y, radius: 0.0, mirror: None }
|
||||
}
|
||||
pub fn point(&self) -> Point {
|
||||
Point::new(self.x, self.y)
|
||||
@@ -92,9 +121,27 @@ impl VectorNetwork {
|
||||
net.vertices.push(Vertex::new(p.x, p.y));
|
||||
net.vertices.len() - 1
|
||||
};
|
||||
// A subpath that ends back on its start point without a ClosePath (e.g. ellipse outlines)
|
||||
// is closed too, so it keeps its fill.
|
||||
let close_open =
|
||||
|net: &mut VectorNetwork, loop_segs: &mut Vec<usize>, all_loops: &mut Vec<Vec<usize>>, first_v: Option<usize>, cur_v: Option<usize>| {
|
||||
let (Some(a), Some(f)) = (cur_v, first_v) else { return };
|
||||
let (Some(pa), Some(pf)) = (net.vertices.get(a).map(Vertex::point), net.vertices.get(f).map(Vertex::point)) else { return };
|
||||
if a == f || loop_segs.len() < 2 || (pa - pf).hypot() > 1e-6 || a + 1 != net.vertices.len() {
|
||||
return;
|
||||
}
|
||||
for s in net.segments.iter_mut() {
|
||||
if s.b == a {
|
||||
s.b = f;
|
||||
}
|
||||
}
|
||||
net.vertices.pop();
|
||||
all_loops.push(std::mem::take(loop_segs));
|
||||
};
|
||||
for el in path.elements() {
|
||||
match *el {
|
||||
PathEl::MoveTo(p) => {
|
||||
close_open(&mut net, &mut loop_segs, &mut all_loops, first_v, cur_v);
|
||||
loop_segs.clear();
|
||||
let v = push_v(&mut net, p);
|
||||
first_v = Some(v);
|
||||
@@ -129,7 +176,9 @@ impl VectorNetwork {
|
||||
let (Some(a), Some(f)) = (cur_v, first_v) else { continue };
|
||||
let close_pt = net.vertices.get(a).map(Vertex::point);
|
||||
let first_pt = net.vertices.get(f).map(Vertex::point);
|
||||
if close_pt.is_some() && close_pt == first_pt && a != f {
|
||||
// The end vertex lands on the start (up to float noise): merge them.
|
||||
let same = matches!((close_pt, first_pt), (Some(c), Some(f)) if (c - f).hypot() <= 1e-6);
|
||||
if same && a != f {
|
||||
// Merge the duplicate end vertex into the first one.
|
||||
for s in net.segments.iter_mut() {
|
||||
if s.b == a {
|
||||
@@ -150,12 +199,93 @@ impl VectorNetwork {
|
||||
}
|
||||
}
|
||||
}
|
||||
close_open(&mut net, &mut loop_segs, &mut all_loops, first_v, cur_v);
|
||||
if !all_loops.is_empty() {
|
||||
net.regions.push(Region { loops: all_loops, winding: WindingRule::NonZero });
|
||||
}
|
||||
net
|
||||
}
|
||||
|
||||
/// Segments touching vertex `v`: (segment index, `true` when `v` is the segment's `a` end).
|
||||
pub fn incident(&self, v: usize) -> Vec<(usize, bool)> {
|
||||
self.segments
|
||||
.iter()
|
||||
.enumerate()
|
||||
.filter_map(|(i, s)| {
|
||||
if s.a == v {
|
||||
Some((i, true))
|
||||
} else if s.b == v {
|
||||
Some((i, false))
|
||||
} else {
|
||||
None
|
||||
}
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// The handle (relative to its vertex) at one end of a segment.
|
||||
pub fn handle(&self, seg: usize, start: bool) -> Option<(f64, f64)> {
|
||||
let s = self.segments.get(seg)?;
|
||||
Some(if start { s.ta } else { s.tb })
|
||||
}
|
||||
|
||||
pub fn set_handle(&mut self, seg: usize, start: bool, t: (f64, f64)) {
|
||||
if let Some(s) = self.segments.get_mut(seg) {
|
||||
if start {
|
||||
s.ta = t;
|
||||
} else {
|
||||
s.tb = t;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// The vertex's handle mirroring: its explicit setting, else inferred from its two handles
|
||||
/// (opposite and equal → angle and length, opposite → angle, otherwise none).
|
||||
pub fn mirroring(&self, v: usize) -> HandleMirroring {
|
||||
if let Some(m) = self.vertices.get(v).and_then(|x| x.mirror) {
|
||||
return m;
|
||||
}
|
||||
let inc = self.incident(v);
|
||||
let [(s1, a1), (s2, a2)] = inc.as_slice() else { return HandleMirroring::None };
|
||||
let (Some(h1), Some(h2)) = (self.handle(*s1, *a1), self.handle(*s2, *a2)) else { return HandleMirroring::None };
|
||||
let (l1, l2) = (h1.0.hypot(h1.1), h2.0.hypot(h2.1));
|
||||
if l1 < 1e-9 || l2 < 1e-9 {
|
||||
return HandleMirroring::None;
|
||||
}
|
||||
// Opposite directions: the cross product is ~0 and the dot product negative.
|
||||
let cross = (h1.0 * h2.1 - h1.1 * h2.0) / (l1 * l2);
|
||||
let dot = (h1.0 * h2.0 + h1.1 * h2.1) / (l1 * l2);
|
||||
if cross.abs() > 1e-3 || dot > -0.999 {
|
||||
HandleMirroring::None
|
||||
} else if (l1 - l2).abs() <= 1e-6 * l1.max(l2).max(1.0) {
|
||||
HandleMirroring::AngleAndLength
|
||||
} else {
|
||||
HandleMirroring::Angle
|
||||
}
|
||||
}
|
||||
|
||||
/// Segment `i` as a cubic bezier.
|
||||
pub fn cubic(&self, i: usize) -> Option<figcraft_geom::kurbo::CubicBez> {
|
||||
let (a, c1, c2, b) = self.seg_points(self.segments.get(i)?)?;
|
||||
Some(figcraft_geom::kurbo::CubicBez::new(a, c1, c2, b))
|
||||
}
|
||||
|
||||
/// One closed loop of segments as a path.
|
||||
pub fn loop_path(&self, lp: &[usize]) -> BezPath {
|
||||
let mut path = BezPath::new();
|
||||
self.append_loop(&mut path, lp);
|
||||
path
|
||||
}
|
||||
|
||||
/// Whether the rendered fill covers `p` (the renderer fills every region with one rule: even-odd
|
||||
/// when any region asks for it).
|
||||
pub fn fill_contains(&self, p: Point) -> bool {
|
||||
use figcraft_geom::Shape;
|
||||
let path = self.fill_path();
|
||||
let w = path.winding(p);
|
||||
if self.regions.iter().any(|r| r.winding == WindingRule::EvenOdd) { w % 2 != 0 } else { w != 0 }
|
||||
}
|
||||
|
||||
fn seg_points(&self, s: &Segment) -> Option<(Point, Point, Point, Point)> {
|
||||
let a = self.vertices.get(s.a)?.point();
|
||||
let b = self.vertices.get(s.b)?.point();
|
||||
|
||||
@@ -0,0 +1,517 @@
|
||||
//! Faces of a vector network: the closed areas between its segments, which the paint bucket fills
|
||||
//! and unfills.
|
||||
//!
|
||||
//! Faces come from a half-edge walk of the planar graph: at each vertex the walk turns to the next
|
||||
//! segment in angular order, which traces every bounded area once (positive signed area) and the
|
||||
//! outside of each connected piece once (negative). Pieces nested inside a face become its holes.
|
||||
//! Segments are assumed not to cross each other; crossing networks still give well-formed (if
|
||||
//! approximate) faces.
|
||||
|
||||
use figcraft_geom::kurbo::{ParamCurve, ParamCurveDeriv, PathEl};
|
||||
use figcraft_geom::{BezPath, Point, Rect, Shape, Vec2};
|
||||
|
||||
use crate::vector::{Region, VectorNetwork, WindingRule};
|
||||
|
||||
/// A bounded area: its outer loop first, then the outer loops of pieces nested inside it.
|
||||
#[derive(Clone, Debug, PartialEq)]
|
||||
pub struct Face {
|
||||
/// Loops of segment indices.
|
||||
pub loops: Vec<Vec<usize>>,
|
||||
/// Enclosed area (the outer loop's minus the holes').
|
||||
pub area: f64,
|
||||
/// The face's outline (all loops); fill it even-odd.
|
||||
pub path: BezPath,
|
||||
}
|
||||
|
||||
impl Face {
|
||||
pub fn contains(&self, p: Point) -> bool {
|
||||
self.path.winding(p) % 2 != 0
|
||||
}
|
||||
|
||||
/// The fill region for this face.
|
||||
pub fn region(&self) -> Region {
|
||||
Region { loops: self.loops.clone(), winding: if self.loops.len() > 1 { WindingRule::EvenOdd } else { WindingRule::NonZero } }
|
||||
}
|
||||
|
||||
/// Some point strictly inside the face (not in a hole).
|
||||
pub fn interior_point(&self) -> Option<Point> {
|
||||
interior_point(&self.path)
|
||||
}
|
||||
}
|
||||
|
||||
/// A traced cycle of half-edges (segment index, forward?).
|
||||
struct Cycle {
|
||||
half_edges: Vec<(usize, bool)>,
|
||||
area: f64,
|
||||
component: usize,
|
||||
}
|
||||
|
||||
impl VectorNetwork {
|
||||
/// Every bounded face, holes included.
|
||||
pub fn faces(&self) -> Vec<Face> {
|
||||
let cycles = self.cycles();
|
||||
// Each connected piece's outside: its most negative cycle.
|
||||
let mut outside: std::collections::BTreeMap<usize, &Cycle> = std::collections::BTreeMap::new();
|
||||
for c in cycles.iter().filter(|c| c.area < -AREA_EPS) {
|
||||
let e = outside.entry(c.component).or_insert(c);
|
||||
if c.area < e.area {
|
||||
*e = c;
|
||||
}
|
||||
}
|
||||
// A piece's outer boundary (loops + path), only for pieces that enclose any area.
|
||||
let boundaries: Vec<(usize, Vec<Vec<usize>>, BezPath, f64)> = outside
|
||||
.iter()
|
||||
.map(|(comp, c)| {
|
||||
let loops = self.split_loops(&c.half_edges);
|
||||
let path = self.loops_path(&loops);
|
||||
(*comp, loops, path, -c.area)
|
||||
})
|
||||
.filter(|(_, loops, _, _)| !loops.is_empty())
|
||||
.collect();
|
||||
let mut faces = Vec::new();
|
||||
for c in cycles.iter().filter(|c| c.area > AREA_EPS) {
|
||||
let loops = self.split_loops(&c.half_edges);
|
||||
if loops.is_empty() {
|
||||
continue;
|
||||
}
|
||||
let outer = self.loops_path(&loops);
|
||||
// Pieces directly inside this face (not inside another piece that is) are its holes.
|
||||
let inside: Vec<&(usize, Vec<Vec<usize>>, BezPath, f64)> = boundaries
|
||||
.iter()
|
||||
.filter(|(comp, ..)| *comp != c.component)
|
||||
.filter(|(comp, ..)| self.component_point(&cycles, *comp).is_some_and(|p| outer.winding(p) % 2 != 0))
|
||||
.collect();
|
||||
let mut face = Face { loops, area: c.area, path: outer };
|
||||
for (comp, hl, hp, ha) in &inside {
|
||||
let nested = inside
|
||||
.iter()
|
||||
.any(|(other, _, op, _)| other != comp && self.component_point(&cycles, *comp).is_some_and(|p| op.winding(p) % 2 != 0));
|
||||
if nested {
|
||||
continue;
|
||||
}
|
||||
face.loops.extend(hl.iter().cloned());
|
||||
face.path.extend(hp.elements().iter().copied());
|
||||
face.area -= ha;
|
||||
}
|
||||
faces.push(face);
|
||||
}
|
||||
faces
|
||||
}
|
||||
|
||||
/// The smallest face containing `p`.
|
||||
pub fn face_at(&self, p: Point) -> Option<Face> {
|
||||
self.faces().into_iter().filter(|f| f.contains(p)).min_by(|a, b| a.area.total_cmp(&b.area))
|
||||
}
|
||||
|
||||
/// The paint bucket: fill the face under `p`, or unfill it when it's filled. Returns the new
|
||||
/// state (`Some(true)` = filled), or `None` when `p` isn't inside a closed area.
|
||||
///
|
||||
/// Regions that aren't single faces (e.g. from a converted shape or an import) are first
|
||||
/// re-expressed as one region per filled face, so any face can be toggled on its own.
|
||||
pub fn paint(&mut self, p: Point) -> Option<bool> {
|
||||
let face = self.face_at(p)?;
|
||||
let key = |loops: &[Vec<usize>]| -> Vec<Vec<usize>> {
|
||||
let mut k: Vec<Vec<usize>> = loops
|
||||
.iter()
|
||||
.map(|l| {
|
||||
let mut l = l.clone();
|
||||
l.sort_unstable();
|
||||
l
|
||||
})
|
||||
.collect();
|
||||
k.sort();
|
||||
k
|
||||
};
|
||||
let faces = self.faces();
|
||||
let face_keys: Vec<Vec<Vec<usize>>> = faces.iter().map(|f| key(&f.loops)).collect();
|
||||
if !self.regions.iter().all(|r| face_keys.contains(&key(&r.loops))) {
|
||||
let filled: Vec<Region> = faces.iter().filter(|f| f.interior_point().is_some_and(|q| self.fill_contains(q))).map(Face::region).collect();
|
||||
self.regions = filled;
|
||||
}
|
||||
let k = key(&face.loops);
|
||||
if let Some(i) = self.regions.iter().position(|r| key(&r.loops) == k) {
|
||||
self.regions.remove(i);
|
||||
Some(false)
|
||||
} else {
|
||||
self.regions.push(face.region());
|
||||
Some(true)
|
||||
}
|
||||
}
|
||||
|
||||
/// Trace every half-edge cycle.
|
||||
fn cycles(&self) -> Vec<Cycle> {
|
||||
let n = self.segments.len();
|
||||
let nv = self.vertices.len();
|
||||
let valid = |i: usize| self.segments.get(i).is_some_and(|s| s.a < nv && s.b < nv);
|
||||
// Outgoing half-edges per vertex, sorted by angle.
|
||||
let mut out: Vec<Vec<(f64, f64, usize, bool)>> = vec![Vec::new(); nv];
|
||||
for i in (0..n).filter(|i| valid(*i)) {
|
||||
for fwd in [true, false] {
|
||||
let Some((origin, dir, along)) = self.half_edge_dir(i, fwd) else { continue };
|
||||
if let Some(list) = out.get_mut(origin) {
|
||||
list.push((dir.y.atan2(dir.x), along.y.atan2(along.x), i, fwd));
|
||||
}
|
||||
}
|
||||
}
|
||||
for list in &mut out {
|
||||
list.sort_by(|a, b| a.0.total_cmp(&b.0).then(a.1.total_cmp(&b.1)));
|
||||
}
|
||||
let origin = |i: usize, fwd: bool| self.segments.get(i).map(|s| if fwd { s.a } else { s.b });
|
||||
let dest = |i: usize, fwd: bool| self.segments.get(i).map(|s| if fwd { s.b } else { s.a });
|
||||
// The walk: arrive at v along (i, fwd); leave by the edge before the twin in angular order.
|
||||
let next = |i: usize, fwd: bool| -> Option<(usize, bool)> {
|
||||
let v = dest(i, fwd)?;
|
||||
let list = out.get(v)?;
|
||||
let k = list.iter().position(|e| e.2 == i && e.3 != fwd)?;
|
||||
let e = list.get((k + list.len() - 1) % list.len())?;
|
||||
Some((e.2, e.3))
|
||||
};
|
||||
let comp = self.components();
|
||||
let idx = |i: usize, fwd: bool| i * 2 + usize::from(!fwd);
|
||||
let mut seen = vec![false; n * 2];
|
||||
let mut cycles = Vec::new();
|
||||
for i in (0..n).filter(|i| valid(*i)) {
|
||||
for fwd in [true, false] {
|
||||
if seen.get(idx(i, fwd)).copied().unwrap_or(true) {
|
||||
continue;
|
||||
}
|
||||
let mut half_edges = Vec::new();
|
||||
let mut cur = (i, fwd);
|
||||
let mut closed = false;
|
||||
for _ in 0..=n * 2 {
|
||||
if let Some(s) = seen.get_mut(idx(cur.0, cur.1)) {
|
||||
*s = true;
|
||||
}
|
||||
half_edges.push(cur);
|
||||
let Some(nx) = next(cur.0, cur.1) else { break };
|
||||
if nx == (i, fwd) {
|
||||
closed = true;
|
||||
break;
|
||||
}
|
||||
if seen.get(idx(nx.0, nx.1)).copied().unwrap_or(true) {
|
||||
break;
|
||||
}
|
||||
cur = nx;
|
||||
}
|
||||
if !closed {
|
||||
continue;
|
||||
}
|
||||
let area = self.half_edge_path(&half_edges).area();
|
||||
let component = origin(i, fwd).and_then(|v| comp.get(v).copied()).unwrap_or(0);
|
||||
cycles.push(Cycle { half_edges, area: if area.is_finite() { area } else { 0.0 }, component });
|
||||
}
|
||||
}
|
||||
cycles
|
||||
}
|
||||
|
||||
/// Where half-edge (i, fwd) starts, its initial direction and a direction a little along it
|
||||
/// (tie-break for tangent curves).
|
||||
fn half_edge_dir(&self, i: usize, fwd: bool) -> Option<(usize, Vec2, Vec2)> {
|
||||
let s = self.segments.get(i)?;
|
||||
let c = self.cubic(i)?;
|
||||
let (origin, p0, d, along) = if fwd {
|
||||
(s.a, c.p0, c.deriv().eval(0.0).to_vec2(), c.eval(0.25) - c.p0)
|
||||
} else {
|
||||
(s.b, c.p3, -c.deriv().eval(1.0).to_vec2(), c.eval(0.75) - c.p3)
|
||||
};
|
||||
let fallback = if fwd { [c.p2 - p0, c.p3 - p0] } else { [c.p1 - p0, c.p0 - p0] };
|
||||
// A zero-length segment gets a nominal direction; the walk stays a permutation, so its
|
||||
// cycles still close (and its angle is irrelevant at the usual two-segment vertex).
|
||||
let nominal = if fwd { Vec2::new(1.0, 0.0) } else { Vec2::new(-1.0, 0.0) };
|
||||
let dir = std::iter::once(d).chain(fallback).find(|v| v.hypot() > 1e-9).unwrap_or(nominal);
|
||||
Some((origin, dir, if along.hypot() > 1e-9 { along } else { dir }))
|
||||
}
|
||||
|
||||
fn half_edge_path(&self, half_edges: &[(usize, bool)]) -> BezPath {
|
||||
let mut path = BezPath::new();
|
||||
for (k, (i, fwd)) in half_edges.iter().enumerate() {
|
||||
let Some(c) = self.cubic(*i) else { continue };
|
||||
let c = if *fwd { c } else { figcraft_geom::kurbo::CubicBez::new(c.p3, c.p2, c.p1, c.p0) };
|
||||
if k == 0 {
|
||||
path.move_to(c.p0);
|
||||
}
|
||||
path.curve_to(c.p1, c.p2, c.p3);
|
||||
}
|
||||
path.close_path();
|
||||
path
|
||||
}
|
||||
|
||||
/// A cycle's segments as closed loops: segments walked twice (dangling paths, bridges between
|
||||
/// loops) are dropped and what's left is chained into loops.
|
||||
fn split_loops(&self, half_edges: &[(usize, bool)]) -> Vec<Vec<usize>> {
|
||||
let count = |i: usize| half_edges.iter().filter(|h| h.0 == i).count();
|
||||
let kept: Vec<(usize, bool)> = half_edges.iter().copied().filter(|h| count(h.0) == 1).collect();
|
||||
let ends = |h: (usize, bool)| self.segments.get(h.0).map(|s| if h.1 { (s.a, s.b) } else { (s.b, s.a) });
|
||||
let mut used = vec![false; kept.len()];
|
||||
let mut loops = Vec::new();
|
||||
for start in 0..kept.len() {
|
||||
if used.get(start).copied().unwrap_or(true) {
|
||||
continue;
|
||||
}
|
||||
let Some(first) = kept.get(start).copied() else { continue };
|
||||
let Some((v0, mut v)) = ends(first) else { continue };
|
||||
if let Some(u) = used.get_mut(start) {
|
||||
*u = true;
|
||||
}
|
||||
let mut lp = vec![first.0];
|
||||
let mut pos = start;
|
||||
while v != v0 {
|
||||
// Prefer the next half-edge in walk order that continues from `v`.
|
||||
let found = (1..=kept.len())
|
||||
.map(|d| (pos + d) % kept.len())
|
||||
.find(|j| !used.get(*j).copied().unwrap_or(true) && kept.get(*j).and_then(|h| ends(*h)).is_some_and(|(a, _)| a == v));
|
||||
let Some(j) = found else { break };
|
||||
if let Some(u) = used.get_mut(j) {
|
||||
*u = true;
|
||||
}
|
||||
let Some(h) = kept.get(j).copied() else { break };
|
||||
lp.push(h.0);
|
||||
v = ends(h).map(|e| e.1).unwrap_or(v0);
|
||||
pos = j;
|
||||
}
|
||||
if v == v0 {
|
||||
loops.push(lp);
|
||||
}
|
||||
}
|
||||
loops
|
||||
}
|
||||
|
||||
fn loops_path(&self, loops: &[Vec<usize>]) -> BezPath {
|
||||
let mut path = BezPath::new();
|
||||
for lp in loops {
|
||||
path.extend(self.loop_path(lp).elements().iter().copied());
|
||||
}
|
||||
path
|
||||
}
|
||||
|
||||
/// Connected piece of every vertex.
|
||||
fn components(&self) -> Vec<usize> {
|
||||
let nv = self.vertices.len();
|
||||
let mut parent: Vec<usize> = (0..nv).collect();
|
||||
fn find(parent: &mut [usize], mut x: usize) -> usize {
|
||||
for _ in 0..parent.len() {
|
||||
let p = parent.get(x).copied().unwrap_or(x);
|
||||
if p == x {
|
||||
break;
|
||||
}
|
||||
let gp = parent.get(p).copied().unwrap_or(p);
|
||||
if let Some(px) = parent.get_mut(x) {
|
||||
*px = gp;
|
||||
}
|
||||
x = gp;
|
||||
}
|
||||
x
|
||||
}
|
||||
for s in &self.segments {
|
||||
if s.a < nv && s.b < nv {
|
||||
let (ra, rb) = (find(&mut parent, s.a), find(&mut parent, s.b));
|
||||
if let Some(r) = parent.get_mut(ra) {
|
||||
*r = rb;
|
||||
}
|
||||
}
|
||||
}
|
||||
(0..nv).map(|v| find(&mut parent, v)).collect()
|
||||
}
|
||||
|
||||
/// A vertex of piece `comp` (for nesting tests).
|
||||
fn component_point(&self, cycles: &[Cycle], comp: usize) -> Option<Point> {
|
||||
let c = cycles.iter().find(|c| c.component == comp)?;
|
||||
let (i, fwd) = c.half_edges.first().copied()?;
|
||||
let s = self.segments.get(i)?;
|
||||
self.vertices.get(if fwd { s.a } else { s.b }).map(|v| v.point())
|
||||
}
|
||||
}
|
||||
|
||||
const AREA_EPS: f64 = 1e-9;
|
||||
|
||||
/// A point inside a closed path under the even-odd rule: the middle of the widest span on a few
|
||||
/// horizontal scanlines.
|
||||
pub fn interior_point(path: &BezPath) -> Option<Point> {
|
||||
let bb: Rect = path.bounding_box();
|
||||
if !(bb.width() > 0.0 && bb.height() > 0.0 && bb.width().is_finite() && bb.height().is_finite()) {
|
||||
return None;
|
||||
}
|
||||
let mut lines: Vec<(Point, Point)> = Vec::new();
|
||||
let (mut start, mut last) = (Point::ZERO, Point::ZERO);
|
||||
figcraft_geom::kurbo::flatten(path.iter(), bb.width().max(bb.height()) * 1e-3, |el| match el {
|
||||
PathEl::MoveTo(p) => {
|
||||
start = p;
|
||||
last = p;
|
||||
}
|
||||
PathEl::LineTo(p) => {
|
||||
lines.push((last, p));
|
||||
last = p;
|
||||
}
|
||||
PathEl::ClosePath => {
|
||||
lines.push((last, start));
|
||||
last = start;
|
||||
}
|
||||
_ => {}
|
||||
});
|
||||
let mut best: Option<(f64, Point)> = None;
|
||||
for k in [0.5, 0.3, 0.7, 0.15, 0.85, 0.05, 0.95] {
|
||||
// An irrational nudge keeps the scanline off vertices.
|
||||
let y = bb.y0 + bb.height() * (k + 0.000_123_456_7);
|
||||
let mut xs: Vec<f64> = lines
|
||||
.iter()
|
||||
.filter(|(p, q)| (p.y <= y && y < q.y) || (q.y <= y && y < p.y))
|
||||
.map(|(p, q)| p.x + (y - p.y) * (q.x - p.x) / (q.y - p.y))
|
||||
.filter(|x| x.is_finite())
|
||||
.collect();
|
||||
xs.sort_by(f64::total_cmp);
|
||||
for pair in xs.chunks_exact(2) {
|
||||
let (Some(a), Some(b)) = (pair.first(), pair.get(1)) else { continue };
|
||||
let w = b - a;
|
||||
if w > best.map(|b| b.0).unwrap_or(1e-9) {
|
||||
best = Some((w, Point::new((a + b) / 2.0, y)));
|
||||
}
|
||||
}
|
||||
}
|
||||
best.map(|b| b.1)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::vector::{Segment, Vertex};
|
||||
|
||||
fn square(x: f64, y: f64, s: f64) -> VectorNetwork {
|
||||
VectorNetwork::from_points(&[Point::new(x, y), Point::new(x + s, y), Point::new(x + s, y + s), Point::new(x, y + s)], true)
|
||||
}
|
||||
|
||||
fn merge(a: &VectorNetwork, b: &VectorNetwork) -> VectorNetwork {
|
||||
let mut n = a.clone();
|
||||
let (nv, ns) = (n.vertices.len(), n.segments.len());
|
||||
n.vertices.extend(b.vertices.iter().copied());
|
||||
n.segments.extend(b.segments.iter().map(|s| Segment { a: s.a + nv, b: s.b + nv, ..*s }));
|
||||
n.regions
|
||||
.extend(b.regions.iter().map(|r| Region { loops: r.loops.iter().map(|l| l.iter().map(|i| i + ns).collect()).collect(), ..r.clone() }));
|
||||
n
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn square_has_one_face_and_the_bucket_toggles_it() {
|
||||
let mut n = square(0.0, 0.0, 100.0);
|
||||
n.regions.clear();
|
||||
let f = n.faces();
|
||||
assert_eq!(f.len(), 1);
|
||||
assert!((f[0].area - 10000.0).abs() < 1e-6);
|
||||
assert_eq!(n.paint(Point::new(50.0, 50.0)), Some(true));
|
||||
assert!(n.fill_contains(Point::new(50.0, 50.0)));
|
||||
assert_eq!(n.paint(Point::new(50.0, 50.0)), Some(false));
|
||||
assert!(n.regions.is_empty());
|
||||
assert_eq!(n.paint(Point::new(150.0, 50.0)), None, "outside every face");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_diagonal_splits_the_square_into_two_faces() {
|
||||
let mut n = square(0.0, 0.0, 100.0);
|
||||
n.regions.clear();
|
||||
n.segments.push(Segment::line(0, 2));
|
||||
let f = n.faces();
|
||||
assert_eq!(f.len(), 2);
|
||||
assert!(f.iter().all(|f| (f.area - 5000.0).abs() < 1e-6));
|
||||
assert_eq!(n.paint(Point::new(75.0, 25.0)), Some(true));
|
||||
assert!(n.fill_contains(Point::new(75.0, 25.0)));
|
||||
assert!(!n.fill_contains(Point::new(25.0, 75.0)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn unfilling_part_of_a_converted_shape_keeps_the_rest() {
|
||||
// A filled square (one region over the outline) cut by a diagonal.
|
||||
let mut n = square(0.0, 0.0, 100.0);
|
||||
n.segments.push(Segment::line(0, 2));
|
||||
assert!(n.fill_contains(Point::new(75.0, 25.0)));
|
||||
assert_eq!(n.paint(Point::new(75.0, 25.0)), Some(false));
|
||||
assert!(!n.fill_contains(Point::new(75.0, 25.0)));
|
||||
assert!(n.fill_contains(Point::new(25.0, 75.0)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn nested_pieces_become_holes() {
|
||||
let mut outer = square(0.0, 0.0, 100.0);
|
||||
outer.regions.clear();
|
||||
let mut inner = square(25.0, 25.0, 50.0);
|
||||
inner.regions.clear();
|
||||
let mut n = merge(&outer, &inner);
|
||||
let mut areas: Vec<f64> = n.faces().iter().map(|f| f.area).collect();
|
||||
areas.sort_by(f64::total_cmp);
|
||||
assert_eq!(areas.len(), 2);
|
||||
assert!((areas[0] - 2500.0).abs() < 1e-6 && (areas[1] - 7500.0).abs() < 1e-6, "{areas:?}");
|
||||
assert_eq!(n.paint(Point::new(10.0, 10.0)), Some(true));
|
||||
assert!(n.fill_contains(Point::new(10.0, 10.0)));
|
||||
assert!(!n.fill_contains(Point::new(50.0, 50.0)), "the ring has a hole");
|
||||
assert_eq!(n.paint(Point::new(50.0, 50.0)), Some(true));
|
||||
assert!(n.fill_contains(Point::new(50.0, 50.0)));
|
||||
// A third square inside the inner one is the inner face's hole, not the ring's.
|
||||
let mut core = square(45.0, 45.0, 10.0);
|
||||
core.regions.clear();
|
||||
let n3 = merge(&n, &core);
|
||||
let ring = n3.face_at(Point::new(10.0, 10.0)).unwrap();
|
||||
assert_eq!(ring.loops.len(), 2);
|
||||
assert!((ring.area - 7500.0).abs() < 1e-6);
|
||||
let mid = n3.face_at(Point::new(30.0, 30.0)).unwrap();
|
||||
assert!((mid.area - 2400.0).abs() < 1e-6);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn dangling_paths_and_bridges_dont_break_faces() {
|
||||
let mut n = square(0.0, 0.0, 100.0);
|
||||
n.regions.clear();
|
||||
// A spur into the square.
|
||||
n.vertices.push(Vertex::new(50.0, 50.0));
|
||||
n.segments.push(Segment::line(0, 4));
|
||||
// A second square inside, bridged to the spur's end.
|
||||
let inner = square(60.0, 60.0, 20.0);
|
||||
let mut m = merge(&n, &inner);
|
||||
m.regions.clear();
|
||||
m.segments.push(Segment::line(4, 5));
|
||||
let f = m.face_at(Point::new(10.0, 90.0)).unwrap();
|
||||
assert!((f.area - (10000.0 - 400.0)).abs() < 1e-6, "{}", f.area);
|
||||
assert_eq!(f.loops.len(), 2);
|
||||
assert_eq!(m.paint(Point::new(10.0, 90.0)), Some(true));
|
||||
assert!(m.fill_contains(Point::new(10.0, 90.0)));
|
||||
assert!(!m.fill_contains(Point::new(70.0, 70.0)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn curves_and_junk_networks() {
|
||||
let mut circle = figcraft_geom::kurbo::Ellipse::new((50.0, 50.0), (50.0, 50.0), 0.0).to_path(0.01);
|
||||
circle.close_path();
|
||||
let mut n = VectorNetwork::from_path(&circle);
|
||||
let close = n.vertices.iter().enumerate().any(|(i, a)| n.vertices.iter().skip(i + 1).any(|b| (a.point() - b.point()).hypot() < 1e-3));
|
||||
assert!(!close, "the closing point merges into the first");
|
||||
n.regions.clear();
|
||||
eprintln!(
|
||||
"{:?}
|
||||
{:?}",
|
||||
n.vertices, n.segments
|
||||
);
|
||||
for c in n.cycles() {
|
||||
eprintln!("cycle {:?} area {}", c.half_edges, c.area);
|
||||
}
|
||||
let f = n.faces();
|
||||
assert_eq!(f.len(), 1);
|
||||
assert!((f[0].area - std::f64::consts::PI * 2500.0).abs() < 5.0, "{}", f[0].area);
|
||||
// Without the ClosePath (kurbo's ellipse outline) it's still one closed, filled loop.
|
||||
let open = figcraft_geom::kurbo::Ellipse::new((50.0, 50.0), (50.0, 50.0), 0.0).to_path(0.01);
|
||||
let o = VectorNetwork::from_path(&open);
|
||||
assert_eq!(o.vertices.len(), n.vertices.len());
|
||||
assert_eq!(o.regions.len(), 1);
|
||||
assert!(o.fill_contains(Point::new(50.0, 50.0)));
|
||||
assert_eq!(n.paint(Point::new(50.0, 50.0)), Some(true));
|
||||
// Junk: NaN coordinates, dangling indices, self-loops, coincident points.
|
||||
let mut j = square(0.0, 0.0, 10.0);
|
||||
j.vertices.push(Vertex::new(f64::NAN, 1.0));
|
||||
j.segments.push(Segment::line(0, 4));
|
||||
j.segments.push(Segment::line(2, 99));
|
||||
j.segments.push(Segment::line(1, 1));
|
||||
j.segments.push(Segment { a: 3, b: 3, ta: (5.0, 5.0), tb: (-5.0, 5.0) });
|
||||
j.vertices.push(Vertex::new(0.0, 0.0));
|
||||
j.segments.push(Segment::line(0, 5));
|
||||
let _ = j.faces();
|
||||
let _ = j.paint(Point::new(5.0, 5.0));
|
||||
let _ = j.paint(Point::new(f64::NAN, 5.0));
|
||||
assert!(interior_point(&BezPath::new()).is_none());
|
||||
}
|
||||
}
|
||||
@@ -70,8 +70,12 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
}),
|
||||
spec!("vector.simplify", "Simplify vector", "{ids?, tolerance?}", true, simplify),
|
||||
spec!("vector.offset", "Offset path", "{ids?, offset}", true, offset_path),
|
||||
spec!("vector.join", "Join selection", "{ids?}", true, join),
|
||||
spec!("vector.smoothJoin", "Smooth join selection", "{ids?}", true, join),
|
||||
spec!("vector.join", "Join selection", "{ids?} (in vector edit mode: joins the selected points)", true, |s, p| {
|
||||
crate::vectoredit::join_in_edit_mode(s, false).unwrap_or_else(|| join(s, p))
|
||||
}),
|
||||
spec!("vector.smoothJoin", "Smooth join selection", "{ids?} (in vector edit mode: joins the selected points smoothly)", true, |s, p| {
|
||||
crate::vectoredit::join_in_edit_mode(s, true).unwrap_or_else(|| join(s, p))
|
||||
}),
|
||||
]
|
||||
}
|
||||
|
||||
|
||||
@@ -205,8 +205,8 @@ pub struct DocState {
|
||||
pub text_edit: Option<TextEdit>,
|
||||
/// Hovered node (canvas hover outline).
|
||||
pub hover: Option<NodeId>,
|
||||
/// Vector edit mode: the vector being edited and its selected vertices.
|
||||
pub vector_edit: Option<(NodeId, Vec<usize>)>,
|
||||
/// Vector edit mode: the vector being edited, its selected points and the edit tool.
|
||||
pub vector_edit: Option<vectoredit::VectorEdit>,
|
||||
pub uid: u64,
|
||||
}
|
||||
|
||||
@@ -274,6 +274,15 @@ impl DocState {
|
||||
{
|
||||
self.text_edit = None;
|
||||
}
|
||||
// Vector edit mode ends when its layer is gone (or no longer a vector, e.g. undone).
|
||||
match self.vector_edit.as_mut() {
|
||||
Some(ve) if doc.page_of(ve.id) == Some(page) => match vectoredit::network(&doc, ve.id) {
|
||||
Some(net) => ve.clamp(net),
|
||||
None => self.vector_edit = None,
|
||||
},
|
||||
Some(_) => self.vector_edit = None,
|
||||
None => {}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
+20
-165
@@ -171,7 +171,7 @@ impl PointerEvent {
|
||||
pub fn at(kind: PointerKind, x: f64, y: f64, zoom: f64) -> Self {
|
||||
Self { kind, x, y, sx: x * zoom, sy: y * zoom, shift: false, alt: false, cmd: false, ctrl: false, zoom }
|
||||
}
|
||||
fn p(&self) -> Point {
|
||||
pub(crate) fn p(&self) -> Point {
|
||||
Point::new(self.x, self.y)
|
||||
}
|
||||
fn tol(&self) -> f64 {
|
||||
@@ -218,10 +218,15 @@ pub struct Overlay {
|
||||
pub drop_target: Option<NodeId>,
|
||||
/// Auto layout insertion indicator (x0, y0, x1, y1).
|
||||
pub insert_line: Option<[f64; 4]>,
|
||||
/// Vector edit mode: what the pointer is over, the lasso being drawn and the closed area the
|
||||
/// paint bucket would fill (page-space polygons).
|
||||
pub vector_hover: Option<crate::vectoredit::VHit>,
|
||||
pub lasso: Vec<[f64; 2]>,
|
||||
pub face: Vec<Vec<[f64; 2]>>,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug)]
|
||||
struct ResizeStart {
|
||||
pub(crate) struct ResizeStart {
|
||||
/// Node id → (world transform, w, h) at the start.
|
||||
nodes: Vec<(NodeId, Affine, f64, f64)>,
|
||||
/// Single node: its world transform and size; else the AABB.
|
||||
@@ -231,7 +236,7 @@ struct ResizeStart {
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Default)]
|
||||
enum Gesture {
|
||||
pub(crate) enum Gesture {
|
||||
#[default]
|
||||
None,
|
||||
Marquee {
|
||||
@@ -280,26 +285,8 @@ enum Gesture {
|
||||
PenDrag {
|
||||
anchor: Point,
|
||||
},
|
||||
VertexDrag {
|
||||
start: Point,
|
||||
id: NodeId,
|
||||
verts: Vec<usize>,
|
||||
},
|
||||
HandleDrag {
|
||||
id: NodeId,
|
||||
seg: usize,
|
||||
start_end: bool,
|
||||
},
|
||||
Bend {
|
||||
id: NodeId,
|
||||
seg: usize,
|
||||
t: f64,
|
||||
},
|
||||
VertexMarquee {
|
||||
start: Point,
|
||||
id: NodeId,
|
||||
base: Vec<usize>,
|
||||
},
|
||||
/// Vector edit mode gestures (handled in `vectoredit`).
|
||||
Vector(crate::vectoredit::VGesture),
|
||||
}
|
||||
|
||||
/// The pen tool's in-progress path (page coordinates).
|
||||
@@ -315,7 +302,7 @@ pub struct PenPath {
|
||||
#[derive(Clone, Debug, Default)]
|
||||
pub struct ToolState {
|
||||
pub tool: ToolId,
|
||||
gesture: Gesture,
|
||||
pub(crate) gesture: Gesture,
|
||||
pub overlay: Overlay,
|
||||
pub pen: Option<PenPath>,
|
||||
/// Tool to return to after a temporary tool (space → hand).
|
||||
@@ -412,6 +399,9 @@ impl Session {
|
||||
if self.tool.tool == ToolId::Pen && t != ToolId::Pen {
|
||||
let _ = self.pen_finish();
|
||||
}
|
||||
if !matches!(t, ToolId::Move | ToolId::Scale | ToolId::Hand) && self.ds().is_ok_and(|d| d.vector_edit.is_some()) {
|
||||
let _ = crate::vectoredit::end(self);
|
||||
}
|
||||
self.tool.tool = t;
|
||||
self.tool.gesture = Gesture::None;
|
||||
self.tool.overlay = Overlay::default();
|
||||
@@ -425,17 +415,9 @@ impl Session {
|
||||
|
||||
/// Feed one pointer event to the current tool.
|
||||
pub fn pointer(&mut self, ev: &PointerEvent) -> Result<()> {
|
||||
let tool = if ev.kind == PointerKind::Down && ev.ctrl && !ev.cmd && false { ToolId::Hand } else { self.tool.tool };
|
||||
if matches!(tool, ToolId::Move | ToolId::Scale) && self.ds().map(|d| d.vector_edit.is_some()).unwrap_or(false) {
|
||||
match ev.kind {
|
||||
PointerKind::Down => return self.vector_down(ev),
|
||||
PointerKind::DoubleClick => return crate::vectoredit::end(self),
|
||||
PointerKind::Hover => {
|
||||
self.tool.overlay.cursor = Cursor::Pen;
|
||||
return Ok(());
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
let tool = self.tool.tool;
|
||||
if matches!(tool, ToolId::Move | ToolId::Scale) && self.ds().is_ok_and(|d| d.vector_edit.is_some()) && self.vector_pointer(ev)? {
|
||||
return Ok(());
|
||||
}
|
||||
match ev.kind {
|
||||
PointerKind::Hover => self.hover(ev),
|
||||
@@ -776,60 +758,7 @@ impl Session {
|
||||
textedit::set_caret(self, idx, true)?;
|
||||
Ok(())
|
||||
}
|
||||
Gesture::VertexDrag { start, id, verts } => {
|
||||
let (id, verts) = (*id, verts.clone());
|
||||
let w = self.doc()?.world_transform(id);
|
||||
let mut d = ev.p() - *start;
|
||||
if ev.shift {
|
||||
if d.x.abs() > d.y.abs() {
|
||||
d.y = 0.0;
|
||||
} else {
|
||||
d.x = 0.0;
|
||||
}
|
||||
}
|
||||
// Page delta → local delta.
|
||||
let inv = w.inverse();
|
||||
let ld = inv * (Point::ZERO + d) - inv * Point::ZERO;
|
||||
self.preview(|doc, _| {
|
||||
crate::vectoredit::move_vertices(doc, id, &verts, ld)?;
|
||||
crate::vectoredit::refit(doc, id)
|
||||
})?;
|
||||
Ok(())
|
||||
}
|
||||
Gesture::HandleDrag { id, seg, start_end } => {
|
||||
let (id, seg, se) = (*id, *seg, *start_end);
|
||||
let lp = self.doc()?.world_transform(id).inverse() * ev.p();
|
||||
let alt = ev.alt;
|
||||
self.preview(|doc, _| {
|
||||
crate::vectoredit::set_handle(doc, id, seg, se, lp, alt)?;
|
||||
crate::vectoredit::refit(doc, id)
|
||||
})?;
|
||||
Ok(())
|
||||
}
|
||||
Gesture::Bend { id, seg, t } => {
|
||||
let (id, seg, t) = (*id, *seg, *t);
|
||||
let lp = self.doc()?.world_transform(id).inverse() * ev.p();
|
||||
self.preview(|doc, _| {
|
||||
crate::vectoredit::bend(doc, id, seg, t, lp)?;
|
||||
crate::vectoredit::refit(doc, id)
|
||||
})?;
|
||||
Ok(())
|
||||
}
|
||||
Gesture::VertexMarquee { start, id, base } => {
|
||||
let r = Rect::from_points(*start, ev.p());
|
||||
self.tool.overlay.marquee = Some([r.x0, r.y0, r.x1, r.y1]);
|
||||
let mut v = base.clone();
|
||||
for i in crate::vectoredit::vertices_in(self.doc()?, *id, r) {
|
||||
if !v.contains(&i) {
|
||||
v.push(i);
|
||||
}
|
||||
}
|
||||
let id = *id;
|
||||
let ds = self.ds_mut()?;
|
||||
ds.vector_edit = Some((id, v));
|
||||
ds.revision += 1;
|
||||
Ok(())
|
||||
}
|
||||
Gesture::Vector(_) => Ok(()),
|
||||
Gesture::PenDrag { anchor } => {
|
||||
let out = ev.p() - *anchor;
|
||||
if let Some(pen) = self.tool.pen.as_mut()
|
||||
@@ -1238,8 +1167,7 @@ impl Session {
|
||||
}
|
||||
Gesture::TextSelect { .. } | Gesture::Marquee { .. } | Gesture::None => {}
|
||||
Gesture::PenDrag { .. } => {}
|
||||
Gesture::VertexDrag { .. } | Gesture::HandleDrag { .. } | Gesture::Bend { .. } => self.commit_interaction()?,
|
||||
Gesture::VertexMarquee { .. } => {}
|
||||
Gesture::Vector(_) => self.commit_interaction()?,
|
||||
}
|
||||
if let Ok(ds) = self.ds_mut() {
|
||||
ds.revision += 1;
|
||||
@@ -1508,79 +1436,6 @@ impl Session {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn vector_down(&mut self, ev: &PointerEvent) -> Result<()> {
|
||||
let Some((id, sel)) = self.ds()?.vector_edit.clone() else { return Ok(()) };
|
||||
let doc = self.doc()?.clone();
|
||||
let tol = 6.0 / ev.zoom.max(1e-6);
|
||||
match crate::vectoredit::hit(&doc, id, &sel, ev.p(), tol) {
|
||||
Some(crate::vectoredit::VHit::Vertex(v)) => {
|
||||
let verts = if ev.shift {
|
||||
let mut s2 = sel.clone();
|
||||
if let Some(i) = s2.iter().position(|x| *x == v) {
|
||||
s2.remove(i);
|
||||
} else {
|
||||
s2.push(v);
|
||||
}
|
||||
s2
|
||||
} else if sel.contains(&v) {
|
||||
sel.clone()
|
||||
} else {
|
||||
vec![v]
|
||||
};
|
||||
let ds = self.ds_mut()?;
|
||||
ds.vector_edit = Some((id, verts.clone()));
|
||||
self.begin_interaction("Move points")?;
|
||||
self.tool.gesture = Gesture::VertexDrag { start: ev.p(), id, verts };
|
||||
}
|
||||
Some(crate::vectoredit::VHit::Handle { seg, start }) => {
|
||||
self.begin_interaction("Edit handle")?;
|
||||
self.tool.gesture = Gesture::HandleDrag { id, seg, start_end: start };
|
||||
}
|
||||
Some(crate::vectoredit::VHit::Segment { seg, t }) => {
|
||||
if ev.cmd {
|
||||
self.begin_interaction("Bend")?;
|
||||
self.tool.gesture = Gesture::Bend { id, seg, t };
|
||||
} else if ev.alt || ev.shift {
|
||||
// Add a point on the segment.
|
||||
self.begin_interaction("Add point")?;
|
||||
let mut v = 0;
|
||||
self.preview(|doc, _| {
|
||||
v = crate::vectoredit::split_segment(doc, id, seg, t)?;
|
||||
Ok(())
|
||||
})?;
|
||||
let ds = self.ds_mut()?;
|
||||
ds.vector_edit = Some((id, vec![v]));
|
||||
self.tool.gesture = Gesture::VertexDrag { start: ev.p(), id, verts: vec![v] };
|
||||
} else {
|
||||
// Select the segment's end points and drag them together.
|
||||
let net_seg = match &doc.node(id).map(|n| &n.kind) {
|
||||
Some(NodeKind::Vector { network }) => network.segments.get(seg).copied(),
|
||||
_ => None,
|
||||
};
|
||||
let verts: Vec<usize> = net_seg.map(|s| vec![s.a, s.b]).unwrap_or_default();
|
||||
let ds = self.ds_mut()?;
|
||||
ds.vector_edit = Some((id, verts.clone()));
|
||||
self.begin_interaction("Move segment")?;
|
||||
self.tool.gesture = Gesture::VertexDrag { start: ev.p(), id, verts };
|
||||
}
|
||||
}
|
||||
None => {
|
||||
// Outside the shape entirely (and not shift): leave edit mode.
|
||||
let inside = doc.world_render_bounds(id).is_some_and(|b| b.inflate(tol * 4.0, tol * 4.0).contains(ev.p()));
|
||||
if !inside && !ev.shift {
|
||||
crate::vectoredit::end(self)?;
|
||||
return self.move_down(ev);
|
||||
}
|
||||
let base = if ev.shift { sel } else { vec![] };
|
||||
let ds = self.ds_mut()?;
|
||||
ds.vector_edit = Some((id, base.clone()));
|
||||
ds.revision += 1;
|
||||
self.tool.gesture = Gesture::VertexMarquee { start: ev.p(), id, base };
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Keyboard Escape: cancel gestures, finish pens, leave text editing, then deselect.
|
||||
pub fn escape(&mut self) -> Result<()> {
|
||||
if self.tool.pen.is_some() {
|
||||
@@ -1595,7 +1450,7 @@ impl Session {
|
||||
return textedit::end(self);
|
||||
}
|
||||
if self.ds()?.vector_edit.is_some() {
|
||||
return crate::vectoredit::end(self);
|
||||
return crate::vectoredit::escape(self);
|
||||
}
|
||||
if self.tool.tool != ToolId::Move {
|
||||
self.set_tool(ToolId::Move);
|
||||
|
||||
+591
-310
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,721 @@
|
||||
//! Vector network edits in node-local coordinates: points, handles and mirroring, bending, adding,
|
||||
//! deleting, cutting and joining. Pure functions on a [`VectorNetwork`]; the session wrappers in
|
||||
//! `vectoredit.rs` re-fit the node afterwards.
|
||||
|
||||
use figcraft_doc::*;
|
||||
use figcraft_geom::kurbo::{CubicBez, ParamCurve};
|
||||
use figcraft_geom::{Point, Vec2};
|
||||
|
||||
fn tvec(t: (f64, f64)) -> Vec2 {
|
||||
Vec2::new(t.0, t.1)
|
||||
}
|
||||
|
||||
fn finite(v: Vec2) -> bool {
|
||||
v.x.is_finite() && v.y.is_finite()
|
||||
}
|
||||
|
||||
/// Move vertices by `d`; their handles move with them.
|
||||
pub fn move_vertices(net: &mut VectorNetwork, idx: &[usize], d: Vec2) {
|
||||
if !finite(d) {
|
||||
return;
|
||||
}
|
||||
let mut seen = std::collections::BTreeSet::new();
|
||||
for i in idx {
|
||||
if seen.insert(*i)
|
||||
&& let Some(v) = net.vertices.get_mut(*i)
|
||||
{
|
||||
v.x += d.x;
|
||||
v.y += d.y;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Set a handle to `rel` (relative to its vertex). The vertex's other handle follows its mirroring
|
||||
/// (none, angle, or angle and length); `independent` (Alt-drag) breaks the mirroring.
|
||||
pub fn set_handle(net: &mut VectorNetwork, seg: usize, start: bool, rel: (f64, f64), independent: bool) {
|
||||
if !(rel.0.is_finite() && rel.1.is_finite()) {
|
||||
return;
|
||||
}
|
||||
let Some(s) = net.segments.get(seg).copied() else { return };
|
||||
let vi = if start { s.a } else { s.b };
|
||||
let mode = if independent { HandleMirroring::None } else { net.mirroring(vi) };
|
||||
net.set_handle(seg, start, rel);
|
||||
if independent {
|
||||
if let Some(v) = net.vertices.get_mut(vi) {
|
||||
v.mirror = Some(HandleMirroring::None);
|
||||
}
|
||||
return;
|
||||
}
|
||||
let others: Vec<(usize, bool)> = net.incident(vi).into_iter().filter(|(i, st)| !(*i == seg && *st == start)).collect();
|
||||
let [(oi, ostart)] = others.as_slice() else { return };
|
||||
let r = tvec(rel);
|
||||
let mirrored = match mode {
|
||||
HandleMirroring::None => return,
|
||||
HandleMirroring::AngleAndLength => -r,
|
||||
HandleMirroring::Angle => {
|
||||
let len = net.handle(*oi, *ostart).map(|h| tvec(h).hypot()).unwrap_or(0.0);
|
||||
if r.hypot() < 1e-9 {
|
||||
return;
|
||||
}
|
||||
-r.normalize() * if len > 1e-9 { len } else { r.hypot() }
|
||||
}
|
||||
};
|
||||
net.set_handle(*oi, *ostart, (mirrored.x, mirrored.y));
|
||||
}
|
||||
|
||||
/// Set the mirroring of vertices and make their handles agree with it.
|
||||
pub fn set_mirroring(net: &mut VectorNetwork, verts: &[usize], mode: HandleMirroring) {
|
||||
for &vi in verts {
|
||||
let inc = net.incident(vi);
|
||||
if let [(s1, a1), (s2, a2)] = inc.as_slice()
|
||||
&& let (Some(h1), Some(h2)) = (net.handle(*s1, *a1), net.handle(*s2, *a2))
|
||||
{
|
||||
let (h1, h2) = (tvec(h1), tvec(h2));
|
||||
match mode {
|
||||
HandleMirroring::None => {}
|
||||
HandleMirroring::Angle | HandleMirroring::AngleAndLength => {
|
||||
// Keep the longer handle's direction; the other turns opposite.
|
||||
let (lead, lead_is_first) = if h1.hypot() >= h2.hypot() { (h1, true) } else { (h2, false) };
|
||||
if lead.hypot() > 1e-9 {
|
||||
let other_len = if mode == HandleMirroring::AngleAndLength {
|
||||
(h1.hypot() + h2.hypot()) / 2.0
|
||||
} else if lead_is_first {
|
||||
h2.hypot()
|
||||
} else {
|
||||
h1.hypot()
|
||||
};
|
||||
let dir = lead.normalize();
|
||||
let lead_len = if mode == HandleMirroring::AngleAndLength { other_len } else { lead.hypot() };
|
||||
let (a, b) = (dir * lead_len, -dir * if other_len > 1e-9 { other_len } else { lead_len });
|
||||
let (n1, n2) = if lead_is_first { (a, b) } else { (b, a) };
|
||||
net.set_handle(*s1, *a1, (n1.x, n1.y));
|
||||
net.set_handle(*s2, *a2, (n2.x, n2.y));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
if let Some(v) = net.vertices.get_mut(vi) {
|
||||
v.mirror = Some(mode);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Direction from vertex `vi` along segment `seg` (towards its other end).
|
||||
fn away(net: &VectorNetwork, seg: usize, at_start: bool) -> Option<Vec2> {
|
||||
let c = net.cubic(seg)?;
|
||||
let d = if at_start { c.p3 - c.p0 } else { c.p0 - c.p3 };
|
||||
(d.hypot() > 1e-9).then(|| d.normalize())
|
||||
}
|
||||
|
||||
/// Pull symmetric handles out of a point (the Bend tool dragged on a point): `d` is the handle on
|
||||
/// the side the drag points towards; the opposite side gets `-d`. Mirroring becomes "angle".
|
||||
pub fn make_smooth(net: &mut VectorNetwork, vi: usize, d: Vec2) {
|
||||
if !finite(d) || d.hypot() < 1e-9 {
|
||||
return;
|
||||
}
|
||||
let inc = net.incident(vi);
|
||||
let scored: Vec<(usize, bool, f64)> = inc.iter().map(|(s, st)| (*s, *st, away(net, *s, *st).map(|a| a.dot(d)).unwrap_or(0.0))).collect();
|
||||
match scored.as_slice() {
|
||||
[] => return,
|
||||
[(s, st, dot)] => {
|
||||
let h = if *dot >= 0.0 { d } else { -d };
|
||||
net.set_handle(*s, *st, (h.x, h.y));
|
||||
}
|
||||
_ => {
|
||||
let fwd = scored.iter().copied().max_by(|a, b| a.2.total_cmp(&b.2));
|
||||
let back = scored.iter().copied().filter(|x| Some(*x) != fwd).min_by(|a, b| a.2.total_cmp(&b.2));
|
||||
if let (Some((s1, a1, _)), Some((s2, a2, _))) = (fwd, back) {
|
||||
net.set_handle(s1, a1, (d.x, d.y));
|
||||
net.set_handle(s2, a2, (-d.x, -d.y));
|
||||
}
|
||||
}
|
||||
}
|
||||
if let Some(v) = net.vertices.get_mut(vi) {
|
||||
v.mirror = Some(HandleMirroring::Angle);
|
||||
}
|
||||
}
|
||||
|
||||
/// Smooth points automatically: handles along the line between the neighbours, a third of the way
|
||||
/// to each (endpoints stay as they are).
|
||||
pub fn auto_smooth(net: &mut VectorNetwork, vi: usize) {
|
||||
let inc = net.incident(vi);
|
||||
let [(s1, a1), (s2, a2)] = inc.as_slice() else { return };
|
||||
let other = |s: usize, st: bool| net.segments.get(s).and_then(|x| net.vertices.get(if st { x.b } else { x.a })).map(|v| v.point());
|
||||
let (Some(v), Some(p1), Some(p2)) = (net.vertices.get(vi).map(|v| v.point()), other(*s1, *a1), other(*s2, *a2)) else { return };
|
||||
let dir = p1 - p2;
|
||||
if dir.hypot() < 1e-9 {
|
||||
return;
|
||||
}
|
||||
let dir = dir.normalize();
|
||||
let (l1, l2) = ((p1 - v).hypot() / 3.0, (p2 - v).hypot() / 3.0);
|
||||
let (h1, h2) = (dir * l1, -dir * l2);
|
||||
net.set_handle(*s1, *a1, (h1.x, h1.y));
|
||||
net.set_handle(*s2, *a2, (h2.x, h2.y));
|
||||
if let Some(x) = net.vertices.get_mut(vi) {
|
||||
x.mirror = Some(HandleMirroring::Angle);
|
||||
}
|
||||
}
|
||||
|
||||
/// Remove a point's handles (a sharp corner).
|
||||
pub fn make_sharp(net: &mut VectorNetwork, vi: usize) {
|
||||
for (s, st) in net.incident(vi) {
|
||||
net.set_handle(s, st, (0.0, 0.0));
|
||||
}
|
||||
if let Some(v) = net.vertices.get_mut(vi) {
|
||||
v.mirror = None;
|
||||
}
|
||||
}
|
||||
|
||||
/// Bend a segment so its curve passes through `p` at parameter `t` (the Bend tool).
|
||||
pub fn bend(net: &mut VectorNetwork, seg: usize, t: f64, p: Point) {
|
||||
let Some(s) = net.segments.get(seg).copied() else { return };
|
||||
let (Some(a), Some(b)) = (net.vertices.get(s.a).copied(), net.vertices.get(s.b).copied()) else { return };
|
||||
if !(t.is_finite() && p.x.is_finite() && p.y.is_finite()) {
|
||||
return;
|
||||
}
|
||||
let t = t.clamp(0.05, 0.95);
|
||||
// Controls at the chord's thirds plus a shared offset: B(t) = L(t) + 3t(1-t)·off.
|
||||
let (a, b) = (a.point(), b.point());
|
||||
let k = 3.0 * t * (1.0 - t);
|
||||
let off = (p - a.lerp(b, t)) / k;
|
||||
let c1 = a + (b - a) / 3.0 + off;
|
||||
let c2 = b + (a - b) / 3.0 + off;
|
||||
net.set_handle(seg, true, (c1.x - a.x, c1.y - a.y));
|
||||
net.set_handle(seg, false, (c2.x - b.x, c2.y - b.y));
|
||||
}
|
||||
|
||||
/// Split segment `seg` at `t`, inserting a vertex; returns its index.
|
||||
pub fn split_segment(net: &mut VectorNetwork, seg: usize, t: f64) -> Option<usize> {
|
||||
let s = net.segments.get(seg).copied()?;
|
||||
let c: CubicBez = net.cubic(seg)?;
|
||||
let t = if t.is_finite() { t.clamp(0.01, 0.99) } else { 0.5 };
|
||||
let l = c.subsegment(0.0..t);
|
||||
let r = c.subsegment(t..1.0);
|
||||
net.vertices.push(Vertex::new(l.p3.x, l.p3.y));
|
||||
let mid = net.vertices.len() - 1;
|
||||
let straight = s.is_straight();
|
||||
if let Some(sm) = net.segments.get_mut(seg) {
|
||||
sm.b = mid;
|
||||
if straight {
|
||||
sm.ta = (0.0, 0.0);
|
||||
sm.tb = (0.0, 0.0);
|
||||
} else {
|
||||
sm.ta = (l.p1.x - l.p0.x, l.p1.y - l.p0.y);
|
||||
sm.tb = (l.p2.x - l.p3.x, l.p2.y - l.p3.y);
|
||||
}
|
||||
}
|
||||
let new_seg = if straight {
|
||||
Segment::line(mid, s.b)
|
||||
} else {
|
||||
// A point added on a curve keeps it smooth.
|
||||
if let Some(v) = net.vertices.get_mut(mid) {
|
||||
v.mirror = Some(HandleMirroring::Angle);
|
||||
}
|
||||
Segment { a: mid, b: s.b, ta: (r.p1.x - r.p0.x, r.p1.y - r.p0.y), tb: (r.p2.x - r.p3.x, r.p2.y - r.p3.y) }
|
||||
};
|
||||
net.segments.push(new_seg);
|
||||
let ni = net.segments.len() - 1;
|
||||
for reg in &mut net.regions {
|
||||
for lp in &mut reg.loops {
|
||||
if let Some(pos) = lp.iter().position(|x| *x == seg) {
|
||||
lp.insert(pos + 1, ni);
|
||||
}
|
||||
}
|
||||
}
|
||||
Some(mid)
|
||||
}
|
||||
|
||||
/// Remove vertex `vi` (no segment may use it) and shift higher indices down.
|
||||
fn remove_vertex_index(net: &mut VectorNetwork, vi: usize) {
|
||||
if vi >= net.vertices.len() {
|
||||
return;
|
||||
}
|
||||
net.vertices.remove(vi);
|
||||
for s in &mut net.segments {
|
||||
if s.a > vi {
|
||||
s.a -= 1;
|
||||
}
|
||||
if s.b > vi {
|
||||
s.b -= 1;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Remove segments (fixing region loops); points left without any segment go too.
|
||||
pub fn remove_segments(net: &mut VectorNetwork, segs: &[usize]) {
|
||||
let mut touched: Vec<usize> = segs.iter().filter_map(|s| net.segments.get(*s)).flat_map(|s| [s.a, s.b]).collect();
|
||||
remove_segments_only(net, segs);
|
||||
touched.sort_unstable();
|
||||
touched.dedup();
|
||||
for &vi in touched.iter().rev() {
|
||||
if net.incident(vi).is_empty() {
|
||||
remove_vertex_index(net, vi);
|
||||
}
|
||||
}
|
||||
reopen_loops(net);
|
||||
}
|
||||
|
||||
fn remove_segments_only(net: &mut VectorNetwork, segs: &[usize]) {
|
||||
let mut segs: Vec<usize> = segs.iter().copied().filter(|s| *s < net.segments.len()).collect();
|
||||
segs.sort_unstable();
|
||||
segs.dedup();
|
||||
let mut map: Vec<Option<usize>> = (0..net.segments.len()).map(Some).collect();
|
||||
for &si in segs.iter().rev() {
|
||||
net.segments.remove(si);
|
||||
if let Some(m) = map.get_mut(si) {
|
||||
*m = None;
|
||||
}
|
||||
}
|
||||
for (k, m) in map.iter_mut().flatten().enumerate() {
|
||||
*m = k;
|
||||
}
|
||||
for r in &mut net.regions {
|
||||
for lp in &mut r.loops {
|
||||
*lp = lp.iter().filter_map(|i| map.get(*i).copied().flatten()).collect();
|
||||
}
|
||||
r.loops.retain(|l| l.len() >= 2);
|
||||
}
|
||||
net.regions.retain(|r| !r.loops.is_empty());
|
||||
}
|
||||
|
||||
/// Delete vertices, joining their neighbours where a vertex had exactly two segments (`heal`).
|
||||
pub fn delete_vertices(net: &mut VectorNetwork, idx: &[usize], heal: bool) {
|
||||
let mut idx: Vec<usize> = idx.to_vec();
|
||||
idx.sort_unstable();
|
||||
idx.dedup();
|
||||
for &vi in idx.iter().rev() {
|
||||
if vi >= net.vertices.len() {
|
||||
continue;
|
||||
}
|
||||
let touching: Vec<usize> = net.incident(vi).into_iter().map(|(i, _)| i).collect();
|
||||
let mut healed = None;
|
||||
if heal && let [s1, s2] = touching.as_slice() {
|
||||
let (Some(x), Some(y)) = (net.segments.get(*s1).copied(), net.segments.get(*s2).copied()) else { continue };
|
||||
let o1 = if x.a == vi { (x.b, x.tb) } else { (x.a, x.ta) };
|
||||
let o2 = if y.a == vi { (y.b, y.tb) } else { (y.a, y.ta) };
|
||||
if o1.0 != o2.0 {
|
||||
healed = Some(Segment { a: o1.0, b: o2.0, ta: o1.1, tb: o2.1 });
|
||||
}
|
||||
}
|
||||
// Remove touching segments (descending) and fix region loops (the healed segment takes
|
||||
// the removed ones' place).
|
||||
let mut map: Vec<Option<usize>> = (0..net.segments.len()).map(Some).collect();
|
||||
for &si in touching.iter().rev() {
|
||||
net.segments.remove(si);
|
||||
if let Some(m) = map.get_mut(si) {
|
||||
*m = None;
|
||||
}
|
||||
}
|
||||
for (k, m) in map.iter_mut().flatten().enumerate() {
|
||||
*m = k;
|
||||
}
|
||||
let healed_index = healed.map(|s| {
|
||||
net.segments.push(s);
|
||||
net.segments.len() - 1
|
||||
});
|
||||
for r in &mut net.regions {
|
||||
for lp in &mut r.loops {
|
||||
let mut out = Vec::new();
|
||||
let mut inserted = false;
|
||||
for si in lp.iter() {
|
||||
match map.get(*si).copied().flatten() {
|
||||
Some(n) => out.push(n),
|
||||
None => {
|
||||
if let (Some(h), false) = (healed_index, inserted) {
|
||||
out.push(h);
|
||||
inserted = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
*lp = out;
|
||||
}
|
||||
r.loops.retain(|l| l.len() >= 2);
|
||||
}
|
||||
net.regions.retain(|r| !r.loops.is_empty());
|
||||
remove_vertex_index(net, vi);
|
||||
}
|
||||
net.sanitize();
|
||||
reopen_loops(net);
|
||||
}
|
||||
|
||||
/// After a loop was broken open, start it where the break is so it's one continuous chain (the fill
|
||||
/// then closes it with a straight line, like an open path's fill).
|
||||
fn reopen_loops(net: &mut VectorNetwork) {
|
||||
let segs = net.segments.clone();
|
||||
let shares = |x: usize, y: usize| -> bool {
|
||||
match (segs.get(x), segs.get(y)) {
|
||||
(Some(a), Some(b)) => a.a == b.a || a.a == b.b || a.b == b.a || a.b == b.b,
|
||||
_ => false,
|
||||
}
|
||||
};
|
||||
for r in &mut net.regions {
|
||||
for lp in &mut r.loops {
|
||||
let n = lp.len();
|
||||
if n < 2 {
|
||||
continue;
|
||||
}
|
||||
let brk = (0..n).find(|k| {
|
||||
let prev = lp.get((k + n - 1) % n).copied().unwrap_or(0);
|
||||
let cur = lp.get(*k).copied().unwrap_or(0);
|
||||
!shares(prev, cur)
|
||||
});
|
||||
if let Some(k) = brk {
|
||||
lp.rotate_left(k);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Cut a segment at `t` into two open ends at the same spot. Returns the two end vertices.
|
||||
pub fn cut_segment(net: &mut VectorNetwork, seg: usize, t: f64) -> Option<(usize, usize)> {
|
||||
let mid = split_segment(net, seg, t)?;
|
||||
let ni = net.segments.len().checked_sub(1)?;
|
||||
let v = *net.vertices.get(mid)?;
|
||||
net.vertices.push(Vertex { mirror: None, ..v });
|
||||
let mid2 = net.vertices.len() - 1;
|
||||
if let Some(s) = net.segments.get_mut(ni) {
|
||||
s.a = mid2;
|
||||
}
|
||||
if let Some(x) = net.vertices.get_mut(mid) {
|
||||
x.mirror = None;
|
||||
}
|
||||
reopen_loops(net);
|
||||
Some((mid, mid2))
|
||||
}
|
||||
|
||||
/// Cut the path at a point: every segment after the first gets its own copy of the point.
|
||||
pub fn cut_vertex(net: &mut VectorNetwork, vi: usize) -> Vec<usize> {
|
||||
let inc = net.incident(vi);
|
||||
let Some(v) = net.vertices.get(vi).copied() else { return vec![] };
|
||||
let mut out = vec![vi];
|
||||
for (s, st) in inc.into_iter().skip(1) {
|
||||
net.vertices.push(Vertex { mirror: None, ..v });
|
||||
let nv = net.vertices.len() - 1;
|
||||
if let Some(seg) = net.segments.get_mut(s) {
|
||||
if st {
|
||||
seg.a = nv;
|
||||
} else {
|
||||
seg.b = nv;
|
||||
}
|
||||
}
|
||||
out.push(nv);
|
||||
}
|
||||
if let Some(x) = net.vertices.get_mut(vi) {
|
||||
x.mirror = None;
|
||||
}
|
||||
reopen_loops(net);
|
||||
out
|
||||
}
|
||||
|
||||
/// Shortest chain of segments from `from` to `to` (breadth first), skipping segment `skip`.
|
||||
fn path_between(net: &VectorNetwork, from: usize, to: usize, skip: usize) -> Option<Vec<usize>> {
|
||||
let n = net.vertices.len();
|
||||
let mut prev: Vec<Option<(usize, usize)>> = vec![None; n];
|
||||
let mut seen = vec![false; n];
|
||||
let mut queue = std::collections::VecDeque::from([from]);
|
||||
if let Some(s) = seen.get_mut(from) {
|
||||
*s = true;
|
||||
}
|
||||
while let Some(v) = queue.pop_front() {
|
||||
if v == to {
|
||||
let mut segs = Vec::new();
|
||||
let mut cur = to;
|
||||
while cur != from {
|
||||
let (pv, ps) = prev.get(cur).copied().flatten()?;
|
||||
segs.push(ps);
|
||||
cur = pv;
|
||||
}
|
||||
segs.reverse();
|
||||
return Some(segs);
|
||||
}
|
||||
for (s, st) in net.incident(v) {
|
||||
if s == skip {
|
||||
continue;
|
||||
}
|
||||
let Some(seg) = net.segments.get(s) else { continue };
|
||||
let w = if st { seg.b } else { seg.a };
|
||||
if !seen.get(w).copied().unwrap_or(true) {
|
||||
if let Some(x) = seen.get_mut(w) {
|
||||
*x = true;
|
||||
}
|
||||
if let Some(x) = prev.get_mut(w) {
|
||||
*x = Some((v, s));
|
||||
}
|
||||
queue.push_back(w);
|
||||
}
|
||||
}
|
||||
}
|
||||
None
|
||||
}
|
||||
|
||||
/// Connect two points with a new segment (handles relative to each end). Closing an open path
|
||||
/// (both points were its ends) makes the loop a fill region. Returns the new segment.
|
||||
pub fn connect(net: &mut VectorNetwork, a: usize, b: usize, ta: (f64, f64), tb: (f64, f64)) -> Option<usize> {
|
||||
if a == b || a >= net.vertices.len() || b >= net.vertices.len() {
|
||||
return None;
|
||||
}
|
||||
let ends = net.incident(a).len() == 1 && net.incident(b).len() == 1;
|
||||
let finite_t = |t: (f64, f64)| if t.0.is_finite() && t.1.is_finite() { t } else { (0.0, 0.0) };
|
||||
net.segments.push(Segment { a, b, ta: finite_t(ta), tb: finite_t(tb) });
|
||||
let si = net.segments.len() - 1;
|
||||
if ends && let Some(mut lp) = path_between(net, b, a, si) {
|
||||
let unfilled = !net.regions.iter().any(|r| r.loops.iter().flatten().any(|s| lp.contains(s)));
|
||||
if unfilled {
|
||||
lp.insert(0, si);
|
||||
net.regions.push(Region { loops: vec![lp], winding: WindingRule::NonZero });
|
||||
}
|
||||
}
|
||||
Some(si)
|
||||
}
|
||||
|
||||
/// Merge vertex `drop` into `keep` (segments re-attach; ones that collapse go).
|
||||
pub fn merge_vertices(net: &mut VectorNetwork, keep: usize, drop: usize) {
|
||||
if keep == drop || keep >= net.vertices.len() || drop >= net.vertices.len() {
|
||||
return;
|
||||
}
|
||||
for s in &mut net.segments {
|
||||
if s.a == drop {
|
||||
s.a = keep;
|
||||
}
|
||||
if s.b == drop {
|
||||
s.b = keep;
|
||||
}
|
||||
}
|
||||
let collapsed: Vec<usize> = net.segments.iter().enumerate().filter(|(_, s)| s.a == s.b && s.is_straight()).map(|(i, _)| i).collect();
|
||||
remove_segments_only(net, &collapsed);
|
||||
remove_vertex_index(net, drop);
|
||||
reopen_loops(net);
|
||||
}
|
||||
|
||||
/// Join points (⌘J): two points that overlap merge; otherwise open ends are connected in pairs
|
||||
/// (nearest first). `smooth` continues each end's curve. Returns the number of joins.
|
||||
pub fn join(net: &mut VectorNetwork, verts: &[usize], smooth: bool) -> usize {
|
||||
let mut verts: Vec<usize> = verts.iter().copied().filter(|v| *v < net.vertices.len()).collect();
|
||||
verts.sort_unstable();
|
||||
verts.dedup();
|
||||
if let [a, b] = verts.as_slice() {
|
||||
let (pa, pb) = (net.vertices.get(*a).map(|v| v.point()), net.vertices.get(*b).map(|v| v.point()));
|
||||
if let (Some(pa), Some(pb)) = (pa, pb)
|
||||
&& (pa - pb).hypot() < 0.5
|
||||
{
|
||||
merge_vertices(net, *a, *b);
|
||||
return 1;
|
||||
}
|
||||
}
|
||||
let mut ends: Vec<usize> = if verts.len() == 2 { verts.clone() } else { verts.iter().copied().filter(|v| net.incident(*v).len() <= 1).collect() };
|
||||
let mut joins = 0;
|
||||
while ends.len() >= 2 {
|
||||
let a = ends.remove(0);
|
||||
let Some(pa) = net.vertices.get(a).map(|v| v.point()) else { continue };
|
||||
let Some((k, _)) = ends
|
||||
.iter()
|
||||
.enumerate()
|
||||
.filter_map(|(k, b)| net.vertices.get(*b).map(|v| (k, (v.point() - pa).hypot())))
|
||||
.min_by(|x, y| x.1.total_cmp(&y.1))
|
||||
else {
|
||||
break;
|
||||
};
|
||||
let b = ends.remove(k);
|
||||
let Some(pb) = net.vertices.get(b).map(|v| v.point()) else { continue };
|
||||
let (ta, tb) = if smooth {
|
||||
let len = (pb - pa).hypot() / 3.0;
|
||||
let out = |v: usize, toward: Vec2| -> Vec2 {
|
||||
// Continue the existing curve out of the end; fall back to the chord.
|
||||
let inc = net.incident(v);
|
||||
let dir = match inc.as_slice() {
|
||||
[(s, st)] => {
|
||||
net.handle(*s, *st).map(tvec).filter(|h| h.hypot() > 1e-9).map(|h| -h.normalize()).or_else(|| away(net, *s, *st).map(|a| -a))
|
||||
}
|
||||
_ => None,
|
||||
};
|
||||
dir.unwrap_or(toward) * len
|
||||
};
|
||||
let chord = if (pb - pa).hypot() > 1e-9 { (pb - pa).normalize() } else { Vec2::ZERO };
|
||||
let (oa, ob) = (out(a, chord), out(b, -chord));
|
||||
((oa.x, oa.y), (ob.x, ob.y))
|
||||
} else {
|
||||
((0.0, 0.0), (0.0, 0.0))
|
||||
};
|
||||
if connect(net, a, b, ta, tb).is_some() {
|
||||
joins += 1;
|
||||
}
|
||||
}
|
||||
joins
|
||||
}
|
||||
|
||||
/// Vertices inside a closed polygon (even-odd), all in the same coordinates.
|
||||
pub fn in_polygon(points: &[Point], poly: &[Point]) -> Vec<usize> {
|
||||
if poly.len() < 3 {
|
||||
return vec![];
|
||||
}
|
||||
points
|
||||
.iter()
|
||||
.enumerate()
|
||||
.filter(|(_, p)| {
|
||||
let mut inside = false;
|
||||
let n = poly.len();
|
||||
for k in 0..n {
|
||||
let (Some(a), Some(b)) = (poly.get(k), poly.get((k + 1) % n)) else { continue };
|
||||
if (a.y > p.y) != (b.y > p.y) {
|
||||
let x = a.x + (p.y - a.y) * (b.x - a.x) / (b.y - a.y);
|
||||
if p.x < x {
|
||||
inside = !inside;
|
||||
}
|
||||
}
|
||||
}
|
||||
inside
|
||||
})
|
||||
.map(|(i, _)| i)
|
||||
.collect()
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
fn square() -> VectorNetwork {
|
||||
VectorNetwork::from_points(&[Point::new(0.0, 0.0), Point::new(100.0, 0.0), Point::new(100.0, 100.0), Point::new(0.0, 100.0)], true)
|
||||
}
|
||||
|
||||
fn open_line() -> VectorNetwork {
|
||||
VectorNetwork::from_points(&[Point::new(0.0, 0.0), Point::new(50.0, 0.0), Point::new(100.0, 0.0)], false)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn handles_follow_mirroring() {
|
||||
let mut n = open_line();
|
||||
set_mirroring(&mut n, &[1], HandleMirroring::AngleAndLength);
|
||||
// Seg 0 ends at v1 (tb), seg 1 starts there (ta).
|
||||
set_handle(&mut n, 1, true, (20.0, 10.0), false);
|
||||
assert_eq!(n.segments[0].tb, (-20.0, -10.0));
|
||||
set_mirroring(&mut n, &[1], HandleMirroring::Angle);
|
||||
set_handle(&mut n, 1, true, (0.0, 30.0), false);
|
||||
let other = n.segments[0].tb;
|
||||
assert!(other.0.abs() < 1e-9 && (other.1 + 22.360679).abs() < 1e-4, "angle mirrors, length kept: {other:?}");
|
||||
assert_eq!(n.mirroring(1), HandleMirroring::Angle);
|
||||
set_handle(&mut n, 1, true, (10.0, 0.0), true);
|
||||
assert_eq!(n.mirroring(1), HandleMirroring::None);
|
||||
assert!(n.segments[0].tb.1 < -20.0, "independent drag leaves the other handle");
|
||||
set_mirroring(&mut n, &[1], HandleMirroring::AngleAndLength);
|
||||
let (a, b) = (tvec(n.segments[1].ta), tvec(n.segments[0].tb));
|
||||
assert!((a + b).hypot() < 1e-9, "made symmetric: {a:?} {b:?}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn inferred_mirroring() {
|
||||
let mut n = open_line();
|
||||
assert_eq!(n.mirroring(1), HandleMirroring::None);
|
||||
n.segments[0].tb = (-10.0, 0.0);
|
||||
n.segments[1].ta = (10.0, 0.0);
|
||||
assert_eq!(n.mirroring(1), HandleMirroring::AngleAndLength);
|
||||
n.segments[1].ta = (20.0, 0.0);
|
||||
assert_eq!(n.mirroring(1), HandleMirroring::Angle);
|
||||
n.segments[1].ta = (20.0, 5.0);
|
||||
assert_eq!(n.mirroring(1), HandleMirroring::None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn smooth_and_sharp() {
|
||||
let mut n = square();
|
||||
make_smooth(&mut n, 0, Vec2::new(30.0, 0.0));
|
||||
// Seg 0 goes 0→1 (towards +x): gets +d; seg 3 (3→0) gets -d at its b end.
|
||||
assert_eq!(n.segments[0].ta, (30.0, 0.0));
|
||||
assert_eq!(n.segments[3].tb, (-30.0, 0.0));
|
||||
assert_eq!(n.mirroring(0), HandleMirroring::Angle);
|
||||
make_sharp(&mut n, 0);
|
||||
assert!(n.segments[0].is_straight() && n.segments[3].is_straight());
|
||||
auto_smooth(&mut n, 1);
|
||||
assert_eq!(n.mirroring(1), HandleMirroring::Angle);
|
||||
assert!(!n.segments[0].is_straight());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn cut_opens_a_closed_shape_but_keeps_its_fill() {
|
||||
let mut n = square();
|
||||
let (a, b) = cut_segment(&mut n, 0, 0.5).unwrap();
|
||||
assert_ne!(a, b);
|
||||
assert_eq!(n.vertices[a].point(), n.vertices[b].point());
|
||||
assert_eq!(n.incident(a).len(), 1);
|
||||
assert_eq!(n.incident(b).len(), 1);
|
||||
assert!(n.fill_contains(Point::new(50.0, 50.0)), "fill kept");
|
||||
// Cutting at a corner detaches it.
|
||||
let mut m = square();
|
||||
let vs = cut_vertex(&mut m, 2);
|
||||
assert_eq!(vs.len(), 2);
|
||||
assert!(vs.iter().all(|v| m.incident(*v).len() == 1));
|
||||
assert!(m.fill_contains(Point::new(50.0, 50.0)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn connecting_ends_closes_and_fills() {
|
||||
let mut n = open_line();
|
||||
n.vertices[2].y = 100.0;
|
||||
assert!(n.regions.is_empty());
|
||||
let s = connect(&mut n, 2, 0, (0.0, 0.0), (0.0, 0.0)).unwrap();
|
||||
assert_eq!(n.regions.len(), 1);
|
||||
assert!(n.regions[0].loops[0].contains(&s));
|
||||
assert!(n.fill_contains(Point::new(50.0, 20.0)));
|
||||
// Branching from the middle point doesn't make a region.
|
||||
n.vertices.push(Vertex::new(50.0, -50.0));
|
||||
connect(&mut n, 1, 3, (0.0, 0.0), (0.0, 0.0)).unwrap();
|
||||
assert_eq!(n.regions.len(), 1);
|
||||
assert!(connect(&mut n, 1, 1, (0.0, 0.0), (0.0, 0.0)).is_none());
|
||||
assert!(connect(&mut n, 1, 99, (0.0, 0.0), (0.0, 0.0)).is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn join_merges_overlapping_points_and_connects_ends() {
|
||||
let mut n = open_line();
|
||||
n.vertices.push(Vertex::new(100.2, 0.0));
|
||||
n.vertices.push(Vertex::new(150.0, 0.0));
|
||||
n.segments.push(Segment::line(3, 4));
|
||||
assert_eq!(join(&mut n, &[2, 3], false), 1);
|
||||
assert_eq!(n.vertices.len(), 4, "merged");
|
||||
assert_eq!(n.incident(2).len(), 2);
|
||||
// Two separate open ends far apart: connected (smoothly).
|
||||
let mut m = open_line();
|
||||
m.vertices.push(Vertex::new(200.0, 50.0));
|
||||
m.vertices.push(Vertex::new(300.0, 50.0));
|
||||
m.segments.push(Segment::line(3, 4));
|
||||
assert_eq!(join(&mut m, &[0, 2, 3, 4], true), 2);
|
||||
assert!(m.segments.iter().skip(3).any(|s| !s.is_straight()));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn deleting_segments_and_points() {
|
||||
let mut n = square();
|
||||
remove_segments(&mut n, &[0]);
|
||||
assert_eq!(n.segments.len(), 3);
|
||||
assert_eq!(n.vertices.len(), 4, "points with other segments stay");
|
||||
let mut m = open_line();
|
||||
remove_segments(&mut m, &[1]);
|
||||
assert_eq!(m.vertices.len(), 2, "the orphaned end goes");
|
||||
let mut h = square();
|
||||
delete_vertices(&mut h, &[1], true);
|
||||
assert_eq!((h.vertices.len(), h.segments.len(), h.regions.len()), (3, 3, 1));
|
||||
let mut d = square();
|
||||
delete_vertices(&mut d, &[1], false);
|
||||
assert_eq!((d.vertices.len(), d.segments.len()), (3, 2));
|
||||
// Junk indices.
|
||||
remove_segments(&mut d, &[99]);
|
||||
delete_vertices(&mut d, &[99], true);
|
||||
assert!(split_segment(&mut d, 99, 0.5).is_none());
|
||||
assert!(cut_segment(&mut d, 99, f64::NAN).is_none());
|
||||
assert!(cut_vertex(&mut d, 99).is_empty());
|
||||
set_handle(&mut d, 99, true, (1.0, 1.0), false);
|
||||
set_handle(&mut d, 0, true, (f64::NAN, 1.0), false);
|
||||
make_smooth(&mut d, 99, Vec2::new(1.0, 1.0));
|
||||
bend(&mut d, 0, f64::NAN, Point::new(1.0, 1.0));
|
||||
move_vertices(&mut d, &[0], Vec2::new(f64::INFINITY, 0.0));
|
||||
assert!(d.vertices.iter().all(|v| v.x.is_finite() && v.y.is_finite()));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn lasso_polygon() {
|
||||
let pts = [Point::new(5.0, 5.0), Point::new(50.0, 50.0), Point::new(15.0, 5.0)];
|
||||
let poly = [Point::new(0.0, 0.0), Point::new(20.0, 0.0), Point::new(20.0, 20.0), Point::new(0.0, 20.0)];
|
||||
assert_eq!(in_polygon(&pts, &poly), vec![0, 2]);
|
||||
assert!(in_polygon(&pts, &poly[..2]).is_empty());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,569 @@
|
||||
//! Pointer gestures in vector edit mode, one per edit tool. Each gesture is one undo step: it
|
||||
//! starts an interaction and previews its edit against the snapshot until the pointer goes up.
|
||||
|
||||
use figcraft_doc::*;
|
||||
use figcraft_geom::{Point, Rect, Vec2};
|
||||
|
||||
use super::{VHit, VTool, VectorEdit, edit, editing, hit, network, ops, set_state};
|
||||
use crate::tools::{Cursor, Gesture, PointerEvent, PointerKind};
|
||||
use crate::{Result, Session};
|
||||
|
||||
/// An in-progress edit-mode gesture.
|
||||
#[derive(Clone, Debug)]
|
||||
pub(crate) enum VGesture {
|
||||
/// Drag points (also a segment's ends). `split` re-adds a point on a segment first.
|
||||
Points {
|
||||
start: Point,
|
||||
verts: Vec<usize>,
|
||||
split: Option<(usize, f64)>,
|
||||
moved: bool,
|
||||
},
|
||||
Handle {
|
||||
seg: usize,
|
||||
start: bool,
|
||||
independent: bool,
|
||||
},
|
||||
BendSegment {
|
||||
seg: usize,
|
||||
t: f64,
|
||||
},
|
||||
/// Bend tool on a point: drag pulls out handles, a click on a smooth point sharpens it.
|
||||
BendVertex {
|
||||
v: usize,
|
||||
origin: Point,
|
||||
moved: bool,
|
||||
smooth: bool,
|
||||
},
|
||||
Marquee {
|
||||
start: Point,
|
||||
base: Vec<usize>,
|
||||
},
|
||||
Lasso {
|
||||
points: Vec<Point>,
|
||||
base: Vec<usize>,
|
||||
},
|
||||
Pen {
|
||||
plan: PenPlan,
|
||||
origin: Point,
|
||||
out: Option<(f64, f64)>,
|
||||
},
|
||||
}
|
||||
|
||||
/// What a pen click does (replayed against the snapshot while the handle is dragged).
|
||||
#[derive(Clone, Copy, Debug, PartialEq)]
|
||||
pub(crate) enum PenPlan {
|
||||
/// A new point at `at` (local), connected from the pen's open end.
|
||||
New { at: Point, from: Option<usize> },
|
||||
/// Connect the open end to an existing point (ends the path).
|
||||
Connect { from: usize, to: usize },
|
||||
/// Add a point on a segment, connected from the open end if any (ends the path).
|
||||
Split { seg: usize, t: f64, from: Option<usize> },
|
||||
}
|
||||
|
||||
/// Apply a pen plan; `out` is the open end's outgoing handle, `drag` the handle being dragged out
|
||||
/// of the new point. Returns the point the click landed on.
|
||||
fn apply_pen(net: &mut VectorNetwork, plan: PenPlan, out: Option<(f64, f64)>, drag: Option<Vec2>) -> Option<usize> {
|
||||
let out = out.unwrap_or((0.0, 0.0));
|
||||
let back = drag.map(|d| (-d.x, -d.y)).unwrap_or((0.0, 0.0));
|
||||
match plan {
|
||||
PenPlan::New { at, from } => {
|
||||
net.vertices.push(Vertex::new(at.x, at.y));
|
||||
let v = net.vertices.len() - 1;
|
||||
if let Some(f) = from {
|
||||
ops::connect(net, f, v, out, back);
|
||||
}
|
||||
if drag.is_some()
|
||||
&& let Some(x) = net.vertices.get_mut(v)
|
||||
{
|
||||
x.mirror = Some(HandleMirroring::AngleAndLength);
|
||||
}
|
||||
Some(v)
|
||||
}
|
||||
PenPlan::Connect { from, to } => {
|
||||
let others = net.incident(to);
|
||||
let seg = ops::connect(net, from, to, out, back)?;
|
||||
// Dragging while closing shapes the curve on both sides of the point.
|
||||
if let (Some(d), [(os, ost)]) = (drag, others.as_slice()) {
|
||||
net.set_handle(*os, *ost, (d.x, d.y));
|
||||
if let Some(x) = net.vertices.get_mut(to) {
|
||||
x.mirror = Some(HandleMirroring::AngleAndLength);
|
||||
}
|
||||
}
|
||||
let _ = seg;
|
||||
Some(to)
|
||||
}
|
||||
PenPlan::Split { seg, t, from } => {
|
||||
let v = ops::split_segment(net, seg, t)?;
|
||||
if let Some(f) = from {
|
||||
ops::connect(net, f, v, out, (0.0, 0.0));
|
||||
}
|
||||
Some(v)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl Session {
|
||||
/// Feed a pointer event to vector edit mode (the canvas is in edit mode and the main tool is
|
||||
/// Move). Returns false when edit mode ended and the event should go to the normal tool.
|
||||
pub(crate) fn vector_pointer(&mut self, ev: &PointerEvent) -> Result<bool> {
|
||||
let Ok(ve) = editing(self) else { return Ok(false) };
|
||||
match ev.kind {
|
||||
PointerKind::Hover => self.vector_hover(&ve, ev).map(|()| true),
|
||||
PointerKind::Down => self.vector_down(&ve, ev),
|
||||
PointerKind::Move => self.vector_drag(&ve, ev).map(|()| true),
|
||||
PointerKind::Up => self.vector_up(&ve, ev).map(|()| true),
|
||||
PointerKind::DoubleClick => {
|
||||
let tol = 6.0 / ev.zoom.max(1e-6);
|
||||
let doc = self.doc()?.clone();
|
||||
if ve.anchor.is_some() {
|
||||
super::escape(self)?;
|
||||
} else if hit(&doc, &ve, ev.p(), tol).is_none() && ve.tool == VTool::Move {
|
||||
super::end(self)?;
|
||||
}
|
||||
Ok(true)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn vgesture(&mut self, g: VGesture) {
|
||||
self.tool.gesture = Gesture::Vector(g);
|
||||
}
|
||||
|
||||
fn vector_hover(&mut self, ve: &VectorEdit, ev: &PointerEvent) -> Result<()> {
|
||||
let doc = self.doc()?.clone();
|
||||
let tol = 6.0 / ev.zoom.max(1e-6);
|
||||
let h = hit(&doc, ve, ev.p(), tol);
|
||||
let ov = &mut self.tool.overlay;
|
||||
ov.vector_hover = h;
|
||||
ov.face.clear();
|
||||
ov.rubber = None;
|
||||
ov.cursor = match ve.tool {
|
||||
VTool::Move if ev.cmd => Cursor::Crosshair,
|
||||
VTool::Move => Cursor::Default,
|
||||
VTool::Pen => Cursor::Pen,
|
||||
_ => Cursor::Crosshair,
|
||||
};
|
||||
let w = doc.world_transform(ve.id);
|
||||
match ve.tool {
|
||||
VTool::Pen => {
|
||||
if let Some(a) = ve.anchor.and_then(|a| network(&doc, ve.id)?.vertices.get(a).copied()) {
|
||||
let ap = w * a.point();
|
||||
self.tool.overlay.rubber = Some([ap.x, ap.y, ev.x, ev.y]);
|
||||
}
|
||||
}
|
||||
VTool::Paint => {
|
||||
// Highlight the closed area the bucket would fill (skipped on huge networks).
|
||||
if let Some(net) = network(&doc, ve.id).filter(|n| n.segments.len() <= 3000)
|
||||
&& let Some(face) = net.face_at(w.inverse() * ev.p())
|
||||
{
|
||||
let mut polys: Vec<Vec<[f64; 2]>> = Vec::new();
|
||||
let mut path = face.path.clone();
|
||||
path.apply_affine(w);
|
||||
figcraft_geom::kurbo::flatten(path.iter(), 0.25 / ev.zoom.max(1e-6), |el| {
|
||||
use figcraft_geom::kurbo::PathEl;
|
||||
match el {
|
||||
PathEl::MoveTo(p) => polys.push(vec![[p.x, p.y]]),
|
||||
PathEl::LineTo(p) => {
|
||||
if let Some(l) = polys.last_mut() {
|
||||
l.push([p.x, p.y]);
|
||||
}
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
});
|
||||
self.tool.overlay.face = polys;
|
||||
}
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
if let Ok(ds) = self.ds_mut() {
|
||||
ds.revision += 1;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Returns false when the click left edit mode (the caller then runs the normal tool).
|
||||
fn vector_down(&mut self, ve: &VectorEdit, ev: &PointerEvent) -> Result<bool> {
|
||||
let doc = self.doc()?.clone();
|
||||
let tol = 6.0 / ev.zoom.max(1e-6);
|
||||
let h = hit(&doc, ve, ev.p(), tol);
|
||||
self.tool.overlay.vector_hover = None;
|
||||
self.tool.overlay.face.clear();
|
||||
let tool = if ev.cmd && ve.tool == VTool::Move { VTool::Bend } else { ve.tool };
|
||||
let id = ve.id;
|
||||
let w = doc.world_transform(id);
|
||||
match tool {
|
||||
VTool::Move => match h {
|
||||
Some(VHit::Handle { seg, start }) => {
|
||||
self.begin_interaction("Edit handle")?;
|
||||
self.vgesture(VGesture::Handle { seg, start, independent: ev.alt });
|
||||
}
|
||||
Some(VHit::Vertex { index }) => {
|
||||
let verts = if ev.shift {
|
||||
let mut v = ve.vertices.clone();
|
||||
if let Some(i) = v.iter().position(|x| *x == index) {
|
||||
v.remove(i);
|
||||
} else {
|
||||
v.push(index);
|
||||
}
|
||||
v
|
||||
} else if ve.vertices.contains(&index) {
|
||||
ve.vertices.clone()
|
||||
} else {
|
||||
vec![index]
|
||||
};
|
||||
let keep_segs = ev.shift;
|
||||
set_state(self, |e| {
|
||||
e.vertices = verts.clone();
|
||||
if !keep_segs {
|
||||
e.segments.clear();
|
||||
}
|
||||
})?;
|
||||
let moving = network(&doc, id)
|
||||
.map(|n| {
|
||||
super::selected_points(
|
||||
n,
|
||||
&VectorEdit { vertices: verts, segments: if keep_segs { ve.segments.clone() } else { vec![] }, ..ve.clone() },
|
||||
)
|
||||
})
|
||||
.unwrap_or_default();
|
||||
self.begin_interaction("Move points")?;
|
||||
self.vgesture(VGesture::Points { start: ev.p(), verts: moving, split: None, moved: false });
|
||||
}
|
||||
Some(VHit::Segment { seg, t }) => {
|
||||
if ev.alt {
|
||||
// Add a point on the segment and drag it.
|
||||
self.begin_interaction("Add point")?;
|
||||
let mut v = None;
|
||||
self.preview(|doc, _| {
|
||||
v = edit(doc, id, |net| ops::split_segment(net, seg, t))?;
|
||||
Ok(())
|
||||
})?;
|
||||
let Some(v) = v else { return Ok(true) };
|
||||
set_state(self, |e| {
|
||||
e.vertices = vec![v];
|
||||
e.segments.clear();
|
||||
})?;
|
||||
self.vgesture(VGesture::Points { start: ev.p(), verts: vec![v], split: Some((seg, t)), moved: false });
|
||||
} else if ev.shift {
|
||||
set_state(self, |e| {
|
||||
if let Some(i) = e.segments.iter().position(|x| *x == seg) {
|
||||
e.segments.remove(i);
|
||||
} else {
|
||||
e.segments.push(seg);
|
||||
}
|
||||
})?;
|
||||
} else {
|
||||
let ends = network(&doc, id).and_then(|n| n.segments.get(seg)).map(|s| vec![s.a, s.b]).unwrap_or_default();
|
||||
set_state(self, |e| {
|
||||
e.vertices.clear();
|
||||
e.segments = vec![seg];
|
||||
})?;
|
||||
self.begin_interaction("Move segment")?;
|
||||
self.vgesture(VGesture::Points { start: ev.p(), verts: ends, split: None, moved: false });
|
||||
}
|
||||
}
|
||||
None => {
|
||||
// Outside the shape entirely (and not shift): leave edit mode.
|
||||
let inside = doc.world_render_bounds(id).is_some_and(|b| b.inflate(tol * 4.0, tol * 4.0).contains(ev.p()));
|
||||
if !inside && !ev.shift {
|
||||
super::end(self)?;
|
||||
return Ok(false);
|
||||
}
|
||||
let base = if ev.shift { ve.vertices.clone() } else { vec![] };
|
||||
set_state(self, |e| {
|
||||
e.vertices = base.clone();
|
||||
if !ev.shift {
|
||||
e.segments.clear();
|
||||
}
|
||||
})?;
|
||||
self.vgesture(VGesture::Marquee { start: ev.p(), base });
|
||||
}
|
||||
},
|
||||
VTool::Bend => match h {
|
||||
Some(VHit::Handle { seg, start }) => {
|
||||
self.begin_interaction("Edit handle")?;
|
||||
self.vgesture(VGesture::Handle { seg, start, independent: true });
|
||||
}
|
||||
Some(VHit::Vertex { index }) => {
|
||||
let smooth =
|
||||
network(&doc, id).is_some_and(|n| n.incident(index).iter().any(|(s, st)| n.handle(*s, *st).is_some_and(|h| h != (0.0, 0.0))));
|
||||
let origin = network(&doc, id).and_then(|n| n.vertices.get(index)).map(|v| v.point()).unwrap_or_default();
|
||||
set_state(self, |e| {
|
||||
e.vertices = vec![index];
|
||||
e.segments.clear();
|
||||
})?;
|
||||
self.begin_interaction("Bend")?;
|
||||
self.vgesture(VGesture::BendVertex { v: index, origin, moved: false, smooth });
|
||||
}
|
||||
Some(VHit::Segment { seg, t }) => {
|
||||
self.begin_interaction("Bend")?;
|
||||
self.vgesture(VGesture::BendSegment { seg, t });
|
||||
}
|
||||
None => {
|
||||
let base = if ev.shift { ve.vertices.clone() } else { vec![] };
|
||||
self.vgesture(VGesture::Marquee { start: ev.p(), base });
|
||||
}
|
||||
},
|
||||
VTool::Pen => {
|
||||
let Some(plan) = self.pen_plan(ve, h, w.inverse() * ev.p(), ev.shift) else { return Ok(true) };
|
||||
self.begin_interaction("Pen")?;
|
||||
let out = ve.pen_out;
|
||||
let mut v = None;
|
||||
self.preview(|doc, _| {
|
||||
v = edit(doc, id, |net| apply_pen(net, plan, out, None))?;
|
||||
Ok(())
|
||||
})?;
|
||||
self.pen_state(plan, v, None)?;
|
||||
self.vgesture(VGesture::Pen { plan, origin: ev.p(), out });
|
||||
}
|
||||
VTool::Paint => {
|
||||
self.begin_interaction("Paint bucket")?;
|
||||
let lp = w.inverse() * ev.p();
|
||||
if self.preview(|doc, _| super::paint(doc, id, lp).map(|_| ())).is_ok() {
|
||||
self.commit_interaction()?;
|
||||
} else {
|
||||
self.cancel_interaction()?;
|
||||
}
|
||||
}
|
||||
VTool::Lasso => {
|
||||
let base = if ev.shift { ve.vertices.clone() } else { vec![] };
|
||||
self.tool.overlay.lasso = vec![[ev.x, ev.y]];
|
||||
self.vgesture(VGesture::Lasso { points: vec![ev.p()], base });
|
||||
}
|
||||
VTool::Cut => {
|
||||
let res: Option<Vec<usize>> = match h {
|
||||
Some(VHit::Vertex { index }) => {
|
||||
self.begin_interaction("Cut")?;
|
||||
let mut out = vec![];
|
||||
self.preview(|doc, _| {
|
||||
out = edit(doc, id, |net| ops::cut_vertex(net, index))?;
|
||||
Ok(())
|
||||
})?;
|
||||
Some(out)
|
||||
}
|
||||
Some(VHit::Segment { seg, t }) => {
|
||||
self.begin_interaction("Cut")?;
|
||||
let mut out = vec![];
|
||||
self.preview(|doc, _| {
|
||||
out = edit(doc, id, |net| ops::cut_segment(net, seg, t))?.map(|(a, b)| vec![a, b]).unwrap_or_default();
|
||||
Ok(())
|
||||
})?;
|
||||
Some(out)
|
||||
}
|
||||
_ => None,
|
||||
};
|
||||
if let Some(ends) = res {
|
||||
self.commit_interaction()?;
|
||||
set_state(self, |e| {
|
||||
e.vertices = ends;
|
||||
e.segments.clear();
|
||||
})?;
|
||||
}
|
||||
}
|
||||
}
|
||||
Ok(true)
|
||||
}
|
||||
|
||||
/// What a pen click at local point `lp` does.
|
||||
fn pen_plan(&mut self, ve: &VectorEdit, h: Option<VHit>, lp: Point, shift: bool) -> Option<PenPlan> {
|
||||
let doc = self.doc().ok()?;
|
||||
let net = network(doc, ve.id)?;
|
||||
match h {
|
||||
Some(VHit::Vertex { index }) => match ve.anchor {
|
||||
Some(a) if a != index => Some(PenPlan::Connect { from: a, to: index }),
|
||||
Some(_) => {
|
||||
let _ = super::escape(self);
|
||||
None
|
||||
}
|
||||
None => {
|
||||
// Continue (or branch) from an existing point.
|
||||
let _ = set_state(self, |e| {
|
||||
e.anchor = Some(index);
|
||||
e.pen_out = None;
|
||||
e.vertices = vec![index];
|
||||
e.segments.clear();
|
||||
});
|
||||
None
|
||||
}
|
||||
},
|
||||
Some(VHit::Segment { seg, t }) => Some(PenPlan::Split { seg, t, from: ve.anchor }),
|
||||
_ => {
|
||||
let mut at = lp;
|
||||
// Shift: 45° steps from the open end.
|
||||
if shift && let Some(a) = ve.anchor.and_then(|a| net.vertices.get(a)) {
|
||||
let v = lp - a.point();
|
||||
let step = std::f64::consts::FRAC_PI_4;
|
||||
let ang = (v.atan2() / step).round() * step;
|
||||
at = a.point() + Vec2::from_angle(ang) * v.hypot();
|
||||
}
|
||||
Some(PenPlan::New { at, from: ve.anchor })
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Edit state after a pen click.
|
||||
fn pen_state(&mut self, plan: PenPlan, v: Option<usize>, out: Option<(f64, f64)>) -> Result<()> {
|
||||
set_state(self, |e| {
|
||||
e.segments.clear();
|
||||
e.vertices = v.into_iter().collect();
|
||||
match plan {
|
||||
PenPlan::New { .. } => {
|
||||
e.anchor = v;
|
||||
e.pen_out = out;
|
||||
}
|
||||
PenPlan::Connect { .. } | PenPlan::Split { .. } => {
|
||||
e.anchor = None;
|
||||
e.pen_out = None;
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
/// A pen click from a command (`drag` = the handle pulled out of the new point, page units).
|
||||
pub(crate) fn pen_click(&mut self, ve: &VectorEdit, p: Point, drag: Vec2, zoom: f64) -> Result<Option<usize>> {
|
||||
let doc = self.doc()?.clone();
|
||||
let tol = 6.0 / zoom.max(1e-6);
|
||||
let w = doc.world_transform(ve.id);
|
||||
let Some(plan) = self.pen_plan(ve, hit(&doc, ve, p, tol), w.inverse() * p, false) else {
|
||||
return Ok(self.ds()?.vector_edit.as_ref().and_then(|e| e.anchor));
|
||||
};
|
||||
let inv = w.inverse();
|
||||
let ld = inv * (Point::ZERO + drag) - inv * Point::ZERO;
|
||||
let drag = (ld.hypot() > 1e-9 && ld.x.is_finite() && ld.y.is_finite()).then_some(ld);
|
||||
let v = edit(self.doc_mut()?, ve.id, |net| apply_pen(net, plan, ve.pen_out, drag))?;
|
||||
self.pen_state(plan, v, drag.map(|d| (d.x, d.y)))?;
|
||||
Ok(v)
|
||||
}
|
||||
|
||||
fn vector_drag(&mut self, ve: &VectorEdit, ev: &PointerEvent) -> Result<()> {
|
||||
let Gesture::Vector(g) = self.tool.gesture.clone() else { return Ok(()) };
|
||||
let id = ve.id;
|
||||
let w = self.doc()?.world_transform(id);
|
||||
let inv = w.inverse();
|
||||
let lp = inv * ev.p();
|
||||
let to_local = |d: Vec2| inv * (Point::ZERO + d) - inv * Point::ZERO;
|
||||
match g {
|
||||
VGesture::Points { start, verts, split, .. } => {
|
||||
let mut d = ev.p() - start;
|
||||
if d.hypot() * ev.zoom < 1.0 {
|
||||
return Ok(());
|
||||
}
|
||||
if ev.shift {
|
||||
if d.x.abs() > d.y.abs() {
|
||||
d.y = 0.0;
|
||||
} else {
|
||||
d.x = 0.0;
|
||||
}
|
||||
}
|
||||
let ld = to_local(d);
|
||||
self.preview(|doc, _| {
|
||||
edit(doc, id, |net| {
|
||||
if let Some((seg, t)) = split {
|
||||
ops::split_segment(net, seg, t);
|
||||
}
|
||||
ops::move_vertices(net, &verts, ld);
|
||||
})
|
||||
})?;
|
||||
self.vgesture(VGesture::Points { start, verts, split, moved: true });
|
||||
}
|
||||
VGesture::Handle { seg, start, independent } => {
|
||||
let alt = independent || ev.alt;
|
||||
self.preview(|doc, _| {
|
||||
edit(doc, id, |net| {
|
||||
let Some(s) = net.segments.get(seg).copied() else { return };
|
||||
let Some(v) = net.vertices.get(if start { s.a } else { s.b }).copied() else { return };
|
||||
ops::set_handle(net, seg, start, (lp.x - v.x, lp.y - v.y), alt);
|
||||
})
|
||||
})?;
|
||||
}
|
||||
VGesture::BendSegment { seg, t } => {
|
||||
self.preview(|doc, _| edit(doc, id, |net| ops::bend(net, seg, t, lp)))?;
|
||||
}
|
||||
VGesture::BendVertex { v, origin, moved, smooth } => {
|
||||
let d = lp - origin;
|
||||
let page_d = (w * lp - w * origin).hypot() * ev.zoom;
|
||||
if !moved && page_d < 2.0 {
|
||||
return Ok(());
|
||||
}
|
||||
self.preview(|doc, _| edit(doc, id, |net| ops::make_smooth(net, v, d)))?;
|
||||
self.vgesture(VGesture::BendVertex { v, origin, moved: true, smooth });
|
||||
}
|
||||
VGesture::Marquee { start, base } => {
|
||||
let r = Rect::from_points(start, ev.p());
|
||||
self.tool.overlay.marquee = Some([r.x0, r.y0, r.x1, r.y1]);
|
||||
let mut v = base.clone();
|
||||
for i in super::vertices_in(self.doc()?, id, r) {
|
||||
if !v.contains(&i) {
|
||||
v.push(i);
|
||||
}
|
||||
}
|
||||
set_state(self, |e| e.vertices = v)?;
|
||||
}
|
||||
VGesture::Lasso { mut points, base } => {
|
||||
if points.last().is_none_or(|l| (*l - ev.p()).hypot() * ev.zoom >= 2.0) && points.len() < 100_000 {
|
||||
points.push(ev.p());
|
||||
}
|
||||
self.tool.overlay.lasso = points.iter().map(|p| [p.x, p.y]).collect();
|
||||
let mut v = base.clone();
|
||||
for i in super::vertices_in_polygon(self.doc()?, id, &points) {
|
||||
if !v.contains(&i) {
|
||||
v.push(i);
|
||||
}
|
||||
}
|
||||
set_state(self, |e| {
|
||||
e.vertices = v;
|
||||
e.segments.clear();
|
||||
})?;
|
||||
self.vgesture(VGesture::Lasso { points, base });
|
||||
}
|
||||
VGesture::Pen { plan, origin, out, .. } => {
|
||||
let d = to_local(ev.p() - origin);
|
||||
if (ev.p() - origin).hypot() * ev.zoom < 2.0 {
|
||||
return Ok(());
|
||||
}
|
||||
let mut v = None;
|
||||
self.preview(|doc, _| {
|
||||
v = edit(doc, id, |net| apply_pen(net, plan, out, Some(d)))?;
|
||||
Ok(())
|
||||
})?;
|
||||
self.pen_state(plan, v, Some((d.x, d.y)))?;
|
||||
self.vgesture(VGesture::Pen { plan, origin, out });
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn vector_up(&mut self, ve: &VectorEdit, _ev: &PointerEvent) -> Result<()> {
|
||||
let g = std::mem::take(&mut self.tool.gesture);
|
||||
self.tool.overlay.marquee = None;
|
||||
self.tool.overlay.lasso.clear();
|
||||
let Gesture::Vector(g) = g else { return Ok(()) };
|
||||
match g {
|
||||
VGesture::Points { moved, split, .. } => {
|
||||
if moved || split.is_some() {
|
||||
self.commit_interaction()?;
|
||||
} else {
|
||||
self.cancel_interaction()?;
|
||||
}
|
||||
}
|
||||
VGesture::BendVertex { v, moved, smooth, .. } => {
|
||||
if moved {
|
||||
self.commit_interaction()?;
|
||||
} else if smooth {
|
||||
let id = ve.id;
|
||||
self.preview(|doc, _| edit(doc, id, |net| ops::make_sharp(net, v)))?;
|
||||
self.commit_interaction()?;
|
||||
} else {
|
||||
self.cancel_interaction()?;
|
||||
}
|
||||
}
|
||||
VGesture::Handle { .. } | VGesture::BendSegment { .. } | VGesture::Pen { .. } => self.commit_interaction()?,
|
||||
VGesture::Marquee { .. } | VGesture::Lasso { .. } => {}
|
||||
}
|
||||
if let Ok(ds) = self.ds_mut() {
|
||||
ds.revision += 1;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,321 @@
|
||||
//! Vector edit mode through the session: tools, pointer gestures, keys and undo.
|
||||
|
||||
use serde_json::{Value, json};
|
||||
|
||||
use super::*;
|
||||
use crate::EngineError;
|
||||
use crate::tools::{PointerEvent, PointerKind};
|
||||
|
||||
fn ex(s: &mut Session, c: &str, p: Value) -> Value {
|
||||
s.execute(c, &p).unwrap_or_else(|e| panic!("{c}: {e}"))
|
||||
}
|
||||
|
||||
fn net(s: &Session, id: NodeId) -> VectorNetwork {
|
||||
network(s.doc().unwrap(), id).unwrap().clone()
|
||||
}
|
||||
|
||||
fn ev(kind: PointerKind, x: f64, y: f64) -> PointerEvent {
|
||||
PointerEvent::at(kind, x, y, 1.0)
|
||||
}
|
||||
|
||||
fn click(s: &mut Session, x: f64, y: f64) {
|
||||
s.pointer(&ev(PointerKind::Down, x, y)).unwrap();
|
||||
s.pointer(&ev(PointerKind::Up, x, y)).unwrap();
|
||||
}
|
||||
|
||||
fn drag(s: &mut Session, from: (f64, f64), to: (f64, f64)) {
|
||||
s.pointer(&ev(PointerKind::Down, from.0, from.1)).unwrap();
|
||||
s.pointer(&ev(PointerKind::Move, (from.0 + to.0) / 2.0, (from.1 + to.1) / 2.0)).unwrap();
|
||||
s.pointer(&ev(PointerKind::Move, to.0, to.1)).unwrap();
|
||||
s.pointer(&ev(PointerKind::Up, to.0, to.1)).unwrap();
|
||||
}
|
||||
|
||||
/// A 100×100 rectangle at the origin, in edit mode.
|
||||
fn rect(s: &mut Session) -> NodeId {
|
||||
let r = ex(s, "shape.rectangle", json!({"x": 0, "y": 0, "width": 100, "height": 100}));
|
||||
let id = crate::params::id_of(&r["id"]).unwrap();
|
||||
ex(s, "vector.edit.begin", json!({"id": id}));
|
||||
id
|
||||
}
|
||||
|
||||
fn ve(s: &Session) -> VectorEdit {
|
||||
s.ds().unwrap().vector_edit.clone().unwrap()
|
||||
}
|
||||
|
||||
fn vertex_at(n: &VectorNetwork, x: f64, y: f64) -> usize {
|
||||
n.vertices.iter().position(|v| (v.x - x).abs() < 1e-6 && (v.y - y).abs() < 1e-6).unwrap()
|
||||
}
|
||||
|
||||
fn pt(x: f64, y: f64) -> Point {
|
||||
Point::new(x, y)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn pen_extends_branches_and_closes_paths() {
|
||||
let mut s = Session::new();
|
||||
let r = ex(&mut s, "vector.create", json!({"points": [[0, 0], [100, 0]], "closed": false}));
|
||||
let id = crate::params::id_of(&r["id"]).unwrap();
|
||||
ex(&mut s, "vector.edit.begin", json!({"id": id}));
|
||||
ex(&mut s, "vector.edit.tool", json!({"tool": "pen"}));
|
||||
// Click the end point to continue from it, then add a point below and close onto the start.
|
||||
click(&mut s, 100.0, 0.0);
|
||||
assert!(ve(&s).anchor.is_some());
|
||||
click(&mut s, 100.0, 100.0);
|
||||
let n = net(&s, id);
|
||||
assert_eq!((n.vertices.len(), n.segments.len()), (3, 2));
|
||||
click(&mut s, 0.0, 0.0);
|
||||
let n = net(&s, id);
|
||||
assert_eq!(n.segments.len(), 3);
|
||||
assert_eq!(n.regions.len(), 1, "closing the path makes a fill region");
|
||||
assert!(ve(&s).anchor.is_none(), "closing ends the path");
|
||||
// Branch from an existing corner.
|
||||
click(&mut s, 100.0, 100.0);
|
||||
click(&mut s, 200.0, 150.0);
|
||||
let n = net(&s, id);
|
||||
let corner = vertex_at(&n, 100.0, 100.0);
|
||||
assert_eq!(n.incident(corner).len(), 3, "a vector network branch");
|
||||
// Escape ends the path but stays in edit mode; the next Escape leaves.
|
||||
s.escape().unwrap();
|
||||
assert!(ve(&s).anchor.is_none());
|
||||
s.escape().unwrap();
|
||||
assert!(s.ds().unwrap().vector_edit.is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn pen_drag_makes_a_smooth_point() {
|
||||
let mut s = Session::new();
|
||||
let id = rect(&mut s);
|
||||
ex(&mut s, "vector.edit.tool", json!({"tool": "pen"}));
|
||||
click(&mut s, 100.0, 0.0);
|
||||
drag(&mut s, (150.0, 50.0), (150.0, 80.0));
|
||||
let n = net(&s, id);
|
||||
let v = n.vertices.len() - 1;
|
||||
assert_eq!(n.mirroring(v), HandleMirroring::AngleAndLength);
|
||||
let (seg, start) = n.incident(v)[0];
|
||||
let h = n.handle(seg, start).unwrap();
|
||||
assert!(h.0.abs() < 1e-6 && (h.1 + 30.0).abs() < 1e-6, "incoming handle mirrors the drag: {h:?}");
|
||||
assert_eq!(ve(&s).pen_out, Some((0.0, 30.0)));
|
||||
// The next segment starts with the dragged-out handle.
|
||||
click(&mut s, 250.0, 50.0);
|
||||
let n = net(&s, id);
|
||||
let last = *n.segments.last().unwrap();
|
||||
assert_eq!(last.ta, (0.0, 30.0));
|
||||
// One undo step per click.
|
||||
assert!(s.undo().unwrap());
|
||||
assert_eq!(net(&s, id).vertices.len(), n.vertices.len() - 1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn bend_tool_smooths_and_sharpens_points() {
|
||||
let mut s = Session::new();
|
||||
let id = rect(&mut s);
|
||||
ex(&mut s, "vector.edit.tool", json!({"tool": "bend"}));
|
||||
drag(&mut s, (100.0, 0.0), (130.0, 0.0));
|
||||
let n = net(&s, id);
|
||||
let v = vertex_at(&n, 100.0, 0.0);
|
||||
assert_eq!(n.mirroring(v), HandleMirroring::Angle);
|
||||
assert!(n.incident(v).iter().all(|(s, st)| n.handle(*s, *st) != Some((0.0, 0.0))));
|
||||
click(&mut s, 100.0, 0.0);
|
||||
let n = net(&s, id);
|
||||
let v = vertex_at(&n, 100.0, 0.0);
|
||||
assert!(n.incident(v).iter().all(|(s, st)| n.handle(*s, *st) == Some((0.0, 0.0))), "a click sharpens it");
|
||||
// ⌘-drag on a segment with the Move tool bends it.
|
||||
ex(&mut s, "vector.edit.tool", json!({"tool": "move"}));
|
||||
let mut down = ev(PointerKind::Down, 0.0, 50.0);
|
||||
down.cmd = true;
|
||||
s.pointer(&down).unwrap();
|
||||
s.pointer(&ev(PointerKind::Move, -40.0, 50.0)).unwrap();
|
||||
s.pointer(&ev(PointerKind::Up, -40.0, 50.0)).unwrap();
|
||||
let b = s.doc().unwrap().world_bounds(id).unwrap();
|
||||
assert!(b.x0 < -25.0, "bent outwards: {b:?}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn paint_bucket_fills_and_unfills_areas() {
|
||||
let mut s = Session::new();
|
||||
let id = rect(&mut s);
|
||||
ex(&mut s, "vector.edit.connect", json!({"a": 0, "b": 2}));
|
||||
ex(&mut s, "vector.edit.tool", json!({"tool": "paint"}));
|
||||
assert!(net(&s, id).fill_contains(pt(75.0, 25.0)));
|
||||
// Hovering highlights the area; clicking unfills just that triangle.
|
||||
s.pointer(&ev(PointerKind::Hover, 75.0, 25.0)).unwrap();
|
||||
assert!(!s.tool.overlay.face.is_empty());
|
||||
click(&mut s, 75.0, 25.0);
|
||||
let n = net(&s, id);
|
||||
assert!(!n.fill_contains(pt(75.0, 25.0)));
|
||||
assert!(n.fill_contains(pt(25.0, 75.0)));
|
||||
click(&mut s, 75.0, 25.0);
|
||||
assert!(net(&s, id).fill_contains(pt(75.0, 25.0)));
|
||||
s.undo().unwrap();
|
||||
assert!(!net(&s, id).fill_contains(pt(75.0, 25.0)));
|
||||
// A vector without fills gets one when painted.
|
||||
ex(&mut s, "node.set", json!({"id": id, "props": {"fills": []}}));
|
||||
let r = ex(&mut s, "vector.edit.paint", json!({"x": 75, "y": 25}));
|
||||
assert_eq!(r["filled"], json!(true));
|
||||
assert!(!s.doc().unwrap().node(id).unwrap().fills.is_empty());
|
||||
assert!(s.execute("vector.edit.paint", &json!({"x": 500, "y": 500})).is_err(), "outside any area");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn lasso_marquee_and_segment_selection() {
|
||||
let mut s = Session::new();
|
||||
let _id = rect(&mut s);
|
||||
ex(&mut s, "vector.edit.tool", json!({"tool": "lasso"}));
|
||||
s.pointer(&ev(PointerKind::Down, -10.0, -10.0)).unwrap();
|
||||
for (x, y) in [(110.0, -10.0), (110.0, 10.0), (-10.0, 10.0)] {
|
||||
s.pointer(&ev(PointerKind::Move, x, y)).unwrap();
|
||||
}
|
||||
assert!(!s.tool.overlay.lasso.is_empty());
|
||||
s.pointer(&ev(PointerKind::Up, -10.0, 10.0)).unwrap();
|
||||
assert_eq!(ve(&s).vertices.len(), 2, "the top two points");
|
||||
assert!(s.tool.overlay.lasso.is_empty());
|
||||
// Move tool: clicking a segment selects it; shift adds another.
|
||||
ex(&mut s, "vector.edit.tool", json!({"tool": "move"}));
|
||||
click(&mut s, 50.0, 100.0);
|
||||
assert_eq!(ve(&s).segments.len(), 1);
|
||||
assert!(ve(&s).vertices.is_empty());
|
||||
let mut sh = ev(PointerKind::Down, 0.0, 50.0);
|
||||
sh.shift = true;
|
||||
s.pointer(&sh).unwrap();
|
||||
s.pointer(&ev(PointerKind::Up, 0.0, 50.0)).unwrap();
|
||||
assert_eq!(ve(&s).segments.len(), 2);
|
||||
ex(&mut s, "vector.edit.selectAll", json!({}));
|
||||
assert_eq!(ve(&s).vertices.len(), 4);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn alt_click_adds_a_point_that_drags_in_the_same_gesture() {
|
||||
let mut s = Session::new();
|
||||
let id = rect(&mut s);
|
||||
let mut down = ev(PointerKind::Down, 50.0, 0.0);
|
||||
down.alt = true;
|
||||
s.pointer(&down).unwrap();
|
||||
s.pointer(&ev(PointerKind::Move, 50.0, -20.0)).unwrap();
|
||||
s.pointer(&ev(PointerKind::Up, 50.0, -20.0)).unwrap();
|
||||
assert_eq!(net(&s, id).vertices.len(), 5);
|
||||
let b = s.doc().unwrap().world_bounds(id).unwrap();
|
||||
assert_eq!(b.y0, -20.0, "the new point was dragged up");
|
||||
s.undo().unwrap();
|
||||
assert_eq!(net(&s, id).vertices.len(), 4, "add + drag is one step");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn cut_tool_and_keys() {
|
||||
let mut s = Session::new();
|
||||
let id = rect(&mut s);
|
||||
ex(&mut s, "vector.edit.tool", json!({"tool": "cut"}));
|
||||
click(&mut s, 50.0, 0.0);
|
||||
assert_eq!(net(&s, id).vertices.len(), 6);
|
||||
assert_eq!(ve(&s).vertices.len(), 2);
|
||||
// Keys: tool letters, arrows nudge, ⌘A selects all, Delete removes, Enter leaves.
|
||||
assert!(key(&mut s, "V", false, false, false).unwrap());
|
||||
assert_eq!(ve(&s).tool, VTool::Move);
|
||||
assert!(key(&mut s, "Q", false, false, false).unwrap());
|
||||
assert_eq!(ve(&s).tool, VTool::Lasso);
|
||||
assert!(key(&mut s, "B", true, false, false).unwrap());
|
||||
assert_eq!(ve(&s).tool, VTool::Paint);
|
||||
let v = vertex_at(&net(&s, id), 100.0, 100.0);
|
||||
ex(&mut s, "vector.edit.select", json!({"vertices": [v]}));
|
||||
assert!(key(&mut s, "Right", false, false, false).unwrap());
|
||||
let w = s.doc().unwrap().world_transform(id);
|
||||
assert_eq!((w * net(&s, id).vertices[v].point()).x, 101.0);
|
||||
assert!(key(&mut s, "Down", true, false, false).unwrap());
|
||||
let w = s.doc().unwrap().world_transform(id);
|
||||
assert_eq!((w * net(&s, id).vertices[v].point()).y, 110.0);
|
||||
assert!(key(&mut s, "A", false, false, true).unwrap());
|
||||
assert_eq!(ve(&s).vertices.len(), 6);
|
||||
assert!(!key(&mut s, "Z", false, false, true).unwrap(), "undo falls through to shortcuts");
|
||||
ex(&mut s, "vector.edit.select", json!({"vertices": [v]}));
|
||||
assert!(key(&mut s, "Backspace", false, false, false).unwrap());
|
||||
assert_eq!(net(&s, id).vertices.len(), 5);
|
||||
assert!(key(&mut s, "Enter", false, false, false).unwrap());
|
||||
assert!(s.ds().unwrap().vector_edit.is_none());
|
||||
assert!(!key(&mut s, "V", false, false, false).unwrap(), "keys pass through outside edit mode");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn delete_vs_delete_and_heal() {
|
||||
let mut s = Session::new();
|
||||
let id = rect(&mut s);
|
||||
ex(&mut s, "vector.edit.select", json!({"vertices": [1]}));
|
||||
ex(&mut s, "vector.edit.delete", json!({}));
|
||||
let n = net(&s, id);
|
||||
assert_eq!((n.vertices.len(), n.segments.len()), (3, 2), "the path opens");
|
||||
s.undo().unwrap();
|
||||
ex(&mut s, "vector.edit.select", json!({"vertices": [1]}));
|
||||
ex(&mut s, "vector.deleteHeal", json!({}));
|
||||
let n = net(&s, id);
|
||||
assert_eq!((n.vertices.len(), n.segments.len(), n.regions.len()), (3, 3, 1), "healed and closed");
|
||||
// Deleting a selected segment keeps its points.
|
||||
s.undo().unwrap();
|
||||
ex(&mut s, "vector.edit.select", json!({"segments": [0]}));
|
||||
ex(&mut s, "vector.edit.delete", json!({}));
|
||||
let n = net(&s, id);
|
||||
assert_eq!((n.vertices.len(), n.segments.len()), (4, 3));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn mirroring_position_and_join_commands() {
|
||||
let mut s = Session::new();
|
||||
let id = rect(&mut s);
|
||||
ex(&mut s, "vector.edit.select", json!({"vertices": [1]}));
|
||||
ex(&mut s, "vector.edit.smooth", json!({}));
|
||||
ex(&mut s, "vector.edit.mirroring", json!({"mode": "angleAndLength"}));
|
||||
let st = ex(&mut s, "vector.edit.state", json!({}));
|
||||
assert_eq!(st["mirroring"][1], json!("angleAndLength"));
|
||||
assert_eq!(st["tool"], json!("move"));
|
||||
assert!(s.execute("vector.edit.mirroring", &json!({"mode": "sideways"})).is_err());
|
||||
// Position: move the selected point to x = -50 in parent coordinates.
|
||||
ex(&mut s, "vector.edit.select", json!({"vertices": [0]}));
|
||||
ex(&mut s, "vector.edit.position", json!({"x": -50}));
|
||||
let p = selection_position(s.doc().unwrap(), &ve(&s)).unwrap();
|
||||
assert!((p.x + 50.0).abs() < 1e-6, "{p:?}");
|
||||
// Join: cut a corner open, then join its two ends back (they overlap: merged).
|
||||
ex(&mut s, "vector.edit.cut", json!({"vertex": 2}));
|
||||
let before = net(&s, id).vertices.len();
|
||||
ex(&mut s, "vector.join", json!({}));
|
||||
assert_eq!(net(&s, id).vertices.len(), before - 1);
|
||||
// Outside edit mode, vector.join keeps its layer behaviour.
|
||||
ex(&mut s, "vector.edit.end", json!({}));
|
||||
assert!(s.execute("vector.join", &json!({"ids": [id]})).is_ok());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn undo_back_past_the_conversion_leaves_edit_mode() {
|
||||
let mut s = Session::new();
|
||||
let id = rect(&mut s);
|
||||
assert!(s.ds().unwrap().vector_edit.is_some());
|
||||
s.undo().unwrap();
|
||||
assert_eq!(s.doc().unwrap().node(id).unwrap().kind.type_name(), "RECTANGLE");
|
||||
assert!(s.ds().unwrap().vector_edit.is_none());
|
||||
// Choosing a drawing tool leaves edit mode too.
|
||||
let _ = rect(&mut s);
|
||||
s.set_tool(crate::tools::ToolId::Rectangle);
|
||||
assert!(s.ds().unwrap().vector_edit.is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn every_command_survives_junk_params_while_editing() {
|
||||
let junk = [
|
||||
json!({}),
|
||||
json!(null),
|
||||
json!({"vertices": [-1, 1e12, "x"], "segments": [99999], "segment": -5, "t": "NaN", "x": 1e308, "y": -1e308}),
|
||||
json!({"segment": 0, "t": 2, "x": 5, "y": 5, "dx": 1e300, "dy": 1, "a": 0, "b": 0, "vertex": 99}),
|
||||
json!({"points": [[0, 0], [1], "x", [5, 5], [0, 9]], "mode": 7, "tool": "nope", "radius": -3}),
|
||||
];
|
||||
for spec in crate::all_commands().iter().filter(|c| c.id.starts_with("vector.")) {
|
||||
for j in &junk {
|
||||
let mut s = Session::new();
|
||||
let _ = rect(&mut s);
|
||||
if let Err(EngineError::Internal { cmd, msg }) = s.execute(spec.id, j) {
|
||||
panic!("{cmd} panicked on {j}: {msg}");
|
||||
}
|
||||
// The network stays sane.
|
||||
if let Some(ve) = s.ds().unwrap().vector_edit.clone()
|
||||
&& let Some(n) = network(s.doc().unwrap(), ve.id)
|
||||
{
|
||||
assert!(n.segments.iter().all(|x| x.a < n.vertices.len() && x.b < n.vertices.len()), "{} broke the network", spec.id);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -133,7 +133,7 @@ fn kjoin(j: StrokeJoin) -> Join {
|
||||
|
||||
/// Plain centred stroke of `path` at `width`.
|
||||
pub fn stroke_center(path: &BezPath, width: f64, props: &StrokeProps) -> BezPath {
|
||||
if !(width.is_finite() && width > 0.0) || !finite(path) {
|
||||
if !(width.is_finite() && width > 0.0 && finite(path)) {
|
||||
return BezPath::new();
|
||||
}
|
||||
let mut style = kurbo::Stroke::new(width)
|
||||
|
||||
@@ -443,7 +443,7 @@ impl<'a> Exporter<'a> {
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
fn raster_paint(&mut self, s: &mut Surface, path: &BezPath, kp: &Path, r: FillRule, p: &Paint, w: f64, h: f64, xf: Affine) {
|
||||
let bb = path.bounding_box();
|
||||
if !(bb.width() > 0.0 && bb.height() > 0.0) || !crate::rect_finite(bb) {
|
||||
if !(bb.width() > 0.0 && bb.height() > 0.0 && crate::rect_finite(bb)) {
|
||||
return;
|
||||
}
|
||||
let page_scale = xf.determinant().abs().sqrt();
|
||||
|
||||
@@ -46,7 +46,7 @@ impl Exporter<'_> {
|
||||
/// [`MAX_RASTER_SIDE`] pixels a side.
|
||||
fn raster_view(&self, b: Rect) -> Option<View> {
|
||||
let b = b.intersect(self.page_rect);
|
||||
if !(b.width() > 0.0 && b.height() > 0.0) || !crate::rect_finite(b) {
|
||||
if !(b.width() > 0.0 && b.height() > 0.0 && crate::rect_finite(b)) {
|
||||
return None;
|
||||
}
|
||||
let mut k = self.raster_scale();
|
||||
|
||||
@@ -606,7 +606,7 @@ impl TextLayout {
|
||||
let last_line = li + 1 == self.lines.len();
|
||||
let in_line = index >= line.char_start && (index < line.char_end || (last_line && index <= line.char_end));
|
||||
// A caret at the end of a soft-wrapped line belongs to the next line.
|
||||
if !in_line && !(index == line.char_end && (line.para_end && !last_line)) {
|
||||
if !(in_line || index == line.char_end && line.para_end && !last_line) {
|
||||
continue;
|
||||
}
|
||||
if index == line.char_end && line.para_end && !last_line {
|
||||
|
||||
+103
-25
@@ -512,32 +512,10 @@ fn overlays(app: &FigApp, painter: &egui::Painter, t: &Tokens) {
|
||||
painter.line_segment([app.to_screen(x0, y0), app.to_screen(x1, y1)], stroke(ACCENT, 2.0));
|
||||
}
|
||||
// Vector edit mode.
|
||||
if let Some((vid, vsel)) = ds.vector_edit.clone()
|
||||
&& let Some(o) = figcraft_engine::vectoredit::overlay(&doc, vid, &vsel)
|
||||
if let Some(ve) = ds.vector_edit.clone()
|
||||
&& let Some(o) = figcraft_engine::vectoredit::overlay(&doc, &ve)
|
||||
{
|
||||
let pt = |v: &serde_json::Value| -> Option<Pos2> { Some(app.to_screen(v.get(0)?.as_f64()?, v.get(1)?.as_f64()?)) };
|
||||
for seg in o["segments"].as_array().cloned().unwrap_or_default() {
|
||||
let pts: Vec<Pos2> = seg.as_array().map(|a| a.iter().filter_map(pt).collect()).unwrap_or_default();
|
||||
if let [a, b, c, d] = pts.as_slice() {
|
||||
painter.add(egui::epaint::CubicBezierShape::from_points_stroke([*a, *b, *c, *d], false, Color32::TRANSPARENT, stroke(ACCENT, 1.5)));
|
||||
}
|
||||
}
|
||||
for h in o["handles"].as_array().cloned().unwrap_or_default() {
|
||||
let pts: Vec<Pos2> = h.as_array().map(|a| a.iter().filter_map(pt).collect()).unwrap_or_default();
|
||||
if let [a, b] = pts.as_slice() {
|
||||
painter.line_segment([*a, *b], stroke(ACCENT, 1.0));
|
||||
painter.circle_filled(*b, 3.5, Color32::WHITE);
|
||||
painter.circle_stroke(*b, 3.5, stroke(ACCENT, 1.0));
|
||||
}
|
||||
}
|
||||
for (i, v) in o["vertices"].as_array().cloned().unwrap_or_default().iter().enumerate() {
|
||||
if let Some(p) = pt(v) {
|
||||
let r = Rect::from_center_size(p, vec2(7.0, 7.0));
|
||||
let on = vsel.contains(&i);
|
||||
painter.rect_filled(r, CornerRadius::same(4), if on { ACCENT } else { Color32::WHITE });
|
||||
painter.rect_stroke(r, CornerRadius::same(4), stroke(ACCENT, 1.0), StrokeKind::Inside);
|
||||
}
|
||||
}
|
||||
vector_overlay(app, painter, &o);
|
||||
}
|
||||
// Selection.
|
||||
let editing = ds.text_edit.clone();
|
||||
@@ -658,12 +636,112 @@ fn overlays(app: &FigApp, painter: &egui::Painter, t: &Tokens) {
|
||||
}
|
||||
}
|
||||
}
|
||||
// Lasso (vector edit mode).
|
||||
if ov.lasso.len() > 1 {
|
||||
let mut pts: Vec<Pos2> = ov.lasso.iter().map(|[x, y]| app.to_screen(*x, *y)).collect();
|
||||
if let Some(first) = pts.first().copied() {
|
||||
pts.push(first);
|
||||
}
|
||||
painter.add(Shape::dashed_line(&pts, stroke(ACCENT, 1.0), 4.0, 3.0));
|
||||
}
|
||||
// Rulers.
|
||||
if view.rulers {
|
||||
rulers(app, painter, t, &doc, &sel);
|
||||
}
|
||||
}
|
||||
|
||||
/// Vector edit mode: the network's segments, handles and points, the hovered part, the closed
|
||||
/// area under the paint bucket and the pen's rubber band.
|
||||
fn vector_overlay(app: &FigApp, painter: &egui::Painter, o: &figcraft_engine::vectoredit::EditOverlay) {
|
||||
use figcraft_engine::vectoredit::VHit;
|
||||
let ov = &app.session.tool.overlay;
|
||||
let sp = |p: &[f64; 2]| app.to_screen(p[0], p[1]);
|
||||
// Paint bucket target: hatched like a fill to come.
|
||||
if !ov.face.is_empty() {
|
||||
let polys: Vec<Vec<Pos2>> = ov.face.iter().map(|poly| poly.iter().map(sp).collect()).collect();
|
||||
for l in hatch(&polys, 5.0) {
|
||||
painter.line_segment(l, stroke(ACCENT, 1.0));
|
||||
}
|
||||
for poly in &polys {
|
||||
painter.add(Shape::closed_line(poly.clone(), stroke(ACCENT, 2.0)));
|
||||
}
|
||||
}
|
||||
let hover_seg = match ov.vector_hover {
|
||||
Some(VHit::Segment { seg, .. }) => Some(seg),
|
||||
_ => None,
|
||||
};
|
||||
for (i, seg) in o.segments.iter().enumerate() {
|
||||
let [a, b, c, d] = [sp(&seg[0]), sp(&seg[1]), sp(&seg[2]), sp(&seg[3])];
|
||||
let w = if o.selected_segments.contains(&i) {
|
||||
3.0
|
||||
} else if hover_seg == Some(i) {
|
||||
2.5
|
||||
} else {
|
||||
1.5
|
||||
};
|
||||
painter.add(egui::epaint::CubicBezierShape::from_points_stroke([a, b, c, d], false, Color32::TRANSPARENT, stroke(ACCENT, w)));
|
||||
}
|
||||
for h in &o.handles {
|
||||
let (v, e) = (sp(&h.vertex), sp(&h.handle));
|
||||
painter.line_segment([v, e], stroke(ACCENT, 1.0));
|
||||
let hovered = ov.vector_hover == Some(VHit::Handle { seg: h.seg, start: h.start });
|
||||
let r = if hovered { 4.5 } else { 3.5 };
|
||||
painter.circle_filled(e, r, Color32::WHITE);
|
||||
painter.circle_stroke(e, r, stroke(ACCENT, 1.0));
|
||||
}
|
||||
for (i, v) in o.vertices.iter().enumerate() {
|
||||
let p = sp(v);
|
||||
let hovered = matches!(ov.vector_hover, Some(VHit::Vertex { index }) if index == i);
|
||||
let size = if hovered { 9.0 } else { 7.0 };
|
||||
let r = Rect::from_center_size(p, vec2(size, size));
|
||||
let on = o.selected_vertices.contains(&i);
|
||||
painter.rect_filled(r, CornerRadius::same(5), if on { ACCENT } else { Color32::WHITE });
|
||||
painter.rect_stroke(r, CornerRadius::same(5), stroke(ACCENT, 1.0), StrokeKind::Inside);
|
||||
if o.anchor == Some(i) {
|
||||
painter.circle_stroke(p, 7.5, stroke(ACCENT, 1.0));
|
||||
}
|
||||
}
|
||||
if let Some([x0, y0, x1, y1]) = ov.rubber {
|
||||
painter.add(Shape::dashed_line(&[app.to_screen(x0, y0), app.to_screen(x1, y1)], stroke(ACCENT, 1.0), 4.0, 3.0));
|
||||
}
|
||||
}
|
||||
|
||||
/// 45° hatch lines `gap` apart inside polygons (even-odd), in screen space.
|
||||
fn hatch(polys: &[Vec<Pos2>], gap: f32) -> Vec<[Pos2; 2]> {
|
||||
// Rotate so the hatch is horizontal, scan, rotate back.
|
||||
let rot = |p: Pos2| pos2((p.x + p.y) * std::f32::consts::FRAC_1_SQRT_2, (p.y - p.x) * std::f32::consts::FRAC_1_SQRT_2);
|
||||
let back = |x: f32, y: f32| pos2((x - y) * std::f32::consts::FRAC_1_SQRT_2, (x + y) * std::f32::consts::FRAC_1_SQRT_2);
|
||||
let edges: Vec<(Pos2, Pos2)> = polys
|
||||
.iter()
|
||||
.flat_map(|poly| {
|
||||
let pts: Vec<Pos2> = poly.iter().map(|p| rot(*p)).collect();
|
||||
let n = pts.len();
|
||||
(0..n).filter_map(move |k| Some((*pts.get(k)?, *pts.get((k + 1) % n)?)))
|
||||
})
|
||||
.collect();
|
||||
let (y0, y1) = edges.iter().fold((f32::INFINITY, f32::NEG_INFINITY), |(lo, hi), (a, b)| (lo.min(a.y).min(b.y), hi.max(a.y).max(b.y)));
|
||||
let mut out = Vec::new();
|
||||
if !(y0.is_finite() && y1.is_finite()) || (y1 - y0) / gap > 4000.0 {
|
||||
return out;
|
||||
}
|
||||
let mut y = (y0 / gap).ceil() * gap;
|
||||
while y < y1 {
|
||||
let mut xs: Vec<f32> = edges
|
||||
.iter()
|
||||
.filter(|(a, b)| (a.y <= y && y < b.y) || (b.y <= y && y < a.y))
|
||||
.map(|(a, b)| a.x + (y - a.y) * (b.x - a.x) / (b.y - a.y))
|
||||
.collect();
|
||||
xs.sort_by(f32::total_cmp);
|
||||
for pair in xs.chunks_exact(2) {
|
||||
if let [a, b] = pair {
|
||||
out.push([back(*a, y), back(*b, y)]);
|
||||
}
|
||||
}
|
||||
y += gap;
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
fn rulers(app: &FigApp, painter: &egui::Painter, t: &Tokens, doc: &Document, sel: &[NodeId]) {
|
||||
let r = app.canvas.rect;
|
||||
let size = 20.0;
|
||||
|
||||
@@ -136,6 +136,12 @@ pub fn handle(app: &mut FigApp, ctx: &Context, method: &str, p: &Value) -> Resul
|
||||
figcraft_engine::textedit::key(&mut app.session, key, b(p, "shift"), b(p, "alt"), b(p, "cmd")).map_err(|e| e.to_string())?;
|
||||
return Ok(json!({"ok": true}));
|
||||
}
|
||||
// Vector edit mode keys (tools, nudging points, delete, join).
|
||||
if app.session.ds().is_ok_and(|d| d.vector_edit.is_some())
|
||||
&& figcraft_engine::vectoredit::key(&mut app.session, key, b(p, "shift"), b(p, "alt"), b(p, "cmd")).map_err(|e| e.to_string())?
|
||||
{
|
||||
return Ok(json!({"ok": true}));
|
||||
}
|
||||
match crate::shortcuts::parse(&spec) {
|
||||
Some(want) => {
|
||||
let id = figcraft_engine::catalog::MENU
|
||||
|
||||
@@ -38,6 +38,10 @@ pub enum Icon {
|
||||
ComponentSet,
|
||||
Group,
|
||||
Vector,
|
||||
Bend,
|
||||
PaintBucket,
|
||||
Lasso,
|
||||
Cut,
|
||||
Boolean,
|
||||
Page,
|
||||
Check,
|
||||
@@ -202,6 +206,43 @@ pub fn paint(painter: &Painter, rect: Rect, icon: Icon, color: Color32) {
|
||||
p.line(3.0, 13.0, 13.0, 3.0);
|
||||
p.rect(2.5, 2.5, 13.5, 13.5, 0.0);
|
||||
}
|
||||
Bend => {
|
||||
// A curve with a smooth point and its handle line.
|
||||
let pts: Vec<(f32, f32)> = (0..=12)
|
||||
.map(|i| {
|
||||
let t = i as f32 / 12.0;
|
||||
let u = 1.0 - t;
|
||||
(u * u * 2.5 + 2.0 * u * t * 8.0 + t * t * 13.5, u * u * 13.0 + 2.0 * u * t * 1.5 + t * t * 13.0)
|
||||
})
|
||||
.collect();
|
||||
p.poly(&pts, false);
|
||||
p.line(3.5, 7.25, 12.5, 7.25);
|
||||
p.dot(3.5, 7.25, 1.2);
|
||||
p.dot(12.5, 7.25, 1.2);
|
||||
p.fill_rect(6.9, 6.15, 9.1, 8.35, 0.4);
|
||||
}
|
||||
PaintBucket => {
|
||||
p.poly(&[(2.5, 8.0), (7.5, 3.0), (12.5, 8.0), (7.5, 13.0)], true);
|
||||
p.line(4.0, 6.5, 11.0, 6.5);
|
||||
p.line(7.5, 3.0, 5.5, 1.0);
|
||||
p.dot(13.5, 11.8, 1.4);
|
||||
}
|
||||
Lasso => {
|
||||
let pts: Vec<(f32, f32)> = (0..=20)
|
||||
.map(|i| {
|
||||
let a = i as f32 / 20.0 * std::f32::consts::TAU;
|
||||
(8.5 + 5.5 * a.cos(), 6.0 + 3.5 * a.sin())
|
||||
})
|
||||
.collect();
|
||||
p.poly(&pts, true);
|
||||
p.poly(&[(5.0, 8.8), (4.6, 11.0), (5.6, 12.6), (4.2, 14.5)], false);
|
||||
}
|
||||
Cut => {
|
||||
p.circle(4.5, 11.5, 2.2);
|
||||
p.circle(11.5, 11.5, 2.2);
|
||||
p.line(6.0, 9.8, 11.5, 2.5);
|
||||
p.line(10.0, 9.8, 4.5, 2.5);
|
||||
}
|
||||
Rectangle => p.rect(3.0, 3.0, 13.0, 13.0, 0.5),
|
||||
Line => p.line(3.0, 13.0, 13.0, 3.0),
|
||||
Arrow => {
|
||||
|
||||
@@ -245,7 +245,7 @@ fn collect(doc: &Document, id: NodeId, depth: usize, expanded: &std::collections
|
||||
// Layers panel lists top of the stack first.
|
||||
for c in n.children.iter().rev() {
|
||||
let Some(cn) = doc.node(*c) else { continue };
|
||||
let has_kids = !cn.children.is_empty() && !matches!(cn.kind, NodeKind::BooleanOp { .. }) || matches!(cn.kind, NodeKind::BooleanOp { .. });
|
||||
let has_kids = !cn.children.is_empty() || matches!(cn.kind, NodeKind::BooleanOp { .. });
|
||||
out.push(Row { id: *c, depth, has_kids });
|
||||
if expanded.contains(c) && depth < 64 {
|
||||
collect(doc, *c, depth + 1, expanded, out);
|
||||
|
||||
@@ -27,6 +27,7 @@ pub mod tabs;
|
||||
pub mod theme;
|
||||
pub mod toolbar;
|
||||
pub mod typography;
|
||||
pub mod vector_panel;
|
||||
pub mod widgets;
|
||||
|
||||
use std::collections::BTreeSet;
|
||||
|
||||
@@ -178,13 +178,17 @@ fn design(app: &mut FigApp, ui: &mut Ui) {
|
||||
let Ok(ds) = app.session.ds() else { return };
|
||||
let doc = ds.doc.clone();
|
||||
let sel = ds.selection.clone();
|
||||
let vector_edit = ds.vector_edit.clone();
|
||||
if sel.is_empty() {
|
||||
page_section(app, ui, &doc);
|
||||
return;
|
||||
}
|
||||
header(app, ui, &doc, &sel);
|
||||
widgets::separator(ui);
|
||||
position(app, ui, &doc, &sel);
|
||||
match vector_edit {
|
||||
Some(ve) => crate::vector_panel::points(app, ui, &doc, &ve),
|
||||
None => position(app, ui, &doc, &sel),
|
||||
}
|
||||
widgets::separator(ui);
|
||||
layout(app, ui, &doc, &sel);
|
||||
widgets::separator(ui);
|
||||
|
||||
@@ -86,6 +86,17 @@ pub fn handle(app: &mut FigApp, ctx: &Context) {
|
||||
}
|
||||
|
||||
fn key_event(app: &mut FigApp, key: Key, m: Modifiers, repeat: bool) -> bool {
|
||||
// Vector edit mode has its own keys (tools, nudging points, delete, join); the rest fall through.
|
||||
if app.session.ds().is_ok_and(|d| d.vector_edit.is_some()) {
|
||||
match figcraft_engine::vectoredit::key(&mut app.session, key.name(), m.shift, m.alt, m.command) {
|
||||
Ok(true) => return true,
|
||||
Ok(false) => {}
|
||||
Err(e) => {
|
||||
app.toast(e.to_string());
|
||||
return true;
|
||||
}
|
||||
}
|
||||
}
|
||||
// Catalog shortcuts (exact modifier match).
|
||||
for item in MENU.iter().filter(|i| !i.shortcut.is_empty()) {
|
||||
if let Some((want, k)) = parse(item.shortcut)
|
||||
@@ -106,10 +117,6 @@ fn key_event(app: &mut FigApp, key: Key, m: Modifiers, repeat: bool) -> bool {
|
||||
app.ui.context_menu = None;
|
||||
return true;
|
||||
}
|
||||
if item.id == "edit.delete" && app.session.ds().map(|d| d.vector_edit.is_some()).unwrap_or(false) {
|
||||
app.run("vector.deleteHeal", json!({}));
|
||||
return true;
|
||||
}
|
||||
if item.id == "edit.delete" {
|
||||
// Delete key also removes the pen path / ends gestures.
|
||||
if app.session.tool.pen.is_some() {
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
|
||||
use egui::{Align2, Color32, CornerRadius, Rect, Sense, Stroke, StrokeKind, Ui, pos2, vec2};
|
||||
use figcraft_engine::ToolId;
|
||||
use figcraft_engine::vectoredit::VTool;
|
||||
use serde_json::json;
|
||||
|
||||
use crate::FigApp;
|
||||
@@ -39,6 +40,11 @@ fn remembered(ui: &Ui, key: &str, default: ToolId) -> ToolId {
|
||||
pub fn show(app: &mut FigApp, ui: &mut Ui) {
|
||||
let t = Tokens::get(ui.ctx());
|
||||
let canvas = app.canvas.rect;
|
||||
// Vector edit mode swaps the tools for the edit tools.
|
||||
if let Some(tool) = app.session.ds().ok().and_then(|d| d.vector_edit.as_ref()).map(|v| v.tool) {
|
||||
edit_toolbar(app, ui, tool);
|
||||
return;
|
||||
}
|
||||
let cell = 32.0;
|
||||
let gap = 4.0;
|
||||
let width = GROUPS.len() as f32 * (cell + 4.0) + 4.0 * 10.0 + 2.0 * (cell + 4.0) + 16.0 + 4.0 * 34.0 + 24.0;
|
||||
@@ -51,21 +57,6 @@ pub fn show(app: &mut FigApp, ui: &mut Ui) {
|
||||
}
|
||||
painter.rect_filled(rect, CornerRadius::same(13), t.panel);
|
||||
painter.rect_stroke(rect, CornerRadius::same(13), Stroke::new(1.0, t.border), StrokeKind::Inside);
|
||||
// Vector edit mode: a contextual strip above the toolbar.
|
||||
if app.session.ds().map(|d| d.vector_edit.is_some()).unwrap_or(false) {
|
||||
let strip = Rect::from_center_size(pos2(rect.center().x, rect.min.y - 30.0), vec2(360.0, 40.0));
|
||||
ui.painter().rect_filled(strip, CornerRadius::same(10), t.panel);
|
||||
ui.painter().rect_stroke(strip, CornerRadius::same(10), Stroke::new(1.0, t.border), StrokeKind::Inside);
|
||||
let hints = "Drag points · ⌘-drag bends · ⌥-click adds a point · ⌫ deletes";
|
||||
ui.painter().text(pos2(strip.min.x + 12.0, strip.center().y), Align2::LEFT_CENTER, hints, regular(10.5), t.text_secondary);
|
||||
let done = Rect::from_min_size(pos2(strip.max.x - 56.0, strip.center().y - 13.0), vec2(48.0, 26.0));
|
||||
let dresp = ui.interact(done, ui.id().with("vdone"), Sense::click());
|
||||
ui.painter().rect_filled(done, CornerRadius::same(6), if dresp.hovered() { t.accent_hover } else { ACCENT });
|
||||
ui.painter().text(done.center(), Align2::CENTER_CENTER, "Done", crate::theme::semibold(11.0), Color32::WHITE);
|
||||
if dresp.clicked() {
|
||||
app.run("vector.edit.end", json!({}));
|
||||
}
|
||||
}
|
||||
let current = app.session.tool.tool;
|
||||
let mut x = rect.min.x + 8.0;
|
||||
let cy = rect.center().y;
|
||||
@@ -181,6 +172,66 @@ pub fn show(app: &mut FigApp, ui: &mut Ui) {
|
||||
});
|
||||
}
|
||||
|
||||
/// The pill shown in vector edit mode: the edit tools and Done.
|
||||
fn edit_toolbar(app: &mut FigApp, ui: &mut Ui, current: VTool) {
|
||||
let t = Tokens::get(ui.ctx());
|
||||
let canvas = app.canvas.rect;
|
||||
let cell = 32.0;
|
||||
let done_w = 56.0;
|
||||
let width = VTool::ALL.len() as f32 * (cell + 4.0) + 8.0 + 12.0 + done_w + 8.0;
|
||||
let rect = Rect::from_center_size(pos2(canvas.center().x, canvas.max.y - 12.0 - 24.0), vec2(width, 48.0));
|
||||
let painter = ui.painter();
|
||||
for (i, a) in [(6.0, 10u8), (3.0, 16), (1.0, 24)] {
|
||||
painter.rect_filled(rect.expand(i).translate(vec2(0.0, i * 0.5)), CornerRadius::same((13.0 + i) as u8), Color32::from_black_alpha(a));
|
||||
}
|
||||
painter.rect_filled(rect, CornerRadius::same(13), t.panel);
|
||||
painter.rect_stroke(rect, CornerRadius::same(13), Stroke::new(1.0, t.border), StrokeKind::Inside);
|
||||
let cy = rect.center().y;
|
||||
let mut x = rect.min.x + 8.0;
|
||||
// Holding Cmd with the Move tool bends: show it.
|
||||
let held = current == VTool::Move && ui.input(|i| i.modifiers.command);
|
||||
for tool in VTool::ALL {
|
||||
let r = Rect::from_center_size(pos2(x + cell / 2.0, cy), vec2(cell, cell));
|
||||
let tip = if tool == VTool::Bend {
|
||||
format!("{} hold {}", tool.label(), pretty_shortcut(tool.shortcut()))
|
||||
} else {
|
||||
format!("{} {}", tool.label(), pretty_shortcut(tool.shortcut()))
|
||||
};
|
||||
let resp = ui.interact(r, ui.id().with(("vtool", tool.id())), Sense::click()).on_hover_text(tip);
|
||||
let active = tool == current && !held || held && tool == VTool::Bend;
|
||||
let bg = if active {
|
||||
ACCENT
|
||||
} else if resp.hovered() {
|
||||
t.hover
|
||||
} else {
|
||||
Color32::TRANSPARENT
|
||||
};
|
||||
ui.painter().rect_filled(r, CornerRadius::same(5), bg);
|
||||
let icon = match tool {
|
||||
VTool::Move => Icon::Move,
|
||||
VTool::Pen => Icon::Pen,
|
||||
VTool::Bend => Icon::Bend,
|
||||
VTool::Paint => Icon::PaintBucket,
|
||||
VTool::Lasso => Icon::Lasso,
|
||||
VTool::Cut => Icon::Cut,
|
||||
};
|
||||
icons::paint(ui.painter(), r.shrink(7.0), icon, if active { Color32::WHITE } else { t.icon });
|
||||
if resp.clicked() {
|
||||
app.run("vector.edit.tool", json!({"tool": tool.id()}));
|
||||
}
|
||||
x += cell + 4.0;
|
||||
}
|
||||
ui.painter().line_segment([pos2(x + 2.0, cy - 14.0), pos2(x + 2.0, cy + 14.0)], Stroke::new(1.0, t.border));
|
||||
x += 12.0;
|
||||
let done = Rect::from_min_size(pos2(x, cy - 14.0), vec2(done_w, 28.0));
|
||||
let dresp = ui.interact(done, ui.id().with("vdone"), Sense::click()).on_hover_text("Done editing Esc");
|
||||
ui.painter().rect_filled(done, CornerRadius::same(6), if dresp.hovered() { t.accent_hover } else { ACCENT });
|
||||
ui.painter().text(done.center(), Align2::CENTER_CENTER, "Done", crate::theme::semibold(11.5), Color32::WHITE);
|
||||
if dresp.clicked() {
|
||||
app.run("vector.edit.end", json!({}));
|
||||
}
|
||||
}
|
||||
|
||||
fn flyout(app: &mut FigApp, ui: &mut Ui, g: &Group, bottom_left: egui::Pos2) {
|
||||
let t = Tokens::get(ui.ctx());
|
||||
let item_h = 32.0;
|
||||
|
||||
@@ -0,0 +1,88 @@
|
||||
//! Design panel in vector edit mode: the selected points' position, corner radius and handle
|
||||
//! mirroring (in place of the layer's position section).
|
||||
|
||||
use egui::{Id, Ui};
|
||||
use figcraft_doc::{Document, HandleMirroring};
|
||||
use figcraft_engine::vectoredit::{self, VectorEdit};
|
||||
use serde_json::{Value, json};
|
||||
|
||||
use crate::FigApp;
|
||||
use crate::icons::Icon;
|
||||
use crate::right::{menu_item, row};
|
||||
use crate::widgets::{self, FieldEdit, Lead, PAD};
|
||||
|
||||
/// Run a point command from a field: typed values are one step, scrubs one step when released.
|
||||
fn point_field(app: &mut FigApp, cmd: &str, edit: Option<FieldEdit>, params: impl Fn(f64) -> Value) {
|
||||
match edit {
|
||||
Some(FieldEdit::Set(v)) => {
|
||||
app.run(cmd, params(v));
|
||||
}
|
||||
Some(FieldEdit::Scrub(v, done)) => {
|
||||
let _ = app.session.begin_interaction("Edit points");
|
||||
let _ = app.session.execute(cmd, ¶ms(v));
|
||||
if done {
|
||||
let _ = app.session.commit_interaction();
|
||||
}
|
||||
}
|
||||
None => {}
|
||||
}
|
||||
}
|
||||
|
||||
pub fn points(app: &mut FigApp, ui: &mut Ui, doc: &Document, ve: &VectorEdit) {
|
||||
let Some(net) = vectoredit::network(doc, ve.id) else { return };
|
||||
let pts = vectoredit::selected_points(net, ve);
|
||||
widgets::section_header(ui, if pts.len() > 1 { "Points" } else { "Point" }, &[], false);
|
||||
if pts.is_empty() {
|
||||
let t = crate::theme::Tokens::get(ui.ctx());
|
||||
ui.horizontal(|ui| {
|
||||
ui.add_space(PAD);
|
||||
widgets::label(ui, "Select points to edit their position, radius and mirroring", t.text_secondary);
|
||||
});
|
||||
ui.add_space(8.0);
|
||||
return;
|
||||
}
|
||||
let col = widgets::col_width(ui.available_width());
|
||||
let pos = vectoredit::selection_position(doc, ve);
|
||||
let round = |v: f64| (v * 100.0).round() / 100.0;
|
||||
widgets::sublabels(ui, "Position", None);
|
||||
row(ui, 32.0, |ui| {
|
||||
let e = widgets::number_field(ui, Id::new("vpt_x"), Lead::Text("X"), pos.map(|p| round(p.x)), "", col, 1.0);
|
||||
point_field(app, "vector.edit.position", e, |v| json!({"x": v}));
|
||||
let e = widgets::number_field(ui, Id::new("vpt_y"), Lead::Text("Y"), pos.map(|p| round(p.y)), "", col, 1.0);
|
||||
point_field(app, "vector.edit.position", e, |v| json!({"y": v}));
|
||||
});
|
||||
let common = |f: &dyn Fn(usize) -> Option<f64>| -> Option<f64> {
|
||||
let mut it = pts.iter().map(|v| f(*v));
|
||||
let first = it.next().flatten()?;
|
||||
it.all(|x| x == Some(first)).then_some(first)
|
||||
};
|
||||
let radius = common(&|v| net.vertices.get(v).map(|x| x.radius));
|
||||
let mirror = {
|
||||
let mut it = pts.iter().map(|v| net.mirroring(*v));
|
||||
let first = it.next();
|
||||
first.filter(|m| it.all(|x| x == *m))
|
||||
};
|
||||
widgets::sublabels(ui, "Corner radius", None);
|
||||
row(ui, 32.0, |ui| {
|
||||
let e = widgets::number_field(ui, Id::new("vpt_radius"), Lead::Icon(Icon::CornerRadius), radius.map(round), "", col, 1.0);
|
||||
point_field(app, "vector.edit.cornerRadius", e, |v| json!({"radius": v.max(0.0)}));
|
||||
});
|
||||
widgets::sublabels(ui, "Mirroring", None);
|
||||
row(ui, 32.0, |ui| {
|
||||
let label = |m: HandleMirroring| match m {
|
||||
HandleMirroring::None => "No mirroring",
|
||||
HandleMirroring::Angle => "Mirror angle",
|
||||
HandleMirroring::AngleAndLength => "Mirror angle and length",
|
||||
};
|
||||
let resp = widgets::dropdown(ui, mirror.map(label).unwrap_or("Mixed"), col * 2.0 + 8.0, None);
|
||||
egui::Popup::menu(&resp).show(|ui| {
|
||||
for m in [HandleMirroring::None, HandleMirroring::Angle, HandleMirroring::AngleAndLength] {
|
||||
if menu_item(ui, label(m), "") {
|
||||
app.run("vector.edit.mirroring", json!({"mode": m.id()}));
|
||||
ui.close();
|
||||
}
|
||||
}
|
||||
});
|
||||
});
|
||||
ui.add_space(8.0);
|
||||
}
|
||||
@@ -72,7 +72,7 @@ A mouse gesture with the current tool. `kind`: `down`, `move`, `up`, `click`, `d
|
||||
|
||||
### `ui.key {key, shift?, alt?, cmd?, ctrl?}`
|
||||
|
||||
A key as if typed on the canvas: a menu shortcut (`{"key":"G","cmd":true}` groups), a tool key,
|
||||
A key as if typed on the canvas (in vector edit mode the edit keys come first: `V` `P` `Q` `X` `Shift+B` tools, arrows nudge points, `Delete`/`Shift+Delete`, `Cmd+A`, `Cmd+J`): a menu shortcut (`{"key":"G","cmd":true}` groups), a tool key,
|
||||
`Escape`, `Delete`/`Backspace`, `Enter`, arrows (nudge; shift = big nudge). While text is being
|
||||
edited, keys go to the text editor. Returns `{"command": id}` for shortcuts.
|
||||
|
||||
|
||||
+16
@@ -91,6 +91,22 @@ Or in a project's `.mcp.json`:
|
||||
| `type_text {text, id?}` | Type into the text being edited, or replace the contents of text layer `id`. |
|
||||
| `inspect_ui {}` | Tool, view and status (and panels/timings when attached). |
|
||||
|
||||
### Editing vectors
|
||||
|
||||
Vector edit mode is driven with `run_command` (or `pointer`/`key` for gestures):
|
||||
`vector.edit.begin {id}` (converts shapes to vectors), `vector.edit.state` (page-space overlay, the
|
||||
local network and each point's handle mirroring), `vector.edit.tool {tool: move|pen|bend|paint|lasso|cut}`,
|
||||
`vector.edit.select {vertices?, segments?, add?}`, `vector.edit.selectAll`, `vector.edit.moveVertices`,
|
||||
`vector.edit.nudge`, `vector.edit.position {x?, y?}`, `vector.edit.setHandle`, `vector.edit.mirroring
|
||||
{mode: none|angle|angleAndLength}`, `vector.edit.smooth {smooth?}`, `vector.edit.bend`,
|
||||
`vector.edit.addVertex`, `vector.edit.connect {a, b}`, `vector.edit.pen {x, y, dx?, dy?}` (continues
|
||||
from the pen's open end; clicking a point connects or branches), `vector.edit.penEnd`,
|
||||
`vector.edit.paint {x, y}` (paint bucket: fills or unfills the closed area under a page point),
|
||||
`vector.edit.cut {segment, t} | {vertex}`, `vector.edit.lasso {points}`, `vector.edit.delete`,
|
||||
`vector.deleteHeal`, `vector.join` / `vector.smoothJoin` (join the selected points while editing),
|
||||
`vector.edit.cornerRadius`, `vector.edit.end`. Point indices refer to `network.vertices` in
|
||||
`vector.edit.state`; local coordinates are the layer's own.
|
||||
|
||||
Tool failures come back as results with `isError: true` and a message; protocol problems (unknown
|
||||
tool, malformed JSON, wrong parameter types) are JSON-RPC errors. Nothing crashes the server.
|
||||
|
||||
|
||||
+2
-4
@@ -1,16 +1,14 @@
|
||||
# Parity report
|
||||
|
||||
Generated by `cargo xtask parity`: FigCraft commands registered for the incumbent's main menu and toolbar (278 commands in the engine).
|
||||
Generated by `cargo xtask parity`: FigCraft commands registered for the incumbent's main menu and toolbar (327 commands in the engine).
|
||||
|
||||
**191 / 199 menu items (96.0%)**
|
||||
**193 / 199 menu items (97.0%)**
|
||||
|
||||
## Missing
|
||||
|
||||
- `file.exportFramesPdf`
|
||||
- `file.openRecent`
|
||||
- `view.outlinesSelection`
|
||||
- `view.maskOutlines`
|
||||
- `vector.deleteHeal`
|
||||
- `plugins.runLast`
|
||||
- `plugins.manage`
|
||||
- `libraries.publish`
|
||||
|
||||
Reference in New Issue
Block a user