Files
vectorcraft/crates/engine/src/tests_envelope_edit.rs
T
bflatasticandClaude Opus 5.5 695c541f0d M8.17: Edit envelopes on the canvas: Edit Contents, mesh points and handles, Control bar
- Edit Contents: while an envelope's contents are edited, clicks hit the
  content where it sits undistorted and the Selection tool selects it; the
  menu item reads Edit Envelope then. No default shortcut: the reference
  app's (Shift+Cmd+V) is Paste in Place here.
- A selected envelope shows its mesh (a warp's surface grid, a mesh's own,
  a top object's outline) instead of its content's outlines.
- Mesh envelope points get bezier handles (append-only; none stored is the
  smooth Catmull-Rom mesh, which the first handle edit fills in exactly
  along the grid lines). setMeshPoint takes `handle`; object.mesh.movePoint,
  addLine and deletePoint edit mesh envelopes too. The Mesh tool and Direct
  Selection drag points and the clicked point's handles (shared MeshEdit);
  the Mesh tool adds rows and columns inside mesh envelopes. Reset with
  Mesh keeps the shape along the grid with handles; Release returns the
  exact mesh.
- Control bar: Edit Envelope / Edit Contents, warp style, orientation,
  bend and distortions or mesh rows and columns, Reset, Envelope Options.

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

137 lines
7.1 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
//! 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);
}
}