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