Skip to main content

brep_kernel/construction/
revolve_topology.rs

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