Files
figcraft/crates/engine/src/arc_handles.rs
T
BFlatandClaude Opus 5.5 abf7a23fdd M4: arc handles on the canvas (#84)
* M4: arc handles on the canvas

A selected ellipse shows its arc handles with the Move tool, as public
documentation of Figma-class tools describes them:

- Sweep, inside the right edge on the centre line: dragged round the ellipse
  it opens an arc (positive up, negative down, continuous through a full
  turn) and closes again back at the start.
- Start (a dot inside), once there is an arc: turns the arc, keeping its sweep.
- Ratio, at the centre or the inner edge halfway round a ring: drags out to a
  ring, and into the gap to show the other part.
- Shift snaps angles to 15 degree steps and the ratio to 5% steps; a live
  readout (Sweep 75%, Start 90 degrees, Ratio 40%) shows beside the handle.
- Rotated and flipped ellipses, any zoom; hidden under 40 px on screen.
  One undo step per drag through node.set's arcStart/arcSweep/arcInner;
  Escape cancels.

The geometry lives in crates/engine/src/arc_handles.rs (tests in
arc_handles_tests.rs); the gesture in tools.rs; ui.inspect / inspect_ui list
tool.arcHandles and the overlay's arc readout. Docs, README and ROADMAP
updated.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* Merge origin/main into feat/arc-handles; weighted depth 78%

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-10 16:30:18 +07:00

300 lines
12 KiB
Rust

//! Arc handles: the on-canvas handles of a selected ellipse's arc (as public documentation of
//! Figma-class tools describes them).
//!
//! - **Sweep** starts on the ellipse's right edge, on its centre line. Dragging it around the
//! ellipse opens it into an arc: the sweep is a signed share of a full turn, positive dragging up
//! from the right edge and negative dragging down. Brought back to the start, it closes the
//! ellipse again.
//! - **Start** (drawn with a dot inside) shows once there is an arc. It turns the arc around the
//! ellipse and keeps its sweep.
//! - **Ratio** sits at the centre (on a ring, at the inner edge, halfway round the arc). Dragging
//! it outwards makes a ring. Dragged into the gap, it shows the other part of the ellipse.
//!
//! Shift snaps angles to 15° steps and the ratio to 5% steps. Positions are in page coordinates
//! and work for rotated and flipped ellipses at any zoom. The handles hide when the ellipse is too
//! small on screen. The Move tool's gesture (`tools.rs`) writes the result through `node.set`'s
//! `arcStart` / `arcSweep` / `arcInner` keys, one undo step per drag.
use std::f64::consts::TAU;
use figcraft_doc::{Document, NodeId, NodeKind};
use figcraft_geom::{Affine, Point, Vec2};
use serde::Serialize;
/// One of an ellipse's arc handles.
#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize)]
#[serde(rename_all = "camelCase")]
pub enum ArcHandle {
Sweep,
Start,
Ratio,
}
/// An ellipse's arc: start and sweep angles in radians (clockwise from the right, y down; a
/// negative sweep runs counter-clockwise) and the inner radius as a share of the outer one.
#[derive(Clone, Copy, Debug, PartialEq, Serialize)]
pub struct Arc {
pub start: f64,
pub sweep: f64,
pub inner: f64,
}
impl Arc {
/// A full turn (an ellipse or a ring, no gap).
pub fn full(&self) -> bool {
self.sweep.abs() >= TAU - 1e-9
}
/// Whether `angle` falls on the arc (not in its gap).
fn covers(&self, angle: f64) -> bool {
let along = if self.sweep >= 0.0 { angle - self.start } else { self.start - angle };
self.full() || along.rem_euclid(TAU) <= self.sweep.abs()
}
/// The live readout while dragging `h`: `Sweep 75%`, `Start 90°` or `Ratio 40%`.
pub fn label(&self, h: ArcHandle) -> String {
match h {
ArcHandle::Sweep => format!("Sweep {}%", one_decimal(self.sweep / TAU * 100.0)),
ArcHandle::Start => format!("Start {}°", one_decimal(self.start.to_degrees().rem_euclid(360.0))),
ArcHandle::Ratio => format!("Ratio {}%", one_decimal(self.inner * 100.0)),
}
}
}
/// `v` rounded to one decimal, without a trailing `.0` (or a minus on zero).
fn one_decimal(v: f64) -> String {
let r = (v * 10.0).round() / 10.0;
let r = if r == 0.0 { 0.0 } else { r };
if r.fract() == 0.0 { format!("{r:.0}") } else { format!("{r:.1}") }
}
/// The selected ellipse's handles as drawn at a zoom (page coordinates). Start shows once there
/// is an arc, Ratio once there is an arc or a ring.
#[derive(Clone, Debug, PartialEq, Serialize)]
pub struct ArcHandles {
pub id: NodeId,
pub sweep: [f64; 2],
pub start: Option<[f64; 2]>,
pub ratio: Option<[f64; 2]>,
}
impl ArcHandles {
/// Where handle `h` is, if shown.
pub fn at(&self, h: ArcHandle) -> Option<[f64; 2]> {
match h {
ArcHandle::Sweep => Some(self.sweep),
ArcHandle::Start => self.start,
ArcHandle::Ratio => self.ratio,
}
}
}
/// The live readout of an arc handle drag: the handle, its label and where the handle is now.
#[derive(Clone, Debug, PartialEq, Serialize)]
pub struct ArcHint {
pub handle: ArcHandle,
pub label: String,
pub at: [f64; 2],
}
/// Ellipses smaller than this on screen (either side, in pixels) show no arc handles.
pub const MIN_PX: f64 = 40.0;
/// How far inside the ellipse's edge the Sweep and Start handles sit (screen pixels), clear of
/// the resize edge.
const INSET_PX: f64 = 10.0;
/// How near the pointer must be to pick a handle (screen pixels).
const HIT_PX: f64 = 7.0;
/// The Sweep handle closes the ellipse when it comes this near the start (screen pixels).
const CLOSE_PX: f64 = 6.0;
/// Shift steps.
const SNAP_DEG: f64 = 15.0;
const SNAP_RATIO: f64 = 0.05;
/// The largest ratio `node.set`'s `arcInner` keeps.
const MAX_RATIO: f64 = 0.99;
/// An ellipse as its handles see it: its local → page transform, radii and arc.
struct Ellipse {
t: Affine,
rx: f64,
ry: f64,
arc: Arc,
}
impl Ellipse {
fn of(doc: &Document, id: NodeId) -> Option<Self> {
let n = doc.node(id)?;
let NodeKind::Ellipse { start, sweep, inner } = n.kind else { return None };
let (rx, ry) = (n.width / 2.0, n.height / 2.0);
let t = doc.world_transform(id);
let ok = rx.is_finite() && ry.is_finite() && rx > 0.0 && ry > 0.0 && t.determinant().is_finite() && t.determinant().abs() > 1e-12;
let arc = Arc { start: finite(start), sweep: finite(sweep).clamp(-TAU, TAU), inner: finite(inner).clamp(0.0, 1.0) };
ok.then_some(Self { t, rx, ry, arc })
}
/// The page point at angle `a`, `k` of the way from the centre to the edge.
fn point(&self, a: f64, k: f64) -> Point {
self.t * Point::new(self.rx * (1.0 + k * a.cos()), self.ry * (1.0 + k * a.sin()))
}
/// The edge point at angle `a`, pulled `INSET_PX` towards the centre (at most halfway).
fn inset(&self, a: f64, zoom: f64) -> Point {
let (e, c) = (self.point(a, 1.0), self.point(0.0, 0.0));
let d = c - e;
let len = d.hypot();
if len <= 1e-12 {
return e;
}
e + d * ((INSET_PX / zoom.max(1e-6)).min(len / 2.0) / len)
}
/// Where handle `h` is for `arc` at `zoom`.
fn handle(&self, h: ArcHandle, arc: Arc, zoom: f64) -> Point {
match h {
ArcHandle::Sweep => self.inset(arc.start + arc.sweep, zoom),
ArcHandle::Start => self.inset(arc.start, zoom),
ArcHandle::Ratio => self.point(arc.start + arc.sweep / 2.0, arc.inner),
}
}
/// Page point `p` on the unit circle the ellipse maps from: its angle and ratio.
fn unit(&self, p: Point) -> Vec2 {
let q = self.t.inverse() * p;
Vec2::new(q.x / self.rx - 1.0, q.y / self.ry - 1.0)
}
/// Its smaller side on screen, in pixels.
fn screen_px(&self, zoom: f64) -> f64 {
let o = self.t * Point::ZERO;
let w = (self.t * Point::new(2.0 * self.rx, 0.0) - o).hypot();
let h = (self.t * Point::new(0.0, 2.0 * self.ry) - o).hypot();
w.min(h) * zoom
}
}
fn finite(v: f64) -> f64 {
if v.is_finite() { v } else { 0.0 }
}
/// `zoom` if usable, else 1.
fn sane_zoom(zoom: f64) -> f64 {
if zoom.is_finite() && zoom > 0.0 { zoom } else { 1.0 }
}
fn pt(p: Point) -> [f64; 2] {
[p.x, p.y]
}
/// The ellipse whose arc handles show: the only selected layer, an unlocked visible ellipse at
/// least [`MIN_PX`] on screen.
fn target(doc: &Document, sel: &[NodeId], zoom: f64) -> Option<(NodeId, Ellipse)> {
let [id] = sel else { return None };
let n = doc.node(*id)?;
if n.locked || !n.visible || !zoom.is_finite() || zoom <= 0.0 {
return None;
}
let e = Ellipse::of(doc, *id)?;
(e.screen_px(zoom) >= MIN_PX).then_some((*id, e))
}
/// The arc of ellipse `id`.
pub fn arc_of(doc: &Document, id: NodeId) -> Option<Arc> {
Ellipse::of(doc, id).map(|e| e.arc)
}
/// The arc handles of the selection at `zoom` (screen pixels per page unit), if it is one ellipse
/// that shows them.
pub fn handles(doc: &Document, sel: &[NodeId], zoom: f64) -> Option<ArcHandles> {
let (id, e) = target(doc, sel, zoom)?;
let a = e.arc;
let full = a.full();
let at = |h| pt(e.handle(h, a, zoom));
Some(ArcHandles {
id,
sweep: at(ArcHandle::Sweep),
start: (!full).then(|| at(ArcHandle::Start)),
ratio: (!full || a.inner > 0.0).then(|| at(ArcHandle::Ratio)),
})
}
/// The arc handle under page point `p` (the nearest; Sweep first when two coincide), with its
/// ellipse.
pub fn hit(doc: &Document, sel: &[NodeId], p: Point, zoom: f64) -> Option<(NodeId, ArcHandle)> {
let hs = handles(doc, sel, zoom)?;
let tol = HIT_PX / zoom;
let mut best: Option<(ArcHandle, f64)> = None;
for h in [ArcHandle::Sweep, ArcHandle::Start, ArcHandle::Ratio] {
let Some(q) = hs.at(h) else { continue };
let d = (Point::new(q[0], q[1]) - p).hypot();
if d <= tol && best.is_none_or(|(_, bd)| d < bd) {
best = Some((h, d));
}
}
best.map(|(h, _)| (hs.id, h))
}
/// The sweep ending at `d` (radians clockwise from the start, 0..2π) nearest to `prev`: `d` or
/// `d - 2π`. A full turn either way is the same shape, so sweeps live on a circle 4π round and a
/// drag carries on smoothly through ±100%.
fn nearest_sweep(prev: f64, d: f64) -> f64 {
let dist = |s: f64| {
let x = (s - prev).rem_euclid(2.0 * TAU);
x.min(2.0 * TAU - x)
};
if dist(d - TAU) < dist(d) { d - TAU } else { d }
}
fn snap(v: f64, step: f64) -> f64 {
(v / step).round() * step
}
/// The arc after dragging handle `h` of ellipse `id` to page point `p`, from `arc` (the arc so
/// far in this drag, which keeps the sweep continuous). `shift` snaps.
pub fn drag(doc: &Document, id: NodeId, h: ArcHandle, arc: Arc, p: Point, shift: bool, zoom: f64) -> Option<Arc> {
let e = Ellipse::of(doc, id)?;
let zoom = sane_zoom(zoom);
let u = e.unit(p);
if !(u.x.is_finite() && u.y.is_finite()) {
return None;
}
let angle = u.y.atan2(u.x);
let mut out = arc;
match h {
ArcHandle::Sweep => {
let sweep = nearest_sweep(arc.sweep, (angle - arc.start).rem_euclid(TAU));
let sweep = if shift { snap(sweep, SNAP_DEG.to_radians()) } else { sweep };
// Back at the start (from the far side), it closes.
let gap = (e.point(arc.start + sweep, 1.0) - e.point(arc.start, 1.0)).hypot() * zoom;
out.sweep = if sweep.abs() > TAU / 2.0 && gap <= CLOSE_PX { TAU.copysign(sweep) } else { sweep.clamp(-TAU, TAU) };
}
ArcHandle::Start => {
let a = if shift { snap(angle, SNAP_DEG.to_radians()) } else { angle };
out.start = a.rem_euclid(TAU);
}
ArcHandle::Ratio => {
let r = u.hypot();
out.inner = (if shift { snap(r, SNAP_RATIO) } else { r }).clamp(0.0, MAX_RATIO);
// Dragged into the gap (clear of the centre), the other part of the ellipse shows.
let off_centre = (p - e.point(0.0, 0.0)).hypot() * zoom > HIT_PX;
if off_centre && !arc.covers(angle) {
out.start = (arc.start + arc.sweep).rem_euclid(TAU);
out.sweep = (TAU - arc.sweep.abs()).copysign(arc.sweep);
}
}
}
[out.start, out.sweep, out.inner].iter().all(|v| v.is_finite()).then_some(out)
}
/// Where handle `h` of ellipse `id` is for `arc` at `zoom` (the readout's anchor while dragging).
pub fn handle_at(doc: &Document, id: NodeId, h: ArcHandle, arc: Arc, zoom: f64) -> Option<[f64; 2]> {
Ellipse::of(doc, id).map(|e| pt(e.handle(h, arc, sane_zoom(zoom))))
}
/// `arc` as `node.set` props (degrees and a ratio).
pub fn props(arc: Arc) -> serde_json::Map<String, serde_json::Value> {
serde_json::Map::from_iter([
("arcStart".to_string(), serde_json::json!(arc.start.to_degrees())),
("arcSweep".to_string(), serde_json::json!(arc.sweep.to_degrees())),
("arcInner".to_string(), serde_json::json!(arc.inner)),
])
}