Another project
0

Configure Feed

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

at main 41 kB View raw
1use bone_document::{ 2 BlobHash, BlobKind, Document, DocumentFolder, DocumentHeader, EditOutcome, Sketch, 3 SketchDimension, SketchEdit, SketchEntity, SketchRegistry, SketchRegistryEntry, SketchRelation, 4 evaluate_extrude, evaluate_sketch, from_str, load, save, to_string, 5}; 6use bone_kernel::{ 7 BrepFace, ExtrudeDirection, ExtrudeEndCondition, ExtrudeFeature, ExtrudeSense, MergeResult, 8}; 9use bone_types::{ 10 Angle, DocumentId, ExtrudeId, FaceFingerprint, FaceLabel, FaceRef, FaceRole, FeatureId, Length, 11 Plane3, Point2, Point3, PositiveLength, RollbackMarker, SketchEntityId, SketchId, 12 SketchPlaneBasis, SuppressionState, Tolerance, UnitVec3, degree, millimeter, 13}; 14use slotmap::{Key, KeyData}; 15use tempfile::{TempDir, tempdir}; 16 17fn plane() -> SketchPlaneBasis { 18 let Ok(basis) = SketchPlaneBasis::new( 19 Point3::origin(), 20 UnitVec3::x_axis(), 21 UnitVec3::y_axis(), 22 Tolerance::new(1e-9), 23 ) else { 24 panic!("xy plane basis is orthogonal"); 25 }; 26 basis 27} 28 29fn mm(v: f64) -> Length { 30 Length::new::<millimeter>(v) 31} 32 33fn deg(v: f64) -> Angle { 34 Angle::new::<degree>(v) 35} 36 37fn ok_dir() -> TempDir { 38 let Ok(dir) = tempdir() else { 39 panic!("tempdir"); 40 }; 41 dir 42} 43 44fn sketch_id(idx: u32) -> SketchId { 45 SketchId::from(KeyData::from_ffi((1u64 << 32) | u64::from(idx))) 46} 47 48fn document_id(idx: u32) -> DocumentId { 49 DocumentId::from(KeyData::from_ffi((1u64 << 32) | u64::from(idx))) 50} 51 52fn extrude_id(idx: u32) -> ExtrudeId { 53 ExtrudeId::from(KeyData::from_ffi((1u64 << 32) | u64::from(idx))) 54} 55 56fn feature_id(idx: u32) -> FeatureId { 57 FeatureId::from(KeyData::from_ffi((1u64 << 32) | u64::from(idx))) 58} 59 60fn blind_extrude(sketch: SketchId) -> ExtrudeFeature { 61 let Ok(depth) = PositiveLength::new(mm(10.0)) else { 62 panic!("positive depth"); 63 }; 64 ExtrudeFeature { 65 sketch, 66 direction: ExtrudeDirection::Normal { 67 sense: ExtrudeSense::Forward, 68 }, 69 end_condition: ExtrudeEndCondition::Blind { depth }, 70 draft: None, 71 thin_wall: None, 72 merge_result: MergeResult::Merge, 73 } 74} 75 76fn separate_extrude(sketch: SketchId) -> ExtrudeFeature { 77 ExtrudeFeature { 78 merge_result: MergeResult::Separate, 79 ..blind_extrude(sketch) 80 } 81} 82 83fn sample_face_ref(feature: FeatureId) -> FaceRef { 84 FaceRef::new( 85 FaceLabel { 86 feature, 87 role: FaceRole::EndCap, 88 }, 89 FaceFingerprint { 90 plane: Plane3::new_unchecked( 91 Point3::from_mm(0.0, 0.0, 10.0), 92 UnitVec3::x_axis(), 93 UnitVec3::y_axis(), 94 ), 95 centroid: Point3::from_mm(0.0, 0.0, 10.0), 96 }, 97 ) 98} 99 100fn rectangle() -> Sketch { 101 let script = [ 102 SketchEdit::AddEntity(SketchEntity::point(Point2::from_mm(0.0, 0.0))), 103 SketchEdit::AddEntity(SketchEntity::point(Point2::from_mm(10.0, 0.0))), 104 SketchEdit::AddEntity(SketchEntity::point(Point2::from_mm(10.0, 5.0))), 105 SketchEdit::AddEntity(SketchEntity::point(Point2::from_mm(0.0, 5.0))), 106 ]; 107 let Ok((s, _)) = Sketch::new(plane()).apply_all(script) else { 108 panic!("rectangle"); 109 }; 110 s 111} 112 113fn assert_save(doc: &Document, folder: &DocumentFolder) { 114 let Ok(()) = save(doc, folder) else { 115 panic!("save clean"); 116 }; 117} 118 119fn assert_load(folder: &DocumentFolder) -> Document { 120 let Ok(doc) = load(folder) else { 121 panic!("load clean"); 122 }; 123 doc 124} 125 126fn read_file(path: &std::path::Path) -> String { 127 let Ok(text) = std::fs::read_to_string(path) else { 128 panic!("read {}", path.display()); 129 }; 130 text 131} 132 133fn write_file(path: &std::path::Path, contents: &str) { 134 let Ok(()) = std::fs::write(path, contents) else { 135 panic!("write {}", path.display()); 136 }; 137} 138 139fn folder_bytes(root: &std::path::Path) -> std::collections::BTreeMap<std::path::PathBuf, Vec<u8>> { 140 let Ok(entries) = std::fs::read_dir(root) else { 141 panic!("read dir {}", root.display()); 142 }; 143 entries 144 .map(|entry| { 145 let Ok(entry) = entry else { 146 panic!("dir entry under {}", root.display()); 147 }; 148 entry.path() 149 }) 150 .flat_map(|path| { 151 if path.is_dir() { 152 folder_bytes(&path).into_iter().collect::<Vec<_>>() 153 } else { 154 let Ok(bytes) = std::fs::read(&path) else { 155 panic!("read {}", path.display()); 156 }; 157 vec![(path, bytes)] 158 } 159 }) 160 .collect() 161} 162 163#[test] 164fn rectangle_roundtrips_through_folder() { 165 let dir = ok_dir(); 166 let folder = DocumentFolder::new(dir.path().join("part.bone")); 167 168 let mut doc = Document::new(document_id(1), "part".to_owned()); 169 doc.insert_sketch(sketch_id(1), "Sketch1".to_owned(), rectangle()); 170 doc.set_parameter("width".to_owned(), 10.0); 171 172 assert_save(&doc, &folder); 173 let loaded = assert_load(&folder); 174 175 assert_eq!(loaded.name(), doc.name()); 176 assert_eq!(loaded.registry().order(), doc.registry().order()); 177 assert_eq!(loaded.feature_tree(), doc.feature_tree()); 178 assert_eq!(loaded.sketches_map(), doc.sketches_map()); 179 assert_eq!(loaded.parameters().get("width"), Some(10.0)); 180} 181 182fn add_entity_returning_id(s: Sketch, entity: SketchEntity) -> (Sketch, SketchEntityId) { 183 let Ok((next, EditOutcome::Entity(id))) = s.apply(SketchEdit::AddEntity(entity)) else { 184 panic!("add entity {entity:?}"); 185 }; 186 (next, id) 187} 188 189fn rich_sketch_with_every_variant() -> Sketch { 190 let s = Sketch::new(plane()); 191 let (s, p0) = add_entity_returning_id(s, SketchEntity::point(Point2::from_mm(0.0, 0.0))); 192 let (s, p1) = add_entity_returning_id(s, SketchEntity::point(Point2::from_mm(10.0, 0.0))); 193 let (s, p2) = add_entity_returning_id(s, SketchEntity::point(Point2::from_mm(5.0, 6.0))); 194 let (s, line) = add_entity_returning_id(s, SketchEntity::line(p0, p1, false)); 195 let (s, line2) = add_entity_returning_id(s, SketchEntity::line(p0, p2, true)); 196 let (s, arc) = add_entity_returning_id(s, SketchEntity::arc(p0, p1, p2, false)); 197 let (s, circle) = add_entity_returning_id(s, SketchEntity::circle(p0, mm(3.0), false)); 198 let relations = [ 199 SketchRelation::Coincident(p0, line), 200 SketchRelation::Horizontal(line), 201 SketchRelation::Vertical(line2), 202 SketchRelation::Parallel(line, line2), 203 SketchRelation::Perpendicular(line, line2), 204 SketchRelation::Tangent(line, circle), 205 SketchRelation::Equal(line, line2), 206 SketchRelation::Concentric(arc, circle), 207 SketchRelation::Midpoint { point: p2, line }, 208 SketchRelation::Fix(p0), 209 ]; 210 let s = relations.into_iter().fold(s, |s, relation| { 211 let Ok((next, _)) = s.apply(SketchEdit::AddRelation(relation)) else { 212 panic!("relation {relation:?}"); 213 }; 214 next 215 }); 216 let dimensions = [ 217 SketchDimension::Linear { 218 a: p0, 219 b: p1, 220 value: mm(10.0), 221 kind: bone_document::DimensionKind::Driving, 222 }, 223 SketchDimension::Radius { 224 target: circle, 225 value: mm(3.0), 226 kind: bone_document::DimensionKind::Driven, 227 }, 228 SketchDimension::Diameter { 229 target: circle, 230 value: mm(6.0), 231 kind: bone_document::DimensionKind::Driving, 232 }, 233 SketchDimension::Angular { 234 a: line, 235 b: line2, 236 value: deg(45.0), 237 kind: bone_document::DimensionKind::Driving, 238 }, 239 ]; 240 dimensions.into_iter().fold(s, |s, dim| { 241 let Ok((next, _)) = s.apply(SketchEdit::AddDimension(dim)) else { 242 panic!("dim {dim:?}"); 243 }; 244 next 245 }) 246} 247 248#[test] 249fn rich_sketch_with_every_variant_roundtrips() { 250 let dir = ok_dir(); 251 let folder = DocumentFolder::new(dir.path().join("rich.bone")); 252 let s = rich_sketch_with_every_variant(); 253 let mut doc = Document::new(document_id(1), "rich".to_owned()); 254 doc.insert_sketch(sketch_id(1), "Rich".to_owned(), s.clone()); 255 256 assert_save(&doc, &folder); 257 let loaded = assert_load(&folder); 258 let Some(loaded_sketch) = loaded.sketch(sketch_id(1)) else { 259 panic!("sketch missing after load"); 260 }; 261 assert_eq!(loaded_sketch, &s); 262} 263 264#[test] 265fn load_refuses_unknown_schema_major() { 266 use bone_types::{SchemaHeader, SchemaVersion}; 267 268 let dir = ok_dir(); 269 let folder = DocumentFolder::new(dir.path().join("future.bone")); 270 let mut doc = Document::new(document_id(1), "future".to_owned()); 271 doc.insert_sketch(sketch_id(1), "Sketch1".to_owned(), rectangle()); 272 assert_save(&doc, &folder); 273 274 let header_path = folder.document_file(); 275 let text = read_file(&header_path); 276 write_file(&header_path, &text.replace("major: 1,", "major: 9999,")); 277 278 let load_result = load(&folder).map_err(bone_document::FolderError::into_kind); 279 let Err(bone_document::FolderErrorKind::UnsupportedMajor { 280 found, supported, .. 281 }) = load_result 282 else { 283 panic!("expected UnsupportedMajor"); 284 }; 285 assert_eq!( 286 found, 287 SchemaVersion::new(9999, SchemaHeader::BONE_DOCUMENT_MINOR) 288 ); 289 assert_eq!( 290 supported, 291 SchemaVersion::new( 292 SchemaHeader::BONE_DOCUMENT_MAJOR, 293 SchemaHeader::BONE_DOCUMENT_MINOR 294 ) 295 ); 296} 297 298#[test] 299fn load_reports_unsupported_major_ahead_of_unknown_fields() { 300 let dir = ok_dir(); 301 let folder = DocumentFolder::new(dir.path().join("future_major.bone")); 302 let mut doc = Document::new(document_id(1), "future".to_owned()); 303 doc.insert_sketch(sketch_id(1), "Sketch1".to_owned(), rectangle()); 304 assert_save(&doc, &folder); 305 306 let header_path = folder.document_file(); 307 let text = read_file(&header_path); 308 let bumped = text.replace("major: 1,", "major: 9999,"); 309 assert_ne!(bumped, text, "schema major pattern missing"); 310 let injected = bumped.replace( 311 "DocumentHeader(\n schema:", 312 "DocumentHeader(\n mystery_field: 7,\n schema:", 313 ); 314 assert_ne!(injected, bumped, "document header anchor missing"); 315 write_file(&header_path, &injected); 316 317 let result = load(&folder).map_err(bone_document::FolderError::into_kind); 318 let Err(bone_document::FolderErrorKind::UnsupportedMajor { found, .. }) = result else { 319 panic!("a newer major must refuse outright, not surface a raw field error: {result:?}"); 320 }; 321 assert_eq!(found.major, 9999); 322} 323 324#[test] 325fn load_refuses_unknown_schema_name() { 326 let dir = ok_dir(); 327 let folder = DocumentFolder::new(dir.path().join("foreign.bone")); 328 let mut doc = Document::new(document_id(1), "foreign".to_owned()); 329 doc.insert_sketch(sketch_id(1), "Sketch1".to_owned(), rectangle()); 330 assert_save(&doc, &folder); 331 332 let header_path = folder.document_file(); 333 let text = read_file(&header_path); 334 write_file( 335 &header_path, 336 &text.replace("\"bone-document\"", "\"kicad-pcb\""), 337 ); 338 339 let load_result = load(&folder).map_err(bone_document::FolderError::into_kind); 340 let Err(bone_document::FolderErrorKind::UnknownSchema { found, .. }) = load_result else { 341 panic!("expected UnknownSchema"); 342 }; 343 assert_eq!(found, "kicad-pcb"); 344} 345 346#[test] 347fn save_ships_vcs_files() { 348 let dir = ok_dir(); 349 let folder = DocumentFolder::new(dir.path().join("vcs.bone")); 350 let doc = Document::new(document_id(1), "vcs".to_owned()); 351 assert_save(&doc, &folder); 352 353 let gitignore = read_file(&folder.path().join(".gitignore")); 354 assert!(gitignore.contains("caches/")); 355 let gitattributes = read_file(&folder.path().join(".gitattributes")); 356 assert!(gitattributes.contains("* text=auto eol=lf")); 357 assert!(gitattributes.contains("*.brep text eol=lf")); 358 assert!(gitattributes.contains("*.labels text eol=lf")); 359 let caches_tag = read_file(&folder.caches_dir().join("CACHEDIR.TAG")); 360 assert!(caches_tag.starts_with("Signature: 8a477f597d28d172789f06886806bc55")); 361 let caches_gitignore = read_file(&folder.caches_dir().join(".gitignore")); 362 assert_eq!(caches_gitignore, "*\n!.gitignore\n!CACHEDIR.TAG\n"); 363} 364 365#[test] 366fn save_is_idempotent_byte_for_byte() { 367 let dir = ok_dir(); 368 let folder = DocumentFolder::new(dir.path().join("idem.bone")); 369 let mut doc = Document::new(document_id(1), "idem".to_owned()); 370 doc.insert_sketch(sketch_id(1), "S".to_owned(), rectangle()); 371 assert_save(&doc, &folder); 372 let Ok(first) = std::fs::read(folder.document_file()) else { 373 panic!("read first"); 374 }; 375 assert_save(&doc, &folder); 376 let Ok(second) = std::fs::read(folder.document_file()) else { 377 panic!("read second"); 378 }; 379 assert_eq!(first, second, "document.ron drifted between saves"); 380} 381 382#[test] 383fn loaded_document_preserves_slotmap_keys() { 384 let dir = ok_dir(); 385 let folder = DocumentFolder::new(dir.path().join("keys.bone")); 386 387 let mut s = Sketch::new(plane()); 388 let Ok((s_next, EditOutcome::Entity(a))) = s.apply(SketchEdit::AddEntity(SketchEntity::point( 389 Point2::from_mm(0.0, 0.0), 390 ))) else { 391 panic!("a"); 392 }; 393 s = s_next; 394 let Ok((s_next, EditOutcome::Entity(b))) = s.apply(SketchEdit::AddEntity(SketchEntity::point( 395 Point2::from_mm(1.0, 0.0), 396 ))) else { 397 panic!("b"); 398 }; 399 s = s_next; 400 let Ok((s_next, _)) = s.apply(SketchEdit::AddEntity(SketchEntity::line(a, b, false))) else { 401 panic!("line"); 402 }; 403 s = s_next; 404 405 let mut doc = Document::new(document_id(1), "keys".to_owned()); 406 let id = sketch_id(1); 407 doc.insert_sketch(id, "S".to_owned(), s.clone()); 408 assert_save(&doc, &folder); 409 410 let loaded = assert_load(&folder); 411 let Some(loaded_sketch) = loaded.sketch(id) else { 412 panic!("sketch"); 413 }; 414 let first_id = loaded_sketch.entity_order()[0]; 415 assert_eq!(first_id.data(), a.data()); 416 let entries: Vec<(SketchId, SketchRegistryEntry)> = loaded 417 .registry() 418 .iter() 419 .map(|(id, e)| (id, SketchRegistryEntry::clone(e))) 420 .collect(); 421 assert_eq!(entries.len(), 1); 422 let (_, entry) = &entries[0]; 423 assert_eq!(entry.label, "S"); 424 assert!( 425 std::path::Path::new(&entry.filename) 426 .extension() 427 .is_some_and(|ext| ext.eq_ignore_ascii_case("ron")) 428 ); 429} 430 431#[test] 432fn folder_reserves_removed_ids_across_roundtrip() { 433 let dir = ok_dir(); 434 let folder = DocumentFolder::new(dir.path().join("hist.bone")); 435 436 let mut doc = Document::new(document_id(1), "doc".to_owned()); 437 doc.insert_sketch(sketch_id(1), "S".to_owned(), rectangle()); 438 doc.insert_sketch(sketch_id(2), "T".to_owned(), rectangle()); 439 let Some(removed_feature) = doc.feature_tree().feature_of_sketch(sketch_id(2)) else { 440 panic!("feature node for sketch 2"); 441 }; 442 doc.remove_sketch(sketch_id(2)); 443 assert_save(&doc, &folder); 444 445 let Ok(mut reloaded) = load(&folder) else { 446 panic!("reload"); 447 }; 448 assert_eq!( 449 reloaded.registry().order().len(), 450 1, 451 "the recipe lists only the live sketch, not a tombstone vector" 452 ); 453 454 let reborn_sketch = reloaded.allocate_sketch(); 455 assert_ne!( 456 reborn_sketch, 457 sketch_id(2), 458 "a removed sketch id stays reserved across the folder round-trip" 459 ); 460 reloaded.insert_sketch(reborn_sketch, "U".to_owned(), rectangle()); 461 let Some(reborn_feature) = reloaded.feature_tree().feature_of_sketch(reborn_sketch) else { 462 panic!("feature node for the reborn sketch"); 463 }; 464 assert_ne!( 465 reborn_feature, removed_feature, 466 "a removed feature id stays reserved across the folder round-trip" 467 ); 468} 469 470#[test] 471fn push_sketch_is_idempotent_on_duplicate_id() { 472 let mut doc = Document::new(document_id(1), "dup".to_owned()); 473 let id = sketch_id(1); 474 doc.insert_sketch(id, "S".to_owned(), rectangle()); 475 doc.insert_sketch(id, "S-renamed".to_owned(), rectangle()); 476 let sketch_nodes = doc 477 .feature_tree() 478 .iter() 479 .filter(|(_, n)| matches!(n, bone_document::FeatureNode::Sketch(_))) 480 .count(); 481 assert_eq!( 482 sketch_nodes, 1, 483 "duplicate sketch id must not spawn a second feature node" 484 ); 485} 486 487#[test] 488fn removing_sketch_drops_stale_file() { 489 let dir = ok_dir(); 490 let folder = DocumentFolder::new(dir.path().join("drop.bone")); 491 let mut doc = Document::new(document_id(1), "drop".to_owned()); 492 let id = sketch_id(1); 493 doc.insert_sketch(id, "S".to_owned(), rectangle()); 494 assert_save(&doc, &folder); 495 assert!(folder.sketch_path(id).exists()); 496 doc.remove_sketch(id); 497 assert_save(&doc, &folder); 498 assert!(!folder.sketch_path(id).exists()); 499} 500 501#[test] 502fn registry_kept_sorted_by_order_across_roundtrip() { 503 let dir = ok_dir(); 504 let folder = DocumentFolder::new(dir.path().join("order.bone")); 505 let mut doc = Document::new(document_id(1), "order".to_owned()); 506 let rect = rectangle(); 507 let a = sketch_id(1); 508 let b = sketch_id(2); 509 let c = sketch_id(3); 510 doc.insert_sketch(a, "A".to_owned(), rect.clone()); 511 doc.insert_sketch(b, "B".to_owned(), rect.clone()); 512 doc.insert_sketch(c, "C".to_owned(), rect); 513 assert_save(&doc, &folder); 514 let loaded = assert_load(&folder); 515 assert_eq!(loaded.registry().order(), &[a, b, c]); 516 let _: &SketchRegistry = loaded.registry(); 517} 518 519#[test] 520fn f64_special_values_roundtrip_bit_identically() { 521 let dir = ok_dir(); 522 let folder = DocumentFolder::new(dir.path().join("bits.bone")); 523 let specials: [(f64, f64); 7] = [ 524 (-0.0, 0.0), 525 (0.0, 1.0), 526 (f64::INFINITY, 2.0), 527 (f64::NEG_INFINITY, 3.0), 528 (f64::MIN_POSITIVE, 4.0), 529 (f64::MIN_POSITIVE / 2.0, 5.0), 530 (1.0 + f64::EPSILON, 6.0), 531 ]; 532 let sketch = specials 533 .into_iter() 534 .try_fold(Sketch::new(plane()), |s, (x, y)| { 535 s.apply(SketchEdit::AddEntity(SketchEntity::point(Point2::from_mm( 536 x, y, 537 )))) 538 .map(|(next, _)| next) 539 }); 540 let Ok(sketch) = sketch else { 541 panic!("apply points"); 542 }; 543 let mut doc = Document::new(document_id(1), "bits".to_owned()); 544 doc.insert_sketch(sketch_id(1), "S".to_owned(), sketch.clone()); 545 assert_save(&doc, &folder); 546 let loaded = assert_load(&folder); 547 let Some(back) = loaded.sketch(sketch_id(1)) else { 548 panic!("sketch missing"); 549 }; 550 let coords = |s: &Sketch| -> Vec<(f64, f64)> { 551 s.entity_order() 552 .iter() 553 .map(|id| match s.entities()[*id] { 554 SketchEntity::Point(p) => p.at().coords_mm(), 555 other => panic!("expected Point, got {other:?}"), 556 }) 557 .collect() 558 }; 559 let originals = coords(&sketch); 560 let reloaded = coords(back); 561 assert_eq!(originals.len(), reloaded.len()); 562 originals 563 .iter() 564 .zip(reloaded.iter()) 565 .for_each(|((ax, ay), (bx, by))| { 566 assert_eq!(ax.to_bits(), bx.to_bits(), "x bits drift: {ax} -> {bx}"); 567 assert_eq!(ay.to_bits(), by.to_bits(), "y bits drift: {ay} -> {by}"); 568 }); 569} 570 571#[test] 572fn load_refuses_sketch_file_unsupported_major() { 573 let dir = ok_dir(); 574 let folder = DocumentFolder::new(dir.path().join("bad-sketch-schema.bone")); 575 let mut doc = Document::new(document_id(1), "bad".to_owned()); 576 doc.insert_sketch(sketch_id(1), "S".to_owned(), rectangle()); 577 assert_save(&doc, &folder); 578 579 let sketch_path = folder.sketch_path(sketch_id(1)); 580 let text = read_file(&sketch_path); 581 let patched = text.replace("major: 1,", "major: 9999,"); 582 assert_ne!(patched, text, "schema major pattern missing"); 583 write_file(&sketch_path, &patched); 584 585 let result = load(&folder).map_err(bone_document::FolderError::into_kind); 586 let Err(bone_document::FolderErrorKind::UnsupportedMajor { name, found, .. }) = result else { 587 panic!("expected UnsupportedMajor on sketch file, got {result:?}"); 588 }; 589 assert_eq!(name, "bone-document"); 590 assert_eq!(found.major, 9999); 591} 592 593#[test] 594fn load_refuses_tampered_sketch_integrity() { 595 let dir = ok_dir(); 596 let folder = DocumentFolder::new(dir.path().join("neg.bone")); 597 let Ok((s, EditOutcome::Entity(center))) = Sketch::new(plane()).apply(SketchEdit::AddEntity( 598 SketchEntity::point(Point2::from_mm(0.0, 0.0)), 599 )) else { 600 panic!("center"); 601 }; 602 let Ok((s, _)) = s.apply(SketchEdit::AddEntity(SketchEntity::circle( 603 center, 604 mm(1.0), 605 false, 606 ))) else { 607 panic!("circle"); 608 }; 609 let mut doc = Document::new(document_id(1), "neg".to_owned()); 610 doc.insert_sketch(sketch_id(1), "S".to_owned(), s); 611 assert_save(&doc, &folder); 612 613 let sketch_path = folder.sketch_path(sketch_id(1)); 614 let text = read_file(&sketch_path); 615 let patched = text.replace("radius: 0.001,", "radius: -0.001,"); 616 assert_ne!(patched, text, "radius pattern missing"); 617 write_file(&sketch_path, &patched); 618 619 let result = load(&folder).map_err(bone_document::FolderError::into_kind); 620 let Err(bone_document::FolderErrorKind::SketchIntegrity { source, .. }) = result else { 621 panic!("expected SketchIntegrity, got {result:?}"); 622 }; 623 assert!(matches!( 624 source, 625 bone_document::SketchEditError::DegenerateEntity(_) 626 )); 627} 628 629fn patch_header(path: &std::path::Path, f: impl FnOnce(&mut DocumentHeader)) { 630 let text = read_file(path); 631 let Ok(mut header) = from_str::<DocumentHeader>(&text) else { 632 panic!("parse header"); 633 }; 634 f(&mut header); 635 let Ok(out) = to_string(&header) else { 636 panic!("serialize header"); 637 }; 638 write_file(path, &out); 639} 640 641#[test] 642fn load_refuses_dangling_feature_tree_sketch() { 643 let dir = ok_dir(); 644 let folder = DocumentFolder::new(dir.path().join("dangle.bone")); 645 let mut doc = Document::new(document_id(1), "dangle".to_owned()); 646 doc.insert_sketch(sketch_id(1), "S".to_owned(), rectangle()); 647 assert_save(&doc, &folder); 648 649 patch_header(&folder.document_file(), |h| { 650 h.feature_tree.push_sketch(sketch_id(99)); 651 }); 652 653 let result = load(&folder).map_err(bone_document::FolderError::into_kind); 654 let Err(bone_document::FolderErrorKind::DanglingTreeSketch { id }) = result else { 655 panic!("expected DanglingTreeSketch, got {result:?}"); 656 }; 657 assert_eq!(id, sketch_id(99)); 658} 659 660#[test] 661fn load_refuses_dangling_rollback_marker() { 662 let dir = ok_dir(); 663 let folder = DocumentFolder::new(dir.path().join("dangle_rollback.bone")); 664 let mut doc = Document::new(document_id(1), "dangle".to_owned()); 665 doc.insert_sketch(sketch_id(1), "S".to_owned(), rectangle()); 666 assert_save(&doc, &folder); 667 668 let bogus = feature_id(9999); 669 patch_header(&folder.document_file(), |h| { 670 h.rollback = RollbackMarker::Above(bogus); 671 }); 672 673 let result = load(&folder).map_err(bone_document::FolderError::into_kind); 674 let Err(bone_document::FolderErrorKind::DanglingRollback { id }) = result else { 675 panic!("expected DanglingRollback, got {result:?}"); 676 }; 677 assert_eq!(id, bogus); 678} 679 680#[test] 681fn load_refuses_rollback_marker_on_a_datum() { 682 let dir = ok_dir(); 683 let folder = DocumentFolder::new(dir.path().join("rollback_datum.bone")); 684 let mut doc = Document::new(document_id(1), "datum".to_owned()); 685 doc.insert_sketch(sketch_id(1), "S".to_owned(), rectangle()); 686 let Some((datum, _)) = doc.feature_tree().iter().next() else { 687 panic!("the seeded tree opens with a datum"); 688 }; 689 assert_save(&doc, &folder); 690 691 patch_header(&folder.document_file(), |h| { 692 h.rollback = RollbackMarker::Above(datum); 693 }); 694 695 let result = load(&folder).map_err(bone_document::FolderError::into_kind); 696 let Err(bone_document::FolderErrorKind::RollbackOnDatum { id }) = result else { 697 panic!("expected RollbackOnDatum, got {result:?}"); 698 }; 699 assert_eq!(id, datum); 700} 701 702#[test] 703fn load_refuses_dangling_suppressed_feature() { 704 let dir = ok_dir(); 705 let folder = DocumentFolder::new(dir.path().join("dangle_suppressed.bone")); 706 let mut doc = Document::new(document_id(1), "dangle".to_owned()); 707 doc.insert_sketch(sketch_id(1), "S".to_owned(), rectangle()); 708 assert_save(&doc, &folder); 709 710 let bogus = feature_id(9999); 711 patch_header(&folder.document_file(), |h| { 712 h.suppressed.insert(bogus); 713 }); 714 715 let result = load(&folder).map_err(bone_document::FolderError::into_kind); 716 let Err(bone_document::FolderErrorKind::DanglingSuppressed { id }) = result else { 717 panic!("expected DanglingSuppressed, got {result:?}"); 718 }; 719 assert_eq!(id, bogus); 720} 721 722#[test] 723fn load_refuses_suppressed_datum_feature() { 724 let dir = ok_dir(); 725 let folder = DocumentFolder::new(dir.path().join("suppressed_datum.bone")); 726 let mut doc = Document::new(document_id(1), "datum".to_owned()); 727 doc.insert_sketch(sketch_id(1), "S".to_owned(), rectangle()); 728 let Some((datum, _)) = doc.feature_tree().iter().next() else { 729 panic!("the seeded tree opens with a datum"); 730 }; 731 assert_save(&doc, &folder); 732 733 patch_header(&folder.document_file(), |h| { 734 h.suppressed.insert(datum); 735 }); 736 737 let result = load(&folder).map_err(bone_document::FolderError::into_kind); 738 let Err(bone_document::FolderErrorKind::SuppressedDatum { id }) = result else { 739 panic!("expected SuppressedDatum, got {result:?}"); 740 }; 741 assert_eq!(id, datum); 742} 743 744#[test] 745fn extrude_roundtrips_through_folder() { 746 let dir = ok_dir(); 747 let folder = DocumentFolder::new(dir.path().join("extrude.bone")); 748 let mut doc = Document::new(document_id(1), "extrude".to_owned()); 749 doc.insert_sketch(sketch_id(1), "S".to_owned(), rectangle()); 750 doc.insert_extrude(extrude_id(1), blind_extrude(sketch_id(1))); 751 assert_save(&doc, &folder); 752 753 assert!( 754 folder.extrude_path(extrude_id(1)).exists(), 755 "extrude lands in its own file under extrudes/" 756 ); 757 let document_ron = read_file(&folder.document_file()); 758 assert!( 759 !document_ron.contains("ExtrudeFeature"), 760 "extrude feature must not inline into document.ron:\n{document_ron}" 761 ); 762 763 let loaded = assert_load(&folder); 764 assert_eq!(loaded, doc, "full document survives the round-trip"); 765 let Some(feature) = loaded.feature_tree().feature_of_extrude(extrude_id(1)) else { 766 panic!("extrude node survives the round-trip"); 767 }; 768 assert_eq!( 769 loaded.extrude_of_feature(feature), 770 Some(&blind_extrude(sketch_id(1))) 771 ); 772 assert_eq!(loaded.feature_tree().edges().len(), 1); 773} 774 775#[test] 776fn renamed_extrude_label_survives_folder_roundtrip() { 777 let dir = ok_dir(); 778 let folder = DocumentFolder::new(dir.path().join("labeled.bone")); 779 let mut doc = Document::new(document_id(1), "labeled".to_owned()); 780 doc.insert_sketch(sketch_id(1), "S".to_owned(), rectangle()); 781 doc.insert_extrude(extrude_id(1), blind_extrude(sketch_id(1))); 782 let Ok(()) = doc.rename_extrude(extrude_id(1), "Boss") else { 783 panic!("rename accepts a non-empty label"); 784 }; 785 assert_save(&doc, &folder); 786 787 let loaded = assert_load(&folder); 788 assert_eq!( 789 loaded.extrude_label(extrude_id(1)), 790 Some("Boss"), 791 "the file's stored label is read back, not recomputed from the id", 792 ); 793} 794 795#[test] 796fn two_extrudes_keep_canonical_edge_order_through_folder() { 797 let dir = ok_dir(); 798 let folder = DocumentFolder::new(dir.path().join("two_extrude.bone")); 799 let mut doc = Document::new(document_id(1), "two".to_owned()); 800 doc.insert_sketch(sketch_id(1), "S".to_owned(), rectangle()); 801 doc.insert_extrude(extrude_id(5), blind_extrude(sketch_id(1))); 802 doc.insert_extrude(extrude_id(2), blind_extrude(sketch_id(1))); 803 assert_save(&doc, &folder); 804 805 let loaded = assert_load(&folder); 806 assert_eq!( 807 loaded.feature_tree(), 808 doc.feature_tree(), 809 "save then load must preserve feature-tree equality with multiple edges" 810 ); 811 assert_eq!(loaded.feature_tree().edges().len(), 2); 812} 813 814#[test] 815fn load_refuses_tree_extrude_without_file() { 816 let dir = ok_dir(); 817 let folder = DocumentFolder::new(dir.path().join("missing_extrude.bone")); 818 let mut doc = Document::new(document_id(1), "missing".to_owned()); 819 doc.insert_sketch(sketch_id(1), "S".to_owned(), rectangle()); 820 doc.insert_extrude(extrude_id(1), blind_extrude(sketch_id(1))); 821 assert_save(&doc, &folder); 822 823 let Ok(()) = std::fs::remove_file(folder.extrude_path(extrude_id(1))) else { 824 panic!("remove extrude file"); 825 }; 826 827 let result = load(&folder).map_err(bone_document::FolderError::into_kind); 828 let Err(bone_document::FolderErrorKind::MissingExtrudeFile { id }) = result else { 829 panic!("expected MissingExtrudeFile, got {result:?}"); 830 }; 831 assert_eq!(id, extrude_id(1)); 832} 833 834#[test] 835fn removing_extrude_drops_stale_file() { 836 let dir = ok_dir(); 837 let folder = DocumentFolder::new(dir.path().join("drop_extrude.bone")); 838 let mut doc = Document::new(document_id(1), "drop".to_owned()); 839 doc.insert_sketch(sketch_id(1), "S".to_owned(), rectangle()); 840 doc.insert_extrude(extrude_id(1), blind_extrude(sketch_id(1))); 841 assert_save(&doc, &folder); 842 assert!(folder.extrude_path(extrude_id(1)).exists()); 843 844 doc.remove_extrude(extrude_id(1)); 845 assert_save(&doc, &folder); 846 assert!(!folder.extrude_path(extrude_id(1)).exists()); 847 848 let loaded = assert_load(&folder); 849 assert!( 850 loaded 851 .feature_tree() 852 .feature_of_extrude(extrude_id(1)) 853 .is_none() 854 ); 855} 856 857#[test] 858fn load_refuses_extrude_with_unregistered_sketch() { 859 let dir = ok_dir(); 860 let folder = DocumentFolder::new(dir.path().join("ghost_sketch.bone")); 861 let mut doc = Document::new(document_id(1), "ghost".to_owned()); 862 doc.insert_extrude(extrude_id(1), blind_extrude(sketch_id(99))); 863 assert_save(&doc, &folder); 864 865 let result = load(&folder).map_err(bone_document::FolderError::into_kind); 866 let Err(bone_document::FolderErrorKind::DanglingExtrudeSketch { extrude, sketch }) = result 867 else { 868 panic!("expected DanglingExtrudeSketch, got {result:?}"); 869 }; 870 assert_eq!(extrude, extrude_id(1)); 871 assert_eq!(sketch, sketch_id(99)); 872} 873 874#[test] 875fn load_refuses_orphan_registered_sketch() { 876 let dir = ok_dir(); 877 let folder = DocumentFolder::new(dir.path().join("orphan.bone")); 878 let mut doc = Document::new(document_id(1), "orphan".to_owned()); 879 doc.insert_sketch(sketch_id(1), "S".to_owned(), rectangle()); 880 assert_save(&doc, &folder); 881 882 patch_header(&folder.document_file(), |h| { 883 h.feature_tree.remove_sketch(sketch_id(1)); 884 }); 885 886 let result = load(&folder).map_err(bone_document::FolderError::into_kind); 887 let Err(bone_document::FolderErrorKind::OrphanRegistered { id }) = result else { 888 panic!("expected OrphanRegistered, got {result:?}"); 889 }; 890 assert_eq!(id, sketch_id(1)); 891} 892 893fn closed_rectangle(width_mm: f64) -> Sketch { 894 let s = Sketch::new(plane()); 895 let (s, p0) = add_entity_returning_id(s, SketchEntity::point(Point2::from_mm(0.0, 0.0))); 896 let (s, p1) = add_entity_returning_id(s, SketchEntity::point(Point2::from_mm(width_mm, 0.0))); 897 let (s, p2) = add_entity_returning_id(s, SketchEntity::point(Point2::from_mm(width_mm, 5.0))); 898 let (s, p3) = add_entity_returning_id(s, SketchEntity::point(Point2::from_mm(0.0, 5.0))); 899 [(p0, p1), (p1, p2), (p2, p3), (p3, p0)] 900 .into_iter() 901 .fold(s, |s, (a, b)| { 902 let Ok((next, _)) = s.apply(SketchEdit::AddEntity(SketchEntity::line(a, b, false))) 903 else { 904 panic!("rectangle edge"); 905 }; 906 next 907 }) 908} 909 910fn evaluated_solid(doc: &Document, sketch: SketchId, extrude: ExtrudeId) -> bone_kernel::BrepSolid { 911 let Some(sketch_value) = doc.sketch(sketch) else { 912 panic!("sketch present"); 913 }; 914 let Some(feature_id) = doc.feature_tree().feature_of_extrude(extrude) else { 915 panic!("extrude node present"); 916 }; 917 let Some(&feature) = doc.extrude_of_feature(feature_id) else { 918 panic!("extrude feature present"); 919 }; 920 let evaluated = evaluate_sketch(sketch_value); 921 let extruded = evaluate_extrude(feature_id, &evaluated, &feature); 922 let Some(solid) = extruded.solid() else { 923 panic!("rectangle extrudes to a solid"); 924 }; 925 solid.clone() 926} 927 928fn face_labels(solid: &bone_kernel::BrepSolid) -> Vec<bone_types::FaceLabel> { 929 solid.iter_faces().map(BrepFace::label).collect() 930} 931 932#[test] 933fn width_change_yields_text_diff_and_new_blob() { 934 let dir = ok_dir(); 935 let folder = DocumentFolder::new(dir.path().join("edit.bone")); 936 let sid = sketch_id(1); 937 let eid = extrude_id(1); 938 let mut doc = Document::new(document_id(1), "edit".to_owned()); 939 doc.insert_sketch(sid, "S".to_owned(), closed_rectangle(10.0)); 940 doc.insert_extrude(eid, blind_extrude(sid)); 941 assert_save(&doc, &folder); 942 943 let document_before = read_file(&folder.document_file()); 944 let sketch_before = read_file(&folder.sketch_path(sid)); 945 let solid_before = evaluated_solid(&doc, sid, eid); 946 let Ok(hash_before) = bone_document::write_solid(&folder, &solid_before) else { 947 panic!("cache first solid"); 948 }; 949 950 doc.replace_sketch(sid, closed_rectangle(14.0)); 951 assert_save(&doc, &folder); 952 953 let document_after = read_file(&folder.document_file()); 954 let sketch_after = read_file(&folder.sketch_path(sid)); 955 let solid_after = evaluated_solid(&doc, sid, eid); 956 let Ok(hash_after) = bone_document::write_solid(&folder, &solid_after) else { 957 panic!("cache widened solid"); 958 }; 959 960 assert_ne!( 961 sketch_before, sketch_after, 962 "a width edit must change the sketch file" 963 ); 964 assert_eq!( 965 document_before, document_after, 966 "a sketch-data edit must not churn document.ron" 967 ); 968 let Ok(raw) = std::fs::read(folder.sketch_path(sid)) else { 969 panic!("read sketch bytes"); 970 }; 971 assert!( 972 std::str::from_utf8(&raw).is_ok() && !raw.contains(&0u8), 973 "sketch file stays text, no binary noise" 974 ); 975 976 assert_ne!(hash_before, hash_after, "wider box content-addresses anew"); 977 assert!( 978 folder.blob_path(hash_before, BlobKind::BREP).exists(), 979 "previous geometry blob survives content-addressing" 980 ); 981 assert!( 982 folder.blob_path(hash_after, BlobKind::BREP).exists(), 983 "new geometry blob is written" 984 ); 985 assert_eq!( 986 face_labels(&solid_before), 987 face_labels(&solid_after), 988 "a width change preserves face labels, so blob churn is geometry-only" 989 ); 990} 991 992fn sample_solid(feature: FeatureId) -> bone_kernel::BrepSolid { 993 let evaluated = evaluate_sketch(&closed_rectangle(10.0)); 994 let extruded = evaluate_extrude(feature, &evaluated, &blind_extrude(SketchId::null())); 995 let Some(solid) = extruded.solid() else { 996 panic!("rectangle extrudes to a solid"); 997 }; 998 solid.clone() 999} 1000 1001#[test] 1002fn imported_body_id_stays_reserved_across_roundtrip() { 1003 let dir = ok_dir(); 1004 let folder = DocumentFolder::new(dir.path().join("body.bone")); 1005 1006 let mut doc = Document::new(document_id(1), "body".to_owned()); 1007 let Ok((_, first)) = doc.import_body(|feature| Ok::<_, ()>(sample_solid(feature))) else { 1008 panic!("import the first body"); 1009 }; 1010 assert_save(&doc, &folder); 1011 1012 let mut reloaded = assert_load(&folder); 1013 let Ok((_, second)) = reloaded.import_body(|feature| Ok::<_, ()>(sample_solid(feature))) else { 1014 panic!("import a body after reload"); 1015 }; 1016 assert_ne!( 1017 first, second, 1018 "a body id allocated before the save is not handed out again after the load" 1019 ); 1020} 1021 1022#[test] 1023fn blob_path_matches_locked_shape() { 1024 let folder = DocumentFolder::new("/tmp/part.bone"); 1025 let hash = BlobHash::of(b"example"); 1026 let path = folder.blob_path(hash, BlobKind::STEP); 1027 let Ok(suffix) = path.strip_prefix("/tmp/part.bone/blobs") else { 1028 panic!("blob under blobs/: {}", path.display()); 1029 }; 1030 let components: Vec<String> = suffix 1031 .components() 1032 .map(|c| c.as_os_str().to_string_lossy().into_owned()) 1033 .collect(); 1034 assert_eq!( 1035 components.len(), 1036 2, 1037 "<aa>/<rest>.<kind>: got {components:?}" 1038 ); 1039 assert_eq!(components[0].len(), 2, "fanout is 2 hex chars"); 1040 let Some(stem) = components[1].strip_suffix(".step") else { 1041 panic!("extension is .step: {}", components[1]); 1042 }; 1043 assert_eq!(stem.len(), 30, "remaining 30 hex chars"); 1044 let hex = hash.truncated_128_hex(); 1045 assert_eq!(components[0], hex[..2]); 1046 assert_eq!(stem, &hex[2..]); 1047} 1048 1049#[test] 1050fn phase_three_history_state_round_trips_through_the_folder() { 1051 let dir = ok_dir(); 1052 let folder = DocumentFolder::new(dir.path().join("phase3.bone")); 1053 1054 let base_sketch = sketch_id(1); 1055 let base_extrude = extrude_id(1); 1056 let cap_sketch = sketch_id(2); 1057 let cap_extrude = extrude_id(2); 1058 1059 let mut doc = Document::new(document_id(1), "phase3".to_owned()); 1060 doc.insert_sketch(base_sketch, "Base".to_owned(), rectangle()); 1061 doc.insert_extrude(base_extrude, blind_extrude(base_sketch)); 1062 let Some(base_body) = doc.feature_tree().feature_of_extrude(base_extrude) else { 1063 panic!("base extrude feature present"); 1064 }; 1065 1066 doc.insert_sketch(cap_sketch, "Top".to_owned(), rectangle()); 1067 let face = sample_face_ref(base_body); 1068 let Ok(()) = doc.bind_sketch_to_face(cap_sketch, face) else { 1069 panic!("the face binding is acyclic"); 1070 }; 1071 doc.insert_extrude(cap_extrude, separate_extrude(cap_sketch)); 1072 1073 let Some(cap_sketch_feature) = doc.feature_tree().feature_of_sketch(cap_sketch) else { 1074 panic!("cap sketch feature present"); 1075 }; 1076 let Some(cap_extrude_feature) = doc.feature_tree().feature_of_extrude(cap_extrude) else { 1077 panic!("cap extrude feature present"); 1078 }; 1079 doc.roll_to_here(cap_sketch_feature); 1080 doc.suppress(cap_extrude_feature); 1081 1082 assert_save(&doc, &folder); 1083 let loaded = assert_load(&folder); 1084 1085 assert_eq!(loaded, doc, "every phase-3 state survives the round-trip"); 1086 assert_eq!( 1087 loaded.sketch_plane_binding(cap_sketch), 1088 Some(face), 1089 "the face ref held by the bound sketch persists", 1090 ); 1091 assert_eq!( 1092 loaded.rollback(), 1093 RollbackMarker::Above(cap_sketch_feature), 1094 "the rollback marker persists, keyed by a stable feature id", 1095 ); 1096 assert_eq!( 1097 loaded.suppression_state(cap_extrude_feature), 1098 SuppressionState::Suppressed, 1099 "the suppressed set persists", 1100 ); 1101 assert_eq!( 1102 loaded.suppression_state(base_body), 1103 SuppressionState::Active, 1104 "a feature absent from the suppressed set loads active", 1105 ); 1106 1107 let before = folder_bytes(folder.path()); 1108 assert_save(&loaded, &folder); 1109 let after = folder_bytes(folder.path()); 1110 assert_eq!( 1111 before, after, 1112 "saving the reloaded document reproduces every file byte for byte", 1113 ); 1114} 1115 1116#[test] 1117fn phase_three_header_ron_surface() { 1118 let base_sketch = sketch_id(1); 1119 let base_extrude = extrude_id(1); 1120 let cap_sketch = sketch_id(2); 1121 let cap_extrude = extrude_id(2); 1122 1123 let mut doc = Document::new(document_id(1), "phase3".to_owned()); 1124 doc.insert_sketch(base_sketch, "Base".to_owned(), rectangle()); 1125 doc.insert_extrude(base_extrude, blind_extrude(base_sketch)); 1126 let Some(base_body) = doc.feature_tree().feature_of_extrude(base_extrude) else { 1127 panic!("base extrude feature present"); 1128 }; 1129 1130 doc.insert_sketch(cap_sketch, "Top".to_owned(), rectangle()); 1131 let Ok(()) = doc.bind_sketch_to_face(cap_sketch, sample_face_ref(base_body)) else { 1132 panic!("the face binding is acyclic"); 1133 }; 1134 doc.insert_extrude(cap_extrude, separate_extrude(cap_sketch)); 1135 1136 let Some(cap_sketch_feature) = doc.feature_tree().feature_of_sketch(cap_sketch) else { 1137 panic!("cap sketch feature present"); 1138 }; 1139 let Some(cap_extrude_feature) = doc.feature_tree().feature_of_extrude(cap_extrude) else { 1140 panic!("cap extrude feature present"); 1141 }; 1142 doc.roll_to_here(cap_sketch_feature); 1143 doc.suppress(cap_extrude_feature); 1144 1145 let Ok(ron) = to_string(doc.header()) else { 1146 panic!("ron"); 1147 }; 1148 insta::assert_snapshot!("phase_three_header", ron); 1149}