use crate::gui::solid_preview::diagram2d::{self, DiagramConfig, DiagramStyle};
use image::{ExtendedColorType, ImageEncoder, codecs::png::PngEncoder};
use indicatrix::geometry::{
meet_solver::{Block, SolvedTier, classify_blocks},
stone_metrics::{SolidStatus, build_solid_mesh},
};
use indicatrix_cut_core::{CutSheetRow, Design};
use std::{fmt::Write as _, path::PathBuf};
const SHEET_DIAGRAM_WIDTH: u32 = 900;
const SHEET_DIAGRAM_HEIGHT: u32 = 360;
pub const DIAGRAM_EXPORT_WIDTH: u32 = 1800;
pub const DIAGRAM_EXPORT_HEIGHT: u32 = 720;
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct DiagramImage {
pub width: u32,
pub height: u32,
pub png_bytes: Vec<u8>,
}
#[must_use]
pub fn render_cut_diagram(
design: &Design,
solved: &[SolvedTier],
width: u32,
height: u32,
) -> Option<DiagramImage> {
let planes = design.planes_from_solved(solved);
let SolidStatus::Closed(mesh) = build_solid_mesh(&planes) else {
return None;
};
let config = DiagramConfig {
width,
height,
gear_teeth: design.meta.gear_teeth_abs(),
gear_reference_angle: design.meta.gear_reference_angle as f32,
symmetry_order: design.meta.symmetry_order,
mirror: design.meta.mirror,
};
let frame = diagram2d::render_diagram(&mesh, &config, &DiagramStyle::default());
let png_bytes = encode_png(frame.width, frame.height, &frame.color).ok()?;
Some(DiagramImage {
width: frame.width,
height: frame.height,
png_bytes,
})
}
fn encode_png(width: u32, height: u32, rgba: &[u8]) -> Result<Vec<u8>, String> {
let mut bytes = Vec::new();
PngEncoder::new(&mut bytes)
.write_image(rgba, width, height, ExtendedColorType::Rgba8)
.map_err(|e| e.to_string())?;
Ok(bytes)
}
const BASE64_ALPHABET: &[u8; 64] =
b"ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/";
#[must_use]
fn base64_encode(data: &[u8]) -> String {
let mut out = String::with_capacity(data.len().div_ceil(3) * 4);
for chunk in data.chunks(3) {
let b0 = u32::from(chunk[0]);
let b1 = chunk.get(1).copied().map_or(0, u32::from);
let b2 = chunk.get(2).copied().map_or(0, u32::from);
let n = (b0 << 16) | (b1 << 8) | b2;
out.push(BASE64_ALPHABET[((n >> 18) & 0x3f) as usize] as char);
out.push(BASE64_ALPHABET[((n >> 12) & 0x3f) as usize] as char);
out.push(if chunk.len() > 1 {
BASE64_ALPHABET[((n >> 6) & 0x3f) as usize] as char
} else {
'='
});
out.push(if chunk.len() > 2 {
BASE64_ALPHABET[(n & 0x3f) as usize] as char
} else {
'='
});
}
out
}
fn png_data_uri(image: &DiagramImage) -> String {
format!("data:image/png;base64,{}", base64_encode(&image.png_bytes))
}
fn html_escape(s: &str) -> String {
let mut out = String::with_capacity(s.len());
for c in s.chars() {
match c {
'&' => out.push_str("&"),
'<' => out.push_str("<"),
'>' => out.push_str(">"),
'"' => out.push_str("""),
'\'' => out.push_str("'"),
_ => out.push(c),
}
}
out
}
const fn block_label(block: Block) -> &'static str {
match block {
Block::Crown => "Crown",
Block::Pavilion => "Pavilion",
Block::Girdle => "Girdle",
}
}
const fn block_css_class(block: Block) -> &'static str {
match block {
Block::Crown => "block-crown",
Block::Pavilion => "block-pavilion",
Block::Girdle => "block-girdle",
}
}
fn format_indices_html(indices: &[f64]) -> String {
if indices.is_empty() {
return "-".to_string();
}
indices
.iter()
.map(|&i| format_index(i))
.collect::<Vec<_>>()
.join(", ")
}
fn format_index(index: f64) -> String {
if (index - index.round()).abs() < 1e-9 {
format!("{:.0}", index.round())
} else {
format!("{index:.2}")
}
}
#[must_use]
pub fn cutting_sheet_html(
design: &Design,
solved: &[SolvedTier],
diagram: Option<&DiagramImage>,
) -> String {
let sheet = design.cutting_sheet(solved);
let blocks = classify_blocks(&design.meet_tier_inputs());
let mm_per_unit = design.yield_report(solved).mm_per_unit;
let show_mm_column = design.girdle_diameter_mm.is_some();
let show_cheater_column = sheet
.rows
.iter()
.any(|row| row.cheater_offset_deg.is_some());
let mut out = String::new();
out.push_str(HTML_HEAD);
out.push_str("<h1>Cutting Sheet</h1>\n");
push_header_block(&mut out, design);
push_diagram_block(&mut out, diagram);
push_tier_table(
&mut out,
&sheet.rows,
&blocks,
TierTableColumns {
show_mm: show_mm_column,
show_cheater: show_cheater_column,
},
mm_per_unit,
);
out.push_str(HTML_TAIL);
out
}
fn push_header_block(out: &mut String, design: &Design) {
out.push_str("<table class=\"meta\">\n");
push_meta_row(
out,
"Material",
design.material.name.as_deref().unwrap_or("(unset)"),
);
push_meta_row(
out,
"Refractive index",
&format!("{:.4}", design.effective_refractive_index()),
);
push_meta_row(
out,
"Index gear",
&format!(
"{} teeth, symmetry {}{}",
design.meta.gear_teeth_abs(),
design.meta.symmetry_order,
if design.meta.mirror { ", mirrored" } else { "" }
),
);
if let Some(mm) = design.girdle_diameter_mm {
push_meta_row(out, "Girdle diameter", &format!("{mm:.3} mm"));
}
out.push_str("</table>\n");
}
fn push_meta_row(out: &mut String, label: &str, value: &str) {
let _ = writeln!(
out,
"<tr><td class=\"label\">{label}</td><td>{value}</td></tr>"
);
}
fn push_diagram_block(out: &mut String, diagram: Option<&DiagramImage>) {
match diagram {
Some(image) => {
let _ = writeln!(
out,
"<div class=\"diagram\"><img src=\"{}\" width=\"{}\" height=\"{}\" \
alt=\"2D facet diagram\"></div>",
png_data_uri(image),
image.width,
image.height
);
}
None => out.push_str(
"<p class=\"diagram-missing\">Diagram unavailable: this design does not \
currently close to a solid.</p>\n",
),
}
}
#[derive(Clone, Copy)]
struct TierTableColumns {
show_mm: bool,
show_cheater: bool,
}
fn push_tier_table(
out: &mut String,
rows: &[CutSheetRow],
blocks: &[Block],
columns: TierTableColumns,
mm_per_unit: Option<f64>,
) {
out.push_str("<table class=\"tiers\">\n<thead><tr>");
out.push_str(
"<th>#</th><th>Tier</th><th>Block</th><th>Angle</th><th>Indices</th><th>Mast</th>",
);
if columns.show_mm {
out.push_str("<th>Mast (mm)</th>");
}
if columns.show_cheater {
out.push_str("<th>Cheater</th>");
}
out.push_str("<th>Meet</th></tr></thead>\n<tbody>\n");
for (row, &block) in rows.iter().zip(blocks) {
push_tier_row(out, row, block, columns, mm_per_unit);
}
out.push_str("</tbody>\n</table>\n");
}
fn push_tier_row(
out: &mut String,
row: &CutSheetRow,
block: Block,
columns: TierTableColumns,
mm_per_unit: Option<f64>,
) {
let name = if row.name.is_empty() {
"(unnamed)"
} else {
row.name.as_str()
};
out.push_str("<tr>");
let _ = write!(out, "<td>{}</td>", row.sequence);
let _ = write!(out, "<td>{}</td>", html_escape(name));
let _ = write!(
out,
"<td class=\"{}\">{}</td>",
block_css_class(block),
block_label(block)
);
let _ = write!(out, "<td>{:+.2}°</td>", row.angle_deg);
let _ = write!(out, "<td>{}</td>", format_indices_html(&row.indices));
let _ = write!(out, "<td>{:.4}</td>", row.mast);
if columns.show_mm {
let cell = mm_per_unit.map_or_else(
|| "-".to_string(),
|scale| format!("{:.4}", row.mast * scale),
);
let _ = write!(out, "<td>{cell}</td>");
}
if columns.show_cheater {
let cell = row
.cheater_offset_deg
.map_or_else(|| "-".to_string(), |deg| format!("{deg:+.2}°"));
let _ = write!(out, "<td>{cell}</td>");
}
let _ = write!(out, "<td>{}</td>", html_escape(&row.meet_instruction));
out.push_str("</tr>\n");
}
const HTML_HEAD: &str = r#"<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8">
<title>Cutting Sheet</title>
<style>
:root { color-scheme: light; }
* { box-sizing: border-box; }
body {
font-family: Arial, Helvetica, sans-serif;
background: #ffffff;
color: #111111;
margin: 24px;
}
h1 { font-size: 20px; margin: 0 0 12px; }
table.meta { border-collapse: collapse; margin-bottom: 16px; }
table.meta td { padding: 2px 12px 2px 0; font-size: 13px; vertical-align: top; }
table.meta td.label { font-weight: 700; color: #333333; white-space: nowrap; }
.diagram { text-align: center; margin: 8px 0 20px; }
.diagram img { max-width: 100%; height: auto; }
.diagram-missing { color: #b91c1c; font-style: italic; }
table.tiers { border-collapse: collapse; width: 100%; font-size: 12px; }
table.tiers th, table.tiers td {
border: 1px solid #999999;
padding: 4px 6px;
text-align: left;
}
table.tiers thead { background: #eeeeee; }
table.tiers tr { page-break-inside: avoid; break-inside: avoid; }
td.block-crown { color: #92400e; font-weight: 600; }
td.block-pavilion { color: #1e3a8a; font-weight: 600; }
td.block-girdle { color: #444444; font-weight: 600; }
@page { size: A4; margin: 14mm; }
@media print {
body { margin: 0; background: #ffffff; }
table.tiers thead { display: table-header-group; }
.diagram { break-inside: avoid; page-break-inside: avoid; }
}
</style>
</head>
<body>
"#;
const HTML_TAIL: &str = "</body>\n</html>\n";
pub fn write_cutting_sheet_html(
design: &Design,
solved: &[SolvedTier],
suggested_base_name: &str,
) -> Result<Option<PathBuf>, String> {
let diagram = render_cut_diagram(design, solved, SHEET_DIAGRAM_WIDTH, SHEET_DIAGRAM_HEIGHT);
let html = cutting_sheet_html(design, solved, diagram.as_ref());
let Some(dest) = rfd::FileDialog::new()
.set_file_name(format!("{suggested_base_name}_cutting_sheet.html"))
.add_filter("Cutting sheet (HTML)", &["html"])
.save_file()
else {
return Ok(None);
};
if let Some(parent) = dest.parent() {
let _ = std::fs::create_dir_all(parent);
}
std::fs::write(&dest, html).map_err(|e| format!("Failed to write {}: {e}", dest.display()))?;
Ok(Some(dest))
}
pub fn write_diagram_png(
design: &Design,
solved: &[SolvedTier],
suggested_base_name: &str,
) -> Result<Option<PathBuf>, String> {
let Some(image) =
render_cut_diagram(design, solved, DIAGRAM_EXPORT_WIDTH, DIAGRAM_EXPORT_HEIGHT)
else {
return Err(
"This design does not currently close to a solid -- nothing to diagram.".to_string(),
);
};
let Some(dest) = rfd::FileDialog::new()
.set_file_name(format!("{suggested_base_name}_diagram.png"))
.add_filter("Diagram (PNG)", &["png"])
.save_file()
else {
return Ok(None);
};
if let Some(parent) = dest.parent() {
let _ = std::fs::create_dir_all(parent);
}
std::fs::write(&dest, &image.png_bytes)
.map_err(|e| format!("Failed to write {}: {e}", dest.display()))?;
Ok(Some(dest))
}
#[cfg(test)]
mod tests {
use super::*;
use indicatrix_cut_core::{ConstraintTier, PreformSpec, ScheduleMeta};
fn round_brilliant_design() -> Design {
Design::new(
PreformSpec::block(2.0, 1.0, 2.0),
ScheduleMeta::standard_round_brilliant(),
ConstraintTier::standard_round_brilliant(),
)
}
#[test]
fn cutting_sheet_html_contains_expected_rows() {
let design = round_brilliant_design();
let solved = design.solve().expect("every tier is pinned");
let html = cutting_sheet_html(&design, &solved, None);
assert!(html.contains("<h1>Cutting Sheet</h1>"));
assert!(html.contains("Table"));
assert!(html.contains("Crown Main"));
assert!(html.contains("Pavilion Main"));
assert!(html.contains("Culet"));
let tbody_start = html.find("<tbody>").expect("tbody present") + "<tbody>".len();
let tbody_end = html.find("</tbody>").expect("tbody closes");
let tr_count = html[tbody_start..tbody_end].matches("<tr>").count();
assert_eq!(tr_count, design.tiers.len());
}
#[test]
fn mm_column_appears_only_when_girdle_diameter_is_set() {
let design_no_mm = round_brilliant_design();
let solved = design_no_mm.solve().expect("every tier is pinned");
let html_no_mm = cutting_sheet_html(&design_no_mm, &solved, None);
assert!(!html_no_mm.contains("Mast (mm)"));
let mut design_with_mm = round_brilliant_design();
design_with_mm.girdle_diameter_mm = Some(6.5);
let solved_mm = design_with_mm.solve().expect("every tier is pinned");
let html_with_mm = cutting_sheet_html(&design_with_mm, &solved_mm, None);
assert!(html_with_mm.contains("Mast (mm)"));
assert!(html_with_mm.contains("Girdle diameter"));
}
#[test]
fn block_labels_match_classify_blocks_not_angle_sign() {
let design = round_brilliant_design();
let solved = design.solve().expect("every tier is pinned");
let blocks = classify_blocks(&design.meet_tier_inputs());
assert_eq!(
blocks,
vec![
Block::Crown,
Block::Crown,
Block::Crown,
Block::Crown,
Block::Girdle,
Block::Pavilion,
Block::Pavilion,
Block::Pavilion,
]
);
let html = cutting_sheet_html(&design, &solved, None);
let culet_row_start = html.find("Culet").expect("Culet row present");
let row_html =
&html[culet_row_start..culet_row_start + 400.min(html.len() - culet_row_start)];
assert!(
row_html.contains("block-pavilion"),
"Culet's row must be labelled Pavilion, got: {row_html}"
);
}
#[test]
fn render_cut_diagram_produces_a_sized_png_for_a_closed_design() {
let design = round_brilliant_design();
let solved = design.solve().expect("every tier is pinned");
let image = render_cut_diagram(&design, &solved, 200, 100)
.expect("standard round brilliant closes to a solid");
assert_eq!(image.width, 200);
assert_eq!(image.height, 100);
assert_ne!(image.png_bytes, [] as [u8; 0]);
assert_eq!(&image.png_bytes[..8], &[137, 80, 78, 71, 13, 10, 26, 10]);
}
#[test]
fn diagram_embedding_switches_on_the_option() {
const NOTICE: &str = "<p class=\"diagram-missing\">";
let design = round_brilliant_design();
let solved = design.solve().expect("every tier is pinned");
let image = render_cut_diagram(&design, &solved, 64, 32).expect("closes to a solid");
let with_image = cutting_sheet_html(&design, &solved, Some(&image));
assert!(with_image.contains("data:image/png;base64,"));
assert!(!with_image.contains(NOTICE));
let without_image = cutting_sheet_html(&design, &solved, None);
assert!(without_image.contains(NOTICE));
assert!(!without_image.contains("data:image/png;base64,"));
}
#[test]
fn base64_encode_matches_known_vectors() {
assert_eq!(base64_encode(b""), "");
assert_eq!(base64_encode(b"f"), "Zg==");
assert_eq!(base64_encode(b"fo"), "Zm8=");
assert_eq!(base64_encode(b"foo"), "Zm9v");
assert_eq!(base64_encode(b"foobar"), "Zm9vYmFy");
}
#[test]
fn html_escape_handles_unsafe_characters() {
assert_eq!(
html_escape("A & B <tag> \"quoted\" 'apos'"),
"A & B <tag> "quoted" 'apos'"
);
}
}