mirror of
https://github.com/storytold/figcraft.git
synced 2026-11-14 03:01:11 +00:00
Merge pull request #2308 from TrevorBrown-dev/feat/polygon-count-handle
M8: polygons and stars get a Count handle on the canvas, as in Figma
This commit is contained in:
@@ -6,12 +6,16 @@
|
||||
//! [`INSET_PX`] in, so it can be grabbed at radius 0). Dragging it along the bisector sets the
|
||||
//! radius of every corner; with Alt, only that corner of a rectangle or frame (the documentation
|
||||
//! is silent on Alt).
|
||||
//! - **Count**: a polygon's or star's sits on its first point clockwise from the top; dragging it
|
||||
//! sets the count from the pointer's angle about the centre, measured clockwise from the top
|
||||
//! point: `round(360 / angle)`, clamped to 3 to 60 (measured in Figma's web app, 2026-11-12; a
|
||||
//! polygon's maximum wasn't read).
|
||||
//! - **Ratio**: a star's sits on its first inner point; dragging it towards or away from the
|
||||
//! centre sets the inner radius as a share of the outer one.
|
||||
//!
|
||||
//! Positions are in page coordinates and work for rotated and flipped layers at any zoom; the
|
||||
//! handles hide on layers smaller than [`MIN_PX`] on screen. The Move tool's gesture (`tools.rs`)
|
||||
//! writes the result through `node.set` (`cornerRadius`, `topLeftRadius`…, `innerRadius`), one
|
||||
//! writes the result through `node.set` (`cornerRadius`, `topLeftRadius`…, `innerRadius`, `pointCount`), one
|
||||
//! undo step per drag.
|
||||
|
||||
use figcraft_doc::{Document, Node, NodeId, NodeKind};
|
||||
@@ -20,18 +24,22 @@ use serde::Serialize;
|
||||
use serde_json::{Map, Value, json};
|
||||
|
||||
/// One shape handle: a corner's radius (corner index: tl, tr, br, bl; 0 for a polygon's or
|
||||
/// star's top point) or a star's ratio.
|
||||
/// star's top point), a star's ratio or a polygon's or star's point count.
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize)]
|
||||
#[serde(rename_all = "camelCase", tag = "kind")]
|
||||
pub enum ShapeHandle {
|
||||
Radius { corner: usize },
|
||||
Ratio,
|
||||
Count,
|
||||
}
|
||||
|
||||
/// Layers smaller than this on screen (either side, in pixels) show no shape handles.
|
||||
pub const MIN_PX: f64 = 48.0;
|
||||
/// How far in from its corner a handle sits at least (screen pixels).
|
||||
pub const INSET_PX: f64 = 10.0;
|
||||
/// The fewest and most points the Count handle sets (a star's range in the help center).
|
||||
const COUNT_MIN: f64 = 3.0;
|
||||
const COUNT_MAX: f64 = 60.0;
|
||||
/// How near the pointer must be to pick a handle (screen pixels).
|
||||
const HIT_PX: f64 = 6.0;
|
||||
|
||||
@@ -113,6 +121,34 @@ fn ratio_point(n: &Node, count: u32, ratio: f64) -> Option<Point> {
|
||||
figcraft_geom::star_points(n.width, n.height, count, ratio).get(1).copied()
|
||||
}
|
||||
|
||||
/// The Count handle (local): a polygon's vertex 1 or a star's outer point 1, the first clockwise
|
||||
/// from the top.
|
||||
fn count_point(n: &Node) -> Option<Point> {
|
||||
match n.kind {
|
||||
NodeKind::Polygon { count } => figcraft_geom::polygon_points(n.width, n.height, count).get(1).copied(),
|
||||
NodeKind::Star { count, inner_ratio } => figcraft_geom::star_points(n.width, n.height, count, inner_ratio).get(2).copied(),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
/// The count a pointer at local point `q` sets: `round(360 / angle)` for its clockwise angle from
|
||||
/// the top about the centre, on the unit circle the shape's box maps from; finite for any `q`.
|
||||
fn count_at(n: &Node, q: Point) -> Option<f64> {
|
||||
let (rx, ry) = (n.width / 2.0, n.height / 2.0);
|
||||
if rx.partial_cmp(&0.0) != Some(std::cmp::Ordering::Greater) || ry.partial_cmp(&0.0) != Some(std::cmp::Ordering::Greater) {
|
||||
return None;
|
||||
}
|
||||
let (x, y) = (q.x / rx - 1.0, q.y / ry - 1.0);
|
||||
// On the centre there is no direction (atan2 of two zeros would read as 180 degrees): treat it
|
||||
// as no angle, the most points.
|
||||
if x.hypot(y) < 1e-9 {
|
||||
return Some(COUNT_MAX);
|
||||
}
|
||||
let angle = x.atan2(-y).rem_euclid(std::f64::consts::TAU).to_degrees();
|
||||
let count = (360.0 / angle.max(1e-9)).round();
|
||||
count.is_finite().then(|| count.clamp(COUNT_MIN, COUNT_MAX))
|
||||
}
|
||||
|
||||
/// The shape handles of the selection at `zoom` (screen pixels per page unit): each handle and
|
||||
/// where it is (page coordinates).
|
||||
pub fn handles(doc: &Document, sel: &[NodeId], zoom: f64) -> Vec<(ShapeHandle, [f64; 2])> {
|
||||
@@ -128,6 +164,9 @@ pub fn handles(doc: &Document, sel: &[NodeId], zoom: f64) -> Vec<(ShapeHandle, [
|
||||
{
|
||||
out.push((ShapeHandle::Ratio, pt(p)));
|
||||
}
|
||||
if let Some(p) = count_point(n) {
|
||||
out.push((ShapeHandle::Count, pt(p)));
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
@@ -160,6 +199,10 @@ pub fn drag(doc: &Document, id: NodeId, h: ShapeHandle, p: Point, alt: bool) ->
|
||||
};
|
||||
Some(Map::from_iter([(key.to_string(), json!(r))]))
|
||||
}
|
||||
ShapeHandle::Count => {
|
||||
count_point(n)?;
|
||||
Some(Map::from_iter([("pointCount".to_string(), json!(count_at(n, q)?))]))
|
||||
}
|
||||
ShapeHandle::Ratio => {
|
||||
let NodeKind::Star { count, .. } = n.kind else { return None };
|
||||
// Along the inner point's ray, measured on the unit circle the star's box maps from.
|
||||
@@ -172,11 +215,12 @@ pub fn drag(doc: &Document, id: NodeId, h: ShapeHandle, p: Point, alt: bool) ->
|
||||
}
|
||||
}
|
||||
|
||||
/// The live readout while dragging `h` of `id`: the corner's radius, or the star's ratio in percent.
|
||||
/// The live readout while dragging `h` of `id`: the corner's radius, the star's ratio in percent or the point count.
|
||||
pub fn value(doc: &Document, id: NodeId, h: ShapeHandle) -> Option<f64> {
|
||||
let n = doc.node(id)?;
|
||||
let v = match (h, &n.kind) {
|
||||
(ShapeHandle::Ratio, NodeKind::Star { inner_ratio, .. }) => inner_ratio * 100.0,
|
||||
(ShapeHandle::Count, NodeKind::Polygon { count }) | (ShapeHandle::Count, NodeKind::Star { count, .. }) => f64::from(*count),
|
||||
(ShapeHandle::Radius { corner }, _) => corners(n).into_iter().nth(corner).map(|(c, r)| r.clamp(0.0, c.max_r))?,
|
||||
_ => return None,
|
||||
};
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
use figcraft_doc::{NodeId, NodeKind};
|
||||
use figcraft_geom::Point;
|
||||
use serde_json::{Value, json};
|
||||
use std::f64::consts::TAU;
|
||||
|
||||
use crate::Session;
|
||||
use crate::params::id_of;
|
||||
@@ -63,7 +64,11 @@ fn polygon_and_star_handles() {
|
||||
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");
|
||||
assert_eq!(
|
||||
kinds,
|
||||
vec![ShapeHandle::Radius { corner: 0 }, ShapeHandle::Ratio, ShapeHandle::Count],
|
||||
"a star has a radius, a ratio and a count 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];
|
||||
@@ -78,7 +83,7 @@ fn polygon_and_star_handles() {
|
||||
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");
|
||||
assert_eq!(handles(s.doc().unwrap(), &[poly], 1.0).len(), 2, "a polygon has a radius and a count handle");
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -100,3 +105,100 @@ fn handles_show_only_where_they_can_be_used() {
|
||||
s.execute("vector.edit.begin", &json!({"id": r})).unwrap();
|
||||
assert!(s.shape_handles().is_empty(), "none while editing points");
|
||||
}
|
||||
|
||||
fn count_of(s: &Session, id: NodeId) -> u32 {
|
||||
match s.doc().unwrap().node(id).unwrap().kind {
|
||||
NodeKind::Polygon { count } | NodeKind::Star { count, .. } => count,
|
||||
_ => panic!("a polygon or star"),
|
||||
}
|
||||
}
|
||||
|
||||
fn count_handle(s: &Session, id: NodeId) -> [f64; 2] {
|
||||
handles(s.doc().unwrap(), &[id], 1.0).into_iter().find(|h| h.0 == ShapeHandle::Count).expect("a count handle").1
|
||||
}
|
||||
|
||||
/// The page point at `deg` degrees clockwise from the shape's own top, `r` from its centre (200 x 200 box).
|
||||
fn at_angle(s: &Session, id: NodeId, deg: f64, r: f64) -> (f64, f64) {
|
||||
let a = deg.to_radians();
|
||||
let p = s.doc().unwrap().world_transform(id) * Point::new(100.0 + r * a.sin(), 100.0 - r * a.cos());
|
||||
(p.x, p.y)
|
||||
}
|
||||
|
||||
fn count_drag(s: &mut Session, id: NodeId, deg: f64) {
|
||||
let h = count_handle(s, id);
|
||||
let to = at_angle(s, id, deg, 80.0);
|
||||
drag(s, (h[0], h[1]), to, false);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_polygon_has_a_count_handle_on_its_first_vertex_clockwise_from_the_top() {
|
||||
let mut s = Session::new();
|
||||
let poly = create(&mut s, "shape.polygon", json!({"x": 0, "y": 0, "width": 200, "height": 200}));
|
||||
s.execute("node.set", &json!({"ids": [poly], "props": {"pointCount": 5}})).unwrap();
|
||||
let h = count_handle(&s, poly);
|
||||
let v1 = figcraft_geom::polygon_points(200.0, 200.0, 5)[1];
|
||||
assert!((Point::new(h[0], h[1]) - v1).hypot() < 1e-6, "on vertex 1, not inset: {h:?} vs {v1:?}");
|
||||
assert!((h[0] - (100.0 + 100.0 * (TAU / 5.0).sin())).abs() < 1e-6, "72 degrees from the top");
|
||||
// Hovering it finds it, not the radius handle.
|
||||
assert_eq!(hit(s.doc().unwrap(), &[poly], Point::new(h[0], h[1]), 1.0).map(|x| x.1), Some(ShapeHandle::Count));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn dragging_the_count_handle_sets_the_count_by_the_angle_about_the_centre() {
|
||||
let mut s = Session::new();
|
||||
let poly = create(&mut s, "shape.polygon", json!({"x": 0, "y": 0, "width": 200, "height": 200}));
|
||||
s.execute("node.set", &json!({"ids": [poly], "props": {"pointCount": 5}})).unwrap();
|
||||
for (deg, want) in [(90.0, 4), (45.0, 8), (120.0, 3), (0.5, 60), (0.0, 60), (200.0, 3)] {
|
||||
count_drag(&mut s, poly, deg);
|
||||
assert_eq!(count_of(&s, poly), want, "{deg} degrees");
|
||||
}
|
||||
// The radius from the centre doesn't matter.
|
||||
let h = count_handle(&s, poly);
|
||||
let to = at_angle(&s, poly, 90.0, 30.0);
|
||||
drag(&mut s, (h[0], h[1]), to, false);
|
||||
assert_eq!(count_of(&s, poly), 4, "90 degrees near the centre");
|
||||
// The readout is the count.
|
||||
let doc = s.doc().unwrap();
|
||||
assert_eq!(crate::shape_handles::value(doc, poly, ShapeHandle::Count), Some(4.0));
|
||||
// A pointer on the centre has no direction: it gives the most points.
|
||||
let h = count_handle(&s, poly);
|
||||
drag(&mut s, (h[0], h[1]), (100.0, 100.0), false);
|
||||
assert_eq!(count_of(&s, poly), 60, "a pointer on the centre");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn dragging_a_stars_count_handle_sets_its_points() {
|
||||
let mut s = Session::new();
|
||||
let star = create(&mut s, "shape.star", json!({"x": 0, "y": 0, "width": 200, "height": 200}));
|
||||
let h = count_handle(&s, star);
|
||||
let outer = figcraft_geom::star_points(200.0, 200.0, 5, 0.38)[2];
|
||||
assert!((Point::new(h[0], h[1]) - outer).hypot() < 1e-6, "on its first outer point clockwise from the top");
|
||||
for (deg, want) in [(90.0, 4), (45.0, 8), (130.0, 3), (1.0, 60)] {
|
||||
count_drag(&mut s, star, deg);
|
||||
assert_eq!(count_of(&s, star), want, "{deg} degrees");
|
||||
}
|
||||
let NodeKind::Star { inner_ratio, .. } = s.doc().unwrap().node(star).unwrap().kind else { panic!("star") };
|
||||
assert!((inner_ratio - 0.38).abs() < 0.02, "the ratio stays through count drags: {inner_ratio}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_count_drag_is_one_undo_step() {
|
||||
let mut s = Session::new();
|
||||
let poly = create(&mut s, "shape.polygon", json!({"x": 0, "y": 0, "width": 200, "height": 200}));
|
||||
s.execute("node.set", &json!({"ids": [poly], "props": {"pointCount": 5}})).unwrap();
|
||||
count_drag(&mut s, poly, 45.0);
|
||||
assert_eq!(count_of(&s, poly), 8);
|
||||
s.undo().unwrap();
|
||||
assert_eq!(count_of(&s, poly), 5, "one Ctrl+Z takes the drag back");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_rotated_polygons_count_angle_is_measured_in_its_own_frame() {
|
||||
let mut s = Session::new();
|
||||
let poly = create(&mut s, "shape.polygon", json!({"x": 0, "y": 0, "width": 200, "height": 200}));
|
||||
s.execute("node.set", &json!({"ids": [poly], "props": {"pointCount": 5, "rotation": 30}})).unwrap();
|
||||
for (deg, want) in [(90.0, 4), (45.0, 8)] {
|
||||
count_drag(&mut s, poly, deg);
|
||||
assert_eq!(count_of(&s, poly), want, "{deg} degrees in the shape's frame");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -701,6 +701,7 @@ fn shape_overlay(app: &FigApp, painter: &egui::Painter, col: Color32) {
|
||||
let text = match hint.handle {
|
||||
ShapeHandle::Radius { .. } => format!("{} {}", tr("Radius"), hint.value),
|
||||
ShapeHandle::Ratio => format!("{} {}%", tr("Ratio"), hint.value),
|
||||
ShapeHandle::Count => format!("{} {}", tr("Count"), hint.value),
|
||||
};
|
||||
pill(painter, c + vec2(12.0, 12.0), Align2::LEFT_TOP, text, col);
|
||||
}
|
||||
|
||||
+3
-2
File diff suppressed because one or more lines are too long
Reference in New Issue
Block a user