pub mod annotations;
pub mod font;
pub mod layout;
pub mod resolve;
use serde::{Deserialize, Serialize};
use serde_json::Value;
use std::collections::BTreeMap;
use crate::feature_pipeline::{Env, HistoryRequest, SceneMap, SelectionProbe};
pub use annotations::{pmi_schema_catalogue, pmi_type, PmiTypeDef, PMI_TYPES};
#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)]
pub struct PmiState {
#[serde(default)]
pub views: Vec<PmiView>,
#[serde(default, rename = "idCounter")]
pub id_counter: u64,
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct PmiView {
pub id: String,
#[serde(default)]
pub name: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub camera: Option<PmiCamera>,
#[serde(default)]
pub display: PmiDisplay,
#[serde(default)]
pub annotations: Vec<PmiAnnotation>,
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct PmiCamera {
pub eye: [f64; 3],
pub target: [f64; 3],
pub up: [f64; 3],
pub projection: PmiProjection,
#[serde(default = "default_viewport")]
pub viewport: [f64; 2],
}
fn default_viewport() -> [f64; 2] {
[1280.0, 800.0]
}
impl PmiCamera {
pub fn view_direction(&self) -> [f64; 3] {
let d = [
self.target[0] - self.eye[0],
self.target[1] - self.eye[1],
self.target[2] - self.eye[2],
];
let len = (d[0] * d[0] + d[1] * d[1] + d[2] * d[2]).sqrt();
if len < 1e-12 {
[0.0, 0.0, -1.0]
} else {
[d[0] / len, d[1] / len, d[2] / len]
}
}
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
#[serde(tag = "kind", rename_all = "camelCase")]
pub enum PmiProjection {
Orthographic {
#[serde(rename = "halfHeight")]
half_height: f64,
},
Perspective {
#[serde(rename = "fovYDeg")]
fov_y_deg: f64,
},
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct PmiDisplay {
#[serde(default = "default_text_size", rename = "textSizePt")]
pub text_size_pt: f64,
#[serde(default)]
pub wireframe: bool,
#[serde(default)]
pub hidden: Vec<String>,
}
fn default_text_size() -> f64 {
12.0
}
impl Default for PmiDisplay {
fn default() -> Self {
Self {
text_size_pt: default_text_size(),
wireframe: false,
hidden: Vec::new(),
}
}
}
pub fn clamp_text_size(size: f64) -> f64 {
if !size.is_finite() {
return default_text_size();
}
size.clamp(1.0, 288.0)
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct PmiAnnotation {
#[serde(rename = "type")]
pub kind: String,
#[serde(default = "default_true")]
pub enabled: bool,
#[serde(default, rename = "inputParams")]
pub params: Value,
#[serde(default, rename = "labelWorld", skip_serializing_if = "Option::is_none")]
pub label_world: Option<[f64; 3]>,
}
fn default_true() -> bool {
true
}
impl PmiAnnotation {
pub fn id(&self) -> &str {
self.params
.get("id")
.and_then(Value::as_str)
.unwrap_or("")
}
pub fn text(&self, key: &str) -> &str {
self.params
.get(key)
.and_then(Value::as_str)
.map(str::trim)
.unwrap_or("")
}
pub fn flag(&self, key: &str) -> bool {
self.params
.get(key)
.and_then(Value::as_bool)
.unwrap_or(false)
}
pub fn number(&self, key: &str, env: &Env, default: f64) -> Result<f64, String> {
match self.params.get(key) {
None | Some(Value::Null) => Ok(default),
Some(Value::Number(number)) => Ok(number.as_f64().unwrap_or(default)),
Some(Value::Bool(flag)) => Ok(if *flag { 1.0 } else { 0.0 }),
Some(Value::String(source)) => {
let source = source.trim();
if source.is_empty() {
return Ok(default);
}
env.eval(source)
.map_err(|error| format!("{key}: {error}"))
}
Some(other) => Err(format!("{key}: expected a number, got {other}")),
}
}
pub fn plane_ref(&self) -> Option<&str> {
let name = self.text("plane").trim();
(!name.is_empty()).then_some(name)
}
pub fn references(&self, key: &str) -> Vec<String> {
match self.params.get(key) {
Some(Value::String(name)) => {
let name = name.trim();
if name.is_empty() {
Vec::new()
} else {
vec![name.to_string()]
}
}
Some(Value::Array(items)) => items
.iter()
.filter_map(Value::as_str)
.map(str::trim)
.filter(|name| !name.is_empty())
.map(String::from)
.collect(),
_ => Vec::new(),
}
}
}
impl PmiState {
pub fn next_id(&mut self, prefix: &str) -> String {
let seen = self.max_numeric_suffix();
if seen > self.id_counter {
self.id_counter = seen;
}
loop {
self.id_counter += 1;
let candidate = format!("{prefix}{}", self.id_counter);
if self.find_view(&candidate).is_none() && self.find_annotation(&candidate).is_none() {
return candidate;
}
}
}
fn max_numeric_suffix(&self) -> u64 {
let mut best = 0u64;
let mut consider = |id: &str| {
let digits = id
.bytes()
.rev()
.take_while(u8::is_ascii_digit)
.count();
if digits > 0 {
if let Ok(value) = id[id.len() - digits..].parse::<u64>() {
best = best.max(value);
}
}
};
for view in &self.views {
consider(&view.id);
for annotation in &view.annotations {
consider(annotation.id());
}
}
best
}
pub fn find_view(&self, id: &str) -> Option<&PmiView> {
self.views.iter().find(|view| view.id == id)
}
pub fn find_view_mut(&mut self, id: &str) -> Option<&mut PmiView> {
self.views.iter_mut().find(|view| view.id == id)
}
pub fn find_annotation(&self, id: &str) -> Option<(&PmiView, &PmiAnnotation)> {
self.views.iter().find_map(|view| {
view.annotations
.iter()
.find(|annotation| annotation.id() == id)
.map(|annotation| (view, annotation))
})
}
pub fn locate_annotation(&self, id: &str) -> Option<(usize, usize)> {
self.views.iter().enumerate().find_map(|(view_index, view)| {
view.annotations
.iter()
.position(|annotation| annotation.id() == id)
.map(|index| (view_index, index))
})
}
pub fn find_annotation_mut(&mut self, id: &str) -> Option<&mut PmiAnnotation> {
self.views.iter_mut().find_map(|view| {
view.annotations
.iter_mut()
.find(|annotation| annotation.id() == id)
})
}
pub fn datum_letters(&self) -> Vec<(String, String)> {
let mut out = Vec::new();
for view in &self.views {
for annotation in &view.annotations {
if annotation.kind == annotations::datum::DEF.type_id {
let letter = annotation.text("letter").to_uppercase();
if !letter.is_empty() {
out.push((letter, annotation.id().to_string()));
}
}
}
}
out
}
pub fn next_datum_letter(&self) -> Option<String> {
let used: Vec<String> = self.datum_letters().into_iter().map(|(l, _)| l).collect();
let alphabet: Vec<char> = ('A'..='Z').filter(|c| !matches!(c, 'I' | 'O' | 'Q')).collect();
for letter in &alphabet {
let candidate = letter.to_string();
if !used.contains(&candidate) {
return Some(candidate);
}
}
for first in &alphabet {
for second in &alphabet {
let candidate = format!("{first}{second}");
if !used.contains(&candidate) {
return Some(candidate);
}
}
}
None
}
pub fn is_empty(&self) -> bool {
self.views.is_empty() && self.id_counter == 0
}
}
#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)]
pub struct PmiReport {
pub views: Vec<PmiViewReport>,
}
impl PmiReport {
pub fn view(&self, id: &str) -> Option<&PmiViewReport> {
self.views.iter().find(|view| view.id == id)
}
pub fn annotation(&self, id: &str) -> Option<&PmiAnnotationReport> {
self.views
.iter()
.find_map(|view| view.annotations.iter().find(|a| a.id == id))
}
pub fn annotation_mut(&mut self, id: &str) -> Option<&mut PmiAnnotationReport> {
self.views
.iter_mut()
.find_map(|view| view.annotations.iter_mut().find(|a| a.id == id))
}
}
#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)]
pub struct PmiViewReport {
pub id: String,
pub annotations: Vec<PmiAnnotationReport>,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "kebab-case")]
pub enum PmiStatus {
Ok,
Error,
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct PmiAnnotationReport {
pub id: String,
#[serde(rename = "type")]
pub kind: String,
pub enabled: bool,
pub status: PmiStatus,
#[serde(default)]
pub message: String,
#[serde(default)]
pub text: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub value: Option<f64>,
#[serde(default)]
pub unit: String,
#[serde(default)]
pub references: Vec<String>,
#[serde(rename = "labelWorld")]
pub label_world: [f64; 3],
#[serde(default, skip_serializing_if = "Option::is_none")]
pub plane: Option<PmiPlane>,
pub geometry: PmiGeometry,
}
#[derive(Debug, Clone, Copy, PartialEq, Serialize, Deserialize)]
pub struct PmiPlane {
pub origin: [f64; 3],
pub normal: [f64; 3],
#[serde(rename = "xAxis")]
pub x_axis: [f64; 3],
}
impl PmiPlane {
pub fn project(&self, point: [f64; 3]) -> [f64; 3] {
let n = self.normal;
let d = (point[0] - self.origin[0]) * n[0] + (point[1] - self.origin[1]) * n[1] + (point[2] - self.origin[2]) * n[2];
[point[0] - n[0] * d, point[1] - n[1] * d, point[2] - n[2] * d]
}
pub fn y_axis(&self) -> [f64; 3] {
let n = self.normal;
let x = self.x_axis;
[n[1] * x[2] - n[2] * x[1], n[2] * x[0] - n[0] * x[2], n[0] * x[1] - n[1] * x[0]]
}
pub fn hit(&self, origin: [f64; 3], dir: [f64; 3]) -> Option<[f64; 3]> {
let n = self.normal;
let denominator = dir[0] * n[0] + dir[1] * n[1] + dir[2] * n[2];
if denominator.abs() < 1e-12 {
return None;
}
let diff = [self.origin[0] - origin[0], self.origin[1] - origin[1], self.origin[2] - origin[2]];
let t = (diff[0] * n[0] + diff[1] * n[1] + diff[2] * n[2]) / denominator;
Some([origin[0] + dir[0] * t, origin[1] + dir[1] * t, origin[2] + dir[2] * t])
}
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
#[serde(tag = "kind", rename_all = "camelCase")]
pub enum PmiGeometry {
None,
Linear {
a: [f64; 3],
b: [f64; 3],
#[serde(default, skip_serializing_if = "Option::is_none")]
component: Option<char>,
},
Radial {
center: [f64; 3],
axis: [f64; 3],
radius: f64,
diameter: bool,
sphere: bool,
},
Angular {
vertex: [f64; 3],
#[serde(rename = "dirA")]
dir_a: [f64; 3],
#[serde(rename = "dirB")]
dir_b: [f64; 3],
axis: [f64; 3],
degrees: f64,
},
Leader {
targets: Vec<[f64; 3]>,
dot: bool,
},
Note {
position: [f64; 3],
},
Hole {
anchor: [f64; 3],
normal: [f64; 3],
},
Explode {
solids: Vec<String>,
translate: [f64; 3],
#[serde(rename = "rotateDeg")]
rotate_deg: [f64; 3],
scale: [f64; 3],
center: [f64; 3],
trace: bool,
},
Datum {
anchor: [f64; 3],
normal: [f64; 3],
letter: String,
},
Fcf {
anchor: [f64; 3],
normal: [f64; 3],
frame: FcfFrame,
},
}
#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)]
pub struct FcfFrame {
pub characteristic: String,
pub symbol: String,
pub zone: String,
pub datums: Vec<String>,
}
#[derive(Debug, Clone, Copy, PartialEq)]
pub struct ToleranceBlock {
pub mode: ToleranceMode,
pub upper: f64,
pub lower: f64,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum ToleranceMode {
None,
Symmetric,
Deviation,
Limits,
}
impl ToleranceMode {
pub fn parse(text: &str) -> Self {
match text.trim().to_ascii_lowercase().as_str() {
"symmetric" => ToleranceMode::Symmetric,
"deviation" => ToleranceMode::Deviation,
"limits" => ToleranceMode::Limits,
_ => ToleranceMode::None,
}
}
pub fn as_str(self) -> &'static str {
match self {
ToleranceMode::None => "none",
ToleranceMode::Symmetric => "symmetric",
ToleranceMode::Deviation => "deviation",
ToleranceMode::Limits => "limits",
}
}
}
impl ToleranceBlock {
pub fn read(annotation: &PmiAnnotation, env: &Env) -> Result<Self, String> {
Ok(Self {
mode: ToleranceMode::parse(annotation.text("tolMode")),
upper: annotation.number("tolUpper", env, 0.0)?.abs(),
lower: annotation.number("tolLower", env, 0.0)?.abs(),
})
}
pub fn bounds(&self) -> Option<(f64, f64)> {
match self.mode {
ToleranceMode::None => None,
ToleranceMode::Symmetric => Some((-self.upper, self.upper)),
ToleranceMode::Deviation | ToleranceMode::Limits => Some((-self.lower, self.upper)),
}
}
}
pub fn format_number(value: f64, decimals: usize) -> String {
let decimals = decimals.min(8);
let text = format!("{:.*}", decimals, value);
if text.starts_with('-') && text[1..].bytes().all(|b| b == b'0' || b == b'.') {
text[1..].to_string()
} else {
text
}
}
pub fn format_dimension(
value: f64,
decimals: usize,
tolerance: &ToleranceBlock,
is_reference: bool,
prefix: &str,
suffix: &str,
) -> String {
let nominal = format!("{prefix}{}{suffix}", format_number(value, decimals));
if is_reference {
return format!("({nominal})");
}
match tolerance.mode {
ToleranceMode::None => nominal,
ToleranceMode::Symmetric => format!(
"{nominal} \u{00B1}{}{suffix}",
format_number(tolerance.upper, decimals)
),
ToleranceMode::Deviation => format!(
"{nominal} +{}{suffix}/\u{2212}{}{suffix}",
format_number(tolerance.upper, decimals),
format_number(tolerance.lower, decimals)
),
ToleranceMode::Limits => format!(
"{prefix}{}{suffix} / {prefix}{}{suffix}",
format_number(value + tolerance.upper, decimals),
format_number(value - tolerance.lower, decimals)
),
}
}
pub struct PmiContext<'a> {
pub scene: &'a SceneMap,
pub request: &'a HistoryRequest,
pub env: &'a Env,
pub datums: BTreeMap<String, String>,
}
pub struct Resolved {
pub text: String,
pub value: Option<f64>,
pub unit: &'static str,
pub references: Vec<String>,
pub geometry: PmiGeometry,
pub default_label: [f64; 3],
}
pub(crate) fn finish_history_run(
request: &HistoryRequest,
scene: &SceneMap,
env: &Env,
) -> Option<PmiReport> {
let state = request.pmi.as_ref()?;
Some(resolve_state(state, scene, request, env))
}
pub fn resolve_state(
state: &PmiState,
scene: &SceneMap,
request: &HistoryRequest,
env: &Env,
) -> PmiReport {
let mut datums: BTreeMap<String, String> = BTreeMap::new();
for (letter, id) in state.datum_letters() {
datums.entry(letter).or_insert(id);
}
let context = PmiContext {
scene,
request,
env,
datums,
};
PmiReport {
views: state
.views
.iter()
.map(|view| PmiViewReport {
id: view.id.clone(),
annotations: view
.annotations
.iter()
.map(|annotation| resolve_annotation(annotation, &context, view.camera.as_ref()))
.collect(),
})
.collect(),
}
}
pub fn resolve_annotation(
annotation: &PmiAnnotation,
context: &PmiContext<'_>,
camera: Option<&PmiCamera>,
) -> PmiAnnotationReport {
let outcome = match pmi_type(&annotation.kind) {
Some(def) => (def.resolve)(annotation, context),
None => Err(format!("unknown PMI annotation type '{}'", annotation.kind)),
}
.and_then(|resolved| {
let plane = annotation_plane(annotation, context, camera, &resolved.geometry)?;
Ok((resolved, plane))
});
match outcome {
Ok((resolved, plane)) => {
let label = annotation.label_world.unwrap_or(resolved.default_label);
PmiAnnotationReport {
id: annotation.id().to_string(),
kind: annotation.kind.clone(),
enabled: annotation.enabled,
status: PmiStatus::Ok,
message: String::new(),
text: resolved.text,
value: resolved.value,
unit: resolved.unit.to_string(),
references: resolved.references,
label_world: plane.map_or(label, |plane| plane.project(label)),
plane,
geometry: resolved.geometry,
}
}
Err(message) => PmiAnnotationReport {
id: annotation.id().to_string(),
kind: annotation.kind.clone(),
enabled: annotation.enabled,
status: PmiStatus::Error,
message,
text: String::new(),
value: None,
unit: String::new(),
references: Vec::new(),
label_world: annotation.label_world.unwrap_or([0.0; 3]),
plane: None,
geometry: PmiGeometry::None,
},
}
}
fn annotation_plane(
annotation: &PmiAnnotation,
context: &PmiContext<'_>,
camera: Option<&PmiCamera>,
geometry: &PmiGeometry,
) -> Result<Option<PmiPlane>, String> {
use crate::SelectionGeometry;
use resolve::{a3, perpendicular_in_plane, v3};
let Some(name) = annotation.plane_ref() else {
return Ok(None);
};
let (origin, normal) = match resolve::resolve_reference(context.scene, name)? {
SelectionGeometry::Plane { origin, normal } => (origin, normal),
_ => return Err(format!("annotation plane '{name}' must be a planar face or a reference plane")),
};
let mut normal = normal
.normalized()
.map_err(|_| format!("annotation plane '{name}' has no normal"))?;
const PARALLEL: f64 = 1e-3;
match geometry {
PmiGeometry::Linear { a, b, .. } => {
let span = v3(*b).sub(v3(*a));
if span.length() > 1e-9 && span.normalized().map(|d| d.dot(normal).abs()).unwrap_or(0.0) > PARALLEL {
return Err(format!("annotation plane '{name}' is not parallel to the measured direction"));
}
}
PmiGeometry::Angular { axis, .. } => {
if v3(*axis).cross(normal).length() > PARALLEL {
return Err(format!("annotation plane '{name}' is not parallel to the angle's plane"));
}
}
PmiGeometry::Radial { axis, sphere: false, .. } => {
if v3(*axis).cross(normal).length() > PARALLEL {
return Err(format!("annotation plane '{name}' is not parallel to the circle's plane"));
}
}
_ => {}
}
let x_axis = match camera {
Some(camera) => {
let view = v3(camera.view_direction());
if normal.dot(view) > 0.0 {
normal = normal.scale(-1.0);
}
perpendicular_in_plane(normal, view.cross(v3(camera.up)))
}
None => perpendicular_in_plane(normal, crate::Vec3::new(1.0, 0.0, 0.0)),
};
Ok(Some(PmiPlane {
origin: a3(origin),
normal: a3(normal),
x_axis: a3(x_axis),
}))
}
pub fn selection_total(probe: &SelectionProbe) -> usize {
probe.faces + probe.edges + probe.vertices + probe.planes + probe.solids
}