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 project;
42pub mod svg;
43pub mod u3d;
44
45use serde::{Deserialize, Serialize};
46use serde_json::{json, Value};
47
48/// The document key the block is stored under.
49pub const SHEETS: &str = "sheets";
50
51/// The only projection kind this slice draws.
52pub const ORTHOGRAPHIC: &str = "orthographic";
53
54/// The paper-size presets, `(key, width mm, height mm)` — ISO A landscape plus
55/// the two ANSI sizes in millimetres (the block stores millimetres only, so a
56/// Letter sheet is 279.4 × 215.9 and not a second unit system).
57pub 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
67/// The `size` value of a sheet whose `widthMm` / `heightMm` are the user's own.
68pub const CUSTOM: &str = "Custom";
69
70/// The default paper size of a new sheet.
71pub const DEFAULT_SIZE: &str = "A3";
72
73/// The default scale of a newly placed view (1 mm of paper per model unit).
74pub const DEFAULT_SCALE: f64 = 1.0;
75
76/// The default inset of the drawing BORDER from the paper's edge, millimetres.
77pub const DEFAULT_BORDER_INSET_MM: f64 = 10.0;
78
79/// A preset's millimetres, or `None` for [`CUSTOM`] and anything unknown.
80pub 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/// The document's `sheets` block.
88#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)]
89pub struct SheetState {
90    #[serde(default)]
91    pub sheets: Vec<Sheet>,
92    /// Mints every sheet AND placed-view id (`SHEET{n}`, `SV{n}`); monotonic,
93    /// never reused, re-seeded from the largest numeric suffix on load — the
94    /// `pmi` block's counter rule, so a merged or edited document never
95    /// collides.
96    #[serde(default, rename = "idCounter")]
97    pub id_counter: u64,
98}
99
100/// One sheet of paper.
101#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
102pub struct Sheet {
103    pub id: String,
104    #[serde(default)]
105    pub name: String,
106    /// A [`PAPER_SIZES`] key, or [`CUSTOM`].
107    #[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    /// Draw the BORDER: the rectangle inset from the paper's edge that the
114    /// drawing lives inside. On by default; a sheet may switch it off.
115    #[serde(default = "default_true")]
116    pub border: bool,
117    /// How far the border sits from the paper's edge, millimetres. It is also
118    /// the margin the TITLE BLOCK is placed against, so a sheet with no border
119    /// still has a margin.
120    #[serde(default = "default_border_inset", rename = "borderInsetMm")]
121    pub border_inset_mm: f64,
122    /// Draw the TITLE BLOCK in the paper's bottom-right corner, inside the
123    /// border. On by default.
124    #[serde(default = "default_true", rename = "titleBlock")]
125    pub title_block: bool,
126    /// The title block's free text field — whatever the drawing has to say.
127    #[serde(default)]
128    pub notes: String,
129    #[serde(default)]
130    pub views: Vec<PlacedView>,
131    /// The sheet's OWN dimensions — drawn on the paper, anchored to the
132    /// projected geometry of the placements above ([`dimension`]). A sheet
133    /// saved before they existed simply has none.
134    #[serde(default)]
135    pub dimensions: Vec<dimension::SheetDimension>,
136    /// The sheet's ORDINATE SETS ([`ordinate`]) — a datum and a run of
137    /// members, each reading its signed distance from the datum. Beside the
138    /// dimensions rather than among them: a set measures any number of points
139    /// against one origin and draws no dimension line, which is a different
140    /// object and not a fifth kind.
141    #[serde(default)]
142    pub ordinates: Vec<ordinate::SheetOrdinate>,
143    /// The sheet's REVISIONS, oldest first as the user keeps them — drawn as
144    /// the revision table beside the title block ([`frame`]). A sheet saved
145    /// before they existed has none, and a sheet with none draws no table.
146    #[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/// A live assembly parts list placed in paper millimetres.
153#[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/// One row of a sheet's revision table: the revision's letter, its date and
175/// what changed. All three are the user's own text — a drawing office letters,
176/// dates and words its revisions its own way — so none is validated beyond
177/// being a string, and a blank one leaves its cell ruled and empty.
178#[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    /// The three fields, by the keys the block, the form and the commands use.
190    pub const FIELDS: &'static [&'static str] = &["rev", "date", "description"];
191
192    /// Patch this row from `params`; absent keys are unchanged, and a key that
193    /// is not a string is refused by name.
194    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/// A saved PMI view placed on a sheet.
231#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
232pub struct PlacedView {
233    pub id: String,
234    /// The PMI view's id (`VIEW3`).
235    #[serde(default)]
236    pub view: String,
237    /// Millimetres from the paper's TOP-LEFT corner (SVG's own origin), of the
238    /// point the saved camera targets — the view's centre, not its corner, so
239    /// changing the scale grows the drawing about where it was placed.
240    #[serde(default)]
241    pub position: [f64; 2],
242    /// Paper millimetres per model unit.
243    #[serde(default = "default_scale")]
244    pub scale: f64,
245    /// `orthographic` — the only kind this slice draws.
246    #[serde(default = "default_projection")]
247    pub projection: String,
248    /// Rotate every text run — and every framed symbol, frame and cells
249    /// together — FLAT to the sheet, about its own anchor, at its sheet-scale
250    /// size. ON by default, because that is the drawing convention: a
251    /// drawing's text reads left to right whatever plane the annotation was
252    /// authored in. Off, each run is drawn as the projection gives it:
253    /// foreshortened, rotated, and on an edge-on plane collapsed onto a line.
254    #[serde(default = "default_true", rename = "flattenText")]
255    pub flatten_text: bool,
256    /// Draw this placement LIVE in the Sheets + 3D PDF as well: a 3D box over the
257    /// drawing that Acrobat and Foxit turn and zoom, opening on this
258    /// placement's camera and scale ([`pdf3d`]). Every other reader, and the
259    /// SVG, show the drawing alone. Off by default, and absent from a saved
260    /// placement that is off, so a document that never asks for 3D saves
261    /// exactly as before.
262    #[serde(default, rename = "threeD", skip_serializing_if = "is_false")]
263    pub three_d: bool,
264    /// This placement is a SECTION VIEW: its camera is derived from a cutting
265    /// plane picked on ANOTHER placement, and it draws the model clipped at
266    /// that plane with the cut faces hatched. Absent on a plain placement,
267    /// which is every one saved before sections existed.
268    #[serde(default, skip_serializing_if = "Option::is_none")]
269    pub section: Option<SectionCut>,
270    /// This placement is a DETAIL VIEW: a circled region of ANOTHER placement
271    /// redrawn at this placement's own scale, through the source's camera and
272    /// clipped to the circle. Absent on a plain placement and on a section; a
273    /// placement is at most one of the two.
274    #[serde(default, skip_serializing_if = "Option::is_none")]
275    pub detail: Option<DetailCircle>,
276}
277
278/// The cutting plane of a section view, as it was PICKED: two anchors of an
279/// existing placement's projection.
280///
281/// The line is stored as two ANCHOR REFERENCES rather than two paper points,
282/// for the reason every other sheet reference is: a paper point is not a
283/// property of the model, and a placement that moved or a scale that changed
284/// would leave the cut somewhere else. Two anchors follow their geometry
285/// through a camera, scale, placement or model change — and a section whose
286/// anchor is gone goes UNRESOLVED with the reason, exactly as a dimension
287/// does, rather than quietly cutting the wrong place.
288///
289/// It is also what makes the cut reachable without a pointer: an anchor has a
290/// keyed hit rect (`sheet/anchor:<reference>`) and a bare paper point does
291/// not, so a section picked from free points could not be driven by a script
292/// or by anything but a mouse.
293#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
294pub struct SectionCut {
295    /// The placement the line is drawn ON — the view the cut was picked in.
296    /// Its camera's viewing direction lies IN the cutting plane, which is what
297    /// makes the section camera's frame orthonormal by construction.
298    #[serde(default)]
299    pub source: String,
300    /// The two anchors the cutting line runs through.
301    #[serde(default)]
302    pub from: String,
303    #[serde(default)]
304    pub to: String,
305    /// The section's letter — drawn at both arrows on the source view and in
306    /// this view's own `SECTION A-A` caption.
307    #[serde(default = "default_label")]
308    pub label: String,
309    /// Look the other way along the plane's normal. The unflipped direction is
310    /// the drawing convention read off the pick order: 90\u{00B0} clockwise from
311    /// `from` \u{2192} `to` on the source's paper, which is the side a reader walking
312    /// from the first arrow to the second has on their right.
313    #[serde(default)]
314    pub flip: bool,
315}
316
317fn default_label() -> String {
318    "A".to_string()
319}
320
321/// The circle of a detail view, as it was PICKED: two anchors of an existing
322/// placement's projection — the circle's centre and a point on its rim.
323///
324/// Anchors rather than a paper centre and a radius in millimetres, for the
325/// section's reason: a paper point is not a property of the model, and the
326/// two anchors are what keeps the circle on the feature it was drawn round
327/// through a camera, scale, placement or model change. They also give the
328/// circle keyed hit rects (`sheet/anchor:<reference>`), so a script picks one
329/// exactly as a user does.
330#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
331pub struct DetailCircle {
332    /// The placement the circle is drawn ON. The detail looks through that
333    /// placement's camera; only the scale and the clip are its own.
334    #[serde(default)]
335    pub source: String,
336    /// The anchor at the circle's centre.
337    #[serde(default)]
338    pub centre: String,
339    /// An anchor ON the circle: its distance from the centre, measured on the
340    /// source's paper and divided by the source's scale, is the radius in
341    /// model units.
342    #[serde(default)]
343    pub rim: String,
344    /// The detail's letter — beside the circle on the source and in this
345    /// view's own `DETAIL B` caption. Handed out from the SAME run of letters
346    /// as a section's ([`Sheet::next_view_letter`]).
347    #[serde(default = "default_label")]
348    pub label: String,
349}
350
351/// A new detail view draws at this multiple of its source's scale — the
352/// usual reason for a detail is that the source is too small to read there.
353pub const DETAIL_SCALE_FACTOR: f64 = 2.0;
354
355impl SectionCut {
356    /// The letters a sheet hands out, in order, skipping the three a drawing
357    /// office does not use for a section (I, O and Q read as 1, 0 and O).
358    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    /// The paper's millimetres: the preset's when `size` names one (so a
374    /// preset sheet cannot drift), else the stored custom pair.
375    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    /// The border's inset, clamped to something the paper can carry: a sheet
383    /// whose inset ate the page would have no drawing area at all.
384    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    /// The next view LETTER this sheet has not handed out, for a section or a
406    /// detail alike: the two draw from ONE run of letters, because a drawing
407    /// with a `SECTION A—A` and a `DETAIL A` names two different views with
408    /// one letter. A sheet past `Z` reuses the last one rather than refusing
409    /// the view: two views sharing a letter is a drawing-office mistake, not a
410    /// broken document, and the form is where it is corrected.
411    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    /// Mint the next id with `prefix` (`SHEET` → `SHEET3`), re-seeding the
432    /// counter from the largest numeric suffix already in the block.
433    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    /// The sheet id and index of placed view `id`.
485    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    /// The sheet id and index of dimension `id`.
502    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    /// The sheet id and index of ordinate set `id`.
523    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    /// Whether the block carries anything worth persisting.
544    pub fn is_empty(&self) -> bool {
545        self.sheets.is_empty() && self.id_counter == 0
546    }
547}
548
549// ===========================================================================
550// Schemas — the catalogue the dialogs and the MCP contract are drawn from
551// ===========================================================================
552
553/// The sheet object schemas, in the `inputParamsSchema` vocabulary the shared
554/// form engine already renders ([`crate::features::form_fields_from_schema`]),
555/// so neither dialog is hand-written. `view_ids` are the document's saved PMI
556/// views — the options of a placed view's `view` field, which is why this
557/// catalogue is built per document rather than being a static.
558pub 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
571/// The **Section view** dialog's schema: where the view sits and at what
572/// scale, and the section itself — its CUTTING LINE, two anchors picked on a
573/// placed view with the reference picker, its letter and which way it looks.
574/// Its saved view is its source's, so it has no `view` field of its own.
575pub 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
593/// The **Detail view** dialog's schema: the circle — its CENTRE and a point on
594/// its RIM, each an anchor picked on one placed view — its letter, and where
595/// the enlarged view sits and at what scale.
596pub 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
614/// The **Sheet** dialog's schema.
615pub 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            // A LIST of rows, which the schema vocabulary has no field for: the
636            // Sheets pane draws it as the dialog's own Revisions section, and
637            // `sheet_update` takes the whole list in this shape.
638            "revisions": { "type": "revision_table", "label": "Revisions", "columns": Revision::FIELDS, "default_value": [] },
639        }
640    })
641}
642
643/// The **Placed view** dialog's schema. `view` offers the document's saved PMI
644/// views; `projection` offers only [`ORTHOGRAPHIC`] in this slice.
645pub 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    /// The params the **Sheet** form edits, as the schema shapes them.
665    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    /// Apply an edited **Sheet** form. `id` is identity and is never taken
683    /// from the form.
684    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        // A preset's dimensions are the preset's; storing them keeps the block
713        // readable and keeps a switch to Custom from starting at zero.
714        if let Some((w, h)) = paper_size(&self.size) {
715            self.width_mm = w;
716            self.height_mm = h;
717        }
718        // The paper's furniture. The inset is refused rather than clamped when
719        // it would eat the page — a form that silently kept a quarter of what
720        // was typed would be lying about what the sheet is.
721        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        // Notes are the user's own text, empty included — the one field on a
740        // sheet whose blank value is a value.
741        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        // The revision list is replaced WHOLE and in the order given — the
753        // order IS the table's — and a row the list cannot mean refuses the
754        // edit before anything is kept.
755        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    /// The params the placement's form edits — the **Placed view**, **Section
771    /// view** or **Detail view** schema's, whichever this placement is. A
772    /// section's cutting line (`cut`) and a detail's `centre` and `rim` are
773    /// the picked anchors its reference rows show.
774    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    /// The anchors picked for a derived placement's reference `field` (`cut`,
802    /// `centre`, `rim`), blanks left out — empty for a field this placement
803    /// does not have.
804    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    /// Which catalogue object this placement's form is: `sheetView`,
815    /// `sectionView` or `detailView`.
816    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    /// Apply an edited **Placed view** form.
825    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        // Live 3D is a PLAIN placement's: a section's clipped model and a
859        // detail's circle are not what the 3D box would show, so asking for it
860        // there is refused rather than drawn wrong.
861        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        // The section's two editable fields are refused on a PLAIN placement
868        // rather than ignored: a form that silently swallowed "look the other
869        // way" on a view with no cutting plane would be lying about what it
870        // did. A blank label from the plain placement's own form is not an
871        // edit, so it passes.
872        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        // The detail's letter, by the same rule: refused on a placement that is
893        // not a detail, and a blank one from any other placement's form passes.
894        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