Another project
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}