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:
parent
5305b80308
commit
7c0ce8a382
File diff suppressed because one or more lines are too long
|
|
@ -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 };
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
|
|
|||
|
|
@ -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}");
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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() {
|
||||
|
|
|
|||
|
|
@ -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") {
|
||||
|
|
|
|||
|
|
@ -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.
|
|
|
@ -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.
|
|
|
@ -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.
|
|
|
@ -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.
|
|
|
@ -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.
|
|
|
@ -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.
|
|
|
@ -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.
|
|
|
@ -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.
|
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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"),
|
||||
_ => {}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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());
|
||||
|
|
|
|||
13
docs/mcp.md
13
docs/mcp.md
|
|
@ -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
|
||||
|
|
|
|||
Loading…
Reference in New Issue