Skip to main content

brep_render/sheets/
svg.rs

1//! The sheet's SVG output contract.
2//!
3//! One file per sheet. The paper is the `viewBox` — `0 0 <width> <height>` in
4//! millimetres, with `width` / `height` carrying the `mm` unit, so the file
5//! prints at its real size. The paper's furniture comes first as one
6//! `<g class="sheet-frame">` (the border, the title block's rules and its text),
7//! then each placed view is one `<g>` carrying its placed-view id, its saved
8//! view's id, its scale and which pass drew its model lines (`data-hidden-line`:
9//! `analytic`, or `mesh` for the approximation drawn before the B-rep arrived). Inside a view group the model is `<path>` elements
10//! (the visible edge runs, one path per run) and each annotation is its own
11//! `<g>` of paths plus `<text>` elements holding the kernel's own display
12//! string, so the value in the file is the value in the viewport and a reader
13//! can select it.
14//!
15//! **Lines and text only.** There are no fills anywhere: an arrowhead is its
16//! closed outline, a datum triangle and an FCF frame are their borders, and the
17//! border and title block are polylines. The same drawing goes out as PDF
18//! through [`super::pdf`], which shares the stroke widths and the cap-height
19//! ratio below so the two files cannot drift.
20
21use super::dimension::DimensionDrawing;
22use super::ordinate::OrdinateDrawing;
23use super::frame::FrameDrawing;
24use super::project::{AnnotationDrawing, SheetDrawing, SheetText, ViewDrawing};
25
26/// Model edge stroke width, millimetres.
27pub(super) const EDGE_WIDTH: f64 = 0.35;
28/// Annotation stroke width, millimetres.
29pub(super) const ANNOTATION_WIDTH: f64 = 0.18;
30/// The HIGHLIGHT stroke ([`super::project::ViewDrawing::highlighted`]): the
31/// instructions' orange, and heavier than a model edge so it still stands out
32/// printed in black.
33pub(super) const HIGHLIGHT_RGB: [f64; 3] = [0.85, 0.40, 0.0];
34pub(super) const HIGHLIGHT_STROKE: &str = "#d96600";
35pub(super) const HIGHLIGHT_WIDTH_FACTOR: f64 = 1.6;
36/// The border and title-block stroke width, millimetres.
37pub(super) const FRAME_WIDTH: f64 = 0.5;
38/// A text run's CAP height as a fraction of the font's em. The layout sizes a
39/// cap; an SVG `font-size` and a PDF `Tf` size are both the em, so this is the
40/// one conversion both writers (and the viewport) have to agree on.
41pub(super) const CAP_PER_EM: f64 = 0.72;
42
43/// Serialise a projected sheet.
44pub fn to_svg(sheet: &SheetDrawing) -> String {
45    let (w, h) = (sheet.width_mm.max(1.0), sheet.height_mm.max(1.0));
46    let mut out = String::new();
47    out.push_str(&format!(
48        "<svg xmlns=\"http://www.w3.org/2000/svg\" width=\"{w:.6}mm\" height=\"{h:.6}mm\" viewBox=\"0 0 {w:.6} {h:.6}\">\n"
49    ));
50    out.push_str(&format!("<title>{}</title>\n", escape(&sheet.name)));
51    if let Some(frame) = &sheet.frame {
52        out.push_str(&frame_group(frame));
53    }
54    for view in &sheet.views {
55        out.push_str(&view_group(view));
56    }
57    if !sheet.dimensions.is_empty() {
58        out.push_str(&dimension_group(&sheet.dimensions));
59    }
60    if !sheet.ordinates.is_empty() {
61        out.push_str(&ordinate_group(&sheet.ordinates));
62    }
63    out.push_str("</svg>\n");
64    out
65}
66
67/// The paper's furniture: the border and the title block, lines and text, in
68/// one group a reader can turn off.
69fn frame_group(frame: &FrameDrawing) -> String {
70    let mut out = String::new();
71    out.push_str(&format!(
72        "<g class=\"sheet-frame\" fill=\"none\" stroke=\"#000000\" stroke-width=\"{FRAME_WIDTH:.6}\" stroke-linecap=\"round\" stroke-linejoin=\"round\">\n"
73    ));
74    for line in &frame.lines {
75        out.push_str(&format!("<path d=\"{}\" />\n", path_data(line)));
76    }
77    for text in &frame.texts {
78        out.push_str(&text_element(text));
79    }
80    // The REVISION TABLE, as its own group inside the frame's: it IS paper
81    // furniture, and a drawing-office tool looking for the revisions finds
82    // them by class with the row count and the place the table went.
83    if let Some(table) = &frame.revision_table {
84        out.push_str(&format!(
85            "<g class=\"revision-table\" data-rows=\"{}\" data-placement=\"{}\">\n",
86            table.rows,
87            escape(&table.placement)
88        ));
89        for line in &table.lines {
90            out.push_str(&format!("<path d=\"{}\" />\n", path_data(line)));
91        }
92        for text in &table.texts {
93            out.push_str(&text_element(text));
94        }
95        out.push_str("</g>\n");
96    }
97    out.push_str("</g>\n");
98    out
99}
100
101/// A Line art picture ([`super::project::LineArtLayer`]), the PDF's own
102/// layering: the ground, the fills back to front, the placed parts' lines
103/// and outlines, the added parts' lines and outlines, the arrows.
104fn line_art_group(view: &ViewDrawing, layer: &super::project::LineArtLayer, out: &mut String) {
105    use super::project::LineArtLayer as L;
106    let hex = |c: [f64; 3]| format!("#{:02x}{:02x}{:02x}", (c[0] * 255.0).round() as u8, (c[1] * 255.0).round() as u8, (c[2] * 255.0).round() as u8);
107    let [x0, y0, x1, y1] = layer.ground;
108    out.push_str("<g class=\"line-art\">\n");
109    out.push_str(&format!("<rect class=\"ground\" x=\"{x0:.4}\" y=\"{y0:.4}\" width=\"{:.4}\" height=\"{:.4}\" fill=\"{}\" />\n", x1 - x0, y1 - y0, hex(L::GROUND)));
110    let mut tier: Option<bool> = None;
111    for (triangle, adding) in &layer.fills {
112        if tier != Some(*adding) {
113            if tier.is_some() {
114                out.push_str("</g>\n");
115            }
116            let c = hex(if *adding { L::ADDING_FILL } else { L::PLACED_FILL });
117            out.push_str(&format!(
118                "<g class=\"fill-{}\" fill=\"{c}\" stroke=\"{c}\" stroke-width=\"{:.3}\" stroke-linejoin=\"round\">\n",
119                if *adding { "adding" } else { "placed" },
120                L::SEAM_MM
121            ));
122            tier = Some(*adding);
123        }
124        out.push_str(&format!("<path d=\"{} Z\" />\n", path_data(triangle)));
125    }
126    if tier.is_some() {
127        out.push_str("</g>\n");
128    }
129    let runs = |class: &str, runs: &mut dyn Iterator<Item = &Vec<[f64; 2]>>, color: [f64; 3], width: f64, out: &mut String| {
130        let mut runs = runs.peekable();
131        if runs.peek().is_none() {
132            return;
133        }
134        out.push_str(&format!(
135            "<g class=\"{class}\" fill=\"none\" stroke=\"{}\" stroke-width=\"{width:.3}\" stroke-linecap=\"round\" stroke-linejoin=\"round\">\n",
136            hex(color)
137        ));
138        for run in runs {
139            out.push_str(&format!("<path d=\"{}\" />\n", path_data(run)));
140        }
141        out.push_str("</g>\n");
142    };
143    runs("model", &mut view.edges.iter(), L::PLACED_EDGE, L::PLACED_EDGE_MM, out);
144    runs("outline-placed", &mut layer.outlines.iter().filter(|(_, a)| !a).map(|(r, _)| r), L::PLACED_OUTLINE, L::PLACED_OUTLINE_MM, out);
145    runs("model-highlighted", &mut view.highlighted.iter(), L::INK, L::ADDING_EDGE_MM, out);
146    runs("outline-adding", &mut layer.outlines.iter().filter(|(_, a)| *a).map(|(r, _)| r), L::INK, L::ADDING_OUTLINE_MM, out);
147    if !layer.arrows.is_empty() {
148        out.push_str(&format!("<g class=\"arrows\" fill=\"#000000\" stroke=\"#000000\" stroke-width=\"{:.3}\">\n", L::ARROW_SHAFT_MM));
149        for (tail, tip) in &layer.arrows {
150            let Some((base, head)) = L::head(*tail, *tip) else { continue };
151            out.push_str(&format!("<path class=\"arrow\" d=\"{}\" fill=\"none\" />\n", path_data(&[*tail, base])));
152            out.push_str(&format!("<path class=\"arrow-head\" d=\"{} Z\" stroke=\"none\" />\n", path_data(&head)));
153        }
154        out.push_str("</g>\n");
155    }
156    out.push_str("</g>\n");
157}
158
159fn view_group(view: &ViewDrawing) -> String {
160    let mut out = String::new();
161    out.push_str(&format!(
162        "<g class=\"sheet-view\" id=\"{}\" data-view=\"{}\" data-name=\"{}\" data-scale=\"{:.6}\"{}>\n",
163        escape(&view.id),
164        escape(&view.view),
165        escape(&view.name),
166        view.scale,
167        if view.hidden_line.is_empty() { String::new() } else { format!(" data-hidden-line=\"{}\"", escape(&view.hidden_line)) }
168    ));
169    if !view.error.is_empty() {
170        // A placement that could not project says so IN the file rather than
171        // leaving a silent blank on the paper.
172        out.push_str(&format!("<desc>{}</desc>\n", escape(&view.error)));
173    }
174    if let Some(layer) = &view.line_art {
175        line_art_group(view, layer, &mut out);
176    } else if !view.edges.is_empty() {
177        out.push_str(&format!(
178            "<g class=\"model\" fill=\"none\" stroke=\"#000000\" stroke-width=\"{EDGE_WIDTH:.6}\" stroke-linecap=\"round\" stroke-linejoin=\"round\">\n"
179        ));
180        for run in &view.edges {
181            out.push_str(&format!("<path d=\"{}\" />\n", path_data(run)));
182        }
183        out.push_str("</g>\n");
184    }
185    if !view.highlighted.is_empty() && view.line_art.is_none() {
186        out.push_str(&format!(
187            "<g class=\"model-highlighted\" fill=\"none\" stroke=\"{HIGHLIGHT_STROKE}\" stroke-width=\"{:.6}\" stroke-linecap=\"round\" stroke-linejoin=\"round\">\n",
188            EDGE_WIDTH * HIGHLIGHT_WIDTH_FACTOR
189        ));
190        for run in &view.highlighted {
191            out.push_str(&format!("<path d=\"{}\" />\n", path_data(run)));
192        }
193        out.push_str("</g>\n");
194    }
195    // A SECTION's cut face, under the annotations: the hatch is a fill drawn
196    // as parallel LINES, because the sheet's whole contract is lines and text
197    // and a `fill` would be the one mark in the file a plotter could not draw
198    // the same way twice.
199    if let Some(section) = &view.section {
200        out.push_str(&format!(
201            "<g class=\"section-hatch\" data-label=\"{}\" fill=\"none\" stroke=\"#000000\" stroke-width=\"{ANNOTATION_WIDTH:.6}\" stroke-linecap=\"round\" stroke-linejoin=\"round\">\n",
202            escape(&section.label)
203        ));
204        for line in &section.hatch {
205            out.push_str(&format!("<path d=\"{}\" />\n", path_data(line)));
206        }
207        for text in &section.texts {
208            out.push_str(&text_element(text));
209        }
210        out.push_str("</g>\n");
211    }
212    // …and the section LINES this placement carries for the sections cut
213    // through it, each naming the view it is the cut of.
214    for mark in &view.section_marks {
215        out.push_str(&format!(
216            "<g class=\"section-line\" data-section=\"{}\" data-label=\"{}\" fill=\"none\" stroke=\"#000000\" stroke-width=\"{FRAME_WIDTH:.6}\" stroke-linecap=\"round\" stroke-linejoin=\"round\">\n",
217            escape(&mark.id),
218            escape(&mark.label)
219        ));
220        for line in &mark.lines {
221            out.push_str(&format!("<path d=\"{}\" />\n", path_data(line)));
222        }
223        for text in &mark.texts {
224            out.push_str(&text_element(text));
225        }
226        out.push_str("</g>\n");
227    }
228    // A DETAIL's boundary circle and caption, inside the detail's own group
229    // and carrying its letter and its scale…
230    if let Some(detail) = &view.detail {
231        out.push_str(&format!(
232            "<g class=\"detail-view\" data-label=\"{}\" data-scale=\"{:.6}\" fill=\"none\" stroke=\"#000000\" stroke-width=\"{ANNOTATION_WIDTH:.6}\" stroke-linecap=\"round\" stroke-linejoin=\"round\">\n",
233            escape(&detail.label),
234            view.scale
235        ));
236        out.push_str(&format!("<path d=\"{}\" />\n", path_data(&detail.circle)));
237        for text in &detail.texts {
238            out.push_str(&text_element(text));
239        }
240        out.push_str("</g>\n");
241    }
242    // …and the detail CIRCLES this placement carries for the details drawn
243    // from it, each naming the view it is the region of — the section's two
244    // halves, in the same two places.
245    for mark in &view.detail_marks {
246        out.push_str(&format!(
247            "<g class=\"detail-circle\" data-detail=\"{}\" data-label=\"{}\" fill=\"none\" stroke=\"#000000\" stroke-width=\"{FRAME_WIDTH:.6}\" stroke-linecap=\"round\" stroke-linejoin=\"round\">\n",
248            escape(&mark.id),
249            escape(&mark.label)
250        ));
251        for line in &mark.lines {
252            out.push_str(&format!("<path d=\"{}\" />\n", path_data(line)));
253        }
254        for text in &mark.texts {
255            out.push_str(&text_element(text));
256        }
257        out.push_str("</g>\n");
258    }
259    for annotation in &view.annotations {
260        out.push_str(&annotation_group(annotation));
261    }
262    out.push_str("</g>\n");
263    out
264}
265
266/// The SHEET's own dimensions, in one group of their own — they belong to the
267/// paper rather than to any placement, so they sit beside the view groups and
268/// not inside one. Each is a `<g>` carrying its id, its kind and its value, so
269/// a drawing-office tool can read what the sheet measured without re-deriving
270/// it from the paths.
271///
272/// A dimension that did NOT resolve writes its reason as a `<desc>` and no
273/// geometry: a lost anchor is said out loud, the way a refused placement is,
274/// rather than leaving a silent blank. A dimension whose tolerance block is a
275/// PMI dimension's carries that dimension's id as `data-tolerance-from`.
276fn dimension_group(dimensions: &[DimensionDrawing]) -> String {
277    let mut out = String::from("<g class=\"sheet-dimensions\">\n");
278    for dimension in dimensions {
279        let from = if dimension.tolerance_from.is_empty() {
280            String::new()
281        } else {
282            format!(" data-tolerance-from=\"{}\"", escape(&dimension.tolerance_from))
283        };
284        out.push_str(&format!(
285            "<g class=\"sheet-dimension\" id=\"{}\" data-type=\"{}\" data-value=\"{}\"{from} fill=\"none\" stroke=\"#000000\" stroke-width=\"{ANNOTATION_WIDTH:.6}\" stroke-linecap=\"round\" stroke-linejoin=\"round\">\n",
286            escape(&dimension.id),
287            escape(&dimension.kind),
288            escape(&dimension.text)
289        ));
290        if !dimension.error.is_empty() {
291            out.push_str(&format!("<desc>{}</desc>\n", escape(&dimension.error)));
292        }
293        for line in &dimension.lines {
294            out.push_str(&format!("<path d=\"{}\" />\n", path_data(line)));
295        }
296        for text in &dimension.texts {
297            out.push_str(&text_element(text));
298        }
299        out.push_str("</g>\n");
300    }
301    out.push_str("</g>\n");
302    out
303}
304
305/// The sheet's ORDINATE SETS, in their own group beside the dimensions.
306///
307/// Each set is a `<g>` carrying its id and its axis, and each STATION is a
308/// nested `<g>` carrying the anchor it reads, its value and whether it is the
309/// datum — so a drawing-office tool can read the run without re-deriving which
310/// leader belongs to which point. A station that did not resolve writes its
311/// reason as a `<desc>` and no geometry, exactly as a refused dimension does;
312/// the rest of the set still draws, because losing one point of five is not
313/// losing the set.
314fn ordinate_group(ordinates: &[OrdinateDrawing]) -> String {
315    let mut out = String::from("<g class=\"sheet-ordinates\">\n");
316    for set in ordinates {
317        out.push_str(&format!(
318            "<g class=\"sheet-ordinate\" id=\"{}\" data-axis=\"{}\" fill=\"none\" stroke=\"#000000\" stroke-width=\"{ANNOTATION_WIDTH:.6}\" stroke-linecap=\"round\" stroke-linejoin=\"round\">\n",
319            escape(&set.id),
320            escape(&set.axis)
321        ));
322        if !set.error.is_empty() {
323            out.push_str(&format!("<desc>{}</desc>\n", escape(&set.error)));
324        }
325        for station in &set.stations {
326            out.push_str(&format!(
327                "<g class=\"{}\" data-anchor=\"{}\" data-value=\"{}\">\n",
328                if station.datum { "ordinate-datum" } else { "ordinate-member" },
329                escape(&station.anchor),
330                escape(&station.text)
331            ));
332            if !station.error.is_empty() {
333                out.push_str(&format!("<desc>{}</desc>\n", escape(&station.error)));
334            }
335            out.push_str("</g>\n");
336        }
337        // The leaders, the origin circle and the values, once for the set: the
338        // stations above say WHAT was measured, these are the marks.
339        for line in &set.lines {
340            out.push_str(&format!("<path d=\"{}\" />\n", path_data(line)));
341        }
342        for text in &set.texts {
343            out.push_str(&text_element(text));
344        }
345        out.push_str("</g>\n");
346    }
347    out.push_str("</g>\n");
348    out
349}
350
351fn annotation_group(annotation: &AnnotationDrawing) -> String {
352    let mut out = String::new();
353    out.push_str(&format!(
354        "<g class=\"annotation\" id=\"{}\" data-type=\"{}\" fill=\"none\" stroke=\"#000000\" stroke-width=\"{ANNOTATION_WIDTH:.6}\" stroke-linecap=\"round\" stroke-linejoin=\"round\">\n",
355        escape(&annotation.id),
356        escape(&annotation.kind)
357    ));
358    for line in &annotation.lines {
359        out.push_str(&format!("<path d=\"{}\" />\n", path_data(line)));
360    }
361    for text in &annotation.texts {
362        out.push_str(&text_element(text));
363    }
364    out.push_str("</g>\n");
365    out
366}
367
368/// One placed text run as a `<text>` element: centred on its anchor, and
369/// selectable, because the file hands the string to whatever renders it rather
370/// than stroking it.
371///
372/// The run's own basis becomes the element's TRANSFORM. The element is placed
373/// at the origin of a local frame whose x axis is the run's reading direction
374/// and whose y axis runs DOWN its up direction, each one cap height long and
375/// divided by the height so a flat run's matrix is the identity and is left
376/// off. An oblique annotation plane gives a basis that is sheared, not merely
377/// rotated, and `matrix` is what carries it — a `rotate` cannot.
378fn text_element(text: &SheetText) -> String {
379    let (x, y) = (text.anchor[0], text.anchor[1]);
380    // The cap height is what the layout sizes; an SVG `font-size` is the em,
381    // and a cap is about [`CAP_PER_EM`] of it for the sans fallbacks this asks
382    // for — the one place a sheet has to guess. The PDF writer converts with the
383    // same ratio, so the two files agree.
384    let font = text.height / CAP_PER_EM;
385    let transform = match text_basis(text) {
386        None => String::new(),
387        Some([a, b, c, d]) => {
388            format!(" transform=\"matrix({a:.6} {b:.6} {c:.6} {d:.6} {x:.6} {y:.6})\"")
389        }
390    };
391    let (x, y) = if transform.is_empty() { (x, y) } else { (0.0, 0.0) };
392    format!(
393        "<text x=\"{x:.6}\" y=\"{y:.6}\"{transform} font-family=\"sans-serif\" font-size=\"{font:.6}\" text-anchor=\"middle\" dominant-baseline=\"central\" fill=\"#000000\" stroke=\"none\">{}</text>\n",
394        escape(&text.text)
395    )
396}
397
398/// The run's basis as `a b c d` of an SVG matrix, normalised by its cap
399/// height (x along the reading direction, y DOWN the run's up direction), or
400/// `None` when that is the identity — a run laid flat on the paper, which is
401/// the whole title block and every run of a placement with `flattenText` on.
402/// A run with no height left to normalise by is not drawn at all.
403fn text_basis(text: &SheetText) -> Option<[f64; 4]> {
404    if !(text.height > 1e-12) {
405        return None;
406    }
407    let m = [
408        text.right[0] / text.height,
409        text.right[1] / text.height,
410        -text.up[0] / text.height,
411        -text.up[1] / text.height,
412    ];
413    let identity = [1.0, 0.0, 0.0, 1.0];
414    (0..4).any(|i| (m[i] - identity[i]).abs() > 1e-9).then_some(m)
415}
416
417/// A polyline as `M x y L x y …` with `{:.6}` coordinates.
418fn path_data(points: &[[f64; 2]]) -> String {
419    let mut out = String::new();
420    for (i, p) in points.iter().enumerate() {
421        out.push_str(if i == 0 { "M " } else { " L " });
422        out.push_str(&format!("{:.6} {:.6}", p[0], p[1]));
423    }
424    out
425}
426
427/// XML text escaping — the five predefined entities. A note's text is the
428/// user's own, so this is the boundary that keeps it from being markup.
429/// Every text string an SVG this module wrote shows, in document order: each
430/// `<text>` element's content read back from the file, entities undone. What
431/// the export door reports ABOUT the file, so a script can assert the drawing's
432/// text without an XML reader.
433/// Every placed view's `data-hidden-line` read back from the SVG, in view
434/// order: `analytic` or `mesh` per view.
435pub fn hidden_lines(svg: &str) -> Vec<String> {
436    let key = "data-hidden-line=\"";
437    svg.split(key).skip(1).filter_map(|rest| rest.split('"').next().map(str::to_string)).collect()
438}
439
440pub fn text_strings(svg: &str) -> Vec<String> {
441    let mut out = Vec::new();
442    let mut rest = svg;
443    while let Some(open) = rest.find("<text ") {
444        let after = &rest[open..];
445        let (Some(start), Some(end)) = (after.find('>'), after.find("</text>")) else { break };
446        if start < end {
447            out.push(
448                after[start + 1..end]
449                    .replace("&lt;", "<")
450                    .replace("&gt;", ">")
451                    .replace("&quot;", "\"")
452                    .replace("&apos;", "'")
453                    .replace("&amp;", "&"),
454            );
455        }
456        rest = &after[end + "</text>".len()..];
457    }
458    out
459}
460
461fn escape(text: &str) -> String {
462    let mut out = String::with_capacity(text.len());
463    for ch in text.chars() {
464        match ch {
465            '&' => out.push_str("&amp;"),
466            '<' => out.push_str("&lt;"),
467            '>' => out.push_str("&gt;"),
468            '"' => out.push_str("&quot;"),
469            '\'' => out.push_str("&apos;"),
470            _ => out.push(ch),
471        }
472    }
473    out
474}
475
476
477
478
479