1use 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#[derive(Clone, Copy, Debug, PartialEq, Eq)]
42pub enum WorkPartMode {
43 InPlace,
45 NewTab,
47}
48
49pub(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
60struct Pushed {
62 signature: String,
63 undo_top: Option<u64>,
66 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 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: Vec<Pushed>,
85 redo: Vec<Pushed>,
87 seen: Option<(u64, u64)>,
89 context_seen: Option<(u64, u64)>,
92 problems: Vec<(String, String, String, String)>,
95 pushes: u64,
97 error: Option<String>,
99 checkout: CheckoutOffer,
102}
103
104#[derive(Default)]
106enum CheckoutOffer {
107 #[default]
109 Unasked,
110 Asking { part: String, revision: String },
112 Running(crate::plm::PlmFuture<()>),
113 Done(Option<String>),
115}
116
117pub(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 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
178fn 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 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 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 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 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 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 let load = |docs: &Documents| -> Result<Document, String> {
298 let mut engine = docs.spawn_engine();
299 engine.set_history_json(&document.to_string())?;
300 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 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 pub fn work_part_in_place(&self) -> bool {
411 self.hosted().is_some()
412 }
413
414 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 pub fn work_part_path(&self) -> Option<&str> {
428 self.work_part.as_ref().map(|s| s.component_path.as_str())
429 }
430
431 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 let _ = self.push_work_part(false);
452 }
453 self.refresh_context();
454 }
455
456 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 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 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 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 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 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 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 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 pub fn work_part_settling(&self) -> bool {
595 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 pub fn work_part_modified_sources(&self) -> Vec<String> {
614 self.owned_modified_keys()
615 }
616
617 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 let mut context_action: Option<bool> = None;
1001 let mut finish = false;
1002 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 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 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 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
1149fn 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
1159pub(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 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
1200fn 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 brep_render::geometry3d::mat4_mul_row_major(&a, &b)
1262}
1263fn inverse_rigid(m: Matrix) -> Matrix {
1264 brep_render::geometry3d::mat4_rigid_inverse(&m)
1265}
1266fn vector_at(m: Matrix, p: [f64; 3]) -> [f64; 3] {
1267 std::array::from_fn(|r| (0..3).map(|k| m[r * 4 + k] * p[k]).sum())
1268}
1269fn point_at(m: Matrix, p: [f64; 3]) -> [f64; 3] {
1270 let v = vector_at(m, p);
1271 std::array::from_fn(|r| v[r] + m[r * 4 + 3])
1272}
1273
1274pub static HIT_KEYS: &[crate::automation::hit_keys::HitKeyDoc] =
1275 &[crate::automation::hit_keys::HitKeyDoc {
1276 panel: "workpart",
1277 prefix: "return",
1278 meaning: "return to assembly and apply Work part edits",
1279 command: None,
1280 },
1281 crate::automation::hit_keys::HitKeyDoc {
1282 panel: "workpart",
1283 prefix: "checkout:",
1284 meaning: "the offer to check out a PLM part made the Work part: checkout:yes (default), checkout:no",
1285 command: None,
1286 },
1287 crate::automation::hit_keys::HitKeyDoc {
1288 panel: "workpart",
1289 prefix: "context-",
1290 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)",
1291 command: None,
1292 },
1293 crate::automation::hit_keys::HitKeyDoc {
1294 panel: "workpart",
1295 prefix: "display:",
1296 meaning: "the rest of the assembly (context): display:show (show/hide it), display:opacity (its opacity slider)",
1297 command: None,
1298 },
1299 crate::automation::hit_keys::HitKeyDoc {
1300 panel: "workpart",
1301 prefix: "crumb:",
1302 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",
1303 command: None,
1304 },
1305 crate::automation::hit_keys::HitKeyDoc {
1306 panel: "workpart",
1307 prefix: "up",
1308 meaning: "a nested Work part: make the sub-assembly holding it the Work part",
1309 command: None,
1310 }];
1311