Another project
0

Configure Feed

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

feat(kernel): bridge step shells to brep solids w/ order-keyed reattach

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

author
Lewis
date (Jun 7, 2026, 6:04 PM +0300) commit a4fbbcaf parent e8cb983d change-id kspuqnwo
+606 -60
+4 -2
crates/bone-kernel/src/brep/mod.rs
··· 120 120 found: usize, 121 121 expected: usize, 122 122 }, 123 + #[error("reattach payload geometry order does not match the solid")] 124 + ReattachOrder, 123 125 #[error("solid geometry blob could not be serialized")] 124 126 BlobSerialize, 125 127 #[error("solid geometry blob could not be parsed")] ··· 132 134 StepShellMalformed, 133 135 #[error("STEP file carries no solid shell")] 134 136 StepEmpty, 135 - #[error("STEP file carries {count} shells; only a single closed shell is supported")] 136 - StepMultipleShells { count: usize }, 137 + #[error("STEP file carries {count} solids; assemblies are not yet imported")] 138 + StepMultipleSolids { count: usize }, 137 139 #[error("STEP geometry uses {kind}, which the facade does not yet bridge")] 138 140 StepUnsupported { kind: StepEntityKind }, 139 141 }
+85 -35
crates/bone-kernel/src/brep/persist.rs
··· 29 29 edges: Vec<EdgeReattach>, 30 30 vertices: Vec<VertexLabel>, 31 31 closed_curves: Vec<SketchEntityId>, 32 + order: SolidKey, 32 33 } 33 34 34 35 impl BrepReattach { ··· 123 124 edges, 124 125 vertices, 125 126 closed_curves, 127 + order: order_key(solid), 126 128 }) 127 129 } 128 130 ··· 130 132 solid: &Solid, 131 133 reattach: &BrepReattach, 132 134 ) -> Result<SolidLabeling, BrepError> { 135 + if order_key(solid) != reattach.order { 136 + return Err(BrepError::ReattachOrder); 137 + } 133 138 let face_ids = ordered_faces(solid); 134 139 let edge_ids = ordered_edges(solid); 135 140 let vertex_ids = ordered_vertices(solid); ··· 189 194 } 190 195 } 191 196 197 + fn point_token(point: truck_modeling::Point3) -> String { 198 + format!( 199 + "{},{},{}", 200 + quantize(point.x), 201 + quantize(point.y), 202 + quantize(point.z) 203 + ) 204 + } 205 + 206 + fn edge_token(front: truck_modeling::Point3, back: truck_modeling::Point3) -> String { 207 + let mut ends = [point_token(front), point_token(back)]; 208 + ends.sort_unstable(); 209 + ends.join("|") 210 + } 211 + 212 + fn digest_to_key(canonical: &str) -> SolidKey { 213 + let digest = blake3::hash(canonical.as_bytes()); 214 + let mut key = [0u8; 16]; 215 + key.copy_from_slice(&digest.as_bytes()[..16]); 216 + SolidKey::from_bytes(key) 217 + } 218 + 219 + fn face_centroid(face: &Face) -> truck_modeling::Point3 { 220 + let (x, y, z, count) = face 221 + .boundaries() 222 + .iter() 223 + .flat_map(|wire| wire.vertex_iter().map(|vertex| vertex.point())) 224 + .fold((0.0, 0.0, 0.0, 0.0_f64), |(x, y, z, count), point| { 225 + (x + point.x, y + point.y, z + point.z, count + 1.0) 226 + }); 227 + if count > 0.0 { 228 + truck_modeling::Point3::new(x / count, y / count, z / count) 229 + } else { 230 + truck_modeling::Point3::new(0.0, 0.0, 0.0) 231 + } 232 + } 233 + 234 + fn face_token(face: &Face) -> String { 235 + let mut edges: Vec<String> = face 236 + .boundaries() 237 + .iter() 238 + .flat_map(|wire| wire.edge_iter()) 239 + .map(|edge| edge_token(edge.front().point(), edge.back().point())) 240 + .collect(); 241 + edges.sort_unstable(); 242 + edges.dedup(); 243 + format!("{}|{}", point_token(face_centroid(face)), edges.join(",")) 244 + } 245 + 192 246 pub(super) fn content_key(solid: &Solid) -> SolidKey { 193 247 let mut vertices: Vec<String> = solid 194 248 .vertex_iter() 195 - .map(|vertex| { 196 - let point = vertex.point(); 197 - format!( 198 - "{},{},{}", 199 - quantize(point.x), 200 - quantize(point.y), 201 - quantize(point.z) 202 - ) 203 - }) 249 + .map(|vertex| point_token(vertex.point())) 204 250 .collect(); 205 251 vertices.sort_unstable(); 206 252 vertices.dedup(); 207 253 208 254 let mut edges: Vec<String> = solid 209 255 .edge_iter() 210 - .map(|edge| { 211 - let front = edge.front().point(); 212 - let back = edge.back().point(); 213 - let mut ends = [ 214 - format!( 215 - "{},{},{}", 216 - quantize(front.x), 217 - quantize(front.y), 218 - quantize(front.z) 219 - ), 220 - format!( 221 - "{},{},{}", 222 - quantize(back.x), 223 - quantize(back.y), 224 - quantize(back.z) 225 - ), 226 - ]; 227 - ends.sort_unstable(); 228 - ends.join("|") 229 - }) 256 + .map(|edge| edge_token(edge.front().point(), edge.back().point())) 230 257 .collect(); 231 258 edges.sort_unstable(); 232 259 edges.dedup(); 233 260 234 - let canonical = format!( 261 + digest_to_key(&format!( 235 262 "t:{}/{}/{}\nv:{}\n{}\ne:{}\n{}\n", 236 263 ordered_faces(solid).len(), 237 264 ordered_edges(solid).len(), ··· 240 267 vertices.join("\n"), 241 268 edges.len(), 242 269 edges.join("\n") 243 - ); 244 - let digest = blake3::hash(canonical.as_bytes()); 245 - let mut key = [0u8; 16]; 246 - key.copy_from_slice(&digest.as_bytes()[..16]); 247 - SolidKey::from_bytes(key) 270 + )) 271 + } 272 + 273 + pub(super) fn order_key(solid: &Solid) -> SolidKey { 274 + let mut seen_vertices = HashSet::new(); 275 + let vertices: Vec<String> = solid 276 + .vertex_iter() 277 + .filter(|vertex| seen_vertices.insert(vertex.id())) 278 + .map(|vertex| point_token(vertex.point())) 279 + .collect(); 280 + let mut seen_edges = HashSet::new(); 281 + let edges: Vec<String> = solid 282 + .edge_iter() 283 + .filter(|edge| seen_edges.insert(edge.id())) 284 + .map(|edge| edge_token(edge.front().point(), edge.back().point())) 285 + .collect(); 286 + let faces: Vec<String> = solid 287 + .boundaries() 288 + .iter() 289 + .flat_map(Shell::face_iter) 290 + .map(face_token) 291 + .collect(); 292 + digest_to_key(&format!( 293 + "ov:{}\noe:{}\nof:{}\n", 294 + vertices.join("|"), 295 + edges.join("|"), 296 + faces.join("|") 297 + )) 248 298 } 249 299 250 300 impl BrepSolid {
+479 -17
crates/bone-kernel/src/brep/step.rs
··· 1 - use std::collections::{HashMap, HashSet}; 1 + use std::collections::{BTreeSet, HashMap, HashSet}; 2 2 3 3 use bone_types::{ 4 4 EdgeLabel, EdgeRole, FaceLabel, FaceRole, FeatureId, ImportOrdinal, SolidKey, StepEntityKind, 5 5 VertexLabel, VertexRole, 6 6 }; 7 - use truck_modeling::{Curve, Shell, Solid, Surface}; 7 + use truck_modeling::{ 8 + BSplineCurve, Curve, Cut, Matrix4, ParametricCurve, Point3, RevolutedCurve, Shell, Solid, 9 + Surface, 10 + }; 8 11 use truck_stepio::r#in::Table; 9 12 use truck_stepio::r#in::alias as step_in; 10 - use truck_stepio::r#in::ruststep::ast::DataSection; 13 + use truck_stepio::r#in::ruststep::ast::{DataSection, EntityInstance, Name, Parameter}; 11 14 use truck_stepio::r#in::ruststep::parser::exchange::data_section; 12 15 use truck_stepio::out::StepModels; 13 - use truck_topology::compress::{CompressedEdge, CompressedFace, CompressedShell}; 16 + use truck_topology::compress::{ 17 + CompressedEdge, CompressedEdgeIndex, CompressedFace, CompressedShell, 18 + }; 14 19 15 20 use super::build::{SolidLabeling, assemble}; 16 21 use super::persist::{ ··· 41 46 } 42 47 } 43 48 49 + const CLOSED_SHELL: &str = "CLOSED_SHELL"; 50 + const SOLID_ROOT_KEYWORDS: &[&str] = &["MANIFOLD_SOLID_BREP", "BREP_WITH_VOIDS"]; 51 + 52 + #[derive(Copy, Clone, PartialEq, Eq, PartialOrd, Ord, Hash)] 53 + struct StepEntityId(u64); 54 + 55 + struct StepTopology { 56 + closed_shells: BTreeSet<StepEntityId>, 57 + solid_roots: BTreeSet<StepEntityId>, 58 + references: HashMap<StepEntityId, BTreeSet<StepEntityId>>, 59 + } 60 + 44 61 fn parse_step_solid(text: &str) -> Result<Solid, BrepError> { 45 62 let data = first_data_section(text)?; 63 + let topology = classify_topology(&data); 64 + if topology.solid_roots.len() >= 2 { 65 + return Err(BrepError::StepMultipleSolids { 66 + count: topology.solid_roots.len(), 67 + }); 68 + } 46 69 let table = Table::from_data_section(&data); 47 - let mut shell_keys: Vec<u64> = table.shell.keys().copied().collect(); 48 - shell_keys.sort_unstable(); 49 - let shells = shell_keys 70 + let shells = solid_shell_keys(&table, &topology)? 50 71 .iter() 51 72 .map(|key| { 52 - let holder = &table.shell[key]; 73 + let holder = &table.shell[&key.0]; 53 74 let compressed = table 54 75 .to_compressed_shell(holder) 55 76 .map_err(|_| BrepError::StepShellMalformed)?; 56 77 let bridged = bridge_shell(compressed)?; 57 - Shell::extract(bridged).map_err(|_| BrepError::StepShellMalformed) 78 + let normalized = split_closed_edges(bridged); 79 + Shell::extract(normalized).map_err(|_| BrepError::StepShellMalformed) 58 80 }) 59 81 .collect::<Result<Vec<Shell>, BrepError>>()?; 60 82 match shells.len() { 61 83 0 => Err(BrepError::StepEmpty), 62 - 1 => Ok(Solid::new_unchecked(shells)), 63 - count => Err(BrepError::StepMultipleShells { count }), 84 + _ => Ok(Solid::new_unchecked(shells)), 85 + } 86 + } 87 + 88 + fn solid_shell_keys( 89 + table: &Table, 90 + topology: &StepTopology, 91 + ) -> Result<Vec<StepEntityId>, BrepError> { 92 + let chosen: BTreeSet<StepEntityId> = if topology.solid_roots.is_empty() { 93 + topology.closed_shells.clone() 94 + } else { 95 + shells_reachable( 96 + &topology.references, 97 + &topology.closed_shells, 98 + &topology.solid_roots, 99 + ) 100 + }; 101 + let mut keys: Vec<StepEntityId> = chosen 102 + .into_iter() 103 + .filter(|key| table.shell.contains_key(&key.0)) 104 + .collect(); 105 + if topology.solid_roots.is_empty() && keys.len() >= 2 { 106 + return Err(BrepError::StepMultipleSolids { count: keys.len() }); 107 + } 108 + keys.sort_unstable(); 109 + Ok(keys) 110 + } 111 + 112 + fn classify_topology(data: &DataSection) -> StepTopology { 113 + data.entities.iter().fold( 114 + StepTopology { 115 + closed_shells: BTreeSet::new(), 116 + solid_roots: BTreeSet::new(), 117 + references: HashMap::new(), 118 + }, 119 + |mut topology, instance| { 120 + let (id, keywords, parameters) = entity_record(instance); 121 + if keywords 122 + .iter() 123 + .any(|name| name.eq_ignore_ascii_case(CLOSED_SHELL)) 124 + { 125 + topology.closed_shells.insert(id); 126 + } 127 + if keywords.iter().any(|name| { 128 + SOLID_ROOT_KEYWORDS 129 + .iter() 130 + .any(|root| name.eq_ignore_ascii_case(root)) 131 + }) { 132 + topology.solid_roots.insert(id); 133 + } 134 + let references: BTreeSet<StepEntityId> = 135 + parameters.iter().flat_map(|&p| entity_refs(p)).collect(); 136 + if !references.is_empty() { 137 + topology.references.insert(id, references); 138 + } 139 + topology 140 + }, 141 + ) 142 + } 143 + 144 + fn entity_record(instance: &EntityInstance) -> (StepEntityId, Vec<&str>, Vec<&Parameter>) { 145 + match instance { 146 + EntityInstance::Simple { id, record } => ( 147 + StepEntityId(*id), 148 + vec![record.name.as_str()], 149 + vec![&record.parameter], 150 + ), 151 + EntityInstance::Complex { id, subsuper } => ( 152 + StepEntityId(*id), 153 + subsuper 154 + .0 155 + .iter() 156 + .map(|record| record.name.as_str()) 157 + .collect(), 158 + subsuper.0.iter().map(|record| &record.parameter).collect(), 159 + ), 160 + } 161 + } 162 + 163 + fn entity_refs(parameter: &Parameter) -> BTreeSet<StepEntityId> { 164 + match parameter { 165 + Parameter::Ref(Name::Entity(id)) => BTreeSet::from([StepEntityId(*id)]), 166 + Parameter::List(items) => items.iter().flat_map(entity_refs).collect(), 167 + Parameter::Typed { parameter, .. } => entity_refs(parameter), 168 + _ => BTreeSet::new(), 169 + } 170 + } 171 + 172 + fn shells_reachable( 173 + references: &HashMap<StepEntityId, BTreeSet<StepEntityId>>, 174 + closed_shells: &BTreeSet<StepEntityId>, 175 + seeds: &BTreeSet<StepEntityId>, 176 + ) -> BTreeSet<StepEntityId> { 177 + let expanded: BTreeSet<StepEntityId> = seeds 178 + .iter() 179 + .filter(|id| !closed_shells.contains(id)) 180 + .flat_map(|id| references.get(id).into_iter().flatten().copied()) 181 + .chain(seeds.iter().copied()) 182 + .collect(); 183 + if expanded.len() == seeds.len() { 184 + expanded.intersection(closed_shells).copied().collect() 185 + } else { 186 + shells_reachable(references, closed_shells, &expanded) 64 187 } 65 188 } 66 189 ··· 108 231 }) 109 232 } 110 233 234 + const CIRCLE_POSITION_TOL: f64 = 1.0e-4; 235 + const CIRCLE_DERIVATIVE_TOL: f64 = 1.0e-3; 236 + const CIRCLE_TRIALS: usize = 24; 237 + 238 + fn split_closed_edges(shell: ModelShell) -> ModelShell { 239 + let CompressedShell { 240 + vertices, 241 + edges, 242 + faces, 243 + } = shell; 244 + let (edges, vertices, remap) = edges.into_iter().fold( 245 + (Vec::new(), vertices, Vec::<Vec<usize>>::new()), 246 + |(mut edges, mut vertices, mut remap), edge| { 247 + if let Some((midpoint, head, tail)) = split_closed_edge(&edge, vertices.len()) { 248 + vertices.push(midpoint); 249 + remap.push(vec![edges.len(), edges.len() + 1]); 250 + edges.push(head); 251 + edges.push(tail); 252 + } else { 253 + remap.push(vec![edges.len()]); 254 + edges.push(edge); 255 + } 256 + (edges, vertices, remap) 257 + }, 258 + ); 259 + let faces = faces 260 + .into_iter() 261 + .map(|face| CompressedFace { 262 + boundaries: face 263 + .boundaries 264 + .into_iter() 265 + .map(|wire| { 266 + wire.into_iter() 267 + .flat_map(|step| expand_edge(step, &remap)) 268 + .collect() 269 + }) 270 + .collect(), 271 + orientation: face.orientation, 272 + surface: face.surface, 273 + }) 274 + .collect(); 275 + CompressedShell { 276 + vertices, 277 + edges, 278 + faces, 279 + } 280 + } 281 + 282 + fn split_closed_edge( 283 + edge: &CompressedEdge<Curve>, 284 + midpoint_index: usize, 285 + ) -> Option<(Point3, CompressedEdge<Curve>, CompressedEdge<Curve>)> { 286 + if edge.vertices.0 != edge.vertices.1 { 287 + return None; 288 + } 289 + let (start, end) = edge.curve.try_range_tuple()?; 290 + let middle = f64::midpoint(start, end); 291 + let midpoint = edge.curve.subs(middle); 292 + let mut head = edge.curve.clone(); 293 + let tail = head.cut(middle); 294 + Some(( 295 + midpoint, 296 + CompressedEdge { 297 + vertices: (edge.vertices.0, midpoint_index), 298 + curve: head, 299 + }, 300 + CompressedEdge { 301 + vertices: (midpoint_index, edge.vertices.1), 302 + curve: tail, 303 + }, 304 + )) 305 + } 306 + 307 + fn expand_edge(step: CompressedEdgeIndex, remap: &[Vec<usize>]) -> Vec<CompressedEdgeIndex> { 308 + let pieces = &remap[step.index]; 309 + if step.orientation { 310 + pieces 311 + .iter() 312 + .map(|&index| CompressedEdgeIndex { 313 + index, 314 + orientation: true, 315 + }) 316 + .collect() 317 + } else { 318 + pieces 319 + .iter() 320 + .rev() 321 + .map(|&index| CompressedEdgeIndex { 322 + index, 323 + orientation: false, 324 + }) 325 + .collect() 326 + } 327 + } 328 + 111 329 fn bridge_curve(curve: step_in::Curve3D) -> Result<Curve, BrepError> { 112 330 match curve { 113 331 step_in::Curve3D::Line(line) => Ok(Curve::Line(line)), 114 332 step_in::Curve3D::BSplineCurve(spline) => Ok(Curve::BSplineCurve(spline)), 115 333 step_in::Curve3D::NurbsCurve(nurbs) => Ok(Curve::NurbsCurve(nurbs)), 334 + step_in::Curve3D::Conic(step_in::Conic3D::Ellipse(circle)) => bridge_circle(&circle), 116 335 step_in::Curve3D::Conic(_) => Err(unsupported(StepEntityKind::ConicCurve)), 117 336 step_in::Curve3D::Polyline(_) => Err(unsupported(StepEntityKind::PolylineCurve)), 118 337 step_in::Curve3D::PCurve(_) => Err(unsupported(StepEntityKind::ParametricCurve)), 119 338 } 120 339 } 121 340 341 + fn bridge_circle(circle: &step_in::Ellipse<Point3, Matrix4>) -> Result<Curve, BrepError> { 342 + let range = circle 343 + .try_range_tuple() 344 + .ok_or(unsupported(StepEntityKind::ConicCurve))?; 345 + BSplineCurve::cubic_approximation( 346 + circle, 347 + range, 348 + CIRCLE_POSITION_TOL, 349 + CIRCLE_DERIVATIVE_TOL, 350 + CIRCLE_TRIALS, 351 + ) 352 + .map(Curve::BSplineCurve) 353 + .ok_or(unsupported(StepEntityKind::ConicCurve)) 354 + } 355 + 122 356 fn bridge_surface(surface: step_in::Surface) -> Result<Surface, BrepError> { 123 357 match surface { 124 358 step_in::Surface::ElementarySurface(elementary) => match *elementary { 125 359 step_in::ElementarySurface::Plane(plane) => Ok(Surface::Plane(plane)), 360 + step_in::ElementarySurface::CylindricalSurface(cylinder) => Ok(revolution(cylinder)), 361 + step_in::ElementarySurface::ConicalSurface(cone) => Ok(revolution(cone)), 126 362 step_in::ElementarySurface::Sphere(_) => { 127 363 Err(unsupported(StepEntityKind::SphericalSurface)) 128 - } 129 - step_in::ElementarySurface::CylindricalSurface(_) => { 130 - Err(unsupported(StepEntityKind::CylindricalSurface)) 131 364 } 132 365 step_in::ElementarySurface::ToroidalSurface(_) => { 133 366 Err(unsupported(StepEntityKind::ToroidalSurface)) 134 367 } 135 - step_in::ElementarySurface::ConicalSurface(_) => { 136 - Err(unsupported(StepEntityKind::ConicalSurface)) 137 - } 138 368 }, 139 369 step_in::Surface::BSplineSurface(spline) => Ok(Surface::BSplineSurface(*spline)), 140 370 step_in::Surface::NurbsSurface(nurbs) => Ok(Surface::NurbsSurface(*nurbs)), 141 371 step_in::Surface::SweptCurve(_) => Err(unsupported(StepEntityKind::SweptSurface)), 142 372 } 373 + } 374 + 375 + fn revolution(surface: step_in::CylindricalSurface) -> Surface { 376 + Surface::RevolutedCurve(surface.map_ref(|revolved| { 377 + RevolutedCurve::by_revolution( 378 + Curve::Line(*revolved.entity_curve()), 379 + revolved.origin(), 380 + revolved.axis(), 381 + ) 382 + })) 143 383 } 144 384 145 385 fn unsupported(kind: StepEntityKind) -> BrepError { ··· 204 444 fn ordinal(index: usize) -> ImportOrdinal { 205 445 ImportOrdinal::new(u32::try_from(index).unwrap_or(u32::MAX)) 206 446 } 447 + 448 + #[cfg(test)] 449 + mod tests { 450 + use super::{ 451 + CompressedEdge, CompressedEdgeIndex, bridge_curve, bridge_surface, expand_edge, 452 + split_closed_edge, 453 + }; 454 + use core::str::FromStr; 455 + use truck_modeling::{Curve, Matrix4, ParametricCurve, ParametricSurface, Point3, Surface}; 456 + use truck_stepio::r#in::ruststep::ast::DataSection; 457 + use truck_stepio::r#in::ruststep::tables::{EntityTable, Holder}; 458 + use truck_stepio::r#in::{CircleHolder, ElementarySurfaceAnyHolder, Table, alias}; 459 + 460 + const AXES: &str = "#2 = AXIS2_PLACEMENT_3D('', #3, #4, #5);\n#3 = CARTESIAN_POINT('', (0.0, 0.0, 0.0));\n#4 = DIRECTION('', (0.0, 0.0, 1.0));\n#5 = DIRECTION('', (1.0, 0.0, 0.0));\n"; 461 + 462 + fn first<H>(step: &str) -> H::Owned 463 + where 464 + H: Holder<Table = Table>, 465 + Table: EntityTable<H>, 466 + { 467 + let Ok(section) = DataSection::from_str(step) else { 468 + panic!("data section parses"); 469 + }; 470 + let table = Table::from_data_section(&section); 471 + let Ok(owned) = EntityTable::<H>::get_owned(&table, 1) else { 472 + panic!("entity #1 resolves"); 473 + }; 474 + owned 475 + } 476 + 477 + fn distance(a: Point3, b: Point3) -> f64 { 478 + let (dx, dy, dz) = (a.x - b.x, a.y - b.y, a.z - b.z); 479 + f64::sqrt(dx * dx + dy * dy + dz * dz) 480 + } 481 + 482 + #[test] 483 + fn cylindrical_surface_bridges_to_an_exact_revolution() { 484 + let step = format!("DATA;\n#1 = CYLINDRICAL_SURFACE('', #2, 5.0);\n{AXES}ENDSEC;"); 485 + let surface: alias::ElementarySurface = 486 + (&first::<ElementarySurfaceAnyHolder>(&step)).into(); 487 + let Ok(bridged) = bridge_surface(alias::Surface::ElementarySurface(Box::new(surface))) 488 + else { 489 + panic!("a cylindrical surface bridges into the kernel"); 490 + }; 491 + assert!(matches!(bridged, Surface::RevolutedCurve(_))); 492 + let base = bridged.subs(0.0, 3.0); 493 + assert!( 494 + distance(base, Point3::new(5.0, 0.0, 3.0)) < 1.0e-9, 495 + "the seam sits at radius 5 on the x axis: {base:?}" 496 + ); 497 + let quarter = bridged.subs(core::f64::consts::FRAC_PI_2, 1.0); 498 + assert!( 499 + distance(quarter, Point3::new(0.0, 5.0, 1.0)) < 1.0e-9, 500 + "a quarter turn lands on the y axis: {quarter:?}" 501 + ); 502 + } 503 + 504 + #[test] 505 + fn conical_surface_bridges_to_an_exact_revolution() { 506 + let step = format!("DATA;\n#1 = CONICAL_SURFACE('', #2, 5.0, 0.5);\n{AXES}ENDSEC;"); 507 + let surface: alias::ElementarySurface = 508 + (&first::<ElementarySurfaceAnyHolder>(&step)).into(); 509 + let Ok(bridged) = bridge_surface(alias::Surface::ElementarySurface(Box::new(surface))) 510 + else { 511 + panic!("a conical surface bridges into the kernel"); 512 + }; 513 + assert!(matches!(bridged, Surface::RevolutedCurve(_))); 514 + let radius = |p: Point3| (p.x * p.x + p.y * p.y).sqrt(); 515 + let seam = bridged.subs(0.0, 0.3); 516 + let spun = bridged.subs(core::f64::consts::FRAC_PI_2, 0.3); 517 + assert!( 518 + (radius(seam) - radius(spun)).abs() < 1.0e-9, 519 + "a revolution holds one radius around the axis: {seam:?} vs {spun:?}" 520 + ); 521 + let low = radius(bridged.subs(0.0, 0.0)); 522 + let high = radius(bridged.subs(0.0, 1.0)); 523 + assert!( 524 + (low - high).abs() > 1.0e-3, 525 + "a cone changes radius along its axis: {low} vs {high}" 526 + ); 527 + } 528 + 529 + #[test] 530 + fn circle_bridges_within_tolerance() { 531 + let step = format!("DATA;\n#1 = CIRCLE('', #2, 5.0);\n{AXES}ENDSEC;"); 532 + let Ok(circle): Result<alias::Ellipse<Point3, Matrix4>, _> = 533 + (&first::<CircleHolder>(&step)).try_into() 534 + else { 535 + panic!("a circle converts to truck's analytic ellipse"); 536 + }; 537 + let Some((lo, hi)) = circle.try_range_tuple() else { 538 + panic!("a parsed circle carries a bounded parameter range"); 539 + }; 540 + let analytic = circle; 541 + let Ok(bridged) = bridge_curve(alias::Curve3D::Conic(alias::Conic3D::Ellipse(circle))) 542 + else { 543 + panic!("a circle bridges into the kernel"); 544 + }; 545 + assert!(matches!(bridged, Curve::BSplineCurve(_))); 546 + (0..=12).for_each(|i| { 547 + let t = lo + (hi - lo) * (f64::from(i) / 12.0); 548 + let drift = distance(bridged.subs(t), analytic.subs(t)); 549 + assert!( 550 + drift < 1.0e-2, 551 + "circle approximation drifts {drift} at t={t}" 552 + ); 553 + }); 554 + } 555 + 556 + #[test] 557 + fn spherical_surface_stays_unsupported() { 558 + let step = format!("DATA;\n#1 = SPHERICAL_SURFACE('', #2, 5.0);\n{AXES}ENDSEC;"); 559 + let surface: alias::ElementarySurface = 560 + (&first::<ElementarySurfaceAnyHolder>(&step)).into(); 561 + assert!(bridge_surface(alias::Surface::ElementarySurface(Box::new(surface))).is_err()); 562 + } 563 + 564 + fn circle_curve() -> Curve { 565 + let step = format!("DATA;\n#1 = CIRCLE('', #2, 5.0);\n{AXES}ENDSEC;"); 566 + let Ok(circle): Result<alias::Ellipse<Point3, Matrix4>, _> = 567 + (&first::<CircleHolder>(&step)).try_into() 568 + else { 569 + panic!("a circle converts to truck's analytic ellipse"); 570 + }; 571 + let Ok(bridged) = bridge_curve(alias::Curve3D::Conic(alias::Conic3D::Ellipse(circle))) 572 + else { 573 + panic!("a circle bridges into the kernel"); 574 + }; 575 + bridged 576 + } 577 + 578 + #[test] 579 + fn closed_edge_splits_into_two_open_halves() { 580 + let edge = CompressedEdge { 581 + vertices: (0, 0), 582 + curve: circle_curve(), 583 + }; 584 + let Some((midpoint, head, tail)) = split_closed_edge(&edge, 1) else { 585 + panic!("a closed circular edge splits at its parameter midpoint"); 586 + }; 587 + assert_eq!( 588 + head.vertices, 589 + (0, 1), 590 + "the head runs from the original endpoint to the fresh midpoint vertex" 591 + ); 592 + assert_eq!( 593 + tail.vertices, 594 + (1, 0), 595 + "the tail runs from the midpoint vertex back to the original endpoint" 596 + ); 597 + let Some((_, head_end)) = head.curve.try_range_tuple() else { 598 + panic!("the head half carries a bounded range"); 599 + }; 600 + let Some((tail_start, _)) = tail.curve.try_range_tuple() else { 601 + panic!("the tail half carries a bounded range"); 602 + }; 603 + assert!( 604 + distance(head.curve.subs(head_end), midpoint) < 1.0e-6, 605 + "the head half ends at the midpoint" 606 + ); 607 + assert!( 608 + distance(tail.curve.subs(tail_start), midpoint) < 1.0e-6, 609 + "the tail half starts at the midpoint" 610 + ); 611 + } 612 + 613 + #[test] 614 + fn open_edge_is_left_intact() { 615 + let edge = CompressedEdge { 616 + vertices: (0, 1), 617 + curve: circle_curve(), 618 + }; 619 + assert!( 620 + split_closed_edge(&edge, 2).is_none(), 621 + "an edge with distinct endpoints is never split" 622 + ); 623 + } 624 + 625 + #[test] 626 + fn expand_edge_rewrites_boundaries_through_the_remap() { 627 + let remap = vec![vec![5usize], vec![7usize, 8usize]]; 628 + let as_pairs = |steps: Vec<CompressedEdgeIndex>| { 629 + steps 630 + .iter() 631 + .map(|step| (step.index, step.orientation)) 632 + .collect::<Vec<_>>() 633 + }; 634 + assert_eq!( 635 + as_pairs(expand_edge( 636 + CompressedEdgeIndex { 637 + index: 1, 638 + orientation: true, 639 + }, 640 + &remap, 641 + )), 642 + vec![(7, true), (8, true)], 643 + "a forward split edge expands to head then tail, both forward" 644 + ); 645 + assert_eq!( 646 + as_pairs(expand_edge( 647 + CompressedEdgeIndex { 648 + index: 1, 649 + orientation: false, 650 + }, 651 + &remap, 652 + )), 653 + vec![(8, false), (7, false)], 654 + "a reversed split edge expands to tail then head, both reversed" 655 + ); 656 + assert_eq!( 657 + as_pairs(expand_edge( 658 + CompressedEdgeIndex { 659 + index: 0, 660 + orientation: true, 661 + }, 662 + &remap, 663 + )), 664 + vec![(5, true)], 665 + "an unsplit edge passes through unchanged" 666 + ); 667 + } 668 + }
+14 -5
crates/bone-kernel/src/brep/tessellate.rs
··· 168 168 chord: ChordHeightTolerance, 169 169 angle: AngleTolerance, 170 170 ) -> f64 { 171 - let chord_mm = chord.millimeters(); 172 - let angle_chord = brep.bounding_box().map_or(f64::INFINITY, |bbox| { 171 + chord 172 + .millimeters() 173 + .min(angular_chord(brep, angle)) 174 + .max(TOLERANCE) 175 + } 176 + 177 + fn angular_chord(brep: &BrepSolid, angle: AngleTolerance) -> f64 { 178 + let radians = angle.radians(); 179 + if !(radians.is_finite() && radians > 0.0) { 180 + return f64::INFINITY; 181 + } 182 + brep.bounding_box().map_or(f64::INFINITY, |bbox| { 173 183 let radius = bbox.extent().norm_mm() / 2.0; 174 - radius * (1.0 - (angle.radians() / 2.0).cos()) 175 - }); 176 - chord_mm.min(angle_chord).max(TOLERANCE) 184 + radius * (1.0 - (radians / 2.0).cos()) 185 + }) 177 186 } 178 187 179 188 fn append_polygon(slab: &mut FaceMesh, polygon: &PolygonMesh) -> Result<(), MeshError> {
+23
crates/bone-kernel/tests/extrude.rs
··· 521 521 } 522 522 523 523 #[test] 524 + fn blob_reattach_rejects_a_mismatched_order() { 525 + let mut ids = Ids::new(); 526 + let cube_profile = 527 + ExtrudeProfile::new(xy_plane(), vec![rectangle(&mut ids, 0.0, 0.0, 2.0, 3.0)]); 528 + let cube = evaluate(&mut ids, &cube_profile, &blind(5.0)); 529 + let cylinder_profile = ExtrudeProfile::new( 530 + xy_plane(), 531 + vec![circle_loop(&mut ids, point(0.0, 0.0), 5.0)], 532 + ); 533 + let cylinder = evaluate(&mut ids, &cylinder_profile, &blind(10.0)); 534 + let Ok(blob) = cube.to_blob() else { 535 + panic!("cube serializes to a blob"); 536 + }; 537 + assert!( 538 + matches!( 539 + BrepSolid::from_blob(&blob, cylinder.reattach_data()), 540 + Err(BrepError::ReattachOrder) 541 + ), 542 + "a reattach captured from a different solid is rejected before any label binds" 543 + ); 544 + } 545 + 546 + #[test] 524 547 fn content_key_is_stable_and_geometry_sensitive() { 525 548 let mut ids = Ids::new(); 526 549 let narrow = ExtrudeProfile::new(xy_plane(), vec![rectangle(&mut ids, 0.0, 0.0, 2.0, 2.0)]);
+1 -1
crates/bone-kernel/tests/step_geometry.rs
··· 68 68 let doc = envelope(&StepModels::from_iter([&first, &second]).to_string()); 69 69 assert!(matches!( 70 70 BrepSolid::from_step(&doc, feature(), None), 71 - Err(BrepError::StepMultipleShells { count: 2 }) 71 + Err(BrepError::StepMultipleSolids { count: 2 }) 72 72 )); 73 73 }