Part of #812: polygon properties in the Properties and Transform panels

A selected live polygon now shows Illustrator's Polygon Properties in the
Transform panel and the Properties panel's Transform section: side count,
angle, corner type and radius, radius, side length and Make Sides Equal
(enabled once an uneven scale has made its sides differ). Before, only the
side count and corner radius were there.

object.setLiveShape takes polygonAngle (degrees counterclockwise),
polygonRadius and sideLength (points, centre to vertex / side) and
makeSidesEqual, which drops the uneven scale and shear and keeps the
polygon's centre, angle and mean radius. Each call is one undo step; a
radius or length that isn't a number above 0 is an error. A star stays a
plain path, as in Illustrator.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
bflatastic 2026-10-10 12:08:12 +07:00
parent 5305b80308
commit 7c0ce8a382
18 changed files with 322 additions and 45 deletions

File diff suppressed because one or more lines are too long

View File

@ -174,6 +174,61 @@ impl LiveShape {
spread(radii, *sides as usize);
spread(kinds, *sides as usize);
}
/// A polygon's radius in the document, centre to vertex: its own radius by the mean scale of
/// `xf` (exact while its sides are equal). None for other shapes.
pub fn polygon_radius(&self) -> Option<f64> {
let LiveShape::Polygon { radius, xf, .. } = self else { return None };
Some(radius * xf.determinant().abs().sqrt())
}
/// A polygon's angle: the counterclockwise degrees in [0, 360) its first vertex is turned by
/// from straight up (the Rotate field's sense). None for other shapes.
pub fn polygon_angle(&self) -> Option<f64> {
let LiveShape::Polygon { .. } = self else { return None };
// No −0° or 360° from rounding: a polygon drawn upright reads 0°.
let a = (-self.angle_deg()).rem_euclid(360.0);
Some(if a > 360.0 - 1e-9 { 0.0 } else { a + 0.0 })
}
/// Whether a polygon's sides are all as long: `xf` scales it evenly and doesn't shear it. True
/// for other shapes.
pub fn polygon_sides_equal(&self) -> bool {
let LiveShape::Polygon { xf, .. } = self else { return true };
let [a, b, c, d, _, _] = xf.as_coeffs();
let (sx, sy) = (a.hypot(b), c.hypot(d));
(sx - sy).abs() <= 1e-9 * sx.max(sy) && (a * c + b * d).abs() <= 1e-9 * sx * sy
}
/// Give a polygon the document radius `r` (centre to vertex, see [`LiveShape::polygon_radius`]).
pub fn set_polygon_radius(&mut self, r: f64) {
let LiveShape::Polygon { radius, xf, .. } = self else { return };
let k = xf.determinant().abs().sqrt();
if k > 1e-12 && r.is_finite() {
*radius = r.max(0.0) / k;
}
}
/// The document radius that makes a polygon's sides `side` long (with its sides equal).
pub fn polygon_radius_for_side(&self, side: f64) -> Option<f64> {
let LiveShape::Polygon { sides, .. } = self else { return None };
Some(side / (2.0 * (std::f64::consts::PI / f64::from((*sides).max(3))).sin()))
}
/// Turn a polygon about its centre to `angle` (degrees, as [`LiveShape::polygon_angle`]).
pub fn set_polygon_angle(&mut self, angle: f64) {
let Some(now) = self.polygon_angle() else { return };
if let LiveShape::Polygon { xf, .. } = self
&& angle.is_finite()
{
let centre = *xf * Point::ZERO;
*xf = Affine::rotate_about((now - angle).to_radians(), centre) * *xf;
}
}
/// Make a polygon's sides equal (Make Sides Equal): drop the uneven scale and the shear from
/// `xf`, keeping its centre, angle, reflection and [`LiveShape::polygon_radius`].
pub fn make_sides_equal(&mut self) {
let Some(r) = self.polygon_radius() else { return };
let LiveShape::Polygon { radius, xf, .. } = self else { return };
let [a, b, _, _, e, f] = xf.as_coeffs();
let flip = if xf.determinant() < 0.0 { Affine::FLIP_Y } else { Affine::IDENTITY };
*xf = Affine::translate((e, f)) * Affine::rotate(b.atan2(a)) * flip;
*radius = r;
}
/// Apply `a` to the shape. True when the shape is no longer `a` applied to the old path, so
/// the caller must regenerate the path with [`LiveShape::to_path`].
///
@ -253,7 +308,7 @@ impl LiveShape {
radii.iter_mut().for_each(|r| *r /= k);
true
}
/// Rotation angle of the live shape in degrees (shown in the Properties panel).
/// Rotation angle of the live shape in degrees, clockwise on the page (y points down).
pub fn angle_deg(&self) -> f64 {
match self {
LiveShape::Rectangle { xf, .. } | LiveShape::Ellipse { xf, .. } | LiveShape::Polygon { xf, .. } => {
@ -1357,6 +1412,34 @@ mod tests {
assert_eq!(path.label(), "Path");
}
#[test]
fn a_polygon_reports_and_takes_its_radius_angle_and_equal_sides() {
let close = |a: f64, b: f64| (a - b).abs() < 1e-9;
// A hexagon of radius 10 at (50, 50), drawn 2× and turned 30° clockwise on the page.
let xf = Affine::translate((50.0, 50.0)) * Affine::rotate(30f64.to_radians()) * Affine::scale(2.0);
let mut p = LiveShape::Polygon { radius: 10.0, sides: 6, xf, radii: vec![], kinds: vec![] };
assert!(close(p.polygon_radius().unwrap(), 20.0) && close(p.polygon_angle().unwrap(), 330.0) && p.polygon_sides_equal());
p.set_polygon_angle(45.0);
assert!(close(p.polygon_angle().unwrap(), 45.0));
let centre = p.to_path().bounds().unwrap().center();
assert!(close(centre.x, 50.0) && close(centre.y, 50.0), "turned about its centre: {centre:?}");
p.set_polygon_radius(30.0);
assert!(close(p.polygon_radius().unwrap(), 30.0));
// A hexagon's side is as long as its radius.
assert!(close(p.polygon_radius_for_side(12.0).unwrap(), 12.0));
// Stretched, its sides differ; Make Sides Equal keeps its centre, angle and mean radius.
p.transform(Affine::scale_non_uniform(2.0, 1.0));
assert!(!p.polygon_sides_equal());
let (r, a) = (p.polygon_radius().unwrap(), p.polygon_angle().unwrap());
p.make_sides_equal();
assert!(p.polygon_sides_equal() && close(p.polygon_radius().unwrap(), r) && close(p.polygon_angle().unwrap(), a));
let LiveShape::Polygon { xf, .. } = &p else { panic!("polygon") };
assert_eq!(*xf * Point::ZERO, Point::new(100.0, 50.0));
// Other shapes have none of it.
let e = LiveShape::Ellipse { w: 1.0, h: 1.0, pie: (0.0, 360.0), xf: Affine::IDENTITY };
assert_eq!((e.polygon_radius(), e.polygon_angle()), (None, None));
}
/// A node holding a live rectangle (`w` × `h`, all radii `r`, placed by `xf`) and its path.
fn live_rect(w: f64, h: f64, r: f64, xf: Affine) -> Node {
let live = LiveShape::Rectangle { w, h, radii: [r; 4], kinds: Default::default(), xf };

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, 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)",
"{id?, ids?, radius?: pt, kind?: \"round\"|\"invertedRound\"|\"chamfer\", corners?: [i…], sides?: n, polygonAngle?: degrees (counterclockwise from its first vertex straight up), polygonRadius?: pt (centre to vertex), sideLength?: pt (sets the radius), makeSidesEqual?: true (a polygon scaled unevenly: drops the uneven scale and shear, keeping its centre, angle and mean radius), 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; the polygon params apply in that order: sides, makeSidesEqual, polygonAngle, then sideLength or else polygonRadius)",
has_selection,
set_live_shape
),
@ -1095,24 +1095,44 @@ 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>> {
// A number that must be `what` (None when absent).
let number = |k: &str, ok: fn(f64) -> bool, what: &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}"))),
.filter(|x| x.is_finite() && ok(*x))
.map(Some)
.ok_or_else(|| bad("object.setLiveShape", format!("`{k}` must be {what}, not {v}"))),
}
};
let angle = |k: &str| Ok::<_, EngineError>(number(k, |_| true, "an angle in degrees")?.map(|a| a.rem_euclid(360.0)));
let length = |k: &str| number(k, |x| x > 0.0 && x <= crate::MAX_COORD, "a length in points above 0");
// An ellipse's pie (Ellipse Properties: Pie Start and End Angle, degrees; Invert Pie swaps them).
let (pie_start, pie_end) = (angle("pieStart")?, angle("pieEnd")?);
let invert = p.get("invertPie").and_then(Value::as_bool) == Some(true);
// A polygon's Polygon Properties: its angle, radius or side length, Make Sides Equal.
let (polygon_angle, polygon_radius, side) = (angle("polygonAngle")?, length("polygonRadius")?, length("sideLength")?);
let equal = p.get("makeSidesEqual").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 };
if let (Some(l @ LiveShape::Polygon { .. }), Some(n)) = (live.as_mut(), sides) {
l.set_sides(n);
if let Some(l @ LiveShape::Polygon { .. }) = live.as_mut()
&& (sides.is_some() || polygon_angle.is_some() || polygon_radius.is_some() || side.is_some() || equal)
{
if let Some(n) = sides {
l.set_sides(n);
}
if equal {
l.make_sides_equal();
}
if let Some(a) = polygon_angle {
l.set_polygon_angle(a);
}
// A side length sets the radius that gives it (with the new side count).
if let Some(r) = side.and_then(|len| l.polygon_radius_for_side(len)).or(polygon_radius) {
l.set_polygon_radius(r);
}
*path = l.to_path();
}
if let Some(l @ LiveShape::Ellipse { .. }) = live.as_mut()

View File

@ -307,3 +307,42 @@ fn an_ellipse_takes_pie_angles_and_inverts() {
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());
}
/// #812: a polygon's Polygon Properties: its angle, radius and side length, each one undo step;
/// Make Sides Equal after an uneven scale; bad values are errors.
#[test]
fn a_polygon_takes_its_angle_radius_side_length_and_equal_sides() {
let mut s = Session::new();
s.execute("file.new", &json!({"width": 400, "height": 400})).unwrap();
let id = NodeId(s.execute("shape.polygon", &json!({"cx": 200, "cy": 200, "radius": 50, "sides": 6})).unwrap()["id"].as_u64().unwrap());
let live = |s: &Session| match &s.doc().unwrap().doc.node(id).unwrap().kind {
NodeKind::Path { live: Some(l @ LiveShape::Polygon { .. }), path, .. } => (l.clone(), path.bounds().unwrap()),
k => panic!("{k:?}"),
};
let close = |a: f64, b: f64| (a - b).abs() < 1e-6;
run(&mut s, json!({"polygonAngle": 90}));
let (l, b) = live(&s);
assert!(close(l.polygon_angle().unwrap(), 90.0), "{l:?}");
// Its first vertex, straight up before, points left now: the hexagon is 100 wide, 86.6 tall.
assert!(close(b.width(), 100.0) && close(b.height(), 86.602_540_378) && close(b.center().x, 200.0), "{b:?}");
run(&mut s, json!({"polygonRadius": 80}));
assert!(close(live(&s).0.polygon_radius().unwrap(), 80.0));
// A hexagon's side is as long as its radius; with 4 sides the radius is side / √2.
run(&mut s, json!({"sideLength": 30}));
assert!(close(live(&s).0.polygon_radius().unwrap(), 30.0));
run(&mut s, json!({"sides": 4, "sideLength": 30}));
assert!(close(live(&s).0.polygon_radius().unwrap(), 30.0 / 2f64.sqrt()));
s.execute("edit.undo", &json!({})).unwrap();
let LiveShape::Polygon { sides, .. } = live(&s).0 else { panic!("polygon") };
assert_eq!(sides, 6, "one undo step");
// Scaled unevenly its sides differ; Make Sides Equal makes them equal again, in place.
s.execute("object.scale", &json!({"sx": 200, "sy": 100})).unwrap();
let (l, b) = live(&s);
assert!(!l.polygon_sides_equal() && l.polygon_angle().is_some());
run(&mut s, json!({"makeSidesEqual": true}));
let (l, after) = live(&s);
assert!(l.polygon_sides_equal() && close(after.center().x, b.center().x) && close(after.center().y, b.center().y), "{b:?} → {after:?}");
for bad in [json!({"polygonRadius": 0}), json!({"sideLength": -3}), json!({"polygonRadius": "big"}), json!({"polygonAngle": "left"})] {
assert!(s.execute("object.setLiveShape", &bad).is_err(), "{bad}");
}
}

