Skip to main content

brep_app/document/
work_part.rs

1//! Work part: edit one part occurrence's own history in the context of its
2//! assembly, at any depth of the product structure (NX "Make Work Part").
3//!
4//! IN PLACE (the default, a BOM double-click): the part's document is HOSTED by
5//! the assembly tab. It is not a tab of its own; the tab strip, the tab's name,
6//! Save and the dirty dot all stay the assembly's. [`Documents::engine`] answers
7//! with the hosted part engine, so every modeling tool and the history panel
8//! edit the part, while the rest of the assembly is drawn around it as a
9//! translucent, non-selectable context scene in the same camera.
10//!
11//! NEW TAB (the row menu's "Open in new tab"): the same session, but the part
12//! document is an ordinary tab and the assembly a background one.
13//!
14//! LIVE UPDATE. Every settled part change is pushed into the assembly's
15//! embedded definitions (every copy with the same source identity, at every
16//! depth) through `edit_document_json`, which re-runs the assembly on its own
17//! runner. Mates are solved at the tail of that run, so components constrained
18//! to the edited geometry move in the same update. When the assembly's run
19//! lands the context scene is rebuilt from it (and re-mapped if the work
20//! occurrence itself moved). A push is never queued behind a running assembly
21//! run: while one is in flight the latest part state waits, so a burst of edits
22//! costs one rerun of the newest state.
23//!
24//! UNDO. One part edit is one part undo step and one assembly undo step. The
25//! session remembers the part states it pushed; a part undo (redo) that returns
26//! to the previous (next) pushed state undoes (redoes) the assembly step
27//! instead of pushing a new one, and a different edit after an undo discards
28//! the redo branch of both. Coalesced part edits (one slider drag) replace the
29//! assembly step they started rather than stacking. Returning adds nothing:
30//! the assembly already holds one step per part edit.
31use super::{Document, Documents};
32use brep_render::{brep_kernel, camera::Aabb, engine_state::EngineState, scene::RenderScene};
33use serde_json::Value;
34
35type Matrix = [f64; 16];
36const IDENTITY: Matrix = [
37    1., 0., 0., 0., 0., 1., 0., 0., 0., 0., 1., 0., 0., 0., 0., 1.,
38];
39
40/// Where the work part's document lives while it is being edited.
41#[derive(Clone, Copy, Debug, PartialEq, Eq)]
42pub enum WorkPartMode {
43    /// Hosted by the assembly tab (no tab switch).
44    InPlace,
45    /// An ordinary tab of its own; the assembly keeps running in the background.
46    NewTab,
47}
48
49/// The entry at the end of a part-name chain in an assembly document.
50pub(super) fn entry_at<'a>(document: &'a Value, names: &[String]) -> Option<&'a Value> {
51    let (last, parents) = names.split_last()?;
52    let mut owner = document;
53    for name in parents {
54        owner = &owner["partsLibrary"][name]["document"];
55    }
56    let entry = &owner["partsLibrary"][last];
57    entry.is_object().then_some(entry)
58}
59
60/// One part state the session pushed into the assembly.
61struct Pushed {
62    signature: String,
63    /// The part's undo-stack top when it was pushed: unchanged across two
64    /// pushes means the second edit coalesced into the first's undo step.
65    undo_top: Option<u64>,
66    /// The parts this push re-signed and the mates it captured before its
67    /// commit, from the document its assembly step starts from. A coalesced
68    /// edit replaces the step from that same document, so it reuses them.
69    capture: Option<(Vec<String>, brep_render::engine_state::replace_component::ReplaceCapture)>,
70}
71
72pub(super) struct WorkPart {
73    pub assembly_id: u64,
74    pub part_id: u64,
75    /// `Some` while in place: the hosted part document.
76    pub(super) hosted: Option<Box<Document>>,
77    name: String,
78    component_path: String,
79    part_path: Vec<String>,
80    source: Option<String>,
81    world: Matrix,
82    /// Pushed part states, oldest first; `[0]` is the state the session began
83    /// with (nothing to undo past it).
84    pushed: Vec<Pushed>,
85    /// Pushed states undone in step with the part, newest last.
86    redo: Vec<Pushed>,
87    /// The part's (applied generation, history revision) last examined.
88    seen: Option<(u64, u64)>,
89    /// The assembly's (applied generation, history revision) the context was
90    /// built from.
91    context_seen: Option<(u64, u64)>,
92    /// Constraints of the assembly that are not satisfied after the last
93    /// update: `(id, type, status, message)`.
94    problems: Vec<(String, String, String, String)>,
95    /// How many pushes reached the assembly (automation readback).
96    pushes: u64,
97    /// The last push refusal, shown in the bar.
98    error: Option<String>,
99    /// A PLM part not checked out to this user: the offer to check it out
100    /// now (asked once per session), and the checkout in flight.
101    checkout: CheckoutOffer,
102}
103
104/// [`WorkPart::checkout`].
105#[derive(Default)]
106enum CheckoutOffer {
107    /// Not asked yet (or not a PLM part: asked of nothing).
108    #[default]
109    Unasked,
110    /// The question is on screen.
111    Asking { part: String, revision: String },
112    Running(crate::plm::PlmFuture<()>),
113    /// Answered, landed, or nothing to ask; `Some` is a line for the bar.
114    Done(Option<String>),
115}
116
117/// Resolve `component_path` (`ACOMP1:ACOMP2`) in the assembly: the
118/// occurrence's world matrix, the part-name chain, the part's source key and
119/// its embedded document.
120pub(super) fn resolve_occurrence(
121    root: &EngineState,
122    root_document: &Value,
123    component_path: &str,
124) -> Result<(Matrix, Vec<String>, Option<String>, Value), String> {
125    let mut document = root_document;
126    let mut world = IDENTITY;
127    let mut part_path = Vec::new();
128    let mut source = None;
129    let ids: Vec<&str> = component_path.split(':').collect();
130    let mut top: Option<&brep_render::pipeline::ComponentSnapshot> = None;
131    for (depth, &id) in ids.iter().enumerate() {
132        let feature = document["features"]
133            .as_array()
134            .and_then(|features| {
135                features.iter().find(|f| {
136                    matches!(f["type"].as_str(), Some("ACOMP" | "ASSEMBLY COMPONENT"))
137                        && f["inputParams"]["id"].as_str() == Some(id)
138                })
139            })
140            .ok_or_else(|| format!("component '{id}' is missing"))?;
141        let name = feature["inputParams"]["partName"]
142            .as_str()
143            .ok_or("component has no part name")?;
144        // Every depth reads the SOLVED pose: the assembly's mates move a
145        // top-level component, and a sub-assembly's own mates (solved inside
146        // it, rigid to the parent) can move a nested occurrence away from the
147        // transform its document authors. The top-level record carries where
148        // each nested occurrence ended up, in its own frame; the authored
149        // transform is only the fallback for a record that has none.
150        world = if depth == 0 {
151            let record = root
152                .assembly_components()
153                .iter()
154                .find(|c| c.id == id)
155                .ok_or("component is not present in the current assembly")?;
156            top = Some(record);
157            record.transform
158        } else {
159            match top.and_then(|record| Some((record, record.nested_poses.get(&ids[1..=depth].join(":"))?))) {
160                Some((record, solved)) => multiply(record.transform, *solved),
161                None => multiply(world, component_pose(document, &feature["inputParams"]["transform"])?),
162            }
163        };
164        part_path.push(name.to_string());
165        let entry = &document["partsLibrary"][name];
166        source = entry["sourceKey"]
167            .as_str()
168            .filter(|s| !s.is_empty() && !s.starts_with('@'))
169            .map(str::to_string);
170        document = &entry["document"];
171        if !document.is_object() {
172            return Err(format!("part '{name}' has no embedded document"));
173        }
174    }
175    Ok((world, part_path, source, document.clone()))
176}
177
178/// The assembly drawn around the work part: every visible solid OUTSIDE the
179/// work occurrence, mapped into the part's frame, without kernel handles.
180///
181/// A ghost built exactly as it stood in `previous` keeps that one's revision,
182/// so the GPU keeps its buffers. A run of the assembly gives every solid it
183/// rebuilt a new revision, and on l500 each edit rebuilds all five modules:
184/// the 490 context solids were uploaded again on the frame the run landed
185/// (~60 ms), the same meshes nearly all of them.
186fn context_scene(host: &EngineState, component_path: &str, world: Matrix, previous: &RenderScene) -> RenderScene {
187    let inverse = inverse_rigid(world);
188    let prefix = format!("{component_path}:");
189    // A remap moves every vertex without a new host revision, so the GPU
190    // cache must see a different one: derive it from the host revision and
191    // the frame (high bit set, clear of the scene's own monotonic counter).
192    let frame = world
193        .iter()
194        .fold(0xcbf2_9ce4_8422_2325u64, |h, v| (h ^ v.to_bits()).wrapping_mul(0x100_0000_01b3));
195    let mut context = RenderScene::new();
196    for solid in host
197        .scene
198        .solids()
199        .iter()
200        .filter(|solid| solid.visible && !solid.name.starts_with(&prefix))
201    {
202        let mut ghost = solid.clone();
203        ghost.source_handle = 0;
204        ghost.revision = ((solid.revision ^ frame).wrapping_mul(0x9e37_79b9_7f4a_7c15)) | (1 << 63);
205        ghost.shape_revision = 0;
206        ghost.pose = None;
207        ghost.bbox = Aabb::empty();
208        for point in &mut ghost.mesh.positions {
209            let mapped = point_at(inverse, point.map(f64::from));
210            ghost.bbox.expand(mapped);
211            *point = mapped.map(|v| v as f32);
212        }
213        for normal in &mut ghost.mesh.normals {
214            *normal = vector_at(inverse, normal.map(f64::from)).map(|v| v as f32);
215        }
216        // Context edges stay, in the part's frame, so a sketch can copy one;
217        // vertices and exact kernel handles do not.
218        for edge in &mut ghost.edges {
219            for point in &mut edge.polyline {
220                *point = point_at(inverse, point.map(f64::from)).map(|v| v as f32);
221            }
222        }
223        ghost.vertices.clear();
224        if let Some(before) = previous.solid(&ghost.name) {
225            let fresh = ghost.revision;
226            ghost.revision = before.revision;
227            if ghost != *before {
228                ghost.revision = fresh;
229            }
230        }
231        context.insert_solid(ghost);
232    }
233    context
234}
235
236fn edit_key(engine: &EngineState) -> (u64, u64) {
237    (engine.applied_generation(), engine.history_revision())
238}
239
240impl Documents {
241    /// The part an occurrence places, at any depth (`ACOMP1:ACOMP2`), in the
242    /// active tab: its part-library name and its source file (`None` or empty:
243    /// a part with no file of its own).
244    pub fn occurrence_part(&self, component_path: &str) -> Result<(String, Option<String>), String> {
245        let root = self.engine();
246        let document: Value = serde_json::from_str(&root.history_request_json()).map_err(|e| e.to_string())?;
247        let (_, names, source, _) = resolve_occurrence(root, &document, component_path)?;
248        Ok((names.last().cloned().unwrap_or_default(), source))
249    }
250
251    /// Make `component_path` the Work part, in place (no tab switch).
252    pub fn begin_work_part(&mut self, component_path: &str) -> Result<(), String> {
253        self.begin_work_part_as(component_path, WorkPartMode::InPlace)
254    }
255
256    pub fn begin_work_part_as(
257        &mut self,
258        component_path: &str,
259        mode: WorkPartMode,
260    ) -> Result<(), String> {
261        if self.work_part.is_some() {
262            // Another line of the same assembly: switch the Work part.
263            let host = self.work_part.as_ref().map(|s| s.assembly_id);
264            self.finish_work_part()?;
265            if let Some(index) = host.and_then(|id| self.open.iter().position(|d| d.id() == id)) {
266                self.activate(index);
267            }
268        }
269        let root = &self.open[self.active].engine;
270        if root.run_pending()
271            || root.sketch_mode()
272            || root.sketch3d_mode()
273            || root.ref_select_active()
274        {
275            return Err("finish the current operation before choosing a Work part".into());
276        }
277        if !self.active().access().is_editable() {
278            return Err("the assembly is read-only".into());
279        }
280        let assembly_id = self.active_id();
281        let root_document: Value =
282            serde_json::from_str(&root.history_request_json()).map_err(|e| e.to_string())?;
283        let (world, part_path, source, document) =
284            resolve_occurrence(root, &root_document, component_path)?;
285        let context = context_scene(root, component_path, world, &RenderScene::new());
286        let inverse = inverse_rigid(world);
287        let mut camera = root.camera.clone();
288        camera.eye = point_at(inverse, camera.eye);
289        camera.target = point_at(inverse, camera.target);
290        camera.up = vector_at(inverse, camera.up);
291        let existing = source.as_deref().and_then(|name| self.index_of(name));
292        if existing == Some(self.active_index()) {
293            return Err("the component source refers to its assembly".into());
294        }
295        // A part also open in its own tab is fine in place: the two are kept
296        // one by the live link (`super::live_link`) on every tab switch.
297        let load = |docs: &Documents| -> Result<Document, String> {
298            let mut engine = docs.spawn_engine();
299            engine.set_history_json(&document.to_string())?;
300            // A part without its own workbench must not inherit Assembly tools.
301            if document["workbench"].as_str().is_none() {
302                let is_assembly = document["features"].as_array().is_some_and(|features| {
303                    features
304                        .iter()
305                        .any(|f| matches!(f["type"].as_str(), Some("ACOMP" | "ASSEMBLY COMPONENT")))
306                });
307                engine.settings.workbench =
308                    if is_assembly { "assembly" } else { "modeling" }.into();
309            }
310            let mut doc = Document::new(engine);
311            doc.set_name(source.clone());
312            doc.mark_clean();
313            Ok(doc)
314        };
315        let (hosted, part_id) = match (mode, existing) {
316            (WorkPartMode::NewTab, Some(index)) => {
317                if !self.open[index].access().is_editable() {
318                    return Err("the part is read-only".into());
319                }
320                let id = self.open[index].id();
321                self.activate(index);
322                (None, id)
323            }
324            (WorkPartMode::NewTab, None) => {
325                let doc = load(self).inspect_err(|_| self.install_active_library())?;
326                let id = doc.id();
327                self.open_document(doc);
328                (None, id)
329            }
330            (WorkPartMode::InPlace, _) => {
331                let doc = load(self).inspect_err(|_| self.install_active_library())?;
332                let id = doc.id();
333                (Some(Box::new(doc)), id)
334            }
335        };
336        let mut session = WorkPart {
337            assembly_id,
338            part_id,
339            hosted,
340            name: part_path.last().cloned().unwrap_or_default(),
341            component_path: component_path.into(),
342            part_path,
343            source,
344            world,
345            pushed: Vec::new(),
346            redo: Vec::new(),
347            seen: None,
348            context_seen: None,
349            problems: Vec::new(),
350            pushes: 0,
351            error: None,
352            checkout: CheckoutOffer::default(),
353        };
354        let host_key = edit_key(&self.open[self.active_host_index(&session)].engine);
355        session.context_seen = Some(host_key);
356        self.work_part = Some(session);
357        let part = self.work_engine_mut().expect("work part engine");
358        part.context_scene = context;
359        part.sync_context_highlight();
360        part.camera = camera;
361        part.clear_selection();
362        part.dirty = true;
363        let baseline = Pushed {
364            signature: super::signature(part),
365            undo_top: part.history.undo_top(),
366            capture: None,
367        };
368        let seen = edit_key(part);
369        let session = self.work_part.as_mut().unwrap();
370        session.pushed.push(baseline);
371        session.seen = Some(seen);
372        self.install_active_library();
373        Ok(())
374    }
375
376    fn active_host_index(&self, session: &WorkPart) -> usize {
377        self.open
378            .iter()
379            .position(|d| d.id() == session.assembly_id)
380            .unwrap_or(self.active)
381    }
382
383    /// The hosted part document, while a Work part is edited in place.
384    pub(super) fn hosted(&self) -> Option<&Document> {
385        self.work_part.as_ref().and_then(|s| s.hosted.as_deref())
386    }
387
388    pub(super) fn hosted_mut(&mut self) -> Option<&mut Document> {
389        self.work_part.as_mut().and_then(|s| s.hosted.as_deref_mut())
390    }
391
392    fn work_engine_mut(&mut self) -> Option<&mut EngineState> {
393        let session = self.work_part.as_mut()?;
394        if let Some(doc) = session.hosted.as_deref_mut() {
395            return Some(&mut doc.engine);
396        }
397        let id = session.part_id;
398        self.open.iter_mut().find(|d| d.id() == id).map(|d| &mut d.engine)
399    }
400
401    fn work_engine(&self) -> Option<&EngineState> {
402        let session = self.work_part.as_ref()?;
403        if let Some(doc) = session.hosted.as_deref() {
404            return Some(&doc.engine);
405        }
406        self.open.iter().find(|d| d.id() == session.part_id).map(|d| &d.engine)
407    }
408
409    /// Whether a Work part is being edited in place in the active tab.
410    pub fn work_part_in_place(&self) -> bool {
411        self.hosted().is_some()
412    }
413
414    /// The assembly engine of the active Work part session (the tab's own
415    /// engine while in place).
416    pub fn work_part_assembly(&self) -> Option<&EngineState> {
417        let session = self.work_part.as_ref()?;
418        self.open.iter().find(|d| d.id() == session.assembly_id).map(|d| &d.engine)
419    }
420
421    pub fn work_part_assembly_mut(&mut self) -> Option<&mut EngineState> {
422        let id = self.work_part.as_ref()?.assembly_id;
423        self.open.iter_mut().find(|d| d.id() == id).map(|d| &mut d.engine)
424    }
425
426    /// The component path being edited, if any.
427    pub fn work_part_path(&self) -> Option<&str> {
428        self.work_part.as_ref().map(|s| s.component_path.as_str())
429    }
430
431    /// Push the part's settled state into the assembly and keep the context
432    /// current. Called every frame after the pumps ([`Documents::pump_all`]).
433    pub(super) fn sync_work_part(&mut self) {
434        let Some(session) = self.work_part.as_ref() else {
435            return;
436        };
437        let Some(host) = self.open.iter().position(|d| d.id() == session.assembly_id) else {
438            return;
439        };
440        let Some(part) = self.work_engine() else {
441            return;
442        };
443        let busy = part.run_pending()
444            || part.sketch_mode()
445            || part.sketch3d_mode()
446            || part.ref_select_active();
447        let key = edit_key(part);
448        if !busy && Some(key) != session.seen && !self.open[host].engine.run_pending() {
449            // Coalesce: while the assembly is still running the previous push,
450            // `seen` stays put so the NEWEST part state goes next frame.
451            let _ = self.push_work_part(false);
452        }
453        self.refresh_context();
454    }
455
456    /// Push the work part's current state, if it differs from the last pushed
457    /// one. `force` pushes even while the assembly is running (Return).
458    fn push_work_part(&mut self, force: bool) -> Result<(), String> {
459        let session = self.work_part.as_ref().ok_or("no Work part is active")?;
460        let host = self
461            .open
462            .iter()
463            .position(|d| d.id() == session.assembly_id)
464            .ok_or("assembly tab is missing")?;
465        if !force && self.open[host].engine.run_pending() {
466            return Ok(());
467        }
468        let part = self.work_engine().ok_or("part document is missing")?;
469        let key = edit_key(part);
470        let signature = super::signature(part);
471        let undo_top = part.history.undo_top();
472        let edited: Value =
473            serde_json::from_str(&part.history_request_json()).map_err(|e| e.to_string())?;
474        let session = self.work_part.as_mut().unwrap();
475        session.seen = Some(key);
476        if session.pushed.last().is_some_and(|p| p.signature == signature) {
477            return Ok(());
478        }
479        let pushed_len = session.pushed.len();
480        // A part undo back to the previous pushed state: undo the assembly step.
481        if pushed_len >= 2 && session.pushed[pushed_len - 2].signature == signature {
482            let step = session.pushed.pop().unwrap();
483            session.redo.push(step);
484            self.open[host].engine.undo();
485            self.install_active_library();
486            self.work_part.as_mut().unwrap().context_seen = None;
487            return Ok(());
488        }
489        // A part redo to the next pushed state: redo the assembly step.
490        if session.redo.last().is_some_and(|p| p.signature == signature) {
491            let step = session.redo.pop().unwrap();
492            session.pushed.push(step);
493            self.open[host].engine.redo();
494            self.install_active_library();
495            self.work_part.as_mut().unwrap().context_seen = None;
496            return Ok(());
497        }
498        // The same part undo step as the last push (a coalesced drag): replace
499        // that assembly step instead of stacking another.
500        let replace = pushed_len >= 2
501            && undo_top.is_some()
502            && session.pushed.last().is_some_and(|p| p.undo_top == undo_top);
503        if !self.open[host].access().is_editable() {
504            let session = self.work_part.as_mut().unwrap();
505            session.error = Some("the assembly is read-only; part edits are not applied".into());
506            return Err("the assembly is read-only".into());
507        }
508        // Replacing a coalesced step: its capture is reused, so the undo
509        // runs nothing (the commit's run is the one the assembly needs); the
510        // replay a capture would take with that run in flight goes too.
511        let reused = if replace { session.pushed.last().and_then(|p| p.capture.clone()) } else { None };
512        if replace {
513            if reused.is_some() {
514                self.open[host].engine.undo_before_edit();
515            } else {
516                self.open[host].engine.undo();
517            }
518        }
519        let path = session.part_path.clone();
520        let source = session.source.clone();
521        let mut assembly_document: Value =
522            serde_json::from_str(&self.open[host].engine.history_request_json())
523                .map_err(|e| e.to_string())?;
524        let mut notes = Vec::new();
525        let signatures_before = library_signatures(&assembly_document);
526        let result = refresh_parts(&mut assembly_document, &path, source.as_deref(), &edited, &mut notes)
527            .and_then(|changed| {
528                if changed {
529                    Ok(())
530                } else {
531                    Err("the assembly no longer holds this part's definition".to_string())
532                }
533            })
534            .and_then(|()| {
535                // Mates on the edited parts' occurrences are checked once the
536                // run lands: a face the edit renamed on the same plane is
537                // re-attached, anything ambiguous is held for Repair.
538                let after = library_signatures(&assembly_document);
539                let parts: Vec<String> =
540                    after.iter().filter(|(name, sig)| signatures_before.get(*name) != Some(*sig)).map(|(name, _)| name.clone()).collect();
541                // The capture reads the HOST's assembly session, replayed on
542                // this thread: with the Work part active the kernel store holds
543                // the part's library, and the last replay may be another
544                // document's, so both are put back to the host's first.
545                super::install_library_of(&self.open[host]);
546                let engine = &mut self.open[host].engine;
547                engine.forget_assembly_session();
548                let capture = match reused {
549                    Some((reused_parts, capture)) if reused_parts == parts => capture,
550                    stale => {
551                        // Other parts this time: capture from the undone
552                        // document, run as the plain undo would have.
553                        if stale.is_some() {
554                            engine.rebuild_current_history();
555                        }
556                        engine.capture_part_edit(&engine.components_of_parts(&parts))
557                    }
558                };
559                let kept = (parts.clone(), capture.clone());
560                engine.commit_part_edit(capture, assembly_document.clone(), &parts).map(|()| kept)
561            });
562        self.install_active_library();
563        let session = self.work_part.as_mut().unwrap();
564        match result {
565            Ok(capture) => {
566                if replace {
567                    session.pushed.pop();
568                }
569                session.pushed.push(Pushed { signature, undo_top, capture: Some(capture) });
570                session.redo.clear();
571                session.pushes += 1;
572                session.error = None;
573                session.context_seen = None;
574                for note in notes {
575                    self.open[host].engine.push_notice(format!("Work part: {note}"));
576                }
577                Ok(())
578            }
579            Err(error) => {
580                if replace {
581                    self.open[host].engine.redo();
582                    self.install_active_library();
583                }
584                let session = self.work_part.as_mut().unwrap();
585                session.error = Some(error.clone());
586                Err(error)
587            }
588        }
589    }
590
591    /// Whether the Work part session still owes the assembly an update: a
592    /// settled part state not pushed yet, an assembly run in flight, or a
593    /// context not rebuilt from the assembly's latest run.
594    pub fn work_part_settling(&self) -> bool {
595        // Going up to a sub-assembly: it begins when the assembly's run lands.
596        if self.work_part_next.is_some() {
597            return true;
598        }
599        let Some(session) = self.work_part.as_ref() else {
600            return false;
601        };
602        let (Some(part), Some(host)) = (self.work_engine(), self.work_part_assembly()) else {
603            return false;
604        };
605        let editing = part.sketch_mode() || part.sketch3d_mode() || part.ref_select_active();
606        host.run_pending()
607            || (!editing && Some(edit_key(part)) != session.seen && session.error.is_none())
608            || session.context_seen != Some(edit_key(host))
609    }
610
611    /// The source files the ACTIVE tab's next Save writes besides itself:
612    /// every owned document its edits changed ([`super::owned_save`]).
613    pub fn work_part_modified_sources(&self) -> Vec<String> {
614        self.owned_modified_keys()
615    }
616
617    /// Push the Work part's latest state now, even while the assembly is still
618    /// running (Save and the document readback call this). A refusal is kept
619    /// for the bar; the part edits stay in the Work part.
620    pub fn flush_work_part(&mut self) {
621        if self.work_part.is_some() {
622            let _ = self.push_work_part(true);
623        }
624    }
625
626    /// Rebuild the context scene once the assembly's latest run has landed.
627    fn refresh_context(&mut self) {
628        let Some(session) = self.work_part.as_ref() else {
629            return;
630        };
631        let Some(host) = self.open.iter().position(|d| d.id() == session.assembly_id) else {
632            return;
633        };
634        let engine = &self.open[host].engine;
635        if engine.run_pending() || session.context_seen == Some(edit_key(engine)) {
636            return;
637        }
638        let key = edit_key(engine);
639        let world = serde_json::from_str::<Value>(&engine.history_request_json())
640            .ok()
641            .and_then(|document| resolve_occurrence(engine, &document, &session.component_path).ok())
642            .map(|(world, ..)| world)
643            .unwrap_or(session.world);
644        let previous = self.work_engine().map(|part| &part.context_scene);
645        let context = context_scene(engine, &session.component_path, world, previous.unwrap_or(&RenderScene::new()));
646        let mut statuses = self.open[host].engine.assembly_statuses_value();
647        let mut problems: Vec<(String, String, String, String)> = statuses
648            .as_array_mut()
649            .map(|rows| {
650                rows.iter()
651                    .filter(|row| {
652                        let status = row["status"].as_str().unwrap_or("");
653                        row["enabled"].as_bool() != Some(false)
654                            && !matches!(status, "satisfied" | "disabled" | "over" | "")
655                    })
656                    .map(|row| {
657                        let text = |k: &str| row[k].as_str().unwrap_or("").to_string();
658                        (text("id"), text("type"), text("status"), text("message"))
659                    })
660                    .collect()
661            })
662            .unwrap_or_default();
663        // A mate the push held for Repair is disabled, so its status no longer
664        // says so: it is a problem all the same until it is repaired.
665        for held in self.open[host].engine.replace_unresolved().as_array().into_iter().flatten() {
666            let id = held["id"].as_str().unwrap_or("").to_string();
667            if !problems.iter().any(|(known, ..)| *known == id) {
668                let message = "held for Repair: the face or edge it used is gone".to_string();
669                problems.push((id, held["type"].as_str().unwrap_or("").to_string(), "held".into(), message));
670            }
671        }
672        let moved = world != session.world;
673        let session = self.work_part.as_mut().unwrap();
674        session.context_seen = Some(key);
675        session.problems = problems;
676        if moved {
677            // The work occurrence itself moved (a mate re-posed it): keep the
678            // view where it was in the assembly by re-mapping the camera too.
679            let delta = multiply(inverse_rigid(world), session.world);
680            session.world = world;
681            if let Some(part) = self.work_engine_mut() {
682                part.camera.eye = point_at(delta, part.camera.eye);
683                part.camera.target = point_at(delta, part.camera.target);
684                part.camera.up = vector_at(delta, part.camera.up);
685            }
686        }
687        if let Some(part) = self.work_engine_mut() {
688            part.context_scene = context;
689            part.sync_context_highlight();
690            part.dirty = true;
691        }
692    }
693
694    /// Called before leaving the work context, including ordinary tab changes.
695    pub(super) fn finish_work_part(&mut self) -> Result<(), String> {
696        let Some(session) = &self.work_part else {
697            return Ok(());
698        };
699        let assembly = self
700            .open
701            .iter()
702            .position(|d| d.id() == session.assembly_id)
703            .ok_or("assembly tab is missing")?;
704        if self.work_engine().is_none() {
705            self.work_part = None;
706            return Err("part document is missing".into());
707        }
708        {
709            let part = self.work_engine_mut().unwrap();
710            if part.ref_select_active() {
711                part.cancel_ref_select();
712            }
713            // Sketch edits are already kept in the document. Finish the editor
714            // so a standalone tab does not retain context references.
715            if part.sketch_mode() {
716                part.exit_sketch_mode(true);
717            }
718            if part.sketch3d_mode() {
719                part.exit_sketch3d_mode(true);
720            }
721        }
722        let part = self.work_engine().unwrap();
723        let changed = self
724            .work_part
725            .as_ref()
726            .and_then(|s| s.pushed.last())
727            .is_none_or(|p| p.signature != super::signature(part));
728        if changed {
729            if !self.open[assembly].access().is_editable() {
730                return Err("assembly is read-only; part edits remain open".into());
731            }
732            self.push_work_part(true).map_err(|error| {
733                format!("{error}; edits remain in the Work part")
734            })?;
735        }
736        let session = self.work_part.take().unwrap();
737        let world = session.world;
738        let camera = match &session.hosted {
739            Some(doc) => doc.engine.camera.clone(),
740            None => {
741                let part = self.open.iter_mut().find(|d| d.id() == session.part_id).unwrap();
742                part.engine.context_scene.clear();
743                part.engine.sync_context_highlight();
744                part.engine.dirty = true;
745                part.engine.camera.clone()
746            }
747        };
748        let host = &mut self.open[assembly].engine;
749        host.camera.eye = point_at(world, camera.eye);
750        host.camera.target = point_at(world, camera.target);
751        host.camera.up = vector_at(world, camera.up);
752        host.camera.projection = camera.projection;
753        host.dirty = true;
754        self.install_active_library();
755        Ok(())
756    }
757
758    /// Go up the Work part's path to `depth`: 0 is the assembly itself, `k`
759    /// the occurrence of its first `k` components (a sub-assembly above the
760    /// part). The part's edits are pushed first, and the sub-assembly becomes
761    /// the Work part once that update has landed, so it is placed where the
762    /// solved assembly puts it (`Self::begin_next_work_part`).
763    pub fn work_part_up_to(&mut self, depth: usize) -> Result<(), String> {
764        let session = self.work_part.as_ref().ok_or("no Work part is active")?;
765        let ids: Vec<&str> = session.component_path.split(':').collect();
766        if depth >= ids.len() {
767            return Ok(());
768        }
769        let next = (
770            session.assembly_id,
771            ids[..depth].join(":"),
772            if session.hosted.is_some() { WorkPartMode::InPlace } else { WorkPartMode::NewTab },
773        );
774        self.return_to_assembly()?;
775        if depth > 0 {
776            self.work_part_next = Some(next);
777            self.begin_next_work_part();
778        }
779        Ok(())
780    }
781
782    /// One level up: the sub-assembly holding the Work part, or the assembly.
783    pub fn work_part_up(&mut self) -> Result<(), String> {
784        let depth = self.work_part.as_ref().ok_or("no Work part is active")?.part_path.len();
785        self.work_part_up_to(depth.saturating_sub(1))
786    }
787
788    /// Begin the Work part [`Self::work_part_up_to`] left waiting, once its
789    /// assembly is the active tab with no run in flight. Dropped if the user
790    /// moved to another tab or chose another Work part meanwhile.
791    pub(super) fn begin_next_work_part(&mut self) {
792        let Some((assembly, _, _)) = &self.work_part_next else {
793            return;
794        };
795        if self.work_part.is_some() || self.active_id() != *assembly {
796            self.work_part_next = None;
797            return;
798        }
799        if self.engine().run_pending() {
800            return;
801        }
802        let (_, path, mode) = self.work_part_next.take().unwrap();
803        if let Err(error) = self.begin_work_part_as(&path, mode) {
804            self.engine_mut().push_notice(format!("Work part: {error}"));
805        }
806    }
807
808    pub fn return_to_assembly(&mut self) -> Result<(), String> {
809        let id = self
810            .work_part
811            .as_ref()
812            .ok_or("no Work part is active")?
813            .assembly_id;
814        self.finish_work_part()?;
815        let index = self
816            .open
817            .iter()
818            .position(|d| d.id() == id)
819            .ok_or("assembly tab is missing")?;
820        self.activate(index);
821        Ok(())
822    }
823
824    /// The Work part session as automation state (`null` when inactive).
825    pub fn work_part_state(&self) -> Value {
826        let Some(s) = self.work_part.as_ref() else {
827            return Value::Null;
828        };
829        let assembly = self.work_part_assembly();
830        let part = self.work_engine();
831        // Context solids in ASSEMBLY coordinates, so a script can read where
832        // the other components are after an update.
833        // Rebuilt only when the host scene changed (a solid's revision or
834        // visibility, or the set): built every frame it cost 35-58 ms at 500
835        // parts, about half of a Work part edit's measured time.
836        let context: Value = assembly
837            .map(|engine| {
838                let fingerprint = engine.scene.solids().iter().fold(engine.scene.solids().len() as u64, |h, solid| {
839                    (h ^ solid.revision ^ u64::from(solid.visible)).wrapping_mul(0x100_0000_01b3)
840                });
841                let key = (s.component_path.clone(), fingerprint);
842                if let Some((cached, value)) = self.work_part_context.borrow().as_ref() {
843                    if *cached == key {
844                        return value.clone();
845                    }
846                }
847                let prefix = format!("{}:", s.component_path);
848                let map: serde_json::Map<String, Value> = engine
849                    .scene
850                    .solids()
851                    .iter()
852                    .filter(|solid| solid.visible && !solid.name.starts_with(&prefix))
853                    .map(|solid| {
854                        (solid.name.clone(), serde_json::json!({"min": solid.bbox.min, "max": solid.bbox.max}))
855                    })
856                    .collect();
857                let value = Value::Object(map);
858                *self.work_part_context.borrow_mut() = Some((key, value.clone()));
859                value
860            })
861            .unwrap_or_else(|| Value::Object(Default::default()));
862        let [r, g, b, _] = brep_render::render::WORK_PART_CONTEXT_RGBA;
863        let (shown, alpha) = part.map_or((true, 0.0), |engine| engine.context_display());
864        let style = serde_json::json!({"rgba": [r, g, b, alpha], "alpha": alpha, "shown": shown, "depthWrite": false, "dimmed": alpha < 1.0});
865        serde_json::json!({
866            "name": s.name,
867            "componentPath": s.component_path,
868            "partPath": s.part_path,
869            "mode": if s.hosted.is_some() { "inPlace" } else { "newTab" },
870            "contextSolids": part.map_or(0, |engine| engine.context_scene.solids().len()),
871            "contextSelectable": true,
872            "contextSelection": part.map_or(serde_json::Value::Null, |engine| engine.context_selection_json()),
873            "contextHover": part.map_or(serde_json::Value::Null, |engine| engine.context_hover_json()),
874            "contextStyle": style,
875            "context": context,
876            "modifiedSources": self.owned_modified_keys_of(s.assembly_id),
877            "assemblyId": s.assembly_id,
878            "partId": s.part_id,
879            "pushes": s.pushes,
880            "pending": assembly.is_some_and(EngineState::run_pending),
881            "undoSteps": s.pushed.len().saturating_sub(1),
882            "redoSteps": s.redo.len(),
883            "error": s.error,
884            "constraintProblems": s.problems.iter().map(|(id, ty, status, message)| {
885                serde_json::json!({"id": id, "type": ty, "status": status, "message": message})
886            }).collect::<Vec<_>>(),
887        })
888    }
889
890    /// Starting to edit a PLM part in context that is not checked out to this
891    /// user: offer the checkout right there, Yes by default (owner,
892    /// 2026-10-20). Locked by someone else or not editable: said in the bar;
893    /// the edits stay in the assembly. A file store never reaches this.
894    fn offer_checkout(&mut self, ui: &mut eframe::egui::Ui, store: &dyn crate::store::ModelStore, hits: &mut Value) {
895        use eframe::egui;
896        let Some(session) = self.work_part.as_mut() else {
897            return;
898        };
899        if matches!(session.checkout, CheckoutOffer::Unasked) {
900            session.checkout = CheckoutOffer::Done(None);
901            let key = session.source.clone().unwrap_or_default();
902            if let (Some(_), Some((part, revision))) = (store.plm_client(), crate::plm::bom::revision_of_document(&key)) {
903                session.checkout = match store.plm_revision(&key) {
904                    Some(entry) if entry.editable && entry.locked_by_me => CheckoutOffer::Done(None),
905                    Some(entry) if !entry.editable => CheckoutOffer::Done(Some(format!(
906                        "{key}: revision {} is {} — your edits stay in the assembly", entry.revision_label, entry.lifecycle
907                    ))),
908                    Some(entry) if entry.locked_by.is_some() => CheckoutOffer::Done(Some(format!(
909                        "{key} is checked out by {} — your edits stay in the assembly", entry.locked_by.unwrap_or_default()
910                    ))),
911                    _ => CheckoutOffer::Asking { part, revision },
912                };
913            }
914        }
915        let name = session.name.clone();
916        match &mut session.checkout {
917            CheckoutOffer::Asking { part, revision } => {
918                let (part, revision) = (part.clone(), revision.clone());
919                let mut answer = None;
920                let modal = egui::Modal::new(egui::Id::new("brep-work-part-checkout")).show(ui.ctx(), |ui| {
921                    ui.heading("Check out before editing?");
922                    ui.label(format!("{name} is not checked out to you. Check it out now, so Save can write it?"));
923                    ui.horizontal(|ui| {
924                        let yes = ui.button("Check out");
925                        hits["checkout:yes"] = serde_json::json!([yes.rect.min.x, yes.rect.min.y, yes.rect.width(), yes.rect.height()]);
926                        if yes.clicked() || ui.input(|i| i.key_pressed(egui::Key::Enter)) {
927                            answer = Some(true);
928                        }
929                        let no = ui.button("Not now");
930                        hits["checkout:no"] = serde_json::json!([no.rect.min.x, no.rect.min.y, no.rect.width(), no.rect.height()]);
931                        if no.clicked() {
932                            answer = Some(false);
933                        }
934                    });
935                });
936                let answer = answer.or_else(|| modal.should_close().then_some(false));
937                match (answer, store.plm_client()) {
938                    (Some(true), Some(client)) => {
939                        use crate::panels::plm::PlmLifecycle;
940                        session.checkout = CheckoutOffer::Running(client.checkout(&part, &revision));
941                    }
942                    (Some(_), _) => session.checkout = CheckoutOffer::Done(Some(format!("{name} is not checked out; Save will offer it"))),
943                    (None, _) => {}
944                }
945            }
946            CheckoutOffer::Running(future) => {
947                let mut cx = std::task::Context::from_waker(std::task::Waker::noop());
948                match std::future::Future::poll(future.as_mut(), &mut cx) {
949                    std::task::Poll::Ready(Ok(())) => {
950                        session.checkout = CheckoutOffer::Done(Some(format!("{name} is checked out to you")));
951                        store.refresh_plm_index(&[session.source.clone().unwrap_or_default()], false);
952                    }
953                    std::task::Poll::Ready(Err(error)) => {
954                        session.checkout = CheckoutOffer::Done(Some(format!("{name} could not be checked out: {error}")));
955                    }
956                    std::task::Poll::Pending => ui.ctx().request_repaint_after(std::time::Duration::from_millis(50)),
957                }
958            }
959            CheckoutOffer::Unasked | CheckoutOffer::Done(_) => {}
960        }
961    }
962
963    pub fn show_work_part_bar(&mut self, ui: &mut eframe::egui::Ui, store: &dyn crate::store::ModelStore) {
964        // The readback walks every assembly solid: only for automation.
965        let state = crate::automation::registry::enabled().then(|| self.work_part_state());
966        let mut hits = serde_json::json!({});
967        self.offer_checkout(ui, store, &mut hits);
968        // The checkout question is a modal: the bar behind it takes no click,
969        // so its widgets are not published while it is up (a script's click on
970        // `return` would otherwise report a hit that did nothing).
971        let modal = self.work_part.as_ref().is_some_and(|s| matches!(s.checkout, CheckoutOffer::Asking { .. }));
972        if let Some(session) = &self.work_part {
973            // The breadcrumb: the assembly, each sub-assembly above the part,
974            // the part. Every level above the part can be clicked.
975            let assembly = self.open.iter().find(|d| d.id() == session.assembly_id).map_or_else(|| "assembly".into(), |d| d.title());
976            let crumbs: Vec<String> = std::iter::once(assembly).chain(session.part_path.iter().cloned()).collect();
977            let component_path = session.component_path.clone();
978            let problems: Vec<String> = session
979                .problems
980                .iter()
981                .map(|(id, ty, status, message)| {
982                    if message.is_empty() {
983                        format!("{id} ({ty}): {status}")
984                    } else {
985                        format!("{id} ({ty}): {status}, {message}")
986                    }
987                })
988                .collect();
989            let error = session.error.clone();
990            let checkout = match &session.checkout {
991                CheckoutOffer::Done(line) => line.clone(),
992                CheckoutOffer::Running(_) => Some("checking out…".into()),
993                _ => None,
994            };
995            let modified: Vec<String> = self.owned_modified_keys_of(session.assembly_id);
996            let context_part = self.work_engine().and_then(|engine| engine.context_selection().map(|c| c.solid.rsplit(':').next().unwrap_or(&c.solid).to_string()));
997            let display = self.work_engine().map(|engine| engine.context_display());
998            let mut new_display = display;
999            // Some(true): sketch on the face; Some(false): datum only.
1000            let mut context_action: Option<bool> = None;
1001            let mut finish = false;
1002            // A breadcrumb level clicked (or Up): its depth.
1003            let mut up_to: Option<usize> = None;
1004            eframe::egui::Frame::group(ui.style()).show(ui, |ui| {
1005                ui.horizontal_wrapped(|ui| {
1006                    ui.label("Work part:");
1007                    let last = crumbs.len() - 1;
1008                    for (depth, crumb) in crumbs.iter().enumerate() {
1009                        if depth > 0 {
1010                            ui.weak("›");
1011                        }
1012                        if depth == last {
1013                            ui.strong(crumb);
1014                            continue;
1015                        }
1016                        let link = ui.link(crumb).on_hover_text(if depth == 0 {
1017                            "Return to the assembly"
1018                        } else {
1019                            "Make this sub-assembly the Work part"
1020                        });
1021                        hits[format!("crumb:{depth}")] = serde_json::json!([link.rect.min.x, link.rect.min.y, link.rect.width(), link.rect.height()]);
1022                        if link.clicked() {
1023                            up_to = Some(depth);
1024                        }
1025                    }
1026                    ui.weak(format!("({component_path})"));
1027                    // Nested: one level up is the sub-assembly holding the part.
1028                    if last > 1 {
1029                        let button = ui.button("Up").on_hover_text(format!("Make {} the Work part", crumbs[last - 1]));
1030                        hits["up"] = serde_json::json!([button.rect.min.x, button.rect.min.y, button.rect.width(), button.rect.height()]);
1031                        if button.clicked() {
1032                            up_to = Some(last - 1);
1033                        }
1034                    }
1035                    let button = ui
1036                        .button("Return to assembly")
1037                        .on_hover_text("Make the assembly the work context again (Esc)");
1038                    hits["return"] = serde_json::json!([
1039                        button.rect.min.x,
1040                        button.rect.min.y,
1041                        button.rect.width(),
1042                        button.rect.height()
1043                    ]);
1044                    finish = button.clicked();
1045                    // The rest of the assembly: shown for reference (it updates
1046                    // as you edit), or hidden, and how solid it looks.
1047                    if let Some((shown, opacity)) = &mut new_display {
1048                        let toggle = ui
1049                            .checkbox(shown, "Show the rest of the assembly")
1050                            .on_hover_text("Shown for reference; it updates as you edit. Hidden, it is neither drawn nor picked");
1051                        hits["display:show"] = serde_json::json!([toggle.rect.min.x, toggle.rect.min.y, toggle.rect.width(), toggle.rect.height()]);
1052                        ui.add_enabled_ui(*shown, |ui| {
1053                            let slider = ui
1054                                .add(eframe::egui::Slider::new(opacity, 0.05..=1.0).show_value(false))
1055                                .on_hover_text("How solid the rest of the assembly looks");
1056                            hits["display:opacity"] = serde_json::json!([slider.rect.min.x, slider.rect.min.y, slider.rect.width(), slider.rect.height()]);
1057                        });
1058                    }
1059                });
1060                // A face of another part selected: what it can become in this
1061                // part (copied, never linked).
1062                if let Some(part) = &context_part {
1063                    ui.horizontal_wrapped(|ui| {
1064                        ui.label(format!("Face of {part}:"));
1065                        let button = ui.button("Sketch on this face").on_hover_text(
1066                            "A datum on this face (copied, not linked: it stays if that part moves), and a new sketch on it",
1067                        );
1068                        hits["context-sketch"] = serde_json::json!([button.rect.min.x, button.rect.min.y, button.rect.width(), button.rect.height()]);
1069                        if button.clicked() {
1070                            context_action = Some(true);
1071                        }
1072                        let button = ui.button("Datum here").on_hover_text(
1073                            "A datum on this face (copied, not linked: it stays if that part moves)",
1074                        );
1075                        hits["context-datum"] = serde_json::json!([button.rect.min.x, button.rect.min.y, button.rect.width(), button.rect.height()]);
1076                        if button.clicked() {
1077                            context_action = Some(false);
1078                        }
1079                    });
1080                }
1081                for problem in &problems {
1082                    ui.colored_label(ui.visuals().warn_fg_color, format!("Constraint {problem}"));
1083                }
1084                if !modified.is_empty() {
1085                    ui.weak(format!(
1086                        "Modified with their own files (written when the assembly is saved): {}",
1087                        modified.join(", ")
1088                    ));
1089                }
1090                if let Some(error) = &error {
1091                    ui.colored_label(ui.visuals().error_fg_color, format!("Not applied: {error}"));
1092                }
1093                if let Some(line) = &checkout {
1094                    ui.weak(line);
1095                }
1096            });
1097            let escape = ui.input(|i| i.key_pressed(eframe::egui::Key::Escape))
1098                && ui.ctx().memory(|m| m.focused().is_none())
1099                && self.work_engine().is_some_and(|engine| {
1100                    !engine.has_selection()
1101                        && engine.context_selection().is_none()
1102                        && !engine.sketch_mode()
1103                        && !engine.sketch3d_mode()
1104                        && !engine.ref_select_active()
1105                });
1106            if let (Some((shown, opacity)), true) = (new_display, new_display != display) {
1107                if let Some(engine) = self.work_engine_mut() {
1108                    engine.set_context_display(shown, opacity);
1109                }
1110            }
1111            if let Some(sketch) = context_action {
1112                if let Some(engine) = self.work_engine_mut() {
1113                    let made = if sketch { engine.sketch_on_context() } else { engine.datum_on_context() };
1114                    if let Err(error) = made {
1115                        engine.push_notice(format!("Work part: {error}"));
1116                    }
1117                }
1118            }
1119            if finish || escape {
1120                if let Err(error) = self.return_to_assembly() {
1121                    self.engine_mut().push_notice(format!("Work part: {error}"));
1122                }
1123            } else if let Some(depth) = up_to {
1124                if let Err(error) = self.work_part_up_to(depth) {
1125                    self.engine_mut().push_notice(format!("Work part: {error}"));
1126                }
1127            }
1128        }
1129        if modal {
1130            if let Some(hits) = hits.as_object_mut() {
1131                hits.retain(|key, _| key.starts_with("checkout:"));
1132            }
1133        }
1134        if let Some(state) = state {
1135            crate::automation::registry::publish(
1136                "__brepWorkPart",
1137                "active Work part session: component path, part path, mode (inPlace|newTab), context render state, assembly context solids (assembly coordinates), pushes, undo coupling, constraint problems; null when inactive",
1138                &state.to_string(),
1139            );
1140            crate::automation::registry::publish(
1141                "__brepWorkPartHit",
1142                "Work part controls: return, crumb:<depth> (the breadcrumb: 0 the assembly, then each sub-assembly above the part), up (nested parts), and checkout:yes / checkout:no while the checkout offer is shown",
1143                &hits.to_string(),
1144            );
1145        }
1146    }
1147}
1148
1149/// The assembly's parts-library entries → their `sourceSignature`.
1150fn library_signatures(document: &Value) -> std::collections::BTreeMap<String, String> {
1151    document["partsLibrary"]
1152        .as_object()
1153        .into_iter()
1154        .flatten()
1155        .map(|(name, entry)| (name.clone(), entry["sourceSignature"].as_str().unwrap_or_default().to_string()))
1156        .collect()
1157}
1158
1159/// Refresh the selected definition and any copies with the same source identity.
1160/// Invalidate every changed ancestor snapshot so nested assemblies rebuild too.
1161pub(crate) fn refresh_parts(
1162    owner: &mut Value,
1163    path: &[String],
1164    source: Option<&str>,
1165    edited: &Value,
1166    notes: &mut Vec<String>,
1167) -> Result<bool, String> {
1168    let names: Vec<String> = owner["partsLibrary"]
1169        .as_object()
1170        .map(|entries| entries.keys().cloned().collect())
1171        .unwrap_or_default();
1172    let mut changed = false;
1173    for name in names {
1174        let entry = &owner["partsLibrary"][&name];
1175        let on_path = path.first() == Some(&name);
1176        let target = (on_path && path.len() == 1)
1177            || source.is_some_and(|s| entry["sourceKey"].as_str() == Some(s));
1178        let tail = if on_path { &path[1..] } else { &[] };
1179        // A definition that holds no copy of the part is left as it is, without
1180        // the copy of it a refresh would make and throw away (on l500 most of
1181        // each module, at every depth).
1182        if !target && !holds_part(&entry["document"], tail, source) {
1183            continue;
1184        }
1185        let mut child = entry["document"].clone();
1186        let updated = if target {
1187            child = edited.clone();
1188            true
1189        } else {
1190            refresh_parts(&mut child, tail, source, edited, notes)?
1191        };
1192        if updated {
1193            notes.extend(crate::panels::parts_library::refresh_library_entry_value(owner, &name, child)?);
1194            changed = true;
1195        }
1196    }
1197    Ok(changed)
1198}
1199
1200/// Whether `owner`'s library holds, at any depth, an entry [`refresh_parts`]
1201/// would replace: the one at the end of `path`, or one with `source`.
1202fn holds_part(owner: &Value, path: &[String], source: Option<&str>) -> bool {
1203    owner["partsLibrary"].as_object().into_iter().flatten().any(|(name, entry)| {
1204        let on_path = path.first() == Some(name);
1205        (on_path && path.len() == 1)
1206            || source.is_some_and(|s| entry["sourceKey"].as_str() == Some(s))
1207            || holds_part(&entry["document"], if on_path { &path[1..] } else { &[] }, source)
1208    })
1209}
1210
1211fn component_pose(document: &Value, transform: &Value) -> Result<Matrix, String> {
1212    let read = |field: &str| -> Result<[f64; 3], String> {
1213        let mut out = [0.; 3];
1214        for (i, key) in ["x", "y", "z"].iter().enumerate() {
1215            let value = if transform[field].is_object() {
1216                &transform[field][*key]
1217            } else {
1218                &transform[field][i]
1219            };
1220            out[i] = match value {
1221                Value::Null => 0.,
1222                Value::Number(n) => n.as_f64().ok_or("invalid component pose")?,
1223                Value::String(s) => brep_kernel::eval_expression(
1224                    document["expressions"].as_str().unwrap_or(""),
1225                    &document["configurator"],
1226                    s,
1227                )?,
1228                _ => return Err("invalid component pose".into()),
1229            };
1230            if !out[i].is_finite() {
1231                return Err("non-finite component pose".into());
1232            }
1233        }
1234        Ok(out)
1235    };
1236    let t = read("translate")?;
1237    let r = read("rotateEulerDeg")?.map(f64::to_radians);
1238    let (a, b) = (r[0].cos(), r[0].sin());
1239    let (c, d) = (r[1].cos(), r[1].sin());
1240    let (e, f) = (r[2].cos(), r[2].sin());
1241    Ok([
1242        c * e,
1243        -c * f,
1244        d,
1245        t[0],
1246        a * f + b * e * d,
1247        a * e - b * f * d,
1248        -b * c,
1249        t[1],
1250        b * f - a * e * d,
1251        b * e + a * f * d,
1252        a * c,
1253        t[2],
1254        0.,
1255        0.,
1256        0.,
1257        1.,
1258    ])
1259}
1260fn multiply(a: Matrix, b: Matrix) -> Matrix {
1261    let mut out = [0.; 16];
1262    for r in 0..4 {
1263        for c in 0..4 {
1264            out[r * 4 + c] = (0..4).map(|k| a[r * 4 + k] * b[k * 4 + c]).sum();
1265        }
1266    }
1267    out
1268}
1269fn inverse_rigid(m: Matrix) -> Matrix {
1270    let mut out = IDENTITY;
1271    for r in 0..3 {
1272        for c in 0..3 {
1273            out[r * 4 + c] = m[c * 4 + r];
1274        }
1275        out[r * 4 + 3] = -(0..3).map(|k| m[k * 4 + r] * m[k * 4 + 3]).sum::<f64>();
1276    }
1277    out
1278}
1279fn vector_at(m: Matrix, p: [f64; 3]) -> [f64; 3] {
1280    std::array::from_fn(|r| (0..3).map(|k| m[r * 4 + k] * p[k]).sum())
1281}
1282fn point_at(m: Matrix, p: [f64; 3]) -> [f64; 3] {
1283    let v = vector_at(m, p);
1284    std::array::from_fn(|r| v[r] + m[r * 4 + 3])
1285}
1286
1287pub static HIT_KEYS: &[crate::automation::hit_keys::HitKeyDoc] =
1288    &[crate::automation::hit_keys::HitKeyDoc {
1289        panel: "workpart",
1290        prefix: "return",
1291        meaning: "return to assembly and apply Work part edits",
1292        command: None,
1293    },
1294    crate::automation::hit_keys::HitKeyDoc {
1295        panel: "workpart",
1296        prefix: "checkout:",
1297        meaning: "the offer to check out a PLM part made the Work part: checkout:yes (default), checkout:no",
1298        command: None,
1299    },
1300    crate::automation::hit_keys::HitKeyDoc {
1301        panel: "workpart",
1302        prefix: "context-",
1303        meaning: "a face of another part selected (context): context-sketch (a datum on it, copied not linked, and a new sketch there), context-datum (the datum only)",
1304        command: None,
1305    },
1306    crate::automation::hit_keys::HitKeyDoc {
1307        panel: "workpart",
1308        prefix: "display:",
1309        meaning: "the rest of the assembly (context): display:show (show/hide it), display:opacity (its opacity slider)",
1310        command: None,
1311    },
1312    crate::automation::hit_keys::HitKeyDoc {
1313        panel: "workpart",
1314        prefix: "crumb:",
1315        meaning: "the Work part breadcrumb: crumb:0 returns to the assembly, crumb:<k> makes the k-th sub-assembly above the part the Work part",
1316        command: None,
1317    },
1318    crate::automation::hit_keys::HitKeyDoc {
1319        panel: "workpart",
1320        prefix: "up",
1321        meaning: "a nested Work part: make the sub-assembly holding it the Work part",
1322        command: None,
1323    }];
1324