140 lines
7.2 KiB
Rust
140 lines
7.2 KiB
Rust
//! 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, ¶ms).unwrap()))
|
||
}
|
||
|
||
fn mesh(s: &Session, e: NodeId) -> (u32, u32, Vec<Point>, Vec<[Vec2; 4]>) {
|
||
match node(s, e).kind {
|
||
NodeKind::Envelope { kind, .. } => match *kind {
|
||
EnvelopeKind::Mesh { rows, cols, points, handles } => (rows, cols, points, handles),
|
||
k => panic!("{k:?}"),
|
||
},
|
||
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, .. } if matches!(&*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);
|
||
}
|
||
}
|