View File

@ -397,6 +397,37 @@ fn area_options_fit_junk() {
assert!(failures.is_empty(), "{} failures:\n{}", failures.len(), failures.join("\n"));
}
/// A live polygon's Polygon Properties with junk values (the fixtures have no polygon), drawn
/// plain, scaled unevenly and flattened to nothing: never a panic, and the document stays sound.
#[test]
fn polygon_properties_junk() {
let mut cases: Vec<Value> = vec![json!({"makeSidesEqual": true}), json!({"sides": u64::MAX, "sideLength": 1e-300, "makeSidesEqual": true})];
for key in ["polygonRadius", "sideLength", "polygonAngle"] {
for v in junk_values() {
cases.push(json!({ key: v }));
}
}
let mut failures = vec![];
for scale in [None, Some((300.0, 20.0)), Some((1e-300, 100.0))] {
for p in &cases {
let mut s = Fixture::Multi.session();
let Ok(_) = s.execute("shape.polygon", &json!({"cx": 100, "cy": 100, "radius": 40, "sides": 7, "rotation": 10})) else { continue };
if let Some((sx, sy)) = scale {
let _ = s.execute("object.scale", &json!({"sx": sx, "sy": sy}));
}
match catch_quiet(|| s.execute("object.setLiveShape", p)) {
Err(m) => failures.push(format!("PANIC object.setLiveShape {p} [{scale:?}]: {m}")),
Ok(_) => {
if let Err(e) = catch_quiet(|| check_all(&mut s)).unwrap_or_else(|m| Err(format!("panic in checks: {m}"))) {
failures.push(format!("object.setLiveShape {p} [{scale:?}]: {e}"));
}
}
}
}
}
assert!(failures.is_empty(), "{} failures:\n{}", failures.len(), failures.join("\n"));
}
/// After a successful fuzzed call, the document still round-trips and exports.
#[test]
fn fuzzed_calls_keep_documents_serializable() {

View File

@ -63,12 +63,7 @@ fn body(app: &mut VectorcraftApp, ui: &mut egui::Ui, d: &mut Dialog) -> bool {
label(ui, tl!("Corner:"));
ui.vertical(|ui| {
for k in CornerKind::ALL {
let name = match k {
CornerKind::Round => tl!("Round"),
CornerKind::InvertedRound => tl!("Inverted Round"),
CornerKind::Chamfer => tl!("Chamfer"),
};
if widgets::radio(ui, name, Some(k) == current, true) {
if widgets::radio(ui, kind_label(k), Some(k) == current, true) {
d.fields.insert("kind".into(), json!(k));
}
}
@ -81,6 +76,15 @@ fn body(app: &mut VectorcraftApp, ui: &mut egui::Ui, d: &mut Dialog) -> bool {
false
}
/// A corner kind's name, in the UI language.
pub(crate) fn kind_label(k: CornerKind) -> &'static str {
match k {
CornerKind::Round => tl!("Round"),
CornerKind::InvertedRound => tl!("Inverted Round"),
CornerKind::Chamfer => tl!("Chamfer"),
}
}
fn confirm(app: &mut VectorcraftApp, d: &Dialog) -> Result<Value, String> {
let mut p = json!({"id": d.fields.get("id"), "corners": d.fields.get("corners")});
if let Some(k) = d.fields.get("kind") {

View File

@ -228,6 +228,8 @@
Auto-Collapse Iconic Panels Contraer automáticamente los paneles de iconos
Ellipse Properties: Propiedades de la elipse:
Invert Pie Invertir sector
Side Length: Longitud del lado:
Make Sides Equal Igualar los lados
Pie End Angle: Ángulo final del sector:
Pie Start Angle: Ángulo inicial del sector:
Show Tool Group Labels Mostrar etiquetas de grupos de herramientas

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

View File

@ -229,6 +229,8 @@
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
Side Length: Longueur du côté :
Make Sides Equal Égaliser les côtés
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

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

View File

@ -228,6 +228,8 @@
Auto-Collapse Iconic Panels Comprimi automaticamente i pannelli a icona
Ellipse Properties: Proprietà dell’ellisse:
Invert Pie Inverti settore
Side Length: Lunghezza del lato:
Make Sides Equal Rendi uguali i lati
Pie End Angle: Angolo finale del settore:
Pie Start Angle: Angolo iniziale del settore:
Show Tool Group Labels Mostra etichette dei gruppi di strumenti

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

View File

@ -354,6 +354,8 @@
Fill & Stroke 塗りと線
Fill Color カラー (塗り)
Invert Pie 扇形を反転
Side Length: 辺の長さ:
Make Sides Equal 辺の長さをそろえる
Opacity 不透明度
Pie End Angle: 扇形の終了角度:
Pie Start Angle: 扇形の開始角度:

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

View File

@ -226,6 +226,8 @@
Auto-Collapse Iconic Panels Автоматически сворачивать значки панелей
Ellipse Properties: Свойства эллипса:
Invert Pie Инвертировать сектор
Side Length: Длина стороны:
Make Sides Equal Выровнять стороны
Pie End Angle: Конечный угол сектора:
Pie Start Angle: Начальный угол сектора:
Show Tool Group Labels Показывать названия групп инструментов

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

View File

@ -222,6 +222,8 @@
Auto-Collapse Iconic Panels Автоматично згортати значки панелей
Ellipse Properties: Властивості еліпса:
Invert Pie Інвертувати сектор
Side Length: Довжина сторони:
Make Sides Equal Вирівняти сторони
Pie End Angle: Кінцевий кут сектора:
Pie Start Angle: Початковий кут сектора:
Show Tool Group Labels Показувати назви груп інструментів

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

View File

@ -211,6 +211,8 @@
Auto-Collapse Iconic Panels 自动折叠图标面板
Ellipse Properties: 椭圆属性:
Invert Pie 反转扇形
Side Length: 边长:
Make Sides Equal 使各边等长
Pie End Angle: 扇形终止角度:
Pie Start Angle: 扇形起始角度:
Show Tool Group Labels 显示工具组标签

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

View File

@ -222,6 +222,8 @@
Auto-Collapse Iconic Panels 自動收合圖示面板
Ellipse Properties: 橢圓屬性:
Invert Pie 反轉扇形
Side Length: 邊長:
Make Sides Equal 使各邊等長
Pie End Angle: 扇形終止角度:
Pie Start Angle: 扇形起始角度:
Show Tool Group Labels 顯示工具組標籤

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

View File

@ -48,6 +48,7 @@ use serde_json::{Value, json};
use vectorcraft_color::{BlendMode, Color, Paint};
use vectorcraft_doc::{LiveCorners, Node, StrokeLayer};
use vectorcraft_engine::inspect::StrokeMixed;
use vectorcraft_geom::shapes::CornerKind;
use crate::theme::Tokens;
use crate::widgets::{Live, dim_label};
@ -61,13 +62,15 @@ pub fn first_selected(app: &VectorcraftApp) -> Option<Node> {
/// The radius the Live Corners of path `n` show in the panels: that of the corners the panels set
/// (the Direct-Selected ones, else every corner), blank when they differ.
pub(crate) fn corner_radius(app: &VectorcraftApp, n: &Node) -> Option<f64> {
let partial = app.session.active().and_then(|d| d.selection.partial(n.id));
let corners = LiveCorners::of(n)?;
corners.style(&corners.picked(partial)).0
corner_style(app, n).0
}
/// The radius and kind of the corners [`corner_radius`] reads, each None when they differ.
pub(crate) fn corner_style(app: &VectorcraftApp, n: &Node) -> (Option<f64>, Option<CornerKind>) {
let partial = app.session.active().and_then(|d| d.selection.partial(n.id));
LiveCorners::of(n).map_or((None, None), |c| c.style(&c.picked(partial)))
}
/// The Corner Radius field of a live rectangle's or polygon's properties: [`corner_radius`],
/// which a new value sets on those corners.
/// The width of a panel's number fields: the Transform fields', two to a row beside the reference
/// point, their labels and the W/H link, so every field in the panel is as wide (#696). Measured
/// from the full row, before its widgets.
@ -77,17 +80,23 @@ pub(crate) fn field_width(ui: &Ui) -> f32 {
((ui.available_width() - AROUND) / 2.0).clamp(60.0, 110.0)
}
/// The Corner Radius field of a live rectangle's properties: [`corner_radius`], which a new value
/// sets on those corners.
pub(crate) fn corner_radius_row(app: &mut VectorcraftApp, ui: &mut Ui, n: &Node, id: &str) {
let units = app.session.general_unit();
let fw = field_width(ui);
ui.horizontal(|ui| {
dim_label(ui, tl!("Corner Radius:"));
if let Some(r) = crate::widgets::num_field(ui, id, corner_radius(app, n), units, fw) {
app.run("object.setLiveShape", json!({"radius": r})).ok();
}
corner_radius_field(app, ui, n, id, fw);
});
}
/// [`corner_radius_row`]'s field, `width` wide.
pub(crate) fn corner_radius_field(app: &mut VectorcraftApp, ui: &mut Ui, n: &Node, id: impl std::hash::Hash + std::fmt::Debug, width: f32) {
if let Some(r) = crate::widgets::num_field(ui, id, corner_radius(app, n), app.session.general_unit(), width) {
app.run("object.setLiveShape", json!({"radius": r})).ok();
}
}
/// Number of selected objects.
pub(crate) fn selection_len(app: &VectorcraftApp) -> usize {
app.session.active().map(|d| d.selection.len()).unwrap_or(0)

View File

@ -447,16 +447,7 @@ pub fn transform_section(app: &mut VectorcraftApp, ui: &mut Ui) {
{
match live {
vectorcraft_doc::LiveShape::Rectangle { .. } => corner_radius_row(app, ui, &n, "radius"),
vectorcraft_doc::LiveShape::Polygon { sides, .. } => {
let fw = super::field_width(ui);
ui.horizontal(|ui| {
dim_label(ui, tl!("Sides:"));
if let Some(s) = widgets::plain_field(ui, "sides", *sides as f64, "", 0, fw) {
app.run("object.setLiveShape", json!({"sides": s as u32})).ok();
}
});
corner_radius_row(app, ui, &n, "radius");
}
vectorcraft_doc::LiveShape::Polygon { .. } => super::transform::polygon_rows(app, ui, &n, live, "props-polygon"),
vectorcraft_doc::LiveShape::Ellipse { pie, .. } => super::transform::pie_rows(app, ui, *pie, "props-pie"),
_ => {}
}

View File

@ -4,10 +4,12 @@
use egui::Ui;
use serde_json::json;
use vectorcraft_doc::NodeKind;
use vectorcraft_doc::{LiveShape, Node, NodeKind};
use vectorcraft_geom::Rect;
use vectorcraft_geom::shapes::CornerKind;
use super::{corner_radius_row, first_selected, pstate, set_pstate};
use crate::dialogs::corners::kind_label as corner_kind;
use crate::theme::Tokens;
use crate::widgets::{self, menu_item};
use crate::{VectorcraftApp, icons};
@ -182,15 +184,9 @@ pub fn show(app: &mut VectorcraftApp, ui: &mut Ui) {
widgets::subheader(ui, tl!("Rectangle Properties:"));
corner_radius_row(app, ui, &n, "xfp-radius");
}
vectorcraft_doc::LiveShape::Polygon { sides, .. } => {
vectorcraft_doc::LiveShape::Polygon { .. } => {
widgets::subheader(ui, tl!("Polygon Properties:"));
ui.horizontal(|ui| {
widgets::dim_label(ui, tl!("Sides:"));
if let Some(s) = widgets::plain_field(ui, "xfp-sides", *sides as f64, "", 0, 60.0) {
app.run("object.setLiveShape", json!({"sides": s.clamp(3.0, 20.0) as u32})).ok();
}
});
corner_radius_row(app, ui, &n, "xfp-radius");
polygon_rows(app, ui, &n, live, "xfp-polygon");
}
vectorcraft_doc::LiveShape::Ellipse { pie, .. } => {
widgets::subheader(ui, tl!("Ellipse Properties:"));
@ -231,6 +227,56 @@ pub(crate) fn pie_rows(app: &mut VectorcraftApp, ui: &mut Ui, pie: (f64, f64), i
}
}
/// A live polygon's Polygon Properties (Transform and Properties panels): its side count, angle
/// (counterclockwise), corner type and radius, radius (centre to vertex) and side length, and Make
/// Sides Equal, which an uneven scale enables.
pub(crate) fn polygon_rows(app: &mut VectorcraftApp, ui: &mut Ui, n: &Node, live: &LiveShape, id: &str) {
let LiveShape::Polygon { sides, .. } = live else { return };
let (units, fw) = (app.session.general_unit(), super::field_width(ui));
let radius = live.polygon_radius();
let side = radius.map(|r| 2.0 * r * (std::f64::consts::PI / f64::from((*sides).max(3))).sin());
let kind = super::corner_style(app, n).1;
let kinds: Vec<&str> = CornerKind::ALL.iter().map(|k| corner_kind(*k)).collect();
// The `object.setLiveShape` param an edited field sets.
let mut set = None;
egui::Grid::new((id, "grid")).num_columns(2).spacing([6.0, 6.0]).show(ui, |ui| {
widgets::dim_label(ui, tl!("Sides:"));
if let Some(s) = widgets::plain_field(ui, (id, "sides"), f64::from(*sides), "", 0, fw) {
set = Some(("sides", json!(s.round().max(3.0) as u64)));
}
ui.end_row();
widgets::dim_label(ui, tl!("Angle:"));
if let Some(a) = widgets::plain_field(ui, (id, "angle"), live.polygon_angle().unwrap_or(0.0), "°", 1, fw) {
set = Some(("polygonAngle", json!(a)));
}
ui.end_row();
widgets::dim_label(ui, tl!("Corner:"));
if let Some(k) = widgets::dropdown_names(ui, (id, "kind"), kind.map_or("", corner_kind), &kinds, fw).and_then(|i| CornerKind::ALL.get(i)) {
set = Some(("kind", json!(k)));
}
ui.end_row();
widgets::dim_label(ui, tl!("Corner Radius:"));
super::corner_radius_field(app, ui, n, (id, "cornerRadius"), fw);
ui.end_row();
widgets::dim_label(ui, tl!("Radius:"));
if let Some(r) = widgets::num_field(ui, (id, "radius"), radius, units, fw) {
set = Some(("polygonRadius", json!(r)));
}
ui.end_row();
widgets::dim_label(ui, tl!("Side Length:"));
if let Some(l) = widgets::num_field(ui, (id, "side"), side, units, fw) {
set = Some(("sideLength", json!(l)));
}
ui.end_row();
});
if ui.add_enabled_ui(!live.polygon_sides_equal(), |ui| widgets::flat_button(ui, tl!("Make Sides Equal"), 130.0)).inner.clicked() {
set = Some(("makeSidesEqual", json!(true)));
}
if let Some((key, v)) = set {
app.run("object.setLiveShape", json!({ key: v })).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) {
@ -335,6 +381,31 @@ mod tests {
assert!(shown.iter().any(|t| t == "0°") && !shown.iter().any(|t| t == "100 pt"), "{shown:?}");
}
/// #812: a selected live polygon shows its Polygon Properties in the Transform panel and the
/// Properties panel's Transform section, and Make Sides Equal evens it out after a stretch.
#[test]
fn a_polygon_shows_its_polygon_properties() {
let mut app = VectorcraftApp::new(vectorcraft_engine::Session::new(), Default::default());
app.run("file.new", json!({"width": 300, "height": 300})).unwrap();
app.run("shape.polygon", json!({"cx": 150, "cy": 150, "radius": 50, "sides": 6})).unwrap();
for draw in [show as fn(&mut VectorcraftApp, &mut Ui), crate::panels::properties::transform_section] {
let shown = texts(&mut app, draw);
// A hexagon's side is as long as its radius.
for t in ["Sides:", "6", "Angle:", "0°", "Corner:", "Round", "Corner Radius:", "Radius:", "Side Length:", "50 pt", "Make Sides Equal"] {
assert!(shown.iter().any(|s| s == t), "{t} in {shown:?}");
}
}
assert!(texts(&mut app, show).iter().any(|s| s == "Polygon Properties:"));
app.run("object.scale", json!({"sx": 200, "sy": 100})).unwrap();
let live = |app: &VectorcraftApp| match &first_selected(app).unwrap().kind {
NodeKind::Path { live: Some(l), .. } => l.clone(),
k => panic!("{k:?}"),
};
assert!(!live(&app).polygon_sides_equal());
app.run("object.setLiveShape", json!({"makeSidesEqual": true})).unwrap();
assert!(live(&app).polygon_sides_equal());
}
#[test]
fn the_artboard_tool_shows_the_active_artboard() {
let mut app = VectorcraftApp::new(vectorcraft_engine::Session::new(), Default::default());

View File

@ -1340,6 +1340,19 @@ dialog `corners`: `kind`, `radius`; OK runs `object.setLiveShape`).
{"name":"run_command","arguments":{"command":"object.setLiveShape","params":{"radius":5}}}
```
The same command sets the rest of a live shape's properties, as the Properties and Transform panels show them. A
polygon (Polygon Properties): `sides` (3–1000), `polygonAngle` (degrees counterclockwise, its first vertex straight up
at 0), `polygonRadius` (pt, centre to vertex), `sideLength` (pt, which sets the radius) and `makeSidesEqual: true`,
which drops an uneven scale or shear (its sides then differ) and keeps its centre, angle and mean radius; a radius or
length that isn't a number above 0 is an error. An ellipse (Ellipse Properties): `pieStart` and `pieEnd` (degrees
counterclockwise from 3 o'clock; 0 to 360 is the whole ellipse) and `invertPie: true`, which swaps them. Each call is
one undo step.
```json
{"name":"run_command","arguments":{"command":"object.setLiveShape","params":{"sides":8,"polygonAngle":22.5,"sideLength":40}}}
{"name":"run_command","arguments":{"command":"object.setLiveShape","params":{"makeSidesEqual":true}}}
```
## Use Preview Bounds
With the preference `usePreviewBounds` on (`prefs.set {key: "usePreviewBounds", value: true}`; also the Align panel