Skip to main content

brep_kernel/feature_pipeline/pmi/
plugin.rs

1//! Typed, interpreter-independent adapter for plugin annotation presentation.
2use super::{PmiAnnotation, PmiContext, PmiGeometry, Resolved};
3use serde::{Deserialize, Serialize};
4use serde_json::{json, Value};
5use std::collections::BTreeMap;
6
7/// Provider output. Only geometry with a host-owned layout adapter is accepted.
8#[derive(Clone, Debug, Serialize, Deserialize)]
9#[serde(rename_all = "camelCase", deny_unknown_fields)]
10pub struct PluginAnnotationOutput {
11    pub text: String,
12    pub geometry: PmiGeometry,
13    pub default_label: [f64; 3],
14    #[serde(default)]
15    pub references: Vec<String>,
16    #[serde(default)]
17    pub value: Option<f64>,
18    #[serde(default)]
19    pub unit: String,
20    #[serde(default)]
21    pub persistent_data: Value,
22}
23
24pub(super) fn resolve(
25    annotation: &PmiAnnotation,
26    context: &PmiContext<'_>,
27) -> Result<(Resolved, Value, PluginAnnotationReplay), String> {
28    let mut input = annotation.clone();
29    if let Some(replay) = &annotation.plugin_replay {
30        if replay.matches(annotation, context) {
31            input.persistent_data = replay.input_data.clone();
32        }
33    }
34    // Providers must still validate exact pins and execute on every replay. A
35    // saved checkpoint is input state, not proof that a provider is available.
36    input.plugin_replay = None;
37    let output = super::super::extension::execute_annotation(&input, context)?;
38    output.validate()?;
39    if annotation
40        .label_world
41        .as_ref()
42        .is_some_and(|p| p.iter().any(|x| !x.is_finite()))
43    {
44        return Err("invalid plugin annotation label position".into());
45    }
46    for name in &output.references {
47        super::resolve::resolve_reference(context.scene, name)?;
48    }
49    let unit = match output.unit.as_str() {
50        "" => "",
51        "mm" => "mm",
52        "deg" => "deg",
53        _ => return Err("unsupported plugin annotation unit".into()),
54    };
55    let replay = PluginAnnotationReplay {
56        version: 1,
57        fingerprint: fingerprint(annotation, context),
58        dependencies: dependency_snapshots(context, output.references.iter().map(String::as_str))?,
59        input_data: input.persistent_data,
60        output_data: output.persistent_data.clone(),
61    };
62    Ok((
63        Resolved {
64            text: output.text,
65            value: output.value,
66            unit,
67            references: output.references,
68            geometry: output.geometry,
69            default_label: output.default_label,
70        },
71        output.persistent_data,
72        replay,
73    ))
74}
75
76impl PluginAnnotationOutput {
77    /// Shared validation for runtime helpers and replay's Rust adapter.
78    pub fn validate(&self) -> Result<(), String> {
79        let finite = |p: &[f64; 3]| p.iter().all(|x| x.is_finite());
80    if self.text.len() > 16_384 || self.references.len() > 256
81        || !finite(&self.default_label)
82        || self.value.is_some_and(|x| !x.is_finite())
83        || serde_json::to_vec(&self.persistent_data).map_err(|e| e.to_string())?.len() > 1_048_576
84    {
85        return Err("invalid plugin annotation output or output limit exceeded".into());
86    }
87    match &self.geometry {
88        PmiGeometry::Note { position } if finite(position) => {},
89        PmiGeometry::Leader { targets, .. }
90            if !targets.is_empty() && targets.len() <= 256 && targets.iter().all(finite) => {},
91        PmiGeometry::Linear { a, b, component }
92            if finite(a) && finite(b) && component.is_none_or(|c| matches!(c, 'X' | 'Y' | 'Z')) => {},
93        _ => return Err("unsupported or invalid plugin annotation geometry (expected note, leader or linear)".into()),
94    }
95        if let PmiGeometry::Linear { a, b, component: Some(axis) } = &self.geometry {
96            let index = match axis { 'X' => 0, 'Y' => 1, 'Z' => 2, _ => unreachable!() };
97            if (0..3).any(|i| i != index && (a[i] - b[i]).abs() > 1e-9) {
98                return Err("invalid plugin annotation geometry: component dimension endpoints must be projected onto its axis".into());
99            }
100        }
101        if !matches!(self.unit.as_str(), "" | "mm" | "deg") {
102            return Err("unsupported plugin annotation unit".into());
103        }
104        Ok(())
105    }
106}
107
108/// Serializable transition input needed to reproduce an accepted callback result.
109/// No geometry/output presentation is cached: callbacks always run and validate.
110#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
111#[serde(rename_all = "camelCase")]
112pub struct PluginAnnotationReplay {
113    pub version: u32,
114    pub fingerprint: Value,
115    pub dependencies: BTreeMap<String, Value>,
116    pub input_data: Value,
117    pub output_data: Value,
118}
119
120impl PluginAnnotationReplay {
121    fn matches(&self, annotation: &PmiAnnotation, context: &PmiContext<'_>) -> bool {
122        self.version == 1
123            && self.output_data == annotation.persistent_data
124            && self.fingerprint == fingerprint(annotation, context)
125            && dependency_snapshots(context, self.dependencies.keys().map(String::as_str))
126                .is_ok_and(|current| current == self.dependencies)
127    }
128}
129
130fn fingerprint(annotation: &PmiAnnotation, context: &PmiContext<'_>) -> Value {
131    let owner = annotation.kind.split('/').next().unwrap_or("");
132    let pins: Vec<_> = context
133        .request
134        .plugins
135        .iter()
136        .filter(|p| p.id == owner)
137        .collect();
138    // Schema evaluation belongs to the provider. Including raw expressions and
139    // configurator conservatively invalidates every expression-environment edit.
140    // Camera, label placement and unrelated annotations are not callback inputs.
141    json!({"type": annotation.kind, "pins": pins, "params": annotation.params,
142        "expressions": context.request.expressions, "configurator": context.request.configurator})
143}
144
145fn dependency_snapshots<'a>(
146    context: &PmiContext<'_>,
147    names: impl IntoIterator<Item = &'a str>,
148) -> Result<BTreeMap<String, Value>, String> {
149    names
150        .into_iter()
151        .map(|name| {
152            let geometry = super::resolve::resolve_reference(context.scene, name)?;
153            let value = serde_json::to_value(geometry).map_err(|e| e.to_string())?;
154            Ok((name.to_owned(), value))
155        })
156        .collect()
157}