use crate::store::ModelStore;
use brep_render::engine_state::EngineState;
pub type EngineFactory = Box<dyn Fn() -> EngineState>;
pub const EMPTY_DOCUMENT: &str = r#"{"expressions":"","configurator":{},"features":[]}"#;
pub struct Document {
pub engine: EngineState,
name: Option<String>,
saved_signature: Option<String>,
dirty_marker: bool,
marker_generation: Option<u64>,
id: u64,
}
impl Document {
pub fn new(engine: EngineState) -> Self {
let saved_signature = Some(engine.history_request_json());
Self {
engine,
name: None,
saved_signature,
dirty_marker: false,
marker_generation: None,
id: next_document_id(),
}
}
pub fn recovered(engine: EngineState, name: Option<String>) -> Self {
Self {
engine,
name,
saved_signature: None,
dirty_marker: true,
marker_generation: None,
id: next_document_id(),
}
}
pub fn name(&self) -> Option<&str> {
self.name.as_deref()
}
pub fn set_name(&mut self, name: Option<String>) {
self.name = name;
}
pub fn title(&self) -> String {
match &self.name {
Some(name) => crate::store::model_display_name(name),
None => "untitled".to_string(),
}
}
pub fn mark_clean(&mut self) {
self.saved_signature = Some(self.engine.history_request_json());
self.dirty_marker = false;
self.marker_generation = Some(self.engine.applied_generation());
}
pub fn is_dirty(&self) -> bool {
match &self.saved_signature {
Some(saved) => *saved != self.engine.history_request_json(),
None => true,
}
}
pub fn refresh_dirty_marker(&mut self) {
let generation = self.engine.applied_generation();
if self.marker_generation == Some(generation) {
return;
}
self.marker_generation = Some(generation);
self.dirty_marker = self.is_dirty();
}
pub fn dirty_marker(&self) -> bool {
self.dirty_marker
}
pub fn id(&self) -> u64 {
self.id
}
}
pub struct Documents {
open: Vec<Document>,
active: usize,
new_engine: EngineFactory,
}
impl Documents {
pub fn new(new_engine: EngineFactory) -> Self {
let first = Document::new(new_engine());
Self {
open: vec![first],
active: 0,
new_engine,
}
}
pub fn spawn_engine(&self) -> EngineState {
let mut engine = (self.new_engine)();
carry_settings(&self.engine().settings_json(), &mut engine, true);
engine
}
pub fn engine(&self) -> &EngineState {
&self.open[self.active].engine
}
pub fn engine_mut(&mut self) -> &mut EngineState {
&mut self.open[self.active].engine
}
pub fn active(&self) -> &Document {
&self.open[self.active]
}
pub fn active_mut(&mut self) -> &mut Document {
&mut self.open[self.active]
}
pub fn active_index(&self) -> usize {
self.active
}
pub fn active_id(&self) -> u64 {
self.open[self.active].id
}
pub fn len(&self) -> usize {
self.open.len()
}
pub fn get(&self, index: usize) -> Option<&Document> {
self.open.get(index)
}
pub fn get_mut(&mut self, index: usize) -> Option<&mut Document> {
self.open.get_mut(index)
}
pub fn iter(&self) -> std::slice::Iter<'_, Document> {
self.open.iter()
}
pub fn iter_mut(&mut self) -> std::slice::IterMut<'_, Document> {
self.open.iter_mut()
}
pub fn index_of(&self, name: &str) -> Option<usize> {
self.open
.iter()
.position(|doc| doc.name.as_deref() == Some(name))
}
pub fn focus_named(&mut self, name: &str) -> bool {
match self.index_of(name) {
Some(index) => {
self.activate(index);
true
}
None => false,
}
}
pub fn open_document(&mut self, doc: Document) -> usize {
self.open.push(doc);
let index = self.open.len() - 1;
self.activate(index);
index
}
pub fn activate(&mut self, index: usize) {
if index >= self.open.len() || index == self.active {
return;
}
let settings = self.open[self.active].engine.settings_json();
self.active = index;
carry_settings(&settings, &mut self.open[index].engine, false);
}
pub fn close(&mut self, index: usize) {
if index >= self.open.len() {
return;
}
let settings = self.open[self.active].engine.settings_json();
let closed_active = index == self.active;
self.open.remove(index);
let refilled = self.open.is_empty();
if refilled {
self.open.push(Document::new((self.new_engine)()));
self.active = 0;
} else if closed_active {
self.active = index.min(self.open.len() - 1);
} else if index < self.active {
self.active -= 1;
}
if closed_active {
let active = self.active;
carry_settings(&settings, &mut self.open[active].engine, refilled);
}
}
pub fn refresh_dirty_markers(&mut self) {
for doc in &mut self.open {
doc.refresh_dirty_marker();
}
}
pub fn session_json(&self) -> String {
let open: Vec<&str> = self.open.iter().filter_map(Document::name).collect();
serde_json::json!({
"open": open,
"active": self.active().name(),
})
.to_string()
}
pub fn restore_session(&mut self, store: &dyn ModelStore, json: &str) -> usize {
let Ok(session) = serde_json::from_str::<serde_json::Value>(json) else {
return 0;
};
let names: Vec<String> = session["open"]
.as_array()
.map(|list| {
list.iter()
.filter_map(|name| name.as_str().map(str::to_string))
.collect()
})
.unwrap_or_default();
let wanted_active = session["active"].as_str().unwrap_or_default().to_string();
let mut restored: Vec<Document> = Vec::new();
let mut active = 0usize;
for name in names {
let Some(contents) = store.read(&name) else {
continue;
};
let mut engine = (self.new_engine)();
if engine.load_model_and_fit(&contents).is_err() {
continue;
}
let mut doc = Document::new(engine);
doc.set_name(Some(name.clone()));
if name == wanted_active {
active = restored.len();
}
restored.push(doc);
}
if restored.is_empty() {
return 0;
}
self.open = restored;
self.active = active;
self.open.len()
}
}
fn carry_settings(settings_json: &str, target: &mut EngineState, with_workbench: bool) {
let Ok(mut settings) = serde_json::from_str::<serde_json::Value>(settings_json) else {
return;
};
if !with_workbench {
if let Some(object) = settings.as_object_mut() {
object.remove("workbench");
}
}
let _ = target.apply_settings_json(&settings.to_string());
}
fn next_document_id() -> u64 {
use std::sync::atomic::{AtomicU64, Ordering};
static NEXT: AtomicU64 = AtomicU64::new(1);
NEXT.fetch_add(1, Ordering::Relaxed)
}
#[cfg(all(test, not(target_arch = "wasm32")))]
mod tests {
use super::*;
use crate::store::MemModelStore;
fn documents() -> Documents {
Documents::new(Box::new(EngineState::new))
}
const BOX_SEED: &str = r#"{"expressions":"","configurator":{},"features":[
{"type":"P.CU","inputParams":{"id":"Box","sizeX":8.0,"sizeY":8.0,"sizeZ":8.0,
"transform":{"position":[0,0,0],"rotationEuler":[0,0,0],"scale":[1,1,1]},
"boolean":{"targets":[],"operation":"NONE"}},"persistentData":{}}
]}"#;
fn named(docs: &mut Documents, name: &str) -> usize {
let mut engine = docs.spawn_engine();
engine.set_history_json(BOX_SEED).unwrap();
let mut doc = Document::new(engine);
doc.set_name(Some(name.to_string()));
docs.open_document(doc)
}
#[test]
fn a_session_always_holds_at_least_one_document() {
let mut docs = documents();
assert_eq!(docs.len(), 1);
assert_eq!(docs.active_index(), 0);
assert_eq!(docs.active().title(), "untitled");
assert!(!docs.active().is_dirty(), "a fresh document is clean");
named(&mut docs, "part-a");
assert_eq!(docs.len(), 2);
assert_eq!(docs.active_index(), 1, "an opened document takes focus");
assert_eq!(docs.active().title(), "part-a");
}
#[test]
fn focus_named_activates_the_existing_tab() {
let mut docs = documents();
named(&mut docs, "part-a");
named(&mut docs, "part-b");
assert_eq!(docs.active_index(), 2);
assert!(docs.focus_named("part-a"), "already open");
assert_eq!(docs.active_index(), 1);
assert_eq!(docs.len(), 3, "focusing never adds a tab");
assert!(!docs.focus_named("part-z"), "never opened");
assert_eq!(docs.active_index(), 1, "a miss leaves the focus alone");
}
#[test]
fn closing_fixes_up_the_active_index() {
let mut docs = documents();
named(&mut docs, "part-a"); named(&mut docs, "part-b"); docs.activate(2);
let active_id = docs.active_id();
docs.close(0);
assert_eq!(docs.len(), 2);
assert_eq!(docs.active_index(), 1);
assert_eq!(docs.active_id(), active_id, "the same document stays active");
assert_eq!(docs.active().title(), "part-b");
docs.close(1);
assert_eq!(docs.len(), 1);
assert_eq!(docs.active_index(), 0);
assert_eq!(docs.active().title(), "part-a");
assert_ne!(docs.active_id(), active_id, "a different document is active");
docs.close(0);
assert_eq!(docs.len(), 1, "there is always a document");
assert_eq!(docs.active().title(), "untitled");
assert_eq!(docs.active().engine.history_len(), 0);
docs.close(7);
assert_eq!(docs.len(), 1);
}
#[test]
fn dirty_tracking_is_per_document() {
let mut docs = documents();
named(&mut docs, "part-a");
named(&mut docs, "part-b");
docs.refresh_dirty_markers();
assert!(docs.iter().all(|doc| !doc.is_dirty()), "all clean");
assert!(docs.iter().all(|doc| !doc.dirty_marker()));
docs.engine_mut()
.update_feature_params(
"Box",
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"}}"#,
)
.unwrap();
docs.refresh_dirty_markers();
assert!(docs.active().is_dirty(), "the edited document is dirty");
assert!(docs.active().dirty_marker(), "…and its tab dot lights");
assert!(!docs.get(1).unwrap().is_dirty(), "the other tab is untouched");
assert!(!docs.get(1).unwrap().dirty_marker());
docs.active_mut().mark_clean();
docs.refresh_dirty_markers();
assert!(!docs.active().is_dirty());
assert!(!docs.active().dirty_marker());
}
#[test]
fn switching_carries_display_settings_but_not_the_workbench() {
let mut docs = documents();
docs.engine_mut()
.apply_settings_json(r#"{"workbench":"assembly","wireframe":true}"#)
.unwrap();
named(&mut docs, "part-a");
docs.engine_mut()
.apply_settings_json(r#"{"workbench":"sheetMetal"}"#)
.unwrap();
assert!(
docs.engine().settings.wireframe,
"the new tab inherited the session's display settings"
);
docs.engine_mut()
.apply_settings_json(r#"{"wireframe":false}"#)
.unwrap();
docs.activate(0);
assert_eq!(docs.engine().settings.workbench, "assembly", "per document");
assert!(!docs.engine().settings.wireframe, "per session");
docs.activate(1);
assert_eq!(docs.engine().settings.workbench, "sheetMetal");
}
#[test]
fn the_session_round_trips_and_tolerates_a_missing_file() {
let store = MemModelStore::new();
store.put("part-a", BOX_SEED);
store.put("part-b", BOX_SEED);
let mut docs = documents();
named(&mut docs, "part-a");
named(&mut docs, "part-b");
docs.activate(1);
let session = docs.session_json();
let parsed: serde_json::Value = serde_json::from_str(&session).unwrap();
assert_eq!(
parsed["open"].as_array().unwrap().len(),
2,
"the untitled tab is not persistable: {session}"
);
assert_eq!(parsed["active"], "part-a");
let mut restored = documents();
assert_eq!(restored.restore_session(&store, &session), 2);
assert_eq!(restored.len(), 2);
assert_eq!(restored.active().title(), "part-a");
assert!(!restored.active().is_dirty(), "a restored document is clean");
assert_eq!(restored.active().engine.scene.solids().len(), 1);
store.remove("part-a").unwrap();
let mut partial = documents();
assert_eq!(partial.restore_session(&store, &session), 1);
assert_eq!(partial.active().title(), "part-b");
store.remove("part-b").unwrap();
let mut none = documents();
assert_eq!(none.restore_session(&store, &session), 0);
assert_eq!(none.len(), 1, "the untouched boot document survives");
assert_eq!(none.restore_session(&store, "not json"), 0);
}
}