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 #[serde(default, rename = "bomTable", skip_serializing_if = "Option::is_none")]
149 pub bom_table: Option<BomTable>,
150}
151
152#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
154#[serde(rename_all = "camelCase")]
155pub struct BomTable {
156 pub position: [f64; 2],
157 pub column_width_mm: f64,
158 pub columns: Vec<String>,
159}
160impl Default for BomTable {
161 fn default() -> Self {
162 Self {
163 position: [20., 20.],
164 column_width_mm: 32.,
165 columns: vec![
166 "occurrence.Find_Number".into(),
167 "partName".into(),
168 "occurrence.Quantity".into(),
169 ],
170 }
171 }
172}
173
174#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)]
179pub struct Revision {
180 #[serde(default)]
181 pub rev: String,
182 #[serde(default)]
183 pub date: String,
184 #[serde(default)]
185 pub description: String,
186}
187
188impl Revision {
189 pub const FIELDS: &'static [&'static str] = &["rev", "date", "description"];
191
192 pub fn apply(&mut self, params: &Value) -> Result<(), String> {
195 let Some(object) = params.as_object() else {
196 return Err("a revision is an object of rev, date and description".into());
197 };
198 for (key, value) in object {
199 let slot = match key.as_str() {
200 "rev" => &mut self.rev,
201 "date" => &mut self.date,
202 "description" => &mut self.description,
203 other => return Err(format!("a revision has no field '{other}'")),
204 };
205 *slot = value
206 .as_str()
207 .ok_or_else(|| format!("revision {key}: {value} is not text"))?
208 .to_string();
209 }
210 Ok(())
211 }
212}
213
214fn default_size() -> String {
215 DEFAULT_SIZE.to_string()
216}
217
218fn default_true() -> bool {
219 true
220}
221
222fn is_false(value: &bool) -> bool {
223 !*value
224}
225
226fn default_border_inset() -> f64 {
227 DEFAULT_BORDER_INSET_MM
228}
229
230#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
232pub struct PlacedView {
233 pub id: String,
234 #[serde(default)]
236 pub view: String,
237 #[serde(default)]
241 pub position: [f64; 2],
242 #[serde(default = "default_scale")]
244 pub scale: f64,
245 #[serde(default = "default_projection")]
247 pub projection: String,
248 #[serde(default = "default_true", rename = "flattenText")]
255 pub flatten_text: bool,
256 #[serde(default, rename = "threeD", skip_serializing_if = "is_false")]
263 pub three_d: bool,
264 #[serde(default, skip_serializing_if = "Option::is_none")]
269 pub section: Option<SectionCut>,
270 #[serde(default, skip_serializing_if = "Option::is_none")]
275 pub detail: Option<DetailCircle>,
276}
277
278#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
294pub struct SectionCut {
295 #[serde(default)]
299 pub source: String,
300 #[serde(default)]
302 pub from: String,
303 #[serde(default)]
304 pub to: String,
305 #[serde(default = "default_label")]
308 pub label: String,
309 #[serde(default)]
314 pub flip: bool,
315}
316
317fn default_label() -> String {
318 "A".to_string()
319}
320
321#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
331pub struct DetailCircle {
332 #[serde(default)]
335 pub source: String,
336 #[serde(default)]
338 pub centre: String,
339 #[serde(default)]
343 pub rim: String,
344 #[serde(default = "default_label")]
348 pub label: String,
349}
350
351pub const DETAIL_SCALE_FACTOR: f64 = 2.0;
354
355impl SectionCut {
356 pub const LETTERS: &'static [char] = &[
359 'A', 'B', 'C', 'D', 'E', 'F', 'G', 'H', 'J', 'K', 'L', 'M', 'N', 'P', 'R', 'S', 'T', 'U',
360 'V', 'W', 'X', 'Y', 'Z',
361 ];
362}
363
364fn default_scale() -> f64 {
365 DEFAULT_SCALE
366}
367
368fn default_projection() -> String {
369 ORTHOGRAPHIC.to_string()
370}
371
372impl Sheet {
373 pub fn millimetres(&self) -> (f64, f64) {
376 match paper_size(&self.size) {
377 Some((w, h)) => (w, h),
378 None => (self.width_mm.max(1.0), self.height_mm.max(1.0)),
379 }
380 }
381
382 pub fn border_inset(&self) -> f64 {
385 let (w, h) = self.millimetres();
386 self.border_inset_mm.max(0.0).min(w.min(h) * 0.25)
387 }
388
389 pub fn find_view(&self, id: &str) -> Option<&PlacedView> {
390 self.views.iter().find(|view| view.id == id)
391 }
392
393 pub fn find_view_mut(&mut self, id: &str) -> Option<&mut PlacedView> {
394 self.views.iter_mut().find(|view| view.id == id)
395 }
396
397 pub fn find_dimension(&self, id: &str) -> Option<&dimension::SheetDimension> {
398 self.dimensions.iter().find(|d| d.id == id)
399 }
400
401 pub fn find_ordinate(&self, id: &str) -> Option<&ordinate::SheetOrdinate> {
402 self.ordinates.iter().find(|o| o.id == id)
403 }
404
405 pub fn next_view_letter(&self) -> String {
412 let taken: Vec<&str> = self
413 .views
414 .iter()
415 .filter_map(|view| {
416 view.section
417 .as_ref()
418 .map(|cut| cut.label.as_str())
419 .or_else(|| view.detail.as_ref().map(|circle| circle.label.as_str()))
420 })
421 .collect();
422 SectionCut::LETTERS
423 .iter()
424 .map(|letter| letter.to_string())
425 .find(|letter| !taken.iter().any(|used| used == letter))
426 .unwrap_or_else(|| SectionCut::LETTERS[SectionCut::LETTERS.len() - 1].to_string())
427 }
428}
429
430impl SheetState {
431 pub fn next_id(&mut self, prefix: &str) -> String {
434 let seen = self.max_numeric_suffix();
435 if seen > self.id_counter {
436 self.id_counter = seen;
437 }
438 loop {
439 self.id_counter += 1;
440 let candidate = format!("{prefix}{}", self.id_counter);
441 if self.find_sheet(&candidate).is_none()
442 && self.locate_view(&candidate).is_none()
443 && self.locate_dimension(&candidate).is_none()
444 && self.locate_ordinate(&candidate).is_none()
445 {
446 return candidate;
447 }
448 }
449 }
450
451 fn max_numeric_suffix(&self) -> u64 {
452 let mut best = 0u64;
453 let mut consider = |id: &str| {
454 let digits = id.bytes().rev().take_while(u8::is_ascii_digit).count();
455 if digits > 0 {
456 if let Ok(value) = id[id.len() - digits..].parse::<u64>() {
457 best = best.max(value);
458 }
459 }
460 };
461 for sheet in &self.sheets {
462 consider(&sheet.id);
463 for view in &sheet.views {
464 consider(&view.id);
465 }
466 for dimension in &sheet.dimensions {
467 consider(&dimension.id);
468 }
469 for set in &sheet.ordinates {
470 consider(&set.id);
471 }
472 }
473 best
474 }
475
476 pub fn find_sheet(&self, id: &str) -> Option<&Sheet> {
477 self.sheets.iter().find(|sheet| sheet.id == id)
478 }
479
480 pub fn find_sheet_mut(&mut self, id: &str) -> Option<&mut Sheet> {
481 self.sheets.iter_mut().find(|sheet| sheet.id == id)
482 }
483
484 pub fn locate_view(&self, id: &str) -> Option<(String, usize)> {
486 self.sheets.iter().find_map(|sheet| {
487 sheet
488 .views
489 .iter()
490 .position(|view| view.id == id)
491 .map(|index| (sheet.id.clone(), index))
492 })
493 }
494
495 pub fn find_view_mut(&mut self, id: &str) -> Option<&mut PlacedView> {
496 self.sheets
497 .iter_mut()
498 .find_map(|sheet| sheet.views.iter_mut().find(|view| view.id == id))
499 }
500
501 pub fn locate_dimension(&self, id: &str) -> Option<(String, usize)> {
503 self.sheets.iter().find_map(|sheet| {
504 sheet
505 .dimensions
506 .iter()
507 .position(|dimension| dimension.id == id)
508 .map(|index| (sheet.id.clone(), index))
509 })
510 }
511
512 pub fn find_dimension(&self, id: &str) -> Option<&dimension::SheetDimension> {
513 self.sheets.iter().find_map(|sheet| sheet.find_dimension(id))
514 }
515
516 pub fn find_dimension_mut(&mut self, id: &str) -> Option<&mut dimension::SheetDimension> {
517 self.sheets
518 .iter_mut()
519 .find_map(|sheet| sheet.dimensions.iter_mut().find(|d| d.id == id))
520 }
521
522 pub fn locate_ordinate(&self, id: &str) -> Option<(String, usize)> {
524 self.sheets.iter().find_map(|sheet| {
525 sheet
526 .ordinates
527 .iter()
528 .position(|set| set.id == id)
529 .map(|index| (sheet.id.clone(), index))
530 })
531 }
532
533 pub fn find_ordinate(&self, id: &str) -> Option<&ordinate::SheetOrdinate> {
534 self.sheets.iter().find_map(|sheet| sheet.find_ordinate(id))
535 }
536
537 pub fn find_ordinate_mut(&mut self, id: &str) -> Option<&mut ordinate::SheetOrdinate> {
538 self.sheets
539 .iter_mut()
540 .find_map(|sheet| sheet.ordinates.iter_mut().find(|o| o.id == id))
541 }
542
543 pub fn is_empty(&self) -> bool {
545 self.sheets.is_empty() && self.id_counter == 0
546 }
547}
548
549pub fn schema_catalogue(view_ids: &[String]) -> Value {
559 json!({
560 "objects": [
561 sheet_schema(),
562 sheet_view_schema(view_ids),
563 dimension::sheet_dimension_schema(),
564 ordinate::sheet_ordinate_schema(),
565 section_view_schema(),
566 detail_view_schema(),
567 ]
568 })
569}
570
571pub fn section_view_schema() -> Value {
576 json!({
577 "type": "sectionView",
578 "shortName": "SV",
579 "longName": "Section view",
580 "inputParamsSchema": {
581 "id": { "type": "string", "default_value": null },
582 "cut": { "type": "reference_selection", "label": "Cutting line", "selectionFilter": ["sheetAnchor"], "multiple": true, "hint": "Two anchors on one placed view" },
583 "sectionLabel": { "type": "string", "label": "Section letter", "default_value": "A" },
584 "sectionFlip": { "type": "boolean", "label": "Section looks the other way", "default_value": false },
585 "positionXMm": { "type": "number", "label": "X (mm)", "default_value": 0.0 },
586 "positionYMm": { "type": "number", "label": "Y (mm)", "default_value": 0.0 },
587 "scale": { "type": "number", "label": "Scale (mm per model unit)", "default_value": DEFAULT_SCALE },
588 "flattenText": { "type": "boolean", "label": "Flatten text to sheet", "default_value": true },
589 }
590 })
591}
592
593pub fn detail_view_schema() -> Value {
597 json!({
598 "type": "detailView",
599 "shortName": "SV",
600 "longName": "Detail view",
601 "inputParamsSchema": {
602 "id": { "type": "string", "default_value": null },
603 "centre": { "type": "reference_selection", "label": "Centre", "selectionFilter": ["sheetAnchor"], "multiple": false, "hint": "The circle's centre, an anchor on a placed view" },
604 "rim": { "type": "reference_selection", "label": "Point on the rim", "selectionFilter": ["sheetAnchor"], "multiple": false, "hint": "An anchor on the same view, on the circle" },
605 "detailLabel": { "type": "string", "label": "Detail letter", "default_value": "A" },
606 "positionXMm": { "type": "number", "label": "X (mm)", "default_value": 0.0 },
607 "positionYMm": { "type": "number", "label": "Y (mm)", "default_value": 0.0 },
608 "scale": { "type": "number", "label": "Scale (mm per model unit)", "default_value": DEFAULT_SCALE },
609 "flattenText": { "type": "boolean", "label": "Flatten text to sheet", "default_value": true },
610 }
611 })
612}
613
614pub fn sheet_schema() -> Value {
616 let sizes: Vec<&str> = PAPER_SIZES
617 .iter()
618 .map(|(key, _, _)| *key)
619 .chain(std::iter::once(CUSTOM))
620 .collect();
621 json!({
622 "type": "sheet",
623 "shortName": "SHT",
624 "longName": "Sheet",
625 "inputParamsSchema": {
626 "id": { "type": "string", "default_value": null },
627 "name": { "type": "string", "default_value": "Sheet 1" },
628 "size": { "type": "options", "label": "Paper size", "options": sizes, "default_value": DEFAULT_SIZE },
629 "widthMm": { "type": "number", "label": "Width (mm)", "default_value": 420.0 },
630 "heightMm": { "type": "number", "label": "Height (mm)", "default_value": 297.0 },
631 "border": { "type": "boolean", "label": "Border", "default_value": true },
632 "borderInsetMm": { "type": "number", "label": "Border inset (mm)", "default_value": DEFAULT_BORDER_INSET_MM },
633 "titleBlock": { "type": "boolean", "label": "Title block", "default_value": true },
634 "notes": { "type": "string", "label": "Notes", "default_value": "" },
635 "revisions": { "type": "revision_table", "label": "Revisions", "columns": Revision::FIELDS, "default_value": [] },
639 }
640 })
641}
642
643pub fn sheet_view_schema(view_ids: &[String]) -> Value {
646 json!({
647 "type": "sheetView",
648 "shortName": "SV",
649 "longName": "Placed view",
650 "inputParamsSchema": {
651 "id": { "type": "string", "default_value": null },
652 "view": { "type": "options", "label": "PMI view", "options": view_ids, "default_value": view_ids.first().cloned().unwrap_or_default() },
653 "positionXMm": { "type": "number", "label": "X (mm)", "default_value": 0.0 },
654 "positionYMm": { "type": "number", "label": "Y (mm)", "default_value": 0.0 },
655 "scale": { "type": "number", "label": "Scale (mm per model unit)", "default_value": DEFAULT_SCALE },
656 "projection": { "type": "options", "label": "Projection", "options": [ORTHOGRAPHIC], "default_value": ORTHOGRAPHIC },
657 "flattenText": { "type": "boolean", "label": "Flatten text to sheet", "default_value": true },
658 "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" },
659 }
660 })
661}
662
663impl Sheet {
664 pub fn params(&self) -> Value {
666 let (w, h) = self.millimetres();
667 json!({
668 "id": self.id,
669 "name": self.name,
670 "size": self.size,
671 "widthMm": w,
672 "heightMm": h,
673 "border": self.border,
674 "borderInsetMm": self.border_inset_mm,
675 "titleBlock": self.title_block,
676 "notes": self.notes,
677 "revisions": self.revisions,
678 "bomTable": self.bom_table,
679 })
680 }
681
682 pub fn apply(&mut self, params: &Value) -> Result<(), String> {
685 if let Some(name) = params.get("name").and_then(Value::as_str) {
686 let name = name.trim();
687 if name.is_empty() {
688 return Err("a sheet needs a name".into());
689 }
690 self.name = name.to_string();
691 }
692 if let Some(size) = params.get("size").and_then(Value::as_str) {
693 let size = size.trim();
694 if !size.eq_ignore_ascii_case(CUSTOM) && paper_size(size).is_none() {
695 return Err(format!("no paper size '{size}'"));
696 }
697 self.size = if size.eq_ignore_ascii_case(CUSTOM) {
698 CUSTOM.to_string()
699 } else {
700 size.to_string()
701 };
702 }
703 let dimension = |key: &str, current: f64| -> Result<f64, String> {
704 match params.get(key).and_then(Value::as_f64) {
705 None => Ok(current),
706 Some(value) if value.is_finite() && value > 0.0 => Ok(value),
707 Some(value) => Err(format!("{key}: {value} is not a positive length")),
708 }
709 };
710 self.width_mm = dimension("widthMm", self.width_mm)?;
711 self.height_mm = dimension("heightMm", self.height_mm)?;
712 if let Some((w, h)) = paper_size(&self.size) {
715 self.width_mm = w;
716 self.height_mm = h;
717 }
718 if let Some(border) = params.get("border").and_then(Value::as_bool) {
722 self.border = border;
723 }
724 if let Some(inset) = params.get("borderInsetMm").and_then(Value::as_f64) {
725 let (w, h) = self.millimetres();
726 if !inset.is_finite() || inset < 0.0 {
727 return Err(format!("borderInsetMm: {inset} is not a length"));
728 }
729 if inset > w.min(h) * 0.25 {
730 return Err(format!(
731 "borderInsetMm: {inset} leaves no drawing area on a {w:.0} × {h:.0} mm sheet"
732 ));
733 }
734 self.border_inset_mm = inset;
735 }
736 if let Some(title_block) = params.get("titleBlock").and_then(Value::as_bool) {
737 self.title_block = title_block;
738 }
739 if let Some(notes) = params.get("notes").and_then(Value::as_str) {
742 self.notes = notes.to_string();
743 }
744 if let Some(value) = params.get("bomTable") {
745 self.bom_table = if value.is_null() { None } else {
746 let table: BomTable = serde_json::from_value(value.clone()).map_err(|e| format!("BOM table: {e}"))?;
747 if table.columns.is_empty() || table.columns.len() > 32 { return Err("choose between 1 and 32 BOM columns".into()); }
748 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()); }
749 Some(table)
750 };
751 }
752 if let Some(rows) = params.get("revisions") {
756 let rows = rows.as_array().ok_or("revisions: not a list of rows")?;
757 let mut revisions = Vec::with_capacity(rows.len());
758 for (index, row) in rows.iter().enumerate() {
759 let mut revision = Revision::default();
760 revision.apply(row).map_err(|error| format!("revisions[{index}]: {error}"))?;
761 revisions.push(revision);
762 }
763 self.revisions = revisions;
764 }
765 Ok(())
766 }
767}
768
769impl PlacedView {
770 pub fn params(&self) -> Value {
775 let mut params = json!({
776 "id": self.id,
777 "view": self.view,
778 "positionXMm": self.position[0],
779 "positionYMm": self.position[1],
780 "scale": self.scale,
781 "projection": self.projection,
782 "flattenText": self.flatten_text,
783 });
784 if self.schema_type() == "sheetView" {
785 params.as_object_mut().expect("an object").insert("threeD".into(), json!(self.three_d));
786 }
787 let object = params.as_object_mut().expect("an object");
788 if let Some(cut) = &self.section {
789 object.insert("cut".into(), json!(self.picked("cut")));
790 object.insert("sectionLabel".into(), json!(cut.label));
791 object.insert("sectionFlip".into(), json!(cut.flip));
792 }
793 if let Some(circle) = &self.detail {
794 object.insert("centre".into(), json!(circle.centre));
795 object.insert("rim".into(), json!(circle.rim));
796 object.insert("detailLabel".into(), json!(circle.label));
797 }
798 params
799 }
800
801 pub fn picked(&self, field: &str) -> Vec<String> {
805 let list: Vec<&String> = match (field, &self.section, &self.detail) {
806 ("cut", Some(cut), _) => vec![&cut.from, &cut.to],
807 ("centre", _, Some(circle)) => vec![&circle.centre],
808 ("rim", _, Some(circle)) => vec![&circle.rim],
809 _ => Vec::new(),
810 };
811 list.into_iter().filter(|anchor| !anchor.is_empty()).cloned().collect()
812 }
813
814 pub fn schema_type(&self) -> &'static str {
817 match (&self.section, &self.detail) {
818 (Some(_), _) => "sectionView",
819 (_, Some(_)) => "detailView",
820 _ => "sheetView",
821 }
822 }
823
824 pub fn apply(&mut self, params: &Value, view_ids: &[String]) -> Result<(), String> {
826 if let Some(view) = params.get("view").and_then(Value::as_str) {
827 let view = view.trim();
828 if !view.is_empty() && !view_ids.iter().any(|id| id == view) {
829 return Err(format!("no PMI view '{view}'"));
830 }
831 self.view = view.to_string();
832 }
833 for (key, slot) in [("positionXMm", 0usize), ("positionYMm", 1usize)] {
834 if let Some(value) = params.get(key).and_then(Value::as_f64) {
835 if !value.is_finite() {
836 return Err(format!("{key}: not a finite length"));
837 }
838 self.position[slot] = value;
839 }
840 }
841 if let Some(scale) = params.get("scale").and_then(Value::as_f64) {
842 if !scale.is_finite() || scale <= 0.0 {
843 return Err(format!("scale: {scale} is not a positive ratio"));
844 }
845 self.scale = scale;
846 }
847 if let Some(projection) = params.get("projection").and_then(Value::as_str) {
848 if !projection.eq_ignore_ascii_case(ORTHOGRAPHIC) {
849 return Err(format!(
850 "projection '{projection}' is not drawn on a sheet yet — this slice is orthographic only"
851 ));
852 }
853 self.projection = ORTHOGRAPHIC.to_string();
854 }
855 if let Some(flatten) = params.get("flattenText").and_then(Value::as_bool) {
856 self.flatten_text = flatten;
857 }
858 if let Some(three_d) = params.get("threeD").and_then(Value::as_bool) {
862 if three_d && self.schema_type() != "sheetView" {
863 return Err(format!("'{}' is a {}; live 3D is for a plain placed view", self.id, self.schema_type()));
864 }
865 self.three_d = three_d;
866 }
867 let label = params.get("sectionLabel").and_then(Value::as_str).map(str::trim);
873 let flip = params.get("sectionFlip").and_then(Value::as_bool);
874 match self.section.as_mut() {
875 Some(cut) => {
876 if let Some(label) = label.filter(|l| !l.is_empty()) {
877 cut.label = label.to_string();
878 }
879 if let Some(flip) = flip {
880 cut.flip = flip;
881 }
882 }
883 None => {
884 if label.is_some_and(|l| !l.is_empty()) || flip == Some(true) {
885 return Err(format!(
886 "'{}' is not a section view; it has no cutting plane to letter or turn round",
887 self.id
888 ));
889 }
890 }
891 }
892 let detail_label = params.get("detailLabel").and_then(Value::as_str).map(str::trim);
895 match self.detail.as_mut() {
896 Some(circle) => {
897 if let Some(label) = detail_label.filter(|l| !l.is_empty()) {
898 circle.label = label.to_string();
899 }
900 }
901 None => {
902 if detail_label.is_some_and(|l| !l.is_empty()) {
903 return Err(format!(
904 "'{}' is not a detail view; it has no circle to letter",
905 self.id
906 ));
907 }
908 }
909 }
910 Ok(())
911 }
912}
913