use crate::gui::rough_plan::saved::dto::SavedDesignDto;
use glam::DVec3;
use indicatrix::geometry::stone_metrics::measure_solid;
use indicatrix_cut_core::rough_plan::{
PlacedStone, RoughLayout, RoughMesh, RoughModel,
shaped::{CLASS_EXTERIOR, CLASS_INTERIOR, classify_box_into},
};
use indicatrix_vault::model::{
solid_extents::SolidExtents,
tilt_curves::{AxisTiltCurves, TILT_CURVE_POINTS_PER_AXIS, TiltPerformanceCurves},
};
use std::{
collections::BTreeMap,
sync::{Arc, Mutex, PoisonError},
};
const FACES: [(&str, &str, [f64; 3]); 6] = [
("Top", "+Y", [0.0, 1.0, 0.0]),
("Bottom", "-Y", [0.0, -1.0, 0.0]),
("Right", "+X", [1.0, 0.0, 0.0]),
("Left", "-X", [-1.0, 0.0, 0.0]),
("Front", "+Z", [0.0, 0.0, 1.0]),
("Back", "-Z", [0.0, 0.0, -1.0]),
];
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
enum FillBase {
Piece,
Model,
}
#[derive(Debug, Clone)]
pub struct ModelGeometry {
pub volume_mm3: f64,
pub extents_mm: [f64; 3],
pub planes: Vec<(DVec3, f64)>,
pub mesh: Option<MeshGeometry>,
}
#[derive(Debug, Clone)]
pub struct MeshGeometry {
pub mesh: Arc<RoughMesh>,
pub cuts: Vec<(DVec3, f64)>,
volumes: PieceVolumes,
}
type PieceVolumes = Arc<Mutex<BTreeMap<[u64; 6], Option<f64>>>>;
impl ModelGeometry {
#[must_use]
pub fn of(model: &RoughModel) -> Option<Self> {
let planes = model.halfspaces().ok()?;
let measure = model.measure().ok()?;
let mesh = model.mesh().map(|mesh| {
let base = model.base.to_halfspaces(false).map_or(0, |base| base.len());
MeshGeometry {
mesh,
cuts: planes.get(base..).unwrap_or_default().to_vec(),
volumes: PieceVolumes::default(),
}
});
Some(Self {
volume_mm3: measure.volume_mm3,
extents_mm: measure.extents_mm,
planes,
mesh,
})
}
}
#[derive(Debug, Clone, Copy, PartialEq)]
pub struct SavedShape {
pub width_caliper: f64,
pub fingerprint: [f64; 3],
}
impl TryFrom<&SavedDesignDto> for SavedShape {
type Error = ();
fn try_from(design: &SavedDesignDto) -> Result<Self, ()> {
design
.width_caliper
.map(|width_caliper| Self {
width_caliper,
fingerprint: design.fingerprint,
})
.ok_or(())
}
}
#[derive(Debug, Clone, Copy, Default)]
pub struct DesignFacts<'a> {
pub extents: Option<&'a SolidExtents>,
pub curves: Option<&'a TiltPerformanceCurves>,
pub preview_material: Option<&'a str>,
pub saved: Option<SavedShape>,
}
#[must_use]
pub fn group_metrics(
stones: &[&PlacedStone],
layout: &RoughLayout,
model: Option<&ModelGeometry>,
design: &DesignFacts<'_>,
) -> Vec<(String, String)> {
let mut metrics = Vec::new();
if stones.is_empty() {
return metrics;
}
let mut push = |label: &str, value: String| metrics.push((label.to_string(), value));
push("Weight", weight_value(stones, layout));
if let Some(size) = size_value(stones, design.extents, design.saved.as_ref()) {
push("Size", size);
}
if let Some(ratios) = design.extents.and_then(ratios_value) {
push("Ratios", ratios);
}
push("Volume", volume_value(stones));
push("Orientation", orientation_value(stones));
if let Some(fill) = model.and_then(|model| fill_value(stones, layout, model)) {
push("Fill", fill);
}
push(
"Optics",
format_optics(design.curves, design.preview_material),
);
metrics
}
fn spread_alike(values: impl Iterator<Item = f64>, decimals: usize) -> (String, bool) {
let (low, high) = values.fold((f64::INFINITY, f64::NEG_INFINITY), |(low, high), v| {
(low.min(v), high.max(v))
});
let (low, high) = (format!("{low:.decimals$}"), format!("{high:.decimals$}"));
if low == high {
(low, true)
} else {
(format!("{low}-{high}"), false)
}
}
fn spread(values: impl Iterator<Item = f64>, decimals: usize) -> String {
spread_alike(values, decimals).0
}
const fn each_suffix(count: usize, alike: bool) -> &'static str {
if count > 1 && alike { " each" } else { "" }
}
fn weight_value(stones: &[&PlacedStone], layout: &RoughLayout) -> String {
let group_carat: f64 = stones.iter().map(|s| s.carat).sum();
let share = if layout.total_carat > 0.0 {
((group_carat / layout.total_carat) * 100.0).round() as i32
} else {
0
};
let (weight, alike) = spread_alike(stones.iter().map(|s| s.carat), 2);
let each = each_suffix(stones.len(), alike);
format!("{weight} ct{each} · {share} % of the total")
}
fn volume_value(stones: &[&PlacedStone]) -> String {
let (volume, alike) = spread_alike(stones.iter().map(|s| s.volume_mm3), 1);
format!("{volume} mm³{}", each_suffix(stones.len(), alike))
}
const fn width_and_length(extents: &SolidExtents) -> (f64, f64) {
(
extents.width_caliper.min(extents.length_caliper),
extents.width_caliper.max(extents.length_caliper),
)
}
type Dimensions = (f64, f64, f64);
fn saved_dimensions(saved: &SavedShape) -> Option<Dimensions> {
let [length_ratio, height_ratio, _] = saved.fingerprint;
let usable = [saved.width_caliper, length_ratio, height_ratio]
.iter()
.all(|figure| figure.is_finite() && *figure > 0.0);
usable.then_some((
saved.width_caliper,
saved.width_caliper * length_ratio,
saved.width_caliper * height_ratio,
))
}
fn widths_differ(a: f64, b: f64) -> bool {
(a - b).abs() > 1e-6 * a.abs().max(b.abs())
}
fn size_value(
stones: &[&PlacedStone],
extents: Option<&SolidExtents>,
saved: Option<&SavedShape>,
) -> Option<String> {
let current: Option<Dimensions> = extents.map(|extents| {
let (width, length) = width_and_length(extents);
(width, length, extents.height)
});
let recorded = saved.and_then(saved_dimensions);
let (width, length, height) = recorded.or(current)?;
let scaled = |unit: f64| spread(stones.iter().map(|s| s.pose.mm_per_unit * unit), 2);
let mut text = format!(
"L x W x D = {} x {} x {} mm",
scaled(length),
scaled(width),
scaled(height)
);
if let (Some((was, ..)), Some((now, ..))) = (recorded, current)
&& widths_differ(was, now)
{
text.push_str(" (design rescaled)");
}
Some(text)
}
fn ratios_value(extents: &SolidExtents) -> Option<String> {
let (width, length) = width_and_length(extents);
(width > 0.0).then(|| {
format!(
"L/W {:.2} · D/W {:.0} %",
length / width,
extents.height / width * 100.0
)
})
}
fn orientation_phrase(table_normal: [f64; 3]) -> String {
let mut best = FACES[0];
let mut best_dot = f64::NEG_INFINITY;
for face in FACES {
let dot = table_normal[0].mul_add(
face.2[0],
table_normal[1].mul_add(face.2[1], table_normal[2] * face.2[2]),
);
if dot > best_dot {
best_dot = dot;
best = face;
}
}
let angle_deg = best_dot.clamp(-1.0, 1.0).acos().to_degrees().round() as i32;
if angle_deg == 0 {
format!("faces {} ({})", best.0, best.1)
} else {
format!("tilted {angle_deg}° from {}", best.0)
}
}
fn orientation_value(stones: &[&PlacedStone]) -> String {
let mut variants: Vec<(String, usize)> = Vec::new();
for stone in stones {
let phrase = orientation_phrase(stone.pose.axes[1]);
match variants.iter_mut().find(|(seen, _)| *seen == phrase) {
Some((_, count)) => *count += 1,
None => variants.push((phrase, 1)),
}
}
if let [(phrase, _)] = variants.as_slice() {
return format!("table {phrase}");
}
variants.sort_by_key(|(_, count)| std::cmp::Reverse(*count));
let parts: Vec<String> = variants
.iter()
.map(|(phrase, count)| format!("{count} × {phrase}"))
.collect();
format!("table: {}", parts.join(", "))
}
fn piece_model_volume(model: &ModelGeometry, origin: [f64; 3], size: [f64; 3]) -> Option<f64> {
let planes = &model.planes;
let box_volume = size[0] * size[1] * size[2];
if !box_volume.is_finite() || box_volume <= 0.0 {
return None;
}
let far = [0, 1, 2].map(|i| origin[i] + size[i]);
if let Some(rough) = &model.mesh {
let key = [0, 1, 2, 3, 4, 5].map(|i| if i < 3 { origin[i] } else { size[i - 3] }.to_bits());
if let Some(&known) = rough
.volumes
.lock()
.unwrap_or_else(PoisonError::into_inner)
.get(&key)
{
return known;
}
let mut region = rough.cuts.clone();
for (axis, normal) in [DVec3::X, DVec3::Y, DVec3::Z].into_iter().enumerate() {
region.push((normal, far[axis]));
region.push((-normal, -origin[axis]));
}
let volume = Some(rough.mesh.volume_within(®ion)).filter(|volume| *volume > 0.0);
rough
.volumes
.lock()
.unwrap_or_else(PoisonError::into_inner)
.insert(key, volume);
return volume;
}
let mut crossed = Vec::new();
match classify_box_into(origin, far, planes, &mut crossed) {
CLASS_INTERIOR => Some(box_volume),
CLASS_EXTERIOR => None,
_ => {
let half = DVec3::from(size) * 0.5;
let centre = DVec3::from(origin) + half;
let mut region: Vec<(DVec3, f64)> = crossed
.iter()
.map(|&i| (planes[i].0, planes[i].1 - planes[i].0.dot(centre)))
.collect();
for (axis, reach) in [(DVec3::X, half.x), (DVec3::Y, half.y), (DVec3::Z, half.z)] {
region.push((axis, reach));
region.push((-axis, reach));
}
measure_solid(®ion)
.map(|metrics| metrics.volume)
.filter(|volume| *volume > 0.0)
}
}
}
fn stone_fill(
stone: &PlacedStone,
layout: &RoughLayout,
model: &ModelGeometry,
) -> Option<(f64, FillBase)> {
if layout.exact_fit {
if model.volume_mm3 <= 0.0 {
return None;
}
return Some((stone.volume_mm3 / model.volume_mm3 * 100.0, FillBase::Model));
}
let piece = piece_model_volume(model, stone.piece_origin_mm, stone.piece_size_mm)?;
Some((stone.volume_mm3 / piece * 100.0, FillBase::Piece))
}
fn fill_value(
stones: &[&PlacedStone],
layout: &RoughLayout,
model: &ModelGeometry,
) -> Option<String> {
let fills: Vec<(f64, FillBase)> = stones
.iter()
.filter_map(|stone| stone_fill(stone, layout, model))
.collect();
let (_, base) = *fills.first()?;
let percent = spread(fills.iter().map(|(percent, _)| *percent), 0);
Some(match (base, stones.len()) {
(FillBase::Model, _) => format!("stone uses {percent} % of the model"),
(FillBase::Piece, 1) => format!("stone uses {percent} % of its piece"),
(FillBase::Piece, _) => format!("stones use {percent} % of their pieces"),
})
}
const FACE_UP: usize = TILT_CURVE_POINTS_PER_AXIS / 2;
fn format_optics(curves: Option<&TiltPerformanceCurves>, preview_material: Option<&str>) -> String {
let Some(curves) = curves else {
return "not generated yet".to_string();
};
let mean = |pick: fn(&AxisTiltCurves) -> f32| -> Option<i32> {
let samples: Vec<f64> = curves
.axes
.iter()
.map(|axis| f64::from(pick(axis)))
.filter(|sample| sample.is_finite())
.collect();
(!samples.is_empty())
.then(|| (samples.iter().sum::<f64>() / samples.len() as f64).round() as i32)
};
let figures = [
mean(|axis| axis.brilliance_pct[FACE_UP]),
mean(|axis| axis.extinction_pct[FACE_UP]),
mean(|axis| axis.windowing_pct[FACE_UP]),
];
if figures.iter().all(Option::is_none) {
return "not generated yet".to_string();
}
let [brilliance, extinction, windowing] = figures
.map(|figure| figure.map_or_else(|| "n/a".to_string(), |value| format!("{value} %")));
let material = preview_material
.filter(|name| !name.trim().is_empty())
.map_or_else(String::new, |name| format!(" (preview material: {name})"));
format!("brilliance {brilliance} · extinction {extinction} · windowing {windowing}{material}")
}
#[cfg(test)]
mod tests;