mirror of
https://github.com/storytold/vectorcraft.git
synced 2026-10-25 18:27:02 -06:00
- A compound path's stroke lives on the compound: the tool edits it through any member, and stroke.widthPoint.* / stroke.widthProfile.set given a member edit the compound's profile. - Width points show on every subpath the stroke runs along (not just the first) for the hovered and the selected stroke. - Cursors: the Width tool's own glyphs off a stroke, over a stroke (a drag adds a point) and over a width point or handle (a drag moves or widens it). - Hint-bar text for the tool. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
184 lines
7.2 KiB
Rust
184 lines
7.2 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::{Affine, Point, Vec2};
|
|
|
|
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) {
|
|
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_with(Arc::make_mut(c), f);
|
|
}
|
|
}
|
|
_ => {
|
|
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));
|
|
}
|
|
|
|
/// The affine map that best matches `f` near `p` (finite differences with step `eps`).
|
|
pub fn affine_near(f: &dyn Fn(Point) -> Point, p: Point, eps: f64) -> Affine {
|
|
let o = f(p);
|
|
let ex = (f(p + Vec2::new(eps, 0.0)) - f(p - Vec2::new(eps, 0.0))) / (2.0 * eps);
|
|
let ey = (f(p + Vec2::new(0.0, eps)) - f(p - Vec2::new(0.0, eps))) / (2.0 * eps);
|
|
let lin = Affine::new([ex.x, ex.y, ey.x, ey.y, 0.0, 0.0]);
|
|
Affine::translate(o.to_vec2()) * lin * Affine::translate(-p.to_vec2())
|
|
}
|
|
|
|
/// 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::{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);
|
|
}
|
|
}
|
|
}
|