Files
figcraft/crates/doc/src/board.rs
T

623 lines
24 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
//! Boards: a file kind for whiteboarding, and the small piece of metadata board objects carry.
//!
//! Board objects are ordinary nodes (frames, vectors, text) with a [`BoardItem`] on them, so
//! rendering, hit testing, undo, saving, export, copy and paste and live sessions treat them like
//! any other layer. The metadata says what the object is for: which text a sticky note fits, what
//! a connector joins, who placed a stamp.
use figcraft_color::Color;
use figcraft_geom::kurbo::{Ellipse, RoundedRect};
use figcraft_geom::{BezPath, Point, Rect, Shape, Vec2};
use serde::{Deserialize, Serialize};
use crate::NodeId;
/// What a file is for: designing screens, or a board to think together on.
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Hash, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub enum FileKind {
#[default]
Design,
Board,
}
impl FileKind {
pub fn is_design(&self) -> bool {
*self == FileKind::Design
}
pub fn id(self) -> &'static str {
match self {
FileKind::Design => "design",
FileKind::Board => "board",
}
}
}
/// What a board object is.
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
#[serde(tag = "type", rename_all = "camelCase")]
pub enum BoardItem {
/// A sticky note: a frame whose first text layer is the note (its size shrinks as the note
/// grows) and, below it, who wrote it.
Sticky {
#[serde(default, skip_serializing_if = "String::is_empty")]
author: String,
},
/// A shape with text: a frame holding the outline (a vector) and a centred text.
Shape { shape: BoardShape },
/// A line between two objects or points (a vector, re-routed when they move).
Connector(Connector),
/// The label in the middle of connector `connector` (a frame holding its text).
Label { connector: NodeId },
/// A stamp, who placed it, and the object it sits on (it moves with it).
Stamp {
stamp: Stamp,
#[serde(default, skip_serializing_if = "String::is_empty")]
author: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
on: Option<NodeId>,
},
/// A marker or highlighter stroke (what the eraser takes).
Ink,
/// A table: a frame of row frames holding cell frames with text (its column lines are thin
/// rectangles laid over it).
Table,
}
impl BoardItem {
/// Objects selected and moved as a whole: a click on their text or outline picks them, and
/// they never take other layers in.
pub fn is_atom(&self) -> bool {
matches!(self, BoardItem::Sticky { .. } | BoardItem::Shape { .. } | BoardItem::Label { .. } | BoardItem::Stamp { .. } | BoardItem::Table)
}
/// Objects whose first text layer is their text (a double-click or Enter edits it).
pub fn has_text(&self) -> bool {
matches!(self, BoardItem::Sticky { .. } | BoardItem::Shape { .. } | BoardItem::Label { .. })
}
/// Who made it (stickies and stamps; empty when unknown).
pub fn author(&self) -> &str {
match self {
BoardItem::Sticky { author } | BoardItem::Stamp { author, .. } => author,
_ => "",
}
}
/// Point the layers this object refers to (a connector's ends, a label's connector) at their
/// copies in `map`; the others stay.
pub fn remap(&mut self, map: &std::collections::BTreeMap<NodeId, NodeId>) {
match self {
BoardItem::Connector(c) => {
for end in [&mut c.from, &mut c.to] {
if let End::Node { node, .. } = end
&& let Some(m) = map.get(node)
{
*node = *m;
}
}
}
BoardItem::Label { connector } => {
if let Some(m) = map.get(connector) {
*connector = *m;
}
}
_ => {}
}
}
pub fn kind_id(&self) -> &'static str {
match self {
BoardItem::Sticky { .. } => "sticky",
BoardItem::Shape { .. } => "shape",
BoardItem::Connector(_) => "connector",
BoardItem::Label { .. } => "label",
BoardItem::Stamp { .. } => "stamp",
BoardItem::Ink => "ink",
BoardItem::Table => "table",
}
}
}
/// The colours stickies, shapes and connectors come in: soft tints that dark text reads on.
pub const BOARD_COLORS: [(&str, Color); 8] = [
("yellow", Color::from_rgb8_const(0xFF, 0xE7, 0x8A)),
("orange", Color::from_rgb8_const(0xFF, 0xC9, 0x94)),
("red", Color::from_rgb8_const(0xFF, 0xAE, 0xA6)),
("pink", Color::from_rgb8_const(0xF7, 0xB9, 0xDA)),
("violet", Color::from_rgb8_const(0xD4, 0xC2, 0xFC)),
("blue", Color::from_rgb8_const(0xAC, 0xD3, 0xFA)),
("green", Color::from_rgb8_const(0xC2, 0xE8, 0xA6)),
("gray", Color::from_rgb8_const(0xE3, 0xE1, 0xDD)),
];
/// A board colour by name ("yellow") or as hex ("#FFE78A").
pub fn board_color(s: &str) -> Option<Color> {
let s = s.trim();
BOARD_COLORS.iter().find(|(n, _)| n.eq_ignore_ascii_case(s)).map(|(_, c)| *c).or_else(|| Color::from_hex(s))
}
/// The page colour boards start with: a warm light grey the colours stand out on.
pub const BOARD_PAGE: Color = Color::from_rgb8_const(0xF4, 0xF3, 0xF0);
/// The outlines shapes with text come in: the basic ones, then arrows and flowchart symbols.
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Hash, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub enum BoardShape {
#[default]
Rectangle,
RoundedRectangle,
Ellipse,
Diamond,
Triangle,
SpeechBubble,
Chevron,
// Added later: after the first ones, which keep their place in binary encodings.
TriangleDown,
ParallelogramRight,
ParallelogramLeft,
Trapezoid,
Pentagon,
Hexagon,
Octagon,
Star,
Plus,
ArrowLeft,
ArrowRight,
Database,
Document,
PredefinedProcess,
ManualInput,
InternalStorage,
}
impl BoardShape {
pub const ALL: [BoardShape; 23] = [
BoardShape::Rectangle,
BoardShape::RoundedRectangle,
BoardShape::Ellipse,
BoardShape::Diamond,
BoardShape::Triangle,
BoardShape::TriangleDown,
BoardShape::ParallelogramRight,
BoardShape::ParallelogramLeft,
BoardShape::Trapezoid,
BoardShape::Pentagon,
BoardShape::Hexagon,
BoardShape::Octagon,
BoardShape::Star,
BoardShape::Plus,
BoardShape::ArrowLeft,
BoardShape::ArrowRight,
BoardShape::Chevron,
BoardShape::SpeechBubble,
BoardShape::Database,
BoardShape::Document,
BoardShape::PredefinedProcess,
BoardShape::ManualInput,
BoardShape::InternalStorage,
];
/// The id used in commands and saved files, and the English name shown in the UI.
fn names(self) -> (&'static str, &'static str) {
match self {
BoardShape::Rectangle => ("rectangle", "Rectangle"),
BoardShape::RoundedRectangle => ("roundedRectangle", "Rounded rectangle"),
BoardShape::Ellipse => ("ellipse", "Ellipse"),
BoardShape::Diamond => ("diamond", "Diamond"),
BoardShape::Triangle => ("triangle", "Triangle"),
BoardShape::TriangleDown => ("triangleDown", "Triangle down"),
BoardShape::ParallelogramRight => ("parallelogramRight", "Parallelogram right"),
BoardShape::ParallelogramLeft => ("parallelogramLeft", "Parallelogram left"),
BoardShape::Trapezoid => ("trapezoid", "Trapezoid"),
BoardShape::Pentagon => ("pentagon", "Pentagon"),
BoardShape::Hexagon => ("hexagon", "Hexagon"),
BoardShape::Octagon => ("octagon", "Octagon"),
BoardShape::Star => ("star", "Star"),
BoardShape::Plus => ("plus", "Plus"),
BoardShape::ArrowLeft => ("arrowLeft", "Arrow left"),
BoardShape::ArrowRight => ("arrowRight", "Arrow right"),
BoardShape::Chevron => ("chevron", "Chevron"),
BoardShape::SpeechBubble => ("speechBubble", "Speech bubble"),
BoardShape::Database => ("database", "Database"),
BoardShape::Document => ("document", "Document"),
BoardShape::PredefinedProcess => ("predefinedProcess", "Predefined process"),
BoardShape::ManualInput => ("manualInput", "Manual input"),
BoardShape::InternalStorage => ("internalStorage", "Internal storage"),
}
}
pub fn id(self) -> &'static str {
self.names().0
}
pub fn from_id(s: &str) -> Option<BoardShape> {
BoardShape::ALL.into_iter().find(|b| b.id().eq_ignore_ascii_case(s))
}
pub fn label(self) -> &'static str {
self.names().1
}
/// The outline in a `w` × `h` box at the origin.
pub fn path(self, w: f64, h: f64) -> BezPath {
let poly = |pts: &[(f64, f64)]| {
let mut p = BezPath::new();
for (i, (x, y)) in pts.iter().enumerate() {
let q = Point::new(x * w, y * h);
if i == 0 { p.move_to(q) } else { p.line_to(q) }
}
p.close_path();
p
};
let r = w.min(h);
match self {
BoardShape::Rectangle => Rect::new(0.0, 0.0, w, h).to_path(0.1),
BoardShape::RoundedRectangle => RoundedRect::new(0.0, 0.0, w, h, r * 0.16).to_path(0.1),
BoardShape::Ellipse => Ellipse::from_rect(Rect::new(0.0, 0.0, w, h)).to_path(0.1),
BoardShape::Diamond => poly(&[(0.5, 0.0), (1.0, 0.5), (0.5, 1.0), (0.0, 0.5)]),
BoardShape::Triangle => poly(&[(0.5, 0.0), (1.0, 1.0), (0.0, 1.0)]),
BoardShape::TriangleDown => poly(&[(0.0, 0.0), (1.0, 0.0), (0.5, 1.0)]),
BoardShape::ParallelogramRight => poly(&[(0.2, 0.0), (1.0, 0.0), (0.8, 1.0), (0.0, 1.0)]),
BoardShape::ParallelogramLeft => poly(&[(0.0, 0.0), (0.8, 0.0), (1.0, 1.0), (0.2, 1.0)]),
BoardShape::Trapezoid => poly(&[(0.2, 0.0), (0.8, 0.0), (1.0, 1.0), (0.0, 1.0)]),
BoardShape::Pentagon => poly(&[(0.5, 0.0), (1.0, 0.38), (0.81, 1.0), (0.19, 1.0), (0.0, 0.38)]),
BoardShape::Hexagon => poly(&[(0.25, 0.0), (0.75, 0.0), (1.0, 0.5), (0.75, 1.0), (0.25, 1.0), (0.0, 0.5)]),
BoardShape::Octagon => poly(&[(0.3, 0.0), (0.7, 0.0), (1.0, 0.3), (1.0, 0.7), (0.7, 1.0), (0.3, 1.0), (0.0, 0.7), (0.0, 0.3)]),
BoardShape::Star => poly(&star_points()),
BoardShape::Plus => poly(&[
(THIRD, 0.0),
(2.0 * THIRD, 0.0),
(2.0 * THIRD, THIRD),
(1.0, THIRD),
(1.0, 2.0 * THIRD),
(2.0 * THIRD, 2.0 * THIRD),
(2.0 * THIRD, 1.0),
(THIRD, 1.0),
(THIRD, 2.0 * THIRD),
(0.0, 2.0 * THIRD),
(0.0, THIRD),
(THIRD, THIRD),
]),
BoardShape::ArrowLeft => poly(&[(1.0, 0.25), (0.4, 0.25), (0.4, 0.0), (0.0, 0.5), (0.4, 1.0), (0.4, 0.75), (1.0, 0.75)]),
BoardShape::ArrowRight => poly(&[(0.0, 0.25), (0.6, 0.25), (0.6, 0.0), (1.0, 0.5), (0.6, 1.0), (0.6, 0.75), (0.0, 0.75)]),
BoardShape::Chevron => poly(&[(0.0, 0.0), (0.72, 0.0), (1.0, 0.5), (0.72, 1.0), (0.0, 1.0), (0.28, 0.5)]),
BoardShape::ManualInput => poly(&[(0.0, 0.25), (1.0, 0.0), (1.0, 1.0), (0.0, 1.0)]),
BoardShape::Database => database(w, h),
BoardShape::Document => document(w, h),
BoardShape::PredefinedProcess => with_lines(w, h, &[[0.1, 0.0, 0.1, 1.0], [0.9, 0.0, 0.9, 1.0]]),
BoardShape::InternalStorage => with_lines(w, h, &[[0.12, 0.0, 0.12, 1.0], [0.0, 0.12, 1.0, 0.12]]),
BoardShape::SpeechBubble => {
// A rounded body over the bottom fifth, with a tail down to the left.
let (b, c) = (h * 0.8, r * 0.14);
let mut p = BezPath::new();
p.move_to((c, 0.0));
p.line_to((w - c, 0.0));
p.quad_to((w, 0.0), (w, c));
p.line_to((w, b - c));
p.quad_to((w, b), (w - c, b));
p.line_to((w * 0.42, b));
p.line_to((w * 0.18, h));
p.line_to((w * 0.24, b));
p.line_to((c, b));
p.quad_to((0.0, b), (0.0, b - c));
p.line_to((0.0, c));
p.quad_to((0.0, 0.0), (c, 0.0));
p.close_path();
p
}
}
}
/// Where the text goes, as insets of the box (top, right, bottom, left, in fractions of its
/// size): inside the outline's widest part.
pub fn text_insets(self) -> [f64; 4] {
match self {
BoardShape::Rectangle | BoardShape::RoundedRectangle => [0.08, 0.08, 0.08, 0.08],
BoardShape::Ellipse => [0.16, 0.16, 0.16, 0.16],
BoardShape::Diamond => [0.25, 0.25, 0.25, 0.25],
BoardShape::Triangle => [0.45, 0.22, 0.06, 0.22],
BoardShape::TriangleDown => [0.06, 0.22, 0.45, 0.22],
BoardShape::ParallelogramRight | BoardShape::ParallelogramLeft | BoardShape::Hexagon => [0.08, 0.22, 0.08, 0.22],
BoardShape::Trapezoid => [0.1, 0.22, 0.08, 0.22],
BoardShape::Pentagon => [0.25, 0.18, 0.08, 0.18],
BoardShape::Octagon => [0.12, 0.12, 0.12, 0.12],
BoardShape::Star => [0.38, 0.32, 0.28, 0.32],
BoardShape::Plus => [0.34, 0.1, 0.34, 0.1],
BoardShape::ArrowLeft => [0.27, 0.04, 0.27, 0.3],
BoardShape::ArrowRight => [0.27, 0.3, 0.27, 0.04],
BoardShape::SpeechBubble => [0.08, 0.08, 0.28, 0.08],
BoardShape::Chevron => [0.12, 0.3, 0.12, 0.3],
BoardShape::Database => [0.24, 0.06, 0.1, 0.06],
BoardShape::Document => [0.08, 0.08, 0.2, 0.08],
BoardShape::PredefinedProcess => [0.08, 0.14, 0.08, 0.14],
BoardShape::ManualInput => [0.3, 0.08, 0.08, 0.08],
BoardShape::InternalStorage => [0.18, 0.08, 0.08, 0.18],
}
}
}
const THIRD: f64 = 1.0 / 3.0;
/// How far a cubic's handles reach to draw a quarter ellipse.
const QUARTER: f64 = 0.552_284_75;
/// A five-pointed star's points in the unit box, stretched to fill it (the lower points of a
/// regular star stop short of the bottom).
fn star_points() -> Vec<(f64, f64)> {
let pts: Vec<(f64, f64)> = (0..10)
.map(|i| {
let r = if i % 2 == 0 { 0.5 } else { 0.2 };
let a = f64::from(i) * std::f64::consts::PI / 5.0;
(0.5 + r * a.sin(), 0.5 - r * a.cos())
})
.collect();
let bottom = pts.iter().map(|p| p.1).fold(0.0, f64::max);
pts.into_iter().map(|(x, y)| (x, y / bottom)).collect()
}
/// A cylinder seen from the side: an elliptic top, with its near rim drawn, and an elliptic
/// bottom.
fn database(w: f64, h: f64) -> BezPath {
let (rx, ry) = (w / 2.0, (h * 0.1).min(w * 0.25));
let (k, kx) = (ry * QUARTER, rx * QUARTER);
let mut p = BezPath::new();
p.move_to((0.0, ry));
p.curve_to((0.0, ry - k), (rx - kx, 0.0), (rx, 0.0));
p.curve_to((rx + kx, 0.0), (w, ry - k), (w, ry));
p.line_to((w, h - ry));
p.curve_to((w, h - ry + k), (rx + kx, h), (rx, h));
p.curve_to((rx - kx, h), (0.0, h - ry + k), (0.0, h - ry));
p.close_path();
// The top's near rim: a line, not a region.
p.move_to((0.0, ry));
p.curve_to((0.0, ry + k), (rx - kx, 2.0 * ry), (rx, 2.0 * ry));
p.curve_to((rx + kx, 2.0 * ry), (w, ry + k), (w, ry));
p
}
/// A page whose bottom edge waves.
fn document(w: f64, h: f64) -> BezPath {
let mut p = BezPath::new();
p.move_to((0.0, 0.0));
p.line_to((w, 0.0));
p.line_to((w, h * 0.86));
p.curve_to((w * 0.7, h * 0.7), (w * 0.3, h * 1.04), (0.0, h * 0.86));
p.close_path();
p
}
/// A rectangle with straight `lines` across it (`[x0, y0, x1, y1]` in the unit box), drawn as
/// lines, not regions.
fn with_lines(w: f64, h: f64, lines: &[[f64; 4]]) -> BezPath {
let mut p = Rect::new(0.0, 0.0, w, h).to_path(0.1);
for [x0, y0, x1, y1] in lines {
p.move_to((x0 * w, y0 * h));
p.line_to((x1 * w, y1 * h));
}
p
}
/// The stamps: round badges with a mark.
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Hash, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub enum Stamp {
#[default]
ThumbsUp,
Heart,
PlusOne,
Star,
Question,
Check,
}
/// One part of a stamp's mark: SVG path data on the badge's 40-unit box, filled or stroked.
pub struct MarkPart {
pub d: &'static str,
pub filled: bool,
}
/// A stamp badge's size.
pub const STAMP_SIZE: f64 = 40.0;
impl Stamp {
pub const ALL: [Stamp; 6] = [Stamp::ThumbsUp, Stamp::Heart, Stamp::PlusOne, Stamp::Star, Stamp::Question, Stamp::Check];
pub fn id(self) -> &'static str {
match self {
Stamp::ThumbsUp => "thumbsUp",
Stamp::Heart => "heart",
Stamp::PlusOne => "plusOne",
Stamp::Star => "star",
Stamp::Question => "question",
Stamp::Check => "check",
}
}
pub fn from_id(s: &str) -> Option<Stamp> {
Stamp::ALL.into_iter().find(|b| b.id().eq_ignore_ascii_case(s))
}
pub fn label(self) -> &'static str {
match self {
Stamp::ThumbsUp => "Thumbs up",
Stamp::Heart => "Heart",
Stamp::PlusOne => "Plus one",
Stamp::Star => "Star",
Stamp::Question => "Question",
Stamp::Check => "Check",
}
}
/// The badge's colour (the mark is white on it).
pub fn color(self) -> Color {
match self {
Stamp::ThumbsUp => Color::from_rgb8_const(0x3B, 0x82, 0xE8),
Stamp::Heart => Color::from_rgb8_const(0xE5, 0x48, 0x5F),
Stamp::PlusOne => Color::from_rgb8_const(0x2E, 0xA0, 0x63),
Stamp::Star => Color::from_rgb8_const(0xEE, 0x9B, 0x1A),
Stamp::Question => Color::from_rgb8_const(0x84, 0x5E, 0xD8),
Stamp::Check => Color::from_rgb8_const(0x14, 0x96, 0x8C),
}
}
/// The mark, drawn for FigCraft from lines, arcs and simple outlines.
pub fn mark(self) -> &'static [MarkPart] {
match self {
// A cuff and a raised hand with its thumb up.
Stamp::ThumbsUp => &[
MarkPart { d: "M10.5 19 H15 V29 H10.5 Z", filled: true },
MarkPart {
d: "M16.5 19 L20.6 11.4 C21.6 9.8 24.6 10.6 24.2 13 L23.4 17 H28.2 C29.8 17 30.9 18.5 30.4 20 L28.4 27.3 C28 28.4 27.1 29 25.9 29 H16.5 Z",
filled: true,
},
],
// Two round lobes meeting in a point.
Stamp::Heart => &[MarkPart {
d: "M20 29.5 C12.5 24.5 9.5 20.6 10.4 16.4 C11.3 12.2 16.8 11.2 20 15.4 C23.2 11.2 28.7 12.2 29.6 16.4 C30.5 20.6 27.5 24.5 20 29.5 Z",
filled: true,
}],
Stamp::PlusOne => &[MarkPart { d: "M10 20 H20 M15 15 V25", filled: false }, MarkPart { d: "M24.5 15 L27.5 12.5 V27.5", filled: false }],
Stamp::Star => &[MarkPart {
d: "M20 10.5 L22.8 16.7 L29.5 17.4 L24.5 21.9 L25.9 28.6 L20 25.2 L14.1 28.6 L15.5 21.9 L10.5 17.4 L17.2 16.7 Z",
filled: true,
}],
Stamp::Question => &[
MarkPart { d: "M15.6 16.2 C15.6 10.6 24.4 10.6 24.4 16 C24.4 19.6 20 19.9 20 23.6", filled: false },
MarkPart { d: "M18.2 28 A1.8 1.8 0 1 0 21.8 28 A1.8 1.8 0 1 0 18.2 28 Z", filled: true },
],
Stamp::Check => &[MarkPart { d: "M12.5 20.5 L18 26 L28 14.5", filled: false }],
}
}
}
/// How a connector runs between its ends.
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Hash, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub enum Routing {
#[default]
Straight,
/// Right angles only: out of each end along its side's normal, turning once or twice, around
/// the objects it joins when the short way would cross them.
Elbow,
/// One smooth curve, leaving and arriving along the sides' normals.
Curved,
}
impl Routing {
pub fn id(self) -> &'static str {
match self {
Routing::Straight => "straight",
Routing::Elbow => "elbow",
Routing::Curved => "curved",
}
}
pub fn from_id(s: &str) -> Option<Routing> {
[Routing::Straight, Routing::Elbow, Routing::Curved].into_iter().find(|r| r.id().eq_ignore_ascii_case(s))
}
}
/// The side of an object a connector end is attached to (at its middle).
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Hash, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub enum Side {
/// The side facing the other end.
#[default]
Auto,
Top,
Right,
Bottom,
Left,
}
impl Side {
pub fn from_id(s: &str) -> Option<Side> {
Some(match s.to_ascii_lowercase().as_str() {
"auto" => Side::Auto,
"top" => Side::Top,
"right" => Side::Right,
"bottom" => Side::Bottom,
"left" => Side::Left,
_ => return None,
})
}
/// The side's middle on `b`.
pub fn point(self, b: Rect) -> Point {
let c = b.center();
match self {
Side::Top => Point::new(c.x, b.y0),
Side::Right => Point::new(b.x1, c.y),
Side::Bottom => Point::new(c.x, b.y1),
Side::Left => Point::new(b.x0, c.y),
Side::Auto => c,
}
}
pub fn horizontal(self) -> bool {
matches!(self, Side::Left | Side::Right)
}
/// The way out of the side (y down; none for [`Side::Auto`]).
pub fn normal(self) -> Vec2 {
match self {
Side::Top => Vec2::new(0.0, -1.0),
Side::Right => Vec2::new(1.0, 0.0),
Side::Bottom => Vec2::new(0.0, 1.0),
Side::Left => Vec2::new(-1.0, 0.0),
Side::Auto => Vec2::ZERO,
}
}
}
/// One end of a connector: attached to a layer, or a free point on the page.
#[derive(Clone, Copy, Debug, PartialEq, Serialize, Deserialize)]
#[serde(untagged)]
pub enum End {
Node {
node: NodeId,
#[serde(default)]
side: Side,
/// Where on the layer it sits by hand, as fractions of its box (`None`: on its side).
#[serde(default, skip_serializing_if = "Option::is_none")]
at: Option<[f64; 2]>,
},
Point {
x: f64,
y: f64,
},
}
impl End {
pub fn node(&self) -> Option<NodeId> {
match self {
End::Node { node, .. } => Some(*node),
End::Point { .. } => None,
}
}
}
/// A connector's path as reshaped by hand. Its numbers are measured from the middle of the
/// connector's two ends, so the shape travels with what the connector joins.
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub enum ConnectorPath {
/// An elbow's straight runs, each at right angles to the one before: whether the first runs
/// across (else up or down), and where the runs between the first and last lie (x of one that
/// runs up or down, y of one that runs across). The first and last lie on the ends.
Runs { across: bool, at: Vec<f64> },
/// How far a curve's middle was moved.
Bow { x: f64, y: f64 },
}
/// What a connector joins and how it runs (its arrowheads are its stroke's caps).
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
pub struct Connector {
pub from: End,
pub to: End,
#[serde(default)]
pub routing: Routing,
/// Its path as reshaped by hand; `None`: routed by itself.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub path: Option<ConnectorPath>,
}