#[cfg(feature = "schema")]
use schemars::JsonSchema;
use serde::{Deserialize, Serialize};
use std::collections::BTreeMap;
use std::collections::{HashMap, HashSet};
use crate::ids::{BodyId, OccurrenceId, ProductDefinitionId};
use crate::transform::Transform;
#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)]
#[cfg_attr(feature = "schema", derive(JsonSchema))]
#[serde(transparent)]
pub struct JointId(#[serde(serialize_with = "crate::schema::serialize_reference_id")] pub String);
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[cfg_attr(feature = "schema", derive(JsonSchema))]
#[serde(rename_all = "snake_case")]
pub enum ProductDefinitionKind {
Part,
Group,
LinkGroup,
Object,
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
#[cfg_attr(feature = "schema", derive(JsonSchema))]
pub struct ProductDefinition {
pub id: ProductDefinitionId,
pub kind: ProductDefinitionKind,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub source_name: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub label: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub description: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub part_number: Option<String>,
#[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
pub bom_properties: BTreeMap<String, String>,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub bodies: Vec<BodyId>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub native_ref: Option<String>,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[cfg_attr(feature = "schema", derive(JsonSchema))]
#[serde(tag = "scope", rename_all = "snake_case")]
pub enum PrototypeReference {
Local {
definition: ProductDefinitionId,
},
External {
document: ExternalDocumentReference,
#[serde(default, skip_serializing_if = "Option::is_none")]
object: Option<String>,
},
Unresolved,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[cfg_attr(feature = "schema", derive(JsonSchema))]
pub struct ExternalDocumentReference {
#[serde(default, skip_serializing_if = "Option::is_none")]
pub path: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub document_id: Option<String>,
pub resolution: ExternalResolution,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[cfg_attr(feature = "schema", derive(JsonSchema))]
#[serde(rename_all = "snake_case")]
pub enum ExternalResolution {
Unresolved,
MissingReference,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[cfg_attr(feature = "schema", derive(JsonSchema))]
#[serde(tag = "policy", content = "native_policy", rename_all = "snake_case")]
pub enum CopyOnChangePolicy {
Disabled,
Enabled,
Owned,
Tracking,
Native(String),
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[cfg_attr(feature = "schema", derive(JsonSchema))]
#[serde(tag = "kind", rename_all = "snake_case")]
pub enum OccurrenceParent {
Root,
Occurrence {
occurrence: OccurrenceId,
},
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
#[cfg_attr(feature = "schema", derive(JsonSchema))]
pub struct Occurrence {
pub id: OccurrenceId,
pub prototype: PrototypeReference,
pub parent: OccurrenceParent,
pub ordinal: u32,
pub transform: Transform,
#[serde(default)]
pub prototype_transform: Transform,
pub scale: [f64; 3],
#[serde(default, skip_serializing_if = "Option::is_none")]
pub name: Option<String>,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub linked_subelements: Vec<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub visible: Option<bool>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub element_component: Option<ProductDefinitionId>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub claim_child: Option<bool>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub copy_on_change: Option<CopyOnChangePolicy>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub copy_on_change_source: Option<ProductDefinitionId>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub copy_on_change_group: Option<ProductDefinitionId>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub copy_on_change_touched: Option<bool>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub link_transform: Option<bool>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub native_ref: Option<String>,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum AssemblyGraphError {
DuplicateOccurrence(OccurrenceId),
MissingParent {
occurrence: OccurrenceId,
parent: OccurrenceId,
},
ParentCycle(OccurrenceId),
}
impl std::fmt::Display for AssemblyGraphError {
fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
Self::DuplicateOccurrence(id) => write!(formatter, "duplicate occurrence {id}"),
Self::MissingParent { occurrence, parent } => {
write!(
formatter,
"occurrence {occurrence} has missing parent {parent}"
)
}
Self::ParentCycle(id) => write!(formatter, "occurrence parent cycle at {id}"),
}
}
}
impl std::error::Error for AssemblyGraphError {}
pub struct AssemblyGraph<'a> {
occurrences: HashMap<&'a str, &'a Occurrence>,
resolved: HashMap<&'a str, Transform>,
}
#[cfg(test)]
mod tests {
use super::*;
fn translation(x: f64) -> Transform {
let mut transform = Transform::identity();
transform.rows[0][3] = x;
transform
}
fn occurrence(id: &str, parent: OccurrenceParent, x: f64) -> Occurrence {
Occurrence {
id: OccurrenceId(id.into()),
prototype: PrototypeReference::Unresolved,
parent,
ordinal: 0,
transform: translation(x),
prototype_transform: translation(10.0),
scale: [1.0; 3],
name: None,
linked_subelements: Vec::new(),
visible: None,
element_component: None,
claim_child: None,
copy_on_change: None,
copy_on_change_source: None,
copy_on_change_group: None,
copy_on_change_touched: None,
link_transform: None,
native_ref: None,
}
}
#[test]
fn resolves_parent_chains_and_conditional_prototype_placement() {
let root = occurrence("root", OccurrenceParent::Root, 1.0);
let mut child = occurrence(
"child",
OccurrenceParent::Occurrence {
occurrence: root.id.clone(),
},
2.0,
);
child.link_transform = Some(true);
let occurrences = [child, root];
let graph = AssemblyGraph::new(&occurrences).expect("valid graph");
assert_eq!(
graph
.resolved_transform(&OccurrenceId("child".into()))
.expect("resolved child")
.rows[0][3],
13.0
);
}
#[test]
fn rejects_duplicate_missing_and_cyclic_parent_links() {
let duplicate = occurrence("same", OccurrenceParent::Root, 0.0);
assert!(matches!(
AssemblyGraph::new(&[duplicate.clone(), duplicate]),
Err(AssemblyGraphError::DuplicateOccurrence(_))
));
let missing = occurrence(
"child",
OccurrenceParent::Occurrence {
occurrence: OccurrenceId("missing".into()),
},
0.0,
);
assert!(matches!(
AssemblyGraph::new(&[missing]),
Err(AssemblyGraphError::MissingParent { .. })
));
let first = occurrence(
"first",
OccurrenceParent::Occurrence {
occurrence: OccurrenceId("second".into()),
},
0.0,
);
let second = occurrence(
"second",
OccurrenceParent::Occurrence {
occurrence: OccurrenceId("first".into()),
},
0.0,
);
assert!(matches!(
AssemblyGraph::new(&[first, second]),
Err(AssemblyGraphError::ParentCycle(_))
));
}
}
impl<'a> AssemblyGraph<'a> {
pub fn new(occurrences: &'a [Occurrence]) -> Result<Self, AssemblyGraphError> {
let mut by_id = HashMap::with_capacity(occurrences.len());
for occurrence in occurrences {
if by_id.insert(occurrence.id.as_str(), occurrence).is_some() {
return Err(AssemblyGraphError::DuplicateOccurrence(
occurrence.id.clone(),
));
}
}
let mut resolved = HashMap::with_capacity(occurrences.len());
for occurrence in occurrences {
resolve_occurrence(occurrence, &by_id, &mut resolved, &mut HashSet::new())?;
}
Ok(Self {
occurrences: by_id,
resolved,
})
}
pub fn occurrence(&self, id: &OccurrenceId) -> Option<&'a Occurrence> {
self.occurrences.get(id.as_str()).copied()
}
pub fn resolved_transform(&self, id: &OccurrenceId) -> Option<Transform> {
self.resolved.get(id.as_str()).copied()
}
}
fn resolve_occurrence<'a>(
occurrence: &'a Occurrence,
occurrences: &HashMap<&'a str, &'a Occurrence>,
resolved: &mut HashMap<&'a str, Transform>,
active: &mut HashSet<&'a str>,
) -> Result<Transform, AssemblyGraphError> {
if let Some(transform) = resolved.get(occurrence.id.as_str()) {
return Ok(*transform);
}
if !active.insert(occurrence.id.as_str()) {
return Err(AssemblyGraphError::ParentCycle(occurrence.id.clone()));
}
let parent = match &occurrence.parent {
OccurrenceParent::Root => Transform::identity(),
OccurrenceParent::Occurrence {
occurrence: parent_id,
} => {
let Some(parent_occurrence) = occurrences.get(parent_id.as_str()).copied() else {
return Err(AssemblyGraphError::MissingParent {
occurrence: occurrence.id.clone(),
parent: parent_id.clone(),
});
};
resolve_occurrence(parent_occurrence, occurrences, resolved, active)?
}
};
let mut transform = parent.compose(occurrence.transform);
if occurrence.link_transform.unwrap_or(false) {
transform = transform.compose(occurrence.prototype_transform);
}
active.remove(occurrence.id.as_str());
resolved.insert(occurrence.id.as_str(), transform);
Ok(transform)
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[cfg_attr(feature = "schema", derive(JsonSchema))]
#[serde(tag = "kind", content = "native_kind", rename_all = "snake_case")]
pub enum JointKind {
Fixed,
Revolute,
Slider,
Cylindrical,
Ball,
Distance,
Parallel,
Perpendicular,
Angle,
RackPinion,
Screw,
Gears,
Belt,
Grounded,
Native(String),
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[cfg_attr(feature = "schema", derive(JsonSchema))]
pub struct JointOperand {
#[serde(default, skip_serializing_if = "Option::is_none")]
pub occurrence: Option<OccurrenceId>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub external_document: Option<ExternalDocumentReference>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub object: Option<String>,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub subelements: Vec<String>,
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
#[cfg_attr(feature = "schema", derive(JsonSchema))]
pub struct JointLimits {
#[serde(default, skip_serializing_if = "Option::is_none")]
pub minimum: Option<f64>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub maximum: Option<f64>,
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
#[cfg_attr(feature = "schema", derive(JsonSchema))]
pub struct AssemblyJoint {
pub id: JointId,
pub kind: JointKind,
pub operands: Vec<JointOperand>,
pub frames: Vec<Transform>,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub offset_frames: Vec<Transform>,
pub suppressed: bool,
pub detached: [bool; 2],
#[serde(default, skip_serializing_if = "Option::is_none")]
pub angle: Option<f64>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub translation_offset: Option<[f64; 3]>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub distance: Option<f64>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub distance2: Option<f64>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub angular_limits: Option<JointLimits>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub linear_limits: Option<JointLimits>,
#[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
pub properties: BTreeMap<String, String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub native_ref: Option<String>,
}