use super::{
checks::{RoughFrame, check_layout},
convert::{
CandidateSource, candidate_source_from_dto, check_designs, layout_from_dto, layout_to_dto,
material_from_dto, rough_from_dto, rough_to_dto, settings_from_dto, settings_to_dto,
},
dto::{
CURRENT_SCHEMA_VERSION, DesignShape, HeaderDto, MAX_LAYOUTS, MAX_NAME_CHARS,
MAX_PAYLOAD_BYTES, ROUGH_PLAN_FORMAT, SavedDesignDto, SavedLayoutDto, SavedPlanDto,
SummaryDto,
},
};
use indicatrix_cut_core::rough_plan::{PlanSettings, RoughLayout, RoughModel};
use indicatrix_vault::model::solid_extents::{SOLID_EXTENTS_VERSION, SolidExtents};
use std::{borrow::Cow, collections::BTreeSet};
const FINGERPRINT_DIGITS: usize = 9;
#[derive(Debug, Clone, PartialEq)]
pub struct LoadedPlan {
pub version: u32,
pub name: String,
pub created_at: i64,
pub library_id: Option<u32>,
pub model: RoughModel,
pub material_name: String,
pub weighed_ct: Option<f64>,
pub settings: PlanSettings,
pub candidate_source: CandidateSource,
pub designs: Vec<SavedDesignDto>,
pub layouts: Vec<RoughLayout>,
}
#[must_use]
pub fn round_significant(value: f64, digits: usize) -> f64 {
if !value.is_finite() {
return value;
}
format!("{value:.precision$e}", precision = digits.saturating_sub(1))
.parse()
.unwrap_or(value)
}
#[must_use]
pub fn compute_fingerprint(extents: &SolidExtents) -> [f64; 3] {
let width = extents.width_caliper.min(extents.length_caliper);
let length = extents.width_caliper.max(extents.length_caliper);
if !width.is_finite() || width <= 0.0 {
return [0.0; 3];
}
let ratios = [
length / width,
extents.height / width,
extents.volume / (width * width * width),
];
if ratios.iter().all(|ratio| ratio.is_finite()) {
ratios.map(|ratio| round_significant(ratio, FINGERPRINT_DIGITS))
} else {
[0.0; 3]
}
}
#[must_use]
pub fn design_shape(extents: &SolidExtents) -> DesignShape {
let fingerprint = compute_fingerprint(extents);
if fingerprint
.iter()
.all(|ratio| ratio.abs() < f64::MIN_POSITIVE)
{
return DesignShape::default();
}
DesignShape {
fingerprint,
width_caliper: Some(round_significant(
extents.width_caliper.min(extents.length_caliper),
FINGERPRINT_DIGITS,
)),
extents_version: SOLID_EXTENTS_VERSION,
}
}
fn header_end(text: &str) -> usize {
let mut offset = 0;
for line in text.split_inclusive('\n') {
if line.trim_start().starts_with('[') {
return offset;
}
offset += line.len();
}
text.len()
}
fn parse_error_text(error: &toml::de::Error, text: &str) -> String {
let line = error
.span()
.and_then(|span| text.get(..span.start))
.map(|before| before.bytes().filter(|byte| *byte == b'\n').count() + 1);
line.map_or_else(
|| error.message().to_string(),
|line| format!("{} (line {line})", error.message()),
)
}
fn read_header(text: &str) -> Result<HeaderDto, String> {
if let Some(prefix) = text.get(..header_end(text))
&& let Ok(header) = toml::from_str::<HeaderDto>(prefix)
{
return Ok(header);
}
toml::from_str::<HeaderDto>(text).map_err(|e| {
format!(
"This is not a rough plan file: {}",
parse_error_text(&e, text)
)
})
}
pub fn check_header(text: &str) -> Result<u32, String> {
let header = read_header(text)?;
match header.format.as_deref() {
Some(ROUGH_PLAN_FORMAT) => {}
Some(other) => {
return Err(format!(
"This is not an Indicatrix rough plan: its format is '{other}', expected '{ROUGH_PLAN_FORMAT}'."
));
}
None => {
return Err(
"This is not an Indicatrix rough plan: the 'format' key is missing.".to_string(),
);
}
}
match header.version {
Some(version) if version > i64::from(CURRENT_SCHEMA_VERSION) => Err(format!(
"This plan was made by a newer Indicatrix (plan version {version}; this one reads up to {CURRENT_SCHEMA_VERSION}). Update Indicatrix to open it."
)),
Some(version) if version >= 1 => u32::try_from(version)
.map_err(|_| format!("The plan's 'version' {version} is invalid.")),
Some(version) => Err(format!(
"The plan's 'version' must be at least 1, found {version}."
)),
None => Err("The plan's 'version' key is missing.".to_string()),
}
}
#[must_use]
pub fn payload_version_of(text: &str) -> Option<u32> {
read_header(text)
.ok()?
.version
.and_then(|version| u32::try_from(version).ok())
}
fn migrate(text: &str, version: u32) -> Result<Cow<'_, str>, String> {
match version {
CURRENT_SCHEMA_VERSION => Ok(Cow::Borrowed(text)),
older => Err(format!(
"This build cannot bring a version {older} plan up to version {CURRENT_SCHEMA_VERSION}."
)),
}
}
fn check_limits(dto: &SavedPlanDto) -> Result<(), String> {
if dto.name.chars().count() > MAX_NAME_CHARS {
return Err(format!(
"name is longer than {MAX_NAME_CHARS} characters ({})",
dto.name.chars().count()
));
}
if dto.layouts.len() > MAX_LAYOUTS {
return Err(format!(
"layouts lists {} layouts; a plan holds at most {MAX_LAYOUTS}",
dto.layouts.len()
));
}
Ok(())
}
fn layouts_from_dto(
dtos: &[SavedLayoutDto],
frame: &RoughFrame,
) -> Result<Vec<RoughLayout>, String> {
let mut ranked = Vec::with_capacity(dtos.len());
let mut seen = BTreeSet::new();
for (i, dto) in dtos.iter().enumerate() {
let path = format!("layouts[{i}]");
let layout = layout_from_dto(dto, &path)?;
check_layout(&layout, frame, &path)?;
if !seen.insert(dto.rank) {
return Err(format!(
"{path}.rank {} appears twice; every layout needs its own rank",
dto.rank
));
}
ranked.push((dto.rank, layout));
}
ranked.sort_by_key(|(rank, _)| *rank);
Ok(ranked.into_iter().map(|(_, layout)| layout).collect())
}
fn check_design_use(designs: &[SavedDesignDto], layouts: &[RoughLayout]) -> Result<(), String> {
let known: BTreeSet<i64> = designs.iter().map(|design| design.entry_id).collect();
let mut used = BTreeSet::new();
for (i, layout) in layouts.iter().enumerate() {
for (j, stone) in layout.stones.iter().enumerate() {
if !known.contains(&stone.entry_id) {
return Err(format!(
"layouts[{i}].stones[{j}].entry_id {} has no entry in designs",
stone.entry_id
));
}
used.insert(stone.entry_id);
}
}
match designs
.iter()
.enumerate()
.find(|(_, design)| !used.contains(&design.entry_id))
{
Some((i, design)) => Err(format!(
"designs[{i}].entry_id {} is not used by any stone",
design.entry_id
)),
None => Ok(()),
}
}
fn plan_from_dto(dto: SavedPlanDto, version: u32) -> Result<LoadedPlan, String> {
check_limits(&dto)?;
let model = rough_from_dto(&dto.rough)?;
let measure = model.measure().map_err(|e| format!("rough: {e}"))?;
let (material_name, specific_gravity, weighed_ct) = material_from_dto(&dto.rough)?;
let settings = settings_from_dto(&dto.settings, specific_gravity)?;
let candidate_source = candidate_source_from_dto(&dto.settings)?;
check_designs(&dto.designs)?;
let frame = RoughFrame {
specific_gravity,
volume_mm3: measure.volume_mm3,
bbox_mm: model.base.bounding_box_extents(),
};
let layouts = layouts_from_dto(&dto.layouts, &frame)?;
check_design_use(&dto.designs, &layouts)?;
Ok(LoadedPlan {
version,
name: dto.name,
created_at: dto.created_at,
library_id: dto.library_id,
model,
material_name,
weighed_ct,
settings,
candidate_source,
designs: dto.designs,
layouts,
})
}
pub fn parse_and_validate_plan(text: &str) -> Result<LoadedPlan, String> {
if text.len() > MAX_PAYLOAD_BYTES {
return Err(format!(
"The file is too large to be a rough plan ({} bytes).",
text.len()
));
}
let version = check_header(text)?;
let body = migrate(text, version)?;
let dto: SavedPlanDto = toml::from_str(&body).map_err(|e| {
format!(
"The rough plan is incomplete or malformed: {}",
parse_error_text(&e, &body)
)
})?;
plan_from_dto(dto, version)
}
#[must_use]
pub fn plan_summary(payload: &str) -> String {
let Ok(summary) = toml::from_str::<SummaryDto>(payload) else {
return "Saved plan".to_string();
};
let count = summary.layouts.len();
let mut base = summary.rough.base.trim().chars();
let base: String = base
.next()
.map(|first| first.to_uppercase().chain(base).collect())
.unwrap_or_default();
format!(
"{base} · {} · {count} result{}",
summary.rough.material.trim(),
if count == 1 { "" } else { "s" }
)
}
fn name_value_span(line: &str) -> Option<(usize, usize)> {
let after_key = line.trim_start().strip_prefix("name")?;
let value = after_key.trim_start().strip_prefix('=')?.trim_start();
let start = line.len() - value.len();
let mut chars = value.char_indices();
let (_, quote) = chars.next()?;
if quote != '"' && quote != '\'' {
return None;
}
if value.starts_with("\"\"\"") || value.starts_with("'''") {
return None;
}
let mut escaped = false;
for (offset, c) in chars {
if quote == '"' && escaped {
escaped = false;
} else if quote == '"' && c == '\\' {
escaped = true;
} else if c == quote {
return Some((start, start + offset + 1));
}
}
None
}
fn patch_name_line(payload: &str, name: &str) -> Option<String> {
let header = payload.get(..header_end(payload))?;
let mut offset = 0;
for line in header.split_inclusive('\n') {
if let Some((start, end)) = name_value_span(line) {
let quoted = toml::Value::String(name.to_string()).to_string();
return Some(format!(
"{}{quoted}{}",
&payload[..offset + start],
&payload[offset + end..]
));
}
offset += line.len();
}
None
}
#[must_use]
pub fn payload_with_name(payload: &str, name: &str) -> String {
if let Some(patched) = patch_name_line(payload, name) {
return patched;
}
let Ok(mut dto) = toml::from_str::<SavedPlanDto>(payload) else {
return payload.to_string();
};
dto.name = name.to_string();
toml::to_string_pretty(&dto).unwrap_or_else(|_| payload.to_string())
}
pub struct RankedLayout<'a> {
pub rank: usize,
pub layout: &'a RoughLayout,
}
pub struct SerializeInput<'a> {
pub name: &'a str,
pub created_at: i64,
pub library_id: Option<u32>,
pub model: &'a RoughModel,
pub material_name: &'a str,
pub weighed_ct: Option<f64>,
pub settings: &'a PlanSettings,
pub candidate_source: CandidateSource,
pub designs: &'a [SavedDesignDto],
pub layouts: &'a [RankedLayout<'a>],
}
pub fn serialize_plan_to_toml(input: &SerializeInput<'_>) -> Result<String, String> {
let doc = SavedPlanDto {
format: ROUGH_PLAN_FORMAT.to_string(),
version: CURRENT_SCHEMA_VERSION,
name: input.name.to_string(),
created_at: input.created_at,
library_id: input.library_id,
rough: rough_to_dto(
input.model,
input.material_name,
input.settings.specific_gravity,
input.weighed_ct,
),
settings: settings_to_dto(input.settings, input.candidate_source),
designs: input.designs.to_vec(),
layouts: input
.layouts
.iter()
.map(|ranked| layout_to_dto(ranked.layout, ranked.rank))
.collect(),
};
toml::to_string_pretty(&doc).map_err(|e| format!("Could not write the plan: {e}"))
}
pub fn serialize_checked_plan(input: &SerializeInput<'_>) -> Result<String, String> {
let text = serialize_plan_to_toml(input)?;
parse_and_validate_plan(&text)
.map_err(|e| format!("The plan cannot be saved because it would not open again: {e}"))?;
Ok(text)
}