M8: polygons and stars get a Count handle on the canvas, as in Figma

A selected polygon or star shows a Count handle on its first point clockwise from the top (a polygon's vertex 1, a star's outer point 1, not inset). Dragging it sets the count to round(360 / the pointer's clockwise angle from the top point about the centre), measured in the shape's own frame and clamped to 3 to 60, written through node.set pointCount in one undo step. The readout and hover label read "Count N". Measured in Figma's web app on 2026-11-12; a polygon's maximum wasn't read, so it is clamped at 60 like the star's.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Model-Effort: Opus 5.5 drove; Fable 5.2 queued; Opus 5.5 briefed; Sonnet 5.5 measured and wrote at medium
This commit is contained in:
Trevor BrownandClaude Sonnet 5.5 committed 2026-11-12 09:56:45 -05:00
1 parent 0d6ce1124c
commit 89d8b50e9d
4 files changed
+150 -7

No files matched your search

+42 -3
View File
@@ -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,29 @@ 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);
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 +159,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 +194,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 +210,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,
};
+104 -2
View File
@@ -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));
// The finite guard: a pointer on the centre gives a finite count.
let h = count_handle(&s, poly);
drag(&mut s, (h[0], h[1]), (100.0, 100.0), false);
assert!((3..=60).contains(&count_of(&s, poly)));
}
#[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");
}
}