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