1use super::dimension::DimensionDrawing;
22use super::ordinate::OrdinateDrawing;
23use super::frame::FrameDrawing;
24use super::project::{AnnotationDrawing, SheetDrawing, SheetText, ViewDrawing};
25
26pub(super) const EDGE_WIDTH: f64 = 0.35;
28pub(super) const ANNOTATION_WIDTH: f64 = 0.18;
30pub(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;
36pub(super) const FRAME_WIDTH: f64 = 0.5;
38pub(super) const CAP_PER_EM: f64 = 0.72;
42
43pub 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
67fn 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 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
101fn 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 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 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(§ion.label)
203 ));
204 for line in §ion.hatch {
205 out.push_str(&format!("<path d=\"{}\" />\n", path_data(line)));
206 }
207 for text in §ion.texts {
208 out.push_str(&text_element(text));
209 }
210 out.push_str("</g>\n");
211 }
212 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 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 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
266fn 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
305fn 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 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
368fn text_element(text: &SheetText) -> String {
379 let (x, y) = (text.anchor[0], text.anchor[1]);
380 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
398fn 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
417fn 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
427pub 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("<", "<")
450 .replace(">", ">")
451 .replace(""", "\"")
452 .replace("'", "'")
453 .replace("&", "&"),
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("&"),
466 '<' => out.push_str("<"),
467 '>' => out.push_str(">"),
468 '"' => out.push_str("""),
469 '\'' => out.push_str("'"),
470 _ => out.push(ch),
471 }
472 }
473 out
474}
475
476
477
478
479