1pub mod dimension;
36pub mod frame;
37pub mod hlr;
38pub mod ordinate;
39pub mod pdf;
40pub mod pdf3d;
41pub mod project;
42pub mod svg;
43pub mod u3d;
44
45use serde::{Deserialize, Serialize};
46use serde_json::{json, Value};
47
48pub const SHEETS: &str = "sheets";
50
51pub const ORTHOGRAPHIC: &str = "orthographic";
53
54pub const PAPER_SIZES: &[(&str, f64, f64)] = &[
58 ("A4", 297.0, 210.0),
59 ("A3", 420.0, 297.0),
60 ("A2", 594.0, 420.0),
61 ("A1", 841.0, 594.0),
62 ("A0", 1189.0, 841.0),
63 ("Letter", 279.4, 215.9),
64 ("Tabloid", 431.8, 279.4),
65];
66
67pub const CUSTOM: &str = "Custom";
69
70pub const DEFAULT_SIZE: &str = "A3";
72
73pub const DEFAULT_SCALE: f64 = 1.0;
75
76pub const DEFAULT_BORDER_INSET_MM: f64 = 10.0;
78
79pub fn paper_size(key: &str) -> Option<(f64, f64)> {
81 PAPER_SIZES
82 .iter()
83 .find(|(name, _, _)| name.eq_ignore_ascii_case(key))
84 .map(|(_, w, h)| (*w, *h))
85}
86
87#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)]
89pub struct SheetState {
90 #[serde(default)]
91 pub sheets: Vec<Sheet>,
92 #[serde(default, rename = "idCounter")]
97 pub id_counter: u64,
98}
99
100#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
102pub struct Sheet {
103 pub id: String,
104 #[serde(default)]
105 pub name: String,
106 #[serde(default = "default_size")]
108 pub size: String,
109 #[serde(default, rename = "widthMm")]
110 pub width_mm: f64,
111 #[serde(default, rename = "heightMm")]
112 pub height_mm: f64,
113 #[serde(default = "default_true")]
116 pub border: bool,
117 #[serde(default = "default_border_inset", rename = "borderInsetMm")]
121 pub border_inset_mm: f64,
122 #[serde(default = "default_true", rename = "titleBlock")]
125 pub title_block: bool,
126 #[serde(default)]
128 pub notes: String,
129 #[serde(default)]
130 pub views: Vec<PlacedView>,
131 #[serde(default)]
135 pub dimensions: Vec<dimension::SheetDimension>,
136 #[serde(default)]
142 pub ordinates: Vec<ordinate::SheetOrdinate>,
143 #[serde(default, skip_serializing_if = "Vec::is_empty")]
147 pub revisions: Vec<Revision>,
148}
149
150#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)]
155pub struct Revision {
156 #[serde(default)]
157 pub rev: String,
158 #[serde(default)]
159 pub date: String,
160 #[serde(default)]
161 pub description: String,
162}
163
164impl Revision {
165 pub const FIELDS: &'static [&'static str] = &["rev", "date", "description"];
167
168 pub fn apply(&mut self, params: &Value) -> Result<(), String> {
171 let Some(object) = params.as_object() else {
172 return Err("a revision is an object of rev, date and description".into());
173 };
174 for (key, value) in object {
175 let slot = match key.as_str() {
176 "rev" => &mut self.rev,
177 "date" => &mut self.date,
178 "description" => &mut self.description,
179 other => return Err(format!("a revision has no field '{other}'")),
180 };
181 *slot = value
182 .as_str()
183 .ok_or_else(|| format!("revision {key}: {value} is not text"))?
184 .to_string();
185 }
186 Ok(())
187 }
188}
189
190fn default_size() -> String {
191 DEFAULT_SIZE.to_string()
192}
193
194fn default_true() -> bool {
195 true
196}
197
198fn is_false(value: &bool) -> bool {
199 !*value
200}
201
202fn default_border_inset() -> f64 {
203 DEFAULT_BORDER_INSET_MM
204}
205
206#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
208pub struct PlacedView {
209 pub id: String,
210 #[serde(default)]
212 pub view: String,
213 #[serde(default)]
217 pub position: [f64; 2],
218 #[serde(default = "default_scale")]
220 pub scale: f64,
221 #[serde(default = "default_projection")]
223 pub projection: String,
224 #[serde(default = "default_true", rename = "flattenText")]
231 pub flatten_text: bool,
232 #[serde(default, rename = "threeD", skip_serializing_if = "is_false")]
239 pub three_d: bool,
240 #[serde(default, skip_serializing_if = "Option::is_none")]
245 pub section: Option<SectionCut>,
246 #[serde(default, skip_serializing_if = "Option::is_none")]
251 pub detail: Option<DetailCircle>,
252}
253
254#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
270pub struct SectionCut {
271 #[serde(default)]
275 pub source: String,
276 #[serde(default)]
278 pub from: String,
279 #[serde(default)]
280 pub to: String,
281 #[serde(default = "default_label")]
284 pub label: String,
285 #[serde(default)]
290 pub flip: bool,
291}
292
293fn default_label() -> String {
294 "A".to_string()
295}
296
297#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
307pub struct DetailCircle {
308 #[serde(default)]
311 pub source: String,
312 #[serde(default)]
314 pub centre: String,
315 #[serde(default)]
319 pub rim: String,
320 #[serde(default = "default_label")]
324 pub label: String,
325}
326
327pub const DETAIL_SCALE_FACTOR: f64 = 2.0;
330
331impl SectionCut {
332 pub const LETTERS: &'static [char] = &[
335 'A', 'B', 'C', 'D', 'E', 'F', 'G', 'H', 'J', 'K', 'L', 'M', 'N', 'P', 'R', 'S', 'T', 'U',
336 'V', 'W', 'X', 'Y', 'Z',
337 ];
338}
339
340fn default_scale() -> f64 {
341 DEFAULT_SCALE
342}
343
344fn default_projection() -> String {
345 ORTHOGRAPHIC.to_string()
346}
347
348impl Sheet {
349 pub fn millimetres(&self) -> (f64, f64) {
352 match paper_size(&self.size) {
353 Some((w, h)) => (w, h),
354 None => (self.width_mm.max(1.0), self.height_mm.max(1.0)),
355 }
356 }
357
358 pub fn border_inset(&self) -> f64 {
361 let (w, h) = self.millimetres();
362 self.border_inset_mm.max(0.0).min(w.min(h) * 0.25)
363 }
364
365 pub fn find_view(&self, id: &str) -> Option<&PlacedView> {
366 self.views.iter().find(|view| view.id == id)
367 }
368
369 pub fn find_view_mut(&mut self, id: &str) -> Option<&mut PlacedView> {
370 self.views.iter_mut().find(|view| view.id == id)
371 }
372
373 pub fn find_dimension(&self, id: &str) -> Option<&dimension::SheetDimension> {
374 self.dimensions.iter().find(|d| d.id == id)
375 }
376
377 pub fn find_ordinate(&self, id: &str) -> Option<&ordinate::SheetOrdinate> {
378 self.ordinates.iter().find(|o| o.id == id)
379 }
380
381 pub fn next_view_letter(&self) -> String {
388 let taken: Vec<&str> = self
389 .views
390 .iter()
391 .filter_map(|view| {
392 view.section
393 .as_ref()
394 .map(|cut| cut.label.as_str())
395 .or_else(|| view.detail.as_ref().map(|circle| circle.label.as_str()))
396 })
397 .collect();
398 SectionCut::LETTERS
399 .iter()
400 .map(|letter| letter.to_string())
401 .find(|letter| !taken.iter().any(|used| used == letter))
402 .unwrap_or_else(|| SectionCut::LETTERS[SectionCut::LETTERS.len() - 1].to_string())
403 }
404}
405
406impl SheetState {
407 pub fn next_id(&mut self, prefix: &str) -> String {
410 let seen = self.max_numeric_suffix();
411 if seen > self.id_counter {
412 self.id_counter = seen;
413 }
414 loop {
415 self.id_counter += 1;
416 let candidate = format!("{prefix}{}", self.id_counter);
417 if self.find_sheet(&candidate).is_none()
418 && self.locate_view(&candidate).is_none()
419 && self.locate_dimension(&candidate).is_none()
420 && self.locate_ordinate(&candidate).is_none()
421 {
422 return candidate;
423 }
424 }
425 }
426
427 fn max_numeric_suffix(&self) -> u64 {
428 let mut best = 0u64;
429 let mut consider = |id: &str| {
430 let digits = id.bytes().rev().take_while(u8::is_ascii_digit).count();
431 if digits > 0 {
432 if let Ok(value) = id[id.len() - digits..].parse::<u64>() {
433 best = best.max(value);
434 }
435 }
436 };
437 for sheet in &self.sheets {
438 consider(&sheet.id);
439 for view in &sheet.views {
440 consider(&view.id);
441 }
442 for dimension in &sheet.dimensions {
443 consider(&dimension.id);
444 }
445 for set in &sheet.ordinates {
446 consider(&set.id);
447 }
448 }
449 best
450 }
451
452 pub fn find_sheet(&self, id: &str) -> Option<&Sheet> {
453 self.sheets.iter().find(|sheet| sheet.id == id)
454 }
455
456 pub fn find_sheet_mut(&mut self, id: &str) -> Option<&mut Sheet> {
457 self.sheets.iter_mut().find(|sheet| sheet.id == id)
458 }
459
460 pub fn locate_view(&self, id: &str) -> Option<(String, usize)> {
462 self.sheets.iter().find_map(|sheet| {
463 sheet
464 .views
465 .iter()
466 .position(|view| view.id == id)
467 .map(|index| (sheet.id.clone(), index))
468 })
469 }
470
471 pub fn find_view_mut(&mut self, id: &str) -> Option<&mut PlacedView> {
472 self.sheets
473 .iter_mut()
474 .find_map(|sheet| sheet.views.iter_mut().find(|view| view.id == id))
475 }
476
477 pub fn locate_dimension(&self, id: &str) -> Option<(String, usize)> {
479 self.sheets.iter().find_map(|sheet| {
480 sheet
481 .dimensions
482 .iter()
483 .position(|dimension| dimension.id == id)
484 .map(|index| (sheet.id.clone(), index))
485 })
486 }
487
488 pub fn find_dimension(&self, id: &str) -> Option<&dimension::SheetDimension> {
489 self.sheets.iter().find_map(|sheet| sheet.find_dimension(id))
490 }
491
492 pub fn find_dimension_mut(&mut self, id: &str) -> Option<&mut dimension::SheetDimension> {
493 self.sheets
494 .iter_mut()
495 .find_map(|sheet| sheet.dimensions.iter_mut().find(|d| d.id == id))
496 }
497
498 pub fn locate_ordinate(&self, id: &str) -> Option<(String, usize)> {
500 self.sheets.iter().find_map(|sheet| {
501 sheet
502 .ordinates
503 .iter()
504 .position(|set| set.id == id)
505 .map(|index| (sheet.id.clone(), index))
506 })
507 }
508
509 pub fn find_ordinate(&self, id: &str) -> Option<&ordinate::SheetOrdinate> {
510 self.sheets.iter().find_map(|sheet| sheet.find_ordinate(id))
511 }
512
513 pub fn find_ordinate_mut(&mut self, id: &str) -> Option<&mut ordinate::SheetOrdinate> {
514 self.sheets
515 .iter_mut()
516 .find_map(|sheet| sheet.ordinates.iter_mut().find(|o| o.id == id))
517 }
518
519 pub fn is_empty(&self) -> bool {
521 self.sheets.is_empty() && self.id_counter == 0
522 }
523}
524
525pub fn schema_catalogue(view_ids: &[String]) -> Value {
535 json!({
536 "objects": [
537 sheet_schema(),
538 sheet_view_schema(view_ids),
539 dimension::sheet_dimension_schema(),
540 ordinate::sheet_ordinate_schema(),
541 section_view_schema(),
542 detail_view_schema(),
543 ]
544 })
545}
546
547pub fn section_view_schema() -> Value {
552 json!({
553 "type": "sectionView",
554 "shortName": "SV",
555 "longName": "Section view",
556 "inputParamsSchema": {
557 "id": { "type": "string", "default_value": null },
558 "cut": { "type": "reference_selection", "label": "Cutting line", "selectionFilter": ["sheetAnchor"], "multiple": true, "hint": "Two anchors on one placed view" },
559 "sectionLabel": { "type": "string", "label": "Section letter", "default_value": "A" },
560 "sectionFlip": { "type": "boolean", "label": "Section looks the other way", "default_value": false },
561 "positionXMm": { "type": "number", "label": "X (mm)", "default_value": 0.0 },
562 "positionYMm": { "type": "number", "label": "Y (mm)", "default_value": 0.0 },
563 "scale": { "type": "number", "label": "Scale (mm per model unit)", "default_value": DEFAULT_SCALE },
564 "flattenText": { "type": "boolean", "label": "Flatten text to sheet", "default_value": true },
565 }
566 })
567}
568
569pub fn detail_view_schema() -> Value {
573 json!({
574 "type": "detailView",
575 "shortName": "SV",
576 "longName": "Detail view",
577 "inputParamsSchema": {
578 "id": { "type": "string", "default_value": null },
579 "centre": { "type": "reference_selection", "label": "Centre", "selectionFilter": ["sheetAnchor"], "multiple": false, "hint": "The circle's centre, an anchor on a placed view" },
580 "rim": { "type": "reference_selection", "label": "Point on the rim", "selectionFilter": ["sheetAnchor"], "multiple": false, "hint": "An anchor on the same view, on the circle" },
581 "detailLabel": { "type": "string", "label": "Detail letter", "default_value": "A" },
582 "positionXMm": { "type": "number", "label": "X (mm)", "default_value": 0.0 },
583 "positionYMm": { "type": "number", "label": "Y (mm)", "default_value": 0.0 },
584 "scale": { "type": "number", "label": "Scale (mm per model unit)", "default_value": DEFAULT_SCALE },
585 "flattenText": { "type": "boolean", "label": "Flatten text to sheet", "default_value": true },
586 }
587 })
588}
589
590pub fn sheet_schema() -> Value {
592 let sizes: Vec<&str> = PAPER_SIZES
593 .iter()
594 .map(|(key, _, _)| *key)
595 .chain(std::iter::once(CUSTOM))
596 .collect();
597 json!({
598 "type": "sheet",
599 "shortName": "SHT",
600 "longName": "Sheet",
601 "inputParamsSchema": {
602 "id": { "type": "string", "default_value": null },
603 "name": { "type": "string", "default_value": "Sheet 1" },
604 "size": { "type": "options", "label": "Paper size", "options": sizes, "default_value": DEFAULT_SIZE },
605 "widthMm": { "type": "number", "label": "Width (mm)", "default_value": 420.0 },
606 "heightMm": { "type": "number", "label": "Height (mm)", "default_value": 297.0 },
607 "border": { "type": "boolean", "label": "Border", "default_value": true },
608 "borderInsetMm": { "type": "number", "label": "Border inset (mm)", "default_value": DEFAULT_BORDER_INSET_MM },
609 "titleBlock": { "type": "boolean", "label": "Title block", "default_value": true },
610 "notes": { "type": "string", "label": "Notes", "default_value": "" },
611 "revisions": { "type": "revision_table", "label": "Revisions", "columns": Revision::FIELDS, "default_value": [] },
615 }
616 })
617}
618
619pub fn sheet_view_schema(view_ids: &[String]) -> Value {
622 json!({
623 "type": "sheetView",
624 "shortName": "SV",
625 "longName": "Placed view",
626 "inputParamsSchema": {
627 "id": { "type": "string", "default_value": null },
628 "view": { "type": "options", "label": "PMI view", "options": view_ids, "default_value": view_ids.first().cloned().unwrap_or_default() },
629 "positionXMm": { "type": "number", "label": "X (mm)", "default_value": 0.0 },
630 "positionYMm": { "type": "number", "label": "Y (mm)", "default_value": 0.0 },
631 "scale": { "type": "number", "label": "Scale (mm per model unit)", "default_value": DEFAULT_SCALE },
632 "projection": { "type": "options", "label": "Projection", "options": [ORTHOGRAPHIC], "default_value": ORTHOGRAPHIC },
633 "flattenText": { "type": "boolean", "label": "Flatten text to sheet", "default_value": true },
634 "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" },
635 }
636 })
637}
638
639impl Sheet {
640 pub fn params(&self) -> Value {
642 let (w, h) = self.millimetres();
643 json!({
644 "id": self.id,
645 "name": self.name,
646 "size": self.size,
647 "widthMm": w,
648 "heightMm": h,
649 "border": self.border,
650 "borderInsetMm": self.border_inset_mm,
651 "titleBlock": self.title_block,
652 "notes": self.notes,
653 "revisions": self.revisions,
654 })
655 }
656
657 pub fn apply(&mut self, params: &Value) -> Result<(), String> {
660 if let Some(name) = params.get("name").and_then(Value::as_str) {
661 let name = name.trim();
662 if name.is_empty() {
663 return Err("a sheet needs a name".into());
664 }
665 self.name = name.to_string();
666 }
667 if let Some(size) = params.get("size").and_then(Value::as_str) {
668 let size = size.trim();
669 if !size.eq_ignore_ascii_case(CUSTOM) && paper_size(size).is_none() {
670 return Err(format!("no paper size '{size}'"));
671 }
672 self.size = if size.eq_ignore_ascii_case(CUSTOM) {
673 CUSTOM.to_string()
674 } else {
675 size.to_string()
676 };
677 }
678 let dimension = |key: &str, current: f64| -> Result<f64, String> {
679 match params.get(key).and_then(Value::as_f64) {
680 None => Ok(current),
681 Some(value) if value.is_finite() && value > 0.0 => Ok(value),
682 Some(value) => Err(format!("{key}: {value} is not a positive length")),
683 }
684 };
685 self.width_mm = dimension("widthMm", self.width_mm)?;
686 self.height_mm = dimension("heightMm", self.height_mm)?;
687 if let Some((w, h)) = paper_size(&self.size) {
690 self.width_mm = w;
691 self.height_mm = h;
692 }
693 if let Some(border) = params.get("border").and_then(Value::as_bool) {
697 self.border = border;
698 }
699 if let Some(inset) = params.get("borderInsetMm").and_then(Value::as_f64) {
700 let (w, h) = self.millimetres();
701 if !inset.is_finite() || inset < 0.0 {
702 return Err(format!("borderInsetMm: {inset} is not a length"));
703 }
704 if inset > w.min(h) * 0.25 {
705 return Err(format!(
706 "borderInsetMm: {inset} leaves no drawing area on a {w:.0} × {h:.0} mm sheet"
707 ));
708 }
709 self.border_inset_mm = inset;
710 }
711 if let Some(title_block) = params.get("titleBlock").and_then(Value::as_bool) {
712 self.title_block = title_block;
713 }
714 if let Some(notes) = params.get("notes").and_then(Value::as_str) {
717 self.notes = notes.to_string();
718 }
719 if let Some(rows) = params.get("revisions") {
723 let rows = rows.as_array().ok_or("revisions: not a list of rows")?;
724 let mut revisions = Vec::with_capacity(rows.len());
725 for (index, row) in rows.iter().enumerate() {
726 let mut revision = Revision::default();
727 revision.apply(row).map_err(|error| format!("revisions[{index}]: {error}"))?;
728 revisions.push(revision);
729 }
730 self.revisions = revisions;
731 }
732 Ok(())
733 }
734}
735
736impl PlacedView {
737 pub fn params(&self) -> Value {
742 let mut params = json!({
743 "id": self.id,
744 "view": self.view,
745 "positionXMm": self.position[0],
746 "positionYMm": self.position[1],
747 "scale": self.scale,
748 "projection": self.projection,
749 "flattenText": self.flatten_text,
750 });
751 if self.schema_type() == "sheetView" {
752 params.as_object_mut().expect("an object").insert("threeD".into(), json!(self.three_d));
753 }
754 let object = params.as_object_mut().expect("an object");
755 if let Some(cut) = &self.section {
756 object.insert("cut".into(), json!(self.picked("cut")));
757 object.insert("sectionLabel".into(), json!(cut.label));
758 object.insert("sectionFlip".into(), json!(cut.flip));
759 }
760 if let Some(circle) = &self.detail {
761 object.insert("centre".into(), json!(circle.centre));
762 object.insert("rim".into(), json!(circle.rim));
763 object.insert("detailLabel".into(), json!(circle.label));
764 }
765 params
766 }
767
768 pub fn picked(&self, field: &str) -> Vec<String> {
772 let list: Vec<&String> = match (field, &self.section, &self.detail) {
773 ("cut", Some(cut), _) => vec![&cut.from, &cut.to],
774 ("centre", _, Some(circle)) => vec![&circle.centre],
775 ("rim", _, Some(circle)) => vec![&circle.rim],
776 _ => Vec::new(),
777 };
778 list.into_iter().filter(|anchor| !anchor.is_empty()).cloned().collect()
779 }
780
781 pub fn schema_type(&self) -> &'static str {
784 match (&self.section, &self.detail) {
785 (Some(_), _) => "sectionView",
786 (_, Some(_)) => "detailView",
787 _ => "sheetView",
788 }
789 }
790
791 pub fn apply(&mut self, params: &Value, view_ids: &[String]) -> Result<(), String> {
793 if let Some(view) = params.get("view").and_then(Value::as_str) {
794 let view = view.trim();
795 if !view.is_empty() && !view_ids.iter().any(|id| id == view) {
796 return Err(format!("no PMI view '{view}'"));
797 }
798 self.view = view.to_string();
799 }
800 for (key, slot) in [("positionXMm", 0usize), ("positionYMm", 1usize)] {
801 if let Some(value) = params.get(key).and_then(Value::as_f64) {
802 if !value.is_finite() {
803 return Err(format!("{key}: not a finite length"));
804 }
805 self.position[slot] = value;
806 }
807 }
808 if let Some(scale) = params.get("scale").and_then(Value::as_f64) {
809 if !scale.is_finite() || scale <= 0.0 {
810 return Err(format!("scale: {scale} is not a positive ratio"));
811 }
812 self.scale = scale;
813 }
814 if let Some(projection) = params.get("projection").and_then(Value::as_str) {
815 if !projection.eq_ignore_ascii_case(ORTHOGRAPHIC) {
816 return Err(format!(
817 "projection '{projection}' is not drawn on a sheet yet — this slice is orthographic only"
818 ));
819 }
820 self.projection = ORTHOGRAPHIC.to_string();
821 }
822 if let Some(flatten) = params.get("flattenText").and_then(Value::as_bool) {
823 self.flatten_text = flatten;
824 }
825 if let Some(three_d) = params.get("threeD").and_then(Value::as_bool) {
829 if three_d && self.schema_type() != "sheetView" {
830 return Err(format!("'{}' is a {}; live 3D is for a plain placed view", self.id, self.schema_type()));
831 }
832 self.three_d = three_d;
833 }
834 let label = params.get("sectionLabel").and_then(Value::as_str).map(str::trim);
840 let flip = params.get("sectionFlip").and_then(Value::as_bool);
841 match self.section.as_mut() {
842 Some(cut) => {
843 if let Some(label) = label.filter(|l| !l.is_empty()) {
844 cut.label = label.to_string();
845 }
846 if let Some(flip) = flip {
847 cut.flip = flip;
848 }
849 }
850 None => {
851 if label.is_some_and(|l| !l.is_empty()) || flip == Some(true) {
852 return Err(format!(
853 "'{}' is not a section view; it has no cutting plane to letter or turn round",
854 self.id
855 ));
856 }
857 }
858 }
859 let detail_label = params.get("detailLabel").and_then(Value::as_str).map(str::trim);
862 match self.detail.as_mut() {
863 Some(circle) => {
864 if let Some(label) = detail_label.filter(|l| !l.is_empty()) {
865 circle.label = label.to_string();
866 }
867 }
868 None => {
869 if detail_label.is_some_and(|l| !l.is_empty()) {
870 return Err(format!(
871 "'{}' is not a detail view; it has no circle to letter",
872 self.id
873 ));
874 }
875 }
876 }
877 Ok(())
878 }
879}
880