Merge pull request #820 from storytold/fix/812-ellipse-pie

Part of #812: an ellipse's Pie Start and End Angle and Invert Pie
This commit is contained in:
BFlat 2026-10-10 03:00:32 +07:00 committed by GitHub
commit 4cf912fa94
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
15 changed files with 175 additions and 3 deletions

View File

@ -129,7 +129,7 @@ grade by behaviour against `plan/illustrator/`, not by whether a menu item exist
Clear deletes individual selected compound-path members without deleting their siblings, and explicit object ids work without a selection; Cut still removes the same objects it copies (#786).
- **Saved selections:** Select ▸ Save Selection… asks for a name and keeps the selection in the document (saved with the file, undoable, up to 25); each one is listed at the bottom of the Select menu and a click selects its objects again; Edit Selection… lists them to rename or delete, applied together on OK. A file's saved selections are capped and checked against its objects when it opens. Not yet: Update Selection and the Layers panel's saved-selection menu.
- **Drawing:** Pen, Curvature, anchor tools, Pencil, Paintbrush, Blob Brush, Smooth, Path Eraser, Join. Direction handles dragged with Direct Selection or the Anchor Point tool snap to smart guides, and Shift keeps them at 45° steps from their anchor. The Pen continues any open path from the end clicked, and while drawing, a click on an end of another open path joins the two into one (#776). Every point a drawing tool places snaps to Smart Guides through one snapper (#506): the Pen's and Curvature tool's anchors, the Pencil's ends, the shape tools' start and dragged corner or radius (Rectangle, Rounded Rectangle, Ellipse, Polygon, Star, Line Segment, Arc, Spiral, the grids), the Type tool's click and frame, a new artboard's corners.
- **Shapes:** all shape tools (including Flare) and the line, arc, spiral and grid tools. Space held while dragging out a shape, line, arc, spiral or grid moves it at its current size. While a grid is dragged out, Up/Down change its rows (concentric dividers) and Left/Right its columns (radial dividers).
- **Shapes:** all shape tools (including Flare) and the line, arc, spiral and grid tools. Space held while dragging out a shape, line, arc, spiral or grid moves it at its current size. While a grid is dragged out, Up/Down change its rows (concentric dividers) and Left/Right its columns (radial dividers). An ellipse's Pie Start and End Angle and Invert Pie (Transform and Properties panels' Ellipse Properties, `object.setLiveShape {pieStart, pieEnd, invertPie}`) cut it as a pie (#812; the on-canvas pie widget is still to come).
- **Cutting:** Eraser, Scissors, Knife, Mirror & Cut, Line Cut and Rectangle Cut (real geometry, compound paths keep their holes).
- **Transform:** Rotate, Reflect, Scale, Shear (click or Alt-click snaps the reference point to anchors and centres; Alt-click opens the tool's dialog, and a drag with Alt down at the release, from the press or pressed mid-drag, transforms a copy), Reshape, Free Transform (free and perspective distort and shear, switched by the keys held during a handle drag).
- **Live Corners:** drag a live rectangle's corner widgets (Selection or Direct Selection) to round all corners, with a radius readout; with Direct Selection, select corner anchors to show and round just those corners (#381). Each corner keeps its own radius and kind (Round, Inverted Round, Chamfer): Alt-click a widget to cycle the kind, double-click it for the Corners dialog, and the Properties and Transform radius fields set the selected corners (`object.setLiveShape {corners, radius, kind}`). Corners stay circular when the rectangle is scaled unevenly or along its own sides when rotated (the radius keeps its size, or scales by the mean scale with Scale Corners on); only a shear distorts them. On a rectangle that isn't square every corner is the same circle: a radius past half the shorter side stops there for all of them, the widgets sit on the corners as drawn and turn the edited corners red at that limit, CSS export writes the drawn radii, and rectangles saved stretched by older versions get circular corners when their corners are next set (#442). Live Corners work on any path's corners too, as on a rectangle's (#511): a star's, a live polygon's (which also shows them to the Selection tool, has a Corner Radius field in the Properties and Transform panels and keeps its radius as its sides change) and a pen path's, wherever two straight sides meet at an anchor. Direct Selection shows the widgets of every corner, or of the corners holding selected anchors; each corner is a circle tangent to both sides, drawn no larger than takes its cut halfway along its shorter side; a path keeps its uncut outline so its corners stay editable, and the same command, dialog, angle preference and View › Hide Corner Widget apply.

View File

@ -146,7 +146,7 @@ impl LiveShape {
LiveShape::Rectangle { w, h, radii, kinds, xf } => {
shapes::rectangle_with_corners(Rect::new(0.0, 0.0, *w, *h), *radii, *kinds).transformed(*xf)
}
LiveShape::Ellipse { w, h, xf, .. } => shapes::ellipse(Rect::new(0.0, 0.0, *w, *h)).transformed(*xf),
LiveShape::Ellipse { w, h, pie, xf } => shapes::ellipse_pie(Rect::new(0.0, 0.0, *w, *h), pie.0, pie.1).transformed(*xf),
LiveShape::Polygon { radii, kinds, .. } => cut_corners(&self.polygon_outline(), radii, kinds).0,
LiveShape::Line { a, b } => shapes::line(*a, *b),
LiveShape::Path { base, radii, kinds } => cut_corners(base, radii, kinds).0,

View File

@ -196,7 +196,7 @@ pub fn specs() -> Vec<CommandSpec> {
"Live Shape Properties",
[],
None,
"{id?, ids?, radius?: pt, kind?: \"round\"|\"invertedRound\"|\"chamfer\", corners?: [i…], sides?: n} (Live Corners on any path: radius and kind set its corners (anchors without handles between two straight sides): `corners` (anchor indices of the path with its corners uncut, counting every subpath's anchors in order: a rectangle's 0 top-left, 1 top-right, 2 bottom-right, 3 bottom-left; a polygon's from the first vertex clockwise), else the corners with a Direct-Selected anchor, else every corner; each radius is drawn no larger than half the corner's shorter side allows; a path that isn't a live shape keeps its uncut outline so its corners stay editable, and is plain again once none is cut; sides: a polygon's, which keep the radius they shared)",
"{id?, ids?, radius?: pt, kind?: \"round\"|\"invertedRound\"|\"chamfer\", corners?: [i…], sides?: n, pieStart?, pieEnd?: degrees (an ellipse's pie, counterclockwise from 3 o'clock; 0 to 360 is the whole ellipse), invertPie?: true (swaps them: the other part of the ellipse)} (Live Corners on any path: radius and kind set its corners (anchors without handles between two straight sides): `corners` (anchor indices of the path with its corners uncut, counting every subpath's anchors in order: a rectangle's 0 top-left, 1 top-right, 2 bottom-right, 3 bottom-left; a polygon's from the first vertex clockwise), else the corners with a Direct-Selected anchor, else every corner; each radius is drawn no larger than half the corner's shorter side allows; a path that isn't a live shape keeps its uncut outline so its corners stay editable, and is plain again once none is cut; sides: a polygon's, which keep the radius they shared)",
has_selection,
set_live_shape
),
@ -1095,6 +1095,19 @@ fn set_live_shape(s: &mut Session, p: &Value) -> Result<Value> {
.transpose()?;
let radius = p.get("radius").and_then(Value::as_f64);
let sides = p.get("sides").and_then(Value::as_u64);
// An ellipse's pie (Ellipse Properties: Pie Start and End Angle, degrees; Invert Pie swaps them).
let angle = |k: &str| -> Result<Option<f64>> {
match p.get(k) {
None | Some(Value::Null) => Ok(None),
Some(v) => v
.as_f64()
.filter(|a| a.is_finite())
.map(|a| Some(a.rem_euclid(360.0)))
.ok_or_else(|| bad("object.setLiveShape", format!("`{k}` must be an angle in degrees, not {v}"))),
}
};
let (pie_start, pie_end) = (angle("pieStart")?, angle("pieEnd")?);
let invert = p.get("invertPie").and_then(Value::as_bool) == Some(true);
s.edit("Live Shape", |d, sel| {
for id in &ids {
let Some(NodeKind::Path { path, live, .. }) = d.node_mut(*id).map(|n| &mut n.kind) else { continue };
@ -1102,6 +1115,19 @@ fn set_live_shape(s: &mut Session, p: &Value) -> Result<Value> {
l.set_sides(n);
*path = l.to_path();
}
if let Some(l @ LiveShape::Ellipse { .. }) = live.as_mut()
&& (pie_start.is_some() || pie_end.is_some() || invert)
{
if let LiveShape::Ellipse { pie, .. } = l {
// An end of 0 is a full turn (Illustrator shows a whole ellipse as 0° to 360°).
let end_of = |a: f64| if a == 0.0 { 360.0 } else { a };
*pie = (pie_start.unwrap_or(pie.0), pie_end.map_or(pie.1, end_of));
if invert {
*pie = (pie.1.rem_euclid(360.0), end_of(pie.0));
}
}
*path = l.to_path();
}
if radius.is_none() && kind.is_none() {
continue;
}

View File

@ -279,3 +279,31 @@ fn a_pen_path_rounds_its_corners_but_not_its_ends() {
run(&mut s, json!({"id": e.0, "radius": 8}));
assert_eq!(node_path(&s, e), before);
}
/// #812: an ellipse's Pie Start and End Angle cut it as a pie; Invert Pie shows the other part;
/// one undo step each; a bad angle is an error.
#[test]
fn an_ellipse_takes_pie_angles_and_inverts() {
let mut s = Session::new();
s.execute("file.new", &json!({"width": 300, "height": 300})).unwrap();
let id = NodeId(s.execute("shape.ellipse", &json!({"x": 0, "y": 0, "width": 200, "height": 100})).unwrap()["id"].as_u64().unwrap());
let pie = |s: &Session| match &s.doc().unwrap().doc.node(id).unwrap().kind {
NodeKind::Path { live: Some(LiveShape::Ellipse { pie, .. }), path, .. } => (*pie, path.bounds().unwrap()),
k => panic!("{k:?}"),
};
s.execute("object.setLiveShape", &json!({"pieStart": 0, "pieEnd": 90})).unwrap();
let (p, b) = pie(&s);
assert_eq!(p, (0.0, 90.0));
assert!((b.x0 - 100.0).abs() < 1e-6 && (b.y1 - 50.0).abs() < 1e-6, "the quarter up and right of the centre: {b:?}");
s.execute("object.setLiveShape", &json!({"invertPie": true})).unwrap();
let (p, b) = pie(&s);
assert_eq!(p, (90.0, 360.0));
assert!(b.x0.abs() < 1e-6 && (b.y1 - 100.0).abs() < 1e-6, "the other three quarters: {b:?}");
s.execute("edit.undo", &json!({})).unwrap();
assert_eq!(pie(&s).0, (0.0, 90.0));
// 0 to 360 (or an end of 0) is the whole ellipse again.
s.execute("object.setLiveShape", &json!({"pieStart": 0, "pieEnd": 0})).unwrap();
assert_eq!(pie(&s).0, (0.0, 360.0));
assert_eq!(s.doc().unwrap().doc.node(id).unwrap().path_data().unwrap().anchor_count(), 4);
assert!(s.execute("object.setLiveShape", &json!({"pieStart": "half"})).is_err());
}

View File

@ -86,6 +86,50 @@ pub fn ellipse(r: Rect) -> PathData {
PathData::single(SubPath::new(anchors, true))
}
/// The ellipse inscribed in `r` cut as a pie: from `start` to `end` degrees, measured
/// counterclockwise on screen from 3 o'clock (as Illustrator's Pie Start and End Angle are), then
/// back through the centre. A pie spanning the whole turn (or nothing) is the whole [`ellipse`].
/// The arc is one cubic per quarter turn at most: the centre, then the arc's anchors.
pub fn ellipse_pie(r: Rect, start: f64, end: f64) -> PathData {
let span = (end - start).rem_euclid(360.0);
if !(start.is_finite() && end.is_finite()) || span < 1e-9 || 360.0 - span < 1e-9 {
return ellipse(r);
}
let r = r.abs();
let c = r.center();
let (rx, ry) = (r.width() / 2.0, r.height() / 2.0);
// Screen y runs down: counterclockwise on screen is a falling y.
let at = |deg: f64| {
let t = deg.to_radians();
Point::new(c.x + rx * t.cos(), c.y - ry * t.sin())
};
let tangent = |deg: f64| {
let t = deg.to_radians();
Vec2::new(-rx * t.sin(), -ry * t.cos())
};
let pieces = if span <= 90.0 {
1
} else if span <= 180.0 {
2
} else if span <= 270.0 {
3
} else {
4
};
let step = span / f64::from(pieces);
let k = 4.0 / 3.0 * (step.to_radians() / 4.0).tan();
let mut anchors = vec![Anchor::corner(c)];
for i in 0..=pieces {
let a = start + step * f64::from(i);
let (p, d) = (at(a), tangent(a) * k);
let h_in = if i == 0 { p } else { p - d };
let h_out = if i == pieces { p } else { p + d };
let kind = if i == 0 || i == pieces { AnchorKind::Corner } else { AnchorKind::Smooth };
anchors.push(Anchor { p, h_in, h_out, kind });
}
PathData::single(SubPath::new(anchors, true))
}
/// Regular polygon with `sides` (≥ 3), first vertex straight up from the centre.
pub fn polygon(center: Point, radius: f64, sides: u32, rotation_deg: f64) -> PathData {
let n = sides.max(3);
@ -207,6 +251,27 @@ mod tests {
crate::corners::cut_corners(&rectangle(r), &radii, &[]).1.concat()
}
/// A pie is the arc from its start to its end angle, counterclockwise on screen, closed through
/// the centre; a whole turn (or none) is the ellipse.
#[test]
fn a_pie_is_its_arc_closed_through_the_centre() {
let r = Rect::new(0.0, 0.0, 200.0, 100.0);
assert_eq!(ellipse_pie(r, 0.0, 360.0), ellipse(r));
assert_eq!(ellipse_pie(r, 90.0, 90.0), ellipse(r));
// A quarter, from 3 o'clock up to 12: the centre, then the arc's two ends.
let q = ellipse_pie(r, 0.0, 90.0);
let a: Vec<Point> = q.subpaths[0].anchors.iter().map(|a| a.p).collect();
assert_eq!(a.len(), 3);
assert!(a[0].distance(Point::new(100.0, 50.0)) < 1e-9 && a[1].distance(Point::new(200.0, 50.0)) < 1e-9);
assert!(a[2].distance(Point::new(100.0, 0.0)) < 1e-9, "counterclockwise on screen: up");
assert_eq!(q.bounds().map(|b| (b.x0.round(), b.y0.round(), b.x1.round(), b.y1.round())), Some((100.0, 0.0, 200.0, 50.0)));
// Three quarters, from 12 round to 3 o'clock: four pieces at most, the whole box covered.
let big = ellipse_pie(r, 90.0, 0.0);
assert!(big.subpaths[0].anchors.len() <= 6 && big.subpaths[0].closed);
assert_eq!(big.bounds().map(|b| (b.x0.round(), b.y0.round(), b.x1.round(), b.y1.round())), Some((0.0, 0.0, 200.0, 100.0)));
assert!(ellipse_pie(r, f64::NAN, 10.0).anchor_count() == 4, "not a number: the whole ellipse");
}
#[test]
fn rect_is_4_corners_clockwise() {
let p = rectangle(Rect::new(0.0, 0.0, 100.0, 50.0));

View File

@ -221,6 +221,10 @@
Auto Size New Area Type Ajustar automáticamente el tamaño del nuevo texto de área
Auto Spell Check Revisión ortográfica automática
Auto-Collapse Iconic Panels Contraer automáticamente los paneles de iconos
Ellipse Properties: Propiedades de la elipse:
Invert Pie Invertir sector
Pie End Angle: Ángulo final del sector:
Pie Start Angle: Ángulo inicial del sector:
Show Tool Group Labels Mostrar etiquetas de grupos de herramientas
Auto-Rotate (turn the paper to each artboard) Rotar automáticamente (girar el papel según cada mesa de trabajo)
Automatic flatness Lisura automática

Can't render this file because it contains an unexpected character in line 2 and column 73.

View File

@ -222,6 +222,10 @@
Auto Size New Area Type Taille automatique du nouveau texte captif
Auto Spell Check Vérification orthographique automatique
Auto-Collapse Iconic Panels Réduire automatiquement les panneaux en icônes
Ellipse Properties: Propriétés de l’ellipse :
Invert Pie Inverser le secteur
Pie End Angle: Angle de fin du secteur :
Pie Start Angle: Angle de début du secteur :
Show Tool Group Labels Afficher les libellés des groupes d'outils
Auto-Rotate (turn the paper to each artboard) Rotation automatique (orienter le papier selon chaque plan de travail)
Automatic flatness Planéité automatique

Can't render this file because it contains an unexpected character in line 2 and column 73.

View File

@ -221,6 +221,10 @@
Auto Size New Area Type Dimensionamento automatico del nuovo testo in area
Auto Spell Check Controllo ortografico automatico
Auto-Collapse Iconic Panels Comprimi automaticamente i pannelli a icona
Ellipse Properties: Proprietà dell’ellisse:
Invert Pie Inverti settore
Pie End Angle: Angolo finale del settore:
Pie Start Angle: Angolo iniziale del settore:
Show Tool Group Labels Mostra etichette dei gruppi di strumenti
Auto-Rotate (turn the paper to each artboard) Rotazione automatica (orienta la carta in base a ogni tavola da disegno)
Automatic flatness Planarità automatica

Can't render this file because it contains an unexpected character in line 2 and column 73.

View File

@ -345,9 +345,13 @@
Appearance Attribute アピアランス属性
Apply 適用
Blending Mode 描画モード
Ellipse Properties: 楕円形のプロパティ:
Fill & Stroke 塗りと線
Fill Color カラー (塗り)
Invert Pie 扇形を反転
Opacity 不透明度
Pie End Angle: 扇形の終了角度:
Pie Start Angle: 扇形の開始角度:
Stroke Color カラー (線)
Stroke Weight 線幅
Graphic Style グラフィックスタイル

Can't render this file because it contains an unexpected character in line 2 and column 73.

View File

@ -219,6 +219,10 @@
Auto Size New Area Type Автоматически подгонять размер нового текста в области
Auto Spell Check Автоматическая проверка орфографии
Auto-Collapse Iconic Panels Автоматически сворачивать значки панелей
Ellipse Properties: Свойства эллипса:
Invert Pie Инвертировать сектор
Pie End Angle: Конечный угол сектора:
Pie Start Angle: Начальный угол сектора:
Show Tool Group Labels Показывать названия групп инструментов
Auto-Rotate (turn the paper to each artboard) Автоповорот (поворачивать лист по каждой монтажной области)
Automatic flatness Автоматическая плоскостность

Can't render this file because it contains an unexpected character in line 2 and column 73.

View File

@ -215,6 +215,10 @@
Auto Size New Area Type Автоматично підганяти розмір нового тексту в області
Auto Spell Check Автоматична перевірка орфографії
Auto-Collapse Iconic Panels Автоматично згортати значки панелей
Ellipse Properties: Властивості еліпса:
Invert Pie Інвертувати сектор
Pie End Angle: Кінцевий кут сектора:
Pie Start Angle: Початковий кут сектора:
Show Tool Group Labels Показувати назви груп інструментів
Auto-Rotate (turn the paper to each artboard) Автоматичний поворот (повертати аркуш відповідно до кожної монтажної області)
Automatic flatness Автоматична площинність

Can't render this file because it has a wrong number of fields in line 14.

View File

@ -204,6 +204,10 @@
Auto Size New Area Type 自动调整新区域文字大小
Auto Spell Check 自动拼写检查
Auto-Collapse Iconic Panels 自动折叠图标面板
Ellipse Properties: 椭圆属性:
Invert Pie 反转扇形
Pie End Angle: 扇形终止角度:
Pie Start Angle: 扇形起始角度:
Show Tool Group Labels 显示工具组标签
Auto-Rotate (turn the paper to each artboard) 自动旋转 (为每个画板转动纸张)
Automatic flatness 自动平滑度

Can't render this file because it contains an unexpected character in line 2 and column 73.

View File

@ -215,6 +215,10 @@
Auto Size New Area Type 自動調整新區域文字大小
Auto Spell Check 自動拼字檢查
Auto-Collapse Iconic Panels 自動收合圖示面板
Ellipse Properties: 橢圓屬性:
Invert Pie 反轉扇形
Pie End Angle: 扇形終止角度:
Pie Start Angle: 扇形起始角度:
Show Tool Group Labels 顯示工具組標籤
Auto-Rotate (turn the paper to each artboard) 自動旋轉 (將紙張轉向各工作區域)
Automatic flatness 自動平滑度

Can't render this file because it contains an unexpected character in line 6 and column 47.

View File

@ -468,6 +468,7 @@ pub fn transform_section(app: &mut VectorcraftApp, ui: &mut Ui) {
});
corner_radius_row(app, ui, &n, "radius");
}
vectorcraft_doc::LiveShape::Ellipse { pie, .. } => super::transform::pie_rows(app, ui, *pie, "props-pie"),
_ => {}
}
}

View File

@ -192,6 +192,10 @@ pub fn show(app: &mut VectorcraftApp, ui: &mut Ui) {
});
corner_radius_row(app, ui, &n, "xfp-radius");
}
vectorcraft_doc::LiveShape::Ellipse { pie, .. } => {
widgets::subheader(ui, tl!("Ellipse Properties:"));
pie_rows(app, ui, *pie, "xfp-pie");
}
_ => {
widgets::subheader(ui, tl!("Shape Properties:"));
widgets::dim_label(ui, tl!(n.kind_label()));
@ -211,6 +215,22 @@ pub fn show(app: &mut VectorcraftApp, ui: &mut Ui) {
}
}
/// An ellipse's Pie Start and End Angle (degrees, counterclockwise from 3 o'clock; 0 to 360 is the
/// whole ellipse) and Invert Pie, which shows the other part of it (Transform and Properties panels).
pub(crate) fn pie_rows(app: &mut VectorcraftApp, ui: &mut Ui, pie: (f64, f64), id: &str) {
for (label, key, v) in [(tl!("Pie Start Angle:"), "pieStart", pie.0), (tl!("Pie End Angle:"), "pieEnd", pie.1)] {
ui.horizontal(|ui| {
widgets::dim_label(ui, label);
if let Some(a) = widgets::plain_field(ui, (id, key), v, "°", 1, 60.0) {
app.run("object.setLiveShape", json!({ key: a })).ok();
}
});
}
if widgets::flat_button(ui, tl!("Invert Pie"), 90.0).clicked() {
app.run("object.setLiveShape", json!({"invertPie": true})).ok();
}
}
/// The Control bar's underlined "Transform" link: a click toggles the Transform panel in a
/// popover under it, as the Stroke link does with the Stroke panel.
pub fn link(app: &mut VectorcraftApp, ui: &mut Ui) {