Files
vectorcraft/crates/tools/src/distort.rs
T
arhammusheer 5beac4f122 #1244: smaller objects: Node 352 -> 152 bytes, NodeKind 200 -> 80, and a cheaper undo estimate
Rarely set fields move into one optional boxed NodeExtras, and large NodeKind payloads (images,
live shapes, blends, envelopes) are boxed. Every object, and every undo step that copies it,
shrinks: a 50k-path move keeps ~24.5 MB per step instead of ~40 MB. Files stay byte-identical
(Node keeps its flat JSON through a borrowed Serialize and a from-wire Deserialize, no flatten),
and loading is slightly faster. Undo and redo reuse their step's size estimate instead of
walking the document again (8.3 -> 5.6 ms for a 50k-path move).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VmMmc1J8XeNLxhRihWv9cc

# Conflicts:
#	crates/engine/src/cmd/graph.rs
#	crates/svg/src/export.rs
2026-10-12 11:02:32 +07:00

196 lines
7.5 KiB
Rust

//! Distortion tools: Width (Shift+W), the Liquify family (Warp Shift+R, Twirl, Pucker, Bloat,
//! Scallop, Crystallize, Wrinkle), Puppet Warp, Perspective Grid (Shift+P) and Perspective
//! Selection (Shift+V).
//!
//! Like every tool they only emit actions. The geometry kernels the engine commands run live here
//! too (so tools, engine and tests share one implementation):
//! - [`liquify`]: deterministic brush deformation of anchors and handles with adaptive subdivision;
//! - [`arap`]: As-Rigid-As-Possible mesh deformation (Igarashi, Moscovich & Hughes 2005) with a
//! small banded Cholesky solver;
//! - [`perspective`]: the perspective grid model (one/two/three-point) and plane homographies;
//! - [`pathutil`]: arc-length helpers for width points.
pub mod arap;
pub mod liquify;
pub mod pathutil;
pub mod perspective;
mod puppet;
mod width;
use std::sync::Arc;
use vectorcraft_doc::{Node, NodeKind};
use vectorcraft_geom::{Homography, Point};
use crate::Tool;
pub use liquify::LiquifyTool;
pub use perspective::{PerspectiveGridTool, PerspectiveSelectionTool};
pub use puppet::{PinSet, PuppetWarpTool, mesh_for, mesh_input, rest_and_pins, warp_from_rest};
pub use width::{WidthTool, stroke_owner};
/// Create a distortion tool by id (None = not one of ours).
pub fn create(id: &str) -> Option<Box<dyn Tool>> {
Some(match id {
"width" => Box::new(WidthTool::default()),
"warp" | "twirl" | "pucker" | "bloat" | "scallop" | "crystallize" | "wrinkle" => Box::new(LiquifyTool::new(id)),
"puppetWarp" => Box::new(PuppetWarpTool::default()),
"perspectiveGrid" => Box::new(PerspectiveGridTool::default()),
"perspectiveSelection" => Box::new(PerspectiveSelectionTool::default()),
_ => return None,
})
}
/// Apply a point map to every anchor and handle of `n` and its descendants. Gradients (pinned
/// first if unplaced) follow the map's affine approximation at their centre; objects without
/// editable points (text, images, symbols, meshes, live objects) get the approximation at their
/// centre. Live shapes become plain paths.
pub fn warp_node_with(n: &mut Node, f: &dyn Fn(Point) -> Point) {
warp_node(n, f, None);
}
/// [`warp_node_with`] for the projective map `h` (perspective): type and symbol instances keep
/// their flat art and are drawn through `h` (composed with the projection they had).
pub fn project_node(n: &mut Node, h: &Homography) {
warp_node(n, &|p| h.apply(p).unwrap_or(p), Some(h));
}
fn warp_node(n: &mut Node, f: &dyn Fn(Point) -> Point, h: Option<&Homography>) {
if let Some(h) = h
&& matches!(n.kind, NodeKind::Text(_) | NodeKind::SymbolInstance { .. })
{
let mut extras = n.extras_mut();
let rec = extras.perspective.get_or_insert_with(Default::default);
let had = rec.homography().unwrap_or(Homography::IDENTITY);
rec.projection = Some(h.then_after(&had).to_array());
return;
}
let bounds = n.geometric_bounds();
// Finite-difference step: 5% of the object (at least half a point).
let eps = bounds.map_or(0.5, |b| (b.width().max(b.height()) * 0.05).max(0.5));
n.pin_gradients();
match &mut n.kind {
NodeKind::Path { path, live, .. } => {
*live = None;
for sp in &mut path.subpaths {
for a in &mut sp.anchors {
a.p = f(a.p);
a.h_in = f(a.h_in);
a.h_out = f(a.h_out);
}
}
}
NodeKind::Layer { children, .. }
| NodeKind::Group { children, .. }
| NodeKind::Compound { children, .. }
| NodeKind::Blend { children, .. } => {
for c in children.iter_mut() {
warp_node(Arc::make_mut(c), f, h);
}
}
_ => {
if let Some(b) = bounds {
n.transform(affine_near(f, b.center(), eps), false);
}
return;
}
}
n.appearance.warp_gradients(&|p| affine_near(f, p, eps));
}
pub use vectorcraft_doc::live::affine_near;
/// Every anchor and handle position in `n` (for meshes and bounds).
pub fn collect_points(n: &Node, out: &mut Vec<Point>) {
n.walk(&mut |c| match &c.kind {
NodeKind::Path { path, .. } => {
for sp in &path.subpaths {
for a in &sp.anchors {
out.extend([a.p, a.h_in, a.h_out]);
}
}
}
NodeKind::Layer { .. } | NodeKind::Group { .. } | NodeKind::Compound { .. } | NodeKind::Blend { .. } => {}
_ => {
if let Some(b) = c.geometric_bounds() {
out.extend([Point::new(b.x0, b.y0), Point::new(b.x1, b.y1)]);
}
}
});
}
/// Selection blue used by the distortion tool overlays.
pub(crate) const BLUE: [u8; 3] = [0x4a, 0x7c, 0xff];
/// A small diamond polygon at `p` (half-diagonal `r`).
pub(crate) fn diamond(p: Point, r: f64) -> vectorcraft_geom::BezPath {
crate::xform::polygon(&[Point::new(p.x, p.y - r), Point::new(p.x + r, p.y), Point::new(p.x, p.y + r), Point::new(p.x - r, p.y)], true)
}
/// A circle / ellipse outline for brush feedback.
pub(crate) fn ellipse_path(c: Point, rx: f64, ry: f64, angle_deg: f64) -> vectorcraft_geom::BezPath {
use vectorcraft_geom::Shape;
let e = vectorcraft_geom::kurbo::Ellipse::new(c, (rx.max(0.01), ry.max(0.01)), angle_deg.to_radians());
e.to_path(0.05)
}
#[cfg(test)]
mod tests {
use super::*;
use vectorcraft_color::{Gradient, GradientPaint, Paint};
use vectorcraft_doc::Appearance;
use vectorcraft_geom::{Affine, PathData, Rect, shapes};
#[test]
fn create_covers_all_distort_tools() {
for id in [
"width",
"warp",
"twirl",
"pucker",
"bloat",
"scallop",
"crystallize",
"wrinkle",
"puppetWarp",
"perspectiveGrid",
"perspectiveSelection",
] {
assert_eq!(create(id).map(|t| t.id()), Some(id));
}
assert!(create("pen").is_none());
}
#[test]
fn warp_node_maps_all_points_and_drops_live_shape() {
let mut n = Node::path(vectorcraft_doc::NodeId(1), shapes::rectangle(Rect::new(0.0, 0.0, 10.0, 10.0)), Appearance::default_art());
warp_node_with(&mut n, &|p| Point::new(p.x * 2.0, p.y + 1.0));
let b = n.geometric_bounds().unwrap();
assert_eq!((b.x0, b.y0, b.x1, b.y1), (0.0, 1.0, 20.0, 11.0));
if let NodeKind::Path { live, .. } = &n.kind {
assert!(live.is_none());
}
let _ = PathData::default();
}
#[test]
fn warp_pins_and_maps_gradients_once() {
let grad = Paint::Gradient(Box::new(GradientPaint::new(Gradient::default())));
let mut n =
Node::path(vectorcraft_doc::NodeId(1), shapes::rectangle(Rect::new(0.0, 0.0, 10.0, 10.0)), Appearance::basic(grad, Paint::None, 0.0));
warp_node_with(&mut n, &|p| Point::new(p.x * 2.0, p.y + 1.0));
let Paint::Gradient(g) = n.appearance.fill_paint() else { panic!() };
let geom = g.geom.expect("pinned before the warp");
assert!(geom.start.distance(Point::new(0.0, 6.0)) < 1e-9 && geom.end.distance(Point::new(20.0, 6.0)) < 1e-9, "{geom:?}");
}
#[test]
fn affine_near_recovers_affine_maps() {
let a = Affine::new([2.0, 0.5, -0.3, 1.5, 10.0, -4.0]);
let got = affine_near(&|p| a * p, Point::new(3.0, 7.0), 0.5);
for (x, y) in a.as_coeffs().iter().zip(got.as_coeffs()) {
assert!((x - y).abs() < 1e-9);
}
}
}