mirror of
https://github.com/storytold/figcraft.git
synced 2026-10-31 05:46:46 -06:00
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>
This commit is contained in:
@@ -88,7 +88,7 @@
|
||||
<tr>
|
||||
<td width="50%" valign="top">
|
||||
<h3>Vectors without compromise</h3>
|
||||
Real vector networks: the Pen and Pencil tools, vector edit mode with its own tools (pen that extends and branches paths, bend, paint bucket fills for any closed area, lasso, cut), point panel with handle mirroring and per-point corner radius, join, split and heal-on-delete, the shape builder (click or drag across the regions of overlapping shapes to merge them, Alt to remove them), boolean groups (union, subtract, intersect, exclude), flatten, outline stroke, arrowheads, dashes, per-side strokes and variable width strokes (width profile presets, and a Variable width tool that drags width points along the path). Rectangles with smooth (squircle) corners, polygons, stars and arcs.
|
||||
Real vector networks: the Pen and Pencil tools, vector edit mode with its own tools (pen that extends and branches paths, bend, paint bucket fills for any closed area, lasso, cut), point panel with handle mirroring and per-point corner radius, join, split and heal-on-delete, the shape builder (click or drag across the regions of overlapping shapes to merge them, Alt to remove them), boolean groups (union, subtract, intersect, exclude), flatten, outline stroke, arrowheads, dashes, per-side strokes and variable width strokes (width profile presets, and a Variable width tool that drags width points along the path). Rectangles with smooth (squircle) corners, polygons, stars, and arcs and rings shaped with on-canvas sweep, start and ratio handles.
|
||||
</td>
|
||||
<td width="50%" valign="top">
|
||||
<h3>Paints and effects</h3>
|
||||
|
||||
+7
-5
@@ -18,7 +18,7 @@ _Last updated: 2026-10-10 (day 2, evening: measuring, scale tool handles and sel
|
||||
| Infrastructure (crates, command registry, undo, renderer, formats, control channel, tests) | **~72%** |
|
||||
| Look & feel vs the incumbent's 2026 UI (measured side by side) | **~65%** |
|
||||
| Menu/command breadth (`docs/parity.md`) | **97.5%** (197/202) of the catalogued menu tree has a command |
|
||||
| Feature depth (weighted rubric below) | **~77%** |
|
||||
| Feature depth (weighted rubric below) | **~78%** |
|
||||
| Parity including interaction fidelity and hardening | **~45–50%** |
|
||||
| Time to **feature parity** | **~45–85 h** one agent · **~12–25 h** with 4–6 agents |
|
||||
| Time to **full parity** (feature parity + fidelity pass + hardening) | **~115–195 h** one agent · **~32–55 h** with 4–6 agents |
|
||||
@@ -52,8 +52,10 @@ dimension measured side by side.
|
||||
to move; the Design panel's X/Y/W/H and flip/rotate act on the points), point snapping while
|
||||
dragging points or handles and with both pens (to the points and segment midpoints of this and
|
||||
other vectors, the usual layer targets and the pixel grid, with guides and a marker on the point
|
||||
landed on; Ctrl/Cmd turns it off). *Left:* arc handles on the canvas, live boolean editing,
|
||||
one-sided widths. 4–8 h.
|
||||
landed on; Ctrl/Cmd turns it off), arc handles on the canvas (sweep, start and ratio with a
|
||||
live readout, Shift snapping, closing at the start, the other part from the gap; rotated and
|
||||
flipped ellipses, any zoom). *Left:* live boolean editing, one-sided widths, Design panel arc
|
||||
fields. 4–8 h.
|
||||
2. **Text editing depth**: *landed:* OpenType features panel, small caps, lists, title case,
|
||||
truncation/max lines, font picker with previews and recents, word/line selection, IME commit,
|
||||
inline IME composition (underlined at the caret, reflowing, candidate window at the caret; the
|
||||
@@ -271,7 +273,7 @@ Scores by behaviour, 0–100%. Hours are what remains (one agent).
|
||||
| Canvas & navigation | 6 | 78% | rulers and guides on rotated frames, visual QA at extreme zooms | 1–2 |
|
||||
| Selection & transform | 8 | 78% | measuring and transform handles checked side by side, rotated groups and mixed selections QA | 2–4 |
|
||||
| Layers panel | 5 | 86% | Tab to rename the next layer, visual QA of long lists | 1 |
|
||||
| Shapes & vector | 8 | 74% | live boolean editing, arc handles on canvas, shape builder on strokes and open paths, one-sided widths | 4–8 |
|
||||
| Shapes & vector | 8 | 76% | live boolean editing, Design panel arc fields, shape builder on strokes and open paths, one-sided widths | 4–8 |
|
||||
| Paint | 7 | 78% | a wide-gamut canvas (egui draws sRGB, so Display P3 colours outside sRGB clip on screen; exports keep them), images' own embedded colour profiles (images are taken as sRGB) | 1–3 |
|
||||
| Strokes | 3 | 82% | stroke styles | 1–2 |
|
||||
| Effects | 4 | 80% | inner shadows under strokes, progressive blur, noise and texture in SVG, glass tuned side by side | 3–5 |
|
||||
@@ -285,7 +287,7 @@ Scores by behaviour, 0–100%. Hours are what remains (one agent).
|
||||
| Dev mode / inspect | 2 | 84% | changes since ready for dev (compare view), focus view on one frame, annotation categories, visual QA | 1–2 |
|
||||
| Comments, versions, misc | 3 | 55% | plugins (WASM), multiplayer | 8–14 |
|
||||
|
||||
Weighted feature depth ≈ **77%** (self-graded, from the table).
|
||||
Weighted feature depth ≈ **78%** (self-graded, from the table).
|
||||
|
||||
## Milestones
|
||||
|
||||
|
||||
@@ -0,0 +1,299 @@
|
||||
//! 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)),
|
||||
])
|
||||
}
|
||||
@@ -0,0 +1,234 @@
|
||||
//! Arc handles on a selected ellipse: where they show, the sweep, start and ratio drags (Shift
|
||||
//! snapping, closing, the other part), rotated and flipped ellipses, zoom, and one undo step per
|
||||
//! drag.
|
||||
|
||||
use std::f64::consts::{PI, TAU};
|
||||
|
||||
use serde_json::{Value, json};
|
||||
|
||||
use crate::Session;
|
||||
use crate::arc_handles::{Arc, ArcHandle, MIN_PX, arc_of, drag, handles, hit};
|
||||
use crate::params::id_of;
|
||||
use crate::tools::{PointerEvent, PointerKind};
|
||||
use figcraft_doc::NodeId;
|
||||
use figcraft_geom::Point;
|
||||
|
||||
fn ex(s: &mut Session, cmd: &str, p: Value) -> Value {
|
||||
s.execute(cmd, &p).unwrap_or_else(|e| panic!("{cmd}: {e}"))
|
||||
}
|
||||
|
||||
/// A selected 100 × 100 ellipse at the origin.
|
||||
fn ellipse(s: &mut Session) -> NodeId {
|
||||
let id = id_of(&ex(s, "shape.ellipse", json!({"x": 0, "y": 0, "width": 100, "height": 100}))["id"]).unwrap();
|
||||
ex(s, "select.set", json!({"ids": [id]}));
|
||||
id
|
||||
}
|
||||
|
||||
fn near(a: f64, b: f64) -> bool {
|
||||
(a - b).abs() < 1e-6
|
||||
}
|
||||
|
||||
fn assert_at(p: Option<[f64; 2]>, x: f64, y: f64) {
|
||||
let p = p.unwrap_or_else(|| panic!("no handle where ({x}, {y}) was expected"));
|
||||
assert!((p[0] - x).abs() < 1e-6 && (p[1] - y).abs() < 1e-6, "{p:?} != ({x}, {y})");
|
||||
}
|
||||
|
||||
fn arc(s: &Session, id: NodeId) -> Arc {
|
||||
arc_of(s.doc().unwrap(), id).unwrap()
|
||||
}
|
||||
|
||||
fn ev(kind: PointerKind, p: (f64, f64), zoom: f64, shift: bool) -> PointerEvent {
|
||||
let mut e = PointerEvent::at(kind, p.0, p.1, zoom);
|
||||
e.shift = shift;
|
||||
e
|
||||
}
|
||||
|
||||
/// Drag with the Move tool from `a` through `via` to `to` at `zoom`; the overlay's readout seen
|
||||
/// mid-drag.
|
||||
fn drag_handle(s: &mut Session, a: (f64, f64), via: &[(f64, f64)], zoom: f64, shift: bool) -> Option<String> {
|
||||
s.pointer(&ev(PointerKind::Down, a, zoom, shift)).unwrap();
|
||||
let mut label = None;
|
||||
for p in via {
|
||||
s.pointer(&ev(PointerKind::Move, *p, zoom, shift)).unwrap();
|
||||
label = s.tool.overlay.arc.as_ref().map(|h| h.label.clone());
|
||||
}
|
||||
let last = via.last().copied().unwrap_or(a);
|
||||
s.pointer(&ev(PointerKind::Up, last, zoom, shift)).unwrap();
|
||||
assert!(s.tool.overlay.arc.is_none(), "the readout goes when the drag ends");
|
||||
label
|
||||
}
|
||||
|
||||
/// The point at `deg` degrees (clockwise from the right) `r` from the 100 × 100 ellipse's centre.
|
||||
fn at_angle(deg: f64, r: f64) -> (f64, f64) {
|
||||
let a = deg.to_radians();
|
||||
(50.0 + r * a.cos(), 50.0 + r * a.sin())
|
||||
}
|
||||
|
||||
fn undo_len(s: &Session) -> usize {
|
||||
s.ds().unwrap().history.undo.len()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_selected_ellipse_shows_its_sweep_handle_inside_the_right_edge() {
|
||||
let mut s = Session::new();
|
||||
let id = ellipse(&mut s);
|
||||
let doc = s.doc().unwrap().clone();
|
||||
let h = handles(&doc, &[id], 1.0).expect("handles");
|
||||
assert_eq!(h.id, id);
|
||||
assert_at(Some(h.sweep), 90.0, 50.0);
|
||||
assert!(h.start.is_none() && h.ratio.is_none(), "a full ellipse has only the sweep handle: {h:?}");
|
||||
// The inset is in screen pixels: closer to the edge zoomed in.
|
||||
assert_at(handles(&doc, &[id], 4.0).map(|h| h.sweep), 97.5, 50.0);
|
||||
// Too small on screen, several layers, or not an ellipse: none.
|
||||
assert!(handles(&doc, &[id], (MIN_PX - 1.0) / 100.0).is_none());
|
||||
let r = id_of(&ex(&mut s, "shape.rectangle", json!({"x": 200, "y": 0, "width": 100, "height": 100}))["id"]).unwrap();
|
||||
let doc = s.doc().unwrap().clone();
|
||||
assert!(handles(&doc, &[id, r], 1.0).is_none() && handles(&doc, &[r], 1.0).is_none());
|
||||
// Locked ellipses show none.
|
||||
ex(&mut s, "node.set", json!({"ids": [id], "props": {"locked": true}}));
|
||||
assert!(handles(s.doc().unwrap(), &[id], 1.0).is_none());
|
||||
// The session shows them with the Move tool only.
|
||||
ex(&mut s, "node.set", json!({"ids": [id], "props": {"locked": false}}));
|
||||
ex(&mut s, "select.set", json!({"ids": [id]}));
|
||||
assert!(s.arc_handles().is_some() && s.tool_info()["arcHandles"]["sweep"].is_array());
|
||||
ex(&mut s, "tool.set", json!({"tool": "scale"}));
|
||||
assert!(s.arc_handles().is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn dragging_the_sweep_handle_up_opens_a_positive_arc_in_one_undo_step() {
|
||||
let mut s = Session::new();
|
||||
let id = ellipse(&mut s);
|
||||
let undo = undo_len(&s);
|
||||
// Hovering it highlights it.
|
||||
s.pointer(&ev(PointerKind::Hover, (90.0, 50.0), 1.0, false)).unwrap();
|
||||
assert_eq!(s.tool.overlay.arc_hover, Some(ArcHandle::Sweep));
|
||||
// Up from the right edge, round to the top: three quarters of a turn.
|
||||
let label = drag_handle(&mut s, (90.0, 50.0), &[at_angle(-20.0, 40.0), at_angle(-60.0, 40.0), at_angle(-90.0, 40.0)], 1.0, false);
|
||||
assert_eq!(label.as_deref(), Some("Sweep 75%"));
|
||||
let a = arc(&s, id);
|
||||
assert!(near(a.start, 0.0) && near(a.sweep, 1.5 * PI) && near(a.inner, 0.0), "{a:?}");
|
||||
assert_eq!(undo_len(&s), undo + 1, "one undo step");
|
||||
// Now the start and ratio handles show; the sweep handle sits at the arc's end.
|
||||
let h = handles(s.doc().unwrap(), &[id], 1.0).unwrap();
|
||||
assert_at(Some(h.sweep), 50.0, 10.0);
|
||||
assert_at(h.start, 90.0, 50.0);
|
||||
assert_at(h.ratio, 50.0, 50.0);
|
||||
ex(&mut s, "edit.undo", json!({}));
|
||||
assert!(near(arc(&s, id).sweep, TAU));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn dragging_down_gives_a_negative_sweep_and_back_to_the_start_closes_it() {
|
||||
let mut s = Session::new();
|
||||
let id = ellipse(&mut s);
|
||||
drag_handle(&mut s, (90.0, 50.0), &[at_angle(20.0, 40.0), at_angle(60.0, 40.0), at_angle(90.0, 40.0)], 1.0, false);
|
||||
assert!(near(arc(&s, id).sweep, -1.5 * PI), "{:?}", arc(&s, id));
|
||||
// Back up round to the start: it snaps shut there, then opens the other way.
|
||||
let label = drag_handle(&mut s, (50.0, 90.0), &[at_angle(45.0, 40.0), at_angle(10.0, 40.0), at_angle(1.0, 40.0)], 1.0, false);
|
||||
assert_eq!(label.as_deref(), Some("Sweep -100%"));
|
||||
assert!(near(arc(&s, id).sweep, -TAU));
|
||||
drag_handle(&mut s, (90.0, 50.0), &[at_angle(-10.0, 40.0), at_angle(-45.0, 40.0)], 1.0, false);
|
||||
assert!(near(arc(&s, id).sweep, 1.75 * PI), "through a full turn the sweep carries on: {:?}", arc(&s, id));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn shift_snaps_the_sweep_and_start_to_15_degrees_and_the_ratio_to_5_percent() {
|
||||
let mut s = Session::new();
|
||||
let id = ellipse(&mut s);
|
||||
drag_handle(&mut s, (90.0, 50.0), &[at_angle(-30.0, 40.0), at_angle(-50.0, 40.0)], 1.0, true);
|
||||
assert!(near(arc(&s, id).sweep, 315f64.to_radians()), "{:?}", arc(&s, id));
|
||||
// The start handle turns the arc and keeps its sweep.
|
||||
drag_handle(&mut s, (90.0, 50.0), &[at_angle(40.0, 40.0), at_angle(52.0, 40.0)], 1.0, true);
|
||||
let a = arc(&s, id);
|
||||
assert!(near(a.start, 45f64.to_radians()) && near(a.sweep, 315f64.to_radians()), "{a:?}");
|
||||
let label = drag_handle(&mut s, (50.0, 50.0), &[at_angle(120.0, 20.0), at_angle(120.0, 31.0)], 1.0, true);
|
||||
assert_eq!(label.as_deref(), Some("Ratio 60%"));
|
||||
assert!(near(arc(&s, id).inner, 0.6));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_ratio_handle_makes_a_ring_and_shows_the_other_part_from_the_gap() {
|
||||
let mut s = Session::new();
|
||||
let id = ellipse(&mut s);
|
||||
// A three-quarter arc from the right edge, clockwise round to the top.
|
||||
ex(&mut s, "node.set", json!({"ids": [id], "props": {"arcSweep": 270}}));
|
||||
let label = drag_handle(&mut s, (50.0, 50.0), &[at_angle(135.0, 10.0), at_angle(135.0, 25.0)], 1.0, false);
|
||||
assert_eq!(label.as_deref(), Some("Ratio 50%"));
|
||||
let a = arc(&s, id);
|
||||
assert!(near(a.inner, 0.5) && near(a.sweep, 1.5 * PI), "{a:?}");
|
||||
// On a ring the ratio handle sits on the inner edge, halfway round the arc.
|
||||
let h = handles(s.doc().unwrap(), &[id], 1.0).unwrap();
|
||||
let (x, y) = at_angle(135.0, 25.0);
|
||||
assert_at(h.ratio, x, y);
|
||||
// Into the gap (the top right quarter): the quarter shows instead.
|
||||
drag_handle(&mut s, (x, y), &[at_angle(-45.0, 30.0)], 1.0, false);
|
||||
let a = arc(&s, id);
|
||||
assert!(near(a.start, 1.5 * PI) && near(a.sweep, 0.5 * PI) && near(a.inner, 0.6), "{a:?}");
|
||||
// A full ring keeps its ratio handle (and no start handle).
|
||||
ex(&mut s, "node.set", json!({"ids": [id], "props": {"arcSweep": 360, "arcStart": 0}}));
|
||||
let h = handles(s.doc().unwrap(), &[id], 1.0).unwrap();
|
||||
assert!(h.start.is_none() && h.ratio.is_some(), "{h:?}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn handles_follow_rotated_and_flipped_ellipses() {
|
||||
let mut s = Session::new();
|
||||
let id = ellipse(&mut s);
|
||||
ex(&mut s, "node.set", json!({"ids": [id], "props": {"rotation": 30}}));
|
||||
ex(&mut s, "object.flipH", json!({"ids": [id]}));
|
||||
let doc = s.doc().unwrap().clone();
|
||||
let w = doc.world_transform(id);
|
||||
let local = |x: f64, y: f64| {
|
||||
let p = w * Point::new(x, y);
|
||||
(p.x, p.y)
|
||||
};
|
||||
let (sx, sy) = local(90.0, 50.0);
|
||||
assert_at(handles(&doc, &[id], 1.0).map(|h| h.sweep), sx, sy);
|
||||
assert_eq!(hit(&doc, &[id], Point::new(sx, sy), 1.0), Some((id, ArcHandle::Sweep)));
|
||||
// Dragged to the ellipse's own top (in its local space): three quarters, as unrotated.
|
||||
drag_handle(&mut s, (sx, sy), &[local(80.0, 20.0), local(50.0, 10.0)], 1.0, false);
|
||||
assert!(near(arc(&s, id).sweep, 1.5 * PI), "{:?}", arc(&s, id));
|
||||
assert_at(handles(s.doc().unwrap(), &[id], 1.0).map(|h| h.sweep), local(50.0, 10.0).0, local(50.0, 10.0).1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn zoomed_out_the_handles_still_pick_and_escape_cancels_a_drag() {
|
||||
let mut s = Session::new();
|
||||
let id = ellipse(&mut s);
|
||||
// At 50% the inset is 20 page units and the pick distance 14.
|
||||
let doc = s.doc().unwrap().clone();
|
||||
assert_at(handles(&doc, &[id], 0.5).map(|h| h.sweep), 80.0, 50.0);
|
||||
assert_eq!(hit(&doc, &[id], Point::new(80.0, 62.0), 0.5), Some((id, ArcHandle::Sweep)));
|
||||
assert_eq!(hit(&doc, &[id], Point::new(80.0, 62.0), 2.0), None);
|
||||
let undo = undo_len(&s);
|
||||
s.pointer(&ev(PointerKind::Down, (80.0, 50.0), 0.5, false)).unwrap();
|
||||
s.pointer(&ev(PointerKind::Move, at_angle(-90.0, 30.0), 0.5, false)).unwrap();
|
||||
assert!(near(arc(&s, id).sweep, 1.5 * PI));
|
||||
s.escape().unwrap();
|
||||
assert!(near(arc(&s, id).sweep, TAU) && undo_len(&s) == undo, "escape restores the ellipse");
|
||||
// A click on a handle without moving changes nothing.
|
||||
drag_handle(&mut s, (80.0, 50.0), &[], 0.5, false);
|
||||
assert_eq!(undo_len(&s), undo);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn drag_math_survives_junk() {
|
||||
let mut s = Session::new();
|
||||
let id = ellipse(&mut s);
|
||||
let doc = s.doc().unwrap().clone();
|
||||
let a = arc(&s, id);
|
||||
for p in [Point::new(f64::NAN, 0.0), Point::new(f64::INFINITY, 1.0), Point::new(50.0, 50.0)] {
|
||||
for h in [ArcHandle::Sweep, ArcHandle::Start, ArcHandle::Ratio] {
|
||||
for zoom in [0.0, -1.0, f64::NAN, 1.0, 1e9] {
|
||||
if let Some(out) = drag(&doc, id, h, a, p, true, zoom) {
|
||||
assert!(out.start.is_finite() && out.sweep.abs() <= TAU && (0.0..=0.99).contains(&out.inner), "{out:?}");
|
||||
}
|
||||
assert!(hit(&doc, &[id], p, zoom).is_none_or(|(i, _)| i == id));
|
||||
}
|
||||
}
|
||||
}
|
||||
// A zero-sized ellipse has no handles.
|
||||
ex(&mut s, "node.set", json!({"ids": [id], "props": {"width": 0}}));
|
||||
assert!(handles(s.doc().unwrap(), &[id], 1.0).is_none());
|
||||
}
|
||||
@@ -9,6 +9,7 @@
|
||||
|
||||
pub mod al_keys;
|
||||
pub mod annotations;
|
||||
pub mod arc_handles;
|
||||
pub mod catalog;
|
||||
pub mod cmd;
|
||||
pub mod codegen;
|
||||
@@ -946,6 +947,8 @@ mod al_nesting_tests;
|
||||
#[cfg(test)]
|
||||
mod annotations_tests;
|
||||
#[cfg(test)]
|
||||
mod arc_handles_tests;
|
||||
#[cfg(test)]
|
||||
mod autosave_tests;
|
||||
#[cfg(test)]
|
||||
mod binding_lists_tests;
|
||||
|
||||
@@ -10,6 +10,7 @@ use figcraft_geom::{Affine, Point, Rect, Vec2};
|
||||
use serde::Serialize;
|
||||
use serde_json::json;
|
||||
|
||||
use crate::arc_handles::{ArcHandle, ArcHint};
|
||||
use crate::cmd::arrange::move_world;
|
||||
use crate::cmd::create::frame_at;
|
||||
use crate::cmd::edit::roots;
|
||||
@@ -242,6 +243,10 @@ pub struct Overlay {
|
||||
pub measure_box: Option<[f64; 4]>,
|
||||
/// Scale tool drag: the factor so far and the point that stays put.
|
||||
pub scale: Option<ScaleHint>,
|
||||
/// The selected ellipse's arc handle under the pointer, and the readout while one is dragged
|
||||
/// (see [`crate::arc_handles`]).
|
||||
pub arc_hover: Option<ArcHandle>,
|
||||
pub arc: Option<ArcHint>,
|
||||
}
|
||||
|
||||
/// The Scale tool's live readout while dragging a handle.
|
||||
@@ -314,6 +319,12 @@ pub(crate) enum Gesture {
|
||||
},
|
||||
/// Vector edit mode gestures (handled in `vectoredit`).
|
||||
Vector(crate::vectoredit::VGesture),
|
||||
/// Dragging an ellipse's arc handle: the arc so far (see [`crate::arc_handles`]).
|
||||
Arc {
|
||||
id: NodeId,
|
||||
handle: ArcHandle,
|
||||
arc: crate::arc_handles::Arc,
|
||||
},
|
||||
/// Shape builder: regions collected so far (handled in `shapebuilder`).
|
||||
ShapeBuild {
|
||||
picked: Vec<usize>,
|
||||
@@ -759,7 +770,9 @@ impl Session {
|
||||
let mut hover = None;
|
||||
let mut guides = Vec::new();
|
||||
let mut measure_box = None;
|
||||
if matches!(tool, ToolId::Move | ToolId::Scale) {
|
||||
let arc_hover = if self.arc_handles_shown() { crate::arc_handles::hit(&doc, &sel, ev.p(), ev.zoom).map(|(_, h)| h) } else { None };
|
||||
self.tool.overlay.arc_hover = arc_hover;
|
||||
if matches!(tool, ToolId::Move | ToolId::Scale) && arc_hover.is_none() {
|
||||
if let Some(h) = handle_at(&doc, &sel, ev.p(), ev.zoom) {
|
||||
cursor = resize_cursor(&doc, &sel, h);
|
||||
} else {
|
||||
@@ -814,7 +827,17 @@ impl Session {
|
||||
}
|
||||
textedit::end(self)?;
|
||||
}
|
||||
// Handles first.
|
||||
// Handles first: a selected ellipse's arc handles (drawn over the box), then the box's.
|
||||
if self.arc_handles_shown()
|
||||
&& let Some((id, handle)) = crate::arc_handles::hit(&doc, &sel, ev.p(), ev.zoom)
|
||||
&& let Some(arc) = crate::arc_handles::arc_of(&doc, id)
|
||||
{
|
||||
self.begin_interaction("Arc")?;
|
||||
self.tool.gesture = Gesture::Arc { id, handle, arc };
|
||||
self.tool.overlay.arc =
|
||||
crate::arc_handles::handle_at(&doc, id, handle, arc, ev.zoom).map(|at| ArcHint { handle, label: arc.label(handle), at });
|
||||
return Ok(());
|
||||
}
|
||||
if let Some(h) = handle_at(&doc, &sel, ev.p(), ev.zoom) {
|
||||
let ids = roots(&doc, &sel);
|
||||
if let Handle::Rotate(_) = h {
|
||||
@@ -1052,6 +1075,11 @@ impl Session {
|
||||
self.tool.gesture = g;
|
||||
return self.draw_drag(ev, start, id, parent, &targets);
|
||||
}
|
||||
Gesture::Arc { id, handle, arc } => {
|
||||
let (id, handle, arc) = (*id, *handle, *arc);
|
||||
self.tool.gesture = g;
|
||||
return self.arc_drag(ev, id, handle, arc);
|
||||
}
|
||||
Gesture::Pan { sx, sy, cx, cy } => {
|
||||
let ds = self.ds_mut()?;
|
||||
let z = ds.view.zoom.max(1e-6);
|
||||
@@ -1095,6 +1123,19 @@ impl Session {
|
||||
res
|
||||
}
|
||||
|
||||
/// An arc handle drag: the new arc, written through `node.set`'s keys.
|
||||
fn arc_drag(&mut self, ev: &PointerEvent, id: NodeId, handle: ArcHandle, arc: crate::arc_handles::Arc) -> Result<()> {
|
||||
let doc = self.doc()?.clone();
|
||||
let Some(next) = crate::arc_handles::drag(&doc, id, handle, arc, ev.p(), ev.shift, ev.zoom) else { return Ok(()) };
|
||||
let props = crate::arc_handles::props(next);
|
||||
self.preview(|doc, _| crate::cmd::props::apply("node.set", doc, id, &props))?;
|
||||
self.tool.gesture = Gesture::Arc { id, handle, arc: next };
|
||||
let at = crate::arc_handles::handle_at(self.doc()?, id, handle, next, ev.zoom);
|
||||
self.tool.overlay.arc = at.map(|at| ArcHint { handle, label: next.label(handle), at });
|
||||
self.tool.overlay.arc_hover = Some(handle);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn resize_drag(&mut self, ev: &PointerEvent, start: Point, r: &ResizeStart, scale_tool: bool) -> Result<()> {
|
||||
let page = self.page()?;
|
||||
let snap = self.ds().map(|d| d.view.snap_objects).unwrap_or(true) && !ev.cmd;
|
||||
@@ -1312,6 +1353,7 @@ impl Session {
|
||||
self.tool.overlay.marquee = None;
|
||||
self.tool.overlay.guides.clear();
|
||||
self.tool.overlay.scale = None;
|
||||
self.tool.overlay.arc = None;
|
||||
self.tool.overlay.measure_box = None;
|
||||
self.tool.overlay.insert_line = None;
|
||||
self.tool.overlay.drop_cell = None;
|
||||
@@ -1360,7 +1402,7 @@ impl Session {
|
||||
let _ = ev;
|
||||
}
|
||||
}
|
||||
Gesture::Resize { .. } | Gesture::Rotate { .. } => self.commit_interaction()?,
|
||||
Gesture::Resize { .. } | Gesture::Rotate { .. } | Gesture::Arc { .. } => self.commit_interaction()?,
|
||||
Gesture::Draw { start, id, parent, .. } => {
|
||||
let tiny = (ev.p() - start).hypot() * ev.zoom < 3.0;
|
||||
if tiny || id.is_none() {
|
||||
@@ -1730,9 +1772,24 @@ impl Session {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Whether the Move tool shows arc handles now (not while another gesture runs, nor in vector
|
||||
/// or text edit mode).
|
||||
fn arc_handles_shown(&self) -> bool {
|
||||
let arc_drag = matches!(self.tool.gesture, Gesture::Arc { .. });
|
||||
self.tool.tool == ToolId::Move
|
||||
&& (arc_drag || !self.tool.busy())
|
||||
&& self.ds().is_ok_and(|ds| ds.vector_edit.is_none() && ds.text_edit.is_none())
|
||||
}
|
||||
|
||||
/// The selected ellipse's arc handles as the canvas shows them at the current zoom.
|
||||
pub fn arc_handles(&self) -> Option<crate::arc_handles::ArcHandles> {
|
||||
let ds = self.ds().ok()?;
|
||||
self.arc_handles_shown().then(|| crate::arc_handles::handles(&ds.doc, &ds.selection, ds.view.zoom)).flatten()
|
||||
}
|
||||
|
||||
/// Snapshot of tool state for the UI / inspect.
|
||||
pub fn tool_info(&self) -> serde_json::Value {
|
||||
json!({"tool": self.tool.tool.id(), "busy": self.tool.busy(), "overlay": self.tool.overlay})
|
||||
json!({"tool": self.tool.tool.id(), "busy": self.tool.busy(), "overlay": self.tool.overlay, "arcHandles": self.arc_handles()})
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -703,6 +703,7 @@ fn overlays(app: &FigApp, painter: &egui::Painter, t: &Tokens) {
|
||||
label = format!("{label} · {}x", fmt(hint.factor.abs()));
|
||||
}
|
||||
size_label = transform_box(app, painter, b.map(|p| app.to_screen(p.x, p.y)), col, sel.len() > 1, if is_line { 2 } else { 4 }, label);
|
||||
arc_overlay(app, painter, col);
|
||||
if app.session.tool.tool == ToolId::Scale {
|
||||
// The point that stays put: the dragged pivot, else the anchor.
|
||||
let pivot = scale
|
||||
@@ -833,15 +834,42 @@ fn transform_box(app: &FigApp, painter: &egui::Painter, q: [Pos2; 4], col: Color
|
||||
if app.session.prefs.show_dimensions {
|
||||
let bottom = q.iter().map(|p| p.y).fold(f32::MIN, f32::max);
|
||||
let cx = q.iter().map(|p| p.x).sum::<f32>() / 4.0;
|
||||
let galley = painter.layout_no_wrap(label, semibold(11.0), Color32::WHITE);
|
||||
let r = Rect::from_center_size(pos2(cx, bottom + 8.0 + galley.size().y / 2.0 + 3.0), galley.size() + vec2(8.0, 4.0));
|
||||
painter.rect_filled(r, CornerRadius::same(3), col);
|
||||
painter.galley(r.min + vec2(4.0, 2.0), galley, Color32::WHITE);
|
||||
return Some(r);
|
||||
return Some(pill(painter, pos2(cx, bottom + 9.0), Align2::CENTER_TOP, label, col));
|
||||
}
|
||||
None
|
||||
}
|
||||
|
||||
/// A canvas readout: white `text` on a `col` pill placed at `at` by `align` (its box returned).
|
||||
fn pill(painter: &egui::Painter, at: Pos2, align: Align2, text: String, col: Color32) -> Rect {
|
||||
let galley = painter.layout_no_wrap(text, semibold(11.0), Color32::WHITE);
|
||||
let r = align.anchor_size(at, galley.size() + vec2(8.0, 4.0));
|
||||
painter.rect_filled(r, CornerRadius::same(3), col);
|
||||
painter.galley(r.min + vec2(4.0, 2.0), galley, Color32::WHITE);
|
||||
r
|
||||
}
|
||||
|
||||
/// The selected ellipse's arc handles (the hovered or dragged one larger, Start with a dot inside)
|
||||
/// and, while one is dragged, its readout beside it.
|
||||
fn arc_overlay(app: &FigApp, painter: &egui::Painter, col: Color32) {
|
||||
use figcraft_engine::arc_handles::ArcHandle;
|
||||
let Some(hs) = app.session.arc_handles() else { return };
|
||||
let ov = &app.session.tool.overlay;
|
||||
for h in [ArcHandle::Ratio, ArcHandle::Start, ArcHandle::Sweep] {
|
||||
let Some([x, y]) = hs.at(h) else { continue };
|
||||
let c = app.to_screen(x, y);
|
||||
let r = if ov.arc_hover == Some(h) { 5.0 } else { 4.0 };
|
||||
painter.circle_filled(c, r, Color32::WHITE);
|
||||
painter.circle_stroke(c, r, stroke(col, 1.0));
|
||||
if h == ArcHandle::Start {
|
||||
painter.circle_filled(c, 1.5, col);
|
||||
}
|
||||
}
|
||||
if let Some(hint) = &ov.arc {
|
||||
let c = app.to_screen(hint.at[0], hint.at[1]);
|
||||
pill(painter, c + vec2(12.0, 12.0), Align2::LEFT_TOP, hint.label.clone(), col);
|
||||
}
|
||||
}
|
||||
|
||||
/// Variable width tool: each width point on the path with a bar across the stroke to its two
|
||||
/// width handles (the selected point filled), and a faint preview where a drag would add one.
|
||||
fn width_overlay(app: &FigApp, painter: &egui::Painter, doc: &Document, ve: &figcraft_engine::vectoredit::VectorEdit, zoom: f64) {
|
||||
|
||||
@@ -62,7 +62,8 @@ that `node.set` accepts are listed in [mcp.md](mcp.md#conventions-agents-must-kn
|
||||
|
||||
### `ui.inspect {}`
|
||||
|
||||
UI state: panels and tabs, current tool, canvas rect, dialogs, view, timings (`frameMs`,
|
||||
UI state: panels and tabs, current tool (`tool`: its id, overlay hints and, for a selected
|
||||
ellipse, its arc handles `arcHandles`; see [mcp.md](mcp.md#measuring-the-scale-tool-arc-handles-and-selecting)), canvas rect, dialogs, view, timings (`frameMs`,
|
||||
`renderMs`) and what the last canvas render had to do (`canvas.renderWork`: `full`, `partial` with
|
||||
the `pixels` re-rendered after a pan or an edit, or `reused`). While a prototype plays, `presenter`
|
||||
(full window) and `preview` (the preview pane) hold the player's state: the `current` frame and its
|
||||
|
||||
+12
-1
@@ -478,7 +478,7 @@ first, the last one is on top (the Design panel lists them the other way round,
|
||||
coordinates: they move with the frame, span only it, are copied with it and snap the layers inside
|
||||
it. In the app, a guide dragged out of a ruler and dropped on a frame becomes that frame's guide.
|
||||
|
||||
### Measuring, the Scale tool and selecting
|
||||
### Measuring, the Scale tool, arc handles and selecting
|
||||
|
||||
- **Measure** (what Alt/Option-hover shows): `measure.distances {ids? (default the selection), to?
|
||||
(a layer; default the selection's parent)}` returns `{lines: [{x0, y0, x1, y1, label, dashed}]}`
|
||||
@@ -497,6 +497,17 @@ it. In the app, a guide dragged out of a ruler and dropped on a frame becomes th
|
||||
lies on the side being dragged, the opposite handle; `alt: true` scales from the centre), with the
|
||||
factor and pivot in `tool.overlay.scale` while dragging. Without
|
||||
dragging, `transform.scale {ids?, factor, cx?, cy?}` does the same numerically.
|
||||
- **Arc handles**: with the Move tool, a single selected ellipse (at least 40 px on screen, unlocked)
|
||||
shows its arc handles, listed by `inspect_ui` under `tool.arcHandles` (`{id, sweep: [x, y],
|
||||
start, ratio}` in page coordinates; `start` once there is an arc, `ratio` once there is an arc or
|
||||
a ring). Drag them with `pointer`. **Sweep** starts inside the right edge. Dragged round the
|
||||
ellipse, it opens an arc: positive up from the right edge, negative down. It closes again back at
|
||||
the start. **Start** turns the arc and keeps its sweep. **Ratio** (the centre, or the inner edge
|
||||
halfway round a ring) drags out to make a ring, and into the gap to show the other part.
|
||||
`shift: true` snaps angles to 15° steps and the ratio to 5% steps. Each drag is one undo step
|
||||
and writes `node.set`'s `arcStart` / `arcSweep` (degrees) and `arcInner` (0–0.99).
|
||||
`tool.overlay.arc` holds the readout while dragging (`{handle, label: "Sweep 75%", at}`), and
|
||||
`tool.overlay.arc_hover` holds the handle under the pointer.
|
||||
- **Select**: `select.layersAt {x, y}` lists every layer under a page point, front-most first with its
|
||||
depth (the context menu's Select layer); `select.set`, `select.children` / `select.parent` (Enter
|
||||
/ Shift+Enter), `select.next` / `select.previous` (Tab / Shift+Tab). With `pointer`, `cmd: true`
|
||||
|
||||
Reference in New Issue
Block a user