Another project
1use std::collections::{BTreeMap, BTreeSet};
2
3use bone_kernel::{BrepSolid, ExtrudeFeature};
4use bone_types::{
5 BodyId, BuildFailure, FaceRef, FeatureId, GeometryGeneration, RebuildError, RebuildStatus,
6 RollbackMarker,
7};
8
9use crate::Sketch;
10use crate::document::{Document, FeatureNode, FeatureTree};
11use crate::evaluator::{
12 EvaluatedExtrude, EvaluatedSketch, ExtrudeError, evaluate_extrude, evaluate_sketch,
13};
14use crate::matcher::unique_face;
15
16#[derive(Copy, Clone, Debug, Default, PartialEq, Eq, PartialOrd, Ord, Hash)]
17pub struct RebuildPass(u64);
18
19impl RebuildPass {
20 #[must_use]
21 const fn succ(self) -> Self {
22 Self(self.0.saturating_add(1))
23 }
24}
25
26#[derive(Copy, Clone, Debug, PartialEq, Eq)]
27pub enum RecomputeScope {
28 Full,
29 Edited(FeatureId),
30}
31
32#[derive(Copy, Clone, Debug, Default, PartialEq, Eq, PartialOrd, Ord, Hash)]
33pub struct RebuildCost(usize);
34
35#[derive(Copy, Clone, Debug, PartialEq, Eq, PartialOrd, Ord, Hash)]
36pub struct RebuildBudget(usize);
37
38impl RebuildBudget {
39 pub const INTERACTIVE: Self = Self(2048);
40
41 #[must_use]
42 pub const fn new(limit: usize) -> Self {
43 Self(limit)
44 }
45}
46
47impl RebuildCost {
48 #[must_use]
49 pub const fn new(weight: usize) -> Self {
50 Self(weight)
51 }
52
53 #[must_use]
54 pub const fn within(self, budget: RebuildBudget) -> bool {
55 self.0 <= budget.0
56 }
57}
58
59#[derive(Copy, Clone, Debug, PartialEq, Eq)]
60enum SkipState {
61 Active,
62 Suppressed,
63 RolledBack,
64}
65
66#[derive(Clone)]
67enum NodeOutput {
68 Datum,
69 Sketch(EvaluatedSketch),
70 Extrude(EvaluatedExtrude),
71 Imported(BrepSolid),
72 Unbuilt,
73}
74
75#[derive(Clone, PartialEq)]
76enum ParentDigest {
77 Datum,
78 Sketch(EvaluatedSketch),
79 Geometry(GeometryGeneration),
80 Absent,
81}
82
83#[derive(Clone, PartialEq)]
84enum PayloadKey {
85 Datum,
86 Sketch(Sketch, Option<FaceRef>),
87 Extrude(Option<ExtrudeFeature>),
88 Imported(BodyId),
89}
90
91#[derive(Clone, PartialEq)]
92struct NodeFingerprint {
93 payload: PayloadKey,
94 parents: Vec<(FeatureId, ParentDigest)>,
95}
96
97struct NodeMemo {
98 fingerprint: NodeFingerprint,
99 output: NodeOutput,
100 digest: ParentDigest,
101 skip: SkipState,
102 status: RebuildStatus,
103 built_at: RebuildPass,
104}
105
106type Memo = BTreeMap<FeatureId, NodeMemo>;
107
108#[derive(Default)]
109pub struct EvaluatedModel {
110 memo: Memo,
111 order: Vec<FeatureId>,
112 pass: RebuildPass,
113}
114
115impl EvaluatedModel {
116 #[must_use]
117 pub fn new() -> Self {
118 Self::default()
119 }
120
121 pub fn recompute(
122 &mut self,
123 document: &Document,
124 suppressed: &BTreeSet<FeatureId>,
125 rollback: RollbackMarker,
126 scope: RecomputeScope,
127 ) {
128 let tree = document.feature_tree();
129 debug_assert!(
130 tree.find_cycle().is_none(),
131 "recompute requires an acyclic feature graph",
132 );
133 debug_assert!(
134 tree.order_violation().is_none(),
135 "recompute requires every parent to precede its child in feature order",
136 );
137 let live: BTreeSet<FeatureId> = tree.iter().map(|(id, _)| id).collect();
138 let rolled = tree.rolled_back(rollback);
139 let suppressed = closed_over_descendants(tree, suppressed);
140 let pass = self.pass.succ();
141
142 let retained: Memo = core::mem::take(&mut self.memo)
143 .into_iter()
144 .filter(|(id, _)| live.contains(id))
145 .collect();
146 let dirty = dirty_set(tree, &live, &retained, &suppressed, &rolled, scope);
147 let order = tree.topo_order();
148
149 self.memo = order.iter().fold(retained, |mut memo, &id| {
150 if !dirty.contains(&id) {
151 return memo;
152 }
153 let Some(node) = tree.node(id) else {
154 return memo;
155 };
156 let fingerprint = NodeFingerprint {
157 payload: payload_key(document, node),
158 parents: parent_digests(tree, id, &memo),
159 };
160 let skip = skip_state(id, &suppressed, &rolled);
161 let unchanged = memo
162 .get(&id)
163 .is_some_and(|entry| entry.fingerprint == fingerprint && entry.skip == skip);
164 if !unchanged {
165 let entry = build_entry(document, id, node, &memo, fingerprint, skip, pass);
166 memo.insert(id, entry);
167 }
168 memo
169 });
170 self.order = order;
171 self.pass = pass;
172 }
173
174 #[must_use]
175 pub fn status(&self, id: FeatureId) -> Option<RebuildStatus> {
176 self.memo.get(&id).map(|entry| entry.status)
177 }
178
179 #[must_use]
180 pub fn body(&self, id: FeatureId) -> Option<&BrepSolid> {
181 self.memo
182 .get(&id)
183 .and_then(|entry| output_solid(&entry.output))
184 }
185
186 pub fn bodies(&self) -> impl Iterator<Item = (FeatureId, &BrepSolid)> + '_ {
187 self.order
188 .iter()
189 .filter_map(move |&id| self.body(id).map(|solid| (id, solid)))
190 }
191
192 #[must_use]
193 pub fn built_at(&self, id: FeatureId) -> Option<RebuildPass> {
194 self.memo.get(&id).map(|entry| entry.built_at)
195 }
196
197 #[must_use]
198 pub fn rebuild_cost(&self) -> RebuildCost {
199 RebuildCost(
200 self.bodies()
201 .map(|(_, solid)| solid.iter_faces().count() + solid.iter_edges().count())
202 .sum(),
203 )
204 }
205}
206
207fn closed_over_descendants(tree: &FeatureTree, seed: &BTreeSet<FeatureId>) -> BTreeSet<FeatureId> {
208 seed.iter()
209 .flat_map(|&feature| tree.descendants(feature))
210 .chain(seed.iter().copied())
211 .collect()
212}
213
214fn dirty_set(
215 tree: &FeatureTree,
216 live: &BTreeSet<FeatureId>,
217 retained: &Memo,
218 suppressed: &BTreeSet<FeatureId>,
219 rolled: &BTreeSet<FeatureId>,
220 scope: RecomputeScope,
221) -> BTreeSet<FeatureId> {
222 let scoped = match scope {
223 RecomputeScope::Full => live.clone(),
224 RecomputeScope::Edited(root) => subtree(tree, root),
225 };
226 let appeared = live.iter().copied().filter(|id| !retained.contains_key(id));
227 let reskinned = retained
228 .iter()
229 .filter(|(id, entry)| entry.skip != skip_state(**id, suppressed, rolled))
230 .flat_map(|(id, _)| subtree(tree, *id));
231 appeared
232 .chain(reskinned)
233 .filter(|id| live.contains(id))
234 .fold(scoped, |mut acc, id| {
235 acc.insert(id);
236 acc
237 })
238}
239
240fn subtree(tree: &FeatureTree, root: FeatureId) -> BTreeSet<FeatureId> {
241 tree.descendants(root).into_iter().chain([root]).collect()
242}
243
244fn skip_state(
245 id: FeatureId,
246 suppressed: &BTreeSet<FeatureId>,
247 rolled: &BTreeSet<FeatureId>,
248) -> SkipState {
249 match (rolled.contains(&id), suppressed.contains(&id)) {
250 (true, _) => SkipState::RolledBack,
251 (false, true) => SkipState::Suppressed,
252 (false, false) => SkipState::Active,
253 }
254}
255
256fn payload_key(document: &Document, node: FeatureNode) -> PayloadKey {
257 match node {
258 FeatureNode::Origin | FeatureNode::PrincipalPlane(_) => PayloadKey::Datum,
259 FeatureNode::Sketch(sketch) => document.sketch(sketch).map_or(PayloadKey::Datum, |value| {
260 PayloadKey::Sketch(value.clone(), document.sketch_plane_binding(sketch))
261 }),
262 FeatureNode::Extrude(extrude) => PayloadKey::Extrude(document.extrude(extrude).copied()),
263 FeatureNode::ImportedBody(body) => PayloadKey::Imported(body),
264 }
265}
266
267fn parent_digests(
268 tree: &FeatureTree,
269 id: FeatureId,
270 memo: &Memo,
271) -> Vec<(FeatureId, ParentDigest)> {
272 tree.parents(id)
273 .into_iter()
274 .map(|parent| {
275 let digest = memo
276 .get(&parent)
277 .map_or(ParentDigest::Absent, |entry| entry.digest.clone());
278 (parent, digest)
279 })
280 .collect()
281}
282
283fn build_entry(
284 document: &Document,
285 id: FeatureId,
286 node: FeatureNode,
287 memo: &Memo,
288 fingerprint: NodeFingerprint,
289 skip: SkipState,
290 pass: RebuildPass,
291) -> NodeMemo {
292 let output = match skip {
293 SkipState::Active => node_output(document, id, node, memo),
294 SkipState::Suppressed | SkipState::RolledBack => NodeOutput::Unbuilt,
295 };
296 let digest = digest_of(&output, skip);
297 let status = status_of(&output, skip);
298 NodeMemo {
299 fingerprint,
300 output,
301 digest,
302 skip,
303 status,
304 built_at: pass,
305 }
306}
307
308fn node_output(document: &Document, id: FeatureId, node: FeatureNode, memo: &Memo) -> NodeOutput {
309 match node {
310 FeatureNode::Origin | FeatureNode::PrincipalPlane(_) => NodeOutput::Datum,
311 FeatureNode::Sketch(sketch) => {
312 document
313 .sketch(sketch)
314 .map_or(NodeOutput::Unbuilt, |value| {
315 let evaluated = match document.sketch_plane_binding(sketch) {
316 None => evaluate_sketch(value),
317 Some(face) => face_bound_sketch(memo, value, face),
318 };
319 NodeOutput::Sketch(evaluated)
320 })
321 }
322 FeatureNode::Extrude(extrude) => {
323 let feature = document.extrude(extrude);
324 let profile = parent_sketch(document.feature_tree(), id, memo);
325 match (feature, profile) {
326 (Some(feature), Some(profile)) => {
327 NodeOutput::Extrude(evaluate_extrude(id, profile, feature))
328 }
329 _ => NodeOutput::Unbuilt,
330 }
331 }
332 FeatureNode::ImportedBody(body) => document
333 .imported_body(body)
334 .map_or(NodeOutput::Unbuilt, |solid| {
335 NodeOutput::Imported(solid.clone())
336 }),
337 }
338}
339
340fn face_bound_sketch(memo: &Memo, value: &Sketch, face: FaceRef) -> EvaluatedSketch {
341 let Some(entry) = memo.get(&face.label.feature) else {
342 return EvaluatedSketch::PlaneUnresolved(RebuildError::DanglingReference(
343 face.entity_ref(),
344 ));
345 };
346 let Some(solid) = output_solid(&entry.output) else {
347 return EvaluatedSketch::PlaneUnresolved(RebuildError::UpstreamUnresolved);
348 };
349 let Some(matched) = unique_face(solid, face.label) else {
350 return EvaluatedSketch::PlaneUnresolved(RebuildError::DanglingReference(
351 face.entity_ref(),
352 ));
353 };
354 match solid.face_plane_basis(matched.id()) {
355 Some(basis) => evaluate_sketch(&value.with_plane(basis)),
356 None => EvaluatedSketch::PlaneUnresolved(RebuildError::NonPlanarSketchTarget),
357 }
358}
359
360fn output_solid(output: &NodeOutput) -> Option<&BrepSolid> {
361 match output {
362 NodeOutput::Extrude(extrude) => extrude.solid(),
363 NodeOutput::Imported(solid) => Some(solid),
364 NodeOutput::Datum | NodeOutput::Sketch(_) | NodeOutput::Unbuilt => None,
365 }
366}
367
368fn parent_sketch<'memo>(
369 tree: &FeatureTree,
370 id: FeatureId,
371 memo: &'memo Memo,
372) -> Option<&'memo EvaluatedSketch> {
373 tree.parents(id).into_iter().find_map(|parent| {
374 memo.get(&parent).and_then(|entry| match &entry.output {
375 NodeOutput::Sketch(sketch) => Some(sketch),
376 NodeOutput::Datum
377 | NodeOutput::Extrude(_)
378 | NodeOutput::Imported(_)
379 | NodeOutput::Unbuilt => None,
380 })
381 })
382}
383
384fn digest_of(output: &NodeOutput, skip: SkipState) -> ParentDigest {
385 match skip {
386 SkipState::Suppressed | SkipState::RolledBack => ParentDigest::Absent,
387 SkipState::Active => match output {
388 NodeOutput::Datum => ParentDigest::Datum,
389 NodeOutput::Sketch(sketch) => ParentDigest::Sketch(sketch.clone()),
390 NodeOutput::Extrude(extrude) => extrude
391 .generation()
392 .map_or(ParentDigest::Absent, ParentDigest::Geometry),
393 NodeOutput::Imported(solid) => {
394 ParentDigest::Geometry(GeometryGeneration::from_solid_key(solid.content_key()))
395 }
396 NodeOutput::Unbuilt => ParentDigest::Absent,
397 },
398 }
399}
400
401fn status_of(output: &NodeOutput, skip: SkipState) -> RebuildStatus {
402 match skip {
403 SkipState::Suppressed | SkipState::RolledBack => RebuildStatus::UpToDate,
404 SkipState::Active => match output {
405 NodeOutput::Datum | NodeOutput::Imported(_) => RebuildStatus::UpToDate,
406 NodeOutput::Unbuilt => RebuildStatus::NeedsRebuild,
407 NodeOutput::Sketch(sketch) => match sketch {
408 EvaluatedSketch::Solved(_) => RebuildStatus::UpToDate,
409 EvaluatedSketch::Failed(_) => {
410 RebuildStatus::Error(RebuildError::Build(BuildFailure::UnsolvedSketch))
411 }
412 EvaluatedSketch::PlaneUnresolved(error) => RebuildStatus::Error(*error),
413 },
414 NodeOutput::Extrude(extrude) => match extrude.result() {
415 Ok(_) => RebuildStatus::UpToDate,
416 Err(ExtrudeError::UnsolvedSketch(_)) => {
417 RebuildStatus::Error(RebuildError::Build(BuildFailure::UnsolvedSketch))
418 }
419 Err(ExtrudeError::Kernel(_)) => {
420 RebuildStatus::Error(RebuildError::Build(BuildFailure::Kernel))
421 }
422 Err(ExtrudeError::PlaneUnresolved(_)) => {
423 RebuildStatus::Error(RebuildError::UpstreamUnresolved)
424 }
425 },
426 },
427 }
428}
429
430#[cfg(test)]
431mod tests {
432 use std::collections::BTreeSet;
433
434 use bone_types::{
435 BuildFailure, DocumentId, FaceRef, FeatureId, RebuildError, RebuildStatus, RollbackMarker,
436 };
437 use proptest::prelude::*;
438
439 use super::{EvaluatedModel, RebuildBudget, RebuildCost, RecomputeScope};
440 use crate::document::Document;
441 use crate::test_support::{
442 blind_extrude, chain_handles, circle_sketch, end_cap_ref, full_model, open_chain_sketch,
443 placeholder_fingerprint, push_chain, push_face_bound_chain, side_face_label,
444 };
445
446 fn body_max_z(model: &EvaluatedModel, body: FeatureId) -> f64 {
447 let Some(solid) = model.body(body) else {
448 panic!("the body is built");
449 };
450 let Some(aabb) = solid.bounding_box() else {
451 panic!("a built body has a bounding box");
452 };
453 aabb.max().coords_mm().2
454 }
455
456 fn body_min_z(model: &EvaluatedModel, body: FeatureId) -> f64 {
457 let Some(solid) = model.body(body) else {
458 panic!("the body is built");
459 };
460 let Some(aabb) = solid.bounding_box() else {
461 panic!("a built body has a bounding box");
462 };
463 aabb.min().coords_mm().2
464 }
465
466 fn body_keys(model: &EvaluatedModel) -> Vec<(FeatureId, [u8; 16])> {
467 model
468 .bodies()
469 .map(|(id, solid)| (id, solid.content_key().bytes()))
470 .collect()
471 }
472
473 #[test]
474 fn an_extrude_chain_builds_one_body() {
475 let mut document = Document::new(DocumentId::default(), "doc".to_owned());
476 let chain = push_chain(&mut document, "Sketch1", 5.0, 4.0);
477 let model = full_model(&document);
478
479 assert!(
480 model.body(chain.extrude).is_some(),
481 "the extrude yields a body"
482 );
483 assert_eq!(model.status(chain.extrude), Some(RebuildStatus::UpToDate));
484 assert_eq!(model.status(chain.sketch), Some(RebuildStatus::UpToDate));
485 assert_eq!(model.bodies().count(), 1, "one extrude, one body");
486 }
487
488 #[test]
489 fn recompute_is_idempotent_without_an_edit() {
490 let mut document = Document::new(DocumentId::default(), "doc".to_owned());
491 let chain = push_chain(&mut document, "Sketch1", 5.0, 4.0);
492 let mut model = full_model(&document);
493 let first = model.built_at(chain.extrude);
494
495 model.recompute(
496 &document,
497 &BTreeSet::new(),
498 RollbackMarker::AtEnd,
499 RecomputeScope::Edited(chain.sketch),
500 );
501
502 assert_eq!(
503 model.built_at(chain.extrude),
504 first,
505 "an edit scope with no real input change must not re-evaluate the subtree",
506 );
507 }
508
509 #[test]
510 fn recompute_cascade_closes_a_raw_suppressed_sketch() {
511 let mut document = Document::new(DocumentId::default(), "doc".to_owned());
512 let chain = push_chain(&mut document, "Sketch1", 5.0, 4.0);
513 let mut model = EvaluatedModel::new();
514 let suppressed = BTreeSet::from([chain.sketch]);
515 model.recompute(
516 &document,
517 &suppressed,
518 RollbackMarker::AtEnd,
519 RecomputeScope::Full,
520 );
521
522 assert!(model.body(chain.extrude).is_none(), "no profile, no body");
523 assert_eq!(
524 model.status(chain.sketch),
525 Some(RebuildStatus::UpToDate),
526 "a suppressed feature is not an error",
527 );
528 assert_eq!(
529 model.status(chain.extrude),
530 Some(RebuildStatus::UpToDate),
531 "recompute closes the suppressed set over descendants, so the child is suppressed not stranded",
532 );
533 }
534
535 #[test]
536 fn a_cascaded_suppression_marks_the_extrude_suppressed_not_stranded() {
537 let mut document = Document::new(DocumentId::default(), "doc".to_owned());
538 let chain = push_chain(&mut document, "Sketch1", 5.0, 4.0);
539 document.suppress(chain.sketch);
540 let mut model = EvaluatedModel::new();
541 model.recompute(
542 &document,
543 document.suppressed(),
544 document.rollback(),
545 RecomputeScope::Full,
546 );
547
548 assert!(
549 model.body(chain.extrude).is_none(),
550 "a suppressed chain builds no body",
551 );
552 assert_eq!(
553 model.status(chain.sketch),
554 Some(RebuildStatus::UpToDate),
555 "a suppressed feature is not an error",
556 );
557 assert_eq!(
558 model.status(chain.extrude),
559 Some(RebuildStatus::UpToDate),
560 "the cascaded child is suppressed, not a stranded needs-rebuild",
561 );
562 }
563
564 #[test]
565 fn rolling_back_above_an_extrude_drops_its_body() {
566 let mut document = Document::new(DocumentId::default(), "doc".to_owned());
567 let chain = push_chain(&mut document, "Sketch1", 5.0, 4.0);
568 let mut model = EvaluatedModel::new();
569 model.recompute(
570 &document,
571 &BTreeSet::new(),
572 RollbackMarker::Above(chain.extrude),
573 RecomputeScope::Full,
574 );
575
576 assert!(
577 model.body(chain.extrude).is_none(),
578 "a rolled-back extrude is not evaluated",
579 );
580 assert_eq!(model.bodies().count(), 0);
581 assert_eq!(model.status(chain.sketch), Some(RebuildStatus::UpToDate));
582 }
583
584 #[test]
585 fn a_feature_both_rolled_back_and_suppressed_stays_up_to_date() {
586 let mut document = Document::new(DocumentId::default(), "doc".to_owned());
587 let chain = push_chain(&mut document, "Sketch1", 5.0, 4.0);
588 let mut model = EvaluatedModel::new();
589 let suppressed = BTreeSet::from([chain.extrude]);
590 model.recompute(
591 &document,
592 &suppressed,
593 RollbackMarker::Above(chain.extrude),
594 RecomputeScope::Full,
595 );
596
597 assert!(
598 model.body(chain.extrude).is_none(),
599 "a feature that is both rolled back and suppressed builds nothing",
600 );
601 assert_eq!(
602 model.status(chain.extrude),
603 Some(RebuildStatus::UpToDate),
604 "overlapping skip states collapse to a single up-to-date skip, never an error",
605 );
606 }
607
608 #[test]
609 fn a_suppression_outside_the_edit_scope_still_takes_effect() {
610 let mut document = Document::new(DocumentId::default(), "doc".to_owned());
611 let a = push_chain(&mut document, "Sketch1", 5.0, 4.0);
612 let b = push_chain(&mut document, "Sketch2", 6.0, 4.0);
613 let mut model = full_model(&document);
614 assert!(
615 model.body(b.extrude).is_some(),
616 "b builds before suppression"
617 );
618
619 document.replace_sketch(a.sketch_id, circle_sketch(7.0));
620 let suppressed = BTreeSet::from([b.sketch]);
621 model.recompute(
622 &document,
623 &suppressed,
624 RollbackMarker::AtEnd,
625 RecomputeScope::Edited(a.sketch),
626 );
627
628 assert!(
629 model.body(b.extrude).is_none(),
630 "suppressing a feature outside the edit scope must still strand its body",
631 );
632 assert_eq!(
633 model.status(b.sketch),
634 Some(RebuildStatus::UpToDate),
635 "a suppressed feature is not an error",
636 );
637 assert!(
638 model.body(a.extrude).is_some(),
639 "the edited chain still builds"
640 );
641 }
642
643 #[test]
644 fn a_rollback_outside_the_edit_scope_still_takes_effect() {
645 let mut document = Document::new(DocumentId::default(), "doc".to_owned());
646 let a = push_chain(&mut document, "Sketch1", 5.0, 4.0);
647 let b = push_chain(&mut document, "Sketch2", 6.0, 4.0);
648 let mut model = full_model(&document);
649 assert!(model.body(b.extrude).is_some(), "b builds before rollback");
650
651 document.replace_sketch(a.sketch_id, circle_sketch(7.0));
652 model.recompute(
653 &document,
654 &BTreeSet::new(),
655 RollbackMarker::Above(b.extrude),
656 RecomputeScope::Edited(a.sketch),
657 );
658
659 assert!(
660 model.body(b.extrude).is_none(),
661 "rolling back a feature outside the edit scope must still drop its body",
662 );
663 }
664
665 #[test]
666 fn an_unbuildable_profile_reports_a_build_error() {
667 let mut document = Document::new(DocumentId::default(), "doc".to_owned());
668 let sketch_id = document.allocate_sketch();
669 document.insert_sketch(sketch_id, "Sketch1".to_owned(), open_chain_sketch());
670 let extrude_id = document.commit_extrude(blind_extrude(sketch_id, 4.0));
671 let chain = chain_handles(&document, sketch_id, extrude_id);
672 let model = full_model(&document);
673
674 assert_eq!(
675 model.status(chain.sketch),
676 Some(RebuildStatus::UpToDate),
677 "the open chain solves, only the profile is unbuildable",
678 );
679 assert_eq!(
680 model.status(chain.extrude),
681 Some(RebuildStatus::Error(RebuildError::Build(
682 BuildFailure::Kernel
683 ))),
684 "an open profile is a hard build error, not a stale mark",
685 );
686 assert!(model.body(chain.extrude).is_none());
687 }
688
689 proptest! {
690 #[test]
691 fn rebuilding_twice_is_bit_identical(radius in 1.0f64..20.0, depth in 1.0f64..20.0) {
692 let mut document = Document::new(DocumentId::default(), "doc".to_owned());
693 push_chain(&mut document, "Sketch1", radius, depth);
694 let first = body_keys(&full_model(&document));
695 let second = body_keys(&full_model(&document));
696 prop_assert_eq!(first, second, "two rebuilds of one document must agree byte for byte");
697 }
698
699 #[test]
700 fn editing_one_chain_leaves_the_other_untouched(
701 radius_a in 1.0f64..10.0,
702 radius_b in 1.0f64..10.0,
703 edited in 11.0f64..20.0,
704 ) {
705 let mut document = Document::new(DocumentId::default(), "doc".to_owned());
706 let a = push_chain(&mut document, "Sketch1", radius_a, 4.0);
707 let b = push_chain(&mut document, "Sketch2", radius_b, 4.0);
708 let mut model = full_model(&document);
709
710 let b_sketch_before = model.built_at(b.sketch);
711 let b_extrude_before = model.built_at(b.extrude);
712 let b_body_before = model.body(b.extrude).map(bone_kernel::BrepSolid::content_key);
713 let a_extrude_before = model.built_at(a.extrude);
714
715 document.replace_sketch(a.sketch_id, circle_sketch(edited));
716 model.recompute(
717 &document,
718 &BTreeSet::new(),
719 RollbackMarker::AtEnd,
720 RecomputeScope::Edited(a.sketch),
721 );
722
723 prop_assert_eq!(model.built_at(b.sketch), b_sketch_before, "sibling sketch must not re-evaluate");
724 prop_assert_eq!(model.built_at(b.extrude), b_extrude_before, "sibling extrude must not re-evaluate");
725 prop_assert_eq!(
726 model.body(b.extrude).map(bone_kernel::BrepSolid::content_key),
727 b_body_before,
728 "the untouched body must stay byte-identical",
729 );
730 prop_assert!(
731 model.built_at(a.extrude) > a_extrude_before,
732 "the edited chain's extrude must rebuild",
733 );
734 }
735 }
736
737 #[test]
738 #[cfg_attr(
739 debug_assertions,
740 ignore = "frame budget assertions are only meaningful in release builds"
741 )]
742 fn single_edit_rebuild_under_frame_budget() {
743 use std::time::{Duration, Instant};
744
745 const BASE_MM: f64 = 4.0;
746 const STEP_MM: f64 = 0.5;
747 const STEPS: u32 = 16;
748
749 let mut document = Document::new(DocumentId::default(), "doc".to_owned());
750 let chain = push_chain(&mut document, "Sketch1", 5.0, BASE_MM);
751 let mut model = full_model(&document);
752
753 let step = |document: &mut Document, model: &mut EvaluatedModel, radius_mm: f64| {
754 document.replace_sketch(chain.sketch_id, circle_sketch(radius_mm));
755 let started = Instant::now();
756 model.recompute(
757 document,
758 &BTreeSet::new(),
759 RollbackMarker::AtEnd,
760 RecomputeScope::Edited(chain.sketch),
761 );
762 let elapsed = started.elapsed();
763 assert!(
764 model.body(chain.extrude).is_some(),
765 "the edit rebuilds a body"
766 );
767 elapsed
768 };
769
770 let _warmup = step(&mut document, &mut model, 5.0);
771 let durations: Vec<Duration> = (0..STEPS)
772 .map(|i| step(&mut document, &mut model, 5.0 + STEP_MM * f64::from(i)))
773 .collect();
774 let sorted = {
775 let mut v = durations.clone();
776 v.sort_unstable();
777 v
778 };
779 let median = sorted[sorted.len() / 2];
780 let budget = Duration::from_millis(16);
781 assert!(
782 median <= budget,
783 "median single-edit recompute {median:?} exceeds {budget:?} frame budget; samples {durations:?}",
784 );
785 }
786
787 #[test]
788 fn a_face_bound_sketch_builds_a_disjoint_body_on_the_cap() {
789 let mut document = Document::new(DocumentId::default(), "doc".to_owned());
790 let base = push_chain(&mut document, "Base", 6.0, 4.0);
791 let probe = full_model(&document);
792 let cap = end_cap_ref(&probe, base.extrude);
793 let top = push_face_bound_chain(&mut document, "Top", cap, 3.0, 5.0);
794 let model = full_model(&document);
795
796 let descendants = document.feature_tree().descendants(base.extrude);
797 assert!(
798 descendants.contains(&top.sketch) && descendants.contains(&top.extrude),
799 "the face binding records a dependency from the cap's body onto the new chain",
800 );
801 assert_eq!(model.status(top.sketch), Some(RebuildStatus::UpToDate));
802 assert!(model.body(top.extrude).is_some());
803 assert_eq!(model.bodies().count(), 2, "two separate bodies, no boolean");
804
805 let cap_z = body_max_z(&model, base.extrude);
806 assert!(
807 (body_min_z(&model, top.extrude) - cap_z).abs() < 1.0e-6,
808 "the derived plane sits on the cap so the second body rises from it",
809 );
810 }
811
812 #[test]
813 fn an_upstream_edit_moves_the_face_bound_body() {
814 let mut document = Document::new(DocumentId::default(), "doc".to_owned());
815 let base = push_chain(&mut document, "Base", 6.0, 4.0);
816 let probe = full_model(&document);
817 let cap = end_cap_ref(&probe, base.extrude);
818 let top = push_face_bound_chain(&mut document, "Top", cap, 3.0, 5.0);
819 let mut model = full_model(&document);
820 let before = model.built_at(top.extrude);
821
822 document.insert_extrude(base.extrude_id, blind_extrude(base.sketch_id, 7.0));
823 model.recompute(
824 &document,
825 &BTreeSet::new(),
826 RollbackMarker::AtEnd,
827 RecomputeScope::Edited(base.extrude),
828 );
829
830 assert!(
831 model.built_at(top.extrude) > before,
832 "deepening the base re-resolves the face and rebuilds the downstream body",
833 );
834 assert!((body_max_z(&model, base.extrude) - 7.0).abs() < 1.0e-6);
835 assert!(
836 (body_min_z(&model, top.extrude) - 7.0).abs() < 1.0e-6,
837 "the face-bound body follows the cap to its new height",
838 );
839 }
840
841 #[test]
842 fn a_deleted_cap_dangles_the_face_bound_chain() {
843 let mut document = Document::new(DocumentId::default(), "doc".to_owned());
844 let base = push_chain(&mut document, "Base", 6.0, 4.0);
845 let probe = full_model(&document);
846 let cap = end_cap_ref(&probe, base.extrude);
847 let top = push_face_bound_chain(&mut document, "Top", cap, 3.0, 5.0);
848 let mut model = full_model(&document);
849 assert!(model.body(top.extrude).is_some(), "the chain builds first");
850
851 document.remove_extrude(base.extrude_id);
852 model.recompute(
853 &document,
854 &BTreeSet::new(),
855 RollbackMarker::AtEnd,
856 RecomputeScope::Full,
857 );
858
859 assert!(
860 matches!(
861 model.status(top.sketch),
862 Some(RebuildStatus::Error(RebuildError::DanglingReference(_)))
863 ),
864 "a removed cap leaves the face-bound sketch dangling, never a silent wrong plane",
865 );
866 assert_eq!(
867 model.status(top.extrude),
868 Some(RebuildStatus::Error(RebuildError::UpstreamUnresolved)),
869 "the downstream extrude fails as upstream-unresolved, not as a dangling owner",
870 );
871 assert!(model.body(top.extrude).is_none());
872 }
873
874 #[test]
875 fn a_non_planar_target_is_a_typed_rebuild_error() {
876 let mut document = Document::new(DocumentId::default(), "doc".to_owned());
877 let base = push_chain(&mut document, "Base", 6.0, 4.0);
878 let probe = full_model(&document);
879 let side = side_face_label(&probe, base.extrude);
880 let top = push_face_bound_chain(
881 &mut document,
882 "Top",
883 FaceRef::new(side, placeholder_fingerprint()),
884 1.0,
885 2.0,
886 );
887 let model = full_model(&document);
888
889 assert_eq!(
890 model.status(top.sketch),
891 Some(RebuildStatus::Error(RebuildError::NonPlanarSketchTarget)),
892 "a sketch bound to a cylindrical side fails with the non-planar contract",
893 );
894 assert!(model.body(top.extrude).is_none());
895 }
896
897 #[test]
898 fn rebuild_cost_sums_topology_and_gates_on_the_budget() {
899 let mut document = Document::new(DocumentId::default(), "doc".to_owned());
900 push_chain(&mut document, "Sketch1", 5.0, 4.0);
901 let model = full_model(&document);
902 let cost = model.rebuild_cost();
903
904 assert!(
905 cost > RebuildCost::default(),
906 "a built body contributes faces and edges to the cost",
907 );
908 assert!(
909 cost.within(RebuildBudget::INTERACTIVE),
910 "a single extrude stays under the interactive budget, so the rebuild is eager",
911 );
912 assert!(
913 !cost.within(RebuildBudget::new(0)),
914 "a zero budget forces the deferred path for any non-empty model",
915 );
916 assert_eq!(
917 EvaluatedModel::new().rebuild_cost(),
918 RebuildCost::default(),
919 "an empty model costs nothing to rebuild",
920 );
921 }
922
923 proptest! {
924 #[test]
925 fn the_face_bound_body_tracks_the_cap_across_depth_edits(
926 base_depth in 2.0f64..12.0,
927 edited_depth in 2.0f64..12.0,
928 ) {
929 let mut document = Document::new(DocumentId::default(), "doc".to_owned());
930 let base = push_chain(&mut document, "Base", 6.0, base_depth);
931 let probe = full_model(&document);
932 let cap = end_cap_ref(&probe, base.extrude);
933 let top = push_face_bound_chain(&mut document, "Top", cap, 3.0, 5.0);
934 let mut model = full_model(&document);
935
936 document.insert_extrude(base.extrude_id, blind_extrude(base.sketch_id, edited_depth));
937 model.recompute(
938 &document,
939 &BTreeSet::new(),
940 RollbackMarker::AtEnd,
941 RecomputeScope::Edited(base.extrude),
942 );
943
944 prop_assert!(
945 (body_min_z(&model, top.extrude) - edited_depth).abs() < 1.0e-6,
946 "the face-bound body always rises from the current cap height",
947 );
948 }
949 }
950}