use super::dimension::DimensionDrawing;
use super::ordinate::OrdinateDrawing;
use super::frame::FrameDrawing;
use super::project::{AnnotationDrawing, SheetDrawing, SheetText, ViewDrawing};
pub(super) const EDGE_WIDTH: f64 = 0.35;
pub(super) const ANNOTATION_WIDTH: f64 = 0.18;
pub(super) const HIGHLIGHT_RGB: [f64; 3] = [0.85, 0.40, 0.0];
pub(super) const HIGHLIGHT_STROKE: &str = "#d96600";
pub(super) const HIGHLIGHT_WIDTH_FACTOR: f64 = 1.6;
pub(super) const FRAME_WIDTH: f64 = 0.5;
pub(super) const CAP_PER_EM: f64 = 0.72;
pub fn to_svg(sheet: &SheetDrawing) -> String {
let (w, h) = (sheet.width_mm.max(1.0), sheet.height_mm.max(1.0));
let mut out = String::new();
out.push_str(&format!(
"<svg xmlns=\"http://www.w3.org/2000/svg\" width=\"{w:.6}mm\" height=\"{h:.6}mm\" viewBox=\"0 0 {w:.6} {h:.6}\">\n"
));
out.push_str(&format!("<title>{}</title>\n", escape(&sheet.name)));
if let Some(frame) = &sheet.frame {
out.push_str(&frame_group(frame));
}
for view in &sheet.views {
out.push_str(&view_group(view));
}
if !sheet.dimensions.is_empty() {
out.push_str(&dimension_group(&sheet.dimensions));
}
if !sheet.ordinates.is_empty() {
out.push_str(&ordinate_group(&sheet.ordinates));
}
out.push_str("</svg>\n");
out
}
fn frame_group(frame: &FrameDrawing) -> String {
let mut out = String::new();
out.push_str(&format!(
"<g class=\"sheet-frame\" fill=\"none\" stroke=\"#000000\" stroke-width=\"{FRAME_WIDTH:.6}\" stroke-linecap=\"round\" stroke-linejoin=\"round\">\n"
));
for line in &frame.lines {
out.push_str(&format!("<path d=\"{}\" />\n", path_data(line)));
}
for text in &frame.texts {
out.push_str(&text_element(text));
}
if let Some(table) = &frame.revision_table {
out.push_str(&format!(
"<g class=\"revision-table\" data-rows=\"{}\" data-placement=\"{}\">\n",
table.rows,
escape(&table.placement)
));
for line in &table.lines {
out.push_str(&format!("<path d=\"{}\" />\n", path_data(line)));
}
for text in &table.texts {
out.push_str(&text_element(text));
}
out.push_str("</g>\n");
}
out.push_str("</g>\n");
out
}
fn line_art_group(view: &ViewDrawing, layer: &super::project::LineArtLayer, out: &mut String) {
use super::project::LineArtLayer as L;
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);
let [x0, y0, x1, y1] = layer.ground;
out.push_str("<g class=\"line-art\">\n");
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)));
let mut tier: Option<bool> = None;
for (triangle, adding) in &layer.fills {
if tier != Some(*adding) {
if tier.is_some() {
out.push_str("</g>\n");
}
let c = hex(if *adding { L::ADDING_FILL } else { L::PLACED_FILL });
out.push_str(&format!(
"<g class=\"fill-{}\" fill=\"{c}\" stroke=\"{c}\" stroke-width=\"{:.3}\" stroke-linejoin=\"round\">\n",
if *adding { "adding" } else { "placed" },
L::SEAM_MM
));
tier = Some(*adding);
}
out.push_str(&format!("<path d=\"{} Z\" />\n", path_data(triangle)));
}
if tier.is_some() {
out.push_str("</g>\n");
}
let runs = |class: &str, runs: &mut dyn Iterator<Item = &Vec<[f64; 2]>>, color: [f64; 3], width: f64, out: &mut String| {
let mut runs = runs.peekable();
if runs.peek().is_none() {
return;
}
out.push_str(&format!(
"<g class=\"{class}\" fill=\"none\" stroke=\"{}\" stroke-width=\"{width:.3}\" stroke-linecap=\"round\" stroke-linejoin=\"round\">\n",
hex(color)
));
for run in runs {
out.push_str(&format!("<path d=\"{}\" />\n", path_data(run)));
}
out.push_str("</g>\n");
};
runs("model", &mut view.edges.iter(), L::PLACED_EDGE, L::PLACED_EDGE_MM, out);
runs("outline-placed", &mut layer.outlines.iter().filter(|(_, a)| !a).map(|(r, _)| r), L::PLACED_OUTLINE, L::PLACED_OUTLINE_MM, out);
runs("model-highlighted", &mut view.highlighted.iter(), L::INK, L::ADDING_EDGE_MM, out);
runs("outline-adding", &mut layer.outlines.iter().filter(|(_, a)| *a).map(|(r, _)| r), L::INK, L::ADDING_OUTLINE_MM, out);
if !layer.arrows.is_empty() {
out.push_str(&format!("<g class=\"arrows\" fill=\"#000000\" stroke=\"#000000\" stroke-width=\"{:.3}\">\n", L::ARROW_SHAFT_MM));
for (tail, tip) in &layer.arrows {
let Some((base, head)) = L::head(*tail, *tip) else { continue };
out.push_str(&format!("<path class=\"arrow\" d=\"{}\" fill=\"none\" />\n", path_data(&[*tail, base])));
out.push_str(&format!("<path class=\"arrow-head\" d=\"{} Z\" stroke=\"none\" />\n", path_data(&head)));
}
out.push_str("</g>\n");
}
out.push_str("</g>\n");
}
fn view_group(view: &ViewDrawing) -> String {
let mut out = String::new();
out.push_str(&format!(
"<g class=\"sheet-view\" id=\"{}\" data-view=\"{}\" data-name=\"{}\" data-scale=\"{:.6}\"{}>\n",
escape(&view.id),
escape(&view.view),
escape(&view.name),
view.scale,
if view.hidden_line.is_empty() { String::new() } else { format!(" data-hidden-line=\"{}\"", escape(&view.hidden_line)) }
));
if !view.error.is_empty() {
out.push_str(&format!("<desc>{}</desc>\n", escape(&view.error)));
}
if let Some(layer) = &view.line_art {
line_art_group(view, layer, &mut out);
} else if !view.edges.is_empty() {
out.push_str(&format!(
"<g class=\"model\" fill=\"none\" stroke=\"#000000\" stroke-width=\"{EDGE_WIDTH:.6}\" stroke-linecap=\"round\" stroke-linejoin=\"round\">\n"
));
for run in &view.edges {
out.push_str(&format!("<path d=\"{}\" />\n", path_data(run)));
}
out.push_str("</g>\n");
}
if !view.highlighted.is_empty() && view.line_art.is_none() {
out.push_str(&format!(
"<g class=\"model-highlighted\" fill=\"none\" stroke=\"{HIGHLIGHT_STROKE}\" stroke-width=\"{:.6}\" stroke-linecap=\"round\" stroke-linejoin=\"round\">\n",
EDGE_WIDTH * HIGHLIGHT_WIDTH_FACTOR
));
for run in &view.highlighted {
out.push_str(&format!("<path d=\"{}\" />\n", path_data(run)));
}
out.push_str("</g>\n");
}
if let Some(section) = &view.section {
out.push_str(&format!(
"<g class=\"section-hatch\" data-label=\"{}\" fill=\"none\" stroke=\"#000000\" stroke-width=\"{ANNOTATION_WIDTH:.6}\" stroke-linecap=\"round\" stroke-linejoin=\"round\">\n",
escape(§ion.label)
));
for line in §ion.hatch {
out.push_str(&format!("<path d=\"{}\" />\n", path_data(line)));
}
for text in §ion.texts {
out.push_str(&text_element(text));
}
out.push_str("</g>\n");
}
for mark in &view.section_marks {
out.push_str(&format!(
"<g class=\"section-line\" data-section=\"{}\" data-label=\"{}\" fill=\"none\" stroke=\"#000000\" stroke-width=\"{FRAME_WIDTH:.6}\" stroke-linecap=\"round\" stroke-linejoin=\"round\">\n",
escape(&mark.id),
escape(&mark.label)
));
for line in &mark.lines {
out.push_str(&format!("<path d=\"{}\" />\n", path_data(line)));
}
for text in &mark.texts {
out.push_str(&text_element(text));
}
out.push_str("</g>\n");
}
if let Some(detail) = &view.detail {
out.push_str(&format!(
"<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",
escape(&detail.label),
view.scale
));
out.push_str(&format!("<path d=\"{}\" />\n", path_data(&detail.circle)));
for text in &detail.texts {
out.push_str(&text_element(text));
}
out.push_str("</g>\n");
}
for mark in &view.detail_marks {
out.push_str(&format!(
"<g class=\"detail-circle\" data-detail=\"{}\" data-label=\"{}\" fill=\"none\" stroke=\"#000000\" stroke-width=\"{FRAME_WIDTH:.6}\" stroke-linecap=\"round\" stroke-linejoin=\"round\">\n",
escape(&mark.id),
escape(&mark.label)
));
for line in &mark.lines {
out.push_str(&format!("<path d=\"{}\" />\n", path_data(line)));
}
for text in &mark.texts {
out.push_str(&text_element(text));
}
out.push_str("</g>\n");
}
for annotation in &view.annotations {
out.push_str(&annotation_group(annotation));
}
out.push_str("</g>\n");
out
}
fn dimension_group(dimensions: &[DimensionDrawing]) -> String {
let mut out = String::from("<g class=\"sheet-dimensions\">\n");
for dimension in dimensions {
let from = if dimension.tolerance_from.is_empty() {
String::new()
} else {
format!(" data-tolerance-from=\"{}\"", escape(&dimension.tolerance_from))
};
out.push_str(&format!(
"<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",
escape(&dimension.id),
escape(&dimension.kind),
escape(&dimension.text)
));
if !dimension.error.is_empty() {
out.push_str(&format!("<desc>{}</desc>\n", escape(&dimension.error)));
}
for line in &dimension.lines {
out.push_str(&format!("<path d=\"{}\" />\n", path_data(line)));
}
for text in &dimension.texts {
out.push_str(&text_element(text));
}
out.push_str("</g>\n");
}
out.push_str("</g>\n");
out
}
fn ordinate_group(ordinates: &[OrdinateDrawing]) -> String {
let mut out = String::from("<g class=\"sheet-ordinates\">\n");
for set in ordinates {
out.push_str(&format!(
"<g class=\"sheet-ordinate\" id=\"{}\" data-axis=\"{}\" fill=\"none\" stroke=\"#000000\" stroke-width=\"{ANNOTATION_WIDTH:.6}\" stroke-linecap=\"round\" stroke-linejoin=\"round\">\n",
escape(&set.id),
escape(&set.axis)
));
if !set.error.is_empty() {
out.push_str(&format!("<desc>{}</desc>\n", escape(&set.error)));
}
for station in &set.stations {
out.push_str(&format!(
"<g class=\"{}\" data-anchor=\"{}\" data-value=\"{}\">\n",
if station.datum { "ordinate-datum" } else { "ordinate-member" },
escape(&station.anchor),
escape(&station.text)
));
if !station.error.is_empty() {
out.push_str(&format!("<desc>{}</desc>\n", escape(&station.error)));
}
out.push_str("</g>\n");
}
for line in &set.lines {
out.push_str(&format!("<path d=\"{}\" />\n", path_data(line)));
}
for text in &set.texts {
out.push_str(&text_element(text));
}
out.push_str("</g>\n");
}
out.push_str("</g>\n");
out
}
fn annotation_group(annotation: &AnnotationDrawing) -> String {
let mut out = String::new();
out.push_str(&format!(
"<g class=\"annotation\" id=\"{}\" data-type=\"{}\" fill=\"none\" stroke=\"#000000\" stroke-width=\"{ANNOTATION_WIDTH:.6}\" stroke-linecap=\"round\" stroke-linejoin=\"round\">\n",
escape(&annotation.id),
escape(&annotation.kind)
));
for line in &annotation.lines {
out.push_str(&format!("<path d=\"{}\" />\n", path_data(line)));
}
for text in &annotation.texts {
out.push_str(&text_element(text));
}
out.push_str("</g>\n");
out
}
fn text_element(text: &SheetText) -> String {
let (x, y) = (text.anchor[0], text.anchor[1]);
let font = text.height / CAP_PER_EM;
let transform = match text_basis(text) {
None => String::new(),
Some([a, b, c, d]) => {
format!(" transform=\"matrix({a:.6} {b:.6} {c:.6} {d:.6} {x:.6} {y:.6})\"")
}
};
let (x, y) = if transform.is_empty() { (x, y) } else { (0.0, 0.0) };
format!(
"<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",
escape(&text.text)
)
}
fn text_basis(text: &SheetText) -> Option<[f64; 4]> {
if !(text.height > 1e-12) {
return None;
}
let m = [
text.right[0] / text.height,
text.right[1] / text.height,
-text.up[0] / text.height,
-text.up[1] / text.height,
];
let identity = [1.0, 0.0, 0.0, 1.0];
(0..4).any(|i| (m[i] - identity[i]).abs() > 1e-9).then_some(m)
}
fn path_data(points: &[[f64; 2]]) -> String {
let mut out = String::new();
for (i, p) in points.iter().enumerate() {
out.push_str(if i == 0 { "M " } else { " L " });
out.push_str(&format!("{:.6} {:.6}", p[0], p[1]));
}
out
}
pub fn hidden_lines(svg: &str) -> Vec<String> {
let key = "data-hidden-line=\"";
svg.split(key).skip(1).filter_map(|rest| rest.split('"').next().map(str::to_string)).collect()
}
pub fn text_strings(svg: &str) -> Vec<String> {
let mut out = Vec::new();
let mut rest = svg;
while let Some(open) = rest.find("<text ") {
let after = &rest[open..];
let (Some(start), Some(end)) = (after.find('>'), after.find("</text>")) else { break };
if start < end {
out.push(
after[start + 1..end]
.replace("<", "<")
.replace(">", ">")
.replace(""", "\"")
.replace("'", "'")
.replace("&", "&"),
);
}
rest = &after[end + "</text>".len()..];
}
out
}
fn escape(text: &str) -> String {
let mut out = String::with_capacity(text.len());
for ch in text.chars() {
match ch {
'&' => out.push_str("&"),
'<' => out.push_str("<"),
'>' => out.push_str(">"),
'"' => out.push_str("""),
'\'' => out.push_str("'"),
_ => out.push(ch),
}
}
out
}