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    pub(super) 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    pub(super) 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, &self.pmi_explode_originals, 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. A solid the active
439            // view sections away is drawn from its un-posed original, whose
440            // own hides count too.
441            self.scene.visibility_revision() + self.pmi_unposed_visibility,
442            self.sheet_lines.revision,
443            format!("{}\u{1f}{}", context.document, context.date),
444        )
445    }
446
447    /// The CACHED projection of sheet `id`, without projecting: `None` unless
448    /// [`Self::sheet_project`] put that sheet in the cache and nothing has
449    /// invalidated it since (the whole key is checked, not just the id).
450    pub fn sheet_drawing_cached(&self, id: &str) -> Option<&SheetDrawing> {
451        let key = self.sheet_cache_key(id, &self.sheet_frame_context());
452        self.sheet_cache
453            .as_ref()
454            .filter(|cache| cache.key == key)
455            .map(|cache| &cache.drawing)
456    }
457
458    /// The projected sheet `id`, built on demand and cached until the model
459    /// rebuilds or the block changes. `None` when the block carries no such
460    /// sheet. The one-call door for an export; a PAINT uses the split pair
461    /// above.
462    pub fn sheet_drawing(&mut self, id: &str) -> Option<&SheetDrawing> {
463        if !self.sheet_project(id) {
464            return None;
465        }
466        self.sheet_cache.as_ref().map(|cache| &cache.drawing)
467    }
468
469    /// Sheet `id` as SVG — one of the sheet export's two output contracts.
470    pub fn export_sheet_svg(&mut self, id: &str) -> Result<String, String> {
471        self.plugin_export_ready()?;
472        let id = self.resolve_sheet(id)?;
473        let drawing = self
474            .sheet_drawing_exact(&id)
475            .ok_or_else(|| format!("no sheet '{id}'"))?;
476        Ok(crate::sheets::svg::to_svg(drawing))
477    }
478
479    /// [`Self::sheet_drawing`] with every exact pass it lacks run HERE: a file
480    /// is never written from a mesh approximation that only stood in while a
481    /// pass was on the runner. The finished drawing replaces the cached one,
482    /// so the viewport shows what was exported.
483    pub fn sheet_drawing_exact(&mut self, id: &str) -> Option<&SheetDrawing> {
484        if !self.sheet_project(id) {
485            return None;
486        }
487        if self.sheet_cache.as_ref().is_some_and(|cache| !cache.complete) {
488            let state = self.sheet_state();
489            let sheet = state.find_sheet(id)?;
490            let pmi = self.pmi_state();
491            let env = self.sheet_env();
492            let model = Model { scene: &self.scene, unposed: &self.pmi_explode_originals, env: &env };
493            let mut drawing = project_sheet_with(
494                &model,
495                sheet,
496                &pmi.views,
497                self.pmi_report.as_ref(),
498                &self.sheet_frame_context(),
499                &mut LinesMode::Compute,
500            );
501            self.draw_sheet_bom(&sheet, &mut drawing);
502            let drawing_json = serde_json::to_string(&drawing).unwrap_or_else(|_| "null".into());
503            if let Some(cache) = self.sheet_cache.as_mut() {
504                cache.drawing = drawing;
505                cache.drawing_json = drawing_json;
506                cache.complete = true;
507                cache.reproject = false;
508            }
509        }
510        self.sheet_cache.as_ref().map(|cache| &cache.drawing)
511    }
512
513    /// Sheet `id` as a one-page PDF titled with the sheet's name — the
514    /// one-page case of [`Self::export_sheets_pdf`]. What the PDF cannot carry
515    /// that the SVG does is written down in [`crate::sheets::pdf`].
516    pub fn export_sheet_pdf(&mut self, id: &str) -> Result<Vec<u8>, String> {
517        let id = self.resolve_sheet(id)?;
518        let name = self.sheet_state().find_sheet(&id).map(|sheet| sheet.name.clone()).unwrap_or_default();
519        self.export_sheets_pdf(&[id], &name)
520    }
521
522    /// EVERY sheet of the document as one PDF, in the block's sheet order —
523    /// one page per sheet, each at its own paper size — titled with the
524    /// document's name (the first sheet's when the document has none). This is
525    /// what the export doors write; the SVG stays one file per sheet. The plain
526    /// 2D drawing set: a placement's `threeD` is the 3D export's alone.
527    pub fn export_document_pdf(&mut self) -> Result<Vec<u8>, String> {
528        self.export_document_pdf_with(false)
529    }
530
531    /// The same drawing set with the live 3D model ([`crate::sheets::pdf3d`]):
532    /// a 3D box over every placement marked `threeD`, and a last page given
533    /// over to the model with a button per saved PMI view — Sheets + 3D (PDF).
534    pub fn export_document_pdf_3d(&mut self) -> Result<Vec<u8>, String> {
535        self.export_document_pdf_with(true)
536    }
537
538    fn export_document_pdf_with(&mut self, three_d: bool) -> Result<Vec<u8>, String> {
539        let state = self.sheet_state();
540        if state.sheets.is_empty() {
541            return Err("the document has no sheets".into());
542        }
543        let ids: Vec<String> = state.sheets.iter().map(|sheet| sheet.id.clone()).collect();
544        let title = if self.document_name.trim().is_empty() {
545            state.sheets[0].name.clone()
546        } else {
547            self.document_name.clone()
548        };
549        self.export_sheets_pdf_with(&ids, &title, three_d)
550    }
551
552    /// Sheets `ids` as one PDF, one page each in the order given.
553    ///
554    /// The projection cache holds ONE sheet, the one on screen, and an export
555    /// must not evict it: a sheet the cache already holds is read from it, and
556    /// every other is projected for this export alone and dropped, so closing
557    /// the export leaves the viewport exactly as warm as it was.
558    pub fn export_sheets_pdf(&mut self, ids: &[String], title: &str) -> Result<Vec<u8>, String> {
559        self.export_sheets_pdf_with(ids, title, false)
560    }
561
562    /// [`Self::export_sheets_pdf`], with the 3D model when `three_d`: over
563    /// every placement marked `threeD`, and on a last page. Without it the
564    /// file is [`crate::sheets::pdf::write_pdf`]'s, byte for byte, whatever
565    /// the placements say.
566    pub fn export_sheets_pdf_with(&mut self, ids: &[String], title: &str, three_d: bool) -> Result<Vec<u8>, String> {
567        self.plugin_export_ready()?;
568        let state = self.sheet_state();
569        for id in ids {
570            if let Some(sheet) = state.find_sheet(id) {
571                self.request_sheet_topology(sheet);
572            }
573        }
574        let context = self.sheet_frame_context();
575        let pmi = self.pmi_state();
576        let mut drawings: Vec<SheetDrawing> = Vec::with_capacity(ids.len());
577        for id in ids {
578            let sheet = state.find_sheet(id).ok_or_else(|| format!("no sheet '{id}'"))?;
579            let complete = self.sheet_cache.as_ref().is_some_and(|cache| cache.complete);
580            let drawing = match self.sheet_drawing_cached(id).filter(|_| complete) {
581                Some(cached) => cached.clone(),
582                None => {
583                    let mut drawing = project_sheet_with(
584                        &Model { scene: &self.scene, unposed: &self.pmi_explode_originals, env: &self.sheet_env() },
585                        sheet, &pmi.views, self.pmi_report.as_ref(), &context, &mut LinesMode::Compute,
586                    );
587                    self.draw_sheet_bom(sheet, &mut drawing);
588                    drawing
589                },
590            };
591            drawings.push(drawing);
592        }
593        let pages: Vec<&SheetDrawing> = drawings.iter().collect();
594        if !three_d {
595            return Ok(crate::sheets::pdf::write_pdf(title, &pages));
596        }
597        let three_d = self.pdf3d_content(ids, &drawings, title)?;
598        Ok(crate::sheets::pdf::write_pdf_3d(title, &pages, Some(&three_d)))
599    }
600
601    /// The 3D content of a sheet PDF: the model, a live box over each `threeD`
602    /// placement that projected (one that did not — no camera, a perspective
603    /// view — has no drawing to stand over and is left 2D), and the 3D page.
604    fn pdf3d_content(
605        &self,
606        ids: &[String],
607        drawings: &[SheetDrawing],
608        title: &str,
609    ) -> Result<crate::sheets::pdf3d::Pdf3d, String> {
610        use crate::sheets::pdf3d::{self, Page3d, Placement3d};
611        let state = self.sheet_state();
612        let pmi = self.pmi_state();
613        let bodies = self.pdf3d_bodies();
614        let model = pdf3d::build(&bodies, &pmi, self.pmi_report.as_ref())?;
615        // The live box stands over the drawing with a margin, centred on the
616        // camera target's point so the model turns about the box's centre.
617        const MARGIN_MM: f64 = 4.0;
618        let mut placements = Vec::new();
619        for (page, (id, drawing)) in ids.iter().zip(drawings).enumerate() {
620            let Some(sheet) = state.find_sheet(id) else { continue };
621            for placed in sheet.views.iter().filter(|placed| placed.three_d) {
622                let Some(view) = drawing.views.iter().find(|view| view.id == placed.id) else { continue };
623                if !view.error.is_empty() || model.view(&placed.view).is_none() {
624                    continue;
625                }
626                let [x0, y0, x1, y1] = view.bounds;
627                let p = placed.position;
628                let half = [
629                    ((p[0] - x0).max(x1 - p[0]) + MARGIN_MM).max(10.0),
630                    ((p[1] - y0).max(y1 - p[1]) + MARGIN_MM).max(10.0),
631                ];
632                placements.push(Placement3d {
633                    page,
634                    id: placed.id.clone(),
635                    view: placed.view.clone(),
636                    centre_mm: p,
637                    half_mm: half,
638                    scale: placed.scale,
639                });
640            }
641        }
642        let page = Some({
643            let (w, h) = state
644                .find_sheet(ids.first().map(String::as_str).unwrap_or(""))
645                .map(|sheet| sheet.millimetres())
646                .unwrap_or((297.0, 210.0));
647            let mut page = Page3d::layout(w.max(h), w.min(h), title, &model);
648            if let Some((poster, scale)) = self.pdf3d_poster(&model, &page, &pmi) {
649                page.poster = Some(poster);
650                page.poster_scale = Some(scale);
651            }
652            page
653        });
654        Ok(pdf3d::Pdf3d { model, placements, page })
655    }
656
657    /// The 3D page's drawing of its default view: the view projected as a
658    /// sheet placement at the centre of the 3D box, scaled to fill it — and
659    /// the scale, which the box then opens at. A perspective view is drawn
660    /// through the orthographic camera that frames its target alike (a sheet
661    /// draws orthographic views only); `None` when the view does not project.
662    fn pdf3d_poster(
663        &self,
664        model: &crate::sheets::pdf3d::Model3d,
665        page: &crate::sheets::pdf3d::Page3d,
666        pmi: &brep_kernel::PmiState,
667    ) -> Option<(crate::sheets::project::ViewDrawing, f64)> {
668        let default = model.views.get(page.default_view)?;
669        let mut camera = default.camera.clone();
670        if let brep_kernel::PmiProjection::Perspective { fov_y_deg } = camera.projection {
671            let distance = {
672                let d = [camera.target[0] - camera.eye[0], camera.target[1] - camera.eye[1], camera.target[2] - camera.eye[2]];
673                (d[0] * d[0] + d[1] * d[1] + d[2] * d[2]).sqrt()
674            };
675            camera.projection = brep_kernel::PmiProjection::Orthographic {
676                half_height: distance * (fov_y_deg.to_radians() * 0.5).tan(),
677            };
678        }
679        let mut views = pmi.views.clone();
680        match views.iter_mut().find(|view| view.id == default.id) {
681            Some(view) => view.camera = Some(camera),
682            None => views.push(brep_kernel::PmiView {
683                id: default.id.clone(),
684                name: default.name.clone(),
685                camera: Some(camera),
686                display: Default::default(),
687                annotations: Vec::new(),
688            }),
689        }
690        let centre = page.box_centre_mm();
691        let half = page.box_half_mm();
692        let project = |scale: f64| -> Option<crate::sheets::project::ViewDrawing> {
693            let mut sheet: crate::sheets::Sheet = serde_json::from_value(serde_json::json!({
694                "id": "MODEL3D",
695                "size": crate::sheets::CUSTOM,
696                "widthMm": page.width_mm,
697                "heightMm": page.height_mm,
698                "border": false,
699                "titleBlock": false,
700            }))
701            .ok()?;
702            sheet.views.push(PlacedView {
703                id: "MODEL3D".into(),
704                view: default.id.clone(),
705                position: centre,
706                scale,
707                projection: ORTHOGRAPHIC.to_string(),
708                flatten_text: true,
709                three_d: false,
710                section: None,
711                detail: None,
712            });
713            let drawing = project_sheet_with(
714                &Model { scene: &self.scene, unposed: &self.pmi_explode_originals, env: &self.sheet_env() },
715                &sheet,
716                &views,
717                self.pmi_report.as_ref(),
718                &self.sheet_frame_context(),
719                &mut LinesMode::Compute,
720            );
721            drawing.views.into_iter().next().filter(|view| view.error.is_empty())
722        };
723        // Once at unit scale to measure, once at the scale that fills the box.
724        let probe = project(1.0)?;
725        let [x0, y0, x1, y1] = probe.bounds;
726        let reach = [(centre[0] - x0).max(x1 - centre[0]), (centre[1] - y0).max(y1 - centre[1])];
727        if !(reach[0] > 1e-9 && reach[1] > 1e-9) {
728            return None;
729        }
730        let scale = (half[0] / reach[0]).min(half[1] / reach[1]) * 0.9;
731        project(scale).map(|drawing| (drawing, scale))
732    }
733
734    /// The sheet an export writes: the named one, else the OPEN one, else the
735    /// first in the block.
736    pub fn resolve_sheet(&self, id: &str) -> Result<String, String> {
737        let state = self.sheet_state();
738        let id = id.trim();
739        if !id.is_empty() {
740            return state
741                .find_sheet(id)
742                .map(|sheet| sheet.id.clone())
743                .ok_or_else(|| format!("no sheet '{id}'"));
744        }
745        if let Some(open) = self.sheet_open.as_ref().filter(|open| state.find_sheet(open).is_some()) {
746            return Ok(open.clone());
747        }
748        state
749            .sheets
750            .first()
751            .map(|sheet| sheet.id.clone())
752            .ok_or_else(|| "the document has no sheets".to_string())
753    }
754
755    // --- block writes --------------------------------------------------------
756
757    /// Write `state` as the document's block (checkpointed). An empty block is
758    /// removed so a part that never had a sheet saves byte-identically.
759    fn write_sheet_state(&mut self, state: SheetState, coalesce_key: Option<&str>) {
760        let block = if state.is_empty() { None } else { serde_json::to_value(&state).ok() };
761        self.history.set_sheets_block(block, coalesce_key);
762        self.dirty = true;
763    }
764
765    // --- sheets ---------------------------------------------------------------
766
767    /// Add a sheet (`name` or `Sheet N`, the default paper size) and open it.
768    /// Returns the id.
769    pub fn sheet_add(&mut self, name: Option<&str>) -> String {
770        let mut state = self.sheet_state();
771        let id = state.next_id("SHEET");
772        let name = name
773            .map(str::trim)
774            .filter(|name| !name.is_empty())
775            .map(String::from)
776            .unwrap_or_else(|| format!("Sheet {}", state.sheets.len() + 1));
777        let (width_mm, height_mm) = crate::sheets::paper_size(DEFAULT_SIZE).unwrap_or((420.0, 297.0));
778        state.sheets.push(Sheet {
779            id: id.clone(),
780            name,
781            size: DEFAULT_SIZE.to_string(),
782            width_mm,
783            height_mm,
784            border: true,
785            border_inset_mm: DEFAULT_BORDER_INSET_MM,
786            title_block: true,
787            notes: String::new(),
788            views: Vec::new(),
789            dimensions: Vec::new(),
790            ordinates: Vec::new(),
791            revisions: Vec::new(),
792            bom_table: None,
793        });
794        self.write_sheet_state(state, None);
795        self.set_open_sheet(Some(id.clone()));
796        id
797    }
798
799    /// Insert a live BOM table on the open sheet and open its settings.
800    pub fn sheet_insert_bom(&mut self) -> Result<(), String> {
801        let id = self
802            .sheet_open()
803            .ok_or("open a drawing sheet first")?
804            .to_owned();
805        if self
806            .sheet_state()
807            .find_sheet(&id)
808            .and_then(|s| s.bom_table.as_ref())
809            .is_none()
810        {
811            self.sheet_update(
812                &id,
813                &serde_json::json!({ "bomTable": crate::sheets::BomTable::default() }).to_string(),
814            )?;
815        }
816        self.sheet_set_object_open(Some(&id));
817        Ok(())
818    }
819
820    /// Available built-in and custom attributes, in stable order.
821    pub fn sheet_bom_columns(&self) -> Vec<String> {
822        let mut columns = std::collections::BTreeSet::from([
823            "partName".to_owned(),
824            "occurrence.Quantity".to_owned(),
825            "occurrence.Find_Number".to_owned(),
826            "part.Part_Number".to_owned(),
827            "part.Description".to_owned(),
828            "part.Material".to_owned(),
829        ]);
830        for component in &self.assembly_components {
831            for (scope, attrs) in [
832                ("part", self.part_attributes(&component.part_name)),
833                ("occurrence", self.occurrence_attributes(&component.id)),
834            ] {
835                if let Some(attrs) = attrs.as_object() {
836                    columns.extend(attrs.keys().map(|key| format!("{scope}.{key}")));
837                }
838            }
839        }
840        columns.into_iter().collect()
841    }
842
843    fn draw_sheet_bom(&self, sheet: &Sheet, drawing: &mut SheetDrawing) {
844        let Some(table) = &sheet.bom_table else {
845            return;
846        };
847        let mut rows: std::collections::BTreeMap<Vec<String>, usize> =
848            std::collections::BTreeMap::new();
849        let occurrences = self.occurrence_attributes_all();
850        let mut parts = std::collections::HashMap::new();
851        for component in &self.assembly_components {
852            let part = parts
853                .entry(component.part_name.clone())
854                .or_insert_with(|| self.part_attributes(&component.part_name));
855            let occurrence = occurrences.get(&component.id).unwrap_or(&Value::Null);
856            let cells: Vec<String> = table
857                .columns
858                .iter()
859                .map(|column| {
860                    if column == "partName" {
861                        return component.part_name.clone();
862                    }
863                    if column == "quantity" || column == "occurrence.Quantity" {
864                        return String::new();
865                    }
866                    let (scope, key) = column.split_once('.').unwrap_or(("part", column));
867                    if column == "part.PMI" {
868                        return self.part_pmi_count(&component.part_name).to_string();
869                    }
870                    let value = if scope == "occurrence" {
871                        &occurrence[key]
872                    } else {
873                        &part[key]
874                    };
875                    match value {
876                        Value::Null => String::new(),
877                        Value::String(s) => s.clone(),
878                        v => v.to_string(),
879                    }
880                })
881                .collect();
882            // Include the part identity even when its name column is hidden.
883            let mut key = cells;
884            key.push(component.part_name.clone());
885            *rows.entry(key).or_default() += 1;
886        }
887        for wire in self.wire_bom_lines() {
888            let mut cells: Vec<String> = table
889                .columns
890                .iter()
891                .map(|column| match column.as_str() {
892                    "partName" | "part.Part_Number" => wire.stock_part_number.clone(),
893                    "occurrence.MF_QTY" => wire
894                        .mf_qty
895                        .map(|q| crate::formatting::compact_decimal(q, 3))
896                        .unwrap_or_else(|| wire.state.as_str().to_owned()),
897                    "occurrence.Reference_Designator" => wire.connection_id.clone(),
898                    _ => String::new(),
899                })
900                .collect();
901            cells.push(format!("wire:{}", wire.connection_id));
902            rows.insert(cells, 1);
903        }
904        let frame = drawing
905            .frame
906            .get_or_insert_with(|| crate::sheets::frame::FrameDrawing {
907                lines: Vec::new(),
908                texts: Vec::new(),
909                revision_table: None,
910            });
911        let [x, y] = table.position;
912        let width = table.column_width_mm;
913        let height = 7.;
914        let count = rows.len() + 1;
915        for row in 0..=count {
916            frame.lines.push(vec![
917                [x, y + row as f64 * height],
918                [
919                    x + table.columns.len() as f64 * width,
920                    y + row as f64 * height,
921                ],
922            ]);
923        }
924        for col in 0..=table.columns.len() {
925            frame.lines.push(vec![
926                [x + col as f64 * width, y],
927                [x + col as f64 * width, y + count as f64 * height],
928            ]);
929        }
930        let headers: Vec<String> = table
931            .columns
932            .iter()
933            .map(|c| match c.as_str() {
934                "partName" => "Part name".into(),
935                "quantity" => "Quantity".into(),
936                _ => c
937                    .split_once('.')
938                    .map_or(c.as_str(), |(_, key)| key)
939                    .replace('_', " "),
940            })
941            .collect();
942        let mut all = vec![headers];
943        for (mut cells, quantity) in rows {
944            cells.pop();
945            for (i, column) in table.columns.iter().enumerate() {
946                if column == "quantity" || column == "occurrence.Quantity" {
947                    cells[i] = quantity.to_string();
948                }
949            }
950            all.push(cells);
951        }
952        for (row, cells) in all.iter().enumerate() {
953            for (col, cell) in cells.iter().enumerate() {
954                let available = ((width - 2.) / (2.5 * 0.65)).floor() as usize;
955                let text = if cell.chars().count() > available {
956                    format!(
957                        "{}…",
958                        cell.chars()
959                            .take(available.saturating_sub(1))
960                            .collect::<String>()
961                    )
962                } else {
963                    cell.clone()
964                };
965                frame.texts.push(crate::sheets::project::SheetText::new(
966                    text,
967                    [
968                        x + (col as f64 + 0.5) * width,
969                        y + (row as f64 + 0.5) * height,
970                    ],
971                    [2.5, 0.],
972                    [0., -2.5],
973                ));
974            }
975        }
976    }
977
978    /// Apply an edited **Sheet** form.
979    pub fn sheet_update(&mut self, id: &str, params_json: &str) -> Result<(), String> {
980        let params: Value = serde_json::from_str(params_json)
981            .map_err(|error| format!("sheet params: {error}"))?;
982        let mut state = self.sheet_state();
983        let sheet = state.find_sheet_mut(id).ok_or_else(|| format!("no sheet '{id}'"))?;
984        sheet.apply(&params)?;
985        self.write_sheet_state(state, None);
986        Ok(())
987    }
988
989    // --- the revision table ---------------------------------------------------
990
991    /// Append a revision row to sheet `id` and return its index. Absent
992    /// fields are seeded the way a drawing office starts a row: the letter
993    /// after the last row's (`A` on an empty table, skipping `I`, `O` and `Q`
994    /// as a view letter does), today's date, and no description.
995    pub fn sheet_add_revision(
996        &mut self,
997        id: &str,
998        rev: Option<&str>,
999        date: Option<&str>,
1000        description: Option<&str>,
1001    ) -> Result<usize, String> {
1002        let mut state = self.sheet_state();
1003        let sheet = state.find_sheet_mut(id).ok_or_else(|| format!("no sheet '{id}'"))?;
1004        let next = || {
1005            let letters = crate::sheets::SectionCut::LETTERS;
1006            let last = sheet.revisions.last().and_then(|row| row.rev.trim().chars().next());
1007            match last.and_then(|c| letters.iter().position(|l| l.eq_ignore_ascii_case(&c))) {
1008                Some(at) => letters[(at + 1).min(letters.len() - 1)].to_string(),
1009                None if sheet.revisions.is_empty() => letters[0].to_string(),
1010                None => String::new(),
1011            }
1012        };
1013        let revision = crate::sheets::Revision {
1014            rev: rev.map(str::to_string).unwrap_or_else(next),
1015            date: date.map(str::to_string).unwrap_or_else(today_iso),
1016            description: description.unwrap_or_default().to_string(),
1017        };
1018        sheet.revisions.push(revision);
1019        let index = sheet.revisions.len() - 1;
1020        self.write_sheet_state(state, None);
1021        Ok(index)
1022    }
1023
1024    /// Patch revision row `index` of sheet `id` (`rev`, `date`,
1025    /// `description`; absent keys are unchanged).
1026    pub fn sheet_update_revision(&mut self, id: &str, index: usize, params_json: &str) -> Result<(), String> {
1027        let params: Value = serde_json::from_str(params_json)
1028            .map_err(|error| format!("revision params: {error}"))?;
1029        let mut state = self.sheet_state();
1030        let sheet = state.find_sheet_mut(id).ok_or_else(|| format!("no sheet '{id}'"))?;
1031        let count = sheet.revisions.len();
1032        let row = sheet
1033            .revisions
1034            .get_mut(index)
1035            .ok_or_else(|| format!("sheet '{id}' has {count} revision row(s); there is no row {index}"))?;
1036        row.apply(&params)?;
1037        self.write_sheet_state(state, None);
1038        Ok(())
1039    }
1040
1041    /// Remove revision row `index` of sheet `id`. The rows after it move up
1042    /// one and keep their order.
1043    pub fn sheet_remove_revision(&mut self, id: &str, index: usize) -> Result<(), String> {
1044        let mut state = self.sheet_state();
1045        let sheet = state.find_sheet_mut(id).ok_or_else(|| format!("no sheet '{id}'"))?;
1046        if index >= sheet.revisions.len() {
1047            return Err(format!(
1048                "sheet '{id}' has {} revision row(s); there is no row {index}",
1049                sheet.revisions.len()
1050            ));
1051        }
1052        sheet.revisions.remove(index);
1053        self.write_sheet_state(state, None);
1054        Ok(())
1055    }
1056
1057    pub fn sheet_delete(&mut self, id: &str) -> Result<(), String> {
1058        let mut state = self.sheet_state();
1059        let before = state.sheets.len();
1060        state.sheets.retain(|sheet| sheet.id != id);
1061        if state.sheets.len() == before {
1062            return Err(format!("no sheet '{id}'"));
1063        }
1064        if self.sheet_open.as_deref() == Some(id) {
1065            self.set_open_sheet(state.sheets.first().map(|sheet| sheet.id.clone()));
1066        }
1067        self.clear_open_object(&state);
1068        self.write_sheet_state(state, None);
1069        Ok(())
1070    }
1071
1072    /// Show sheet `id` in the sheet viewport (`None` returns to the 3D view).
1073    pub fn sheet_set_open(&mut self, id: Option<&str>) -> Result<(), String> {
1074        match id {
1075            None => {
1076                self.set_open_sheet(None);
1077                self.dirty = true;
1078                Ok(())
1079            }
1080            Some(id) => {
1081                let state = self.sheet_state();
1082                if state.find_sheet(id).is_none() {
1083                    return Err(format!("no sheet '{id}'"));
1084                }
1085                self.set_open_sheet(Some(id.to_string()));
1086                self.dirty = true;
1087                Ok(())
1088            }
1089        }
1090    }
1091
1092    /// Open a sheet or placed view's form in the Sheets pane (`None` closes).
1093    /// An open COUNTS (`Self::open_sheet_object`) — including a re-open of
1094    /// the object already open, which is a click on the same dimension on the
1095    /// paper while the Sheets pane sits behind another tab.
1096    pub fn sheet_set_object_open(&mut self, id: Option<&str>) {
1097        match id {
1098            Some(id) => self.open_sheet_object(id.to_string()),
1099            None => self.sheet_open_object = None,
1100        }
1101    }
1102
1103    // --- placed views ----------------------------------------------------------
1104
1105    /// Place PMI view `view_id` on sheet `sheet_id` (the open sheet when
1106    /// absent), centred on the paper unless `position` says otherwise, and
1107    /// open its form. Returns the placement id.
1108    pub fn sheet_place_view(
1109        &mut self,
1110        sheet_id: Option<&str>,
1111        view_id: &str,
1112        position: Option<[f64; 2]>,
1113        scale: Option<f64>,
1114    ) -> Result<String, String> {
1115        let pmi = self.pmi_state();
1116        let view = pmi
1117            .find_view(view_id)
1118            .ok_or_else(|| format!("no PMI view '{view_id}'"))?;
1119        let camera = view
1120            .camera
1121            .as_ref()
1122            .ok_or_else(|| format!("PMI view '{view_id}' has no captured camera — capture one first"))?;
1123        refuse_perspective(view_id, camera)?;
1124        let mut state = self.sheet_state();
1125        let sheet_id = match sheet_id.map(str::trim).filter(|id| !id.is_empty()) {
1126            Some(id) => id.to_string(),
1127            None => self.resolve_sheet("")?,
1128        };
1129        let id = state.next_id("SV");
1130        let sheet = state
1131            .find_sheet_mut(&sheet_id)
1132            .ok_or_else(|| format!("no sheet '{sheet_id}'"))?;
1133        let (width_mm, height_mm) = sheet.millimetres();
1134        let scale = match scale {
1135            Some(scale) if scale.is_finite() && scale > 0.0 => scale,
1136            Some(scale) => return Err(format!("scale: {scale} is not a positive ratio")),
1137            None => DEFAULT_SCALE,
1138        };
1139        sheet.views.push(PlacedView {
1140            id: id.clone(),
1141            view: view_id.to_string(),
1142            position: position.unwrap_or([width_mm * 0.5, height_mm * 0.5]),
1143            scale,
1144            projection: ORTHOGRAPHIC.to_string(),
1145            flatten_text: true,
1146            three_d: false,
1147            section: None,
1148            detail: None,
1149        });
1150        self.write_sheet_state(state, None);
1151        self.set_open_sheet(Some(sheet_id));
1152        self.open_sheet_object(id.clone());
1153        Ok(id)
1154    }
1155
1156    /// Apply an edited **Placed view** form.
1157    pub fn sheet_update_view(&mut self, id: &str, params_json: &str) -> Result<(), String> {
1158        let mut params: Value = serde_json::from_str(params_json)
1159            .map_err(|error| format!("placed view params: {error}"))?;
1160        // A section's or a detail's PICKED references — the form's reference
1161        // rows — go through their own door, which checks them against the
1162        // sheet; and only when they changed, so an unrelated edit (the letter,
1163        // the scale) does not re-write the construction.
1164        if let Some(object) = params.as_object_mut() {
1165            let current = self.sheet_state().sheets.iter().find_map(|sheet| sheet.find_view(id).cloned());
1166            for field in ["cut", "centre", "rim"] {
1167                let Some(value) = object.remove(field) else { continue };
1168                // A single reference row hands back a string, a list row an array.
1169                let names: Vec<String> = match value.as_str() {
1170                    Some(one) => vec![one.to_string()],
1171                    None => crate::json_support::string_values(Some(&value)).map(str::to_string).collect(),
1172                }
1173                .into_iter()
1174                .filter(|name| !name.is_empty())
1175                .collect();
1176                let now = current.as_ref().map(|placed| placed.picked(field)).unwrap_or_default();
1177                if names != now {
1178                    self.sheet_set_placement_refs(id, field, &names)?;
1179                }
1180            }
1181        }
1182        // Re-pointing a placement at another saved view is a PLACEMENT of that
1183        // view, and refused on the same terms as the first one.
1184        if let Some(view_id) = params.get("view").and_then(Value::as_str).map(str::trim).filter(|id| !id.is_empty()) {
1185            if let Some(camera) = self.pmi_state().find_view(view_id).and_then(|view| view.camera.clone()) {
1186                refuse_perspective(view_id, &camera)?;
1187            }
1188        }
1189        let views = self.sheet_pmi_view_ids();
1190        let mut state = self.sheet_state();
1191        let view = state.find_view_mut(id).ok_or_else(|| format!("no placed view '{id}'"))?;
1192        view.apply(&params, &views)?;
1193        self.write_sheet_state(state, None);
1194        Ok(())
1195    }
1196
1197    /// Move a placed view to `position` (paper millimetres). `coalesce` groups
1198    /// a drag into ONE undo step, the PMI label drag's rule.
1199    pub fn sheet_move_view(&mut self, id: &str, position: [f64; 2], coalesce: bool) -> Result<(), String> {
1200        if !position[0].is_finite() || !position[1].is_finite() {
1201            return Err("position: not a finite point".into());
1202        }
1203        let mut state = self.sheet_state();
1204        let view = state.find_view_mut(id).ok_or_else(|| format!("no placed view '{id}'"))?;
1205        view.position = position;
1206        let key = format!("sheet:view:{id}");
1207        self.write_sheet_state(state, coalesce.then_some(key.as_str()));
1208        Ok(())
1209    }
1210
1211    /// End a placement drag: the next move starts a fresh undo entry.
1212    pub fn sheet_move_view_end(&mut self) {
1213        self.history.break_coalescing();
1214    }
1215
1216    pub fn sheet_remove_view(&mut self, id: &str) -> Result<(), String> {
1217        let mut state = self.sheet_state();
1218        let Some((sheet_id, index)) = state.locate_view(id) else {
1219            return Err(format!("no placed view '{id}'"));
1220        };
1221        if let Some(sheet) = state.find_sheet_mut(&sheet_id) {
1222            sheet.views.remove(index);
1223        }
1224        self.clear_open_object(&state);
1225        self.write_sheet_state(state, None);
1226        Ok(())
1227    }
1228
1229    /// Close the open form when the object it showed is gone.
1230    fn clear_open_object(&mut self, state: &SheetState) {
1231        if let Some(open) = self.sheet_open_object.clone() {
1232            if state.find_sheet(&open).is_none()
1233                && state.locate_view(&open).is_none()
1234                && state.locate_dimension(&open).is_none()
1235                && state.locate_ordinate(&open).is_none()
1236            {
1237                self.sheet_open_object = None;
1238            }
1239        }
1240    }
1241
1242    // --- sheet dimensions ------------------------------------------------------
1243
1244    /// Add a dimension to sheet `sheet_id` (the open sheet when absent) and
1245    /// open its form. `anchors` are reference strings; an EMPTY list is
1246    /// allowed and gives an unresolved dimension whose form is where its
1247    /// anchors get picked.
1248    pub fn sheet_add_dimension(
1249        &mut self,
1250        sheet_id: Option<&str>,
1251        kind: &str,
1252        alignment: Option<&str>,
1253        anchors: Vec<String>,
1254    ) -> Result<String, String> {
1255        let kind = KINDS
1256            .iter()
1257            .find(|known| known.eq_ignore_ascii_case(kind.trim()))
1258            .ok_or_else(|| format!("no dimension kind '{kind}'"))?;
1259        let alignment = match alignment.map(str::trim).filter(|a| !a.is_empty()) {
1260            None => ALIGNED,
1261            Some(wanted) => ALIGNMENTS
1262                .iter()
1263                .copied()
1264                .find(|known| known.eq_ignore_ascii_case(wanted))
1265                .ok_or_else(|| format!("no alignment '{wanted}'"))?,
1266        };
1267        for anchor in &anchors {
1268            SheetAnchor::parse(anchor)?;
1269        }
1270        let sheet_id = match sheet_id.map(str::trim).filter(|id| !id.is_empty()) {
1271            Some(id) => id.to_string(),
1272            None => self.resolve_sheet("")?,
1273        };
1274        let mut state = self.sheet_state();
1275        let id = state.next_id("SD");
1276        let sheet = state
1277            .find_sheet_mut(&sheet_id)
1278            .ok_or_else(|| format!("no sheet '{sheet_id}'"))?;
1279        sheet
1280            .dimensions
1281            .push(SheetDimension::new(id.clone(), kind, alignment, anchors));
1282        self.write_sheet_state(state, None);
1283        self.set_open_sheet(Some(sheet_id));
1284        self.open_sheet_object(id.clone());
1285        Ok(id)
1286    }
1287
1288    /// Apply an edited **Sheet dimension** form.
1289    pub fn sheet_update_dimension(&mut self, id: &str, params_json: &str) -> Result<(), String> {
1290        let params: Value = serde_json::from_str(params_json)
1291            .map_err(|error| format!("sheet dimension params: {error}"))?;
1292        let mut state = self.sheet_state();
1293        let dimension = state
1294            .find_dimension_mut(id)
1295            .ok_or_else(|| format!("no sheet dimension '{id}'"))?;
1296        dimension.apply(&params)?;
1297        self.write_sheet_state(state, None);
1298        Ok(())
1299    }
1300
1301    /// Move a dimension's line: `offset_mm` from its anchors, signed. What
1302    /// dragging its value box in the sheet viewport does, so `coalesce` groups
1303    /// a drag into ONE undo step exactly as a placement drag does.
1304    pub fn sheet_move_dimension(
1305        &mut self,
1306        id: &str,
1307        offset_mm: f64,
1308        coalesce: bool,
1309    ) -> Result<(), String> {
1310        if !offset_mm.is_finite() {
1311            return Err(format!("offsetMm: {offset_mm} is not a length"));
1312        }
1313        let mut state = self.sheet_state();
1314        let dimension = state
1315            .find_dimension_mut(id)
1316            .ok_or_else(|| format!("no sheet dimension '{id}'"))?;
1317        dimension.offset_mm = offset_mm;
1318        let key = format!("sheet:dim:{id}");
1319        self.write_sheet_state(state, coalesce.then_some(key.as_str()));
1320        Ok(())
1321    }
1322
1323    /// End an offset drag: the next move starts a fresh undo entry.
1324    pub fn sheet_move_dimension_end(&mut self) {
1325        self.history.break_coalescing();
1326    }
1327
1328    pub fn sheet_remove_dimension(&mut self, id: &str) -> Result<(), String> {
1329        let mut state = self.sheet_state();
1330        let Some((sheet_id, index)) = state.locate_dimension(id) else {
1331            return Err(format!("no sheet dimension '{id}'"));
1332        };
1333        if let Some(sheet) = state.find_sheet_mut(&sheet_id) {
1334            sheet.dimensions.remove(index);
1335        }
1336        self.clear_open_object(&state);
1337        self.write_sheet_state(state, None);
1338        Ok(())
1339    }
1340
1341    // --- ordinate sets ------------------------------------------------------
1342
1343    /// Add an ordinate set to sheet `sheet_id` (the open sheet when absent)
1344    /// and open its form. `datum` may be empty: the set is then unresolved and
1345    /// its form is where the datum gets picked.
1346    pub fn sheet_add_ordinate(
1347        &mut self,
1348        sheet_id: Option<&str>,
1349        axis: &str,
1350        datum: Option<&str>,
1351        members: Vec<String>,
1352    ) -> Result<String, String> {
1353        let axis = AXES
1354            .iter()
1355            .find(|known| known.eq_ignore_ascii_case(axis.trim()))
1356            .ok_or_else(|| format!("no ordinate axis '{axis}'"))?;
1357        let datum = datum.map(str::trim).filter(|d| !d.is_empty()).unwrap_or_default();
1358        if !datum.is_empty() {
1359            SheetAnchor::parse(datum)?;
1360        }
1361        for member in &members {
1362            SheetAnchor::parse(member)?;
1363        }
1364        let sheet_id = match sheet_id.map(str::trim).filter(|id| !id.is_empty()) {
1365            Some(id) => id.to_string(),
1366            None => self.resolve_sheet("")?,
1367        };
1368        let mut state = self.sheet_state();
1369        let id = state.next_id("OD");
1370        let sheet = state
1371            .find_sheet_mut(&sheet_id)
1372            .ok_or_else(|| format!("no sheet '{sheet_id}'"))?;
1373        let mut set = SheetOrdinate::new(id.clone(), axis, datum.to_string());
1374        set.members = members;
1375        sheet.ordinates.push(set);
1376        self.write_sheet_state(state, None);
1377        self.set_open_sheet(Some(sheet_id));
1378        self.open_sheet_object(id.clone());
1379        Ok(id)
1380    }
1381
1382    /// Apply an edited **Ordinate set** form.
1383    ///
1384    /// A set with no DATUM is kept, unresolved: it is what the Ordinate set
1385    /// button creates, and its form is where the datum gets picked. Every value
1386    /// it would draw is a distance from that datum, so until there is one the
1387    /// set draws its id and the reason instead.
1388    pub fn sheet_update_ordinate(&mut self, id: &str, params_json: &str) -> Result<(), String> {
1389        let params: Value = serde_json::from_str(params_json)
1390            .map_err(|error| format!("ordinate set params: {error}"))?;
1391        let mut state = self.sheet_state();
1392        let set = state
1393            .find_ordinate_mut(id)
1394            .ok_or_else(|| format!("no ordinate set '{id}'"))?;
1395        set.apply(&params)?;
1396        self.write_sheet_state(state, None);
1397        Ok(())
1398    }
1399
1400    /// Move a set's BASELINE: its signed distance from the datum in paper
1401    /// millimetres. One number for the whole set, so `coalesce` groups a drag
1402    /// into one undo step exactly as a dimension's offset drag does.
1403    pub fn sheet_move_ordinate(
1404        &mut self,
1405        id: &str,
1406        offset_mm: f64,
1407        coalesce: bool,
1408    ) -> Result<(), String> {
1409        if !offset_mm.is_finite() {
1410            return Err(format!("offsetMm: {offset_mm} is not a length"));
1411        }
1412        let mut state = self.sheet_state();
1413        let set = state
1414            .find_ordinate_mut(id)
1415            .ok_or_else(|| format!("no ordinate set '{id}'"))?;
1416        set.offset_mm = offset_mm;
1417        let key = format!("sheet:ord:{id}");
1418        self.write_sheet_state(state, coalesce.then_some(key.as_str()));
1419        Ok(())
1420    }
1421
1422    /// End a baseline drag: the next move starts a fresh undo entry.
1423    pub fn sheet_move_ordinate_end(&mut self) {
1424        self.history.break_coalescing();
1425    }
1426
1427    pub fn sheet_remove_ordinate(&mut self, id: &str) -> Result<(), String> {
1428        let mut state = self.sheet_state();
1429        let Some((sheet_id, index)) = state.locate_ordinate(id) else {
1430            return Err(format!("no ordinate set '{id}'"));
1431        };
1432        if let Some(sheet) = state.find_sheet_mut(&sheet_id) {
1433            sheet.ordinates.remove(index);
1434        }
1435        self.clear_open_object(&state);
1436        self.write_sheet_state(state, None);
1437        Ok(())
1438    }
1439
1440    /// The baseline offset that would put set `id`'s baseline through the
1441    /// paper point `at`. `None` when the set has no datum to measure from.
1442    ///
1443    /// Unlike a dimension's, this needs no probe: a set's offset runs along
1444    /// ONE known paper axis — down the page for a horizontal set, across it
1445    /// for a vertical one — and the datum's own anchor point is the base.
1446    pub fn sheet_ordinate_offset_at(&mut self, id: &str, at: [f64; 2]) -> Option<f64> {
1447        let sheet_id = self.sheet_state().locate_ordinate(id).map(|(sheet, _)| sheet)?;
1448        let vertical = self
1449            .sheet_state()
1450            .find_ordinate(id)?
1451            .axis
1452            .eq_ignore_ascii_case(crate::sheets::dimension::VERTICAL);
1453        let drawing = self.sheet_drawing(&sheet_id)?;
1454        let drawn = drawing.ordinates.iter().find(|o| o.id == id)?;
1455        if !drawn.error.is_empty() {
1456            return None;
1457        }
1458        let datum = drawn.stations.iter().find(|station| station.datum)?;
1459        if !datum.error.is_empty() {
1460            return None;
1461        }
1462        Some(if vertical { at[0] - datum.at[0] } else { at[1] - datum.at[1] })
1463    }
1464
1465    // --- section views ------------------------------------------------------
1466
1467    /// Add a SECTION VIEW of `source`, cutting along the line through the two
1468    /// anchors, placed at `position` (beside the source when absent).
1469    ///
1470    /// The section places the SAME saved PMI view its source does — it needs
1471    /// that view's hidden-solid set and its text size — and overrides only the
1472    /// camera, which is derived from the cut rather than stored. Returns the
1473    /// placement id.
1474    pub fn sheet_add_section(
1475        &mut self,
1476        source: &str,
1477        from: &str,
1478        to: &str,
1479        position: Option<[f64; 2]>,
1480    ) -> Result<String, String> {
1481        SheetAnchor::parse(from)?;
1482        SheetAnchor::parse(to)?;
1483        if from == to {
1484            return Err("a cutting line needs two different anchors".into());
1485        }
1486        // Both ends belong to ONE placement: the line is drawn on that view
1487        // and it is that view's camera that turns a paper line into a plane.
1488        let (a, b) = (SheetAnchor::parse(from)?, SheetAnchor::parse(to)?);
1489        if a.view != b.view {
1490            return Err(format!(
1491                "'{from}' is on '{}' and '{to}' on '{}'; a cutting line is drawn on ONE view",
1492                a.view, b.view
1493            ));
1494        }
1495        if a.view != source {
1496            return Err(format!("the cutting line is on '{}', not on '{source}'", a.view));
1497        }
1498        let mut state = self.sheet_state();
1499        let Some((sheet_id, _)) = state.locate_view(source) else {
1500            return Err(format!("no placed view '{source}'"));
1501        };
1502        let (view, scale, at, label) = {
1503            let sheet = state
1504                .find_sheet(&sheet_id)
1505                .ok_or_else(|| format!("no sheet '{sheet_id}'"))?;
1506            let label = sheet.next_view_letter(&self.pmi_state().views);
1507            let parent = sheet
1508                .find_view(source)
1509                .ok_or_else(|| format!("no placed view '{source}'"))?;
1510            if parent.section.is_some() {
1511                return Err(format!(
1512                    "'{source}' is itself a section; a section of a section is not drawn"
1513                ));
1514            }
1515            if parent.detail.is_some() {
1516                return Err(format!("'{source}' is a detail; a section of a detail is not drawn"));
1517            }
1518            (parent.view.clone(), parent.scale, parent.position, label)
1519        };
1520        let id = state.next_id("SV");
1521        let sheet = state.find_sheet_mut(&sheet_id).expect("just found");
1522        sheet.views.push(PlacedView {
1523            id: id.clone(),
1524            view,
1525            // Beside the source by default, a paper width apart, because a
1526            // section drawn on top of the view it cuts is unreadable and the
1527            // pick that made it had nowhere to say where it goes.
1528            position: position.unwrap_or([at[0] + 120.0, at[1]]),
1529            scale,
1530            projection: ORTHOGRAPHIC.to_string(),
1531            flatten_text: true,
1532            three_d: false,
1533            section: Some(SectionCut {
1534                source: source.to_string(),
1535                from: from.to_string(),
1536                to: to.to_string(),
1537                label,
1538                flip: false,
1539            }),
1540            detail: None,
1541        });
1542        self.write_sheet_state(state, None);
1543        self.set_open_sheet(Some(sheet_id));
1544        self.open_sheet_object(id.clone());
1545        Ok(id)
1546    }
1547
1548    /// Add a SECTION VIEW with no cutting line yet to sheet `sheet_id` (the
1549    /// open sheet when absent) and open its form — what the Drawing
1550    /// workbench's Section view button does. The placement is unresolved and
1551    /// says so on the paper; its form's **Cutting line** row is where the two
1552    /// anchors are picked ([`Self::sheet_set_placement_refs`]), and the first
1553    /// line picked decides which placement it is a section OF.
1554    pub fn sheet_new_section(&mut self, sheet_id: Option<&str>) -> Result<String, String> {
1555        self.sheet_new_derived(sheet_id, true)
1556    }
1557
1558    /// The detail twin of [`Self::sheet_new_section`]: a DETAIL VIEW with no
1559    /// circle yet, whose form's **Centre** and **Point on the rim** rows are
1560    /// picked on a placed view.
1561    pub fn sheet_new_detail(&mut self, sheet_id: Option<&str>) -> Result<String, String> {
1562        self.sheet_new_derived(sheet_id, false)
1563    }
1564
1565    fn sheet_new_derived(&mut self, sheet_id: Option<&str>, section: bool) -> Result<String, String> {
1566        let sheet_id = match sheet_id.map(str::trim).filter(|id| !id.is_empty()) {
1567            Some(id) => id.to_string(),
1568            None => self.resolve_sheet("")?,
1569        };
1570        let mut state = self.sheet_state();
1571        let id = state.next_id("SV");
1572        let sheet = state
1573            .find_sheet_mut(&sheet_id)
1574            .ok_or_else(|| format!("no sheet '{sheet_id}'"))?;
1575        let label = sheet.next_view_letter(&self.pmi_state().views);
1576        let (w, h) = sheet.millimetres();
1577        sheet.views.push(PlacedView {
1578            id: id.clone(),
1579            // No saved view until the first pick names a source: a section or
1580            // a detail draws its SOURCE's view.
1581            view: String::new(),
1582            position: [w * 0.5, h * 0.5],
1583            scale: DEFAULT_SCALE,
1584            projection: ORTHOGRAPHIC.to_string(),
1585            flatten_text: true,
1586            three_d: false,
1587            section: section.then(|| SectionCut {
1588                source: String::new(),
1589                from: String::new(),
1590                to: String::new(),
1591                label: label.clone(),
1592                flip: false,
1593            }),
1594            detail: (!section).then(|| DetailCircle {
1595                source: String::new(),
1596                centre: String::new(),
1597                rim: String::new(),
1598                label,
1599            }),
1600        });
1601        self.write_sheet_state(state, None);
1602        self.set_open_sheet(Some(sheet_id));
1603        self.open_sheet_object(id.clone());
1604        Ok(id)
1605    }
1606
1607    /// Set a derived placement's PICKED references: `cut` (a section's two
1608    /// cutting-line anchors), `centre` or `rim` (a detail circle's). What a
1609    /// form's reference rows commit, the picker's Finish and a row's ✕ alike.
1610    ///
1611    /// The anchors must all lie on ONE plain placement of the same sheet — the
1612    /// line or circle is drawn on that view and read through its camera — and
1613    /// that placement becomes the source. A placement that has no saved view
1614    /// yet (made by [`Self::sheet_new_section`] / `_detail`) takes its
1615    /// source's view and scale — twice it, for a detail — and moves beside the
1616    /// source, where [`Self::sheet_add_section`] would have put it. Fewer
1617    /// anchors than the construction needs is allowed: the placement stays
1618    /// unresolved and says what is missing.
1619    pub fn sheet_set_placement_refs(&mut self, id: &str, field: &str, names: &[String]) -> Result<(), String> {
1620        for name in names {
1621            SheetAnchor::parse(name)?;
1622        }
1623        let mut state = self.sheet_state();
1624        let (sheet_id, _) = state.locate_view(id).ok_or_else(|| format!("no placed view '{id}'"))?;
1625        let sheet = state.find_sheet(&sheet_id).ok_or_else(|| format!("no sheet '{sheet_id}'"))?;
1626        let placed = sheet.find_view(id).ok_or_else(|| format!("no placed view '{id}'"))?.clone();
1627        let single = |names: &[String]| -> Result<String, String> {
1628            match names {
1629                [] => Ok(String::new()),
1630                [one] => Ok(one.clone()),
1631                _ => Err(format!("'{field}' is one anchor")),
1632            }
1633        };
1634        // Every anchor of the construction after this edit, in pick order.
1635        let anchors: Vec<String> = match (field, &placed.section, &placed.detail) {
1636            ("cut", Some(_), _) => {
1637                if names.len() > 2 {
1638                    return Err("a cutting line is two anchors".into());
1639                }
1640                names.to_vec()
1641            }
1642            ("centre", _, Some(circle)) => vec![single(names)?, circle.rim.clone()],
1643            ("rim", _, Some(circle)) => vec![circle.centre.clone(), single(names)?],
1644            _ => return Err(format!("'{id}' has no {field} to pick")),
1645        };
1646        let picked: Vec<&String> = anchors.iter().filter(|a| !a.is_empty()).collect();
1647        if picked.len() == 2 && picked[0] == picked[1] {
1648            return Err(if placed.section.is_some() {
1649                "a cutting line needs two different anchors".into()
1650            } else {
1651                "a detail circle needs a radius: pick a rim point away from its centre".into()
1652            });
1653        }
1654        let on: Vec<String> = picked
1655            .iter()
1656            .map(|anchor| SheetAnchor::parse(anchor).map(|a| a.view))
1657            .collect::<Result<_, _>>()?;
1658        if on.windows(2).any(|pair| pair[0] != pair[1]) {
1659            return Err(format!(
1660                "'{}' is on '{}' and '{}' on '{}'; {} is drawn on ONE view",
1661                picked[0], on[0], picked[1], on[1],
1662                if placed.section.is_some() { "a cutting line" } else { "a detail circle" }
1663            ));
1664        }
1665        let source = on.first().cloned();
1666        let parent = match &source {
1667            Some(source) => {
1668                if source == id {
1669                    return Err(format!("'{id}' cannot be drawn on itself"));
1670                }
1671                let parent = sheet
1672                    .find_view(source)
1673                    .ok_or_else(|| format!("no placement '{source}' on this sheet"))?;
1674                let what = if placed.section.is_some() { "section" } else { "detail" };
1675                if parent.section.is_some() {
1676                    return Err(format!("'{source}' is a section; a {what} of a section is not drawn"));
1677                }
1678                if parent.detail.is_some() {
1679                    return Err(format!("'{source}' is a detail; a {what} of a detail is not drawn"));
1680                }
1681                Some((parent.view.clone(), parent.scale, parent.position))
1682            }
1683            None => None,
1684        };
1685        let sheet = state.find_sheet_mut(&sheet_id).expect("just found");
1686        let placed = sheet.views.iter_mut().find(|view| view.id == id).expect("just found");
1687        let source = source.unwrap_or_default();
1688        if let Some(cut) = placed.section.as_mut() {
1689            cut.from = anchors.first().cloned().unwrap_or_default();
1690            cut.to = anchors.get(1).cloned().unwrap_or_default();
1691            cut.source = source;
1692        } else if let Some(circle) = placed.detail.as_mut() {
1693            circle.centre = anchors[0].clone();
1694            circle.rim = anchors[1].clone();
1695            circle.source = source;
1696        }
1697        if let Some((view, scale, at)) = parent {
1698            if placed.view.is_empty() {
1699                placed.view = view;
1700                placed.scale = if placed.detail.is_some() { scale * DETAIL_SCALE_FACTOR } else { scale };
1701                placed.position = [at[0] + 120.0, at[1]];
1702            }
1703        }
1704        self.write_sheet_state(state, None);
1705        Ok(())
1706    }
1707
1708    /// Enter the reference picker for sheet object `id`'s reference field
1709    /// `path` — a dimension's `anchors`, an ordinate set's `datum` or
1710    /// `members`, a section's `cut`, a detail's `centre` or `rim`. The same
1711    /// modal the feature, constraint and PMI dialogs use: the rest of the UI
1712    /// steps aside, picks land in the running list, **Finish** commits and
1713    /// **Cancel** discards. Here the picks are ANCHORS clicked on the paper, so
1714    /// the object's sheet is opened first, and nothing rolls.
1715    pub fn begin_ref_select_for_sheet(
1716        &mut self,
1717        id: &str,
1718        path: Vec<String>,
1719        label: String,
1720        filter: Vec<String>,
1721        multiple: bool,
1722        seed_names: Vec<String>,
1723    ) -> Result<(), String> {
1724        let state = self.sheet_state();
1725        let sheet = state
1726            .locate_view(id)
1727            .or_else(|| state.locate_dimension(id))
1728            .or_else(|| state.locate_ordinate(id))
1729            .map(|(sheet, _)| sheet)
1730            .ok_or_else(|| format!("no sheet object '{id}'"))?;
1731        self.set_open_sheet(Some(sheet));
1732        self.ref_select = Some(RefSelectState {
1733            feature_id: id.to_string(),
1734            path,
1735            label,
1736            filter,
1737            multiple,
1738            names: seed_names,
1739            restore_index: self.history.rollback(),
1740            target: RefSelectTarget::Sheet,
1741            picks: 0,
1742            claimed: false,
1743            finish_requested: false,
1744        });
1745        self.dirty = true;
1746        Ok(())
1747    }
1748
1749    /// The most anchors the field being picked can hold, when the object
1750    /// says: a dimension's kind, a section's two ends. `None` = no limit.
1751    fn sheet_ref_capacity(&self, id: &str, field: &str) -> Option<usize> {
1752        let state = self.sheet_state();
1753        match field {
1754            "anchors" => state
1755                .find_dimension(id)
1756                .map(|dimension| crate::sheets::dimension::anchor_count(&dimension.kind)),
1757            "cut" => Some(2),
1758            _ => None,
1759        }
1760    }
1761
1762    /// An anchor clicked on the paper while the sheet picker is up: added to
1763    /// the running list — replacing it in a single field, appended in a list
1764    /// (once), and in a list that is FULL for its object (a linear
1765    /// dimension's two, a cutting line's two) the oldest pick makes room, so a
1766    /// wrong pick is corrected by picking again.
1767    pub fn ref_select_pick_sheet_anchor(&mut self, anchor: &str) -> Result<(), String> {
1768        SheetAnchor::parse(anchor)?;
1769        let Some(state) = self.ref_select.as_ref().filter(|s| s.target == RefSelectTarget::Sheet) else {
1770            return Err("no sheet reference is being picked".into());
1771        };
1772        let capacity = state
1773            .path
1774            .last()
1775            .and_then(|field| self.sheet_ref_capacity(&state.feature_id, field));
1776        let state = self.ref_select.as_mut().expect("guarded above");
1777        if !state.multiple {
1778            state.names = vec![anchor.to_string()];
1779        } else if !state.names.iter().any(|name| name == anchor) {
1780            if capacity.is_some_and(|cap| cap > 0 && state.names.len() >= cap) {
1781                state.names.remove(0);
1782            }
1783            state.names.push(anchor.to_string());
1784        }
1785        state.picks += 1;
1786        self.dirty = true;
1787        Ok(())
1788    }
1789
1790    /// Commit a finished sheet pick into its object — the one field it edits.
1791    pub(crate) fn sheet_commit_refs(&mut self, id: &str, path: &[String], names: &[String], multiple: bool) -> Result<(), String> {
1792        let field = path.last().map(String::as_str).unwrap_or_default();
1793        let value = if multiple {
1794            Value::Array(names.iter().cloned().map(Value::String).collect())
1795        } else {
1796            Value::String(names.first().cloned().unwrap_or_default())
1797        };
1798        let params = serde_json::json!({ field: value }).to_string();
1799        let state = self.sheet_state();
1800        if state.find_dimension(id).is_some() {
1801            self.sheet_update_dimension(id, &params)
1802        } else if state.find_ordinate(id).is_some() {
1803            self.sheet_update_ordinate(id, &params)
1804        } else if state.locate_view(id).is_some() {
1805            self.sheet_set_placement_refs(id, field, names)
1806        } else {
1807            Err(format!("no sheet object '{id}'"))
1808        }
1809    }
1810
1811    // --- detail views ------------------------------------------------------
1812
1813    /// Add a DETAIL VIEW of `source`: the region inside the circle centred on
1814    /// anchor `centre` and passing through anchor `rim`, redrawn at `scale`
1815    /// (twice the source's when absent) and placed at `position` (beside the
1816    /// source when absent).
1817    ///
1818    /// The detail places the SAME saved PMI view its source does and looks
1819    /// through the source's camera; its scale and its clip are its own.
1820    /// Returns the placement id.
1821    pub fn sheet_add_detail(
1822        &mut self,
1823        source: &str,
1824        centre: &str,
1825        rim: &str,
1826        position: Option<[f64; 2]>,
1827        scale: Option<f64>,
1828    ) -> Result<String, String> {
1829        let (a, b) = (SheetAnchor::parse(centre)?, SheetAnchor::parse(rim)?);
1830        if centre == rim {
1831            return Err("a detail circle needs two different anchors: its centre and a point on its rim".into());
1832        }
1833        // Both on ONE placement: the circle is drawn on that view, and it is
1834        // that view's camera the detail looks through.
1835        if a.view != b.view {
1836            return Err(format!(
1837                "'{centre}' is on '{}' and '{rim}' on '{}'; a detail circle is drawn on ONE view",
1838                a.view, b.view
1839            ));
1840        }
1841        if a.view != source {
1842            return Err(format!("the detail circle is on '{}', not on '{source}'", a.view));
1843        }
1844        if let Some(scale) = scale {
1845            if !scale.is_finite() || scale <= 0.0 {
1846                return Err(format!("scale: {scale} is not a positive ratio"));
1847            }
1848        }
1849        let mut state = self.sheet_state();
1850        let Some((sheet_id, _)) = state.locate_view(source) else {
1851            return Err(format!("no placed view '{source}'"));
1852        };
1853        let (view, parent_scale, at, label) = {
1854            let sheet = state
1855                .find_sheet(&sheet_id)
1856                .ok_or_else(|| format!("no sheet '{sheet_id}'"))?;
1857            let label = sheet.next_view_letter(&self.pmi_state().views);
1858            let parent = sheet
1859                .find_view(source)
1860                .ok_or_else(|| format!("no placed view '{source}'"))?;
1861            // Refused BY NAME, both of them: the detail pass is one pass over
1862            // plain sources, so a chain is said out loud rather than drawn
1863            // half-resolved.
1864            if parent.detail.is_some() {
1865                return Err(format!("'{source}' is itself a detail; a detail of a detail is not drawn"));
1866            }
1867            if parent.section.is_some() {
1868                return Err(format!("'{source}' is a section; a detail of a section is not drawn"));
1869            }
1870            (parent.view.clone(), parent.scale, parent.position, label)
1871        };
1872        let id = state.next_id("SV");
1873        let sheet = state.find_sheet_mut(&sheet_id).expect("just found");
1874        sheet.views.push(PlacedView {
1875            id: id.clone(),
1876            view,
1877            // Beside the source by default, as a section is: a detail drawn on
1878            // top of the view it enlarges would hide the circle that names it.
1879            position: position.unwrap_or([at[0] + 120.0, at[1]]),
1880            scale: scale.unwrap_or(parent_scale * DETAIL_SCALE_FACTOR),
1881            projection: ORTHOGRAPHIC.to_string(),
1882            flatten_text: true,
1883            three_d: false,
1884            section: None,
1885            detail: Some(DetailCircle {
1886                source: source.to_string(),
1887                centre: centre.to_string(),
1888                rim: rim.to_string(),
1889                label,
1890            }),
1891        });
1892        self.write_sheet_state(state, None);
1893        self.set_open_sheet(Some(sheet_id));
1894        self.open_sheet_object(id.clone());
1895        Ok(id)
1896    }
1897
1898    /// The offset that would put dimension `id`'s line through the paper point
1899    /// `at`: the component of `at - (the dimension's own base point)` along the
1900    /// direction the offset moves the line in. `None` when the dimension has
1901    /// not resolved — there is nothing to measure from.
1902    ///
1903    /// This is a PROBE of the current projection rather than a second layout:
1904    /// it re-reads the dimension's drawn label, which already sits at
1905    /// `base + offset`, so the answer is exact for any kind without this
1906    /// module knowing how any of them is constructed.
1907    pub fn sheet_dimension_offset_at(&mut self, id: &str, at: [f64; 2]) -> Option<f64> {
1908        let sheet_id = self.sheet_state().locate_dimension(id).map(|(sheet, _)| sheet)?;
1909        let drawing = self.sheet_drawing(&sheet_id)?;
1910        let drawn = drawing.dimensions.iter().find(|d| d.id == id)?;
1911        if !drawn.error.is_empty() {
1912            return None;
1913        }
1914        let (label, offset) = (drawn.label, drawn.offset_mm);
1915        // The label moves along ONE direction as the offset changes, so one
1916        // more sample of the same projection would be a second hidden-line
1917        // pass. Take the direction from the drawn geometry instead: the label
1918        // sits `offset` from the base along it.
1919        let direction = self.sheet_dimension_direction(&sheet_id, id)?;
1920        let base = [label[0] - direction[0] * offset, label[1] - direction[1] * offset];
1921        Some((at[0] - base[0]) * direction[0] + (at[1] - base[1]) * direction[1])
1922    }
1923
1924    /// The unit paper direction dimension `id`'s offset moves its line along.
1925    fn sheet_dimension_direction(&mut self, sheet_id: &str, id: &str) -> Option<[f64; 2]> {
1926        // Re-lay the dimension out one millimetre further and read where the
1927        // label went: one projection is already cached, and the SECOND layout
1928        // is over the cached placements, not a fresh hidden-line pass.
1929        let state = self.sheet_state();
1930        let sheet = state.find_sheet(sheet_id)?;
1931        let mut probe = sheet.dimensions.iter().find(|d| d.id == id)?.clone();
1932        // Step AWAY from zero: a linear dimension measures its offset from
1933        // whichever anchor is farther from the side it is offset to, and that
1934        // choice turns over at zero — a probe that crossed it would read the
1935        // step between two different base points.
1936        let delta = if probe.offset_mm < 0.0 { -1.0 } else { 1.0 };
1937        probe.offset_mm += delta;
1938        let drawing = self.sheet_drawing_cached(sheet_id)?;
1939        let moved = crate::sheets::dimension::layout_dimensions(
1940            std::slice::from_ref(&probe),
1941            &drawing.views,
1942            [drawing.width_mm * 0.5, drawing.height_mm * 0.5],
1943        );
1944        let here = drawing.dimensions.iter().find(|d| d.id == id)?;
1945        let there = moved.first()?;
1946        if !there.error.is_empty() {
1947            return None;
1948        }
1949        let step = [
1950            (there.label[0] - here.label[0]) / delta,
1951            (there.label[1] - here.label[1]) / delta,
1952        ];
1953        let len = (step[0] * step[0] + step[1] * step[1]).sqrt();
1954        (len > 1e-9).then(|| [step[0] / len, step[1] / len])
1955    }
1956
1957    /// Drop everything a sheet holds about the document being replaced.
1958    pub(crate) fn forget_sheets(&mut self) {
1959        self.sheet_open = None;
1960        self.sheet_open_object = None;
1961        self.sheet_selected_object = None;
1962        self.sheet_cache = None;
1963        self.sheet_lines.forget();
1964    }
1965}
1966
1967/// Orthographic only, REFUSED at placement — by the placement door and by
1968/// the form that re-points a placement — rather than drawn flattened: a
1969/// perspective drawing is a different construction, not a worse one.
1970fn refuse_perspective(view_id: &str, camera: &brep_kernel::PmiCamera) -> Result<(), String> {
1971    if matches!(camera.projection, brep_kernel::PmiProjection::Orthographic { .. }) {
1972        Ok(())
1973    } else {
1974        Err(format!("PMI view '{view_id}' is a perspective camera; a sheet places {ORTHOGRAPHIC} views only"))
1975    }
1976}
1977
1978
1979
1980
1981