use crate::{
recognize_surfaces_with_unresolved, reconstruct_surface, AnalyticSurface, Mesh,
MeshAnalysisOptions, PhaseTimings, RecognitionOptions, SamplingMode, SurfaceFitResult,
SurfaceHint, SurfaceRegion, SurfaceType, Vec3,
};
use brep_kernel::{
audit_step_manifold, export_step, import_step, make_cone_brep, make_cylinder_brep,
make_sphere_brep, make_torus_brep, merge_same_surface_faces, mesh_regions_to_brep,
mesh_to_faceted_brep, segment_mesh_faces, RegionCarrier, SegmentOptions, UNASSIGNED_REGION,
};
use serde::{Deserialize, Serialize};
use std::collections::{BTreeMap, BTreeSet, VecDeque};
use std::fmt::{Display, Formatter};
use web_time::Instant;
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Deserialize, Serialize)]
pub enum ConversionPolicy {
#[default]
AllowFacetedFallback,
RequireFullyAnalytic,
}
#[derive(Clone, Debug, Deserialize, Serialize)]
#[serde(default)]
pub struct StlConversionOptions {
pub recognition: RecognitionOptions,
pub policy: ConversionPolicy,
pub try_kernel_analytic_rebuild: bool,
pub try_hybrid_rebuild: bool,
pub weld_tolerance: f64,
pub coordinate_precision_tolerance: f64,
pub kernel_deflection_angle_degrees: f64,
pub kernel_fit_tolerance: f64,
pub kernel_normal_tolerance_degrees: f64,
}
impl Default for StlConversionOptions {
fn default() -> Self {
let recognition = RecognitionOptions {
collect_phase_timings: true,
sampling: SamplingMode::Vertices,
..RecognitionOptions::default()
};
Self {
recognition,
policy: ConversionPolicy::AllowFacetedFallback,
try_kernel_analytic_rebuild: true,
try_hybrid_rebuild: true,
weld_tolerance: -1.0,
coordinate_precision_tolerance: 0.0,
kernel_deflection_angle_degrees: 30.0,
kernel_fit_tolerance: 1.0e-3,
kernel_normal_tolerance_degrees: 15.0,
}
}
}
#[derive(Clone, Copy, Debug, PartialEq, Eq, Deserialize, Serialize)]
#[serde(rename_all = "snake_case")]
pub enum ConversionBackend {
RansacSphere,
RansacTorus,
RansacCappedCylinder,
RansacCappedCone,
KernelAnalyticRebuild,
#[serde(alias = "hybrid_plane_cylinder_rebuild")]
HybridAnalyticRebuild,
FacetedRepair,
FacetedRepairCoplanarMerged,
}
#[derive(Clone, Debug, Deserialize, Serialize)]
pub struct ConversionRegionReport {
pub surface_type: SurfaceType,
pub surface: AnalyticSurface,
pub support_triangles: usize,
pub supported_area: f64,
pub rms_error: f64,
pub max_error: f64,
pub rms_normal_error_radians: f64,
pub max_normal_error_radians: f64,
pub confidence: f64,
pub orientation: i8,
pub reason: String,
pub phase_timings: PhaseTimings,
}
#[derive(Clone, Copy, Debug, Default, Deserialize, Serialize)]
pub struct ConversionTimings {
pub recognition_seconds: f64,
pub topology_build_seconds: f64,
pub step_export_seconds: f64,
pub step_validation_seconds: f64,
pub total_seconds: f64,
}
#[derive(Clone, Copy, Debug, Default, Deserialize, Serialize)]
pub struct HybridConversionReport {
pub total_faces: usize,
pub analytic_plane_faces: usize,
pub analytic_plane_triangles: usize,
pub analytic_cylinder_faces: usize,
pub analytic_cylinder_triangles: usize,
#[serde(default)]
pub analytic_cone_faces: usize,
#[serde(default)]
pub analytic_cone_triangles: usize,
pub faceted_faces: usize,
pub faceted_triangles: usize,
pub demoted_regions: usize,
pub demoted_triangles: usize,
pub segmentation_plane_regions: usize,
pub segmentation_cylinder_regions: usize,
#[serde(default)]
pub segmentation_cone_regions: usize,
pub segmentation_unsupported_regions: usize,
}
#[derive(Clone, Debug, Deserialize, Serialize)]
pub struct StlConversionReport {
pub backend: ConversionBackend,
pub backend_reason: String,
pub fallback_cause: Option<String>,
pub input_vertices: usize,
pub input_triangles: usize,
pub recognized_regions: usize,
pub recognized_triangles: usize,
pub unresolved_triangles: usize,
pub source_closed_manifold: bool,
#[serde(default)]
pub requested_distance_tolerance: f64,
#[serde(default)]
pub coordinate_precision_tolerance: f64,
#[serde(default)]
pub effective_distance_tolerance: f64,
pub region_type_counts: BTreeMap<String, usize>,
pub regions: Vec<ConversionRegionReport>,
pub topology_refit: Option<ConversionRegionReport>,
#[serde(default)]
pub hybrid_rebuild: Option<HybridConversionReport>,
pub exported_step_bytes: usize,
pub exported_advanced_faces: usize,
#[serde(default)]
pub faceted_faces_before_merge: Option<usize>,
#[serde(default)]
pub faceted_faces_after_merge: Option<usize>,
pub roundtrip_solids: usize,
pub manifold_audit_issues: Vec<String>,
pub timings: ConversionTimings,
pub messages: Vec<String>,
}
#[derive(Clone, Debug, Deserialize, Serialize)]
pub struct StlConversionOutput {
pub step_text: String,
pub report: StlConversionReport,
}
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct StlConversionError(pub String);
impl Display for StlConversionError {
fn fmt(&self, formatter: &mut Formatter<'_>) -> std::fmt::Result {
formatter.write_str(&self.0)
}
}
impl std::error::Error for StlConversionError {}
impl From<String> for StlConversionError {
fn from(value: String) -> Self {
Self(value)
}
}
pub fn binary_stl_coordinate_precision_tolerance(mesh: &Mesh) -> f64 {
let maximum_absolute_coordinate = mesh
.vertices
.iter()
.flat_map(|point| [point.x.abs(), point.y.abs(), point.z.abs()])
.fold(0.0_f64, f64::max);
let coordinate_scale = maximum_absolute_coordinate.max(mesh_diagonal(mesh));
coordinate_scale * (f32::EPSILON as f64) * 0.5 * 3.0_f64.sqrt()
}
pub fn convert_stl_mesh_to_step(
mesh: &Mesh,
source_positions: &[f64],
source_indices: Option<&[u32]>,
options: &StlConversionOptions,
part_name: &str,
unit: &str,
timestamp: &str,
) -> Result<StlConversionOutput, StlConversionError> {
let total_started = Instant::now();
validate_source_buffers(mesh, source_positions, source_indices)?;
if !options.coordinate_precision_tolerance.is_finite()
|| options.coordinate_precision_tolerance < 0.0
{
return Err(StlConversionError(
"coordinate_precision_tolerance must be finite and non-negative".to_owned(),
));
}
let mut recognition_options = options.recognition.clone();
recognition_options.collect_phase_timings = true;
let requested_distance_tolerance = recognition_options.distance_tolerance;
recognition_options.distance_tolerance = recognition_options
.distance_tolerance
.max(options.coordinate_precision_tolerance);
let effective_distance_tolerance = recognition_options.distance_tolerance;
let recognition_started = Instant::now();
let mut recognition = recognize_surfaces_with_unresolved(mesh, &recognition_options)
.map_err(|error| StlConversionError(format!("surface recognition failed: {error}")))?;
let completed_plane_regions = complete_small_planar_regions(
mesh,
&recognition_options,
&mut recognition.regions,
&mut recognition.unresolved_triangles,
)?;
let recognition_seconds = recognition_started.elapsed().as_secs_f64();
let source_closed_manifold = closed_manifold(mesh);
let recognized_triangles = recognition
.regions
.iter()
.map(|region| region.triangle_indices.len())
.sum();
let regions = recognition
.regions
.iter()
.map(region_report)
.collect::<Vec<_>>();
let mut region_type_counts = BTreeMap::new();
for region in &recognition.regions {
*region_type_counts
.entry(region.surface.surface_type().name().to_owned())
.or_insert(0) += 1;
}
let topology_started = Instant::now();
let analytic_coverage = recognition.unresolved_triangles.is_empty()
&& recognized_triangles == mesh.triangles.len()
&& disjoint_complete_partition(&recognition.regions, mesh.triangles.len());
let primitive = if analytic_coverage && source_closed_manifold {
direct_analytic_solid(mesh, &recognition.regions, &recognition_options)
} else {
None
};
let mut fallback_cause = None;
let mut messages = vec![format!(
"RANSAC recognized {} region(s) covering {recognized_triangles}/{} triangles",
recognition.regions.len(),
mesh.triangles.len()
)];
if effective_distance_tolerance > requested_distance_tolerance {
messages.push(format!(
"source coordinate precision raised the absolute recognition distance from {requested_distance_tolerance:.6e} to {effective_distance_tolerance:.6e}"
));
}
if completed_plane_regions > 0 {
messages.push(format!(
"a plane-only completion pass recovered {completed_plane_regions} small feature-bounded planar region(s)"
));
}
let (
solid,
backend,
backend_reason,
topology_refit,
hybrid_rebuild,
faceted_faces_before_merge,
faceted_faces_after_merge,
) = if let Some(primitive) = primitive {
(
primitive.solid?,
primitive.backend,
primitive.reason,
primitive.topology_refit,
None,
None,
None,
)
} else {
let direct_cause = if !analytic_coverage {
format!(
"RANSAC did not prove complete analytic coverage ({} unresolved triangle(s))",
recognition.unresolved_triangles.len()
)
} else if !source_closed_manifold {
"recognized mesh is not a closed two-manifold".to_owned()
} else {
"recognized regions do not match a safe complete primitive topology".to_owned()
};
let kernel_eligible = analytic_coverage
&& recognition.regions.iter().all(|region| {
matches!(
region.surface.surface_type(),
SurfaceType::Plane | SurfaceType::Cylinder | SurfaceType::Cone
)
});
let kernel_attempt = if source_closed_manifold
&& kernel_eligible
&& options.try_kernel_analytic_rebuild
{
try_kernel_analytic(source_positions, source_indices, options)
} else {
Err("independent kernel analytic rebuild requires complete RANSAC plane/cylinder/cone coverage".to_owned())
};
let analytic_attempt = match kernel_attempt {
Ok(solid) => Ok((
solid,
ConversionBackend::KernelAnalyticRebuild,
"RANSAC proved complete plane/cylinder/cone coverage; independent kernel segmentation then rebuilt and validated the complete analytic shell".to_owned(),
None,
)),
Err(kernel_error) => {
let hybrid_attempt = if source_closed_manifold && options.try_hybrid_rebuild {
try_hybrid_analytic(source_positions, source_indices, options)
} else if !options.try_hybrid_rebuild {
Err("mixed analytic/faceted rebuild was disabled".to_owned())
} else {
Err("mixed analytic/faceted rebuild requires a closed two-manifold".to_owned())
};
match hybrid_attempt {
Ok(output) => {
let hybrid_report = hybrid_report(output.stats);
if options.policy == ConversionPolicy::RequireFullyAnalytic
&& hybrid_report.faceted_faces != 0
{
Err(format!(
"{kernel_error}; mixed rebuild retained {} faceted face(s) for {} source triangle(s)",
hybrid_report.faceted_faces, hybrid_report.faceted_triangles
))
} else {
let reason = if hybrid_report.faceted_faces == 0 {
"independent segmentation reconstructed and validated a fully analytic plane/cylinder/cone shell"
} else {
"independent segmentation reconstructed exact plane/cylinder/cone regions and retained unsupported or unsafe regions as facets"
};
Ok((
output.solid,
ConversionBackend::HybridAnalyticRebuild,
reason.to_owned(),
Some(hybrid_report),
))
}
}
Err(hybrid_error) => Err(format!("{kernel_error}; {hybrid_error}")),
}
}
};
match analytic_attempt {
Ok((solid, backend, reason, hybrid)) => {
(solid, backend, reason, None, hybrid, None, None)
}
Err(analytic_error) => {
let cause = format!("{direct_cause}; {analytic_error}");
if options.policy == ConversionPolicy::RequireFullyAnalytic {
return Err(StlConversionError(format!(
"strict analytic conversion refused faceted fallback: {cause}"
)));
}
fallback_cause = Some(cause.clone());
messages.push(format!("analytic rebuild unavailable: {cause}"));
let faceted =
mesh_to_faceted_brep(source_positions, source_indices, options.weld_tolerance)
.map_err(|error| {
StlConversionError(format!("faceted mesh repair failed: {error}"))
})?;
let faces_before = solid_face_count(&faceted);
let merge_tolerance = coplanar_merge_tolerance(mesh);
match merge_same_surface_faces(&faceted, merge_tolerance) {
Ok(merged) => {
let faces_after = solid_face_count(&merged);
if faces_after < faces_before {
messages.push(format!(
"coplanar faceted merge reduced faces from {faces_before} to {faces_after} (tolerance {merge_tolerance:.3e})"
));
(
merged,
ConversionBackend::FacetedRepairCoplanarMerged,
"the complete source triangle set was repaired as a faceted BREP, then adjacent coplanar facets were merged without introducing curved replacement topology".to_owned(),
None,
None,
Some(faces_before),
Some(faces_after),
)
} else {
messages.push(format!(
"coplanar faceted merge found no mergeable faces (tolerance {merge_tolerance:.3e})"
));
(
faceted,
ConversionBackend::FacetedRepair,
"the complete source triangle set was repaired and preserved as a faceted BREP; no adjacent coplanar facets could be merged".to_owned(),
None,
None,
Some(faces_before),
Some(faces_before),
)
}
}
Err(error) => {
messages.push(format!(
"coplanar faceted merge was unavailable; retained the validated repaired mesh: {error}"
));
(
faceted,
ConversionBackend::FacetedRepair,
"the complete source triangle set was repaired and preserved as a faceted BREP; the optional coplanar merge was unavailable".to_owned(),
None,
None,
Some(faces_before),
Some(faces_before),
)
}
}
}
}
};
let topology_build_seconds = topology_started.elapsed().as_secs_f64();
let topology_issues = solid.validate();
if !topology_issues.is_empty() {
return Err(StlConversionError(format!(
"constructed BREP failed topology validation: {topology_issues:?}"
)));
}
let export_started = Instant::now();
let step_text = export_step(&[solid], part_name, unit, timestamp)
.map_err(|error| StlConversionError(format!("STEP export failed: {error}")))?;
let step_export_seconds = export_started.elapsed().as_secs_f64();
let validation_started = Instant::now();
let manifold_audit_issues = audit_step_manifold(&step_text);
if !manifold_audit_issues.is_empty() {
return Err(StlConversionError(format!(
"STEP manifold audit failed: {}",
manifold_audit_issues.join("; ")
)));
}
let imported = import_step(&step_text).map_err(|error| {
StlConversionError(format!("STEP round-trip validation failed: {error}"))
})?;
if let Some(issues) = imported
.iter()
.map(|solid| solid.validate())
.find(|issues| !issues.is_empty())
{
return Err(StlConversionError(format!(
"round-tripped STEP has invalid topology: {issues:?}"
)));
}
let step_validation_seconds = validation_started.elapsed().as_secs_f64();
messages.push(format!(
"STEP manifold audit and round-trip import passed for {} solid(s)",
imported.len()
));
messages.push(backend_reason.clone());
let timings = ConversionTimings {
recognition_seconds,
topology_build_seconds,
step_export_seconds,
step_validation_seconds,
total_seconds: total_started.elapsed().as_secs_f64(),
};
let report = StlConversionReport {
backend,
backend_reason,
fallback_cause,
input_vertices: mesh.vertices.len(),
input_triangles: mesh.triangles.len(),
recognized_regions: recognition.regions.len(),
recognized_triangles,
unresolved_triangles: recognition.unresolved_triangles.len(),
source_closed_manifold,
requested_distance_tolerance,
coordinate_precision_tolerance: options.coordinate_precision_tolerance,
effective_distance_tolerance,
region_type_counts,
regions,
topology_refit,
hybrid_rebuild,
exported_step_bytes: step_text.len(),
exported_advanced_faces: step_text.matches("ADVANCED_FACE(").count(),
faceted_faces_before_merge,
faceted_faces_after_merge,
roundtrip_solids: imported.len(),
manifold_audit_issues,
timings,
messages,
};
Ok(StlConversionOutput { step_text, report })
}
fn validate_source_buffers(
mesh: &Mesh,
positions: &[f64],
indices: Option<&[u32]>,
) -> Result<(), StlConversionError> {
if positions.is_empty() || !positions.len().is_multiple_of(3) {
return Err(StlConversionError(
"source position buffer must contain complete non-empty xyz triples".to_owned(),
));
}
if positions.iter().any(|value| !value.is_finite()) {
return Err(StlConversionError(
"source position buffer contains a non-finite coordinate".to_owned(),
));
}
let source_triangles = match indices {
Some(indices) => {
if !indices.len().is_multiple_of(3) {
return Err(StlConversionError(
"source index buffer must contain complete triangles".to_owned(),
));
}
if indices
.iter()
.any(|&index| index as usize >= positions.len() / 3)
{
return Err(StlConversionError(
"source index buffer references a missing position".to_owned(),
));
}
indices.len() / 3
}
None => {
if !positions.len().is_multiple_of(9) {
return Err(StlConversionError(
"unindexed source positions must be a triangle soup".to_owned(),
));
}
positions.len() / 9
}
};
if source_triangles != mesh.triangles.len() {
return Err(StlConversionError(format!(
"source buffers contain {source_triangles} triangles but recognition mesh contains {}",
mesh.triangles.len()
)));
}
Ok(())
}
fn complete_small_planar_regions(
mesh: &Mesh,
options: &RecognitionOptions,
regions: &mut Vec<SurfaceRegion>,
unresolved: &mut Vec<usize>,
) -> Result<usize, StlConversionError> {
if unresolved.len() < 2 {
return Ok(0);
}
let analyzed = mesh
.analyze(&MeshAnalysisOptions {
feature_angle: options.feature_angle,
..MeshAnalysisOptions::default()
})
.map_err(|error| {
StlConversionError(format!("plane completion analysis failed: {error}"))
})?;
let allowed = unresolved.iter().copied().collect::<BTreeSet<_>>();
let mut visited = BTreeSet::new();
let mut accepted = BTreeSet::new();
let mut completed = 0;
let mut plane_options = options.clone();
plane_options.minimum_support = 2;
for &seed in unresolved.iter() {
if !visited.insert(seed) {
continue;
}
let mut component = Vec::new();
let mut queue = VecDeque::from([seed]);
while let Some(triangle) = queue.pop_front() {
component.push(triangle);
let data = &analyzed.triangles[triangle];
for edge in 0..3 {
if data.feature_edges[edge] {
continue;
}
if let Some(neighbor) = data.neighbors[edge] {
if allowed.contains(&neighbor) && visited.insert(neighbor) {
queue.push_back(neighbor);
}
}
}
}
component.sort_unstable();
if component.len() < 2 {
continue;
}
let Ok(fit) = reconstruct_surface(
mesh,
&component,
&SurfaceHint::KnownType {
surface_type: SurfaceType::Plane,
},
&plane_options,
) else {
continue;
};
if fit.metrics.support_triangles != component.len()
|| !matches!(fit.surface, AnalyticSurface::Plane(_))
{
continue;
}
accepted.extend(component.iter().copied());
regions.push(SurfaceRegion {
surface: fit.surface,
orientation: fit.orientation,
triangle_indices: component,
metrics: fit.metrics,
confidence: fit.confidence,
diagnostics: fit.diagnostics,
});
completed += 1;
}
unresolved.retain(|triangle| !accepted.contains(triangle));
Ok(completed)
}
fn region_report(region: &SurfaceRegion) -> ConversionRegionReport {
fit_fields(
region.surface,
region.orientation,
®ion.metrics,
region.confidence,
®ion.diagnostics,
)
}
fn refit_report(fit: &SurfaceFitResult) -> ConversionRegionReport {
fit_fields(
fit.surface,
fit.orientation,
&fit.metrics,
fit.confidence,
&fit.diagnostics,
)
}
fn fit_fields(
surface: AnalyticSurface,
orientation: i8,
metrics: &crate::FitMetrics,
confidence: f64,
diagnostics: &crate::FitDiagnostics,
) -> ConversionRegionReport {
ConversionRegionReport {
surface_type: surface.surface_type(),
surface,
support_triangles: metrics.support_triangles,
supported_area: metrics.supported_area,
rms_error: metrics.rms_error,
max_error: metrics.max_error,
rms_normal_error_radians: metrics.rms_normal_error,
max_normal_error_radians: metrics.max_normal_error,
confidence,
orientation,
reason: diagnostics.reason.clone(),
phase_timings: diagnostics.phase_timings,
}
}
fn disjoint_complete_partition(regions: &[SurfaceRegion], triangle_count: usize) -> bool {
let mut seen = BTreeSet::new();
regions.iter().all(|region| {
region
.triangle_indices
.iter()
.all(|&triangle| triangle < triangle_count && seen.insert(triangle))
}) && seen.len() == triangle_count
}
fn closed_manifold(mesh: &Mesh) -> bool {
let mut uses = BTreeMap::<(u32, u32), (usize, i32)>::new();
for triangle in &mesh.triangles {
if triangle[0] == triangle[1] || triangle[1] == triangle[2] || triangle[2] == triangle[0] {
return false;
}
for edge in [
(triangle[0], triangle[1]),
(triangle[1], triangle[2]),
(triangle[2], triangle[0]),
] {
let key = if edge.0 < edge.1 {
(edge.0, edge.1)
} else {
(edge.1, edge.0)
};
let entry = uses.entry(key).or_default();
entry.0 += 1;
entry.1 += if edge == key { 1 } else { -1 };
}
}
!uses.is_empty()
&& uses
.values()
.all(|&(count, sense)| count == 2 && sense == 0)
}
struct DirectAnalyticBuild {
solid: Result<brep_kernel::BrepSolid, StlConversionError>,
backend: ConversionBackend,
reason: String,
topology_refit: Option<ConversionRegionReport>,
}
fn direct_analytic_solid(
mesh: &Mesh,
regions: &[SurfaceRegion],
options: &RecognitionOptions,
) -> Option<DirectAnalyticBuild> {
if regions.len() == 1 {
return match regions[0].surface {
AnalyticSurface::Sphere(sphere) => Some(DirectAnalyticBuild {
solid: make_sphere_brep(
kernel_vec(sphere.center),
sphere.radius,
kernel_vec(Vec3::new(0.0, 0.0, 1.0)),
)
.map_err(StlConversionError),
backend: ConversionBackend::RansacSphere,
reason: "RANSAC proved complete closed spherical coverage; exported an exact analytic sphere".to_owned(),
topology_refit: None,
}),
AnalyticSurface::Torus(torus) if torus.major_radius > torus.minor_radius => Some(DirectAnalyticBuild {
solid: make_torus_brep(
kernel_vec(torus.center),
kernel_vec(torus.axis),
torus.major_radius,
torus.minor_radius,
)
.map_err(StlConversionError),
backend: ConversionBackend::RansacTorus,
reason: "RANSAC proved complete closed ring-torus coverage; exported an exact analytic torus".to_owned(),
topology_refit: None,
}),
_ => None,
};
}
let curved = regions
.iter()
.filter(|region| !matches!(region.surface, AnalyticSurface::Plane(_)))
.collect::<Vec<_>>();
let planes = regions
.iter()
.filter_map(|region| match region.surface {
AnalyticSurface::Plane(plane) => Some(plane),
_ => None,
})
.collect::<Vec<_>>();
if curved.len() != 1 {
return None;
}
let topology_refit;
let mut topology_refit_report = None;
let curved = if let AnalyticSurface::Torus(torus) = curved[0].surface {
let scale = mesh_diagonal(mesh);
let collapsed = torus.major_radius.min(torus.minor_radius) > 20.0 * scale;
let forced_type = match planes.len() {
2 if collapsed => Some(SurfaceType::Cylinder),
1 if collapsed => Some(SurfaceType::Cone),
_ => None,
};
if let Some(surface_type) = forced_type {
topology_refit = reconstruct_surface(
mesh,
&curved[0].triangle_indices,
&SurfaceHint::KnownType { surface_type },
options,
)
.ok()?;
topology_refit_report = Some(refit_report(&topology_refit));
&topology_refit.surface
} else {
&curved[0].surface
}
} else {
&curved[0].surface
};
match *curved {
AnalyticSurface::Cylinder(cylinder) if planes.len() == 2 => {
let (low, high) = axial_range(mesh, cylinder.axis_origin, cylinder.axis)?;
let tolerance = conversion_tolerance(mesh, options);
if high - low <= tolerance
|| !caps_match(
&planes,
cylinder.axis_origin,
cylinder.axis,
&[low, high],
tolerance,
)
{
return None;
}
let base = cylinder.axis_origin + cylinder.axis * low;
Some(DirectAnalyticBuild {
solid: make_cylinder_brep(
kernel_vec(base),
kernel_vec(cylinder.axis),
cylinder.radius,
high - low,
)
.map_err(StlConversionError),
backend: ConversionBackend::RansacCappedCylinder,
reason: if topology_refit_report.is_some() {
"two-cap topology disambiguated a large-radius torus limit; the production known-type cylinder refit passed all gates and supplied the exact capped-cylinder parameters".to_owned()
} else {
"RANSAC proved one cylindrical wall plus two matching planar caps; exported an exact capped cylinder".to_owned()
},
topology_refit: topology_refit_report,
})
}
AnalyticSurface::Cone(cone) if planes.len() == 1 || planes.len() == 2 => {
let (low, high) = axial_range(mesh, cone.apex, cone.axis)?;
let tolerance = conversion_tolerance(mesh, options);
if low < -tolerance || high - low <= tolerance {
return None;
}
let expected_caps = if low <= tolerance {
vec![high]
} else {
vec![low, high]
};
if planes.len() != expected_caps.len()
|| !caps_match(&planes, cone.apex, cone.axis, &expected_caps, tolerance)
{
return None;
}
let radius_bottom = low.max(0.0) * cone.half_angle.tan();
let radius_top = high * cone.half_angle.tan();
if radius_top <= tolerance || (low > tolerance && radius_bottom <= tolerance) {
return None;
}
let (base, build_axis, build_bottom, build_top) = if low <= tolerance {
(
cone.apex + cone.axis * high,
cone.axis * -1.0,
radius_top,
0.0,
)
} else {
(
cone.apex + cone.axis * low,
cone.axis,
radius_bottom,
radius_top,
)
};
Some(DirectAnalyticBuild {
solid: make_cone_brep(
kernel_vec(base),
kernel_vec(build_axis),
build_bottom,
build_top,
high - low.max(0.0),
)
.map_err(StlConversionError),
backend: ConversionBackend::RansacCappedCone,
reason: if topology_refit_report.is_some() {
"cone-cap topology disambiguated a large-radius torus limit; the production known-type cone refit passed all gates and supplied the exact capped-cone parameters".to_owned()
} else {
"RANSAC proved one conical wall and matching planar cap topology; exported an exact capped cone".to_owned()
},
topology_refit: topology_refit_report,
})
}
_ => None,
}
}
fn axial_range(mesh: &Mesh, origin: Vec3, axis: Vec3) -> Option<(f64, f64)> {
let mut low = f64::INFINITY;
let mut high = f64::NEG_INFINITY;
for &point in &mesh.vertices {
let height = (point - origin).dot(axis);
low = low.min(height);
high = high.max(height);
}
(low.is_finite() && high.is_finite()).then_some((low, high))
}
fn caps_match(
planes: &[crate::PlaneSurface],
origin: Vec3,
axis: Vec3,
expected_heights: &[f64],
tolerance: f64,
) -> bool {
let mut actual = Vec::with_capacity(planes.len());
for plane in planes {
if plane.normal.dot(axis).abs() < 1.0 - 1.0e-6 {
return false;
}
actual.push((plane.origin - origin).dot(axis));
}
actual.sort_by(f64::total_cmp);
let mut expected = expected_heights.to_vec();
expected.sort_by(f64::total_cmp);
actual.len() == expected.len()
&& actual
.iter()
.zip(expected)
.all(|(actual, expected)| (*actual - expected).abs() <= tolerance)
}
fn mesh_tolerance(mesh: &Mesh) -> f64 {
let mut low = mesh.vertices[0];
let mut high = mesh.vertices[0];
for &point in &mesh.vertices[1..] {
low.x = low.x.min(point.x);
low.y = low.y.min(point.y);
low.z = low.z.min(point.z);
high.x = high.x.max(point.x);
high.y = high.y.max(point.y);
high.z = high.z.max(point.z);
}
(high - low).length().max(1.0) * 1.0e-5
}
fn conversion_tolerance(mesh: &Mesh, options: &RecognitionOptions) -> f64 {
mesh_tolerance(mesh)
.max(options.distance_tolerance + options.relative_tolerance * mesh_diagonal(mesh))
}
fn mesh_diagonal(mesh: &Mesh) -> f64 {
let mut low = mesh.vertices[0];
let mut high = mesh.vertices[0];
for &point in &mesh.vertices[1..] {
low.x = low.x.min(point.x);
low.y = low.y.min(point.y);
low.z = low.z.min(point.z);
high.x = high.x.max(point.x);
high.y = high.y.max(point.y);
high.z = high.z.max(point.z);
}
(high - low).length()
}
fn coplanar_merge_tolerance(mesh: &Mesh) -> f64 {
(mesh_diagonal(mesh) * 1.0e-9).max(1.0e-12)
}
fn solid_face_count(solid: &brep_kernel::BrepSolid) -> usize {
solid.shells.iter().map(|shell| shell.faces.len()).sum()
}
fn try_kernel_analytic(
positions: &[f64],
indices: Option<&[u32]>,
options: &StlConversionOptions,
) -> Result<brep_kernel::BrepSolid, String> {
let owned_indices;
let indices = match indices {
Some(indices) => indices,
None => {
owned_indices = (0..positions.len() as u32 / 3).collect::<Vec<_>>();
&owned_indices
}
};
let segmentation_options = SegmentOptions {
deflection_angle_deg: options.kernel_deflection_angle_degrees,
fit_tolerance: options.kernel_fit_tolerance,
normal_tolerance_deg: options.kernel_normal_tolerance_degrees,
weld_tolerance: options.weld_tolerance,
..SegmentOptions::default()
};
let segmentation = segment_mesh_faces(positions, indices, &segmentation_options)?;
if segmentation
.triangle_region_ids
.contains(&UNASSIGNED_REGION)
{
return Err("independent kernel segmentation left triangles unassigned".to_owned());
}
if let Some(region) = segmentation.regions.iter().find(|region| {
matches!(
region.carrier,
RegionCarrier::Freeform | RegionCarrier::Sphere { .. } | RegionCarrier::Torus { .. }
)
}) {
return Err(format!(
"independent kernel segmentation produced unsupported {} region {}",
region.carrier.kind(),
region.id
));
}
mesh_regions_to_brep(positions, indices, &segmentation_options)
}
fn try_hybrid_analytic(
positions: &[f64],
indices: Option<&[u32]>,
options: &StlConversionOptions,
) -> Result<crate::hybrid_region_brep::HybridBrepOutput, String> {
let segmentation_options = SegmentOptions {
deflection_angle_deg: options.kernel_deflection_angle_degrees,
fit_tolerance: options.kernel_fit_tolerance,
normal_tolerance_deg: options.kernel_normal_tolerance_degrees,
weld_tolerance: options.weld_tolerance,
..SegmentOptions::default()
};
crate::hybrid_region_brep::hybrid_plane_cylinder_brep(positions, indices, &segmentation_options)
}
fn hybrid_report(stats: crate::hybrid_region_brep::HybridBrepStats) -> HybridConversionReport {
HybridConversionReport {
total_faces: stats.total_faces,
analytic_plane_faces: stats.analytic_plane_faces,
analytic_plane_triangles: stats.analytic_plane_triangles,
analytic_cylinder_faces: stats.analytic_cylinder_faces,
analytic_cylinder_triangles: stats.analytic_cylinder_triangles,
analytic_cone_faces: stats.analytic_cone_faces,
analytic_cone_triangles: stats.analytic_cone_triangles,
faceted_faces: stats.faceted_faces,
faceted_triangles: stats.faceted_triangles,
demoted_regions: stats.demoted_regions,
demoted_triangles: stats.demoted_triangles,
segmentation_plane_regions: stats.segmentation_plane_regions,
segmentation_cylinder_regions: stats.segmentation_cylinder_regions,
segmentation_cone_regions: stats.segmentation_cone_regions,
segmentation_unsupported_regions: stats.segmentation_unsupported_regions,
}
}
fn kernel_vec(value: Vec3) -> brep_kernel::Vec3 {
brep_kernel::Vec3::new(value.x, value.y, value.z)
}