Skip to main content

brepkit_operations/
sweep.rs

1//! Path sweep: sweep a profile along a NURBS curve.
2//!
3//! Creates a solid by moving a planar profile along an arbitrary NURBS curve
4//! path, keeping the profile perpendicular to the path tangent at each sample
5//! point. Uses rotation-minimizing frames (double-reflection method) to avoid
6//! Frenet-frame singularities on straight segments and inflection points.
7
8use brepkit_math::mat::Mat4;
9use brepkit_math::nurbs::curve::NurbsCurve;
10use brepkit_math::nurbs::surface_fitting::interpolate_surface;
11use brepkit_math::tolerance::Tolerance;
12use brepkit_math::vec::{Point3, Vec3};
13use brepkit_topology::Topology;
14use brepkit_topology::edge::{Edge, EdgeCurve};
15use brepkit_topology::face::{Face, FaceId, FaceSurface};
16use brepkit_topology::shell::Shell;
17use brepkit_topology::solid::{Solid, SolidId};
18use brepkit_topology::vertex::{Vertex, VertexId};
19use brepkit_topology::wire::{OrientedEdge, Wire};
20
21use crate::dot_normal_point;
22
23/// A coordinate frame at a point along the path.
24struct Frame {
25    origin: Point3,
26    tangent: Vec3,
27    up: Vec3,
28    right: Vec3,
29}
30
31/// Compute rotation-minimizing frames along a NURBS path.
32///
33/// Samples the path at evenly-spaced parameters across its own domain
34/// `[u_min, u_max]` and propagates the initial up-vector using the
35/// double-reflection method to produce smooth, twist-free frames. For open
36/// paths, produces `num_segments + 1` frames (domain start through domain
37/// end). For closed paths, produces `num_segments` frames, omitting the
38/// domain end since it duplicates the start.
39fn compute_frames(
40    path: &NurbsCurve,
41    num_segments: usize,
42    initial_up: Vec3,
43    is_closed: bool,
44) -> Result<Vec<Frame>, crate::OperationsError> {
45    let frame_count = if is_closed {
46        num_segments
47    } else {
48        num_segments + 1
49    };
50    let mut frames = Vec::with_capacity(frame_count);
51
52    // Sample within the curve's own domain: split sub-curves keep their
53    // parent's sub-range, and a clamped NURBS evaluated outside its domain
54    // extrapolates the end spans linearly.
55    let (u0, u1) = path.domain();
56
57    let t0 = path.tangent(u0)?;
58    let up0 = orthogonalize(initial_up, t0);
59    let right0 = t0.cross(up0);
60    frames.push(Frame {
61        origin: path.evaluate(u0),
62        tangent: t0,
63        up: up0,
64        right: right0,
65    });
66
67    // Propagate frames using the double-reflection method (Wang et al. 2008).
68    //
69    // Two reflections per step:
70    //   1. Reflect across the plane bisecting consecutive origins (position change).
71    //   2. Reflect across the plane bisecting the reflected tangent and new tangent.
72    let last_k = if is_closed {
73        num_segments - 1
74    } else {
75        num_segments
76    };
77    for k in 1..=last_k {
78        #[allow(clippy::cast_precision_loss)]
79        let t_param = u0 + (u1 - u0) * (k as f64) / (num_segments as f64);
80
81        let origin = path.evaluate(t_param);
82        let tangent = path.tangent(t_param)?;
83
84        let prev = &frames[k - 1];
85
86        // Reflection 1: across the plane bisecting the two consecutive origins.
87        let v1 = origin - prev.origin;
88        let c1 = v1.dot(v1);
89        let (up_l, tangent_l) = if c1 < 1e-30 {
90            (prev.up, prev.tangent)
91        } else {
92            let up_r = prev.up - v1 * (2.0 * v1.dot(prev.up) / c1);
93            let t_r = prev.tangent - v1 * (2.0 * v1.dot(prev.tangent) / c1);
94            (up_r, t_r)
95        };
96
97        // Reflection 2: across the plane bisecting the reflected tangent
98        // and the actual tangent at the new sample.
99        let v2 = tangent - tangent_l;
100        let c2 = v2.dot(v2);
101        let up = if c2 < 1e-30 {
102            orthogonalize(up_l, tangent)
103        } else {
104            let reflected = up_l - v2 * (2.0 * v2.dot(up_l) / c2);
105            orthogonalize(reflected, tangent)
106        };
107
108        let right = tangent.cross(up);
109        frames.push(Frame {
110            origin,
111            tangent,
112            up,
113            right,
114        });
115    }
116
117    Ok(frames)
118}
119
120/// The profile's own orthonormal basis `(right, up, tangent)` for mapping its 2D
121/// shape onto the path's perpendicular plane.
122///
123/// `tangent` is the profile normal, so a planar profile has ~zero tangent
124/// component and is mapped entirely into `right`/`up`; the profile is then swept
125/// perpendicular to the path regardless of how its plane was oriented relative
126/// to the path (an edge-on profile no longer collapses to a flat ribbon). For a
127/// profile already perpendicular to the path this equals the path frame's basis,
128/// leaving such sweeps unchanged.
129fn profile_basis(input_normal: Vec3) -> (Vec3, Vec3, Vec3) {
130    let tangent = input_normal
131        .normalize()
132        .unwrap_or_else(|_| Vec3::new(0.0, 0.0, 1.0));
133    let up = orthogonalize(pick_reference_axis(tangent), tangent);
134    let right = tangent.cross(up);
135    (right, up, tangent)
136}
137
138/// Project `v` to be perpendicular to `tangent`, then normalize.
139///
140/// Falls back to a world-axis-based vector if the projection is degenerate.
141fn orthogonalize(v: Vec3, tangent: Vec3) -> Vec3 {
142    let projected = v - tangent * tangent.dot(v);
143    projected.normalize().unwrap_or_else(|_| {
144        // Fallback: pick a world axis that isn't parallel to the tangent.
145        let candidate = if tangent.x().abs() < 0.9 {
146            Vec3::new(1.0, 0.0, 0.0)
147        } else {
148            Vec3::new(0.0, 1.0, 0.0)
149        };
150        let proj2 = candidate - tangent * tangent.dot(candidate);
151        // This should always succeed since candidate is chosen to not be
152        // parallel to tangent.
153        proj2.normalize().unwrap_or(Vec3::new(0.0, 0.0, 1.0))
154    })
155}
156
157/// How the profile is positioned relative to the path before sweeping.
158#[derive(Clone, Copy, PartialEq, Eq)]
159pub(crate) enum ProfilePlacement {
160    /// Sweep the profile from where it lies: the first ring reproduces the
161    /// profile exactly, and later rings are its rotation-minimizing-frame
162    /// transports along the path (the reference-kernel pipe semantic).
163    AsPositioned,
164    /// Translate the profile so its centroid lands on the path start and its
165    /// plane is re-oriented perpendicular to the path. Used by operations
166    /// whose API positions the profile itself (e.g. helical sweep).
167    CentroidOnPath,
168}
169
170/// Minimum |cos| between the profile normal and the path start tangent for the
171/// profile to count as deliberately placed perpendicular to the path.
172///
173/// An edge-on or oblique profile has no meaningful as-positioned sweep (its
174/// plane contains the path direction and the result collapses), so those fall
175/// back to the auto-orienting centroid placement.
176const PROFILE_PERP_MIN_COS: f64 = 0.99;
177
178/// Resolve `AsPositioned` to `CentroidOnPath` when the profile is not
179/// perpendicular to the path start tangent (see [`PROFILE_PERP_MIN_COS`]).
180pub(crate) fn resolve_placement(
181    placement: ProfilePlacement,
182    input_normal: Vec3,
183    path_tangent_0: Vec3,
184) -> ProfilePlacement {
185    match placement {
186        ProfilePlacement::AsPositioned
187            if input_normal.dot(path_tangent_0).abs() < PROFILE_PERP_MIN_COS =>
188        {
189            ProfilePlacement::CentroidOnPath
190        }
191        other => other,
192    }
193}
194
195/// Transform a profile vertex from its original position to a frame location.
196///
197/// The vertex's offset from `reference` is decomposed into the initial
198/// coordinate system (right, up, tangent), then reconstructed in the target
199/// frame. Including the tangent component ensures correct geometry even when
200/// the profile plane is not perpendicular to the initial path tangent.
201fn transform_point(
202    point: Point3,
203    reference: Point3,
204    initial_right: Vec3,
205    initial_up: Vec3,
206    initial_tangent: Vec3,
207    frame: &Frame,
208) -> Point3 {
209    let offset = point - reference;
210    let local_r = initial_right.dot(offset);
211    let local_u = initial_up.dot(offset);
212    let local_t = initial_tangent.dot(offset);
213    frame.origin + frame.right * local_r + frame.up * local_u + frame.tangent * local_t
214}
215
216/// Data from sweeping a single wire through frames.
217struct SweptWireData {
218    ring_verts: Vec<Vec<VertexId>>,
219    ring_edges: Vec<Vec<brepkit_topology::edge::EdgeId>>,
220    path_edges: Vec<Vec<brepkit_topology::edge::EdgeId>>,
221    n: usize,
222}
223
224/// Sweep a wire's vertices through the given frames, creating ring vertices,
225/// ring edges, and path edges.
226///
227/// `reference`, `initial_right/up/tangent` define the local coordinate system
228/// from which profile offsets are measured.
229#[allow(clippy::too_many_arguments)]
230fn sweep_wire_through_frames(
231    topo: &mut Topology,
232    wire_id: brepkit_topology::wire::WireId,
233    reference: Point3,
234    initial_right: Vec3,
235    initial_up: Vec3,
236    initial_tangent: Vec3,
237    frames: &[Frame],
238    num_segments: usize,
239    is_closed: bool,
240) -> Result<SweptWireData, crate::OperationsError> {
241    let tol = Tolerance::new();
242
243    let wire = topo.wire(wire_id)?;
244    let oriented: Vec<_> = wire.edges().to_vec();
245    let n = oriented.len();
246
247    let mut verts: Vec<VertexId> = Vec::with_capacity(n);
248    for oe in &oriented {
249        let edge = topo.edge(oe.edge())?;
250        let vid = oe.oriented_start(edge);
251        verts.push(vid);
252    }
253
254    let positions: Vec<Point3> = verts
255        .iter()
256        .map(|&vid| {
257            topo.vertex(vid)
258                .map(brepkit_topology::vertex::Vertex::point)
259        })
260        .collect::<Result<_, _>>()?;
261
262    let mut ring_verts: Vec<Vec<VertexId>> = Vec::with_capacity(num_segments + 1);
263    for frame in frames {
264        let ring: Vec<VertexId> = positions
265            .iter()
266            .map(|&pos| {
267                let transformed = transform_point(
268                    pos,
269                    reference,
270                    initial_right,
271                    initial_up,
272                    initial_tangent,
273                    frame,
274                );
275                topo.add_vertex(Vertex::new(transformed, tol.linear))
276            })
277            .collect();
278        ring_verts.push(ring);
279    }
280
281    // For closed paths, alias first ring as last so indexing works unchanged.
282    if is_closed {
283        ring_verts.push(ring_verts[0].clone());
284    }
285
286    let real_ring_count = if is_closed {
287        ring_verts.len() - 1
288    } else {
289        ring_verts.len()
290    };
291    let mut ring_edges: Vec<Vec<brepkit_topology::edge::EdgeId>> =
292        Vec::with_capacity(num_segments + 1);
293    for ring in &ring_verts[..real_ring_count] {
294        let edges: Vec<_> = (0..n)
295            .map(|i| {
296                let next = (i + 1) % n;
297                topo.add_edge(Edge::new(ring[i], ring[next], EdgeCurve::Line))
298            })
299            .collect();
300        ring_edges.push(edges);
301    }
302    if is_closed {
303        ring_edges.push(ring_edges[0].clone());
304    }
305
306    let mut path_edges: Vec<Vec<brepkit_topology::edge::EdgeId>> = Vec::with_capacity(num_segments);
307    for seg in 0..num_segments {
308        let edges: Vec<_> = (0..n)
309            .map(|i| {
310                topo.add_edge(Edge::new(
311                    ring_verts[seg][i],
312                    ring_verts[seg + 1][i],
313                    EdgeCurve::Line,
314                ))
315            })
316            .collect();
317        path_edges.push(edges);
318    }
319
320    Ok(SweptWireData {
321        ring_verts,
322        ring_edges,
323        path_edges,
324        n,
325    })
326}
327
328/// Build inward-facing side faces for an inner wire swept through frames.
329fn build_inner_side_faces(
330    topo: &mut Topology,
331    iwd: &SweptWireData,
332    num_segments: usize,
333) -> Result<Vec<FaceId>, crate::OperationsError> {
334    let mut faces = Vec::new();
335
336    for seg in 0..num_segments {
337        for i in 0..iwd.n {
338            let next_i = (i + 1) % iwd.n;
339
340            let p0 = topo.vertex(iwd.ring_verts[seg][i])?.point();
341            let p1 = topo.vertex(iwd.ring_verts[seg][next_i])?.point();
342            let p_next = topo.vertex(iwd.ring_verts[seg + 1][i])?.point();
343            let edge_dir = p1 - p0;
344            let path_dir = p_next - p0;
345            // Reversed normal (inward-facing).
346            let side_normal = path_dir
347                .cross(edge_dir)
348                .normalize()
349                .unwrap_or(Vec3::new(1.0, 0.0, 0.0));
350            let side_d = dot_normal_point(side_normal, p0);
351
352            // Reversed winding compared to outer side faces.
353            let side_wire = Wire::new(
354                vec![
355                    OrientedEdge::new(iwd.path_edges[seg][i], true),
356                    OrientedEdge::new(iwd.ring_edges[seg + 1][i], true),
357                    OrientedEdge::new(iwd.path_edges[seg][next_i], false),
358                    OrientedEdge::new(iwd.ring_edges[seg][i], false),
359                ],
360                true,
361            )
362            .map_err(crate::OperationsError::Topology)?;
363
364            let side_wire_id = topo.add_wire(side_wire);
365            let fid = topo.add_face(Face::new(
366                side_wire_id,
367                vec![],
368                FaceSurface::Plane {
369                    normal: side_normal,
370                    d: side_d,
371                },
372            ));
373            faces.push(fid);
374        }
375    }
376
377    Ok(faces)
378}
379
380/// Build inner wire loops for a cap face at the given ring index.
381fn build_inner_cap_wires(
382    topo: &mut Topology,
383    inner_data: &[SweptWireData],
384    ring_idx: usize,
385    reversed: bool,
386) -> Result<Vec<brepkit_topology::wire::WireId>, crate::OperationsError> {
387    let mut wires = Vec::new();
388    for iwd in inner_data {
389        let edges: Vec<OrientedEdge> = if reversed {
390            (0..iwd.n)
391                .rev()
392                .map(|i| OrientedEdge::new(iwd.ring_edges[ring_idx][i], false))
393                .collect()
394        } else {
395            (0..iwd.n)
396                .map(|i| OrientedEdge::new(iwd.ring_edges[ring_idx][i], true))
397                .collect()
398        };
399        let wire = Wire::new(edges, true).map_err(crate::OperationsError::Topology)?;
400        wires.push(topo.add_wire(wire));
401    }
402    Ok(wires)
403}
404
405/// Insert interior points into a polyline so no gap exceeds a small multiple
406/// of the typical (median) sample spacing.
407///
408/// A single global interpolating NURBS fit through unevenly-spaced points
409/// overshoots wildly where a long, sparsely-sampled span (e.g. a long straight
410/// spine edge sampled only at its endpoints) sits between densely-sampled
411/// high-curvature corners — chord-length parameterization does not prevent this
412/// when one span is orders of magnitude longer than its neighbours. Bounding
413/// the max gap keeps the fit well-conditioned. The threshold is derived from
414/// the median gap, so it is scale-invariant; interior insertion per segment is
415/// capped to avoid blow-up on pathological inputs.
416#[must_use]
417pub fn densify_path_points(points: &[Point3]) -> Vec<Point3> {
418    const GAP_RATIO: f64 = 4.0;
419    const MAX_INSERT_PER_SEGMENT: usize = 256;
420
421    if points.len() < 3 {
422        return points.to_vec();
423    }
424
425    let mut gaps: Vec<f64> = points
426        .windows(2)
427        .map(|w| (w[1] - w[0]).length())
428        .filter(|d| *d > 1e-12)
429        .collect();
430    if gaps.is_empty() {
431        return points.to_vec();
432    }
433    gaps.sort_by(|a, b| a.partial_cmp(b).unwrap_or(std::cmp::Ordering::Equal));
434    let median = gaps[gaps.len() / 2];
435    let max_gap = median * GAP_RATIO;
436    if max_gap <= 0.0 {
437        return points.to_vec();
438    }
439
440    let mut out: Vec<Point3> = Vec::with_capacity(points.len());
441    for w in points.windows(2) {
442        let (a, b) = (w[0], w[1]);
443        out.push(a);
444        let seg = (b - a).length();
445        if seg > max_gap {
446            #[allow(clippy::cast_possible_truncation, clippy::cast_sign_loss)]
447            let steps = ((seg / max_gap).ceil() as usize).min(MAX_INSERT_PER_SEGMENT);
448            for s in 1..steps {
449                #[allow(clippy::cast_precision_loss)]
450                let f = (s as f64) / (steps as f64);
451                out.push(a + (b - a) * f);
452            }
453        }
454    }
455    if let Some(&last) = points.last() {
456        out.push(last);
457    }
458    out
459}
460
461/// Interior samples used to confirm a path is a straight segment.
462const STRAIGHT_SAMPLES: usize = 8;
463
464/// Approximate the centroid of a planar profile's outer boundary by sampling
465/// its edges.
466///
467/// Sampling (rather than averaging stored vertices) is needed for a single
468/// closed circle, whose start and end vertex coincide at the seam — the seam
469/// point is not the center.
470fn profile_outer_centroid(
471    topo: &Topology,
472    profile: FaceId,
473) -> Result<Point3, crate::OperationsError> {
474    let face = topo.face(profile)?;
475    let wire = topo.wire(face.outer_wire())?;
476    let (mut sx, mut sy, mut sz) = (0.0_f64, 0.0_f64, 0.0_f64);
477    let mut count = 0_usize;
478    for oe in wire.edges() {
479        let edge = topo.edge(oe.edge())?;
480        let start = topo.vertex(edge.start())?.point();
481        let end = topo.vertex(edge.end())?.point();
482        let (t0, t1) = edge.curve().domain_with_endpoints(start, end);
483        // Four samples per edge span a closed circle's full sweep.
484        for k in 0..4 {
485            let t = t0 + (t1 - t0) * (f64::from(k) / 4.0);
486            let p = edge.curve().evaluate_with_endpoints(t, start, end);
487            sx += p.x();
488            sy += p.y();
489            sz += p.z();
490            count += 1;
491        }
492    }
493    if count == 0 {
494        return Err(crate::OperationsError::InvalidInput {
495            reason: "sweep profile has no outer-wire edges".into(),
496        });
497    }
498    #[allow(clippy::cast_precision_loss)]
499    let n = count as f64;
500    Ok(Point3::new(sx / n, sy / n, sz / n))
501}
502
503/// Fast exact path for a straight, perpendicular sweep: it is a prism, so
504/// delegate to [`crate::extrude::extrude`], which builds exact analytic side
505/// faces (a circular profile becomes a true cylinder matching π·r²·L). The
506/// general sweep inscribes a curved profile as a polygon and undercounts its
507/// volume by ~2% (gh #965).
508///
509/// Returns `Ok(None)` — fall back to the general sweep — when the path is not
510/// straight or the profile plane is not perpendicular to it (an oblique sweep
511/// is not a prism and has different semantics).
512fn try_straight_extrude(
513    topo: &mut Topology,
514    profile: FaceId,
515    path: &NurbsCurve,
516    placement: ProfilePlacement,
517) -> Result<Option<SolidId>, crate::OperationsError> {
518    let tol = Tolerance::new();
519
520    let normal = match topo.face(profile)?.surface() {
521        FaceSurface::Plane { normal, .. } => *normal,
522        _ => return Ok(None),
523    };
524
525    let start = path.evaluate(0.0);
526    let end = path.evaluate(1.0);
527    let chord = end - start;
528    let length = chord.length();
529    if length < tol.linear {
530        return Ok(None); // closed or degenerate path
531    }
532    let Ok(dir) = chord.normalize() else {
533        return Ok(None);
534    };
535
536    // Confirm straightness: every interior sample stays on the start→end line
537    // and advances monotonically along it.
538    for k in 1..STRAIGHT_SAMPLES {
539        #[allow(clippy::cast_precision_loss)]
540        let t = k as f64 / STRAIGHT_SAMPLES as f64;
541        let v = path.evaluate(t) - start;
542        let along = v.dot(dir);
543        let perp = (v - dir * along).length();
544        if perp > tol.linear * 100.0 || along < -tol.linear || along > length + tol.linear {
545            return Ok(None);
546        }
547    }
548
549    // The profile must be perpendicular to the path (normal parallel to the
550    // direction); an oblique profile is not a prism.
551    if normal.dot(dir).abs() < 1.0 - 1e-6 {
552        return Ok(None);
553    }
554
555    // Match the general sweep's frame[0] placement, then extrude: as-positioned
556    // sweeps extrude the profile in place; centroid placement first translates
557    // the profile's centroid onto the path start.
558    let moved = crate::copy::copy_face(topo, profile)?;
559    if placement == ProfilePlacement::CentroidOnPath {
560        let centroid = profile_outer_centroid(topo, profile)?;
561        let shift = start - centroid;
562        crate::transform::transform_face(
563            topo,
564            moved,
565            &Mat4::translation(shift.x(), shift.y(), shift.z()),
566        )?;
567    }
568
569    Ok(Some(crate::extrude::extrude(topo, moved, dir, length)?))
570}
571
572/// Sweep a face along a path curve to produce a solid.
573///
574/// The profile is swept from where it lies: the first section reproduces the
575/// profile exactly, and later sections are its rotation-minimizing-frame
576/// transports along the path, so the profile's position relative to the path
577/// is preserved (the reference-kernel pipe semantic). Side faces are planar
578/// quads connecting consecutive profile rings. The profile surface may be
579/// planar or curved — only its boundary is used; the end caps are filled from
580/// that boundary (a planar ring gets a `Plane` cap, a non-planar 4-sided ring
581/// a bilinear patch). An edge-on or oblique profile (plane not perpendicular
582/// to the path) is instead re-oriented onto the path via centroid placement.
583///
584/// # Errors
585///
586/// Returns an error if the path has fewer than 2 control points, a degenerate
587/// tangent is encountered, or a section boundary is non-planar with more than
588/// four edges or with holes (unsupported cap).
589pub fn sweep(
590    topo: &mut Topology,
591    profile: FaceId,
592    path: &NurbsCurve,
593) -> Result<SolidId, crate::OperationsError> {
594    sweep_placed(topo, profile, path, ProfilePlacement::AsPositioned)
595}
596
597#[allow(clippy::too_many_lines)]
598pub(crate) fn sweep_placed(
599    topo: &mut Topology,
600    profile: FaceId,
601    path: &NurbsCurve,
602    placement: ProfilePlacement,
603) -> Result<SolidId, crate::OperationsError> {
604    let tol = Tolerance::new();
605
606    if path.control_points().len() < 2 {
607        return Err(crate::OperationsError::InvalidInput {
608            reason: "sweep path must have at least 2 control points".into(),
609        });
610    }
611
612    // A straight perpendicular sweep is a prism — build it exactly via extrude.
613    if let Some(solid) = try_straight_extrude(topo, profile, path, placement)? {
614        return Ok(solid);
615    }
616
617    let face_data = topo.face(profile)?;
618    let input_wire_id = face_data.outer_wire();
619    let inner_wire_ids: Vec<brepkit_topology::wire::WireId> = face_data.inner_wires().to_vec();
620
621    let start_end_coincide = tol.approx_eq(
622        (path.evaluate(1.0) - path.evaluate(0.0)).length_squared(),
623        0.0,
624    );
625    let is_closed = if start_end_coincide {
626        // Distinguish closed loop (midpoint differs from start) from degenerate
627        // (all control points coincident, truly zero arc length).
628        let mid = path.evaluate(0.5);
629        let mid_dist_sq = (mid - path.evaluate(0.0)).length_squared();
630        if tol.approx_eq(mid_dist_sq, 0.0) {
631            return Err(crate::OperationsError::InvalidInput {
632                reason: "sweep path has zero length (start and end coincide)".into(),
633            });
634        }
635        true
636    } else {
637        false
638    };
639
640    let input_wire = topo.wire(input_wire_id)?;
641    let original_oriented: Vec<_> = input_wire.edges().to_vec();
642
643    if original_oriented.is_empty() {
644        return Err(crate::OperationsError::InvalidInput {
645            reason: "sweep profile has no edges".into(),
646        });
647    }
648
649    // Split closed edges (e.g. full circles) into multiple line segments
650    // so that the sweep can create proper side faces.
651    let input_oriented = crate::extrude::maybe_split_closed_wire(
652        topo,
653        &original_oriented,
654        tol.linear,
655        crate::extrude::DEFAULT_DEFLECTION,
656    )?;
657    let n = input_oriented.len();
658
659    let mut input_verts: Vec<VertexId> = Vec::with_capacity(n);
660    for oe in &input_oriented {
661        let edge = topo.edge(oe.edge())?;
662        let vid = oe.oriented_start(edge);
663        input_verts.push(vid);
664    }
665
666    let mut input_positions: Vec<Point3> = input_verts
667        .iter()
668        .map(|&vid| {
669            topo.vertex(vid)
670                .map(brepkit_topology::vertex::Vertex::point)
671        })
672        .collect::<Result<_, _>>()?;
673
674    // Up-hint for the rotation-minimizing frame: the section boundary's own
675    // normal (Newell), which works for planar and non-planar profiles alike —
676    // a profile's stored surface is no longer required to be a plane.
677    let mut input_normal = crate::winding::newell_normal(&input_positions)
678        .normalize()
679        .unwrap_or(Vec3::new(0.0, 0.0, 1.0));
680
681    // Ensure CCW winding relative to the path direction at t=0.
682    // CW-wound profiles (e.g. from brepjs) make `edge_dir.cross(path_dir)` point
683    // inward instead of outward, producing inside-out side faces.
684    let path_tangent_0 = path.tangent(0.0)?;
685    if crate::winding::ensure_ccw_positions(&mut input_positions, path_tangent_0) {
686        // Positions were reversed → boundary normal flips with them.
687        input_normal = -input_normal;
688    }
689
690    let num_segments = (path.control_points().len() * 2).max(4);
691
692    // Seed the first frame's up-vector from the profile normal, projected
693    // perpendicular to the path tangent at t=0.
694    let up_hint = orthogonalize(input_normal, path_tangent_0);
695
696    let frames = compute_frames(path, num_segments, up_hint, is_closed)?;
697
698    // The decomposition basis and reference point pick the placement semantic:
699    // frame-0's own basis and origin make ring 0 the identity map (profile
700    // swept as positioned); the profile's basis with its centroid re-centers
701    // and re-orients the profile onto the path.
702    let (reference, initial_right, initial_up, initial_tangent) =
703        match resolve_placement(placement, input_normal, path_tangent_0) {
704            ProfilePlacement::AsPositioned => (
705                frames[0].origin,
706                frames[0].right,
707                frames[0].up,
708                frames[0].tangent,
709            ),
710            ProfilePlacement::CentroidOnPath => {
711                let (r, u, t) = profile_basis(input_normal);
712                (crate::winding::polygon_centroid(&input_positions), r, u, t)
713            }
714        };
715
716    // ring_verts[k][i] = vertex at path sample k, profile vertex i.
717    // For closed paths, frames has num_segments entries; we append a copy
718    // of the first ring so indexing ring_verts[num_segments] works unchanged.
719    let mut ring_verts: Vec<Vec<VertexId>> = Vec::with_capacity(num_segments + 1);
720
721    for frame in &frames {
722        let ring: Vec<VertexId> = input_positions
723            .iter()
724            .map(|&pos| {
725                let transformed = transform_point(
726                    pos,
727                    reference,
728                    initial_right,
729                    initial_up,
730                    initial_tangent,
731                    frame,
732                );
733                topo.add_vertex(Vertex::new(transformed, tol.linear))
734            })
735            .collect();
736        ring_verts.push(ring);
737    }
738
739    // For closed paths, alias the first ring as the "last" so that
740    // ring_verts[num_segments] == ring_verts[0] by vertex ID.
741    if is_closed {
742        ring_verts.push(ring_verts[0].clone());
743    }
744
745    // ring_edges[k][i] = edge from ring_verts[k][i] to ring_verts[k][(i+1)%n].
746    let real_ring_count = if is_closed {
747        num_segments
748    } else {
749        num_segments + 1
750    };
751    let mut ring_edges: Vec<Vec<brepkit_topology::edge::EdgeId>> =
752        Vec::with_capacity(num_segments + 1);
753    for ring in &ring_verts[..real_ring_count] {
754        let edges: Vec<_> = (0..n)
755            .map(|i| {
756                let next = (i + 1) % n;
757                topo.add_edge(Edge::new(ring[i], ring[next], EdgeCurve::Line))
758            })
759            .collect();
760        ring_edges.push(edges);
761    }
762    // For closed paths, alias the first ring's edges as the last ring's edges.
763    if is_closed {
764        ring_edges.push(ring_edges[0].clone());
765    }
766
767    // path_edges[seg][i] = edge from ring_verts[seg][i] to ring_verts[seg+1][i].
768    let mut path_edges: Vec<Vec<brepkit_topology::edge::EdgeId>> = Vec::with_capacity(num_segments);
769    for seg in 0..num_segments {
770        let edges: Vec<_> = (0..n)
771            .map(|i| {
772                topo.add_edge(Edge::new(
773                    ring_verts[seg][i],
774                    ring_verts[seg + 1][i],
775                    EdgeCurve::Line,
776                ))
777            })
778            .collect();
779        path_edges.push(edges);
780    }
781
782    let mut inner_swept: Vec<SweptWireData> = Vec::new();
783    for &iw_id in &inner_wire_ids {
784        inner_swept.push(sweep_wire_through_frames(
785            topo,
786            iw_id,
787            reference,
788            initial_right,
789            initial_up,
790            initial_tangent,
791            &frames,
792            num_segments,
793            is_closed,
794        )?);
795    }
796
797    let mut all_faces = Vec::with_capacity(num_segments * n + if is_closed { 0 } else { 2 });
798
799    // Start cap (open paths only — closed paths have no caps).
800    if !is_closed {
801        let start_inner_wires = build_inner_cap_wires(topo, &inner_swept, 0, true)?;
802        let start_verts = crate::cap::ring_point_positions(topo, &ring_verts[0])?;
803        let outward = crate::cap::outward_normal(&start_verts, -frames[0].tangent)?;
804        all_faces.push(crate::cap::build_cap_face(
805            topo,
806            &ring_edges[0],
807            start_inner_wires,
808            &start_verts,
809            outward,
810            true,
811        )?);
812    }
813
814    // Side faces: one quad per profile-edge × path-segment.
815    // Winding: ring_edge[seg][i](fwd) → path_edge[seg][next_i](fwd) →
816    //          ring_edge[seg+1][i](rev) → path_edge[seg][i](rev).
817    for seg in 0..num_segments {
818        for i in 0..n {
819            let next_i = (i + 1) % n;
820
821            let p0 = topo.vertex(ring_verts[seg][i])?.point();
822            let p1 = topo.vertex(ring_verts[seg][next_i])?.point();
823            let p_next = topo.vertex(ring_verts[seg + 1][i])?.point();
824            let edge_dir = p1 - p0;
825            let path_dir = p_next - p0;
826            let side_normal = edge_dir
827                .cross(path_dir)
828                .normalize()
829                .unwrap_or(Vec3::new(1.0, 0.0, 0.0));
830            let side_d = dot_normal_point(side_normal, p0);
831
832            let side_wire = Wire::new(
833                vec![
834                    OrientedEdge::new(ring_edges[seg][i], true),
835                    OrientedEdge::new(path_edges[seg][next_i], true),
836                    OrientedEdge::new(ring_edges[seg + 1][i], false),
837                    OrientedEdge::new(path_edges[seg][i], false),
838                ],
839                true,
840            )
841            .map_err(crate::OperationsError::Topology)?;
842
843            let side_wire_id = topo.add_wire(side_wire);
844            let side_face = topo.add_face(Face::new(
845                side_wire_id,
846                vec![],
847                FaceSurface::Plane {
848                    normal: side_normal,
849                    d: side_d,
850                },
851            ));
852            all_faces.push(side_face);
853        }
854    }
855
856    for iwd in &inner_swept {
857        let inner_faces = build_inner_side_faces(topo, iwd, num_segments)?;
858        all_faces.extend(inner_faces);
859    }
860
861    // End cap (open paths only).
862    if !is_closed {
863        let end_inner_wires = build_inner_cap_wires(topo, &inner_swept, num_segments, false)?;
864        let end_verts = crate::cap::ring_point_positions(topo, &ring_verts[num_segments])?;
865        let outward = crate::cap::outward_normal(&end_verts, frames[num_segments].tangent)?;
866        all_faces.push(crate::cap::build_cap_face(
867            topo,
868            &ring_edges[num_segments],
869            end_inner_wires,
870            &end_verts,
871            outward,
872            false,
873        )?);
874    }
875
876    let shell = Shell::new(all_faces).map_err(crate::OperationsError::Topology)?;
877    let shell_id = topo.add_shell(shell);
878    let solid = topo.add_solid(Solid::new(shell_id, vec![]));
879
880    Ok(solid)
881}
882
883/// Sweep a face along a path with smooth NURBS side surfaces.
884///
885/// Like [`sweep`], but produces a single NURBS surface per edge strip
886/// instead of `N` flat quads. The side surfaces interpolate through all
887/// ring positions using tensor-product surface fitting, giving smooth
888/// geometry that tessellates to arbitrary quality.
889///
890/// This produces `n + 2` faces (n NURBS sides + 2 caps) instead of
891/// `num_segments × n + 2` flat faces, making the topology significantly
892/// more compact while improving geometric quality.
893///
894/// The profile surface may be planar or curved — only its boundary is used; the
895/// end caps are filled from that boundary (planar ring → `Plane`, non-planar
896/// 4-edge ring → bilinear patch).
897///
898/// # Errors
899///
900/// Returns an error if the path has fewer than 2 control points, surface
901/// fitting fails, or a section boundary is non-planar with more than four edges
902/// or with holes (unsupported cap).
903#[allow(clippy::too_many_lines)]
904pub fn sweep_smooth(
905    topo: &mut Topology,
906    profile: FaceId,
907    path: &NurbsCurve,
908) -> Result<SolidId, crate::OperationsError> {
909    let tol = Tolerance::new();
910
911    if path.control_points().len() < 2 {
912        return Err(crate::OperationsError::InvalidInput {
913            reason: "sweep path must have at least 2 control points".into(),
914        });
915    }
916
917    let face_data = topo.face(profile)?;
918    let input_wire_id = face_data.outer_wire();
919    let inner_wire_ids_smooth: Vec<brepkit_topology::wire::WireId> =
920        face_data.inner_wires().to_vec();
921
922    // Detect closed vs degenerate paths.
923    let start_end_coincide_smooth = tol.approx_eq(
924        (path.evaluate(1.0) - path.evaluate(0.0)).length_squared(),
925        0.0,
926    );
927    let is_closed = if start_end_coincide_smooth {
928        let mid = path.evaluate(0.5);
929        let mid_dist_sq = (mid - path.evaluate(0.0)).length_squared();
930        if tol.approx_eq(mid_dist_sq, 0.0) {
931            return Err(crate::OperationsError::InvalidInput {
932                reason: "sweep path has zero length".into(),
933            });
934        }
935        true
936    } else {
937        false
938    };
939
940    let input_wire = topo.wire(input_wire_id)?;
941    let original_oriented: Vec<_> = input_wire.edges().to_vec();
942
943    if original_oriented.is_empty() {
944        return Err(crate::OperationsError::InvalidInput {
945            reason: "sweep profile has no edges".into(),
946        });
947    }
948
949    let input_oriented = crate::extrude::maybe_split_closed_wire(
950        topo,
951        &original_oriented,
952        tol.linear,
953        crate::extrude::DEFAULT_DEFLECTION,
954    )?;
955    let n = input_oriented.len();
956
957    let mut input_verts: Vec<VertexId> = Vec::with_capacity(n);
958    for oe in &input_oriented {
959        let edge = topo.edge(oe.edge())?;
960        let vid = oe.oriented_start(edge);
961        input_verts.push(vid);
962    }
963
964    let mut input_positions: Vec<Point3> = input_verts
965        .iter()
966        .map(|&vid| {
967            topo.vertex(vid)
968                .map(brepkit_topology::vertex::Vertex::point)
969        })
970        .collect::<Result<_, _>>()?;
971
972    // Profile normal from the section boundary (planar or non-planar alike); the
973    // gate that required a planar surface is gone.
974    let mut input_normal = crate::winding::newell_normal(&input_positions)
975        .normalize()
976        .unwrap_or(Vec3::new(0.0, 0.0, 1.0));
977
978    // Ensure CCW winding relative to path direction (same fix as sweep()).
979    let path_tangent_0 = path.tangent(0.0)?;
980    if crate::winding::ensure_ccw_positions(&mut input_positions, path_tangent_0) {
981        input_normal = -input_normal;
982    }
983
984    let num_segments = (path.control_points().len() * 2).max(4);
985    let up_hint = orthogonalize(input_normal, path_tangent_0);
986    let frames = compute_frames(path, num_segments, up_hint, is_closed)?;
987
988    // As-positioned placement (see `sweep`): frame-0's basis and origin make
989    // ring 0 the identity map, so the profile is swept from where it lies.
990    // Edge-on/oblique profiles fall back to the auto-orienting placement.
991    let (reference, initial_right, initial_up, initial_tangent) =
992        match resolve_placement(ProfilePlacement::AsPositioned, input_normal, path_tangent_0) {
993            ProfilePlacement::AsPositioned => (
994                frames[0].origin,
995                frames[0].right,
996                frames[0].up,
997                frames[0].tangent,
998            ),
999            ProfilePlacement::CentroidOnPath => {
1000                let (r, u, t) = profile_basis(input_normal);
1001                (crate::winding::polygon_centroid(&input_positions), r, u, t)
1002            }
1003        };
1004
1005    // Compute all ring positions (without allocating vertices yet).
1006    let num_rings = frames.len();
1007    let ring_positions: Vec<Vec<Point3>> = frames
1008        .iter()
1009        .map(|frame| {
1010            input_positions
1011                .iter()
1012                .map(|&pos| {
1013                    transform_point(
1014                        pos,
1015                        reference,
1016                        initial_right,
1017                        initial_up,
1018                        initial_tangent,
1019                        frame,
1020                    )
1021                })
1022                .collect()
1023        })
1024        .collect();
1025
1026    // For closed paths, delegate to non-smooth sweep which already handles
1027    // closed topology properly. Smooth closed sweep (periodic NURBS surfaces)
1028    // is more complex and can be added later.
1029    if is_closed {
1030        return sweep(topo, profile, path);
1031    }
1032
1033    // Create vertices for first and last rings only (for edge topology).
1034    let first_ring: Vec<VertexId> = ring_positions[0]
1035        .iter()
1036        .map(|&p| topo.add_vertex(Vertex::new(p, tol.linear)))
1037        .collect();
1038    let last_ring: Vec<VertexId> = ring_positions[num_rings - 1]
1039        .iter()
1040        .map(|&p| topo.add_vertex(Vertex::new(p, tol.linear)))
1041        .collect();
1042
1043    let first_ring_edges: Vec<_> = (0..n)
1044        .map(|i| {
1045            let next = (i + 1) % n;
1046            topo.add_edge(Edge::new(first_ring[i], first_ring[next], EdgeCurve::Line))
1047        })
1048        .collect();
1049    let last_ring_edges: Vec<_> = (0..n)
1050        .map(|i| {
1051            let next = (i + 1) % n;
1052            topo.add_edge(Edge::new(last_ring[i], last_ring[next], EdgeCurve::Line))
1053        })
1054        .collect();
1055
1056    let mut inner_swept_smooth: Vec<SweptWireData> = Vec::new();
1057    for &iw_id in &inner_wire_ids_smooth {
1058        inner_swept_smooth.push(sweep_wire_through_frames(
1059            topo,
1060            iw_id,
1061            reference,
1062            initial_right,
1063            initial_up,
1064            initial_tangent,
1065            &frames,
1066            num_segments,
1067            is_closed,
1068        )?);
1069    }
1070
1071    let mut all_faces = Vec::with_capacity(n + 2);
1072
1073    let start_inner_wires_smooth = build_inner_cap_wires(topo, &inner_swept_smooth, 0, true)?;
1074    let start_outward = crate::cap::outward_normal(&ring_positions[0], -frames[0].tangent)?;
1075    all_faces.push(crate::cap::build_cap_face(
1076        topo,
1077        &first_ring_edges,
1078        start_inner_wires_smooth,
1079        &ring_positions[0],
1080        start_outward,
1081        true,
1082    )?);
1083
1084    // NURBS side faces: one surface per edge index spanning all rings.
1085    let degree_u = (num_rings - 1).min(3);
1086    let degree_v = 1;
1087
1088    // Rail edges, one per profile vertex (the swept-vertex path from the first
1089    // to the last ring), shared between the two adjacent side faces so the shell
1090    // is manifold.
1091    let rail_edges: Vec<_> = (0..n)
1092        .map(|i| topo.add_edge(Edge::new(first_ring[i], last_ring[i], EdgeCurve::Line)))
1093        .collect();
1094
1095    for i in 0..n {
1096        let next_i = (i + 1) % n;
1097
1098        // Build interpolation grid: rings × 2 (edge endpoints).
1099        let grid: Vec<Vec<Point3>> = (0..num_rings)
1100            .map(|k| vec![ring_positions[k][i], ring_positions[k][next_i]])
1101            .collect();
1102
1103        let surface =
1104            interpolate_surface(&grid, degree_u, degree_v).map_err(crate::OperationsError::Math)?;
1105
1106        // The surface normal ∂u×∂v = path×edge points *into* the swept body for
1107        // a CCW profile; reverse the face when it opposes the geometric outward
1108        // so the solid's faces are consistently outward. Probe everything at the
1109        // start ring (u=0), mid-edge (v=0.5): the edge tangent crossed with the
1110        // local path direction *at the edge midpoint*. If they are parallel (the
1111        // edge runs along the path) the cross product vanishes, so fall back to
1112        // the radial direction from the ring centroid.
1113        let mid0 = ring_positions[0][i] + (ring_positions[0][next_i] - ring_positions[0][i]) * 0.5;
1114        let mid1 = ring_positions[1][i] + (ring_positions[1][next_i] - ring_positions[1][i]) * 0.5;
1115        let edge_dir = ring_positions[0][next_i] - ring_positions[0][i];
1116        let mut outward = edge_dir.cross(mid1 - mid0);
1117        if outward.length() < tol.linear {
1118            outward = mid0 - crate::winding::polygon_centroid(&ring_positions[0]);
1119        }
1120        let reversed = surface
1121            .normal(0.0, 0.5)
1122            .map(|nrm| nrm.dot(outward) < 0.0)
1123            .unwrap_or(false);
1124
1125        // A reversed face flips every edge's effective traversal, so its wire
1126        // must be built with reversed winding or the face traverses the ring
1127        // edges in the same effective sense as the caps.
1128        let side_wire = if reversed {
1129            Wire::new(
1130                vec![
1131                    OrientedEdge::new(rail_edges[i], true),
1132                    OrientedEdge::new(last_ring_edges[i], true),
1133                    OrientedEdge::new(rail_edges[next_i], false),
1134                    OrientedEdge::new(first_ring_edges[i], false),
1135                ],
1136                true,
1137            )
1138        } else {
1139            Wire::new(
1140                vec![
1141                    OrientedEdge::new(first_ring_edges[i], true),
1142                    OrientedEdge::new(rail_edges[next_i], true),
1143                    OrientedEdge::new(last_ring_edges[i], false),
1144                    OrientedEdge::new(rail_edges[i], false),
1145                ],
1146                true,
1147            )
1148        }
1149        .map_err(crate::OperationsError::Topology)?;
1150
1151        let side_wire_id = topo.add_wire(side_wire);
1152        let mut face = Face::new(side_wire_id, vec![], FaceSurface::Nurbs(surface));
1153        if reversed {
1154            face.set_reversed(true);
1155        }
1156        all_faces.push(topo.add_face(face));
1157    }
1158
1159    for iwd in &inner_swept_smooth {
1160        let inner_faces = build_inner_side_faces(topo, iwd, num_segments)?;
1161        all_faces.extend(inner_faces);
1162    }
1163
1164    let end_inner_wires_smooth =
1165        build_inner_cap_wires(topo, &inner_swept_smooth, num_segments, false)?;
1166    let end_outward =
1167        crate::cap::outward_normal(&ring_positions[num_rings - 1], frames[num_segments].tangent)?;
1168    all_faces.push(crate::cap::build_cap_face(
1169        topo,
1170        &last_ring_edges,
1171        end_inner_wires_smooth,
1172        &ring_positions[num_rings - 1],
1173        end_outward,
1174        false,
1175    )?);
1176
1177    let shell = Shell::new(all_faces).map_err(crate::OperationsError::Topology)?;
1178    let shell_id = topo.add_shell(shell);
1179    Ok(topo.add_solid(Solid::new(shell_id, vec![])))
1180}
1181
1182/// Contact mode for advanced sweep operations.
1183///
1184/// Determines how the profile is oriented as it moves along the path.
1185#[derive(Debug, Clone, Copy, Default)]
1186pub enum SweepContactMode {
1187    /// Rotation-minimizing frames (default, twist-free).
1188    #[default]
1189    RotationMinimizing,
1190    /// Fixed orientation: profile does not rotate along the path.
1191    Fixed,
1192    /// Profile normal stays aligned to a given direction.
1193    ConstantNormal(Vec3),
1194}
1195
1196/// Corner handling mode for sweep operations.
1197///
1198/// When a path has sharp corners (tangent discontinuities), this controls
1199/// how the swept solid handles the transition between path segments.
1200#[derive(Debug, Clone, Copy, Default)]
1201pub enum SweepCornerMode {
1202    /// Smooth interpolation through corners (default behavior).
1203    /// May produce self-intersections at sharp corners.
1204    #[default]
1205    Smooth,
1206    /// Miter joints at sharp corners.
1207    /// Each path segment is swept independently, and adjacent segments
1208    /// are joined by miter faces on the bisector plane of the two
1209    /// tangent directions. Produces clean geometry at sharp turns.
1210    Miter,
1211    /// At each kink, insert a smooth fillet blend by rotating the profile
1212    /// through the turn angle in small angular steps.
1213    Round,
1214}
1215
1216/// Options for advanced sweep operations.
1217#[derive(Default)]
1218pub struct SweepOptions {
1219    /// Contact mode for profile orientation.
1220    pub contact_mode: SweepContactMode,
1221    /// Corner handling mode for path kinks.
1222    pub corner_mode: SweepCornerMode,
1223    /// Scale function: maps path parameter `t ∈ [0, 1]` to a scale factor.
1224    /// `None` means uniform scale (1.0 everywhere).
1225    pub scale_law: Option<Box<dyn Fn(f64) -> f64 + Send + Sync>>,
1226    /// Number of path segments (0 = auto from control point count).
1227    pub segments: usize,
1228    /// Auxiliary spine (guide curve). When set, the profile is oriented so its
1229    /// up-vector points toward this curve at each path parameter — a guided
1230    /// (two-rail) sweep — overriding `contact_mode`. Sampled at the same
1231    /// parameter as the main path.
1232    pub aux_spine: Option<NurbsCurve>,
1233}
1234
1235impl std::fmt::Debug for SweepOptions {
1236    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
1237        f.debug_struct("SweepOptions")
1238            .field("contact_mode", &self.contact_mode)
1239            .field("corner_mode", &self.corner_mode)
1240            .field(
1241                "scale_law",
1242                &self.scale_law.as_ref().map(|_| "fn(f64)->f64"),
1243            )
1244            .field("segments", &self.segments)
1245            .field("aux_spine", &self.aux_spine.as_ref().map(|_| "NurbsCurve"))
1246            .finish()
1247    }
1248}
1249
1250/// Sweep a face along a path with advanced options.
1251///
1252/// Supports scaling laws (tapered sweep) and multiple contact modes.
1253///
1254/// # Errors
1255///
1256/// Returns errors for invalid input (see [`sweep`]).
1257#[allow(clippy::too_many_lines)]
1258pub fn sweep_with_options(
1259    topo: &mut Topology,
1260    profile: FaceId,
1261    path: &NurbsCurve,
1262    options: &SweepOptions,
1263) -> Result<SolidId, crate::OperationsError> {
1264    let tol = Tolerance::new();
1265
1266    if path.control_points().len() < 2 {
1267        return Err(crate::OperationsError::InvalidInput {
1268            reason: "sweep path must have at least 2 control points".into(),
1269        });
1270    }
1271
1272    // Dispatch to miter sweep for Miter corners, but only if the path
1273    // actually has kinks. This avoids entering sweep_miter just to fall
1274    // back to smooth sweep, which would drop the caller's scale_law
1275    // (Box<dyn Fn> is not Clone).
1276    // Round is not yet implemented — fall through to smooth sweep as the safe default.
1277    // TODO: implement proper fillet-blend round corners.
1278    if matches!(options.corner_mode, SweepCornerMode::Miter) && !detect_kinks(path).is_empty() {
1279        return sweep_miter(topo, profile, path, options);
1280    }
1281
1282    let face_data = topo.face(profile)?;
1283    let input_wire_id = face_data.outer_wire();
1284    let inner_wire_ids_opts: Vec<brepkit_topology::wire::WireId> = face_data.inner_wires().to_vec();
1285
1286    // Detect closed paths — delegate to basic sweep for now since advanced
1287    // options (scale laws, contact modes) with closed paths needs more work.
1288    let start_end_coincide_opts = tol.approx_eq(
1289        (path.evaluate(1.0) - path.evaluate(0.0)).length_squared(),
1290        0.0,
1291    );
1292    if start_end_coincide_opts {
1293        let mid = path.evaluate(0.5);
1294        let mid_dist_sq = (mid - path.evaluate(0.0)).length_squared();
1295        if tol.approx_eq(mid_dist_sq, 0.0) {
1296            return Err(crate::OperationsError::InvalidInput {
1297                reason: "sweep path has zero length (start and end coincide)".into(),
1298            });
1299        }
1300        return sweep(topo, profile, path);
1301    }
1302
1303    // A straight perpendicular sweep is a prism — build it exactly via extrude.
1304    // Only when no scale law or guide spine applies, since either makes the
1305    // result non-prismatic. As-positioned, matching the general path below
1306    // (the fast path's gate already requires a perpendicular profile).
1307    if options.scale_law.is_none()
1308        && options.aux_spine.is_none()
1309        && let Some(solid) =
1310            try_straight_extrude(topo, profile, path, ProfilePlacement::AsPositioned)?
1311    {
1312        return Ok(solid);
1313    }
1314
1315    let input_wire = topo.wire(input_wire_id)?;
1316    let original_oriented: Vec<_> = input_wire.edges().to_vec();
1317
1318    if original_oriented.is_empty() {
1319        return Err(crate::OperationsError::InvalidInput {
1320            reason: "sweep profile has no edges".into(),
1321        });
1322    }
1323
1324    let input_oriented = crate::extrude::maybe_split_closed_wire(
1325        topo,
1326        &original_oriented,
1327        tol.linear,
1328        crate::extrude::DEFAULT_DEFLECTION,
1329    )?;
1330    let n = input_oriented.len();
1331
1332    let mut input_verts: Vec<VertexId> = Vec::with_capacity(n);
1333    for oe in &input_oriented {
1334        let edge = topo.edge(oe.edge())?;
1335        let vid = oe.oriented_start(edge);
1336        input_verts.push(vid);
1337    }
1338
1339    let mut input_positions: Vec<Point3> = input_verts
1340        .iter()
1341        .map(|&vid| {
1342            topo.vertex(vid)
1343                .map(brepkit_topology::vertex::Vertex::point)
1344        })
1345        .collect::<Result<_, _>>()?;
1346
1347    // Profile normal from the section boundary (planar or non-planar alike); the
1348    // gate that required a planar surface is gone.
1349    let mut input_normal = crate::winding::newell_normal(&input_positions)
1350        .normalize()
1351        .unwrap_or(Vec3::new(0.0, 0.0, 1.0));
1352
1353    // Ensure CCW winding relative to path direction (same fix as sweep()).
1354    let path_tangent_0 = path.tangent(0.0)?;
1355    if crate::winding::ensure_ccw_positions(&mut input_positions, path_tangent_0) {
1356        input_normal = -input_normal;
1357    }
1358
1359    let num_segments = if options.segments > 0 {
1360        options.segments
1361    } else {
1362        (path.control_points().len() * 2).max(4)
1363    };
1364
1365    // Compute frames based on contact mode (open paths only at this point).
1366    let frames: Vec<Frame> = if let Some(aux) = options.aux_spine.as_ref() {
1367        // Guided (two-rail) sweep: orient the profile so its up-vector points
1368        // toward the auxiliary spine at each path parameter. The tangent still
1369        // follows the main path, so this overrides `contact_mode`.
1370        let mut out = Vec::with_capacity(num_segments + 1);
1371        let mut prev_up: Option<Vec3> = None;
1372        for k in 0..=num_segments {
1373            #[allow(clippy::cast_precision_loss)]
1374            let t = k as f64 / num_segments as f64;
1375            let origin = path.evaluate(t);
1376            let tangent = path.tangent(t).unwrap_or(path_tangent_0);
1377            let guide_dir = aux.evaluate(t) - origin;
1378            // Where the guide momentarily coincides with the spine the up-vector
1379            // is undefined; carry the previous frame's up (re-orthogonalized) so
1380            // orientation stays continuous instead of snapping to a world axis.
1381            let up = if guide_dir.length() < 1e-9 {
1382                let seed = prev_up.unwrap_or_else(|| pick_reference_axis(tangent));
1383                orthogonalize(seed, tangent)
1384            } else {
1385                orthogonalize(guide_dir, tangent)
1386            };
1387            prev_up = Some(up);
1388            out.push(Frame {
1389                origin,
1390                tangent,
1391                up,
1392                right: tangent.cross(up),
1393            });
1394        }
1395        out
1396    } else {
1397        match options.contact_mode {
1398            SweepContactMode::RotationMinimizing => {
1399                let up_hint = orthogonalize(input_normal, path_tangent_0);
1400                compute_frames(path, num_segments, up_hint, false)?
1401            }
1402            SweepContactMode::Fixed => {
1403                // Fixed: use the same orientation at every point
1404                let tangent0 = path_tangent_0;
1405                let up = orthogonalize(input_normal, tangent0);
1406                let right = tangent0.cross(up);
1407
1408                (0..=num_segments)
1409                    .map(|k| {
1410                        #[allow(clippy::cast_precision_loss)]
1411                        let t = k as f64 / num_segments as f64;
1412                        Frame {
1413                            origin: path.evaluate(t),
1414                            tangent: path.tangent(t).unwrap_or(tangent0),
1415                            up,
1416                            right,
1417                        }
1418                    })
1419                    .collect()
1420            }
1421            SweepContactMode::ConstantNormal(normal_dir) => {
1422                // Constant normal: up vector stays aligned to normal_dir
1423                (0..=num_segments)
1424                    .map(|k| {
1425                        #[allow(clippy::cast_precision_loss)]
1426                        let t = k as f64 / num_segments as f64;
1427                        let tangent = path.tangent(t).unwrap_or(Vec3::new(0.0, 0.0, 1.0));
1428                        let up = orthogonalize(normal_dir, tangent);
1429                        let right = tangent.cross(up);
1430                        Frame {
1431                            origin: path.evaluate(t),
1432                            tangent,
1433                            up,
1434                            right,
1435                        }
1436                    })
1437                    .collect()
1438            }
1439        }
1440    };
1441
1442    // As-positioned placement (see `sweep`): frame-0's basis and origin make
1443    // ring 0 the identity map for a profile perpendicular to the path;
1444    // edge-on/oblique profiles keep the auto-orienting centroid placement.
1445    let (reference, initial_right, initial_up, initial_tangent) =
1446        match resolve_placement(ProfilePlacement::AsPositioned, input_normal, path_tangent_0) {
1447            ProfilePlacement::AsPositioned => (
1448                frames[0].origin,
1449                frames[0].right,
1450                frames[0].up,
1451                frames[0].tangent,
1452            ),
1453            ProfilePlacement::CentroidOnPath => {
1454                let (r, u, t) = profile_basis(input_normal);
1455                (crate::winding::polygon_centroid(&input_positions), r, u, t)
1456            }
1457        };
1458
1459    let mut ring_verts: Vec<Vec<VertexId>> = Vec::with_capacity(num_segments + 1);
1460
1461    for (k, frame) in frames.iter().enumerate() {
1462        #[allow(clippy::cast_precision_loss)]
1463        let t = k as f64 / num_segments as f64;
1464        let scale = options.scale_law.as_ref().map_or(1.0, |law| law(t));
1465
1466        let ring: Vec<VertexId> = input_positions
1467            .iter()
1468            .map(|&pos| {
1469                let mut transformed = transform_point(
1470                    pos,
1471                    reference,
1472                    initial_right,
1473                    initial_up,
1474                    initial_tangent,
1475                    frame,
1476                );
1477                // Apply scaling relative to frame origin
1478                if (scale - 1.0).abs() > tol.linear {
1479                    let offset = transformed - frame.origin;
1480                    transformed = frame.origin
1481                        + Vec3::new(offset.x() * scale, offset.y() * scale, offset.z() * scale);
1482                }
1483                topo.add_vertex(Vertex::new(transformed, tol.linear))
1484            })
1485            .collect();
1486        ring_verts.push(ring);
1487    }
1488
1489    // Build edges, faces, and assemble (same as basic sweep)
1490    let mut ring_edges: Vec<Vec<brepkit_topology::edge::EdgeId>> =
1491        Vec::with_capacity(num_segments + 1);
1492    for ring in &ring_verts {
1493        let edges: Vec<_> = (0..n)
1494            .map(|i| {
1495                let next = (i + 1) % n;
1496                topo.add_edge(Edge::new(ring[i], ring[next], EdgeCurve::Line))
1497            })
1498            .collect();
1499        ring_edges.push(edges);
1500    }
1501
1502    let mut path_edges: Vec<Vec<brepkit_topology::edge::EdgeId>> = Vec::with_capacity(num_segments);
1503    for seg in 0..num_segments {
1504        let edges: Vec<_> = (0..n)
1505            .map(|i| {
1506                topo.add_edge(Edge::new(
1507                    ring_verts[seg][i],
1508                    ring_verts[seg + 1][i],
1509                    EdgeCurve::Line,
1510                ))
1511            })
1512            .collect();
1513        path_edges.push(edges);
1514    }
1515
1516    // Closed paths are delegated to sweep() earlier, so is_closed is always false here.
1517    let mut inner_swept_opts: Vec<SweptWireData> = Vec::new();
1518    for &iw_id in &inner_wire_ids_opts {
1519        inner_swept_opts.push(sweep_wire_through_frames(
1520            topo,
1521            iw_id,
1522            reference,
1523            initial_right,
1524            initial_up,
1525            initial_tangent,
1526            &frames,
1527            num_segments,
1528            false,
1529        )?);
1530    }
1531
1532    let mut all_faces = Vec::with_capacity(num_segments * n + 2);
1533
1534    let start_inner_wires_opts = build_inner_cap_wires(topo, &inner_swept_opts, 0, true)?;
1535    let start_verts = crate::cap::ring_point_positions(topo, &ring_verts[0])?;
1536    let start_outward = crate::cap::outward_normal(&start_verts, -frames[0].tangent)?;
1537    all_faces.push(crate::cap::build_cap_face(
1538        topo,
1539        &ring_edges[0],
1540        start_inner_wires_opts,
1541        &start_verts,
1542        start_outward,
1543        true,
1544    )?);
1545
1546    for seg in 0..num_segments {
1547        for i in 0..n {
1548            let next_i = (i + 1) % n;
1549            let p0 = topo.vertex(ring_verts[seg][i])?.point();
1550            let p1 = topo.vertex(ring_verts[seg][next_i])?.point();
1551            let p_next = topo.vertex(ring_verts[seg + 1][i])?.point();
1552            let edge_dir = p1 - p0;
1553            let path_dir = p_next - p0;
1554            let side_normal = edge_dir
1555                .cross(path_dir)
1556                .normalize()
1557                .unwrap_or(Vec3::new(1.0, 0.0, 0.0));
1558            let side_d = dot_normal_point(side_normal, p0);
1559
1560            let side_wire = Wire::new(
1561                vec![
1562                    OrientedEdge::new(ring_edges[seg][i], true),
1563                    OrientedEdge::new(path_edges[seg][next_i], true),
1564                    OrientedEdge::new(ring_edges[seg + 1][i], false),
1565                    OrientedEdge::new(path_edges[seg][i], false),
1566                ],
1567                true,
1568            )
1569            .map_err(crate::OperationsError::Topology)?;
1570
1571            let side_wire_id = topo.add_wire(side_wire);
1572            let side_face = topo.add_face(Face::new(
1573                side_wire_id,
1574                vec![],
1575                FaceSurface::Plane {
1576                    normal: side_normal,
1577                    d: side_d,
1578                },
1579            ));
1580            all_faces.push(side_face);
1581        }
1582    }
1583
1584    for iwd in &inner_swept_opts {
1585        let inner_faces = build_inner_side_faces(topo, iwd, num_segments)?;
1586        all_faces.extend(inner_faces);
1587    }
1588
1589    let end_inner_wires_opts = build_inner_cap_wires(topo, &inner_swept_opts, num_segments, false)?;
1590    let end_verts = crate::cap::ring_point_positions(topo, &ring_verts[num_segments])?;
1591    let end_outward = crate::cap::outward_normal(&end_verts, frames[num_segments].tangent)?;
1592    all_faces.push(crate::cap::build_cap_face(
1593        topo,
1594        &ring_edges[num_segments],
1595        end_inner_wires_opts,
1596        &end_verts,
1597        end_outward,
1598        false,
1599    )?);
1600
1601    let shell = Shell::new(all_faces).map_err(crate::OperationsError::Topology)?;
1602    let shell_id = topo.add_shell(shell);
1603    Ok(topo.add_solid(Solid::new(shell_id, vec![])))
1604}
1605
1606/// Detect kink parameters in a NURBS path.
1607///
1608/// A kink is an internal knot where the tangent direction changes
1609/// discontinuously (C0 but not C1 continuity). For a degree-`p` curve,
1610/// this happens at knots with multiplicity >= `p`. For degree-1 (polyline)
1611/// paths, every internal knot is a kink where the line direction changes.
1612///
1613/// Returns the kink parameter values (not including the path endpoints).
1614fn detect_kinks(path: &NurbsCurve) -> Vec<f64> {
1615    /// Small epsilon for comparing knot parameter values (dimensionless).
1616    const KNOT_EPS: f64 = 1e-10;
1617    /// Angular threshold for tangent discontinuity detection (~1 degree).
1618    const KINK_ANGLE_RAD: f64 = 0.0175;
1619
1620    let p = path.degree();
1621    let knots = path.knots();
1622    let (u_min, u_max) = path.domain();
1623
1624    let mut kinks = Vec::new();
1625    let mut i = 0;
1626    while i < knots.len() {
1627        let u = knots[i];
1628
1629        if u <= u_min + KNOT_EPS || u >= u_max - KNOT_EPS {
1630            i += 1;
1631            continue;
1632        }
1633
1634        let mut mult = 1;
1635        while i + mult < knots.len() && (knots[i + mult] - u).abs() < KNOT_EPS {
1636            mult += 1;
1637        }
1638
1639        // For a degree-p curve, a knot with multiplicity m gives C^(p-m)
1640        // continuity. C0 (position only) occurs when m >= p. For degree 1
1641        // (polyline), every internal knot has multiplicity 1 == degree,
1642        // so every junction is a kink.
1643        if mult >= p {
1644            // Verify there's actually a tangent discontinuity by checking
1645            // the tangent just before and just after the knot.
1646            let eps = 1e-8;
1647            if let (Ok(t_before), Ok(t_after)) = (path.tangent(u - eps), path.tangent(u + eps)) {
1648                let dot = t_before.dot(t_after).clamp(-1.0, 1.0);
1649                // Compare using angular threshold: if the angle between
1650                // tangents exceeds ~1 degree, it's a kink.
1651                let angle = dot.acos();
1652                if angle > KINK_ANGLE_RAD {
1653                    kinks.push(u);
1654                }
1655            }
1656        }
1657
1658        i += mult;
1659    }
1660
1661    kinks
1662}
1663
1664/// Sweep a face along a path with miter joints at sharp corners.
1665///
1666/// Detects kinks (tangent discontinuities) in the path, sweeps each
1667/// smooth segment independently, and joins them with miter faces on
1668/// the bisector plane between adjacent tangent directions.
1669///
1670/// # Errors
1671///
1672/// Returns an error if the profile is invalid, path has fewer than 2
1673/// control points, or the path has no kinks (falls back to smooth sweep).
1674#[allow(clippy::too_many_lines)]
1675fn sweep_miter(
1676    topo: &mut Topology,
1677    profile: FaceId,
1678    path: &NurbsCurve,
1679    options: &SweepOptions,
1680) -> Result<SolidId, crate::OperationsError> {
1681    use brepkit_math::nurbs::knot_ops::curve_split;
1682
1683    let tol = Tolerance::new();
1684
1685    // Detect kinks in the path. The caller (sweep_with_options) already
1686    // checks for empty kinks before dispatching here.
1687    let kinks = detect_kinks(path);
1688    debug_assert!(
1689        !kinks.is_empty(),
1690        "sweep_miter should only be called when the path has kinks"
1691    );
1692
1693    let face_data = topo.face(profile)?;
1694    let mut input_normal = match face_data.surface() {
1695        FaceSurface::Plane { normal, .. } => *normal,
1696        _ => {
1697            return Err(crate::OperationsError::InvalidInput {
1698                reason: "sweep of non-planar faces is not supported".into(),
1699            });
1700        }
1701    };
1702    let input_wire_id = face_data.outer_wire();
1703    let inner_wire_ids: Vec<brepkit_topology::wire::WireId> = face_data.inner_wires().to_vec();
1704
1705    let input_wire = topo.wire(input_wire_id)?;
1706    let original_oriented: Vec<_> = input_wire.edges().to_vec();
1707    if original_oriented.is_empty() {
1708        return Err(crate::OperationsError::InvalidInput {
1709            reason: "sweep profile has no edges".into(),
1710        });
1711    }
1712    let input_oriented = crate::extrude::maybe_split_closed_wire(
1713        topo,
1714        &original_oriented,
1715        tol.linear,
1716        crate::extrude::DEFAULT_DEFLECTION,
1717    )?;
1718    let n = input_oriented.len();
1719
1720    let mut input_verts: Vec<VertexId> = Vec::with_capacity(n);
1721    for oe in &input_oriented {
1722        let edge = topo.edge(oe.edge())?;
1723        let vid = oe.oriented_start(edge);
1724        input_verts.push(vid);
1725    }
1726    let mut input_positions: Vec<Point3> = input_verts
1727        .iter()
1728        .map(|&vid| {
1729            topo.vertex(vid)
1730                .map(brepkit_topology::vertex::Vertex::point)
1731        })
1732        .collect::<Result<_, _>>()?;
1733
1734    // Ensure CCW winding relative to the path direction at domain start.
1735    let (domain_start, _domain_end) = path.domain();
1736    let path_tangent_0 = path.tangent(domain_start)?;
1737    if crate::winding::ensure_ccw_positions(&mut input_positions, path_tangent_0) {
1738        input_normal = -input_normal;
1739    }
1740
1741    // As-positioned placement (see `sweep`): a perpendicular profile's
1742    // offsets are decomposed ONCE in the global frame-0 basis and measured
1743    // from the path start, so the first ring reproduces the profile exactly
1744    // and later sub-paths reconstruct the same coordinates in their own
1745    // (up-transported, continuous) frames. Edge-on/oblique profiles keep the
1746    // centroid placement with per-sub-path bases.
1747    let placement = resolve_placement(ProfilePlacement::AsPositioned, input_normal, path_tangent_0);
1748    let reference = match placement {
1749        ProfilePlacement::AsPositioned => path.evaluate(domain_start),
1750        ProfilePlacement::CentroidOnPath => crate::winding::polygon_centroid(&input_positions),
1751    };
1752    let mut global_basis: Option<(Vec3, Vec3, Vec3)> = None;
1753
1754    // Split the path at each kink to get smooth sub-curves.
1755    let mut sub_paths: Vec<NurbsCurve> = Vec::with_capacity(kinks.len() + 1);
1756    let mut remaining = path.clone();
1757    let mut offset = domain_start;
1758
1759    for &kink_u in &kinks {
1760        // The kink parameter is in the original domain. After splitting,
1761        // the remaining curve's domain starts at the split point.
1762        // curve_split takes a parameter in the current curve's domain.
1763        let split_u = kink_u - offset + remaining.domain().0;
1764        let (left, right) = curve_split(&remaining, split_u)?;
1765        sub_paths.push(left);
1766        offset = kink_u;
1767        remaining = right;
1768    }
1769    sub_paths.push(remaining);
1770
1771    let mut all_faces: Vec<FaceId> = Vec::new();
1772
1773    // Track the ring vertices at the end of each segment / start of the next
1774    // so we can connect them via miter faces.
1775    let mut prev_end_ring: Option<Vec<VertexId>> = None;
1776    let mut prev_end_ring_edges: Option<Vec<brepkit_topology::edge::EdgeId>> = None;
1777    let mut prev_end_on_bisector = false;
1778    let mut prev_up: Option<(Vec3, Vec3)> = None; // (up, tangent) at the previous segment's end
1779
1780    for (seg_idx, sub_path) in sub_paths.iter().enumerate() {
1781        let is_first = seg_idx == 0;
1782        let is_last = seg_idx == sub_paths.len() - 1;
1783
1784        let sub_tangent_0 = sub_path.tangent(sub_path.domain().0)?;
1785        // Chain the frame convention across sub-paths: transport the previous
1786        // segment's end up-vector through the kink by the rotation that maps
1787        // the old tangent onto the new one. A bare re-orthogonalization
1788        // degenerates when the old up parallels the new tangent (an L-path
1789        // whose first leg fell back to a world-axis up), spinning the section
1790        // 90 degrees at the joint. The first sub-path seeds from the profile
1791        // normal as before.
1792        let up_hint = match prev_up {
1793            None => orthogonalize(input_normal, sub_tangent_0),
1794            Some((up_prev, t_prev)) => {
1795                let cross = t_prev.cross(sub_tangent_0);
1796                let transported = match cross.normalize() {
1797                    Ok(axis) => {
1798                        let angle = t_prev.dot(sub_tangent_0).clamp(-1.0, 1.0).acos();
1799                        crate::revolve::rotate_vec(up_prev, axis, angle)
1800                    }
1801                    Err(_) => up_prev, // parallel tangents — no rotation needed
1802                };
1803                orthogonalize(transported, sub_tangent_0)
1804            }
1805        };
1806
1807        let num_segments = if options.segments > 0 {
1808            options.segments
1809        } else {
1810            (sub_path.control_points().len() * 2).max(4)
1811        };
1812
1813        let sub_frames = match options.contact_mode {
1814            SweepContactMode::RotationMinimizing => {
1815                compute_frames(sub_path, num_segments, up_hint, false)?
1816            }
1817            SweepContactMode::Fixed => {
1818                let up = orthogonalize(input_normal, sub_tangent_0);
1819                let right = sub_tangent_0.cross(up);
1820                (0..=num_segments)
1821                    .map(|k| {
1822                        let (u0, u1) = sub_path.domain();
1823                        #[allow(clippy::cast_precision_loss)]
1824                        let t = u0 + (u1 - u0) * (k as f64 / num_segments as f64);
1825                        Frame {
1826                            origin: sub_path.evaluate(t),
1827                            tangent: sub_path.tangent(t).unwrap_or(sub_tangent_0),
1828                            up,
1829                            right,
1830                        }
1831                    })
1832                    .collect()
1833            }
1834            SweepContactMode::ConstantNormal(normal_dir) => (0..=num_segments)
1835                .map(|k| {
1836                    let (u0, u1) = sub_path.domain();
1837                    #[allow(clippy::cast_precision_loss)]
1838                    let t = u0 + (u1 - u0) * (k as f64 / num_segments as f64);
1839                    let tangent = sub_path.tangent(t).unwrap_or(Vec3::new(0.0, 0.0, 1.0));
1840                    let up = orthogonalize(normal_dir, tangent);
1841                    let right = tangent.cross(up);
1842                    Frame {
1843                        origin: sub_path.evaluate(t),
1844                        tangent,
1845                        up,
1846                        right,
1847                    }
1848                })
1849                .collect(),
1850        };
1851
1852        if global_basis.is_none() {
1853            global_basis = Some((sub_frames[0].right, sub_frames[0].up, sub_frames[0].tangent));
1854        }
1855        if let Some(last) = sub_frames.last() {
1856            prev_up = Some((last.up, last.tangent));
1857        }
1858        let (initial_right, initial_up, initial_tangent) = match (placement, global_basis) {
1859            (ProfilePlacement::AsPositioned, Some((r, u, t))) => (r, u, t),
1860            _ => (sub_frames[0].right, sub_frames[0].up, sub_frames[0].tangent),
1861        };
1862
1863        // Ring positions for this segment (vertices created after the miter
1864        // adjustment below).
1865        let mut ring_positions: Vec<Vec<Point3>> = sub_frames
1866            .iter()
1867            .map(|frame| {
1868                input_positions
1869                    .iter()
1870                    .map(|&pos| {
1871                        transform_point(
1872                            pos,
1873                            reference,
1874                            initial_right,
1875                            initial_up,
1876                            initial_tangent,
1877                            frame,
1878                        )
1879                    })
1880                    .collect()
1881            })
1882            .collect();
1883
1884        // Exact miter: slide the exit ring onto the bisector plane along this
1885        // segment's end tangent. For a profile perpendicular to the path,
1886        // reflection through the bisector plane equals the tangent-to-tangent
1887        // rotation, so the next segment's entry ring lands on the same points
1888        // and both legs share one kink ring with no transition faces. Each
1889        // vertex slides along its own prism edge line, so wall quads stay in
1890        // their side planes. Falls back to the transition-quad bridge when
1891        // the slide would cross the first interior ring (inverting a wall
1892        // band) or the kink is near-degenerate.
1893        let mut exit_on_bisector = false;
1894        if !is_last && matches!(placement, ProfilePlacement::AsPositioned) {
1895            let kink_u = kinks[seg_idx];
1896            let eps = 1e-8;
1897            let t_b = path.tangent(kink_u - eps)?;
1898            let t_a = path.tangent(kink_u + eps)?;
1899            if let Ok(bisector) = (t_b + t_a).normalize() {
1900                let kink_point = path.evaluate(kink_u);
1901                let t_end = sub_frames[num_segments].tangent;
1902                let denom = bisector.dot(t_end);
1903                let spacing = (sub_frames[num_segments].origin
1904                    - sub_frames[num_segments - 1].origin)
1905                    .length();
1906                if denom.abs() > 0.1 {
1907                    let slid: Vec<Point3> = ring_positions[num_segments]
1908                        .iter()
1909                        .map(|&p| p + t_end * (bisector.dot(kink_point - p) / denom))
1910                        .collect();
1911                    let max_slide = ring_positions[num_segments]
1912                        .iter()
1913                        .zip(&slid)
1914                        .map(|(&p, &q)| (q - p).length())
1915                        .fold(0.0_f64, f64::max);
1916                    if max_slide < spacing * 0.95 {
1917                        ring_positions[num_segments] = slid;
1918                        exit_on_bisector = true;
1919                    }
1920                }
1921            }
1922        }
1923
1924        // A segment whose predecessor ended on the shared bisector ring
1925        // reuses those vertices and edges as its own entry ring (the guard
1926        // keeps the first interior ring strictly ahead of the shared ring).
1927        let entry_shared = match (&prev_end_ring, prev_end_on_bisector) {
1928            (Some(prev_ring), true) => {
1929                let t_start = sub_frames[0].tangent;
1930                let mut ahead = true;
1931                for (i, &vid) in prev_ring.iter().enumerate() {
1932                    let p_prev = topo.vertex(vid)?.point();
1933                    if (ring_positions[1][i] - p_prev).dot(t_start) <= tol.linear {
1934                        ahead = false;
1935                        break;
1936                    }
1937                }
1938                ahead
1939            }
1940            _ => false,
1941        };
1942
1943        let mut ring_verts: Vec<Vec<VertexId>> = Vec::with_capacity(num_segments + 1);
1944        for (ring_idx, positions) in ring_positions.iter().enumerate() {
1945            if let (0, true, Some(prev_ring)) = (ring_idx, entry_shared, prev_end_ring.as_ref()) {
1946                ring_verts.push(prev_ring.clone());
1947                continue;
1948            }
1949            let ring: Vec<VertexId> = positions
1950                .iter()
1951                .map(|&p| topo.add_vertex(Vertex::new(p, tol.linear)))
1952                .collect();
1953            ring_verts.push(ring);
1954        }
1955
1956        // If we have a previous segment's end ring, either share it directly
1957        // (exact miter — the shared ring already lies on the bisector plane)
1958        // or replace this segment's start ring with a bridge miter ring.
1959        #[allow(clippy::useless_let_if_seq)]
1960        let mut miter_ring_edges_for_reuse: Option<Vec<brepkit_topology::edge::EdgeId>> = None;
1961        if entry_shared {
1962            miter_ring_edges_for_reuse.clone_from(&prev_end_ring_edges);
1963        } else if let Some(ref prev_ring) = prev_end_ring {
1964            // The kink point is where the previous segment ended / this one starts.
1965            let kink_idx = seg_idx - 1;
1966            let kink_u = kinks[kink_idx];
1967            let eps = 1e-8;
1968
1969            // Get tangents on either side of the kink.
1970            let t_before = path.tangent(kink_u - eps)?;
1971            let t_after = path.tangent(kink_u + eps)?;
1972
1973            // Bisector direction: average of the two tangent directions.
1974            let bisector = (t_before + t_after).normalize().unwrap_or(t_before);
1975
1976            // Miter plane: passes through the kink point with normal = bisector.
1977            let kink_point = path.evaluate(kink_u);
1978
1979            // Project the profile ring onto the miter plane.
1980            // For each profile vertex, find where the line from the previous
1981            // segment's end position to the current segment's start position
1982            // intersects the bisector plane.
1983            let miter_ring: Vec<VertexId> = (0..n)
1984                .map(|i| {
1985                    let prev_pos = topo
1986                        .vertex(prev_ring[i])
1987                        .map(brepkit_topology::vertex::Vertex::point)
1988                        .unwrap_or(kink_point);
1989                    let curr_pos = topo
1990                        .vertex(ring_verts[0][i])
1991                        .map(brepkit_topology::vertex::Vertex::point)
1992                        .unwrap_or(kink_point);
1993
1994                    // Ray-plane intersection: find t where
1995                    // prev_pos + t*(curr_pos - prev_pos) lies on the bisector plane.
1996                    let ray_dir = curr_pos - prev_pos;
1997                    let denom = bisector.dot(ray_dir);
1998                    let miter_pos = if denom.abs() > tol.linear {
1999                        let d = bisector.dot(Vec3::new(
2000                            kink_point.x() - prev_pos.x(),
2001                            kink_point.y() - prev_pos.y(),
2002                            kink_point.z() - prev_pos.z(),
2003                        ));
2004                        let t_intersect = d / denom;
2005                        prev_pos + ray_dir * t_intersect
2006                    } else {
2007                        // Ray parallel to plane — use midpoint.
2008                        Point3::new(
2009                            (prev_pos.x() + curr_pos.x()) * 0.5,
2010                            (prev_pos.y() + curr_pos.y()) * 0.5,
2011                            (prev_pos.z() + curr_pos.z()) * 0.5,
2012                        )
2013                    };
2014                    topo.add_vertex(Vertex::new(miter_pos, tol.linear))
2015                })
2016                .collect();
2017
2018            let miter_ring_edges: Vec<brepkit_topology::edge::EdgeId> = (0..n)
2019                .map(|i| {
2020                    let next = (i + 1) % n;
2021                    topo.add_edge(Edge::new(miter_ring[i], miter_ring[next], EdgeCurve::Line))
2022                })
2023                .collect();
2024
2025            // Build miter face connecting the previous segment's end to
2026            // the miter ring. The miter face is on the bisector plane.
2027            let prev_ring_edges = prev_end_ring_edges.as_ref().ok_or_else(|| {
2028                crate::OperationsError::InvalidInput {
2029                    reason: "internal error: missing previous ring edges".into(),
2030                }
2031            })?;
2032
2033            let prev_to_miter_path_edges: Vec<brepkit_topology::edge::EdgeId> = (0..n)
2034                .map(|i| topo.add_edge(Edge::new(prev_ring[i], miter_ring[i], EdgeCurve::Line)))
2035                .collect();
2036
2037            for i in 0..n {
2038                let next_i = (i + 1) % n;
2039
2040                let p0 = topo.vertex(prev_ring[i])?.point();
2041                let p1 = topo.vertex(prev_ring[next_i])?.point();
2042                let p_next = topo.vertex(miter_ring[i])?.point();
2043                let edge_dir = p1 - p0;
2044                let path_dir = p_next - p0;
2045                let side_normal = edge_dir
2046                    .cross(path_dir)
2047                    .normalize()
2048                    .unwrap_or(Vec3::new(1.0, 0.0, 0.0));
2049                let side_d = dot_normal_point(side_normal, p0);
2050
2051                let side_wire = Wire::new(
2052                    vec![
2053                        OrientedEdge::new(prev_ring_edges[i], true),
2054                        OrientedEdge::new(prev_to_miter_path_edges[next_i], true),
2055                        OrientedEdge::new(miter_ring_edges[i], false),
2056                        OrientedEdge::new(prev_to_miter_path_edges[i], false),
2057                    ],
2058                    true,
2059                )
2060                .map_err(crate::OperationsError::Topology)?;
2061
2062                let side_wire_id = topo.add_wire(side_wire);
2063                all_faces.push(topo.add_face(Face::new(
2064                    side_wire_id,
2065                    vec![],
2066                    FaceSurface::Plane {
2067                        normal: side_normal,
2068                        d: side_d,
2069                    },
2070                )));
2071            }
2072
2073            // Replace this segment's start ring with the miter ring so the
2074            // next segment's side faces connect miter→ring[1]. No separate
2075            // miter cap faces are needed — the transition quad faces already
2076            // connect prev_end_ring→miter_ring.
2077            ring_verts[0] = miter_ring;
2078            miter_ring_edges_for_reuse = Some(miter_ring_edges);
2079        }
2080
2081        // Create ring edges. If the start ring was replaced by a miter ring,
2082        // reuse the miter_ring_edges so both the miter transition faces and
2083        // this segment's side faces reference the same edge entities.
2084        let mut ring_edges: Vec<Vec<brepkit_topology::edge::EdgeId>> =
2085            Vec::with_capacity(num_segments + 1);
2086        for (ring_idx, ring) in ring_verts.iter().enumerate() {
2087            if ring_idx == 0 {
2088                if let Some(ref reused) = miter_ring_edges_for_reuse {
2089                    ring_edges.push(reused.clone());
2090                } else {
2091                    let edges: Vec<_> = (0..n)
2092                        .map(|i| {
2093                            let next = (i + 1) % n;
2094                            topo.add_edge(Edge::new(ring[i], ring[next], EdgeCurve::Line))
2095                        })
2096                        .collect();
2097                    ring_edges.push(edges);
2098                }
2099            } else {
2100                let edges: Vec<_> = (0..n)
2101                    .map(|i| {
2102                        let next = (i + 1) % n;
2103                        topo.add_edge(Edge::new(ring[i], ring[next], EdgeCurve::Line))
2104                    })
2105                    .collect();
2106                ring_edges.push(edges);
2107            }
2108        }
2109
2110        let mut path_edges: Vec<Vec<brepkit_topology::edge::EdgeId>> =
2111            Vec::with_capacity(num_segments);
2112        for seg in 0..num_segments {
2113            let edges: Vec<_> = (0..n)
2114                .map(|i| {
2115                    topo.add_edge(Edge::new(
2116                        ring_verts[seg][i],
2117                        ring_verts[seg + 1][i],
2118                        EdgeCurve::Line,
2119                    ))
2120                })
2121                .collect();
2122            path_edges.push(edges);
2123        }
2124
2125        let mut inner_swept: Vec<SweptWireData> = Vec::new();
2126        for &iw_id in &inner_wire_ids {
2127            inner_swept.push(sweep_wire_through_frames(
2128                topo,
2129                iw_id,
2130                reference,
2131                initial_right,
2132                initial_up,
2133                initial_tangent,
2134                &sub_frames,
2135                num_segments,
2136                false,
2137            )?);
2138        }
2139
2140        // Start cap (only for the first segment).
2141        if is_first {
2142            let start_reversed_edges: Vec<OrientedEdge> = (0..n)
2143                .rev()
2144                .map(|i| OrientedEdge::new(ring_edges[0][i], false))
2145                .collect();
2146            let start_wire =
2147                Wire::new(start_reversed_edges, true).map_err(crate::OperationsError::Topology)?;
2148            let start_wire_id = topo.add_wire(start_wire);
2149            let start_inner_wires = build_inner_cap_wires(topo, &inner_swept, 0, true)?;
2150
2151            let start_normal = -sub_frames[0].tangent;
2152            let start_d = dot_normal_point(start_normal, topo.vertex(ring_verts[0][0])?.point());
2153            all_faces.push(topo.add_face(Face::new(
2154                start_wire_id,
2155                start_inner_wires,
2156                FaceSurface::Plane {
2157                    normal: start_normal,
2158                    d: start_d,
2159                },
2160            )));
2161        }
2162
2163        for seg in 0..num_segments {
2164            for i in 0..n {
2165                let next_i = (i + 1) % n;
2166                let p0 = topo.vertex(ring_verts[seg][i])?.point();
2167                let p1 = topo.vertex(ring_verts[seg][next_i])?.point();
2168                let p_next = topo.vertex(ring_verts[seg + 1][i])?.point();
2169                let edge_dir = p1 - p0;
2170                let path_dir = p_next - p0;
2171                let side_normal = edge_dir
2172                    .cross(path_dir)
2173                    .normalize()
2174                    .unwrap_or(Vec3::new(1.0, 0.0, 0.0));
2175                let side_d = dot_normal_point(side_normal, p0);
2176
2177                let side_wire = Wire::new(
2178                    vec![
2179                        OrientedEdge::new(ring_edges[seg][i], true),
2180                        OrientedEdge::new(path_edges[seg][next_i], true),
2181                        OrientedEdge::new(ring_edges[seg + 1][i], false),
2182                        OrientedEdge::new(path_edges[seg][i], false),
2183                    ],
2184                    true,
2185                )
2186                .map_err(crate::OperationsError::Topology)?;
2187
2188                let side_wire_id = topo.add_wire(side_wire);
2189                all_faces.push(topo.add_face(Face::new(
2190                    side_wire_id,
2191                    vec![],
2192                    FaceSurface::Plane {
2193                        normal: side_normal,
2194                        d: side_d,
2195                    },
2196                )));
2197            }
2198        }
2199
2200        for iwd in &inner_swept {
2201            let inner_faces = build_inner_side_faces(topo, iwd, num_segments)?;
2202            all_faces.extend(inner_faces);
2203        }
2204
2205        // End cap (only for the last segment).
2206        if is_last {
2207            let end_edges: Vec<OrientedEdge> = (0..n)
2208                .map(|i| OrientedEdge::new(ring_edges[num_segments][i], true))
2209                .collect();
2210            let end_wire = Wire::new(end_edges, true).map_err(crate::OperationsError::Topology)?;
2211            let end_wire_id = topo.add_wire(end_wire);
2212            let end_inner_wires = build_inner_cap_wires(topo, &inner_swept, num_segments, false)?;
2213
2214            let end_normal = sub_frames[num_segments].tangent;
2215            let end_d = dot_normal_point(
2216                end_normal,
2217                topo.vertex(ring_verts[num_segments][0])?.point(),
2218            );
2219            all_faces.push(topo.add_face(Face::new(
2220                end_wire_id,
2221                end_inner_wires,
2222                FaceSurface::Plane {
2223                    normal: end_normal,
2224                    d: end_d,
2225                },
2226            )));
2227        }
2228
2229        // Save the end ring for the next segment's miter connection.
2230        prev_end_ring = Some(ring_verts[num_segments].clone());
2231        prev_end_ring_edges = Some(ring_edges[num_segments].clone());
2232        prev_end_on_bisector = exit_on_bisector;
2233    }
2234
2235    let shell = Shell::new(all_faces).map_err(crate::OperationsError::Topology)?;
2236    let shell_id = topo.add_shell(shell);
2237    Ok(topo.add_solid(Solid::new(shell_id, vec![])))
2238}
2239
2240/// Sweep through multiple section profiles along a `spine`, lofting the
2241/// positioned profiles.
2242///
2243/// Each planar profile is placed rigidly at its parameter along the spine: its
2244/// centroid maps to the spine point, its normal to the spine tangent, and its
2245/// plane to the frame's right/up plane. Orientation uses a rotation-minimizing
2246/// frame so profiles stay twist-free on curved spines (unlike a per-section
2247/// swing rotation). The placed profiles are then lofted — ruled (planar bands)
2248/// or smooth (NURBS).
2249///
2250/// `sections` pairs each profile face with its spine parameter in `[0, 1]`.
2251///
2252/// # Errors
2253///
2254/// Returns [`crate::OperationsError::InvalidInput`] for fewer than two
2255/// sections, a parameter outside `[0, 1]`, or a spine with fewer than two
2256/// control points; propagates loft errors otherwise. Sections may be planar or
2257/// non-planar (they are joined by [`crate::loft::loft`], which supports both).
2258pub fn multi_section_sweep(
2259    topo: &mut Topology,
2260    spine: &NurbsCurve,
2261    sections: &[(FaceId, f64)],
2262    ruled: bool,
2263) -> Result<SolidId, crate::OperationsError> {
2264    if sections.len() < 2 {
2265        return Err(crate::OperationsError::InvalidInput {
2266            reason: format!(
2267                "multi-section sweep requires at least 2 sections, got {}",
2268                sections.len()
2269            ),
2270        });
2271    }
2272    if spine.control_points().len() < 2 {
2273        return Err(crate::OperationsError::InvalidInput {
2274            reason: "multi-section sweep spine must have at least 2 control points".into(),
2275        });
2276    }
2277    for &(_, p) in sections {
2278        if !(0.0..=1.0).contains(&p) {
2279            return Err(crate::OperationsError::InvalidInput {
2280                reason: format!("section parameter {p} is outside [0, 1]"),
2281            });
2282        }
2283    }
2284
2285    // Dense rotation-minimizing frame for twist-free orientation; the exact
2286    // origin/tangent per section is taken directly from the spine.
2287    let dense_segments: usize = 64;
2288    let t_start = spine.tangent(0.0)?;
2289    let initial_up = orthogonalize(pick_reference_axis(t_start), t_start);
2290    let dense = compute_frames(spine, dense_segments, initial_up, false)?;
2291
2292    // The loft joins profiles in order along the spine, so place them by
2293    // ascending parameter regardless of the caller's ordering.
2294    let mut ordered: Vec<(FaceId, f64)> = sections.to_vec();
2295    ordered.sort_by(|a, b| a.1.partial_cmp(&b.1).unwrap_or(std::cmp::Ordering::Equal));
2296
2297    let mut placed: Vec<FaceId> = Vec::with_capacity(ordered.len());
2298    for (face_id, p) in ordered {
2299        #[allow(clippy::cast_possible_truncation, clippy::cast_sign_loss)]
2300        let idx = ((p * dense_segments as f64).round() as usize).min(dense_segments);
2301        let tangent = spine.tangent(p)?;
2302        let up = orthogonalize(dense[idx].up, tangent);
2303        let frame = Frame {
2304            origin: spine.evaluate(p),
2305            tangent,
2306            up,
2307            right: tangent.cross(up),
2308        };
2309        let mat = profile_to_frame_matrix(topo, face_id, &frame)?;
2310        let placed_face = crate::copy::copy_face(topo, face_id)?;
2311        crate::transform::transform_face(topo, placed_face, &mat)?;
2312        placed.push(placed_face);
2313    }
2314
2315    if ruled {
2316        crate::loft::loft(topo, &placed)
2317    } else {
2318        crate::loft::loft_smooth(topo, &placed)
2319    }
2320}
2321
2322/// Pick a world axis not nearly parallel to `dir`, for seeding a frame.
2323fn pick_reference_axis(dir: Vec3) -> Vec3 {
2324    if dir.x().abs() < 0.9 {
2325        Vec3::new(1.0, 0.0, 0.0)
2326    } else {
2327        Vec3::new(0.0, 1.0, 0.0)
2328    }
2329}
2330
2331/// Rigid transform placing a profile face into `frame`: centroid →
2332/// `frame.origin`, normal → tangent, in-plane axes → right/up. A planar profile
2333/// uses its stored plane normal; a non-planar section uses its boundary's
2334/// Newell normal.
2335fn profile_to_frame_matrix(
2336    topo: &Topology,
2337    face_id: FaceId,
2338    frame: &Frame,
2339) -> Result<Mat4, crate::OperationsError> {
2340    // Centroid of the sampled boundary — robust for a circle profile whose
2341    // outer wire is a single closed edge (where averaging start vertices would
2342    // collapse to the seam point).
2343    let boundary = crate::boolean::face_polygon(topo, face_id)?;
2344    if boundary.is_empty() {
2345        return Err(crate::OperationsError::InvalidInput {
2346            reason: "multi-section sweep profile has no boundary".into(),
2347        });
2348    }
2349    // A planar profile uses its stored plane normal; a non-planar section's
2350    // orientation normal is its boundary's Newell normal (the planar-only gate
2351    // is gone — the placed sections are joined by `loft`, which handles
2352    // non-planar profiles).
2353    let normal = match topo.face(face_id)?.surface() {
2354        FaceSurface::Plane { normal, .. } => normal.normalize().unwrap_or(*normal),
2355        _ => crate::winding::newell_normal(&boundary)
2356            .normalize()
2357            .unwrap_or(Vec3::new(0.0, 0.0, 1.0)),
2358    };
2359    let centroid = crate::winding::polygon_centroid(&boundary);
2360
2361    // Profile in-plane basis from a consistent world reference, so all profiles
2362    // share an orientation and the RMF alone controls twist along the spine.
2363    // `p_y = p_x × normal` makes (p_x, p_y, normal) left-handed to match the
2364    // target frame (right, up, tangent) — so R is a proper rotation (det +1)
2365    // and asymmetric profiles are not mirrored.
2366    let p_x = orthogonalize(pick_reference_axis(normal), normal);
2367    let p_y = p_x.cross(normal);
2368
2369    // R maps the profile basis (p_x, p_y, normal) onto (right, up, tangent):
2370    // R = right⊗p_x + up⊗p_y + tangent⊗normal.
2371    let t_cols = [
2372        [frame.right.x(), frame.up.x(), frame.tangent.x()],
2373        [frame.right.y(), frame.up.y(), frame.tangent.y()],
2374        [frame.right.z(), frame.up.z(), frame.tangent.z()],
2375    ];
2376    let l_cols = [
2377        [p_x.x(), p_y.x(), normal.x()],
2378        [p_x.y(), p_y.y(), normal.y()],
2379        [p_x.z(), p_y.z(), normal.z()],
2380    ];
2381    let mut rot = [[0.0_f64; 3]; 3];
2382    for i in 0..3 {
2383        for j in 0..3 {
2384            rot[i][j] = (0..3).map(|k| t_cols[i][k] * l_cols[j][k]).sum();
2385        }
2386    }
2387
2388    // translation = origin − R·centroid
2389    let c = [centroid.x(), centroid.y(), centroid.z()];
2390    let o = [frame.origin.x(), frame.origin.y(), frame.origin.z()];
2391    let trans: [f64; 3] =
2392        std::array::from_fn(|i| o[i] - (0..3).map(|k| rot[i][k] * c[k]).sum::<f64>());
2393
2394    Ok(Mat4([
2395        [rot[0][0], rot[0][1], rot[0][2], trans[0]],
2396        [rot[1][0], rot[1][1], rot[1][2], trans[1]],
2397        [rot[2][0], rot[2][1], rot[2][2], trans[2]],
2398        [0.0, 0.0, 0.0, 1.0],
2399    ]))
2400}
2401
2402/// Guided (two-rail) sweep of `profile` along `spine`, oriented by `aux`.
2403///
2404/// At each path parameter the profile's up-vector points toward the auxiliary
2405/// spine `aux`, so the profile rolls to track the guide curve rather than
2406/// holding a fixed or rotation-minimizing orientation. Thin wrapper over
2407/// [`sweep_with_options`] with `aux_spine` set.
2408///
2409/// # Errors
2410///
2411/// Propagates [`sweep_with_options`] errors (e.g. a degenerate path or an
2412/// unsupported non-planar cap).
2413pub fn sweep_guided(
2414    topo: &mut Topology,
2415    profile: FaceId,
2416    spine: &NurbsCurve,
2417    aux: NurbsCurve,
2418) -> Result<SolidId, crate::OperationsError> {
2419    sweep_with_options(
2420        topo,
2421        profile,
2422        spine,
2423        &SweepOptions {
2424            aux_spine: Some(aux),
2425            ..Default::default()
2426        },
2427    )
2428}
2429
2430mod spine;
2431
2432/// Sweep a face profile along a path defined by a chain of edges.
2433///
2434/// A closed planar G1 chain of lines and tangent circular arcs (a rounded
2435/// rectangle) with an all-line perpendicular profile is swept analytically:
2436/// one exact plane / cylinder / cone face per profile edge per spine segment
2437/// (the `spine` submodule). Anything else falls back to sampling the chain, fitting an
2438/// interpolating NURBS curve, and sweeping along that.
2439///
2440/// # Errors
2441///
2442/// Returns an error if the chain has no edges, too few distinct points to fit
2443/// a path, or the underlying sweep fails.
2444pub fn sweep_along_edges(
2445    topo: &mut Topology,
2446    profile: FaceId,
2447    edges: &[brepkit_topology::edge::EdgeId],
2448) -> Result<SolidId, crate::OperationsError> {
2449    if edges.is_empty() {
2450        return Err(crate::OperationsError::InvalidInput {
2451            reason: "sweep_along_edges requires at least one edge".into(),
2452        });
2453    }
2454
2455    if let Some(solid) = spine::try_analytic_spine_sweep(topo, profile, edges)? {
2456        return Ok(solid);
2457    }
2458
2459    let tol = Tolerance::new();
2460
2461    // Collect ordered points from the edge chain, sampling curved edges.
2462    let mut points: Vec<Point3> = Vec::new();
2463    for &eid in edges {
2464        let edge_data = topo.edge(eid)?;
2465        let start = topo.vertex(edge_data.start())?.point();
2466        if points
2467            .last()
2468            .is_none_or(|p: &Point3| (*p - start).length() > tol.linear)
2469        {
2470            points.push(start);
2471        }
2472
2473        match edge_data.curve() {
2474            EdgeCurve::NurbsCurve(curve) => {
2475                let (u0, u1) = curve.domain();
2476                let n_samples = 4;
2477                for i in 1..n_samples {
2478                    #[allow(clippy::cast_precision_loss)]
2479                    let frac = i as f64 / n_samples as f64;
2480                    points.push(curve.evaluate(u0 + frac * (u1 - u0)));
2481                }
2482            }
2483            EdgeCurve::Circle(circle) => {
2484                let t_start = circle.project(start);
2485                let end_pt = topo.vertex(edge_data.end())?.point();
2486                let mut t_end = circle.project(end_pt);
2487                if t_end <= t_start {
2488                    t_end += std::f64::consts::TAU;
2489                }
2490                let n_samples = 8;
2491                for i in 1..n_samples {
2492                    #[allow(clippy::cast_precision_loss)]
2493                    let t = t_start + (t_end - t_start) * (i as f64) / (n_samples as f64);
2494                    points.push(circle.evaluate(t));
2495                }
2496            }
2497            EdgeCurve::Ellipse(ellipse) => {
2498                let t_start = ellipse.project(start);
2499                let end_pt = topo.vertex(edge_data.end())?.point();
2500                let mut t_end = ellipse.project(end_pt);
2501                if t_end <= t_start {
2502                    t_end += std::f64::consts::TAU;
2503                }
2504                let n_samples = 8;
2505                for i in 1..n_samples {
2506                    #[allow(clippy::cast_precision_loss)]
2507                    let t = t_start + (t_end - t_start) * (i as f64) / (n_samples as f64);
2508                    points.push(ellipse.evaluate(t));
2509                }
2510            }
2511            EdgeCurve::Line => {}
2512        }
2513
2514        let end = topo.vertex(edge_data.end())?.point();
2515        points.push(end);
2516    }
2517
2518    if points.len() < 2 {
2519        return Err(crate::OperationsError::InvalidInput {
2520            reason: "sweep_along_edges: need at least 2 distinct points".into(),
2521        });
2522    }
2523
2524    // Densify long, sparsely-sampled spans so the global interpolating fit
2525    // does not overshoot at adjacent high-curvature corners.
2526    let points = densify_path_points(&points);
2527    let degree = std::cmp::min(3, points.len() - 1);
2528    let path_curve = brepkit_math::nurbs::fitting::interpolate(&points, degree)?;
2529
2530    sweep(topo, profile, &path_curve)
2531}
2532
2533#[cfg(test)]
2534mod tests;