mirror of
https://github.com/storytold/vectorcraft.git
synced 2026-10-25 10:17:02 -06:00
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>
195 lines
7.4 KiB
Rust
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);
|
|
}
|
|
}
|
|
}
|