Another project
1use std::collections::BTreeSet;
2use std::num::NonZeroUsize;
3
4use bone_document::{
5 Document, EditOutcome, EvaluatedModel, FeatureEdge, RecomputeScope, Sketch, SketchEdit,
6 SketchEntity, UndoStack, evaluate_extrude, evaluate_sketch,
7};
8use bone_kernel::{
9 BrepSolid, ExtrudeDirection, ExtrudeEndCondition, ExtrudeFeature, ExtrudeSense, MergeResult,
10};
11use bone_types::{
12 DocumentId, ExtrudeId, FeatureId, Length, Point2, Point3, PositiveLength, RollbackMarker,
13 SketchEntityId, SketchId, SketchPlaneBasis, Tolerance, UnitVec3, millimeter,
14};
15use slotmap::{Key, KeyData};
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");
25 };
26 basis
27}
28
29fn sketch_id(idx: u32) -> SketchId {
30 SketchId::from(KeyData::from_ffi((1u64 << 32) | u64::from(idx)))
31}
32
33fn document_id(idx: u32) -> DocumentId {
34 DocumentId::from(KeyData::from_ffi((1u64 << 32) | u64::from(idx)))
35}
36
37fn cap(n: usize) -> NonZeroUsize {
38 let Some(nz) = NonZeroUsize::new(n) else {
39 panic!("capacity");
40 };
41 nz
42}
43
44fn base_doc() -> Document {
45 Document::new(document_id(1), "d".to_owned())
46}
47
48fn with_sketch(mut doc: Document, sid: SketchId) -> Document {
49 doc.insert_sketch(sid, format!("S{sid:?}"), Sketch::new(plane()));
50 doc
51}
52
53fn extrude_id(idx: u32) -> ExtrudeId {
54 ExtrudeId::from(KeyData::from_ffi((1u64 << 32) | u64::from(idx)))
55}
56
57fn entity_of(outcome: EditOutcome) -> SketchEntityId {
58 let EditOutcome::Entity(id) = outcome else {
59 panic!("entity outcome");
60 };
61 id
62}
63
64fn rectangle() -> Sketch {
65 rectangle_width(10.0)
66}
67
68fn rectangle_width(width_mm: f64) -> Sketch {
69 let Ok((with_points, outcomes)) = Sketch::new(plane()).apply_all(vec![
70 SketchEdit::AddEntity(SketchEntity::point(Point2::from_mm(0.0, 0.0))),
71 SketchEdit::AddEntity(SketchEntity::point(Point2::from_mm(width_mm, 0.0))),
72 SketchEdit::AddEntity(SketchEntity::point(Point2::from_mm(width_mm, 5.0))),
73 SketchEdit::AddEntity(SketchEntity::point(Point2::from_mm(0.0, 5.0))),
74 ]) else {
75 panic!("rectangle corners");
76 };
77 let [c0, c1, c2, c3] = [0, 1, 2, 3].map(|i| entity_of(outcomes[i]));
78 let Ok((closed, _)) = with_points.apply_all(vec![
79 SketchEdit::AddEntity(SketchEntity::line(c0, c1, false)),
80 SketchEdit::AddEntity(SketchEntity::line(c1, c2, false)),
81 SketchEdit::AddEntity(SketchEntity::line(c2, c3, false)),
82 SketchEdit::AddEntity(SketchEntity::line(c3, c0, false)),
83 ]) else {
84 panic!("rectangle edges");
85 };
86 closed
87}
88
89fn sample_solid(feature: FeatureId) -> BrepSolid {
90 let evaluated = evaluate_sketch(&rectangle());
91 let extruded = evaluate_extrude(feature, &evaluated, &blind_extrude(SketchId::null()));
92 let Some(solid) = extruded.solid() else {
93 panic!("rectangle extrudes to a solid");
94 };
95 solid.clone()
96}
97
98fn blind_extrude(sketch: SketchId) -> ExtrudeFeature {
99 let Ok(depth) = PositiveLength::new(Length::new::<millimeter>(10.0)) else {
100 panic!("10 mm is positive");
101 };
102 ExtrudeFeature {
103 sketch,
104 direction: ExtrudeDirection::Normal {
105 sense: ExtrudeSense::Forward,
106 },
107 end_condition: ExtrudeEndCondition::Blind { depth },
108 draft: None,
109 thin_wall: None,
110 merge_result: MergeResult::Merge,
111 }
112}
113
114#[test]
115fn fresh_stack_has_no_history() {
116 let stack = UndoStack::with_capacity(cap(5));
117 assert!(!stack.can_undo());
118 assert!(!stack.can_redo());
119 assert_eq!(stack.past_len(), 0);
120 assert_eq!(stack.future_len(), 0);
121}
122
123#[test]
124fn undo_swaps_current_with_previous() {
125 let a = base_doc();
126 let mut live = with_sketch(a.clone(), sketch_id(1));
127 let mut stack = UndoStack::with_capacity(cap(5));
128 stack.record(a.clone());
129 assert!(stack.undo(&mut live));
130 assert_eq!(live, a);
131 assert!(!stack.can_undo());
132 assert!(stack.can_redo());
133}
134
135#[test]
136fn undo_on_empty_leaves_current_unchanged() {
137 let mut live = base_doc();
138 let snapshot = live.clone();
139 let mut stack = UndoStack::with_capacity(cap(5));
140 assert!(!stack.undo(&mut live));
141 assert_eq!(live, snapshot);
142 assert_eq!(stack.past_len(), 0);
143 assert_eq!(stack.future_len(), 0);
144}
145
146#[test]
147fn redo_on_empty_leaves_current_unchanged() {
148 let mut live = base_doc();
149 let snapshot = live.clone();
150 let mut stack = UndoStack::with_capacity(cap(5));
151 assert!(!stack.redo(&mut live));
152 assert_eq!(live, snapshot);
153 assert_eq!(stack.past_len(), 0);
154 assert_eq!(stack.future_len(), 0);
155}
156
157#[test]
158fn redo_replays_undone_state() {
159 let a = base_doc();
160 let b = with_sketch(a.clone(), sketch_id(1));
161 let mut live = b.clone();
162 let mut stack = UndoStack::with_capacity(cap(5));
163 stack.record(a.clone());
164 assert!(stack.undo(&mut live));
165 assert!(stack.redo(&mut live));
166 assert_eq!(live, b);
167 assert!(stack.can_undo());
168 assert!(!stack.can_redo());
169}
170
171#[test]
172fn recording_after_undo_clears_redo() {
173 let a = base_doc();
174 let b = with_sketch(a.clone(), sketch_id(1));
175 let c = with_sketch(a.clone(), sketch_id(2));
176 let mut live = b;
177 let mut stack = UndoStack::with_capacity(cap(5));
178 stack.record(a.clone());
179 assert!(stack.undo(&mut live));
180 assert_eq!(live, a);
181 stack.record(live.clone());
182 let _ = c;
183 assert!(stack.can_undo());
184 assert!(!stack.can_redo());
185 assert_eq!(stack.future_len(), 0);
186}
187
188#[test]
189fn capacity_drops_oldest_entries() {
190 let mut stack = UndoStack::with_capacity(cap(2));
191 let snapshots: Vec<Document> = (1..=4)
192 .map(|i| with_sketch(base_doc(), sketch_id(i)))
193 .collect();
194 snapshots.iter().cloned().for_each(|doc| stack.record(doc));
195 assert_eq!(stack.past_len(), 2);
196 let mut live = with_sketch(base_doc(), sketch_id(99));
197 assert!(stack.undo(&mut live));
198 assert_eq!(live, snapshots[3]);
199 assert!(stack.undo(&mut live));
200 assert_eq!(live, snapshots[2]);
201 assert!(!stack.can_undo());
202}
203
204#[test]
205fn redo_never_exceeds_capacity() {
206 let mut stack = UndoStack::with_capacity(cap(2));
207 let mut live = base_doc();
208 [1u32, 2, 3, 4].iter().for_each(|i| {
209 stack.record(live.clone());
210 live.insert_sketch(sketch_id(*i), format!("S{i}"), Sketch::new(plane()));
211 assert!(stack.past_len() <= 2, "record stays within capacity");
212 });
213 std::iter::from_fn(|| {
214 stack
215 .undo(&mut live)
216 .then(|| assert!(stack.past_len() <= 2 && stack.future_len() <= 2))
217 })
218 .for_each(drop);
219 std::iter::from_fn(|| {
220 stack.redo(&mut live).then(|| {
221 assert!(
222 stack.past_len() <= 2,
223 "redo must not grow past beyond capacity"
224 );
225 })
226 })
227 .for_each(drop);
228 assert!(!stack.can_redo());
229}
230
231#[test]
232fn undo_redo_cycle_preserves_determinism() {
233 let a = base_doc();
234 let b = with_sketch(a.clone(), sketch_id(1));
235 let c = with_sketch(b.clone(), sketch_id(2));
236 let mut live = c.clone();
237 let mut stack = UndoStack::with_capacity(cap(5));
238 stack.record(a.clone());
239 stack.record(b.clone());
240 assert!(stack.undo(&mut live));
241 assert_eq!(live, b);
242 assert!(stack.undo(&mut live));
243 assert_eq!(live, a);
244 assert!(stack.redo(&mut live));
245 assert_eq!(live, b);
246 assert!(stack.redo(&mut live));
247 assert_eq!(live, c);
248}
249
250#[test]
251fn undo_extrude_restores_sketch_only_document() {
252 let sid = sketch_id(1);
253 let xid = extrude_id(1);
254
255 let mut live = base_doc();
256 live.insert_sketch(sid, "Sketch1".to_owned(), rectangle());
257 let pre_extrude = live.clone();
258
259 let mut stack = UndoStack::with_capacity(cap(5));
260 stack.record(pre_extrude.clone());
261
262 live.insert_extrude(xid, blind_extrude(sid));
263 assert_ne!(
264 live, pre_extrude,
265 "inserting an extrude must change the document"
266 );
267 let Some(sketch_feature) = live.feature_tree().feature_of_sketch(sid) else {
268 panic!("sketch feature present");
269 };
270 let Some(extrude_feature) = live.feature_tree().feature_of_extrude(xid) else {
271 panic!("extrude feature present");
272 };
273 let wired = [FeatureEdge::SketchToExtrude {
274 sketch: sketch_feature,
275 extrude: extrude_feature,
276 }];
277 assert_eq!(
278 live.feature_tree().edges(),
279 wired,
280 "inserting an extrude wires the sketch-to-extrude edge"
281 );
282
283 assert!(stack.undo(&mut live));
284 assert_eq!(
285 live, pre_extrude,
286 "undo restores the sketch-only document exactly"
287 );
288 assert!(
289 live.feature_tree().feature_of_extrude(xid).is_none(),
290 "no extrude node survives the undo"
291 );
292 assert!(
293 live.header().extrudes.is_empty(),
294 "no extrude payload survives the undo"
295 );
296 assert!(
297 live.feature_tree().edges().is_empty(),
298 "undo drops the derived sketch-to-extrude edge"
299 );
300
301 assert!(stack.redo(&mut live));
302 let Some(feature) = live.feature_tree().feature_of_extrude(xid) else {
303 panic!("redo restores the extrude node");
304 };
305 assert_eq!(
306 live.extrude_of_feature(feature),
307 Some(&blind_extrude(sid)),
308 "redo restores the extrude payload"
309 );
310 assert_eq!(
311 live.feature_tree().edges(),
312 wired,
313 "redo restores the derived edge even though it is never serialized"
314 );
315}
316
317#[test]
318fn undo_and_redo_cross_a_recompute() {
319 let sid = sketch_id(1);
320 let xid = extrude_id(1);
321 let mut live = base_doc();
322 live.insert_sketch(sid, "Sketch1".to_owned(), rectangle_width(10.0));
323 live.insert_extrude(xid, blind_extrude(sid));
324
325 let Some(extrude_feature) = live.feature_tree().feature_of_extrude(xid) else {
326 panic!("extrude feature present");
327 };
328 let Some(sketch_feature) = live.feature_tree().feature_of_sketch(sid) else {
329 panic!("sketch feature present");
330 };
331
332 let mut model = EvaluatedModel::new();
333 let active = BTreeSet::new();
334 model.recompute(&live, &active, RollbackMarker::AtEnd, RecomputeScope::Full);
335 let Some(narrow) = model.body(extrude_feature).map(BrepSolid::content_key) else {
336 panic!("the base extrude builds a body");
337 };
338
339 let mut stack = UndoStack::with_capacity(cap(5));
340 stack.record(live.clone());
341 live.replace_sketch(sid, rectangle_width(14.0));
342 model.recompute(
343 &live,
344 &active,
345 RollbackMarker::AtEnd,
346 RecomputeScope::Edited(sketch_feature),
347 );
348 let Some(wide) = model.body(extrude_feature).map(BrepSolid::content_key) else {
349 panic!("the widened extrude builds a body");
350 };
351 assert_ne!(narrow, wide, "widening the profile must change the body");
352
353 assert!(stack.undo(&mut live));
354 model.recompute(&live, &active, RollbackMarker::AtEnd, RecomputeScope::Full);
355 assert_eq!(
356 model.body(extrude_feature).map(BrepSolid::content_key),
357 Some(narrow),
358 "undo restores the recipe and the rebuild reproduces the pre-edit body",
359 );
360
361 assert!(stack.redo(&mut live));
362 model.recompute(&live, &active, RollbackMarker::AtEnd, RecomputeScope::Full);
363 assert_eq!(
364 model.body(extrude_feature).map(BrepSolid::content_key),
365 Some(wide),
366 "redo restores the edited recipe and the body widens again",
367 );
368}
369
370#[test]
371fn rollback_and_suppression_survive_undo_and_redo() {
372 let sid = sketch_id(1);
373 let xid = extrude_id(1);
374 let mut live = base_doc();
375 live.insert_sketch(sid, "Sketch1".to_owned(), rectangle());
376 live.insert_extrude(xid, blind_extrude(sid));
377 let Some(feature) = live.feature_tree().feature_of_extrude(xid) else {
378 panic!("extrude feature present");
379 };
380 let before = live.clone();
381
382 let mut stack = UndoStack::with_capacity(cap(5));
383 stack.record(live.clone());
384 live.roll_to_here(feature);
385 live.suppress(feature);
386 assert_eq!(live.rollback(), RollbackMarker::Above(feature));
387 assert!(live.suppressed().contains(&feature));
388
389 assert!(stack.undo(&mut live));
390 assert_eq!(live, before, "undo restores the pre-history-edit document");
391 assert_eq!(
392 live.rollback(),
393 RollbackMarker::AtEnd,
394 "undo clears the rollback marker set by the undone step",
395 );
396 assert!(
397 live.suppressed().is_empty(),
398 "undo clears the suppression set by the undone step",
399 );
400
401 assert!(stack.redo(&mut live));
402 assert_eq!(
403 live.rollback(),
404 RollbackMarker::Above(feature),
405 "redo restores the rollback marker",
406 );
407 assert!(
408 live.suppressed().contains(&feature),
409 "redo restores the suppression",
410 );
411}
412
413#[test]
414fn cloning_a_document_shares_imported_body_storage() {
415 let mut live = base_doc();
416 let Ok((_, body)) = live.import_body(|feature| Ok::<_, ()>(sample_solid(feature))) else {
417 panic!("import a body");
418 };
419
420 let snapshot = live.clone();
421 let Some(live_solid) = live.imported_body(body) else {
422 panic!("live body present");
423 };
424 let Some(snapshot_solid) = snapshot.imported_body(body) else {
425 panic!("snapshot body present");
426 };
427 assert!(
428 std::ptr::eq(live_solid, snapshot_solid),
429 "a clone shares the imported body payload, the snapshot is not a deep brep copy",
430 );
431}
432
433#[test]
434fn an_undo_needs_a_full_recompute_to_restore_an_out_of_scope_body() {
435 let sid_a = sketch_id(1);
436 let xid_a = extrude_id(1);
437 let sid_b = sketch_id(2);
438 let xid_b = extrude_id(2);
439
440 let mut live = base_doc();
441 live.insert_sketch(sid_a, "A".to_owned(), rectangle_width(10.0));
442 live.insert_extrude(xid_a, blind_extrude(sid_a));
443 live.insert_sketch(sid_b, "B".to_owned(), rectangle_width(10.0));
444 live.insert_extrude(xid_b, blind_extrude(sid_b));
445
446 let Some(a_sketch_feature) = live.feature_tree().feature_of_sketch(sid_a) else {
447 panic!("sketch a feature present");
448 };
449 let Some(b_sketch_feature) = live.feature_tree().feature_of_sketch(sid_b) else {
450 panic!("sketch b feature present");
451 };
452 let Some(b_extrude_feature) = live.feature_tree().feature_of_extrude(xid_b) else {
453 panic!("extrude b feature present");
454 };
455
456 let mut model = EvaluatedModel::new();
457 let active = BTreeSet::new();
458 model.recompute(&live, &active, RollbackMarker::AtEnd, RecomputeScope::Full);
459 let Some(b_original) = model.body(b_extrude_feature).map(BrepSolid::content_key) else {
460 panic!("b builds a body");
461 };
462
463 let mut stack = UndoStack::with_capacity(cap(5));
464 stack.record(live.clone());
465 live.replace_sketch(sid_b, rectangle_width(18.0));
466 model.recompute(
467 &live,
468 &active,
469 RollbackMarker::AtEnd,
470 RecomputeScope::Edited(b_sketch_feature),
471 );
472 let Some(b_wide) = model.body(b_extrude_feature).map(BrepSolid::content_key) else {
473 panic!("b rebuilds wide");
474 };
475 assert_ne!(b_original, b_wide, "widening b must change its body");
476
477 assert!(stack.undo(&mut live));
478 model.recompute(
479 &live,
480 &active,
481 RollbackMarker::AtEnd,
482 RecomputeScope::Edited(a_sketch_feature),
483 );
484 assert_eq!(
485 model.body(b_extrude_feature).map(BrepSolid::content_key),
486 Some(b_wide),
487 "an edit-scoped recompute after undo cannot see b reverted outside the scope",
488 );
489
490 model.recompute(&live, &active, RollbackMarker::AtEnd, RecomputeScope::Full);
491 assert_eq!(
492 model.body(b_extrude_feature).map(BrepSolid::content_key),
493 Some(b_original),
494 "a full recompute after undo restores every reverted body, the contract the app must use",
495 );
496}
497
498#[test]
499fn cloning_a_document_shares_sketch_storage() {
500 let sid = sketch_id(1);
501 let mut live = base_doc();
502 live.insert_sketch(sid, "Sketch1".to_owned(), rectangle_width(10.0));
503
504 let snapshot = live.clone();
505 let Some(live_sketch) = live.sketch(sid) else {
506 panic!("live sketch present");
507 };
508 let Some(snapshot_sketch) = snapshot.sketch(sid) else {
509 panic!("snapshot sketch present");
510 };
511 assert!(
512 std::ptr::eq(live_sketch.entities(), snapshot_sketch.entities()),
513 "a clone shares the sketch entity payload, the snapshot is not a deep copy",
514 );
515
516 live.replace_sketch(sid, rectangle_width(14.0));
517 let Some(edited_sketch) = live.sketch(sid) else {
518 panic!("edited sketch present");
519 };
520 let Some(snapshot_after) = snapshot.sketch(sid) else {
521 panic!("snapshot survives the edit");
522 };
523 assert!(
524 !std::ptr::eq(edited_sketch.entities(), snapshot_after.entities()),
525 "editing the live document leaves the snapshot's payload untouched",
526 );
527}