Merge pull request #203 from storytold/m8-envelope-editing

M8.17: Edit envelopes on the canvas (M8 done)
This commit is contained in:
BFlat authored and GitHub committed 2026-10-06 12:23:30 +07:00
commit 2e2bb33909
19 files changed
+784 -87

No files matched your search

+1 -1
View File
@@ -169,7 +169,7 @@ The details are in [`docs/mcp.md`](docs/mcp.md) and [`docs/control-protocol.md`]
VectorCraft is under active development. [**ROADMAP.md**](ROADMAP.md) covers what ships today, the
milestones, and honest time-to-parity estimates.
**Where we are (2026-10-05):** roughly 68–74% of Illustrator's features exist and work, and about 40–55% of
**Where we are (2026-10-06):** roughly 69–75% of Illustrator's features exist and work, and about 40–55% of
"a power user can't tell the difference". Everyday vector illustration is close to usable: drawing and path tools,
Pathfinder and Shape Builder, paint, gradients, appearance and transparency, type with styles and threading, and
files (SVG, PDF and PDF-compatible `.ai` with PDF/X, EPS, DXF, EMF/WMF, raster formats and PSD, Print, Package). The scores are
+11 -11
View File
@@ -4,7 +4,7 @@ VectorCraft is a clean-room, open-source, pure-Rust reimplementation of the Adob
This file tracks **how far we are and what's left**. Time estimates are wall-clock hours of continuous Claude Opus 5.5 agent work (including builds and the CI gate), given both for **one agent** and for **4–6 parallel agents** on disjoint crates. They are counted from the remaining work (see [Parity estimate](#parity-estimate)), calibrated against measured throughput, and updated as work lands.
_Last updated: 2026-10-05 (after M4.14–M4.98 and M14.4–M14.6; see [Honest assessment](#honest-assessment-2026-10-05))._
_Last updated: 2026-10-06 (after M4.14–M4.98, M8.1–M8.20 and M14.4–M14.7; see [Honest assessment](#honest-assessment-2026-10-05))._
## Where we are
@@ -12,10 +12,10 @@ _Last updated: 2026-10-05 (after M4.14–M4.98 and M14.4–M14.6; see [Honest as
|---|---|
| Infrastructure (engine, command registry, history, render, formats, MCP, web, packaging, tests) | **~90%** |
| Look & feel vs Illustrator 2026 default workspace (measured) | **~75–80%** |
| Feature surface vs full Illustrator (weighted, see below) | **~74%** on the rubric · **68–74%** honest range (self-graded, see [Honest assessment](#honest-assessment-2026-10-05)) |
| Feature surface vs full Illustrator (weighted, see below) | **~75%** on the rubric · **69–75%** honest range (self-graded, see [Honest assessment](#honest-assessment-2026-10-05)) |
| Parity including interaction fidelity and hardening ("a power user can't tell the difference, but faster") | **~40–55%** |
| Time to **feature parity** (every menu item, tool, panel, effect and dialog functional) | **~230–350 h** one agent · **~60–100 h** with 4–6 agents |
| Time to **full parity** (feature parity + interaction-fidelity pass + hardening) | **~335–520 h** one agent · **~85–150 h** with 4–6 agents |
| Time to **feature parity** (every menu item, tool, panel, effect and dialog functional) | **~225–345 h** one agent · **~60–100 h** with 4–6 agents |
| Time to **full parity** (feature parity + interaction-fidelity pass + hardening) | **~330–515 h** one agent · **~85–150 h** with 4–6 agents |
## Honest assessment (2026-10-05)
@@ -34,7 +34,7 @@ since 2026-10-01.
| Dimension | Estimate | Evidence and what's missing |
|---|---|---|
| **Breadth:** menus, tools and panels exist | ~90% | 18 menu items still stubbed; every tool implemented except Touch Type and vertical type ×3; 51 panel modules |
| **Depth:** each feature behaves like Illustrator | ~68–74% | Strong: paint, appearance and colour (M3), Pathfinder and booleans, selection, drawing, files (M4). Weak: advanced type (~35%), brushes and symbols (in progress), raster effects (~20%) |
| **Depth:** each feature behaves like Illustrator | ~69–75% | Strong: paint, appearance and colour (M3), Pathfinder and booleans, selection, drawing, files (M4). Weak: advanced type (~35%), brushes and symbols (in progress), raster effects (~20%) |
| **Large missing subsystems** | 0–20% | 3D & Materials (0%), Photoshop-style raster effects and the Effect Gallery (~1 of ~56 filters), SVG Filters, vertical/CJK type, Variables (data merge), scripting |
| **Interaction fidelity:** modifiers, cursors, small behaviours | ~30–40% | The dedicated pass hasn't started, and there has been no side-by-side session with Illustrator yet. A power user notices this first |
| **Look & feel** | ~75–80% | Measured against Illustrator 2026 screenshots (2026-10-02); the panels added since haven't been re-measured |
@@ -112,7 +112,7 @@ grade by behaviour against `plan/illustrator/`, not by whether a menu item exist
- Live effects with previewing dialogs: Distort & Transform, Path, Convert to Shape, 15 Warp styles, Round Corners, Scribble, Effect → Pathfinder (all 10 operations, live on groups), Color Adjustments (Brightness/Contrast, Curves, Levels, Hue/Saturation, Shift to Color, Temperature/Tint, on vectors, live type and embedded images), and raster drop shadow, glows and feather. SVG and PDF export keep live effects (geometry baked; SVG raster effects as filters).
- **Type:** Text Wrap, Type on a Path effects (Rainbow/Skew/3D Ribbon/Stair Step/Gravity), Character and Paragraph Styles (override-preserving redefine), Area Type Options (rows/columns/inset/first baseline), threaded text across any closed shapes, Fit Headline.
- **Transparency:** opacity masks (clip/invert/disable/link), exported as SVG `<mask>` and PDF soft masks.
- **Advanced art:** live Blends (steps/distance/smooth colour, spine), Envelope Distort (warp/mesh/top object), Gradient Mesh, Shape Builder, Live Paint, Image Trace (12 presets), pattern swatches with pattern editing mode, live Repeat (radial/grid/mirror).
- **Advanced art:** live Blends (steps/distance/smooth colour, editable spine, anchor-targeted Blend tool, faithful interpolation, knockout), Envelope Distort (warp/mesh/top object, Reset, full Envelope Options, mesh handles, Edit Contents; type, images, symbols, gradients, patterns and appearance distorted everywhere), Liquify tools with options and pen pressure, Puppet Warp with rotating pins, Perspective Grid with presets, movable planes and projected type, Gradient Mesh, Shape Builder, Live Paint, Image Trace (12 presets), pattern swatches with pattern editing mode, live Repeat (radial/grid/mirror).
- **Paint and appearance (M3):**
- **Swatches:** names follow the colour model and are unique; Swatch Options (process/spot, Global, Gray/RGB/HSB/CMYK/Web, live preview); editing a global or spot swatch recolours every linked fill, stroke and text run in one undo step; multi-select and delete with confirmation; New Swatch and New Color Group dialogs (a group from the selected artwork's colours); swatches in colour groups convert, separate and count like any other. The Swatches panel selects ranges and whole groups, has a find field, and drags swatches to reorder, regroup or paint art; Add Used Colors, Select All Unused, Merge, Ungroup and Sort by Kind; a CMYK document keeps applied colours in CMYK. Swatch libraries: nine generated colour libraries and five gradient libraries in a library panel, saved and loaded as `.vcswatches`, `.gpl` or CSS. Tints of global and spot colours ("Name 40%") stay linked and separate as a percentage of their plate. Spot colours can be defined in Lab (Spot Colors options). A built-in [Registration] swatch prints on every plate.
- **Colour:** a Color Picker (colour field, H/S/B/R/G/B channel slider, HSB/RGB/Lab/CMYK fields, hex, web-only snapping, out-of-gamut correction); the Color panel follows each colour's own model and Alt-click paints the other proxy; proxies look through groups and show "?" for mixed paint; Invert, Complement, Apply Last Color (`,`) and Apply Last Gradient (`.`) keep the colour model; every paint command feeds the recent colours. The Color Guide has 21 harmony rules, Steps and Variation options and Limit to Library. Adjust Color Balance (its Global mode shifts tints) and Saturate preview live; Edit Colors and Recolor also reach meshes, images and patterns. Recolor Artwork has colour reduction, five recolour methods, presets, Limit to Library, a colour-wheel Edit tab and Edit Color Group; a local Color Themes panel saves five-colour themes. Fill/Stroke chips open swatch and mixer popovers, panels have their shortcuts, and Eyedropper Options pick up and apply chosen attributes (Alt and Shift+Alt, image pixels). Fills and strokes can overprint (Attributes panel, Overprint Black options), and CMYK documents blend in inks on screen, in PDF and when flattening.
@@ -144,7 +144,7 @@ grade by behaviour against `plan/illustrator/`, not by whether a menu item exist
| M5 | Performance | 🟡 background render + caches + MT done; `vectorcraft-cli bench` and `vectorcraft-cli perf` (budget suite); file format v2 opens 3× faster (50k paths: 722 → 244 ms); raster effects (glows, shadows, blur, feather) no longer force the whole frame single-threaded (filtered offscreen per effect, verified equal to the single-threaded reference); effect-heavy demos need a clean-machine benchmark; dirty-region rendering, GPU backend spike pending | 15–25 |
| M6 | Path operations (Pathfinder, Shape Builder, offset…) | ✅ mostly done (Boolean precision on almost-horizontal edges fixed and the property tests made deterministic; Shape Builder edge erase, large-offset bug open) | 3–6 |
| M7 | Type (point/area/path, editing, styles, OpenType, threading, glyphs) | 🟡 Character/Paragraph Styles, Area Type Options, threaded text, Fit Headline, Glyphs, OpenType panel, Find Font, installed system fonts in every font menu (searchable) and found by name on open/import, CJK names in the UI, Text Wrap (offset, invert, both sides of an object; follows edits), Type on a Path effects, tab stops + Tabs panel done; tab leaders, spell check, vertical type pending | 45–60 |
| M8 | Transform & distort (Puppet Warp, Liquify tools, Envelopes, Blends, Perspective Grid) | 🟡 live Blends, Envelopes (warp/mesh/top object), Width tool, Liquify tools, Puppet Warp and Perspective Grid landed; fidelity pass under way (M8.2 tool options persist and Liquify tools share one brush, M8.1 Delete removes Width points and Puppet Warp pins, not the art, M8.8 Define Grid dialog with viewing angle, units, colours, M8.15 Envelope dialogs, Reset with Warp/Mesh, full Envelope Options, M8.16 enveloped type exports distorted, M8.5 Puppet Warp pins rotate, multi-select, Control bar, M8.3 Liquify Tool Options, pen pressure, Brush Affects, M8.9 perspective grid presets and manager, M8.16 test fix (EPS dates), M8.12 Perspective Selection scales, copies, moves perpendicular, Transform Again, M8.18 Blend Options dialog, anchor-targeted Blend tool, add keys, M8.19 blend spine and keys editable on canvas, Release keeps spine, M8.6 Puppet Warp pins appear at once, warp from rest shape, follow Undo, M8.13 movable perspective planes, plane dialog, Move Plane to Match Object, M8.10 perspective Lock Grid, Lock Station Point, Snap to Grid, Show Rulers, M8.4 Liquify hold-to-apply, safe scope, incremental strokes, M8.7 Width tool on compound paths, every subpath, cursors, M8.20 blend interpolation fidelity, knockout, Expand/Release keep attributes, M8.16 envelopes bend images, symbols, gradients, patterns, appearance; warps keep their frame, M8.14 type and symbols really projected with Edit Text, attachments survive copies, M8.11 grid widgets, screen-fixed Plane Switching Widget with 1-4 keys) | 8–12 |
| M8 | Transform & distort (Puppet Warp, Liquify tools, Envelopes, Blends, Perspective Grid) | ✅ done (M8.1–M8.20 on 2026-10-06, fidelity pass): Liquify Tool Options with one shared brush, pen pressure, hold-to-apply, a safe scope and incremental strokes; tool options that last; Puppet Warp rotation, multi-select, Control bar and a rest-shape session that follows Undo; Width tool on compound paths; Envelope dialogs with preview, Reset with Warp/Mesh, full Envelope Options, type/images/symbols/appearance/gradients/patterns distorted on canvas and in every export, warps that keep their frame, Edit Contents, mesh handles and Control bar; Blend Options, anchor-targeted Blend tool, editable spine, keys picked on the canvas, faithful interpolation and knockout; Perspective Grid with Define Grid, presets, lock/snap/rulers, widgets, the 1–4 plane switch, movable planes, Perspective Selection scale/copy/perpendicular/Transform Again, and type and symbols truly projected with Edit Text. Left: Anti-Alias and Preserve Shape output, warp-envelope point editing, brush interpolation in blends, Free Transform/Scale/Reflect along a rotated box | 2–4 |
| M9 | Live effects (+ 3D & Materials) | 🟡 2D effects done incl. Effect → Pathfinder and Color Adjustments; SVG Filters, Document Raster Effects Settings, 3D pending | 86–135 |
| M10 | Brushes, symbols, patterns, Repeat | 🟡 pattern swatches (5 tile types, Pattern Options, editing mode, SVG `<pattern>`/PDF export) and live Repeat (radial/grid/mirror) done; brushes/symbols in progress | 23–37 |
| M11 | Artboards & views (artboard panel/tool, Trim View, middle-button pan and print tiling done; multiple windows, presentation polish) | 🟡 | 15–25 |
@@ -153,7 +153,7 @@ grade by behaviour against `plan/illustrator/`, not by whether a menu item exist
| M14 | 1.0 polish (preferences, shortcut editor, workspaces, accessibility, packaging for all OSes) | 🟡 Preferences, shortcut editor, workspaces, a custom title bar on Windows/Linux with a Home button, content-sized dialogs and menus that scroll when longer than the window done; accessibility, Windows/Linux packaging pending | 18–28 |
| — | Interaction fidelity pass (every tool's modifiers, Properties panel per context, isolation, nuance) | ⬜ | 60–90 |
| — | Hardening at scale (big-file corpus, fuzzing, cross-platform + browser QA) | 🟡 | 50–80 |
| | **Total to full parity** (one agent; re-derived from the [Parity estimate](#parity-estimate) table) | | **~335–520** |
| | **Total to full parity** (one agent; re-derived from the [Parity estimate](#parity-estimate) table) | | **~330–515** |
## Parity estimate
@@ -180,7 +180,7 @@ about 1–1.5 agent-hours each, so the other rows (estimated on the older scale)
| 3D and Materials | 4 | 0% | Extrude & Bevel, Revolve, Inflate, Rotate, lighting, materials (software renderer) | 50–80 |
| Type core | 9 | 78% | composer/hyphenation options, Optical Margin Alignment, hidden characters | 15–20 |
| Type advanced | 4 | 35% | vertical type/CJK, tab leaders, spell check (open dictionary), Touch Type, Retype | 26–35 |
| Symbols, blends, envelopes, Repeat, perspective | 5 | 75% | symbol libraries (original), dynamic symbols, perspective edge cases | 8–12 |
| Symbols, blends, envelopes, Repeat, perspective | 5 | 88% | symbol libraries (original), dynamic symbols, envelope Anti-Alias/Preserve Shape output and warp point editing, brush interpolation in blends (M8 fidelity pass done) | 5–8 |
| Image Trace, graphs, image tools | 3 | 70% | graph Design/Column/Marker, Create Object Mosaic, Crop Image polish (Vector Halftone is done) | 6–10 |
| Layers, artboards, document setup | 5 | 80% | Layers panel options depth, artboard presets/rearrange polish (Document Setup and New Document are done) | 5–8 |
| View & navigation | 3 | 70% | New View/Edit Views, multiple windows/arrange, Snap to Pixel/Glyph (print tiling is done) | 9–14 |
@@ -191,10 +191,10 @@ about 1–1.5 agent-hours each, so the other rows (estimated on the older scale)
| Automation | 3 | 60% | Variables (data merge), scripting surface, batch | 8–12 |
| UI chrome (panels, contextual Properties, workspaces, prefs) | 6 | 78% | Variables, SVG Interactivity, Properties per context, Consolidate All Windows (Links, Asset Export, CSS Properties, Attributes are done) | 15–25 |
| Libraries, Links, Package | 2 | 50% | a local Libraries panel (no cloud by design); Links and Package are done | 5–8 |
| **Feature parity** | **103** | **~74%** | | **~230–350** |
| **Feature parity** | **103** | **~75%** | | **~225–345** |
| Interaction-fidelity pass (side by side with Illustrator: every tool modifier, cursor, dialog, Properties context) | | | | 60–90 |
| Hardening (big-file corpus, fuzzing, cross-platform and browser QA, accessibility, packaging) | | | | 50–80 |
| **Full parity** | | **~55%** | | **~335–520** |
| **Full parity** | | **~56%** | | **~330–515** |
With 4–6 agents working on disjoint crates (as the layering allows) the wall-clock time divides by roughly 3.5–4
(integration, review and shared files such as `menus.rs` serialize some work): **~60–100 h** to feature parity,
+21 -9
View File
@@ -20,15 +20,18 @@ pub struct Hit {
pub leaf: NodeId,
pub ancestry: Vec<NodeId>,
pub kind: HitKind,
/// The innermost envelope in the ancestry whose contents are being edited (Edit Contents):
/// its content, not the envelope, is the object clicked.
pub contents_of: Option<NodeId>,
}
impl Hit {
/// The object the Selection tool selects: the child of the layer (the outermost group).
/// Inside isolation mode (`scope`), the child of the isolated container instead.
pub fn top_object(&self, scope: Option<NodeId>) -> NodeId {
if let Some(s) = scope
&& let Some(i) = self.ancestry.iter().position(|x| *x == s)
{
// The innermost of the isolated container and an envelope whose contents are edited.
let at = |s: Option<NodeId>| s.and_then(|s| self.ancestry.iter().position(|x| *x == s));
if let Some(i) = at(scope).max(at(self.contents_of)) {
return self.ancestry.get(i + 1).copied().unwrap_or(self.leaf);
}
// Skip layers and sublayers.
@@ -68,7 +71,8 @@ pub fn hit_test(doc: &Document, p: Point, opt: HitOptions) -> Option<Hit> {
continue;
}
chain.push(layer.id);
if let Some(h) = hit_children(layer, p, opt, &mut chain) {
if let Some(mut h) = hit_children(layer, p, opt, &mut chain) {
h.contents_of = h.ancestry.iter().rev().copied().find(|a| doc.node(*a).is_some_and(edits_contents));
return Some(h);
}
chain.pop();
@@ -76,6 +80,11 @@ pub fn hit_test(doc: &Document, p: Point, opt: HitOptions) -> Option<Hit> {
None
}
/// Is `n` an envelope whose contents are being edited (they hit, not the envelope)?
fn edits_contents(n: &Node) -> bool {
matches!(n.kind, NodeKind::Envelope { editing: true, .. })
}
/// Is `p` inside the region `clip` clips to ([`Node::clip_shapes`])? Text counts by its frame:
/// glyph outlines need the font engine, above this crate.
fn clip_contains(clip: &Node, p: Point) -> bool {
@@ -99,7 +108,9 @@ fn hit_children(parent: &Node, p: Point, opt: HitOptions, chain: &mut Vec<NodeId
if !c.visible || c.locked {
continue;
}
if let Some(b) = c.reach_bounds()
// (An envelope's content sits where it was, not where the envelope draws it.)
if !edits_contents(c)
&& let Some(b) = c.reach_bounds()
&& !b.inflate(opt.tol, opt.tol).contains(p)
{
continue;
@@ -107,12 +118,13 @@ fn hit_children(parent: &Node, p: Point, opt: HitOptions, chain: &mut Vec<NodeId
chain.push(c.id);
let hit = match &c.kind {
NodeKind::Layer { .. } | NodeKind::Group { .. } => hit_children(c, p, opt, chain),
// Edit Contents: the envelope's content hits, undistorted.
NodeKind::Envelope { editing: true, .. } => hit_children(c, p, opt, chain),
// A blend's key objects first (Direct and Group Selection pick them); its steps hit
// as the blend.
NodeKind::Blend { .. } => {
hit_children(c, p, opt, chain).or_else(|| hit_leaf(c, p, opt).map(|kind| Hit { leaf: c.id, ancestry: chain.clone(), kind }))
}
_ => hit_leaf(c, p, opt).map(|kind| Hit { leaf: c.id, ancestry: chain.clone(), kind }),
NodeKind::Blend { .. } => hit_children(c, p, opt, chain)
.or_else(|| hit_leaf(c, p, opt).map(|kind| Hit { leaf: c.id, ancestry: chain.clone(), kind, contents_of: None })),
_ => hit_leaf(c, p, opt).map(|kind| Hit { leaf: c.id, ancestry: chain.clone(), kind, contents_of: None }),
};
if hit.is_some() {
return hit;
+95 -4
View File
@@ -114,7 +114,16 @@ pub enum EnvelopeKind {
horizontal: bool,
},
/// A `(rows+1)×(cols+1)` grid of points (row-major) the content's bounding box maps onto.
Mesh { rows: u32, cols: u32, points: Vec<Point> },
Mesh {
rows: u32,
cols: u32,
points: Vec<Point>,
/// Each point's bezier handles (offsets, see [`H_RIGHT`] …), row-major like `points`.
/// Empty: the smooth (Catmull-Rom) mesh through the points, as before handles existed;
/// editing the mesh's points or handles on the canvas fills them in.
#[serde(default, skip_serializing_if = "Vec::is_empty")]
handles: Vec<[Vec2; 4]>,
},
/// The content's bounding box maps onto the outline of this path.
TopObject { path: PathData },
}
@@ -1081,11 +1090,16 @@ fn surface_of<'a>(kind: &'a EnvelopeKind, src: Rect, frame: Affine) -> Box<dyn F
frame * Point::new(c.x + x2 * hw, c.y + y2 * hh)
})
}
EnvelopeKind::Mesh { rows, cols, points } => {
let (rows, cols) = (*rows as usize, *cols as usize);
if rows == 0 || cols == 0 || points.len() != (rows + 1) * (cols + 1) {
EnvelopeKind::Mesh { rows, cols, points, handles } => {
if !handles.is_empty()
&& let Some(m) = envelope_grid(*rows, *cols, points, handles)
{
return Box::new(move |u: f64, v: f64| mesh_at(&m, u, v));
}
if *rows == 0 || *cols == 0 || points.len() as u64 != (u64::from(*rows) + 1) * (u64::from(*cols) + 1) {
return Box::new(flat);
}
let (rows, cols) = (*rows as usize, *cols as usize);
Box::new(move |u: f64, v: f64| grid_eval(rows, cols, points, u, v))
}
EnvelopeKind::TopObject { path } => match Coons::from_path(path) {
@@ -1102,6 +1116,82 @@ pub fn envelope_mapper<'a>(kind: &'a EnvelopeKind, src: Rect) -> Box<dyn Fn(Poin
Box::new(move |q: Point| s((q.x - src.x0) / w, (q.y - src.y0) / h))
}
/// Catmull-Rom tangents of a `(rows+1)×(cols+1)` point grid (row-major) as mesh handles, a third
/// of the tangent towards each neighbour (past the borders the grid goes on straight): the curves
/// an envelope mesh without handles of its own runs along between its points.
pub fn smooth_handles(rows: u32, cols: u32, points: &[Point]) -> Vec<[Vec2; 4]> {
let (rows, cols) = (rows as usize, cols as usize);
let at = |r: usize, c: usize| points.get(r * (cols + 1) + c).copied().unwrap_or_default();
let tangent = |prev: Option<Point>, p: Point, next: Option<Point>| match (prev, next) {
(Some(a), Some(b)) => (b - a) / 2.0,
(None, Some(b)) => b - p,
(Some(a), None) => p - a,
(None, None) => Vec2::ZERO,
};
let mut out = Vec::with_capacity(points.len());
for r in 0..=rows {
for c in 0..=cols {
let p = at(r, c);
let du = tangent(c.checked_sub(1).map(|c| at(r, c)), p, (c < cols).then(|| at(r, c + 1))) / 3.0;
let dv = tangent(r.checked_sub(1).map(|r| at(r, c)), p, (r < rows).then(|| at(r + 1, c))) / 3.0;
// Handles pointing out of the grid are unused: they stay at the point.
let keep = |inside: bool, d: Vec2| if inside { d } else { Vec2::ZERO };
let mut h = [Vec2::ZERO; 4];
(h[H_RIGHT], h[H_LEFT], h[H_DOWN], h[H_UP]) = (keep(c < cols, du), keep(c > 0, -du), keep(r < rows, dv), keep(r > 0, -dv));
out.push(h);
}
}
out
}
/// A mesh envelope's grid (`rows`×`cols` patches, `points` and their `handles`; without handles
/// the smooth ones, see [`smooth_handles`]) as a gradient mesh with black points: what the
/// canvas draws and the Mesh tool and Direct Selection edit. `None` for a malformed grid.
pub fn envelope_grid(rows: u32, cols: u32, points: &[Point], handles: &[[Vec2; 4]]) -> Option<GradientMesh> {
// (Counts come from files: no overflow on 32-bit targets.)
if rows == 0 || cols == 0 || points.len() as u64 != (u64::from(rows) + 1) * (u64::from(cols) + 1) {
return None;
}
let smooth;
let handles = if handles.len() == points.len() {
handles
} else {
smooth = smooth_handles(rows, cols, points);
&smooth
};
let points = points.iter().zip(handles).map(|(p, h)| MeshPoint { p: *p, color: Color::BLACK, opacity: 1.0, handles: *h }).collect();
Some(GradientMesh { rows, cols, points })
}
/// The point of mesh `m` at (u, v) over the whole mesh (each patch an equal share; past the edges
/// the outer patches go on).
fn mesh_at(m: &GradientMesh, u: f64, v: f64) -> Point {
let (rows, cols) = (m.rows.max(1) as i64, m.cols.max(1) as i64);
let (su, sv) = (u * cols as f64, v * rows as f64);
let (c, r) = ((su.floor() as i64).clamp(0, cols - 1), (sv.floor() as i64).clamp(0, rows - 1));
m.eval(r as usize, c as usize, su - c as f64, sv - r as f64)
}
/// The envelope `id` is, or the innermost one it sits in.
pub fn envelope_of(doc: &crate::Document, id: NodeId) -> Option<&Node> {
doc.ancestry(id)?.into_iter().rev().find_map(|a| doc.node(a).filter(|n| matches!(n.kind, NodeKind::Envelope { .. })))
}
/// What a selected envelope shows on the canvas: its mesh lines (a warp's surface on a 4×4 grid,
/// a mesh envelope's own grid) or its top object's outline, and for a mesh envelope the grid to
/// edit (its points and handles).
pub fn envelope_overlay(n: &Node) -> Option<(PathData, Option<GradientMesh>)> {
let NodeKind::Envelope { kind, .. } = &n.kind else { return None };
match kind {
EnvelopeKind::Mesh { rows, cols, points, handles } => {
let grid = envelope_grid(*rows, *cols, points, handles)?;
Some((grid.lines(), Some(grid)))
}
EnvelopeKind::Warp { .. } => Some((EnvelopeMap::of(n)?.surface_mesh(4, 4, Color::BLACK).lines(), None)),
EnvelopeKind::TopObject { path } => Some((path.clone(), None)),
}
}
/// The frame an envelope keeps after transform `m` (its frame so far composed with the
/// transform): identity when its axes stay square to the page, unflipped, since evaluating on
/// the page axes gives the same result then.
@@ -1461,6 +1551,7 @@ fn expand_envelope(content: &[Arc<Node>], kind: &EnvelopeKind, fidelity: f64, op
/// Envelope bounds: a mesh's points, a top object's path, the warp's surface (sampled).
pub fn envelope_bounds(content: &[Arc<Node>], kind: &EnvelopeKind, frame: Affine) -> Option<Rect> {
match kind {
EnvelopeKind::Mesh { rows, cols, points, handles } if !handles.is_empty() => envelope_grid(*rows, *cols, points, handles)?.outline().bounds(),
EnvelopeKind::Mesh { points, .. } => {
let first = *points.first()?;
Some(points.iter().fold(Rect::from_points(first, first), |r, p| r.union_pt(*p)))
+7 -1
View File
@@ -652,10 +652,16 @@ impl Node {
}
*frame = crate::live::envelope_frame(a * *frame);
match kind {
EnvelopeKind::Mesh { points, .. } => {
EnvelopeKind::Mesh { points, handles, .. } => {
for p in points.iter_mut() {
*p = a * *p;
}
// Handles are offsets: they take the linear part.
let [m0, m1, m2, m3, _, _] = a.as_coeffs();
let lin = Affine::new([m0, m1, m2, m3, 0.0, 0.0]);
for h in handles.iter_mut().flatten() {
*h = (lin * h.to_point()).to_vec2();
}
}
EnvelopeKind::TopObject { path } => path.transform(a),
EnvelopeKind::Warp { .. } => {}
+62 -21
View File
@@ -218,7 +218,7 @@ pub fn specs() -> Vec<CommandSpec> {
"Move Envelope Mesh Point",
[],
None,
"{id, index, x, y} move one point of a mesh envelope",
"{id, index, x, y, handle?: 0 right|1 left|2 down|3 up} move a point of a mesh envelope (its handles follow), or with `handle` place that handle end at (x, y); object.mesh.movePoint, addLine and deletePoint edit mesh envelopes too",
has_doc,
env_set_mesh_point
),
@@ -246,7 +246,7 @@ pub fn specs() -> Vec<CommandSpec> {
"Move Mesh Point",
[],
None,
"{id, index, x, y, handle?: 0 right|1 left|2 down|3 up} move a mesh point (its handles follow), or with `handle` place that handle end at (x, y)",
"{id, index, x, y, handle?: 0 right|1 left|2 down|3 up} move a point of a gradient mesh or mesh envelope (its handles follow), or with `handle` place that handle end at (x, y)",
has_doc,
mesh_move_point
),
@@ -255,7 +255,7 @@ pub fn specs() -> Vec<CommandSpec> {
"Add Mesh Line",
[],
None,
"{id, x, y, color?} add a mesh row and column through (x, y) → {index}",
"{id, x, y, color?} add a row and a column through (x, y) to a gradient mesh (new point in `color`) or a mesh envelope → {index}",
has_doc,
mesh_add_line
),
@@ -264,7 +264,7 @@ pub fn specs() -> Vec<CommandSpec> {
"Delete Mesh Point",
[],
None,
"{id, index} delete the mesh lines through a point",
"{id, index} delete the mesh lines through a point of a gradient mesh or mesh envelope",
has_doc,
mesh_delete_point
),
@@ -958,8 +958,12 @@ fn env_make_warp(s: &mut Session, p: &Value) -> Result<Value> {
/// A mesh envelope of `rows`×`cols` patches for an envelope with map `map`: following its
/// current surface (`maintain`), or a flat grid over its content.
fn mesh_kind(map: &EnvelopeMap, maintain: bool, rows: u32, cols: u32) -> EnvelopeKind {
let points = if maintain { map.surface_mesh(rows, cols, Color::BLACK).points.into_iter().map(|m| m.p).collect() } else { map.grid(rows, cols) };
EnvelopeKind::Mesh { rows, cols, points }
if !maintain {
return EnvelopeKind::Mesh { rows, cols, points: map.grid(rows, cols), handles: vec![] };
}
// The surface's points with handles along it: the grid lines keep the current shape.
let m = map.surface_mesh(rows, cols, Color::BLACK);
EnvelopeKind::Mesh { rows, cols, points: m.points.iter().map(|q| q.p).collect(), handles: m.points.iter().map(|q| q.handles).collect() }
}
fn env_make_mesh(s: &mut Session, p: &Value) -> Result<Value> {
@@ -969,7 +973,7 @@ fn env_make_mesh(s: &mut Session, p: &Value) -> Result<Value> {
let st = s.doc()?;
let nodes: Vec<Arc<Node>> = roots.iter().filter_map(|id| st.doc.node(*id).cloned()).map(Arc::new).collect();
let src = live::nodes_bounds(&nodes).ok_or_else(|| bad(C, "selection has no bounds"))?;
let kind = EnvelopeKind::Mesh { rows, cols, points: live::grid_points(src, rows, cols) };
let kind = EnvelopeKind::Mesh { rows, cols, points: live::grid_points(src, rows, cols), handles: vec![] };
let defaults = envelope_defaults(s);
let id = s.edit("Make Envelope", |d, sel| wrap(d, sel, &roots, |id, content| envelope(id, content, kind, defaults)))?;
Ok(json!({ "id": id.0 }))
@@ -1106,7 +1110,12 @@ fn envelope_shape(env: &Node, kind: &EnvelopeKind) -> Option<Node> {
EnvelopeKind::TopObject { path } => {
return Some(Node::path(NodeId(0), path.clone(), Appearance::basic(Paint::solid(grey), Paint::None, 0.0)));
}
EnvelopeKind::Mesh { rows, cols, .. } => (*rows, *cols),
// The mesh itself, handles and all.
EnvelopeKind::Mesh { rows, cols, points, handles } => {
let mut m = live::envelope_grid(*rows, *cols, points, handles)?;
m.points.iter_mut().for_each(|q| q.color = grey);
return Some(Node::new(NodeId(0), NodeKind::Mesh(m)));
}
EnvelopeKind::Warp { .. } => (4, 4),
};
EnvelopeMap::of(env).map(|map| Node::new(NodeId(0), NodeKind::Mesh(map.surface_mesh(rows, cols, grey))))
@@ -1268,10 +1277,24 @@ fn env_set_mesh_point(s: &mut Session, p: &Value) -> Result<Value> {
let id = id_param(p, "id").ok_or_else(|| bad(C, "missing `id`"))?;
let index = f64_req(p, "index", C)? as usize;
let q = Point::new(f64_req(p, "x", C)?, f64_req(p, "y", C)?);
let handle = handle_param(p, C)?;
s.edit("Move Envelope Point", |d, _| {
match d.node_mut(id).map(|n| &mut n.kind) {
Some(NodeKind::Envelope { kind: EnvelopeKind::Mesh { points, .. }, .. }) if index < points.len() => points[index] = q,
_ => return Err(bad(C, "not a mesh envelope point")),
let Some(NodeKind::Envelope { kind: EnvelopeKind::Mesh { rows, cols, points, handles }, .. }) = d.node_mut(id).map(|n| &mut n.kind) else {
return Err(bad(C, "not a mesh envelope point"));
};
let at = *points.get(index).ok_or_else(|| bad(C, "not a mesh envelope point"))?;
match handle {
// The handles are offsets: they follow.
None => points.get_mut(index).into_iter().for_each(|p| *p = q),
Some(h) => {
// The first handle edited fills in the smooth mesh's handles.
if handles.len() != points.len() {
*handles = live::smooth_handles(*rows, *cols, points);
}
if let Some(hs) = handles.get_mut(index) {
hs[h] = q - at;
}
}
}
Ok(())
})?;
@@ -1333,16 +1356,37 @@ fn mesh_create(s: &mut Session, p: &Value) -> Result<Value> {
Ok(r)
}
fn with_mesh<T>(s: &mut Session, p: &Value, cmd: &str, label: &str, f: impl FnOnce(&mut GradientMesh) -> Result<T>) -> Result<T> {
/// Run `f` on the gradient mesh `p.id` (one undo step `label`); with `envelopes`, a mesh
/// envelope's grid too (its points and handles are written back: editing fills in the handles).
fn with_mesh<T>(s: &mut Session, p: &Value, cmd: &str, label: &str, envelopes: bool, f: impl FnOnce(&mut GradientMesh) -> Result<T>) -> Result<T> {
let id = id_param(p, "id").ok_or_else(|| bad(cmd, "missing `id`"))?;
let c = cmd.to_string();
s.edit(label, move |d, _| match d.node_mut(id).map(|n| &mut n.kind) {
Some(NodeKind::Mesh(m)) => f(m),
Some(_) => Err(bad(&c, "not a gradient mesh")),
Some(NodeKind::Envelope { kind: EnvelopeKind::Mesh { rows, cols, points, handles }, .. }) if envelopes => {
let mut m = live::envelope_grid(*rows, *cols, points, handles).ok_or_else(|| bad(&c, "the envelope's mesh is malformed"))?;
let r = f(&mut m)?;
(*rows, *cols) = (m.rows, m.cols);
*points = m.points.iter().map(|q| q.p).collect();
*handles = m.points.iter().map(|q| q.handles).collect();
Ok(r)
}
Some(_) => Err(bad(&c, if envelopes { "not a gradient mesh or mesh envelope" } else { "not a gradient mesh" })),
None => Err(EngineError::NoNode(id)),
})
}
/// The `handle` parameter (0 right, 1 left, 2 down, 3 up), if any.
fn handle_param(p: &Value, cmd: &str) -> Result<Option<usize>> {
match p.get("handle") {
None | Some(Value::Null) => Ok(None),
Some(v) => match v.as_u64() {
Some(h) if h <= 3 => Ok(Some(h as usize)),
_ => Err(bad(cmd, "handle must be 0..3")),
},
}
}
fn index_param(p: &Value, key: &str, cmd: &str) -> Result<usize> {
let i = f64_req(p, key, cmd)?;
if i < 0.0 {
@@ -1359,7 +1403,7 @@ fn mesh_set_color(s: &mut Session, p: &Value) -> Result<Value> {
if color.is_none() && opacity.is_none() {
return Err(bad(C, "missing `color` or `opacity`"));
}
with_mesh(s, p, C, "Mesh Point Color", |m| {
with_mesh(s, p, C, "Mesh Point Color", false, |m| {
let pt = m.points.get_mut(index).ok_or_else(|| bad(C, "index out of range"))?;
if let Some(c) = color {
pt.color = c;
@@ -1376,11 +1420,8 @@ fn mesh_move_point(s: &mut Session, p: &Value) -> Result<Value> {
const C: &str = "object.mesh.movePoint";
let index = index_param(p, "index", C)?;
let q = Point::new(f64_req(p, "x", C)?, f64_req(p, "y", C)?);
let handle = p.get("handle").and_then(Value::as_u64).map(|h| h as usize);
if handle.is_some_and(|h| h > 3) {
return Err(bad(C, "handle must be 0..3"));
}
with_mesh(s, p, C, "Move Mesh Point", |m| {
let handle = handle_param(p, C)?;
with_mesh(s, p, C, "Move Mesh Point", true, |m| {
let pt = m.points.get_mut(index).ok_or_else(|| bad(C, "index out of range"))?;
match handle {
Some(h) => pt.handles[h] = q - pt.p,
@@ -1395,7 +1436,7 @@ fn mesh_add_line(s: &mut Session, p: &Value) -> Result<Value> {
const C: &str = "object.mesh.addLine";
let q = Point::new(f64_req(p, "x", C)?, f64_req(p, "y", C)?);
let color = p.get("color").and_then(color_value);
let index = with_mesh(s, p, C, "Add Mesh Line", |m| {
let index = with_mesh(s, p, C, "Add Mesh Line", true, |m| {
let i = m.add_lines_at(q).ok_or_else(|| bad(C, "point is outside the mesh"))?;
if let Some(c) = color {
m.points[i].color = c;
@@ -1408,7 +1449,7 @@ fn mesh_add_line(s: &mut Session, p: &Value) -> Result<Value> {
fn mesh_delete_point(s: &mut Session, p: &Value) -> Result<Value> {
const C: &str = "object.mesh.deletePoint";
let index = index_param(p, "index", C)?;
with_mesh(s, p, C, "Delete Mesh Point", |m| {
with_mesh(s, p, C, "Delete Mesh Point", true, |m| {
if m.remove_point_lines(index) { Ok(()) } else { Err(bad(C, "only interior mesh lines can be deleted")) }
})?;
ok()
+2
View File
@@ -1220,6 +1220,8 @@ mod tests_envelope;
#[cfg(test)]
mod tests_envelope_distort;
#[cfg(test)]
mod tests_envelope_edit;
#[cfg(test)]
mod tests_expand;
#[cfg(test)]
mod tests_eyedropper;
+5 -2
View File
@@ -98,11 +98,14 @@ fn reset_with_warp_and_mesh_switch_the_kind_and_keep_the_content() {
s.execute("object.envelope.resetWithWarp", &json!({"style": "arch", "bend": 50, "horizontal": true})).unwrap();
let top = live::EnvelopeMap::of(&node(&s, e)).unwrap().surface_mesh(2, 2, vectorcraft_color::Color::BLACK);
s.execute("object.envelope.resetWithMesh", &json!({"rows": 2, "cols": 2})).unwrap();
let EnvelopeKind::Mesh { rows: 2, cols: 2, points } = kind(&s, e) else { panic!("{:?}", kind(&s, e)) };
let EnvelopeKind::Mesh { rows: 2, cols: 2, points, .. } = kind(&s, e) else { panic!("{:?}", kind(&s, e)) };
assert!(points[1].distance(top.points[1].p) < 1e-9 && points[1].y < 99.0, "{:?}", points[1]);
// Without Maintain Envelope Shape: a flat grid over the content.
s.execute("object.envelope.resetWithMesh", &json!({"rows": 1, "cols": 1, "maintainShape": false})).unwrap();
assert_eq!(kind(&s, e), EnvelopeKind::Mesh { rows: 1, cols: 1, points: live::grid_points(Rect::new(100.0, 100.0, 300.0, 200.0), 1, 1) });
assert_eq!(
kind(&s, e),
EnvelopeKind::Mesh { rows: 1, cols: 1, points: live::grid_points(Rect::new(100.0, 100.0, 300.0, 200.0), 1, 1), handles: vec![] }
);
assert!(s.execute("object.envelope.resetWithMesh", &json!({"rows": 0})).is_err());
// One undo step per reset.
s.execute("edit.undo", &json!({})).unwrap();
+136
View File
@@ -0,0 +1,136 @@
//! Editing envelopes on the canvas: Edit Contents hit-testing, mesh points and handles.
use serde_json::{Value, json};
use vectorcraft_doc::hit::{HitOptions, hit_test};
use vectorcraft_doc::live::{self, EnvelopeMap};
use vectorcraft_doc::{EnvelopeKind, Node, NodeKind};
use vectorcraft_geom::{Affine, Point, Vec2};
use vectorcraft_tools::{PointerEvent, PointerKind};
use super::*;
fn session() -> Session {
let mut s = Session::new();
s.execute("file.new", &json!({"width": 800, "height": 600})).unwrap();
s
}
fn id_of(v: &Value) -> NodeId {
NodeId(v["id"].as_u64().unwrap())
}
fn node(s: &Session, id: NodeId) -> Node {
s.doc().unwrap().doc.node(id).unwrap().clone()
}
/// A 200×100 rectangle at (100, 100) enveloped by `cmd` → (rectangle, envelope).
fn enveloped(s: &mut Session, cmd: &str, params: Value) -> (NodeId, NodeId) {
let a = id_of(&s.execute("shape.rectangle", &json!({"x": 100, "y": 100, "width": 200, "height": 100})).unwrap());
s.execute("select.set", &json!({"ids": [a.0]})).unwrap();
(a, id_of(&s.execute(cmd, &params).unwrap()))
}
fn mesh(s: &Session, e: NodeId) -> (u32, u32, Vec<Point>, Vec<[Vec2; 4]>) {
match node(s, e).kind {
NodeKind::Envelope { kind: EnvelopeKind::Mesh { rows, cols, points, handles }, .. } => (rows, cols, points, handles),
k => panic!("{k:?}"),
}
}
#[test]
fn edit_contents_clicks_reach_the_content_where_it_is() {
let mut s = session();
let (a, e) = enveloped(&mut s, "object.envelope.makeWithWarp", json!({"style": "arch", "bend": 50}));
let doc = |s: &Session| s.doc().unwrap().doc.clone();
// Inside the rectangle near its bottom middle, which the arch lifted away.
let below = Point::new(200.0, 195.0);
let h = hit_test(&doc(&s), below, HitOptions::default()).unwrap();
assert_eq!((h.leaf, h.top_object(None), h.contents_of), (e, e, None), "the envelope is one object");
s.execute("object.envelope.editContents", &json!({"editing": true})).unwrap();
let h = hit_test(&doc(&s), below, HitOptions::default()).unwrap();
assert_eq!((h.leaf, h.top_object(None), h.contents_of), (a, a, Some(e)), "Edit Contents: the content is the object clicked");
// The Selection tool picks it.
s.execute("select.none", &json!({})).unwrap();
s.select_tool("selection", ViewInfo::default()).unwrap();
for kind in [PointerKind::Down, PointerKind::Up] {
s.pointer(&PointerEvent::new(kind, 200.0, 195.0), ViewInfo::default()).unwrap();
}
assert_eq!(s.doc().unwrap().selection.objects, vec![a]);
}
#[test]
fn mesh_handles_start_from_the_smooth_mesh() {
let mut s = session();
let (_, e) = enveloped(&mut s, "object.envelope.makeWithMesh", json!({"rows": 2, "cols": 2}));
s.execute("object.envelope.setMeshPoint", &json!({"id": e.0, "index": 4, "x": 210, "y": 130})).unwrap();
assert!(mesh(&s, e).3.is_empty(), "moving a point keeps the smooth mesh");
let smooth = |s: &Session, u: f64, v: f64| EnvelopeMap::of(&node(s, e)).unwrap().at(u, v);
let before: Vec<Point> = (0..=8).flat_map(|i| [(i as f64 / 8.0, 0.5), (0.5, i as f64 / 8.0)]).map(|(u, v)| smooth(&s, u, v)).collect();
// Placing a handle where it already is fills in the handles: the grid lines don't move.
let (rows, cols, points, _) = mesh(&s, e);
let h = live::smooth_handles(rows, cols, &points)[4][live::H_RIGHT];
let end = points[4] + h;
s.execute("object.envelope.setMeshPoint", &json!({"id": e.0, "index": 4, "x": end.x, "y": end.y, "handle": 0})).unwrap();
assert_eq!(mesh(&s, e).3.len(), 9);
let after: Vec<Point> = (0..=8).flat_map(|i| [(i as f64 / 8.0, 0.5), (0.5, i as f64 / 8.0)]).map(|(u, v)| smooth(&s, u, v)).collect();
for (p, q) in before.iter().zip(&after) {
assert!(p.distance(*q) < 1e-6, "{p:?} vs {q:?}");
}
// Now the handle bends the row line up right of the point.
let y0 = smooth(&s, 0.62, 0.5).y;
s.execute("object.envelope.setMeshPoint", &json!({"id": e.0, "index": 4, "x": end.x, "y": end.y - 40.0, "handle": 0})).unwrap();
assert!(smooth(&s, 0.62, 0.5).y < y0 - 1.0, "{} vs {y0}", smooth(&s, 0.62, 0.5).y);
assert!(s.execute("object.envelope.setMeshPoint", &json!({"id": e.0, "index": 4, "x": 0, "y": 0, "handle": 7})).is_err());
}
#[test]
fn mesh_commands_edit_mesh_envelopes() {
let mut s = session();
let (_, e) = enveloped(&mut s, "object.envelope.makeWithMesh", json!({"rows": 1, "cols": 1}));
let r = s.execute("object.mesh.addLine", &json!({"id": e.0, "x": 150, "y": 175})).unwrap();
let (rows, cols, points, handles) = mesh(&s, e);
assert_eq!((rows, cols, handles.len()), (2, 2, 9));
let i = r["index"].as_u64().unwrap() as usize;
assert!(points[i].distance(Point::new(150.0, 175.0)) < 0.5);
s.execute("object.mesh.movePoint", &json!({"id": e.0, "index": i, "x": 160, "y": 160})).unwrap();
assert!(mesh(&s, e).2[i].distance(Point::new(160.0, 160.0)) < 1e-9);
s.execute("object.mesh.movePoint", &json!({"id": e.0, "index": i, "x": 200, "y": 160, "handle": 0})).unwrap();
assert!((mesh(&s, e).3[i][0] - Vec2::new(40.0, 0.0)).hypot() < 1e-9);
s.execute("object.mesh.deletePoint", &json!({"id": e.0, "index": i})).unwrap();
assert_eq!(mesh(&s, e).0, 1);
assert!(s.execute("object.mesh.setPointColor", &json!({"id": e.0, "index": 0, "color": "#ff0000"})).is_err(), "envelope points have no colour");
// One undo step per edit.
s.execute("edit.undo", &json!({})).unwrap();
assert_eq!(mesh(&s, e).0, 2);
}
#[test]
fn mesh_handles_turn_with_the_envelope_and_round_trip() {
let mut s = session();
let (_, e) = enveloped(&mut s, "object.envelope.makeWithMesh", json!({"rows": 1, "cols": 1}));
s.execute("object.envelope.setMeshPoint", &json!({"id": e.0, "index": 0, "x": 150, "y": 100, "handle": 0})).unwrap();
s.execute("object.rotate", &json!({"angle": 90, "origin": [200, 150]})).unwrap();
let h = mesh(&s, e).3[0][0];
let want = Affine::rotate(-std::f64::consts::FRAC_PI_2) * Point::new(50.0, 0.0);
assert!((h - want.to_vec2()).hypot() < 1e-9, "{h:?}");
let d = &s.doc().unwrap().doc;
let back = vectorcraft_format::load(&vectorcraft_format::save(d, false)).unwrap();
assert_eq!(back.node(e), d.node(e));
// Mesh envelopes saved before handles existed load as smooth meshes.
let mut old = serde_json::to_value(node(&s, e)).unwrap();
old["kind"]["kind"].as_object_mut().unwrap().remove("handles");
let n: Node = serde_json::from_value(old).unwrap();
assert!(matches!(n.kind, NodeKind::Envelope { kind: EnvelopeKind::Mesh { handles, .. }, .. } if handles.is_empty()));
}
#[test]
fn reset_with_mesh_keeps_the_warp_shape_along_the_grid() {
let mut s = session();
let (_, e) = enveloped(&mut s, "object.envelope.makeWithWarp", json!({"style": "arc", "bend": 60}));
let warp = EnvelopeMap::of(&node(&s, e)).unwrap().surface_mesh(2, 2, vectorcraft_color::Color::BLACK);
s.execute("object.envelope.resetWithMesh", &json!({"rows": 2, "cols": 2})).unwrap();
let (_, _, points, handles) = mesh(&s, e);
for (q, (p, h)) in warp.points.iter().zip(points.iter().zip(&handles)) {
assert!(q.p.distance(*p) < 1e-9 && q.handles == *h);
}
}
+22 -3
View File
@@ -5,7 +5,8 @@
//! reshape, marquee to select anchors, drag a live rectangle's corner widget to round its corners.
//! Group Selection: click selects the leaf; each further click on it adds the next enclosing group.
//! Both pick the key objects of a blend. Direct Selection also edits a blend's spine: drag its
//! points (a key object on a point moves with it) and, once a point is clicked, its handles.
//! points (a key object on a point moves with it) and, once a point is clicked, its handles; and
//! the points of selected gradient meshes and mesh envelopes and their handles ([`MeshEdit`]).
use serde_json::{Value, json};
use vectorcraft_doc::hit::hit_test;
@@ -14,6 +15,7 @@ use vectorcraft_geom::{PathData, Point, Rect};
use crate::bbox::move_delta;
use crate::corners::{CornerDrag, over_widget};
use crate::meshedit::MeshEdit;
use crate::select::matrix_json;
use crate::{Action, Cursor, Mods, Overlay, PointerEvent, PointerKind, Tool, ToolContext};
@@ -54,6 +56,8 @@ enum State {
anchor: usize,
out: bool,
},
/// Dragging a mesh point or handle ([`MeshEdit`]).
Mesh,
}
pub struct DirectSelectionTool {
@@ -61,11 +65,12 @@ pub struct DirectSelectionTool {
state: State,
/// The spine point last clicked (blend, anchor): its handles show and can be dragged.
spine: Option<(NodeId, usize)>,
mesh: MeshEdit,
}
impl DirectSelectionTool {
pub fn new(group: bool) -> Self {
Self { group, state: State::Idle, spine: None }
Self { group, state: State::Idle, spine: None, mesh: MeshEdit::default() }
}
}
@@ -204,6 +209,11 @@ impl Tool for DirectSelectionTool {
return vec![Action::Begin("Reshape Spine".into())];
}
self.spine = None;
if let Some(g) = self.mesh.hit(cx, &cx.selection.objects, p) {
self.state = State::Mesh;
return self.mesh.press(g);
}
self.mesh.unfocus();
if let Some((id, si, ai, out)) = hit_handle(cx, p, tol) {
self.state = State::Handle { id, si, ai, out };
return vec![Action::Begin("Reshape".into())];
@@ -290,6 +300,11 @@ impl Tool for DirectSelectionTool {
"object.blend.spine.moveAnchor".into(),
json!({"id": id.0, "anchor": anchor, "handle": if out {"out"} else {"in"}, "x": p.x, "y": p.y, "independent": ev.mods.alt}),
)],
(PointerKind::Drag, State::Mesh) => self.mesh.drag_to(p).unwrap_or_default(),
(PointerKind::Up, State::Mesh) => {
self.state = State::Idle;
self.mesh.release().unwrap_or_default()
}
(PointerKind::Drag, State::Marquee { start, add, .. }) => {
self.state = State::Marquee { start, cur: p, add };
vec![]
@@ -338,7 +353,11 @@ impl Tool for DirectSelectionTool {
match &self.state {
State::Marquee { start, cur, .. } => vec![Overlay::Marquee(Rect::from_points(*start, *cur))],
State::Corner(c) => c.overlays(cx),
_ => self.spine_overlays(cx),
_ => {
let mut out = self.spine_overlays(cx);
out.extend(self.mesh.overlays(cx));
out
}
}
}
fn cursor(&self, cx: &ToolContext, p: Point, _m: Mods) -> Cursor {
+1
View File
@@ -18,6 +18,7 @@ pub mod draw2;
pub mod extra;
pub mod guides;
pub mod meshblend;
pub mod meshedit;
pub mod params;
pub mod pen;
pub mod place;
+29 -31
View File
@@ -5,15 +5,17 @@
//! more key of the new blend (as does clicking a blend first). Double-click or Alt-click opens
//! Blend Options (dialog `blendOptions`). Clicking empty canvas starts over.
//! - Mesh: click inside a filled path → `object.mesh.create {ids, at}` (1×1 mesh plus lines through
//! the click); click inside a mesh → `object.mesh.addLine` (new point takes the current fill
//! colour unless Shift); drag a mesh point → `object.mesh.movePoint` previews; Alt-click a mesh
//! point → `object.mesh.deletePoint`.
//! the click); click inside a mesh or a mesh envelope → `object.mesh.addLine` (a gradient mesh's
//! new point takes the current fill colour unless Shift); drag a mesh point, or a handle of the
//! point last clicked → `object.mesh.movePoint` previews ([`MeshEdit`]); Alt-click a mesh point →
//! `object.mesh.deletePoint`.
use serde_json::json;
use vectorcraft_color::Paint;
use vectorcraft_doc::{NodeId, NodeKind};
use vectorcraft_geom::Point;
use crate::meshedit::{MeshEdit, grid_of};
use crate::{Action, Cursor, Mods, Overlay, PointerEvent, PointerKind, Tool, ToolContext};
const FEEDBACK: [u8; 3] = [0x4f, 0x9d, 0xff];
@@ -140,17 +142,17 @@ impl Tool for BlendTool {
#[derive(Default)]
pub struct MeshTool {
drag: Option<(NodeId, usize)>,
edit: MeshEdit,
}
impl MeshTool {
/// Meshes to consider for point hits: selected ones first, then any mesh in the document.
/// Meshes (gradient meshes and mesh envelopes) to consider for point hits: selected ones
/// first, then any in the document.
fn meshes(cx: &ToolContext) -> Vec<NodeId> {
let mut v: Vec<NodeId> =
cx.selection.objects.iter().copied().filter(|id| cx.doc.node(*id).is_some_and(|n| matches!(n.kind, NodeKind::Mesh(_)))).collect();
let mut v: Vec<NodeId> = cx.selection.objects.iter().copied().filter(|id| cx.doc.node(*id).and_then(grid_of).is_some()).collect();
let mut rest = vec![];
cx.doc.walk(|n| {
if matches!(n.kind, NodeKind::Mesh(_)) {
if grid_of(n).is_some() {
rest.push(n.id);
}
});
@@ -163,12 +165,8 @@ impl MeshTool {
v
}
fn point_at(cx: &ToolContext, p: Point) -> Option<(NodeId, usize)> {
let tol = cx.tol(5.0);
Self::meshes(cx).into_iter().find_map(|id| match &cx.doc.node(id)?.kind {
NodeKind::Mesh(m) => m.point_near(p, tol).map(|i| (id, i)),
_ => None,
})
fn grab_at(&self, cx: &ToolContext, p: Point) -> Option<crate::meshedit::MeshGrab> {
self.edit.hit(cx, &Self::meshes(cx), p)
}
}
@@ -180,13 +178,14 @@ impl Tool for MeshTool {
let p = ev.pos;
match ev.kind {
PointerKind::Down => {
if let Some((id, index)) = Self::point_at(cx, p) {
if ev.mods.alt {
return vec![Action::Exec("object.mesh.deletePoint".into(), json!({"id": id.0, "index": index}))];
if let Some(g) = self.grab_at(cx, p) {
if ev.mods.alt && g.handle.is_none() {
self.edit.unfocus();
return vec![Action::Exec("object.mesh.deletePoint".into(), json!({"id": g.id.0, "index": g.index}))];
}
self.drag = Some((id, index));
return vec![Action::Begin("Move Mesh Point".into())];
return self.edit.press(g);
}
self.edit.unfocus();
let Some((top, leaf)) = hit_top(cx, p) else { return vec![] };
let color = match (&cx.paint.fill, ev.mods.shift) {
(Paint::Solid { color, .. }, false) => Some(color.to_hex()),
@@ -201,6 +200,9 @@ impl Tool for MeshTool {
}
return vec![Action::Exec("object.mesh.addLine".into(), params)];
}
Some(NodeKind::Envelope { .. }) if cx.doc.node(id).and_then(grid_of).is_some() => {
return vec![Action::Exec("object.mesh.addLine".into(), json!({"id": id.0, "x": p.x, "y": p.y}))];
}
Some(NodeKind::Path { guide: false, .. }) | Some(NodeKind::Compound { .. }) => {
return vec![Action::Exec("object.mesh.create".into(), json!({"ids": [id.0], "at": [p.x, p.y]}))];
}
@@ -209,14 +211,8 @@ impl Tool for MeshTool {
}
vec![]
}
PointerKind::Drag => match self.drag {
Some((id, index)) => vec![Action::Preview("object.mesh.movePoint".into(), json!({"id": id.0, "index": index, "x": p.x, "y": p.y}))],
None => vec![],
},
PointerKind::Up => match self.drag.take() {
Some(_) => vec![Action::Commit],
None => vec![],
},
PointerKind::Drag => self.edit.drag_to(p).unwrap_or_default(),
PointerKind::Up => self.edit.release().unwrap_or_default(),
_ => vec![],
}
}
@@ -230,20 +226,22 @@ impl Tool for MeshTool {
}
}
}
out.extend(self.edit.overlays(cx));
out
}
fn cursor(&self, cx: &ToolContext, p: Point, mods: Mods) -> Cursor {
match Self::point_at(cx, p) {
Some(_) if mods.alt => Cursor::PenDelete,
match self.grab_at(cx, p) {
Some(g) if mods.alt && g.handle.is_none() => Cursor::PenDelete,
Some(_) => Cursor::Move,
None => Cursor::PenAdd,
}
}
fn busy(&self) -> bool {
self.drag.is_some()
self.edit.dragging()
}
fn deactivate(&mut self, _cx: &ToolContext) -> Vec<Action> {
if self.drag.take().is_some() { vec![Action::Commit] } else { vec![] }
self.edit.unfocus();
self.edit.release().unwrap_or_default()
}
}
+183
View File
@@ -0,0 +1,183 @@
//! Dragging mesh points and their handles on the canvas, shared by the Mesh tool and Direct
//! Selection: gradient meshes and mesh envelopes alike (`object.mesh.movePoint`). Clicking a
//! point focuses it: its handles show and can be dragged too.
use std::borrow::Cow;
use serde_json::json;
use vectorcraft_doc::live::{GradientMesh, envelope_grid};
use vectorcraft_doc::{EnvelopeKind, Node, NodeId, NodeKind};
use vectorcraft_geom::Point;
use crate::{Action, Overlay, ToolContext};
const FEEDBACK: [u8; 3] = [0x4f, 0x9d, 0xff];
/// The editable grid of `n`: a gradient mesh, or a mesh envelope's grid (not while its contents
/// are being edited).
pub fn grid_of(n: &Node) -> Option<Cow<'_, GradientMesh>> {
match &n.kind {
NodeKind::Mesh(m) => Some(Cow::Borrowed(m)),
NodeKind::Envelope { kind: EnvelopeKind::Mesh { rows, cols, points, handles }, editing: false, .. } => {
envelope_grid(*rows, *cols, points, handles).map(Cow::Owned)
}
_ => None,
}
}
/// What a press on a mesh grabs: the mesh, the point and (on the focused point) a handle.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub struct MeshGrab {
pub id: NodeId,
pub index: usize,
pub handle: Option<usize>,
}
/// Point and handle drags on meshes.
#[derive(Default)]
pub struct MeshEdit {
/// The point last clicked: its handles show and can be dragged.
focus: Option<(NodeId, usize)>,
drag: Option<MeshGrab>,
}
impl MeshEdit {
/// The handle of the focused point, else the mesh point, under `p` among the meshes `ids`
/// (editable ones, in order).
pub fn hit(&self, cx: &ToolContext, ids: &[NodeId], p: Point) -> Option<MeshGrab> {
let tol = cx.tol(5.0);
if let Some((id, index)) = self.focus
&& ids.contains(&id)
&& let Some(grid) = cx.doc.node(id).and_then(grid_of)
&& let Some(q) = grid.points.get(index)
&& let Some(h) = (0..4).find(|h| q.handles[*h].hypot() > 1e-6 && (q.p + q.handles[*h]).distance(p) <= tol)
{
return Some(MeshGrab { id, index, handle: Some(h) });
}
ids.iter().filter(|id| cx.doc.is_editable(**id)).find_map(|id| {
let index = grid_of(cx.doc.node(*id)?)?.point_near(p, tol)?;
Some(MeshGrab { id: *id, index, handle: None })
})
}
/// Start dragging `grab` (its point takes the focus).
pub fn press(&mut self, grab: MeshGrab) -> Vec<Action> {
self.focus = Some((grab.id, grab.index));
self.drag = Some(grab);
vec![Action::Begin("Move Mesh Point".into())]
}
/// The preview of a drag to `p` (`None` when nothing is being dragged).
pub fn drag_to(&self, p: Point) -> Option<Vec<Action>> {
let g = self.drag?;
let mut params = json!({"id": g.id.0, "index": g.index, "x": p.x, "y": p.y});
if let Some(h) = g.handle {
params["handle"] = json!(h);
}
Some(vec![Action::Preview("object.mesh.movePoint".into(), params)])
}
/// End a drag (`None` when nothing was being dragged).
pub fn release(&mut self) -> Option<Vec<Action>> {
self.drag.take().map(|_| vec![Action::Commit])
}
pub fn dragging(&self) -> bool {
self.drag.is_some()
}
/// Forget the focused point.
pub fn unfocus(&mut self) {
self.focus = None;
}
/// The focused point's handles (lines and ends).
pub fn overlays(&self, cx: &ToolContext) -> Vec<Overlay> {
let Some((id, index)) = self.focus else { return vec![] };
let Some(grid) = cx.doc.node(id).and_then(grid_of) else { return vec![] };
let Some(q) = grid.points.get(index) else { return vec![] };
let mut out = vec![Overlay::Anchor { p: q.p, color: FEEDBACK, filled: true, size: 5.0 }];
for h in q.handles.iter().filter(|h| h.hypot() > 1e-6) {
out.push(Overlay::Line { a: q.p, b: q.p + *h, color: FEEDBACK, dashed: false });
out.push(Overlay::Handle { p: q.p + *h, color: FEEDBACK });
}
out
}
}
#[cfg(test)]
mod tests {
use std::sync::Arc;
use super::*;
use crate::testutil::*;
use crate::{PointerEvent, PointerKind, Tool};
use vectorcraft_doc::live::{EnvelopeOptions, grid_points};
use vectorcraft_doc::{Document, Selection};
use vectorcraft_geom::{Affine, Rect};
/// The rectangle of [`doc_with_rect`] (100..200 square) in a 1×1 mesh envelope.
fn doc_with_envelope() -> (Document, NodeId) {
let (mut d, a) = doc_with_rect();
let rect = d.node(a).cloned().unwrap();
d.remove(a).unwrap();
let id = d.alloc_id();
let kind = EnvelopeKind::Mesh { rows: 1, cols: 1, points: grid_points(Rect::new(100.0, 100.0, 200.0, 200.0), 1, 1), handles: vec![] };
let env = NodeKind::Envelope {
content: vec![Arc::new(rect)],
kind,
fidelity: 50.0,
editing: false,
options: EnvelopeOptions::NEW,
frame: Affine::IDENTITY,
};
let l = d.layers[0].id;
d.insert(Some(l), 0, Node::new(id, env)).unwrap();
(d, id)
}
#[test]
fn the_mesh_tool_drags_envelope_points_and_adds_lines() {
let (d, e) = doc_with_envelope();
let (s, p) = (Selection::default(), paint());
let c = cx(&d, &s, &p);
let mut t = crate::meshblend::create("mesh").unwrap();
assert_eq!(t.pointer(&c, &PointerEvent::new(PointerKind::Down, 200.0, 100.0)), vec![Action::Begin("Move Mesh Point".into())]);
assert_eq!(
t.pointer(&c, &PointerEvent::new(PointerKind::Drag, 210.0, 90.0)),
vec![Action::Preview("object.mesh.movePoint".into(), json!({"id": e.0, "index": 1, "x": 210.0, "y": 90.0}))]
);
assert_eq!(t.pointer(&c, &PointerEvent::new(PointerKind::Up, 210.0, 90.0)), vec![Action::Commit]);
// The clicked point shows its handles (the smooth mesh's) and they can be dragged.
assert!(t.overlays(&c).iter().any(|o| matches!(o, Overlay::Handle { .. })));
assert_eq!(t.pointer(&c, &PointerEvent::new(PointerKind::Down, 200.0, 133.33333)), vec![Action::Begin("Move Mesh Point".into())]);
let drag = t.pointer(&c, &PointerEvent::new(PointerKind::Drag, 220.0, 140.0));
assert!(matches!(&drag[..], [Action::Preview(id, p)] if id == "object.mesh.movePoint" && p["handle"] == json!(2)), "{drag:?}");
t.pointer(&c, &PointerEvent::new(PointerKind::Up, 220.0, 140.0));
// Inside the envelope: a new row and column.
assert_eq!(
t.pointer(&c, &PointerEvent::new(PointerKind::Down, 150.0, 160.0)),
vec![Action::Exec("object.mesh.addLine".into(), json!({"id": e.0, "x": 150.0, "y": 160.0}))]
);
}
#[test]
fn direct_selection_drags_a_selected_envelopes_points() {
let (d, e) = doc_with_envelope();
let mut s = Selection::default();
let p = paint();
let mut t = crate::direct::DirectSelectionTool::new(false);
// Not selected: its points aren't handles to grab.
let c = cx(&d, &s, &p);
assert_ne!(t.pointer(&c, &PointerEvent::new(PointerKind::Down, 100.0, 200.0)), vec![Action::Begin("Move Mesh Point".into())]);
t.pointer(&c, &PointerEvent::new(PointerKind::Up, 100.0, 200.0));
s.set([e]);
let c = cx(&d, &s, &p);
assert_eq!(t.pointer(&c, &PointerEvent::new(PointerKind::Down, 100.0, 200.0)), vec![Action::Begin("Move Mesh Point".into())]);
assert_eq!(
t.pointer(&c, &PointerEvent::new(PointerKind::Drag, 90.0, 210.0)),
vec![Action::Preview("object.mesh.movePoint".into(), json!({"id": e.0, "index": 2, "x": 90.0, "y": 210.0}))]
);
assert_eq!(t.pointer(&c, &PointerEvent::new(PointerKind::Up, 90.0, 210.0)), vec![Action::Commit]);
}
}
+27 -3
View File
@@ -785,18 +785,36 @@ fn c32(rgb: [u8; 3]) -> Color32 {
/// Outline of a node for highlighting (paths, compound children, text/image bounds).
fn node_outline(n: &Node) -> BezPath {
let mut bp = BezPath::new();
n.walk(&mut |c| match &c.kind {
walk_drawn(n, &mut |c| match &c.kind {
NodeKind::Path { path, .. } => bp.extend(path.to_bezpath()),
NodeKind::Text(_) | NodeKind::Image(_) | NodeKind::SymbolInstance { .. } => {
if let Some(b) = c.geometric_bounds() {
bp.extend(vectorcraft_geom::shapes::rectangle(b).to_bezpath());
}
}
// An envelope shows its mesh (or top object), not its content.
NodeKind::Envelope { .. } => {
if let Some((lines, _)) = vectorcraft_doc::live::envelope_overlay(c) {
bp.extend(lines.to_bezpath());
}
}
_ => {}
});
bp
}
/// [`Node::walk`] over what a selection highlight shows: an envelope's content is left out (the
/// envelope shows its mesh instead).
fn walk_drawn<'a>(n: &'a Node, f: &mut impl FnMut(&'a Node)) {
f(n);
if matches!(n.kind, NodeKind::Envelope { .. }) {
return;
}
for c in n.children().into_iter().flatten() {
walk_drawn(c, f);
}
}
/// The topmost editable object under document point `p` at `zoom` (3 px tolerance).
fn hit_at(app: &VectorcraftApp, p: Point, zoom: f64) -> Option<vectorcraft_doc::hit::Hit> {
let opt = vectorcraft_doc::hit::HitOptions { tol: 3.0 / zoom, outline: app.ui.view.outline, path_only: false };
@@ -1039,8 +1057,14 @@ fn selection_overlay(app: &mut VectorcraftApp, p: &egui::Painter, xf: &Xf) {
let partial = st.selection.partial(*id);
// Path outlines.
stroke_path(p, &node_outline(n), xf, Stroke::new(1.0, color));
// Anchors (and handles for selected anchors in direct mode).
n.walk(&mut |c| {
// Anchors (and handles for selected anchors in direct mode); a mesh envelope's points.
walk_drawn(n, &mut |c| {
if let Some((_, Some(grid))) = vectorcraft_doc::live::envelope_overlay(c) {
for q in &grid.points {
anchor_square(p, xf.to_screen(q.p), color, false, if direct { 5.0 } else { 4.0 });
}
return;
}
let NodeKind::Path { path, .. } = &c.kind else { return };
for (si, ai, a) in path.anchors() {
let sel = match partial {
+1
View File
@@ -138,6 +138,7 @@ pub fn control_bar(app: &mut VectorcraftApp, ui: &mut Ui) {
ui.add_space(6.0);
crate::place::control_bar_details(app, ui);
crate::toolbar::control_bar_options(app, ui);
crate::dialogs::envelope::control_bar(app, ui);
let shown_stroke = crate::panels::current_stroke(app);
let mixed = crate::panels::stroke_mixed(app, ui.ctx());
let weight = stroke_panel::shown_weight(app, shown_stroke.as_ref(), &mixed);
+145
View File
@@ -11,11 +11,14 @@
//! `preserveShape` (`clippingMask` | `transparency`), `fidelity` (0..100),
//! `distortAppearance`, `distortLinearGradients`, `distortPatternFills`.
//!
//! The Control bar's envelope controls ([`control_bar`]) live here too.
//!
//! Every dialog also has `preview`. They open with the selected envelope's values
//! (`object.envelope.info`); the menu items and shortcuts of Make with Warp and Make with Mesh
//! open the Reset variant while an envelope is selected, as the reference app's do.
use serde_json::{Value, json};
use vectorcraft_doc::{EnvelopeKind, NodeKind};
use vectorcraft_render::effects::WARP_STYLES;
use super::{DialogSpec, form};
@@ -221,6 +224,97 @@ fn confirm(app: &mut VectorcraftApp, d: &Dialog) -> Result<Value, String> {
form::commit_preview(app, cmd, p)
}
/// What the Control bar shows for the selected envelope.
#[derive(Clone, Debug, PartialEq)]
enum Bar {
Warp { style: String, bend: f64, h: f64, v: f64, horizontal: bool },
Mesh { rows: u32, cols: u32 },
TopObject,
}
/// The selected envelope (or the one whose contents are selected), whether its contents are being
/// edited, and its settings.
fn bar_of(app: &VectorcraftApp) -> Option<(bool, Bar)> {
let st = app.session.active()?;
let env = st.selection.objects.first().and_then(|id| vectorcraft_doc::live::envelope_of(&st.doc, *id))?;
let NodeKind::Envelope { kind, editing, .. } = &env.kind else { return None };
let bar = match kind {
EnvelopeKind::Warp { style, bend, h, v, horizontal } => {
Bar::Warp { style: style.clone(), bend: *bend, h: *h, v: *v, horizontal: *horizontal }
}
EnvelopeKind::Mesh { rows, cols, .. } => Bar::Mesh { rows: *rows, cols: *cols },
EnvelopeKind::TopObject { .. } => Bar::TopObject,
};
Some((*editing, bar))
}
/// The Control bar's envelope controls (as in the reference app) while an envelope, or content
/// whose envelope is being edited, is selected: Edit Envelope / Edit Contents; a warp's style,
/// orientation, bend and distortions, or a mesh's rows and columns; Reset Envelope Shape; and
/// Envelope Options.
pub fn control_bar(app: &mut VectorcraftApp, ui: &mut egui::Ui) {
let Some((editing, bar)) = bar_of(app) else { return };
let mut run: Option<(&str, Value)> = None;
ui.separator();
for (contents, icon, tip) in [(false, "dc-mesh", "Edit Envelope"), (true, "shapes", "Edit Contents")] {
if widgets::icon_button(ui, icon, tip, editing == contents, 24.0).clicked() && editing != contents {
run = Some(("object.envelope.editContents", json!({ "editing": contents })));
}
}
ui.add_space(4.0);
let field = |ui: &mut egui::Ui, key: &str, label: &str, value: f64, suffix: &str| {
widgets::dim_label(ui, label);
widgets::plain_field(ui, ("cb-envelope", key), value, suffix, 0, 46.0)
};
match &bar {
Bar::Warp { style, bend, h, v, horizontal } => {
let styles: Vec<&str> = WARP_STYLES.iter().map(|(_, l)| l.trim_end_matches('…')).collect();
let current = WARP_STYLES.iter().find(|(id, _)| id == style).map_or(style.as_str(), |(_, l)| l.trim_end_matches('…'));
widgets::dim_label(ui, "Style:");
if let Some((id, _)) = widgets::dropdown(ui, "cb-envelope-style", current, &styles, 110.0).and_then(|i| WARP_STYLES.get(i)) {
run = Some((OPTIONS_CMD, json!({ "style": id })));
}
for (h, label) in [(true, "Horizontal"), (false, "Vertical")] {
if widgets::radio(ui, label, *horizontal == h, true) {
run = Some((OPTIONS_CMD, json!({ "horizontal": h })));
}
}
for (key, label, value) in [("bend", "Bend:", *bend), ("h", "H:", *h), ("v", "V:", *v)] {
if let Some(n) = field(ui, key, label, value, "%") {
run = Some((OPTIONS_CMD, json!({ key: n.round().clamp(-100.0, 100.0) })));
}
}
}
Bar::Mesh { rows, cols } => {
for (key, label, value) in [("rows", "Rows:", *rows), ("cols", "Columns:", *cols)] {
if let Some(n) = field(ui, key, label, f64::from(value), "") {
run = Some((RESET_MESH, json!({ key: n.round().clamp(1.0, 50.0), "maintainShape": true })));
}
}
}
Bar::TopObject => {}
}
// Reset Envelope Shape: an unbent warp, a flat mesh.
let reset = match bar {
Bar::Warp { .. } => Some((OPTIONS_CMD, json!({"bend": 0, "h": 0, "v": 0}))),
Bar::Mesh { .. } => Some((RESET_MESH, json!({"maintainShape": false}))),
Bar::TopObject => None,
};
if let Some(r) = reset
&& widgets::flat_button(ui, "Reset", 50.0).on_hover_text("Reset Envelope Shape").clicked()
{
run = Some(r);
}
if widgets::icon_button(ui, "dc-options", "Envelope Options", false, 24.0).clicked() {
crate::menus::invoke(app, OPTIONS_CMD, json!({}));
}
if let Some((cmd, p)) = run
&& let Err(e) = app.run(cmd, p)
{
app.status(e);
}
}
#[cfg(test)]
mod tests {
use super::*;
@@ -343,4 +437,55 @@ mod tests {
super::super::confirm(&mut app).unwrap();
assert_eq!(app.session.execute("object.envelope.info", &json!({})).unwrap()["distortAppearance"], json!(false));
}
#[test]
fn edit_contents_reads_edit_envelope_while_editing() {
let (mut app, _) = app_with_rect();
app.run(MAKE_WARP, json!({})).unwrap();
let label = |app: &VectorcraftApp| crate::menus::dynamic_label(app, "object.envelope.editContents", "Edit Contents");
assert_eq!(label(&app), "Edit Contents");
app.run("object.envelope.editContents", json!({})).unwrap();
assert_eq!(label(&app), "Edit Envelope");
assert!(crate::menus::shortcut_of("object.envelope.editContents").is_none(), "its reference shortcut is Paste in Place's here");
}
/// One Control bar frame with `events`; the rectangles of its 24 pt icon buttons, left to right.
fn bar_frame(app: &mut VectorcraftApp, ctx: &egui::Context, events: Vec<egui::Event>) -> Vec<egui::Rect> {
let screen_rect = Some(egui::Rect::from_min_size(egui::Pos2::ZERO, egui::vec2(1800.0, 600.0)));
let mut out = ctx.run_ui(egui::RawInput { events, screen_rect, ..Default::default() }, |ui| crate::chrome::control_bar(app, ui));
out.textures_delta.clear();
let mut r: Vec<egui::Rect> = ctx.viewport(|vp| {
vp.prev_pass.widgets.layers().flat_map(|(_, w)| w.iter()).filter(|w| w.rect.size() == egui::vec2(24.0, 24.0)).map(|w| w.rect).collect()
});
r.sort_by(|a, b| a.left().total_cmp(&b.left()));
r
}
#[test]
fn the_control_bar_switches_to_edit_contents_and_back() {
let (mut app, rect) = app_with_rect();
app.run(MAKE_WARP, json!({"style": "arch"})).unwrap();
assert!(bar_of(&app).is_some_and(|(editing, bar)| !editing && matches!(bar, Bar::Warp { ref style, .. } if style == "arch")));
let ctx = egui::Context::default();
crate::theme::install_fonts(&ctx);
let click = |app: &mut VectorcraftApp, at: egui::Pos2| {
let button =
|pressed| egui::Event::PointerButton { pos: at, button: egui::PointerButton::Primary, pressed, modifiers: Default::default() };
bar_frame(app, &ctx, vec![egui::Event::PointerMoved(at), button(true)]);
bar_frame(app, &ctx, vec![button(false)]);
};
bar_frame(&mut app, &ctx, vec![]);
let buttons = bar_frame(&mut app, &ctx, vec![]);
// Edit Envelope, Edit Contents come first.
click(&mut app, buttons[1].center());
assert_eq!(app.session.doc().unwrap().selection.objects, vec![rect], "Edit Contents selects the content");
assert!(bar_of(&app).is_some_and(|(editing, _)| editing), "the content keeps the envelope's controls");
let buttons = bar_frame(&mut app, &ctx, vec![]);
click(&mut app, buttons[0].center());
assert!(bar_of(&app).is_some_and(|(editing, _)| !editing));
// A mesh envelope shows its rows and columns instead.
app.run(RESET_MESH, json!({"rows": 3, "cols": 2})).unwrap();
assert_eq!(bar_of(&app).map(|b| b.1), Some(Bar::Mesh { rows: 3, cols: 2 }));
bar_frame(&mut app, &ctx, vec![]);
}
}
+11
View File
@@ -1194,6 +1194,17 @@ pub fn dynamic_label(app: &VectorcraftApp, id: &str, label: &str) -> String {
id if id.starts_with("file.openRecent") => recent_slot(app, id)
.map(|p| std::path::Path::new(p).file_name().map_or(p.clone(), |f| f.to_string_lossy().to_string()))
.unwrap_or_else(|| "—".into()),
// Edit Contents reads Edit Envelope while an envelope's contents are being edited.
"object.envelope.editContents" => {
let editing = app.session.active().is_some_and(|st| {
st.selection
.objects
.first()
.and_then(|id| vectorcraft_doc::live::envelope_of(&st.doc, *id))
.is_some_and(|e| matches!(e.kind, vectorcraft_doc::NodeKind::Envelope { editing: true, .. }))
});
if editing { "Edit Envelope" } else { "Edit Contents" }.into()
}
"edit.undo" => app.session.active().and_then(|d| d.history.undo.last()).map(|h| format!("Undo {}", h.label)).unwrap_or_else(|| "Undo".into()),
"edit.redo" => app.session.active().and_then(|d| d.history.redo.last()).map(|h| format!("Redo {}", h.label)).unwrap_or_else(|| "Redo".into()),
id if id.starts_with(crate::panels::swatches::USER_SLOT) => {
+6
View File
@@ -526,3 +526,9 @@ Perspective grid widgets: the Plane Switching Widget is fixed in a corner of the
the grid shows. `ui.key` with `key` `"1"`…`"4"` picks the left, horizontal, right or no plane. Double-clicking the
Perspective Grid tool (`tool.options {tool: "perspectiveGrid"}`) opens `perspectiveGridOptions` (fields `show`,
`position`: `topLeft`, `topRight`, `bottomLeft`, `bottomRight`); `ui.dialog.confirm` runs `perspective.widget.options`.
Envelopes on the canvas: `pointer_gesture` with the Mesh tool (`mesh`) or Direct Selection on a selected mesh envelope
drags its points (a press on a point focuses it, and a press on one of the focused point's handle ends drags that
handle); a Mesh tool click inside a mesh envelope adds a row and a column. While an envelope or its content is
selected the Control bar shows its controls (Edit Envelope / Edit Contents, warp style, orientation, bend and
distortions or mesh rows and columns, Reset, Envelope Options); each runs an `object.envelope.*` command.
+19 -1
View File
@@ -1960,7 +1960,6 @@ sits on; the first edit turns the spine into a path. Moving a key moves its end
{"name":"run_command","arguments":{"command":"object.blend.spine.moveAnchor","params":{"id":40,"anchor":1,"x":150,"y":20}}}
```
How blends interpolate: closed shapes start where they twist least unless `starts` picks the anchors; strokes
interpolate weight, dashes (a solid stroke counts as the dashed one with its gaps closed), miter limit, arrowhead scale
and width profiles, while caps, joins, alignment, arrowheads and brushes switch halfway; gradients of one kind with
@@ -1971,3 +1970,22 @@ symbol switch halfway). New blends are knockout groups (`object.setProps {knocko
canvas as they export. `object.blend.expand` and `object.blend.release` keep the blend's name, transparency,
opacity mask and appearance (Release on a group around the keys and spine when the blend has any), and
`object.expand` with `object: true` expands the blends in the selection.
## Editing envelopes
`object.envelope.editContents {editing}` switches between editing the envelope and its contents (the menu item reads
Edit Envelope while the contents are edited; it has no default shortcut, since the reference app's is Paste in Place
here). While the contents are edited, clicks hit the content where it sits undistorted and the Selection tool selects
it. A mesh envelope's points have bezier handles (`kind.handles`, offsets
right/left/down/up; none stored means the smooth mesh through the points, as older files have):
`object.envelope.setMeshPoint {id, index, x, y, handle?}` and `object.mesh.movePoint`, `object.mesh.addLine` and
`object.mesh.deletePoint` edit them like a gradient mesh's (one undo step each). On the canvas a selected envelope
shows its mesh; the Mesh tool and Direct Selection drag its points and the clicked point's handles, and the Mesh tool
adds a row and a column where it clicks inside. The Control bar shows Edit Envelope / Edit Contents, the warp's
style, orientation, bend and distortions (or the mesh's rows and columns), Reset (an unbent warp, a flat mesh) and
Envelope Options.
```json
{"name":"run_command","arguments":{"command":"object.envelope.editContents","params":{"editing":true}}}
{"name":"run_command","arguments":{"command":"object.mesh.movePoint","params":{"id":12,"index":4,"x":220,"y":140,"handle":0}}}
```