1use 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
36pub(crate) struct SheetCache {
40 pub key: (String, u64, u64, u64, u64, u64, String),
41 pub drawing: SheetDrawing,
42 pub complete: bool,
46 pub reproject: bool,
50 pub drawing_json: String,
55}
56
57#[derive(Default)]
69pub(crate) struct SheetLinesWork {
70 next_id: u64,
71 in_flight: Option<LinesFlight>,
72 parked: Vec<LinesJob>,
73 refused: std::collections::HashSet<String>,
75 unsupported: bool,
77 revision: u64,
79}
80
81struct LinesFlight {
82 id: u64,
83 placement: String,
84 key: String,
85 superseded: bool,
86}
87
88impl SheetLinesWork {
89 pub(crate) fn pending(&self) -> bool {
91 self.in_flight.is_some() || !self.parked.is_empty()
92 }
93
94 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 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 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 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 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; }
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 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 pub fn sheet_open(&self) -> Option<&str> {
197 self.sheet_open.as_deref()
198 }
199
200 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 pub fn sheet_open_object(&self) -> Option<&str> {
217 self.sheet_open_object.as_deref()
218 }
219
220 pub fn sheet_object_opens(&self) -> u64 {
225 self.sheet_object_opens
226 }
227
228 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 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 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 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 fn set_open_sheet(&mut self, id: Option<String>) {
280 self.sheet_open = id;
281 }
282
283 pub fn sheet_pmi_view_ids(&self) -> Vec<String> {
285 self.pmi_state().views.iter().map(|view| view.id.clone()).collect()
286 }
287
288 pub fn sheet_catalogue(&self) -> Value {
291 crate::sheets::schema_catalogue(&self.sheet_pmi_view_ids())
292 }
293
294 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 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 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 let env = self.sheet_env();
350 let mut complete = true;
351 let (runner, work) = (&mut self.runner, &mut self.sheet_lines);
352 let mut defer = |job: LinesJob| {
353 let answer = work.defer(runner.as_mut(), job);
354 complete &= !matches!(answer, Deferred::Pending);
355 answer
356 };
357 let model = Model { scene: &self.scene, unposed: &self.pmi_explode_originals, env: &env };
358 let mut drawing = project_sheet_with(
359 &model,
360 &sheet,
361 &pmi.views,
362 self.pmi_report.as_ref(),
363 &context,
364 &mut LinesMode::Defer(&mut defer),
365 );
366 self.draw_sheet_bom(&sheet, &mut drawing);
367 let drawing_json = serde_json::to_string(&drawing).unwrap_or_else(|_| "null".into());
368 let landed = self.sheet_cache_key(id, &context);
374 let reproject = !complete && landed != key;
375 let key = landed;
376 self.sheet_cache = Some(SheetCache { key, drawing, drawing_json, complete, reproject });
377 }
378 true
379 }
380
381 pub(super) fn sheet_env(&self) -> brep_kernel::Env {
384 brep_kernel::Env::build(&self.history.expressions(), &self.history.configurator()).unwrap_or_default()
385 }
386
387 pub fn pump_sheet_lines(&mut self) {
389 self.sheet_lines.pump(self.runner.as_mut());
390 }
391
392 pub fn sheet_lines_pending(&self) -> bool {
395 self.sheet_lines.pending()
396 }
397
398 fn request_sheet_topology(&mut self, sheet: &crate::sheets::Sheet) {
403 let lacking = self.scene.solids().iter().any(|solid| {
405 solid.source_handle != 0
406 && !solid.is_sketch
407 && !self.topology_asked.contains(&solid.source_handle)
408 && self.scene.exact_solid(&solid.name).is_none()
409 });
410 if !lacking {
411 self.pump_topology();
412 return;
413 }
414 let pmi = self.pmi_state();
415 let names = crate::sheets::project::sheet_solid_names(&self.scene, &self.pmi_explode_originals, sheet, &pmi.views);
416 self.request_exact_solids(names.iter().map(String::as_str));
417 }
418
419 fn sheet_frame_context(&self) -> FrameContext {
422 FrameContext { document: self.document_name.clone(), date: today_iso() }
423 }
424
425 fn sheet_cache_key(
426 &self,
427 id: &str,
428 context: &FrameContext,
429 ) -> (String, u64, u64, u64, u64, u64, String) {
430 (
431 id.to_string(),
432 self.applied_generation,
433 self.history.revision(),
434 self.scene.exact_revision(),
435 self.scene.visibility_revision() + self.pmi_unposed_visibility,
442 self.sheet_lines.revision,
443 format!("{}\u{1f}{}", context.document, context.date),
444 )
445 }
446
447 pub fn sheet_drawing_cached(&self, id: &str) -> Option<&SheetDrawing> {
451 let key = self.sheet_cache_key(id, &self.sheet_frame_context());
452 self.sheet_cache
453 .as_ref()
454 .filter(|cache| cache.key == key)
455 .map(|cache| &cache.drawing)
456 }
457
458 pub fn sheet_drawing(&mut self, id: &str) -> Option<&SheetDrawing> {
463 if !self.sheet_project(id) {
464 return None;
465 }
466 self.sheet_cache.as_ref().map(|cache| &cache.drawing)
467 }
468
469 pub fn export_sheet_svg(&mut self, id: &str) -> Result<String, String> {
471 self.plugin_export_ready()?;
472 let id = self.resolve_sheet(id)?;
473 let drawing = self
474 .sheet_drawing_exact(&id)
475 .ok_or_else(|| format!("no sheet '{id}'"))?;
476 Ok(crate::sheets::svg::to_svg(drawing))
477 }
478
479 pub fn sheet_drawing_exact(&mut self, id: &str) -> Option<&SheetDrawing> {
484 if !self.sheet_project(id) {
485 return None;
486 }
487 if self.sheet_cache.as_ref().is_some_and(|cache| !cache.complete) {
488 let state = self.sheet_state();
489 let sheet = state.find_sheet(id)?;
490 let pmi = self.pmi_state();
491 let env = self.sheet_env();
492 let model = Model { scene: &self.scene, unposed: &self.pmi_explode_originals, env: &env };
493 let mut drawing = project_sheet_with(
494 &model,
495 sheet,
496 &pmi.views,
497 self.pmi_report.as_ref(),
498 &self.sheet_frame_context(),
499 &mut LinesMode::Compute,
500 );
501 self.draw_sheet_bom(&sheet, &mut drawing);
502 let drawing_json = serde_json::to_string(&drawing).unwrap_or_else(|_| "null".into());
503 if let Some(cache) = self.sheet_cache.as_mut() {
504 cache.drawing = drawing;
505 cache.drawing_json = drawing_json;
506 cache.complete = true;
507 cache.reproject = false;
508 }
509 }
510 self.sheet_cache.as_ref().map(|cache| &cache.drawing)
511 }
512
513 pub fn export_sheet_pdf(&mut self, id: &str) -> Result<Vec<u8>, String> {
517 let id = self.resolve_sheet(id)?;
518 let name = self.sheet_state().find_sheet(&id).map(|sheet| sheet.name.clone()).unwrap_or_default();
519 self.export_sheets_pdf(&[id], &name)
520 }
521
522 pub fn export_document_pdf(&mut self) -> Result<Vec<u8>, String> {
528 self.export_document_pdf_with(false)
529 }
530
531 pub fn export_document_pdf_3d(&mut self) -> Result<Vec<u8>, String> {
535 self.export_document_pdf_with(true)
536 }
537
538 fn export_document_pdf_with(&mut self, three_d: bool) -> Result<Vec<u8>, String> {
539 let state = self.sheet_state();
540 if state.sheets.is_empty() {
541 return Err("the document has no sheets".into());
542 }
543 let ids: Vec<String> = state.sheets.iter().map(|sheet| sheet.id.clone()).collect();
544 let title = if self.document_name.trim().is_empty() {
545 state.sheets[0].name.clone()
546 } else {
547 self.document_name.clone()
548 };
549 self.export_sheets_pdf_with(&ids, &title, three_d)
550 }
551
552 pub fn export_sheets_pdf(&mut self, ids: &[String], title: &str) -> Result<Vec<u8>, String> {
559 self.export_sheets_pdf_with(ids, title, false)
560 }
561
562 pub fn export_sheets_pdf_with(&mut self, ids: &[String], title: &str, three_d: bool) -> Result<Vec<u8>, String> {
567 self.plugin_export_ready()?;
568 let state = self.sheet_state();
569 for id in ids {
570 if let Some(sheet) = state.find_sheet(id) {
571 self.request_sheet_topology(sheet);
572 }
573 }
574 let context = self.sheet_frame_context();
575 let pmi = self.pmi_state();
576 let mut drawings: Vec<SheetDrawing> = Vec::with_capacity(ids.len());
577 for id in ids {
578 let sheet = state.find_sheet(id).ok_or_else(|| format!("no sheet '{id}'"))?;
579 let complete = self.sheet_cache.as_ref().is_some_and(|cache| cache.complete);
580 let drawing = match self.sheet_drawing_cached(id).filter(|_| complete) {
581 Some(cached) => cached.clone(),
582 None => {
583 let mut drawing = project_sheet_with(
584 &Model { scene: &self.scene, unposed: &self.pmi_explode_originals, env: &self.sheet_env() },
585 sheet, &pmi.views, self.pmi_report.as_ref(), &context, &mut LinesMode::Compute,
586 );
587 self.draw_sheet_bom(sheet, &mut drawing);
588 drawing
589 },
590 };
591 drawings.push(drawing);
592 }
593 let pages: Vec<&SheetDrawing> = drawings.iter().collect();
594 if !three_d {
595 return Ok(crate::sheets::pdf::write_pdf(title, &pages));
596 }
597 let three_d = self.pdf3d_content(ids, &drawings, title)?;
598 Ok(crate::sheets::pdf::write_pdf_3d(title, &pages, Some(&three_d)))
599 }
600
601 fn pdf3d_content(
605 &self,
606 ids: &[String],
607 drawings: &[SheetDrawing],
608 title: &str,
609 ) -> Result<crate::sheets::pdf3d::Pdf3d, String> {
610 use crate::sheets::pdf3d::{self, Page3d, Placement3d};
611 let state = self.sheet_state();
612 let pmi = self.pmi_state();
613 let bodies = self.pdf3d_bodies();
614 let model = pdf3d::build(&bodies, &pmi, self.pmi_report.as_ref())?;
615 const MARGIN_MM: f64 = 4.0;
618 let mut placements = Vec::new();
619 for (page, (id, drawing)) in ids.iter().zip(drawings).enumerate() {
620 let Some(sheet) = state.find_sheet(id) else { continue };
621 for placed in sheet.views.iter().filter(|placed| placed.three_d) {
622 let Some(view) = drawing.views.iter().find(|view| view.id == placed.id) else { continue };
623 if !view.error.is_empty() || model.view(&placed.view).is_none() {
624 continue;
625 }
626 let [x0, y0, x1, y1] = view.bounds;
627 let p = placed.position;
628 let half = [
629 ((p[0] - x0).max(x1 - p[0]) + MARGIN_MM).max(10.0),
630 ((p[1] - y0).max(y1 - p[1]) + MARGIN_MM).max(10.0),
631 ];
632 placements.push(Placement3d {
633 page,
634 id: placed.id.clone(),
635 view: placed.view.clone(),
636 centre_mm: p,
637 half_mm: half,
638 scale: placed.scale,
639 });
640 }
641 }
642 let page = Some({
643 let (w, h) = state
644 .find_sheet(ids.first().map(String::as_str).unwrap_or(""))
645 .map(|sheet| sheet.millimetres())
646 .unwrap_or((297.0, 210.0));
647 let mut page = Page3d::layout(w.max(h), w.min(h), title, &model);
648 if let Some((poster, scale)) = self.pdf3d_poster(&model, &page, &pmi) {
649 page.poster = Some(poster);
650 page.poster_scale = Some(scale);
651 }
652 page
653 });
654 Ok(pdf3d::Pdf3d { model, placements, page })
655 }
656
657 fn pdf3d_poster(
663 &self,
664 model: &crate::sheets::pdf3d::Model3d,
665 page: &crate::sheets::pdf3d::Page3d,
666 pmi: &brep_kernel::PmiState,
667 ) -> Option<(crate::sheets::project::ViewDrawing, f64)> {
668 let default = model.views.get(page.default_view)?;
669 let mut camera = default.camera.clone();
670 if let brep_kernel::PmiProjection::Perspective { fov_y_deg } = camera.projection {
671 let distance = {
672 let d = [camera.target[0] - camera.eye[0], camera.target[1] - camera.eye[1], camera.target[2] - camera.eye[2]];
673 (d[0] * d[0] + d[1] * d[1] + d[2] * d[2]).sqrt()
674 };
675 camera.projection = brep_kernel::PmiProjection::Orthographic {
676 half_height: distance * (fov_y_deg.to_radians() * 0.5).tan(),
677 };
678 }
679 let mut views = pmi.views.clone();
680 match views.iter_mut().find(|view| view.id == default.id) {
681 Some(view) => view.camera = Some(camera),
682 None => views.push(brep_kernel::PmiView {
683 id: default.id.clone(),
684 name: default.name.clone(),
685 camera: Some(camera),
686 display: Default::default(),
687 annotations: Vec::new(),
688 }),
689 }
690 let centre = page.box_centre_mm();
691 let half = page.box_half_mm();
692 let project = |scale: f64| -> Option<crate::sheets::project::ViewDrawing> {
693 let mut sheet: crate::sheets::Sheet = serde_json::from_value(serde_json::json!({
694 "id": "MODEL3D",
695 "size": crate::sheets::CUSTOM,
696 "widthMm": page.width_mm,
697 "heightMm": page.height_mm,
698 "border": false,
699 "titleBlock": false,
700 }))
701 .ok()?;
702 sheet.views.push(PlacedView {
703 id: "MODEL3D".into(),
704 view: default.id.clone(),
705 position: centre,
706 scale,
707 projection: ORTHOGRAPHIC.to_string(),
708 flatten_text: true,
709 three_d: false,
710 section: None,
711 detail: None,
712 });
713 let drawing = project_sheet_with(
714 &Model { scene: &self.scene, unposed: &self.pmi_explode_originals, env: &self.sheet_env() },
715 &sheet,
716 &views,
717 self.pmi_report.as_ref(),
718 &self.sheet_frame_context(),
719 &mut LinesMode::Compute,
720 );
721 drawing.views.into_iter().next().filter(|view| view.error.is_empty())
722 };
723 let probe = project(1.0)?;
725 let [x0, y0, x1, y1] = probe.bounds;
726 let reach = [(centre[0] - x0).max(x1 - centre[0]), (centre[1] - y0).max(y1 - centre[1])];
727 if !(reach[0] > 1e-9 && reach[1] > 1e-9) {
728 return None;
729 }
730 let scale = (half[0] / reach[0]).min(half[1] / reach[1]) * 0.9;
731 project(scale).map(|drawing| (drawing, scale))
732 }
733
734 pub fn resolve_sheet(&self, id: &str) -> Result<String, String> {
737 let state = self.sheet_state();
738 let id = id.trim();
739 if !id.is_empty() {
740 return state
741 .find_sheet(id)
742 .map(|sheet| sheet.id.clone())
743 .ok_or_else(|| format!("no sheet '{id}'"));
744 }
745 if let Some(open) = self.sheet_open.as_ref().filter(|open| state.find_sheet(open).is_some()) {
746 return Ok(open.clone());
747 }
748 state
749 .sheets
750 .first()
751 .map(|sheet| sheet.id.clone())
752 .ok_or_else(|| "the document has no sheets".to_string())
753 }
754
755 fn write_sheet_state(&mut self, state: SheetState, coalesce_key: Option<&str>) {
760 let block = if state.is_empty() { None } else { serde_json::to_value(&state).ok() };
761 self.history.set_sheets_block(block, coalesce_key);
762 self.dirty = true;
763 }
764
765 pub fn sheet_add(&mut self, name: Option<&str>) -> String {
770 let mut state = self.sheet_state();
771 let id = state.next_id("SHEET");
772 let name = name
773 .map(str::trim)
774 .filter(|name| !name.is_empty())
775 .map(String::from)
776 .unwrap_or_else(|| format!("Sheet {}", state.sheets.len() + 1));
777 let (width_mm, height_mm) = crate::sheets::paper_size(DEFAULT_SIZE).unwrap_or((420.0, 297.0));
778 state.sheets.push(Sheet {
779 id: id.clone(),
780 name,
781 size: DEFAULT_SIZE.to_string(),
782 width_mm,
783 height_mm,
784 border: true,
785 border_inset_mm: DEFAULT_BORDER_INSET_MM,
786 title_block: true,
787 notes: String::new(),
788 views: Vec::new(),
789 dimensions: Vec::new(),
790 ordinates: Vec::new(),
791 revisions: Vec::new(),
792 bom_table: None,
793 });
794 self.write_sheet_state(state, None);
795 self.set_open_sheet(Some(id.clone()));
796 id
797 }
798
799 pub fn sheet_insert_bom(&mut self) -> Result<(), String> {
801 let id = self
802 .sheet_open()
803 .ok_or("open a drawing sheet first")?
804 .to_owned();
805 if self
806 .sheet_state()
807 .find_sheet(&id)
808 .and_then(|s| s.bom_table.as_ref())
809 .is_none()
810 {
811 self.sheet_update(
812 &id,
813 &serde_json::json!({ "bomTable": crate::sheets::BomTable::default() }).to_string(),
814 )?;
815 }
816 self.sheet_set_object_open(Some(&id));
817 Ok(())
818 }
819
820 pub fn sheet_bom_columns(&self) -> Vec<String> {
822 let mut columns = std::collections::BTreeSet::from([
823 "partName".to_owned(),
824 "occurrence.Quantity".to_owned(),
825 "occurrence.Find_Number".to_owned(),
826 "part.Part_Number".to_owned(),
827 "part.Description".to_owned(),
828 "part.Material".to_owned(),
829 ]);
830 for component in &self.assembly_components {
831 for (scope, attrs) in [
832 ("part", self.part_attributes(&component.part_name)),
833 ("occurrence", self.occurrence_attributes(&component.id)),
834 ] {
835 if let Some(attrs) = attrs.as_object() {
836 columns.extend(attrs.keys().map(|key| format!("{scope}.{key}")));
837 }
838 }
839 }
840 columns.into_iter().collect()
841 }
842
843 fn draw_sheet_bom(&self, sheet: &Sheet, drawing: &mut SheetDrawing) {
844 let Some(table) = &sheet.bom_table else {
845 return;
846 };
847 let mut rows: std::collections::BTreeMap<Vec<String>, usize> =
848 std::collections::BTreeMap::new();
849 let occurrences = self.occurrence_attributes_all();
850 let mut parts = std::collections::HashMap::new();
851 for component in &self.assembly_components {
852 let part = parts
853 .entry(component.part_name.clone())
854 .or_insert_with(|| self.part_attributes(&component.part_name));
855 let occurrence = occurrences.get(&component.id).unwrap_or(&Value::Null);
856 let cells: Vec<String> = table
857 .columns
858 .iter()
859 .map(|column| {
860 if column == "partName" {
861 return component.part_name.clone();
862 }
863 if column == "quantity" || column == "occurrence.Quantity" {
864 return String::new();
865 }
866 let (scope, key) = column.split_once('.').unwrap_or(("part", column));
867 if column == "part.PMI" {
868 return self.part_pmi_count(&component.part_name).to_string();
869 }
870 let value = if scope == "occurrence" {
871 &occurrence[key]
872 } else {
873 &part[key]
874 };
875 match value {
876 Value::Null => String::new(),
877 Value::String(s) => s.clone(),
878 v => v.to_string(),
879 }
880 })
881 .collect();
882 let mut key = cells;
884 key.push(component.part_name.clone());
885 *rows.entry(key).or_default() += 1;
886 }
887 for wire in self.wire_bom_lines() {
888 let mut cells: Vec<String> = table
889 .columns
890 .iter()
891 .map(|column| match column.as_str() {
892 "partName" | "part.Part_Number" => wire.stock_part_number.clone(),
893 "occurrence.MF_QTY" => wire
894 .mf_qty
895 .map(|q| crate::formatting::compact_decimal(q, 3))
896 .unwrap_or_else(|| wire.state.as_str().to_owned()),
897 "occurrence.Reference_Designator" => wire.connection_id.clone(),
898 _ => String::new(),
899 })
900 .collect();
901 cells.push(format!("wire:{}", wire.connection_id));
902 rows.insert(cells, 1);
903 }
904 let frame = drawing
905 .frame
906 .get_or_insert_with(|| crate::sheets::frame::FrameDrawing {
907 lines: Vec::new(),
908 texts: Vec::new(),
909 revision_table: None,
910 });
911 let [x, y] = table.position;
912 let width = table.column_width_mm;
913 let height = 7.;
914 let count = rows.len() + 1;
915 for row in 0..=count {
916 frame.lines.push(vec![
917 [x, y + row as f64 * height],
918 [
919 x + table.columns.len() as f64 * width,
920 y + row as f64 * height,
921 ],
922 ]);
923 }
924 for col in 0..=table.columns.len() {
925 frame.lines.push(vec![
926 [x + col as f64 * width, y],
927 [x + col as f64 * width, y + count as f64 * height],
928 ]);
929 }
930 let headers: Vec<String> = table
931 .columns
932 .iter()
933 .map(|c| match c.as_str() {
934 "partName" => "Part name".into(),
935 "quantity" => "Quantity".into(),
936 _ => c
937 .split_once('.')
938 .map_or(c.as_str(), |(_, key)| key)
939 .replace('_', " "),
940 })
941 .collect();
942 let mut all = vec![headers];
943 for (mut cells, quantity) in rows {
944 cells.pop();
945 for (i, column) in table.columns.iter().enumerate() {
946 if column == "quantity" || column == "occurrence.Quantity" {
947 cells[i] = quantity.to_string();
948 }
949 }
950 all.push(cells);
951 }
952 for (row, cells) in all.iter().enumerate() {
953 for (col, cell) in cells.iter().enumerate() {
954 let available = ((width - 2.) / (2.5 * 0.65)).floor() as usize;
955 let text = if cell.chars().count() > available {
956 format!(
957 "{}…",
958 cell.chars()
959 .take(available.saturating_sub(1))
960 .collect::<String>()
961 )
962 } else {
963 cell.clone()
964 };
965 frame.texts.push(crate::sheets::project::SheetText::new(
966 text,
967 [
968 x + (col as f64 + 0.5) * width,
969 y + (row as f64 + 0.5) * height,
970 ],
971 [2.5, 0.],
972 [0., -2.5],
973 ));
974 }
975 }
976 }
977
978 pub fn sheet_update(&mut self, id: &str, params_json: &str) -> Result<(), String> {
980 let params: Value = serde_json::from_str(params_json)
981 .map_err(|error| format!("sheet params: {error}"))?;
982 let mut state = self.sheet_state();
983 let sheet = state.find_sheet_mut(id).ok_or_else(|| format!("no sheet '{id}'"))?;
984 sheet.apply(¶ms)?;
985 self.write_sheet_state(state, None);
986 Ok(())
987 }
988
989 pub fn sheet_add_revision(
996 &mut self,
997 id: &str,
998 rev: Option<&str>,
999 date: Option<&str>,
1000 description: Option<&str>,
1001 ) -> Result<usize, String> {
1002 let mut state = self.sheet_state();
1003 let sheet = state.find_sheet_mut(id).ok_or_else(|| format!("no sheet '{id}'"))?;
1004 let next = || {
1005 let letters = crate::sheets::SectionCut::LETTERS;
1006 let last = sheet.revisions.last().and_then(|row| row.rev.trim().chars().next());
1007 match last.and_then(|c| letters.iter().position(|l| l.eq_ignore_ascii_case(&c))) {
1008 Some(at) => letters[(at + 1).min(letters.len() - 1)].to_string(),
1009 None if sheet.revisions.is_empty() => letters[0].to_string(),
1010 None => String::new(),
1011 }
1012 };
1013 let revision = crate::sheets::Revision {
1014 rev: rev.map(str::to_string).unwrap_or_else(next),
1015 date: date.map(str::to_string).unwrap_or_else(today_iso),
1016 description: description.unwrap_or_default().to_string(),
1017 };
1018 sheet.revisions.push(revision);
1019 let index = sheet.revisions.len() - 1;
1020 self.write_sheet_state(state, None);
1021 Ok(index)
1022 }
1023
1024 pub fn sheet_update_revision(&mut self, id: &str, index: usize, params_json: &str) -> Result<(), String> {
1027 let params: Value = serde_json::from_str(params_json)
1028 .map_err(|error| format!("revision params: {error}"))?;
1029 let mut state = self.sheet_state();
1030 let sheet = state.find_sheet_mut(id).ok_or_else(|| format!("no sheet '{id}'"))?;
1031 let count = sheet.revisions.len();
1032 let row = sheet
1033 .revisions
1034 .get_mut(index)
1035 .ok_or_else(|| format!("sheet '{id}' has {count} revision row(s); there is no row {index}"))?;
1036 row.apply(¶ms)?;
1037 self.write_sheet_state(state, None);
1038 Ok(())
1039 }
1040
1041 pub fn sheet_remove_revision(&mut self, id: &str, index: usize) -> Result<(), String> {
1044 let mut state = self.sheet_state();
1045 let sheet = state.find_sheet_mut(id).ok_or_else(|| format!("no sheet '{id}'"))?;
1046 if index >= sheet.revisions.len() {
1047 return Err(format!(
1048 "sheet '{id}' has {} revision row(s); there is no row {index}",
1049 sheet.revisions.len()
1050 ));
1051 }
1052 sheet.revisions.remove(index);
1053 self.write_sheet_state(state, None);
1054 Ok(())
1055 }
1056
1057 pub fn sheet_delete(&mut self, id: &str) -> Result<(), String> {
1058 let mut state = self.sheet_state();
1059 let before = state.sheets.len();
1060 state.sheets.retain(|sheet| sheet.id != id);
1061 if state.sheets.len() == before {
1062 return Err(format!("no sheet '{id}'"));
1063 }
1064 if self.sheet_open.as_deref() == Some(id) {
1065 self.set_open_sheet(state.sheets.first().map(|sheet| sheet.id.clone()));
1066 }
1067 self.clear_open_object(&state);
1068 self.write_sheet_state(state, None);
1069 Ok(())
1070 }
1071
1072 pub fn sheet_set_open(&mut self, id: Option<&str>) -> Result<(), String> {
1074 match id {
1075 None => {
1076 self.set_open_sheet(None);
1077 self.dirty = true;
1078 Ok(())
1079 }
1080 Some(id) => {
1081 let state = self.sheet_state();
1082 if state.find_sheet(id).is_none() {
1083 return Err(format!("no sheet '{id}'"));
1084 }
1085 self.set_open_sheet(Some(id.to_string()));
1086 self.dirty = true;
1087 Ok(())
1088 }
1089 }
1090 }
1091
1092 pub fn sheet_set_object_open(&mut self, id: Option<&str>) {
1097 match id {
1098 Some(id) => self.open_sheet_object(id.to_string()),
1099 None => self.sheet_open_object = None,
1100 }
1101 }
1102
1103 pub fn sheet_place_view(
1109 &mut self,
1110 sheet_id: Option<&str>,
1111 view_id: &str,
1112 position: Option<[f64; 2]>,
1113 scale: Option<f64>,
1114 ) -> Result<String, String> {
1115 let pmi = self.pmi_state();
1116 let view = pmi
1117 .find_view(view_id)
1118 .ok_or_else(|| format!("no PMI view '{view_id}'"))?;
1119 let camera = view
1120 .camera
1121 .as_ref()
1122 .ok_or_else(|| format!("PMI view '{view_id}' has no captured camera — capture one first"))?;
1123 refuse_perspective(view_id, camera)?;
1124 let mut state = self.sheet_state();
1125 let sheet_id = match sheet_id.map(str::trim).filter(|id| !id.is_empty()) {
1126 Some(id) => id.to_string(),
1127 None => self.resolve_sheet("")?,
1128 };
1129 let id = state.next_id("SV");
1130 let sheet = state
1131 .find_sheet_mut(&sheet_id)
1132 .ok_or_else(|| format!("no sheet '{sheet_id}'"))?;
1133 let (width_mm, height_mm) = sheet.millimetres();
1134 let scale = match scale {
1135 Some(scale) if scale.is_finite() && scale > 0.0 => scale,
1136 Some(scale) => return Err(format!("scale: {scale} is not a positive ratio")),
1137 None => DEFAULT_SCALE,
1138 };
1139 sheet.views.push(PlacedView {
1140 id: id.clone(),
1141 view: view_id.to_string(),
1142 position: position.unwrap_or([width_mm * 0.5, height_mm * 0.5]),
1143 scale,
1144 projection: ORTHOGRAPHIC.to_string(),
1145 flatten_text: true,
1146 three_d: false,
1147 section: None,
1148 detail: None,
1149 });
1150 self.write_sheet_state(state, None);
1151 self.set_open_sheet(Some(sheet_id));
1152 self.open_sheet_object(id.clone());
1153 Ok(id)
1154 }
1155
1156 pub fn sheet_update_view(&mut self, id: &str, params_json: &str) -> Result<(), String> {
1158 let mut params: Value = serde_json::from_str(params_json)
1159 .map_err(|error| format!("placed view params: {error}"))?;
1160 if let Some(object) = params.as_object_mut() {
1165 let current = self.sheet_state().sheets.iter().find_map(|sheet| sheet.find_view(id).cloned());
1166 for field in ["cut", "centre", "rim"] {
1167 let Some(value) = object.remove(field) else { continue };
1168 let names: Vec<String> = match value.as_str() {
1170 Some(one) => vec![one.to_string()],
1171 None => crate::json_support::string_values(Some(&value)).map(str::to_string).collect(),
1172 }
1173 .into_iter()
1174 .filter(|name| !name.is_empty())
1175 .collect();
1176 let now = current.as_ref().map(|placed| placed.picked(field)).unwrap_or_default();
1177 if names != now {
1178 self.sheet_set_placement_refs(id, field, &names)?;
1179 }
1180 }
1181 }
1182 if let Some(view_id) = params.get("view").and_then(Value::as_str).map(str::trim).filter(|id| !id.is_empty()) {
1185 if let Some(camera) = self.pmi_state().find_view(view_id).and_then(|view| view.camera.clone()) {
1186 refuse_perspective(view_id, &camera)?;
1187 }
1188 }
1189 let views = self.sheet_pmi_view_ids();
1190 let mut state = self.sheet_state();
1191 let view = state.find_view_mut(id).ok_or_else(|| format!("no placed view '{id}'"))?;
1192 view.apply(¶ms, &views)?;
1193 self.write_sheet_state(state, None);
1194 Ok(())
1195 }
1196
1197 pub fn sheet_move_view(&mut self, id: &str, position: [f64; 2], coalesce: bool) -> Result<(), String> {
1200 if !position[0].is_finite() || !position[1].is_finite() {
1201 return Err("position: not a finite point".into());
1202 }
1203 let mut state = self.sheet_state();
1204 let view = state.find_view_mut(id).ok_or_else(|| format!("no placed view '{id}'"))?;
1205 view.position = position;
1206 let key = format!("sheet:view:{id}");
1207 self.write_sheet_state(state, coalesce.then_some(key.as_str()));
1208 Ok(())
1209 }
1210
1211 pub fn sheet_move_view_end(&mut self) {
1213 self.history.break_coalescing();
1214 }
1215
1216 pub fn sheet_remove_view(&mut self, id: &str) -> Result<(), String> {
1217 let mut state = self.sheet_state();
1218 let Some((sheet_id, index)) = state.locate_view(id) else {
1219 return Err(format!("no placed view '{id}'"));
1220 };
1221 if let Some(sheet) = state.find_sheet_mut(&sheet_id) {
1222 sheet.views.remove(index);
1223 }
1224 self.clear_open_object(&state);
1225 self.write_sheet_state(state, None);
1226 Ok(())
1227 }
1228
1229 fn clear_open_object(&mut self, state: &SheetState) {
1231 if let Some(open) = self.sheet_open_object.clone() {
1232 if state.find_sheet(&open).is_none()
1233 && state.locate_view(&open).is_none()
1234 && state.locate_dimension(&open).is_none()
1235 && state.locate_ordinate(&open).is_none()
1236 {
1237 self.sheet_open_object = None;
1238 }
1239 }
1240 }
1241
1242 pub fn sheet_add_dimension(
1249 &mut self,
1250 sheet_id: Option<&str>,
1251 kind: &str,
1252 alignment: Option<&str>,
1253 anchors: Vec<String>,
1254 ) -> Result<String, String> {
1255 let kind = KINDS
1256 .iter()
1257 .find(|known| known.eq_ignore_ascii_case(kind.trim()))
1258 .ok_or_else(|| format!("no dimension kind '{kind}'"))?;
1259 let alignment = match alignment.map(str::trim).filter(|a| !a.is_empty()) {
1260 None => ALIGNED,
1261 Some(wanted) => ALIGNMENTS
1262 .iter()
1263 .copied()
1264 .find(|known| known.eq_ignore_ascii_case(wanted))
1265 .ok_or_else(|| format!("no alignment '{wanted}'"))?,
1266 };
1267 for anchor in &anchors {
1268 SheetAnchor::parse(anchor)?;
1269 }
1270 let sheet_id = match sheet_id.map(str::trim).filter(|id| !id.is_empty()) {
1271 Some(id) => id.to_string(),
1272 None => self.resolve_sheet("")?,
1273 };
1274 let mut state = self.sheet_state();
1275 let id = state.next_id("SD");
1276 let sheet = state
1277 .find_sheet_mut(&sheet_id)
1278 .ok_or_else(|| format!("no sheet '{sheet_id}'"))?;
1279 sheet
1280 .dimensions
1281 .push(SheetDimension::new(id.clone(), kind, alignment, anchors));
1282 self.write_sheet_state(state, None);
1283 self.set_open_sheet(Some(sheet_id));
1284 self.open_sheet_object(id.clone());
1285 Ok(id)
1286 }
1287
1288 pub fn sheet_update_dimension(&mut self, id: &str, params_json: &str) -> Result<(), String> {
1290 let params: Value = serde_json::from_str(params_json)
1291 .map_err(|error| format!("sheet dimension params: {error}"))?;
1292 let mut state = self.sheet_state();
1293 let dimension = state
1294 .find_dimension_mut(id)
1295 .ok_or_else(|| format!("no sheet dimension '{id}'"))?;
1296 dimension.apply(¶ms)?;
1297 self.write_sheet_state(state, None);
1298 Ok(())
1299 }
1300
1301 pub fn sheet_move_dimension(
1305 &mut self,
1306 id: &str,
1307 offset_mm: f64,
1308 coalesce: bool,
1309 ) -> Result<(), String> {
1310 if !offset_mm.is_finite() {
1311 return Err(format!("offsetMm: {offset_mm} is not a length"));
1312 }
1313 let mut state = self.sheet_state();
1314 let dimension = state
1315 .find_dimension_mut(id)
1316 .ok_or_else(|| format!("no sheet dimension '{id}'"))?;
1317 dimension.offset_mm = offset_mm;
1318 let key = format!("sheet:dim:{id}");
1319 self.write_sheet_state(state, coalesce.then_some(key.as_str()));
1320 Ok(())
1321 }
1322
1323 pub fn sheet_move_dimension_end(&mut self) {
1325 self.history.break_coalescing();
1326 }
1327
1328 pub fn sheet_remove_dimension(&mut self, id: &str) -> Result<(), String> {
1329 let mut state = self.sheet_state();
1330 let Some((sheet_id, index)) = state.locate_dimension(id) else {
1331 return Err(format!("no sheet dimension '{id}'"));
1332 };
1333 if let Some(sheet) = state.find_sheet_mut(&sheet_id) {
1334 sheet.dimensions.remove(index);
1335 }
1336 self.clear_open_object(&state);
1337 self.write_sheet_state(state, None);
1338 Ok(())
1339 }
1340
1341 pub fn sheet_add_ordinate(
1347 &mut self,
1348 sheet_id: Option<&str>,
1349 axis: &str,
1350 datum: Option<&str>,
1351 members: Vec<String>,
1352 ) -> Result<String, String> {
1353 let axis = AXES
1354 .iter()
1355 .find(|known| known.eq_ignore_ascii_case(axis.trim()))
1356 .ok_or_else(|| format!("no ordinate axis '{axis}'"))?;
1357 let datum = datum.map(str::trim).filter(|d| !d.is_empty()).unwrap_or_default();
1358 if !datum.is_empty() {
1359 SheetAnchor::parse(datum)?;
1360 }
1361 for member in &members {
1362 SheetAnchor::parse(member)?;
1363 }
1364 let sheet_id = match sheet_id.map(str::trim).filter(|id| !id.is_empty()) {
1365 Some(id) => id.to_string(),
1366 None => self.resolve_sheet("")?,
1367 };
1368 let mut state = self.sheet_state();
1369 let id = state.next_id("OD");
1370 let sheet = state
1371 .find_sheet_mut(&sheet_id)
1372 .ok_or_else(|| format!("no sheet '{sheet_id}'"))?;
1373 let mut set = SheetOrdinate::new(id.clone(), axis, datum.to_string());
1374 set.members = members;
1375 sheet.ordinates.push(set);
1376 self.write_sheet_state(state, None);
1377 self.set_open_sheet(Some(sheet_id));
1378 self.open_sheet_object(id.clone());
1379 Ok(id)
1380 }
1381
1382 pub fn sheet_update_ordinate(&mut self, id: &str, params_json: &str) -> Result<(), String> {
1389 let params: Value = serde_json::from_str(params_json)
1390 .map_err(|error| format!("ordinate set params: {error}"))?;
1391 let mut state = self.sheet_state();
1392 let set = state
1393 .find_ordinate_mut(id)
1394 .ok_or_else(|| format!("no ordinate set '{id}'"))?;
1395 set.apply(¶ms)?;
1396 self.write_sheet_state(state, None);
1397 Ok(())
1398 }
1399
1400 pub fn sheet_move_ordinate(
1404 &mut self,
1405 id: &str,
1406 offset_mm: f64,
1407 coalesce: bool,
1408 ) -> Result<(), String> {
1409 if !offset_mm.is_finite() {
1410 return Err(format!("offsetMm: {offset_mm} is not a length"));
1411 }
1412 let mut state = self.sheet_state();
1413 let set = state
1414 .find_ordinate_mut(id)
1415 .ok_or_else(|| format!("no ordinate set '{id}'"))?;
1416 set.offset_mm = offset_mm;
1417 let key = format!("sheet:ord:{id}");
1418 self.write_sheet_state(state, coalesce.then_some(key.as_str()));
1419 Ok(())
1420 }
1421
1422 pub fn sheet_move_ordinate_end(&mut self) {
1424 self.history.break_coalescing();
1425 }
1426
1427 pub fn sheet_remove_ordinate(&mut self, id: &str) -> Result<(), String> {
1428 let mut state = self.sheet_state();
1429 let Some((sheet_id, index)) = state.locate_ordinate(id) else {
1430 return Err(format!("no ordinate set '{id}'"));
1431 };
1432 if let Some(sheet) = state.find_sheet_mut(&sheet_id) {
1433 sheet.ordinates.remove(index);
1434 }
1435 self.clear_open_object(&state);
1436 self.write_sheet_state(state, None);
1437 Ok(())
1438 }
1439
1440 pub fn sheet_ordinate_offset_at(&mut self, id: &str, at: [f64; 2]) -> Option<f64> {
1447 let sheet_id = self.sheet_state().locate_ordinate(id).map(|(sheet, _)| sheet)?;
1448 let vertical = self
1449 .sheet_state()
1450 .find_ordinate(id)?
1451 .axis
1452 .eq_ignore_ascii_case(crate::sheets::dimension::VERTICAL);
1453 let drawing = self.sheet_drawing(&sheet_id)?;
1454 let drawn = drawing.ordinates.iter().find(|o| o.id == id)?;
1455 if !drawn.error.is_empty() {
1456 return None;
1457 }
1458 let datum = drawn.stations.iter().find(|station| station.datum)?;
1459 if !datum.error.is_empty() {
1460 return None;
1461 }
1462 Some(if vertical { at[0] - datum.at[0] } else { at[1] - datum.at[1] })
1463 }
1464
1465 pub fn sheet_add_section(
1475 &mut self,
1476 source: &str,
1477 from: &str,
1478 to: &str,
1479 position: Option<[f64; 2]>,
1480 ) -> Result<String, String> {
1481 SheetAnchor::parse(from)?;
1482 SheetAnchor::parse(to)?;
1483 if from == to {
1484 return Err("a cutting line needs two different anchors".into());
1485 }
1486 let (a, b) = (SheetAnchor::parse(from)?, SheetAnchor::parse(to)?);
1489 if a.view != b.view {
1490 return Err(format!(
1491 "'{from}' is on '{}' and '{to}' on '{}'; a cutting line is drawn on ONE view",
1492 a.view, b.view
1493 ));
1494 }
1495 if a.view != source {
1496 return Err(format!("the cutting line is on '{}', not on '{source}'", a.view));
1497 }
1498 let mut state = self.sheet_state();
1499 let Some((sheet_id, _)) = state.locate_view(source) else {
1500 return Err(format!("no placed view '{source}'"));
1501 };
1502 let (view, scale, at, label) = {
1503 let sheet = state
1504 .find_sheet(&sheet_id)
1505 .ok_or_else(|| format!("no sheet '{sheet_id}'"))?;
1506 let label = sheet.next_view_letter(&self.pmi_state().views);
1507 let parent = sheet
1508 .find_view(source)
1509 .ok_or_else(|| format!("no placed view '{source}'"))?;
1510 if parent.section.is_some() {
1511 return Err(format!(
1512 "'{source}' is itself a section; a section of a section is not drawn"
1513 ));
1514 }
1515 if parent.detail.is_some() {
1516 return Err(format!("'{source}' is a detail; a section of a detail is not drawn"));
1517 }
1518 (parent.view.clone(), parent.scale, parent.position, label)
1519 };
1520 let id = state.next_id("SV");
1521 let sheet = state.find_sheet_mut(&sheet_id).expect("just found");
1522 sheet.views.push(PlacedView {
1523 id: id.clone(),
1524 view,
1525 position: position.unwrap_or([at[0] + 120.0, at[1]]),
1529 scale,
1530 projection: ORTHOGRAPHIC.to_string(),
1531 flatten_text: true,
1532 three_d: false,
1533 section: Some(SectionCut {
1534 source: source.to_string(),
1535 from: from.to_string(),
1536 to: to.to_string(),
1537 label,
1538 flip: false,
1539 }),
1540 detail: None,
1541 });
1542 self.write_sheet_state(state, None);
1543 self.set_open_sheet(Some(sheet_id));
1544 self.open_sheet_object(id.clone());
1545 Ok(id)
1546 }
1547
1548 pub fn sheet_new_section(&mut self, sheet_id: Option<&str>) -> Result<String, String> {
1555 self.sheet_new_derived(sheet_id, true)
1556 }
1557
1558 pub fn sheet_new_detail(&mut self, sheet_id: Option<&str>) -> Result<String, String> {
1562 self.sheet_new_derived(sheet_id, false)
1563 }
1564
1565 fn sheet_new_derived(&mut self, sheet_id: Option<&str>, section: bool) -> Result<String, String> {
1566 let sheet_id = match sheet_id.map(str::trim).filter(|id| !id.is_empty()) {
1567 Some(id) => id.to_string(),
1568 None => self.resolve_sheet("")?,
1569 };
1570 let mut state = self.sheet_state();
1571 let id = state.next_id("SV");
1572 let sheet = state
1573 .find_sheet_mut(&sheet_id)
1574 .ok_or_else(|| format!("no sheet '{sheet_id}'"))?;
1575 let label = sheet.next_view_letter(&self.pmi_state().views);
1576 let (w, h) = sheet.millimetres();
1577 sheet.views.push(PlacedView {
1578 id: id.clone(),
1579 view: String::new(),
1582 position: [w * 0.5, h * 0.5],
1583 scale: DEFAULT_SCALE,
1584 projection: ORTHOGRAPHIC.to_string(),
1585 flatten_text: true,
1586 three_d: false,
1587 section: section.then(|| SectionCut {
1588 source: String::new(),
1589 from: String::new(),
1590 to: String::new(),
1591 label: label.clone(),
1592 flip: false,
1593 }),
1594 detail: (!section).then(|| DetailCircle {
1595 source: String::new(),
1596 centre: String::new(),
1597 rim: String::new(),
1598 label,
1599 }),
1600 });
1601 self.write_sheet_state(state, None);
1602 self.set_open_sheet(Some(sheet_id));
1603 self.open_sheet_object(id.clone());
1604 Ok(id)
1605 }
1606
1607 pub fn sheet_set_placement_refs(&mut self, id: &str, field: &str, names: &[String]) -> Result<(), String> {
1620 for name in names {
1621 SheetAnchor::parse(name)?;
1622 }
1623 let mut state = self.sheet_state();
1624 let (sheet_id, _) = state.locate_view(id).ok_or_else(|| format!("no placed view '{id}'"))?;
1625 let sheet = state.find_sheet(&sheet_id).ok_or_else(|| format!("no sheet '{sheet_id}'"))?;
1626 let placed = sheet.find_view(id).ok_or_else(|| format!("no placed view '{id}'"))?.clone();
1627 let single = |names: &[String]| -> Result<String, String> {
1628 match names {
1629 [] => Ok(String::new()),
1630 [one] => Ok(one.clone()),
1631 _ => Err(format!("'{field}' is one anchor")),
1632 }
1633 };
1634 let anchors: Vec<String> = match (field, &placed.section, &placed.detail) {
1636 ("cut", Some(_), _) => {
1637 if names.len() > 2 {
1638 return Err("a cutting line is two anchors".into());
1639 }
1640 names.to_vec()
1641 }
1642 ("centre", _, Some(circle)) => vec![single(names)?, circle.rim.clone()],
1643 ("rim", _, Some(circle)) => vec![circle.centre.clone(), single(names)?],
1644 _ => return Err(format!("'{id}' has no {field} to pick")),
1645 };
1646 let picked: Vec<&String> = anchors.iter().filter(|a| !a.is_empty()).collect();
1647 if picked.len() == 2 && picked[0] == picked[1] {
1648 return Err(if placed.section.is_some() {
1649 "a cutting line needs two different anchors".into()
1650 } else {
1651 "a detail circle needs a radius: pick a rim point away from its centre".into()
1652 });
1653 }
1654 let on: Vec<String> = picked
1655 .iter()
1656 .map(|anchor| SheetAnchor::parse(anchor).map(|a| a.view))
1657 .collect::<Result<_, _>>()?;
1658 if on.windows(2).any(|pair| pair[0] != pair[1]) {
1659 return Err(format!(
1660 "'{}' is on '{}' and '{}' on '{}'; {} is drawn on ONE view",
1661 picked[0], on[0], picked[1], on[1],
1662 if placed.section.is_some() { "a cutting line" } else { "a detail circle" }
1663 ));
1664 }
1665 let source = on.first().cloned();
1666 let parent = match &source {
1667 Some(source) => {
1668 if source == id {
1669 return Err(format!("'{id}' cannot be drawn on itself"));
1670 }
1671 let parent = sheet
1672 .find_view(source)
1673 .ok_or_else(|| format!("no placement '{source}' on this sheet"))?;
1674 let what = if placed.section.is_some() { "section" } else { "detail" };
1675 if parent.section.is_some() {
1676 return Err(format!("'{source}' is a section; a {what} of a section is not drawn"));
1677 }
1678 if parent.detail.is_some() {
1679 return Err(format!("'{source}' is a detail; a {what} of a detail is not drawn"));
1680 }
1681 Some((parent.view.clone(), parent.scale, parent.position))
1682 }
1683 None => None,
1684 };
1685 let sheet = state.find_sheet_mut(&sheet_id).expect("just found");
1686 let placed = sheet.views.iter_mut().find(|view| view.id == id).expect("just found");
1687 let source = source.unwrap_or_default();
1688 if let Some(cut) = placed.section.as_mut() {
1689 cut.from = anchors.first().cloned().unwrap_or_default();
1690 cut.to = anchors.get(1).cloned().unwrap_or_default();
1691 cut.source = source;
1692 } else if let Some(circle) = placed.detail.as_mut() {
1693 circle.centre = anchors[0].clone();
1694 circle.rim = anchors[1].clone();
1695 circle.source = source;
1696 }
1697 if let Some((view, scale, at)) = parent {
1698 if placed.view.is_empty() {
1699 placed.view = view;
1700 placed.scale = if placed.detail.is_some() { scale * DETAIL_SCALE_FACTOR } else { scale };
1701 placed.position = [at[0] + 120.0, at[1]];
1702 }
1703 }
1704 self.write_sheet_state(state, None);
1705 Ok(())
1706 }
1707
1708 pub fn begin_ref_select_for_sheet(
1716 &mut self,
1717 id: &str,
1718 path: Vec<String>,
1719 label: String,
1720 filter: Vec<String>,
1721 multiple: bool,
1722 seed_names: Vec<String>,
1723 ) -> Result<(), String> {
1724 let state = self.sheet_state();
1725 let sheet = state
1726 .locate_view(id)
1727 .or_else(|| state.locate_dimension(id))
1728 .or_else(|| state.locate_ordinate(id))
1729 .map(|(sheet, _)| sheet)
1730 .ok_or_else(|| format!("no sheet object '{id}'"))?;
1731 self.set_open_sheet(Some(sheet));
1732 self.ref_select = Some(RefSelectState {
1733 feature_id: id.to_string(),
1734 path,
1735 label,
1736 filter,
1737 multiple,
1738 names: seed_names,
1739 restore_index: self.history.rollback(),
1740 target: RefSelectTarget::Sheet,
1741 picks: 0,
1742 claimed: false,
1743 finish_requested: false,
1744 });
1745 self.dirty = true;
1746 Ok(())
1747 }
1748
1749 fn sheet_ref_capacity(&self, id: &str, field: &str) -> Option<usize> {
1752 let state = self.sheet_state();
1753 match field {
1754 "anchors" => state
1755 .find_dimension(id)
1756 .map(|dimension| crate::sheets::dimension::anchor_count(&dimension.kind)),
1757 "cut" => Some(2),
1758 _ => None,
1759 }
1760 }
1761
1762 pub fn ref_select_pick_sheet_anchor(&mut self, anchor: &str) -> Result<(), String> {
1768 SheetAnchor::parse(anchor)?;
1769 let Some(state) = self.ref_select.as_ref().filter(|s| s.target == RefSelectTarget::Sheet) else {
1770 return Err("no sheet reference is being picked".into());
1771 };
1772 let capacity = state
1773 .path
1774 .last()
1775 .and_then(|field| self.sheet_ref_capacity(&state.feature_id, field));
1776 let state = self.ref_select.as_mut().expect("guarded above");
1777 if !state.multiple {
1778 state.names = vec![anchor.to_string()];
1779 } else if !state.names.iter().any(|name| name == anchor) {
1780 if capacity.is_some_and(|cap| cap > 0 && state.names.len() >= cap) {
1781 state.names.remove(0);
1782 }
1783 state.names.push(anchor.to_string());
1784 }
1785 state.picks += 1;
1786 self.dirty = true;
1787 Ok(())
1788 }
1789
1790 pub(crate) fn sheet_commit_refs(&mut self, id: &str, path: &[String], names: &[String], multiple: bool) -> Result<(), String> {
1792 let field = path.last().map(String::as_str).unwrap_or_default();
1793 let value = if multiple {
1794 Value::Array(names.iter().cloned().map(Value::String).collect())
1795 } else {
1796 Value::String(names.first().cloned().unwrap_or_default())
1797 };
1798 let params = serde_json::json!({ field: value }).to_string();
1799 let state = self.sheet_state();
1800 if state.find_dimension(id).is_some() {
1801 self.sheet_update_dimension(id, ¶ms)
1802 } else if state.find_ordinate(id).is_some() {
1803 self.sheet_update_ordinate(id, ¶ms)
1804 } else if state.locate_view(id).is_some() {
1805 self.sheet_set_placement_refs(id, field, names)
1806 } else {
1807 Err(format!("no sheet object '{id}'"))
1808 }
1809 }
1810
1811 pub fn sheet_add_detail(
1822 &mut self,
1823 source: &str,
1824 centre: &str,
1825 rim: &str,
1826 position: Option<[f64; 2]>,
1827 scale: Option<f64>,
1828 ) -> Result<String, String> {
1829 let (a, b) = (SheetAnchor::parse(centre)?, SheetAnchor::parse(rim)?);
1830 if centre == rim {
1831 return Err("a detail circle needs two different anchors: its centre and a point on its rim".into());
1832 }
1833 if a.view != b.view {
1836 return Err(format!(
1837 "'{centre}' is on '{}' and '{rim}' on '{}'; a detail circle is drawn on ONE view",
1838 a.view, b.view
1839 ));
1840 }
1841 if a.view != source {
1842 return Err(format!("the detail circle is on '{}', not on '{source}'", a.view));
1843 }
1844 if let Some(scale) = scale {
1845 if !scale.is_finite() || scale <= 0.0 {
1846 return Err(format!("scale: {scale} is not a positive ratio"));
1847 }
1848 }
1849 let mut state = self.sheet_state();
1850 let Some((sheet_id, _)) = state.locate_view(source) else {
1851 return Err(format!("no placed view '{source}'"));
1852 };
1853 let (view, parent_scale, at, label) = {
1854 let sheet = state
1855 .find_sheet(&sheet_id)
1856 .ok_or_else(|| format!("no sheet '{sheet_id}'"))?;
1857 let label = sheet.next_view_letter(&self.pmi_state().views);
1858 let parent = sheet
1859 .find_view(source)
1860 .ok_or_else(|| format!("no placed view '{source}'"))?;
1861 if parent.detail.is_some() {
1865 return Err(format!("'{source}' is itself a detail; a detail of a detail is not drawn"));
1866 }
1867 if parent.section.is_some() {
1868 return Err(format!("'{source}' is a section; a detail of a section is not drawn"));
1869 }
1870 (parent.view.clone(), parent.scale, parent.position, label)
1871 };
1872 let id = state.next_id("SV");
1873 let sheet = state.find_sheet_mut(&sheet_id).expect("just found");
1874 sheet.views.push(PlacedView {
1875 id: id.clone(),
1876 view,
1877 position: position.unwrap_or([at[0] + 120.0, at[1]]),
1880 scale: scale.unwrap_or(parent_scale * DETAIL_SCALE_FACTOR),
1881 projection: ORTHOGRAPHIC.to_string(),
1882 flatten_text: true,
1883 three_d: false,
1884 section: None,
1885 detail: Some(DetailCircle {
1886 source: source.to_string(),
1887 centre: centre.to_string(),
1888 rim: rim.to_string(),
1889 label,
1890 }),
1891 });
1892 self.write_sheet_state(state, None);
1893 self.set_open_sheet(Some(sheet_id));
1894 self.open_sheet_object(id.clone());
1895 Ok(id)
1896 }
1897
1898 pub fn sheet_dimension_offset_at(&mut self, id: &str, at: [f64; 2]) -> Option<f64> {
1908 let sheet_id = self.sheet_state().locate_dimension(id).map(|(sheet, _)| sheet)?;
1909 let drawing = self.sheet_drawing(&sheet_id)?;
1910 let drawn = drawing.dimensions.iter().find(|d| d.id == id)?;
1911 if !drawn.error.is_empty() {
1912 return None;
1913 }
1914 let (label, offset) = (drawn.label, drawn.offset_mm);
1915 let direction = self.sheet_dimension_direction(&sheet_id, id)?;
1920 let base = [label[0] - direction[0] * offset, label[1] - direction[1] * offset];
1921 Some((at[0] - base[0]) * direction[0] + (at[1] - base[1]) * direction[1])
1922 }
1923
1924 fn sheet_dimension_direction(&mut self, sheet_id: &str, id: &str) -> Option<[f64; 2]> {
1926 let state = self.sheet_state();
1930 let sheet = state.find_sheet(sheet_id)?;
1931 let mut probe = sheet.dimensions.iter().find(|d| d.id == id)?.clone();
1932 let delta = if probe.offset_mm < 0.0 { -1.0 } else { 1.0 };
1937 probe.offset_mm += delta;
1938 let drawing = self.sheet_drawing_cached(sheet_id)?;
1939 let moved = crate::sheets::dimension::layout_dimensions(
1940 std::slice::from_ref(&probe),
1941 &drawing.views,
1942 [drawing.width_mm * 0.5, drawing.height_mm * 0.5],
1943 );
1944 let here = drawing.dimensions.iter().find(|d| d.id == id)?;
1945 let there = moved.first()?;
1946 if !there.error.is_empty() {
1947 return None;
1948 }
1949 let step = [
1950 (there.label[0] - here.label[0]) / delta,
1951 (there.label[1] - here.label[1]) / delta,
1952 ];
1953 let len = (step[0] * step[0] + step[1] * step[1]).sqrt();
1954 (len > 1e-9).then(|| [step[0] / len, step[1] / len])
1955 }
1956
1957 pub(crate) fn forget_sheets(&mut self) {
1959 self.sheet_open = None;
1960 self.sheet_open_object = None;
1961 self.sheet_selected_object = None;
1962 self.sheet_cache = None;
1963 self.sheet_lines.forget();
1964 }
1965}
1966
1967fn refuse_perspective(view_id: &str, camera: &brep_kernel::PmiCamera) -> Result<(), String> {
1971 if matches!(camera.projection, brep_kernel::PmiProjection::Orthographic { .. }) {
1972 Ok(())
1973 } else {
1974 Err(format!("PMI view '{view_id}' is a perspective camera; a sheet places {ORTHOGRAPHIC} views only"))
1975 }
1976}
1977
1978
1979
1980
1981