use serde::{Deserialize, Serialize};
use serde_json::{json, Value};
use super::dimension::{SheetAnchor, HORIZONTAL, VERTICAL};
use super::project::{SheetText, ViewDrawing};
use brep_kernel::{format_dimension, format_number};
pub const AXES: &[&str] = &[HORIZONTAL, VERTICAL];
pub const DEFAULT_OFFSET_MM: f64 = 16.0;
pub const DEFAULT_PRECISION: u32 = 3;
const VALUE_GAP: f64 = 0.5;
const ORIGIN_RADIUS: f64 = 0.35;
const ORIGIN_STEPS: usize = 24;
const MAX_STAGGER_ROWS: usize = 8;
const VALUE_PAD: f64 = super::project::TEXT_ADVANCE;
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct SheetOrdinate {
pub id: String,
#[serde(default = "default_axis")]
pub axis: String,
#[serde(default)]
pub datum: String,
#[serde(default)]
pub members: Vec<String>,
#[serde(default = "default_offset", rename = "offsetMm")]
pub offset_mm: f64,
#[serde(default = "default_precision")]
pub precision: u32,
#[serde(flatten)]
pub tolerance: super::dimension::SheetTolerance,
}
fn default_axis() -> String {
HORIZONTAL.to_string()
}
fn default_offset() -> f64 {
DEFAULT_OFFSET_MM
}
fn default_precision() -> u32 {
DEFAULT_PRECISION
}
impl SheetOrdinate {
pub fn new(id: String, axis: &str, datum: String) -> SheetOrdinate {
SheetOrdinate {
id,
axis: axis.to_string(),
datum,
members: Vec::new(),
offset_mm: DEFAULT_OFFSET_MM,
precision: DEFAULT_PRECISION,
tolerance: Default::default(),
}
}
pub fn params(&self) -> Value {
let mut params = json!({
"id": self.id,
"axis": self.axis,
"datum": if self.datum.is_empty() { Value::Null } else { Value::String(self.datum.clone()) },
"members": self.members,
"offsetMm": self.offset_mm,
"precision": self.precision,
});
self.tolerance.write_params(&mut params);
params
}
pub fn apply(&mut self, params: &Value) -> Result<(), String> {
if let Some(axis) = params.get("axis").and_then(Value::as_str) {
let axis = axis.trim();
let Some(known) = AXES.iter().find(|a| a.eq_ignore_ascii_case(axis)) else {
return Err(format!("no ordinate axis '{axis}'"));
};
self.axis = (*known).to_string();
}
if let Some(datum) = params.get("datum") {
let picked: Vec<String> = match datum.as_str() {
Some(one) => vec![one.to_string()],
None => crate::json_support::string_values(Some(datum)).map(str::to_string).collect(),
}
.into_iter()
.filter(|anchor| !anchor.is_empty())
.collect();
if let Some(first) = picked.first() {
SheetAnchor::parse(first)?;
}
self.datum = picked.into_iter().next().unwrap_or_default();
}
if let Some(members) = params.get("members") {
let list: Vec<String> = crate::json_support::string_values(Some(members))
.map(str::to_string)
.collect();
for anchor in &list {
SheetAnchor::parse(anchor)?;
}
self.members = list;
}
if let Some(offset) = params.get("offsetMm").and_then(Value::as_f64) {
if !offset.is_finite() {
return Err(format!("offsetMm: {offset} is not a length"));
}
self.offset_mm = offset;
}
if let Some(precision) = params.get("precision").and_then(Value::as_f64) {
if !precision.is_finite() || !(0.0..=8.0).contains(&precision) {
return Err(format!("precision: {precision} is not 0 to 8 decimals"));
}
self.precision = precision.round() as u32;
}
self.tolerance.apply(params)
}
}
pub fn sheet_ordinate_schema() -> Value {
let mut fields = json!({
"id": { "type": "string", "default_value": null },
"axis": { "type": "options", "label": "Axis", "options": AXES, "default_value": HORIZONTAL },
"datum": { "type": "reference_selection", "selectionFilter": ["sheetAnchor"], "multiple": false },
"members": { "type": "reference_selection", "selectionFilter": ["sheetAnchor"], "multiple": true },
"offsetMm": { "type": "number", "label": "Baseline offset (mm)", "default_value": DEFAULT_OFFSET_MM },
"precision": { "type": "number", "label": "Precision (decimals)", "default_value": DEFAULT_PRECISION },
});
super::dimension::SheetTolerance::schema_fields(fields.as_object_mut().expect("an object"));
json!({
"type": "sheetOrdinate",
"shortName": "OD",
"longName": "Ordinate set",
"inputParamsSchema": fields,
})
}
#[derive(Debug, Clone, PartialEq, serde::Serialize)]
pub struct OrdinateDrawing {
pub id: String,
pub axis: String,
pub lines: Vec<Vec<[f64; 2]>>,
pub texts: Vec<SheetText>,
pub label: [f64; 2],
#[serde(rename = "offsetMm")]
pub offset_mm: f64,
pub stations: Vec<OrdinateMember>,
#[serde(skip_serializing_if = "String::is_empty")]
pub error: String,
}
#[derive(Debug, Clone, PartialEq, serde::Serialize)]
pub struct OrdinateMember {
#[serde(rename = "ref")]
pub anchor: String,
pub datum: bool,
pub value: f64,
pub text: String,
pub at: [f64; 2],
#[serde(skip_serializing_if = "String::is_empty")]
pub error: String,
}
impl OrdinateDrawing {
fn refused(set: &SheetOrdinate, label: [f64; 2], error: String) -> OrdinateDrawing {
OrdinateDrawing {
id: set.id.clone(),
axis: set.axis.clone(),
lines: Vec::new(),
texts: Vec::new(),
label,
offset_mm: set.offset_mm,
stations: Vec::new(),
error,
}
}
pub fn lost(&self) -> usize {
self.stations.iter().filter(|s| !s.error.is_empty()).count()
}
}
pub fn layout_ordinates(
ordinates: &[SheetOrdinate],
views: &[ViewDrawing],
centre: [f64; 2],
) -> Vec<OrdinateDrawing> {
ordinates
.iter()
.map(|set| layout_one(set, views, centre))
.collect()
}
fn layout_one(
set: &SheetOrdinate,
views: &[ViewDrawing],
centre: [f64; 2],
) -> OrdinateDrawing {
if set.datum.trim().is_empty() {
return OrdinateDrawing::refused(
set,
centre,
"an ordinate set has no datum; every value is measured from one".into(),
);
}
let (view, datum) = match super::dimension::resolve_anchor(&set.datum, views) {
Ok(found) => found,
Err(error) => return OrdinateDrawing::refused(set, centre, error),
};
if view.scale <= 0.0 {
return OrdinateDrawing::refused(
set,
datum.at,
format!("placement '{}' has no scale", view.id),
);
}
let vertical = set.axis.eq_ignore_ascii_case(VERTICAL);
let sign = if set.offset_mm < 0.0 { -1.0 } else { 1.0 };
let level = if vertical { datum.at[0] } else { datum.at[1] } + set.offset_mm;
let cap = super::dimension::text_height(view);
let mut lines: Vec<Vec<[f64; 2]>> = Vec::new();
let mut texts: Vec<SheetText> = Vec::new();
let mut stations: Vec<OrdinateMember> = Vec::new();
let mut placed: Vec<[f64; 4]> = Vec::new();
let mut label = datum.at;
let references = std::iter::once((&set.datum, true))
.chain(set.members.iter().map(|member| (member, false)));
for (reference, is_datum) in references {
let point = if is_datum {
Ok(datum)
} else {
match super::dimension::resolve_anchor(reference, views) {
Ok((other, _)) if other.id != view.id => Err(format!(
"'{reference}' is on placement '{}', not on the datum's '{}'",
other.id, view.id
)),
Ok((_, found)) => Ok(found),
Err(error) => Err(error),
}
};
let found = match point {
Ok(found) => found,
Err(error) => {
stations.push(OrdinateMember {
anchor: reference.clone(),
datum: is_datum,
value: f64::NAN,
text: String::new(),
at: [f64::NAN, f64::NAN],
error,
});
continue;
}
};
let value = if vertical {
(datum.at[1] - found.at[1]) / view.scale
} else {
(found.at[0] - datum.at[0]) / view.scale
};
let text = if is_datum {
format_number(value, set.precision as usize)
} else {
let block = set.tolerance.block();
format_dimension(value, set.precision as usize, &block, set.tolerance.reference, "", "")
};
let foot = if vertical {
[level, found.at[1]]
} else {
[found.at[0], level]
};
let half_width = 0.5 * text.chars().count() as f64 * super::project::TEXT_ADVANCE * cap;
let clear = cap * VALUE_GAP + if vertical { half_width } else { 0.5 * cap };
let away = |distance: f64| {
if vertical {
[foot[0] + sign * distance, foot[1]]
} else {
[foot[0], foot[1] + sign * distance]
}
};
let row = 2.0 * cap * VALUE_PAD + if vertical { 2.0 * half_width } else { cap };
let mut anchor = away(clear);
for step in 1..=MAX_STAGGER_ROWS {
if !placed
.iter()
.any(|other| boxes_collide(other, &value_box(anchor, half_width, cap), cap))
{
break;
}
anchor = away(clear + step as f64 * row);
}
placed.push(value_box(anchor, half_width, cap));
lines.push(vec![found.at, foot]);
texts.push(SheetText::flat(text.clone(), anchor, cap));
if is_datum {
lines.push(origin_circle(found.at, cap * ORIGIN_RADIUS));
label = anchor;
}
stations.push(OrdinateMember {
anchor: reference.clone(),
datum: is_datum,
value,
text,
at: found.at,
error: String::new(),
});
}
let lines = super::project::break_for_text_boxes(lines, &texts);
OrdinateDrawing {
id: set.id.clone(),
axis: set.axis.clone(),
lines,
texts,
label,
offset_mm: set.offset_mm,
stations,
error: String::new(),
}
}
fn value_box(anchor: [f64; 2], half_width: f64, cap: f64) -> [f64; 4] {
[
anchor[0] - half_width,
anchor[0] + half_width,
anchor[1] - 0.5 * cap,
anchor[1] + 0.5 * cap,
]
}
fn boxes_collide(a: &[f64; 4], b: &[f64; 4], cap: f64) -> bool {
let pad = cap * VALUE_PAD;
a[0] < b[1] + pad && b[0] < a[1] + pad && a[2] < b[3] + pad && b[2] < a[3] + pad
}
fn origin_circle(centre: [f64; 2], radius: f64) -> Vec<[f64; 2]> {
(0..=ORIGIN_STEPS)
.map(|step| {
let angle = std::f64::consts::TAU * step as f64 / ORIGIN_STEPS as f64;
[centre[0] + radius * angle.cos(), centre[1] + radius * angle.sin()]
})
.collect()
}