1pub mod annotations;
64pub mod font;
65pub mod layout;
66pub mod resolve;
67
68use serde::{Deserialize, Serialize};
69use serde_json::Value;
70use std::collections::BTreeMap;
71
72use crate::feature_pipeline::{Env, HistoryRequest, SceneMap, SelectionProbe};
73
74pub use annotations::{pmi_schema_catalogue, pmi_type, PmiTypeDef, PMI_TYPES};
75
76#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)]
82pub struct PmiState {
83 #[serde(default)]
84 pub views: Vec<PmiView>,
85 #[serde(default, rename = "idCounter")]
88 pub id_counter: u64,
89}
90
91#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
93pub struct PmiView {
94 pub id: String,
95 #[serde(default)]
96 pub name: String,
97 #[serde(default, skip_serializing_if = "Option::is_none")]
98 pub camera: Option<PmiCamera>,
99 #[serde(default)]
100 pub display: PmiDisplay,
101 #[serde(default)]
102 pub annotations: Vec<PmiAnnotation>,
103}
104
105#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
110pub struct PmiCamera {
111 pub eye: [f64; 3],
112 pub target: [f64; 3],
113 pub up: [f64; 3],
114 pub projection: PmiProjection,
115 #[serde(default = "default_viewport")]
117 pub viewport: [f64; 2],
118}
119
120fn default_viewport() -> [f64; 2] {
121 [1280.0, 800.0]
122}
123
124impl PmiCamera {
125 pub fn view_direction(&self) -> [f64; 3] {
127 let d = [
128 self.target[0] - self.eye[0],
129 self.target[1] - self.eye[1],
130 self.target[2] - self.eye[2],
131 ];
132 let len = (d[0] * d[0] + d[1] * d[1] + d[2] * d[2]).sqrt();
133 if len < 1e-12 {
134 [0.0, 0.0, -1.0]
135 } else {
136 [d[0] / len, d[1] / len, d[2] / len]
137 }
138 }
139}
140
141#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
142#[serde(tag = "kind", rename_all = "camelCase")]
143pub enum PmiProjection {
144 Orthographic {
145 #[serde(rename = "halfHeight")]
146 half_height: f64,
147 },
148 Perspective {
149 #[serde(rename = "fovYDeg")]
150 fov_y_deg: f64,
151 },
152}
153
154#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
156pub struct PmiDisplay {
157 #[serde(default = "default_text_size", rename = "textSizePt")]
159 pub text_size_pt: f64,
160 #[serde(default)]
161 pub wireframe: bool,
162 #[serde(default)]
165 pub hidden: Vec<String>,
166}
167
168fn default_text_size() -> f64 {
169 12.0
170}
171
172impl Default for PmiDisplay {
173 fn default() -> Self {
174 Self {
175 text_size_pt: default_text_size(),
176 wireframe: false,
177 hidden: Vec::new(),
178 }
179 }
180}
181
182pub fn clamp_text_size(size: f64) -> f64 {
184 if !size.is_finite() {
185 return default_text_size();
186 }
187 size.clamp(1.0, 288.0)
188}
189
190#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
192pub struct PmiAnnotation {
193 #[serde(rename = "type")]
194 pub kind: String,
195 #[serde(default = "default_true")]
196 pub enabled: bool,
197 #[serde(default, rename = "inputParams")]
198 pub params: Value,
199 #[serde(default, rename = "labelWorld", skip_serializing_if = "Option::is_none")]
202 pub label_world: Option<[f64; 3]>,
203}
204
205fn default_true() -> bool {
206 true
207}
208
209impl PmiAnnotation {
210 pub fn id(&self) -> &str {
212 self.params
213 .get("id")
214 .and_then(Value::as_str)
215 .unwrap_or("")
216 }
217
218 pub fn text(&self, key: &str) -> &str {
220 self.params
221 .get(key)
222 .and_then(Value::as_str)
223 .map(str::trim)
224 .unwrap_or("")
225 }
226
227 pub fn flag(&self, key: &str) -> bool {
229 self.params
230 .get(key)
231 .and_then(Value::as_bool)
232 .unwrap_or(false)
233 }
234
235 pub fn number(&self, key: &str, env: &Env, default: f64) -> Result<f64, String> {
238 match self.params.get(key) {
239 None | Some(Value::Null) => Ok(default),
240 Some(Value::Number(number)) => Ok(number.as_f64().unwrap_or(default)),
241 Some(Value::Bool(flag)) => Ok(if *flag { 1.0 } else { 0.0 }),
242 Some(Value::String(source)) => {
243 let source = source.trim();
244 if source.is_empty() {
245 return Ok(default);
246 }
247 env.eval(source)
248 .map_err(|error| format!("{key}: {error}"))
249 }
250 Some(other) => Err(format!("{key}: expected a number, got {other}")),
251 }
252 }
253
254 pub fn plane_ref(&self) -> Option<&str> {
257 let name = self.text("plane").trim();
258 (!name.is_empty()).then_some(name)
259 }
260
261 pub fn references(&self, key: &str) -> Vec<String> {
264 match self.params.get(key) {
265 Some(Value::String(name)) => {
266 let name = name.trim();
267 if name.is_empty() {
268 Vec::new()
269 } else {
270 vec![name.to_string()]
271 }
272 }
273 Some(Value::Array(items)) => items
274 .iter()
275 .filter_map(Value::as_str)
276 .map(str::trim)
277 .filter(|name| !name.is_empty())
278 .map(String::from)
279 .collect(),
280 _ => Vec::new(),
281 }
282 }
283}
284
285impl PmiState {
286 pub fn next_id(&mut self, prefix: &str) -> String {
290 let seen = self.max_numeric_suffix();
291 if seen > self.id_counter {
292 self.id_counter = seen;
293 }
294 loop {
295 self.id_counter += 1;
296 let candidate = format!("{prefix}{}", self.id_counter);
297 if self.find_view(&candidate).is_none() && self.find_annotation(&candidate).is_none() {
298 return candidate;
299 }
300 }
301 }
302
303 fn max_numeric_suffix(&self) -> u64 {
304 let mut best = 0u64;
305 let mut consider = |id: &str| {
306 let digits = id
307 .bytes()
308 .rev()
309 .take_while(u8::is_ascii_digit)
310 .count();
311 if digits > 0 {
312 if let Ok(value) = id[id.len() - digits..].parse::<u64>() {
313 best = best.max(value);
314 }
315 }
316 };
317 for view in &self.views {
318 consider(&view.id);
319 for annotation in &view.annotations {
320 consider(annotation.id());
321 }
322 }
323 best
324 }
325
326 pub fn find_view(&self, id: &str) -> Option<&PmiView> {
327 self.views.iter().find(|view| view.id == id)
328 }
329
330 pub fn find_view_mut(&mut self, id: &str) -> Option<&mut PmiView> {
331 self.views.iter_mut().find(|view| view.id == id)
332 }
333
334 pub fn find_annotation(&self, id: &str) -> Option<(&PmiView, &PmiAnnotation)> {
336 self.views.iter().find_map(|view| {
337 view.annotations
338 .iter()
339 .find(|annotation| annotation.id() == id)
340 .map(|annotation| (view, annotation))
341 })
342 }
343
344 pub fn locate_annotation(&self, id: &str) -> Option<(usize, usize)> {
346 self.views.iter().enumerate().find_map(|(view_index, view)| {
347 view.annotations
348 .iter()
349 .position(|annotation| annotation.id() == id)
350 .map(|index| (view_index, index))
351 })
352 }
353
354 pub fn find_annotation_mut(&mut self, id: &str) -> Option<&mut PmiAnnotation> {
355 self.views.iter_mut().find_map(|view| {
356 view.annotations
357 .iter_mut()
358 .find(|annotation| annotation.id() == id)
359 })
360 }
361
362 pub fn datum_letters(&self) -> Vec<(String, String)> {
366 let mut out = Vec::new();
367 for view in &self.views {
368 for annotation in &view.annotations {
369 if annotation.kind == annotations::datum::DEF.type_id {
370 let letter = annotation.text("letter").to_uppercase();
371 if !letter.is_empty() {
372 out.push((letter, annotation.id().to_string()));
373 }
374 }
375 }
376 }
377 out
378 }
379
380 pub fn next_datum_letter(&self) -> Option<String> {
383 let used: Vec<String> = self.datum_letters().into_iter().map(|(l, _)| l).collect();
384 let alphabet: Vec<char> = ('A'..='Z').filter(|c| !matches!(c, 'I' | 'O' | 'Q')).collect();
385 for letter in &alphabet {
386 let candidate = letter.to_string();
387 if !used.contains(&candidate) {
388 return Some(candidate);
389 }
390 }
391 for first in &alphabet {
392 for second in &alphabet {
393 let candidate = format!("{first}{second}");
394 if !used.contains(&candidate) {
395 return Some(candidate);
396 }
397 }
398 }
399 None
400 }
401
402 pub fn is_empty(&self) -> bool {
404 self.views.is_empty() && self.id_counter == 0
405 }
406}
407
408#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)]
414pub struct PmiReport {
415 pub views: Vec<PmiViewReport>,
416}
417
418impl PmiReport {
419 pub fn view(&self, id: &str) -> Option<&PmiViewReport> {
420 self.views.iter().find(|view| view.id == id)
421 }
422
423 pub fn annotation(&self, id: &str) -> Option<&PmiAnnotationReport> {
424 self.views
425 .iter()
426 .find_map(|view| view.annotations.iter().find(|a| a.id == id))
427 }
428
429 pub fn annotation_mut(&mut self, id: &str) -> Option<&mut PmiAnnotationReport> {
430 self.views
431 .iter_mut()
432 .find_map(|view| view.annotations.iter_mut().find(|a| a.id == id))
433 }
434}
435
436#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)]
437pub struct PmiViewReport {
438 pub id: String,
439 pub annotations: Vec<PmiAnnotationReport>,
440}
441
442#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
443#[serde(rename_all = "kebab-case")]
444pub enum PmiStatus {
445 Ok,
447 Error,
450}
451
452#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
454pub struct PmiAnnotationReport {
455 pub id: String,
456 #[serde(rename = "type")]
457 pub kind: String,
458 pub enabled: bool,
459 pub status: PmiStatus,
460 #[serde(default)]
461 pub message: String,
462 #[serde(default)]
465 pub text: String,
466 #[serde(default, skip_serializing_if = "Option::is_none")]
468 pub value: Option<f64>,
469 #[serde(default)]
471 pub unit: String,
472 #[serde(default)]
475 pub references: Vec<String>,
476 #[serde(rename = "labelWorld")]
479 pub label_world: [f64; 3],
480 #[serde(default, skip_serializing_if = "Option::is_none")]
482 pub plane: Option<PmiPlane>,
483 pub geometry: PmiGeometry,
484}
485
486#[derive(Debug, Clone, Copy, PartialEq, Serialize, Deserialize)]
493pub struct PmiPlane {
494 pub origin: [f64; 3],
495 pub normal: [f64; 3],
496 #[serde(rename = "xAxis")]
497 pub x_axis: [f64; 3],
498}
499
500impl PmiPlane {
501 pub fn project(&self, point: [f64; 3]) -> [f64; 3] {
503 let n = self.normal;
504 let d = (point[0] - self.origin[0]) * n[0] + (point[1] - self.origin[1]) * n[1] + (point[2] - self.origin[2]) * n[2];
505 [point[0] - n[0] * d, point[1] - n[1] * d, point[2] - n[2] * d]
506 }
507
508 pub fn y_axis(&self) -> [f64; 3] {
510 let n = self.normal;
511 let x = self.x_axis;
512 [n[1] * x[2] - n[2] * x[1], n[2] * x[0] - n[0] * x[2], n[0] * x[1] - n[1] * x[0]]
513 }
514
515 pub fn hit(&self, origin: [f64; 3], dir: [f64; 3]) -> Option<[f64; 3]> {
517 let n = self.normal;
518 let denominator = dir[0] * n[0] + dir[1] * n[1] + dir[2] * n[2];
519 if denominator.abs() < 1e-12 {
520 return None;
521 }
522 let diff = [self.origin[0] - origin[0], self.origin[1] - origin[1], self.origin[2] - origin[2]];
523 let t = (diff[0] * n[0] + diff[1] * n[1] + diff[2] * n[2]) / denominator;
524 Some([origin[0] + dir[0] * t, origin[1] + dir[1] * t, origin[2] + dir[2] * t])
525 }
526}
527
528#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
531#[serde(tag = "kind", rename_all = "camelCase")]
532pub enum PmiGeometry {
533 None,
534 Linear {
537 a: [f64; 3],
538 b: [f64; 3],
539 #[serde(default, skip_serializing_if = "Option::is_none")]
540 component: Option<char>,
541 },
542 Radial {
543 center: [f64; 3],
544 axis: [f64; 3],
545 radius: f64,
546 diameter: bool,
547 sphere: bool,
548 },
549 Angular {
552 vertex: [f64; 3],
553 #[serde(rename = "dirA")]
554 dir_a: [f64; 3],
555 #[serde(rename = "dirB")]
556 dir_b: [f64; 3],
557 axis: [f64; 3],
558 degrees: f64,
559 },
560 Leader {
561 targets: Vec<[f64; 3]>,
562 dot: bool,
563 },
564 Note {
565 position: [f64; 3],
566 },
567 Hole {
568 anchor: [f64; 3],
569 normal: [f64; 3],
570 },
571 Explode {
573 solids: Vec<String>,
574 translate: [f64; 3],
575 #[serde(rename = "rotateDeg")]
576 rotate_deg: [f64; 3],
577 scale: [f64; 3],
578 center: [f64; 3],
580 trace: bool,
581 },
582 Datum {
583 anchor: [f64; 3],
584 normal: [f64; 3],
585 letter: String,
586 },
587 Fcf {
588 anchor: [f64; 3],
589 normal: [f64; 3],
590 frame: FcfFrame,
591 },
592}
593
594#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)]
596pub struct FcfFrame {
597 pub characteristic: String,
599 pub symbol: String,
601 pub zone: String,
603 pub datums: Vec<String>,
605}
606
607#[derive(Debug, Clone, Copy, PartialEq)]
614pub struct ToleranceBlock {
615 pub mode: ToleranceMode,
616 pub upper: f64,
617 pub lower: f64,
618}
619
620#[derive(Debug, Clone, Copy, PartialEq, Eq)]
621pub enum ToleranceMode {
622 None,
623 Symmetric,
624 Deviation,
625 Limits,
626}
627
628impl ToleranceMode {
629 pub fn parse(text: &str) -> Self {
630 match text.trim().to_ascii_lowercase().as_str() {
631 "symmetric" => ToleranceMode::Symmetric,
632 "deviation" => ToleranceMode::Deviation,
633 "limits" => ToleranceMode::Limits,
634 _ => ToleranceMode::None,
635 }
636 }
637
638 pub fn as_str(self) -> &'static str {
639 match self {
640 ToleranceMode::None => "none",
641 ToleranceMode::Symmetric => "symmetric",
642 ToleranceMode::Deviation => "deviation",
643 ToleranceMode::Limits => "limits",
644 }
645 }
646}
647
648impl ToleranceBlock {
649 pub fn read(annotation: &PmiAnnotation, env: &Env) -> Result<Self, String> {
651 Ok(Self {
652 mode: ToleranceMode::parse(annotation.text("tolMode")),
653 upper: annotation.number("tolUpper", env, 0.0)?.abs(),
654 lower: annotation.number("tolLower", env, 0.0)?.abs(),
655 })
656 }
657
658 pub fn bounds(&self) -> Option<(f64, f64)> {
661 match self.mode {
662 ToleranceMode::None => None,
663 ToleranceMode::Symmetric => Some((-self.upper, self.upper)),
664 ToleranceMode::Deviation | ToleranceMode::Limits => Some((-self.lower, self.upper)),
665 }
666 }
667}
668
669pub fn format_number(value: f64, decimals: usize) -> String {
671 let decimals = decimals.min(8);
672 let text = format!("{:.*}", decimals, value);
673 if text.starts_with('-') && text[1..].bytes().all(|b| b == b'0' || b == b'.') {
675 text[1..].to_string()
676 } else {
677 text
678 }
679}
680
681pub fn format_dimension(
685 value: f64,
686 decimals: usize,
687 tolerance: &ToleranceBlock,
688 is_reference: bool,
689 prefix: &str,
690 suffix: &str,
691) -> String {
692 let nominal = format!("{prefix}{}{suffix}", format_number(value, decimals));
693 if is_reference {
694 return format!("({nominal})");
695 }
696 match tolerance.mode {
697 ToleranceMode::None => nominal,
698 ToleranceMode::Symmetric => format!(
699 "{nominal} \u{00B1}{}{suffix}",
700 format_number(tolerance.upper, decimals)
701 ),
702 ToleranceMode::Deviation => format!(
703 "{nominal} +{}{suffix}/\u{2212}{}{suffix}",
704 format_number(tolerance.upper, decimals),
705 format_number(tolerance.lower, decimals)
706 ),
707 ToleranceMode::Limits => format!(
708 "{prefix}{}{suffix} / {prefix}{}{suffix}",
709 format_number(value + tolerance.upper, decimals),
710 format_number(value - tolerance.lower, decimals)
711 ),
712 }
713}
714
715pub struct PmiContext<'a> {
722 pub scene: &'a SceneMap,
723 pub request: &'a HistoryRequest,
724 pub env: &'a Env,
725 pub datums: BTreeMap<String, String>,
727}
728
729pub struct Resolved {
731 pub text: String,
732 pub value: Option<f64>,
733 pub unit: &'static str,
734 pub references: Vec<String>,
735 pub geometry: PmiGeometry,
736 pub default_label: [f64; 3],
738}
739
740pub(crate) fn finish_history_run(
743 request: &HistoryRequest,
744 scene: &SceneMap,
745 env: &Env,
746) -> Option<PmiReport> {
747 let state = request.pmi.as_ref()?;
748 Some(resolve_state(state, scene, request, env))
749}
750
751pub fn resolve_state(
753 state: &PmiState,
754 scene: &SceneMap,
755 request: &HistoryRequest,
756 env: &Env,
757) -> PmiReport {
758 let mut datums: BTreeMap<String, String> = BTreeMap::new();
761 for (letter, id) in state.datum_letters() {
762 datums.entry(letter).or_insert(id);
763 }
764 let context = PmiContext {
765 scene,
766 request,
767 env,
768 datums,
769 };
770 PmiReport {
771 views: state
772 .views
773 .iter()
774 .map(|view| PmiViewReport {
775 id: view.id.clone(),
776 annotations: view
777 .annotations
778 .iter()
779 .map(|annotation| resolve_annotation(annotation, &context, view.camera.as_ref()))
780 .collect(),
781 })
782 .collect(),
783 }
784}
785
786pub fn resolve_annotation(
790 annotation: &PmiAnnotation,
791 context: &PmiContext<'_>,
792 camera: Option<&PmiCamera>,
793) -> PmiAnnotationReport {
794 let outcome = match pmi_type(&annotation.kind) {
795 Some(def) => (def.resolve)(annotation, context),
796 None => Err(format!("unknown PMI annotation type '{}'", annotation.kind)),
797 }
798 .and_then(|resolved| {
799 let plane = annotation_plane(annotation, context, camera, &resolved.geometry)?;
800 Ok((resolved, plane))
801 });
802 match outcome {
803 Ok((resolved, plane)) => {
804 let label = annotation.label_world.unwrap_or(resolved.default_label);
805 PmiAnnotationReport {
806 id: annotation.id().to_string(),
807 kind: annotation.kind.clone(),
808 enabled: annotation.enabled,
809 status: PmiStatus::Ok,
810 message: String::new(),
811 text: resolved.text,
812 value: resolved.value,
813 unit: resolved.unit.to_string(),
814 references: resolved.references,
815 label_world: plane.map_or(label, |plane| plane.project(label)),
816 plane,
817 geometry: resolved.geometry,
818 }
819 }
820 Err(message) => PmiAnnotationReport {
821 id: annotation.id().to_string(),
822 kind: annotation.kind.clone(),
823 enabled: annotation.enabled,
824 status: PmiStatus::Error,
825 message,
826 text: String::new(),
827 value: None,
828 unit: String::new(),
829 references: Vec::new(),
830 label_world: annotation.label_world.unwrap_or([0.0; 3]),
831 plane: None,
832 geometry: PmiGeometry::None,
833 },
834 }
835}
836
837fn annotation_plane(
845 annotation: &PmiAnnotation,
846 context: &PmiContext<'_>,
847 camera: Option<&PmiCamera>,
848 geometry: &PmiGeometry,
849) -> Result<Option<PmiPlane>, String> {
850 use crate::SelectionGeometry;
851 use resolve::{a3, perpendicular_in_plane, v3};
852 let Some(name) = annotation.plane_ref() else {
853 return Ok(None);
854 };
855 let (origin, normal) = match resolve::resolve_reference(context.scene, name)? {
856 SelectionGeometry::Plane { origin, normal } => (origin, normal),
857 _ => return Err(format!("annotation plane '{name}' must be a planar face or a reference plane")),
858 };
859 let mut normal = normal
860 .normalized()
861 .map_err(|_| format!("annotation plane '{name}' has no normal"))?;
862 const PARALLEL: f64 = 1e-3;
864 match geometry {
865 PmiGeometry::Linear { a, b, .. } => {
866 let span = v3(*b).sub(v3(*a));
867 if span.length() > 1e-9 && span.normalized().map(|d| d.dot(normal).abs()).unwrap_or(0.0) > PARALLEL {
868 return Err(format!("annotation plane '{name}' is not parallel to the measured direction"));
869 }
870 }
871 PmiGeometry::Angular { axis, .. } => {
872 if v3(*axis).cross(normal).length() > PARALLEL {
873 return Err(format!("annotation plane '{name}' is not parallel to the angle's plane"));
874 }
875 }
876 PmiGeometry::Radial { axis, sphere: false, .. } => {
877 if v3(*axis).cross(normal).length() > PARALLEL {
878 return Err(format!("annotation plane '{name}' is not parallel to the circle's plane"));
879 }
880 }
881 _ => {}
882 }
883 let x_axis = match camera {
884 Some(camera) => {
885 let view = v3(camera.view_direction());
886 if normal.dot(view) > 0.0 {
887 normal = normal.scale(-1.0);
888 }
889 perpendicular_in_plane(normal, view.cross(v3(camera.up)))
890 }
891 None => perpendicular_in_plane(normal, crate::Vec3::new(1.0, 0.0, 0.0)),
892 };
893 Ok(Some(PmiPlane {
894 origin: a3(origin),
895 normal: a3(normal),
896 x_axis: a3(x_axis),
897 }))
898}
899
900pub fn selection_total(probe: &SelectionProbe) -> usize {
904 probe.faces + probe.edges + probe.vertices + probe.planes + probe.solids
905}
906
907