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 mass_pending = self.measure_sheet_bom_parts(&sheet);
347 let key = self.sheet_cache_key(id, &context);
348 if self.sheet_cache.as_ref().is_none_or(|cache| cache.key != key || cache.reproject) {
349 let pmi = self.pmi_state();
350 let env = self.sheet_env();
354 let mut complete = true;
355 let (runner, work) = (&mut self.runner, &mut self.sheet_lines);
356 let mut defer = |job: LinesJob| {
357 let answer = work.defer(runner.as_mut(), job);
358 complete &= !matches!(answer, Deferred::Pending);
359 answer
360 };
361 let model = Model { scene: &self.scene, unposed: &self.pmi_explode_originals, env: &env };
362 let mut drawing = project_sheet_with(
363 &model,
364 &sheet,
365 &pmi.views,
366 self.pmi_report.as_ref(),
367 &context,
368 &mut LinesMode::Defer(&mut defer),
369 );
370 self.draw_sheet_bom(&sheet, &mut drawing);
371 let drawing_json = serde_json::to_string(&drawing).unwrap_or_else(|_| "null".into());
372 let landed = self.sheet_cache_key(id, &context);
378 let reproject = (!complete && landed != key) || mass_pending;
379 let key = landed;
380 self.sheet_cache = Some(SheetCache { key, drawing, drawing_json, complete, reproject });
381 }
382 true
383 }
384
385 pub(super) fn sheet_env(&self) -> brep_kernel::Env {
388 brep_kernel::Env::build(&self.history.expressions(), &self.history.configurator()).unwrap_or_default()
389 }
390
391 pub fn pump_sheet_lines(&mut self) {
393 self.sheet_lines.pump(self.runner.as_mut());
394 }
395
396 pub fn sheet_lines_pending(&self) -> bool {
399 self.sheet_lines.pending()
400 }
401
402 fn request_sheet_topology(&mut self, sheet: &crate::sheets::Sheet) {
407 let lacking = self.scene.solids().iter().any(|solid| {
409 solid.source_handle != 0
410 && !solid.is_sketch
411 && !self.topology_asked.contains(&solid.source_handle)
412 && self.scene.exact_solid(&solid.name).is_none()
413 });
414 if !lacking {
415 self.pump_topology();
416 return;
417 }
418 let pmi = self.pmi_state();
419 let names = crate::sheets::project::sheet_solid_names(&self.scene, &self.pmi_explode_originals, sheet, &pmi.views);
420 self.request_exact_solids(names.iter().map(String::as_str));
421 }
422
423 fn sheet_frame_context(&self) -> FrameContext {
426 FrameContext { document: self.document_name.clone(), date: today_iso() }
427 }
428
429 fn sheet_cache_key(
430 &self,
431 id: &str,
432 context: &FrameContext,
433 ) -> (String, u64, u64, u64, u64, u64, String) {
434 (
435 id.to_string(),
436 self.applied_generation,
437 self.history.revision(),
438 self.scene.exact_revision(),
439 self.scene.visibility_revision() + self.pmi_unposed_visibility,
446 self.sheet_lines.revision,
447 format!("{}\u{1f}{}", context.document, context.date),
448 )
449 }
450
451 pub fn sheet_drawing_cached(&self, id: &str) -> Option<&SheetDrawing> {
455 let key = self.sheet_cache_key(id, &self.sheet_frame_context());
456 self.sheet_cache
457 .as_ref()
458 .filter(|cache| cache.key == key)
459 .map(|cache| &cache.drawing)
460 }
461
462 pub fn sheet_drawing(&mut self, id: &str) -> Option<&SheetDrawing> {
467 if !self.sheet_project(id) {
468 return None;
469 }
470 self.sheet_cache.as_ref().map(|cache| &cache.drawing)
471 }
472
473 pub fn export_sheet_svg(&mut self, id: &str) -> Result<String, String> {
475 self.plugin_export_ready()?;
476 let id = self.resolve_sheet(id)?;
477 let drawing = self
478 .sheet_drawing_exact(&id)
479 .ok_or_else(|| format!("no sheet '{id}'"))?;
480 Ok(crate::sheets::svg::to_svg(drawing))
481 }
482
483 pub fn sheet_drawing_exact(&mut self, id: &str) -> Option<&SheetDrawing> {
488 if !self.sheet_project(id) {
489 return None;
490 }
491 if self.sheet_cache.as_ref().is_some_and(|cache| !cache.complete) {
492 let state = self.sheet_state();
493 let sheet = state.find_sheet(id)?;
494 let pmi = self.pmi_state();
495 let env = self.sheet_env();
496 let model = Model { scene: &self.scene, unposed: &self.pmi_explode_originals, env: &env };
497 let mut drawing = project_sheet_with(
498 &model,
499 sheet,
500 &pmi.views,
501 self.pmi_report.as_ref(),
502 &self.sheet_frame_context(),
503 &mut LinesMode::Compute,
504 );
505 self.draw_sheet_bom(&sheet, &mut drawing);
506 let drawing_json = serde_json::to_string(&drawing).unwrap_or_else(|_| "null".into());
507 if let Some(cache) = self.sheet_cache.as_mut() {
508 cache.drawing = drawing;
509 cache.drawing_json = drawing_json;
510 cache.complete = true;
511 cache.reproject = false;
512 }
513 }
514 self.sheet_cache.as_ref().map(|cache| &cache.drawing)
515 }
516
517 pub fn export_sheet_pdf(&mut self, id: &str) -> Result<Vec<u8>, String> {
521 let id = self.resolve_sheet(id)?;
522 let name = self.sheet_state().find_sheet(&id).map(|sheet| sheet.name.clone()).unwrap_or_default();
523 self.export_sheets_pdf(&[id], &name)
524 }
525
526 pub fn export_document_pdf(&mut self) -> Result<Vec<u8>, String> {
532 self.export_document_pdf_with(false)
533 }
534
535 pub fn export_document_pdf_3d(&mut self) -> Result<Vec<u8>, String> {
539 self.export_document_pdf_with(true)
540 }
541
542 fn export_document_pdf_with(&mut self, three_d: bool) -> Result<Vec<u8>, String> {
543 let state = self.sheet_state();
544 if state.sheets.is_empty() {
545 return Err("the document has no sheets".into());
546 }
547 let ids: Vec<String> = state.sheets.iter().map(|sheet| sheet.id.clone()).collect();
548 let title = if self.document_name.trim().is_empty() {
549 state.sheets[0].name.clone()
550 } else {
551 self.document_name.clone()
552 };
553 self.export_sheets_pdf_with(&ids, &title, three_d)
554 }
555
556 pub fn export_sheets_pdf(&mut self, ids: &[String], title: &str) -> Result<Vec<u8>, String> {
563 self.export_sheets_pdf_with(ids, title, false)
564 }
565
566 pub fn export_sheets_pdf_with(&mut self, ids: &[String], title: &str, three_d: bool) -> Result<Vec<u8>, String> {
571 self.plugin_export_ready()?;
572 let state = self.sheet_state();
573 for id in ids {
574 if let Some(sheet) = state.find_sheet(id) {
575 self.request_sheet_topology(sheet);
576 }
577 }
578 let context = self.sheet_frame_context();
579 let pmi = self.pmi_state();
580 let mut drawings: Vec<SheetDrawing> = Vec::with_capacity(ids.len());
581 for id in ids {
582 let sheet = state.find_sheet(id).ok_or_else(|| format!("no sheet '{id}'"))?;
583 let complete = self.sheet_cache.as_ref().is_some_and(|cache| cache.complete);
584 let drawing = match self.sheet_drawing_cached(id).filter(|_| complete) {
585 Some(cached) => cached.clone(),
586 None => {
587 let mut drawing = project_sheet_with(
588 &Model { scene: &self.scene, unposed: &self.pmi_explode_originals, env: &self.sheet_env() },
589 sheet, &pmi.views, self.pmi_report.as_ref(), &context, &mut LinesMode::Compute,
590 );
591 self.draw_sheet_bom(sheet, &mut drawing);
592 drawing
593 },
594 };
595 drawings.push(drawing);
596 }
597 let pages: Vec<&SheetDrawing> = drawings.iter().collect();
598 if !three_d {
599 return Ok(crate::sheets::pdf::write_pdf(title, &pages));
600 }
601 let three_d = self.pdf3d_content(ids, &drawings, title)?;
602 Ok(crate::sheets::pdf::write_pdf_3d(title, &pages, Some(&three_d)))
603 }
604
605 fn pdf3d_content(
609 &self,
610 ids: &[String],
611 drawings: &[SheetDrawing],
612 title: &str,
613 ) -> Result<crate::sheets::pdf3d::Pdf3d, String> {
614 use crate::sheets::pdf3d::{self, Page3d, Placement3d};
615 let state = self.sheet_state();
616 let pmi = self.pmi_state();
617 let bodies = self.pdf3d_bodies();
618 let model = pdf3d::build(&bodies, &pmi, self.pmi_report.as_ref())?;
619 const MARGIN_MM: f64 = 4.0;
622 let mut placements = Vec::new();
623 for (page, (id, drawing)) in ids.iter().zip(drawings).enumerate() {
624 let Some(sheet) = state.find_sheet(id) else { continue };
625 for placed in sheet.views.iter().filter(|placed| placed.three_d) {
626 let Some(view) = drawing.views.iter().find(|view| view.id == placed.id) else { continue };
627 if !view.error.is_empty() || model.view(&placed.view).is_none() {
628 continue;
629 }
630 let [x0, y0, x1, y1] = view.bounds;
631 let p = placed.position;
632 let half = [
633 ((p[0] - x0).max(x1 - p[0]) + MARGIN_MM).max(10.0),
634 ((p[1] - y0).max(y1 - p[1]) + MARGIN_MM).max(10.0),
635 ];
636 placements.push(Placement3d {
637 page,
638 id: placed.id.clone(),
639 view: placed.view.clone(),
640 centre_mm: p,
641 half_mm: half,
642 scale: placed.scale,
643 });
644 }
645 }
646 let page = Some({
647 let (w, h) = state
648 .find_sheet(ids.first().map(String::as_str).unwrap_or(""))
649 .map(|sheet| sheet.millimetres())
650 .unwrap_or((297.0, 210.0));
651 let mut page = Page3d::layout(w.max(h), w.min(h), title, &model);
652 if let Some((poster, scale)) = self.pdf3d_poster(&model, &page, &pmi) {
653 page.poster = Some(poster);
654 page.poster_scale = Some(scale);
655 }
656 page
657 });
658 Ok(pdf3d::Pdf3d { model, placements, page })
659 }
660
661 fn pdf3d_poster(
667 &self,
668 model: &crate::sheets::pdf3d::Model3d,
669 page: &crate::sheets::pdf3d::Page3d,
670 pmi: &brep_kernel::PmiState,
671 ) -> Option<(crate::sheets::project::ViewDrawing, f64)> {
672 let default = model.views.get(page.default_view)?;
673 let mut camera = default.camera.clone();
674 if let brep_kernel::PmiProjection::Perspective { fov_y_deg } = camera.projection {
675 let distance = {
676 let d = [camera.target[0] - camera.eye[0], camera.target[1] - camera.eye[1], camera.target[2] - camera.eye[2]];
677 (d[0] * d[0] + d[1] * d[1] + d[2] * d[2]).sqrt()
678 };
679 camera.projection = brep_kernel::PmiProjection::Orthographic {
680 half_height: distance * (fov_y_deg.to_radians() * 0.5).tan(),
681 };
682 }
683 let mut views = pmi.views.clone();
684 match views.iter_mut().find(|view| view.id == default.id) {
685 Some(view) => view.camera = Some(camera),
686 None => views.push(brep_kernel::PmiView {
687 id: default.id.clone(),
688 name: default.name.clone(),
689 camera: Some(camera),
690 display: Default::default(),
691 annotations: Vec::new(),
692 }),
693 }
694 let centre = page.box_centre_mm();
695 let half = page.box_half_mm();
696 let project = |scale: f64| -> Option<crate::sheets::project::ViewDrawing> {
697 let mut sheet: crate::sheets::Sheet = serde_json::from_value(serde_json::json!({
698 "id": "MODEL3D",
699 "size": crate::sheets::CUSTOM,
700 "widthMm": page.width_mm,
701 "heightMm": page.height_mm,
702 "border": false,
703 "titleBlock": false,
704 }))
705 .ok()?;
706 sheet.views.push(PlacedView {
707 id: "MODEL3D".into(),
708 view: default.id.clone(),
709 position: centre,
710 scale,
711 projection: ORTHOGRAPHIC.to_string(),
712 flatten_text: true,
713 three_d: false,
714 section: None,
715 detail: None,
716 });
717 let drawing = project_sheet_with(
718 &Model { scene: &self.scene, unposed: &self.pmi_explode_originals, env: &self.sheet_env() },
719 &sheet,
720 &views,
721 self.pmi_report.as_ref(),
722 &self.sheet_frame_context(),
723 &mut LinesMode::Compute,
724 );
725 drawing.views.into_iter().next().filter(|view| view.error.is_empty())
726 };
727 let probe = project(1.0)?;
729 let [x0, y0, x1, y1] = probe.bounds;
730 let reach = [(centre[0] - x0).max(x1 - centre[0]), (centre[1] - y0).max(y1 - centre[1])];
731 if !(reach[0] > 1e-9 && reach[1] > 1e-9) {
732 return None;
733 }
734 let scale = (half[0] / reach[0]).min(half[1] / reach[1]) * 0.9;
735 project(scale).map(|drawing| (drawing, scale))
736 }
737
738 pub fn resolve_sheet(&self, id: &str) -> Result<String, String> {
741 let state = self.sheet_state();
742 let id = id.trim();
743 if !id.is_empty() {
744 return state
745 .find_sheet(id)
746 .map(|sheet| sheet.id.clone())
747 .ok_or_else(|| format!("no sheet '{id}'"));
748 }
749 if let Some(open) = self.sheet_open.as_ref().filter(|open| state.find_sheet(open).is_some()) {
750 return Ok(open.clone());
751 }
752 state
753 .sheets
754 .first()
755 .map(|sheet| sheet.id.clone())
756 .ok_or_else(|| "the document has no sheets".to_string())
757 }
758
759 fn write_sheet_state(&mut self, state: SheetState, coalesce_key: Option<&str>) {
764 let block = if state.is_empty() { None } else { serde_json::to_value(&state).ok() };
765 self.history.set_sheets_block(block, coalesce_key);
766 self.dirty = true;
767 }
768
769 pub fn sheet_add(&mut self, name: Option<&str>) -> String {
774 let mut state = self.sheet_state();
775 let id = state.next_id("SHEET");
776 let name = name
777 .map(str::trim)
778 .filter(|name| !name.is_empty())
779 .map(String::from)
780 .unwrap_or_else(|| format!("Sheet {}", state.sheets.len() + 1));
781 let (width_mm, height_mm) = crate::sheets::paper_size(DEFAULT_SIZE).unwrap_or((420.0, 297.0));
782 state.sheets.push(Sheet {
783 id: id.clone(),
784 name,
785 size: DEFAULT_SIZE.to_string(),
786 width_mm,
787 height_mm,
788 border: true,
789 border_inset_mm: DEFAULT_BORDER_INSET_MM,
790 title_block: true,
791 notes: String::new(),
792 views: Vec::new(),
793 dimensions: Vec::new(),
794 ordinates: Vec::new(),
795 revisions: Vec::new(),
796 bom_table: None,
797 });
798 self.write_sheet_state(state, None);
799 self.set_open_sheet(Some(id.clone()));
800 id
801 }
802
803 pub fn sheet_insert_bom(&mut self) -> Result<(), String> {
805 let id = self
806 .sheet_open()
807 .ok_or("open a drawing sheet first")?
808 .to_owned();
809 if self
810 .sheet_state()
811 .find_sheet(&id)
812 .and_then(|s| s.bom_table.as_ref())
813 .is_none()
814 {
815 self.sheet_update(
816 &id,
817 &serde_json::json!({ "bomTable": crate::sheets::BomTable::default() }).to_string(),
818 )?;
819 }
820 self.sheet_set_object_open(Some(&id));
821 Ok(())
822 }
823
824 pub fn sheet_bom_columns(&self) -> Vec<String> {
826 let mut columns = std::collections::BTreeSet::from([
827 "partName".to_owned(),
828 "occurrence.Quantity".to_owned(),
829 "occurrence.Find_Number".to_owned(),
830 "part.Part_Number".to_owned(),
831 "part.Description".to_owned(),
832 "part.Material".to_owned(),
833 "part.Mass".to_owned(),
834 ]);
835 for component in &self.assembly_components {
836 for (scope, attrs) in [
837 ("part", self.part_attributes(&component.part_name)),
838 ("occurrence", self.occurrence_attributes(&component.id)),
839 ] {
840 if let Some(attrs) = attrs.as_object() {
841 columns.extend(attrs.keys().map(|key| format!("{scope}.{key}")));
842 }
843 }
844 }
845 columns.into_iter().collect()
846 }
847
848 fn measure_sheet_bom_parts(&mut self, sheet: &Sheet) -> bool {
851 let Some(table) = &sheet.bom_table else {
852 return false;
853 };
854 if !table.columns.iter().any(|c| c == "part.Mass" || c == "part.Volume") {
855 return false;
856 }
857 let parts: Vec<String> =
858 self.assembly_components.iter().map(|c| c.part_name.clone()).collect();
859 self.measure_parts(parts.iter().map(String::as_str));
860 parts.iter().any(|part| {
861 self.part_mass_cached(part).status == super::part_mass::MassStatus::Pending
862 })
863 }
864
865 fn draw_sheet_bom(&self, sheet: &Sheet, drawing: &mut SheetDrawing) {
866 let Some(table) = &sheet.bom_table else {
867 return;
868 };
869 let mut rows: std::collections::BTreeMap<Vec<String>, usize> =
870 std::collections::BTreeMap::new();
871 let occurrences = self.occurrence_attributes_all();
872 let mut parts = std::collections::HashMap::new();
873 for component in &self.assembly_components {
874 let part = parts
877 .entry(component.part_name.clone())
878 .or_insert_with(|| self.part_bom_record_cached(&component.part_name).0);
879 let occurrence = occurrences.get(&component.id).unwrap_or(&Value::Null);
880 let cells: Vec<String> = table
881 .columns
882 .iter()
883 .map(|column| {
884 if column == "partName" {
885 return component.part_name.clone();
886 }
887 if column == "quantity" || column == "occurrence.Quantity" {
888 return String::new();
889 }
890 let (scope, key) = column.split_once('.').unwrap_or(("part", column));
891 if column == "part.PMI" {
892 return self.part_pmi_count(&component.part_name).to_string();
893 }
894 let value = if scope == "occurrence" {
895 &occurrence[key]
896 } else {
897 &part[key]
898 };
899 if scope == "part" {
900 if let Some(text) = super::part_mass::format_derived(key, value) {
901 return text;
902 }
903 }
904 match value {
905 Value::Null => String::new(),
906 Value::String(s) => s.clone(),
907 v => v.to_string(),
908 }
909 })
910 .collect();
911 let mut key = cells;
913 key.push(component.part_name.clone());
914 *rows.entry(key).or_default() += 1;
915 }
916 for wire in self.wire_bom_lines() {
917 let mut cells: Vec<String> = table
918 .columns
919 .iter()
920 .map(|column| match column.as_str() {
921 "partName" | "part.Part_Number" => wire.stock_part_number.clone(),
922 "occurrence.MF_QTY" => wire
923 .mf_qty
924 .map(|q| crate::formatting::compact_decimal(q, 3))
925 .unwrap_or_else(|| wire.state.as_str().to_owned()),
926 "occurrence.Reference_Designator" => wire.connection_id.clone(),
927 _ => String::new(),
928 })
929 .collect();
930 cells.push(format!("wire:{}", wire.connection_id));
931 rows.insert(cells, 1);
932 }
933 let frame = drawing
934 .frame
935 .get_or_insert_with(|| crate::sheets::frame::FrameDrawing {
936 lines: Vec::new(),
937 texts: Vec::new(),
938 revision_table: None,
939 });
940 let [x, y] = table.position;
941 let width = table.column_width_mm;
942 let height = 7.;
943 let count = rows.len() + 1;
944 for row in 0..=count {
945 frame.lines.push(vec![
946 [x, y + row as f64 * height],
947 [
948 x + table.columns.len() as f64 * width,
949 y + row as f64 * height,
950 ],
951 ]);
952 }
953 for col in 0..=table.columns.len() {
954 frame.lines.push(vec![
955 [x + col as f64 * width, y],
956 [x + col as f64 * width, y + count as f64 * height],
957 ]);
958 }
959 let headers: Vec<String> = table
960 .columns
961 .iter()
962 .map(|c| match c.as_str() {
963 "partName" => "Part name".into(),
964 "quantity" => "Quantity".into(),
965 _ => c
966 .split_once('.')
967 .map_or(c.as_str(), |(_, key)| key)
968 .replace('_', " "),
969 })
970 .collect();
971 let mut all = vec![headers];
972 for (mut cells, quantity) in rows {
973 cells.pop();
974 for (i, column) in table.columns.iter().enumerate() {
975 if column == "quantity" || column == "occurrence.Quantity" {
976 cells[i] = quantity.to_string();
977 }
978 }
979 all.push(cells);
980 }
981 for (row, cells) in all.iter().enumerate() {
982 for (col, cell) in cells.iter().enumerate() {
983 let available = ((width - 2.) / (2.5 * 0.65)).floor() as usize;
984 let text = if cell.chars().count() > available {
985 format!(
986 "{}…",
987 cell.chars()
988 .take(available.saturating_sub(1))
989 .collect::<String>()
990 )
991 } else {
992 cell.clone()
993 };
994 frame.texts.push(crate::sheets::project::SheetText::new(
995 text,
996 [
997 x + (col as f64 + 0.5) * width,
998 y + (row as f64 + 0.5) * height,
999 ],
1000 [2.5, 0.],
1001 [0., -2.5],
1002 ));
1003 }
1004 }
1005 }
1006
1007 pub fn sheet_update(&mut self, id: &str, params_json: &str) -> Result<(), String> {
1009 let params: Value = serde_json::from_str(params_json)
1010 .map_err(|error| format!("sheet params: {error}"))?;
1011 let mut state = self.sheet_state();
1012 let sheet = state.find_sheet_mut(id).ok_or_else(|| format!("no sheet '{id}'"))?;
1013 sheet.apply(¶ms)?;
1014 self.write_sheet_state(state, None);
1015 Ok(())
1016 }
1017
1018 pub fn sheet_add_revision(
1025 &mut self,
1026 id: &str,
1027 rev: Option<&str>,
1028 date: Option<&str>,
1029 description: Option<&str>,
1030 ) -> Result<usize, String> {
1031 let mut state = self.sheet_state();
1032 let sheet = state.find_sheet_mut(id).ok_or_else(|| format!("no sheet '{id}'"))?;
1033 let next = || {
1034 let letters = crate::sheets::SectionCut::LETTERS;
1035 let last = sheet.revisions.last().and_then(|row| row.rev.trim().chars().next());
1036 match last.and_then(|c| letters.iter().position(|l| l.eq_ignore_ascii_case(&c))) {
1037 Some(at) => letters[(at + 1).min(letters.len() - 1)].to_string(),
1038 None if sheet.revisions.is_empty() => letters[0].to_string(),
1039 None => String::new(),
1040 }
1041 };
1042 let revision = crate::sheets::Revision {
1043 rev: rev.map(str::to_string).unwrap_or_else(next),
1044 date: date.map(str::to_string).unwrap_or_else(today_iso),
1045 description: description.unwrap_or_default().to_string(),
1046 };
1047 sheet.revisions.push(revision);
1048 let index = sheet.revisions.len() - 1;
1049 self.write_sheet_state(state, None);
1050 Ok(index)
1051 }
1052
1053 pub fn sheet_update_revision(&mut self, id: &str, index: usize, params_json: &str) -> Result<(), String> {
1056 let params: Value = serde_json::from_str(params_json)
1057 .map_err(|error| format!("revision params: {error}"))?;
1058 let mut state = self.sheet_state();
1059 let sheet = state.find_sheet_mut(id).ok_or_else(|| format!("no sheet '{id}'"))?;
1060 let count = sheet.revisions.len();
1061 let row = sheet
1062 .revisions
1063 .get_mut(index)
1064 .ok_or_else(|| format!("sheet '{id}' has {count} revision row(s); there is no row {index}"))?;
1065 row.apply(¶ms)?;
1066 self.write_sheet_state(state, None);
1067 Ok(())
1068 }
1069
1070 pub fn sheet_remove_revision(&mut self, id: &str, index: usize) -> Result<(), String> {
1073 let mut state = self.sheet_state();
1074 let sheet = state.find_sheet_mut(id).ok_or_else(|| format!("no sheet '{id}'"))?;
1075 if index >= sheet.revisions.len() {
1076 return Err(format!(
1077 "sheet '{id}' has {} revision row(s); there is no row {index}",
1078 sheet.revisions.len()
1079 ));
1080 }
1081 sheet.revisions.remove(index);
1082 self.write_sheet_state(state, None);
1083 Ok(())
1084 }
1085
1086 pub fn sheet_delete(&mut self, id: &str) -> Result<(), String> {
1087 let mut state = self.sheet_state();
1088 let before = state.sheets.len();
1089 state.sheets.retain(|sheet| sheet.id != id);
1090 if state.sheets.len() == before {
1091 return Err(format!("no sheet '{id}'"));
1092 }
1093 if self.sheet_open.as_deref() == Some(id) {
1094 self.set_open_sheet(state.sheets.first().map(|sheet| sheet.id.clone()));
1095 }
1096 self.clear_open_object(&state);
1097 self.write_sheet_state(state, None);
1098 Ok(())
1099 }
1100
1101 pub fn sheet_set_open(&mut self, id: Option<&str>) -> Result<(), String> {
1103 match id {
1104 None => {
1105 self.set_open_sheet(None);
1106 self.dirty = true;
1107 Ok(())
1108 }
1109 Some(id) => {
1110 let state = self.sheet_state();
1111 if state.find_sheet(id).is_none() {
1112 return Err(format!("no sheet '{id}'"));
1113 }
1114 self.set_open_sheet(Some(id.to_string()));
1115 self.dirty = true;
1116 Ok(())
1117 }
1118 }
1119 }
1120
1121 pub fn sheet_set_object_open(&mut self, id: Option<&str>) {
1126 match id {
1127 Some(id) => self.open_sheet_object(id.to_string()),
1128 None => self.sheet_open_object = None,
1129 }
1130 }
1131
1132 pub fn sheet_place_view(
1138 &mut self,
1139 sheet_id: Option<&str>,
1140 view_id: &str,
1141 position: Option<[f64; 2]>,
1142 scale: Option<f64>,
1143 ) -> Result<String, String> {
1144 let pmi = self.pmi_state();
1145 let view = pmi
1146 .find_view(view_id)
1147 .ok_or_else(|| format!("no PMI view '{view_id}'"))?;
1148 let camera = view
1149 .camera
1150 .as_ref()
1151 .ok_or_else(|| format!("PMI view '{view_id}' has no captured camera — capture one first"))?;
1152 refuse_perspective(view_id, camera)?;
1153 let mut state = self.sheet_state();
1154 let sheet_id = match sheet_id.map(str::trim).filter(|id| !id.is_empty()) {
1155 Some(id) => id.to_string(),
1156 None => self.resolve_sheet("")?,
1157 };
1158 let id = state.next_id("SV");
1159 let sheet = state
1160 .find_sheet_mut(&sheet_id)
1161 .ok_or_else(|| format!("no sheet '{sheet_id}'"))?;
1162 let (width_mm, height_mm) = sheet.millimetres();
1163 let scale = match scale {
1164 Some(scale) if scale.is_finite() && scale > 0.0 => scale,
1165 Some(scale) => return Err(format!("scale: {scale} is not a positive ratio")),
1166 None => DEFAULT_SCALE,
1167 };
1168 sheet.views.push(PlacedView {
1169 id: id.clone(),
1170 view: view_id.to_string(),
1171 position: position.unwrap_or([width_mm * 0.5, height_mm * 0.5]),
1172 scale,
1173 projection: ORTHOGRAPHIC.to_string(),
1174 flatten_text: true,
1175 three_d: false,
1176 section: None,
1177 detail: None,
1178 });
1179 self.write_sheet_state(state, None);
1180 self.set_open_sheet(Some(sheet_id));
1181 self.open_sheet_object(id.clone());
1182 Ok(id)
1183 }
1184
1185 pub fn sheet_update_view(&mut self, id: &str, params_json: &str) -> Result<(), String> {
1187 let mut params: Value = serde_json::from_str(params_json)
1188 .map_err(|error| format!("placed view params: {error}"))?;
1189 if let Some(object) = params.as_object_mut() {
1194 let current = self.sheet_state().sheets.iter().find_map(|sheet| sheet.find_view(id).cloned());
1195 for field in ["cut", "centre", "rim"] {
1196 let Some(value) = object.remove(field) else { continue };
1197 let names: Vec<String> = match value.as_str() {
1199 Some(one) => vec![one.to_string()],
1200 None => crate::json_support::string_values(Some(&value)).map(str::to_string).collect(),
1201 }
1202 .into_iter()
1203 .filter(|name| !name.is_empty())
1204 .collect();
1205 let now = current.as_ref().map(|placed| placed.picked(field)).unwrap_or_default();
1206 if names != now {
1207 self.sheet_set_placement_refs(id, field, &names)?;
1208 }
1209 }
1210 }
1211 if let Some(view_id) = params.get("view").and_then(Value::as_str).map(str::trim).filter(|id| !id.is_empty()) {
1214 if let Some(camera) = self.pmi_state().find_view(view_id).and_then(|view| view.camera.clone()) {
1215 refuse_perspective(view_id, &camera)?;
1216 }
1217 }
1218 let views = self.sheet_pmi_view_ids();
1219 let mut state = self.sheet_state();
1220 let view = state.find_view_mut(id).ok_or_else(|| format!("no placed view '{id}'"))?;
1221 view.apply(¶ms, &views)?;
1222 self.write_sheet_state(state, None);
1223 Ok(())
1224 }
1225
1226 pub fn sheet_move_view(&mut self, id: &str, position: [f64; 2], coalesce: bool) -> Result<(), String> {
1229 if !position[0].is_finite() || !position[1].is_finite() {
1230 return Err("position: not a finite point".into());
1231 }
1232 let mut state = self.sheet_state();
1233 let view = state.find_view_mut(id).ok_or_else(|| format!("no placed view '{id}'"))?;
1234 view.position = position;
1235 let key = format!("sheet:view:{id}");
1236 self.write_sheet_state(state, coalesce.then_some(key.as_str()));
1237 Ok(())
1238 }
1239
1240 pub fn sheet_move_view_end(&mut self) {
1242 self.history.break_coalescing();
1243 }
1244
1245 pub fn sheet_remove_view(&mut self, id: &str) -> Result<(), String> {
1246 let mut state = self.sheet_state();
1247 let Some((sheet_id, index)) = state.locate_view(id) else {
1248 return Err(format!("no placed view '{id}'"));
1249 };
1250 if let Some(sheet) = state.find_sheet_mut(&sheet_id) {
1251 sheet.views.remove(index);
1252 }
1253 self.clear_open_object(&state);
1254 self.write_sheet_state(state, None);
1255 Ok(())
1256 }
1257
1258 fn clear_open_object(&mut self, state: &SheetState) {
1260 if let Some(open) = self.sheet_open_object.clone() {
1261 if state.find_sheet(&open).is_none()
1262 && state.locate_view(&open).is_none()
1263 && state.locate_dimension(&open).is_none()
1264 && state.locate_ordinate(&open).is_none()
1265 {
1266 self.sheet_open_object = None;
1267 }
1268 }
1269 }
1270
1271 pub fn sheet_add_dimension(
1278 &mut self,
1279 sheet_id: Option<&str>,
1280 kind: &str,
1281 alignment: Option<&str>,
1282 anchors: Vec<String>,
1283 ) -> Result<String, String> {
1284 let kind = KINDS
1285 .iter()
1286 .find(|known| known.eq_ignore_ascii_case(kind.trim()))
1287 .ok_or_else(|| format!("no dimension kind '{kind}'"))?;
1288 let alignment = match alignment.map(str::trim).filter(|a| !a.is_empty()) {
1289 None => ALIGNED,
1290 Some(wanted) => ALIGNMENTS
1291 .iter()
1292 .copied()
1293 .find(|known| known.eq_ignore_ascii_case(wanted))
1294 .ok_or_else(|| format!("no alignment '{wanted}'"))?,
1295 };
1296 for anchor in &anchors {
1297 SheetAnchor::parse(anchor)?;
1298 }
1299 let sheet_id = match sheet_id.map(str::trim).filter(|id| !id.is_empty()) {
1300 Some(id) => id.to_string(),
1301 None => self.resolve_sheet("")?,
1302 };
1303 let mut state = self.sheet_state();
1304 let id = state.next_id("SD");
1305 let sheet = state
1306 .find_sheet_mut(&sheet_id)
1307 .ok_or_else(|| format!("no sheet '{sheet_id}'"))?;
1308 sheet
1309 .dimensions
1310 .push(SheetDimension::new(id.clone(), kind, alignment, anchors));
1311 self.write_sheet_state(state, None);
1312 self.set_open_sheet(Some(sheet_id));
1313 self.open_sheet_object(id.clone());
1314 Ok(id)
1315 }
1316
1317 pub fn sheet_update_dimension(&mut self, id: &str, params_json: &str) -> Result<(), String> {
1319 let params: Value = serde_json::from_str(params_json)
1320 .map_err(|error| format!("sheet dimension params: {error}"))?;
1321 let mut state = self.sheet_state();
1322 let dimension = state
1323 .find_dimension_mut(id)
1324 .ok_or_else(|| format!("no sheet dimension '{id}'"))?;
1325 dimension.apply(¶ms)?;
1326 self.write_sheet_state(state, None);
1327 Ok(())
1328 }
1329
1330 pub fn sheet_move_dimension(
1334 &mut self,
1335 id: &str,
1336 offset_mm: f64,
1337 coalesce: bool,
1338 ) -> Result<(), String> {
1339 if !offset_mm.is_finite() {
1340 return Err(format!("offsetMm: {offset_mm} is not a length"));
1341 }
1342 let mut state = self.sheet_state();
1343 let dimension = state
1344 .find_dimension_mut(id)
1345 .ok_or_else(|| format!("no sheet dimension '{id}'"))?;
1346 dimension.offset_mm = offset_mm;
1347 let key = format!("sheet:dim:{id}");
1348 self.write_sheet_state(state, coalesce.then_some(key.as_str()));
1349 Ok(())
1350 }
1351
1352 pub fn sheet_move_dimension_end(&mut self) {
1354 self.history.break_coalescing();
1355 }
1356
1357 pub fn sheet_remove_dimension(&mut self, id: &str) -> Result<(), String> {
1358 let mut state = self.sheet_state();
1359 let Some((sheet_id, index)) = state.locate_dimension(id) else {
1360 return Err(format!("no sheet dimension '{id}'"));
1361 };
1362 if let Some(sheet) = state.find_sheet_mut(&sheet_id) {
1363 sheet.dimensions.remove(index);
1364 }
1365 self.clear_open_object(&state);
1366 self.write_sheet_state(state, None);
1367 Ok(())
1368 }
1369
1370 pub fn sheet_add_ordinate(
1376 &mut self,
1377 sheet_id: Option<&str>,
1378 axis: &str,
1379 datum: Option<&str>,
1380 members: Vec<String>,
1381 ) -> Result<String, String> {
1382 let axis = AXES
1383 .iter()
1384 .find(|known| known.eq_ignore_ascii_case(axis.trim()))
1385 .ok_or_else(|| format!("no ordinate axis '{axis}'"))?;
1386 let datum = datum.map(str::trim).filter(|d| !d.is_empty()).unwrap_or_default();
1387 if !datum.is_empty() {
1388 SheetAnchor::parse(datum)?;
1389 }
1390 for member in &members {
1391 SheetAnchor::parse(member)?;
1392 }
1393 let sheet_id = match sheet_id.map(str::trim).filter(|id| !id.is_empty()) {
1394 Some(id) => id.to_string(),
1395 None => self.resolve_sheet("")?,
1396 };
1397 let mut state = self.sheet_state();
1398 let id = state.next_id("OD");
1399 let sheet = state
1400 .find_sheet_mut(&sheet_id)
1401 .ok_or_else(|| format!("no sheet '{sheet_id}'"))?;
1402 let mut set = SheetOrdinate::new(id.clone(), axis, datum.to_string());
1403 set.members = members;
1404 sheet.ordinates.push(set);
1405 self.write_sheet_state(state, None);
1406 self.set_open_sheet(Some(sheet_id));
1407 self.open_sheet_object(id.clone());
1408 Ok(id)
1409 }
1410
1411 pub fn sheet_update_ordinate(&mut self, id: &str, params_json: &str) -> Result<(), String> {
1418 let params: Value = serde_json::from_str(params_json)
1419 .map_err(|error| format!("ordinate set params: {error}"))?;
1420 let mut state = self.sheet_state();
1421 let set = state
1422 .find_ordinate_mut(id)
1423 .ok_or_else(|| format!("no ordinate set '{id}'"))?;
1424 set.apply(¶ms)?;
1425 self.write_sheet_state(state, None);
1426 Ok(())
1427 }
1428
1429 pub fn sheet_move_ordinate(
1433 &mut self,
1434 id: &str,
1435 offset_mm: f64,
1436 coalesce: bool,
1437 ) -> Result<(), String> {
1438 if !offset_mm.is_finite() {
1439 return Err(format!("offsetMm: {offset_mm} is not a length"));
1440 }
1441 let mut state = self.sheet_state();
1442 let set = state
1443 .find_ordinate_mut(id)
1444 .ok_or_else(|| format!("no ordinate set '{id}'"))?;
1445 set.offset_mm = offset_mm;
1446 let key = format!("sheet:ord:{id}");
1447 self.write_sheet_state(state, coalesce.then_some(key.as_str()));
1448 Ok(())
1449 }
1450
1451 pub fn sheet_move_ordinate_end(&mut self) {
1453 self.history.break_coalescing();
1454 }
1455
1456 pub fn sheet_remove_ordinate(&mut self, id: &str) -> Result<(), String> {
1457 let mut state = self.sheet_state();
1458 let Some((sheet_id, index)) = state.locate_ordinate(id) else {
1459 return Err(format!("no ordinate set '{id}'"));
1460 };
1461 if let Some(sheet) = state.find_sheet_mut(&sheet_id) {
1462 sheet.ordinates.remove(index);
1463 }
1464 self.clear_open_object(&state);
1465 self.write_sheet_state(state, None);
1466 Ok(())
1467 }
1468
1469 pub fn sheet_ordinate_offset_at(&mut self, id: &str, at: [f64; 2]) -> Option<f64> {
1476 let sheet_id = self.sheet_state().locate_ordinate(id).map(|(sheet, _)| sheet)?;
1477 let vertical = self
1478 .sheet_state()
1479 .find_ordinate(id)?
1480 .axis
1481 .eq_ignore_ascii_case(crate::sheets::dimension::VERTICAL);
1482 let drawing = self.sheet_drawing(&sheet_id)?;
1483 let drawn = drawing.ordinates.iter().find(|o| o.id == id)?;
1484 if !drawn.error.is_empty() {
1485 return None;
1486 }
1487 let datum = drawn.stations.iter().find(|station| station.datum)?;
1488 if !datum.error.is_empty() {
1489 return None;
1490 }
1491 Some(if vertical { at[0] - datum.at[0] } else { at[1] - datum.at[1] })
1492 }
1493
1494 pub fn sheet_add_section(
1504 &mut self,
1505 source: &str,
1506 from: &str,
1507 to: &str,
1508 position: Option<[f64; 2]>,
1509 ) -> Result<String, String> {
1510 SheetAnchor::parse(from)?;
1511 SheetAnchor::parse(to)?;
1512 if from == to {
1513 return Err("a cutting line needs two different anchors".into());
1514 }
1515 let (a, b) = (SheetAnchor::parse(from)?, SheetAnchor::parse(to)?);
1518 if a.view != b.view {
1519 return Err(format!(
1520 "'{from}' is on '{}' and '{to}' on '{}'; a cutting line is drawn on ONE view",
1521 a.view, b.view
1522 ));
1523 }
1524 if a.view != source {
1525 return Err(format!("the cutting line is on '{}', not on '{source}'", a.view));
1526 }
1527 let mut state = self.sheet_state();
1528 let Some((sheet_id, _)) = state.locate_view(source) else {
1529 return Err(format!("no placed view '{source}'"));
1530 };
1531 let (view, scale, at, label) = {
1532 let sheet = state
1533 .find_sheet(&sheet_id)
1534 .ok_or_else(|| format!("no sheet '{sheet_id}'"))?;
1535 let label = sheet.next_view_letter(&self.pmi_state().views);
1536 let parent = sheet
1537 .find_view(source)
1538 .ok_or_else(|| format!("no placed view '{source}'"))?;
1539 if parent.section.is_some() {
1540 return Err(format!(
1541 "'{source}' is itself a section; a section of a section is not drawn"
1542 ));
1543 }
1544 if parent.detail.is_some() {
1545 return Err(format!("'{source}' is a detail; a section of a detail is not drawn"));
1546 }
1547 (parent.view.clone(), parent.scale, parent.position, label)
1548 };
1549 let id = state.next_id("SV");
1550 let sheet = state.find_sheet_mut(&sheet_id).expect("just found");
1551 sheet.views.push(PlacedView {
1552 id: id.clone(),
1553 view,
1554 position: position.unwrap_or([at[0] + 120.0, at[1]]),
1558 scale,
1559 projection: ORTHOGRAPHIC.to_string(),
1560 flatten_text: true,
1561 three_d: false,
1562 section: Some(SectionCut {
1563 source: source.to_string(),
1564 from: from.to_string(),
1565 to: to.to_string(),
1566 label,
1567 flip: false,
1568 }),
1569 detail: None,
1570 });
1571 self.write_sheet_state(state, None);
1572 self.set_open_sheet(Some(sheet_id));
1573 self.open_sheet_object(id.clone());
1574 Ok(id)
1575 }
1576
1577 pub fn sheet_new_section(&mut self, sheet_id: Option<&str>) -> Result<String, String> {
1584 self.sheet_new_derived(sheet_id, true)
1585 }
1586
1587 pub fn sheet_new_detail(&mut self, sheet_id: Option<&str>) -> Result<String, String> {
1591 self.sheet_new_derived(sheet_id, false)
1592 }
1593
1594 fn sheet_new_derived(&mut self, sheet_id: Option<&str>, section: bool) -> Result<String, String> {
1595 let sheet_id = match sheet_id.map(str::trim).filter(|id| !id.is_empty()) {
1596 Some(id) => id.to_string(),
1597 None => self.resolve_sheet("")?,
1598 };
1599 let mut state = self.sheet_state();
1600 let id = state.next_id("SV");
1601 let sheet = state
1602 .find_sheet_mut(&sheet_id)
1603 .ok_or_else(|| format!("no sheet '{sheet_id}'"))?;
1604 let label = sheet.next_view_letter(&self.pmi_state().views);
1605 let (w, h) = sheet.millimetres();
1606 sheet.views.push(PlacedView {
1607 id: id.clone(),
1608 view: String::new(),
1611 position: [w * 0.5, h * 0.5],
1612 scale: DEFAULT_SCALE,
1613 projection: ORTHOGRAPHIC.to_string(),
1614 flatten_text: true,
1615 three_d: false,
1616 section: section.then(|| SectionCut {
1617 source: String::new(),
1618 from: String::new(),
1619 to: String::new(),
1620 label: label.clone(),
1621 flip: false,
1622 }),
1623 detail: (!section).then(|| DetailCircle {
1624 source: String::new(),
1625 centre: String::new(),
1626 rim: String::new(),
1627 label,
1628 }),
1629 });
1630 self.write_sheet_state(state, None);
1631 self.set_open_sheet(Some(sheet_id));
1632 self.open_sheet_object(id.clone());
1633 Ok(id)
1634 }
1635
1636 pub fn sheet_set_placement_refs(&mut self, id: &str, field: &str, names: &[String]) -> Result<(), String> {
1649 for name in names {
1650 SheetAnchor::parse(name)?;
1651 }
1652 let mut state = self.sheet_state();
1653 let (sheet_id, _) = state.locate_view(id).ok_or_else(|| format!("no placed view '{id}'"))?;
1654 let sheet = state.find_sheet(&sheet_id).ok_or_else(|| format!("no sheet '{sheet_id}'"))?;
1655 let placed = sheet.find_view(id).ok_or_else(|| format!("no placed view '{id}'"))?.clone();
1656 let single = |names: &[String]| -> Result<String, String> {
1657 match names {
1658 [] => Ok(String::new()),
1659 [one] => Ok(one.clone()),
1660 _ => Err(format!("'{field}' is one anchor")),
1661 }
1662 };
1663 let anchors: Vec<String> = match (field, &placed.section, &placed.detail) {
1665 ("cut", Some(_), _) => {
1666 if names.len() > 2 {
1667 return Err("a cutting line is two anchors".into());
1668 }
1669 names.to_vec()
1670 }
1671 ("centre", _, Some(circle)) => vec![single(names)?, circle.rim.clone()],
1672 ("rim", _, Some(circle)) => vec![circle.centre.clone(), single(names)?],
1673 _ => return Err(format!("'{id}' has no {field} to pick")),
1674 };
1675 let picked: Vec<&String> = anchors.iter().filter(|a| !a.is_empty()).collect();
1676 if picked.len() == 2 && picked[0] == picked[1] {
1677 return Err(if placed.section.is_some() {
1678 "a cutting line needs two different anchors".into()
1679 } else {
1680 "a detail circle needs a radius: pick a rim point away from its centre".into()
1681 });
1682 }
1683 let on: Vec<String> = picked
1684 .iter()
1685 .map(|anchor| SheetAnchor::parse(anchor).map(|a| a.view))
1686 .collect::<Result<_, _>>()?;
1687 if on.windows(2).any(|pair| pair[0] != pair[1]) {
1688 return Err(format!(
1689 "'{}' is on '{}' and '{}' on '{}'; {} is drawn on ONE view",
1690 picked[0], on[0], picked[1], on[1],
1691 if placed.section.is_some() { "a cutting line" } else { "a detail circle" }
1692 ));
1693 }
1694 let source = on.first().cloned();
1695 let parent = match &source {
1696 Some(source) => {
1697 if source == id {
1698 return Err(format!("'{id}' cannot be drawn on itself"));
1699 }
1700 let parent = sheet
1701 .find_view(source)
1702 .ok_or_else(|| format!("no placement '{source}' on this sheet"))?;
1703 let what = if placed.section.is_some() { "section" } else { "detail" };
1704 if parent.section.is_some() {
1705 return Err(format!("'{source}' is a section; a {what} of a section is not drawn"));
1706 }
1707 if parent.detail.is_some() {
1708 return Err(format!("'{source}' is a detail; a {what} of a detail is not drawn"));
1709 }
1710 Some((parent.view.clone(), parent.scale, parent.position))
1711 }
1712 None => None,
1713 };
1714 let sheet = state.find_sheet_mut(&sheet_id).expect("just found");
1715 let placed = sheet.views.iter_mut().find(|view| view.id == id).expect("just found");
1716 let source = source.unwrap_or_default();
1717 if let Some(cut) = placed.section.as_mut() {
1718 cut.from = anchors.first().cloned().unwrap_or_default();
1719 cut.to = anchors.get(1).cloned().unwrap_or_default();
1720 cut.source = source;
1721 } else if let Some(circle) = placed.detail.as_mut() {
1722 circle.centre = anchors[0].clone();
1723 circle.rim = anchors[1].clone();
1724 circle.source = source;
1725 }
1726 if let Some((view, scale, at)) = parent {
1727 if placed.view.is_empty() {
1728 placed.view = view;
1729 placed.scale = if placed.detail.is_some() { scale * DETAIL_SCALE_FACTOR } else { scale };
1730 placed.position = [at[0] + 120.0, at[1]];
1731 }
1732 }
1733 self.write_sheet_state(state, None);
1734 Ok(())
1735 }
1736
1737 pub fn begin_ref_select_for_sheet(
1745 &mut self,
1746 id: &str,
1747 path: Vec<String>,
1748 label: String,
1749 filter: Vec<String>,
1750 multiple: bool,
1751 seed_names: Vec<String>,
1752 ) -> Result<(), String> {
1753 let state = self.sheet_state();
1754 let sheet = state
1755 .locate_view(id)
1756 .or_else(|| state.locate_dimension(id))
1757 .or_else(|| state.locate_ordinate(id))
1758 .map(|(sheet, _)| sheet)
1759 .ok_or_else(|| format!("no sheet object '{id}'"))?;
1760 self.set_open_sheet(Some(sheet));
1761 self.ref_select = Some(RefSelectState {
1762 feature_id: id.to_string(),
1763 path,
1764 label,
1765 filter,
1766 multiple,
1767 names: seed_names,
1768 restore_index: self.history.rollback(),
1769 target: RefSelectTarget::Sheet,
1770 picks: 0,
1771 claimed: false,
1772 finish_requested: false,
1773 });
1774 self.dirty = true;
1775 Ok(())
1776 }
1777
1778 fn sheet_ref_capacity(&self, id: &str, field: &str) -> Option<usize> {
1781 let state = self.sheet_state();
1782 match field {
1783 "anchors" => state
1784 .find_dimension(id)
1785 .map(|dimension| crate::sheets::dimension::anchor_count(&dimension.kind)),
1786 "cut" => Some(2),
1787 _ => None,
1788 }
1789 }
1790
1791 pub fn ref_select_pick_sheet_anchor(&mut self, anchor: &str) -> Result<(), String> {
1797 SheetAnchor::parse(anchor)?;
1798 let Some(state) = self.ref_select.as_ref().filter(|s| s.target == RefSelectTarget::Sheet) else {
1799 return Err("no sheet reference is being picked".into());
1800 };
1801 let capacity = state
1802 .path
1803 .last()
1804 .and_then(|field| self.sheet_ref_capacity(&state.feature_id, field));
1805 let state = self.ref_select.as_mut().expect("guarded above");
1806 if !state.multiple {
1807 state.names = vec![anchor.to_string()];
1808 } else if !state.names.iter().any(|name| name == anchor) {
1809 if capacity.is_some_and(|cap| cap > 0 && state.names.len() >= cap) {
1810 state.names.remove(0);
1811 }
1812 state.names.push(anchor.to_string());
1813 }
1814 state.picks += 1;
1815 self.dirty = true;
1816 Ok(())
1817 }
1818
1819 pub(crate) fn sheet_commit_refs(&mut self, id: &str, path: &[String], names: &[String], multiple: bool) -> Result<(), String> {
1821 let field = path.last().map(String::as_str).unwrap_or_default();
1822 let value = if multiple {
1823 Value::Array(names.iter().cloned().map(Value::String).collect())
1824 } else {
1825 Value::String(names.first().cloned().unwrap_or_default())
1826 };
1827 let params = serde_json::json!({ field: value }).to_string();
1828 let state = self.sheet_state();
1829 if state.find_dimension(id).is_some() {
1830 self.sheet_update_dimension(id, ¶ms)
1831 } else if state.find_ordinate(id).is_some() {
1832 self.sheet_update_ordinate(id, ¶ms)
1833 } else if state.locate_view(id).is_some() {
1834 self.sheet_set_placement_refs(id, field, names)
1835 } else {
1836 Err(format!("no sheet object '{id}'"))
1837 }
1838 }
1839
1840 pub fn sheet_add_detail(
1851 &mut self,
1852 source: &str,
1853 centre: &str,
1854 rim: &str,
1855 position: Option<[f64; 2]>,
1856 scale: Option<f64>,
1857 ) -> Result<String, String> {
1858 let (a, b) = (SheetAnchor::parse(centre)?, SheetAnchor::parse(rim)?);
1859 if centre == rim {
1860 return Err("a detail circle needs two different anchors: its centre and a point on its rim".into());
1861 }
1862 if a.view != b.view {
1865 return Err(format!(
1866 "'{centre}' is on '{}' and '{rim}' on '{}'; a detail circle is drawn on ONE view",
1867 a.view, b.view
1868 ));
1869 }
1870 if a.view != source {
1871 return Err(format!("the detail circle is on '{}', not on '{source}'", a.view));
1872 }
1873 if let Some(scale) = scale {
1874 if !scale.is_finite() || scale <= 0.0 {
1875 return Err(format!("scale: {scale} is not a positive ratio"));
1876 }
1877 }
1878 let mut state = self.sheet_state();
1879 let Some((sheet_id, _)) = state.locate_view(source) else {
1880 return Err(format!("no placed view '{source}'"));
1881 };
1882 let (view, parent_scale, at, label) = {
1883 let sheet = state
1884 .find_sheet(&sheet_id)
1885 .ok_or_else(|| format!("no sheet '{sheet_id}'"))?;
1886 let label = sheet.next_view_letter(&self.pmi_state().views);
1887 let parent = sheet
1888 .find_view(source)
1889 .ok_or_else(|| format!("no placed view '{source}'"))?;
1890 if parent.detail.is_some() {
1894 return Err(format!("'{source}' is itself a detail; a detail of a detail is not drawn"));
1895 }
1896 if parent.section.is_some() {
1897 return Err(format!("'{source}' is a section; a detail of a section is not drawn"));
1898 }
1899 (parent.view.clone(), parent.scale, parent.position, label)
1900 };
1901 let id = state.next_id("SV");
1902 let sheet = state.find_sheet_mut(&sheet_id).expect("just found");
1903 sheet.views.push(PlacedView {
1904 id: id.clone(),
1905 view,
1906 position: position.unwrap_or([at[0] + 120.0, at[1]]),
1909 scale: scale.unwrap_or(parent_scale * DETAIL_SCALE_FACTOR),
1910 projection: ORTHOGRAPHIC.to_string(),
1911 flatten_text: true,
1912 three_d: false,
1913 section: None,
1914 detail: Some(DetailCircle {
1915 source: source.to_string(),
1916 centre: centre.to_string(),
1917 rim: rim.to_string(),
1918 label,
1919 }),
1920 });
1921 self.write_sheet_state(state, None);
1922 self.set_open_sheet(Some(sheet_id));
1923 self.open_sheet_object(id.clone());
1924 Ok(id)
1925 }
1926
1927 pub fn sheet_dimension_offset_at(&mut self, id: &str, at: [f64; 2]) -> Option<f64> {
1937 let sheet_id = self.sheet_state().locate_dimension(id).map(|(sheet, _)| sheet)?;
1938 let drawing = self.sheet_drawing(&sheet_id)?;
1939 let drawn = drawing.dimensions.iter().find(|d| d.id == id)?;
1940 if !drawn.error.is_empty() {
1941 return None;
1942 }
1943 let (label, offset) = (drawn.label, drawn.offset_mm);
1944 let direction = self.sheet_dimension_direction(&sheet_id, id)?;
1949 let base = [label[0] - direction[0] * offset, label[1] - direction[1] * offset];
1950 Some((at[0] - base[0]) * direction[0] + (at[1] - base[1]) * direction[1])
1951 }
1952
1953 fn sheet_dimension_direction(&mut self, sheet_id: &str, id: &str) -> Option<[f64; 2]> {
1955 let state = self.sheet_state();
1959 let sheet = state.find_sheet(sheet_id)?;
1960 let mut probe = sheet.dimensions.iter().find(|d| d.id == id)?.clone();
1961 let delta = if probe.offset_mm < 0.0 { -1.0 } else { 1.0 };
1966 probe.offset_mm += delta;
1967 let drawing = self.sheet_drawing_cached(sheet_id)?;
1968 let moved = crate::sheets::dimension::layout_dimensions(
1969 std::slice::from_ref(&probe),
1970 &drawing.views,
1971 [drawing.width_mm * 0.5, drawing.height_mm * 0.5],
1972 );
1973 let here = drawing.dimensions.iter().find(|d| d.id == id)?;
1974 let there = moved.first()?;
1975 if !there.error.is_empty() {
1976 return None;
1977 }
1978 let step = [
1979 (there.label[0] - here.label[0]) / delta,
1980 (there.label[1] - here.label[1]) / delta,
1981 ];
1982 let len = (step[0] * step[0] + step[1] * step[1]).sqrt();
1983 (len > 1e-9).then(|| [step[0] / len, step[1] / len])
1984 }
1985
1986 pub(crate) fn forget_sheets(&mut self) {
1988 self.sheet_open = None;
1989 self.sheet_open_object = None;
1990 self.sheet_selected_object = None;
1991 self.sheet_cache = None;
1992 self.sheet_lines.forget();
1993 }
1994}
1995
1996fn refuse_perspective(view_id: &str, camera: &brep_kernel::PmiCamera) -> Result<(), String> {
2000 if matches!(camera.projection, brep_kernel::PmiProjection::Orthographic { .. }) {
2001 Ok(())
2002 } else {
2003 Err(format!("PMI view '{view_id}' is a perspective camera; a sheet places {ORTHOGRAPHIC} views only"))
2004 }
2005}
2006
2007
2008
2009
2010