diff --git a/crates/bone-document/src/document/mod.rs b/crates/bone-document/src/document/mod.rs index 3eb86e8..11fb4a5 100644 --- a/crates/bone-document/src/document/mod.rs +++ b/crates/bone-document/src/document/mod.rs @@ -131,7 +131,7 @@ pub struct DocumentHeader { pub parameters: DocumentParameters, pub feature_tree: FeatureTree, pub sketches: SketchRegistry, - #[serde(default, skip_serializing_if = "BTreeMap::is_empty")] + #[serde(skip)] pub extrudes: BTreeMap, } @@ -173,6 +173,23 @@ impl SketchFile { } } +#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] +#[serde(deny_unknown_fields)] +pub struct ExtrudeFile { + pub schema: SchemaHeader, + pub feature: ExtrudeFeature, +} + +impl ExtrudeFile { + #[must_use] + pub fn new(feature: ExtrudeFeature) -> Self { + Self { + schema: SchemaHeader::bone_document(), + feature, + } + } +} + #[derive(Clone, Debug, PartialEq)] pub struct Document { header: DocumentHeader, @@ -344,10 +361,18 @@ impl Document { } } +fn id_filename(id: K) -> String { + format!("{:016x}.ron", id.data().as_ffi()) +} + #[must_use] pub fn sketch_filename(id: SketchId) -> String { - use slotmap::Key; - format!("{:016x}.ron", id.data().as_ffi()) + id_filename(id) +} + +#[must_use] +pub fn extrude_filename(id: ExtrudeId) -> String { + id_filename(id) } #[cfg(test)] diff --git a/crates/bone-document/src/io/blob.rs b/crates/bone-document/src/io/blob.rs index 9da8bdb..56df3fd 100644 --- a/crates/bone-document/src/io/blob.rs +++ b/crates/bone-document/src/io/blob.rs @@ -14,7 +14,11 @@ impl BlobHash { #[must_use] pub fn of_pair(first: &[u8], second: &[u8]) -> Self { + let Ok(first_len) = u64::try_from(first.len()) else { + unreachable!("slice length exceeds u64 only above 64-bit platforms"); + }; let mut hasher = blake3::Hasher::new(); + hasher.update(&first_len.to_le_bytes()); hasher.update(first); hasher.update(second); Self(hasher.finalize()) @@ -116,6 +120,25 @@ mod tests { ); } + #[test] + fn of_pair_frames_the_boundary_between_parts() { + assert_ne!( + BlobHash::of_pair(b"AB", b"C"), + BlobHash::of_pair(b"A", b"BC"), + "a different split of the same concatenation must not collide" + ); + assert_ne!( + BlobHash::of_pair(b"AB", b"C"), + BlobHash::of(b"ABC"), + "framed pair must not collide with the bare concatenation" + ); + assert_ne!( + BlobHash::of_pair(b"", b"X"), + BlobHash::of_pair(b"X", b""), + "an empty leading part is distinguishable from an empty trailing part" + ); + } + #[test] fn truncated_is_first_32_hex_chars() { let hash = BlobHash::of(b"example"); diff --git a/crates/bone-document/src/io/folder.rs b/crates/bone-document/src/io/folder.rs index 67fc764..c650da6 100644 --- a/crates/bone-document/src/io/folder.rs +++ b/crates/bone-document/src/io/folder.rs @@ -4,17 +4,25 @@ use std::path::{Path, PathBuf}; use std::sync::atomic::{AtomicU64, Ordering}; use std::{fs, io}; -use bone_types::{ExtrudeId, SchemaHeader, SchemaVersion, SketchId}; - -use crate::document::{Document, DocumentHeader, FeatureNode, SketchFile, sketch_filename}; +use bone_kernel::ExtrudeFeature; +use bone_types::{ + AngleTolerance, ChordHeightTolerance, ExtrudeId, SchemaHeader, SchemaVersion, SketchId, +}; + +use crate::document::{ + Document, DocumentHeader, ExtrudeFile, FeatureNode, SketchFile, extrude_filename, + sketch_filename, +}; use crate::io::blob::{BlobHash, BlobKind}; use crate::io::ron_io::{RonError, from_str, to_string}; use crate::sketch::SketchEditError; pub const DOCUMENT_FILE: &str = "document.ron"; pub const SKETCHES_DIR: &str = "sketches"; +pub const EXTRUDES_DIR: &str = "extrudes"; pub const BLOBS_DIR: &str = "blobs"; pub const CACHES_DIR: &str = "caches"; +pub const TESSELLATIONS_DIR: &str = "tessellations"; const ROOT_GITIGNORE: &str = "caches/\n"; const ROOT_GITATTRIBUTES: &str = "* text=auto eol=lf\n*.brep text eol=lf\n*.labels text eol=lf\n"; @@ -90,10 +98,8 @@ pub enum FolderErrorKind { DanglingTreeSketch { id: SketchId }, #[error("registry has sketch {id:?} absent from feature tree")] OrphanRegistered { id: SketchId }, - #[error("feature tree references extrude {id:?} not in extrudes")] - DanglingTreeExtrude { id: ExtrudeId }, - #[error("stored extrude {id:?} absent from feature tree")] - OrphanExtrude { id: ExtrudeId }, + #[error("feature tree references extrude {id:?} with no file on disk")] + MissingExtrudeFile { id: ExtrudeId }, #[error("stored extrude {extrude:?} references sketch {sketch:?} absent from the document")] DanglingExtrudeSketch { extrude: ExtrudeId, @@ -141,6 +147,11 @@ impl DocumentFolder { self.path.join(SKETCHES_DIR) } + #[must_use] + pub fn extrudes_dir(&self) -> PathBuf { + self.path.join(EXTRUDES_DIR) + } + #[must_use] pub fn blobs_dir(&self) -> PathBuf { self.path.join(BLOBS_DIR) @@ -156,22 +167,42 @@ impl DocumentFolder { self.sketches_dir().join(sketch_filename(id)) } + #[must_use] + pub fn extrude_path(&self, id: ExtrudeId) -> PathBuf { + self.extrudes_dir().join(extrude_filename(id)) + } + #[must_use] pub fn blob_path(&self, hash: BlobHash, kind: BlobKind) -> PathBuf { self.blobs_dir().join(hash.relative_path(kind)) } + + #[must_use] + pub fn tessellation_path( + &self, + hash: BlobHash, + chord: ChordHeightTolerance, + angle: AngleTolerance, + ) -> PathBuf { + self.caches_dir().join(TESSELLATIONS_DIR).join(format!( + "{}.{}.{}", + hash.truncated_128_hex(), + tessellation_tier_hex(chord, angle), + BlobKind::TESS.as_str() + )) + } } -pub fn save(document: &Document, folder: &DocumentFolder) -> Result<(), FolderError> { - ensure_dir(folder.path())?; - ensure_dir(&folder.sketches_dir())?; - ensure_dir(&folder.blobs_dir())?; - ensure_dir(&folder.caches_dir())?; +fn tessellation_tier_hex(chord: ChordHeightTolerance, angle: AngleTolerance) -> String { + format!( + "{:016x}{:016x}", + chord.millimeters().to_bits(), + angle.radians().to_bits() + ) +} - write_if_different(&folder.path().join(".gitignore"), ROOT_GITIGNORE)?; - write_if_different(&folder.path().join(".gitattributes"), ROOT_GITATTRIBUTES)?; - write_if_different(&folder.caches_dir().join("CACHEDIR.TAG"), CACHEDIR_TAG)?; - write_if_different(&folder.caches_dir().join(".gitignore"), CACHES_GITIGNORE)?; +pub fn save(document: &Document, folder: &DocumentFolder) -> Result<(), FolderError> { + ensure_scaffold(folder)?; document .sketches() @@ -181,20 +212,53 @@ pub fn save(document: &Document, folder: &DocumentFolder) -> Result<(), FolderEr write_if_different(&folder.sketch_path(id), &ron) })?; + let tree_extrudes = tree_extrude_ids(document.header()); + document + .header() + .extrudes + .iter() + .filter(|(id, _)| tree_extrudes.contains(*id)) + .try_for_each(|(id, feature)| -> Result<(), FolderError> { + let path = folder.extrude_path(*id); + let ron = to_ron(&path, &ExtrudeFile::new(*feature))?; + write_if_different(&path, &ron) + })?; + let document_ron = to_ron(&folder.document_file(), document.header())?; write_if_different(&folder.document_file(), &document_ron)?; - let registry_ids: BTreeSet = document.registry().order().iter().copied().collect(); - remove_stale_sketches(&folder.sketches_dir(), ®istry_ids)?; + let live_sketches = document + .registry() + .order() + .iter() + .copied() + .map(sketch_filename) + .collect(); + remove_stale_files(&folder.sketches_dir(), &live_sketches)?; + let live_extrudes = tree_extrudes.iter().copied().map(extrude_filename).collect(); + remove_stale_files(&folder.extrudes_dir(), &live_extrudes)?; Ok(()) } +fn tree_extrude_ids(header: &DocumentHeader) -> BTreeSet { + header + .feature_tree + .iter() + .filter_map(|(_, node)| match node { + FeatureNode::Extrude(id) => Some(id), + FeatureNode::Origin | FeatureNode::PrincipalPlane(_) | FeatureNode::Sketch(_) => None, + }) + .collect() +} + pub fn load(folder: &DocumentFolder) -> Result { let header_path = folder.document_file(); let header_text = read_to_string(&header_path)?; - let header: DocumentHeader = from_ron(&header_path, &header_text)?; + let mut header: DocumentHeader = from_ron(&header_path, &header_text)?; check_schema(&header.schema)?; + let extrudes = read_extrudes(folder, &header)?; + header.extrudes = extrudes; validate_header(&header)?; let sketches = @@ -229,24 +293,30 @@ pub fn load(folder: &DocumentFolder) -> Result { Ok(Document::from_parts(header, sketches)) } +fn read_extrudes( + folder: &DocumentFolder, + header: &DocumentHeader, +) -> Result, FolderError> { + tree_extrude_ids(header) + .into_iter() + .try_fold(BTreeMap::new(), |mut acc, id| { + let path = folder.extrude_path(id); + let text = read_to_string(&path).map_err(|e| match e.into_kind() { + FolderErrorKind::Io { source, .. } if source.kind() == io::ErrorKind::NotFound => { + FolderErrorKind::MissingExtrudeFile { id }.wrap() + } + other => other.wrap(), + })?; + let file: ExtrudeFile = from_ron(&path, &text)?; + check_schema(&file.schema)?; + acc.insert(id, file.feature); + Ok::<_, FolderError>(acc) + }) +} + fn validate_header(header: &DocumentHeader) -> Result<(), FolderError> { let tree = &header.feature_tree; - let tree_extrudes: BTreeSet = tree - .iter() - .filter_map(|(_, node)| match node { - FeatureNode::Extrude(id) => Some(id), - FeatureNode::Origin | FeatureNode::PrincipalPlane(_) | FeatureNode::Sketch(_) => None, - }) - .collect(); - let stored_extrudes: BTreeSet = header.extrudes.keys().copied().collect(); - if let Some(&id) = tree_extrudes.difference(&stored_extrudes).next() { - return Err(FolderErrorKind::DanglingTreeExtrude { id }.wrap()); - } - if let Some(&id) = stored_extrudes.difference(&tree_extrudes).next() { - return Err(FolderErrorKind::OrphanExtrude { id }.wrap()); - } - let registered: BTreeSet = header.sketches.order().iter().copied().collect(); let tree_sketches: BTreeSet = tree .iter() @@ -302,12 +372,19 @@ fn check_schema(schema: &SchemaHeader) -> Result<(), FolderError> { name = %schema.name, found = %schema.version, supported = %supported, - "accepting newer minor schema version; unknown fields will be rejected by deny_unknown_fields" + "accepting a newer minor schema version than this build writes" ); } Ok(()) } +pub(crate) fn ensure_scaffold(folder: &DocumentFolder) -> Result<(), FolderError> { + write_if_different(&folder.path().join(".gitignore"), ROOT_GITIGNORE)?; + write_if_different(&folder.path().join(".gitattributes"), ROOT_GITATTRIBUTES)?; + write_if_different(&folder.caches_dir().join("CACHEDIR.TAG"), CACHEDIR_TAG)?; + write_if_different(&folder.caches_dir().join(".gitignore"), CACHES_GITIGNORE) +} + pub(crate) fn ensure_dir(path: &Path) -> Result<(), FolderError> { fs::create_dir_all(path).map_err(|source| { FolderErrorKind::Io { @@ -327,15 +404,45 @@ fn write_if_different(path: &Path, contents: &str) -> Result<(), FolderError> { atomic_write(path, contents) } +pub(crate) fn write_if_absent(path: &Path, contents: &[u8]) -> Result<(), FolderError> { + match path.try_exists() { + Ok(true) => Ok(()), + Ok(false) => atomic_write_bytes(path, contents), + Err(source) => Err(FolderErrorKind::Io { + path: path.to_path_buf(), + source, + } + .wrap()), + } +} + fn atomic_write(path: &Path, contents: &str) -> Result<(), FolderError> { atomic_write_bytes(path, contents.as_bytes()) } +#[derive(Copy, Clone)] +enum Durability { + Fsync, + Fast, +} + pub(crate) fn atomic_write_bytes(path: &Path, contents: &[u8]) -> Result<(), FolderError> { + atomic_write_with(path, contents, Durability::Fsync) +} + +pub(crate) fn atomic_write_cache(path: &Path, contents: &[u8]) -> Result<(), FolderError> { + atomic_write_with(path, contents, Durability::Fast) +} + +fn atomic_write_with( + path: &Path, + contents: &[u8], + durability: Durability, +) -> Result<(), FolderError> { let parent = path.parent().unwrap_or_else(|| Path::new(".")); ensure_dir(parent)?; let tmp = tmp_sibling(path); - write_and_sync(&tmp, contents)?; + write_tmp(&tmp, contents, durability)?; fs::rename(&tmp, path).map_err(|source| { let _ = fs::remove_file(&tmp); FolderErrorKind::Io { @@ -344,10 +451,13 @@ pub(crate) fn atomic_write_bytes(path: &Path, contents: &[u8]) -> Result<(), Fol } .wrap() })?; - sync_dir(parent) + match durability { + Durability::Fsync => sync_dir(parent), + Durability::Fast => Ok(()), + } } -fn write_and_sync(path: &Path, contents: &[u8]) -> Result<(), FolderError> { +fn write_tmp(path: &Path, contents: &[u8], durability: Durability) -> Result<(), FolderError> { let mut file = fs::File::create(path).map_err(|source| { FolderErrorKind::Io { path: path.to_path_buf(), @@ -362,13 +472,16 @@ fn write_and_sync(path: &Path, contents: &[u8]) -> Result<(), FolderError> { } .wrap() })?; - file.sync_all().map_err(|source| { - FolderErrorKind::Io { - path: path.to_path_buf(), - source, - } - .wrap() - }) + match durability { + Durability::Fsync => file.sync_all().map_err(|source| { + FolderErrorKind::Io { + path: path.to_path_buf(), + source, + } + .wrap() + }), + Durability::Fast => Ok(()), + } } pub(crate) fn read_bytes(path: &Path) -> Result, FolderError> { @@ -442,7 +555,7 @@ fn from_ron(path: &Path, text: &str) -> Result) -> Result<(), FolderError> { +fn remove_stale_files(dir: &Path, live_names: &BTreeSet) -> Result<(), FolderError> { let entries = match fs::read_dir(dir) { Ok(iter) => iter, Err(ref source) if source.kind() == io::ErrorKind::NotFound => return Ok(()), @@ -454,7 +567,6 @@ fn remove_stale_sketches(dir: &Path, live: &BTreeSet) -> Result<(), Fo .into()); } }; - let live_names: BTreeSet = live.iter().copied().map(sketch_filename).collect(); let modified = entries.into_iter().try_fold(false, |modified, entry| { let entry = entry.map_err(|source| { FolderErrorKind::Io { @@ -480,3 +592,83 @@ fn remove_stale_sketches(dir: &Path, live: &BTreeSet) -> Result<(), Fo } Ok(()) } + +#[cfg(test)] +mod tree_sourced_save { + use super::{DocumentFolder, ensure_dir, load, save}; + use crate::document::{Document, DocumentHeader}; + use bone_kernel::{ + ExtrudeDirection, ExtrudeEndCondition, ExtrudeFeature, ExtrudeSense, MergeResult, + }; + use bone_types::{DocumentId, ExtrudeId, Length, PositiveLength, SketchId, millimeter}; + use slotmap::{Key, KeyData}; + use std::collections::BTreeMap; + + fn extrude_id(idx: u32) -> ExtrudeId { + ExtrudeId::from(KeyData::from_ffi((1u64 << 32) | u64::from(idx))) + } + + fn document_id(idx: u32) -> DocumentId { + DocumentId::from(KeyData::from_ffi((1u64 << 32) | u64::from(idx))) + } + + fn blind(sketch: SketchId) -> ExtrudeFeature { + let Ok(depth) = PositiveLength::new(Length::new::(10.0)) else { + panic!("positive depth"); + }; + ExtrudeFeature { + sketch, + direction: ExtrudeDirection::Normal { + sense: ExtrudeSense::Forward, + }, + end_condition: ExtrudeEndCondition::Blind { depth }, + draft: None, + thin_wall: None, + merge_result: MergeResult::Merge, + } + } + + #[test] + fn extrude_in_map_without_tree_node_is_not_persisted_and_stale_file_is_reaped() { + let Ok(dir) = tempfile::tempdir() else { + panic!("tempdir"); + }; + let folder = DocumentFolder::new(dir.path().join("orphan.bone")); + + let mut header = DocumentHeader::new(document_id(1), "orphan".to_owned()); + let orphan = extrude_id(1); + header.extrudes.insert(orphan, blind(SketchId::null())); + assert!( + header.feature_tree.feature_of_extrude(orphan).is_none(), + "the orphan starts with no feature-tree node" + ); + let doc = Document::from_parts(header, BTreeMap::new()); + + let Ok(()) = save(&doc, &folder) else { + panic!("save"); + }; + assert!( + !folder.extrude_path(orphan).exists(), + "an extrude absent from the feature tree is not part of the document and is not written" + ); + + let Ok(()) = ensure_dir(&folder.extrudes_dir()) else { + panic!("extrudes dir"); + }; + let Ok(()) = std::fs::write(folder.extrude_path(orphan), "ExtrudeFile()") else { + panic!("plant stale file"); + }; + let Ok(()) = save(&doc, &folder) else { + panic!("resave"); + }; + assert!( + !folder.extrude_path(orphan).exists(), + "a stale extrude file with no tree node is reaped even while the map still lists it" + ); + + let Ok(loaded) = load(&folder) else { + panic!("load"); + }; + assert!(!loaded.header().extrudes.contains_key(&orphan)); + } +} diff --git a/crates/bone-document/src/io/mod.rs b/crates/bone-document/src/io/mod.rs index 8251e3a..db39166 100644 --- a/crates/bone-document/src/io/mod.rs +++ b/crates/bone-document/src/io/mod.rs @@ -3,9 +3,11 @@ pub mod folder; pub mod labels; pub mod ron_io; pub mod solid; +pub mod tess; pub use blob::{BlobHash, BlobKind}; pub use folder::{DocumentFolder, FolderError, FolderErrorKind, load, save}; pub use labels::LabelSidecar; pub use ron_io::{RonError, from_str, to_string}; pub use solid::{read_solid, write_solid}; +pub use tess::{read_tessellation, write_tessellation}; diff --git a/crates/bone-document/src/lib.rs b/crates/bone-document/src/lib.rs index 1a9bab0..ea92415 100644 --- a/crates/bone-document/src/lib.rs +++ b/crates/bone-document/src/lib.rs @@ -6,9 +6,9 @@ pub mod sketch; pub mod undo; pub use document::{ - Document, DocumentHeader, DocumentParameters, FeatureEdge, FeatureNode, FeatureTree, - PrincipalPlane, RenameSketchError, SketchFile, SketchRegistry, SketchRegistryEntry, - UnitsPreference, sketch_filename, + Document, DocumentHeader, DocumentParameters, ExtrudeFile, FeatureEdge, FeatureNode, + FeatureTree, PrincipalPlane, RenameSketchError, SketchFile, SketchRegistry, + SketchRegistryEntry, UnitsPreference, extrude_filename, sketch_filename, }; pub use evaluator::{ EvaluatedExtrude, EvaluatedSketch, ExtrudeError, FeatureCache, evaluate_extrude, @@ -16,7 +16,8 @@ pub use evaluator::{ }; pub use io::{ BlobHash, BlobKind, DocumentFolder, FolderError, FolderErrorKind, LabelSidecar, RonError, - from_str, load, read_solid, save, to_string, write_solid, + from_str, load, read_solid, read_tessellation, save, to_string, write_solid, + write_tessellation, }; pub use sketch::{ ArcData, CircleData, DimensionKind, DimensionRefs, DimensionValue, DimensionValueMismatch,