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}
149
150/// One row of a sheet's revision table: the revision's letter, its date and
151/// what changed. All three are the user's own text — a drawing office letters,
152/// dates and words its revisions its own way — so none is validated beyond
153/// being a string, and a blank one leaves its cell ruled and empty.
154#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)]
155pub struct Revision {
156    #[serde(default)]
157    pub rev: String,
158    #[serde(default)]
159    pub date: String,
160    #[serde(default)]
161    pub description: String,
162}
163
164impl Revision {
165    /// The three fields, by the keys the block, the form and the commands use.
166    pub const FIELDS: &'static [&'static str] = &["rev", "date", "description"];
167
168    /// Patch this row from `params`; absent keys are unchanged, and a key that
169    /// is not a string is refused by name.
170    pub fn apply(&mut self, params: &Value) -> Result<(), String> {
171        let Some(object) = params.as_object() else {
172            return Err("a revision is an object of rev, date and description".into());
173        };
174        for (key, value) in object {
175            let slot = match key.as_str() {
176                "rev" => &mut self.rev,
177                "date" => &mut self.date,
178                "description" => &mut self.description,
179                other => return Err(format!("a revision has no field '{other}'")),
180            };
181            *slot = value
182                .as_str()
183                .ok_or_else(|| format!("revision {key}: {value} is not text"))?
184                .to_string();
185        }
186        Ok(())
187    }
188}
189
190fn default_size() -> String {
191    DEFAULT_SIZE.to_string()
192}
193
194fn default_true() -> bool {
195    true
196}
197
198fn is_false(value: &bool) -> bool {
199    !*value
200}
201
202fn default_border_inset() -> f64 {
203    DEFAULT_BORDER_INSET_MM
204}
205
206/// A saved PMI view placed on a sheet.
207#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
208pub struct PlacedView {
209    pub id: String,
210    /// The PMI view's id (`VIEW3`).
211    #[serde(default)]
212    pub view: String,
213    /// Millimetres from the paper's TOP-LEFT corner (SVG's own origin), of the
214    /// point the saved camera targets — the view's centre, not its corner, so
215    /// changing the scale grows the drawing about where it was placed.
216    #[serde(default)]
217    pub position: [f64; 2],
218    /// Paper millimetres per model unit.
219    #[serde(default = "default_scale")]
220    pub scale: f64,
221    /// `orthographic` — the only kind this slice draws.
222    #[serde(default = "default_projection")]
223    pub projection: String,
224    /// Rotate every text run — and every framed symbol, frame and cells
225    /// together — FLAT to the sheet, about its own anchor, at its sheet-scale
226    /// size. ON by default, because that is the drawing convention: a
227    /// drawing's text reads left to right whatever plane the annotation was
228    /// authored in. Off, each run is drawn as the projection gives it:
229    /// foreshortened, rotated, and on an edge-on plane collapsed onto a line.
230    #[serde(default = "default_true", rename = "flattenText")]
231    pub flatten_text: bool,
232    /// Draw this placement LIVE in the Sheets + 3D PDF as well: a 3D box over the
233    /// drawing that Acrobat and Foxit turn and zoom, opening on this
234    /// placement's camera and scale ([`pdf3d`]). Every other reader, and the
235    /// SVG, show the drawing alone. Off by default, and absent from a saved
236    /// placement that is off, so a document that never asks for 3D saves
237    /// exactly as before.
238    #[serde(default, rename = "threeD", skip_serializing_if = "is_false")]
239    pub three_d: bool,
240    /// This placement is a SECTION VIEW: its camera is derived from a cutting
241    /// plane picked on ANOTHER placement, and it draws the model clipped at
242    /// that plane with the cut faces hatched. Absent on a plain placement,
243    /// which is every one saved before sections existed.
244    #[serde(default, skip_serializing_if = "Option::is_none")]
245    pub section: Option<SectionCut>,
246    /// This placement is a DETAIL VIEW: a circled region of ANOTHER placement
247    /// redrawn at this placement's own scale, through the source's camera and
248    /// clipped to the circle. Absent on a plain placement and on a section; a
249    /// placement is at most one of the two.
250    #[serde(default, skip_serializing_if = "Option::is_none")]
251    pub detail: Option<DetailCircle>,
252}
253
254/// The cutting plane of a section view, as it was PICKED: two anchors of an
255/// existing placement's projection.
256///
257/// The line is stored as two ANCHOR REFERENCES rather than two paper points,
258/// for the reason every other sheet reference is: a paper point is not a
259/// property of the model, and a placement that moved or a scale that changed
260/// would leave the cut somewhere else. Two anchors follow their geometry
261/// through a camera, scale, placement or model change — and a section whose
262/// anchor is gone goes UNRESOLVED with the reason, exactly as a dimension
263/// does, rather than quietly cutting the wrong place.
264///
265/// It is also what makes the cut reachable without a pointer: an anchor has a
266/// keyed hit rect (`sheet/anchor:<reference>`) and a bare paper point does
267/// not, so a section picked from free points could not be driven by a script
268/// or by anything but a mouse.
269#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
270pub struct SectionCut {
271    /// The placement the line is drawn ON — the view the cut was picked in.
272    /// Its camera's viewing direction lies IN the cutting plane, which is what
273    /// makes the section camera's frame orthonormal by construction.
274    #[serde(default)]
275    pub source: String,
276    /// The two anchors the cutting line runs through.
277    #[serde(default)]
278    pub from: String,
279    #[serde(default)]
280    pub to: String,
281    /// The section's letter — drawn at both arrows on the source view and in
282    /// this view's own `SECTION A-A` caption.
283    #[serde(default = "default_label")]
284    pub label: String,
285    /// Look the other way along the plane's normal. The unflipped direction is
286    /// the drawing convention read off the pick order: 90\u{00B0} clockwise from
287    /// `from` \u{2192} `to` on the source's paper, which is the side a reader walking
288    /// from the first arrow to the second has on their right.
289    #[serde(default)]
290    pub flip: bool,
291}
292
293fn default_label() -> String {
294    "A".to_string()
295}
296
297/// The circle of a detail view, as it was PICKED: two anchors of an existing
298/// placement's projection — the circle's centre and a point on its rim.
299///
300/// Anchors rather than a paper centre and a radius in millimetres, for the
301/// section's reason: a paper point is not a property of the model, and the
302/// two anchors are what keeps the circle on the feature it was drawn round
303/// through a camera, scale, placement or model change. They also give the
304/// circle keyed hit rects (`sheet/anchor:<reference>`), so a script picks one
305/// exactly as a user does.
306#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
307pub struct DetailCircle {
308    /// The placement the circle is drawn ON. The detail looks through that
309    /// placement's camera; only the scale and the clip are its own.
310    #[serde(default)]
311    pub source: String,
312    /// The anchor at the circle's centre.
313    #[serde(default)]
314    pub centre: String,
315    /// An anchor ON the circle: its distance from the centre, measured on the
316    /// source's paper and divided by the source's scale, is the radius in
317    /// model units.
318    #[serde(default)]
319    pub rim: String,
320    /// The detail's letter — beside the circle on the source and in this
321    /// view's own `DETAIL B` caption. Handed out from the SAME run of letters
322    /// as a section's ([`Sheet::next_view_letter`]).
323    #[serde(default = "default_label")]
324    pub label: String,
325}
326
327/// A new detail view draws at this multiple of its source's scale — the
328/// usual reason for a detail is that the source is too small to read there.
329pub const DETAIL_SCALE_FACTOR: f64 = 2.0;
330
331impl SectionCut {
332    /// The letters a sheet hands out, in order, skipping the three a drawing
333    /// office does not use for a section (I, O and Q read as 1, 0 and O).
334    pub const LETTERS: &'static [char] = &[
335        'A', 'B', 'C', 'D', 'E', 'F', 'G', 'H', 'J', 'K', 'L', 'M', 'N', 'P', 'R', 'S', 'T', 'U',
336        'V', 'W', 'X', 'Y', 'Z',
337    ];
338}
339
340fn default_scale() -> f64 {
341    DEFAULT_SCALE
342}
343
344fn default_projection() -> String {
345    ORTHOGRAPHIC.to_string()
346}
347
348impl Sheet {
349    /// The paper's millimetres: the preset's when `size` names one (so a
350    /// preset sheet cannot drift), else the stored custom pair.
351    pub fn millimetres(&self) -> (f64, f64) {
352        match paper_size(&self.size) {
353            Some((w, h)) => (w, h),
354            None => (self.width_mm.max(1.0), self.height_mm.max(1.0)),
355        }
356    }
357
358    /// The border's inset, clamped to something the paper can carry: a sheet
359    /// whose inset ate the page would have no drawing area at all.
360    pub fn border_inset(&self) -> f64 {
361        let (w, h) = self.millimetres();
362        self.border_inset_mm.max(0.0).min(w.min(h) * 0.25)
363    }
364
365    pub fn find_view(&self, id: &str) -> Option<&PlacedView> {
366        self.views.iter().find(|view| view.id == id)
367    }
368
369    pub fn find_view_mut(&mut self, id: &str) -> Option<&mut PlacedView> {
370        self.views.iter_mut().find(|view| view.id == id)
371    }
372
373    pub fn find_dimension(&self, id: &str) -> Option<&dimension::SheetDimension> {
374        self.dimensions.iter().find(|d| d.id == id)
375    }
376
377    pub fn find_ordinate(&self, id: &str) -> Option<&ordinate::SheetOrdinate> {
378        self.ordinates.iter().find(|o| o.id == id)
379    }
380
381    /// The next view LETTER this sheet has not handed out, for a section or a
382    /// detail alike: the two draw from ONE run of letters, because a drawing
383    /// with a `SECTION A—A` and a `DETAIL A` names two different views with
384    /// one letter. A sheet past `Z` reuses the last one rather than refusing
385    /// the view: two views sharing a letter is a drawing-office mistake, not a
386    /// broken document, and the form is where it is corrected.
387    pub fn next_view_letter(&self) -> String {
388        let taken: Vec<&str> = self
389            .views
390            .iter()
391            .filter_map(|view| {
392                view.section
393                    .as_ref()
394                    .map(|cut| cut.label.as_str())
395                    .or_else(|| view.detail.as_ref().map(|circle| circle.label.as_str()))
396            })
397            .collect();
398        SectionCut::LETTERS
399            .iter()
400            .map(|letter| letter.to_string())
401            .find(|letter| !taken.iter().any(|used| used == letter))
402            .unwrap_or_else(|| SectionCut::LETTERS[SectionCut::LETTERS.len() - 1].to_string())
403    }
404}
405
406impl SheetState {
407    /// Mint the next id with `prefix` (`SHEET` → `SHEET3`), re-seeding the
408    /// counter from the largest numeric suffix already in the block.
409    pub fn next_id(&mut self, prefix: &str) -> String {
410        let seen = self.max_numeric_suffix();
411        if seen > self.id_counter {
412            self.id_counter = seen;
413        }
414        loop {
415            self.id_counter += 1;
416            let candidate = format!("{prefix}{}", self.id_counter);
417            if self.find_sheet(&candidate).is_none()
418                && self.locate_view(&candidate).is_none()
419                && self.locate_dimension(&candidate).is_none()
420                && self.locate_ordinate(&candidate).is_none()
421            {
422                return candidate;
423            }
424        }
425    }
426
427    fn max_numeric_suffix(&self) -> u64 {
428        let mut best = 0u64;
429        let mut consider = |id: &str| {
430            let digits = id.bytes().rev().take_while(u8::is_ascii_digit).count();
431            if digits > 0 {
432                if let Ok(value) = id[id.len() - digits..].parse::<u64>() {
433                    best = best.max(value);
434                }
435            }
436        };
437        for sheet in &self.sheets {
438            consider(&sheet.id);
439            for view in &sheet.views {
440                consider(&view.id);
441            }
442            for dimension in &sheet.dimensions {
443                consider(&dimension.id);
444            }
445            for set in &sheet.ordinates {
446                consider(&set.id);
447            }
448        }
449        best
450    }
451
452    pub fn find_sheet(&self, id: &str) -> Option<&Sheet> {
453        self.sheets.iter().find(|sheet| sheet.id == id)
454    }
455
456    pub fn find_sheet_mut(&mut self, id: &str) -> Option<&mut Sheet> {
457        self.sheets.iter_mut().find(|sheet| sheet.id == id)
458    }
459
460    /// The sheet id and index of placed view `id`.
461    pub fn locate_view(&self, id: &str) -> Option<(String, usize)> {
462        self.sheets.iter().find_map(|sheet| {
463            sheet
464                .views
465                .iter()
466                .position(|view| view.id == id)
467                .map(|index| (sheet.id.clone(), index))
468        })
469    }
470
471    pub fn find_view_mut(&mut self, id: &str) -> Option<&mut PlacedView> {
472        self.sheets
473            .iter_mut()
474            .find_map(|sheet| sheet.views.iter_mut().find(|view| view.id == id))
475    }
476
477    /// The sheet id and index of dimension `id`.
478    pub fn locate_dimension(&self, id: &str) -> Option<(String, usize)> {
479        self.sheets.iter().find_map(|sheet| {
480            sheet
481                .dimensions
482                .iter()
483                .position(|dimension| dimension.id == id)
484                .map(|index| (sheet.id.clone(), index))
485        })
486    }
487
488    pub fn find_dimension(&self, id: &str) -> Option<&dimension::SheetDimension> {
489        self.sheets.iter().find_map(|sheet| sheet.find_dimension(id))
490    }
491
492    pub fn find_dimension_mut(&mut self, id: &str) -> Option<&mut dimension::SheetDimension> {
493        self.sheets
494            .iter_mut()
495            .find_map(|sheet| sheet.dimensions.iter_mut().find(|d| d.id == id))
496    }
497
498    /// The sheet id and index of ordinate set `id`.
499    pub fn locate_ordinate(&self, id: &str) -> Option<(String, usize)> {
500        self.sheets.iter().find_map(|sheet| {
501            sheet
502                .ordinates
503                .iter()
504                .position(|set| set.id == id)
505                .map(|index| (sheet.id.clone(), index))
506        })
507    }
508
509    pub fn find_ordinate(&self, id: &str) -> Option<&ordinate::SheetOrdinate> {
510        self.sheets.iter().find_map(|sheet| sheet.find_ordinate(id))
511    }
512
513    pub fn find_ordinate_mut(&mut self, id: &str) -> Option<&mut ordinate::SheetOrdinate> {
514        self.sheets
515            .iter_mut()
516            .find_map(|sheet| sheet.ordinates.iter_mut().find(|o| o.id == id))
517    }
518
519    /// Whether the block carries anything worth persisting.
520    pub fn is_empty(&self) -> bool {
521        self.sheets.is_empty() && self.id_counter == 0
522    }
523}
524
525// ===========================================================================
526// Schemas — the catalogue the dialogs and the MCP contract are drawn from
527// ===========================================================================
528
529/// The sheet object schemas, in the `inputParamsSchema` vocabulary the shared
530/// form engine already renders ([`crate::features::form_fields_from_schema`]),
531/// so neither dialog is hand-written. `view_ids` are the document's saved PMI
532/// views — the options of a placed view's `view` field, which is why this
533/// catalogue is built per document rather than being a static.
534pub fn schema_catalogue(view_ids: &[String]) -> Value {
535    json!({
536        "objects": [
537            sheet_schema(),
538            sheet_view_schema(view_ids),
539            dimension::sheet_dimension_schema(),
540            ordinate::sheet_ordinate_schema(),
541            section_view_schema(),
542            detail_view_schema(),
543        ]
544    })
545}
546
547/// The **Section view** dialog's schema: where the view sits and at what
548/// scale, and the section itself — its CUTTING LINE, two anchors picked on a
549/// placed view with the reference picker, its letter and which way it looks.
550/// Its saved view is its source's, so it has no `view` field of its own.
551pub fn section_view_schema() -> Value {
552    json!({
553        "type": "sectionView",
554        "shortName": "SV",
555        "longName": "Section view",
556        "inputParamsSchema": {
557            "id": { "type": "string", "default_value": null },
558            "cut": { "type": "reference_selection", "label": "Cutting line", "selectionFilter": ["sheetAnchor"], "multiple": true, "hint": "Two anchors on one placed view" },
559            "sectionLabel": { "type": "string", "label": "Section letter", "default_value": "A" },
560            "sectionFlip": { "type": "boolean", "label": "Section looks the other way", "default_value": false },
561            "positionXMm": { "type": "number", "label": "X (mm)", "default_value": 0.0 },
562            "positionYMm": { "type": "number", "label": "Y (mm)", "default_value": 0.0 },
563            "scale": { "type": "number", "label": "Scale (mm per model unit)", "default_value": DEFAULT_SCALE },
564            "flattenText": { "type": "boolean", "label": "Flatten text to sheet", "default_value": true },
565        }
566    })
567}
568
569/// The **Detail view** dialog's schema: the circle — its CENTRE and a point on
570/// its RIM, each an anchor picked on one placed view — its letter, and where
571/// the enlarged view sits and at what scale.
572pub fn detail_view_schema() -> Value {
573    json!({
574        "type": "detailView",
575        "shortName": "SV",
576        "longName": "Detail view",
577        "inputParamsSchema": {
578            "id": { "type": "string", "default_value": null },
579            "centre": { "type": "reference_selection", "label": "Centre", "selectionFilter": ["sheetAnchor"], "multiple": false, "hint": "The circle's centre, an anchor on a placed view" },
580            "rim": { "type": "reference_selection", "label": "Point on the rim", "selectionFilter": ["sheetAnchor"], "multiple": false, "hint": "An anchor on the same view, on the circle" },
581            "detailLabel": { "type": "string", "label": "Detail letter", "default_value": "A" },
582            "positionXMm": { "type": "number", "label": "X (mm)", "default_value": 0.0 },
583            "positionYMm": { "type": "number", "label": "Y (mm)", "default_value": 0.0 },
584            "scale": { "type": "number", "label": "Scale (mm per model unit)", "default_value": DEFAULT_SCALE },
585            "flattenText": { "type": "boolean", "label": "Flatten text to sheet", "default_value": true },
586        }
587    })
588}
589
590/// The **Sheet** dialog's schema.
591pub fn sheet_schema() -> Value {
592    let sizes: Vec<&str> = PAPER_SIZES
593        .iter()
594        .map(|(key, _, _)| *key)
595        .chain(std::iter::once(CUSTOM))
596        .collect();
597    json!({
598        "type": "sheet",
599        "shortName": "SHT",
600        "longName": "Sheet",
601        "inputParamsSchema": {
602            "id": { "type": "string", "default_value": null },
603            "name": { "type": "string", "default_value": "Sheet 1" },
604            "size": { "type": "options", "label": "Paper size", "options": sizes, "default_value": DEFAULT_SIZE },
605            "widthMm": { "type": "number", "label": "Width (mm)", "default_value": 420.0 },
606            "heightMm": { "type": "number", "label": "Height (mm)", "default_value": 297.0 },
607            "border": { "type": "boolean", "label": "Border", "default_value": true },
608            "borderInsetMm": { "type": "number", "label": "Border inset (mm)", "default_value": DEFAULT_BORDER_INSET_MM },
609            "titleBlock": { "type": "boolean", "label": "Title block", "default_value": true },
610            "notes": { "type": "string", "label": "Notes", "default_value": "" },
611            // A LIST of rows, which the schema vocabulary has no field for: the
612            // Sheets pane draws it as the dialog's own Revisions section, and
613            // `sheet_update` takes the whole list in this shape.
614            "revisions": { "type": "revision_table", "label": "Revisions", "columns": Revision::FIELDS, "default_value": [] },
615        }
616    })
617}
618
619/// The **Placed view** dialog's schema. `view` offers the document's saved PMI
620/// views; `projection` offers only [`ORTHOGRAPHIC`] in this slice.
621pub fn sheet_view_schema(view_ids: &[String]) -> Value {
622    json!({
623        "type": "sheetView",
624        "shortName": "SV",
625        "longName": "Placed view",
626        "inputParamsSchema": {
627            "id": { "type": "string", "default_value": null },
628            "view": { "type": "options", "label": "PMI view", "options": view_ids, "default_value": view_ids.first().cloned().unwrap_or_default() },
629            "positionXMm": { "type": "number", "label": "X (mm)", "default_value": 0.0 },
630            "positionYMm": { "type": "number", "label": "Y (mm)", "default_value": 0.0 },
631            "scale": { "type": "number", "label": "Scale (mm per model unit)", "default_value": DEFAULT_SCALE },
632            "projection": { "type": "options", "label": "Projection", "options": [ORTHOGRAPHIC], "default_value": ORTHOGRAPHIC },
633            "flattenText": { "type": "boolean", "label": "Flatten text to sheet", "default_value": true },
634            "threeD": { "type": "boolean", "label": "Live 3D in PDF", "default_value": false, "hint": "In the Sheets + 3D (PDF) export, Acrobat and Foxit turn and zoom this view; other readers show the drawing" },
635        }
636    })
637}
638
639impl Sheet {
640    /// The params the **Sheet** form edits, as the schema shapes them.
641    pub fn params(&self) -> Value {
642        let (w, h) = self.millimetres();
643        json!({
644            "id": self.id,
645            "name": self.name,
646            "size": self.size,
647            "widthMm": w,
648            "heightMm": h,
649            "border": self.border,
650            "borderInsetMm": self.border_inset_mm,
651            "titleBlock": self.title_block,
652            "notes": self.notes,
653            "revisions": self.revisions,
654        })
655    }
656
657    /// Apply an edited **Sheet** form. `id` is identity and is never taken
658    /// from the form.
659    pub fn apply(&mut self, params: &Value) -> Result<(), String> {
660        if let Some(name) = params.get("name").and_then(Value::as_str) {
661            let name = name.trim();
662            if name.is_empty() {
663                return Err("a sheet needs a name".into());
664            }
665            self.name = name.to_string();
666        }
667        if let Some(size) = params.get("size").and_then(Value::as_str) {
668            let size = size.trim();
669            if !size.eq_ignore_ascii_case(CUSTOM) && paper_size(size).is_none() {
670                return Err(format!("no paper size '{size}'"));
671            }
672            self.size = if size.eq_ignore_ascii_case(CUSTOM) {
673                CUSTOM.to_string()
674            } else {
675                size.to_string()
676            };
677        }
678        let dimension = |key: &str, current: f64| -> Result<f64, String> {
679            match params.get(key).and_then(Value::as_f64) {
680                None => Ok(current),
681                Some(value) if value.is_finite() && value > 0.0 => Ok(value),
682                Some(value) => Err(format!("{key}: {value} is not a positive length")),
683            }
684        };
685        self.width_mm = dimension("widthMm", self.width_mm)?;
686        self.height_mm = dimension("heightMm", self.height_mm)?;
687        // A preset's dimensions are the preset's; storing them keeps the block
688        // readable and keeps a switch to Custom from starting at zero.
689        if let Some((w, h)) = paper_size(&self.size) {
690            self.width_mm = w;
691            self.height_mm = h;
692        }
693        // The paper's furniture. The inset is refused rather than clamped when
694        // it would eat the page — a form that silently kept a quarter of what
695        // was typed would be lying about what the sheet is.
696        if let Some(border) = params.get("border").and_then(Value::as_bool) {
697            self.border = border;
698        }
699        if let Some(inset) = params.get("borderInsetMm").and_then(Value::as_f64) {
700            let (w, h) = self.millimetres();
701            if !inset.is_finite() || inset < 0.0 {
702                return Err(format!("borderInsetMm: {inset} is not a length"));
703            }
704            if inset > w.min(h) * 0.25 {
705                return Err(format!(
706                    "borderInsetMm: {inset} leaves no drawing area on a {w:.0} × {h:.0} mm sheet"
707                ));
708            }
709            self.border_inset_mm = inset;
710        }
711        if let Some(title_block) = params.get("titleBlock").and_then(Value::as_bool) {
712            self.title_block = title_block;
713        }
714        // Notes are the user's own text, empty included — the one field on a
715        // sheet whose blank value is a value.
716        if let Some(notes) = params.get("notes").and_then(Value::as_str) {
717            self.notes = notes.to_string();
718        }
719        // The revision list is replaced WHOLE and in the order given — the
720        // order IS the table's — and a row the list cannot mean refuses the
721        // edit before anything is kept.
722        if let Some(rows) = params.get("revisions") {
723            let rows = rows.as_array().ok_or("revisions: not a list of rows")?;
724            let mut revisions = Vec::with_capacity(rows.len());
725            for (index, row) in rows.iter().enumerate() {
726                let mut revision = Revision::default();
727                revision.apply(row).map_err(|error| format!("revisions[{index}]: {error}"))?;
728                revisions.push(revision);
729            }
730            self.revisions = revisions;
731        }
732        Ok(())
733    }
734}
735
736impl PlacedView {
737    /// The params the placement's form edits — the **Placed view**, **Section
738    /// view** or **Detail view** schema's, whichever this placement is. A
739    /// section's cutting line (`cut`) and a detail's `centre` and `rim` are
740    /// the picked anchors its reference rows show.
741    pub fn params(&self) -> Value {
742        let mut params = json!({
743            "id": self.id,
744            "view": self.view,
745            "positionXMm": self.position[0],
746            "positionYMm": self.position[1],
747            "scale": self.scale,
748            "projection": self.projection,
749            "flattenText": self.flatten_text,
750        });
751        if self.schema_type() == "sheetView" {
752            params.as_object_mut().expect("an object").insert("threeD".into(), json!(self.three_d));
753        }
754        let object = params.as_object_mut().expect("an object");
755        if let Some(cut) = &self.section {
756            object.insert("cut".into(), json!(self.picked("cut")));
757            object.insert("sectionLabel".into(), json!(cut.label));
758            object.insert("sectionFlip".into(), json!(cut.flip));
759        }
760        if let Some(circle) = &self.detail {
761            object.insert("centre".into(), json!(circle.centre));
762            object.insert("rim".into(), json!(circle.rim));
763            object.insert("detailLabel".into(), json!(circle.label));
764        }
765        params
766    }
767
768    /// The anchors picked for a derived placement's reference `field` (`cut`,
769    /// `centre`, `rim`), blanks left out — empty for a field this placement
770    /// does not have.
771    pub fn picked(&self, field: &str) -> Vec<String> {
772        let list: Vec<&String> = match (field, &self.section, &self.detail) {
773            ("cut", Some(cut), _) => vec![&cut.from, &cut.to],
774            ("centre", _, Some(circle)) => vec![&circle.centre],
775            ("rim", _, Some(circle)) => vec![&circle.rim],
776            _ => Vec::new(),
777        };
778        list.into_iter().filter(|anchor| !anchor.is_empty()).cloned().collect()
779    }
780
781    /// Which catalogue object this placement's form is: `sheetView`,
782    /// `sectionView` or `detailView`.
783    pub fn schema_type(&self) -> &'static str {
784        match (&self.section, &self.detail) {
785            (Some(_), _) => "sectionView",
786            (_, Some(_)) => "detailView",
787            _ => "sheetView",
788        }
789    }
790
791    /// Apply an edited **Placed view** form.
792    pub fn apply(&mut self, params: &Value, view_ids: &[String]) -> Result<(), String> {
793        if let Some(view) = params.get("view").and_then(Value::as_str) {
794            let view = view.trim();
795            if !view.is_empty() && !view_ids.iter().any(|id| id == view) {
796                return Err(format!("no PMI view '{view}'"));
797            }
798            self.view = view.to_string();
799        }
800        for (key, slot) in [("positionXMm", 0usize), ("positionYMm", 1usize)] {
801            if let Some(value) = params.get(key).and_then(Value::as_f64) {
802                if !value.is_finite() {
803                    return Err(format!("{key}: not a finite length"));
804                }
805                self.position[slot] = value;
806            }
807        }
808        if let Some(scale) = params.get("scale").and_then(Value::as_f64) {
809            if !scale.is_finite() || scale <= 0.0 {
810                return Err(format!("scale: {scale} is not a positive ratio"));
811            }
812            self.scale = scale;
813        }
814        if let Some(projection) = params.get("projection").and_then(Value::as_str) {
815            if !projection.eq_ignore_ascii_case(ORTHOGRAPHIC) {
816                return Err(format!(
817                    "projection '{projection}' is not drawn on a sheet yet — this slice is orthographic only"
818                ));
819            }
820            self.projection = ORTHOGRAPHIC.to_string();
821        }
822        if let Some(flatten) = params.get("flattenText").and_then(Value::as_bool) {
823            self.flatten_text = flatten;
824        }
825        // Live 3D is a PLAIN placement's: a section's clipped model and a
826        // detail's circle are not what the 3D box would show, so asking for it
827        // there is refused rather than drawn wrong.
828        if let Some(three_d) = params.get("threeD").and_then(Value::as_bool) {
829            if three_d && self.schema_type() != "sheetView" {
830                return Err(format!("'{}' is a {}; live 3D is for a plain placed view", self.id, self.schema_type()));
831            }
832            self.three_d = three_d;
833        }
834        // The section's two editable fields are refused on a PLAIN placement
835        // rather than ignored: a form that silently swallowed "look the other
836        // way" on a view with no cutting plane would be lying about what it
837        // did. A blank label from the plain placement's own form is not an
838        // edit, so it passes.
839        let label = params.get("sectionLabel").and_then(Value::as_str).map(str::trim);
840        let flip = params.get("sectionFlip").and_then(Value::as_bool);
841        match self.section.as_mut() {
842            Some(cut) => {
843                if let Some(label) = label.filter(|l| !l.is_empty()) {
844                    cut.label = label.to_string();
845                }
846                if let Some(flip) = flip {
847                    cut.flip = flip;
848                }
849            }
850            None => {
851                if label.is_some_and(|l| !l.is_empty()) || flip == Some(true) {
852                    return Err(format!(
853                        "'{}' is not a section view; it has no cutting plane to letter or turn round",
854                        self.id
855                    ));
856                }
857            }
858        }
859        // The detail's letter, by the same rule: refused on a placement that is
860        // not a detail, and a blank one from any other placement's form passes.
861        let detail_label = params.get("detailLabel").and_then(Value::as_str).map(str::trim);
862        match self.detail.as_mut() {
863            Some(circle) => {
864                if let Some(label) = detail_label.filter(|l| !l.is_empty()) {
865                    circle.label = label.to_string();
866                }
867            }
868            None => {
869                if detail_label.is_some_and(|l| !l.is_empty()) {
870                    return Err(format!(
871                        "'{}' is not a detail view; it has no circle to letter",
872                        self.id
873                    ));
874                }
875            }
876        }
877        Ok(())
878    }
879}
880