mirror of
https://github.com/storytold/figcraft.git
synced 2026-10-25 19:06:47 -06:00
**A shapes and vector behaviour suite written from the public documentation, and fixes for every divergence it found.** Each documented rule (drawing with Shift and Alt, corner radius and which layers take one, point counts and star ratios, arcs, stroke joins and the miter angle, caps per end, boolean results and the look they take, flatten and outline stroke; entering and leaving vector edit mode, the pen, selecting points and segments, deleting vs. delete and heal, handle mirroring, the bend, paint bucket, cut, join and eraser tools, nudging and undo steps, what converting a shape keeps) is a table row or a step with what the rule implies and a one-line derivation; where the documentation is silent the row says so and pins today's behaviour.
**Fixes**
- **Rounded vector points were stored but never drawn**: a point's corner radius (edit mode's Corner radius field, `vector.edit.cornerRadius`) now rounds the corner between two straight segments on the canvas, in hit testing, booleans and every export (`VectorNetwork::rounded`, used by the fill and stroke paths); the radius shrinks to fit half of each segment, like a polygon's corners. A vector layer's own corner radius rounds every point. The points stay where they are in edit mode.
- **A boolean group's look follows its operation**: union, intersect and exclude take the top layer's fills, strokes and effects, subtract the bottom layer's (it was always the bottom one).
- **Alt draws a line or an arrow from its centre**, like the other shapes (it was ignored).
- **Stars keep to 3–60 points** (they went to 120; polygons keep 120, the documentation is silent); a junk `pointCount` is refused instead of becoming 5.
- **Delete and heal recomputes the curve**: the healed segment keeps its neighbours' tangents and passes through where the deleted point was (it reused the neighbours' handles, so healing over a circle cut deep inside it). Corners between straight segments heal straight, as before.
- **Shift-click on a selected point at a corner of the points' transform box takes it out of the selection** (it started a scale); a Shift-drag there still scales in proportion.
- **Outline stroke** turns the stroke's colour style into the outline's fill style, and the outline keeps the layer's opacity, blend, effects, constraints and visibility (they were lost).
- **The stroke panel's Miter angle shows degrees** (it showed the ratio 4 under that name; the default is ~28.96°); `node.set {miterAngle}` sets it and `get_node` reports a non-default join or angle.
- **Individual corners only for rectangles and frames**: polygons, stars and vectors have one radius (the toggle showed for them and set corners that weren't drawn).
- **Option/Alt+Shift+F flattens** (Cmd/Ctrl+E still does).
- **MCP `key` in vector edit mode reaches the edit tools first**, as the canvas does: tool letters, arrows, Delete / Shift+Delete, Cmd/Ctrl+J, Enter (Delete removed the whole layer; `X` was unknown).
**Missing documented behaviour, added**
- **Corner radius and star ratio handles on the canvas**: a handle inside each corner of a selected rectangle or frame, one inside a polygon's or star's top point, a ratio handle on a star's first inner point (`shape_handles.rs`). Dragging along a corner's bisector sets the radius of every corner (Alt: only that corner of a rectangle or frame, documentation silent), stopping where the corners meet; the ratio handle sets the inner radius. A readout shows the value while dragging; the handles hide under 48 px on screen, on locked layers, with several layers selected and in edit mode. One undo step per drag; `inspect_ui` lists them under `tool.shapeHandles`.
- **Count and Ratio fields** in the Design panel for polygons and stars (there was no way to change them in the UI).
- **Cut tool drags**: dragging across paths cuts them where the line crosses; parts that come apart move to layers of their own, each keeping its piece of the fill (`vector.edit.cut {from, to}`). A click still splits at a point or segment.
- **Paint bucket drags**: a drag fills or unfills every area it crosses the way the first one went (`vector.edit.paint {points}`), one undo step.
New strings go through `tr(...)` (the new fields reuse the existing Count / Ratio / Radius / Star / Polygon entries in `en.toml` and `es.toml`); the new fields have accessible names (an icon lead with its label). Each gesture and edit stays one undo step.
103 lines
5.5 KiB
Rust
103 lines
5.5 KiB
Rust
//! On-canvas corner radius and star ratio handles (see `shape_handles.rs`).
|
|
|
|
use figcraft_doc::{NodeId, NodeKind};
|
|
use figcraft_geom::Point;
|
|
use serde_json::{Value, json};
|
|
|
|
use crate::Session;
|
|
use crate::params::id_of;
|
|
use crate::shape_handles::{ShapeHandle, handles, hit};
|
|
use crate::tools::{PointerEvent, PointerKind};
|
|
|
|
fn create(s: &mut Session, cmd: &str, p: Value) -> NodeId {
|
|
let id = id_of(&s.execute(cmd, &p).unwrap()["id"]).unwrap();
|
|
s.set_selection(vec![id]);
|
|
id
|
|
}
|
|
|
|
fn drag(s: &mut Session, a: (f64, f64), b: (f64, f64), alt: bool) {
|
|
for (kind, p) in [(PointerKind::Down, a), (PointerKind::Move, b), (PointerKind::Up, b)] {
|
|
let mut e = PointerEvent::at(kind, p.0, p.1, 1.0);
|
|
e.alt = alt;
|
|
s.pointer(&e).unwrap();
|
|
}
|
|
}
|
|
|
|
fn radii(s: &Session, id: NodeId) -> [f64; 4] {
|
|
s.doc().unwrap().node(id).unwrap().corners.radii
|
|
}
|
|
|
|
#[test]
|
|
fn rectangle_corner_handles_round_the_corners() {
|
|
let mut s = Session::new();
|
|
let id = create(&mut s, "shape.rectangle", json!({"x": 100, "y": 100, "width": 200, "height": 120}));
|
|
let doc = s.doc().unwrap().clone();
|
|
let hs = handles(&doc, &[id], 1.0);
|
|
assert_eq!(hs.len(), 4, "one handle inside each corner");
|
|
let at = |h: [f64; 2]| Point::new(h[0], h[1]);
|
|
assert_eq!(hs[0].0, ShapeHandle::Radius { corner: 0 });
|
|
assert!(((at(hs[0].1) - Point::new(100.0, 100.0)).hypot() - 10.0).abs() < 1e-9, "at radius 0 a handle sits a little inside its corner");
|
|
assert!(((at(hs[2].1) - Point::new(300.0, 220.0)).hypot() - 10.0).abs() < 1e-9, "the bottom right one too");
|
|
// Dragging the top-left handle along the diagonal rounds every corner, in one undo step.
|
|
drag(&mut s, (hs[0].1[0], hs[0].1[1]), (130.0, 128.0), false);
|
|
let r = radii(&s, id);
|
|
assert!(r.iter().all(|x| (x - 29.0).abs() < 1e-6), "the radius follows the drag along the corner's diagonal: {r:?}");
|
|
assert_eq!(handles(s.doc().unwrap(), &[id], 1.0)[0].1, [129.0, 129.0], "the handle sits at the centre of the corner's arc");
|
|
s.undo().unwrap();
|
|
assert_eq!(radii(&s, id), [0.0; 4], "one undo step");
|
|
// Past the middle it stops at half the short side.
|
|
drag(&mut s, (hs[0].1[0], hs[0].1[1]), (400.0, 400.0), false);
|
|
assert_eq!(radii(&s, id), [60.0; 4], "a radius stops at half the short side");
|
|
// Alt rounds only the corner dragged.
|
|
s.undo().unwrap();
|
|
let br = handles(s.doc().unwrap(), &[id], 1.0)[2].1;
|
|
drag(&mut s, (br[0], br[1]), (290.0, 210.0), true);
|
|
let r = radii(&s, id);
|
|
assert!(r.iter().zip([0.0, 0.0, 10.0, 0.0]).all(|(a, b)| (a - b).abs() < 1e-6), "silent: Alt changes one corner (pinned): {r:?}");
|
|
}
|
|
|
|
#[test]
|
|
fn polygon_and_star_handles() {
|
|
let mut s = Session::new();
|
|
let star = create(&mut s, "shape.star", json!({"x": 0, "y": 0, "width": 200, "height": 200}));
|
|
let doc = s.doc().unwrap().clone();
|
|
let hs = handles(&doc, &[star], 1.0);
|
|
let kinds: Vec<ShapeHandle> = hs.iter().map(|h| h.0).collect();
|
|
assert_eq!(kinds, vec![ShapeHandle::Radius { corner: 0 }, ShapeHandle::Ratio], "a star has a radius and a ratio handle");
|
|
// The ratio handle sits on the first inner point; dragging it to the centre's side shrinks it.
|
|
let ratio_at = hs[1].1;
|
|
let inner = figcraft_geom::star_points(200.0, 200.0, 5, 0.38)[1];
|
|
assert!((Point::new(ratio_at[0], ratio_at[1]) - inner).hypot() < 1e-6);
|
|
let towards = Point::new(100.0, 100.0) + (inner - Point::new(100.0, 100.0)) * (0.5 / 0.38);
|
|
drag(&mut s, (ratio_at[0], ratio_at[1]), (towards.x, towards.y), false);
|
|
let NodeKind::Star { inner_ratio, .. } = s.doc().unwrap().node(star).unwrap().kind else { panic!("star") };
|
|
assert!((inner_ratio - 0.5).abs() < 1e-6, "the ratio follows the drag: {inner_ratio}");
|
|
// The radius handle rounds the points.
|
|
let top = handles(s.doc().unwrap(), &[star], 1.0)[0].1;
|
|
drag(&mut s, (top[0], top[1]), (top[0], top[1] + 30.0), false);
|
|
assert!(radii(&s, star)[0] > 1.0, "the star's points round");
|
|
let mut s = Session::new();
|
|
let poly = create(&mut s, "shape.polygon", json!({"x": 0, "y": 0, "width": 200, "height": 200}));
|
|
assert_eq!(handles(s.doc().unwrap(), &[poly], 1.0).len(), 1, "a polygon has one radius handle");
|
|
}
|
|
|
|
#[test]
|
|
fn handles_show_only_where_they_can_be_used() {
|
|
let mut s = Session::new();
|
|
let small = create(&mut s, "shape.rectangle", json!({"x": 0, "y": 0, "width": 40, "height": 40}));
|
|
assert!(handles(s.doc().unwrap(), &[small], 1.0).is_empty(), "too small on screen");
|
|
assert_eq!(handles(s.doc().unwrap(), &[small], 4.0).len(), 4, "zoomed in, they show");
|
|
let big = create(&mut s, "shape.rectangle", json!({"x": 100, "y": 0, "width": 100, "height": 100}));
|
|
assert!(handles(s.doc().unwrap(), &[small, big], 1.0).is_empty(), "only for a single selected layer");
|
|
let e = create(&mut s, "shape.ellipse", json!({"x": 300, "y": 0, "width": 100, "height": 100}));
|
|
assert!(handles(s.doc().unwrap(), &[e], 1.0).is_empty(), "ellipses have arc handles instead");
|
|
s.execute("object.toggleLock", &json!({"ids": [big], "locked": true})).unwrap();
|
|
assert!(handles(s.doc().unwrap(), &[big], 1.0).is_empty(), "locked layers have none");
|
|
assert!(hit(s.doc().unwrap(), &[small], Point::new(f64::NAN, 0.0), 4.0).is_none());
|
|
// In vector edit mode the Move tool shows none.
|
|
let r = create(&mut s, "shape.rectangle", json!({"x": 0, "y": 200, "width": 100, "height": 100}));
|
|
assert_eq!(s.shape_handles().len(), 4);
|
|
s.execute("vector.edit.begin", &json!({"id": r})).unwrap();
|
|
assert!(s.shape_handles().is_empty(), "none while editing points");
|
|
}
|