Another project
0

Configure Feed

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

feat(document): CID solid blobs, tessellation cache

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

author
Lewis
date (Jun 5, 2026, 3:08 PM +0300) commit 071cac58 parent dd584dcc change-id zyqlvzvp
+387 -3
+58 -3
crates/bone-document/src/io/solid.rs
··· 2 2 3 3 use super::blob::{BlobHash, BlobKind}; 4 4 use super::folder::{ 5 - DocumentFolder, FolderError, FolderErrorKind, atomic_write_bytes, read_bytes, read_to_string, 5 + DocumentFolder, FolderError, FolderErrorKind, ensure_scaffold, read_bytes, read_to_string, 6 + write_if_absent, 6 7 }; 7 8 use super::labels::LabelSidecar; 8 9 9 10 pub fn write_solid(folder: &DocumentFolder, solid: &BrepSolid) -> Result<BlobHash, FolderError> { 11 + ensure_scaffold(folder)?; 10 12 let blob = solid.to_blob().map_err(|source| { 11 13 FolderError::from(FolderErrorKind::Blob { 12 14 path: folder.blobs_dir(), ··· 20 22 }) 21 23 })?; 22 24 let hash = BlobHash::of_pair(&blob, sidecar_ron.as_bytes()); 23 - atomic_write_bytes(&folder.blob_path(hash, BlobKind::BREP), &blob)?; 24 - atomic_write_bytes( 25 + write_if_absent( 25 26 &folder.blob_path(hash, BlobKind::LABELS), 26 27 sidecar_ron.as_bytes(), 27 28 )?; 29 + write_if_absent(&folder.blob_path(hash, BlobKind::BREP), &blob)?; 28 30 Ok(hash) 29 31 } 30 32 ··· 266 268 }; 267 269 assert_eq!(face_labels(&solid_a), face_labels(&restored_a)); 268 270 assert_eq!(face_labels(&solid_b), face_labels(&restored_b)); 271 + } 272 + 273 + #[cfg(unix)] 274 + #[test] 275 + fn write_solid_skips_rewrite_when_blob_already_present() { 276 + use std::os::unix::fs::MetadataExt; 277 + let mut entities: SlotMap<SketchEntityId, ()> = SlotMap::with_key(); 278 + let solid = build(vec![rectangle(&mut entities)], 4.0); 279 + let (_dir, folder) = temp_folder(); 280 + let Ok(hash) = write_solid(&folder, &solid) else { 281 + panic!("first write"); 282 + }; 283 + let brep = folder.blob_path(hash, crate::io::BlobKind::BREP); 284 + let labels = folder.blob_path(hash, crate::io::BlobKind::LABELS); 285 + let Ok(brep_meta) = std::fs::metadata(&brep) else { 286 + panic!("brep metadata"); 287 + }; 288 + let Ok(labels_meta) = std::fs::metadata(&labels) else { 289 + panic!("labels metadata"); 290 + }; 291 + 292 + let Ok(again) = write_solid(&folder, &solid) else { 293 + panic!("second write"); 294 + }; 295 + assert_eq!(hash, again); 296 + let Ok(brep_after) = std::fs::metadata(&brep) else { 297 + panic!("brep metadata after"); 298 + }; 299 + let Ok(labels_after) = std::fs::metadata(&labels) else { 300 + panic!("labels metadata after"); 301 + }; 302 + assert_eq!( 303 + brep_meta.ino(), 304 + brep_after.ino(), 305 + "a present brep blob keeps its inode because the rewrite is skipped" 306 + ); 307 + assert_eq!(labels_meta.ino(), labels_after.ino(), "a present sidecar is not rewritten"); 308 + } 309 + 310 + #[test] 311 + fn write_solid_lays_down_scaffold_before_first_save() { 312 + let mut entities: SlotMap<SketchEntityId, ()> = SlotMap::with_key(); 313 + let solid = build(vec![rectangle(&mut entities)], 3.0); 314 + let (_dir, folder) = temp_folder(); 315 + let Ok(_hash) = write_solid(&folder, &solid) else { 316 + panic!("write solid"); 317 + }; 318 + let Ok(gitattributes) = std::fs::read_to_string(folder.path().join(".gitattributes")) else { 319 + panic!(".gitattributes must exist so .brep/.labels are declared text eol=lf"); 320 + }; 321 + assert!(gitattributes.contains("*.brep text eol=lf")); 322 + assert!(gitattributes.contains("*.labels text eol=lf")); 323 + assert!(folder.caches_dir().join("CACHEDIR.TAG").exists()); 269 324 } 270 325 }
+329
crates/bone-document/src/io/tess.rs
··· 1 + use std::{fs, io}; 2 + 3 + use bone_kernel::SolidMesh; 4 + use bone_types::{AngleTolerance, ChordHeightTolerance}; 5 + 6 + use super::blob::BlobHash; 7 + use super::folder::{ 8 + DocumentFolder, FolderError, FolderErrorKind, atomic_write_cache, ensure_scaffold, 9 + read_to_string, 10 + }; 11 + use super::ron_io; 12 + 13 + pub fn write_tessellation( 14 + folder: &DocumentFolder, 15 + hash: BlobHash, 16 + chord: ChordHeightTolerance, 17 + angle: AngleTolerance, 18 + mesh: &SolidMesh, 19 + ) -> Result<(), FolderError> { 20 + ensure_scaffold(folder)?; 21 + let path = folder.tessellation_path(hash, chord, angle); 22 + let ron = ron_io::to_string(mesh).map_err(|source| { 23 + FolderError::from(FolderErrorKind::Ron { 24 + path: path.clone(), 25 + source, 26 + }) 27 + })?; 28 + atomic_write_cache(&path, ron.as_bytes()) 29 + } 30 + 31 + pub fn read_tessellation( 32 + folder: &DocumentFolder, 33 + hash: BlobHash, 34 + chord: ChordHeightTolerance, 35 + angle: AngleTolerance, 36 + ) -> Result<Option<SolidMesh>, FolderError> { 37 + let path = folder.tessellation_path(hash, chord, angle); 38 + let text = match read_to_string(&path) { 39 + Ok(text) => text, 40 + Err(error) => { 41 + return match error.into_kind() { 42 + FolderErrorKind::Io { source, .. } if source.kind() == io::ErrorKind::NotFound => { 43 + Ok(None) 44 + } 45 + other => Err(FolderError::from(other)), 46 + }; 47 + } 48 + }; 49 + match ron_io::from_str(&text) { 50 + Ok(mesh) => Ok(Some(mesh)), 51 + Err(source) => { 52 + tracing::warn!( 53 + path = %path.display(), 54 + error = %source, 55 + "evicting unreadable tessellation cache entry" 56 + ); 57 + let _ = fs::remove_file(&path); 58 + Ok(None) 59 + } 60 + } 61 + } 62 + 63 + #[cfg(test)] 64 + mod tests { 65 + use super::{read_tessellation, write_tessellation}; 66 + use crate::io::{DocumentFolder, write_solid}; 67 + use bone_kernel::{ 68 + BrepSolid, Circle2, Curve2Kind, ExtrudeDirection, ExtrudeEndCondition, ExtrudeFeature, 69 + ExtrudeProfile, ExtrudeSense, Line2, MergeResult, ProfileEdge, ProfileLoop, SolidMesh, 70 + evaluate_extrude, 71 + }; 72 + use bone_types::{ 73 + AngleTolerance, ChordHeightTolerance, FeatureId, Length, Plane3, Point2, PositiveLength, 74 + SketchEntityId, SketchId, Tolerance, UnitVec3, millimeter, 75 + }; 76 + use slotmap::{Key, SlotMap}; 77 + 78 + const TOL: Tolerance = Tolerance::new(1.0e-9); 79 + 80 + fn xy_plane() -> Plane3 { 81 + let Ok(plane) = Plane3::new( 82 + bone_types::Point3::origin(), 83 + UnitVec3::x_axis(), 84 + UnitVec3::y_axis(), 85 + TOL, 86 + ) else { 87 + panic!("orthonormal axes"); 88 + }; 89 + plane 90 + } 91 + 92 + fn blind(depth_mm: f64) -> ExtrudeFeature { 93 + let Ok(depth) = PositiveLength::new(Length::new::<millimeter>(depth_mm)) else { 94 + panic!("positive depth"); 95 + }; 96 + ExtrudeFeature { 97 + sketch: SketchId::null(), 98 + direction: ExtrudeDirection::Normal { 99 + sense: ExtrudeSense::Forward, 100 + }, 101 + end_condition: ExtrudeEndCondition::Blind { depth }, 102 + draft: None, 103 + thin_wall: None, 104 + merge_result: MergeResult::Merge, 105 + } 106 + } 107 + 108 + fn solid(loop_: ProfileLoop, depth_mm: f64) -> BrepSolid { 109 + let mut features: SlotMap<FeatureId, ()> = SlotMap::with_key(); 110 + let profile = ExtrudeProfile::new(xy_plane(), vec![loop_]); 111 + let Ok(solid) = evaluate_extrude(features.insert(()), &profile, &blind(depth_mm)) else { 112 + panic!("profile extrudes"); 113 + }; 114 + solid 115 + } 116 + 117 + fn cube() -> BrepSolid { 118 + let mut entities: SlotMap<SketchEntityId, ()> = SlotMap::with_key(); 119 + let line = |a: Point2, b: Point2| { 120 + let Ok(segment) = Line2::new(a, b, TOL) else { 121 + panic!("distinct endpoints"); 122 + }; 123 + Curve2Kind::Line(segment) 124 + }; 125 + let corners = [ 126 + Point2::from_mm(0.0, 0.0), 127 + Point2::from_mm(4.0, 0.0), 128 + Point2::from_mm(4.0, 2.0), 129 + Point2::from_mm(0.0, 2.0), 130 + ]; 131 + let edges = (0..4) 132 + .map(|index| { 133 + ProfileEdge::new( 134 + line(corners[index], corners[(index + 1) % 4]), 135 + entities.insert(()), 136 + entities.insert(()), 137 + ) 138 + }) 139 + .collect(); 140 + solid(ProfileLoop::Open(edges), 5.0) 141 + } 142 + 143 + fn cylinder() -> BrepSolid { 144 + let mut entities: SlotMap<SketchEntityId, ()> = SlotMap::with_key(); 145 + let Ok(disk) = Circle2::new(Point2::from_mm(0.0, 0.0), Length::new::<millimeter>(5.0), TOL) 146 + else { 147 + panic!("positive radius"); 148 + }; 149 + solid( 150 + ProfileLoop::Closed { 151 + curve: Curve2Kind::Circle(disk), 152 + curve_entity: entities.insert(()), 153 + }, 154 + 8.0, 155 + ) 156 + } 157 + 158 + fn mesh(solid: &BrepSolid, chord: ChordHeightTolerance, angle: AngleTolerance) -> SolidMesh { 159 + let Ok(mesh) = solid.tessellate(chord, angle) else { 160 + panic!("tessellates"); 161 + }; 162 + mesh 163 + } 164 + 165 + fn temp_folder() -> (tempfile::TempDir, DocumentFolder) { 166 + let Ok(dir) = tempfile::tempdir() else { 167 + panic!("temp dir"); 168 + }; 169 + let folder = DocumentFolder::new(dir.path()); 170 + (dir, folder) 171 + } 172 + 173 + fn round_trips(solid: &BrepSolid) { 174 + let chord = ChordHeightTolerance::from_mm(0.05); 175 + let angle = AngleTolerance::from_radians(0.2); 176 + let original = mesh(solid, chord, angle); 177 + let (_dir, folder) = temp_folder(); 178 + let Ok(hash) = write_solid(&folder, solid) else { 179 + panic!("write solid"); 180 + }; 181 + let Ok(()) = write_tessellation(&folder, hash, chord, angle, &original) else { 182 + panic!("write tessellation"); 183 + }; 184 + let Ok(Some(restored)) = read_tessellation(&folder, hash, chord, angle) else { 185 + panic!("read tessellation"); 186 + }; 187 + assert_eq!(original, restored); 188 + assert_eq!(original.generation(), restored.generation()); 189 + } 190 + 191 + #[test] 192 + fn cube_round_trips_through_cache() { 193 + round_trips(&cube()); 194 + } 195 + 196 + #[test] 197 + fn curved_solid_round_trips_exactly() { 198 + round_trips(&cylinder()); 199 + } 200 + 201 + #[test] 202 + fn tessellation_lands_under_caches() { 203 + let solid = cube(); 204 + let chord = ChordHeightTolerance::from_mm(0.05); 205 + let angle = AngleTolerance::from_radians(0.2); 206 + let (_dir, folder) = temp_folder(); 207 + let Ok(hash) = write_solid(&folder, &solid) else { 208 + panic!("write solid"); 209 + }; 210 + let Ok(()) = write_tessellation(&folder, hash, chord, angle, &mesh(&solid, chord, angle)) 211 + else { 212 + panic!("write tessellation"); 213 + }; 214 + let path = folder.tessellation_path(hash, chord, angle); 215 + assert!(path.exists()); 216 + assert!(path.starts_with(folder.caches_dir())); 217 + assert!( 218 + path.extension() 219 + .is_some_and(|ext| ext.eq_ignore_ascii_case("tess")) 220 + ); 221 + } 222 + 223 + #[test] 224 + fn missing_cache_entry_is_a_miss() { 225 + let solid = cube(); 226 + let chord = ChordHeightTolerance::from_mm(0.05); 227 + let angle = AngleTolerance::from_radians(0.2); 228 + let (_dir, folder) = temp_folder(); 229 + let Ok(hash) = write_solid(&folder, &solid) else { 230 + panic!("write solid"); 231 + }; 232 + let Ok(None) = read_tessellation(&folder, hash, chord, angle) else { 233 + panic!("a cold cache entry must read back as a miss"); 234 + }; 235 + } 236 + 237 + #[test] 238 + fn damaged_cache_entry_is_a_miss_and_is_evicted() { 239 + let solid = cube(); 240 + let chord = ChordHeightTolerance::from_mm(0.05); 241 + let angle = AngleTolerance::from_radians(0.2); 242 + let (_dir, folder) = temp_folder(); 243 + let Ok(hash) = write_solid(&folder, &solid) else { 244 + panic!("write solid"); 245 + }; 246 + let Ok(()) = write_tessellation(&folder, hash, chord, angle, &mesh(&solid, chord, angle)) 247 + else { 248 + panic!("write tessellation"); 249 + }; 250 + let path = folder.tessellation_path(hash, chord, angle); 251 + let Ok(()) = std::fs::write(&path, b"SolidMesh(faces:[FaceMesh(") else { 252 + panic!("truncate the cache entry"); 253 + }; 254 + 255 + let Ok(None) = read_tessellation(&folder, hash, chord, angle) else { 256 + panic!("a reproducible-from-source cache entry that no longer parses must read as a miss"); 257 + }; 258 + assert!( 259 + !path.exists(), 260 + "a damaged cache entry is evicted so the next write replaces it" 261 + ); 262 + } 263 + 264 + #[test] 265 + fn distinct_tiers_coexist() { 266 + let solid = cylinder(); 267 + let coarse_chord = ChordHeightTolerance::from_mm(0.5); 268 + let coarse_angle = AngleTolerance::from_radians(1.0); 269 + let fine_chord = ChordHeightTolerance::from_mm(0.001); 270 + let fine_angle = AngleTolerance::from_radians(0.02); 271 + let coarse = mesh(&solid, coarse_chord, coarse_angle); 272 + let fine = mesh(&solid, fine_chord, fine_angle); 273 + assert_ne!(coarse, fine, "two tiers are genuinely different meshes"); 274 + 275 + let (_dir, folder) = temp_folder(); 276 + let Ok(hash) = write_solid(&folder, &solid) else { 277 + panic!("write solid"); 278 + }; 279 + assert_ne!( 280 + folder.tessellation_path(hash, coarse_chord, coarse_angle), 281 + folder.tessellation_path(hash, fine_chord, fine_angle), 282 + "distinct tiers address distinct files" 283 + ); 284 + 285 + let Ok(()) = write_tessellation(&folder, hash, coarse_chord, coarse_angle, &coarse) else { 286 + panic!("write coarse"); 287 + }; 288 + let Ok(()) = write_tessellation(&folder, hash, fine_chord, fine_angle, &fine) else { 289 + panic!("write fine"); 290 + }; 291 + 292 + let Ok(Some(restored_coarse)) = 293 + read_tessellation(&folder, hash, coarse_chord, coarse_angle) 294 + else { 295 + panic!("read coarse"); 296 + }; 297 + let Ok(Some(restored_fine)) = read_tessellation(&folder, hash, fine_chord, fine_angle) 298 + else { 299 + panic!("read fine"); 300 + }; 301 + assert_eq!(coarse, restored_coarse, "coarse tier survives the fine write"); 302 + assert_eq!(fine, restored_fine); 303 + } 304 + 305 + #[test] 306 + fn write_tessellation_lays_down_cache_scaffold_before_first_save() { 307 + let solid = cube(); 308 + let chord = ChordHeightTolerance::from_mm(0.05); 309 + let angle = AngleTolerance::from_radians(0.2); 310 + let (_dir, folder) = temp_folder(); 311 + let hash = crate::io::BlobHash::of(b"scaffold-probe"); 312 + let Ok(()) = write_tessellation(&folder, hash, chord, angle, &mesh(&solid, chord, angle)) 313 + else { 314 + panic!("write tessellation"); 315 + }; 316 + assert!( 317 + folder.caches_dir().join("CACHEDIR.TAG").exists(), 318 + "CACHEDIR.TAG present so backup tools skip the cache" 319 + ); 320 + assert!( 321 + folder.caches_dir().join(".gitignore").exists(), 322 + "caches/.gitignore present" 323 + ); 324 + assert!( 325 + folder.path().join(".gitignore").exists(), 326 + "root .gitignore present so cache blobs are not committed" 327 + ); 328 + } 329 + }