Files
vectorcraft/crates/tools/src/distort.rs
T
bflatasticandClaude Opus 5.5 8255d606ae M8.14: Type and symbols in perspective are really projected and stay editable; attachments survive copies
Type and symbol instances attached to a plane keep their art and carry a projection
(Node.perspective.projection, append-only); the canvas and every export draw their outlines
through it (reshape/bake), so they are foreshortened and stay type and symbol instances. Bounds,
hit testing and transforms follow the projection. Object › Perspective › Edit Text
(perspective.editText, or double-clicking with the Perspective Selection tool) shows the type flat
where it is drawn in isolation mode; exiting isolation projects it again. Release with
Perspective keeps their look. Copies, pastes, duplicates and Selection-tool moves keep the
attachment (the art moves as dragged). Flare, spiral and polar grid draw on the active plane, and
so do the click-to-size shape dialogs (perspective.draw `at`: sizes in plane units).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-06 12:10:00 +07:00

195 lines
7.4 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 rec = n.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);
}
}
}