diff --git a/crates/bone-kernel/src/brep/mod.rs b/crates/bone-kernel/src/brep/mod.rs index 8723048..79bb580 100644 --- a/crates/bone-kernel/src/brep/mod.rs +++ b/crates/bone-kernel/src/brep/mod.rs @@ -120,6 +120,8 @@ pub enum BrepError { found: usize, expected: usize, }, + #[error("reattach payload geometry order does not match the solid")] + ReattachOrder, #[error("solid geometry blob could not be serialized")] BlobSerialize, #[error("solid geometry blob could not be parsed")] @@ -132,8 +134,8 @@ pub enum BrepError { StepShellMalformed, #[error("STEP file carries no solid shell")] StepEmpty, - #[error("STEP file carries {count} shells; only a single closed shell is supported")] - StepMultipleShells { count: usize }, + #[error("STEP file carries {count} solids; assemblies are not yet imported")] + StepMultipleSolids { count: usize }, #[error("STEP geometry uses {kind}, which the facade does not yet bridge")] StepUnsupported { kind: StepEntityKind }, } diff --git a/crates/bone-kernel/src/brep/persist.rs b/crates/bone-kernel/src/brep/persist.rs index ff0a799..7d92b2c 100644 --- a/crates/bone-kernel/src/brep/persist.rs +++ b/crates/bone-kernel/src/brep/persist.rs @@ -29,6 +29,7 @@ pub struct BrepReattach { edges: Vec, vertices: Vec, closed_curves: Vec, + order: SolidKey, } impl BrepReattach { @@ -123,6 +124,7 @@ pub(crate) fn capture(solid: &Solid, labeling: &SolidLabeling) -> Result Result { + if order_key(solid) != reattach.order { + return Err(BrepError::ReattachOrder); + } let face_ids = ordered_faces(solid); let edge_ids = ordered_edges(solid); let vertex_ids = ordered_vertices(solid); @@ -189,49 +194,71 @@ fn quantize(value: f64) -> String { } } +fn point_token(point: truck_modeling::Point3) -> String { + format!( + "{},{},{}", + quantize(point.x), + quantize(point.y), + quantize(point.z) + ) +} + +fn edge_token(front: truck_modeling::Point3, back: truck_modeling::Point3) -> String { + let mut ends = [point_token(front), point_token(back)]; + ends.sort_unstable(); + ends.join("|") +} + +fn digest_to_key(canonical: &str) -> SolidKey { + let digest = blake3::hash(canonical.as_bytes()); + let mut key = [0u8; 16]; + key.copy_from_slice(&digest.as_bytes()[..16]); + SolidKey::from_bytes(key) +} + +fn face_centroid(face: &Face) -> truck_modeling::Point3 { + let (x, y, z, count) = face + .boundaries() + .iter() + .flat_map(|wire| wire.vertex_iter().map(|vertex| vertex.point())) + .fold((0.0, 0.0, 0.0, 0.0_f64), |(x, y, z, count), point| { + (x + point.x, y + point.y, z + point.z, count + 1.0) + }); + if count > 0.0 { + truck_modeling::Point3::new(x / count, y / count, z / count) + } else { + truck_modeling::Point3::new(0.0, 0.0, 0.0) + } +} + +fn face_token(face: &Face) -> String { + let mut edges: Vec = face + .boundaries() + .iter() + .flat_map(|wire| wire.edge_iter()) + .map(|edge| edge_token(edge.front().point(), edge.back().point())) + .collect(); + edges.sort_unstable(); + edges.dedup(); + format!("{}|{}", point_token(face_centroid(face)), edges.join(",")) +} + pub(super) fn content_key(solid: &Solid) -> SolidKey { let mut vertices: Vec = solid .vertex_iter() - .map(|vertex| { - let point = vertex.point(); - format!( - "{},{},{}", - quantize(point.x), - quantize(point.y), - quantize(point.z) - ) - }) + .map(|vertex| point_token(vertex.point())) .collect(); vertices.sort_unstable(); vertices.dedup(); let mut edges: Vec = solid .edge_iter() - .map(|edge| { - let front = edge.front().point(); - let back = edge.back().point(); - let mut ends = [ - format!( - "{},{},{}", - quantize(front.x), - quantize(front.y), - quantize(front.z) - ), - format!( - "{},{},{}", - quantize(back.x), - quantize(back.y), - quantize(back.z) - ), - ]; - ends.sort_unstable(); - ends.join("|") - }) + .map(|edge| edge_token(edge.front().point(), edge.back().point())) .collect(); edges.sort_unstable(); edges.dedup(); - let canonical = format!( + digest_to_key(&format!( "t:{}/{}/{}\nv:{}\n{}\ne:{}\n{}\n", ordered_faces(solid).len(), ordered_edges(solid).len(), @@ -240,11 +267,34 @@ pub(super) fn content_key(solid: &Solid) -> SolidKey { vertices.join("\n"), edges.len(), edges.join("\n") - ); - let digest = blake3::hash(canonical.as_bytes()); - let mut key = [0u8; 16]; - key.copy_from_slice(&digest.as_bytes()[..16]); - SolidKey::from_bytes(key) + )) +} + +pub(super) fn order_key(solid: &Solid) -> SolidKey { + let mut seen_vertices = HashSet::new(); + let vertices: Vec = solid + .vertex_iter() + .filter(|vertex| seen_vertices.insert(vertex.id())) + .map(|vertex| point_token(vertex.point())) + .collect(); + let mut seen_edges = HashSet::new(); + let edges: Vec = solid + .edge_iter() + .filter(|edge| seen_edges.insert(edge.id())) + .map(|edge| edge_token(edge.front().point(), edge.back().point())) + .collect(); + let faces: Vec = solid + .boundaries() + .iter() + .flat_map(Shell::face_iter) + .map(face_token) + .collect(); + digest_to_key(&format!( + "ov:{}\noe:{}\nof:{}\n", + vertices.join("|"), + edges.join("|"), + faces.join("|") + )) } impl BrepSolid { diff --git a/crates/bone-kernel/src/brep/step.rs b/crates/bone-kernel/src/brep/step.rs index 82c57e4..024ad6b 100644 --- a/crates/bone-kernel/src/brep/step.rs +++ b/crates/bone-kernel/src/brep/step.rs @@ -1,16 +1,21 @@ -use std::collections::{HashMap, HashSet}; +use std::collections::{BTreeSet, HashMap, HashSet}; use bone_types::{ EdgeLabel, EdgeRole, FaceLabel, FaceRole, FeatureId, ImportOrdinal, SolidKey, StepEntityKind, VertexLabel, VertexRole, }; -use truck_modeling::{Curve, Shell, Solid, Surface}; +use truck_modeling::{ + BSplineCurve, Curve, Cut, Matrix4, ParametricCurve, Point3, RevolutedCurve, Shell, Solid, + Surface, +}; use truck_stepio::r#in::Table; use truck_stepio::r#in::alias as step_in; -use truck_stepio::r#in::ruststep::ast::DataSection; +use truck_stepio::r#in::ruststep::ast::{DataSection, EntityInstance, Name, Parameter}; use truck_stepio::r#in::ruststep::parser::exchange::data_section; use truck_stepio::out::StepModels; -use truck_topology::compress::{CompressedEdge, CompressedFace, CompressedShell}; +use truck_topology::compress::{ + CompressedEdge, CompressedEdgeIndex, CompressedFace, CompressedShell, +}; use super::build::{SolidLabeling, assemble}; use super::persist::{ @@ -41,26 +46,144 @@ impl BrepSolid { } } +const CLOSED_SHELL: &str = "CLOSED_SHELL"; +const SOLID_ROOT_KEYWORDS: &[&str] = &["MANIFOLD_SOLID_BREP", "BREP_WITH_VOIDS"]; + +#[derive(Copy, Clone, PartialEq, Eq, PartialOrd, Ord, Hash)] +struct StepEntityId(u64); + +struct StepTopology { + closed_shells: BTreeSet, + solid_roots: BTreeSet, + references: HashMap>, +} + fn parse_step_solid(text: &str) -> Result { let data = first_data_section(text)?; + let topology = classify_topology(&data); + if topology.solid_roots.len() >= 2 { + return Err(BrepError::StepMultipleSolids { + count: topology.solid_roots.len(), + }); + } let table = Table::from_data_section(&data); - let mut shell_keys: Vec = table.shell.keys().copied().collect(); - shell_keys.sort_unstable(); - let shells = shell_keys + let shells = solid_shell_keys(&table, &topology)? .iter() .map(|key| { - let holder = &table.shell[key]; + let holder = &table.shell[&key.0]; let compressed = table .to_compressed_shell(holder) .map_err(|_| BrepError::StepShellMalformed)?; let bridged = bridge_shell(compressed)?; - Shell::extract(bridged).map_err(|_| BrepError::StepShellMalformed) + let normalized = split_closed_edges(bridged); + Shell::extract(normalized).map_err(|_| BrepError::StepShellMalformed) }) .collect::, BrepError>>()?; match shells.len() { 0 => Err(BrepError::StepEmpty), - 1 => Ok(Solid::new_unchecked(shells)), - count => Err(BrepError::StepMultipleShells { count }), + _ => Ok(Solid::new_unchecked(shells)), + } +} + +fn solid_shell_keys( + table: &Table, + topology: &StepTopology, +) -> Result, BrepError> { + let chosen: BTreeSet = if topology.solid_roots.is_empty() { + topology.closed_shells.clone() + } else { + shells_reachable( + &topology.references, + &topology.closed_shells, + &topology.solid_roots, + ) + }; + let mut keys: Vec = chosen + .into_iter() + .filter(|key| table.shell.contains_key(&key.0)) + .collect(); + if topology.solid_roots.is_empty() && keys.len() >= 2 { + return Err(BrepError::StepMultipleSolids { count: keys.len() }); + } + keys.sort_unstable(); + Ok(keys) +} + +fn classify_topology(data: &DataSection) -> StepTopology { + data.entities.iter().fold( + StepTopology { + closed_shells: BTreeSet::new(), + solid_roots: BTreeSet::new(), + references: HashMap::new(), + }, + |mut topology, instance| { + let (id, keywords, parameters) = entity_record(instance); + if keywords + .iter() + .any(|name| name.eq_ignore_ascii_case(CLOSED_SHELL)) + { + topology.closed_shells.insert(id); + } + if keywords.iter().any(|name| { + SOLID_ROOT_KEYWORDS + .iter() + .any(|root| name.eq_ignore_ascii_case(root)) + }) { + topology.solid_roots.insert(id); + } + let references: BTreeSet = + parameters.iter().flat_map(|&p| entity_refs(p)).collect(); + if !references.is_empty() { + topology.references.insert(id, references); + } + topology + }, + ) +} + +fn entity_record(instance: &EntityInstance) -> (StepEntityId, Vec<&str>, Vec<&Parameter>) { + match instance { + EntityInstance::Simple { id, record } => ( + StepEntityId(*id), + vec![record.name.as_str()], + vec![&record.parameter], + ), + EntityInstance::Complex { id, subsuper } => ( + StepEntityId(*id), + subsuper + .0 + .iter() + .map(|record| record.name.as_str()) + .collect(), + subsuper.0.iter().map(|record| &record.parameter).collect(), + ), + } +} + +fn entity_refs(parameter: &Parameter) -> BTreeSet { + match parameter { + Parameter::Ref(Name::Entity(id)) => BTreeSet::from([StepEntityId(*id)]), + Parameter::List(items) => items.iter().flat_map(entity_refs).collect(), + Parameter::Typed { parameter, .. } => entity_refs(parameter), + _ => BTreeSet::new(), + } +} + +fn shells_reachable( + references: &HashMap>, + closed_shells: &BTreeSet, + seeds: &BTreeSet, +) -> BTreeSet { + let expanded: BTreeSet = seeds + .iter() + .filter(|id| !closed_shells.contains(id)) + .flat_map(|id| references.get(id).into_iter().flatten().copied()) + .chain(seeds.iter().copied()) + .collect(); + if expanded.len() == seeds.len() { + expanded.intersection(closed_shells).copied().collect() + } else { + shells_reachable(references, closed_shells, &expanded) } } @@ -108,33 +231,140 @@ fn bridge_shell(shell: StepShell) -> Result { }) } +const CIRCLE_POSITION_TOL: f64 = 1.0e-4; +const CIRCLE_DERIVATIVE_TOL: f64 = 1.0e-3; +const CIRCLE_TRIALS: usize = 24; + +fn split_closed_edges(shell: ModelShell) -> ModelShell { + let CompressedShell { + vertices, + edges, + faces, + } = shell; + let (edges, vertices, remap) = edges.into_iter().fold( + (Vec::new(), vertices, Vec::>::new()), + |(mut edges, mut vertices, mut remap), edge| { + if let Some((midpoint, head, tail)) = split_closed_edge(&edge, vertices.len()) { + vertices.push(midpoint); + remap.push(vec![edges.len(), edges.len() + 1]); + edges.push(head); + edges.push(tail); + } else { + remap.push(vec![edges.len()]); + edges.push(edge); + } + (edges, vertices, remap) + }, + ); + let faces = faces + .into_iter() + .map(|face| CompressedFace { + boundaries: face + .boundaries + .into_iter() + .map(|wire| { + wire.into_iter() + .flat_map(|step| expand_edge(step, &remap)) + .collect() + }) + .collect(), + orientation: face.orientation, + surface: face.surface, + }) + .collect(); + CompressedShell { + vertices, + edges, + faces, + } +} + +fn split_closed_edge( + edge: &CompressedEdge, + midpoint_index: usize, +) -> Option<(Point3, CompressedEdge, CompressedEdge)> { + if edge.vertices.0 != edge.vertices.1 { + return None; + } + let (start, end) = edge.curve.try_range_tuple()?; + let middle = f64::midpoint(start, end); + let midpoint = edge.curve.subs(middle); + let mut head = edge.curve.clone(); + let tail = head.cut(middle); + Some(( + midpoint, + CompressedEdge { + vertices: (edge.vertices.0, midpoint_index), + curve: head, + }, + CompressedEdge { + vertices: (midpoint_index, edge.vertices.1), + curve: tail, + }, + )) +} + +fn expand_edge(step: CompressedEdgeIndex, remap: &[Vec]) -> Vec { + let pieces = &remap[step.index]; + if step.orientation { + pieces + .iter() + .map(|&index| CompressedEdgeIndex { + index, + orientation: true, + }) + .collect() + } else { + pieces + .iter() + .rev() + .map(|&index| CompressedEdgeIndex { + index, + orientation: false, + }) + .collect() + } +} + fn bridge_curve(curve: step_in::Curve3D) -> Result { match curve { step_in::Curve3D::Line(line) => Ok(Curve::Line(line)), step_in::Curve3D::BSplineCurve(spline) => Ok(Curve::BSplineCurve(spline)), step_in::Curve3D::NurbsCurve(nurbs) => Ok(Curve::NurbsCurve(nurbs)), + step_in::Curve3D::Conic(step_in::Conic3D::Ellipse(circle)) => bridge_circle(&circle), step_in::Curve3D::Conic(_) => Err(unsupported(StepEntityKind::ConicCurve)), step_in::Curve3D::Polyline(_) => Err(unsupported(StepEntityKind::PolylineCurve)), step_in::Curve3D::PCurve(_) => Err(unsupported(StepEntityKind::ParametricCurve)), } } +fn bridge_circle(circle: &step_in::Ellipse) -> Result { + let range = circle + .try_range_tuple() + .ok_or(unsupported(StepEntityKind::ConicCurve))?; + BSplineCurve::cubic_approximation( + circle, + range, + CIRCLE_POSITION_TOL, + CIRCLE_DERIVATIVE_TOL, + CIRCLE_TRIALS, + ) + .map(Curve::BSplineCurve) + .ok_or(unsupported(StepEntityKind::ConicCurve)) +} + fn bridge_surface(surface: step_in::Surface) -> Result { match surface { step_in::Surface::ElementarySurface(elementary) => match *elementary { step_in::ElementarySurface::Plane(plane) => Ok(Surface::Plane(plane)), + step_in::ElementarySurface::CylindricalSurface(cylinder) => Ok(revolution(cylinder)), + step_in::ElementarySurface::ConicalSurface(cone) => Ok(revolution(cone)), step_in::ElementarySurface::Sphere(_) => { Err(unsupported(StepEntityKind::SphericalSurface)) } - step_in::ElementarySurface::CylindricalSurface(_) => { - Err(unsupported(StepEntityKind::CylindricalSurface)) - } step_in::ElementarySurface::ToroidalSurface(_) => { Err(unsupported(StepEntityKind::ToroidalSurface)) } - step_in::ElementarySurface::ConicalSurface(_) => { - Err(unsupported(StepEntityKind::ConicalSurface)) - } }, step_in::Surface::BSplineSurface(spline) => Ok(Surface::BSplineSurface(*spline)), step_in::Surface::NurbsSurface(nurbs) => Ok(Surface::NurbsSurface(*nurbs)), @@ -142,6 +372,16 @@ fn bridge_surface(surface: step_in::Surface) -> Result { } } +fn revolution(surface: step_in::CylindricalSurface) -> Surface { + Surface::RevolutedCurve(surface.map_ref(|revolved| { + RevolutedCurve::by_revolution( + Curve::Line(*revolved.entity_curve()), + revolved.origin(), + revolved.axis(), + ) + })) +} + fn unsupported(kind: StepEntityKind) -> BrepError { BrepError::StepUnsupported { kind } } @@ -204,3 +444,225 @@ fn imported_labeling(solid: &Solid, feature: FeatureId) -> SolidLabeling { fn ordinal(index: usize) -> ImportOrdinal { ImportOrdinal::new(u32::try_from(index).unwrap_or(u32::MAX)) } + +#[cfg(test)] +mod tests { + use super::{ + CompressedEdge, CompressedEdgeIndex, bridge_curve, bridge_surface, expand_edge, + split_closed_edge, + }; + use core::str::FromStr; + use truck_modeling::{Curve, Matrix4, ParametricCurve, ParametricSurface, Point3, Surface}; + use truck_stepio::r#in::ruststep::ast::DataSection; + use truck_stepio::r#in::ruststep::tables::{EntityTable, Holder}; + use truck_stepio::r#in::{CircleHolder, ElementarySurfaceAnyHolder, Table, alias}; + + 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"; + + fn first(step: &str) -> H::Owned + where + H: Holder, + Table: EntityTable, + { + let Ok(section) = DataSection::from_str(step) else { + panic!("data section parses"); + }; + let table = Table::from_data_section(§ion); + let Ok(owned) = EntityTable::::get_owned(&table, 1) else { + panic!("entity #1 resolves"); + }; + owned + } + + fn distance(a: Point3, b: Point3) -> f64 { + let (dx, dy, dz) = (a.x - b.x, a.y - b.y, a.z - b.z); + f64::sqrt(dx * dx + dy * dy + dz * dz) + } + + #[test] + fn cylindrical_surface_bridges_to_an_exact_revolution() { + let step = format!("DATA;\n#1 = CYLINDRICAL_SURFACE('', #2, 5.0);\n{AXES}ENDSEC;"); + let surface: alias::ElementarySurface = + (&first::(&step)).into(); + let Ok(bridged) = bridge_surface(alias::Surface::ElementarySurface(Box::new(surface))) + else { + panic!("a cylindrical surface bridges into the kernel"); + }; + assert!(matches!(bridged, Surface::RevolutedCurve(_))); + let base = bridged.subs(0.0, 3.0); + assert!( + distance(base, Point3::new(5.0, 0.0, 3.0)) < 1.0e-9, + "the seam sits at radius 5 on the x axis: {base:?}" + ); + let quarter = bridged.subs(core::f64::consts::FRAC_PI_2, 1.0); + assert!( + distance(quarter, Point3::new(0.0, 5.0, 1.0)) < 1.0e-9, + "a quarter turn lands on the y axis: {quarter:?}" + ); + } + + #[test] + fn conical_surface_bridges_to_an_exact_revolution() { + let step = format!("DATA;\n#1 = CONICAL_SURFACE('', #2, 5.0, 0.5);\n{AXES}ENDSEC;"); + let surface: alias::ElementarySurface = + (&first::(&step)).into(); + let Ok(bridged) = bridge_surface(alias::Surface::ElementarySurface(Box::new(surface))) + else { + panic!("a conical surface bridges into the kernel"); + }; + assert!(matches!(bridged, Surface::RevolutedCurve(_))); + let radius = |p: Point3| (p.x * p.x + p.y * p.y).sqrt(); + let seam = bridged.subs(0.0, 0.3); + let spun = bridged.subs(core::f64::consts::FRAC_PI_2, 0.3); + assert!( + (radius(seam) - radius(spun)).abs() < 1.0e-9, + "a revolution holds one radius around the axis: {seam:?} vs {spun:?}" + ); + let low = radius(bridged.subs(0.0, 0.0)); + let high = radius(bridged.subs(0.0, 1.0)); + assert!( + (low - high).abs() > 1.0e-3, + "a cone changes radius along its axis: {low} vs {high}" + ); + } + + #[test] + fn circle_bridges_within_tolerance() { + let step = format!("DATA;\n#1 = CIRCLE('', #2, 5.0);\n{AXES}ENDSEC;"); + let Ok(circle): Result, _> = + (&first::(&step)).try_into() + else { + panic!("a circle converts to truck's analytic ellipse"); + }; + let Some((lo, hi)) = circle.try_range_tuple() else { + panic!("a parsed circle carries a bounded parameter range"); + }; + let analytic = circle; + let Ok(bridged) = bridge_curve(alias::Curve3D::Conic(alias::Conic3D::Ellipse(circle))) + else { + panic!("a circle bridges into the kernel"); + }; + assert!(matches!(bridged, Curve::BSplineCurve(_))); + (0..=12).for_each(|i| { + let t = lo + (hi - lo) * (f64::from(i) / 12.0); + let drift = distance(bridged.subs(t), analytic.subs(t)); + assert!( + drift < 1.0e-2, + "circle approximation drifts {drift} at t={t}" + ); + }); + } + + #[test] + fn spherical_surface_stays_unsupported() { + let step = format!("DATA;\n#1 = SPHERICAL_SURFACE('', #2, 5.0);\n{AXES}ENDSEC;"); + let surface: alias::ElementarySurface = + (&first::(&step)).into(); + assert!(bridge_surface(alias::Surface::ElementarySurface(Box::new(surface))).is_err()); + } + + fn circle_curve() -> Curve { + let step = format!("DATA;\n#1 = CIRCLE('', #2, 5.0);\n{AXES}ENDSEC;"); + let Ok(circle): Result, _> = + (&first::(&step)).try_into() + else { + panic!("a circle converts to truck's analytic ellipse"); + }; + let Ok(bridged) = bridge_curve(alias::Curve3D::Conic(alias::Conic3D::Ellipse(circle))) + else { + panic!("a circle bridges into the kernel"); + }; + bridged + } + + #[test] + fn closed_edge_splits_into_two_open_halves() { + let edge = CompressedEdge { + vertices: (0, 0), + curve: circle_curve(), + }; + let Some((midpoint, head, tail)) = split_closed_edge(&edge, 1) else { + panic!("a closed circular edge splits at its parameter midpoint"); + }; + assert_eq!( + head.vertices, + (0, 1), + "the head runs from the original endpoint to the fresh midpoint vertex" + ); + assert_eq!( + tail.vertices, + (1, 0), + "the tail runs from the midpoint vertex back to the original endpoint" + ); + let Some((_, head_end)) = head.curve.try_range_tuple() else { + panic!("the head half carries a bounded range"); + }; + let Some((tail_start, _)) = tail.curve.try_range_tuple() else { + panic!("the tail half carries a bounded range"); + }; + assert!( + distance(head.curve.subs(head_end), midpoint) < 1.0e-6, + "the head half ends at the midpoint" + ); + assert!( + distance(tail.curve.subs(tail_start), midpoint) < 1.0e-6, + "the tail half starts at the midpoint" + ); + } + + #[test] + fn open_edge_is_left_intact() { + let edge = CompressedEdge { + vertices: (0, 1), + curve: circle_curve(), + }; + assert!( + split_closed_edge(&edge, 2).is_none(), + "an edge with distinct endpoints is never split" + ); + } + + #[test] + fn expand_edge_rewrites_boundaries_through_the_remap() { + let remap = vec![vec![5usize], vec![7usize, 8usize]]; + let as_pairs = |steps: Vec| { + steps + .iter() + .map(|step| (step.index, step.orientation)) + .collect::>() + }; + assert_eq!( + as_pairs(expand_edge( + CompressedEdgeIndex { + index: 1, + orientation: true, + }, + &remap, + )), + vec![(7, true), (8, true)], + "a forward split edge expands to head then tail, both forward" + ); + assert_eq!( + as_pairs(expand_edge( + CompressedEdgeIndex { + index: 1, + orientation: false, + }, + &remap, + )), + vec![(8, false), (7, false)], + "a reversed split edge expands to tail then head, both reversed" + ); + assert_eq!( + as_pairs(expand_edge( + CompressedEdgeIndex { + index: 0, + orientation: true, + }, + &remap, + )), + vec![(5, true)], + "an unsplit edge passes through unchanged" + ); + } +} diff --git a/crates/bone-kernel/src/brep/tessellate.rs b/crates/bone-kernel/src/brep/tessellate.rs index 93b7a3b..9d6316e 100644 --- a/crates/bone-kernel/src/brep/tessellate.rs +++ b/crates/bone-kernel/src/brep/tessellate.rs @@ -168,12 +168,21 @@ fn effective_tolerance( chord: ChordHeightTolerance, angle: AngleTolerance, ) -> f64 { - let chord_mm = chord.millimeters(); - let angle_chord = brep.bounding_box().map_or(f64::INFINITY, |bbox| { + chord + .millimeters() + .min(angular_chord(brep, angle)) + .max(TOLERANCE) +} + +fn angular_chord(brep: &BrepSolid, angle: AngleTolerance) -> f64 { + let radians = angle.radians(); + if !(radians.is_finite() && radians > 0.0) { + return f64::INFINITY; + } + brep.bounding_box().map_or(f64::INFINITY, |bbox| { let radius = bbox.extent().norm_mm() / 2.0; - radius * (1.0 - (angle.radians() / 2.0).cos()) - }); - chord_mm.min(angle_chord).max(TOLERANCE) + radius * (1.0 - (radians / 2.0).cos()) + }) } fn append_polygon(slab: &mut FaceMesh, polygon: &PolygonMesh) -> Result<(), MeshError> { diff --git a/crates/bone-kernel/tests/extrude.rs b/crates/bone-kernel/tests/extrude.rs index 4936415..57b782e 100644 --- a/crates/bone-kernel/tests/extrude.rs +++ b/crates/bone-kernel/tests/extrude.rs @@ -520,6 +520,29 @@ fn blob_round_trip_preserves_donut_labels() { assert_blob_round_trip(&solid); } +#[test] +fn blob_reattach_rejects_a_mismatched_order() { + let mut ids = Ids::new(); + let cube_profile = + ExtrudeProfile::new(xy_plane(), vec![rectangle(&mut ids, 0.0, 0.0, 2.0, 3.0)]); + let cube = evaluate(&mut ids, &cube_profile, &blind(5.0)); + let cylinder_profile = ExtrudeProfile::new( + xy_plane(), + vec![circle_loop(&mut ids, point(0.0, 0.0), 5.0)], + ); + let cylinder = evaluate(&mut ids, &cylinder_profile, &blind(10.0)); + let Ok(blob) = cube.to_blob() else { + panic!("cube serializes to a blob"); + }; + assert!( + matches!( + BrepSolid::from_blob(&blob, cylinder.reattach_data()), + Err(BrepError::ReattachOrder) + ), + "a reattach captured from a different solid is rejected before any label binds" + ); +} + #[test] fn content_key_is_stable_and_geometry_sensitive() { let mut ids = Ids::new(); diff --git a/crates/bone-kernel/tests/step_geometry.rs b/crates/bone-kernel/tests/step_geometry.rs index a699fa4..304dbdd 100644 --- a/crates/bone-kernel/tests/step_geometry.rs +++ b/crates/bone-kernel/tests/step_geometry.rs @@ -68,6 +68,6 @@ fn multiple_bodies_are_rejected_not_merged() { let doc = envelope(&StepModels::from_iter([&first, &second]).to_string()); assert!(matches!( BrepSolid::from_step(&doc, feature(), None), - Err(BrepError::StepMultipleShells { count: 2 }) + Err(BrepError::StepMultipleSolids { count: 2 }) )); }