diff --git a/crates/bone-kernel/src/brep/boolean/mod.rs b/crates/bone-kernel/src/brep/boolean/mod.rs index dc7f872..cc38e49 100644 --- a/crates/bone-kernel/src/brep/boolean/mod.rs +++ b/crates/bone-kernel/src/brep/boolean/mod.rs @@ -633,7 +633,7 @@ struct RimEdge { vertex: VertexLabel, } -fn rim_geometry(curve: &EdgeCurve3) -> Option<(Point3, f64, f64)> { +pub(crate) fn rim_geometry(curve: &EdgeCurve3) -> Option<(Point3, f64, f64)> { use uom::si::angle::radian; match curve { EdgeCurve3::Circle(circle) => { diff --git a/crates/bone-kernel/src/brep/build.rs b/crates/bone-kernel/src/brep/build.rs index 85292d8..06ef0ee 100644 --- a/crates/bone-kernel/src/brep/build.rs +++ b/crates/bone-kernel/src/brep/build.rs @@ -5,12 +5,13 @@ use std::collections::HashSet; use bone_types::{ Axis3, BrepEdgeId, BrepFaceId, BrepLoopId, BrepShellId, BrepVertexId, CreaseAngle, EdgeLabel, EdgeRole, FaceFingerprint, FaceLabel, Parameter, Plane3, Point3, SideKind, SketchEntityId, - SketchPlaneBasis, Tolerance, UnitVec3, VertexLabel, VertexRole, + SketchPlaneBasis, Tolerance, UnitVec3, Vec3, VertexLabel, VertexRole, }; use slotmap::{Key, SlotMap}; use truck_modeling::{ - BoundedCurve, Edge, EdgeID, FaceID, ParameterDivision1D, ParametricCurve, ParametricSurface3D, - SPHint2D, SearchNearestParameter, Shell, Solid, Surface, Transform, VertexID, + BoundedCurve, Edge, EdgeID, FaceID, ParameterDivision1D, ParametricCurve, ParametricSurface, + ParametricSurface3D, SPHint2D, SearchNearestParameter, Shell, Solid, Surface, Transform, + VertexID, }; use super::convert::{point_from_truck, try_unit_from_truck}; @@ -49,11 +50,16 @@ impl Arena { } pub(crate) fn truck_face(&self, target: BrepFaceId) -> Option<&truck_modeling::Face> { - let face_id = self - .face_index - .iter() - .find_map(|(id, brep)| (*brep == target).then_some(*id))?; - self.solid.face_iter().find(|face| face.id() == face_id) + self.truck_faces_of(target).next() + } + + pub(crate) fn truck_faces_of( + &self, + target: BrepFaceId, + ) -> impl Iterator { + self.solid + .face_iter() + .filter(move |face| self.face_index.get(&face.id()) == Some(&target)) } } @@ -415,13 +421,177 @@ fn edge_midpoint(edge: &Edge) -> truck_modeling::Point3 { } fn face_normal_at(face: &truck_modeling::Face, point: truck_modeling::Point3) -> Option { + face_normal_and_distance(face, point).map(|(normal, _)| normal) +} + +const NEAREST_SEED_DIVISION: u32 = 8; +const NEAREST_NEWTON_TRIALS: usize = 100; + +fn nearest_surface_parameter( + surface: &Surface, + point: truck_modeling::Point3, +) -> Option<(f64, f64)> { + let hint = match surface.try_range_tuple() { + (Some((u0, u1)), Some((v0, v1))) => { + let query = point_from_truck(point); + let div = NEAREST_SEED_DIVISION; + let axis = + |lo: f64, hi: f64, step: u32| lo + (hi - lo) * f64::from(step) / f64::from(div); + (0..=div) + .flat_map(|i| (0..=div).map(move |j| (i, j))) + .map(|(i, j)| { + let (u, v) = (axis(u0, u1, i), axis(v0, v1, j)); + ( + u, + v, + (point_from_truck(surface.subs(u, v)) - query).norm_mm(), + ) + }) + .min_by(|a, b| a.2.total_cmp(&b.2)) + .map_or(SPHint2D::None, |(u, v, _)| SPHint2D::Parameter(u, v)) + } + _ => SPHint2D::None, + }; + surface.search_nearest_parameter(point, hint, NEAREST_NEWTON_TRIALS) +} + +pub(crate) fn face_normal_and_distance( + face: &truck_modeling::Face, + point: truck_modeling::Point3, +) -> Option<(UnitVec3, f64)> { let surface = face.surface(); - let (u, v) = surface.search_nearest_parameter(point, SPHint2D::None, 20)?; + let query = point_from_truck(point); + let (u, v) = nearest_surface_parameter(&surface, point)?; let mut normal = surface.normal(u, v); if !face.orientation() { normal = -normal; } - try_unit_from_truck(normal, Tolerance::new(1.0e-9)) + let unit = try_unit_from_truck(normal, Tolerance::new(1.0e-9))?; + Some(( + unit, + (point_from_truck(surface.subs(u, v)) - query).norm_mm(), + )) +} + +const FACE_BOUNDARY_CHORD_MM: f64 = 1.0e-2; + +pub(crate) fn face_normal_and_bounded_distance( + face: &truck_modeling::Face, + point: truck_modeling::Point3, +) -> Option<(UnitVec3, f64)> { + let surface = face.surface(); + let query = point_from_truck(point); + let (u, v) = nearest_surface_parameter(&surface, point)?; + let mut normal = surface.normal(u, v); + if !face.orientation() { + normal = -normal; + } + let unit = try_unit_from_truck(normal, Tolerance::new(1.0e-9))?; + let foot = point_from_truck(surface.subs(u, v)); + let perpendicular = (foot - query).norm_mm(); + Some(( + unit, + bounded_face_distance(face, query, foot, unit, perpendicular), + )) +} + +fn bounded_face_distance( + face: &truck_modeling::Face, + query: Point3, + foot: Point3, + normal: UnitVec3, + perpendicular: f64, +) -> f64 { + let loops = face_boundary_loops(face); + let Some((ex, ey)) = face_tangent_basis(normal) else { + return perpendicular; + }; + if loops.iter().all(|poly| poly.len() < 2) { + return perpendicular; + } + let project = |p: Point3| (along(p - foot, ex), along(p - foot, ey)); + let segments = || { + loops.iter().flat_map(|poly| { + poly.iter() + .copied() + .zip(poly.iter().copied().cycle().skip(1)) + .take(poly.len()) + }) + }; + let crossings = segments() + .filter(|&(a, b)| crosses_positive_x(project(a), project(b))) + .count(); + if crossings % 2 == 1 { + perpendicular + } else { + segments() + .map(|(a, b)| point_segment_distance(query, a, b)) + .fold(f64::INFINITY, f64::min) + } +} + +fn face_boundary_loops(face: &truck_modeling::Face) -> Vec> { + face.boundaries() + .iter() + .map(|wire| wire.iter().flat_map(sample_boundary).collect()) + .collect() +} + +fn sample_boundary(edge: &Edge) -> Vec { + let curve = edge.curve(); + let (t0, t1) = curve.range_tuple(); + let (_, points) = curve.parameter_division((t0, t1), FACE_BOUNDARY_CHORD_MM); + points.iter().map(|&p| point_from_truck(p)).collect() +} + +fn face_tangent_basis(normal: UnitVec3) -> Option<(UnitVec3, UnitVec3)> { + let (nx, ny, nz) = normal.components(); + let n = Vec3::from_mm(nx, ny, nz); + let seed = if nx.abs() <= ny.abs() && nx.abs() <= nz.abs() { + Vec3::from_mm(1.0, 0.0, 0.0) + } else if ny.abs() <= nz.abs() { + Vec3::from_mm(0.0, 1.0, 0.0) + } else { + Vec3::from_mm(0.0, 0.0, 1.0) + }; + let tolerance = Tolerance::new(1.0e-9); + let ex = seed.cross(n).try_normalize(tolerance).ok()?; + let (ex_x, ex_y, ex_z) = ex.components(); + let ey = n + .cross(Vec3::from_mm(ex_x, ex_y, ex_z)) + .try_normalize(tolerance) + .ok()?; + Some((ex, ey)) +} + +fn along(vector: Vec3, axis: UnitVec3) -> f64 { + let (vx, vy, vz) = vector.coords_mm(); + let (ax, ay, az) = axis.components(); + vx * ax + vy * ay + vz * az +} + +fn crosses_positive_x(a: (f64, f64), b: (f64, f64)) -> bool { + let ((ax, ay), (bx, by)) = (a, b); + if (ay > 0.0) == (by > 0.0) { + return false; + } + let denom = ay - by; + denom != 0.0 && ax + (ay / denom) * (bx - ax) > 0.0 +} + +fn point_segment_distance(p: Point3, a: Point3, b: Point3) -> f64 { + let ab = b - a; + let ap = p - a; + let denom = ab.dot_mm2(ab); + let t = if denom > 0.0 { + (ap.dot_mm2(ab) / denom).clamp(0.0, 1.0) + } else { + 0.0 + }; + let (apx, apy, apz) = ap.coords_mm(); + let (abx, aby, abz) = ab.coords_mm(); + let (dx, dy, dz) = (apx - t * abx, apy - t * aby, apz - t * abz); + dx.mul_add(dx, dy.mul_add(dy, dz * dz)).sqrt() } fn ordered_endpoints( diff --git a/crates/bone-kernel/src/brep/eval.rs b/crates/bone-kernel/src/brep/eval.rs index 5b73803..6d4b208 100644 --- a/crates/bone-kernel/src/brep/eval.rs +++ b/crates/bone-kernel/src/brep/eval.rs @@ -3,7 +3,7 @@ use std::collections::{HashMap, HashSet}; use bone_types::{ Aabb3, Angle, AngleTolerance, Axis3, ChordHeightTolerance, EdgeLabel, EdgeRole, FaceLabel, FaceRole, FeatureId, ImportOrdinal, Length, LoopIndex, Parameter, Plane3, Point2, Point3, - PositiveLength, SideKind, SketchEntityId, Tolerance, UnitVec3, VertexLabel, VertexRole, + PositiveLength, SideKind, SketchEntityId, Tolerance, UnitVec3, Vec3, VertexLabel, VertexRole, }; use core::f64::consts::{PI, TAU}; use truck_meshalgo::prelude::PolygonMesh; @@ -17,30 +17,38 @@ use uom::si::angle::radian; use uom::si::length::millimeter; use crate::arc2::Arc2; +use crate::arc3::Arc3; use crate::circle2::Circle2; use crate::curve2::{Curve2, Curve2Kind}; -use crate::curve3::Curve3Kind; +use crate::curve3::{Curve3, Curve3Kind}; use crate::extrude::{ - DirectionPlan, ExtrudeDirection, ExtrudeEndCondition, ExtrudeFeature, ExtrudeSense, - FromCondition, ResolvedSweep, ThinPlan, + DirectionPlan, ExtrudeDirection, ExtrudeEndCondition, ExtrudeFeature, ExtrudeOperation, + ExtrudeSense, FromCondition, ResolvedSweep, ThinPlan, }; use crate::intersect::{IntersectionSet, intersect_curves}; use crate::line2::Line2; use crate::line3::Line3; use crate::offset2::{OffsetError, offset_loop, offset_open_chain, reverse_curve}; use crate::revolve::{ResolvedRevolve, RevolveEndCondition, RevolveFeature}; -use crate::sweep::{SweepFeature, SweepOrientation, SweepProfileKind}; +use crate::sweep::{ + SweepAlignment, SweepDirection, SweepFeature, SweepOrientation, SweepProfileKind, + SweepTangency, SweepTwist, +}; use crate::sweep_surface::{ - ArcFraction, BSpline3, Frame, PathInterpolator, SampleCount, SectionSpline, StationCount, - SweepPath3, chord_params, section_spline, transport, + ArcFraction, BSpline3, ClampedInterpolator, CompositePath3, Frame, FrameGuide, + PathInterpolator, SampleCount, SectionSpline, StationCount, SweepDefect, SweepPath3, + chord_params, circle_arc_spline, ellipse_arc_spline, reverse_section, section_spline, + station_fractions, transport_stations, }; use slotmap::{Key, KeyData}; +use super::boolean::model::{project_onto_axis, radial_distance}; +use super::boolean::rim_geometry; use super::build::{FragmentId, SolidLabeling, assemble, face_plane_basis}; use super::convert::{point_from_truck, point_to_truck}; use super::edges::{EdgeCurve3, lift_curve2}; use super::profile::{ExtrudeProfile, ProfileEdge, ProfileLoop}; -use super::{BrepError, BrepSolid, ProfileDefect, TruckGap}; +use super::{BrepError, BrepFace, BrepSolid, ProfileDefect, TruckGap}; const ANGLE_EPS_RAD: f64 = 1.0e-9; const REVOLVE_AXIS_TOLERANCE_MM: f64 = 1.0e-6; @@ -530,283 +538,1588 @@ pub fn evaluate_revolve( const SWEEP_PERP_TOLERANCE: f64 = 1.0e-6; -/// Builds the tool solid of a Follow-Path sweep. A single-segment path reduces to -/// an exact primitive: a straight segment is a translational sweep along the -/// profile normal, and a circular arc or circle is a revolution about the arc's -/// center axis. The boolean operation, scope, and flip-side are applied by the -/// caller, exactly as for revolve. A sketched profile whose plane is not -/// perpendicular to the path builds through the generative RMF skin. Multi-segment -/// and spline paths, circular and solid profiles, and non-follow-path orientations -/// report a typed gap. +#[derive(Copy, Clone, Default)] +pub struct SweepRails<'a> { + pub guides: &'a [Vec], + pub direction: Option, + pub tool: Option<&'a BrepSolid>, + pub bodies: &'a [&'a BrepSolid], +} + +#[derive(Copy, Clone)] +enum SectionProfile { + Sketch, + Circular(PositiveLength), +} + pub fn evaluate_sweep( sweep: FeatureId, profile: &ExtrudeProfile, path: &[Curve3Kind], + rails: SweepRails<'_>, feature: &SweepFeature, ) -> Result { - if !matches!(feature.orientation, SweepOrientation::FollowPath) { - return Err(unsupported(TruckGap::SweepOrientation)); - } - let [segment] = path else { - return Err(unsupported(TruckGap::SweepGeneralPath)); + let composite = + CompositePath3::chain(path).map_err(|reason| BrepError::InvalidSweep { reason })?; + let section = match feature.profile_kind { + SweepProfileKind::Sketch => SectionProfile::Sketch, + SweepProfileKind::Circular { diameter } => SectionProfile::Circular(diameter), + SweepProfileKind::Solid { .. } => { + return solid_tool_envelope(sweep, path, &composite, rails, feature); + } }; + validate_sweep_config(feature, rails)?; let thin = thin_wall_to_plan(feature.thin_wall); - let reduced = match (feature.profile_kind, segment) { - (SweepProfileKind::Sketch, Curve3Kind::Line(line)) => { - sweep_along_line(sweep, profile, *line, thin) + if rails.guides.is_empty() + && feature.twist.is_none() + && matches!(feature.start_tangency, SweepTangency::None) + && matches!(feature.end_tangency, SweepTangency::None) + && let [segment] = path + && reduction_preserves_orientation(feature.orientation, segment) + && reduction_preserves_alignment(feature.alignment) + && let Some(solid) = + reduce_single(sweep, profile, segment, section, thin, feature.direction)? + { + return Ok(solid); + } + skin_sweep(sweep, profile, &composite, rails, feature, section) +} + +fn validate_sweep_config(feature: &SweepFeature, rails: SweepRails<'_>) -> Result<(), BrepError> { + let guides = rails.guides; + if guides.iter().any(Vec::is_empty) { + return Err(invalid_sweep(SweepDefect::GuideEmpty)); + } + if guides.len() > 2 { + return Err(unsupported(TruckGap::SweepGuideCount)); + } + if guides.len() < feature.orientation.needs_guides() { + return Err(invalid_sweep(SweepDefect::OrientationNeedsGuides)); + } + if !guides.is_empty() { + if matches!(feature.direction, SweepDirection::Bidirectional) { + return Err(invalid_sweep(SweepDefect::GuideBidirectional)); } - (SweepProfileKind::Sketch, Curve3Kind::Arc(arc)) => sweep_along_axis( - sweep, - profile, - arc.center(), - arc.normal(), - arc.sweep_rad(), - thin, - ), - (SweepProfileKind::Sketch, Curve3Kind::Circle(circle)) => { - sweep_along_axis(sweep, profile, circle.center(), circle.normal(), TAU, thin) + if feature.twist.is_some() { + return Err(invalid_sweep(SweepDefect::GuideTwist)); } - (SweepProfileKind::Circular { diameter }, Curve3Kind::Line(line)) => { - let circle = circular_profile(diameter, *line)?; - sweep_along_line(sweep, &circle, *line, thin) + if matches!(feature.orientation, SweepOrientation::KeepNormalConstant) { + return Err(invalid_sweep(SweepDefect::GuideKeepNormal)); } - (SweepProfileKind::Circular { .. }, Curve3Kind::Arc(_) | Curve3Kind::Circle(_)) => { - Err(unsupported(TruckGap::SweepProfileKind)) + } + let tangent = matches!(feature.start_tangency, SweepTangency::PathTangent) + || matches!(feature.end_tangency, SweepTangency::PathTangent); + if tangent && matches!(feature.direction, SweepDirection::Bidirectional) { + return Err(invalid_sweep(SweepDefect::TangencyBidirectional)); + } + match feature.alignment { + SweepAlignment::NonePlanar + | SweepAlignment::DirectionVector { .. } + | SweepAlignment::AllFaces + if !matches!(feature.orientation, SweepOrientation::FollowPath) => + { + Err(invalid_sweep(SweepDefect::AlignmentOrientation)) } - }; - match reduced { - Err(BrepError::TruckUnsupported { - detail: TruckGap::SweepProfileOffPath, - }) if matches!(feature.profile_kind, SweepProfileKind::Sketch) => { - if feature.thin_wall.is_some() { - Err(unsupported(TruckGap::SweepThinWall)) - } else { - build_skinned_sweep(sweep, profile, segment, StationCount::DEFAULT) - } + SweepAlignment::DirectionVector { .. } => rails + .direction + .map(|_| ()) + .ok_or(BrepError::SweepAlignmentUnresolved), + SweepAlignment::AllFaces if rails.bodies.is_empty() => { + Err(BrepError::SweepAlignmentUnresolved) + } + SweepAlignment::MinimumTwist | SweepAlignment::NonePlanar | SweepAlignment::AllFaces => { + Ok(()) } - other => other, } } -fn circular_profile(diameter: PositiveLength, line: Line3) -> Result { - let direction = (line.end() - line.start()) - .try_normalize(PROFILE_TOLERANCE) - .map_err(|_| BrepError::EmptyExtrudeDepth)?; - let (x_axis, y_axis) = direction.orthonormal_basis(); - let plane = Plane3::new_unchecked(line.start(), x_axis, y_axis); - let circle = Circle2::new(Point2::origin(), diameter.get() * 0.5, PROFILE_TOLERANCE) - .map_err(|_| unsupported(TruckGap::SweepProfileKind))?; - Ok(ExtrudeProfile::new( - plane, - vec![ProfileLoop::Closed { - curve: Curve2Kind::Circle(circle), - curve_entity: SketchEntityId::default(), - }], - )) +fn reduction_preserves_alignment(alignment: SweepAlignment) -> bool { + match alignment { + SweepAlignment::MinimumTwist | SweepAlignment::NonePlanar => true, + SweepAlignment::DirectionVector { .. } | SweepAlignment::AllFaces => false, + } } -fn sweep_along_line( +const PLANAR_PATH_SAMPLES: u32 = 64; +const ALIGNMENT_FACE_TOLERANCE_MM: f64 = 5.0e-2; + +fn nearest_face_up(bodies: &[&BrepSolid], point: Point3) -> Option { + bodies + .iter() + .filter_map(|body| body.nearest_face_normal(point)) + .min_by(|a, b| a.1.total_cmp(&b.1)) + .map(|(normal, _)| normal) +} + +fn path_lies_on_face(path: &CompositePath3, bodies: &[&BrepSolid]) -> bool { + !bodies.is_empty() + && (0..=PLANAR_PATH_SAMPLES).all(|i| { + let point = path.evaluate(Parameter::new( + f64::from(i) / f64::from(PLANAR_PATH_SAMPLES), + )); + bodies + .iter() + .filter_map(|body| body.nearest_face_normal(point)) + .map(|(_, distance)| distance) + .fold(f64::INFINITY, f64::min) + <= ALIGNMENT_FACE_TOLERANCE_MM + }) +} + +fn path_is_planar(composite: &CompositePath3) -> bool { + let samples: Vec = (0..=PLANAR_PATH_SAMPLES) + .map(|i| { + composite.evaluate(Parameter::new( + f64::from(i) / f64::from(PLANAR_PATH_SAMPLES), + )) + }) + .collect(); + let origin = samples[0]; + let normal = samples + .windows(2) + .fold(Vec3::from_mm(0.0, 0.0, 0.0), |acc, pair| { + acc + (pair[0] - origin).cross(pair[1] - origin) + }); + let scale = samples + .iter() + .map(|point| (*point - origin).norm_mm()) + .fold(0.0_f64, f64::max); + let Ok(unit) = normal.try_normalize(PROFILE_TOLERANCE) else { + return true; + }; + let along = |point: Point3| { + let (x, y, z) = unit.components(); + let (dx, dy, dz) = (point - origin).coords_mm(); + x * dx + y * dy + z * dz + }; + samples + .iter() + .all(|point| along(*point).abs() <= PROFILE_TOLERANCE_MM * scale.max(1.0)) +} + +fn alignment_clear_of_path(composite: &CompositePath3, axis: UnitVec3) -> bool { + (0..=PLANAR_PATH_SAMPLES).all(|i| { + let t = Parameter::new(f64::from(i) / f64::from(PLANAR_PATH_SAMPLES)); + composite + .derivative(t) + .try_normalize(PROFILE_TOLERANCE) + .map_or(true, |tangent| { + tangent.dot(axis).abs() < 1.0 - PROFILE_TOLERANCE_MM + }) + }) +} + +fn reduction_preserves_orientation(orientation: SweepOrientation, segment: &Curve3Kind) -> bool { + matches!(orientation, SweepOrientation::FollowPath) || matches!(segment, Curve3Kind::Line(_)) +} + +fn frame_guide(orientation: SweepOrientation) -> FrameGuide { + match orientation { + SweepOrientation::FollowPath + | SweepOrientation::FollowPathAndFirstGuide + | SweepOrientation::FollowFirstAndSecondGuide => FrameGuide::MinimumTwist, + SweepOrientation::KeepNormalConstant => FrameGuide::KeepNormal, + } +} + +fn reduce_single( sweep: FeatureId, profile: &ExtrudeProfile, - line: Line3, + segment: &Curve3Kind, + section: SectionProfile, thin: Option, -) -> Result { - let normal = profile.plane().normal(); - let direction = (line.end() - line.start()) - .try_normalize(PROFILE_TOLERANCE) - .map_err(|_| BrepError::EmptyExtrudeDepth)?; - let alignment = direction.dot(normal); - let forward = if alignment >= 1.0 - SWEEP_PERP_TOLERANCE { - true - } else if alignment <= -1.0 + SWEEP_PERP_TOLERANCE { - false - } else { - return Err(unsupported(TruckGap::SweepProfileOffPath)); - }; - let step = DirectionPlan { - distance_mm: line.length_mm(), - draft: None, - }; - let plan = ResolvedSweep { - base_offset_mm: 0.0, - forward: forward.then_some(step), - backward: (!forward).then_some(step), - thin, - }; - evaluate_extrude_plan(sweep, profile, &plan) + direction: SweepDirection, +) -> Result, BrepError> { + match section { + SectionProfile::Sketch => reduce_segment(sweep, profile, segment, thin, direction), + SectionProfile::Circular(diameter) => { + let synth = circular_profile(diameter, segment)?; + reduce_segment(sweep, &synth, segment, thin, direction) + } + } } -fn sweep_along_axis( +fn reduce_segment( sweep: FeatureId, profile: &ExtrudeProfile, - center: Point3, - axis_dir: UnitVec3, - sweep_rad: f64, + segment: &Curve3Kind, thin: Option, -) -> Result { - let plane = profile.plane(); - let (nx, ny, nz) = plane.normal().components(); - let (dx, dy, dz) = (center - plane.origin()).coords_mm(); - let center_off_plane = nx * dx + ny * dy + nz * dz; - let coplanar = plane.normal().dot(axis_dir).abs() <= SWEEP_PERP_TOLERANCE - && center_off_plane.abs() <= REVOLVE_AXIS_TOLERANCE_MM; - if !coplanar { - return Err(unsupported(TruckGap::SweepProfileOffPath)); + direction: SweepDirection, +) -> Result, BrepError> { + match segment { + Curve3Kind::Line(line) => sweep_along_line(sweep, profile, *line, thin, direction), + Curve3Kind::Arc(arc) => sweep_along_arc(sweep, profile, *arc, thin, direction), + Curve3Kind::Circle(circle) => { + sweep_along_axis(sweep, profile, circle.center(), circle.normal(), TAU, thin) + } } - let direction = if sweep_rad < 0.0 { - axis_dir.reversed() - } else { - axis_dir - }; - let plan = ResolvedRevolve { - axis: Axis3::new(center, direction), - forward_rad: sweep_rad.abs(), - backward_rad: 0.0, - thin, - }; - evaluate_revolve(sweep, profile, &plan) } -const SKIN_VALIDATION_TOLERANCE_MM: f64 = 1.0e-6; +const FULL_RING_TOLERANCE_RAD: f64 = 1.0e-3; -struct EdgeSection { - spline: SectionSpline, - segment: SketchEntityId, - corner: SketchEntityId, +struct ToolRim { + center: Point3, + radius_mm: f64, + sweep_rad: f64, } -fn profile_sections(profile: &ExtrudeProfile) -> Option> { - let [ProfileLoop::Open(edges)] = profile.loops() else { +#[derive(Copy, Clone)] +struct PlainCylinder { + base: Point3, + axis: UnitVec3, + radius_mm: f64, + height_mm: f64, +} + +impl PlainCylinder { + fn along(self, travel: UnitVec3) -> Self { + if self.axis.dot(travel) >= 0.0 { + self + } else { + Self { + base: self.base + + self + .axis + .into_vec(Length::new::(self.height_mm)), + axis: self.axis.reversed(), + ..self + } + } + } +} + +fn rim_circle(curve: &EdgeCurve3) -> Option { + let (center, radius_mm, sweep_rad) = rim_geometry(curve)?; + Some(ToolRim { + center, + radius_mm, + sweep_rad, + }) +} + +fn recognize_plain_cylinder(tool: &BrepSolid) -> Option { + let (caps, walls): (Vec<&BrepFace>, Vec<&BrepFace>) = tool + .iter_faces() + .partition(|face| tool.face_plane_basis(face.id()).is_some()); + let ([wall], [_, _]) = (walls.as_slice(), caps.as_slice()) else { return None; }; - (edges.len() >= 2).then(|| { - edges + let rims: Vec = tool + .face_boundary_edges(wall.id()) + .into_iter() + .filter_map(|edge| rim_circle(tool.edges.get(edge)?.curve())) + .collect(); + let centers: Vec = rims.iter().fold(Vec::new(), |mut acc, rim| { + if !acc .iter() - .map(|edge| EdgeSection { - spline: section_spline(edge.curve()), - segment: edge.curve_entity(), - corner: edge.corner(), - }) - .collect() + .any(|seen| (*seen - rim.center).norm_mm() <= PROFILE_TOLERANCE_MM) + { + acc.push(rim.center); + } + acc + }); + let [center_a, center_b] = centers.as_slice() else { + return None; + }; + let radius_mm = rims.first().map(|rim| rim.radius_mm)?; + let consistent = rims + .iter() + .all(|rim| (rim.radius_mm - radius_mm).abs() <= PROFILE_TOLERANCE_MM); + let ring_sweep = |center: Point3| { + rims.iter() + .filter(|rim| (rim.center - center).norm_mm() <= PROFILE_TOLERANCE_MM) + .map(|rim| rim.sweep_rad) + .sum::() + }; + let full = (ring_sweep(*center_a) - TAU).abs() <= FULL_RING_TOLERANCE_RAD + && (ring_sweep(*center_b) - TAU).abs() <= FULL_RING_TOLERANCE_RAD; + if !consistent || !full { + return None; + } + let span = *center_b - *center_a; + let axis = span.try_normalize(PROFILE_TOLERANCE).ok()?; + Some(PlainCylinder { + base: *center_a, + axis, + radius_mm, + height_mm: span.norm_mm(), }) } -struct SweepFrames { - frames: Vec, - anchor: Point2, - interpolator: PathInterpolator, +fn solid_tool_options_clear(feature: &SweepFeature, guides: &[Vec]) -> bool { + guides.is_empty() + && feature.twist.is_none() + && feature.thin_wall.is_none() + && matches!(feature.orientation, SweepOrientation::FollowPath) + && matches!(feature.alignment, SweepAlignment::MinimumTwist) + && matches!(feature.direction, SweepDirection::DirectionOne) + && matches!(feature.start_tangency, SweepTangency::None) + && matches!(feature.end_tangency, SweepTangency::None) } -impl SweepFrames { - fn new( - profile: &ExtrudeProfile, - path: &Curve3Kind, - stations: StationCount, - ) -> Result { - let plane = profile.plane(); - let spine = SweepPath3::new(path, SampleCount::DEFAULT); - let frames = transport(&spine, plane, stations); - let anchor = plane.project(spine.point_at(ArcFraction::START)); - let centers: Vec = frames.iter().map(|frame| frame.origin()).collect(); - let params = chord_params(¢ers).ok_or(BrepError::EmptyExtrudeDepth)?; - let degree = 3.min(frames.len().saturating_sub(1)); - let interpolator = - PathInterpolator::new(¶ms, degree).ok_or(BrepError::EmptyExtrudeDepth)?; - Ok(Self { - frames, - anchor, - interpolator, - }) +fn solid_tool_envelope( + sweep: FeatureId, + path: &[Curve3Kind], + composite: &CompositePath3, + rails: SweepRails<'_>, + feature: &SweepFeature, +) -> Result { + if let ExtrudeOperation::Boss(_) = feature.operation { + return Err(invalid_sweep(SweepDefect::SolidToolBoss)); } - - fn rail(&self, point: Point2) -> Result { - let stations: Vec = self - .frames - .iter() - .map(|frame| frame.place_point(point, self.anchor)) - .collect(); - self.interpolator - .solve(&stations) - .ok_or(BrepError::EmptyExtrudeDepth) + if composite.has_corner() { + return Err(invalid_sweep(SweepDefect::SolidToolCorner)); } + if !solid_tool_options_clear(feature, rails.guides) { + return Err(unsupported(TruckGap::SweepSolidEnvelope)); + } + let tool = rails.tool.ok_or(BrepError::SweepToolUnresolved)?; + let cylinder = + recognize_plain_cylinder(tool).ok_or_else(|| unsupported(TruckGap::SweepSolidTool))?; + let [Curve3Kind::Line(line)] = path else { + return Err(unsupported(TruckGap::SweepSolidEnvelope)); + }; + let travel = (line.end() - line.start()) + .try_normalize(PROFILE_TOLERANCE) + .map_err(|_| BrepError::EmptyExtrudeDepth)?; + if cylinder.axis.dot(travel).abs() < 1.0 - SWEEP_PERP_TOLERANCE { + return Err(unsupported(TruckGap::SweepSolidEnvelope)); + } + let oriented = cylinder.along(travel); + let along = project_onto_axis(oriented.base, oriented.axis, line.start()); + let on_body = (-PROFILE_TOLERANCE_MM..=oriented.height_mm + PROFILE_TOLERANCE_MM) + .contains(&along) + && radial_distance(oriented.base, oriented.axis, line.start()) + <= oriented.radius_mm + PROFILE_TOLERANCE_MM; + if !on_body { + return Err(invalid_sweep(SweepDefect::SolidToolOffBody)); + } + let reach = Length::new::(oriented.height_mm + line.length_mm()); + let envelope = Line3::new( + oriented.base, + oriented.base + oriented.axis.into_vec(reach), + PROFILE_TOLERANCE, + ) + .map_err(|_| BrepError::EmptyExtrudeDepth)? + .as_kind(); + let diameter = PositiveLength::new(Length::new::(2.0 * oriented.radius_mm)) + .map_err(|_| unsupported(TruckGap::SweepSolidTool))?; + let synth = circular_profile(diameter, &envelope)?; + reduce_segment(sweep, &synth, &envelope, None, SweepDirection::DirectionOne)? + .ok_or_else(|| unsupported(TruckGap::SweepSolidEnvelope)) +} + +fn invalid_sweep(reason: SweepDefect) -> BrepError { + BrepError::InvalidSweep { reason } +} + +fn direction_spans( + direction: SweepDirection, + toward_end: f64, + toward_start: f64, + min_span: f64, +) -> Result<(f64, f64), BrepError> { + let picked = match direction { + SweepDirection::DirectionOne => (toward_end, 0.0), + SweepDirection::DirectionTwo => (0.0, toward_start), + SweepDirection::Bidirectional => (toward_end, toward_start), + }; + if picked.0 <= min_span && picked.1 <= min_span { + return Err(invalid_sweep(SweepDefect::EmptySpan)); + } + Ok(picked) } -fn homogeneous(point: truck_modeling::Point3, weight: f64) -> Vector4 { - Vector4::new(point.x * weight, point.y * weight, point.z * weight, weight) -} - -fn cap_positions(columns: &[BSpline3], at_end: bool) -> Vec { - columns +fn profile_centroid(profile: &ExtrudeProfile) -> Point3 { + let center = profile + .loops() .iter() - .map(|rail| { - let control = rail.control(); - let point = if at_end { - control[control.len() - 1] - } else { - control[0] - }; - point_to_truck(point) - }) - .collect() + .flat_map(loop_curves) + .map(|curve| curve.bounding_box()) + .reduce(|bounds, other| bounds.extend_point(other.min()).extend_point(other.max())) + .map_or(Point2::origin(), |bounds| { + let (min_x, min_y) = bounds.min().coords_mm(); + let (max_x, max_y) = bounds.max().coords_mm(); + Point2::from_mm(f64::midpoint(min_x, max_x), f64::midpoint(min_y, max_y)) + }); + let (cx, cy) = center.coords_mm(); + profile.plane().point_at_local(cx, cy, 0.0) } -fn section_edge( - from: &Vertex, - to: &Vertex, - positions: &[truck_modeling::Point3], - spline: &SectionSpline, -) -> Edge { - if spline.degree <= 1 { - builder::line(from, to) - } else { - let control: Vec = positions - .iter() - .zip(&spline.weights) - .map(|(point, &weight)| homogeneous(*point, weight)) - .collect(); - let curve = Curve::NurbsCurve(NurbsCurve::new(BSplineCurve::new( - KnotVec::from(spline.knots.clone()), - control, - ))); - Edge::debug_new(from, to, curve) - } -} +const PIERCE_SAMPLES: u32 = 1024; +const PIERCE_REFINE_STEPS: u32 = 48; -fn lateral_surface(columns: &[BSpline3], spline: &SectionSpline, path_knots: &KnotVec) -> Surface { - let net: Vec> = columns - .iter() - .zip(&spline.weights) - .map(|(rail, &weight)| { - rail.control() - .iter() - .map(|point| homogeneous(point_to_truck(*point), weight)) - .collect() +fn pierce_fraction( + profile: &ExtrudeProfile, + composite: &CompositePath3, +) -> Result { + let plane = profile.plane(); + let distance = |t: f64| plane_distance_mm(plane, composite.evaluate(Parameter::new(t))); + if distance(0.0).abs() <= PROFILE_TOLERANCE_MM { + return Ok(Parameter::new(0.0)); + } + let samples: Vec<(f64, f64)> = (0..=PIERCE_SAMPLES) + .map(|i| { + let t = f64::from(i) / f64::from(PIERCE_SAMPLES); + (t, distance(t)) }) .collect(); - let section_knots = KnotVec::from(spline.knots.clone()); - Surface::NurbsSurface(NurbsSurface::new(BSplineSurface::new( - (section_knots, path_knots.clone()), - net, - ))) -} - -struct SkinShell { - base: Face, - top: Face, - lateral: Vec, - section_start: Vec, - section_end: Vec, - rail_edges: Vec, - starts: Vec, - ends: Vec, + let crossings = samples.windows(2).filter_map(|pair| { + let (t0, d0) = pair[0]; + let (t1, d1) = pair[1]; + if d1.abs() <= PROFILE_TOLERANCE_MM { + Some(t1) + } else if d0 * d1 < 0.0 { + let (lo, _, hi) = (0..PIERCE_REFINE_STEPS).fold((t0, d0, t1), |(lo, lo_d, hi), _| { + let mid = f64::midpoint(lo, hi); + let mid_d = distance(mid); + if lo_d * mid_d <= 0.0 { + (lo, lo_d, mid) + } else { + (mid, mid_d, hi) + } + }); + Some(f64::midpoint(lo, hi)) + } else { + None + } + }); + let centroid = profile_centroid(profile); + crossings + .min_by(|a, b| { + let miss = |t: f64| (composite.evaluate(Parameter::new(t)) - centroid).norm_mm(); + miss(*a).total_cmp(&miss(*b)) + }) + .map(Parameter::new) + .ok_or_else(|| invalid_sweep(SweepDefect::PathMissesProfile)) +} + +fn directed_span( + composite: &CompositePath3, + direction: SweepDirection, + pierce: Parameter, +) -> Result<(CompositePath3, Parameter), BrepError> { + match direction { + SweepDirection::DirectionOne if pierce.value() <= f64::EPSILON => { + Ok((composite.clone(), Parameter::new(0.0))) + } + SweepDirection::DirectionOne => composite + .trimmed(pierce, Parameter::new(1.0)) + .map(|path| (path, Parameter::new(0.0))) + .ok_or_else(|| invalid_sweep(SweepDefect::EmptySpan)), + SweepDirection::DirectionTwo => composite + .trimmed(Parameter::new(0.0), pierce) + .map(|path| (path.reversed(), Parameter::new(0.0))) + .ok_or_else(|| invalid_sweep(SweepDefect::EmptySpan)), + SweepDirection::Bidirectional => Ok((composite.clone(), pierce)), + } } -fn build_skin_shell( - sections: &[EdgeSection], - corner_rails: &[BSpline3], +fn skin_sweep( + sweep: FeatureId, + profile: &ExtrudeProfile, + composite: &CompositePath3, + rails: SweepRails<'_>, + feature: &SweepFeature, + section: SectionProfile, +) -> Result { + let synthesized; + let effective = match section { + SectionProfile::Sketch => profile, + SectionProfile::Circular(diameter) => { + synthesized = circular_profile(diameter, composite)?; + &synthesized + } + }; + if composite.is_closed() { + return build_closed_sweep(sweep, effective, composite, feature, rails); + } + let pierce = pierce_fraction(effective, composite)?; + let (path, origin) = directed_span(composite, feature.direction, pierce)?; + let locked = match (feature.alignment, rails.direction) { + (SweepAlignment::DirectionVector { .. }, Some(axis)) => { + if !alignment_clear_of_path(&path, axis) { + return Err(invalid_sweep(SweepDefect::AlignmentAlongPath)); + } + Some(axis) + } + (SweepAlignment::NonePlanar, _) => { + if !path_is_planar(&path) { + return Err(invalid_sweep(SweepDefect::PathNotPlanar)); + } + None + } + _ => None, + }; + let face_aligned = matches!(feature.alignment, SweepAlignment::AllFaces); + if path.has_corner() { + if feature.thin_wall.is_some() + || !rails.guides.is_empty() + || feature.twist.is_some() + || locked.is_some() + || face_aligned + { + return Err(unsupported(TruckGap::SweepPathCorner)); + } + return build_mitered_sweep(sweep, effective, &path, path.evaluate(origin)); + } + if face_aligned && !path_lies_on_face(&path, rails.bodies) { + return Err(BrepError::SweepAlignmentUnresolved); + } + validate_sweep_fit(effective, &path, origin)?; + let guide_paths: Vec = rails + .guides + .iter() + .map(|segments| CompositePath3::chain(segments).map_err(invalid_sweep)) + .collect::>()?; + let up_fn = |point: Point3| nearest_face_up(rails.bodies, point); + let plan = SkinPlan { + stations: StationCount::DEFAULT, + guide: locked.map_or_else( + || frame_guide(feature.orientation), + FrameGuide::DirectionLocked, + ), + twist_rad: feature.twist.map_or(0.0, SweepTwist::radians), + origin, + anchor: None, + guides: &guide_paths, + orientation: feature.orientation, + tangency: (feature.start_tangency, feature.end_tangency), + face_up: face_aligned.then_some(&up_fn as &dyn Fn(Point3) -> Option), + }; + match thin_wall_to_plan(feature.thin_wall) { + None => build_skinned_sweep(sweep, effective, &path, &plan), + Some(thin) => build_thin_skin(sweep, effective, &path, &plan, thin), + } +} + +fn build_thin_skin( + sweep: FeatureId, + profile: &ExtrudeProfile, + path: &CompositePath3, + plan: &SkinPlan<'_>, + thin: ThinPlan, +) -> Result { + if thin.cap_ends { + return build_thin_capped_skin(sweep, profile, path, plan, thin); + } + let walls = thin_walls(profile.loops(), thin)?; + let walled = ExtrudeProfile::new(profile.plane(), walls); + build_skinned_sweep(sweep, &walled, path, plan) +} + +fn capped_skin_plain(plan: &SkinPlan<'_>) -> bool { + plan.twist_rad == 0.0 + && plan.guides.is_empty() + && plan.face_up.is_none() + && plan.origin.value().abs() <= f64::EPSILON + && matches!(plan.guide, FrameGuide::MinimumTwist) + && matches!(plan.tangency, (SweepTangency::None, SweepTangency::None)) +} + +fn merge_labelings(mut base: SolidLabeling, extra: SolidLabeling) -> SolidLabeling { + base.faces.extend(extra.faces); + base.edges.extend(extra.edges); + base.vertices.extend(extra.vertices); + base.closed_curves.extend(extra.closed_curves); + base.edge_curves.extend(extra.edge_curves); + base.face_fragments.extend(extra.face_fragments); + base.edge_fragments.extend(extra.edge_fragments); + base.vertex_fragments.extend(extra.vertex_fragments); + base +} + +const VOID_FRAGMENT: FragmentId = FragmentId::new(1); + +fn fragmented(labeling: SolidLabeling, fragment: FragmentId) -> SolidLabeling { + SolidLabeling { + face_fragments: labeling + .faces + .keys() + .map(|&face| (face, fragment)) + .collect(), + ..labeling + } +} + +fn build_thin_capped_skin( + sweep: FeatureId, + profile: &ExtrudeProfile, + path: &CompositePath3, + plan: &SkinPlan<'_>, + thin: ThinPlan, +) -> Result { + let [single] = profile.loops() else { + return Err(unsupported(TruckGap::SweepThinCap)); + }; + if matches!(single, ProfileLoop::Chain(_)) || !capped_skin_plain(plan) || !path_is_planar(path) + { + return Err(unsupported(TruckGap::SweepThinCap)); + } + let base = rounded_for_thin(single, thin)?; + let outer_wall = make_wall(&base, thin.outer_mm, Wall::Outer)?; + let inner_wall = make_wall(&base, -thin.inner_mm, Wall::Inner)?; + let cap_mm = thin.outer_mm + thin.inner_mm; + + let outer_profile = ExtrudeProfile::new(profile.plane(), vec![outer_wall]); + let (outer_shell, outer_labels) = oriented_skin_shell(sweep, &outer_profile, path, plan)?; + + let spine = SweepPath3::new(path, SampleCount::DEFAULT); + let total_mm = spine.length().get::(); + if total_mm <= 2.0f64.mul_add(cap_mm, EXTRUDE_MIN_DEPTH_MM) { + let solid = Solid::try_new(vec![outer_shell]).map_err(|_| BrepError::ShellNotClosed)?; + return assemble_skin(solid, &outer_labels); + } + + let inner_profile = ExtrudeProfile::new(profile.plane(), vec![inner_wall]); + let inner_path = path + .trimmed( + spine.native_at(ArcFraction::new(cap_mm / total_mm)), + spine.native_at(ArcFraction::new(1.0 - cap_mm / total_mm)), + ) + .ok_or(BrepError::EmptyExtrudeDepth)?; + let seed_anchor = profile + .plane() + .project(spine.point_at(spine.fraction_at(plan.origin))); + let inner_plan = SkinPlan { + origin: Parameter::new(0.0), + anchor: Some(seed_anchor), + stations: plan.stations, + guide: plan.guide, + twist_rad: plan.twist_rad, + guides: plan.guides, + orientation: plan.orientation, + tangency: plan.tangency, + face_up: plan.face_up, + }; + let (inner_shell, inner_labels) = + oriented_skin_shell(sweep, &inner_profile, &inner_path, &inner_plan)?; + let void: Shell = inner_shell.face_iter().map(Face::inverse).collect(); + + let solid = Solid::try_new(vec![outer_shell, void]).map_err(|_| BrepError::ShellNotClosed)?; + let inner_labels = fragmented(inner_labels, VOID_FRAGMENT); + assemble_skin(solid, &merge_labelings(outer_labels, inner_labels)) +} + +fn validate_sweep_fit( + profile: &ExtrudeProfile, + composite: &CompositePath3, + origin: Parameter, +) -> Result<(), BrepError> { + let spine = SweepPath3::new(composite, SampleCount::DEFAULT); + let min_radius = spine.min_curvature_radius_mm(StationCount::DEFAULT); + if !min_radius.is_finite() { + return Ok(()); + } + let anchor = profile + .plane() + .project(spine.point_at(spine.fraction_at(origin))); + let Some(reach) = profile_reach_mm(profile, anchor) else { + return Ok(()); + }; + if reach >= min_radius { + return Err(BrepError::InvalidSweep { + reason: SweepDefect::PathTooTight, + }); + } + Ok(()) +} + +fn control_hull_reach_mm(sections: &[EdgeSection], anchor: Point2) -> f64 { + let (ax, ay) = anchor.coords_mm(); + sections + .iter() + .flat_map(|section| section.spline.control.iter()) + .map(|point| { + let (px, py) = point.coords_mm(); + (px - ax).hypot(py - ay) + }) + .fold(0.0_f64, f64::max) +} + +fn point_distance_mm(a: Point2, b: Point2) -> f64 { + let (ax, ay) = a.coords_mm(); + let (bx, by) = b.coords_mm(); + (bx - ax).hypot(by - ay) +} + +fn curve_reach_mm(curve: Curve2Kind, anchor: Point2) -> f64 { + match curve { + Curve2Kind::Circle(circle) => { + point_distance_mm(anchor, circle.center()) + circle.radius_mm() + } + Curve2Kind::Arc(arc) => point_distance_mm(anchor, arc.center()) + arc.radius_mm(), + Curve2Kind::Ellipse(ellipse) => { + point_distance_mm(anchor, ellipse.center()) + ellipse.semi_major_mm() + } + Curve2Kind::Line(line) => [Parameter::new(0.0), Parameter::new(1.0)] + .into_iter() + .map(|t| point_distance_mm(anchor, line.evaluate(t))) + .fold(0.0_f64, f64::max), + Curve2Kind::Bezier(bezier) => bezier + .control() + .into_iter() + .map(|point| point_distance_mm(anchor, point)) + .fold(0.0_f64, f64::max), + } +} + +fn profile_reach_mm(profile: &ExtrudeProfile, anchor: Point2) -> Option { + match profile.loops() { + [ProfileLoop::Open(edges)] if edges.len() >= 2 => Some( + edges + .iter() + .map(|edge| curve_reach_mm(edge.curve(), anchor)) + .fold(0.0_f64, f64::max), + ), + [ProfileLoop::Closed { curve, .. }] => Some(curve_reach_mm(*curve, anchor)), + _ => None, + } +} + +fn synthetic_circle_entity() -> SketchEntityId { + SketchEntityId::from(KeyData::from_ffi(1u64 << 32)) +} + +fn circular_profile( + diameter: PositiveLength, + curve: &C, +) -> Result { + let start = curve.evaluate(Parameter::new(0.0)); + let tangent = curve + .derivative(Parameter::new(0.0)) + .try_normalize(PROFILE_TOLERANCE) + .map_err(|_| BrepError::EmptyExtrudeDepth)?; + let (x_axis, y_axis) = tangent.orthonormal_basis(); + let plane = Plane3::new_unchecked(start, x_axis, y_axis); + let circle = Circle2::new(Point2::origin(), diameter.get() * 0.5, PROFILE_TOLERANCE) + .map_err(|_| invalid(ProfileDefect::ZeroArea))?; + Ok(ExtrudeProfile::new( + plane, + vec![ProfileLoop::Closed { + curve: Curve2Kind::Circle(circle), + curve_entity: synthetic_circle_entity(), + }], + )) +} + +fn plane_distance_mm(plane: Plane3, point: Point3) -> f64 { + let (nx, ny, nz) = plane.normal().components(); + let (dx, dy, dz) = (point - plane.origin()).coords_mm(); + nx * dx + ny * dy + nz * dz +} + +fn sweep_along_line( + sweep: FeatureId, + profile: &ExtrudeProfile, + line: Line3, + thin: Option, + direction: SweepDirection, +) -> Result, BrepError> { + let normal = profile.plane().normal(); + let axis = (line.end() - line.start()) + .try_normalize(PROFILE_TOLERANCE) + .map_err(|_| BrepError::EmptyExtrudeDepth)?; + let alignment = axis.dot(normal); + let end_is_forward = if alignment >= 1.0 - SWEEP_PERP_TOLERANCE { + true + } else if alignment <= -1.0 + SWEEP_PERP_TOLERANCE { + false + } else { + return Ok(None); + }; + let total_mm = line.length_mm(); + let pierce_mm = -plane_distance_mm(profile.plane(), line.start()) / alignment; + if !(-PROFILE_TOLERANCE_MM..=total_mm + PROFILE_TOLERANCE_MM).contains(&pierce_mm) { + return Err(invalid_sweep(SweepDefect::PathMissesProfile)); + } + let toward_end = (total_mm - pierce_mm).max(0.0); + let toward_start = pierce_mm.max(0.0); + let (end_mm, start_mm) = + direction_spans(direction, toward_end, toward_start, EXTRUDE_MIN_DEPTH_MM)?; + let (forward_mm, backward_mm) = if end_is_forward { + (end_mm, start_mm) + } else { + (start_mm, end_mm) + }; + let step = |distance_mm: f64| { + (distance_mm > EXTRUDE_MIN_DEPTH_MM).then_some(DirectionPlan { + distance_mm, + draft: None, + }) + }; + let plan = ResolvedSweep { + base_offset_mm: 0.0, + forward: step(forward_mm), + backward: step(backward_mm), + thin, + }; + evaluate_extrude_plan(sweep, profile, &plan).map(Some) +} + +fn arc_pierce_param(plane: Plane3, arc: Arc3, centroid: Point3) -> Result { + let (nx, ny, nz) = plane.normal().components(); + let against = |axis: UnitVec3| { + let (x, y, z) = axis.components(); + nx * x + ny * y + nz * z + }; + let radial = against(arc.plane().x_axis()); + let transverse = against(arc.plane().y_axis()); + let theta = (-radial).atan2(transverse); + let sweep = arc.sweep_rad(); + let angle_eps = PROFILE_TOLERANCE_MM / arc.radius_mm().max(PROFILE_TOLERANCE_MM); + let param_eps = angle_eps / sweep.abs(); + let param_of = |angle: f64| { + let wound = if sweep >= 0.0 { + (angle - arc.start_rad()).rem_euclid(TAU) + } else { + (arc.start_rad() - angle).rem_euclid(TAU) + }; + let delta = if wound >= TAU - angle_eps { 0.0 } else { wound }; + let u = delta / sweep.abs(); + (-param_eps..=1.0 + param_eps) + .contains(&u) + .then(|| u.clamp(0.0, 1.0)) + }; + [theta, theta + PI] + .into_iter() + .filter_map(param_of) + .min_by(|a, b| { + let miss = |u: f64| (arc.evaluate(Parameter::new(u)) - centroid).norm_mm(); + miss(*a).total_cmp(&miss(*b)) + }) + .ok_or_else(|| invalid_sweep(SweepDefect::PathMissesProfile)) +} + +fn sweep_along_arc( + sweep: FeatureId, + profile: &ExtrudeProfile, + arc: Arc3, + thin: Option, + direction: SweepDirection, +) -> Result, BrepError> { + if thin.is_some_and(|thin| thin.cap_ends) { + return Ok(None); + } + let plane = profile.plane(); + let coplanar = plane.normal().dot(arc.normal()).abs() <= SWEEP_PERP_TOLERANCE + && plane_distance_mm(plane, arc.center()).abs() <= REVOLVE_AXIS_TOLERANCE_MM; + if !coplanar { + return Ok(None); + } + let pierce = arc_pierce_param(plane, arc, profile_centroid(profile))?; + let sweep_rad = arc.sweep_rad(); + let toward_end = sweep_rad.abs() * (1.0 - pierce); + let toward_start = sweep_rad.abs() * pierce; + let min_span = EXTRUDE_MIN_DEPTH_MM / arc.radius_mm().max(PROFILE_TOLERANCE_MM); + let (forward_rad, backward_rad) = + direction_spans(direction, toward_end, toward_start, min_span)?; + let clamp_span = |span: f64| if span > min_span { span } else { 0.0 }; + let (forward_rad, backward_rad) = (clamp_span(forward_rad), clamp_span(backward_rad)); + let axis_dir = if sweep_rad < 0.0 { + arc.normal().reversed() + } else { + arc.normal() + }; + let plan = ResolvedRevolve { + axis: Axis3::new(arc.center(), axis_dir), + forward_rad, + backward_rad, + thin, + }; + evaluate_revolve(sweep, profile, &plan).map(Some) +} + +fn sweep_along_axis( + sweep: FeatureId, + profile: &ExtrudeProfile, + center: Point3, + axis_dir: UnitVec3, + sweep_rad: f64, + thin: Option, +) -> Result, BrepError> { + let plane = profile.plane(); + let coplanar = plane.normal().dot(axis_dir).abs() <= SWEEP_PERP_TOLERANCE + && plane_distance_mm(plane, center).abs() <= REVOLVE_AXIS_TOLERANCE_MM; + if !coplanar { + return Ok(None); + } + let direction = if sweep_rad < 0.0 { + axis_dir.reversed() + } else { + axis_dir + }; + let plan = ResolvedRevolve { + axis: Axis3::new(center, direction), + forward_rad: sweep_rad.abs(), + backward_rad: 0.0, + thin, + }; + evaluate_revolve(sweep, profile, &plan).map(Some) +} + +const SKIN_VALIDATION_TOLERANCE_MM: f64 = 1.0e-6; + +struct EdgeSection { + spline: SectionSpline, + segment: SketchEntityId, + corner: SketchEntityId, +} + +fn profile_sections(profile: &ExtrudeProfile) -> Option> { + match profile.loops() { + [ProfileLoop::Open(edges)] if edges.len() >= 2 => Some( + edges + .iter() + .map(|edge| EdgeSection { + spline: section_spline(edge.curve()), + segment: edge.curve_entity(), + corner: edge.corner(), + }) + .collect(), + ), + [ + ProfileLoop::Closed { + curve, + curve_entity, + }, + ] => closed_loop_sections(*curve, *curve_entity), + _ => None, + } +} + +fn closed_loop_sections(curve: Curve2Kind, entity: SketchEntityId) -> Option> { + match curve { + Curve2Kind::Circle(circle) => quarter_sections(entity, |start, sweep| { + circle_arc_spline(circle, start, sweep) + }), + Curve2Kind::Ellipse(ellipse) => quarter_sections(entity, |start, sweep| { + ellipse_arc_spline(ellipse, start, sweep) + }), + Curve2Kind::Line(_) | Curve2Kind::Arc(_) | Curve2Kind::Bezier(_) => None, + } +} + +fn quarter_sections( + entity: SketchEntityId, + spline_of: impl Fn(Angle, Angle) -> SectionSpline, +) -> Option> { + let quarter = TAU / 4.0; + (0..4u32) + .map(|index| { + let (segment, corner) = arc_section_ids(entity, index)?; + Some(EdgeSection { + spline: spline_of( + Angle::new::(quarter * f64::from(index)), + Angle::new::(quarter), + ), + segment, + corner, + }) + }) + .collect() +} + +struct LoopSections { + loop_index: LoopIndex, + sections: Vec, +} + +fn loop_sections( + profile: &ExtrudeProfile, + resolved: &[ResolvedLoop], +) -> Result, BrepError> { + let multi_loop = || unsupported(TruckGap::SweepMultiLoopProfile); + if resolved.len() <= 1 { + let sections = profile_sections(profile).ok_or_else(multi_loop)?; + return Ok(vec![LoopSections { + loop_index: LoopIndex::OUTER, + sections, + }]); + } + resolved + .iter() + .map(|resolved_loop| { + Ok(LoopSections { + loop_index: resolved_loop.loop_index, + sections: resolved_sections(resolved_loop).ok_or_else(multi_loop)?, + }) + }) + .collect() +} + +fn resolved_sections(resolved: &ResolvedLoop) -> Option> { + let forward = match resolved.segments.as_slice() { + [segment] + if matches!( + segment.curve, + Curve2Kind::Circle(_) | Curve2Kind::Ellipse(_) + ) => + { + closed_loop_sections(segment.curve, segment.curve_entity)? + } + segments if segments.len() >= 2 => segments + .iter() + .map(|segment| EdgeSection { + spline: section_spline(segment.curve), + segment: segment.curve_entity, + corner: segment.corner.unwrap_or(segment.curve_entity), + }) + .collect(), + _ => return None, + }; + Some(if resolved.reversed { + reverse_sections(&forward) + } else { + forward + }) +} + +fn reverse_sections(sections: &[EdgeSection]) -> Vec { + let count = sections.len(); + (0..count) + .map(|index| EdgeSection { + spline: reverse_section(§ions[count - 1 - index].spline), + segment: sections[count - 1 - index].segment, + corner: sections[(count - index) % count].corner, + }) + .collect() +} + +struct SkinPlan<'g> { + stations: StationCount, + guide: FrameGuide, + twist_rad: f64, + origin: Parameter, + anchor: Option, + guides: &'g [CompositePath3], + orientation: SweepOrientation, + tangency: (SweepTangency, SweepTangency), + face_up: Option<&'g dyn Fn(Point3) -> Option>, +} + +#[derive(Copy, Clone)] +struct SectionMap { + xx: f64, + xy: f64, + yx: f64, + yy: f64, +} + +impl SectionMap { + const IDENTITY: Self = Self { + xx: 1.0, + xy: 0.0, + yx: 0.0, + yy: 1.0, + }; + + fn apply(self, u: f64, v: f64) -> (f64, f64) { + ( + self.xx.mul_add(u, self.xy * v), + self.yx.mul_add(u, self.yy * v), + ) + } +} + +struct GuideTrack { + curve: CompositePath3, + samples: Vec<(f64, Point3)>, + cursor: usize, +} + +const GUIDE_SAMPLES: u32 = 1024; +const GUIDE_REACH_STEPS: u32 = 256; +const GUIDE_REFINE_STEPS: u32 = 48; +const GUIDE_CONTACT_TOLERANCE_MM: f64 = 1.0e-3; + +fn against_plane(origin: Point3, normal: UnitVec3, point: Point3) -> f64 { + let (nx, ny, nz) = normal.components(); + let (dx, dy, dz) = (point - origin).coords_mm(); + nx * dx + ny * dy + nz * dz +} + +impl GuideTrack { + fn new(curve: &CompositePath3) -> Self { + let samples = (0..=GUIDE_SAMPLES) + .map(|i| { + let t = f64::from(i) / f64::from(GUIDE_SAMPLES); + (t, curve.evaluate(Parameter::new(t))) + }) + .collect(); + Self { + curve: curve.clone(), + samples, + cursor: 0, + } + } + + fn intersect(&mut self, origin: Point3, normal: UnitVec3) -> Option { + let distance = |point: Point3| against_plane(origin, normal, point); + let bracket = (0..self.samples.len() - 1) + .filter(|&i| { + let d0 = distance(self.samples[i].1); + let d1 = distance(self.samples[i + 1].1); + d0 * d1 <= 0.0 + || d0.abs() <= PROFILE_TOLERANCE_MM + || d1.abs() <= PROFILE_TOLERANCE_MM + }) + .min_by_key(|&i| i.abs_diff(self.cursor))?; + self.cursor = bracket; + let (t0, p0) = self.samples[bracket]; + let (t1, _) = self.samples[bracket + 1]; + let d0 = distance(p0); + let refined = (0..GUIDE_REFINE_STEPS).fold((t0, d0, t1), |(lo, lo_d, hi), _| { + let mid = f64::midpoint(lo, hi); + let mid_d = distance(self.curve.evaluate(Parameter::new(mid))); + if lo_d * mid_d <= 0.0 { + (lo, lo_d, mid) + } else { + (mid, mid_d, hi) + } + }); + let t = f64::midpoint(refined.0, refined.2); + Some(self.curve.evaluate(Parameter::new(t))) + } +} + +fn guide_reach(spine: &SweepPath3, guides: &mut [GuideTrack]) -> f64 { + let station = |fraction: f64| { + let at = ArcFraction::new(fraction); + (spine.point_at(at), spine.tangent_at(at)) + }; + let all_hit = |fraction: f64, guides: &mut [GuideTrack]| { + let (origin, tangent) = station(fraction); + guides + .iter_mut() + .all(|guide| guide.intersect(origin, tangent).is_some()) + }; + let first_miss = (0..=GUIDE_REACH_STEPS) + .map(|i| f64::from(i) / f64::from(GUIDE_REACH_STEPS)) + .find(|&fraction| !all_hit(fraction, guides)); + let Some(miss) = first_miss else { + return 1.0; + }; + if miss == 0.0 { + return 0.0; + } + let step = 1.0 / f64::from(GUIDE_REACH_STEPS); + let refined = (0..20).fold((miss - step, miss), |(ok, bad), _| { + let mid = f64::midpoint(ok, bad); + if all_hit(mid, guides) { + (mid, bad) + } else { + (ok, mid) + } + }); + refined.0 +} + +struct GuideContact { + track: GuideTrack, + base: (f64, f64), +} + +fn frame_coords(frame: &Frame, point: Point3) -> (f64, f64) { + let offset = point - frame.origin(); + let along = |axis: UnitVec3| { + let (x, y, z) = axis.components(); + let (dx, dy, dz) = offset.coords_mm(); + x * dx + y * dy + z * dz + }; + (along(frame.normal()), along(frame.binormal())) +} + +fn contact_on_profile(profile: &ExtrudeProfile, contact: Point2) -> bool { + let tolerance = Tolerance::new(GUIDE_CONTACT_TOLERANCE_MM); + profile.loops().iter().flat_map(loop_curves).any(|curve| { + curve + .closest_point(contact, tolerance) + .distance() + .get::() + <= GUIDE_CONTACT_TOLERANCE_MM + }) +} + +fn guide_contacts( + profile: &ExtrudeProfile, + anchor: Point2, + guides: &[CompositePath3], +) -> Result, BrepError> { + let plane = profile.plane(); + guides + .iter() + .map(|guide| { + let mut track = GuideTrack::new(guide); + let touch = track + .intersect(plane.origin(), plane.normal()) + .ok_or_else(|| invalid_sweep(SweepDefect::GuideMissesProfile))?; + let contact = plane.project(touch); + if !contact_on_profile(profile, contact) { + return Err(invalid_sweep(SweepDefect::GuideMissesProfile)); + } + let (cx, cy) = contact.coords_mm(); + let (ax, ay) = anchor.coords_mm(); + let base = (cx - ax, cy - ay); + if base.0.hypot(base.1) <= GUIDE_CONTACT_TOLERANCE_MM { + return Err(invalid_sweep(SweepDefect::GuideSpanDegenerate)); + } + Ok(GuideContact { track, base }) + }) + .collect() +} + +fn one_guide_map(base: (f64, f64), target: (f64, f64), rotate: bool) -> Option { + let base_len = base.0.hypot(base.1); + let target_len = target.0.hypot(target.1); + let scale = target_len / base_len; + if scale <= 1.0e-6 { + return None; + } + if !rotate { + return Some(SectionMap { + xx: scale, + xy: 0.0, + yx: 0.0, + yy: scale, + }); + } + let cos = (base.0 * target.0 + base.1 * target.1) / (base_len * target_len); + let sin = (base.0 * target.1 - base.1 * target.0) / (base_len * target_len); + Some(SectionMap { + xx: scale * cos, + xy: -scale * sin, + yx: scale * sin, + yy: scale * cos, + }) +} + +fn two_guide_map( + first: ((f64, f64), (f64, f64)), + second: ((f64, f64), (f64, f64)), +) -> Result, BrepError> { + let (v0, v1) = first; + let (w0, w1) = second; + let det = v0.0.mul_add(w0.1, -(v0.1 * w0.0)); + let norm = v0.0.hypot(v0.1) * w0.0.hypot(w0.1); + if det.abs() <= 1.0e-9 * norm.max(1.0e-12) { + return Err(invalid_sweep(SweepDefect::GuideSpanDegenerate)); + } + let inv = |a: f64, b: f64, c: f64, d: f64| (d / det, -b / det, -c / det, a / det); + let (i00, i01, i10, i11) = inv(v0.0, w0.0, v0.1, w0.1); + let map = SectionMap { + xx: v1.0.mul_add(i00, w1.0 * i10), + xy: v1.0.mul_add(i01, w1.0 * i11), + yx: v1.1.mul_add(i00, w1.1 * i10), + yy: v1.1.mul_add(i01, w1.1 * i11), + }; + let map_det = map.xx.mul_add(map.yy, -(map.xy * map.yx)); + if map_det.abs() <= 1.0e-6 { + return Ok(None); + } + Ok(Some(map)) +} + +fn solve_guide_maps( + frames: &[Frame], + contacts: &mut [GuideContact], + orientation: SweepOrientation, +) -> Result, BrepError> { + frames + .iter() + .map(|frame| { + let targets = contacts + .iter_mut() + .map(|contact| { + contact + .track + .intersect(frame.origin(), frame.tangent()) + .map(|point| frame_coords(frame, point)) + }) + .collect::>>() + .ok_or_else(|| invalid_sweep(SweepDefect::GuideCollapse))?; + let map = match (targets.as_slice(), contacts.len()) { + ([target], 1) => one_guide_map( + contacts[0].base, + *target, + matches!(orientation, SweepOrientation::FollowPathAndFirstGuide), + ), + ([first, second], 2) => { + two_guide_map((contacts[0].base, *first), (contacts[1].base, *second))? + } + _ => Some(SectionMap::IDENTITY), + }; + map.ok_or_else(|| invalid_sweep(SweepDefect::GuideCollapse)) + }) + .collect() +} + +enum RailSolver { + Free(PathInterpolator), + Clamped { + interpolator: ClampedInterpolator, + spacing: (f64, f64), + start: Option, + end: Option, + }, +} + +struct SweepFrames { + frames: Vec, + maps: Vec, + anchor: Point2, + solver: RailSolver, +} + +fn tangency_weight(tangency: (SweepTangency, SweepTangency), along: f64) -> f64 { + let smoothstep = |x: f64| { + let clamped = x.clamp(0.0, 1.0); + clamped * clamped * (3.0 - 2.0 * clamped) + }; + let toward = |active: SweepTangency, reach: f64| { + matches!(active, SweepTangency::PathTangent) + .then(|| smoothstep(reach)) + .unwrap_or(0.0) + }; + toward(tangency.0, (0.5 - along) / 0.5).max(toward(tangency.1, (along - 0.5) / 0.5)) +} + +impl SweepFrames { + fn new( + profile: &ExtrudeProfile, + path: &CompositePath3, + plan: &SkinPlan<'_>, + ) -> Result { + let plane = profile.plane(); + let spine = SweepPath3::new(path, SampleCount::DEFAULT); + let seed_fraction = spine.fraction_at(plan.origin); + let anchor = plan + .anchor + .unwrap_or_else(|| plane.project(spine.point_at(seed_fraction))); + let mut contacts = guide_contacts(profile, anchor, plan.guides)?; + let reach = if contacts.is_empty() { + 1.0 + } else { + let mut tracks: Vec = plan.guides.iter().map(GuideTrack::new).collect(); + guide_reach(&spine, &mut tracks) + }; + if reach <= f64::EPSILON { + return Err(invalid_sweep(SweepDefect::EmptySpan)); + } + let (base_fractions, seed_index) = station_fractions(seed_fraction, plan.stations); + let fractions: Vec = base_fractions + .iter() + .map(|fraction| fraction * reach) + .collect(); + let native: Vec = match plan.face_up { + Some(up) => fractions + .iter() + .map(|&fraction| { + let at = ArcFraction::new(fraction); + let origin = spine.point_at(at); + let tangent = spine.tangent_at(at); + let reference = + up(origin).unwrap_or_else(|| Frame::seed(origin, tangent, plane).normal()); + Frame::locked(origin, tangent, reference) + }) + .collect(), + None => fractions + .iter() + .zip(transport_stations( + &spine, plane, &fractions, seed_index, plan.guide, + )) + .map(|(&fraction, frame)| { + frame.twist(plan.twist_rad * (fraction - seed_fraction.get())) + }) + .collect(), + }; + let span = fractions + .last() + .zip(fractions.first()) + .map_or(0.0, |(last, first)| last - first); + let frames: Vec = native + .iter() + .zip(&fractions) + .map(|(frame, &fraction)| { + let along = if span.abs() <= f64::EPSILON { + 0.0 + } else { + (fraction - fractions[0]) / span + }; + let weight = tangency_weight(plan.tangency, along); + frame.blend_tangent(spine.tangent_at(ArcFraction::new(fraction)), weight) + }) + .collect(); + let maps = if contacts.is_empty() { + vec![SectionMap::IDENTITY; frames.len()] + } else { + solve_guide_maps(&frames, &mut contacts, plan.orientation)? + }; + let centers: Vec = frames.iter().map(|frame| frame.origin()).collect(); + let params = chord_params(¢ers).ok_or(BrepError::EmptyExtrudeDepth)?; + let solver = if plan.tangency == (SweepTangency::None, SweepTangency::None) { + let degree = 3.min(frames.len().saturating_sub(1)); + RailSolver::Free( + PathInterpolator::new(¶ms, degree).ok_or(BrepError::EmptyExtrudeDepth)?, + ) + } else { + let interpolator = + ClampedInterpolator::new(¶ms).ok_or(BrepError::EmptyExtrudeDepth)?; + let spacing = ( + params[1] - params[0], + params[params.len() - 1] - params[params.len() - 2], + ); + let constrained = |tangency: SweepTangency, fraction: Option<&f64>| { + matches!(tangency, SweepTangency::PathTangent) + .then(|| fraction.map(|&fraction| spine.tangent_at(ArcFraction::new(fraction)))) + .flatten() + }; + RailSolver::Clamped { + interpolator, + spacing, + start: constrained(plan.tangency.0, fractions.first()), + end: constrained(plan.tangency.1, fractions.last()), + } + }; + Ok(Self { + frames, + maps, + anchor, + solver, + }) + } + + fn rail(&self, point: Point2) -> Result { + let (px, py) = point.coords_mm(); + let (ax, ay) = self.anchor.coords_mm(); + let stations: Vec = self + .frames + .iter() + .zip(&self.maps) + .map(|(frame, map)| { + let (u, v) = map.apply(px - ax, py - ay); + frame.section_plane().point_at_local(u, v, 0.0) + }) + .collect(); + match &self.solver { + RailSolver::Free(interpolator) => interpolator + .solve(&stations) + .ok_or(BrepError::EmptyExtrudeDepth), + RailSolver::Clamped { + interpolator, + spacing, + start, + end, + } => { + let length: f64 = stations + .windows(2) + .map(|pair| (pair[1] - pair[0]).norm_mm()) + .sum(); + let along = |unit: UnitVec3| { + let (x, y, z) = unit.components(); + Vec3::from_mm(x * length, y * length, z * length) + }; + let start_d = + start.map_or_else(|| (stations[1] - stations[0]) * (1.0 / spacing.0), along); + let end_d = end.map_or_else( + || { + (stations[stations.len() - 1] - stations[stations.len() - 2]) + * (1.0 / spacing.1) + }, + along, + ); + interpolator + .solve(&stations, start_d, end_d) + .ok_or(BrepError::EmptyExtrudeDepth) + } + } + } +} + +fn homogeneous(point: truck_modeling::Point3, weight: f64) -> Vector4 { + Vector4::new(point.x * weight, point.y * weight, point.z * weight, weight) +} + +fn cap_positions(columns: &[BSpline3], at_end: bool) -> Vec { + columns + .iter() + .map(|rail| { + let control = rail.control(); + let point = if at_end { + control[control.len() - 1] + } else { + control[0] + }; + point_to_truck(point) + }) + .collect() +} + +fn section_edge( + from: &Vertex, + to: &Vertex, + positions: &[truck_modeling::Point3], + spline: &SectionSpline, +) -> Edge { + if spline.degree <= 1 { + builder::line(from, to) + } else { + let control: Vec = positions + .iter() + .zip(&spline.weights) + .map(|(point, &weight)| homogeneous(*point, weight)) + .collect(); + let curve = Curve::NurbsCurve(NurbsCurve::new(BSplineCurve::new( + KnotVec::from(spline.knots.clone()), + control, + ))); + Edge::debug_new(from, to, curve) + } +} + +fn lateral_surface(columns: &[BSpline3], spline: &SectionSpline, path_knots: &KnotVec) -> Surface { + let net: Vec> = columns + .iter() + .zip(&spline.weights) + .map(|(rail, &weight)| { + rail.control() + .iter() + .map(|point| homogeneous(point_to_truck(*point), weight)) + .collect() + }) + .collect(); + let section_knots = KnotVec::from(spline.knots.clone()); + Surface::NurbsSurface(NurbsSurface::new(BSplineSurface::new( + (section_knots, path_knots.clone()), + net, + ))) +} + +struct LoopShell { + lateral: Vec, + section_start: Vec, + section_end: Vec, + rail_edges: Vec, + starts: Vec, + ends: Vec, +} + +struct SkinShell { + base: Face, + top: Face, + loops: Vec, + tolerance: f64, +} + +fn build_loop_shell( + sections: &[EdgeSection], frames: &SweepFrames, -) -> Result { +) -> Result<(LoopShell, Vec), BrepError> { let count = sections.len(); + let corner_rails: Vec = sections + .iter() + .map(|section| frames.rail(section.spline.control[0])) + .collect::, _>>()?; let path_knots = KnotVec::from(corner_rails[0].knots().to_vec()); let along_knots = path_knots.clone(); @@ -861,144 +2174,807 @@ fn build_skin_shell( ) }) .collect(); - let section_end: Vec = (0..count) - .map(|index| { - let next = (index + 1) % count; - let positions = cap_positions(&columns[index], true); - section_edge( - &ends[index], - &ends[next], - &positions, - §ions[index].spline, + let section_end: Vec = (0..count) + .map(|index| { + let next = (index + 1) % count; + let positions = cap_positions(&columns[index], true); + section_edge( + &ends[index], + &ends[next], + &positions, + §ions[index].spline, + ) + }) + .collect(); + let lateral: Vec = (0..count) + .map(|index| { + let next = (index + 1) % count; + let surface = lateral_surface(&columns[index], §ions[index].spline, &path_knots); + let wire: Wire = vec![ + section_start[index].clone(), + rail_edges[next].clone(), + section_end[index].inverse(), + rail_edges[index].inverse(), + ] + .into(); + Face::new(vec![wire], surface) + }) + .collect(); + Ok(( + LoopShell { + lateral, + section_start, + section_end, + rail_edges, + starts, + ends, + }, + corner_rails, + )) +} + +fn build_skin_shell(loops: &[LoopSections], frames: &SweepFrames) -> Result { + let built: Vec<(LoopShell, Vec)> = loops + .iter() + .map(|loop_sections| build_loop_shell(&loop_sections.sections, frames)) + .collect::>()?; + let base_wires: Vec = built + .iter() + .map(|(shell, _)| shell.section_start.clone().into()) + .collect(); + let top_wires: Vec = built + .iter() + .map(|(shell, _)| shell.section_end.clone().into()) + .collect(); + let base = builder::try_attach_plane(&base_wires) + .map_err(|_| invalid(ProfileDefect::SelfIntersectingLoop))?; + let top = builder::try_attach_plane(&top_wires) + .map_err(|_| invalid(ProfileDefect::SelfIntersectingLoop))?; + let tolerance = coarse_mesh_tolerance( + built + .iter() + .flat_map(|(_, rails)| rails.iter().flat_map(|rail| rail.control().iter().copied())), + ); + Ok(SkinShell { + base, + top, + loops: built.into_iter().map(|(shell, _)| shell).collect(), + tolerance, + }) +} + +fn label_skin(sweep: FeatureId, loops: &[LoopSections], parts: &SkinShell) -> SolidLabeling { + let mut labels = Labels::new(sweep, HashSet::new()); + labels.face(parts.base.id(), FaceRole::StartCap); + labels.face(parts.top.id(), FaceRole::EndCap); + loops + .iter() + .zip(&parts.loops) + .for_each(|(loop_sections, shell)| { + let loop_index = loop_sections.loop_index; + let sections = &loop_sections.sections; + shell + .lateral + .iter() + .zip(sections) + .for_each(|(face, section)| { + labels.face( + face.id(), + FaceRole::Side { + loop_index, + from: section.segment, + }, + ); + }); + sections.iter().enumerate().for_each(|(index, section)| { + labels.edge( + shell.section_start[index].id(), + EdgeRole::StartCapEdge { + from: section.segment, + }, + ); + labels.edge( + shell.section_end[index].id(), + EdgeRole::EndCapEdge { + from: section.segment, + }, + ); + labels.edge( + shell.rail_edges[index].id(), + EdgeRole::SideEdge { + from: section.corner, + side: SideKind::Corner, + }, + ); + labels.vertex( + shell.starts[index].id(), + VertexRole::StartCapVertex { + from: section.corner, + side: SideKind::Corner, + }, + ); + labels.vertex( + shell.ends[index].id(), + VertexRole::EndCapVertex { + from: section.corner, + side: SideKind::Corner, + }, + ); + }); + }); + labels.into_labeling() +} + +fn oriented_skin_shell( + sweep: FeatureId, + profile: &ExtrudeProfile, + path: &CompositePath3, + plan: &SkinPlan<'_>, +) -> Result<(Shell, SolidLabeling), BrepError> { + let resolved = resolve_loops(profile.loops())?; + let loops = loop_sections(profile, &resolved)?; + let frames = SweepFrames::new(profile, path, plan)?; + let parts = build_skin_shell(&loops, &frames)?; + let labeling = label_skin(sweep, &loops, &parts); + + let mut faces: Vec = Vec::new(); + faces.push(parts.base.inverse()); + parts + .loops + .iter() + .for_each(|shell| faces.extend(shell.lateral.iter().cloned())); + faces.push(parts.top.clone()); + let shell: Shell = faces.into(); + let mut solid = Solid::try_new(vec![shell]).map_err(|_| BrepError::ShellNotClosed)?; + if truck_solid_signed_volume(&solid, parts.tolerance) < 0.0 { + solid.not(); + } + let oriented = solid + .into_boundaries() + .into_iter() + .next() + .ok_or(BrepError::ShellNotClosed)?; + Ok((oriented, labeling)) +} + +fn assemble_skin(solid: Solid, labeling: &SolidLabeling) -> Result { + let assembled = assemble(solid, labeling)?; + assembled.validate(Tolerance::new(SKIN_VALIDATION_TOLERANCE_MM))?; + Ok(assembled) +} + +fn build_skinned_sweep( + sweep: FeatureId, + profile: &ExtrudeProfile, + path: &CompositePath3, + plan: &SkinPlan<'_>, +) -> Result { + let (shell, labeling) = oriented_skin_shell(sweep, profile, path, plan)?; + let solid = Solid::try_new(vec![shell]).map_err(|_| BrepError::ShellNotClosed)?; + assemble_skin(solid, &labeling) +} + +fn coarse_mesh_tolerance(points: impl Iterator) -> f64 { + let extent = points + .map(|point| { + let (x, y, z) = point.coords_mm(); + x.abs().max(y.abs()).max(z.abs()) + }) + .fold(0.0_f64, f64::max); + (extent * 1.0e-2).max(SKIN_VALIDATION_TOLERANCE_MM) +} + +const ARC_SECTION_SHIFT: u32 = 52; +const ARC_CORNER_BIT: u64 = 1u64 << 55; +const MITER_CORNER_SHIFT: u32 = 20; +const MITER_RING_SHIFT: u32 = 26; +const MITER_RAIL_BIT: u64 = 1u64 << 31; +const MITER_TAG_MASK: u64 = 0xFFF0_0000; +const MITER_MAX_CORNERS: usize = 64; +const MITER_MAX_RINGS: usize = 32; +const WALL_TAG_OUTER: u64 = 1u64 << 62; +const WALL_TAG_INNER: u64 = 1u64 << 61; +const WALL_TAG_CAP_START: u64 = 1u64 << 60; +const WALL_TAG_CAP_END: u64 = 1u64 << 59; +const WALL_TAG_CLOSE: u64 = 1u64 << 58; +const WALL_TAG_FILLET: u64 = 1u64 << 57; +const WALL_TAG_MASK: u64 = + WALL_TAG_OUTER | WALL_TAG_INNER | WALL_TAG_CAP_START | WALL_TAG_CAP_END | WALL_TAG_CLOSE; + +fn arc_section_ids(entity: SketchEntityId, index: u32) -> Option<(SketchEntityId, SketchEntityId)> { + let raw = entity.data().as_ffi(); + if raw >> ARC_SECTION_SHIFT != 0 { + return None; + } + let base = raw | (u64::from(index + 1) << ARC_SECTION_SHIFT); + Some(( + SketchEntityId::from(KeyData::from_ffi(base)), + SketchEntityId::from(KeyData::from_ffi(base | ARC_CORNER_BIT)), + )) +} + +fn miter_id(base: SketchEntityId, rail: bool, ring: u8, corner: u8) -> SketchEntityId { + let raw = base.data().as_ffi(); + debug_assert!( + raw & MITER_TAG_MASK == 0, + "miter tag bits must be free in a section entity key" + ); + debug_assert!( + usize::from(ring) < MITER_MAX_RINGS, + "miter ring index fits its tag field" + ); + debug_assert!( + usize::from(corner) < MITER_MAX_CORNERS, + "miter corner index fits its tag field" + ); + let tag = (u64::from(ring) << MITER_RING_SHIFT) | (u64::from(corner) << MITER_CORNER_SHIFT); + let flagged = if rail { tag | MITER_RAIL_BIT } else { tag }; + SketchEntityId::from(KeyData::from_ffi(raw | flagged)) +} + +fn validate_miter_capacity(sections: &[EdgeSection], segments: usize) -> Result<(), BrepError> { + let taggable = |entity: SketchEntityId| entity.data().as_ffi() & MITER_TAG_MASK == 0; + let within = sections.len() <= MITER_MAX_CORNERS + && segments < MITER_MAX_RINGS + && sections + .iter() + .all(|section| taggable(section.segment) && taggable(section.corner)); + if within { + Ok(()) + } else { + Err(unsupported(TruckGap::SweepPathCorner)) + } +} + +#[derive(Copy, Clone)] +struct MiterPlane { + apex: Point3, + normal: UnitVec3, + along: UnitVec3, +} + +fn corner_too_tight() -> BrepError { + BrepError::InvalidSweep { + reason: SweepDefect::CornerTooTight, + } +} + +fn project_to_miter(point: Point3, plane: MiterPlane) -> Point3 { + let (px, py, pz) = point.coords_mm(); + let (ax, ay, az) = plane.apex.coords_mm(); + let (mx, my, mz) = plane.normal.components(); + let (tx, ty, tz) = plane.along.components(); + let numerator = (px - ax) * mx + (py - ay) * my + (pz - az) * mz; + let denominator = tx * mx + ty * my + tz * mz; + debug_assert!( + denominator > 0.0, + "a validated miter plane faces its incoming segment" + ); + let step = -numerator / denominator; + Point3::from_mm( + step.mul_add(tx, px), + step.mul_add(ty, py), + step.mul_add(tz, pz), + ) +} + +struct MiterBoundary { + corner_vertices: Vec, + section_edges: Vec, + section_nets: Vec>, +} + +fn build_miter_boundary( + sections: &[EdgeSection], + frame: Frame, + anchor: Point2, + miter: Option, +) -> MiterBoundary { + let count = sections.len(); + let place = |local: Point2| { + let perpendicular = frame.place_point(local, anchor); + match miter { + None => perpendicular, + Some(plane) => project_to_miter(perpendicular, plane), + } + }; + let corner_points: Vec = sections + .iter() + .map(|section| place(section.spline.control[0])) + .collect(); + let corner_vertices: Vec = corner_points + .iter() + .map(|point| builder::vertex(point_to_truck(*point))) + .collect(); + let section_nets: Vec> = sections + .iter() + .enumerate() + .map(|(index, section)| { + let next = (index + 1) % count; + let control = §ion.spline.control; + core::iter::once(corner_points[index]) + .chain( + control[1..control.len() - 1] + .iter() + .map(|&local| place(local)), + ) + .chain(core::iter::once(corner_points[next])) + .map(point_to_truck) + .collect() + }) + .collect(); + let section_edges: Vec = sections + .iter() + .enumerate() + .map(|(index, section)| { + let next = (index + 1) % count; + section_edge( + &corner_vertices[index], + &corner_vertices[next], + §ion_nets[index], + §ion.spline, + ) + }) + .collect(); + MiterBoundary { + corner_vertices, + section_edges, + section_nets, + } +} + +fn run_tangent(run: &CompositePath3, at: f64) -> Result { + run.derivative(Parameter::new(at)) + .try_normalize(PROFILE_TOLERANCE) + .map_err(|_| BrepError::EmptyExtrudeDepth) +} + +fn corner_miter_plane( + apex: Point3, + incoming: UnitVec3, + outgoing: UnitVec3, +) -> Result<(MiterPlane, f64), BrepError> { + let (ix, iy, iz) = incoming.components(); + let (ox, oy, oz) = outgoing.components(); + let sum = Vec3::from_mm(ix + ox, iy + oy, iz + oz); + let diff = Vec3::from_mm(ix - ox, iy - oy, iz - oz); + let normal = sum + .try_normalize(PROFILE_TOLERANCE) + .map_err(|_| corner_too_tight())?; + let bite_ratio = diff.norm_mm() / sum.norm_mm(); + Ok(( + MiterPlane { + apex, + normal, + along: incoming, + }, + bite_ratio, + )) +} + +const MITER_RUN_STATIONS: u32 = 24; + +fn propagate_run( + run: &CompositePath3, + start_frame: Frame, + end_tangent: UnitVec3, +) -> (Vec, Frame) { + if run.is_polyline() { + let end = run.evaluate(Parameter::new(1.0)); + return (Vec::new(), start_frame.propagate(end, end_tangent)); + } + let spine = SweepPath3::new(run, SampleCount::new(64)); + let interior: Vec = (1..MITER_RUN_STATIONS) + .scan(start_frame, |carried, index| { + let at = ArcFraction::new(f64::from(index) / f64::from(MITER_RUN_STATIONS)); + *carried = carried.propagate(spine.point_at(at), spine.tangent_at(at)); + Some(*carried) + }) + .collect(); + let last = interior.last().copied().unwrap_or(start_frame); + let end_frame = last.propagate(run.evaluate(Parameter::new(1.0)), end_tangent); + (interior, end_frame) +} + +fn skin_run( + sections: &[EdgeSection], + interior: &[Frame], + start_frame: Frame, + end_point: Point3, + ring_a: &MiterBoundary, + ring_b: &MiterBoundary, + anchor: Point2, +) -> Result<(Vec, Vec), BrepError> { + let count = sections.len(); + let centers: Vec = core::iter::once(start_frame.origin()) + .chain(interior.iter().map(|frame| frame.origin())) + .chain(core::iter::once(end_point)) + .collect(); + let params = chord_params(¢ers).ok_or(BrepError::EmptyExtrudeDepth)?; + let degree = 3.min(centers.len().saturating_sub(1)); + let interpolator = + PathInterpolator::new(¶ms, degree).ok_or(BrepError::EmptyExtrudeDepth)?; + + let columns: Vec> = sections + .iter() + .enumerate() + .map(|(section_index, section)| { + (0..section.spline.control.len()) + .map(|control_index| { + let start = point_from_truck(ring_a.section_nets[section_index][control_index]); + let end = point_from_truck(ring_b.section_nets[section_index][control_index]); + let mid = interior.iter().map(|frame| { + frame.place_point(section.spline.control[control_index], anchor) + }); + let column: Vec = core::iter::once(start) + .chain(mid) + .chain(core::iter::once(end)) + .collect(); + interpolator + .solve(&column) + .ok_or(BrepError::EmptyExtrudeDepth) + }) + .collect::, _>>() + }) + .collect::, _>>()?; + + let path_knots = KnotVec::from(columns[0][0].knots().to_vec()); + let rails: Vec = (0..count) + .map(|corner| { + let corner_rail = &columns[corner][0]; + let control: Vec = corner_rail + .control() + .iter() + .copied() + .map(point_to_truck) + .collect(); + let curve = Curve::BSplineCurve(BSplineCurve::new(path_knots.clone(), control)); + Edge::debug_new( + &ring_a.corner_vertices[corner], + &ring_b.corner_vertices[corner], + curve, ) }) .collect(); let lateral: Vec = (0..count) - .map(|index| { - let next = (index + 1) % count; - let surface = lateral_surface(&columns[index], §ions[index].spline, &path_knots); + .map(|section_index| { + let next = (section_index + 1) % count; + let surface = lateral_surface( + &columns[section_index], + §ions[section_index].spline, + &path_knots, + ); let wire: Wire = vec![ - section_start[index].clone(), - rail_edges[next].clone(), - section_end[index].inverse(), - rail_edges[index].inverse(), + ring_a.section_edges[section_index].clone(), + rails[next].clone(), + ring_b.section_edges[section_index].inverse(), + rails[section_index].inverse(), ] .into(); Face::new(vec![wire], surface) }) .collect(); - let base = builder::try_attach_plane(&[section_start.clone().into()]) - .map_err(|_| invalid(ProfileDefect::SelfIntersectingLoop))?; - let top = builder::try_attach_plane(&[section_end.clone().into()]) - .map_err(|_| invalid(ProfileDefect::SelfIntersectingLoop))?; - Ok(SkinShell { - base, - top, - lateral, - section_start, - section_end, - rail_edges, - starts, - ends, - }) + Ok((rails, lateral)) } -fn label_skin(sweep: FeatureId, sections: &[EdgeSection], parts: &SkinShell) -> SolidLabeling { - let mut labels = Labels::new(sweep, HashSet::new()); - labels.face(parts.base.id(), FaceRole::StartCap); - labels.face(parts.top.id(), FaceRole::EndCap); - parts - .lateral +fn build_mitered_sweep( + sweep: FeatureId, + profile: &ExtrudeProfile, + path: &CompositePath3, + origin: Point3, +) -> Result { + resolve_loops(profile.loops())?; + let sections = profile_sections(profile).ok_or(unsupported(TruckGap::SweepMultiLoopProfile))?; + let runs = path.runs(); + let segments = runs.len(); + validate_miter_capacity(§ions, segments)?; + let plane = profile.plane(); + let anchor = plane.project(origin); + let extent = control_hull_reach_mm(§ions, anchor); + + let boundary_points: Vec = core::iter::once(runs[0].evaluate(Parameter::new(0.0))) + .chain(runs.iter().map(|run| run.evaluate(Parameter::new(1.0)))) + .collect(); + let start_tangents: Vec = runs .iter() - .zip(sections) - .for_each(|(face, section)| { - labels.face( - face.id(), - FaceRole::Side { - loop_index: LoopIndex::OUTER, - from: section.segment, - }, - ); - }); - sections.iter().enumerate().for_each(|(index, section)| { - labels.edge( - parts.section_start[index].id(), - EdgeRole::StartCapEdge { - from: section.segment, - }, - ); - labels.edge( - parts.section_end[index].id(), - EdgeRole::EndCapEdge { - from: section.segment, - }, - ); - labels.edge( - parts.rail_edges[index].id(), - EdgeRole::SideEdge { - from: section.corner, - side: SideKind::Corner, - }, - ); - labels.vertex( - parts.starts[index].id(), - VertexRole::StartCapVertex { - from: section.corner, - side: SideKind::Corner, - }, - ); - labels.vertex( - parts.ends[index].id(), - VertexRole::EndCapVertex { - from: section.corner, - side: SideKind::Corner, - }, - ); + .map(|run| run_tangent(run, 0.0)) + .collect::>()?; + let end_tangents: Vec = runs + .iter() + .map(|run| run_tangent(run, 1.0)) + .collect::>()?; + let run_lengths: Vec = (0..segments) + .map(|run| (boundary_points[run + 1] - boundary_points[run]).norm_mm()) + .collect(); + + let bitten: Vec<(MiterPlane, f64)> = (1..segments) + .map(|corner| { + let (miter, ratio) = corner_miter_plane( + boundary_points[corner], + end_tangents[corner - 1], + start_tangents[corner], + )?; + Ok((miter, extent * ratio)) + }) + .collect::>()?; + let fits = (0..segments).all(|run| { + let start_bite = run.checked_sub(1).map_or(0.0, |corner| bitten[corner].1); + let end_bite = bitten.get(run).map_or(0.0, |(_, bite)| *bite); + start_bite + end_bite < run_lengths[run] }); - labels.into_labeling() + if !fits { + return Err(corner_too_tight()); + } + let planes: Vec = bitten.into_iter().map(|(plane, _)| plane).collect(); + + let seed_frame = Frame::seed(boundary_points[0], start_tangents[0], plane); + let mut rings: Vec = + vec![build_miter_boundary(§ions, seed_frame, anchor, None)]; + let mut run_parts: Vec<(Vec, Vec)> = Vec::with_capacity(segments); + let mut carried = seed_frame; + for run in 0..segments { + let start_frame = carried.reorient(start_tangents[run]); + let (interior, end_frame) = propagate_run(&runs[run], start_frame, end_tangents[run]); + let end_miter = (run + 1 < segments).then(|| planes[run]); + rings.push(build_miter_boundary( + §ions, end_frame, anchor, end_miter, + )); + run_parts.push(skin_run( + §ions, + &interior, + start_frame, + boundary_points[run + 1], + &rings[run], + &rings[run + 1], + anchor, + )?); + carried = end_frame; + } + let rails: Vec> = run_parts.iter().map(|(rails, _)| rails.clone()).collect(); + let lateral: Vec> = run_parts.iter().map(|(_, faces)| faces.clone()).collect(); + + let base = builder::try_attach_plane(&[rings[0].section_edges.clone().into()]) + .map_err(|_| invalid(ProfileDefect::SelfIntersectingLoop))?; + let top = builder::try_attach_plane(&[rings[segments].section_edges.clone().into()]) + .map_err(|_| invalid(ProfileDefect::SelfIntersectingLoop))?; + + let labeling = label_mitered(sweep, §ions, &rings, &rails, &lateral, &base, &top); + + let mut faces: Vec = Vec::new(); + faces.push(base.inverse()); + faces.extend(lateral.iter().flat_map(|run| run.iter().cloned())); + faces.push(top.clone()); + let shell: Shell = faces.into(); + let mut solid = Solid::try_new(vec![shell]).map_err(|_| BrepError::ShellNotClosed)?; + if truck_solid_signed_volume( + &solid, + coarse_mesh_tolerance(boundary_points.iter().copied()), + ) < 0.0 + { + solid.not(); + } + let assembled = assemble(solid, &labeling)?; + assembled.validate(Tolerance::new(SKIN_VALIDATION_TOLERANCE_MM))?; + Ok(assembled) } -fn build_skinned_sweep( +const CLOSED_PIECES: u32 = 2; +const CLOSED_PIECE_STATIONS: u32 = 32; + +fn build_closed_sweep( sweep: FeatureId, profile: &ExtrudeProfile, - path: &Curve3Kind, - stations: StationCount, + composite: &CompositePath3, + feature: &SweepFeature, + rails: SweepRails<'_>, ) -> Result { + if feature.thin_wall.is_some() + || !rails.guides.is_empty() + || feature.twist.is_some() + || feature.orientation != SweepOrientation::FollowPath + || !matches!( + feature.alignment, + SweepAlignment::MinimumTwist | SweepAlignment::NonePlanar + ) + || composite.has_corner() + { + return Err(unsupported(TruckGap::SweepClosedPath)); + } resolve_loops(profile.loops())?; - let sections = profile_sections(profile).ok_or(unsupported(TruckGap::SweepProfileKind))?; - let frames = SweepFrames::new(profile, path, stations)?; - let corner_rails: Vec = sections - .iter() - .map(|section| frames.rail(section.spline.control[0])) - .collect::, _>>()?; - let parts = build_skin_shell(§ions, &corner_rails, &frames)?; - let labeling = label_skin(sweep, §ions, &parts); + let sections = profile_sections(profile).ok_or(unsupported(TruckGap::SweepMultiLoopProfile))?; + validate_miter_capacity(§ions, usize::try_from(CLOSED_PIECES).unwrap_or(0))?; + if !path_is_planar(composite) { + return Err(unsupported(TruckGap::SweepClosedPath)); + } + let pierce = pierce_fraction(profile, composite)?; + let anchor = profile.plane().project(composite.evaluate(pierce)); - let mut faces: Vec = Vec::with_capacity(sections.len() + 2); - faces.push(parts.base.inverse()); - faces.extend(parts.lateral.iter().cloned()); - faces.push(parts.top.clone()); + let spine = SweepPath3::new(composite, SampleCount::DEFAULT); + let ring_frame = |arc: f64| { + let at = ArcFraction::new(arc); + Frame::seed(spine.point_at(at), spine.tangent_at(at), profile.plane()) + }; + let boundary = |piece: u32| f64::from(piece) / f64::from(CLOSED_PIECES); + let rings: Vec = (0..CLOSED_PIECES) + .map(|piece| build_miter_boundary(§ions, ring_frame(boundary(piece)), anchor, None)) + .collect(); + + let parts: Vec<(Vec, Vec)> = (0..CLOSED_PIECES) + .map(|piece| { + let start = boundary(piece); + let end = boundary(piece + 1); + let start_frame = ring_frame(start); + let interior: Vec = (1..CLOSED_PIECE_STATIONS) + .map(|station| { + let fraction = f64::from(station) / f64::from(CLOSED_PIECE_STATIONS); + ring_frame((end - start).mul_add(fraction, start)) + }) + .collect(); + let end_point = spine.point_at(ArcFraction::new(end.min(1.0))); + let next = usize::try_from((piece + 1) % CLOSED_PIECES).unwrap_or(0); + let here = usize::try_from(piece).unwrap_or(0); + skin_run( + §ions, + &interior, + start_frame, + end_point, + &rings[here], + &rings[next], + anchor, + ) + }) + .collect::>()?; + let piece_rails: Vec> = parts.iter().map(|(rails, _)| rails.clone()).collect(); + let lateral: Vec> = parts.iter().map(|(_, faces)| faces.clone()).collect(); + + let labeling = label_closed(sweep, §ions, &rings, &piece_rails, &lateral); + let faces: Vec = lateral.iter().flat_map(|run| run.iter().cloned()).collect(); let shell: Shell = faces.into(); let mut solid = Solid::try_new(vec![shell]).map_err(|_| BrepError::ShellNotClosed)?; - if truck_solid_signed_volume(&solid, skin_mesh_tolerance(&corner_rails)) < 0.0 { + let tolerance = coarse_mesh_tolerance( + (0..=CLOSED_PIECES).map(|piece| spine.point_at(ArcFraction::new(boundary(piece).min(1.0)))), + ); + if truck_solid_signed_volume(&solid, tolerance) < 0.0 { solid.not(); } - let assembled = assemble(solid, &labeling)?; assembled.validate(Tolerance::new(SKIN_VALIDATION_TOLERANCE_MM))?; Ok(assembled) } -fn skin_mesh_tolerance(rails: &[BSpline3]) -> f64 { - let extent = rails - .iter() - .flat_map(|rail| rail.control().iter()) - .map(|point| { - let (x, y, z) = point.coords_mm(); - x.abs().max(y.abs()).max(z.abs()) - }) - .fold(0.0_f64, f64::max); - (extent * 1.0e-2).max(SKIN_VALIDATION_TOLERANCE_MM) +fn label_closed( + sweep: FeatureId, + sections: &[EdgeSection], + rings: &[MiterBoundary], + rails: &[Vec], + lateral: &[Vec], +) -> SolidLabeling { + let mut labels = Labels::new(sweep, HashSet::new()); + (0u8..).zip(lateral).for_each(|(piece, run)| { + (0u8..).zip(run).for_each(|(corner, face)| { + labels.face( + face.id(), + FaceRole::Side { + loop_index: LoopIndex::OUTER, + from: miter_id(sections[usize::from(corner)].segment, false, piece, corner), + }, + ); + }); + }); + (0u8..).zip(rails).for_each(|(piece, run)| { + (0u8..).zip(run).for_each(|(corner, rail)| { + labels.edge( + rail.id(), + EdgeRole::SideEdge { + from: miter_id(sections[usize::from(corner)].corner, true, piece, corner), + side: SideKind::Corner, + }, + ); + }); + }); + (0u8..).zip(rings).for_each(|(ring, boundary)| { + (0u8..) + .zip(&boundary.section_edges) + .for_each(|(corner, edge)| { + labels.edge( + edge.id(), + EdgeRole::SideEdge { + from: miter_id(sections[usize::from(corner)].segment, false, ring, corner), + side: SideKind::Corner, + }, + ); + }); + (0u8..) + .zip(&boundary.corner_vertices) + .for_each(|(corner, vertex)| { + labels.vertex( + vertex.id(), + VertexRole::StartCapVertex { + from: miter_id(sections[usize::from(corner)].corner, false, ring, corner), + side: SideKind::Corner, + }, + ); + }); + }); + labels.into_labeling() +} + +fn label_mitered( + sweep: FeatureId, + sections: &[EdgeSection], + boundaries: &[MiterBoundary], + rails: &[Vec], + lateral: &[Vec], + base: &Face, + top: &Face, +) -> SolidLabeling { + let last_ring = boundaries.len() - 1; + let mut labels = Labels::new(sweep, HashSet::new()); + labels.face(base.id(), FaceRole::StartCap); + labels.face(top.id(), FaceRole::EndCap); + (0u8..).zip(lateral).for_each(|(segment, run)| { + (0u8..).zip(run).for_each(|(corner, face)| { + labels.face( + face.id(), + FaceRole::Side { + loop_index: LoopIndex::OUTER, + from: miter_id( + sections[usize::from(corner)].segment, + false, + segment, + corner, + ), + }, + ); + }); + }); + (0u8..).zip(rails).for_each(|(segment, run)| { + (0u8..).zip(run).for_each(|(corner, rail)| { + labels.edge( + rail.id(), + EdgeRole::SideEdge { + from: miter_id(sections[usize::from(corner)].corner, true, segment, corner), + side: SideKind::Corner, + }, + ); + }); + }); + (0u8..).zip(boundaries).for_each(|(ring, boundary)| { + (0u8..) + .zip(&boundary.section_edges) + .for_each(|(corner, edge)| { + let from = miter_id(sections[usize::from(corner)].segment, false, ring, corner); + let role = if ring == 0 { + EdgeRole::StartCapEdge { from } + } else if usize::from(ring) == last_ring { + EdgeRole::EndCapEdge { from } + } else { + EdgeRole::SideEdge { + from, + side: SideKind::Corner, + } + }; + labels.edge(edge.id(), role); + }); + (0u8..) + .zip(&boundary.corner_vertices) + .for_each(|(corner, vertex)| { + let from = miter_id(sections[usize::from(corner)].corner, false, ring, corner); + let role = if usize::from(ring) == last_ring { + VertexRole::EndCapVertex { + from, + side: SideKind::Corner, + } + } else { + VertexRole::StartCapVertex { + from, + side: SideKind::Corner, + } + }; + labels.vertex(vertex.id(), role); + }); + }); + labels.into_labeling() } fn segment_is_axial(curve: Curve2Kind, frame: &AxisFrame) -> bool { @@ -1715,15 +3691,6 @@ enum Wall { Fillet, } -const WALL_TAG_OUTER: u64 = 1u64 << 62; -const WALL_TAG_INNER: u64 = 1u64 << 61; -const WALL_TAG_CAP_START: u64 = 1u64 << 60; -const WALL_TAG_CAP_END: u64 = 1u64 << 59; -const WALL_TAG_CLOSE: u64 = 1u64 << 58; -const WALL_TAG_FILLET: u64 = 1u64 << 57; -const WALL_TAG_MASK: u64 = - WALL_TAG_OUTER | WALL_TAG_INNER | WALL_TAG_CAP_START | WALL_TAG_CAP_END | WALL_TAG_CLOSE; - fn derive_entity(source: SketchEntityId, wall: Wall) -> SketchEntityId { let tag = match wall { Wall::Outer => WALL_TAG_OUTER, @@ -1813,10 +3780,7 @@ fn rounded_for_thin(profile_loop: &ProfileLoop, thin: ThinPlan) -> Result Result, BrepError> { - if thin.cap_ends { - return Err(unsupported(TruckGap::ThinWall)); - } +fn thin_walls(loops: &[ProfileLoop], thin: ThinPlan) -> Result, BrepError> { let [single] = loops else { return Err(unsupported(TruckGap::ThinWall)); }; @@ -1829,6 +3793,13 @@ fn augment_thin(loops: &[ProfileLoop], thin: ThinPlan) -> Result Result, BrepError> { + if thin.cap_ends { + return Err(unsupported(TruckGap::ThinWall)); + } + thin_walls(loops, thin) +} + fn fillet_corner( prev: Point2, vertex: Point2, @@ -3641,7 +5612,31 @@ fn revolve_vertex_labels( #[cfg(test)] mod skin_tests { - use super::{build_skinned_sweep, profile_sections}; + use super::{CompositePath3, FrameGuide, SkinPlan, build_skinned_sweep, profile_sections}; + + fn unguided(stations: StationCount, guide: FrameGuide, twist_rad: f64) -> SkinPlan<'static> { + SkinPlan { + stations, + guide, + twist_rad, + origin: bone_types::Parameter::new(0.0), + anchor: None, + guides: &[], + orientation: crate::sweep::SweepOrientation::FollowPath, + tangency: ( + crate::sweep::SweepTangency::None, + crate::sweep::SweepTangency::None, + ), + face_up: None, + } + } + + fn one(path: Curve3Kind) -> CompositePath3 { + match CompositePath3::chain(&[path]) { + Ok(composite) => composite, + Err(defect) => panic!("a single segment chains into a path: {defect:?}"), + } + } use crate::brep::BrepSolid; use crate::brep::profile::{ExtrudeProfile, ProfileEdge, ProfileLoop}; use crate::curve2::Curve2Kind; @@ -3749,7 +5744,12 @@ mod skin_tests { }; let path = Curve3Kind::Line(segment); let sweep = ids.features.insert(()); - let solid = match build_skinned_sweep(sweep, &profile, &path, StationCount::new(8)) { + let solid = match build_skinned_sweep( + sweep, + &profile, + &one(path), + &unguided(StationCount::new(8), FrameGuide::MinimumTwist, 0.0), + ) { Ok(solid) => solid, Err(error) => panic!("the skin builds a valid solid: {error:?}"), }; @@ -3800,7 +5800,12 @@ mod skin_tests { }; let path = Curve3Kind::Line(segment); let sweep = ids.features.insert(()); - let solid = match build_skinned_sweep(sweep, &profile, &path, StationCount::new(8)) { + let solid = match build_skinned_sweep( + sweep, + &profile, + &one(path), + &unguided(StationCount::new(8), FrameGuide::MinimumTwist, 0.0), + ) { Ok(solid) => solid, Err(error) => panic!("the arc-profile skin builds a valid solid: {error:?}"), }; @@ -3821,7 +5826,12 @@ mod skin_tests { }; let path = Curve3Kind::Line(segment); let sweep = ids.features.insert(()); - match build_skinned_sweep(sweep, &profile, &path, StationCount::new(stations)) { + match build_skinned_sweep( + sweep, + &profile, + &one(path), + &unguided(StationCount::new(stations), FrameGuide::MinimumTwist, 0.0), + ) { Ok(solid) => solid, Err(error) => panic!("the skin builds a valid solid: {error:?}"), } @@ -3897,7 +5907,12 @@ mod skin_tests { Curve3Kind::Arc(arc) }; let sweep = ids.features.insert(()); - let solid = match build_skinned_sweep(sweep, &profile, &arc, StationCount::new(64)) { + let solid = match build_skinned_sweep( + sweep, + &profile, + &one(arc), + &unguided(StationCount::new(64), FrameGuide::MinimumTwist, 0.0), + ) { Ok(solid) => solid, Err(error) => panic!("the arc skin builds a valid solid: {error:?}"), }; @@ -3909,6 +5924,69 @@ mod skin_tests { ); } + fn disc(ids: &mut Ids, cx: f64, cy: f64, radius: f64) -> ProfileLoop { + let Ok(circle) = crate::circle2::Circle2::new( + Point2::from_mm(cx, cy), + bone_types::Length::new::(radius), + TOL, + ) else { + panic!("positive radius"); + }; + ProfileLoop::Closed { + curve: Curve2Kind::Circle(circle), + curve_entity: ids.entities.insert(()), + } + } + + #[test] + fn holed_profile_skins_an_annular_tube_along_an_arc() { + let mut ids = Ids::new(); + let Ok(xz) = Plane3::new( + Point3::origin(), + UnitVec3::x_axis(), + UnitVec3::z_axis(), + TOL, + ) else { + panic!("orthonormal axes"); + }; + let rho = 2.5; + let outer_r = 0.5; + let inner_r = 0.25; + let profile = ExtrudeProfile::new( + xz, + vec![ + disc(&mut ids, rho, 0.0, outer_r), + disc(&mut ids, rho, 0.0, inner_r), + ], + ); + let Ok(arc) = crate::arc3::Arc3::new( + xy_plane(), + bone_types::Length::new::(rho), + bone_types::Angle::new::(0.0), + bone_types::Angle::new::(core::f64::consts::PI), + TOL, + ) else { + panic!("valid arc"); + }; + let sweep = ids.features.insert(()); + let solid = match build_skinned_sweep( + sweep, + &profile, + &one(Curve3Kind::Arc(arc)), + &unguided(StationCount::new(64), FrameGuide::MinimumTwist, 0.0), + ) { + Ok(solid) => solid, + Err(error) => panic!("the holed skin builds a valid solid: {error:?}"), + }; + let volume = mesh_volume(&solid).abs(); + let area = core::f64::consts::PI * outer_r.mul_add(outer_r, -(inner_r * inner_r)); + let expected = area * core::f64::consts::PI * rho; + assert!( + (volume - expected).abs() < 0.05, + "an annular tube swept a half turn matches Pappus {expected}: got {volume}" + ); + } + fn shifted_half_disc(ids: &mut Ids, radius: f64, dx: f64) -> ProfileLoop { let Ok(diameter) = Line2::new( Point2::from_mm(dx - radius, 0.0), @@ -3942,8 +6020,12 @@ mod skin_tests { fn curved_on_curved_skin(stations: u32) -> BrepSolid { let mut ids = Ids::new(); - let Ok(xz) = Plane3::new(Point3::origin(), UnitVec3::x_axis(), UnitVec3::z_axis(), TOL) - else { + let Ok(xz) = Plane3::new( + Point3::origin(), + UnitVec3::x_axis(), + UnitVec3::z_axis(), + TOL, + ) else { panic!("orthonormal axes"); }; let profile = ExtrudeProfile::new(xz, vec![shifted_half_disc(&mut ids, 0.4, 2.5)]); @@ -3957,8 +6039,12 @@ mod skin_tests { panic!("valid arc"); }; let sweep = ids.features.insert(()); - match build_skinned_sweep(sweep, &profile, &Curve3Kind::Arc(arc), StationCount::new(stations)) - { + match build_skinned_sweep( + sweep, + &profile, + &one(Curve3Kind::Arc(arc)), + &unguided(StationCount::new(stations), FrameGuide::MinimumTwist, 0.0), + ) { Ok(solid) => solid, Err(error) => panic!("the curved-section curved-path skin builds: {error:?}"), } diff --git a/crates/bone-kernel/src/brep/mod.rs b/crates/bone-kernel/src/brep/mod.rs index c43443d..da85225 100644 --- a/crates/bone-kernel/src/brep/mod.rs +++ b/crates/bone-kernel/src/brep/mod.rs @@ -22,6 +22,7 @@ pub mod profile; pub mod step; pub mod tessellate; use crate::curve3::Curve3; +use crate::sweep_surface::SweepDefect; use build::{Arena, BoundaryIndex, EdgeArenaHandle, edge_length, edge_points}; use convert::point_from_truck; pub use edges::{EdgeCurve3, EdgePolyline, EdgePolylines}; @@ -44,11 +45,13 @@ pub enum TruckGap { MidPlaneDirection2, RevolvePolePartial, RevolvePoleThin, - SweepGeneralPath, - SweepProfileKind, - SweepOrientation, - SweepProfileOffPath, - SweepThinWall, + SweepPathCorner, + SweepClosedPath, + SweepMultiLoopProfile, + SweepThinCap, + SweepGuideCount, + SweepSolidTool, + SweepSolidEnvelope, } #[derive(Debug, Clone, Copy, PartialEq, Eq)] @@ -99,11 +102,13 @@ impl core::fmt::Display for TruckGap { Self::MidPlaneDirection2 => "mid-plane revolve with a second direction", Self::RevolvePolePartial => "partial-angle revolve of an axis-touching profile", Self::RevolvePoleThin => "thin-wall revolve of an axis-touching profile", - Self::SweepGeneralPath => "sweep along a multi-segment or spline path", - Self::SweepProfileKind => "circular or solid sweep profile", - Self::SweepOrientation => "non-follow-path sweep orientation", - Self::SweepProfileOffPath => "sweep profile plane not perpendicular to the path start", - Self::SweepThinWall => "thin-wall sweep of an off-path profile", + Self::SweepPathCorner => "sweep along a path with a tangent break at a vertex", + Self::SweepClosedPath => "sweep along a closed multi-segment path", + Self::SweepMultiLoopProfile => "a multi-loop sweep profile", + Self::SweepThinCap => "capped thin-wall sweep on a curved path", + Self::SweepGuideCount => "more than two sweep guide curves", + Self::SweepSolidTool => "a sweep tool body that is not a plain cylinder", + Self::SweepSolidEnvelope => "a swept tool envelope beyond a coaxial straight line", }) } } @@ -156,10 +161,16 @@ impl core::fmt::Display for ProfileDefect { pub enum BrepError { #[error("extrude profile has {reason}")] InvalidProfile { reason: ProfileDefect }, + #[error("sweep has {reason}")] + InvalidSweep { reason: SweepDefect }, #[error("extrude depth is zero")] EmptyExtrudeDepth, #[error("revolve axis reference could not be resolved")] RevolveAxisUnresolved, + #[error("sweep direction-vector reference could not be resolved")] + SweepAlignmentUnresolved, + #[error("sweep tool body could not be resolved")] + SweepToolUnresolved, #[error("revolve axis does not lie in the profile plane")] RevolveAxisNotInPlane, #[error("revolve profile crosses its axis of revolution")] @@ -407,6 +418,15 @@ impl BrepSolid { self.arena.truck_face(face).and_then(build::revolution_axis) } + #[must_use] + pub fn nearest_face_normal(&self, point: Point3) -> Option<(bone_types::UnitVec3, f64)> { + let query = convert::point_to_truck(point); + self.iter_faces() + .flat_map(|face| self.arena.truck_faces_of(face.id())) + .filter_map(|truck| build::face_normal_and_bounded_distance(truck, query)) + .min_by(|a, b| a.1.total_cmp(&b.1)) + } + #[must_use] pub fn edge_line_axis(&self, edge: BrepEdgeId) -> Option { match self.edges.get(edge)?.curve { diff --git a/crates/bone-kernel/src/brep/tessellate.rs b/crates/bone-kernel/src/brep/tessellate.rs index d20081f..03148cb 100644 --- a/crates/bone-kernel/src/brep/tessellate.rs +++ b/crates/bone-kernel/src/brep/tessellate.rs @@ -4,16 +4,19 @@ use std::hash::{Hash, Hasher}; use serde::{Deserialize, Serialize}; use bone_types::{ - AngleTolerance, BrepFaceId, ChordHeightTolerance, FaceLabel, MeshGeneration, Point3, Tolerance, - UnitVec3, + AngleTolerance, BrepFaceId, ChordHeightTolerance, FaceLabel, FaceRole, MeshGeneration, Point3, + Tolerance, UnitVec3, }; use slotmap::Key; -use truck_meshalgo::prelude::PolygonMesh; +use truck_meshalgo::prelude::{BoundedSurface, ParameterDivision2D, PolygonMesh}; use truck_meshalgo::tessellation::RobustMeshableShape; -use truck_modeling::{Invertible, TOLERANCE}; +use truck_modeling::{ + Invertible, NurbsSurface, Point3 as TruckPoint3, Surface, TOLERANCE, Vector3 as TruckVector3, + Vector4, +}; -use super::BrepSolid; use super::convert::{point_from_truck, try_unit_from_truck}; +use super::{BrepFace, BrepSolid}; const UNIT_NORMAL_TOLERANCE: Tolerance = Tolerance::new(1.0e-9); @@ -103,6 +106,9 @@ pub(super) fn tessellate_solid( angle: AngleTolerance, ) -> Result { let tol = effective_tolerance(brep, chord, angle); + if let Some(mesh) = tessellate_skin(brep, tol, chord, angle) { + return Ok(mesh); + } let original = brep.arena.solid(); let meshed = original.robust_triangulation(tol); @@ -408,6 +414,420 @@ fn edge_key(a: u32, b: u32) -> (u32, u32) { if a < b { (a, b) } else { (b, a) } } +const SKIN_MERGE_TOLERANCE_MM: f64 = 1.0e-6; +const SKIN_MESH_TOLERANCE: Tolerance = Tolerance::new(1.0e-9); + +struct SkinSide { + face: BrepFaceId, + label: FaceLabel, + surface: NurbsSurface, + orientation: bool, +} + +struct SkinCap { + face: BrepFaceId, + label: FaceLabel, + normal: TruckVector3, + orientation: bool, +} + +fn tessellate_skin( + brep: &BrepSolid, + tol: f64, + chord: ChordHeightTolerance, + angle: AngleTolerance, +) -> Option { + let (sides, start_cap, end_cap) = classify_skin(brep)?; + let divided: Vec<(SkinSide, Vec, Vec)> = sides + .into_iter() + .map(|side| { + let (section_div, along_div) = side + .surface + .parameter_division(side.surface.range_tuple(), tol); + (side, section_div, along_div) + }) + .collect(); + let path_division = + merge_divisions(divided.iter().map(|(_, _, along_div)| along_div.as_slice()))?; + let (&v_start, &v_end) = (path_division.first()?, path_division.last()?); + + let section_row = |v: f64| -> Vec> { + divided + .iter() + .map(|(side, section_div, _)| { + section_div + .iter() + .map(|&u| side.surface.subs(u, v)) + .collect() + }) + .collect() + }; + let start_rows = section_row(v_start); + let end_rows = section_row(v_end); + + let mut slabs: Vec = divided + .iter() + .map(|(side, section_div, _)| build_side_slab(side, section_div, &path_division)) + .collect::>()?; + slabs.push(build_cap_slab(&start_cap, &start_rows)?); + slabs.push(build_cap_slab(&end_cap, &end_rows)?); + + let ordered = order_slabs(brep, slabs)?; + if !is_watertight(&ordered) { + return None; + } + let generation = derive_generation(&ordered, chord, angle); + Some(SolidMesh { + faces: ordered, + generation, + }) +} + +fn classify_skin(brep: &BrepSolid) -> Option<(Vec, SkinCap, SkinCap)> { + let (sides, start_cap, end_cap) = brep.iter_faces().try_fold( + (Vec::new(), None, None), + |(mut sides, start_cap, end_cap), face| { + let truck = brep.arena.truck_face(face.id())?; + match face.label().role { + FaceRole::Side { .. } => { + let Surface::NurbsSurface(surface) = truck.surface() else { + return None; + }; + sides.push(SkinSide { + face: face.id(), + label: face.label(), + surface, + orientation: truck.orientation(), + }); + Some((sides, start_cap, end_cap)) + } + FaceRole::StartCap if start_cap.is_none() => { + Some((sides, Some(planar_cap(face, truck)?), end_cap)) + } + FaceRole::EndCap if end_cap.is_none() => { + Some((sides, start_cap, Some(planar_cap(face, truck)?))) + } + FaceRole::StartCap | FaceRole::EndCap | FaceRole::Imported { .. } => None, + } + }, + )?; + (sides.len() >= 3).then_some((sides, start_cap?, end_cap?)) +} + +fn planar_cap(face: &BrepFace, truck: &truck_modeling::Face) -> Option { + let Surface::Plane(plane) = truck.surface() else { + return None; + }; + Some(SkinCap { + face: face.id(), + label: face.label(), + normal: plane.normal(), + orientation: truck.orientation(), + }) +} + +fn merge_divisions<'a>(rows: impl Iterator) -> Option> { + let mut all: Vec = rows.flat_map(|row| row.iter().copied()).collect(); + if all.len() < 2 { + return None; + } + all.sort_by(f64::total_cmp); + let span = all.last()? - all.first()?; + let eps = (span * 1.0e-7).max(1.0e-12); + let merged: Vec = all.into_iter().fold(Vec::new(), |mut acc, value| { + match acc.last() { + Some(&prev) if (value - prev).abs() <= eps => {} + _ => acc.push(value), + } + acc + }); + (merged.len() >= 2).then_some(merged) +} + +fn build_side_slab(side: &SkinSide, section_div: &[f64], along_div: &[f64]) -> Option { + let nu = section_div.len(); + let nv = along_div.len(); + if nu < 2 || nv < 2 { + return None; + } + let positions: Vec = section_div + .iter() + .flat_map(|&u| { + along_div + .iter() + .map(move |&v| point_from_truck(side.surface.subs(u, v))) + }) + .collect(); + let index = |i: usize, j: usize| i * nv + j; + let cells = (1..nu).flat_map(|i| (1..nv).map(move |j| (i, j))); + let triangles: Vec<[usize; 3]> = cells + .flat_map(|(i, j)| { + let a = index(i - 1, j - 1); + let b = index(i, j - 1); + let c = index(i, j); + let d = index(i - 1, j); + if side.orientation { + [[a, b, c], [a, c, d]] + } else { + [[a, c, b], [a, d, c]] + } + }) + .collect(); + let normals = facet_averaged_normals(&positions, &triangles)?; + let triangles: Vec<[u32; 3]> = triangles + .into_iter() + .map(|tri| { + Some([ + u32::try_from(tri[0]).ok()?, + u32::try_from(tri[1]).ok()?, + u32::try_from(tri[2]).ok()?, + ]) + }) + .collect::>()?; + Some(FaceMesh { + face: side.face, + label: side.label, + positions, + normals, + triangles, + }) +} + +fn facet_averaged_normals(positions: &[Point3], triangles: &[[usize; 3]]) -> Option> { + let accum = triangles.iter().fold( + vec![[0.0_f64; 3]; positions.len()], + |mut acc, &[a, b, c]| { + let [nx, ny, nz] = facet_normal(positions[a], positions[b], positions[c]); + [a, b, c].into_iter().for_each(|vertex| { + acc[vertex][0] += nx; + acc[vertex][1] += ny; + acc[vertex][2] += nz; + }); + acc + }, + ); + accum + .into_iter() + .map(|[x, y, z]| try_unit_from_truck(TruckVector3::new(x, y, z), UNIT_NORMAL_TOLERANCE)) + .collect() +} + +fn facet_normal(a: Point3, b: Point3, c: Point3) -> [f64; 3] { + let (ax, ay, az) = a.coords_mm(); + let (bx, by, bz) = b.coords_mm(); + let (cx, cy, cz) = c.coords_mm(); + let (ux, uy, uz) = (bx - ax, by - ay, bz - az); + let (vx, vy, vz) = (cx - ax, cy - ay, cz - az); + let normal = [uy * vz - uz * vy, uz * vx - ux * vz, ux * vy - uy * vx]; + let len = (normal[0] * normal[0] + normal[1] * normal[1] + normal[2] * normal[2]).sqrt(); + if len > 0.0 { + [normal[0] / len, normal[1] / len, normal[2] / len] + } else { + [0.0, 0.0, 0.0] + } +} + +fn build_cap_slab(cap: &SkinCap, rows: &[Vec]) -> Option { + let ring = assemble_ring(rows)?; + let outward = if cap.orientation { + cap.normal + } else { + -cap.normal + }; + let normal = try_unit_from_truck(outward, UNIT_NORMAL_TOLERANCE)?; + let (e1, e2) = plane_basis(outward)?; + let projected: Vec<(f64, f64)> = ring + .iter() + .map(|p| { + ( + p.x * e1.x + p.y * e1.y + p.z * e1.z, + p.x * e2.x + p.y * e2.y + p.z * e2.z, + ) + }) + .collect(); + let triangles: Vec<[u32; 3]> = ear_clip(&projected)? + .into_iter() + .map(|[a, b, c]| { + Some([ + u32::try_from(a).ok()?, + u32::try_from(b).ok()?, + u32::try_from(c).ok()?, + ]) + }) + .collect::>()?; + let positions: Vec = ring.iter().map(|p| point_from_truck(*p)).collect(); + let normals = vec![normal; positions.len()]; + Some(FaceMesh { + face: cap.face, + label: cap.label, + positions, + normals, + triangles, + }) +} + +fn assemble_ring(rows: &[Vec]) -> Option> { + if rows.iter().any(|row| row.len() < 2) { + return None; + } + let mut used = vec![false; rows.len()]; + used[0] = true; + let mut ring: Vec = rows[0].clone(); + while !used.iter().all(|flag| *flag) { + let tail = *ring.last()?; + let (matched, extension) = rows.iter().enumerate().find_map(|(k, row)| { + if used[k] { + return None; + } + let head = row.first()?; + let foot = row.last()?; + if near_point(*head, tail) { + Some((k, row.iter().skip(1).copied().collect::>())) + } else if near_point(*foot, tail) { + Some((k, row.iter().rev().skip(1).copied().collect::>())) + } else { + None + } + })?; + used[matched] = true; + ring.extend(extension); + } + if ring.len() >= 2 && near_point(*ring.first()?, *ring.last()?) { + ring.pop(); + } + (ring.len() >= 3).then_some(ring) +} + +fn ear_clip(poly: &[(f64, f64)]) -> Option> { + let n = poly.len(); + if n < 3 { + return None; + } + let mut remaining: Vec = if polygon_area(poly) >= 0.0 { + (0..n).collect() + } else { + (0..n).rev().collect() + }; + let mut triangles = Vec::with_capacity(n - 2); + while remaining.len() > 3 { + let m = remaining.len(); + let ear = (0..m).find(|&k| { + is_ear( + poly, + &remaining, + remaining[(k + m - 1) % m], + remaining[k], + remaining[(k + 1) % m], + ) + })?; + triangles.push([ + remaining[(ear + m - 1) % m], + remaining[ear], + remaining[(ear + 1) % m], + ]); + remaining.remove(ear); + } + triangles.push([remaining[0], remaining[1], remaining[2]]); + Some(triangles) +} + +fn polygon_area(poly: &[(f64, f64)]) -> f64 { + poly.iter().enumerate().fold(0.0, |acc, (i, &(x0, y0))| { + let (x1, y1) = poly[(i + 1) % poly.len()]; + acc + (x0 * y1 - x1 * y0) + }) / 2.0 +} + +fn is_ear(poly: &[(f64, f64)], remaining: &[usize], i0: usize, i1: usize, i2: usize) -> bool { + let a = poly[i0]; + let b = poly[i1]; + let c = poly[i2]; + let convex = (b.0 - a.0) * (c.1 - a.1) - (b.1 - a.1) * (c.0 - a.0); + if convex <= 0.0 { + return false; + } + !remaining + .iter() + .any(|&idx| idx != i0 && idx != i1 && idx != i2 && point_in_triangle(poly[idx], a, b, c)) +} + +fn point_in_triangle(p: (f64, f64), a: (f64, f64), b: (f64, f64), c: (f64, f64)) -> bool { + let cross = |u: (f64, f64), v: (f64, f64)| u.0 * v.1 - u.1 * v.0; + let s1 = cross((b.0 - a.0, b.1 - a.1), (p.0 - a.0, p.1 - a.1)); + let s2 = cross((c.0 - b.0, c.1 - b.1), (p.0 - b.0, p.1 - b.1)); + let s3 = cross((a.0 - c.0, a.1 - c.1), (p.0 - c.0, p.1 - c.1)); + (s1 >= 0.0 && s2 >= 0.0 && s3 >= 0.0) || (s1 <= 0.0 && s2 <= 0.0 && s3 <= 0.0) +} + +fn plane_basis(normal: TruckVector3) -> Option<(TruckVector3, TruckVector3)> { + let len = (normal.x * normal.x + normal.y * normal.y + normal.z * normal.z).sqrt(); + if len < 1.0e-12 { + return None; + } + let n = normal * (1.0 / len); + let helper = if n.x.abs() < 0.9 { + TruckVector3::new(1.0, 0.0, 0.0) + } else { + TruckVector3::new(0.0, 1.0, 0.0) + }; + let raw = cross3(n, helper); + let raw_len = (raw.x * raw.x + raw.y * raw.y + raw.z * raw.z).sqrt(); + if raw_len < 1.0e-12 { + return None; + } + let e1 = raw * (1.0 / raw_len); + Some((e1, cross3(n, e1))) +} + +fn cross3(a: TruckVector3, b: TruckVector3) -> TruckVector3 { + TruckVector3::new( + a.y * b.z - a.z * b.y, + a.z * b.x - a.x * b.z, + a.x * b.y - a.y * b.x, + ) +} + +fn near_point(a: TruckPoint3, b: TruckPoint3) -> bool { + let (dx, dy, dz) = (a.x - b.x, a.y - b.y, a.z - b.z); + (dx * dx + dy * dy + dz * dz).sqrt() <= SKIN_MERGE_TOLERANCE_MM +} + +fn order_slabs(brep: &BrepSolid, slabs: Vec) -> Option> { + let mut by_id: HashMap = + slabs.into_iter().map(|slab| (slab.face, slab)).collect(); + brep.iter_faces() + .map(|face| by_id.remove(&face.id())) + .collect() +} + +fn is_watertight(faces: &[FaceMesh]) -> bool { + if faces + .iter() + .any(|slab| validate_slab(slab, SKIN_MESH_TOLERANCE).is_err()) + { + return false; + } + let key = |point: &Point3| { + let (x, y, z) = point.coords_mm(); + (x.to_bits(), y.to_bits(), z.to_bits()) + }; + let mut counts: HashMap<[(u64, u64, u64); 2], u32> = HashMap::new(); + faces.iter().for_each(|slab| { + let positions = slab.positions(); + slab.triangles().iter().for_each(|tri| { + [(tri[0], tri[1]), (tri[1], tri[2]), (tri[2], tri[0])] + .into_iter() + .for_each(|(a, b)| { + let ka = key(&positions[a as usize]); + let kb = key(&positions[b as usize]); + let ordered = if ka <= kb { [ka, kb] } else { [kb, ka] }; + *counts.entry(ordered).or_insert(0) += 1; + }); + }); + }); + counts.values().all(|count| *count == 2) +} + #[cfg(test)] mod tests { use super::StableHasher; diff --git a/crates/bone-kernel/src/lib.rs b/crates/bone-kernel/src/lib.rs index 12c8e0f..bb14b43 100644 --- a/crates/bone-kernel/src/lib.rs +++ b/crates/bone-kernel/src/lib.rs @@ -46,8 +46,8 @@ pub use brep::boolean::ssi::{ }; pub use brep::boolean::{BooleanOp, boolean}; pub use brep::eval::{ - ExtrudeScene, RevolveScene, evaluate_extrude, evaluate_extrude_plan, evaluate_revolve, - evaluate_sweep, next_face_distance, resolve_revolve, resolve_sweep, + ExtrudeScene, RevolveScene, SweepRails, evaluate_extrude, evaluate_extrude_plan, + evaluate_revolve, evaluate_sweep, next_face_distance, resolve_revolve, resolve_sweep, }; pub use brep::localops::{ AnalyticPatch, BlendGap, BlendRadius, BlendSpine, FillContinuity, NormalSide, OffsetDegeneracy, @@ -95,8 +95,14 @@ pub use revolve::{ }; pub use sphere_surface::SphereSurface; pub use surface3::Surface3; -pub use sweep::{SweepFeature, SweepOrientation, SweepPath, SweepProfileKind}; -pub use sweep_surface::{ArcFraction, Frame, SampleCount, StationCount, SweepPath3, transport}; +pub use sweep::{ + SweepAlignment, SweepDirection, SweepFeature, SweepOrientation, SweepPath, SweepProfileKind, + SweepTangency, SweepTwist, TwistUnit, +}; +pub use sweep_surface::{ + ArcFraction, CompositePath3, Frame, FrameGuide, SampleCount, StationCount, SweepDefect, + SweepPath3, transport, transport_with, +}; pub use torus_surface::TorusSurface; #[derive(Debug, thiserror::Error)] diff --git a/crates/bone-kernel/src/sweep.rs b/crates/bone-kernel/src/sweep.rs index 7c6c1e4..a94cfba 100644 --- a/crates/bone-kernel/src/sweep.rs +++ b/crates/bone-kernel/src/sweep.rs @@ -1,5 +1,7 @@ use crate::extrude::{ExtrudeOperation, FeatureScope, ThinWall}; -use bone_types::{EdgeLabel, PositiveLength, SketchId}; +use crate::revolve::RevolveAxis; +use bone_types::dimensioned_serde; +use bone_types::{Angle, BodyRef, EdgeLabel, PositiveLength, SketchId, radian}; use serde::{Deserialize, Serialize}; #[derive(Copy, Clone, Debug, PartialEq, Serialize, Deserialize)] @@ -16,6 +18,9 @@ pub enum SweepProfileKind { Circular { diameter: PositiveLength, }, + Solid { + tool: BodyRef, + }, } #[derive(Copy, Clone, Debug, Default, PartialEq, Eq, Hash, Serialize, Deserialize)] @@ -23,18 +28,99 @@ pub enum SweepOrientation { #[default] FollowPath, KeepNormalConstant, + FollowPathAndFirstGuide, + FollowFirstAndSecondGuide, +} + +impl SweepOrientation { + #[must_use] + pub fn needs_guides(self) -> usize { + match self { + Self::FollowPath | Self::KeepNormalConstant => 0, + Self::FollowPathAndFirstGuide => 1, + Self::FollowFirstAndSecondGuide => 2, + } + } +} + +#[derive(Copy, Clone, Debug, Default, PartialEq, Eq, Hash, Serialize, Deserialize)] +pub enum SweepDirection { + #[default] + DirectionOne, + DirectionTwo, + Bidirectional, +} + +#[derive(Copy, Clone, Debug, Default, PartialEq, Eq, Hash, Serialize, Deserialize)] +pub enum SweepTangency { + #[default] + None, + PathTangent, +} + +#[derive(Copy, Clone, Debug, Default, PartialEq, Serialize, Deserialize)] +pub enum SweepAlignment { + #[default] + MinimumTwist, + NonePlanar, + DirectionVector { + axis: RevolveAxis, + }, + AllFaces, +} + +#[derive(Copy, Clone, Debug, Default, PartialEq, Eq, Hash, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum TwistUnit { + #[default] + Degrees, + Radians, + Revolutions, } #[derive(Copy, Clone, Debug, PartialEq, Serialize, Deserialize)] #[serde(deny_unknown_fields)] +pub struct SweepTwist { + #[serde(with = "dimensioned_serde::angle_si")] + pub angle: Angle, + #[serde(default)] + pub unit: TwistUnit, +} + +impl SweepTwist { + #[must_use] + pub fn new(angle: Angle, unit: TwistUnit) -> Self { + Self { angle, unit } + } + + #[must_use] + pub fn radians(self) -> f64 { + self.angle.get::() + } +} + +#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] +#[serde(deny_unknown_fields)] pub struct SweepFeature { pub profile: SketchId, pub path: SweepPath, + #[serde(default, skip_serializing_if = "Vec::is_empty")] + pub guides: Vec, #[serde(default)] pub profile_kind: SweepProfileKind, #[serde(default)] pub orientation: SweepOrientation, #[serde(default)] + pub direction: SweepDirection, + #[serde(default)] + pub start_tangency: SweepTangency, + #[serde(default)] + pub end_tangency: SweepTangency, + #[serde(default)] + pub alignment: SweepAlignment, + #[serde(default)] + pub twist: Option, + #[serde(default)] pub thin_wall: Option, #[serde(default)] pub flip_side: bool, @@ -50,8 +136,14 @@ impl SweepFeature { Self { profile, path, + guides: Vec::new(), profile_kind: SweepProfileKind::Sketch, orientation: SweepOrientation::FollowPath, + direction: SweepDirection::DirectionOne, + start_tangency: SweepTangency::None, + end_tangency: SweepTangency::None, + alignment: SweepAlignment::MinimumTwist, + twist: None, thin_wall: None, flip_side: false, operation: ExtrudeOperation::default(), @@ -67,29 +159,49 @@ mod tests { use slotmap::Key; #[test] - fn sweep_feature_ron_round_trip() { - let feature = SweepFeature::boss(SketchId::null(), SweepPath::Sketch(SketchId::null())); - let Ok(text) = ron::to_string(&feature) else { - panic!("serialize sweep feature"); - }; - let Ok(back) = ron::from_str::(&text) else { - panic!("deserialize sweep feature"); - }; - assert_eq!(feature, back); - } + fn sweep_feature_ron_round_trips_variants_and_fills_defaults() + -> Result<(), Box> { + use bone_types::{Angle, BodyOrdinal, BodyRef, FeatureId, radian}; + let base = || SweepFeature::boss(SketchId::null(), SweepPath::Sketch(SketchId::null())); + [ + base(), + SweepFeature { + orientation: super::SweepOrientation::KeepNormalConstant, + direction: super::SweepDirection::DirectionTwo, + twist: Some(super::SweepTwist::new( + Angle::new::(1.25), + super::TwistUnit::Revolutions, + )), + ..base() + }, + SweepFeature { + profile_kind: SweepProfileKind::Solid { + tool: BodyRef::new(FeatureId::default(), BodyOrdinal::new(3)), + }, + operation: crate::extrude::ExtrudeOperation::Cut, + ..base() + }, + ] + .into_iter() + .try_for_each(|feature| -> Result<(), Box> { + let text = ron::to_string(&feature)?; + let back = ron::from_str::(&text)?; + assert_eq!(feature, back); + Ok(()) + })?; - #[test] - fn feature_fills_defaults() { - let Ok(profile) = ron::to_string(&SketchId::null()) else { - panic!("serialize sketch id"); - }; + let profile = ron::to_string(&SketchId::null())?; let text = format!("(profile:{profile},path:Sketch({profile}))"); - let Ok(feature) = ron::from_str::(&text) else { - panic!("deserialize sweep feature with a defaulted tail in {text}"); - }; - assert_eq!(feature.profile_kind, SweepProfileKind::Sketch); - assert_eq!(feature.orientation, SweepOrientation::FollowPath); - assert_eq!(feature.thin_wall, None); - assert!(!feature.flip_side); + let defaulted = ron::from_str::(&text)?; + assert_eq!(defaulted.profile_kind, SweepProfileKind::Sketch); + assert_eq!(defaulted.orientation, SweepOrientation::FollowPath); + assert_eq!(defaulted.direction, super::SweepDirection::DirectionOne); + assert_eq!(defaulted.start_tangency, super::SweepTangency::None); + assert_eq!(defaulted.end_tangency, super::SweepTangency::None); + assert_eq!(defaulted.alignment, super::SweepAlignment::MinimumTwist); + assert_eq!(defaulted.twist, None); + assert_eq!(defaulted.thin_wall, None); + assert!(!defaulted.flip_side); + Ok(()) } }