Skip to main content

brep_kernel/construction/
revolve_topology.rs

1use crate::topology::{
2    BrepSolid, CoedgeRecord, EdgeRecord, FaceRecord, LoopRecord, ShellRecord, VertexRecord,
3};
4use crate::{make_line, make_plane, make_revolution, NurbsCurve, NurbsSurface, Vec3, Vec4};
5
6const TOLERANCE: f64 = 1e-5;
7
8fn next_id(counter: &mut u64) -> u64 {
9    let id = *counter;
10    *counter += 1;
11    id
12}
13
14fn parameter_line(u0: f64, v0: f64, u1: f64, v1: f64) -> Result<NurbsCurve, String> {
15    make_line(Vec3::new(u0, v0, 0.0), Vec3::new(u1, v1, 0.0))
16}
17
18fn curve_to_plane_parameters(
19    curve: &NurbsCurve,
20    origin: Vec3,
21    x_axis: Vec3,
22    y_axis: Vec3,
23) -> Result<NurbsCurve, String> {
24    NurbsCurve::new(
25        curve.degree,
26        curve.knots.clone(),
27        curve
28            .control_points
29            .iter()
30            .map(|point| {
31                let euclidean = Vec3::new(point.x / point.w, point.y / point.w, point.z / point.w);
32                let delta = euclidean.sub(origin);
33                Vec4 {
34                    x: delta.dot(x_axis) * point.w,
35                    y: delta.dot(y_axis) * point.w,
36                    z: 0.0,
37                    w: point.w,
38                }
39            })
40            .collect(),
41    )
42}
43
44fn rotate_point(point: Vec3, origin: Vec3, axis: Vec3, angle: f64) -> Vec3 {
45    let vector = point.sub(origin);
46    let cosine = angle.cos();
47    let sine = angle.sin();
48    origin
49        .add(vector.scale(cosine))
50        .add(axis.cross(vector).scale(sine))
51        .add(axis.scale(axis.dot(vector) * (1.0 - cosine)))
52}
53
54fn rotate_curve(
55    curve: &NurbsCurve,
56    origin: Vec3,
57    axis: Vec3,
58    angle: f64,
59) -> Result<NurbsCurve, String> {
60    NurbsCurve::new(
61        curve.degree,
62        curve.knots.clone(),
63        curve
64            .control_points
65            .iter()
66            .map(|point| {
67                let euclidean = Vec3::new(point.x / point.w, point.y / point.w, point.z / point.w);
68                Vec4::from_point(rotate_point(euclidean, origin, axis, angle), point.w)
69            })
70            .collect(),
71    )
72}
73
74fn signed_profile_area(
75    curves: &[NurbsCurve],
76    origin: Vec3,
77    radial: Vec3,
78    axis: Vec3,
79) -> Result<f64, String> {
80    let mut area = 0.0;
81    for curve in curves {
82        let [start, end] = curve.domain()?;
83        let mut previous = curve.evaluate(start)?;
84        for index in 1..=64 {
85            let point = curve.evaluate(start + (end - start) * index as f64 / 64.0)?;
86            let a = previous.sub(origin);
87            let b = point.sub(origin);
88            area += 0.5 * (a.dot(radial) * b.dot(axis) - b.dot(radial) * a.dot(axis));
89            previous = point;
90        }
91    }
92    Ok(area)
93}
94
95struct RevolveProfile {
96    curves: Vec<NurbsCurve>,
97    points: Vec<Vec3>,
98    axis_points: Vec<bool>,
99    axis_curves: Vec<bool>,
100    /// For each entry of `curves`, true when it is a straight segment lying
101    /// (within tolerance) in a plane PERPENDICULAR to the axis — a radial
102    /// generatrix.  Revolving one sweeps a flat disk/annulus sector, so the
103    /// partial-revolve side face is genuinely PLANAR and is built with a
104    /// `make_plane` carrier (not `make_revolution`); otherwise the shallow
105    /// "cone" carrier is misrecognized (conical, not planar) and its boolean
106    /// intersections fall to the marcher and shed slivers.
107    radial_curves: Vec<bool>,
108    radial: Vec3,
109    axis: Vec3,
110    /// For each entry of `curves`, the index of the INPUT curve it came
111    /// from.  Winding normalization reverses the loop and axis-lying
112    /// curves produce no side face, so callers cannot recover this mapping
113    /// from the emitted face order — side-face names must travel through
114    /// it (the extrude naming permutation bug, revolve edition).
115    input_indices: Vec<usize>,
116}
117
118fn prepare_profile(
119    input: &[NurbsCurve],
120    axis_point: Vec3,
121    axis_direction: Vec3,
122) -> Result<RevolveProfile, String> {
123    if input.len() < 2 {
124        return Err("profile needs at least 2 curves".into());
125    }
126    let axis = axis_direction.normalized()?;
127    let mut curves: Vec<NurbsCurve> = input
128        .iter()
129        .map(|curve| {
130            NurbsCurve::new(
131                curve.degree,
132                curve.knots.clone(),
133                curve.control_points.clone(),
134            )
135        })
136        .collect::<Result<_, _>>()?;
137    let closure_points = |curves: &[NurbsCurve]| -> Result<Vec<Vec3>, String> {
138        let mut points = Vec::with_capacity(curves.len());
139        for index in 0..curves.len() {
140            let [start, end] = curves[index].domain()?;
141            let next_start = curves[(index + 1) % curves.len()].domain()?[0];
142            let point = curves[index].evaluate(start)?;
143            if curves[index]
144                .evaluate(end)?
145                .sub(curves[(index + 1) % curves.len()].evaluate(next_start)?)
146                .length()
147                > TOLERANCE
148            {
149                return Err(format!("profile not closed at curve {index}"));
150            }
151            points.push(point);
152        }
153        Ok(points)
154    };
155    let mut points = closure_points(&curves)?;
156
157    let radial_distance = |point: Vec3| {
158        let delta = point.sub(axis_point);
159        delta.sub(axis.scale(delta.dot(axis))).length()
160    };
161    let mut radial = None;
162    'outer: for curve in &curves {
163        let [start, end] = curve.domain()?;
164        for index in 0..=24 {
165            let point = curve.evaluate(start + (end - start) * index as f64 / 24.0)?;
166            let delta = point.sub(axis_point);
167            let candidate = delta.sub(axis.scale(delta.dot(axis)));
168            if candidate.length() > TOLERANCE * 100.0 {
169                radial = Some(candidate.normalized()?);
170                break 'outer;
171            }
172        }
173    }
174    let radial = radial.ok_or_else(|| "revolveSolid: profile touches the axis".to_string())?;
175    let curve_on_axis = |curve: &NurbsCurve| -> Result<bool, String> {
176        let [start, end] = curve.domain()?;
177        for index in 0..=8 {
178            if radial_distance(curve.evaluate(start + (end - start) * index as f64 / 8.0)?)
179                > TOLERANCE * 100.0
180            {
181                return Ok(false);
182            }
183        }
184        Ok(true)
185    };
186    for curve in &curves {
187        let [start, end] = curve.domain()?;
188        let on_axis = curve_on_axis(curve)?;
189        for index in 0..=24 {
190            let point = curve.evaluate(start + (end - start) * index as f64 / 24.0)?;
191            let delta = point.sub(axis_point);
192            let in_plane_radial = delta.dot(radial);
193            let off_plane = delta
194                .sub(axis.scale(delta.dot(axis)))
195                .sub(radial.scale(in_plane_radial))
196                .length();
197            if off_plane > TOLERANCE * 100.0 {
198                return Err("revolveSolid: profile not in a half-plane containing the axis".into());
199            }
200            if in_plane_radial < -TOLERANCE * 100.0 {
201                return Err("revolveSolid: profile crosses the revolve axis".into());
202            }
203            if in_plane_radial < TOLERANCE * 100.0 && index != 0 && index != 24 && !on_axis {
204                return Err(
205                    "revolveSolid: profile may only touch the axis at boundary vertices or axis edges"
206                        .into(),
207                );
208            }
209        }
210    }
211    let mut input_indices: Vec<usize> = (0..curves.len()).collect();
212    if signed_profile_area(&curves, axis_point, radial, axis)? < 0.0 {
213        curves = curves
214            .iter()
215            .rev()
216            .map(NurbsCurve::reversed)
217            .collect::<Result<_, _>>()?;
218        points = closure_points(&curves)?;
219        input_indices.reverse();
220    }
221    let mut axis_points: Vec<bool> = points
222        .iter()
223        .map(|point| radial_distance(*point) <= TOLERANCE * 100.0)
224        .collect();
225    if axis_points.iter().any(|flag| *flag) {
226        let snapped: Vec<Vec3> = points
227            .iter()
228            .enumerate()
229            .map(|(index, point)| {
230                if axis_points[index] {
231                    axis_point.add(axis.scale(point.sub(axis_point).dot(axis)))
232                } else {
233                    *point
234                }
235            })
236            .collect();
237        for index in 0..curves.len() {
238            let next = (index + 1) % curves.len();
239            if !axis_points[index] && !axis_points[next] {
240                continue;
241            }
242            let mut control_points = curves[index].control_points.clone();
243            if axis_points[index] {
244                let weight = control_points[0].w;
245                control_points[0] = Vec4::from_point(snapped[index], weight);
246            }
247            if axis_points[next] {
248                let last = control_points.len() - 1;
249                let weight = control_points[last].w;
250                control_points[last] = Vec4::from_point(snapped[next], weight);
251            }
252            curves[index] = NurbsCurve::new(
253                curves[index].degree,
254                curves[index].knots.clone(),
255                control_points,
256            )?;
257        }
258        points = closure_points(&curves)?;
259        axis_points = points
260            .iter()
261            .map(|point| radial_distance(*point) <= TOLERANCE * 100.0)
262            .collect();
263    }
264    let axis_curves = curves
265        .iter()
266        .map(curve_on_axis)
267        .collect::<Result<Vec<_>, _>>()?;
268    // A radial generatrix is a straight segment whose direction is
269    // perpendicular to the axis: its two endpoints share an axial coordinate,
270    // so the revolved patch is a flat disk/annulus sector (a plane), not a
271    // cone.  Curves that lie ON the axis produce no side face and are excluded.
272    let radial_curves = curves
273        .iter()
274        .enumerate()
275        .map(|(index, curve)| {
276            if axis_curves[index] {
277                return Ok(false);
278            }
279            Ok(is_straight_radial(curve, axis)?)
280        })
281        .collect::<Result<Vec<_>, String>>()?;
282    Ok(RevolveProfile {
283        curves,
284        points,
285        axis_points,
286        axis_curves,
287        radial_curves,
288        radial,
289        axis,
290        input_indices,
291    })
292}
293
294/// True when `curve` is a straight, unit-weight line segment whose direction is
295/// perpendicular to `axis` (a radial generatrix).  The threshold is a small
296/// relative slope (~0.057 deg from perpendicular): it captures sketch-solver
297/// noise on an intended flat cap without swallowing a genuine shallow cone.
298fn is_straight_radial(curve: &NurbsCurve, axis: Vec3) -> Result<bool, String> {
299    if curve.degree != 1 || curve.control_points.len() != 2 {
300        return Ok(false);
301    }
302    let [start, end] = curve.domain()?;
303    let p0 = curve.evaluate(start)?;
304    let p1 = curve.evaluate(end)?;
305    let direction = p1.sub(p0);
306    let length = direction.length();
307    if length <= TOLERANCE {
308        return Ok(false);
309    }
310    Ok(direction.dot(axis).abs() <= 1e-3 * length)
311}
312
313fn junction_curve(
314    index: usize,
315    surfaces: &[Option<NurbsSurface>],
316    points: &[Vec3],
317) -> Result<NurbsCurve, String> {
318    if let Some(surface) = &surfaces[index] {
319        return surface.iso_curve_v(surface.knots_v[surface.degree_v]);
320    }
321    let previous = (index + surfaces.len() - 1) % surfaces.len();
322    if let Some(surface) = &surfaces[previous] {
323        return surface.iso_curve_v(surface.knots_v[surface.knots_v.len() - 1 - surface.degree_v]);
324    }
325    make_line(points[index], points[index])
326}
327
328/// Build the PLANAR side face of a partial revolve whose generatrix is radial
329/// (perpendicular to the axis): a flat disk/annulus sector.  The face carrier
330/// is an affine `make_plane` — so it recognizes as a `Plane` (not a shallow
331/// "cone"), reads planar in the app, and its boolean intersections take the
332/// exact analytic path instead of the sliver-prone marcher.  The bounding
333/// loop reuses the same edges as the revolution branch; only the surface and
334/// its pcurves differ (the edges are projected into the plane's frame).
335#[allow(clippy::too_many_arguments)]
336fn radial_plane_face(
337    profile: &RevolveProfile,
338    index: usize,
339    count: usize,
340    origin: Vec3,
341    revolution: &NurbsSurface,
342    arc_curves: &[NurbsCurve],
343    end_curves: &[NurbsCurve],
344    arc_ids: &[u64],
345    start_edge_ids: &[u64],
346    end_edge_ids: &[u64],
347    counter: &mut u64,
348    name: Option<String>,
349) -> Result<FaceRecord, String> {
350    let axis = profile.axis;
351    let a_start = profile.points[index].sub(origin).dot(axis);
352    let a_end = profile.points[(index + 1) % count].sub(origin).dot(axis);
353    let plane_center = origin.add(axis.scale(0.5 * (a_start + a_end)));
354    let e1 = profile.radial;
355    let e2 = axis.cross(e1);
356
357    let next = (index + 1) % count;
358    let boundary: [(NurbsCurve, u64, bool); 4] = [
359        (arc_curves[index].clone(), arc_ids[index], true),
360        (end_curves[index].clone(), end_edge_ids[index], true),
361        (arc_curves[next].clone(), arc_ids[next], false),
362        (profile.curves[index].clone(), start_edge_ids[index], false),
363    ];
364
365    let mut min1 = f64::INFINITY;
366    let mut max1 = f64::NEG_INFINITY;
367    let mut min2 = f64::INFINITY;
368    let mut max2 = f64::NEG_INFINITY;
369    for (curve, _, _) in &boundary {
370        let [s, e] = curve.domain()?;
371        for k in 0..=16 {
372            let point = curve.evaluate(s + (e - s) * k as f64 / 16.0)?;
373            let d = point.sub(plane_center);
374            let c1 = d.dot(e1);
375            let c2 = d.dot(e2);
376            min1 = min1.min(c1);
377            max1 = max1.max(c1);
378            min2 = min2.min(c2);
379            max2 = max2.max(c2);
380        }
381    }
382    let padding = (max1 - min1).max(max2 - min2) * 0.05 + 1e-6;
383    let width = max1 - min1 + 2.0 * padding;
384    let height = max2 - min2 + 2.0 * padding;
385    let corner = plane_center
386        .add(e1.scale(min1 - padding))
387        .add(e2.scale(min2 - padding));
388    let plane = make_plane(corner, e1, e2, width, height)?;
389
390    // Preserve the revolution carrier's outward orientation: the plane's
391    // geometric normal is e1 x e2, so same_sense follows whether that agrees
392    // with the revolution's normal at the patch midpoint.
393    let [ru0, ru1] = revolution.domain_u()?;
394    let [rv0, rv1] = revolution.domain_v()?;
395    let rev_normal = revolution.normal((ru0 + ru1) * 0.5, (rv0 + rv1) * 0.5)?;
396    let same_sense = rev_normal.dot(e1.cross(e2)) >= 0.0;
397
398    let mut coedges = Vec::with_capacity(4);
399    for (curve, edge_id, forward) in boundary {
400        let mapped = curve_to_plane_parameters(&curve, corner, e1, e2)?;
401        let pcurve = if forward { mapped } else { mapped.reversed()? };
402        coedges.push(CoedgeRecord {
403            id: next_id(counter),
404            edge_id,
405            forward,
406            pcurve,
407        });
408    }
409    let loop_id = next_id(counter);
410    Ok(FaceRecord {
411        id: next_id(counter),
412        surface: plane,
413        same_sense,
414        loops: vec![LoopRecord {
415            id: loop_id,
416            coedges,
417        }],
418        name,
419    })
420}
421
422pub fn revolve_profile_brep(
423    input: &[NurbsCurve],
424    axis_point: Vec3,
425    axis_direction: Vec3,
426    angle: f64,
427) -> Result<BrepSolid, String> {
428    revolve_profile_brep_named(input, axis_point, axis_direction, angle, &[], &[])
429}
430
431/// Revolve with caller-supplied names.  `side_names` is aligned with the
432/// INPUT curves; the kernel carries it through winding normalization and
433/// axis-curve skips, which the caller cannot reconstruct from the emitted
434/// face order.  `cap_names` is `[start_cap, end_cap]` for partial
435/// revolutions.  Empty slices leave faces unnamed.
436pub fn revolve_profile_brep_named(
437    input: &[NurbsCurve],
438    axis_point: Vec3,
439    axis_direction: Vec3,
440    angle: f64,
441    side_names: &[Option<String>],
442    cap_names: &[Option<String>],
443) -> Result<BrepSolid, String> {
444    if angle <= 1e-9 || angle > std::f64::consts::TAU + 1e-9 {
445        return Err("revolveSolid: angle must be in (0, 2*PI]".into());
446    }
447    if !side_names.is_empty() && side_names.len() != input.len() {
448        return Err(format!(
449            "revolveSolid: {} side names for {} profile curves",
450            side_names.len(),
451            input.len()
452        ));
453    }
454    let full_turn = angle > std::f64::consts::TAU - 1e-9;
455    let profile = prepare_profile(input, axis_point, axis_direction)?;
456    if full_turn {
457        revolve_full(profile, axis_point, side_names)
458    } else {
459        revolve_partial(profile, axis_point, angle, side_names, cap_names)
460    }
461}
462
463fn side_name(
464    profile: &RevolveProfile,
465    curve_index: usize,
466    side_names: &[Option<String>],
467) -> Option<String> {
468    profile
469        .input_indices
470        .get(curve_index)
471        .and_then(|input_index| side_names.get(*input_index))
472        .cloned()
473        .flatten()
474}
475
476fn revolve_full(
477    profile: RevolveProfile,
478    origin: Vec3,
479    side_names: &[Option<String>],
480) -> Result<BrepSolid, String> {
481    let count = profile.curves.len();
482    let surfaces: Vec<Option<NurbsSurface>> = profile
483        .curves
484        .iter()
485        .enumerate()
486        .map(|(index, curve)| {
487            if profile.axis_curves[index] {
488                Ok(None)
489            } else {
490                make_revolution(origin, profile.axis, curve, std::f64::consts::TAU).map(Some)
491            }
492        })
493        .collect::<Result<_, String>>()?;
494    let vertices: Vec<VertexRecord> = profile
495        .points
496        .iter()
497        .enumerate()
498        .map(|(index, point)| VertexRecord {
499            id: index as u64 + 1,
500            point: *point,
501        })
502        .collect();
503    let mut counter = 100_u64;
504    let mut edges = Vec::new();
505    let mut circle_ids = Vec::with_capacity(count);
506    for index in 0..count {
507        let curve = junction_curve(index, &surfaces, &profile.points)?;
508        let [start, end] = curve.domain()?;
509        let id = next_id(&mut counter);
510        circle_ids.push(id);
511        edges.push(EdgeRecord {
512            id,
513            curve,
514            t0: start,
515            t1: end,
516            start_vertex_id: index as u64 + 1,
517            end_vertex_id: index as u64 + 1,
518            degenerate: profile.axis_points[index],
519            name: None,
520        });
521    }
522    let mut faces = Vec::new();
523    for index in 0..count {
524        if profile.axis_curves[index] {
525            continue;
526        }
527        let curve = profile.curves[index].clone();
528        let [start, end] = curve.domain()?;
529        let seam_id = next_id(&mut counter);
530        edges.push(EdgeRecord {
531            id: seam_id,
532            curve,
533            t0: start,
534            t1: end,
535            start_vertex_id: index as u64 + 1,
536            end_vertex_id: ((index + 1) % count) as u64 + 1,
537            degenerate: false,
538            name: None,
539        });
540        let coedges = vec![
541            CoedgeRecord {
542                id: next_id(&mut counter),
543                edge_id: circle_ids[index],
544                forward: true,
545                pcurve: parameter_line(0.0, start, 1.0, start)?,
546            },
547            CoedgeRecord {
548                id: next_id(&mut counter),
549                edge_id: seam_id,
550                forward: true,
551                pcurve: parameter_line(1.0, start, 1.0, end)?,
552            },
553            CoedgeRecord {
554                id: next_id(&mut counter),
555                edge_id: circle_ids[(index + 1) % count],
556                forward: false,
557                pcurve: parameter_line(1.0, end, 0.0, end)?,
558            },
559            CoedgeRecord {
560                id: next_id(&mut counter),
561                edge_id: seam_id,
562                forward: false,
563                pcurve: parameter_line(0.0, end, 0.0, start)?,
564            },
565        ];
566        let loop_id = next_id(&mut counter);
567        faces.push(FaceRecord {
568            id: next_id(&mut counter),
569            surface: surfaces[index].clone().unwrap(),
570            same_sense: true,
571            loops: vec![LoopRecord {
572                id: loop_id,
573                coedges,
574            }],
575            name: side_name(&profile, index, side_names),
576        });
577    }
578    let solid = BrepSolid {
579        id: next_id(&mut counter),
580        vertices,
581        edges,
582        shells: vec![ShellRecord {
583            id: next_id(&mut counter),
584            faces,
585        }],
586        genus: if profile.axis_points.iter().any(|flag| *flag) {
587            0
588        } else {
589            1
590        },
591    };
592    let issues = solid.validate();
593    if issues.is_empty() {
594        Ok(solid)
595    } else {
596        Err(format!("Rust full revolution is invalid: {issues:?}"))
597    }
598}
599
600fn revolve_partial(
601    profile: RevolveProfile,
602    origin: Vec3,
603    angle: f64,
604    side_names: &[Option<String>],
605    cap_names: &[Option<String>],
606) -> Result<BrepSolid, String> {
607    let count = profile.curves.len();
608    let end_curves: Vec<NurbsCurve> = profile
609        .curves
610        .iter()
611        .map(|curve| rotate_curve(curve, origin, profile.axis, angle))
612        .collect::<Result<_, _>>()?;
613    let surfaces: Vec<Option<NurbsSurface>> = profile
614        .curves
615        .iter()
616        .enumerate()
617        .map(|(index, curve)| {
618            if profile.axis_curves[index] {
619                Ok(None)
620            } else {
621                make_revolution(origin, profile.axis, curve, angle).map(Some)
622            }
623        })
624        .collect::<Result<_, String>>()?;
625    let mut vertices = Vec::new();
626    let mut start_vertex_ids = Vec::with_capacity(count);
627    let mut end_vertex_ids = Vec::with_capacity(count);
628    let mut counter = 100_u64;
629    for point in &profile.points {
630        let id = next_id(&mut counter);
631        start_vertex_ids.push(id);
632        vertices.push(VertexRecord { id, point: *point });
633    }
634    for index in 0..count {
635        if profile.axis_points[index] {
636            end_vertex_ids.push(start_vertex_ids[index]);
637        } else {
638            let id = next_id(&mut counter);
639            end_vertex_ids.push(id);
640            vertices.push(VertexRecord {
641                id,
642                point: rotate_point(profile.points[index], origin, profile.axis, angle),
643            });
644        }
645    }
646
647    let mut edges = Vec::new();
648    let mut arc_ids = Vec::with_capacity(count);
649    let mut arc_curves = Vec::with_capacity(count);
650    for index in 0..count {
651        let curve = junction_curve(index, &surfaces, &profile.points)?;
652        let [start, end] = curve.domain()?;
653        let id = next_id(&mut counter);
654        arc_ids.push(id);
655        arc_curves.push(curve.clone());
656        edges.push(EdgeRecord {
657            id,
658            curve,
659            t0: start,
660            t1: end,
661            start_vertex_id: start_vertex_ids[index],
662            end_vertex_id: end_vertex_ids[index],
663            degenerate: profile.axis_points[index],
664            name: None,
665        });
666    }
667    let mut start_edge_ids = Vec::with_capacity(count);
668    let mut end_edge_ids = Vec::with_capacity(count);
669    for index in 0..count {
670        let [start, end] = profile.curves[index].domain()?;
671        let start_id = next_id(&mut counter);
672        start_edge_ids.push(start_id);
673        edges.push(EdgeRecord {
674            id: start_id,
675            curve: profile.curves[index].clone(),
676            t0: start,
677            t1: end,
678            start_vertex_id: start_vertex_ids[index],
679            end_vertex_id: start_vertex_ids[(index + 1) % count],
680            degenerate: false,
681            name: None,
682        });
683        if profile.axis_curves[index] {
684            end_edge_ids.push(start_id);
685        } else {
686            let end_id = next_id(&mut counter);
687            end_edge_ids.push(end_id);
688            edges.push(EdgeRecord {
689                id: end_id,
690                curve: end_curves[index].clone(),
691                t0: start,
692                t1: end,
693                start_vertex_id: end_vertex_ids[index],
694                end_vertex_id: end_vertex_ids[(index + 1) % count],
695                degenerate: false,
696                name: None,
697            });
698        }
699    }
700
701    let mut faces = Vec::new();
702    for index in 0..count {
703        if profile.axis_curves[index] {
704            continue;
705        }
706        if profile.radial_curves[index] {
707            faces.push(radial_plane_face(
708                &profile,
709                index,
710                count,
711                origin,
712                surfaces[index].as_ref().unwrap(),
713                &arc_curves,
714                &end_curves,
715                &arc_ids,
716                &start_edge_ids,
717                &end_edge_ids,
718                &mut counter,
719                side_name(&profile, index, side_names),
720            )?);
721            continue;
722        }
723        let [start, end] = profile.curves[index].domain()?;
724        let coedges = vec![
725            CoedgeRecord {
726                id: next_id(&mut counter),
727                edge_id: arc_ids[index],
728                forward: true,
729                pcurve: parameter_line(0.0, start, 1.0, start)?,
730            },
731            CoedgeRecord {
732                id: next_id(&mut counter),
733                edge_id: end_edge_ids[index],
734                forward: true,
735                pcurve: parameter_line(1.0, start, 1.0, end)?,
736            },
737            CoedgeRecord {
738                id: next_id(&mut counter),
739                edge_id: arc_ids[(index + 1) % count],
740                forward: false,
741                pcurve: parameter_line(1.0, end, 0.0, end)?,
742            },
743            CoedgeRecord {
744                id: next_id(&mut counter),
745                edge_id: start_edge_ids[index],
746                forward: false,
747                pcurve: parameter_line(0.0, end, 0.0, start)?,
748            },
749        ];
750        let loop_id = next_id(&mut counter);
751        faces.push(FaceRecord {
752            id: next_id(&mut counter),
753            surface: surfaces[index].clone().unwrap(),
754            same_sense: true,
755            loops: vec![LoopRecord {
756                id: loop_id,
757                coedges,
758            }],
759            name: side_name(&profile, index, side_names),
760        });
761    }
762
763    let mut min_radial = f64::INFINITY;
764    let mut max_radial = f64::NEG_INFINITY;
765    let mut min_axis = f64::INFINITY;
766    let mut max_axis = f64::NEG_INFINITY;
767    for curve in &profile.curves {
768        let [start, end] = curve.domain()?;
769        for index in 0..=24 {
770            let delta = curve
771                .evaluate(start + (end - start) * index as f64 / 24.0)?
772                .sub(origin);
773            let radial = delta.dot(profile.radial);
774            let axial = delta.dot(profile.axis);
775            min_radial = min_radial.min(radial);
776            max_radial = max_radial.max(radial);
777            min_axis = min_axis.min(axial);
778            max_axis = max_axis.max(axial);
779        }
780    }
781    let padding = (max_radial - min_radial).max(max_axis - min_axis) * 0.05 + 1e-6;
782    let width = max_radial - min_radial + 2.0 * padding;
783    let height = max_axis - min_axis + 2.0 * padding;
784    let start_origin = origin
785        .add(profile.radial.scale(min_radial - padding))
786        .add(profile.axis.scale(min_axis - padding));
787    let mut start_coedges = Vec::with_capacity(count);
788    for index in 0..count {
789        start_coedges.push(CoedgeRecord {
790            id: next_id(&mut counter),
791            edge_id: start_edge_ids[index],
792            forward: true,
793            pcurve: curve_to_plane_parameters(
794                &profile.curves[index],
795                start_origin,
796                profile.radial,
797                profile.axis,
798            )?,
799        });
800    }
801    let start_loop_id = next_id(&mut counter);
802    faces.push(FaceRecord {
803        id: next_id(&mut counter),
804        surface: make_plane(start_origin, profile.radial, profile.axis, width, height)?,
805        same_sense: true,
806        loops: vec![LoopRecord {
807            id: start_loop_id,
808            coedges: start_coedges,
809        }],
810        name: cap_names.first().cloned().flatten(),
811    });
812
813    let end_radial = rotate_point(origin.add(profile.radial), origin, profile.axis, angle)
814        .sub(origin)
815        .normalized()?;
816    let end_origin = origin
817        .add(end_radial.scale(min_radial - padding))
818        .add(profile.axis.scale(min_axis - padding));
819    let mut end_coedges = Vec::with_capacity(count);
820    for index in (0..count).rev() {
821        end_coedges.push(CoedgeRecord {
822            id: next_id(&mut counter),
823            edge_id: end_edge_ids[index],
824            forward: false,
825            pcurve: curve_to_plane_parameters(
826                &end_curves[index],
827                end_origin,
828                profile.axis,
829                end_radial,
830            )?
831            .reversed()?,
832        });
833    }
834    let end_loop_id = next_id(&mut counter);
835    faces.push(FaceRecord {
836        id: next_id(&mut counter),
837        surface: make_plane(end_origin, profile.axis, end_radial, height, width)?,
838        same_sense: true,
839        loops: vec![LoopRecord {
840            id: end_loop_id,
841            coedges: end_coedges,
842        }],
843        name: cap_names.get(1).cloned().flatten(),
844    });
845
846    let solid = BrepSolid {
847        id: next_id(&mut counter),
848        vertices,
849        edges,
850        shells: vec![ShellRecord {
851            id: next_id(&mut counter),
852            faces,
853        }],
854        genus: 0,
855    };
856    let issues = solid.validate();
857    if issues.is_empty() {
858        Ok(solid)
859    } else {
860        Err(format!("Rust partial revolution is invalid: {issues:?}"))
861    }
862}
863
864#[cfg(test)]
865mod tests {
866    use super::*;
867
868    fn radial_rectangle(inner_radius: f64) -> Vec<NurbsCurve> {
869        let points = [
870            Vec3::new(inner_radius, 0.0, 0.0),
871            Vec3::new(4.0, 0.0, 0.0),
872            Vec3::new(4.0, 3.0, 0.0),
873            Vec3::new(inner_radius, 3.0, 0.0),
874        ];
875        (0..4)
876            .map(|index| make_line(points[index], points[(index + 1) % 4]).unwrap())
877            .collect()
878    }
879
880    #[test]
881    fn full_revolution_handles_axis_edge_and_hollow_profile() {
882        let axis = Vec3::new(0.0, 1.0, 0.0);
883        let solid = revolve_profile_brep(
884            &radial_rectangle(0.0),
885            Vec3::default(),
886            axis,
887            std::f64::consts::TAU,
888        )
889        .unwrap();
890        assert!(solid.validate().is_empty());
891        assert_eq!(solid.genus, 0);
892        let hollow = revolve_profile_brep(
893            &radial_rectangle(2.0),
894            Vec3::default(),
895            axis,
896            std::f64::consts::TAU,
897        )
898        .unwrap();
899        assert!(hollow.validate().is_empty());
900        assert_eq!(hollow.genus, 1);
901    }
902
903    #[test]
904    fn partial_revolution_has_radial_caps() {
905        let solid = revolve_profile_brep(
906            &radial_rectangle(0.0),
907            Vec3::default(),
908            Vec3::new(0.0, 1.0, 0.0),
909            std::f64::consts::PI * 1.25,
910        )
911        .unwrap();
912        assert!(solid.validate().is_empty());
913        assert_eq!(solid.genus, 0);
914    }
915
916    /// Side-face names are aligned with the INPUT curves through both the
917    /// winding normalization (which reverses the loop) and axis-lying
918    /// curves (which produce no side face) — the caller cannot reconstruct
919    /// either from the emitted face order (the extrude wall-naming
920    /// permutation, revolve edition).
921    #[test]
922    fn named_revolve_keeps_names_on_their_input_curves() {
923        // CLOCKWISE variant of radial_rectangle(0.0): the builder reverses
924        // the loop, and the inner_radius=0 edge lies ON the axis and is
925        // skipped.
926        let points = [
927            Vec3::new(0.0, 0.0, 0.0),
928            Vec3::new(0.0, 3.0, 0.0),
929            Vec3::new(4.0, 3.0, 0.0),
930            Vec3::new(4.0, 0.0, 0.0),
931        ];
932        let curves: Vec<NurbsCurve> = (0..4)
933            .map(|index| make_line(points[index], points[(index + 1) % 4]).unwrap())
934            .collect();
935        let side_names: Vec<Option<String>> = ["AXIS", "TOP", "SIDE", "BOTTOM"]
936            .into_iter()
937            .map(|name| Some(name.to_string()))
938            .collect();
939        let cap_names = vec![Some("CAP_START".to_string()), Some("CAP_END".to_string())];
940        let solid = revolve_profile_brep_named(
941            &curves,
942            Vec3::default(),
943            Vec3::new(0.0, 1.0, 0.0),
944            std::f64::consts::PI * 1.25,
945            &side_names,
946            &cap_names,
947        )
948        .unwrap();
949        assert!(solid.validate().is_empty());
950        let faces: Vec<_> = solid.shells[0].faces.iter().collect();
951        // The axis edge produces no face, so AXIS must not appear at all.
952        assert!(faces
953            .iter()
954            .all(|face| face.name.as_deref() != Some("AXIS")));
955        for (name, probe) in [
956            // Midpoints of the revolved input curves at angle 0.
957            ("TOP", Vec3::new(2.0, 3.0, 0.0)),
958            ("SIDE", Vec3::new(4.0, 1.5, 0.0)),
959            ("BOTTOM", Vec3::new(2.0, 0.0, 0.0)),
960        ] {
961            let face = faces
962                .iter()
963                .find(|face| face.name.as_deref() == Some(name))
964                .unwrap_or_else(|| panic!("face {name} missing"));
965            let projection = crate::project_point_to_surface(&face.surface, probe).unwrap();
966            assert!(
967                projection.distance < 1e-9,
968                "{name}: named face does not contain its input curve \
969                 (distance {})",
970                projection.distance
971            );
972        }
973        assert!(faces
974            .iter()
975            .any(|face| face.name.as_deref() == Some("CAP_START")));
976        assert!(faces
977            .iter()
978            .any(|face| face.name.as_deref() == Some("CAP_END")));
979    }
980}