//! Shape creation commands. Every shape takes optional attribute overrides //! (see [`STYLE_PARAMS`]); missing attributes come from the indicator. use super::{num, num_or, opt_bool, opt_num, opt_str, points, string, CmdResult, Command}; use crate::{import, Engine, EngineError}; use rustfig_geom::{Point, Rect}; use rustfig_model::{ Arc, ColorRef, Ellipse, EllipseKind, Justify, LineKind, Object, ObjectKind, Picture, PolyKind, Polyline, Spline, SplineKind, Style, Text, }; use serde_json::{json, Value}; use std::sync::Arc as Shared; /// Attribute overrides accepted by every shape command. pub const STYLE_PARAMS: &str = "\"depth\"?, \"thickness\"?, \"pen_color\"?, \"fill_color\"?, \"area_fill\"?, \"line\"?: solid|dashed|dotted|dash-dotted|..., \"style_val\"?, \"forward_arrow\"?: bool, \"backward_arrow\"?: bool"; macro_rules! shape_cmd { ($id:expr, $label:expr, $params:expr, $run:expr) => { Command { id: $id, label: $label, menu: "", shortcut: "", params: $params, mutates: true, enabled: super::always, run: $run, } }; } pub(super) fn commands() -> Vec { vec![ shape_cmd!( "shape.rectangle", "Add rectangle", "{\"x\", \"y\", \"width\", \"height\", \"radius\"?: 1/80 inch (rounded corners)} + style", rectangle ), shape_cmd!("shape.ellipse", "Add ellipse", "{\"cx\", \"cy\", \"rx\", \"ry\", \"angle\"?: degrees} + style", ellipse), shape_cmd!("shape.circle", "Add circle", "{\"cx\", \"cy\", \"r\"} + style", circle), shape_cmd!("shape.polyline", "Add polyline", "{\"points\": [[x,y],...]} + style", polyline), shape_cmd!("shape.polygon", "Add polygon", "{\"points\": [[x,y],...]} + style", polygon), shape_cmd!( "shape.spline", "Add spline", "{\"points\": [[x,y],...], \"closed\"?: bool, \"kind\"?: approximated|interpolated|x, \"shapes\"?: [s]} + style", spline ), shape_cmd!( "shape.arc", "Add arc", "{\"points\": [p1, p2, p3], \"pie\"?: bool} — circular arc through three points + style", arc ), shape_cmd!( "shape.regular_polygon", "Add regular polygon", "{\"cx\", \"cy\", \"r\", \"sides\"?: number, \"angle\"?: degrees} + style", regpoly ), shape_cmd!( "shape.text", "Add text", "{\"x\", \"y\", \"text\", \"size\"?: points, \"font\"?: number, \"postscript\"?: bool, \"justify\"?: left|center|right, \"angle\"?: degrees, \"color\"?, \"depth\"?}", text ), shape_cmd!( "shape.picture", "Add picture", "{\"path\": string, \"x\", \"y\", \"width\"?, \"height\"?, \"keep_aspect\"?: bool} — width/height default to the image size at 80 dpi; keep_aspect fits the picture into width x height without distorting it, at the corner (x, y)", picture ), shape_cmd!( "object.add", "Add object (raw)", "{\"object\": object JSON as returned by object.get}", add_raw ), ] } pub(crate) fn style_from(e: &Engine, p: &Value) -> Result { let mut s = e.indicator.style(); if let Some(v) = opt_num(p, "depth")? { s.depth = (v.round() as i32).clamp(0, rustfig_model::MAX_DEPTH); } if let Some(v) = opt_num(p, "thickness")? { s.thickness = (v.round() as i32).clamp(0, 10_000); } if let Some(v) = opt_num(p, "pen_color")? { s.pen_color = ColorRef((v.round() as i32).clamp(-1, rustfig_model::LAST_USER_COLOR)); } if let Some(v) = opt_num(p, "fill_color")? { s.fill_color = ColorRef((v.round() as i32).clamp(-1, rustfig_model::LAST_USER_COLOR)); } if let Some(v) = opt_num(p, "area_fill")? { s.area_fill = (v.round() as i32).clamp(-1, 62); } if let Some(name) = opt_str(p, "line")? { s.line = LineKind::from_name(name).ok_or_else(|| EngineError::bad_params(format!("unknown line style {name:?}")))?; if s.line != LineKind::Solid && s.style_val <= 0.0 { s.style_val = e.indicator.style_val; } } if let Some(v) = opt_num(p, "style_val")? { s.style_val = v.clamp(0.0, 1000.0); } Ok(s) } pub(super) fn arrows_from( e: &Engine, p: &Value, ) -> Result<(Option, Option), EngineError> { let (mut f, mut b) = e.indicator.arrows(); let template = e.indicator.arrows(); let a = e.indicator.arrow_value(); if let Some(on) = opt_bool(p, "forward_arrow")? { f = if on { template.0.or(Some(a)) } else { None }; } if let Some(on) = opt_bool(p, "backward_arrow")? { b = if on { template.1.or(Some(a)) } else { None }; } Ok((f, b)) } pub(super) fn added(e: &mut Engine, o: Object) -> CmdResult { let id = e.add_object(o); e.set_selection(vec![id]); Ok(json!({"id": id.0})) } fn rectangle(e: &mut Engine, p: &Value) -> CmdResult { let (x, y, w, h) = (num(p, "x")?, num(p, "y")?, num(p, "width")?, num(p, "height")?); // xfig's `create_boxobject`: a box without area is refused (the Fig // reader drops it, so it would vanish on save). Fig stores whole units. if x.round() == (x + w).round() || y.round() == (y + h).round() { return Err(EngineError::bad_params("Box must have area")); } let style = style_from(e, p)?; let mut pl = Polyline::rect(style, Point::new(x, y), Point::new(x + w, y + h)); if let Some(r) = opt_num(p, "radius")? { pl.kind = PolyKind::ArcBox; pl.radius = (r.round() as i32).clamp(0, 100_000); } added(e, Object::new(ObjectKind::Polyline(pl))) } /// The vertices of xfig's regular polygon (`create_regpoly`, `d_regpoly.c`): /// the point clicked last first, then one vertex per step of `2 pi / sides` /// against the angle of that point seen from the centre (y down), each /// rounded to whole Fig units like xfig's integers. pub(crate) fn xfig_regular_polygon(center: Point, first: Point, sides: usize) -> Vec { let n = sides.clamp(3, 1000); let d = first - center; let mag = d.x.hypot(d.y); let init = d.y.atan2(d.x).rem_euclid(std::f64::consts::TAU); let mut pts = vec![first]; for i in 1..n { let angle = (init - std::f64::consts::TAU * i as f64 / n as f64).rem_euclid(std::f64::consts::TAU); pts.push(Point::new( center.x + (mag * angle.cos()).round(), center.y + (mag * angle.sin()).round(), )); } pts } pub(crate) fn make_ellipse(style: Style, center: Point, rx: f64, ry: f64, angle: f64, kind: EllipseKind) -> Object { Object::new(ObjectKind::Ellipse(Ellipse { kind, style, direction: 1, angle, center, radii: Point::new(rx.abs(), ry.abs()), start: center, end: Point::new(center.x + rx.abs(), center.y + ry.abs()), })) } fn ellipse(e: &mut Engine, p: &Value) -> CmdResult { let c = Point::new(num(p, "cx")?, num(p, "cy")?); let (rx, ry) = (num(p, "rx")?, num(p, "ry")?); // xfig deletes an ellipse without a radius when it reads the file. if rx.abs() < 1.0 || ry.abs() < 1.0 { return Err(EngineError::bad_params( "an ellipse needs two radii of at least 1 Fig unit", )); } let angle = num_or(p, "angle", 0.0)?.to_radians(); let style = style_from(e, p)?; added(e, make_ellipse(style, c, rx, ry, angle, EllipseKind::EllipseRadii)) } fn circle(e: &mut Engine, p: &Value) -> CmdResult { let c = Point::new(num(p, "cx")?, num(p, "cy")?); let r = num(p, "r")?; if r.abs() < 1.0 { return Err(EngineError::bad_params( "a circle needs a radius of at least 1 Fig unit", )); } let style = style_from(e, p)?; added(e, make_ellipse(style, c, r, r, 0.0, EllipseKind::CircleRadius)) } fn need(pts: &[Point], n: usize, what: &str) -> Result<(), EngineError> { if pts.len() < n { return Err(EngineError::bad_params(format!("a {what} needs at least {n} points"))); } Ok(()) } fn polyline(e: &mut Engine, p: &Value) -> CmdResult { let pts = points(p, "points")?; need(&pts, 1, "polyline")?; let mut pl = Polyline::new(PolyKind::Polyline, style_from(e, p)?, pts); (pl.forward_arrow, pl.backward_arrow) = arrows_from(e, p)?; added(e, Object::new(ObjectKind::Polyline(pl))) } fn polygon(e: &mut Engine, p: &Value) -> CmdResult { let pts = points(p, "points")?; need(&pts, 3, "polygon")?; let pl = Polyline::new(PolyKind::Polygon, style_from(e, p)?, pts); added(e, Object::new(ObjectKind::Polyline(pl))) } fn spline(e: &mut Engine, p: &Value) -> CmdResult { let pts = points(p, "points")?; let closed = opt_bool(p, "closed")?.unwrap_or(false); need(&pts, if closed { 3 } else { 2 }, "spline")?; let kind = match opt_str(p, "kind")?.unwrap_or("approximated") { "approximated" | "approx" => SplineKind::Approximated, "interpolated" | "interp" => SplineKind::Interpolated, "x" | "xspline" => SplineKind::X, other => return Err(EngineError::bad_params(format!("unknown spline kind {other:?}"))), }; let mut s = Spline::new(kind, closed, style_from(e, p)?, pts); if let Some(Value::Array(a)) = p.get("shapes") { if a.len() != s.points.len() { return Err(EngineError::bad_params("\"shapes\" needs one value per point")); } s.shapes = a .iter() .map(|v| v.as_f64().filter(|f| f.is_finite()).map(|f| f.clamp(-1.0, 1.0))) .collect::>>() .ok_or_else(|| EngineError::bad_params("shapes must be numbers in [-1, 1]"))?; } if !closed { (s.forward_arrow, s.backward_arrow) = arrows_from(e, p)?; } added(e, Object::new(ObjectKind::Spline(s))) } fn arc(e: &mut Engine, p: &Value) -> CmdResult { let pts = points(p, "points")?; let [a, b, c] = pts.as_slice() else { return Err(EngineError::bad_params("an arc needs exactly three points")); }; let pie = opt_bool(p, "pie")?.unwrap_or(false); let mut arc = Arc::through(style_from(e, p)?, *a, *b, *c, pie) .ok_or_else(|| EngineError::bad_params("the three points of an arc must not be collinear"))?; if !pie { (arc.forward_arrow, arc.backward_arrow) = arrows_from(e, p)?; } added(e, Object::new(ObjectKind::Arc(arc))) } fn regpoly(e: &mut Engine, p: &Value) -> CmdResult { let c = Point::new(num(p, "cx")?, num(p, "cy")?); let r = num(p, "r")?; // A polygon of zero size has no shape (and the Fig reader drops it). if !r.is_finite() || r.abs() < 1.0 { return Err(EngineError::bad_params("the radius must be at least 1")); } let sides = num_or(p, "sides", e.indicator.sides as f64)?.round().clamp(3.0, 1000.0) as usize; let a = num_or(p, "angle", -90.0)?.to_radians(); let first = Point::new(c.x + r * a.cos(), c.y + r * a.sin()); let mut pl = Polyline::new( PolyKind::Polygon, style_from(e, p)?, xfig_regular_polygon(c, first, sides), ); pl.radius = 0; added(e, Object::new(ObjectKind::Polyline(pl))) } pub(crate) fn parse_justify(s: &str) -> Result { match s { "left" => Ok(Justify::Left), "center" | "centre" => Ok(Justify::Center), "right" => Ok(Justify::Right), other => Err(EngineError::bad_params(format!("unknown justification {other:?}"))), } } pub(crate) fn make_text(e: &Engine, origin: Point, s: String) -> Text { let ind = &e.indicator; Text { justify: ind.justify, color: ColorRef(ind.pen_color), depth: ind.depth.clamp(0, rustfig_model::MAX_DEPTH), pen_style: -1, font: ind.font, size: ind.font_size.clamp(1.0, 2000.0), angle: ind.text_angle.to_radians(), flags: ind.text_flags(), height: 0.0, length: 0.0, origin, text: s, local_font: ind.local_font.clone(), } } fn text(e: &mut Engine, p: &Value) -> CmdResult { let origin = Point::new(num(p, "x")?, num(p, "y")?); let s = string(p, "text")?; if s.len() > rustfig_format::MAX_TEXT_BYTES { return Err(EngineError::bad_params("text is too long")); } let mut t = make_text(e, origin, s); if let Some(v) = opt_num(p, "size")? { t.size = v.clamp(1.0, 2000.0); } if let Some(v) = opt_num(p, "font")? { t.font = (v.round() as i32).clamp(-1, 34); t.local_font = None; } if let Some(ps) = opt_bool(p, "postscript")? { if ps { t.flags |= rustfig_model::text_flags::POSTSCRIPT; } else { t.flags &= !rustfig_model::text_flags::POSTSCRIPT; } } if let Some(j) = opt_str(p, "justify")? { t.justify = parse_justify(j)?; } if let Some(a) = opt_num(p, "angle")? { t.angle = a.to_radians(); } if let Some(c) = opt_num(p, "color")? { t.color = ColorRef((c.round() as i32).clamp(-1, rustfig_model::LAST_USER_COLOR)); } if let Some(d) = opt_num(p, "depth")? { t.depth = (d.round() as i32).clamp(0, rustfig_model::MAX_DEPTH); } added(e, Object::new(ObjectKind::Text(t))) } fn picture(e: &mut Engine, p: &Value) -> CmdResult { let path = string(p, "path")?; let img = import::load_picture(std::path::Path::new(&path)).map_err(EngineError::io)?; let (x, y) = (num(p, "x")?, num(p, "y")?); // Default size: 80 pixels per inch, like xfig's screen resolution. let w = opt_num(p, "width")?.unwrap_or(f64::from(img.width) * 15.0); let h = opt_num(p, "height")?.unwrap_or(f64::from(img.height) * 15.0); let (w, h) = if opt_bool(p, "keep_aspect")?.unwrap_or(false) && img.width > 0 && img.height > 0 { // Fit inside the box, keeping the picture's proportions. let k = (w.abs() / f64::from(img.width)).min(h.abs() / f64::from(img.height)); let sign = |v: f64| if v < 0.0 { -1.0 } else { 1.0 }; (f64::from(img.width) * k * sign(w), f64::from(img.height) * k * sign(h)) } else { (w, h) }; let r = Rect::new(Point::new(x, y), Point::new(x + w, y + h)); let mut pl = Polyline::new(PolyKind::Picture, style_from(e, p)?, r.corners().to_vec()); pl.style.thickness = 0; pl.picture = Some(Picture { flipped: false, file: path, image: Some(Shared::new(img)), }); added(e, Object::new(ObjectKind::Polyline(pl))) } fn add_raw(e: &mut Engine, p: &Value) -> CmdResult { let v = p .get("object") .ok_or_else(|| EngineError::bad_params("missing \"object\""))?; let obj: Object = serde_json::from_value(v.clone()).map_err(|err| EngineError::bad_params(format!("bad object: {err}")))?; validate(&obj, 0)?; added(e, obj) } /// Reject objects with non-finite coordinates or absurd sizes. pub(crate) fn validate(o: &Object, depth: usize) -> Result<(), EngineError> { if depth > rustfig_format::MAX_NESTING { return Err(EngineError::bad_params("compound nested too deeply")); } let bad = || EngineError::bad_params("object has invalid coordinates"); let fin = |p: &Point| p.is_finite() && p.x.abs() < 1.0e9 && p.y.abs() < 1.0e9; match &o.kind { ObjectKind::Compound(c) => { for x in &c.objects { validate(x, depth + 1)?; } } ObjectKind::Text(t) => { if !fin(&t.origin) || !t.size.is_finite() || !t.angle.is_finite() { return Err(bad()); } } ObjectKind::Ellipse(el) => { if !fin(&el.center) || !fin(&el.radii) || !el.angle.is_finite() { return Err(bad()); } } _ => { let pts = o.points().unwrap_or(&[]); if pts.len() > super::MAX_PARAM_POINTS || !pts.iter().all(fin) { return Err(bad()); } } } Ok(()) } .