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