Skip to main content

brep_render/engine_state/
sheet_ops.rs

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