Skip to main content

brep_render/
sheets.rs

1//! Drawing sheets — the document's `sheets` block, the projection of a placed
2//! PMI view into paper millimetres, and the SVG writer.
3//!
4//! # The `sheets` block
5//!
6//! `{ sheets: [{ id, name, size, widthMm, heightMm, views: [{ id, view,
7//! position, scale, projection }] }], idCounter }`, a top-level key of the
8//! saved document beside `pmi` and `partAttributes`. It is ABSENT on every
9//! document without a sheet (and omitted on re-serialize), so a part that
10//! never had one saves byte-identically. The kernel never sees it: a sheet
11//! places what the PMI tail already resolved, so the block rides the
12//! document's unknown-key pass-through ([`crate::history::History::request_json`]
13//! serializes the raw document) exactly as `partAttributes` does — no
14//! `HistoryRequest` field, no migration, because it is new.
15//!
16//! A **sheet** is a paper size in millimetres (a preset or a custom pair), the
17//! paper's own FURNITURE — a drawing border inset from the page edge and a
18//! title block in its bottom-right corner ([`frame`]) — and an ordered list of
19//! **placed views**. A placed view names a saved PMI view
20//! by id and adds where it sits on the paper (`position`, mm from the paper's
21//! top-left corner), how big it draws (`scale`, paper mm per model unit) and
22//! its projection kind. The saved view's own camera is what the placement
23//! projects through; its zoom (`halfHeight`) is NOT the sheet scale — a sheet
24//! says how large the part draws on paper, the camera only says from where.
25//!
26//! # This slice
27//!
28//! Orthographic only: a placed view whose saved camera is perspective is
29//! REFUSED at placement with a message rather than silently flattened
30//! (`EngineState::sheet_place_view`, and the placement form that re-points a
31//! placement). Hidden lines are removed exactly, on the B-rep — see
32//! [`hlr`] for the pass and its floors, [`project`] for the placements, and
33//! `docs/panels/sheets.md` for the user-facing contract.
34
35pub mod dimension;
36pub mod frame;
37pub mod hlr;
38pub mod ordinate;
39pub mod pdf;
40pub mod pdf3d;
41pub mod print;
42pub mod project;
43pub mod svg;
44pub mod u3d;
45
46use serde::{Deserialize, Serialize};
47use serde_json::{json, Value};
48
49/// The document key the block is stored under.
50pub const SHEETS: &str = "sheets";
51
52/// The only projection kind this slice draws.
53pub const ORTHOGRAPHIC: &str = "orthographic";
54
55/// The paper-size presets, `(key, width mm, height mm)` — ISO A landscape plus
56/// the two ANSI sizes in millimetres (the block stores millimetres only, so a
57/// Letter sheet is 279.4 × 215.9 and not a second unit system).
58pub const PAPER_SIZES: &[(&str, f64, f64)] = &[
59    ("A4", 297.0, 210.0),
60    ("A3", 420.0, 297.0),
61    ("A2", 594.0, 420.0),
62    ("A1", 841.0, 594.0),
63    ("A0", 1189.0, 841.0),
64    ("Letter", 279.4, 215.9),
65    ("Tabloid", 431.8, 279.4),
66];
67
68/// The `size` value of a sheet whose `widthMm` / `heightMm` are the user's own.
69pub const CUSTOM: &str = "Custom";
70
71/// The default paper size of a new sheet.
72pub const DEFAULT_SIZE: &str = "A3";
73
74/// The default scale of a newly placed view (1 mm of paper per model unit).
75pub const DEFAULT_SCALE: f64 = 1.0;
76
77/// The default inset of the drawing BORDER from the paper's edge, millimetres.
78pub const DEFAULT_BORDER_INSET_MM: f64 = 10.0;
79
80/// A preset's millimetres, or `None` for [`CUSTOM`] and anything unknown.
81pub fn paper_size(key: &str) -> Option<(f64, f64)> {
82    PAPER_SIZES
83        .iter()
84        .find(|(name, _, _)| name.eq_ignore_ascii_case(key))
85        .map(|(_, w, h)| (*w, *h))
86}
87
88/// The document's `sheets` block.
89#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)]
90pub struct SheetState {
91    #[serde(default)]
92    pub sheets: Vec<Sheet>,
93    /// Mints every sheet AND placed-view id (`SHEET{n}`, `SV{n}`); monotonic,
94    /// never reused, re-seeded from the largest numeric suffix on load — the
95    /// `pmi` block's counter rule, so a merged or edited document never
96    /// collides.
97    #[serde(default, rename = "idCounter")]
98    pub id_counter: u64,
99}
100
101/// One sheet of paper.
102#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
103pub struct Sheet {
104    pub id: String,
105    #[serde(default)]
106    pub name: String,
107    /// A [`PAPER_SIZES`] key, or [`CUSTOM`].
108    #[serde(default = "default_size")]
109    pub size: String,
110    #[serde(default, rename = "widthMm")]
111    pub width_mm: f64,
112    #[serde(default, rename = "heightMm")]
113    pub height_mm: f64,
114    /// Draw the BORDER: the rectangle inset from the paper's edge that the
115    /// drawing lives inside. On by default; a sheet may switch it off.
116    #[serde(default = "default_true")]
117    pub border: bool,
118    /// How far the border sits from the paper's edge, millimetres. It is also
119    /// the margin the TITLE BLOCK is placed against, so a sheet with no border
120    /// still has a margin.
121    #[serde(default = "default_border_inset", rename = "borderInsetMm")]
122    pub border_inset_mm: f64,
123    /// Draw the TITLE BLOCK in the paper's bottom-right corner, inside the
124    /// border. On by default.
125    #[serde(default = "default_true", rename = "titleBlock")]
126    pub title_block: bool,
127    /// The title block's free text field — whatever the drawing has to say.
128    #[serde(default)]
129    pub notes: String,
130    #[serde(default)]
131    pub views: Vec<PlacedView>,
132    /// The sheet's OWN dimensions — drawn on the paper, anchored to the
133    /// projected geometry of the placements above ([`dimension`]). A sheet
134    /// saved before they existed simply has none.
135    #[serde(default)]
136    pub dimensions: Vec<dimension::SheetDimension>,
137    /// The sheet's ORDINATE SETS ([`ordinate`]) — a datum and a run of
138    /// members, each reading its signed distance from the datum. Beside the
139    /// dimensions rather than among them: a set measures any number of points
140    /// against one origin and draws no dimension line, which is a different
141    /// object and not a fifth kind.
142    #[serde(default)]
143    pub ordinates: Vec<ordinate::SheetOrdinate>,
144    /// The sheet's REVISIONS, oldest first as the user keeps them — drawn as
145    /// the revision table beside the title block ([`frame`]). A sheet saved
146    /// before they existed has none, and a sheet with none draws no table.
147    #[serde(default, skip_serializing_if = "Vec::is_empty")]
148    pub revisions: Vec<Revision>,
149    #[serde(default, rename = "bomTable", skip_serializing_if = "Option::is_none")]
150    pub bom_table: Option<BomTable>,
151}
152
153/// A live assembly parts list placed in paper millimetres.
154#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
155#[serde(rename_all = "camelCase")]
156pub struct BomTable {
157    pub position: [f64; 2],
158    pub column_width_mm: f64,
159    pub columns: Vec<String>,
160}
161impl Default for BomTable {
162    fn default() -> Self {
163        Self {
164            position: [20., 20.],
165            column_width_mm: 32.,
166            columns: vec![
167                "occurrence.Find_Number".into(),
168                "partName".into(),
169                "occurrence.Quantity".into(),
170            ],
171        }
172    }
173}
174
175/// One row of a sheet's revision table: the revision's letter, its date and
176/// what changed. All three are the user's own text — a drawing office letters,
177/// dates and words its revisions its own way — so none is validated beyond
178/// being a string, and a blank one leaves its cell ruled and empty.
179#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)]
180pub struct Revision {
181    #[serde(default)]
182    pub rev: String,
183    #[serde(default)]
184    pub date: String,
185    #[serde(default)]
186    pub description: String,
187}
188
189impl Revision {
190    /// The three fields, by the keys the block, the form and the commands use.
191    pub const FIELDS: &'static [&'static str] = &["rev", "date", "description"];
192
193    /// Patch this row from `params`; absent keys are unchanged, and a key that
194    /// is not a string is refused by name.
195    pub fn apply(&mut self, params: &Value) -> Result<(), String> {
196        let Some(object) = params.as_object() else {
197            return Err("a revision is an object of rev, date and description".into());
198        };
199        for (key, value) in object {
200            let slot = match key.as_str() {
201                "rev" => &mut self.rev,
202                "date" => &mut self.date,
203                "description" => &mut self.description,
204                other => return Err(format!("a revision has no field '{other}'")),
205            };
206            *slot = value
207                .as_str()
208                .ok_or_else(|| format!("revision {key}: {value} is not text"))?
209                .to_string();
210        }
211        Ok(())
212    }
213}
214
215fn default_size() -> String {
216    DEFAULT_SIZE.to_string()
217}
218
219fn default_true() -> bool {
220    true
221}
222
223fn is_false(value: &bool) -> bool {
224    !*value
225}
226
227fn default_border_inset() -> f64 {
228    DEFAULT_BORDER_INSET_MM
229}
230
231/// A saved PMI view placed on a sheet.
232#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
233pub struct PlacedView {
234    pub id: String,
235    /// The PMI view's id (`VIEW3`).
236    #[serde(default)]
237    pub view: String,
238    /// Millimetres from the paper's TOP-LEFT corner (SVG's own origin), of the
239    /// point the saved camera targets — the view's centre, not its corner, so
240    /// changing the scale grows the drawing about where it was placed.
241    #[serde(default)]
242    pub position: [f64; 2],
243    /// Paper millimetres per model unit.
244    #[serde(default = "default_scale")]
245    pub scale: f64,
246    /// `orthographic` — the only kind this slice draws.
247    #[serde(default = "default_projection")]
248    pub projection: String,
249    /// Rotate every text run — and every framed symbol, frame and cells
250    /// together — FLAT to the sheet, about its own anchor, at its sheet-scale
251    /// size. ON by default, because that is the drawing convention: a
252    /// drawing's text reads left to right whatever plane the annotation was
253    /// authored in. Off, each run is drawn as the projection gives it:
254    /// foreshortened, rotated, and on an edge-on plane collapsed onto a line.
255    #[serde(default = "default_true", rename = "flattenText")]
256    pub flatten_text: bool,
257    /// Draw this placement LIVE in the Sheets + 3D PDF as well: a 3D box over the
258    /// drawing that Acrobat and Foxit turn and zoom, opening on this
259    /// placement's camera and scale ([`pdf3d`]). Every other reader, and the
260    /// SVG, show the drawing alone. Off by default, and absent from a saved
261    /// placement that is off, so a document that never asks for 3D saves
262    /// exactly as before.
263    #[serde(default, rename = "threeD", skip_serializing_if = "is_false")]
264    pub three_d: bool,
265    /// This placement is a SECTION VIEW: its camera is derived from a cutting
266    /// plane picked on ANOTHER placement, and it draws the model clipped at
267    /// that plane with the cut faces hatched. Absent on a plain placement,
268    /// which is every one saved before sections existed.
269    #[serde(default, skip_serializing_if = "Option::is_none")]
270    pub section: Option<SectionCut>,
271    /// This placement is a DETAIL VIEW: a circled region of ANOTHER placement
272    /// redrawn at this placement's own scale, through the source's camera and
273    /// clipped to the circle. Absent on a plain placement and on a section; a
274    /// placement is at most one of the two.
275    #[serde(default, skip_serializing_if = "Option::is_none")]
276    pub detail: Option<DetailCircle>,
277}
278
279/// The cutting plane of a section view, as it was PICKED: two anchors of an
280/// existing placement's projection.
281///
282/// The line is stored as two ANCHOR REFERENCES rather than two paper points,
283/// for the reason every other sheet reference is: a paper point is not a
284/// property of the model, and a placement that moved or a scale that changed
285/// would leave the cut somewhere else. Two anchors follow their geometry
286/// through a camera, scale, placement or model change — and a section whose
287/// anchor is gone goes UNRESOLVED with the reason, exactly as a dimension
288/// does, rather than quietly cutting the wrong place.
289///
290/// It is also what makes the cut reachable without a pointer: an anchor has a
291/// keyed hit rect (`sheet/anchor:<reference>`) and a bare paper point does
292/// not, so a section picked from free points could not be driven by a script
293/// or by anything but a mouse.
294#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
295pub struct SectionCut {
296    /// The placement the line is drawn ON — the view the cut was picked in.
297    /// Its camera's viewing direction lies IN the cutting plane, which is what
298    /// makes the section camera's frame orthonormal by construction.
299    #[serde(default)]
300    pub source: String,
301    /// The two anchors the cutting line runs through.
302    #[serde(default)]
303    pub from: String,
304    #[serde(default)]
305    pub to: String,
306    /// The section's letter — drawn at both arrows on the source view and in
307    /// this view's own `SECTION A-A` caption.
308    #[serde(default = "default_label")]
309    pub label: String,
310    /// Look the other way along the plane's normal. The unflipped direction is
311    /// the drawing convention read off the pick order: 90\u{00B0} clockwise from
312    /// `from` \u{2192} `to` on the source's paper, which is the side a reader walking
313    /// from the first arrow to the second has on their right.
314    #[serde(default)]
315    pub flip: bool,
316}
317
318fn default_label() -> String {
319    "A".to_string()
320}
321
322/// The circle of a detail view, as it was PICKED: two anchors of an existing
323/// placement's projection — the circle's centre and a point on its rim.
324///
325/// Anchors rather than a paper centre and a radius in millimetres, for the
326/// section's reason: a paper point is not a property of the model, and the
327/// two anchors are what keeps the circle on the feature it was drawn round
328/// through a camera, scale, placement or model change. They also give the
329/// circle keyed hit rects (`sheet/anchor:<reference>`), so a script picks one
330/// exactly as a user does.
331#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
332pub struct DetailCircle {
333    /// The placement the circle is drawn ON. The detail looks through that
334    /// placement's camera; only the scale and the clip are its own.
335    #[serde(default)]
336    pub source: String,
337    /// The anchor at the circle's centre.
338    #[serde(default)]
339    pub centre: String,
340    /// An anchor ON the circle: its distance from the centre, measured on the
341    /// source's paper and divided by the source's scale, is the radius in
342    /// model units.
343    #[serde(default)]
344    pub rim: String,
345    /// The detail's letter — beside the circle on the source and in this
346    /// view's own `DETAIL B` caption. Handed out from the SAME run of letters
347    /// as a section's ([`Sheet::next_view_letter`]).
348    #[serde(default = "default_label")]
349    pub label: String,
350}
351
352/// A new detail view draws at this multiple of its source's scale — the
353/// usual reason for a detail is that the source is too small to read there.
354pub const DETAIL_SCALE_FACTOR: f64 = 2.0;
355
356impl SectionCut {
357    /// The letters a sheet hands out, in order, skipping the three a drawing
358    /// office does not use for a section (I, O and Q read as 1, 0 and O).
359    pub const LETTERS: &'static [char] = &[
360        'A', 'B', 'C', 'D', 'E', 'F', 'G', 'H', 'J', 'K', 'L', 'M', 'N', 'P', 'R', 'S', 'T', 'U',
361        'V', 'W', 'X', 'Y', 'Z',
362    ];
363}
364
365/// The first of [`SectionCut::LETTERS`] not in `taken`; past `Z`, the last.
366fn first_free_letter(taken: &[String]) -> String {
367    SectionCut::LETTERS
368        .iter()
369        .map(|letter| letter.to_string())
370        .find(|letter| !taken.iter().any(|used| used == letter))
371        .unwrap_or_else(|| SectionCut::LETTERS[SectionCut::LETTERS.len() - 1].to_string())
372}
373
374fn default_scale() -> f64 {
375    DEFAULT_SCALE
376}
377
378fn default_projection() -> String {
379    ORTHOGRAPHIC.to_string()
380}
381
382impl Sheet {
383    /// The paper's millimetres: the preset's when `size` names one (so a
384    /// preset sheet cannot drift), else the stored custom pair.
385    pub fn millimetres(&self) -> (f64, f64) {
386        match paper_size(&self.size) {
387            Some((w, h)) => (w, h),
388            None => (self.width_mm.max(1.0), self.height_mm.max(1.0)),
389        }
390    }
391
392    /// The border's inset, clamped to something the paper can carry: a sheet
393    /// whose inset ate the page would have no drawing area at all.
394    pub fn border_inset(&self) -> f64 {
395        let (w, h) = self.millimetres();
396        self.border_inset_mm.max(0.0).min(w.min(h) * 0.25)
397    }
398
399    pub fn find_view(&self, id: &str) -> Option<&PlacedView> {
400        self.views.iter().find(|view| view.id == id)
401    }
402
403    pub fn find_view_mut(&mut self, id: &str) -> Option<&mut PlacedView> {
404        self.views.iter_mut().find(|view| view.id == id)
405    }
406
407    pub fn find_dimension(&self, id: &str) -> Option<&dimension::SheetDimension> {
408        self.dimensions.iter().find(|d| d.id == id)
409    }
410
411    pub fn find_ordinate(&self, id: &str) -> Option<&ordinate::SheetOrdinate> {
412        self.ordinates.iter().find(|o| o.id == id)
413    }
414
415    /// The next view LETTER this sheet has not handed out, for a section or a
416    /// detail alike: the two draw from ONE run of letters, because a drawing
417    /// with a `SECTION A—A` and a `DETAIL A` names two different views with
418    /// one letter. A sheet past `Z` reuses the last one rather than refusing
419    /// the view: two views sharing a letter is a drawing-office mistake, not a
420    /// broken document, and the form is where it is corrected. `views` are
421    /// the document's PMI views: a placement of a SECTIONED saved view holds
422    /// a letter too ([`Self::view_letters`]).
423    pub fn next_view_letter(&self, views: &[brep_kernel::PmiView]) -> String {
424        let taken: Vec<String> = self.view_letters(views).into_iter().flatten().collect();
425        first_free_letter(&taken)
426    }
427
428    /// Each placement's letter, in placement order. A section or a detail
429    /// carries its own. A plain placement of a saved view that is itself
430    /// SECTIONED (its PMI view's display has a section) is captioned
431    /// `SECTION A—A` too, and takes the first letter nothing stored holds and
432    /// no earlier such placement took — so it is the same letter every time
433    /// the sheet is drawn, and a section or detail added later skips it.
434    pub fn view_letters(&self, views: &[brep_kernel::PmiView]) -> Vec<Option<String>> {
435        let stored = |placed: &PlacedView| {
436            placed
437                .section
438                .as_ref()
439                .map(|cut| cut.label.clone())
440                .or_else(|| placed.detail.as_ref().map(|circle| circle.label.clone()))
441        };
442        let mut taken: Vec<String> = self.views.iter().filter_map(stored).collect();
443        self.views
444            .iter()
445            .map(|placed| {
446                if let Some(letter) = stored(placed) {
447                    return Some(letter);
448                }
449                let sectioned = views
450                    .iter()
451                    .find(|view| view.id == placed.view)
452                    .is_some_and(|view| view.display.section.is_some());
453                sectioned.then(|| {
454                    let letter = first_free_letter(&taken);
455                    taken.push(letter.clone());
456                    letter
457                })
458            })
459            .collect()
460    }
461}
462
463impl SheetState {
464    /// Mint the next id with `prefix` (`SHEET` → `SHEET3`), re-seeding the
465    /// counter from the largest numeric suffix already in the block.
466    pub fn next_id(&mut self, prefix: &str) -> String {
467        let seen = self.max_numeric_suffix();
468        if seen > self.id_counter {
469            self.id_counter = seen;
470        }
471        loop {
472            self.id_counter += 1;
473            let candidate = format!("{prefix}{}", self.id_counter);
474            if self.find_sheet(&candidate).is_none()
475                && self.locate_view(&candidate).is_none()
476                && self.locate_dimension(&candidate).is_none()
477                && self.locate_ordinate(&candidate).is_none()
478            {
479                return candidate;
480            }
481        }
482    }
483
484    fn max_numeric_suffix(&self) -> u64 {
485        let mut best = 0u64;
486        let mut consider = |id: &str| {
487            let digits = id.bytes().rev().take_while(u8::is_ascii_digit).count();
488            if digits > 0 {
489                if let Ok(value) = id[id.len() - digits..].parse::<u64>() {
490                    best = best.max(value);
491                }
492            }
493        };
494        for sheet in &self.sheets {
495            consider(&sheet.id);
496            for view in &sheet.views {
497                consider(&view.id);
498            }
499            for dimension in &sheet.dimensions {
500                consider(&dimension.id);
501            }
502            for set in &sheet.ordinates {
503                consider(&set.id);
504            }
505        }
506        best
507    }
508
509    pub fn find_sheet(&self, id: &str) -> Option<&Sheet> {
510        self.sheets.iter().find(|sheet| sheet.id == id)
511    }
512
513    pub fn find_sheet_mut(&mut self, id: &str) -> Option<&mut Sheet> {
514        self.sheets.iter_mut().find(|sheet| sheet.id == id)
515    }
516
517    /// The sheet id and index of placed view `id`.
518    pub fn locate_view(&self, id: &str) -> Option<(String, usize)> {
519        self.sheets.iter().find_map(|sheet| {
520            sheet
521                .views
522                .iter()
523                .position(|view| view.id == id)
524                .map(|index| (sheet.id.clone(), index))
525        })
526    }
527
528    pub fn find_view_mut(&mut self, id: &str) -> Option<&mut PlacedView> {
529        self.sheets
530            .iter_mut()
531            .find_map(|sheet| sheet.views.iter_mut().find(|view| view.id == id))
532    }
533
534    /// The sheet id and index of dimension `id`.
535    pub fn locate_dimension(&self, id: &str) -> Option<(String, usize)> {
536        self.sheets.iter().find_map(|sheet| {
537            sheet
538                .dimensions
539                .iter()
540                .position(|dimension| dimension.id == id)
541                .map(|index| (sheet.id.clone(), index))
542        })
543    }
544
545    pub fn find_dimension(&self, id: &str) -> Option<&dimension::SheetDimension> {
546        self.sheets.iter().find_map(|sheet| sheet.find_dimension(id))
547    }
548
549    pub fn find_dimension_mut(&mut self, id: &str) -> Option<&mut dimension::SheetDimension> {
550        self.sheets
551            .iter_mut()
552            .find_map(|sheet| sheet.dimensions.iter_mut().find(|d| d.id == id))
553    }
554
555    /// The sheet id and index of ordinate set `id`.
556    pub fn locate_ordinate(&self, id: &str) -> Option<(String, usize)> {
557        self.sheets.iter().find_map(|sheet| {
558            sheet
559                .ordinates
560                .iter()
561                .position(|set| set.id == id)
562                .map(|index| (sheet.id.clone(), index))
563        })
564    }
565
566    pub fn find_ordinate(&self, id: &str) -> Option<&ordinate::SheetOrdinate> {
567        self.sheets.iter().find_map(|sheet| sheet.find_ordinate(id))
568    }
569
570    pub fn find_ordinate_mut(&mut self, id: &str) -> Option<&mut ordinate::SheetOrdinate> {
571        self.sheets
572            .iter_mut()
573            .find_map(|sheet| sheet.ordinates.iter_mut().find(|o| o.id == id))
574    }
575
576    /// Whether the block carries anything worth persisting.
577    pub fn is_empty(&self) -> bool {
578        self.sheets.is_empty() && self.id_counter == 0
579    }
580}
581
582// ===========================================================================
583// Schemas — the catalogue the dialogs and the MCP contract are drawn from
584// ===========================================================================
585
586/// The sheet object schemas, in the `inputParamsSchema` vocabulary the shared
587/// form engine already renders ([`crate::features::form_fields_from_schema`]),
588/// so neither dialog is hand-written. `view_ids` are the document's saved PMI
589/// views — the options of a placed view's `view` field, which is why this
590/// catalogue is built per document rather than being a static.
591pub fn schema_catalogue(view_ids: &[String]) -> Value {
592    json!({
593        "objects": [
594            sheet_schema(),
595            sheet_view_schema(view_ids),
596            dimension::sheet_dimension_schema(),
597            ordinate::sheet_ordinate_schema(),
598            section_view_schema(),
599            detail_view_schema(),
600        ]
601    })
602}
603
604/// The **Section view** dialog's schema: where the view sits and at what
605/// scale, and the section itself — its CUTTING LINE, two anchors picked on a
606/// placed view with the reference picker, its letter and which way it looks.
607/// Its saved view is its source's, so it has no `view` field of its own.
608pub fn section_view_schema() -> Value {
609    json!({
610        "type": "sectionView",
611        "shortName": "SV",
612        "longName": "Section view",
613        "inputParamsSchema": {
614            "id": { "type": "string", "default_value": null },
615            "cut": { "type": "reference_selection", "label": "Cutting line", "selectionFilter": ["sheetAnchor"], "multiple": true, "hint": "Two anchors on one placed view" },
616            "sectionLabel": { "type": "string", "label": "Section letter", "default_value": "A" },
617            "sectionFlip": { "type": "boolean", "label": "Section looks the other way", "default_value": false },
618            "positionXMm": { "type": "number", "label": "X (mm)", "default_value": 0.0 },
619            "positionYMm": { "type": "number", "label": "Y (mm)", "default_value": 0.0 },
620            "scale": { "type": "number", "label": "Scale (mm per model unit)", "default_value": DEFAULT_SCALE },
621            "flattenText": { "type": "boolean", "label": "Flatten text to sheet", "default_value": true },
622        }
623    })
624}
625
626/// The **Detail view** dialog's schema: the circle — its CENTRE and a point on
627/// its RIM, each an anchor picked on one placed view — its letter, and where
628/// the enlarged view sits and at what scale.
629pub fn detail_view_schema() -> Value {
630    json!({
631        "type": "detailView",
632        "shortName": "SV",
633        "longName": "Detail view",
634        "inputParamsSchema": {
635            "id": { "type": "string", "default_value": null },
636            "centre": { "type": "reference_selection", "label": "Centre", "selectionFilter": ["sheetAnchor"], "multiple": false, "hint": "The circle's centre, an anchor on a placed view" },
637            "rim": { "type": "reference_selection", "label": "Point on the rim", "selectionFilter": ["sheetAnchor"], "multiple": false, "hint": "An anchor on the same view, on the circle" },
638            "detailLabel": { "type": "string", "label": "Detail letter", "default_value": "A" },
639            "positionXMm": { "type": "number", "label": "X (mm)", "default_value": 0.0 },
640            "positionYMm": { "type": "number", "label": "Y (mm)", "default_value": 0.0 },
641            "scale": { "type": "number", "label": "Scale (mm per model unit)", "default_value": DEFAULT_SCALE },
642            "flattenText": { "type": "boolean", "label": "Flatten text to sheet", "default_value": true },
643        }
644    })
645}
646
647/// The **Sheet** dialog's schema.
648pub fn sheet_schema() -> Value {
649    let sizes: Vec<&str> = PAPER_SIZES
650        .iter()
651        .map(|(key, _, _)| *key)
652        .chain(std::iter::once(CUSTOM))
653        .collect();
654    json!({
655        "type": "sheet",
656        "shortName": "SHT",
657        "longName": "Sheet",
658        "inputParamsSchema": {
659            "id": { "type": "string", "default_value": null },
660            "name": { "type": "string", "default_value": "Sheet 1" },
661            "size": { "type": "options", "label": "Paper size", "options": sizes, "default_value": DEFAULT_SIZE },
662            "widthMm": { "type": "number", "label": "Width (mm)", "default_value": 420.0 },
663            "heightMm": { "type": "number", "label": "Height (mm)", "default_value": 297.0 },
664            "border": { "type": "boolean", "label": "Border", "default_value": true },
665            "borderInsetMm": { "type": "number", "label": "Border inset (mm)", "default_value": DEFAULT_BORDER_INSET_MM },
666            "titleBlock": { "type": "boolean", "label": "Title block", "default_value": true },
667            "notes": { "type": "string", "label": "Notes", "default_value": "" },
668            // A LIST of rows, which the schema vocabulary has no field for: the
669            // Sheets pane draws it as the dialog's own Revisions section, and
670            // `sheet_update` takes the whole list in this shape.
671            "revisions": { "type": "revision_table", "label": "Revisions", "columns": Revision::FIELDS, "default_value": [] },
672        }
673    })
674}
675
676/// The **Placed view** dialog's schema. `view` offers the document's saved PMI
677/// views; `projection` offers only [`ORTHOGRAPHIC`] in this slice.
678pub fn sheet_view_schema(view_ids: &[String]) -> Value {
679    json!({
680        "type": "sheetView",
681        "shortName": "SV",
682        "longName": "Placed view",
683        "inputParamsSchema": {
684            "id": { "type": "string", "default_value": null },
685            "view": { "type": "options", "label": "PMI view", "options": view_ids, "default_value": view_ids.first().cloned().unwrap_or_default() },
686            "positionXMm": { "type": "number", "label": "X (mm)", "default_value": 0.0 },
687            "positionYMm": { "type": "number", "label": "Y (mm)", "default_value": 0.0 },
688            "scale": { "type": "number", "label": "Scale (mm per model unit)", "default_value": DEFAULT_SCALE },
689            "projection": { "type": "options", "label": "Projection", "options": [ORTHOGRAPHIC], "default_value": ORTHOGRAPHIC },
690            "flattenText": { "type": "boolean", "label": "Flatten text to sheet", "default_value": true },
691            "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" },
692        }
693    })
694}
695
696impl Sheet {
697    /// The params the **Sheet** form edits, as the schema shapes them.
698    pub fn params(&self) -> Value {
699        let (w, h) = self.millimetres();
700        json!({
701            "id": self.id,
702            "name": self.name,
703            "size": self.size,
704            "widthMm": w,
705            "heightMm": h,
706            "border": self.border,
707            "borderInsetMm": self.border_inset_mm,
708            "titleBlock": self.title_block,
709            "notes": self.notes,
710            "revisions": self.revisions,
711            "bomTable": self.bom_table,
712        })
713    }
714
715    /// Apply an edited **Sheet** form. `id` is identity and is never taken
716    /// from the form.
717    pub fn apply(&mut self, params: &Value) -> Result<(), String> {
718        if let Some(name) = params.get("name").and_then(Value::as_str) {
719            let name = name.trim();
720            if name.is_empty() {
721                return Err("a sheet needs a name".into());
722            }
723            self.name = name.to_string();
724        }
725        if let Some(size) = params.get("size").and_then(Value::as_str) {
726            let size = size.trim();
727            if !size.eq_ignore_ascii_case(CUSTOM) && paper_size(size).is_none() {
728                return Err(format!("no paper size '{size}'"));
729            }
730            self.size = if size.eq_ignore_ascii_case(CUSTOM) {
731                CUSTOM.to_string()
732            } else {
733                size.to_string()
734            };
735        }
736        let dimension = |key: &str, current: f64| -> Result<f64, String> {
737            match params.get(key).and_then(Value::as_f64) {
738                None => Ok(current),
739                Some(value) if value.is_finite() && value > 0.0 => Ok(value),
740                Some(value) => Err(format!("{key}: {value} is not a positive length")),
741            }
742        };
743        self.width_mm = dimension("widthMm", self.width_mm)?;
744        self.height_mm = dimension("heightMm", self.height_mm)?;
745        // A preset's dimensions are the preset's; storing them keeps the block
746        // readable and keeps a switch to Custom from starting at zero.
747        if let Some((w, h)) = paper_size(&self.size) {
748            self.width_mm = w;
749            self.height_mm = h;
750        }
751        // The paper's furniture. The inset is refused rather than clamped when
752        // it would eat the page — a form that silently kept a quarter of what
753        // was typed would be lying about what the sheet is.
754        if let Some(border) = params.get("border").and_then(Value::as_bool) {
755            self.border = border;
756        }
757        if let Some(inset) = params.get("borderInsetMm").and_then(Value::as_f64) {
758            let (w, h) = self.millimetres();
759            if !inset.is_finite() || inset < 0.0 {
760                return Err(format!("borderInsetMm: {inset} is not a length"));
761            }
762            if inset > w.min(h) * 0.25 {
763                return Err(format!(
764                    "borderInsetMm: {inset} leaves no drawing area on a {w:.0} × {h:.0} mm sheet"
765                ));
766            }
767            self.border_inset_mm = inset;
768        }
769        if let Some(title_block) = params.get("titleBlock").and_then(Value::as_bool) {
770            self.title_block = title_block;
771        }
772        // Notes are the user's own text, empty included — the one field on a
773        // sheet whose blank value is a value.
774        if let Some(notes) = params.get("notes").and_then(Value::as_str) {
775            self.notes = notes.to_string();
776        }
777        if let Some(value) = params.get("bomTable") {
778            self.bom_table = if value.is_null() { None } else {
779                let table: BomTable = serde_json::from_value(value.clone()).map_err(|e| format!("BOM table: {e}"))?;
780                if table.columns.is_empty() || table.columns.len() > 32 { return Err("choose between 1 and 32 BOM columns".into()); }
781                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()); }
782                Some(table)
783            };
784        }
785        // The revision list is replaced WHOLE and in the order given — the
786        // order IS the table's — and a row the list cannot mean refuses the
787        // edit before anything is kept.
788        if let Some(rows) = params.get("revisions") {
789            let rows = rows.as_array().ok_or("revisions: not a list of rows")?;
790            let mut revisions = Vec::with_capacity(rows.len());
791            for (index, row) in rows.iter().enumerate() {
792                let mut revision = Revision::default();
793                revision.apply(row).map_err(|error| format!("revisions[{index}]: {error}"))?;
794                revisions.push(revision);
795            }
796            self.revisions = revisions;
797        }
798        Ok(())
799    }
800}
801
802impl PlacedView {
803    /// The params the placement's form edits — the **Placed view**, **Section
804    /// view** or **Detail view** schema's, whichever this placement is. A
805    /// section's cutting line (`cut`) and a detail's `centre` and `rim` are
806    /// the picked anchors its reference rows show.
807    pub fn params(&self) -> Value {
808        let mut params = json!({
809            "id": self.id,
810            "view": self.view,
811            "positionXMm": self.position[0],
812            "positionYMm": self.position[1],
813            "scale": self.scale,
814            "projection": self.projection,
815            "flattenText": self.flatten_text,
816        });
817        if self.schema_type() == "sheetView" {
818            params.as_object_mut().expect("an object").insert("threeD".into(), json!(self.three_d));
819        }
820        let object = params.as_object_mut().expect("an object");
821        if let Some(cut) = &self.section {
822            object.insert("cut".into(), json!(self.picked("cut")));
823            object.insert("sectionLabel".into(), json!(cut.label));
824            object.insert("sectionFlip".into(), json!(cut.flip));
825        }
826        if let Some(circle) = &self.detail {
827            object.insert("centre".into(), json!(circle.centre));
828            object.insert("rim".into(), json!(circle.rim));
829            object.insert("detailLabel".into(), json!(circle.label));
830        }
831        params
832    }
833
834    /// The anchors picked for a derived placement's reference `field` (`cut`,
835    /// `centre`, `rim`), blanks left out — empty for a field this placement
836    /// does not have.
837    pub fn picked(&self, field: &str) -> Vec<String> {
838        let list: Vec<&String> = match (field, &self.section, &self.detail) {
839            ("cut", Some(cut), _) => vec![&cut.from, &cut.to],
840            ("centre", _, Some(circle)) => vec![&circle.centre],
841            ("rim", _, Some(circle)) => vec![&circle.rim],
842            _ => Vec::new(),
843        };
844        list.into_iter().filter(|anchor| !anchor.is_empty()).cloned().collect()
845    }
846
847    /// Which catalogue object this placement's form is: `sheetView`,
848    /// `sectionView` or `detailView`.
849    pub fn schema_type(&self) -> &'static str {
850        match (&self.section, &self.detail) {
851            (Some(_), _) => "sectionView",
852            (_, Some(_)) => "detailView",
853            _ => "sheetView",
854        }
855    }
856
857    /// Apply an edited **Placed view** form.
858    pub fn apply(&mut self, params: &Value, view_ids: &[String]) -> Result<(), String> {
859        if let Some(view) = params.get("view").and_then(Value::as_str) {
860            let view = view.trim();
861            if !view.is_empty() && !view_ids.iter().any(|id| id == view) {
862                return Err(format!("no PMI view '{view}'"));
863            }
864            self.view = view.to_string();
865        }
866        for (key, slot) in [("positionXMm", 0usize), ("positionYMm", 1usize)] {
867            if let Some(value) = params.get(key).and_then(Value::as_f64) {
868                if !value.is_finite() {
869                    return Err(format!("{key}: not a finite length"));
870                }
871                self.position[slot] = value;
872            }
873        }
874        if let Some(scale) = params.get("scale").and_then(Value::as_f64) {
875            if !scale.is_finite() || scale <= 0.0 {
876                return Err(format!("scale: {scale} is not a positive ratio"));
877            }
878            self.scale = scale;
879        }
880        if let Some(projection) = params.get("projection").and_then(Value::as_str) {
881            if !projection.eq_ignore_ascii_case(ORTHOGRAPHIC) {
882                return Err(format!(
883                    "projection '{projection}' is not drawn on a sheet yet — this slice is orthographic only"
884                ));
885            }
886            self.projection = ORTHOGRAPHIC.to_string();
887        }
888        if let Some(flatten) = params.get("flattenText").and_then(Value::as_bool) {
889            self.flatten_text = flatten;
890        }
891        // Live 3D is a PLAIN placement's: a section's clipped model and a
892        // detail's circle are not what the 3D box would show, so asking for it
893        // there is refused rather than drawn wrong.
894        if let Some(three_d) = params.get("threeD").and_then(Value::as_bool) {
895            if three_d && self.schema_type() != "sheetView" {
896                return Err(format!("'{}' is a {}; live 3D is for a plain placed view", self.id, self.schema_type()));
897            }
898            self.three_d = three_d;
899        }
900        // The section's two editable fields are refused on a PLAIN placement
901        // rather than ignored: a form that silently swallowed "look the other
902        // way" on a view with no cutting plane would be lying about what it
903        // did. A blank label from the plain placement's own form is not an
904        // edit, so it passes.
905        let label = params.get("sectionLabel").and_then(Value::as_str).map(str::trim);
906        let flip = params.get("sectionFlip").and_then(Value::as_bool);
907        match self.section.as_mut() {
908            Some(cut) => {
909                if let Some(label) = label.filter(|l| !l.is_empty()) {
910                    cut.label = label.to_string();
911                }
912                if let Some(flip) = flip {
913                    cut.flip = flip;
914                }
915            }
916            None => {
917                if label.is_some_and(|l| !l.is_empty()) || flip == Some(true) {
918                    return Err(format!(
919                        "'{}' is not a section view; it has no cutting plane to letter or turn round",
920                        self.id
921                    ));
922                }
923            }
924        }
925        // The detail's letter, by the same rule: refused on a placement that is
926        // not a detail, and a blank one from any other placement's form passes.
927        let detail_label = params.get("detailLabel").and_then(Value::as_str).map(str::trim);
928        match self.detail.as_mut() {
929            Some(circle) => {
930                if let Some(label) = detail_label.filter(|l| !l.is_empty()) {
931                    circle.label = label.to_string();
932                }
933            }
934            None => {
935                if detail_label.is_some_and(|l| !l.is_empty()) {
936                    return Err(format!(
937                        "'{}' is not a detail view; it has no circle to letter",
938                        self.id
939                    ));
940                }
941            }
942        }
943        Ok(())
944    }
945}
946