pub mod dimension;
pub mod frame;
pub mod hlr;
pub mod ordinate;
pub mod pdf;
pub mod pdf3d;
pub mod print;
pub mod project;
pub mod svg;
pub mod u3d;
use serde::{Deserialize, Serialize};
use serde_json::{json, Value};
pub const SHEETS: &str = "sheets";
pub const ORTHOGRAPHIC: &str = "orthographic";
pub const PAPER_SIZES: &[(&str, f64, f64)] = &[
("A4", 297.0, 210.0),
("A3", 420.0, 297.0),
("A2", 594.0, 420.0),
("A1", 841.0, 594.0),
("A0", 1189.0, 841.0),
("Letter", 279.4, 215.9),
("Tabloid", 431.8, 279.4),
];
pub const CUSTOM: &str = "Custom";
pub const DEFAULT_SIZE: &str = "A3";
pub const DEFAULT_SCALE: f64 = 1.0;
pub const DEFAULT_BORDER_INSET_MM: f64 = 10.0;
pub fn paper_size(key: &str) -> Option<(f64, f64)> {
PAPER_SIZES
.iter()
.find(|(name, _, _)| name.eq_ignore_ascii_case(key))
.map(|(_, w, h)| (*w, *h))
}
#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)]
pub struct SheetState {
#[serde(default)]
pub sheets: Vec<Sheet>,
#[serde(default, rename = "idCounter")]
pub id_counter: u64,
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct Sheet {
pub id: String,
#[serde(default)]
pub name: String,
#[serde(default = "default_size")]
pub size: String,
#[serde(default, rename = "widthMm")]
pub width_mm: f64,
#[serde(default, rename = "heightMm")]
pub height_mm: f64,
#[serde(default = "default_true")]
pub border: bool,
#[serde(default = "default_border_inset", rename = "borderInsetMm")]
pub border_inset_mm: f64,
#[serde(default = "default_true", rename = "titleBlock")]
pub title_block: bool,
#[serde(default)]
pub notes: String,
#[serde(default)]
pub views: Vec<PlacedView>,
#[serde(default)]
pub dimensions: Vec<dimension::SheetDimension>,
#[serde(default)]
pub ordinates: Vec<ordinate::SheetOrdinate>,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub revisions: Vec<Revision>,
#[serde(default, rename = "bomTable", skip_serializing_if = "Option::is_none")]
pub bom_table: Option<BomTable>,
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct BomTable {
pub position: [f64; 2],
pub column_width_mm: f64,
pub columns: Vec<String>,
}
impl Default for BomTable {
fn default() -> Self {
Self {
position: [20., 20.],
column_width_mm: 32.,
columns: vec![
"occurrence.Find_Number".into(),
"partName".into(),
"occurrence.Quantity".into(),
],
}
}
}
#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)]
pub struct Revision {
#[serde(default)]
pub rev: String,
#[serde(default)]
pub date: String,
#[serde(default)]
pub description: String,
}
impl Revision {
pub const FIELDS: &'static [&'static str] = &["rev", "date", "description"];
pub fn apply(&mut self, params: &Value) -> Result<(), String> {
let Some(object) = params.as_object() else {
return Err("a revision is an object of rev, date and description".into());
};
for (key, value) in object {
let slot = match key.as_str() {
"rev" => &mut self.rev,
"date" => &mut self.date,
"description" => &mut self.description,
other => return Err(format!("a revision has no field '{other}'")),
};
*slot = value
.as_str()
.ok_or_else(|| format!("revision {key}: {value} is not text"))?
.to_string();
}
Ok(())
}
}
fn default_size() -> String {
DEFAULT_SIZE.to_string()
}
fn default_true() -> bool {
true
}
fn is_false(value: &bool) -> bool {
!*value
}
fn default_border_inset() -> f64 {
DEFAULT_BORDER_INSET_MM
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct PlacedView {
pub id: String,
#[serde(default)]
pub view: String,
#[serde(default)]
pub position: [f64; 2],
#[serde(default = "default_scale")]
pub scale: f64,
#[serde(default = "default_projection")]
pub projection: String,
#[serde(default = "default_true", rename = "flattenText")]
pub flatten_text: bool,
#[serde(default, rename = "threeD", skip_serializing_if = "is_false")]
pub three_d: bool,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub section: Option<SectionCut>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub detail: Option<DetailCircle>,
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct SectionCut {
#[serde(default)]
pub source: String,
#[serde(default)]
pub from: String,
#[serde(default)]
pub to: String,
#[serde(default = "default_label")]
pub label: String,
#[serde(default)]
pub flip: bool,
}
fn default_label() -> String {
"A".to_string()
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct DetailCircle {
#[serde(default)]
pub source: String,
#[serde(default)]
pub centre: String,
#[serde(default)]
pub rim: String,
#[serde(default = "default_label")]
pub label: String,
}
pub const DETAIL_SCALE_FACTOR: f64 = 2.0;
impl SectionCut {
pub const LETTERS: &'static [char] = &[
'A', 'B', 'C', 'D', 'E', 'F', 'G', 'H', 'J', 'K', 'L', 'M', 'N', 'P', 'R', 'S', 'T', 'U',
'V', 'W', 'X', 'Y', 'Z',
];
}
fn first_free_letter(taken: &[String]) -> String {
SectionCut::LETTERS
.iter()
.map(|letter| letter.to_string())
.find(|letter| !taken.iter().any(|used| used == letter))
.unwrap_or_else(|| SectionCut::LETTERS[SectionCut::LETTERS.len() - 1].to_string())
}
fn default_scale() -> f64 {
DEFAULT_SCALE
}
fn default_projection() -> String {
ORTHOGRAPHIC.to_string()
}
impl Sheet {
pub fn millimetres(&self) -> (f64, f64) {
match paper_size(&self.size) {
Some((w, h)) => (w, h),
None => (self.width_mm.max(1.0), self.height_mm.max(1.0)),
}
}
pub fn border_inset(&self) -> f64 {
let (w, h) = self.millimetres();
self.border_inset_mm.max(0.0).min(w.min(h) * 0.25)
}
pub fn find_view(&self, id: &str) -> Option<&PlacedView> {
self.views.iter().find(|view| view.id == id)
}
pub fn find_view_mut(&mut self, id: &str) -> Option<&mut PlacedView> {
self.views.iter_mut().find(|view| view.id == id)
}
pub fn find_dimension(&self, id: &str) -> Option<&dimension::SheetDimension> {
self.dimensions.iter().find(|d| d.id == id)
}
pub fn find_ordinate(&self, id: &str) -> Option<&ordinate::SheetOrdinate> {
self.ordinates.iter().find(|o| o.id == id)
}
pub fn next_view_letter(&self, views: &[brep_kernel::PmiView]) -> String {
let taken: Vec<String> = self.view_letters(views).into_iter().flatten().collect();
first_free_letter(&taken)
}
pub fn view_letters(&self, views: &[brep_kernel::PmiView]) -> Vec<Option<String>> {
let stored = |placed: &PlacedView| {
placed
.section
.as_ref()
.map(|cut| cut.label.clone())
.or_else(|| placed.detail.as_ref().map(|circle| circle.label.clone()))
};
let mut taken: Vec<String> = self.views.iter().filter_map(stored).collect();
self.views
.iter()
.map(|placed| {
if let Some(letter) = stored(placed) {
return Some(letter);
}
let sectioned = views
.iter()
.find(|view| view.id == placed.view)
.is_some_and(|view| view.display.section.is_some());
sectioned.then(|| {
let letter = first_free_letter(&taken);
taken.push(letter.clone());
letter
})
})
.collect()
}
}
impl SheetState {
pub fn next_id(&mut self, prefix: &str) -> String {
let seen = self.max_numeric_suffix();
if seen > self.id_counter {
self.id_counter = seen;
}
loop {
self.id_counter += 1;
let candidate = format!("{prefix}{}", self.id_counter);
if self.find_sheet(&candidate).is_none()
&& self.locate_view(&candidate).is_none()
&& self.locate_dimension(&candidate).is_none()
&& self.locate_ordinate(&candidate).is_none()
{
return candidate;
}
}
}
fn max_numeric_suffix(&self) -> u64 {
let mut best = 0u64;
let mut consider = |id: &str| {
let digits = id.bytes().rev().take_while(u8::is_ascii_digit).count();
if digits > 0 {
if let Ok(value) = id[id.len() - digits..].parse::<u64>() {
best = best.max(value);
}
}
};
for sheet in &self.sheets {
consider(&sheet.id);
for view in &sheet.views {
consider(&view.id);
}
for dimension in &sheet.dimensions {
consider(&dimension.id);
}
for set in &sheet.ordinates {
consider(&set.id);
}
}
best
}
pub fn find_sheet(&self, id: &str) -> Option<&Sheet> {
self.sheets.iter().find(|sheet| sheet.id == id)
}
pub fn find_sheet_mut(&mut self, id: &str) -> Option<&mut Sheet> {
self.sheets.iter_mut().find(|sheet| sheet.id == id)
}
pub fn locate_view(&self, id: &str) -> Option<(String, usize)> {
self.sheets.iter().find_map(|sheet| {
sheet
.views
.iter()
.position(|view| view.id == id)
.map(|index| (sheet.id.clone(), index))
})
}
pub fn find_view_mut(&mut self, id: &str) -> Option<&mut PlacedView> {
self.sheets
.iter_mut()
.find_map(|sheet| sheet.views.iter_mut().find(|view| view.id == id))
}
pub fn locate_dimension(&self, id: &str) -> Option<(String, usize)> {
self.sheets.iter().find_map(|sheet| {
sheet
.dimensions
.iter()
.position(|dimension| dimension.id == id)
.map(|index| (sheet.id.clone(), index))
})
}
pub fn find_dimension(&self, id: &str) -> Option<&dimension::SheetDimension> {
self.sheets.iter().find_map(|sheet| sheet.find_dimension(id))
}
pub fn find_dimension_mut(&mut self, id: &str) -> Option<&mut dimension::SheetDimension> {
self.sheets
.iter_mut()
.find_map(|sheet| sheet.dimensions.iter_mut().find(|d| d.id == id))
}
pub fn locate_ordinate(&self, id: &str) -> Option<(String, usize)> {
self.sheets.iter().find_map(|sheet| {
sheet
.ordinates
.iter()
.position(|set| set.id == id)
.map(|index| (sheet.id.clone(), index))
})
}
pub fn find_ordinate(&self, id: &str) -> Option<&ordinate::SheetOrdinate> {
self.sheets.iter().find_map(|sheet| sheet.find_ordinate(id))
}
pub fn find_ordinate_mut(&mut self, id: &str) -> Option<&mut ordinate::SheetOrdinate> {
self.sheets
.iter_mut()
.find_map(|sheet| sheet.ordinates.iter_mut().find(|o| o.id == id))
}
pub fn is_empty(&self) -> bool {
self.sheets.is_empty() && self.id_counter == 0
}
}
pub fn schema_catalogue(view_ids: &[String]) -> Value {
json!({
"objects": [
sheet_schema(),
sheet_view_schema(view_ids),
dimension::sheet_dimension_schema(),
ordinate::sheet_ordinate_schema(),
section_view_schema(),
detail_view_schema(),
]
})
}
pub fn section_view_schema() -> Value {
json!({
"type": "sectionView",
"shortName": "SV",
"longName": "Section view",
"inputParamsSchema": {
"id": { "type": "string", "default_value": null },
"cut": { "type": "reference_selection", "label": "Cutting line", "selectionFilter": ["sheetAnchor"], "multiple": true, "hint": "Two anchors on one placed view" },
"sectionLabel": { "type": "string", "label": "Section letter", "default_value": "A" },
"sectionFlip": { "type": "boolean", "label": "Section looks the other way", "default_value": false },
"positionXMm": { "type": "number", "label": "X (mm)", "default_value": 0.0 },
"positionYMm": { "type": "number", "label": "Y (mm)", "default_value": 0.0 },
"scale": { "type": "number", "label": "Scale (mm per model unit)", "default_value": DEFAULT_SCALE },
"flattenText": { "type": "boolean", "label": "Flatten text to sheet", "default_value": true },
}
})
}
pub fn detail_view_schema() -> Value {
json!({
"type": "detailView",
"shortName": "SV",
"longName": "Detail view",
"inputParamsSchema": {
"id": { "type": "string", "default_value": null },
"centre": { "type": "reference_selection", "label": "Centre", "selectionFilter": ["sheetAnchor"], "multiple": false, "hint": "The circle's centre, an anchor on a placed view" },
"rim": { "type": "reference_selection", "label": "Point on the rim", "selectionFilter": ["sheetAnchor"], "multiple": false, "hint": "An anchor on the same view, on the circle" },
"detailLabel": { "type": "string", "label": "Detail letter", "default_value": "A" },
"positionXMm": { "type": "number", "label": "X (mm)", "default_value": 0.0 },
"positionYMm": { "type": "number", "label": "Y (mm)", "default_value": 0.0 },
"scale": { "type": "number", "label": "Scale (mm per model unit)", "default_value": DEFAULT_SCALE },
"flattenText": { "type": "boolean", "label": "Flatten text to sheet", "default_value": true },
}
})
}
pub fn sheet_schema() -> Value {
let sizes: Vec<&str> = PAPER_SIZES
.iter()
.map(|(key, _, _)| *key)
.chain(std::iter::once(CUSTOM))
.collect();
json!({
"type": "sheet",
"shortName": "SHT",
"longName": "Sheet",
"inputParamsSchema": {
"id": { "type": "string", "default_value": null },
"name": { "type": "string", "default_value": "Sheet 1" },
"size": { "type": "options", "label": "Paper size", "options": sizes, "default_value": DEFAULT_SIZE },
"widthMm": { "type": "number", "label": "Width (mm)", "default_value": 420.0 },
"heightMm": { "type": "number", "label": "Height (mm)", "default_value": 297.0 },
"border": { "type": "boolean", "label": "Border", "default_value": true },
"borderInsetMm": { "type": "number", "label": "Border inset (mm)", "default_value": DEFAULT_BORDER_INSET_MM },
"titleBlock": { "type": "boolean", "label": "Title block", "default_value": true },
"notes": { "type": "string", "label": "Notes", "default_value": "" },
"revisions": { "type": "revision_table", "label": "Revisions", "columns": Revision::FIELDS, "default_value": [] },
}
})
}
pub fn sheet_view_schema(view_ids: &[String]) -> Value {
json!({
"type": "sheetView",
"shortName": "SV",
"longName": "Placed view",
"inputParamsSchema": {
"id": { "type": "string", "default_value": null },
"view": { "type": "options", "label": "PMI view", "options": view_ids, "default_value": view_ids.first().cloned().unwrap_or_default() },
"positionXMm": { "type": "number", "label": "X (mm)", "default_value": 0.0 },
"positionYMm": { "type": "number", "label": "Y (mm)", "default_value": 0.0 },
"scale": { "type": "number", "label": "Scale (mm per model unit)", "default_value": DEFAULT_SCALE },
"projection": { "type": "options", "label": "Projection", "options": [ORTHOGRAPHIC], "default_value": ORTHOGRAPHIC },
"flattenText": { "type": "boolean", "label": "Flatten text to sheet", "default_value": true },
"threeD": { "type": "boolean", "label": "Live 3D in PDF", "default_value": false, "hint": "In the Sheets + 3D (PDF) export, Acrobat and Foxit turn and zoom this view; other readers show the drawing" },
}
})
}
impl Sheet {
pub fn params(&self) -> Value {
let (w, h) = self.millimetres();
json!({
"id": self.id,
"name": self.name,
"size": self.size,
"widthMm": w,
"heightMm": h,
"border": self.border,
"borderInsetMm": self.border_inset_mm,
"titleBlock": self.title_block,
"notes": self.notes,
"revisions": self.revisions,
"bomTable": self.bom_table,
})
}
pub fn apply(&mut self, params: &Value) -> Result<(), String> {
if let Some(name) = params.get("name").and_then(Value::as_str) {
let name = name.trim();
if name.is_empty() {
return Err("a sheet needs a name".into());
}
self.name = name.to_string();
}
if let Some(size) = params.get("size").and_then(Value::as_str) {
let size = size.trim();
if !size.eq_ignore_ascii_case(CUSTOM) && paper_size(size).is_none() {
return Err(format!("no paper size '{size}'"));
}
self.size = if size.eq_ignore_ascii_case(CUSTOM) {
CUSTOM.to_string()
} else {
size.to_string()
};
}
let dimension = |key: &str, current: f64| -> Result<f64, String> {
match params.get(key).and_then(Value::as_f64) {
None => Ok(current),
Some(value) if value.is_finite() && value > 0.0 => Ok(value),
Some(value) => Err(format!("{key}: {value} is not a positive length")),
}
};
self.width_mm = dimension("widthMm", self.width_mm)?;
self.height_mm = dimension("heightMm", self.height_mm)?;
if let Some((w, h)) = paper_size(&self.size) {
self.width_mm = w;
self.height_mm = h;
}
if let Some(border) = params.get("border").and_then(Value::as_bool) {
self.border = border;
}
if let Some(inset) = params.get("borderInsetMm").and_then(Value::as_f64) {
let (w, h) = self.millimetres();
if !inset.is_finite() || inset < 0.0 {
return Err(format!("borderInsetMm: {inset} is not a length"));
}
if inset > w.min(h) * 0.25 {
return Err(format!(
"borderInsetMm: {inset} leaves no drawing area on a {w:.0} × {h:.0} mm sheet"
));
}
self.border_inset_mm = inset;
}
if let Some(title_block) = params.get("titleBlock").and_then(Value::as_bool) {
self.title_block = title_block;
}
if let Some(notes) = params.get("notes").and_then(Value::as_str) {
self.notes = notes.to_string();
}
if let Some(value) = params.get("bomTable") {
self.bom_table = if value.is_null() { None } else {
let table: BomTable = serde_json::from_value(value.clone()).map_err(|e| format!("BOM table: {e}"))?;
if table.columns.is_empty() || table.columns.len() > 32 { return Err("choose between 1 and 32 BOM columns".into()); }
if !table.position.iter().all(|v| v.is_finite()) || !table.column_width_mm.is_finite() || table.column_width_mm < 8. { return Err("BOM position must be finite and column width at least 8 mm".into()); }
Some(table)
};
}
if let Some(rows) = params.get("revisions") {
let rows = rows.as_array().ok_or("revisions: not a list of rows")?;
let mut revisions = Vec::with_capacity(rows.len());
for (index, row) in rows.iter().enumerate() {
let mut revision = Revision::default();
revision.apply(row).map_err(|error| format!("revisions[{index}]: {error}"))?;
revisions.push(revision);
}
self.revisions = revisions;
}
Ok(())
}
}
impl PlacedView {
pub fn params(&self) -> Value {
let mut params = json!({
"id": self.id,
"view": self.view,
"positionXMm": self.position[0],
"positionYMm": self.position[1],
"scale": self.scale,
"projection": self.projection,
"flattenText": self.flatten_text,
});
if self.schema_type() == "sheetView" {
params.as_object_mut().expect("an object").insert("threeD".into(), json!(self.three_d));
}
let object = params.as_object_mut().expect("an object");
if let Some(cut) = &self.section {
object.insert("cut".into(), json!(self.picked("cut")));
object.insert("sectionLabel".into(), json!(cut.label));
object.insert("sectionFlip".into(), json!(cut.flip));
}
if let Some(circle) = &self.detail {
object.insert("centre".into(), json!(circle.centre));
object.insert("rim".into(), json!(circle.rim));
object.insert("detailLabel".into(), json!(circle.label));
}
params
}
pub fn picked(&self, field: &str) -> Vec<String> {
let list: Vec<&String> = match (field, &self.section, &self.detail) {
("cut", Some(cut), _) => vec![&cut.from, &cut.to],
("centre", _, Some(circle)) => vec![&circle.centre],
("rim", _, Some(circle)) => vec![&circle.rim],
_ => Vec::new(),
};
list.into_iter().filter(|anchor| !anchor.is_empty()).cloned().collect()
}
pub fn schema_type(&self) -> &'static str {
match (&self.section, &self.detail) {
(Some(_), _) => "sectionView",
(_, Some(_)) => "detailView",
_ => "sheetView",
}
}
pub fn apply(&mut self, params: &Value, view_ids: &[String]) -> Result<(), String> {
if let Some(view) = params.get("view").and_then(Value::as_str) {
let view = view.trim();
if !view.is_empty() && !view_ids.iter().any(|id| id == view) {
return Err(format!("no PMI view '{view}'"));
}
self.view = view.to_string();
}
for (key, slot) in [("positionXMm", 0usize), ("positionYMm", 1usize)] {
if let Some(value) = params.get(key).and_then(Value::as_f64) {
if !value.is_finite() {
return Err(format!("{key}: not a finite length"));
}
self.position[slot] = value;
}
}
if let Some(scale) = params.get("scale").and_then(Value::as_f64) {
if !scale.is_finite() || scale <= 0.0 {
return Err(format!("scale: {scale} is not a positive ratio"));
}
self.scale = scale;
}
if let Some(projection) = params.get("projection").and_then(Value::as_str) {
if !projection.eq_ignore_ascii_case(ORTHOGRAPHIC) {
return Err(format!(
"projection '{projection}' is not drawn on a sheet yet — this slice is orthographic only"
));
}
self.projection = ORTHOGRAPHIC.to_string();
}
if let Some(flatten) = params.get("flattenText").and_then(Value::as_bool) {
self.flatten_text = flatten;
}
if let Some(three_d) = params.get("threeD").and_then(Value::as_bool) {
if three_d && self.schema_type() != "sheetView" {
return Err(format!("'{}' is a {}; live 3D is for a plain placed view", self.id, self.schema_type()));
}
self.three_d = three_d;
}
let label = params.get("sectionLabel").and_then(Value::as_str).map(str::trim);
let flip = params.get("sectionFlip").and_then(Value::as_bool);
match self.section.as_mut() {
Some(cut) => {
if let Some(label) = label.filter(|l| !l.is_empty()) {
cut.label = label.to_string();
}
if let Some(flip) = flip {
cut.flip = flip;
}
}
None => {
if label.is_some_and(|l| !l.is_empty()) || flip == Some(true) {
return Err(format!(
"'{}' is not a section view; it has no cutting plane to letter or turn round",
self.id
));
}
}
}
let detail_label = params.get("detailLabel").and_then(Value::as_str).map(str::trim);
match self.detail.as_mut() {
Some(circle) => {
if let Some(label) = detail_label.filter(|l| !l.is_empty()) {
circle.label = label.to_string();
}
}
None => {
if detail_label.is_some_and(|l| !l.is_empty()) {
return Err(format!(
"'{}' is not a detail view; it has no circle to letter",
self.id
));
}
}
}
Ok(())
}
}