brep_app/document.rs
1//! The MULTI-DOCUMENT model — the app holds N open models, exactly ONE of which
2//! is active.
3//!
4//! Before this, the shell owned a single `EngineState` and the document's
5//! IDENTITY (its store name + the clean baseline the dirty flag compares
6//! against) lived on the file dialog. That pairing is what a document IS, so it
7//! moves here: [`Document`] = one engine + its identity, [`Documents`] = the
8//! open list + the active index.
9//!
10//! # Why the accessor lives on `Documents` and not on `BrepApp`
11//!
12//! Half the shell passes DISJOINT `&mut` borrows of its own fields in one call
13//! (`self.history.show(ui, self.docs.engine_mut(), …)`, and the whole
14//! [`DockContext`](crate::panels::dock::DockContext)). A `BrepApp::engine_mut()`
15//! would borrow ALL of `self` and every one of those sites would stop compiling.
16//! `self.docs.engine_mut()` borrows only the `docs` FIELD, so it composes with a
17//! borrow of any other field exactly as `self.state` used to.
18//!
19//! # One runner per document
20//!
21//! Each engine installs its own history runner (a native thread, or — on wasm —
22//! its own web worker), because a runner owns the RESIDENT kernel state for the
23//! document it executes: sharing one would make a background document's reply
24//! land against the wrong resident registry. The shell therefore pumps EVERY
25//! open document each frame, not just the active one, so a run that outlives a
26//! tab switch is applied to the engine that submitted it. The cost is real and
27//! deliberately unpaid-for here: N documents means N runner threads/workers and
28//! N full scenes in memory. Opening a large assembly in a second tab costs what
29//! opening it in a second window would.
30//!
31//! # Display settings are the SESSION's; the workbench is the DOCUMENT's
32//!
33//! Theme / UI scale / colors / wireframe / lod live on `EngineState.settings`,
34//! so without a rule they would silently become per-tab (change the theme, switch
35//! tabs, watch it flip back). [`carry_settings`] copies them from the outgoing
36//! engine to the incoming one on every activation, and `workbench` is EXCLUDED
37//! there: a saved document remembers the workbench it was authored in and
38//! restores it on open, which is what makes the Assembly panes appear when you
39//! switch to an assembly tab and vanish when you switch back to a part.
40//!
41//! A BRAND NEW tab is the one place the workbench IS carried — by
42//! [`Documents::spawn_engine`], BEFORE the caller loads anything into the
43//! engine, so a document that declares a workbench still overrides it during
44//! the load and one that does not (a New document, a file saved before the
45//! field existed) simply continues the session you were working in.
46
47use crate::store::ModelStore;
48use brep_render::engine_state::EngineState;
49
50/// Builds a fresh engine for a new document — the platform runner, the viewcube,
51/// and the persisted display settings, all applied before anything is loaded
52/// into it. Injected by the shell so tests (which need the SYNCHRONOUS inline
53/// runner) can construct documents without a background thread.
54pub type EngineFactory = Box<dyn Fn() -> EngineState>;
55
56/// The empty model a **New** document starts from.
57pub const EMPTY_DOCUMENT: &str = r#"{"expressions":"","configurator":{},"features":[]}"#;
58
59/// ONE open model: the engine that owns it plus the identity the file lane needs
60/// — the store name it was opened from / saved to, and the clean baseline.
61pub struct Document {
62 /// The windowing-agnostic brain for THIS document (scene, camera, history,
63 /// settings, selection). Panels borrow it; it is never forked.
64 pub engine: EngineState,
65 /// The store identity (`None` = a never-saved "untitled" model). The RAW
66 /// identity, not the display name — a native dialog hands back a full path
67 /// and a plain Save must write back to it.
68 name: Option<String>,
69 /// The model request JSON as of the last New / Open / Save — the baseline
70 /// the dirty flag compares the live history against.
71 saved_signature: Option<String>,
72 /// The cached tab-strip dirty dot. See [`Document::dirty_marker`] for why
73 /// this is not simply `is_dirty()`.
74 dirty_marker: bool,
75 /// The applied-run generation `dirty_marker` was computed at.
76 marker_generation: Option<u64>,
77 /// A process-unique handle, so the shell can notice "the active document is
78 /// a different one now" without comparing indices (closing a tab BEFORE the
79 /// active one moves the index without changing the document).
80 id: u64,
81}
82
83impl Document {
84 /// Wrap `engine` as a document, taking its CURRENT model as the clean
85 /// baseline — so a freshly loaded (or freshly emptied) document is clean and
86 /// the first edit marks it dirty.
87 pub fn new(engine: EngineState) -> Self {
88 let saved_signature = Some(engine.history_request_json());
89 Self {
90 engine,
91 name: None,
92 saved_signature,
93 dirty_marker: false,
94 marker_generation: None,
95 id: next_document_id(),
96 }
97 }
98
99 /// The raw store identity, or `None` for a never-saved document.
100 pub fn name(&self) -> Option<&str> {
101 self.name.as_deref()
102 }
103
104 pub fn set_name(&mut self, name: Option<String>) {
105 self.name = name;
106 }
107
108 /// The tab label: the bare display name, or `untitled`.
109 pub fn title(&self) -> String {
110 match &self.name {
111 Some(name) => crate::store::model_display_name(name),
112 None => "untitled".to_string(),
113 }
114 }
115
116 /// Snapshot the current model as the clean baseline (after New / Open /
117 /// Save, or a `?loadModel=` boot-load).
118 pub fn mark_clean(&mut self) {
119 self.saved_signature = Some(self.engine.history_request_json());
120 self.dirty_marker = false;
121 self.marker_generation = Some(self.engine.applied_generation());
122 }
123
124 /// Dirty = the live model differs from the last saved/opened baseline.
125 /// (Rolling the history does NOT change the request document, so navigating
126 /// steps never marks dirty — only real edits/add/delete/reorder do.)
127 ///
128 /// HONEST but not free: it serializes the whole request document, which for
129 /// an assembly carrying an embedded parts library is megabytes. Every place
130 /// where being wrong would cost the user work — the close prompt, Save —
131 /// calls THIS. The per-frame tab dot calls [`Self::dirty_marker`] instead.
132 pub fn is_dirty(&self) -> bool {
133 match &self.saved_signature {
134 Some(saved) => *saved != self.engine.history_request_json(),
135 None => true,
136 }
137 }
138
139 /// The cached dirty flag the tab strip draws, recomputed only when the
140 /// document's APPLIED RUN generation moved. Every ordinary edit (a param
141 /// change, add, delete, reorder, undo) re-runs the history and bumps that
142 /// generation, so the dot tracks editing; a mutation that changes the
143 /// document WITHOUT a re-run (an object-metadata write) can leave the dot
144 /// one edit behind. That is a marker being briefly optimistic, never a lost
145 /// edit: [`Self::is_dirty`] is recomputed honestly at the close prompt.
146 ///
147 /// The alternative — serializing every open document every frame — is a
148 /// per-frame multi-megabyte cost per tab, and the app pays no such cost today.
149 pub fn refresh_dirty_marker(&mut self) {
150 let generation = self.engine.applied_generation();
151 if self.marker_generation == Some(generation) {
152 return;
153 }
154 self.marker_generation = Some(generation);
155 self.dirty_marker = self.is_dirty();
156 }
157
158 pub fn dirty_marker(&self) -> bool {
159 self.dirty_marker
160 }
161
162 /// The document's process-unique handle (identity across index shuffles).
163 pub fn id(&self) -> u64 {
164 self.id
165 }
166}
167
168/// Every open document + which one is active. Always holds AT LEAST ONE
169/// document: closing the last tab leaves a fresh untitled one in its place, so
170/// [`Documents::engine_mut`] is infallible and the shell never has to render a
171/// "no document" state that would be an empty viewport with extra steps.
172pub struct Documents {
173 open: Vec<Document>,
174 active: usize,
175 /// How a new tab's engine is built (platform runner + persisted settings).
176 new_engine: EngineFactory,
177}
178
179impl Documents {
180 /// A session holding ONE empty document built by `new_engine`.
181 pub fn new(new_engine: EngineFactory) -> Self {
182 let first = Document::new(new_engine());
183 Self {
184 open: vec![first],
185 active: 0,
186 new_engine,
187 }
188 }
189
190 /// A fresh engine for a document about to be opened — the caller loads into
191 /// it and hands the result to [`Self::open_document`].
192 ///
193 /// Seeded with the WHOLE of the session's settings, workbench included, so
194 /// a new tab continues what you were doing. The load that follows overrides
195 /// the workbench if the document names one (see the module header).
196 pub fn spawn_engine(&self) -> EngineState {
197 let mut engine = (self.new_engine)();
198 carry_settings(&self.engine().settings_json(), &mut engine, true);
199 engine
200 }
201
202 pub fn engine(&self) -> &EngineState {
203 &self.open[self.active].engine
204 }
205
206 pub fn engine_mut(&mut self) -> &mut EngineState {
207 &mut self.open[self.active].engine
208 }
209
210 pub fn active(&self) -> &Document {
211 &self.open[self.active]
212 }
213
214 pub fn active_mut(&mut self) -> &mut Document {
215 &mut self.open[self.active]
216 }
217
218 pub fn active_index(&self) -> usize {
219 self.active
220 }
221
222 /// The ACTIVE document's handle — the shell compares it frame to frame to
223 /// notice a switch from ANY source (a tab click, a close, New, Open, Open
224 /// Part, the session restore) with one check instead of a hook per site.
225 pub fn active_id(&self) -> u64 {
226 self.open[self.active].id
227 }
228
229 pub fn len(&self) -> usize {
230 self.open.len()
231 }
232
233 pub fn get(&self, index: usize) -> Option<&Document> {
234 self.open.get(index)
235 }
236
237 pub fn get_mut(&mut self, index: usize) -> Option<&mut Document> {
238 self.open.get_mut(index)
239 }
240
241 pub fn iter(&self) -> std::slice::Iter<'_, Document> {
242 self.open.iter()
243 }
244
245 pub fn iter_mut(&mut self) -> std::slice::IterMut<'_, Document> {
246 self.open.iter_mut()
247 }
248
249 /// The tab holding the document stored under `name`, if any.
250 pub fn index_of(&self, name: &str) -> Option<usize> {
251 self.open
252 .iter()
253 .position(|doc| doc.name.as_deref() == Some(name))
254 }
255
256 /// Activate the tab already holding `name`; `false` when it is not open.
257 /// The "focus the existing tab" half of open-or-focus.
258 pub fn focus_named(&mut self, name: &str) -> bool {
259 match self.index_of(name) {
260 Some(index) => {
261 self.activate(index);
262 true
263 }
264 None => false,
265 }
266 }
267
268 /// Add `doc` as a new tab and make it active.
269 pub fn open_document(&mut self, doc: Document) -> usize {
270 self.open.push(doc);
271 let index = self.open.len() - 1;
272 self.activate(index);
273 index
274 }
275
276 /// Make tab `index` active, carrying the session's display settings over to
277 /// it. Out-of-range indices are ignored (a stale click never panics).
278 pub fn activate(&mut self, index: usize) {
279 if index >= self.open.len() || index == self.active {
280 return;
281 }
282 let settings = self.open[self.active].engine.settings_json();
283 self.active = index;
284 carry_settings(&settings, &mut self.open[index].engine, false);
285 }
286
287 /// Close tab `index`. Closing the LAST document leaves a fresh untitled one
288 /// (the always-one-document invariant); closing a tab before the active one
289 /// keeps the same document active at its new index.
290 ///
291 /// The caller is responsible for the unsaved-changes prompt — this is the
292 /// mechanical close (`panels::file` owns the confirmation).
293 pub fn close(&mut self, index: usize) {
294 if index >= self.open.len() {
295 return;
296 }
297 // Taken BEFORE the removal: if the closing tab was the active one, its
298 // settings are the session's and must survive into whatever is shown next.
299 let settings = self.open[self.active].engine.settings_json();
300 let closed_active = index == self.active;
301 self.open.remove(index);
302 // The replacement for a session closed down to nothing is a brand-new
303 // tab, so it takes the WHOLE of the session's settings — workbench
304 // included — exactly as `New` does through `spawn_engine`.
305 let refilled = self.open.is_empty();
306 if refilled {
307 self.open.push(Document::new((self.new_engine)()));
308 self.active = 0;
309 } else if closed_active {
310 self.active = index.min(self.open.len() - 1);
311 } else if index < self.active {
312 self.active -= 1;
313 }
314 if closed_active {
315 let active = self.active;
316 carry_settings(&settings, &mut self.open[active].engine, refilled);
317 }
318 }
319
320 /// Refresh every tab's dirty dot (see [`Document::refresh_dirty_marker`]).
321 pub fn refresh_dirty_markers(&mut self) {
322 for doc in &mut self.open {
323 doc.refresh_dirty_marker();
324 }
325 }
326
327 // --- session persistence --------------------------------------------------
328
329 /// The persisted session: the NAMED documents in tab order plus the active
330 /// one's name. A never-saved document has no store identity, so it cannot be
331 /// restored and is not listed — reloading loses an unsaved scratch document
332 /// exactly as it did when the app held one document and persisted none.
333 pub fn session_json(&self) -> String {
334 let open: Vec<&str> = self.open.iter().filter_map(Document::name).collect();
335 serde_json::json!({
336 "open": open,
337 "active": self.active().name(),
338 })
339 .to_string()
340 }
341
342 /// Restore a persisted session, REPLACING the current open list. Names the
343 /// store no longer holds (or cannot load) are skipped — a deleted file must
344 /// not stop the rest of the session coming back — and the count of documents
345 /// actually restored is returned, so the caller can seed instead when it is 0.
346 pub fn restore_session(&mut self, store: &dyn ModelStore, json: &str) -> usize {
347 let Ok(session) = serde_json::from_str::<serde_json::Value>(json) else {
348 return 0;
349 };
350 let names: Vec<String> = session["open"]
351 .as_array()
352 .map(|list| {
353 list.iter()
354 .filter_map(|name| name.as_str().map(str::to_string))
355 .collect()
356 })
357 .unwrap_or_default();
358 let wanted_active = session["active"].as_str().unwrap_or_default().to_string();
359
360 let mut restored: Vec<Document> = Vec::new();
361 let mut active = 0usize;
362 for name in names {
363 let Some(contents) = store.read(&name) else {
364 continue;
365 };
366 let mut engine = (self.new_engine)();
367 if engine.load_model_and_fit(&contents).is_err() {
368 continue;
369 }
370 let mut doc = Document::new(engine);
371 doc.set_name(Some(name.clone()));
372 if name == wanted_active {
373 active = restored.len();
374 }
375 restored.push(doc);
376 }
377 if restored.is_empty() {
378 return 0;
379 }
380 self.open = restored;
381 self.active = active;
382 self.open.len()
383 }
384}
385
386/// Copy the SESSION-scoped display settings (theme, UI scale, colors, wireframe,
387/// projection, lod…) from a `settings_json()` snapshot into `target`.
388/// `with_workbench` is true ONLY when seeding a brand-new engine — see the
389/// module header for why activation must never carry it.
390fn carry_settings(settings_json: &str, target: &mut EngineState, with_workbench: bool) {
391 let Ok(mut settings) = serde_json::from_str::<serde_json::Value>(settings_json) else {
392 return;
393 };
394 if !with_workbench {
395 if let Some(object) = settings.as_object_mut() {
396 object.remove("workbench");
397 }
398 }
399 let _ = target.apply_settings_json(&settings.to_string());
400}
401
402/// Hand out the next document handle. A plain process counter: handles only ever
403/// need to be distinct WITHIN a session, and they never reach storage.
404fn next_document_id() -> u64 {
405 use std::sync::atomic::{AtomicU64, Ordering};
406 static NEXT: AtomicU64 = AtomicU64::new(1);
407 NEXT.fetch_add(1, Ordering::Relaxed)
408}
409
410#[cfg(all(test, not(target_arch = "wasm32")))]
411mod tests {
412 use super::*;
413 use crate::store::MemModelStore;
414
415 /// The test factory: the SYNCHRONOUS inline runner, so a load's solids are
416 /// resident by the time the call returns.
417 fn documents() -> Documents {
418 Documents::new(Box::new(EngineState::new))
419 }
420
421 const BOX_SEED: &str = r#"{"expressions":"","configurator":{},"features":[
422 {"type":"P.CU","inputParams":{"id":"Box","sizeX":8.0,"sizeY":8.0,"sizeZ":8.0,
423 "transform":{"position":[0,0,0],"rotationEuler":[0,0,0],"scale":[1,1,1]},
424 "boolean":{"targets":[],"operation":"NONE"}},"persistentData":{}}
425 ]}"#;
426
427 fn named(docs: &mut Documents, name: &str) -> usize {
428 let mut engine = docs.spawn_engine();
429 engine.set_history_json(BOX_SEED).unwrap();
430 let mut doc = Document::new(engine);
431 doc.set_name(Some(name.to_string()));
432 docs.open_document(doc)
433 }
434
435 /// A session starts with exactly one (empty, untitled, clean) document, and
436 /// every opened document becomes the active tab.
437 #[test]
438 fn a_session_always_holds_at_least_one_document() {
439 let mut docs = documents();
440 assert_eq!(docs.len(), 1);
441 assert_eq!(docs.active_index(), 0);
442 assert_eq!(docs.active().title(), "untitled");
443 assert!(!docs.active().is_dirty(), "a fresh document is clean");
444
445 named(&mut docs, "part-a");
446 assert_eq!(docs.len(), 2);
447 assert_eq!(docs.active_index(), 1, "an opened document takes focus");
448 assert_eq!(docs.active().title(), "part-a");
449 }
450
451 /// Open-or-focus: a name already open activates ITS tab instead of adding a
452 /// second one.
453 #[test]
454 fn focus_named_activates_the_existing_tab() {
455 let mut docs = documents();
456 named(&mut docs, "part-a");
457 named(&mut docs, "part-b");
458 assert_eq!(docs.active_index(), 2);
459
460 assert!(docs.focus_named("part-a"), "already open");
461 assert_eq!(docs.active_index(), 1);
462 assert_eq!(docs.len(), 3, "focusing never adds a tab");
463 assert!(!docs.focus_named("part-z"), "never opened");
464 assert_eq!(docs.active_index(), 1, "a miss leaves the focus alone");
465 }
466
467 /// The index fixup: closing a tab BEFORE the active one keeps the same
468 /// document active (its handle is unchanged); closing the active one falls
469 /// to its neighbour; closing the last one leaves a fresh untitled document.
470 #[test]
471 fn closing_fixes_up_the_active_index() {
472 let mut docs = documents();
473 named(&mut docs, "part-a"); // index 1
474 named(&mut docs, "part-b"); // index 2
475 docs.activate(2);
476 let active_id = docs.active_id();
477
478 // Close the untitled tab BEFORE the active one: same document, new index.
479 docs.close(0);
480 assert_eq!(docs.len(), 2);
481 assert_eq!(docs.active_index(), 1);
482 assert_eq!(docs.active_id(), active_id, "the same document stays active");
483 assert_eq!(docs.active().title(), "part-b");
484
485 // Close the ACTIVE (last) tab: focus falls back to its neighbour.
486 docs.close(1);
487 assert_eq!(docs.len(), 1);
488 assert_eq!(docs.active_index(), 0);
489 assert_eq!(docs.active().title(), "part-a");
490 assert_ne!(docs.active_id(), active_id, "a different document is active");
491
492 // Close the ONLY tab: a fresh untitled document takes its place.
493 docs.close(0);
494 assert_eq!(docs.len(), 1, "there is always a document");
495 assert_eq!(docs.active().title(), "untitled");
496 assert_eq!(docs.active().engine.history_len(), 0);
497
498 // Out-of-range indices are ignored rather than panicking.
499 docs.close(7);
500 assert_eq!(docs.len(), 1);
501 }
502
503 /// Dirty tracking is per document: editing one tab never marks another, and
504 /// the cached tab-strip marker follows the honest flag across a re-run.
505 #[test]
506 fn dirty_tracking_is_per_document() {
507 let mut docs = documents();
508 named(&mut docs, "part-a");
509 named(&mut docs, "part-b");
510 docs.refresh_dirty_markers();
511 assert!(docs.iter().all(|doc| !doc.is_dirty()), "all clean");
512 assert!(docs.iter().all(|doc| !doc.dirty_marker()));
513
514 // Edit the ACTIVE document (part-b).
515 docs.engine_mut()
516 .update_feature_params(
517 "Box",
518 r#"{"id":"Box","sizeX":19.0,"sizeY":8.0,"sizeZ":8.0,"transform":{"position":[0,0,0],"rotationEuler":[0,0,0],"scale":[1,1,1]},"boolean":{"targets":[],"operation":"NONE"}}"#,
519 )
520 .unwrap();
521 docs.refresh_dirty_markers();
522 assert!(docs.active().is_dirty(), "the edited document is dirty");
523 assert!(docs.active().dirty_marker(), "…and its tab dot lights");
524 assert!(!docs.get(1).unwrap().is_dirty(), "the other tab is untouched");
525 assert!(!docs.get(1).unwrap().dirty_marker());
526
527 // Saving (marking clean) clears both the flag and the dot.
528 docs.active_mut().mark_clean();
529 docs.refresh_dirty_markers();
530 assert!(!docs.active().is_dirty());
531 assert!(!docs.active().dirty_marker());
532 }
533
534 /// Display settings are the SESSION's: a theme change follows a tab switch.
535 /// The workbench is the DOCUMENT's and must NOT follow it — that is what
536 /// makes the Assembly panes appear on an assembly tab and vanish on a part.
537 #[test]
538 fn switching_carries_display_settings_but_not_the_workbench() {
539 let mut docs = documents();
540 docs.engine_mut()
541 .apply_settings_json(r#"{"workbench":"assembly","wireframe":true}"#)
542 .unwrap();
543 named(&mut docs, "part-a");
544 docs.engine_mut()
545 .apply_settings_json(r#"{"workbench":"sheetMetal"}"#)
546 .unwrap();
547 assert!(
548 docs.engine().settings.wireframe,
549 "the new tab inherited the session's display settings"
550 );
551
552 // Back to the first tab: it keeps its own workbench, and picks up the
553 // display setting changed while it was in the background.
554 docs.engine_mut()
555 .apply_settings_json(r#"{"wireframe":false}"#)
556 .unwrap();
557 docs.activate(0);
558 assert_eq!(docs.engine().settings.workbench, "assembly", "per document");
559 assert!(!docs.engine().settings.wireframe, "per session");
560
561 docs.activate(1);
562 assert_eq!(docs.engine().settings.workbench, "sheetMetal");
563 }
564
565 /// The session round-trips through the store: only NAMED documents are
566 /// listed, the active one comes back active, and a name whose file has gone
567 /// is skipped rather than failing the whole restore.
568 #[test]
569 fn the_session_round_trips_and_tolerates_a_missing_file() {
570 let store = MemModelStore::new();
571 store.put("part-a", BOX_SEED);
572 store.put("part-b", BOX_SEED);
573
574 let mut docs = documents();
575 named(&mut docs, "part-a");
576 named(&mut docs, "part-b");
577 docs.activate(1);
578 let session = docs.session_json();
579 let parsed: serde_json::Value = serde_json::from_str(&session).unwrap();
580 assert_eq!(
581 parsed["open"].as_array().unwrap().len(),
582 2,
583 "the untitled tab is not persistable: {session}"
584 );
585 assert_eq!(parsed["active"], "part-a");
586
587 let mut restored = documents();
588 assert_eq!(restored.restore_session(&store, &session), 2);
589 assert_eq!(restored.len(), 2);
590 assert_eq!(restored.active().title(), "part-a");
591 assert!(!restored.active().is_dirty(), "a restored document is clean");
592 assert_eq!(restored.active().engine.scene.solids().len(), 1);
593
594 // One file removed: the rest of the session still comes back.
595 store.remove("part-a").unwrap();
596 let mut partial = documents();
597 assert_eq!(partial.restore_session(&store, &session), 1);
598 assert_eq!(partial.active().title(), "part-b");
599
600 // Nothing restorable → 0, and the caller seeds instead.
601 store.remove("part-b").unwrap();
602 let mut none = documents();
603 assert_eq!(none.restore_session(&store, &session), 0);
604 assert_eq!(none.len(), 1, "the untouched boot document survives");
605 assert_eq!(none.restore_session(&store, "not json"), 0);
606 }
607}