Another project
0

Configure Feed

Select the types of activity you want to include in your feed.

feat(document): perextrude files, schema 1.1

Lewis: May this revision serve well! <lu5a@proton.me>

author
Lewis
date (Jun 5, 2026, 3:08 PM +0300) commit dd584dcc parent 4c963664 change-id uswrxxux
+296 -53
+28 -3
crates/bone-document/src/document/mod.rs
··· 131 131 pub parameters: DocumentParameters, 132 132 pub feature_tree: FeatureTree, 133 133 pub sketches: SketchRegistry, 134 - #[serde(default, skip_serializing_if = "BTreeMap::is_empty")] 134 + #[serde(skip)] 135 135 pub extrudes: BTreeMap<ExtrudeId, ExtrudeFeature>, 136 136 } 137 137 ··· 169 169 Self { 170 170 schema: SchemaHeader::bone_document(), 171 171 sketch, 172 + } 173 + } 174 + } 175 + 176 + #[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] 177 + #[serde(deny_unknown_fields)] 178 + pub struct ExtrudeFile { 179 + pub schema: SchemaHeader, 180 + pub feature: ExtrudeFeature, 181 + } 182 + 183 + impl ExtrudeFile { 184 + #[must_use] 185 + pub fn new(feature: ExtrudeFeature) -> Self { 186 + Self { 187 + schema: SchemaHeader::bone_document(), 188 + feature, 172 189 } 173 190 } 174 191 } ··· 344 361 } 345 362 } 346 363 364 + fn id_filename<K: slotmap::Key>(id: K) -> String { 365 + format!("{:016x}.ron", id.data().as_ffi()) 366 + } 367 + 347 368 #[must_use] 348 369 pub fn sketch_filename(id: SketchId) -> String { 349 - use slotmap::Key; 350 - format!("{:016x}.ron", id.data().as_ffi()) 370 + id_filename(id) 371 + } 372 + 373 + #[must_use] 374 + pub fn extrude_filename(id: ExtrudeId) -> String { 375 + id_filename(id) 351 376 } 352 377 353 378 #[cfg(test)]
+23
crates/bone-document/src/io/blob.rs
··· 14 14 15 15 #[must_use] 16 16 pub fn of_pair(first: &[u8], second: &[u8]) -> Self { 17 + let Ok(first_len) = u64::try_from(first.len()) else { 18 + unreachable!("slice length exceeds u64 only above 64-bit platforms"); 19 + }; 17 20 let mut hasher = blake3::Hasher::new(); 21 + hasher.update(&first_len.to_le_bytes()); 18 22 hasher.update(first); 19 23 hasher.update(second); 20 24 Self(hasher.finalize()) ··· 113 117 assert_eq!( 114 118 hash.full_hex(), 115 119 "af1349b9f5f9a1a6a0404dea36dcc9499bcb25c9adc112b7cc9a93cae41f3262" 120 + ); 121 + } 122 + 123 + #[test] 124 + fn of_pair_frames_the_boundary_between_parts() { 125 + assert_ne!( 126 + BlobHash::of_pair(b"AB", b"C"), 127 + BlobHash::of_pair(b"A", b"BC"), 128 + "a different split of the same concatenation must not collide" 129 + ); 130 + assert_ne!( 131 + BlobHash::of_pair(b"AB", b"C"), 132 + BlobHash::of(b"ABC"), 133 + "framed pair must not collide with the bare concatenation" 134 + ); 135 + assert_ne!( 136 + BlobHash::of_pair(b"", b"X"), 137 + BlobHash::of_pair(b"X", b""), 138 + "an empty leading part is distinguishable from an empty trailing part" 116 139 ); 117 140 } 118 141
+238 -46
crates/bone-document/src/io/folder.rs
··· 4 4 use std::sync::atomic::{AtomicU64, Ordering}; 5 5 use std::{fs, io}; 6 6 7 - use bone_types::{ExtrudeId, SchemaHeader, SchemaVersion, SketchId}; 7 + use bone_kernel::ExtrudeFeature; 8 + use bone_types::{ 9 + AngleTolerance, ChordHeightTolerance, ExtrudeId, SchemaHeader, SchemaVersion, SketchId, 10 + }; 8 11 9 - use crate::document::{Document, DocumentHeader, FeatureNode, SketchFile, sketch_filename}; 12 + use crate::document::{ 13 + Document, DocumentHeader, ExtrudeFile, FeatureNode, SketchFile, extrude_filename, 14 + sketch_filename, 15 + }; 10 16 use crate::io::blob::{BlobHash, BlobKind}; 11 17 use crate::io::ron_io::{RonError, from_str, to_string}; 12 18 use crate::sketch::SketchEditError; 13 19 14 20 pub const DOCUMENT_FILE: &str = "document.ron"; 15 21 pub const SKETCHES_DIR: &str = "sketches"; 22 + pub const EXTRUDES_DIR: &str = "extrudes"; 16 23 pub const BLOBS_DIR: &str = "blobs"; 17 24 pub const CACHES_DIR: &str = "caches"; 25 + pub const TESSELLATIONS_DIR: &str = "tessellations"; 18 26 19 27 const ROOT_GITIGNORE: &str = "caches/\n"; 20 28 const ROOT_GITATTRIBUTES: &str = "* text=auto eol=lf\n*.brep text eol=lf\n*.labels text eol=lf\n"; ··· 90 98 DanglingTreeSketch { id: SketchId }, 91 99 #[error("registry has sketch {id:?} absent from feature tree")] 92 100 OrphanRegistered { id: SketchId }, 93 - #[error("feature tree references extrude {id:?} not in extrudes")] 94 - DanglingTreeExtrude { id: ExtrudeId }, 95 - #[error("stored extrude {id:?} absent from feature tree")] 96 - OrphanExtrude { id: ExtrudeId }, 101 + #[error("feature tree references extrude {id:?} with no file on disk")] 102 + MissingExtrudeFile { id: ExtrudeId }, 97 103 #[error("stored extrude {extrude:?} references sketch {sketch:?} absent from the document")] 98 104 DanglingExtrudeSketch { 99 105 extrude: ExtrudeId, ··· 142 148 } 143 149 144 150 #[must_use] 151 + pub fn extrudes_dir(&self) -> PathBuf { 152 + self.path.join(EXTRUDES_DIR) 153 + } 154 + 155 + #[must_use] 145 156 pub fn blobs_dir(&self) -> PathBuf { 146 157 self.path.join(BLOBS_DIR) 147 158 } ··· 157 168 } 158 169 159 170 #[must_use] 171 + pub fn extrude_path(&self, id: ExtrudeId) -> PathBuf { 172 + self.extrudes_dir().join(extrude_filename(id)) 173 + } 174 + 175 + #[must_use] 160 176 pub fn blob_path(&self, hash: BlobHash, kind: BlobKind) -> PathBuf { 161 177 self.blobs_dir().join(hash.relative_path(kind)) 162 178 } 179 + 180 + #[must_use] 181 + pub fn tessellation_path( 182 + &self, 183 + hash: BlobHash, 184 + chord: ChordHeightTolerance, 185 + angle: AngleTolerance, 186 + ) -> PathBuf { 187 + self.caches_dir().join(TESSELLATIONS_DIR).join(format!( 188 + "{}.{}.{}", 189 + hash.truncated_128_hex(), 190 + tessellation_tier_hex(chord, angle), 191 + BlobKind::TESS.as_str() 192 + )) 193 + } 194 + } 195 + 196 + fn tessellation_tier_hex(chord: ChordHeightTolerance, angle: AngleTolerance) -> String { 197 + format!( 198 + "{:016x}{:016x}", 199 + chord.millimeters().to_bits(), 200 + angle.radians().to_bits() 201 + ) 163 202 } 164 203 165 204 pub fn save(document: &Document, folder: &DocumentFolder) -> Result<(), FolderError> { 166 - ensure_dir(folder.path())?; 167 - ensure_dir(&folder.sketches_dir())?; 168 - ensure_dir(&folder.blobs_dir())?; 169 - ensure_dir(&folder.caches_dir())?; 170 - 171 - write_if_different(&folder.path().join(".gitignore"), ROOT_GITIGNORE)?; 172 - write_if_different(&folder.path().join(".gitattributes"), ROOT_GITATTRIBUTES)?; 173 - write_if_different(&folder.caches_dir().join("CACHEDIR.TAG"), CACHEDIR_TAG)?; 174 - write_if_different(&folder.caches_dir().join(".gitignore"), CACHES_GITIGNORE)?; 205 + ensure_scaffold(folder)?; 175 206 176 207 document 177 208 .sketches() ··· 181 212 write_if_different(&folder.sketch_path(id), &ron) 182 213 })?; 183 214 215 + let tree_extrudes = tree_extrude_ids(document.header()); 216 + document 217 + .header() 218 + .extrudes 219 + .iter() 220 + .filter(|(id, _)| tree_extrudes.contains(*id)) 221 + .try_for_each(|(id, feature)| -> Result<(), FolderError> { 222 + let path = folder.extrude_path(*id); 223 + let ron = to_ron(&path, &ExtrudeFile::new(*feature))?; 224 + write_if_different(&path, &ron) 225 + })?; 226 + 184 227 let document_ron = to_ron(&folder.document_file(), document.header())?; 185 228 write_if_different(&folder.document_file(), &document_ron)?; 186 229 187 - let registry_ids: BTreeSet<SketchId> = document.registry().order().iter().copied().collect(); 188 - remove_stale_sketches(&folder.sketches_dir(), &registry_ids)?; 230 + let live_sketches = document 231 + .registry() 232 + .order() 233 + .iter() 234 + .copied() 235 + .map(sketch_filename) 236 + .collect(); 237 + remove_stale_files(&folder.sketches_dir(), &live_sketches)?; 238 + let live_extrudes = tree_extrudes.iter().copied().map(extrude_filename).collect(); 239 + remove_stale_files(&folder.extrudes_dir(), &live_extrudes)?; 189 240 190 241 Ok(()) 191 242 } 192 243 244 + fn tree_extrude_ids(header: &DocumentHeader) -> BTreeSet<ExtrudeId> { 245 + header 246 + .feature_tree 247 + .iter() 248 + .filter_map(|(_, node)| match node { 249 + FeatureNode::Extrude(id) => Some(id), 250 + FeatureNode::Origin | FeatureNode::PrincipalPlane(_) | FeatureNode::Sketch(_) => None, 251 + }) 252 + .collect() 253 + } 254 + 193 255 pub fn load(folder: &DocumentFolder) -> Result<Document, FolderError> { 194 256 let header_path = folder.document_file(); 195 257 let header_text = read_to_string(&header_path)?; 196 - let header: DocumentHeader = from_ron(&header_path, &header_text)?; 258 + let mut header: DocumentHeader = from_ron(&header_path, &header_text)?; 197 259 check_schema(&header.schema)?; 260 + let extrudes = read_extrudes(folder, &header)?; 261 + header.extrudes = extrudes; 198 262 validate_header(&header)?; 199 263 200 264 let sketches = ··· 229 293 Ok(Document::from_parts(header, sketches)) 230 294 } 231 295 296 + fn read_extrudes( 297 + folder: &DocumentFolder, 298 + header: &DocumentHeader, 299 + ) -> Result<BTreeMap<ExtrudeId, ExtrudeFeature>, FolderError> { 300 + tree_extrude_ids(header) 301 + .into_iter() 302 + .try_fold(BTreeMap::new(), |mut acc, id| { 303 + let path = folder.extrude_path(id); 304 + let text = read_to_string(&path).map_err(|e| match e.into_kind() { 305 + FolderErrorKind::Io { source, .. } if source.kind() == io::ErrorKind::NotFound => { 306 + FolderErrorKind::MissingExtrudeFile { id }.wrap() 307 + } 308 + other => other.wrap(), 309 + })?; 310 + let file: ExtrudeFile = from_ron(&path, &text)?; 311 + check_schema(&file.schema)?; 312 + acc.insert(id, file.feature); 313 + Ok::<_, FolderError>(acc) 314 + }) 315 + } 316 + 232 317 fn validate_header(header: &DocumentHeader) -> Result<(), FolderError> { 233 318 let tree = &header.feature_tree; 234 - 235 - let tree_extrudes: BTreeSet<ExtrudeId> = tree 236 - .iter() 237 - .filter_map(|(_, node)| match node { 238 - FeatureNode::Extrude(id) => Some(id), 239 - FeatureNode::Origin | FeatureNode::PrincipalPlane(_) | FeatureNode::Sketch(_) => None, 240 - }) 241 - .collect(); 242 - let stored_extrudes: BTreeSet<ExtrudeId> = header.extrudes.keys().copied().collect(); 243 - if let Some(&id) = tree_extrudes.difference(&stored_extrudes).next() { 244 - return Err(FolderErrorKind::DanglingTreeExtrude { id }.wrap()); 245 - } 246 - if let Some(&id) = stored_extrudes.difference(&tree_extrudes).next() { 247 - return Err(FolderErrorKind::OrphanExtrude { id }.wrap()); 248 - } 249 319 250 320 let registered: BTreeSet<SketchId> = header.sketches.order().iter().copied().collect(); 251 321 let tree_sketches: BTreeSet<SketchId> = tree ··· 302 372 name = %schema.name, 303 373 found = %schema.version, 304 374 supported = %supported, 305 - "accepting newer minor schema version; unknown fields will be rejected by deny_unknown_fields" 375 + "accepting a newer minor schema version than this build writes" 306 376 ); 307 377 } 308 378 Ok(()) 309 379 } 310 380 381 + pub(crate) fn ensure_scaffold(folder: &DocumentFolder) -> Result<(), FolderError> { 382 + write_if_different(&folder.path().join(".gitignore"), ROOT_GITIGNORE)?; 383 + write_if_different(&folder.path().join(".gitattributes"), ROOT_GITATTRIBUTES)?; 384 + write_if_different(&folder.caches_dir().join("CACHEDIR.TAG"), CACHEDIR_TAG)?; 385 + write_if_different(&folder.caches_dir().join(".gitignore"), CACHES_GITIGNORE) 386 + } 387 + 311 388 pub(crate) fn ensure_dir(path: &Path) -> Result<(), FolderError> { 312 389 fs::create_dir_all(path).map_err(|source| { 313 390 FolderErrorKind::Io { ··· 327 404 atomic_write(path, contents) 328 405 } 329 406 407 + pub(crate) fn write_if_absent(path: &Path, contents: &[u8]) -> Result<(), FolderError> { 408 + match path.try_exists() { 409 + Ok(true) => Ok(()), 410 + Ok(false) => atomic_write_bytes(path, contents), 411 + Err(source) => Err(FolderErrorKind::Io { 412 + path: path.to_path_buf(), 413 + source, 414 + } 415 + .wrap()), 416 + } 417 + } 418 + 330 419 fn atomic_write(path: &Path, contents: &str) -> Result<(), FolderError> { 331 420 atomic_write_bytes(path, contents.as_bytes()) 332 421 } 333 422 423 + #[derive(Copy, Clone)] 424 + enum Durability { 425 + Fsync, 426 + Fast, 427 + } 428 + 334 429 pub(crate) fn atomic_write_bytes(path: &Path, contents: &[u8]) -> Result<(), FolderError> { 430 + atomic_write_with(path, contents, Durability::Fsync) 431 + } 432 + 433 + pub(crate) fn atomic_write_cache(path: &Path, contents: &[u8]) -> Result<(), FolderError> { 434 + atomic_write_with(path, contents, Durability::Fast) 435 + } 436 + 437 + fn atomic_write_with( 438 + path: &Path, 439 + contents: &[u8], 440 + durability: Durability, 441 + ) -> Result<(), FolderError> { 335 442 let parent = path.parent().unwrap_or_else(|| Path::new(".")); 336 443 ensure_dir(parent)?; 337 444 let tmp = tmp_sibling(path); 338 - write_and_sync(&tmp, contents)?; 445 + write_tmp(&tmp, contents, durability)?; 339 446 fs::rename(&tmp, path).map_err(|source| { 340 447 let _ = fs::remove_file(&tmp); 341 448 FolderErrorKind::Io { ··· 344 451 } 345 452 .wrap() 346 453 })?; 347 - sync_dir(parent) 454 + match durability { 455 + Durability::Fsync => sync_dir(parent), 456 + Durability::Fast => Ok(()), 457 + } 348 458 } 349 459 350 - fn write_and_sync(path: &Path, contents: &[u8]) -> Result<(), FolderError> { 460 + fn write_tmp(path: &Path, contents: &[u8], durability: Durability) -> Result<(), FolderError> { 351 461 let mut file = fs::File::create(path).map_err(|source| { 352 462 FolderErrorKind::Io { 353 463 path: path.to_path_buf(), ··· 362 472 } 363 473 .wrap() 364 474 })?; 365 - file.sync_all().map_err(|source| { 366 - FolderErrorKind::Io { 367 - path: path.to_path_buf(), 368 - source, 369 - } 370 - .wrap() 371 - }) 475 + match durability { 476 + Durability::Fsync => file.sync_all().map_err(|source| { 477 + FolderErrorKind::Io { 478 + path: path.to_path_buf(), 479 + source, 480 + } 481 + .wrap() 482 + }), 483 + Durability::Fast => Ok(()), 484 + } 372 485 } 373 486 374 487 pub(crate) fn read_bytes(path: &Path) -> Result<Vec<u8>, FolderError> { ··· 442 555 }) 443 556 } 444 557 445 - fn remove_stale_sketches(dir: &Path, live: &BTreeSet<SketchId>) -> Result<(), FolderError> { 558 + fn remove_stale_files(dir: &Path, live_names: &BTreeSet<String>) -> Result<(), FolderError> { 446 559 let entries = match fs::read_dir(dir) { 447 560 Ok(iter) => iter, 448 561 Err(ref source) if source.kind() == io::ErrorKind::NotFound => return Ok(()), ··· 454 567 .into()); 455 568 } 456 569 }; 457 - let live_names: BTreeSet<String> = live.iter().copied().map(sketch_filename).collect(); 458 570 let modified = entries.into_iter().try_fold(false, |modified, entry| { 459 571 let entry = entry.map_err(|source| { 460 572 FolderErrorKind::Io { ··· 480 592 } 481 593 Ok(()) 482 594 } 595 + 596 + #[cfg(test)] 597 + mod tree_sourced_save { 598 + use super::{DocumentFolder, ensure_dir, load, save}; 599 + use crate::document::{Document, DocumentHeader}; 600 + use bone_kernel::{ 601 + ExtrudeDirection, ExtrudeEndCondition, ExtrudeFeature, ExtrudeSense, MergeResult, 602 + }; 603 + use bone_types::{DocumentId, ExtrudeId, Length, PositiveLength, SketchId, millimeter}; 604 + use slotmap::{Key, KeyData}; 605 + use std::collections::BTreeMap; 606 + 607 + fn extrude_id(idx: u32) -> ExtrudeId { 608 + ExtrudeId::from(KeyData::from_ffi((1u64 << 32) | u64::from(idx))) 609 + } 610 + 611 + fn document_id(idx: u32) -> DocumentId { 612 + DocumentId::from(KeyData::from_ffi((1u64 << 32) | u64::from(idx))) 613 + } 614 + 615 + fn blind(sketch: SketchId) -> ExtrudeFeature { 616 + let Ok(depth) = PositiveLength::new(Length::new::<millimeter>(10.0)) else { 617 + panic!("positive depth"); 618 + }; 619 + ExtrudeFeature { 620 + sketch, 621 + direction: ExtrudeDirection::Normal { 622 + sense: ExtrudeSense::Forward, 623 + }, 624 + end_condition: ExtrudeEndCondition::Blind { depth }, 625 + draft: None, 626 + thin_wall: None, 627 + merge_result: MergeResult::Merge, 628 + } 629 + } 630 + 631 + #[test] 632 + fn extrude_in_map_without_tree_node_is_not_persisted_and_stale_file_is_reaped() { 633 + let Ok(dir) = tempfile::tempdir() else { 634 + panic!("tempdir"); 635 + }; 636 + let folder = DocumentFolder::new(dir.path().join("orphan.bone")); 637 + 638 + let mut header = DocumentHeader::new(document_id(1), "orphan".to_owned()); 639 + let orphan = extrude_id(1); 640 + header.extrudes.insert(orphan, blind(SketchId::null())); 641 + assert!( 642 + header.feature_tree.feature_of_extrude(orphan).is_none(), 643 + "the orphan starts with no feature-tree node" 644 + ); 645 + let doc = Document::from_parts(header, BTreeMap::new()); 646 + 647 + let Ok(()) = save(&doc, &folder) else { 648 + panic!("save"); 649 + }; 650 + assert!( 651 + !folder.extrude_path(orphan).exists(), 652 + "an extrude absent from the feature tree is not part of the document and is not written" 653 + ); 654 + 655 + let Ok(()) = ensure_dir(&folder.extrudes_dir()) else { 656 + panic!("extrudes dir"); 657 + }; 658 + let Ok(()) = std::fs::write(folder.extrude_path(orphan), "ExtrudeFile()") else { 659 + panic!("plant stale file"); 660 + }; 661 + let Ok(()) = save(&doc, &folder) else { 662 + panic!("resave"); 663 + }; 664 + assert!( 665 + !folder.extrude_path(orphan).exists(), 666 + "a stale extrude file with no tree node is reaped even while the map still lists it" 667 + ); 668 + 669 + let Ok(loaded) = load(&folder) else { 670 + panic!("load"); 671 + }; 672 + assert!(!loaded.header().extrudes.contains_key(&orphan)); 673 + } 674 + }
+2
crates/bone-document/src/io/mod.rs
··· 3 3 pub mod labels; 4 4 pub mod ron_io; 5 5 pub mod solid; 6 + pub mod tess; 6 7 7 8 pub use blob::{BlobHash, BlobKind}; 8 9 pub use folder::{DocumentFolder, FolderError, FolderErrorKind, load, save}; 9 10 pub use labels::LabelSidecar; 10 11 pub use ron_io::{RonError, from_str, to_string}; 11 12 pub use solid::{read_solid, write_solid}; 13 + pub use tess::{read_tessellation, write_tessellation};
+5 -4
crates/bone-document/src/lib.rs
··· 6 6 pub mod undo; 7 7 8 8 pub use document::{ 9 - Document, DocumentHeader, DocumentParameters, FeatureEdge, FeatureNode, FeatureTree, 10 - PrincipalPlane, RenameSketchError, SketchFile, SketchRegistry, SketchRegistryEntry, 11 - UnitsPreference, sketch_filename, 9 + Document, DocumentHeader, DocumentParameters, ExtrudeFile, FeatureEdge, FeatureNode, 10 + FeatureTree, PrincipalPlane, RenameSketchError, SketchFile, SketchRegistry, 11 + SketchRegistryEntry, UnitsPreference, extrude_filename, sketch_filename, 12 12 }; 13 13 pub use evaluator::{ 14 14 EvaluatedExtrude, EvaluatedSketch, ExtrudeError, FeatureCache, evaluate_extrude, ··· 16 16 }; 17 17 pub use io::{ 18 18 BlobHash, BlobKind, DocumentFolder, FolderError, FolderErrorKind, LabelSidecar, RonError, 19 - from_str, load, read_solid, save, to_string, write_solid, 19 + from_str, load, read_solid, read_tessellation, save, to_string, write_solid, 20 + write_tessellation, 20 21 }; 21 22 pub use sketch::{ 22 23 ArcData, CircleData, DimensionKind, DimensionRefs, DimensionValue, DimensionValueMismatch,