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