Skip to main content

geop_lib_sheet/
lib.rs

1//! A [`Sheet`]: plain 2-D strokes and labels on paper, in millimetres with
2//! `y` up, each on a layer — what a drawing and a flat pattern are laid out
3//! as, and written in a [`Format`]: SVG ([`to_svg`]) or DXF ([`to_dxf`]).
4//!
5//! The kernel's curves become lines, arcs and circles where they are those
6//! exactly (see [`NurbCurve3D::as_line`], [`NurbCurve3D::as_arc`]), and
7//! polylines through points of them otherwise.
8//!
9//! Text in an SVG sheet, or in any other XML a writer makes, is escaped
10//! by [`xml_escape`].
11
12// Library code reports what goes wrong as an error, never by panicking;
13// tests may unwrap.
14#![cfg_attr(
15    not(test),
16    deny(
17        clippy::unwrap_used,
18        clippy::expect_used,
19        clippy::panic,
20        clippy::todo,
21        clippy::unimplemented,
22        clippy::unreachable
23    )
24)]
25
26mod dxf;
27mod svg;
28
29pub use dxf::to_dxf;
30pub use svg::to_svg;
31
32use geop_core_geometry::nurb_curve::NurbCurve2D;
33use geop_core_math::{
34    geop_error::GeopResult,
35    scalars::Scalar,
36    solvers::repeat::{Step, repeat},
37    vector::Vector3,
38};
39
40/// A point on the paper.
41pub type P = [f64; 2];
42
43/// `text` fit for XML: for an element's content, or an attribute's value
44/// between either kind of quotes.
45pub fn xml_escape(text: &str) -> String {
46    let mut out = String::with_capacity(text.len());
47    for c in text.chars() {
48        match c {
49            '&' => out.push_str("&"),
50            '<' => out.push_str("&lt;"),
51            '>' => out.push_str("&gt;"),
52            '"' => out.push_str("&quot;"),
53            '\'' => out.push_str("&apos;"),
54            _ => out.push(c),
55        }
56    }
57    out
58}
59
60/// What a stroke or label is, which decides how it is drawn: a layer of a
61/// DXF file, a class of an SVG one. The sheet knows no layer of its own:
62/// whoever draws on it defines the layers it draws on (a drawing its
63/// visible and hidden lines, a flat pattern what is cut and where it is
64/// bent).
65#[derive(Clone, Copy, Debug, PartialEq)]
66pub struct Layer {
67    /// Its name, as a DXF layer and an SVG class.
68    pub name: &'static str,
69    /// Its strokes' width, in millimetres.
70    pub width: f64,
71    pub line_type: LineType,
72    /// Its colour, by AutoCAD colour index.
73    pub color: u8,
74}
75
76/// How a layer's strokes are dashed.
77#[derive(Clone, Copy, Debug, PartialEq, Eq)]
78pub enum LineType {
79    Continuous,
80    /// Short dashes: what is hidden.
81    Hidden,
82    /// Long and short dashes: an axis, a centre, a bend.
83    Center,
84}
85
86impl LineType {
87    pub const ALL: [LineType; 3] = [LineType::Continuous, LineType::Hidden, LineType::Center];
88
89    /// Its name, as a DXF line type.
90    pub fn name(self) -> &'static str {
91        match self {
92            LineType::Continuous => "CONTINUOUS",
93            LineType::Hidden => "HIDDEN",
94            LineType::Center => "CENTER",
95        }
96    }
97
98    /// How a DXF file describes it.
99    pub fn description(self) -> &'static str {
100        match self {
101            LineType::Continuous => "Solid line",
102            LineType::Hidden => "Hidden __ __ __",
103            LineType::Center => "Center ____ _ ____",
104        }
105    }
106
107    /// Its dash pattern: lengths in millimetres, negative for gaps.
108    pub fn dashes(self) -> &'static [f64] {
109        match self {
110            LineType::Continuous => &[],
111            LineType::Hidden => &[3.0, -1.5],
112            LineType::Center => &[8.0, -1.5, 1.5, -1.5],
113        }
114    }
115}
116
117/// A shape on the paper.
118#[derive(Clone, Debug, PartialEq)]
119pub enum Shape {
120    Line(P, P),
121    /// Counter-clockwise around `center`, from the angle `start` to `end`,
122    /// in radians.
123    Arc {
124        center: P,
125        radius: f64,
126        start: f64,
127        end: f64,
128    },
129    Circle {
130        center: P,
131        radius: f64,
132    },
133    Polyline(Vec<P>),
134    /// A filled polygon: an arrowhead.
135    Filled(Vec<P>),
136}
137
138#[derive(Clone, Debug, PartialEq)]
139pub struct Stroke {
140    pub layer: Layer,
141    pub shape: Shape,
142}
143
144/// Which point of a label its position is.
145#[derive(Clone, Copy, Debug, PartialEq, Eq)]
146pub enum Anchor {
147    Start,
148    Middle,
149    End,
150}
151
152/// A line of text, its baseline at `at`, turned `angle` degrees
153/// counter-clockwise.
154#[derive(Clone, Debug, PartialEq)]
155pub struct Label {
156    pub layer: Layer,
157    pub at: P,
158    pub height: f64,
159    pub angle: f64,
160    pub anchor: Anchor,
161    pub text: String,
162}
163
164/// A file format a sheet is written in.
165#[derive(Clone, Copy, Debug, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
166#[serde(rename_all = "snake_case")]
167pub enum Format {
168    Svg,
169    Dxf,
170}
171
172impl Format {
173    /// The format named by its files' extension, `svg` or `dxf`, if it is
174    /// one.
175    pub fn of_extension(extension: &str) -> Option<Format> {
176        match extension.to_ascii_lowercase().as_str() {
177            "svg" => Some(Format::Svg),
178            "dxf" => Some(Format::Dxf),
179            _ => None,
180        }
181    }
182
183    pub fn extension(self) -> &'static str {
184        match self {
185            Format::Svg => "svg",
186            Format::Dxf => "dxf",
187        }
188    }
189
190    /// Its media type, to save it as.
191    pub fn media_type(self) -> &'static str {
192        match self {
193            Format::Svg => "image/svg+xml",
194            Format::Dxf => "image/vnd.dxf",
195        }
196    }
197}
198
199/// A sheet of paper, `width` x `height` millimetres, and what is on it.
200#[derive(Clone, Debug, Default, PartialEq)]
201pub struct Sheet {
202    pub width: f64,
203    pub height: f64,
204    /// The layers it is drawn on, in the order they are written, whether
205    /// anything is on them or not. A layer drawn on but not listed here is
206    /// written after them (see [`Sheet::layers`]).
207    pub layers: Vec<Layer>,
208    pub strokes: Vec<Stroke>,
209    pub labels: Vec<Label>,
210}
211
212impl Sheet {
213    /// Its [`Sheet::layers`], followed by every other layer a stroke or a
214    /// label is on, in the order first drawn on: each layer once.
215    pub fn layers(&self) -> Vec<Layer> {
216        let mut layers = self.layers.clone();
217        let drawn =
218            (self.strokes.iter().map(|s| s.layer)).chain(self.labels.iter().map(|l| l.layer));
219        for layer in drawn {
220            if !layers.contains(&layer) {
221                layers.push(layer);
222            }
223        }
224        layers
225    }
226
227    /// It written as `format`.
228    pub fn write(&self, format: Format) -> String {
229        match format {
230            Format::Svg => to_svg(self),
231            Format::Dxf => to_dxf(self),
232        }
233    }
234
235    pub fn stroke(&mut self, layer: Layer, shape: Shape) {
236        self.strokes.push(Stroke { layer, shape });
237    }
238
239    pub fn label(
240        &mut self,
241        layer: Layer,
242        at: P,
243        height: f64,
244        anchor: Anchor,
245        text: impl Into<String>,
246    ) {
247        self.labels.push(Label {
248            layer,
249            at,
250            height,
251            angle: 0.0,
252            anchor,
253            text: text.into(),
254        });
255    }
256}
257
258/// Points each polyline standing for a curve that is neither a line nor an
259/// arc has, per polynomial piece of the curve.
260const POINTS_PER_PIECE: usize = 16;
261
262/// A curve recognised for what it is, in its own coordinates (the plane
263/// `z = 0`).
264enum Piece {
265    Line(Vector3, Vector3),
266    /// Counter-clockwise around `center` from `start` to `end` — all of it
267    /// if they coincide.
268    Arc {
269        center: Vector3,
270        radius: Scalar,
271        start: Vector3,
272        end: Vector3,
273    },
274    Other(Vec<P>),
275}
276
277/// `curve` recognised: a line or an arc where it is one exactly (see
278/// [`NurbCurve3D::as_line`], [`NurbCurve3D::as_arc`]), else points of it.
279fn piece(curve: &NurbCurve2D) -> GeopResult<Piece> {
280    let lifted = curve.lifted()?;
281    let (t0, t1) = lifted.domain();
282    if lifted.as_line()?.is_some() {
283        return Ok(Piece::Line(lifted.evaluate(t0)?, lifted.evaluate(t1)?));
284    }
285    if let Some(arc) = lifted.as_arc()? {
286        let (start, end) = if arc.circle.normal[2].to_f64() > 0.0 {
287            (arc.start, arc.end)
288        } else {
289            (arc.end, arc.start)
290        };
291        return Ok(Piece::Arc {
292            center: arc.circle.center,
293            radius: arc.circle.radius,
294            start,
295            end,
296        });
297    }
298    let mut points = Vec::new();
299    for piece in curve.bezier_pieces()? {
300        let (a, b) = piece.domain();
301        let first = if points.is_empty() { 0 } else { 1 };
302        for k in first..=POINTS_PER_PIECE {
303            let t = match k {
304                k if k == POINTS_PER_PIECE => b,
305                k => {
306                    let f = Scalar::from_f64(k as f64 / POINTS_PER_PIECE as f64);
307                    Scalar::fraction(a, b, f)
308                }
309            };
310            let p = piece.evaluate(t)?;
311            points.push([p[0].to_f64(), p[1].to_f64()]);
312        }
313    }
314    Ok(Piece::Other(points))
315}
316
317/// `a` and `b` as one, if they continue each other: two lines along one
318/// line meeting at an end, or two arcs of one circle, one starting where
319/// the other ends.
320fn join(a: &Piece, b: &Piece) -> Option<Piece> {
321    match (a, b) {
322        (Piece::Line(a0, a1), Piece::Line(b0, b1)) => {
323            let (da, db) = (a1.sub(a0), b1.sub(b0));
324            if !da.prod_cross(&db)[2].could_be_equal(Scalar::ZERO) {
325                return None;
326            }
327            let ends = [
328                (a0, a1, b0, b1),
329                (a0, a1, b1, b0),
330                (a1, a0, b0, b1),
331                (a1, a0, b1, b0),
332            ];
333            ends.into_iter().find_map(|(far_a, near_a, near_b, far_b)| {
334                // Meeting end to end, not overlapping: the far ends lie on
335                // either side of the meeting point.
336                (near_a.could_be_equal(near_b)
337                    && far_a
338                        .sub(near_a)
339                        .prod_dot(&far_b.sub(near_b))
340                        .definitely_less(Scalar::ZERO))
341                .then_some(Piece::Line(*far_a, *far_b))
342            })
343        }
344        (
345            Piece::Arc {
346                center: ca,
347                radius: ra,
348                start: sa,
349                end: ea,
350            },
351            Piece::Arc {
352                center: cb,
353                radius: rb,
354                start: sb,
355                end: eb,
356            },
357        ) if ca.could_be_equal(cb) && ra.could_be_equal(*rb) => {
358            let arc = |start: &Vector3, end: &Vector3| Piece::Arc {
359                center: ca.union(cb),
360                radius: ra.union(*rb),
361                start: *start,
362                end: *end,
363            };
364            if ea.could_be_equal(sb) {
365                Some(arc(sa, eb))
366            } else if eb.could_be_equal(sa) {
367                Some(arc(sb, ea))
368            } else {
369                None
370            }
371        }
372        _ => None,
373    }
374}
375
376/// `x` as written on a sheet: to `places` decimals, without trailing zeros.
377pub fn decimal(x: f64, places: usize) -> String {
378    let s = format!("{x:.places$}");
379    let s = s.trim_end_matches('0').trim_end_matches('.');
380    if s == "-0" { "0".into() } else { s.into() }
381}
382
383/// The curves `curves` on the paper, through `place` from their own
384/// coordinates at `scale`: each a line, an arc or a circle where it is one
385/// exactly, those continuing each other on one layer joined, and every
386/// other curve a polyline through points of it.
387pub fn strokes_of(
388    curves: &[(Layer, &NurbCurve2D)],
389    place: &impl Fn(P) -> P,
390    scale: f64,
391) -> GeopResult<Vec<Stroke>> {
392    let mut pieces: Vec<(Layer, Piece)> = curves
393        .iter()
394        .map(|(layer, curve)| Ok((*layer, piece(curve)?)))
395        .collect::<GeopResult<_>>()?;
396    // Each piece joined with every one that continues it, until none does:
397    // a step either joins two pieces or moves on to the next, and every
398    // join takes one piece away, so there are at most twice as many steps
399    // as pieces, and one more that finds the last piece passed.
400    let mut i = 0;
401    let most = 2 * pieces.len() + 1;
402    repeat(most, || -> GeopResult<_> {
403        if i >= pieces.len() {
404            return Ok(Step::Done(()));
405        }
406        let joined = (0..pieces.len()).find_map(|j| {
407            if j == i || pieces[j].0 != pieces[i].0 {
408                return None;
409            }
410            join(&pieces[i].1, &pieces[j].1).map(|p| (j, p))
411        });
412        match joined {
413            Some((j, p)) => {
414                pieces[i].1 = p;
415                pieces.remove(j);
416                if j < i {
417                    i -= 1;
418                }
419            }
420            None => i += 1,
421        }
422        Ok(Step::Again)
423    })?
424    .done("joining the strokes that continue each other")?;
425    let at = |p: &Vector3| place([p[0].to_f64(), p[1].to_f64()]);
426    Ok(pieces
427        .into_iter()
428        .map(|(layer, piece)| {
429            let shape = match piece {
430                Piece::Line(a, b) => Shape::Line(at(&a), at(&b)),
431                Piece::Arc {
432                    center,
433                    radius,
434                    start,
435                    end,
436                } => {
437                    let c = at(&center);
438                    let radius = radius.to_f64() * scale;
439                    if start.could_be_equal(&end) {
440                        Shape::Circle { center: c, radius }
441                    } else {
442                        let angle = |p: P| (p[1] - c[1]).atan2(p[0] - c[0]);
443                        Shape::Arc {
444                            center: c,
445                            radius,
446                            start: angle(at(&start)),
447                            end: angle(at(&end)),
448                        }
449                    }
450                }
451                Piece::Other(points) => Shape::Polyline(points.into_iter().map(place).collect()),
452            };
453            Stroke { layer, shape }
454        })
455        .collect())
456}