Skip to main content

brep_render/engine_state/
sheet_ops.rs

1//! Drawing sheets — the engine half: the document's `sheets` block
2//! (checkpointed edits, like `pmi_ops`), which sheet the viewport shows, which
3//! sheet object's form is open, and the projection cache the sheet viewport
4//! and the SVG export both read.
5//!
6//! Persisted vs engine memory, the PMI split exactly: the block (sheets, paper
7//! sizes, placements) lives in the document and rides its undo stack; WHICH
8//! sheet is open and WHICH object's form is showing are engine memory — a
9//! mode, not model state — so a rerun, a save or an undo never churns on them.
10//!
11//! Unlike a PMI edit, a sheet edit never re-runs the history: a sheet places
12//! what the PMI tail already resolved and adds no geometry of its own, so
13//! writing the block is the whole edit. What it DOES invalidate is the
14//! projection cache, which is keyed on the scene's applied-run generation AND
15//! the document's revision — so a model rebuild, a placement move and a PMI
16//! LABEL DRAG all re-project, while a pan or a zoom does not. The label drag
17//! is why the key is not the sheets block alone: it writes the `pmi` block and
18//! patches the cached report WITHOUT a run, so a cache keyed on the run and
19//! the sheet would draw the label where it used to be. The key's fourth part is
20//! the TITLE BLOCK's stamp — the document's name and today's date — because
21//! neither lives in the document and a sheet that printed yesterday's date
22//! after midnight would be wrong about the one thing a date is for.
23
24use super::*;
25use crate::sheets::frame::{today_iso, FrameContext};
26use crate::sheets::dimension::{SheetAnchor, SheetDimension, ALIGNED, ALIGNMENTS, KINDS};
27use crate::sheets::ordinate::{SheetOrdinate, AXES};
28use crate::sheets::{DetailCircle, SectionCut, DETAIL_SCALE_FACTOR};
29use crate::sheets::project::{project_sheet_with, Deferred, LinesJob, LinesMode, Model, SheetDrawing};
30use crate::sheets::{
31    PlacedView, Sheet, SheetState, DEFAULT_BORDER_INSET_MM, DEFAULT_SCALE, DEFAULT_SIZE,
32    ORTHOGRAPHIC,
33};
34use serde_json::Value;
35
36/// A projected sheet and the state it was projected from: `(sheet id, applied
37/// run, document revision, the scene's exact revision, the landed passes'
38/// revision, the title block's stamp)`.
39pub(crate) struct SheetCache {
40    pub key: (String, u64, u64, u64, u64, u64, String),
41    pub drawing: SheetDrawing,
42    /// Every placement drew its exact lines: no pass was handed to the runner
43    /// and left the placement on its mesh approximation. An EXPORT reads only
44    /// a complete drawing.
45    pub complete: bool,
46    /// An answer landed while this incomplete drawing was being projected, so
47    /// a placement it drew by the mesh may already have its exact lines: the
48    /// next call re-projects although the key still matches.
49    pub reproject: bool,
50    /// The drawing as JSON, serialised ONCE per projection. The `__brepSheets`
51    /// registry blob carries it and the registry publishes EVERY frame in the
52    /// browser build, so a per-frame `to_string` of the whole projection would
53    /// be exactly the O(document) per-frame publisher the registry forbids.
54    pub drawing_json: String,
55}
56
57/// The exact passes a sheet handed to the RUNNER, so the UI thread never runs
58/// one (see [`LinesJob`]).
59///
60/// ONE job is on the runner at a time and the rest wait here, at most one per
61/// placement: a scale typed digit by digit, or a camera re-captured twice,
62/// asks for the newest pass rather than queueing every one. A job overtaken by
63/// a newer one for the same placement while it runs is SUPERSEDED and its
64/// answer is dropped when it lands; a cancel or a document switch drops
65/// everything ([`Self::forget`]). A runner that cannot take the job, or
66/// answers that it could not draw it, leaves that pass to the UI thread —
67/// exact and late rather than approximate for ever.
68#[derive(Default)]
69pub(crate) struct SheetLinesWork {
70    next_id: u64,
71    in_flight: Option<LinesFlight>,
72    parked: Vec<LinesJob>,
73    /// Keys the runner could not draw; asked again, they run here.
74    refused: std::collections::HashSet<String>,
75    /// The runner took no job at all (a runner without the command).
76    unsupported: bool,
77    /// Bumps when an answer lands — part of the sheet cache's key.
78    revision: u64,
79}
80
81struct LinesFlight {
82    id: u64,
83    placement: String,
84    key: String,
85    superseded: bool,
86}
87
88impl SheetLinesWork {
89    /// A job is on the runner or waiting for it.
90    pub(crate) fn pending(&self) -> bool {
91        self.in_flight.is_some() || !self.parked.is_empty()
92    }
93
94    /// Drop every job: the runner they were sent to was cancelled, reset for
95    /// another document or replaced, and no answer from it is for this scene.
96    pub(crate) fn forget(&mut self) {
97        self.in_flight = None;
98        self.parked.clear();
99        self.refused.clear();
100        self.unsupported = false;
101        self.revision += 1;
102    }
103
104    /// What a projection's exact-lines miss gets: the lines, when the runner
105    /// answers at once (an inline runner); pending, while it computes; or the
106    /// word to compute them on this thread.
107    fn defer(&mut self, runner: &mut dyn crate::runner::HistoryRunner, job: LinesJob) -> Deferred {
108        if self.unsupported || self.refused.contains(&job.key) {
109            return Deferred::Here;
110        }
111        let key = job.key.clone();
112        match self.in_flight.as_mut() {
113            // Asked again for the pass already running: wait for it, and it is
114            // wanted after all if a newer job had superseded it.
115            Some(flight) if flight.key == key => {
116                flight.superseded = false;
117                self.parked.retain(|parked| parked.placement != job.placement);
118            }
119            Some(flight) => {
120                if flight.placement == job.placement {
121                    flight.superseded = true;
122                }
123                self.park(job);
124            }
125            None => self.park(job),
126        }
127        self.pump(runner);
128        if let Some(lines) = crate::sheets::project::cached_exact_lines(&key) {
129            return Deferred::Lines(lines);
130        }
131        if self.unsupported || self.refused.contains(&key) {
132            return Deferred::Here;
133        }
134        Deferred::Pending
135    }
136
137    fn park(&mut self, job: LinesJob) {
138        self.parked.retain(|parked| parked.placement != job.placement && parked.key != job.key);
139        self.parked.push(job);
140    }
141
142    /// Land every answer the runner has and hand it the next waiting job.
143    pub(crate) fn pump(&mut self, runner: &mut dyn crate::runner::HistoryRunner) {
144        loop {
145            if self.in_flight.is_none() && !self.parked.is_empty() {
146                let job = self.parked.remove(0);
147                self.next_id += 1;
148                let flight = LinesFlight {
149                    id: self.next_id,
150                    placement: job.placement.clone(),
151                    key: job.key.clone(),
152                    superseded: false,
153                };
154                if runner.submit_sheet_lines(crate::runner::SheetLinesRequest { id: self.next_id, job }) {
155                    self.in_flight = Some(flight);
156                } else {
157                    // Every waiting job runs on the UI thread when it is next
158                    // asked for; the cache must re-project to ask.
159                    self.unsupported = true;
160                    self.parked.clear();
161                    self.revision += 1;
162                }
163            }
164            let Some(reply) = runner.poll_sheet_lines() else { break };
165            if self.in_flight.as_ref().is_none_or(|flight| flight.id != reply.id) {
166                continue; // a runner's answer from before a cancel or a reset
167            }
168            let flight = self.in_flight.take().expect("checked above");
169            if flight.superseded {
170                continue;
171            }
172            match reply.lines {
173                Ok(lines) => crate::sheets::project::store_exact_lines(reply.key, lines),
174                Err(_) => {
175                    self.refused.insert(reply.key);
176                }
177            }
178            self.revision += 1;
179        }
180    }
181}
182
183impl EngineState {
184    // --- read surface ------------------------------------------------------
185
186    /// The document's `sheets` block as typed state (the default — no sheets —
187    /// when the document carries none).
188    pub fn sheet_state(&self) -> SheetState {
189        self.history
190            .sheets_block()
191            .and_then(|block| serde_json::from_value(block.clone()).ok())
192            .unwrap_or_default()
193    }
194
195    /// The sheet the sheet viewport shows, if any.
196    pub fn sheet_open(&self) -> Option<&str> {
197        self.sheet_open.as_deref()
198    }
199
200    /// The open document's name — what a sheet's TITLE BLOCK prints. The shell
201    /// owns it (the document does not carry its own name), so the app sets it
202    /// from the active tab; this is a no-op when it has not changed, so it can
203    /// be called every frame.
204    pub fn set_document_name(&mut self, name: &str) {
205        if self.document_name != name {
206            self.document_name = name.to_string();
207            self.dirty = true;
208        }
209    }
210
211    pub fn document_name(&self) -> &str {
212        &self.document_name
213    }
214
215    /// The sheet or placed view whose form the Sheets pane shows.
216    pub fn sheet_open_object(&self) -> Option<&str> {
217        self.sheet_open_object.as_deref()
218    }
219
220    /// How many sheet-object forms this engine has OPENED — the app's dialog
221    /// door reads it beside [`Self::sheet_open_object`] so a re-open of the
222    /// object already open is still an open (the id alone does not move).
223    /// Monotonic and never reset; a CLOSE does not count.
224    pub fn sheet_object_opens(&self) -> u64 {
225        self.sheet_object_opens
226    }
227
228    /// **The sheet half of the dialog door's counter.** Open `id`'s form and
229    /// count the open. EVERY path that opens a sheet object's form goes
230    /// through here — the pane's row Edit and double click, a click on the
231    /// paper, and the place / add-dimension / add-ordinate / new-section /
232    /// new-detail adds that open their own result — so
233    /// the door has one signal to watch and a re-open of the same object still
234    /// reads as an open. Assigning `sheet_open_object = Some(..)` anywhere else
235    /// is the bug this exists to prevent.
236    ///
237    /// An open placement, dimension or ordinate set is the selected one too —
238    /// the dialog is about it. (A SHEET's selection is which sheet is open in
239    /// the viewport, so opening a sheet's own form selects nothing here.)
240    fn open_sheet_object(&mut self, id: String) {
241        if Self::is_sheet_child(&self.sheet_state(), &id) {
242            self.sheet_selected_object = Some(id.clone());
243        }
244        self.sheet_open_object = Some(id);
245        self.sheet_object_opens = self.sheet_object_opens.wrapping_add(1);
246    }
247
248    /// Whether `id` is something a sheet HOLDS — a placement, a dimension or
249    /// an ordinate set — rather than a sheet.
250    fn is_sheet_child(state: &SheetState, id: &str) -> bool {
251        state.locate_view(id).is_some()
252            || state.locate_dimension(id).is_some()
253            || state.locate_ordinate(id).is_some()
254    }
255
256    /// The placement, dimension or ordinate set selected in the Sheets tree
257    /// or on the paper, while it exists.
258    pub fn sheet_selected_object(&self) -> Option<&str> {
259        let id = self.sheet_selected_object.as_deref()?;
260        Self::is_sheet_child(&self.sheet_state(), id).then_some(id)
261    }
262
263    /// SELECT a placement, dimension or ordinate set without opening its form
264    /// — the Sheets tree's single click (`None` deselects). Returns false,
265    /// changing nothing, for an id that names none of those.
266    pub fn sheet_select_object(&mut self, id: Option<&str>) -> bool {
267        match id {
268            Some(id) if Self::is_sheet_child(&self.sheet_state(), id) => {
269                self.sheet_selected_object = Some(id.to_string());
270            }
271            Some(_) => return false,
272            None => self.sheet_selected_object = None,
273        }
274        self.dirty = true;
275        true
276    }
277
278    /// Show a different sheet (or, with `None`, the 3D model again).
279    fn set_open_sheet(&mut self, id: Option<String>) {
280        self.sheet_open = id;
281    }
282
283    /// The saved PMI view ids a placement may name, in block order.
284    pub fn sheet_pmi_view_ids(&self) -> Vec<String> {
285        self.pmi_state().views.iter().map(|view| view.id.clone()).collect()
286    }
287
288    /// The sheet object schemas, with the document's own PMI views as the
289    /// `view` field's options.
290    pub fn sheet_catalogue(&self) -> Value {
291        crate::sheets::schema_catalogue(&self.sheet_pmi_view_ids())
292    }
293
294    /// The `__brepSheets` automation blob: the block, which sheet is open,
295    /// which object's form is open, and the OPEN sheet's projection.
296    ///
297    /// Assembled rather than `json!`-built, because the projection is the big
298    /// part and it is already serialised in the cache: everything this adds is
299    /// O(sheets), never O(model). The registry publishes it every frame.
300    pub fn sheet_state_json(&mut self) -> String {
301        let state = self.sheet_state();
302        let drawing = match self.sheet_open.clone() {
303            Some(id) if self.sheet_project(&id) => self
304                .sheet_cache
305                .as_ref()
306                .map(|cache| cache.drawing_json.as_str())
307                .unwrap_or("null"),
308            _ => "null",
309        };
310        let sheets = serde_json::to_string(&state.sheets).unwrap_or_else(|_| "[]".into());
311        let open = serde_json::to_string(&self.sheet_open).unwrap_or_else(|_| "null".into());
312        let object = serde_json::to_string(&self.sheet_open_object).unwrap_or_else(|_| "null".into());
313        let selected = serde_json::to_string(&self.sheet_selected_object()).unwrap_or_else(|_| "null".into());
314        format!(
315            "{{\"sheets\":{sheets},\"idCounter\":{},\"openSheet\":{open},\"openObject\":{object},\"selectedObject\":{selected},\"drawing\":{drawing}}}",
316            state.id_counter
317        )
318    }
319
320    // --- the projection ------------------------------------------------------
321
322    /// Project sheet `id` into the cache unless it is already there. `false`
323    /// when the block carries no such sheet.
324    ///
325    /// The pair with [`Self::sheet_drawing_cached`] is what keeps the sheet
326    /// viewport off a per-frame COPY of the drawing: the lookup needs `&mut`
327    /// (it may project), the paint needs only `&`, and a caller that wants
328    /// both at once used to satisfy the borrow by cloning the whole projection
329    /// every frame.
330    pub fn sheet_project(&mut self, id: &str) -> bool {
331        let state = self.sheet_state();
332        let Some(sheet) = state.find_sheet(id).cloned() else {
333            return false;
334        };
335        // The cache key is the sheet, the run the scene came from, the
336        // DOCUMENT's revision — every door that writes the saved document
337        // bumps that counter, so no edit can miss it — which bodies are
338        // SHOWN, and the title block's stamp. A camera orbit touches none of
339        // them.
340        let context = self.sheet_frame_context();
341        self.request_sheet_topology(&sheet);
342        self.pump_sheet_lines();
343        let key = self.sheet_cache_key(id, &context);
344        if self.sheet_cache.as_ref().is_none_or(|cache| cache.key != key || cache.reproject) {
345            let pmi = self.pmi_state();
346            // Every exact pass the cache does not hold goes to the RUNNER; the
347            // placement draws its mesh approximation until the answer lands
348            // and moves the key. An inline runner answers inside the call.
349            let env = self.sheet_env();
350            let mut complete = true;
351            let (runner, work) = (&mut self.runner, &mut self.sheet_lines);
352            let mut defer = |job: LinesJob| {
353                let answer = work.defer(runner.as_mut(), job);
354                complete &= !matches!(answer, Deferred::Pending);
355                answer
356            };
357            let model = Model { scene: &self.scene, unposed: &self.pmi_explode_originals, env: &env };
358            let mut drawing = project_sheet_with(
359                &model,
360                &sheet,
361                &pmi.views,
362                self.pmi_report.as_ref(),
363                &context,
364                &mut LinesMode::Defer(&mut defer),
365            );
366            self.draw_sheet_bom(&sheet, &mut drawing);
367            let drawing_json = serde_json::to_string(&drawing).unwrap_or_else(|_| "null".into());
368            // An answer that landed DURING the projection moved the key. The
369            // drawing is stored under the key it ends on, so this frame's
370            // lookup finds it and paints it. A complete drawing already holds
371            // the answer; an incomplete one may have drawn that placement
372            // before it landed, so the next call re-projects over it.
373            let landed = self.sheet_cache_key(id, &context);
374            let reproject = !complete && landed != key;
375            let key = landed;
376            self.sheet_cache = Some(SheetCache { key, drawing, drawing_json, complete, reproject });
377        }
378        true
379    }
380
381    /// The document's expression sheet, which a PMI dimension's tolerance may
382    /// be written in — what a sheet dimension that inherits it reads.
383    fn sheet_env(&self) -> brep_kernel::Env {
384        brep_kernel::Env::build(&self.history.expressions(), &self.history.configurator()).unwrap_or_default()
385    }
386
387    /// Land every sheet pass the runner has answered (see [`SheetLinesWork`]).
388    pub fn pump_sheet_lines(&mut self) {
389        self.sheet_lines.pump(self.runner.as_mut());
390    }
391
392    /// Whether a sheet's exact pass is on the runner or waiting for it — the
393    /// frame loop and the idle contract wait on it like a topology request.
394    pub fn sheet_lines_pending(&self) -> bool {
395        self.sheet_lines.pending()
396    }
397
398    /// Ask the runner for the topology of every solid `sheet` draws that the
399    /// scene lacks (see [`Self::request_exact_solids`]). Until it lands the
400    /// placement is drawn by the mesh approximation and says so; the arrival
401    /// bumps the scene's exact revision, which is in the cache key.
402    fn request_sheet_topology(&mut self, sheet: &crate::sheets::Sheet) {
403        // The per-frame case: every kernel solid already held or asked for.
404        let lacking = self.scene.solids().iter().any(|solid| {
405            solid.source_handle != 0
406                && !solid.is_sketch
407                && !self.topology_asked.contains(&solid.source_handle)
408                && self.scene.exact_solid(&solid.name).is_none()
409        });
410        if !lacking {
411            self.pump_topology();
412            return;
413        }
414        let pmi = self.pmi_state();
415        let names = crate::sheets::project::sheet_solid_names(&self.scene, sheet, &pmi.views);
416        self.request_exact_solids(names.iter().map(String::as_str));
417    }
418
419    /// What the title block prints beside the sheet's own fields: the document's
420    /// name and today's date.
421    fn sheet_frame_context(&self) -> FrameContext {
422        FrameContext { document: self.document_name.clone(), date: today_iso() }
423    }
424
425    fn sheet_cache_key(
426        &self,
427        id: &str,
428        context: &FrameContext,
429    ) -> (String, u64, u64, u64, u64, u64, String) {
430        (
431            id.to_string(),
432            self.applied_generation,
433            self.history.revision(),
434            self.scene.exact_revision(),
435            // A placement draws the solids VISIBLE in the scene, and hiding one
436            // is session state: it writes no history, runs nothing and lands no
437            // exact solid, so without this term every term of the key stood
438            // still while the drawing's content changed.
439            self.scene.visibility_revision(),
440            self.sheet_lines.revision,
441            format!("{}\u{1f}{}", context.document, context.date),
442        )
443    }
444
445    /// The CACHED projection of sheet `id`, without projecting: `None` unless
446    /// [`Self::sheet_project`] put that sheet in the cache and nothing has
447    /// invalidated it since (the whole key is checked, not just the id).
448    pub fn sheet_drawing_cached(&self, id: &str) -> Option<&SheetDrawing> {
449        let key = self.sheet_cache_key(id, &self.sheet_frame_context());
450        self.sheet_cache
451            .as_ref()
452            .filter(|cache| cache.key == key)
453            .map(|cache| &cache.drawing)
454    }
455
456    /// The projected sheet `id`, built on demand and cached until the model
457    /// rebuilds or the block changes. `None` when the block carries no such
458    /// sheet. The one-call door for an export; a PAINT uses the split pair
459    /// above.
460    pub fn sheet_drawing(&mut self, id: &str) -> Option<&SheetDrawing> {
461        if !self.sheet_project(id) {
462            return None;
463        }
464        self.sheet_cache.as_ref().map(|cache| &cache.drawing)
465    }
466
467    /// Sheet `id` as SVG — one of the sheet export's two output contracts.
468    pub fn export_sheet_svg(&mut self, id: &str) -> Result<String, String> {
469        self.plugin_export_ready()?;
470        let id = self.resolve_sheet(id)?;
471        let drawing = self
472            .sheet_drawing_exact(&id)
473            .ok_or_else(|| format!("no sheet '{id}'"))?;
474        Ok(crate::sheets::svg::to_svg(drawing))
475    }
476
477    /// [`Self::sheet_drawing`] with every exact pass it lacks run HERE: a file
478    /// is never written from a mesh approximation that only stood in while a
479    /// pass was on the runner. The finished drawing replaces the cached one,
480    /// so the viewport shows what was exported.
481    pub fn sheet_drawing_exact(&mut self, id: &str) -> Option<&SheetDrawing> {
482        if !self.sheet_project(id) {
483            return None;
484        }
485        if self.sheet_cache.as_ref().is_some_and(|cache| !cache.complete) {
486            let state = self.sheet_state();
487            let sheet = state.find_sheet(id)?;
488            let pmi = self.pmi_state();
489            let env = self.sheet_env();
490            let model = Model { scene: &self.scene, unposed: &self.pmi_explode_originals, env: &env };
491            let mut drawing = project_sheet_with(
492                &model,
493                sheet,
494                &pmi.views,
495                self.pmi_report.as_ref(),
496                &self.sheet_frame_context(),
497                &mut LinesMode::Compute,
498            );
499            self.draw_sheet_bom(&sheet, &mut drawing);
500            let drawing_json = serde_json::to_string(&drawing).unwrap_or_else(|_| "null".into());
501            if let Some(cache) = self.sheet_cache.as_mut() {
502                cache.drawing = drawing;
503                cache.drawing_json = drawing_json;
504                cache.complete = true;
505                cache.reproject = false;
506            }
507        }
508        self.sheet_cache.as_ref().map(|cache| &cache.drawing)
509    }
510
511    /// Sheet `id` as a one-page PDF titled with the sheet's name — the
512    /// one-page case of [`Self::export_sheets_pdf`]. What the PDF cannot carry
513    /// that the SVG does is written down in [`crate::sheets::pdf`].
514    pub fn export_sheet_pdf(&mut self, id: &str) -> Result<Vec<u8>, String> {
515        let id = self.resolve_sheet(id)?;
516        let name = self.sheet_state().find_sheet(&id).map(|sheet| sheet.name.clone()).unwrap_or_default();
517        self.export_sheets_pdf(&[id], &name)
518    }
519
520    /// EVERY sheet of the document as one PDF, in the block's sheet order —
521    /// one page per sheet, each at its own paper size — titled with the
522    /// document's name (the first sheet's when the document has none). This is
523    /// what the export doors write; the SVG stays one file per sheet. The plain
524    /// 2D drawing set: a placement's `threeD` is the 3D export's alone.
525    pub fn export_document_pdf(&mut self) -> Result<Vec<u8>, String> {
526        self.export_document_pdf_with(false)
527    }
528
529    /// The same drawing set with the live 3D model ([`crate::sheets::pdf3d`]):
530    /// a 3D box over every placement marked `threeD`, and a last page given
531    /// over to the model with a button per saved PMI view — Sheets + 3D (PDF).
532    pub fn export_document_pdf_3d(&mut self) -> Result<Vec<u8>, String> {
533        self.export_document_pdf_with(true)
534    }
535
536    fn export_document_pdf_with(&mut self, three_d: bool) -> Result<Vec<u8>, String> {
537        let state = self.sheet_state();
538        if state.sheets.is_empty() {
539            return Err("the document has no sheets".into());
540        }
541        let ids: Vec<String> = state.sheets.iter().map(|sheet| sheet.id.clone()).collect();
542        let title = if self.document_name.trim().is_empty() {
543            state.sheets[0].name.clone()
544        } else {
545            self.document_name.clone()
546        };
547        self.export_sheets_pdf_with(&ids, &title, three_d)
548    }
549
550    /// Sheets `ids` as one PDF, one page each in the order given.
551    ///
552    /// The projection cache holds ONE sheet, the one on screen, and an export
553    /// must not evict it: a sheet the cache already holds is read from it, and
554    /// every other is projected for this export alone and dropped, so closing
555    /// the export leaves the viewport exactly as warm as it was.
556    pub fn export_sheets_pdf(&mut self, ids: &[String], title: &str) -> Result<Vec<u8>, String> {
557        self.export_sheets_pdf_with(ids, title, false)
558    }
559
560    /// [`Self::export_sheets_pdf`], with the 3D model when `three_d`: over
561    /// every placement marked `threeD`, and on a last page. Without it the
562    /// file is [`crate::sheets::pdf::write_pdf`]'s, byte for byte, whatever
563    /// the placements say.
564    pub fn export_sheets_pdf_with(&mut self, ids: &[String], title: &str, three_d: bool) -> Result<Vec<u8>, String> {
565        self.plugin_export_ready()?;
566        let state = self.sheet_state();
567        for id in ids {
568            if let Some(sheet) = state.find_sheet(id) {
569                self.request_sheet_topology(sheet);
570            }
571        }
572        let context = self.sheet_frame_context();
573        let pmi = self.pmi_state();
574        let mut drawings: Vec<SheetDrawing> = Vec::with_capacity(ids.len());
575        for id in ids {
576            let sheet = state.find_sheet(id).ok_or_else(|| format!("no sheet '{id}'"))?;
577            let complete = self.sheet_cache.as_ref().is_some_and(|cache| cache.complete);
578            let drawing = match self.sheet_drawing_cached(id).filter(|_| complete) {
579                Some(cached) => cached.clone(),
580                None => {
581                    let mut drawing = project_sheet_with(
582                        &Model { scene: &self.scene, unposed: &self.pmi_explode_originals, env: &self.sheet_env() },
583                        sheet, &pmi.views, self.pmi_report.as_ref(), &context, &mut LinesMode::Compute,
584                    );
585                    self.draw_sheet_bom(sheet, &mut drawing);
586                    drawing
587                },
588            };
589            drawings.push(drawing);
590        }
591        let pages: Vec<&SheetDrawing> = drawings.iter().collect();
592        if !three_d {
593            return Ok(crate::sheets::pdf::write_pdf(title, &pages));
594        }
595        let three_d = self.pdf3d_content(ids, &drawings, title)?;
596        Ok(crate::sheets::pdf::write_pdf_3d(title, &pages, Some(&three_d)))
597    }
598
599    /// The 3D content of a sheet PDF: the model, a live box over each `threeD`
600    /// placement that projected (one that did not — no camera, a perspective
601    /// view — has no drawing to stand over and is left 2D), and the 3D page.
602    fn pdf3d_content(
603        &self,
604        ids: &[String],
605        drawings: &[SheetDrawing],
606        title: &str,
607    ) -> Result<crate::sheets::pdf3d::Pdf3d, String> {
608        use crate::sheets::pdf3d::{self, Page3d, Placement3d};
609        let state = self.sheet_state();
610        let pmi = self.pmi_state();
611        let bodies = self.pdf3d_bodies();
612        let model = pdf3d::build(&bodies, &pmi, self.pmi_report.as_ref())?;
613        // The live box stands over the drawing with a margin, centred on the
614        // camera target's point so the model turns about the box's centre.
615        const MARGIN_MM: f64 = 4.0;
616        let mut placements = Vec::new();
617        for (page, (id, drawing)) in ids.iter().zip(drawings).enumerate() {
618            let Some(sheet) = state.find_sheet(id) else { continue };
619            for placed in sheet.views.iter().filter(|placed| placed.three_d) {
620                let Some(view) = drawing.views.iter().find(|view| view.id == placed.id) else { continue };
621                if !view.error.is_empty() || model.view(&placed.view).is_none() {
622                    continue;
623                }
624                let [x0, y0, x1, y1] = view.bounds;
625                let p = placed.position;
626                let half = [
627                    ((p[0] - x0).max(x1 - p[0]) + MARGIN_MM).max(10.0),
628                    ((p[1] - y0).max(y1 - p[1]) + MARGIN_MM).max(10.0),
629                ];
630                placements.push(Placement3d {
631                    page,
632                    id: placed.id.clone(),
633                    view: placed.view.clone(),
634                    centre_mm: p,
635                    half_mm: half,
636                    scale: placed.scale,
637                });
638            }
639        }
640        let page = Some({
641            let (w, h) = state
642                .find_sheet(ids.first().map(String::as_str).unwrap_or(""))
643                .map(|sheet| sheet.millimetres())
644                .unwrap_or((297.0, 210.0));
645            let mut page = Page3d::layout(w.max(h), w.min(h), title, &model);
646            if let Some((poster, scale)) = self.pdf3d_poster(&model, &page, &pmi) {
647                page.poster = Some(poster);
648                page.poster_scale = Some(scale);
649            }
650            page
651        });
652        Ok(pdf3d::Pdf3d { model, placements, page })
653    }
654
655    /// The 3D page's drawing of its default view: the view projected as a
656    /// sheet placement at the centre of the 3D box, scaled to fill it — and
657    /// the scale, which the box then opens at. A perspective view is drawn
658    /// through the orthographic camera that frames its target alike (a sheet
659    /// draws orthographic views only); `None` when the view does not project.
660    fn pdf3d_poster(
661        &self,
662        model: &crate::sheets::pdf3d::Model3d,
663        page: &crate::sheets::pdf3d::Page3d,
664        pmi: &brep_kernel::PmiState,
665    ) -> Option<(crate::sheets::project::ViewDrawing, f64)> {
666        let default = model.views.get(page.default_view)?;
667        let mut camera = default.camera.clone();
668        if let brep_kernel::PmiProjection::Perspective { fov_y_deg } = camera.projection {
669            let distance = {
670                let d = [camera.target[0] - camera.eye[0], camera.target[1] - camera.eye[1], camera.target[2] - camera.eye[2]];
671                (d[0] * d[0] + d[1] * d[1] + d[2] * d[2]).sqrt()
672            };
673            camera.projection = brep_kernel::PmiProjection::Orthographic {
674                half_height: distance * (fov_y_deg.to_radians() * 0.5).tan(),
675            };
676        }
677        let mut views = pmi.views.clone();
678        match views.iter_mut().find(|view| view.id == default.id) {
679            Some(view) => view.camera = Some(camera),
680            None => views.push(brep_kernel::PmiView {
681                id: default.id.clone(),
682                name: default.name.clone(),
683                camera: Some(camera),
684                display: Default::default(),
685                annotations: Vec::new(),
686            }),
687        }
688        let centre = page.box_centre_mm();
689        let half = page.box_half_mm();
690        let project = |scale: f64| -> Option<crate::sheets::project::ViewDrawing> {
691            let mut sheet: crate::sheets::Sheet = serde_json::from_value(serde_json::json!({
692                "id": "MODEL3D",
693                "size": crate::sheets::CUSTOM,
694                "widthMm": page.width_mm,
695                "heightMm": page.height_mm,
696                "border": false,
697                "titleBlock": false,
698            }))
699            .ok()?;
700            sheet.views.push(PlacedView {
701                id: "MODEL3D".into(),
702                view: default.id.clone(),
703                position: centre,
704                scale,
705                projection: ORTHOGRAPHIC.to_string(),
706                flatten_text: true,
707                three_d: false,
708                section: None,
709                detail: None,
710            });
711            let drawing = project_sheet_with(
712                &Model { scene: &self.scene, unposed: &self.pmi_explode_originals, env: &self.sheet_env() },
713                &sheet,
714                &views,
715                self.pmi_report.as_ref(),
716                &self.sheet_frame_context(),
717                &mut LinesMode::Compute,
718            );
719            drawing.views.into_iter().next().filter(|view| view.error.is_empty())
720        };
721        // Once at unit scale to measure, once at the scale that fills the box.
722        let probe = project(1.0)?;
723        let [x0, y0, x1, y1] = probe.bounds;
724        let reach = [(centre[0] - x0).max(x1 - centre[0]), (centre[1] - y0).max(y1 - centre[1])];
725        if !(reach[0] > 1e-9 && reach[1] > 1e-9) {
726            return None;
727        }
728        let scale = (half[0] / reach[0]).min(half[1] / reach[1]) * 0.9;
729        project(scale).map(|drawing| (drawing, scale))
730    }
731
732    /// The sheet an export writes: the named one, else the OPEN one, else the
733    /// first in the block.
734    pub fn resolve_sheet(&self, id: &str) -> Result<String, String> {
735        let state = self.sheet_state();
736        let id = id.trim();
737        if !id.is_empty() {
738            return state
739                .find_sheet(id)
740                .map(|sheet| sheet.id.clone())
741                .ok_or_else(|| format!("no sheet '{id}'"));
742        }
743        if let Some(open) = self.sheet_open.as_ref().filter(|open| state.find_sheet(open).is_some()) {
744            return Ok(open.clone());
745        }
746        state
747            .sheets
748            .first()
749            .map(|sheet| sheet.id.clone())
750            .ok_or_else(|| "the document has no sheets".to_string())
751    }
752
753    // --- block writes --------------------------------------------------------
754
755    /// Write `state` as the document's block (checkpointed). An empty block is
756    /// removed so a part that never had a sheet saves byte-identically.
757    fn write_sheet_state(&mut self, state: SheetState, coalesce_key: Option<&str>) {
758        let block = if state.is_empty() { None } else { serde_json::to_value(&state).ok() };
759        self.history.set_sheets_block(block, coalesce_key);
760        self.dirty = true;
761    }
762
763    // --- sheets ---------------------------------------------------------------
764
765    /// Add a sheet (`name` or `Sheet N`, the default paper size) and open it.
766    /// Returns the id.
767    pub fn sheet_add(&mut self, name: Option<&str>) -> String {
768        let mut state = self.sheet_state();
769        let id = state.next_id("SHEET");
770        let name = name
771            .map(str::trim)
772            .filter(|name| !name.is_empty())
773            .map(String::from)
774            .unwrap_or_else(|| format!("Sheet {}", state.sheets.len() + 1));
775        let (width_mm, height_mm) = crate::sheets::paper_size(DEFAULT_SIZE).unwrap_or((420.0, 297.0));
776        state.sheets.push(Sheet {
777            id: id.clone(),
778            name,
779            size: DEFAULT_SIZE.to_string(),
780            width_mm,
781            height_mm,
782            border: true,
783            border_inset_mm: DEFAULT_BORDER_INSET_MM,
784            title_block: true,
785            notes: String::new(),
786            views: Vec::new(),
787            dimensions: Vec::new(),
788            ordinates: Vec::new(),
789            revisions: Vec::new(),
790            bom_table: None,
791        });
792        self.write_sheet_state(state, None);
793        self.set_open_sheet(Some(id.clone()));
794        id
795    }
796
797    /// Insert a live BOM table on the open sheet and open its settings.
798    pub fn sheet_insert_bom(&mut self) -> Result<(), String> {
799        let id = self
800            .sheet_open()
801            .ok_or("open a drawing sheet first")?
802            .to_owned();
803        if self
804            .sheet_state()
805            .find_sheet(&id)
806            .and_then(|s| s.bom_table.as_ref())
807            .is_none()
808        {
809            self.sheet_update(
810                &id,
811                &serde_json::json!({ "bomTable": crate::sheets::BomTable::default() }).to_string(),
812            )?;
813        }
814        self.sheet_set_object_open(Some(&id));
815        Ok(())
816    }
817
818    /// Available built-in and custom attributes, in stable order.
819    pub fn sheet_bom_columns(&self) -> Vec<String> {
820        let mut columns = std::collections::BTreeSet::from([
821            "partName".to_owned(),
822            "occurrence.Quantity".to_owned(),
823            "occurrence.Find_Number".to_owned(),
824            "part.Part_Number".to_owned(),
825            "part.Description".to_owned(),
826            "part.Material".to_owned(),
827        ]);
828        for component in &self.assembly_components {
829            for (scope, attrs) in [
830                ("part", self.part_attributes(&component.part_name)),
831                ("occurrence", self.occurrence_attributes(&component.id)),
832            ] {
833                if let Some(attrs) = attrs.as_object() {
834                    columns.extend(attrs.keys().map(|key| format!("{scope}.{key}")));
835                }
836            }
837        }
838        columns.into_iter().collect()
839    }
840
841    fn draw_sheet_bom(&self, sheet: &Sheet, drawing: &mut SheetDrawing) {
842        let Some(table) = &sheet.bom_table else {
843            return;
844        };
845        let mut rows: std::collections::BTreeMap<Vec<String>, usize> =
846            std::collections::BTreeMap::new();
847        let occurrences = self.occurrence_attributes_all();
848        let mut parts = std::collections::HashMap::new();
849        for component in &self.assembly_components {
850            let part = parts
851                .entry(component.part_name.clone())
852                .or_insert_with(|| self.part_attributes(&component.part_name));
853            let occurrence = occurrences.get(&component.id).unwrap_or(&Value::Null);
854            let cells: Vec<String> = table
855                .columns
856                .iter()
857                .map(|column| {
858                    if column == "partName" {
859                        return component.part_name.clone();
860                    }
861                    if column == "quantity" || column == "occurrence.Quantity" {
862                        return String::new();
863                    }
864                    let (scope, key) = column.split_once('.').unwrap_or(("part", column));
865                    if column == "part.PMI" {
866                        return self.part_pmi_count(&component.part_name).to_string();
867                    }
868                    let value = if scope == "occurrence" {
869                        &occurrence[key]
870                    } else {
871                        &part[key]
872                    };
873                    match value {
874                        Value::Null => String::new(),
875                        Value::String(s) => s.clone(),
876                        v => v.to_string(),
877                    }
878                })
879                .collect();
880            // Include the part identity even when its name column is hidden.
881            let mut key = cells;
882            key.push(component.part_name.clone());
883            *rows.entry(key).or_default() += 1;
884        }
885        for wire in self.wire_bom_lines() {
886            let mut cells: Vec<String> = table
887                .columns
888                .iter()
889                .map(|column| match column.as_str() {
890                    "partName" | "part.Part_Number" => wire.stock_part_number.clone(),
891                    "occurrence.MF_QTY" => wire
892                        .mf_qty
893                        .map(|q| crate::formatting::compact_decimal(q, 3))
894                        .unwrap_or_else(|| wire.state.as_str().to_owned()),
895                    "occurrence.Reference_Designator" => wire.connection_id.clone(),
896                    _ => String::new(),
897                })
898                .collect();
899            cells.push(format!("wire:{}", wire.connection_id));
900            rows.insert(cells, 1);
901        }
902        let frame = drawing
903            .frame
904            .get_or_insert_with(|| crate::sheets::frame::FrameDrawing {
905                lines: Vec::new(),
906                texts: Vec::new(),
907                revision_table: None,
908            });
909        let [x, y] = table.position;
910        let width = table.column_width_mm;
911        let height = 7.;
912        let count = rows.len() + 1;
913        for row in 0..=count {
914            frame.lines.push(vec![
915                [x, y + row as f64 * height],
916                [
917                    x + table.columns.len() as f64 * width,
918                    y + row as f64 * height,
919                ],
920            ]);
921        }
922        for col in 0..=table.columns.len() {
923            frame.lines.push(vec![
924                [x + col as f64 * width, y],
925                [x + col as f64 * width, y + count as f64 * height],
926            ]);
927        }
928        let headers: Vec<String> = table
929            .columns
930            .iter()
931            .map(|c| match c.as_str() {
932                "partName" => "Part name".into(),
933                "quantity" => "Quantity".into(),
934                _ => c
935                    .split_once('.')
936                    .map_or(c.as_str(), |(_, key)| key)
937                    .replace('_', " "),
938            })
939            .collect();
940        let mut all = vec![headers];
941        for (mut cells, quantity) in rows {
942            cells.pop();
943            for (i, column) in table.columns.iter().enumerate() {
944                if column == "quantity" || column == "occurrence.Quantity" {
945                    cells[i] = quantity.to_string();
946                }
947            }
948            all.push(cells);
949        }
950        for (row, cells) in all.iter().enumerate() {
951            for (col, cell) in cells.iter().enumerate() {
952                let available = ((width - 2.) / (2.5 * 0.65)).floor() as usize;
953                let text = if cell.chars().count() > available {
954                    format!(
955                        "{}…",
956                        cell.chars()
957                            .take(available.saturating_sub(1))
958                            .collect::<String>()
959                    )
960                } else {
961                    cell.clone()
962                };
963                frame.texts.push(crate::sheets::project::SheetText::new(
964                    text,
965                    [
966                        x + (col as f64 + 0.5) * width,
967                        y + (row as f64 + 0.5) * height,
968                    ],
969                    [2.5, 0.],
970                    [0., -2.5],
971                ));
972            }
973        }
974    }
975
976    /// Apply an edited **Sheet** form.
977    pub fn sheet_update(&mut self, id: &str, params_json: &str) -> Result<(), String> {
978        let params: Value = serde_json::from_str(params_json)
979            .map_err(|error| format!("sheet params: {error}"))?;
980        let mut state = self.sheet_state();
981        let sheet = state.find_sheet_mut(id).ok_or_else(|| format!("no sheet '{id}'"))?;
982        sheet.apply(&params)?;
983        self.write_sheet_state(state, None);
984        Ok(())
985    }
986
987    // --- the revision table ---------------------------------------------------
988
989    /// Append a revision row to sheet `id` and return its index. Absent
990    /// fields are seeded the way a drawing office starts a row: the letter
991    /// after the last row's (`A` on an empty table, skipping `I`, `O` and `Q`
992    /// as a view letter does), today's date, and no description.
993    pub fn sheet_add_revision(
994        &mut self,
995        id: &str,
996        rev: Option<&str>,
997        date: Option<&str>,
998        description: Option<&str>,
999    ) -> Result<usize, String> {
1000        let mut state = self.sheet_state();
1001        let sheet = state.find_sheet_mut(id).ok_or_else(|| format!("no sheet '{id}'"))?;
1002        let next = || {
1003            let letters = crate::sheets::SectionCut::LETTERS;
1004            let last = sheet.revisions.last().and_then(|row| row.rev.trim().chars().next());
1005            match last.and_then(|c| letters.iter().position(|l| l.eq_ignore_ascii_case(&c))) {
1006                Some(at) => letters[(at + 1).min(letters.len() - 1)].to_string(),
1007                None if sheet.revisions.is_empty() => letters[0].to_string(),
1008                None => String::new(),
1009            }
1010        };
1011        let revision = crate::sheets::Revision {
1012            rev: rev.map(str::to_string).unwrap_or_else(next),
1013            date: date.map(str::to_string).unwrap_or_else(today_iso),
1014            description: description.unwrap_or_default().to_string(),
1015        };
1016        sheet.revisions.push(revision);
1017        let index = sheet.revisions.len() - 1;
1018        self.write_sheet_state(state, None);
1019        Ok(index)
1020    }
1021
1022    /// Patch revision row `index` of sheet `id` (`rev`, `date`,
1023    /// `description`; absent keys are unchanged).
1024    pub fn sheet_update_revision(&mut self, id: &str, index: usize, params_json: &str) -> Result<(), String> {
1025        let params: Value = serde_json::from_str(params_json)
1026            .map_err(|error| format!("revision params: {error}"))?;
1027        let mut state = self.sheet_state();
1028        let sheet = state.find_sheet_mut(id).ok_or_else(|| format!("no sheet '{id}'"))?;
1029        let count = sheet.revisions.len();
1030        let row = sheet
1031            .revisions
1032            .get_mut(index)
1033            .ok_or_else(|| format!("sheet '{id}' has {count} revision row(s); there is no row {index}"))?;
1034        row.apply(&params)?;
1035        self.write_sheet_state(state, None);
1036        Ok(())
1037    }
1038
1039    /// Remove revision row `index` of sheet `id`. The rows after it move up
1040    /// one and keep their order.
1041    pub fn sheet_remove_revision(&mut self, id: &str, index: usize) -> Result<(), String> {
1042        let mut state = self.sheet_state();
1043        let sheet = state.find_sheet_mut(id).ok_or_else(|| format!("no sheet '{id}'"))?;
1044        if index >= sheet.revisions.len() {
1045            return Err(format!(
1046                "sheet '{id}' has {} revision row(s); there is no row {index}",
1047                sheet.revisions.len()
1048            ));
1049        }
1050        sheet.revisions.remove(index);
1051        self.write_sheet_state(state, None);
1052        Ok(())
1053    }
1054
1055    pub fn sheet_delete(&mut self, id: &str) -> Result<(), String> {
1056        let mut state = self.sheet_state();
1057        let before = state.sheets.len();
1058        state.sheets.retain(|sheet| sheet.id != id);
1059        if state.sheets.len() == before {
1060            return Err(format!("no sheet '{id}'"));
1061        }
1062        if self.sheet_open.as_deref() == Some(id) {
1063            self.set_open_sheet(state.sheets.first().map(|sheet| sheet.id.clone()));
1064        }
1065        self.clear_open_object(&state);
1066        self.write_sheet_state(state, None);
1067        Ok(())
1068    }
1069
1070    /// Show sheet `id` in the sheet viewport (`None` returns to the 3D view).
1071    pub fn sheet_set_open(&mut self, id: Option<&str>) -> Result<(), String> {
1072        match id {
1073            None => {
1074                self.set_open_sheet(None);
1075                self.dirty = true;
1076                Ok(())
1077            }
1078            Some(id) => {
1079                let state = self.sheet_state();
1080                if state.find_sheet(id).is_none() {
1081                    return Err(format!("no sheet '{id}'"));
1082                }
1083                self.set_open_sheet(Some(id.to_string()));
1084                self.dirty = true;
1085                Ok(())
1086            }
1087        }
1088    }
1089
1090    /// Open a sheet or placed view's form in the Sheets pane (`None` closes).
1091    /// An open COUNTS ([`Self::open_sheet_object`]) — including a re-open of
1092    /// the object already open, which is a click on the same dimension on the
1093    /// paper while the Sheets pane sits behind another tab.
1094    pub fn sheet_set_object_open(&mut self, id: Option<&str>) {
1095        match id {
1096            Some(id) => self.open_sheet_object(id.to_string()),
1097            None => self.sheet_open_object = None,
1098        }
1099    }
1100
1101    // --- placed views ----------------------------------------------------------
1102
1103    /// Place PMI view `view_id` on sheet `sheet_id` (the open sheet when
1104    /// absent), centred on the paper unless `position` says otherwise, and
1105    /// open its form. Returns the placement id.
1106    pub fn sheet_place_view(
1107        &mut self,
1108        sheet_id: Option<&str>,
1109        view_id: &str,
1110        position: Option<[f64; 2]>,
1111        scale: Option<f64>,
1112    ) -> Result<String, String> {
1113        let pmi = self.pmi_state();
1114        let view = pmi
1115            .find_view(view_id)
1116            .ok_or_else(|| format!("no PMI view '{view_id}'"))?;
1117        let camera = view
1118            .camera
1119            .as_ref()
1120            .ok_or_else(|| format!("PMI view '{view_id}' has no captured camera — capture one first"))?;
1121        refuse_perspective(view_id, camera)?;
1122        let mut state = self.sheet_state();
1123        let sheet_id = match sheet_id.map(str::trim).filter(|id| !id.is_empty()) {
1124            Some(id) => id.to_string(),
1125            None => self.resolve_sheet("")?,
1126        };
1127        let id = state.next_id("SV");
1128        let sheet = state
1129            .find_sheet_mut(&sheet_id)
1130            .ok_or_else(|| format!("no sheet '{sheet_id}'"))?;
1131        let (width_mm, height_mm) = sheet.millimetres();
1132        let scale = match scale {
1133            Some(scale) if scale.is_finite() && scale > 0.0 => scale,
1134            Some(scale) => return Err(format!("scale: {scale} is not a positive ratio")),
1135            None => DEFAULT_SCALE,
1136        };
1137        sheet.views.push(PlacedView {
1138            id: id.clone(),
1139            view: view_id.to_string(),
1140            position: position.unwrap_or([width_mm * 0.5, height_mm * 0.5]),
1141            scale,
1142            projection: ORTHOGRAPHIC.to_string(),
1143            flatten_text: true,
1144            three_d: false,
1145            section: None,
1146            detail: None,
1147        });
1148        self.write_sheet_state(state, None);
1149        self.set_open_sheet(Some(sheet_id));
1150        self.open_sheet_object(id.clone());
1151        Ok(id)
1152    }
1153
1154    /// Apply an edited **Placed view** form.
1155    pub fn sheet_update_view(&mut self, id: &str, params_json: &str) -> Result<(), String> {
1156        let mut params: Value = serde_json::from_str(params_json)
1157            .map_err(|error| format!("placed view params: {error}"))?;
1158        // A section's or a detail's PICKED references — the form's reference
1159        // rows — go through their own door, which checks them against the
1160        // sheet; and only when they changed, so an unrelated edit (the letter,
1161        // the scale) does not re-write the construction.
1162        if let Some(object) = params.as_object_mut() {
1163            let current = self.sheet_state().sheets.iter().find_map(|sheet| sheet.find_view(id).cloned());
1164            for field in ["cut", "centre", "rim"] {
1165                let Some(value) = object.remove(field) else { continue };
1166                // A single reference row hands back a string, a list row an array.
1167                let names: Vec<String> = match value.as_str() {
1168                    Some(one) => vec![one.to_string()],
1169                    None => crate::json_support::string_values(Some(&value)).map(str::to_string).collect(),
1170                }
1171                .into_iter()
1172                .filter(|name| !name.is_empty())
1173                .collect();
1174                let now = current.as_ref().map(|placed| placed.picked(field)).unwrap_or_default();
1175                if names != now {
1176                    self.sheet_set_placement_refs(id, field, &names)?;
1177                }
1178            }
1179        }
1180        // Re-pointing a placement at another saved view is a PLACEMENT of that
1181        // view, and refused on the same terms as the first one.
1182        if let Some(view_id) = params.get("view").and_then(Value::as_str).map(str::trim).filter(|id| !id.is_empty()) {
1183            if let Some(camera) = self.pmi_state().find_view(view_id).and_then(|view| view.camera.clone()) {
1184                refuse_perspective(view_id, &camera)?;
1185            }
1186        }
1187        let views = self.sheet_pmi_view_ids();
1188        let mut state = self.sheet_state();
1189        let view = state.find_view_mut(id).ok_or_else(|| format!("no placed view '{id}'"))?;
1190        view.apply(&params, &views)?;
1191        self.write_sheet_state(state, None);
1192        Ok(())
1193    }
1194
1195    /// Move a placed view to `position` (paper millimetres). `coalesce` groups
1196    /// a drag into ONE undo step, the PMI label drag's rule.
1197    pub fn sheet_move_view(&mut self, id: &str, position: [f64; 2], coalesce: bool) -> Result<(), String> {
1198        if !position[0].is_finite() || !position[1].is_finite() {
1199            return Err("position: not a finite point".into());
1200        }
1201        let mut state = self.sheet_state();
1202        let view = state.find_view_mut(id).ok_or_else(|| format!("no placed view '{id}'"))?;
1203        view.position = position;
1204        let key = format!("sheet:view:{id}");
1205        self.write_sheet_state(state, coalesce.then_some(key.as_str()));
1206        Ok(())
1207    }
1208
1209    /// End a placement drag: the next move starts a fresh undo entry.
1210    pub fn sheet_move_view_end(&mut self) {
1211        self.history.break_coalescing();
1212    }
1213
1214    pub fn sheet_remove_view(&mut self, id: &str) -> Result<(), String> {
1215        let mut state = self.sheet_state();
1216        let Some((sheet_id, index)) = state.locate_view(id) else {
1217            return Err(format!("no placed view '{id}'"));
1218        };
1219        if let Some(sheet) = state.find_sheet_mut(&sheet_id) {
1220            sheet.views.remove(index);
1221        }
1222        self.clear_open_object(&state);
1223        self.write_sheet_state(state, None);
1224        Ok(())
1225    }
1226
1227    /// Close the open form when the object it showed is gone.
1228    fn clear_open_object(&mut self, state: &SheetState) {
1229        if let Some(open) = self.sheet_open_object.clone() {
1230            if state.find_sheet(&open).is_none()
1231                && state.locate_view(&open).is_none()
1232                && state.locate_dimension(&open).is_none()
1233                && state.locate_ordinate(&open).is_none()
1234            {
1235                self.sheet_open_object = None;
1236            }
1237        }
1238    }
1239
1240    // --- sheet dimensions ------------------------------------------------------
1241
1242    /// Add a dimension to sheet `sheet_id` (the open sheet when absent) and
1243    /// open its form. `anchors` are reference strings; an EMPTY list is
1244    /// allowed and gives an unresolved dimension whose form is where its
1245    /// anchors get picked.
1246    pub fn sheet_add_dimension(
1247        &mut self,
1248        sheet_id: Option<&str>,
1249        kind: &str,
1250        alignment: Option<&str>,
1251        anchors: Vec<String>,
1252    ) -> Result<String, String> {
1253        let kind = KINDS
1254            .iter()
1255            .find(|known| known.eq_ignore_ascii_case(kind.trim()))
1256            .ok_or_else(|| format!("no dimension kind '{kind}'"))?;
1257        let alignment = match alignment.map(str::trim).filter(|a| !a.is_empty()) {
1258            None => ALIGNED,
1259            Some(wanted) => ALIGNMENTS
1260                .iter()
1261                .copied()
1262                .find(|known| known.eq_ignore_ascii_case(wanted))
1263                .ok_or_else(|| format!("no alignment '{wanted}'"))?,
1264        };
1265        for anchor in &anchors {
1266            SheetAnchor::parse(anchor)?;
1267        }
1268        let sheet_id = match sheet_id.map(str::trim).filter(|id| !id.is_empty()) {
1269            Some(id) => id.to_string(),
1270            None => self.resolve_sheet("")?,
1271        };
1272        let mut state = self.sheet_state();
1273        let id = state.next_id("SD");
1274        let sheet = state
1275            .find_sheet_mut(&sheet_id)
1276            .ok_or_else(|| format!("no sheet '{sheet_id}'"))?;
1277        sheet
1278            .dimensions
1279            .push(SheetDimension::new(id.clone(), kind, alignment, anchors));
1280        self.write_sheet_state(state, None);
1281        self.set_open_sheet(Some(sheet_id));
1282        self.open_sheet_object(id.clone());
1283        Ok(id)
1284    }
1285
1286    /// Apply an edited **Sheet dimension** form.
1287    pub fn sheet_update_dimension(&mut self, id: &str, params_json: &str) -> Result<(), String> {
1288        let params: Value = serde_json::from_str(params_json)
1289            .map_err(|error| format!("sheet dimension params: {error}"))?;
1290        let mut state = self.sheet_state();
1291        let dimension = state
1292            .find_dimension_mut(id)
1293            .ok_or_else(|| format!("no sheet dimension '{id}'"))?;
1294        dimension.apply(&params)?;
1295        self.write_sheet_state(state, None);
1296        Ok(())
1297    }
1298
1299    /// Move a dimension's line: `offset_mm` from its anchors, signed. What
1300    /// dragging its value box in the sheet viewport does, so `coalesce` groups
1301    /// a drag into ONE undo step exactly as a placement drag does.
1302    pub fn sheet_move_dimension(
1303        &mut self,
1304        id: &str,
1305        offset_mm: f64,
1306        coalesce: bool,
1307    ) -> Result<(), String> {
1308        if !offset_mm.is_finite() {
1309            return Err(format!("offsetMm: {offset_mm} is not a length"));
1310        }
1311        let mut state = self.sheet_state();
1312        let dimension = state
1313            .find_dimension_mut(id)
1314            .ok_or_else(|| format!("no sheet dimension '{id}'"))?;
1315        dimension.offset_mm = offset_mm;
1316        let key = format!("sheet:dim:{id}");
1317        self.write_sheet_state(state, coalesce.then_some(key.as_str()));
1318        Ok(())
1319    }
1320
1321    /// End an offset drag: the next move starts a fresh undo entry.
1322    pub fn sheet_move_dimension_end(&mut self) {
1323        self.history.break_coalescing();
1324    }
1325
1326    pub fn sheet_remove_dimension(&mut self, id: &str) -> Result<(), String> {
1327        let mut state = self.sheet_state();
1328        let Some((sheet_id, index)) = state.locate_dimension(id) else {
1329            return Err(format!("no sheet dimension '{id}'"));
1330        };
1331        if let Some(sheet) = state.find_sheet_mut(&sheet_id) {
1332            sheet.dimensions.remove(index);
1333        }
1334        self.clear_open_object(&state);
1335        self.write_sheet_state(state, None);
1336        Ok(())
1337    }
1338
1339    // --- ordinate sets ------------------------------------------------------
1340
1341    /// Add an ordinate set to sheet `sheet_id` (the open sheet when absent)
1342    /// and open its form. `datum` may be empty: the set is then unresolved and
1343    /// its form is where the datum gets picked.
1344    pub fn sheet_add_ordinate(
1345        &mut self,
1346        sheet_id: Option<&str>,
1347        axis: &str,
1348        datum: Option<&str>,
1349        members: Vec<String>,
1350    ) -> Result<String, String> {
1351        let axis = AXES
1352            .iter()
1353            .find(|known| known.eq_ignore_ascii_case(axis.trim()))
1354            .ok_or_else(|| format!("no ordinate axis '{axis}'"))?;
1355        let datum = datum.map(str::trim).filter(|d| !d.is_empty()).unwrap_or_default();
1356        if !datum.is_empty() {
1357            SheetAnchor::parse(datum)?;
1358        }
1359        for member in &members {
1360            SheetAnchor::parse(member)?;
1361        }
1362        let sheet_id = match sheet_id.map(str::trim).filter(|id| !id.is_empty()) {
1363            Some(id) => id.to_string(),
1364            None => self.resolve_sheet("")?,
1365        };
1366        let mut state = self.sheet_state();
1367        let id = state.next_id("OD");
1368        let sheet = state
1369            .find_sheet_mut(&sheet_id)
1370            .ok_or_else(|| format!("no sheet '{sheet_id}'"))?;
1371        let mut set = SheetOrdinate::new(id.clone(), axis, datum.to_string());
1372        set.members = members;
1373        sheet.ordinates.push(set);
1374        self.write_sheet_state(state, None);
1375        self.set_open_sheet(Some(sheet_id));
1376        self.open_sheet_object(id.clone());
1377        Ok(id)
1378    }
1379
1380    /// Apply an edited **Ordinate set** form.
1381    ///
1382    /// A set with no DATUM is kept, unresolved: it is what the Ordinate set
1383    /// button creates, and its form is where the datum gets picked. Every value
1384    /// it would draw is a distance from that datum, so until there is one the
1385    /// set draws its id and the reason instead.
1386    pub fn sheet_update_ordinate(&mut self, id: &str, params_json: &str) -> Result<(), String> {
1387        let params: Value = serde_json::from_str(params_json)
1388            .map_err(|error| format!("ordinate set params: {error}"))?;
1389        let mut state = self.sheet_state();
1390        let set = state
1391            .find_ordinate_mut(id)
1392            .ok_or_else(|| format!("no ordinate set '{id}'"))?;
1393        set.apply(&params)?;
1394        self.write_sheet_state(state, None);
1395        Ok(())
1396    }
1397
1398    /// Move a set's BASELINE: its signed distance from the datum in paper
1399    /// millimetres. One number for the whole set, so `coalesce` groups a drag
1400    /// into one undo step exactly as a dimension's offset drag does.
1401    pub fn sheet_move_ordinate(
1402        &mut self,
1403        id: &str,
1404        offset_mm: f64,
1405        coalesce: bool,
1406    ) -> Result<(), String> {
1407        if !offset_mm.is_finite() {
1408            return Err(format!("offsetMm: {offset_mm} is not a length"));
1409        }
1410        let mut state = self.sheet_state();
1411        let set = state
1412            .find_ordinate_mut(id)
1413            .ok_or_else(|| format!("no ordinate set '{id}'"))?;
1414        set.offset_mm = offset_mm;
1415        let key = format!("sheet:ord:{id}");
1416        self.write_sheet_state(state, coalesce.then_some(key.as_str()));
1417        Ok(())
1418    }
1419
1420    /// End a baseline drag: the next move starts a fresh undo entry.
1421    pub fn sheet_move_ordinate_end(&mut self) {
1422        self.history.break_coalescing();
1423    }
1424
1425    pub fn sheet_remove_ordinate(&mut self, id: &str) -> Result<(), String> {
1426        let mut state = self.sheet_state();
1427        let Some((sheet_id, index)) = state.locate_ordinate(id) else {
1428            return Err(format!("no ordinate set '{id}'"));
1429        };
1430        if let Some(sheet) = state.find_sheet_mut(&sheet_id) {
1431            sheet.ordinates.remove(index);
1432        }
1433        self.clear_open_object(&state);
1434        self.write_sheet_state(state, None);
1435        Ok(())
1436    }
1437
1438    /// The baseline offset that would put set `id`'s baseline through the
1439    /// paper point `at`. `None` when the set has no datum to measure from.
1440    ///
1441    /// Unlike a dimension's, this needs no probe: a set's offset runs along
1442    /// ONE known paper axis — down the page for a horizontal set, across it
1443    /// for a vertical one — and the datum's own anchor point is the base.
1444    pub fn sheet_ordinate_offset_at(&mut self, id: &str, at: [f64; 2]) -> Option<f64> {
1445        let sheet_id = self.sheet_state().locate_ordinate(id).map(|(sheet, _)| sheet)?;
1446        let vertical = self
1447            .sheet_state()
1448            .find_ordinate(id)?
1449            .axis
1450            .eq_ignore_ascii_case(crate::sheets::dimension::VERTICAL);
1451        let drawing = self.sheet_drawing(&sheet_id)?;
1452        let drawn = drawing.ordinates.iter().find(|o| o.id == id)?;
1453        if !drawn.error.is_empty() {
1454            return None;
1455        }
1456        let datum = drawn.stations.iter().find(|station| station.datum)?;
1457        if !datum.error.is_empty() {
1458            return None;
1459        }
1460        Some(if vertical { at[0] - datum.at[0] } else { at[1] - datum.at[1] })
1461    }
1462
1463    // --- section views ------------------------------------------------------
1464
1465    /// Add a SECTION VIEW of `source`, cutting along the line through the two
1466    /// anchors, placed at `position` (beside the source when absent).
1467    ///
1468    /// The section places the SAME saved PMI view its source does — it needs
1469    /// that view's hidden-solid set and its text size — and overrides only the
1470    /// camera, which is derived from the cut rather than stored. Returns the
1471    /// placement id.
1472    pub fn sheet_add_section(
1473        &mut self,
1474        source: &str,
1475        from: &str,
1476        to: &str,
1477        position: Option<[f64; 2]>,
1478    ) -> Result<String, String> {
1479        SheetAnchor::parse(from)?;
1480        SheetAnchor::parse(to)?;
1481        if from == to {
1482            return Err("a cutting line needs two different anchors".into());
1483        }
1484        // Both ends belong to ONE placement: the line is drawn on that view
1485        // and it is that view's camera that turns a paper line into a plane.
1486        let (a, b) = (SheetAnchor::parse(from)?, SheetAnchor::parse(to)?);
1487        if a.view != b.view {
1488            return Err(format!(
1489                "'{from}' is on '{}' and '{to}' on '{}'; a cutting line is drawn on ONE view",
1490                a.view, b.view
1491            ));
1492        }
1493        if a.view != source {
1494            return Err(format!("the cutting line is on '{}', not on '{source}'", a.view));
1495        }
1496        let mut state = self.sheet_state();
1497        let Some((sheet_id, _)) = state.locate_view(source) else {
1498            return Err(format!("no placed view '{source}'"));
1499        };
1500        let (view, scale, at, label) = {
1501            let sheet = state
1502                .find_sheet(&sheet_id)
1503                .ok_or_else(|| format!("no sheet '{sheet_id}'"))?;
1504            let label = sheet.next_view_letter();
1505            let parent = sheet
1506                .find_view(source)
1507                .ok_or_else(|| format!("no placed view '{source}'"))?;
1508            if parent.section.is_some() {
1509                return Err(format!(
1510                    "'{source}' is itself a section; a section of a section is not drawn"
1511                ));
1512            }
1513            if parent.detail.is_some() {
1514                return Err(format!("'{source}' is a detail; a section of a detail is not drawn"));
1515            }
1516            (parent.view.clone(), parent.scale, parent.position, label)
1517        };
1518        let id = state.next_id("SV");
1519        let sheet = state.find_sheet_mut(&sheet_id).expect("just found");
1520        sheet.views.push(PlacedView {
1521            id: id.clone(),
1522            view,
1523            // Beside the source by default, a paper width apart, because a
1524            // section drawn on top of the view it cuts is unreadable and the
1525            // pick that made it had nowhere to say where it goes.
1526            position: position.unwrap_or([at[0] + 120.0, at[1]]),
1527            scale,
1528            projection: ORTHOGRAPHIC.to_string(),
1529            flatten_text: true,
1530            three_d: false,
1531            section: Some(SectionCut {
1532                source: source.to_string(),
1533                from: from.to_string(),
1534                to: to.to_string(),
1535                label,
1536                flip: false,
1537            }),
1538            detail: None,
1539        });
1540        self.write_sheet_state(state, None);
1541        self.set_open_sheet(Some(sheet_id));
1542        self.open_sheet_object(id.clone());
1543        Ok(id)
1544    }
1545
1546    /// Add a SECTION VIEW with no cutting line yet to sheet `sheet_id` (the
1547    /// open sheet when absent) and open its form — what the Drawing
1548    /// workbench's Section view button does. The placement is unresolved and
1549    /// says so on the paper; its form's **Cutting line** row is where the two
1550    /// anchors are picked ([`Self::sheet_set_placement_refs`]), and the first
1551    /// line picked decides which placement it is a section OF.
1552    pub fn sheet_new_section(&mut self, sheet_id: Option<&str>) -> Result<String, String> {
1553        self.sheet_new_derived(sheet_id, true)
1554    }
1555
1556    /// The detail twin of [`Self::sheet_new_section`]: a DETAIL VIEW with no
1557    /// circle yet, whose form's **Centre** and **Point on the rim** rows are
1558    /// picked on a placed view.
1559    pub fn sheet_new_detail(&mut self, sheet_id: Option<&str>) -> Result<String, String> {
1560        self.sheet_new_derived(sheet_id, false)
1561    }
1562
1563    fn sheet_new_derived(&mut self, sheet_id: Option<&str>, section: bool) -> Result<String, String> {
1564        let sheet_id = match sheet_id.map(str::trim).filter(|id| !id.is_empty()) {
1565            Some(id) => id.to_string(),
1566            None => self.resolve_sheet("")?,
1567        };
1568        let mut state = self.sheet_state();
1569        let id = state.next_id("SV");
1570        let sheet = state
1571            .find_sheet_mut(&sheet_id)
1572            .ok_or_else(|| format!("no sheet '{sheet_id}'"))?;
1573        let label = sheet.next_view_letter();
1574        let (w, h) = sheet.millimetres();
1575        sheet.views.push(PlacedView {
1576            id: id.clone(),
1577            // No saved view until the first pick names a source: a section or
1578            // a detail draws its SOURCE's view.
1579            view: String::new(),
1580            position: [w * 0.5, h * 0.5],
1581            scale: DEFAULT_SCALE,
1582            projection: ORTHOGRAPHIC.to_string(),
1583            flatten_text: true,
1584            three_d: false,
1585            section: section.then(|| SectionCut {
1586                source: String::new(),
1587                from: String::new(),
1588                to: String::new(),
1589                label: label.clone(),
1590                flip: false,
1591            }),
1592            detail: (!section).then(|| DetailCircle {
1593                source: String::new(),
1594                centre: String::new(),
1595                rim: String::new(),
1596                label,
1597            }),
1598        });
1599        self.write_sheet_state(state, None);
1600        self.set_open_sheet(Some(sheet_id));
1601        self.open_sheet_object(id.clone());
1602        Ok(id)
1603    }
1604
1605    /// Set a derived placement's PICKED references: `cut` (a section's two
1606    /// cutting-line anchors), `centre` or `rim` (a detail circle's). What a
1607    /// form's reference rows commit, the picker's Finish and a row's ✕ alike.
1608    ///
1609    /// The anchors must all lie on ONE plain placement of the same sheet — the
1610    /// line or circle is drawn on that view and read through its camera — and
1611    /// that placement becomes the source. A placement that has no saved view
1612    /// yet (made by [`Self::sheet_new_section`] / `_detail`) takes its
1613    /// source's view and scale — twice it, for a detail — and moves beside the
1614    /// source, where [`Self::sheet_add_section`] would have put it. Fewer
1615    /// anchors than the construction needs is allowed: the placement stays
1616    /// unresolved and says what is missing.
1617    pub fn sheet_set_placement_refs(&mut self, id: &str, field: &str, names: &[String]) -> Result<(), String> {
1618        for name in names {
1619            SheetAnchor::parse(name)?;
1620        }
1621        let mut state = self.sheet_state();
1622        let (sheet_id, _) = state.locate_view(id).ok_or_else(|| format!("no placed view '{id}'"))?;
1623        let sheet = state.find_sheet(&sheet_id).ok_or_else(|| format!("no sheet '{sheet_id}'"))?;
1624        let placed = sheet.find_view(id).ok_or_else(|| format!("no placed view '{id}'"))?.clone();
1625        let single = |names: &[String]| -> Result<String, String> {
1626            match names {
1627                [] => Ok(String::new()),
1628                [one] => Ok(one.clone()),
1629                _ => Err(format!("'{field}' is one anchor")),
1630            }
1631        };
1632        // Every anchor of the construction after this edit, in pick order.
1633        let anchors: Vec<String> = match (field, &placed.section, &placed.detail) {
1634            ("cut", Some(_), _) => {
1635                if names.len() > 2 {
1636                    return Err("a cutting line is two anchors".into());
1637                }
1638                names.to_vec()
1639            }
1640            ("centre", _, Some(circle)) => vec![single(names)?, circle.rim.clone()],
1641            ("rim", _, Some(circle)) => vec![circle.centre.clone(), single(names)?],
1642            _ => return Err(format!("'{id}' has no {field} to pick")),
1643        };
1644        let picked: Vec<&String> = anchors.iter().filter(|a| !a.is_empty()).collect();
1645        if picked.len() == 2 && picked[0] == picked[1] {
1646            return Err(if placed.section.is_some() {
1647                "a cutting line needs two different anchors".into()
1648            } else {
1649                "a detail circle needs a radius: pick a rim point away from its centre".into()
1650            });
1651        }
1652        let on: Vec<String> = picked
1653            .iter()
1654            .map(|anchor| SheetAnchor::parse(anchor).map(|a| a.view))
1655            .collect::<Result<_, _>>()?;
1656        if on.windows(2).any(|pair| pair[0] != pair[1]) {
1657            return Err(format!(
1658                "'{}' is on '{}' and '{}' on '{}'; {} is drawn on ONE view",
1659                picked[0], on[0], picked[1], on[1],
1660                if placed.section.is_some() { "a cutting line" } else { "a detail circle" }
1661            ));
1662        }
1663        let source = on.first().cloned();
1664        let parent = match &source {
1665            Some(source) => {
1666                if source == id {
1667                    return Err(format!("'{id}' cannot be drawn on itself"));
1668                }
1669                let parent = sheet
1670                    .find_view(source)
1671                    .ok_or_else(|| format!("no placement '{source}' on this sheet"))?;
1672                let what = if placed.section.is_some() { "section" } else { "detail" };
1673                if parent.section.is_some() {
1674                    return Err(format!("'{source}' is a section; a {what} of a section is not drawn"));
1675                }
1676                if parent.detail.is_some() {
1677                    return Err(format!("'{source}' is a detail; a {what} of a detail is not drawn"));
1678                }
1679                Some((parent.view.clone(), parent.scale, parent.position))
1680            }
1681            None => None,
1682        };
1683        let sheet = state.find_sheet_mut(&sheet_id).expect("just found");
1684        let placed = sheet.views.iter_mut().find(|view| view.id == id).expect("just found");
1685        let source = source.unwrap_or_default();
1686        if let Some(cut) = placed.section.as_mut() {
1687            cut.from = anchors.first().cloned().unwrap_or_default();
1688            cut.to = anchors.get(1).cloned().unwrap_or_default();
1689            cut.source = source;
1690        } else if let Some(circle) = placed.detail.as_mut() {
1691            circle.centre = anchors[0].clone();
1692            circle.rim = anchors[1].clone();
1693            circle.source = source;
1694        }
1695        if let Some((view, scale, at)) = parent {
1696            if placed.view.is_empty() {
1697                placed.view = view;
1698                placed.scale = if placed.detail.is_some() { scale * DETAIL_SCALE_FACTOR } else { scale };
1699                placed.position = [at[0] + 120.0, at[1]];
1700            }
1701        }
1702        self.write_sheet_state(state, None);
1703        Ok(())
1704    }
1705
1706    /// Enter the reference picker for sheet object `id`'s reference field
1707    /// `path` — a dimension's `anchors`, an ordinate set's `datum` or
1708    /// `members`, a section's `cut`, a detail's `centre` or `rim`. The same
1709    /// modal the feature, constraint and PMI dialogs use: the rest of the UI
1710    /// steps aside, picks land in the running list, **Finish** commits and
1711    /// **Cancel** discards. Here the picks are ANCHORS clicked on the paper, so
1712    /// the object's sheet is opened first, and nothing rolls.
1713    pub fn begin_ref_select_for_sheet(
1714        &mut self,
1715        id: &str,
1716        path: Vec<String>,
1717        label: String,
1718        filter: Vec<String>,
1719        multiple: bool,
1720        seed_names: Vec<String>,
1721    ) -> Result<(), String> {
1722        let state = self.sheet_state();
1723        let sheet = state
1724            .locate_view(id)
1725            .or_else(|| state.locate_dimension(id))
1726            .or_else(|| state.locate_ordinate(id))
1727            .map(|(sheet, _)| sheet)
1728            .ok_or_else(|| format!("no sheet object '{id}'"))?;
1729        self.set_open_sheet(Some(sheet));
1730        self.ref_select = Some(RefSelectState {
1731            feature_id: id.to_string(),
1732            path,
1733            label,
1734            filter,
1735            multiple,
1736            names: seed_names,
1737            restore_index: self.history.rollback(),
1738            target: RefSelectTarget::Sheet,
1739        });
1740        self.dirty = true;
1741        Ok(())
1742    }
1743
1744    /// The most anchors the field being picked can hold, when the object
1745    /// says: a dimension's kind, a section's two ends. `None` = no limit.
1746    fn sheet_ref_capacity(&self, id: &str, field: &str) -> Option<usize> {
1747        let state = self.sheet_state();
1748        match field {
1749            "anchors" => state
1750                .find_dimension(id)
1751                .map(|dimension| crate::sheets::dimension::anchor_count(&dimension.kind)),
1752            "cut" => Some(2),
1753            _ => None,
1754        }
1755    }
1756
1757    /// An anchor clicked on the paper while the sheet picker is up: added to
1758    /// the running list — replacing it in a single field, appended in a list
1759    /// (once), and in a list that is FULL for its object (a linear
1760    /// dimension's two, a cutting line's two) the oldest pick makes room, so a
1761    /// wrong pick is corrected by picking again.
1762    pub fn ref_select_pick_sheet_anchor(&mut self, anchor: &str) -> Result<(), String> {
1763        SheetAnchor::parse(anchor)?;
1764        let Some(state) = self.ref_select.as_ref().filter(|s| s.target == RefSelectTarget::Sheet) else {
1765            return Err("no sheet reference is being picked".into());
1766        };
1767        let capacity = state
1768            .path
1769            .last()
1770            .and_then(|field| self.sheet_ref_capacity(&state.feature_id, field));
1771        let state = self.ref_select.as_mut().expect("guarded above");
1772        if !state.multiple {
1773            state.names = vec![anchor.to_string()];
1774        } else if !state.names.iter().any(|name| name == anchor) {
1775            if capacity.is_some_and(|cap| cap > 0 && state.names.len() >= cap) {
1776                state.names.remove(0);
1777            }
1778            state.names.push(anchor.to_string());
1779        }
1780        self.dirty = true;
1781        Ok(())
1782    }
1783
1784    /// Commit a finished sheet pick into its object — the one field it edits.
1785    pub(crate) fn sheet_commit_refs(&mut self, id: &str, path: &[String], names: &[String], multiple: bool) -> Result<(), String> {
1786        let field = path.last().map(String::as_str).unwrap_or_default();
1787        let value = if multiple {
1788            Value::Array(names.iter().cloned().map(Value::String).collect())
1789        } else {
1790            Value::String(names.first().cloned().unwrap_or_default())
1791        };
1792        let params = serde_json::json!({ field: value }).to_string();
1793        let state = self.sheet_state();
1794        if state.find_dimension(id).is_some() {
1795            self.sheet_update_dimension(id, &params)
1796        } else if state.find_ordinate(id).is_some() {
1797            self.sheet_update_ordinate(id, &params)
1798        } else if state.locate_view(id).is_some() {
1799            self.sheet_set_placement_refs(id, field, names)
1800        } else {
1801            Err(format!("no sheet object '{id}'"))
1802        }
1803    }
1804
1805    // --- detail views ------------------------------------------------------
1806
1807    /// Add a DETAIL VIEW of `source`: the region inside the circle centred on
1808    /// anchor `centre` and passing through anchor `rim`, redrawn at `scale`
1809    /// (twice the source's when absent) and placed at `position` (beside the
1810    /// source when absent).
1811    ///
1812    /// The detail places the SAME saved PMI view its source does and looks
1813    /// through the source's camera; its scale and its clip are its own.
1814    /// Returns the placement id.
1815    pub fn sheet_add_detail(
1816        &mut self,
1817        source: &str,
1818        centre: &str,
1819        rim: &str,
1820        position: Option<[f64; 2]>,
1821        scale: Option<f64>,
1822    ) -> Result<String, String> {
1823        let (a, b) = (SheetAnchor::parse(centre)?, SheetAnchor::parse(rim)?);
1824        if centre == rim {
1825            return Err("a detail circle needs two different anchors: its centre and a point on its rim".into());
1826        }
1827        // Both on ONE placement: the circle is drawn on that view, and it is
1828        // that view's camera the detail looks through.
1829        if a.view != b.view {
1830            return Err(format!(
1831                "'{centre}' is on '{}' and '{rim}' on '{}'; a detail circle is drawn on ONE view",
1832                a.view, b.view
1833            ));
1834        }
1835        if a.view != source {
1836            return Err(format!("the detail circle is on '{}', not on '{source}'", a.view));
1837        }
1838        if let Some(scale) = scale {
1839            if !scale.is_finite() || scale <= 0.0 {
1840                return Err(format!("scale: {scale} is not a positive ratio"));
1841            }
1842        }
1843        let mut state = self.sheet_state();
1844        let Some((sheet_id, _)) = state.locate_view(source) else {
1845            return Err(format!("no placed view '{source}'"));
1846        };
1847        let (view, parent_scale, at, label) = {
1848            let sheet = state
1849                .find_sheet(&sheet_id)
1850                .ok_or_else(|| format!("no sheet '{sheet_id}'"))?;
1851            let label = sheet.next_view_letter();
1852            let parent = sheet
1853                .find_view(source)
1854                .ok_or_else(|| format!("no placed view '{source}'"))?;
1855            // Refused BY NAME, both of them: the detail pass is one pass over
1856            // plain sources, so a chain is said out loud rather than drawn
1857            // half-resolved.
1858            if parent.detail.is_some() {
1859                return Err(format!("'{source}' is itself a detail; a detail of a detail is not drawn"));
1860            }
1861            if parent.section.is_some() {
1862                return Err(format!("'{source}' is a section; a detail of a section is not drawn"));
1863            }
1864            (parent.view.clone(), parent.scale, parent.position, label)
1865        };
1866        let id = state.next_id("SV");
1867        let sheet = state.find_sheet_mut(&sheet_id).expect("just found");
1868        sheet.views.push(PlacedView {
1869            id: id.clone(),
1870            view,
1871            // Beside the source by default, as a section is: a detail drawn on
1872            // top of the view it enlarges would hide the circle that names it.
1873            position: position.unwrap_or([at[0] + 120.0, at[1]]),
1874            scale: scale.unwrap_or(parent_scale * DETAIL_SCALE_FACTOR),
1875            projection: ORTHOGRAPHIC.to_string(),
1876            flatten_text: true,
1877            three_d: false,
1878            section: None,
1879            detail: Some(DetailCircle {
1880                source: source.to_string(),
1881                centre: centre.to_string(),
1882                rim: rim.to_string(),
1883                label,
1884            }),
1885        });
1886        self.write_sheet_state(state, None);
1887        self.set_open_sheet(Some(sheet_id));
1888        self.open_sheet_object(id.clone());
1889        Ok(id)
1890    }
1891
1892    /// The offset that would put dimension `id`'s line through the paper point
1893    /// `at`: the component of `at - (the dimension's own base point)` along the
1894    /// direction the offset moves the line in. `None` when the dimension has
1895    /// not resolved — there is nothing to measure from.
1896    ///
1897    /// This is a PROBE of the current projection rather than a second layout:
1898    /// it re-reads the dimension's drawn label, which already sits at
1899    /// `base + offset`, so the answer is exact for any kind without this
1900    /// module knowing how any of them is constructed.
1901    pub fn sheet_dimension_offset_at(&mut self, id: &str, at: [f64; 2]) -> Option<f64> {
1902        let sheet_id = self.sheet_state().locate_dimension(id).map(|(sheet, _)| sheet)?;
1903        let drawing = self.sheet_drawing(&sheet_id)?;
1904        let drawn = drawing.dimensions.iter().find(|d| d.id == id)?;
1905        if !drawn.error.is_empty() {
1906            return None;
1907        }
1908        let (label, offset) = (drawn.label, drawn.offset_mm);
1909        // The label moves along ONE direction as the offset changes, so one
1910        // more sample of the same projection would be a second hidden-line
1911        // pass. Take the direction from the drawn geometry instead: the label
1912        // sits `offset` from the base along it.
1913        let direction = self.sheet_dimension_direction(&sheet_id, id)?;
1914        let base = [label[0] - direction[0] * offset, label[1] - direction[1] * offset];
1915        Some((at[0] - base[0]) * direction[0] + (at[1] - base[1]) * direction[1])
1916    }
1917
1918    /// The unit paper direction dimension `id`'s offset moves its line along.
1919    fn sheet_dimension_direction(&mut self, sheet_id: &str, id: &str) -> Option<[f64; 2]> {
1920        // Re-lay the dimension out one millimetre further and read where the
1921        // label went: one projection is already cached, and the SECOND layout
1922        // is over the cached placements, not a fresh hidden-line pass.
1923        let state = self.sheet_state();
1924        let sheet = state.find_sheet(sheet_id)?;
1925        let mut probe = sheet.dimensions.iter().find(|d| d.id == id)?.clone();
1926        // Step AWAY from zero: a linear dimension measures its offset from
1927        // whichever anchor is farther from the side it is offset to, and that
1928        // choice turns over at zero — a probe that crossed it would read the
1929        // step between two different base points.
1930        let delta = if probe.offset_mm < 0.0 { -1.0 } else { 1.0 };
1931        probe.offset_mm += delta;
1932        let drawing = self.sheet_drawing_cached(sheet_id)?;
1933        let moved = crate::sheets::dimension::layout_dimensions(
1934            std::slice::from_ref(&probe),
1935            &drawing.views,
1936            [drawing.width_mm * 0.5, drawing.height_mm * 0.5],
1937        );
1938        let here = drawing.dimensions.iter().find(|d| d.id == id)?;
1939        let there = moved.first()?;
1940        if !there.error.is_empty() {
1941            return None;
1942        }
1943        let step = [
1944            (there.label[0] - here.label[0]) / delta,
1945            (there.label[1] - here.label[1]) / delta,
1946        ];
1947        let len = (step[0] * step[0] + step[1] * step[1]).sqrt();
1948        (len > 1e-9).then(|| [step[0] / len, step[1] / len])
1949    }
1950
1951    /// Drop everything a sheet holds about the document being replaced.
1952    pub(crate) fn forget_sheets(&mut self) {
1953        self.sheet_open = None;
1954        self.sheet_open_object = None;
1955        self.sheet_selected_object = None;
1956        self.sheet_cache = None;
1957        self.sheet_lines.forget();
1958    }
1959}
1960
1961/// Orthographic only, REFUSED at placement — by the placement door and by
1962/// the form that re-points a placement — rather than drawn flattened: a
1963/// perspective drawing is a different construction, not a worse one.
1964fn refuse_perspective(view_id: &str, camera: &brep_kernel::PmiCamera) -> Result<(), String> {
1965    if matches!(camera.projection, brep_kernel::PmiProjection::Orthographic { .. }) {
1966        Ok(())
1967    } else {
1968        Err(format!("PMI view '{view_id}' is a perspective camera; a sheet places {ORTHOGRAPHIC} views only"))
1969    }
1970}
1971
1972
1973
1974
1975