Skip to main content

brep_kernel/construction/sweep_topology/
draft.rs

1use super::*;
2
3/// Straight extrude of a LINE/ARC profile loop with a draft (taper) angle,
4/// built DIRECTLY as a BREP: every wall is the EXACT drafted surface of its
5/// segment — a tilted plane for a line, a cone patch (rational ruled surface
6/// between the source arc and its concentric offset, over one shared angular
7/// window) for a circular arc — and every junction edge is the EXACT
8/// intersection curve of the two adjacent walls.  Because all drafted walls
9/// shrink linearly at the same rate, any two of them intersect in a straight
10/// line (plane∧plane, or a tangent-junction ruling) or a CONIC (plane∧cone and
11/// cone∧cone both reduce to a plane section of a cone — the z² terms of the
12/// squared implicits cancel), so the junction edges are exact rational
13/// quadratics: no lofted approximation anywhere.
14///
15/// Positive `draft_angle_rad` tapers the section INWARD with height (smaller
16/// far cap); negative widens it; 0 reproduces a straight prism.  The far
17/// section is the exact in-plane miter offset of the profile by
18/// d = distance·tan(draft): line segments re-intersect at their offset
19/// corners, arcs shrink/grow concentrically, and mixed junctions re-join at
20/// the offset primitives' intersection.  An offset that exceeds the local
21/// feature size (a collapsed arc, offsets that no longer meet) is a clear Err.
22/// Face order: [walls in INPUT curve order…, START cap (profile plane), END
23/// cap (offset section at +distance)] — the naming contract callers stamp on.
24pub fn extrude_profile_brep_draft(
25    profile: &[NurbsCurve],
26    direction: Vec3,
27    distance: f64,
28    draft_angle_rad: f64,
29    name: Option<&str>,
30) -> Result<BrepSolid, KernelRefusal> {
31    // The builder carries no face names; accept `name` for ABI symmetry with
32    // the other builders (the app stamps names onto the emitted face order).
33    let _ = name;
34    let tolerance = 1e-6;
35    if profile.len() < 2 {
36        return Err(KernelRefusal::input(
37            KernelStage::Collect,
38            "profile_count",
39            "draftExtrude: profile needs at least 2 curves forming a closed loop",
40        ));
41    }
42    if distance.abs() <= 1e-12 {
43        return Err(KernelRefusal::input(
44            KernelStage::Collect,
45            "distance",
46            "draftExtrude: distance must be non-zero",
47        ));
48    }
49    let axis = direction.normalized().map_err(|_| {
50        KernelRefusal::input(
51            KernelStage::Collect,
52            "direction",
53            "draftExtrude: direction is degenerate",
54        )
55    })?;
56
57    // --- 1. Validate the profile: closed + planar; derive origin O + normal np.
58    let mut samples = Vec::new();
59    for (index, curve) in profile.iter().enumerate() {
60        let [start, end] = curve.domain().or_refuse(KernelStage::Fragment, "domain")?;
61        let next = &profile[(index + 1) % profile.len()];
62        let next_start = next.domain().or_refuse(KernelStage::Fragment, "domain")?[0];
63        if curve
64            .evaluate(end).or_refuse(KernelStage::Fragment, "evaluate")?
65            .sub(next.evaluate(next_start).or_refuse(KernelStage::Fragment, "evaluate")?)
66            .length()
67            > tolerance
68        {
69            return Err(KernelRefusal::input(
70                KernelStage::Collect,
71                "profile_closed",
72                format!("draftExtrude: profile is not closed at curve {index}"),
73            ));
74        }
75        for sample in 0..16 {
76            samples.push(curve.evaluate(start + (end - start) * sample as f64 / 16.0).or_refuse(KernelStage::Fragment, "evaluate")?);
77        }
78    }
79    let normal = crate::polygon::newell_normal(&samples);
80    let centroid = samples.iter().fold(Vec3::default(), |sum, &point| sum.add(point));
81    let np = normal.normalized().map_err(|_| {
82        KernelRefusal::input(
83            KernelStage::Collect,
84            "profile_area",
85            "draftExtrude: profile is degenerate (zero enclosed area)",
86        )
87    })?;
88    let origin = centroid.scale(1.0 / samples.len() as f64);
89    if samples
90        .iter()
91        .any(|point| point.sub(origin).dot(np).abs() > tolerance * 100.0)
92    {
93        return Err(KernelRefusal::input(
94            KernelStage::Collect,
95            "profile_planar",
96            "draftExtrude: profile is not planar",
97        ));
98    }
99    // A straight draft-extrude runs along the profile normal.
100    if np.dot(axis).abs() < 0.999 {
101        return Err(KernelRefusal::unsupported(
102            KernelStage::Classify,
103            "oblique_draft",
104            "draftExtrude: extrude direction must be parallel to the profile normal",
105        ));
106    }
107
108    // --- 2. Normalize the loop CCW about the EXTRUDE direction and derive the
109    //     signed in-plane offset d = distance·tan(draft).  In the CCW frame the
110    //     per-segment offset normal zh×t points INWARD, so positive d shrinks
111    //     the far section — algebraically identical to the historical
112    //     winding·distance·tanθ law about the Newell normal, for either profile
113    //     winding and either extrude side.
114    let displacement = axis.scale(distance);
115    let zh = displacement.normalized().or_refuse(KernelStage::Fragment, "normalized")?;
116    let height = displacement.length();
117    let x_axis = zh.perpendicular().or_refuse(KernelStage::Fragment, "perpendicular")?;
118    let y_axis = zh.cross(x_axis).normalized().or_refuse(KernelStage::Fragment, "normalized")?;
119    let mut curves: Vec<NurbsCurve> = profile.to_vec();
120    let reversed_winding = profile_area(&curves, origin, x_axis, y_axis).or_refuse(KernelStage::Fragment, "profile_area")? < 0.0;
121    if reversed_winding {
122        curves = curves
123            .iter()
124            .rev()
125            .map(NurbsCurve::reversed)
126            .collect::<Result<_, _>>().or_refuse(KernelStage::Fragment, "collect")?;
127    }
128    let signed_d = distance * draft_angle_rad.tan();
129
130    // --- 3. Classify segments and compute the exact junction stations: the
131    //     original vertices (offset 0), the mid-height offsets (d/2, the conic
132    //     shoulder witnesses), and the far offsets (d, raised by the extrude
133    //     vector).  Every station is an exact offset-primitive intersection.
134    let segs = classify_profile_segments(&curves, zh)
135        .map_err(|e| e.with_message(|e| format!("draftExtrude: {e}")))?;
136    let count = segs.len();
137    let mut bottom_junctions = Vec::with_capacity(count);
138    let mut top_junctions = Vec::with_capacity(count);
139    let mut mid_junctions = Vec::with_capacity(count);
140    for index in 0..count {
141        let prev = &segs[(index + count - 1) % count];
142        let next = &segs[index];
143        bottom_junctions.push(
144            offset_junction(prev, next, zh, 0.0)
145                .map_err(|e| e.with_message(|e| format!("draftExtrude: {e}")))?,
146        );
147        top_junctions.push(
148            offset_junction(prev, next, zh, signed_d)
149                .map_err(|e| e.with_message(|e| format!("draftExtrude: {e}")))?
150                .add(displacement),
151        );
152        mid_junctions.push(
153            offset_junction(prev, next, zh, signed_d * 0.5)
154                .map_err(|e| e.with_message(|e| format!("draftExtrude: {e}")))?
155                .add(displacement.scale(0.5)),
156        );
157    }
158
159    // --- 4. Junction (side) edges: the EXACT wall∧wall intersection curve — a
160    //     straight line where the mid-height witness is collinear (plane∧plane
161    //     miters, tangent-junction rulings), otherwise the exact conic through
162    //     both endpoints with the analytic surface-gradient end tangents.
163    let mut side_curves = Vec::with_capacity(count);
164    for index in 0..count {
165        side_curves.push(junction_edge_curve(
166            &segs[(index + count - 1) % count],
167            &segs[index],
168            bottom_junctions[index],
169            mid_junctions[index],
170            top_junctions[index],
171            zh,
172            height,
173            signed_d,
174        )?);
175    }
176
177    // --- 5. Per-segment wall geometry: bottom/top boundary curves + the exact
178    //     wall surface.  Arc walls share ONE angular window between the two
179    //     rows so the ruled surface is the exact cone; their boundary edges are
180    //     SUBRANGES of the rows (identical parameterization → parameter-line
181    //     pcurves are pointwise exact).
182    enum WallSurface {
183        /// Affine plane patch + its frame (pcurves = exact plane projection).
184        Plane { origin: Vec3, ex: Vec3, ey: Vec3 },
185        /// Exact cone patch (pcurves for side edges are fitted on-surface).
186        Cone,
187    }
188    let mut wall_surfaces = Vec::with_capacity(count);
189    let mut wall_kinds = Vec::with_capacity(count);
190    let mut bottom_curves = Vec::with_capacity(count);
191    let mut top_curves = Vec::with_capacity(count);
192    for index in 0..count {
193        let next_index = (index + 1) % count;
194        let a0 = bottom_junctions[index];
195        let a1 = bottom_junctions[next_index];
196        let b0 = top_junctions[index];
197        let b1 = top_junctions[next_index];
198        match &segs[index] {
199            SegGeom::Line { dir, .. } => {
200                let up = b0.sub(a0);
201                let ey = up
202                    .sub(dir.scale(up.dot(*dir)))
203                    .normalized()
204                    .map_err(|_| {
205                        KernelRefusal::internal(
206                            KernelStage::Fragment,
207                            "wall_frame",
208                            "draftExtrude: wall plane frame is degenerate",
209                        )
210                    })?;
211                // Patch extent: the four junctions plus BOTH side curves'
212                // control points (a conic bulges past its chord; the control
213                // polygon's convex hull bounds it).
214                let mut points = vec![a0, a1, b0, b1];
215                for side in [&side_curves[index], &side_curves[next_index]] {
216                    for control in &side.control_points {
217                        points.push(control.point().or_refuse(KernelStage::Fragment, "control_point")?);
218                    }
219                }
220                let mut min_x = f64::INFINITY;
221                let mut min_y = f64::INFINITY;
222                let mut max_x = f64::NEG_INFINITY;
223                let mut max_y = f64::NEG_INFINITY;
224                for point in &points {
225                    let delta = point.sub(a0);
226                    min_x = min_x.min(delta.dot(*dir));
227                    max_x = max_x.max(delta.dot(*dir));
228                    min_y = min_y.min(delta.dot(ey));
229                    max_y = max_y.max(delta.dot(ey));
230                }
231                let padding = (max_x - min_x).max(max_y - min_y) * 0.05 + 1e-6;
232                let patch_origin = a0
233                    .add(dir.scale(min_x - padding))
234                    .add(ey.scale(min_y - padding));
235                wall_surfaces.push(make_plane(
236                    patch_origin,
237                    *dir,
238                    ey,
239                    max_x - min_x + 2.0 * padding,
240                    max_y - min_y + 2.0 * padding,
241                ).or_refuse(KernelStage::Fragment, "make_plane")?);
242                wall_kinds.push(WallSurface::Plane {
243                    origin: patch_origin,
244                    ex: *dir,
245                    ey,
246                });
247                bottom_curves.push(make_line(a0, a1).or_refuse(KernelStage::Fragment, "make_line")?);
248                top_curves.push(make_line(b0, b1).or_refuse(KernelStage::Fragment, "make_line")?);
249            }
250            SegGeom::Arc {
251                center,
252                radius,
253                turn,
254                arc_normal,
255                ..
256            } => {
257                let in_plane = |p: Vec3| {
258                    let rel = p.sub(*center);
259                    rel.sub(zh.scale(rel.dot(zh)))
260                };
261                let ax = in_plane(a0).normalized().or_refuse(KernelStage::Fragment, "normalized")?;
262                let ay = arc_normal.cross(ax).normalized().or_refuse(KernelStage::Fragment, "normalized")?;
263                let angle_near = |p: Vec3, near: f64| {
264                    let ve = in_plane(p);
265                    let mut angle = ve.dot(ay).atan2(ve.dot(ax));
266                    while angle < near - std::f64::consts::PI {
267                        angle += std::f64::consts::TAU;
268                    }
269                    while angle > near + std::f64::consts::PI {
270                        angle -= std::f64::consts::TAU;
271                    }
272                    angle
273                };
274                let mut sweep = in_plane(a1).dot(ay).atan2(in_plane(a1).dot(ax));
275                if sweep <= 1e-9 {
276                    sweep += std::f64::consts::TAU;
277                }
278                let phi0 = angle_near(b0, 0.0);
279                let phi1 = angle_near(b1, sweep);
280                let theta_lo = 0.0_f64.min(phi0);
281                let theta_hi = sweep.max(phi1);
282                if theta_hi - theta_lo > std::f64::consts::TAU {
283                    return Err(KernelRefusal::internal(
284                        KernelStage::Fragment,
285                        "arc_window",
286                        "draftExtrude: a drafted arc's trimmed window exceeds a full circle",
287                    ));
288                }
289                let r_offset = radius - signed_d * turn;
290                if r_offset <= tolerance {
291                    return Err(KernelRefusal::input(
292                        KernelStage::Classify,
293                        "arc_collapse",
294                        "draftExtrude: offset: distance is too large — a concave arc collapses",
295                    ));
296                }
297                let row_bottom = make_arc(*center, ax, ay, *radius, theta_lo, theta_hi).or_refuse(KernelStage::Fragment, "make_arc")?;
298                let row_top =
299                    make_arc(center.add(displacement), ax, ay, r_offset, theta_lo, theta_hi).or_refuse(KernelStage::Fragment, "make_arc")?;
300                wall_surfaces.push(ruled_between(&row_bottom, &row_top)?);
301                wall_kinds.push(WallSurface::Cone);
302                bottom_curves.push(arc_window_subrange(&row_bottom, a0, a1)?);
303                top_curves.push(arc_window_subrange(&row_top, b0, b1)?);
304            }
305        }
306    }
307
308    // --- 6. Assemble the BREP: shared vertices/edges, wall faces with exact
309    //     pcurves (plane projection on plane walls, parameter lines for cone
310    //     row edges, on-surface fits for cone side edges), and the two planar
311    //     caps — the same topology the straight extrude emits.
312    let mut vertices = Vec::with_capacity(2 * count);
313    for (index, point) in bottom_junctions.iter().enumerate() {
314        vertices.push(VertexRecord {
315            id: index as u64 + 1,
316            point: *point,
317        });
318    }
319    for (index, point) in top_junctions.iter().enumerate() {
320        vertices.push(VertexRecord {
321            id: (count + index) as u64 + 1,
322            point: *point,
323        });
324    }
325    let mut edges = Vec::with_capacity(3 * count);
326    for index in 0..count {
327        let [b_start, b_end] = bottom_curves[index].domain().or_refuse(KernelStage::Fragment, "domain")?;
328        edges.push(EdgeRecord {
329            id: 10 + index as u64,
330            curve: bottom_curves[index].clone(),
331            t0: b_start,
332            t1: b_end,
333            start_vertex_id: index as u64 + 1,
334            end_vertex_id: ((index + 1) % count) as u64 + 1,
335            degenerate: false,
336            name: None,
337        });
338        let [t_start, t_end] = top_curves[index].domain().or_refuse(KernelStage::Fragment, "domain")?;
339        edges.push(EdgeRecord {
340            id: 10 + count as u64 + index as u64,
341            curve: top_curves[index].clone(),
342            t0: t_start,
343            t1: t_end,
344            start_vertex_id: (count + index) as u64 + 1,
345            end_vertex_id: (count + (index + 1) % count) as u64 + 1,
346            degenerate: false,
347            name: None,
348        });
349        let [s_start, s_end] = side_curves[index].domain().or_refuse(KernelStage::Fragment, "domain")?;
350        edges.push(EdgeRecord {
351            id: 10 + 2 * count as u64 + index as u64,
352            curve: side_curves[index].clone(),
353            t0: s_start,
354            t1: s_end,
355            start_vertex_id: index as u64 + 1,
356            end_vertex_id: (count + index) as u64 + 1,
357            degenerate: false,
358            name: None,
359        });
360    }
361
362    let mut next_id = 1000_u64;
363    let mut faces = Vec::with_capacity(count + 2);
364    for index in 0..count {
365        let next_index = (index + 1) % count;
366        let surface = &wall_surfaces[index];
367        // Traversal-order pcurves for [bottom fwd, side_next up, top rev,
368        // side_this down] on this wall's surface.
369        let (pc_bottom, pc_side_up, pc_top, pc_side_down) = match &wall_kinds[index] {
370            WallSurface::Plane { origin, ex, ey } => (
371                curve_to_plane_parameters(&bottom_curves[index], *origin, *ex, *ey).or_refuse(KernelStage::Fragment, "plane_parameters")?,
372                curve_to_plane_parameters(&side_curves[next_index], *origin, *ex, *ey).or_refuse(KernelStage::Fragment, "plane_parameters")?,
373                curve_to_plane_parameters(&top_curves[index], *origin, *ex, *ey).or_refuse(KernelStage::Fragment, "plane_parameters")?.reversed().or_refuse(KernelStage::Fragment, "reversed")?,
374                curve_to_plane_parameters(&side_curves[index], *origin, *ex, *ey).or_refuse(KernelStage::Fragment, "plane_parameters")?.reversed().or_refuse(KernelStage::Fragment, "reversed")?,
375            ),
376            WallSurface::Cone => {
377                let [b0, b1] = bottom_curves[index].domain().or_refuse(KernelStage::Fragment, "domain")?;
378                let [t0, t1] = top_curves[index].domain().or_refuse(KernelStage::Fragment, "domain")?;
379                (
380                    parameter_line(b0, 0.0, b1, 0.0).or_refuse(KernelStage::Fragment, "parameter_line")?,
381                    build_pcurve_on_surface(surface, &side_curves[next_index]).or_refuse(KernelStage::Fragment, "pcurve")?,
382                    parameter_line(t1, 1.0, t0, 1.0).or_refuse(KernelStage::Fragment, "parameter_line")?,
383                    build_pcurve_on_surface(surface, &side_curves[index]).or_refuse(KernelStage::Fragment, "pcurve")?.reversed().or_refuse(KernelStage::Fragment, "reversed")?,
384                )
385            }
386        };
387        let coedges = vec![
388            CoedgeRecord {
389                id: next_id,
390                edge_id: 10 + index as u64,
391                forward: true,
392                pcurve: pc_bottom,
393            },
394            CoedgeRecord {
395                id: next_id + 1,
396                edge_id: 10 + 2 * count as u64 + next_index as u64,
397                forward: true,
398                pcurve: pc_side_up,
399            },
400            CoedgeRecord {
401                id: next_id + 2,
402                edge_id: 10 + count as u64 + index as u64,
403                forward: false,
404                pcurve: pc_top,
405            },
406            CoedgeRecord {
407                id: next_id + 3,
408                edge_id: 10 + 2 * count as u64 + index as u64,
409                forward: false,
410                pcurve: pc_side_down,
411            },
412        ];
413        next_id += 4;
414        faces.push(FaceRecord {
415            id: next_id + 1,
416            surface: wall_surfaces[index].clone(),
417            same_sense: true,
418            loops: vec![LoopRecord {
419                id: next_id,
420                coedges,
421            }],
422            name: None,
423        });
424        next_id += 2;
425    }
426    if reversed_winding {
427        // The winding normalization reversed the curve loop above; callers
428        // stamp wall names by INPUT-curve order — emit walls in input order.
429        faces.reverse();
430    }
431
432    // Caps: planar bbox patches over each section's own footprint.
433    let mut cap = |curves: &[NurbsCurve],
434                   edge_base: u64,
435                   forward: bool,
436                   next_id: &mut u64|
437     -> Result<FaceRecord, KernelRefusal> {
438        let mut min_x = f64::INFINITY;
439        let mut min_y = f64::INFINITY;
440        let mut max_x = f64::NEG_INFINITY;
441        let mut max_y = f64::NEG_INFINITY;
442        let mut plane_point = None;
443        for curve in curves {
444            let [start, end] = curve.domain().or_refuse(KernelStage::Fragment, "domain")?;
445            for sample in 0..=16 {
446                let point = curve.evaluate(start + (end - start) * sample as f64 / 16.0).or_refuse(KernelStage::Fragment, "evaluate")?;
447                let anchor = *plane_point.get_or_insert(point);
448                let delta = point.sub(anchor);
449                min_x = min_x.min(delta.dot(x_axis));
450                max_x = max_x.max(delta.dot(x_axis));
451                min_y = min_y.min(delta.dot(y_axis));
452                max_y = max_y.max(delta.dot(y_axis));
453            }
454        }
455        let anchor = plane_point.ok_or_else(|| {
456            KernelRefusal::internal(
457                KernelStage::Fragment,
458                "cap_samples",
459                "draftExtrude: cap has no boundary samples",
460            )
461        })?;
462        let padding = (max_x - min_x).max(max_y - min_y) * 0.05 + 1e-6;
463        let cap_origin = anchor
464            .add(x_axis.scale(min_x - padding))
465            .add(y_axis.scale(min_y - padding));
466        let mut coedges = Vec::with_capacity(curves.len());
467        if forward {
468            for (index, curve) in curves.iter().enumerate() {
469                coedges.push(CoedgeRecord {
470                    id: *next_id,
471                    edge_id: edge_base + index as u64,
472                    forward: true,
473                    pcurve: curve_to_plane_parameters(curve, cap_origin, x_axis, y_axis).or_refuse(KernelStage::Fragment, "plane_parameters")?,
474                });
475                *next_id += 1;
476            }
477        } else {
478            for index in (0..curves.len()).rev() {
479                coedges.push(CoedgeRecord {
480                    id: *next_id,
481                    edge_id: edge_base + index as u64,
482                    forward: false,
483                    pcurve: curve_to_plane_parameters(&curves[index], cap_origin, x_axis, y_axis).or_refuse(KernelStage::Fragment, "plane_parameters")?
484                        .reversed().or_refuse(KernelStage::Fragment, "reversed")?,
485                });
486                *next_id += 1;
487            }
488        }
489        let loop_id = *next_id;
490        let face_id = *next_id + 1;
491        *next_id += 2;
492        Ok(FaceRecord {
493            id: face_id,
494            surface: make_plane(
495                cap_origin,
496                x_axis,
497                y_axis,
498                max_x - min_x + 2.0 * padding,
499                max_y - min_y + 2.0 * padding,
500            ).or_refuse(KernelStage::Fragment, "make_plane")?,
501            same_sense: forward,
502            loops: vec![LoopRecord {
503                id: loop_id,
504                coedges,
505            }],
506            name: None,
507        })
508    };
509    faces.push(cap(&bottom_curves, 10, false, &mut next_id)?);
510    faces.push(cap(&top_curves, 10 + count as u64, true, &mut next_id)?);
511
512    let solid = BrepSolid {
513        mass_properties_cache: Default::default(),
514        id: next_id + 1,
515        vertices,
516        edges,
517        shells: vec![ShellRecord { id: next_id, faces }],
518        genus: 0,
519    };
520    let issues = solid.validate();
521    if issues.is_empty() {
522        Ok(solid)
523    } else {
524        Err(KernelRefusal::internal(
525            KernelStage::Validate,
526            "validate",
527            format!("Rust draft-extrude builder produced invalid topology: {issues:?}"),
528        ))
529    }
530}