Another project
0

Configure Feed

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

feat(document): sketch profile builder, cached extrude eval

Lewis: May this revision serve well! <lu5a@proton.me>

author
Lewis
date (Jun 2, 2026, 1:50 PM +0300) commit 40f6a5d0 parent e700487e change-id wqmustwn
+757 -27
+128 -26
crates/bone-document/src/evaluator.rs
··· 1 1 use std::collections::{BTreeMap, BTreeSet, btree_map::Entry}; 2 2 3 + use bone_kernel::{BrepError, BrepSolid, ExtrudeFeature}; 3 4 use bone_solver::SolverError; 4 - use bone_types::FeatureId; 5 + use bone_types::{FeatureId, GeometryGeneration}; 5 6 6 7 use crate::Sketch; 8 + use crate::profile::build_profile; 7 9 8 10 #[derive(Clone, Debug, PartialEq)] 9 11 pub enum EvaluatedSketch { ··· 19 21 } 20 22 } 21 23 24 + #[derive(Clone, Debug)] 25 + pub enum ExtrudeError { 26 + UnsolvedSketch(SolverError), 27 + Kernel(BrepError), 28 + } 29 + 30 + #[derive(Clone)] 31 + pub struct EvaluatedExtrude { 32 + result: Result<BrepSolid, ExtrudeError>, 33 + generation: Option<GeometryGeneration>, 34 + } 35 + 36 + impl EvaluatedExtrude { 37 + #[must_use] 38 + pub fn result(&self) -> &Result<BrepSolid, ExtrudeError> { 39 + &self.result 40 + } 41 + 42 + #[must_use] 43 + pub fn solid(&self) -> Option<&BrepSolid> { 44 + self.result.as_ref().ok() 45 + } 46 + 47 + #[must_use] 48 + pub fn generation(&self) -> Option<GeometryGeneration> { 49 + self.generation 50 + } 51 + } 52 + 53 + impl core::fmt::Debug for EvaluatedExtrude { 54 + fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result { 55 + let mut state = f.debug_struct("EvaluatedExtrude"); 56 + state.field("generation", &self.generation); 57 + match &self.result { 58 + Ok(solid) => state.field("faces", &solid.iter_faces().count()), 59 + Err(error) => state.field("error", error), 60 + }; 61 + state.finish() 62 + } 63 + } 64 + 65 + #[must_use] 66 + pub fn evaluate_extrude( 67 + extrude: FeatureId, 68 + sketch: &EvaluatedSketch, 69 + feature: &ExtrudeFeature, 70 + ) -> EvaluatedExtrude { 71 + let result = match sketch { 72 + EvaluatedSketch::Solved(solved) => build_profile(solved) 73 + .and_then(|profile| bone_kernel::evaluate_extrude(extrude, &profile, feature)) 74 + .map_err(ExtrudeError::Kernel), 75 + EvaluatedSketch::Failed(error) => Err(ExtrudeError::UnsolvedSketch(error.clone())), 76 + }; 77 + let generation = result 78 + .as_ref() 79 + .ok() 80 + .map(|solid| GeometryGeneration::from_solid_key(solid.content_key())); 81 + EvaluatedExtrude { result, generation } 82 + } 83 + 22 84 #[derive(Clone, Debug, Default)] 23 85 pub struct FeatureCache { 24 - entries: BTreeMap<FeatureId, CachedSketch>, 86 + sketches: BTreeMap<FeatureId, Cached<Sketch, EvaluatedSketch>>, 87 + extrudes: BTreeMap<FeatureId, Cached<(EvaluatedSketch, ExtrudeFeature), EvaluatedExtrude>>, 25 88 } 26 89 27 90 #[derive(Clone, Debug)] 28 - struct CachedSketch { 29 - input: Sketch, 30 - output: EvaluatedSketch, 91 + struct Cached<I, O> { 92 + input: I, 93 + output: O, 94 + } 95 + 96 + fn memoize<I, O>( 97 + cache: &mut BTreeMap<FeatureId, Cached<I, O>>, 98 + feature: FeatureId, 99 + matches: impl FnOnce(&I) -> bool, 100 + build_input: impl FnOnce() -> I, 101 + eval: impl FnOnce(&I) -> O, 102 + ) -> &O { 103 + let cached = match cache.entry(feature) { 104 + Entry::Occupied(mut slot) => { 105 + if !matches(&slot.get().input) { 106 + let input = build_input(); 107 + let output = eval(&input); 108 + *slot.get_mut() = Cached { input, output }; 109 + } 110 + slot.into_mut() 111 + } 112 + Entry::Vacant(slot) => { 113 + let input = build_input(); 114 + let output = eval(&input); 115 + slot.insert(Cached { input, output }) 116 + } 117 + }; 118 + &cached.output 31 119 } 32 120 33 121 impl FeatureCache { ··· 37 125 } 38 126 39 127 pub fn evaluate(&mut self, feature: FeatureId, input: &Sketch) -> &EvaluatedSketch { 40 - let cached = match self.entries.entry(feature) { 41 - Entry::Occupied(mut slot) => { 42 - if &slot.get().input != input { 43 - *slot.get_mut() = CachedSketch { 44 - input: input.clone(), 45 - output: evaluate_sketch(input), 46 - }; 47 - } 48 - slot.into_mut() 49 - } 50 - Entry::Vacant(slot) => slot.insert(CachedSketch { 51 - input: input.clone(), 52 - output: evaluate_sketch(input), 53 - }), 54 - }; 55 - &cached.output 128 + memoize( 129 + &mut self.sketches, 130 + feature, 131 + |cached| cached == input, 132 + || input.clone(), 133 + evaluate_sketch, 134 + ) 135 + } 136 + 137 + pub fn evaluate_extrude( 138 + &mut self, 139 + feature: FeatureId, 140 + sketch: &EvaluatedSketch, 141 + extrude: &ExtrudeFeature, 142 + ) -> &EvaluatedExtrude { 143 + memoize( 144 + &mut self.extrudes, 145 + feature, 146 + |(cached_sketch, cached_extrude)| cached_sketch == sketch && cached_extrude == extrude, 147 + || (sketch.clone(), *extrude), 148 + |(sketch, extrude)| evaluate_extrude(feature, sketch, extrude), 149 + ) 56 150 } 57 151 58 152 #[must_use] 59 153 pub fn lookup(&self, feature: FeatureId) -> Option<&EvaluatedSketch> { 60 - self.entries.get(&feature).map(|cached| &cached.output) 154 + self.sketches.get(&feature).map(|cached| &cached.output) 155 + } 156 + 157 + #[must_use] 158 + pub fn lookup_extrude(&self, feature: FeatureId) -> Option<&EvaluatedExtrude> { 159 + self.extrudes.get(&feature).map(|cached| &cached.output) 61 160 } 62 161 63 162 pub fn invalidate(&mut self, feature: FeatureId) -> bool { 64 - self.entries.remove(&feature).is_some() 163 + let sketch = self.sketches.remove(&feature).is_some(); 164 + let extrude = self.extrudes.remove(&feature).is_some(); 165 + sketch || extrude 65 166 } 66 167 67 168 pub fn retain(&mut self, live: impl IntoIterator<Item = FeatureId>) { 68 169 let keep: BTreeSet<FeatureId> = live.into_iter().collect(); 69 - self.entries.retain(|id, _| keep.contains(id)); 170 + self.sketches.retain(|id, _| keep.contains(id)); 171 + self.extrudes.retain(|id, _| keep.contains(id)); 70 172 } 71 173 72 174 #[must_use] 73 175 pub fn len(&self) -> usize { 74 - self.entries.len() 176 + self.sketches.len() + self.extrudes.len() 75 177 } 76 178 77 179 #[must_use] 78 180 pub fn is_empty(&self) -> bool { 79 - self.entries.is_empty() 181 + self.sketches.is_empty() && self.extrudes.is_empty() 80 182 } 81 183 }
+5 -1
crates/bone-document/src/lib.rs
··· 1 1 pub mod document; 2 2 pub mod evaluator; 3 3 pub mod io; 4 + mod profile; 4 5 pub mod sketch; 5 6 pub mod undo; 6 7 ··· 9 10 PrincipalPlane, RenameSketchError, SketchFile, SketchRegistry, SketchRegistryEntry, 10 11 UnitsPreference, sketch_filename, 11 12 }; 12 - pub use evaluator::{EvaluatedSketch, FeatureCache, evaluate_sketch}; 13 + pub use evaluator::{ 14 + EvaluatedExtrude, EvaluatedSketch, ExtrudeError, FeatureCache, evaluate_extrude, 15 + evaluate_sketch, 16 + }; 13 17 pub use io::{ 14 18 BlobHash, BlobKind, DocumentFolder, FolderError, FolderErrorKind, LabelSidecar, RonError, 15 19 from_str, load, read_solid, save, to_string, write_solid,
+624
crates/bone-document/src/profile.rs
··· 1 + use std::collections::{BTreeMap, BTreeSet}; 2 + 3 + use bone_kernel::{ 4 + Arc2, BrepError, Circle2, Curve2Kind, ExtrudeProfile, Line2, ProfileDefect, ProfileEdge, 5 + ProfileLoop, 6 + }; 7 + use bone_types::{Plane3, Point2, SketchEntityId, Tolerance}; 8 + 9 + use crate::sketch::{ArcData, CircleData, LineData, Sketch, SketchEntity}; 10 + 11 + const PROFILE_TOLERANCE: Tolerance = Tolerance::new(1.0e-9); 12 + 13 + pub(crate) fn build_profile(sketch: &Sketch) -> Result<ExtrudeProfile, BrepError> { 14 + let plane = Plane3::from(sketch.plane()); 15 + let circles = circle_loops(sketch)?; 16 + let chains = chain_loops(sketch)?; 17 + let loops = circles.into_iter().chain(chains).collect(); 18 + Ok(ExtrudeProfile::new(plane, loops)) 19 + } 20 + 21 + fn defect(reason: ProfileDefect) -> BrepError { 22 + BrepError::InvalidProfile { reason } 23 + } 24 + 25 + fn point_at(sketch: &Sketch, id: SketchEntityId) -> Point2 { 26 + let Some(SketchEntity::Point(point)) = sketch.entities().get(id) else { 27 + unreachable!("Sketch validation guarantees entity references resolve to points"); 28 + }; 29 + point.at() 30 + } 31 + 32 + fn circle_loops(sketch: &Sketch) -> Result<Vec<ProfileLoop>, BrepError> { 33 + sketch 34 + .entity_order() 35 + .iter() 36 + .filter_map(|id| match sketch.entities().get(*id) { 37 + Some(SketchEntity::Circle(circle)) if !circle.for_construction() => { 38 + Some((*id, *circle)) 39 + } 40 + _ => None, 41 + }) 42 + .map(|(id, circle)| circle_loop(sketch, id, circle)) 43 + .collect() 44 + } 45 + 46 + fn circle_loop( 47 + sketch: &Sketch, 48 + id: SketchEntityId, 49 + circle: CircleData, 50 + ) -> Result<ProfileLoop, BrepError> { 51 + let center = point_at(sketch, circle.center()); 52 + let disk = Circle2::new(center, circle.radius(), PROFILE_TOLERANCE) 53 + .map_err(|_| defect(ProfileDefect::ZeroArea))?; 54 + Ok(ProfileLoop::Closed { 55 + curve: Curve2Kind::Circle(disk), 56 + curve_entity: id, 57 + }) 58 + } 59 + 60 + #[derive(Copy, Clone)] 61 + enum EdgeCurve { 62 + Line(Line2), 63 + Arc(Arc2), 64 + } 65 + 66 + impl EdgeCurve { 67 + fn into_kind(self) -> Curve2Kind { 68 + match self { 69 + Self::Line(line) => Curve2Kind::Line(line), 70 + Self::Arc(arc) => Curve2Kind::Arc(arc), 71 + } 72 + } 73 + 74 + fn reversed(self) -> Result<Self, BrepError> { 75 + match self { 76 + Self::Line(line) => Line2::new(line.end(), line.start(), PROFILE_TOLERANCE) 77 + .map(Self::Line) 78 + .map_err(|_| defect(ProfileDefect::ZeroArea)), 79 + Self::Arc(arc) => Arc2::new( 80 + arc.center(), 81 + arc.radius(), 82 + arc.start_angle() + arc.sweep_angle(), 83 + -arc.sweep_angle(), 84 + PROFILE_TOLERANCE, 85 + ) 86 + .map(Self::Arc) 87 + .map_err(|_| defect(ProfileDefect::ZeroArea)), 88 + } 89 + } 90 + } 91 + 92 + #[derive(Copy, Clone)] 93 + struct RawEdge { 94 + entity: SketchEntityId, 95 + from: SketchEntityId, 96 + to: SketchEntityId, 97 + curve: EdgeCurve, 98 + } 99 + 100 + impl RawEdge { 101 + fn other(self, point: SketchEntityId) -> SketchEntityId { 102 + debug_assert!( 103 + point == self.from || point == self.to, 104 + "other() requires a vertex of this edge" 105 + ); 106 + if point == self.from { 107 + self.to 108 + } else { 109 + self.from 110 + } 111 + } 112 + 113 + fn oriented(self, start: SketchEntityId) -> Result<Curve2Kind, BrepError> { 114 + if start == self.from { 115 + Ok(self.curve.into_kind()) 116 + } else { 117 + self.curve.reversed().map(EdgeCurve::into_kind) 118 + } 119 + } 120 + } 121 + 122 + fn raw_edges(sketch: &Sketch) -> Result<Vec<RawEdge>, BrepError> { 123 + sketch 124 + .entity_order() 125 + .iter() 126 + .filter_map(|id| match sketch.entities().get(*id) { 127 + Some(SketchEntity::Line(line)) if !line.for_construction() => { 128 + Some(line_edge(sketch, *id, *line)) 129 + } 130 + Some(SketchEntity::Arc(arc)) if !arc.for_construction() => { 131 + Some(arc_edge(sketch, *id, *arc)) 132 + } 133 + _ => None, 134 + }) 135 + .collect() 136 + } 137 + 138 + fn line_edge(sketch: &Sketch, id: SketchEntityId, line: LineData) -> Result<RawEdge, BrepError> { 139 + let start = point_at(sketch, line.a()); 140 + let end = point_at(sketch, line.b()); 141 + let segment = 142 + Line2::new(start, end, PROFILE_TOLERANCE).map_err(|_| defect(ProfileDefect::ZeroArea))?; 143 + Ok(RawEdge { 144 + entity: id, 145 + from: line.a(), 146 + to: line.b(), 147 + curve: EdgeCurve::Line(segment), 148 + }) 149 + } 150 + 151 + fn arc_edge(sketch: &Sketch, id: SketchEntityId, arc: ArcData) -> Result<RawEdge, BrepError> { 152 + let center = point_at(sketch, arc.center()); 153 + let start = point_at(sketch, arc.start()); 154 + let end = point_at(sketch, arc.end()); 155 + let curve = Arc2::from_center_start_end(center, start, end, PROFILE_TOLERANCE) 156 + .map_err(|_| defect(ProfileDefect::ZeroArea))?; 157 + Ok(RawEdge { 158 + entity: id, 159 + from: arc.start(), 160 + to: arc.end(), 161 + curve: EdgeCurve::Arc(curve), 162 + }) 163 + } 164 + 165 + type Incidence = BTreeMap<SketchEntityId, [usize; 2]>; 166 + 167 + fn chain_loops(sketch: &Sketch) -> Result<Vec<ProfileLoop>, BrepError> { 168 + let edges = raw_edges(sketch)?; 169 + if edges.is_empty() { 170 + return Ok(Vec::new()); 171 + } 172 + let incidence = incidence(&edges)?; 173 + walk_all(&edges, &incidence) 174 + } 175 + 176 + fn incidence(edges: &[RawEdge]) -> Result<Incidence, BrepError> { 177 + let grouped: BTreeMap<SketchEntityId, Vec<usize>> = 178 + edges 179 + .iter() 180 + .enumerate() 181 + .fold(BTreeMap::new(), |mut acc, (index, edge)| { 182 + acc.entry(edge.from).or_default().push(index); 183 + acc.entry(edge.to).or_default().push(index); 184 + acc 185 + }); 186 + grouped 187 + .into_iter() 188 + .map(|(point, indices)| match indices.as_slice() { 189 + [a, b] => Ok((point, [*a, *b])), 190 + [_] => Err(defect(ProfileDefect::OpenLoop)), 191 + _ => Err(defect(ProfileDefect::BranchingVertex)), 192 + }) 193 + .collect() 194 + } 195 + 196 + fn other_edge( 197 + incidence: &Incidence, 198 + point: SketchEntityId, 199 + current: usize, 200 + ) -> Result<usize, BrepError> { 201 + match incidence.get(&point) { 202 + Some(&[a, b]) if a == current => Ok(b), 203 + Some(&[a, b]) if b == current => Ok(a), 204 + _ => Err(defect(ProfileDefect::OpenLoop)), 205 + } 206 + } 207 + 208 + #[derive(Copy, Clone)] 209 + struct Step { 210 + edge: usize, 211 + from: SketchEntityId, 212 + } 213 + 214 + fn walk_all(edges: &[RawEdge], incidence: &Incidence) -> Result<Vec<ProfileLoop>, BrepError> { 215 + let (_, loops) = (0..edges.len()).try_fold( 216 + (BTreeSet::<usize>::new(), Vec::<ProfileLoop>::new()), 217 + |(visited, mut loops), start| { 218 + if visited.contains(&start) { 219 + return Ok((visited, loops)); 220 + } 221 + let steps = walk_cycle(edges, incidence, start)?; 222 + let next_visited = steps.iter().fold(visited, |mut acc, step| { 223 + acc.insert(step.edge); 224 + acc 225 + }); 226 + let profile_edges = steps 227 + .iter() 228 + .map(|step| { 229 + let edge = edges[step.edge]; 230 + Ok(ProfileEdge::new( 231 + edge.oriented(step.from)?, 232 + edge.entity, 233 + step.from, 234 + )) 235 + }) 236 + .collect::<Result<Vec<_>, BrepError>>()?; 237 + loops.push(ProfileLoop::Open(profile_edges)); 238 + Ok((next_visited, loops)) 239 + }, 240 + )?; 241 + Ok(loops) 242 + } 243 + 244 + enum Walk { 245 + Going { 246 + edge: usize, 247 + from: SketchEntityId, 248 + steps: Vec<Step>, 249 + }, 250 + Closed(Vec<Step>), 251 + } 252 + 253 + fn walk_cycle( 254 + edges: &[RawEdge], 255 + incidence: &Incidence, 256 + start: usize, 257 + ) -> Result<Vec<Step>, BrepError> { 258 + let origin = edges[start].from; 259 + let walk = (0..edges.len()).try_fold( 260 + Walk::Going { 261 + edge: start, 262 + from: origin, 263 + steps: Vec::new(), 264 + }, 265 + |state, _| match state { 266 + Walk::Closed(steps) => Ok(Walk::Closed(steps)), 267 + Walk::Going { 268 + edge, 269 + from, 270 + mut steps, 271 + } => { 272 + let next_point = edges[edge].other(from); 273 + steps.push(Step { edge, from }); 274 + if next_point == origin { 275 + Ok(Walk::Closed(steps)) 276 + } else { 277 + let next_edge = other_edge(incidence, next_point, edge)?; 278 + Ok(Walk::Going { 279 + edge: next_edge, 280 + from: next_point, 281 + steps, 282 + }) 283 + } 284 + } 285 + }, 286 + )?; 287 + match walk { 288 + Walk::Closed(steps) => Ok(steps), 289 + Walk::Going { .. } => Err(defect(ProfileDefect::OpenLoop)), 290 + } 291 + } 292 + 293 + #[cfg(test)] 294 + mod tests { 295 + use super::build_profile; 296 + use crate::sketch::{EditOutcome, Sketch, SketchEdit, SketchEntity}; 297 + use bone_kernel::{ 298 + BrepError, BrepSolid, Curve2Kind, ExtrudeDirection, ExtrudeEndCondition, ExtrudeFeature, 299 + ExtrudeSense, MergeResult, ProfileDefect, ProfileLoop, evaluate_extrude, 300 + }; 301 + use bone_types::{ 302 + FeatureId, Length, Point2, Point3, PositiveLength, SketchEntityId, SketchId, 303 + SketchPlaneBasis, Tolerance, UnitVec3, millimeter, 304 + }; 305 + use slotmap::{Key, KeyData}; 306 + 307 + const TOL: Tolerance = Tolerance::new(1e-9); 308 + 309 + fn plane() -> SketchPlaneBasis { 310 + let Ok(basis) = SketchPlaneBasis::new( 311 + Point3::origin(), 312 + UnitVec3::x_axis(), 313 + UnitVec3::y_axis(), 314 + TOL, 315 + ) else { 316 + panic!("xy plane is orthonormal"); 317 + }; 318 + basis 319 + } 320 + 321 + fn feature_id(idx: u32) -> FeatureId { 322 + FeatureId::from(KeyData::from_ffi((1u64 << 32) | u64::from(idx))) 323 + } 324 + 325 + fn point(sketch: Sketch, x: f64, y: f64) -> (Sketch, SketchEntityId) { 326 + let Ok((next, EditOutcome::Entity(id))) = sketch.apply(SketchEdit::AddEntity( 327 + SketchEntity::point(Point2::from_mm(x, y)), 328 + )) else { 329 + panic!("add point"); 330 + }; 331 + (next, id) 332 + } 333 + 334 + fn line( 335 + sketch: Sketch, 336 + a: SketchEntityId, 337 + b: SketchEntityId, 338 + construction: bool, 339 + ) -> (Sketch, SketchEntityId) { 340 + let Ok((next, EditOutcome::Entity(id))) = sketch.apply(SketchEdit::AddEntity( 341 + SketchEntity::line(a, b, construction), 342 + )) else { 343 + panic!("add line"); 344 + }; 345 + (next, id) 346 + } 347 + 348 + fn arc( 349 + sketch: Sketch, 350 + center: SketchEntityId, 351 + start: SketchEntityId, 352 + end: SketchEntityId, 353 + ) -> (Sketch, SketchEntityId) { 354 + let Ok((next, EditOutcome::Entity(id))) = sketch.apply(SketchEdit::AddEntity( 355 + SketchEntity::arc(center, start, end, false), 356 + )) else { 357 + panic!("add arc"); 358 + }; 359 + (next, id) 360 + } 361 + 362 + fn circle(sketch: Sketch, center: SketchEntityId, radius_mm: f64) -> (Sketch, SketchEntityId) { 363 + let Ok((next, EditOutcome::Entity(id))) = sketch.apply(SketchEdit::AddEntity( 364 + SketchEntity::circle(center, Length::new::<millimeter>(radius_mm), false), 365 + )) else { 366 + panic!("add circle"); 367 + }; 368 + (next, id) 369 + } 370 + 371 + fn blind(depth_mm: f64) -> ExtrudeFeature { 372 + let Ok(depth) = PositiveLength::new(Length::new::<millimeter>(depth_mm)) else { 373 + panic!("{depth_mm} mm is positive"); 374 + }; 375 + ExtrudeFeature { 376 + sketch: SketchId::null(), 377 + direction: ExtrudeDirection::Normal { 378 + sense: ExtrudeSense::Forward, 379 + }, 380 + end_condition: ExtrudeEndCondition::Blind { depth }, 381 + draft: None, 382 + thin_wall: None, 383 + merge_result: MergeResult::Merge, 384 + } 385 + } 386 + 387 + fn extrude(sketch: &Sketch) -> Result<BrepSolid, BrepError> { 388 + let profile = build_profile(sketch)?; 389 + evaluate_extrude(feature_id(1), &profile, &blind(4.0)) 390 + } 391 + 392 + fn corners(sketch: Sketch) -> (Sketch, [SketchEntityId; 4]) { 393 + let (sketch, p0) = point(sketch, 0.0, 0.0); 394 + let (sketch, p1) = point(sketch, 10.0, 0.0); 395 + let (sketch, p2) = point(sketch, 10.0, 5.0); 396 + let (sketch, p3) = point(sketch, 0.0, 5.0); 397 + (sketch, [p0, p1, p2, p3]) 398 + } 399 + 400 + #[test] 401 + fn rectangle_builds_one_quad_loop() { 402 + let (sketch, [p0, p1, p2, p3]) = corners(Sketch::new(plane())); 403 + let (sketch, l0) = line(sketch, p0, p1, false); 404 + let (sketch, l1) = line(sketch, p1, p2, false); 405 + let (sketch, l2) = line(sketch, p2, p3, false); 406 + let (sketch, l3) = line(sketch, p3, p0, false); 407 + 408 + let Ok(profile) = build_profile(&sketch) else { 409 + panic!("rectangle is a buildable profile"); 410 + }; 411 + let [single] = profile.loops() else { 412 + panic!("rectangle is one loop"); 413 + }; 414 + let ProfileLoop::Open(edges) = single else { 415 + panic!("a line loop is open"); 416 + }; 417 + assert_eq!(edges.len(), 4); 418 + let entities: Vec<SketchEntityId> = edges.iter().map(|edge| edge.curve_entity()).collect(); 419 + assert_eq!(entities, vec![l0, l1, l2, l3]); 420 + let walk_corners: Vec<SketchEntityId> = edges.iter().map(|edge| edge.corner()).collect(); 421 + assert_eq!(walk_corners, vec![p0, p1, p2, p3]); 422 + assert!( 423 + edges 424 + .iter() 425 + .all(|edge| matches!(edge.curve(), Curve2Kind::Line(_))) 426 + ); 427 + } 428 + 429 + #[test] 430 + fn rectangle_extrudes_to_closed_solid() { 431 + let (sketch, [p0, p1, p2, p3]) = corners(Sketch::new(plane())); 432 + let (sketch, _) = line(sketch, p0, p1, false); 433 + let (sketch, _) = line(sketch, p1, p2, false); 434 + let (sketch, _) = line(sketch, p2, p3, false); 435 + let (sketch, _) = line(sketch, p3, p0, false); 436 + let Ok(solid) = extrude(&sketch) else { 437 + panic!("rectangle extrudes"); 438 + }; 439 + assert_eq!(solid.iter_faces().count(), 6); 440 + assert!(solid.validate(TOL).is_ok()); 441 + } 442 + 443 + #[test] 444 + fn mixed_edge_orientation_still_assembles() { 445 + let (sketch, [p0, p1, p2, p3]) = corners(Sketch::new(plane())); 446 + let (sketch, _) = line(sketch, p0, p1, false); 447 + let (sketch, _) = line(sketch, p2, p1, false); 448 + let (sketch, _) = line(sketch, p2, p3, false); 449 + let (sketch, _) = line(sketch, p0, p3, false); 450 + let Ok(solid) = extrude(&sketch) else { 451 + panic!("the walk reverses backwards edges"); 452 + }; 453 + assert_eq!(solid.iter_faces().count(), 6); 454 + assert!(solid.validate(TOL).is_ok()); 455 + } 456 + 457 + #[test] 458 + fn circle_builds_one_closed_loop() { 459 + let (sketch, center) = point(Sketch::new(plane()), 0.0, 0.0); 460 + let (sketch, disk) = circle(sketch, center, 5.0); 461 + let Ok(profile) = build_profile(&sketch) else { 462 + panic!("circle is a buildable profile"); 463 + }; 464 + let [ 465 + ProfileLoop::Closed { 466 + curve_entity, 467 + curve, 468 + }, 469 + ] = profile.loops() 470 + else { 471 + panic!("a circle is one closed loop"); 472 + }; 473 + assert_eq!(*curve_entity, disk); 474 + assert!(matches!(curve, Curve2Kind::Circle(_))); 475 + let Ok(solid) = extrude(&sketch) else { 476 + panic!("circle extrudes"); 477 + }; 478 + assert!(solid.validate(TOL).is_ok()); 479 + } 480 + 481 + #[test] 482 + fn arc_and_chord_assemble_into_a_half_disk() { 483 + let (sketch, center) = point(Sketch::new(plane()), 0.0, 0.0); 484 + let (sketch, start) = point(sketch, -5.0, 0.0); 485 + let (sketch, end) = point(sketch, 5.0, 0.0); 486 + let (sketch, arc_id) = arc(sketch, center, start, end); 487 + let (sketch, _) = line(sketch, end, start, false); 488 + 489 + let Ok(profile) = build_profile(&sketch) else { 490 + panic!("arc plus chord is buildable"); 491 + }; 492 + let [ProfileLoop::Open(edges)] = profile.loops() else { 493 + panic!("half disk is one open loop"); 494 + }; 495 + assert_eq!(edges.len(), 2); 496 + let Some(Curve2Kind::Arc(forward)) = edges 497 + .iter() 498 + .find(|edge| edge.curve_entity() == arc_id) 499 + .map(|edge| edge.curve()) 500 + else { 501 + panic!("arc edge present in the loop"); 502 + }; 503 + assert!( 504 + forward.sweep_rad() > 0.0 505 + && (forward.sweep_rad() - core::f64::consts::PI).abs() < 1e-9, 506 + "forward ordering sweeps ccw through the bottom semicircle" 507 + ); 508 + let Ok(solid) = extrude(&sketch) else { 509 + panic!("half disk extrudes"); 510 + }; 511 + assert!(solid.validate(TOL).is_ok()); 512 + } 513 + 514 + #[test] 515 + fn reversed_arc_assembles_like_the_forward_arc() { 516 + let (sketch, center) = point(Sketch::new(plane()), 0.0, 0.0); 517 + let (sketch, start) = point(sketch, -5.0, 0.0); 518 + let (sketch, end) = point(sketch, 5.0, 0.0); 519 + let (sketch, _) = line(sketch, start, end, false); 520 + let (sketch, arc_id) = arc(sketch, center, start, end); 521 + 522 + let Ok(profile) = build_profile(&sketch) else { 523 + panic!("arc plus chord is buildable in either order"); 524 + }; 525 + let [ProfileLoop::Open(edges)] = profile.loops() else { 526 + panic!("half disk is one open loop"); 527 + }; 528 + let Some(Curve2Kind::Arc(reversed)) = edges 529 + .iter() 530 + .find(|edge| edge.curve_entity() == arc_id) 531 + .map(|edge| edge.curve()) 532 + else { 533 + panic!("arc edge present in the loop"); 534 + }; 535 + assert!( 536 + reversed.sweep_rad() < 0.0, 537 + "chord-first ordering makes the walk traverse the arc backward" 538 + ); 539 + let Ok(solid) = extrude(&sketch) else { 540 + panic!("reversed-arc half disk extrudes"); 541 + }; 542 + assert!(solid.validate(TOL).is_ok()); 543 + } 544 + 545 + #[test] 546 + fn branching_vertex_is_distinct_from_open_loop() { 547 + let (sketch, [p0, p1, p2, p3]) = corners(Sketch::new(plane())); 548 + let (sketch, _) = line(sketch, p0, p1, false); 549 + let (sketch, _) = line(sketch, p1, p2, false); 550 + let (sketch, _) = line(sketch, p2, p3, false); 551 + let (sketch, _) = line(sketch, p3, p0, false); 552 + let (sketch, _) = line(sketch, p0, p2, false); 553 + assert!(matches!( 554 + build_profile(&sketch), 555 + Err(BrepError::InvalidProfile { 556 + reason: ProfileDefect::BranchingVertex 557 + }) 558 + )); 559 + } 560 + 561 + #[test] 562 + fn open_chain_is_rejected() { 563 + let (sketch, [p0, p1, p2, p3]) = corners(Sketch::new(plane())); 564 + let (sketch, _) = line(sketch, p0, p1, false); 565 + let (sketch, _) = line(sketch, p1, p2, false); 566 + let (sketch, _) = line(sketch, p2, p3, false); 567 + assert!(matches!( 568 + build_profile(&sketch), 569 + Err(BrepError::InvalidProfile { 570 + reason: ProfileDefect::OpenLoop 571 + }) 572 + )); 573 + } 574 + 575 + #[test] 576 + fn construction_geometry_is_excluded() { 577 + let (sketch, [p0, p1, p2, p3]) = corners(Sketch::new(plane())); 578 + let (sketch, _) = line(sketch, p0, p1, false); 579 + let (sketch, _) = line(sketch, p1, p2, false); 580 + let (sketch, _) = line(sketch, p2, p3, false); 581 + let (sketch, _) = line(sketch, p3, p0, false); 582 + let (sketch, diag_a) = point(sketch, 0.0, 0.0); 583 + let (sketch, diag_b) = point(sketch, 10.0, 5.0); 584 + let (sketch, _) = line(sketch, diag_a, diag_b, true); 585 + 586 + let Ok(profile) = build_profile(&sketch) else { 587 + panic!("construction line does not break the profile"); 588 + }; 589 + assert_eq!(profile.loops().len(), 1); 590 + } 591 + 592 + #[test] 593 + fn nested_line_loops_extrude_to_a_tube() { 594 + let (sketch, [o0, o1, o2, o3]) = corners(Sketch::new(plane())); 595 + let (sketch, _) = line(sketch, o0, o1, false); 596 + let (sketch, _) = line(sketch, o1, o2, false); 597 + let (sketch, _) = line(sketch, o2, o3, false); 598 + let (sketch, _) = line(sketch, o3, o0, false); 599 + let (sketch, i0) = point(sketch, 3.0, 1.0); 600 + let (sketch, i1) = point(sketch, 7.0, 1.0); 601 + let (sketch, i2) = point(sketch, 7.0, 4.0); 602 + let (sketch, i3) = point(sketch, 3.0, 4.0); 603 + let (sketch, _) = line(sketch, i0, i1, false); 604 + let (sketch, _) = line(sketch, i1, i2, false); 605 + let (sketch, _) = line(sketch, i2, i3, false); 606 + let (sketch, _) = line(sketch, i3, i0, false); 607 + 608 + let Ok(profile) = build_profile(&sketch) else { 609 + panic!("rectangle with a rectangular hole is buildable"); 610 + }; 611 + assert_eq!(profile.loops().len(), 2); 612 + assert!( 613 + profile 614 + .loops() 615 + .iter() 616 + .all(|loop_| matches!(loop_, ProfileLoop::Open(edges) if edges.len() == 4)) 617 + ); 618 + let Ok(solid) = extrude(&sketch) else { 619 + panic!("nested loops extrude to a tube"); 620 + }; 621 + assert!(solid.validate(TOL).is_ok()); 622 + assert_eq!(solid.iter_faces().count(), 10); 623 + } 624 + }