Another project
0

Configure Feed

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

at main 33 kB View raw
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}