Skip to main content

brep_app/plm/
uses.rs

1//! An assembly revision's USES LIST, read off its document (S5, the pure half).
2//!
3//! The server keeps, per revision, the list of child parts it uses
4//! (`PUT /api/parts/:id/revisions/:rev/uses`). The CAD app is that list's
5//! publisher, and the document is its only source: every top-level `ACOMP`
6//! occurrence names a `partsLibrary` entry, whose `sourceKey` is the identity
7//! the part was inserted from. On a PLM store that identity is a revision key,
8//! so an occurrence resolves to a part and a concrete revision (D3: the CAD app
9//! has no floating occurrences).
10//!
11//! * [`occurrences`] walks the document.
12//! * [`uses_lines`] folds them into lines, one per part and revision, and
13//!   REFUSES every occurrence that does not resolve rather than dropping it: a
14//!   uses list one child short is a wrong BOM that validates.
15//! * [`disagreements`] compares a uses list the server holds against the
16//!   document, which is what an assembly must check on open after someone ran
17//!   Replace everywhere (the server swaps the child in the LIST, never in the
18//!   document).
19//!
20//! Nothing here talks to a server or a store. Identity comes in as a resolver
21//! (`ModelStore::identity` in the app), so a file store, whose identities are
22//! all paths, publishes nothing.
23
24use crate::store::DocumentIdentity;
25use serde_json::Value;
26
27/// A use line's fields, EXACTLY: the server answers `400` naming any other key
28/// (`BREP_plm/src/bom.rs` `check_uses`).
29pub const USE_FIELDS: [&str; 7] = [
30    "part",
31    "revision",
32    "quantity",
33    "unit",
34    "find_number",
35    "reference",
36    "notes",
37];
38
39/// One placement of a part in an assembly document.
40#[derive(Clone, Debug, PartialEq, Eq)]
41pub struct Occurrence {
42    /// The owning feature id (`ACOMP1`).
43    pub id: String,
44    /// What a person calls this placement: its `Reference_Designator` when set,
45    /// else its id — the engine's own relabel rule
46    /// (`BREP_render` `set_occurrence_attribute`).
47    pub label: String,
48    /// The `partsLibrary` entry it places.
49    pub part_name: String,
50    /// That entry's `sourceKey`: empty for an embedded-only part, `None` when
51    /// the document has no entry of that name at all.
52    pub source_key: Option<String>,
53}
54
55/// Every top-level occurrence of `document`, in feature order.
56///
57/// Nested occurrences are NOT here: a sub-assembly is one child of this
58/// revision, and its own children are its own revision's uses list. A saved
59/// document carries no rollback point, so every component feature counts.
60pub fn occurrences(document: &Value) -> Vec<Occurrence> {
61    let library = document.get("partsLibrary");
62    document
63        .get("features")
64        .and_then(Value::as_array)
65        .into_iter()
66        .flatten()
67        .filter(|feature| {
68            matches!(
69                feature.get("type").and_then(Value::as_str),
70                Some("ACOMP") | Some("ASSEMBLY COMPONENT")
71            )
72        })
73        .map(|feature| {
74            let params = &feature["inputParams"];
75            let id = params["id"].as_str().unwrap_or_default().to_string();
76            let designator = params["bom"]["Reference_Designator"]
77                .as_str()
78                .map(str::trim)
79                .unwrap_or_default();
80            let part_name = params["partName"].as_str().unwrap_or_default().to_string();
81            let source_key = library
82                .and_then(|library| library.get(&part_name))
83                .map(|entry| entry["sourceKey"].as_str().unwrap_or_default().to_string());
84            Occurrence {
85                label: if designator.is_empty() { id.clone() } else { designator.to_string() },
86                id,
87                part_name,
88                source_key,
89            }
90        })
91        .collect()
92}
93
94/// One line of a uses list, as the server takes it — these seven fields and no
95/// other ([`USE_FIELDS`]).
96#[derive(Clone, Debug, PartialEq, serde::Serialize)]
97pub struct UseLine {
98    /// The child part's id.
99    pub part: String,
100    /// The child revision's id — never empty (D3).
101    pub revision: String,
102    /// How many occurrences place this part and revision.
103    pub quantity: u32,
104    pub unit: String,
105    pub find_number: String,
106    /// The occurrences' labels, `", "`-joined in feature order.
107    pub reference: String,
108    pub notes: String,
109}
110
111/// A line and the occurrences (by feature id) it stands for.
112#[derive(Clone, Debug, PartialEq)]
113pub struct PlacedLine {
114    pub line: UseLine,
115    pub occurrences: Vec<String>,
116}
117
118/// An occurrence that names no PLM revision, and why. The publisher refuses to
119/// publish while any exist.
120#[derive(Clone, Debug, PartialEq, Eq)]
121pub struct Refusal {
122    pub occurrence: String,
123    pub reason: String,
124}
125
126/// A document's uses list: the lines, and the occurrences that could not be
127/// put on one.
128#[derive(Clone, Debug, Default, PartialEq)]
129pub struct UsesList {
130    pub lines: Vec<PlacedLine>,
131    pub refusals: Vec<Refusal>,
132}
133
134impl UsesList {
135    /// The `PUT …/uses` body, `{"uses": [...]}`. Refuses, naming every
136    /// occurrence, while any occurrence is unresolved.
137    pub fn body(&self) -> Result<Value, String> {
138        if !self.refusals.is_empty() {
139            let named: Vec<String> = self
140                .refusals
141                .iter()
142                .map(|refusal| format!("{}: {}", refusal.occurrence, refusal.reason))
143                .collect();
144            return Err(format!(
145                "the uses list was not published — {} occurrence{} name{} no PLM revision: {}",
146                self.refusals.len(),
147                if self.refusals.len() == 1 { "" } else { "s" },
148                if self.refusals.len() == 1 { "s" } else { "" },
149                named.join("; ")
150            ));
151        }
152        let lines: Vec<&UseLine> = self.lines.iter().map(|placed| &placed.line).collect();
153        Ok(serde_json::json!({ "uses": lines }))
154    }
155}
156
157/// Fold `occurrences` into uses lines: one per (part, revision), in the order
158/// each first appears; `quantity` the count, `reference` the labels. `resolve`
159/// reads a `sourceKey` as an identity (`ModelStore::identity`).
160///
161/// The fold key is part and revision ONLY. Two placements of one revision with
162/// different designators are one line with both in `reference`: a second line
163/// for the same part and revision would need a find number to tell it apart,
164/// and the server refuses the repeat.
165pub fn uses_lines(
166    occurrences: &[Occurrence],
167    resolve: &dyn Fn(&str) -> DocumentIdentity,
168) -> UsesList {
169    let mut list = UsesList::default();
170    let mut labels: Vec<Vec<String>> = Vec::new();
171    for occurrence in occurrences {
172        let refuse = |reason: String| Refusal {
173            occurrence: occurrence.label.clone(),
174            reason,
175        };
176        let key = match &occurrence.source_key {
177            None => {
178                list.refusals.push(refuse(format!(
179                    "the document has no parts-library entry '{}'",
180                    occurrence.part_name
181                )));
182                continue;
183            }
184            Some(key) if key.is_empty() => {
185                list.refusals.push(refuse(format!(
186                    "'{}' is embedded in this document and was never a part — insert it from the PLM",
187                    occurrence.part_name
188                )));
189                continue;
190            }
191            Some(key) => key,
192        };
193        let (part, revision) = match resolve(key) {
194            DocumentIdentity::Revision { part, revision } => (part, revision),
195            DocumentIdentity::Path(path) => {
196                list.refusals.push(refuse(format!(
197                    "'{}' was inserted from the file '{path}', not a PLM revision — import it first",
198                    occurrence.part_name
199                )));
200                continue;
201            }
202        };
203        match list
204            .lines
205            .iter()
206            .position(|placed| placed.line.part == part && placed.line.revision == revision)
207        {
208            Some(index) => {
209                list.lines[index].line.quantity += 1;
210                list.lines[index].occurrences.push(occurrence.id.clone());
211                labels[index].push(occurrence.label.clone());
212            }
213            None => {
214                list.lines.push(PlacedLine {
215                    line: UseLine {
216                        part,
217                        revision,
218                        quantity: 1,
219                        unit: "each".into(),
220                        find_number: String::new(),
221                        reference: String::new(),
222                        notes: String::new(),
223                    },
224                    occurrences: vec![occurrence.id.clone()],
225                });
226                labels.push(vec![occurrence.label.clone()]);
227            }
228        }
229    }
230    for (placed, labels) in list.lines.iter_mut().zip(labels) {
231        placed.line.reference = labels.join(", ");
232    }
233    list
234}
235
236/// One line of the uses list the SERVER holds for a revision, reduced to what
237/// the comparison needs. `revision` is a revision ID (the server's
238/// `resolved_uses` answers ids); empty is a floating line, which the CAD app
239/// never writes (D3) but an import or another client may have.
240#[derive(Clone, Debug, PartialEq)]
241pub struct ListedUse {
242    pub part: String,
243    pub revision: String,
244    pub quantity: f64,
245}
246
247/// Occurrences to move from one part revision to another so the document
248/// matches its uses list again.
249#[derive(Clone, Debug, PartialEq)]
250pub struct Repoint {
251    /// Feature ids, in feature order.
252    pub occurrences: Vec<String>,
253    /// `(part, revision)` the document places now.
254    pub from: (String, String),
255    /// `(part, revision)` the uses list names instead.
256    pub to: (String, String),
257}
258
259/// Where a document and its revision's uses list disagree.
260#[derive(Clone, Debug, Default, PartialEq)]
261pub struct Disagreement {
262    /// Proposed re-points: only the pairings that are unambiguous (see
263    /// [`disagreements`]).
264    pub repoints: Vec<Repoint>,
265    /// What the document places that the list does not name, left unpaired.
266    pub unlisted: Vec<PlacedLine>,
267    /// What the list names that the document does not place, left unpaired.
268    pub unplaced: Vec<ListedUse>,
269}
270
271impl Disagreement {
272    pub fn is_empty(&self) -> bool {
273        self.repoints.is_empty() && self.unlisted.is_empty() && self.unplaced.is_empty()
274    }
275}
276
277/// Compare the server's uses list for a revision with the lines its document
278/// yields. Matching is on part and revision ids, and a line that matches on
279/// both is agreement even if the quantities differ: a different COUNT is the
280/// document changing, which the next save republishes, not a swap to undo.
281///
282/// Re-points are proposed only where the pairing cannot be a guess:
283/// * the same part on both sides, once each — a revision swap;
284/// * after that, exactly one line left on each side with the same quantity —
285///   the part swap Replace everywhere makes.
286///
287/// Anything else is reported unpaired, for a person to decide.
288pub fn disagreements(listed: &[ListedUse], document: &UsesList) -> Disagreement {
289    let mut unplaced: Vec<ListedUse> = listed
290        .iter()
291        .filter(|listed| {
292            !document
293                .lines
294                .iter()
295                .any(|placed| placed.line.part == listed.part && placed.line.revision == listed.revision)
296        })
297        .cloned()
298        .collect();
299    let mut unlisted: Vec<PlacedLine> = document
300        .lines
301        .iter()
302        .filter(|placed| {
303            !listed
304                .iter()
305                .any(|listed| listed.part == placed.line.part && listed.revision == placed.line.revision)
306        })
307        .cloned()
308        .collect();
309    let mut repoints = Vec::new();
310
311    let repoint = |placed: &PlacedLine, listed: &ListedUse| Repoint {
312        occurrences: placed.occurrences.clone(),
313        from: (placed.line.part.clone(), placed.line.revision.clone()),
314        to: (listed.part.clone(), listed.revision.clone()),
315    };
316
317    // A revision swap: the part appears exactly once on each side.
318    let mut index = 0;
319    while index < unlisted.len() {
320        let part = unlisted[index].line.part.clone();
321        let same_part_listed: Vec<usize> = (0..unplaced.len())
322            .filter(|&i| unplaced[i].part == part)
323            .collect();
324        let same_part_placed = unlisted.iter().filter(|placed| placed.line.part == part).count();
325        if same_part_listed.len() == 1 && same_part_placed == 1 {
326            let listed = unplaced.remove(same_part_listed[0]);
327            let placed = unlisted.remove(index);
328            repoints.push(repoint(&placed, &listed));
329        } else {
330            index += 1;
331        }
332    }
333    // A part swap: one line left on each side, the same count.
334    if unlisted.len() == 1
335        && unplaced.len() == 1
336        && unplaced[0].quantity == f64::from(unlisted[0].line.quantity)
337    {
338        let listed = unplaced.remove(0);
339        let placed = unlisted.remove(0);
340        repoints.push(repoint(&placed, &listed));
341    }
342    Disagreement {
343        repoints,
344        unlisted,
345        unplaced,
346    }
347}
348
349/// Point the document's occurrences in `repoint` at `to_key`
350/// (`part/<part>/rev/<revision>`): every `partsLibrary` entry whose
351/// `sourceKey` names the revision they place now gets the new key.
352///
353/// Only the KEY moves. The entry's embedded document is still the old part's,
354/// and its `sourceSignature` no longer matches what the key reads, so Update
355/// Components lights and brings the new part's geometry in — the same path
356/// every other outdated component takes, not a second one. Returns how many
357/// entries were moved.
358pub fn repoint(document: &mut Value, repoint: &Repoint, resolve: &dyn Fn(&str) -> DocumentIdentity) -> usize {
359    let to_key = DocumentIdentity::Revision {
360        part: repoint.to.0.clone(),
361        revision: repoint.to.1.clone(),
362    }
363    .key();
364    let Some(library) = document.get_mut("partsLibrary").and_then(Value::as_object_mut) else {
365        return 0;
366    };
367    let mut moved = 0;
368    for entry in library.values_mut() {
369        let key = entry.get("sourceKey").and_then(Value::as_str).unwrap_or_default().to_string();
370        if key.is_empty() {
371            continue;
372        }
373        if let DocumentIdentity::Revision { part, revision } = resolve(&key) {
374            if (part, revision) == repoint.from {
375                entry["sourceKey"] = Value::String(to_key.clone());
376                moved += 1;
377            }
378        }
379    }
380    moved
381}
382
383// --- the publisher, over the PLM client --------------------------------------------
384
385/// `part/<part>/rev/<revision>` split into the route halves, or the sentence
386/// saying the document is not a PLM revision.
387fn revision_of(key: &str) -> Result<(String, String), String> {
388    match DocumentIdentity::parse_revision_key(key) {
389        Some(DocumentIdentity::Revision { part, revision }) => Ok((part, revision)),
390        _ => Err(format!("'{key}' is not a PLM revision — nothing to publish")),
391    }
392}
393
394/// The revision key a PLM store's name for a document carries: the bare key
395/// (`part/<p>/rev/<r>`), or the explorer's spelling of it
396/// (`/models/part/<p>/rev/<r>.nbrep`) — which is what a component inserted
397/// through the app records as its `sourceKey`. `None` for anything else.
398pub fn revision_key_in(name: &str) -> Option<String> {
399    let key = name.strip_prefix("/models/").unwrap_or(name);
400    if !key.starts_with("part/") {
401        return None;
402    }
403    let key = match key.rsplit_once('.') {
404        Some((stem, extension)) if !extension.contains('/') => stem,
405        _ => key,
406    };
407    DocumentIdentity::parse_revision_key(key).map(|identity| identity.key())
408}
409
410/// How a PLM store reads a `sourceKey`: a revision key (either spelling,
411/// [`revision_key_in`]) is a revision, and anything else is a path, which
412/// publishes nothing.
413pub fn plm_identity(key: &str) -> DocumentIdentity {
414    revision_key_in(key)
415        .and_then(|key| DocumentIdentity::parse_revision_key(&key))
416        .unwrap_or_else(|| DocumentIdentity::Path(key.to_string()))
417}
418
419/// Publish the uses list of the assembly revision `key` from its `document`,
420/// right after its document was written, under the same checkout (S5). An
421/// occurrence that names no PLM revision refuses the whole list, naming it;
422/// the server's refusal (no lock, a released revision, a cycle) comes back as
423/// its sentence. Returns the list sent.
424pub async fn publish_uses(
425    client: &crate::plm::client::PlmClient,
426    key: &str,
427    document: &Value,
428) -> Result<UsesList, String> {
429    let (part, revision) = revision_of(key)?;
430    let list = uses_lines(&occurrences(document), &plm_identity);
431    let body = list.body()?;
432    client
433        .call(
434            "PUT",
435            &format!("/api/parts/{part}/revisions/{revision}/uses"),
436            Some(serde_json::to_vec(&body).unwrap_or_default()),
437        )
438        .await
439        .map_err(|error| error.to_string())?;
440    Ok(list)
441}
442
443/// Whether saving `document` must (re)publish its revision's uses list: it
444/// places components, or it still has a parts library (an assembly whose
445/// last component was just deleted must publish its now-empty list, or the
446/// server keeps the old children). A plain part sends nothing.
447pub fn publishes_on_save(document: &Value) -> bool {
448    !occurrences(document).is_empty() || document.get("partsLibrary").is_some_and(|l| l.as_object().is_some_and(|m| !m.is_empty()))
449}
450
451/// The hook the PLM documents backend runs after every document write the
452/// server accepted (`plm::backend::SavedHook`): publish the uses list right
453/// after the document, under the same checkout (S5). Its refusal says the
454/// document itself WAS saved.
455pub(crate) fn publish_on_save() -> crate::plm::backend::SavedHook {
456    std::rc::Rc::new(|client, key, value| {
457        Box::pin(async move {
458            let Ok(document) = serde_json::from_str::<Value>(&value) else {
459                return Ok(());
460            };
461            if !publishes_on_save(&document) {
462                return Ok(());
463            }
464            publish_uses(&client, &key, &document)
465                .await
466                .map(|_| ())
467                .map_err(|refusal| format!("the document was saved, but its parts list was not published: {refusal}"))
468        })
469    })
470}
471
472/// Release the revision `key`, which the caller holds checked out, publishing
473/// its uses list FIRST: the server freezes the list with the revision, and with
474/// `require_released_children` on refuses the release naming every unreleased
475/// child. With it off, the release answers `warnings` naming them, which are
476/// returned for the app to show.
477///
478/// The order is the server's: a uses list is written only under the lock, and
479/// a revision is released only once it is checked in. So: publish, check in,
480/// release. A refused release leaves the revision checked in with its list
481/// published — both true of it, and the user checks out again to change either.
482pub async fn release(
483    client: &crate::plm::client::PlmClient,
484    key: &str,
485    document: &Value,
486) -> Result<Vec<String>, String> {
487    let (part, revision) = revision_of(key)?;
488    if !occurrences(document).is_empty() {
489        publish_uses(client, key, document).await?;
490    }
491    client
492        .call(
493            "POST",
494            &format!("/api/parts/{part}/revisions/{revision}/checkin"),
495            Some(b"{}".to_vec()),
496        )
497        .await
498        .map_err(|error| error.to_string())?;
499    let response = client
500        .call(
501            "POST",
502            &format!("/api/parts/{part}/revisions/{revision}/state"),
503            Some(serde_json::to_vec(&serde_json::json!({ "to": "released" })).unwrap_or_default()),
504        )
505        .await
506        .map_err(|error| error.to_string())?;
507    let answer: Value = serde_json::from_slice(&response.body).unwrap_or_default();
508    Ok(answer["warnings"]
509        .as_array()
510        .into_iter()
511        .flatten()
512        .map(|warning| match warning {
513            Value::String(text) => text.clone(),
514            other => other.to_string(),
515        })
516        .collect())
517}
518
519/// The uses list the server holds for the revision `key`, as the comparison
520/// reads it: part and revision ids (a floating line reads as the revision it
521/// resolves to), and the quantity.
522pub async fn listed_uses(client: &crate::plm::client::PlmClient, key: &str) -> Result<Vec<ListedUse>, String> {
523    let (part, revision) = revision_of(key)?;
524    let response = client
525        .call("GET", &format!("/api/parts/{part}/revisions/{revision}/uses"), None)
526        .await
527        .map_err(|error| error.to_string())?;
528    let answer: Value = serde_json::from_slice(&response.body).map_err(|error| error.to_string())?;
529    Ok(answer["uses"]
530        .as_array()
531        .into_iter()
532        .flatten()
533        .map(|line| ListedUse {
534            part: line["part"].as_str().unwrap_or_default().to_string(),
535            revision: line["revision"].as_str().unwrap_or_default().to_string(),
536            quantity: line["quantity"].as_f64().unwrap_or(0.0),
537        })
538        .collect())
539}
540
541/// On open: where the revision's uses list and its document disagree — what
542/// Replace everywhere leaves behind (it swaps the child in the LIST only). An
543/// empty answer means the next save may republish from the document safely.
544pub async fn check_on_open(
545    client: &crate::plm::client::PlmClient,
546    key: &str,
547    document: &Value,
548) -> Result<Disagreement, String> {
549    let listed = listed_uses(client, key).await?;
550    Ok(disagreements(&listed, &uses_lines(&occurrences(document), &plm_identity)))
551}
552