Skip to main content

brep_kernel/blending/blend/chain/
closed.rs

1use super::*;
2
3/// Piece of a support rim assigned to one mate face.
4pub(super) struct RimPiece {
5    pub(super) face_id: u64,
6    pub(super) loop_index: usize,
7    /// Global-parameter window of this piece.
8    pub(super) window: [f64; 2],
9    pub(super) edge_id: u64,
10}
11
12/// Blend a chain of conjugated edges with one rolling-ball blend face
13/// (Golovanov §6.9.5: all conjugated edges processed together).  A CLOSED
14/// chain (stadium rim, T-pipe saddle) welds into a periodic blend; an OPEN
15/// chain (line->arc->line capped by end faces) rolls a clamped blend that
16/// terminates in a transverse edge on each end face.
17pub fn blend_smooth_chain(
18    solid: &BrepSolid,
19    seed_edge_id: u64,
20    radius: f64,
21    chamfer: bool,
22    name: Option<&str>,
23) -> Result<BrepSolid, String> {
24    if !(radius > 0.0) || !radius.is_finite() {
25        return Err("blend: radius must be positive".into());
26    }
27    let chain = collect_smooth_chain(solid, seed_edge_id)?;
28    if chain.segments.len() < 2 {
29        return Err("blend: chain collapsed to a single segment".into());
30    }
31    if chain.closed {
32        blend_closed_smooth_chain(solid, &chain.segments, radius, chamfer, name)
33    } else {
34        blend_open_smooth_chain(solid, &chain, radius, chamfer, name)
35    }
36}
37
38/// `blend_smooth_chain` restricted to CLOSED chains: an edge that is one arc
39/// of a seam-split rim (a cyl×cyl saddle) is blended as the whole rim, but an
40/// OPEN tangent chain is not walked — filleting one edge of it must not
41/// fillet the unselected edges it runs into.  Tangent propagation is a
42/// selection policy for the feature layer; the kernel caps an unselected
43/// continuation instead (`network.rs`).
44pub fn blend_smooth_chain_if_closed(
45    solid: &BrepSolid,
46    seed_edge_id: u64,
47    radius: f64,
48    chamfer: bool,
49    name: Option<&str>,
50) -> Result<BrepSolid, String> {
51    if !(radius > 0.0) || !radius.is_finite() {
52        return Err("blend: radius must be positive".into());
53    }
54    let chain = collect_smooth_chain(solid, seed_edge_id)?;
55    if chain.segments.len() < 2 {
56        return Err("blend: chain collapsed to a single segment".into());
57    }
58    if !chain.closed {
59        return Err(
60            "blend: the edge is one arc of an OPEN tangent chain; the unselected \
61             continuation is capped, not blended"
62                .into(),
63        );
64    }
65    blend_closed_smooth_chain(solid, &chain.segments, radius, chamfer, name)
66}
67
68/// Blend a CLOSED chain of conjugated edges with one rolling-ball blend
69/// face (Golovanov §6.9.5: all conjugated edges processed together).
70fn blend_closed_smooth_chain(
71    solid: &BrepSolid,
72    segments: &[ChainSegment<'_>],
73    radius: f64,
74    chamfer: bool,
75    name: Option<&str>,
76) -> Result<BrepSolid, String> {
77    let samples = march_chain(segments, radius, false)?;
78
79    // Global rows from the in-segment samples (wrapped-overlap closed fit).
80    let mut global: Vec<(f64, &ChainSample)> = samples
81        .iter()
82        .filter(|sample| (0..CHAIN_PER_SEGMENT as isize).contains(&sample.position))
83        .map(|sample| (sample.parameter.rem_euclid(1.0), sample))
84        .collect();
85    global.sort_by(|a, b| a.0.total_cmp(&b.0));
86    let count = global.len();
87    let degree = FIT_DEGREE;
88    let mut extended_params = Vec::new();
89    let mut samples_cr = Vec::new();
90    let mut samples_mid = Vec::new();
91    let mut samples_cs = Vec::new();
92    let mut push = |station: &Station, parameter: f64| {
93        extended_params.push(parameter);
94        samples_cr.push(Vec4::from_point(station.p1, 1.0));
95        samples_cs.push(Vec4::from_point(station.p2, 1.0));
96        samples_mid.push(Vec4 {
97            x: station.apex.x * station.weight,
98            y: station.apex.y * station.weight,
99            z: station.apex.z * station.weight,
100            w: station.weight,
101        });
102    };
103    for offset in (1..=degree).rev() {
104        let (parameter, sample) = &global[count - offset];
105        push(&sample.station, parameter - 1.0);
106    }
107    for (parameter, sample) in &global {
108        push(&sample.station, *parameter);
109    }
110    // Close the loop: repeat the first station at parameter 1, then the
111    // wrap continuation.
112    push(&global[0].1.station, global[0].0 + 1.0);
113    for offset in 1..=degree {
114        let (parameter, sample) = &global[offset];
115        push(&sample.station, parameter + 1.0);
116    }
117    let low = extended_params[0];
118    let high = *extended_params.last().unwrap();
119    let range = high - low;
120    let normalized: Vec<f64> = extended_params
121        .iter()
122        .map(|parameter| (parameter - low) / range)
123        .collect();
124    let seam_low = (0.0 - low) / range;
125    let seam_high = (1.0 - low) / range;
126    let fit_row = |row: &[Vec4]| -> Result<NurbsCurve, String> {
127        let curve = fit::interpolate_homogeneous(row, degree, &normalized)?;
128        let (_, tail) = curve.split(seam_low)?;
129        let (middle, _) = tail.split(seam_high)?;
130        Ok(middle)
131    };
132    let cr = fit_row(&samples_cr)?;
133    let cs = fit_row(&samples_cs)?;
134    let mid = if chamfer {
135        None
136    } else {
137        Some(fit_row(&samples_mid)?)
138    };
139    let u_domain = cr.domain()?;
140    let rows_u = cr.control_points.len();
141    let mut control = Vec::with_capacity(rows_u);
142    for index in 0..rows_u {
143        let mut column = vec![cr.control_points[index]];
144        if let Some(mid) = &mid {
145            column.push(mid.control_points[index]);
146        }
147        column.push(cs.control_points[index]);
148        control.push(column);
149    }
150    let last = rows_u - 1;
151    for column_index in 0..control[0].len() {
152        control[last][column_index] = control[0][column_index];
153    }
154    let (degree_v, knots_v) = if chamfer {
155        (1usize, vec![0.0, 0.0, 1.0, 1.0])
156    } else {
157        (2usize, vec![0.0, 0.0, 0.0, 1.0, 1.0, 1.0])
158    };
159    let surface = NurbsSurface::new(degree, degree_v, cr.knots.clone(), knots_v, control)?;
160
161    // Single-face sides (e.g. one cap around the whole rim) get ONE uv
162    // fit in the rows' own normalized space — identical parameterization
163    // to cr/cs, so the closed support piece and its pcurve agree exactly.
164    let single_face = |side: usize| -> bool {
165        let first_id = if side == 0 {
166            segments[0].first.face.id
167        } else {
168            segments[0].second.face.id
169        };
170        segments.iter().all(|segment| {
171            let id = if side == 0 {
172                segment.first.face.id
173            } else {
174                segment.second.face.id
175            };
176            id == first_id
177        })
178    };
179    let mut whole_pcurves: [Option<NurbsCurve>; 2] = [None, None];
180    for side in 0..2 {
181        if !single_face(side) {
182            continue;
183        }
184        let mut row = Vec::new();
185        let select = |station: &Station| -> [f64; 2] {
186            if side == 0 {
187                station.uv1
188            } else {
189                station.uv2
190            }
191        };
192        let mut push_uv = |station: &Station| {
193            let uv = select(station);
194            row.push(Vec4::from_point(Vec3::new(uv[0], uv[1], 0.0), 1.0));
195        };
196        for offset in (1..=degree).rev() {
197            push_uv(&global[count - offset].1.station);
198        }
199        for (_, sample) in &global {
200            push_uv(&sample.station);
201        }
202        push_uv(&global[0].1.station);
203        for offset in 1..=degree {
204            push_uv(&global[offset].1.station);
205        }
206        whole_pcurves[side] = Some(fit_row(&row)?);
207    }
208
209    // Per-face pcurve fits over each segment's full sample window
210    // (in-segment + overshoot), in global parameters.
211    let mut pcurves1 = Vec::with_capacity(segments.len());
212    let mut pcurves2 = Vec::with_capacity(segments.len());
213    for segment in 0..segments.len() {
214        let mut window: Vec<&ChainSample> = samples
215            .iter()
216            .filter(|sample| sample.segment == segment)
217            .collect();
218        window.sort_by(|a, b| a.parameter.total_cmp(&b.parameter));
219        let params: Vec<f64> = window.iter().map(|sample| sample.parameter).collect();
220        let low = params[0];
221        let high = *params.last().unwrap();
222        let normalized: Vec<f64> = params
223            .iter()
224            .map(|parameter| (parameter - low) / (high - low))
225            .collect();
226        let fit_uv = |select: &dyn Fn(&Station) -> [f64; 2]| -> Result<NurbsCurve, String> {
227            let points: Vec<Vec4> = window
228                .iter()
229                .map(|sample| {
230                    let uv = select(&sample.station);
231                    Vec4::from_point(Vec3::new(uv[0], uv[1], 0.0), 1.0)
232                })
233                .collect();
234            let curve = fit::interpolate_homogeneous(&points, degree, &normalized)?;
235            // Re-express in GLOBAL parameters: the curve's [0,1] domain
236            // corresponds to [low, high]; keep as-is and remember the
237            // affine map through the window bounds.
238            Ok(curve)
239        };
240        pcurves1.push((low, high, fit_uv(&|station| station.uv1)?));
241        pcurves2.push((low, high, fit_uv(&|station| station.uv2)?));
242    }
243
244    chain_surgery(
245        solid,
246        segments,
247        ChainRows {
248            surface,
249            cr,
250            cs,
251            u_domain,
252            fit_low: low,
253            fit_range: range,
254            pcurves1,
255            pcurves2,
256            whole_pcurves,
257        },
258        name,
259    )
260}
261
262pub(super) struct ChainRows {
263    pub(super) surface: NurbsSurface,
264    pub(super) cr: NurbsCurve,
265    pub(super) cs: NurbsCurve,
266    pub(super) u_domain: [f64; 2],
267    /// Affine map from the rows' fit space to GLOBAL chain parameters:
268    /// global = fit_low + p * fit_range (the global fit normalised its
269    /// wrap-extended parameters onto [0, 1] before interpolating).
270    pub(super) fit_low: f64,
271    pub(super) fit_range: f64,
272    /// Per-segment (window_low, window_high, uv curve over [0,1]) in
273    /// GLOBAL parameters.
274    pub(super) pcurves1: Vec<(f64, f64, NurbsCurve)>,
275    pub(super) pcurves2: Vec<(f64, f64, NurbsCurve)>,
276    /// Whole-chain uv fits (rows' fit space) for single-face sides.
277    pub(super) whole_pcurves: [Option<NurbsCurve>; 2],
278}
279
280impl ChainRows {
281    pub(super) fn to_global(&self, fit_parameter: f64) -> f64 {
282        self.fit_low + fit_parameter * self.fit_range
283    }
284}
285
286/// Extract the pcurve portion for a global window [a, b] from a
287/// segment's fitted uv curve, re-parameterised so its domain maps
288/// affinely onto the portion (matching the split support edge).
289pub(super) fn pcurve_portion(fitted: &(f64, f64, NurbsCurve), a: f64, b: f64) -> Result<NurbsCurve, String> {
290    let (low, high, curve) = fitted;
291    // The chain parameterisation wraps with period 1; pick the period
292    // copy of the window that overlaps this segment's fit range.
293    let to_local = |value: f64| {
294        let mut best = value;
295        for candidate in [value, value + 1.0, value - 1.0] {
296            if candidate >= low - 1e-9 && candidate <= high + 1e-9 {
297                best = candidate;
298                break;
299            }
300        }
301        ((best - low) / (high - low)).clamp(0.0, 1.0)
302    };
303    let mut local_a = to_local(a);
304    let mut local_b = to_local(b);
305    if local_a > local_b {
306        std::mem::swap(&mut local_a, &mut local_b);
307    }
308    let epsilon = 1e-9;
309    let (_, tail) = if local_a > epsilon {
310        curve.split(local_a)?
311    } else {
312        (curve.clone(), curve.clone())
313    };
314    let tail = if local_a > epsilon {
315        tail
316    } else {
317        curve.clone()
318    };
319    let portion = if local_b < 1.0 - epsilon {
320        tail.split(local_b)?.0
321    } else {
322        tail
323    };
324    Ok(portion)
325}
326
327/// The single boundary edge of a side's mate face(s) incident to a chain
328/// vertex, EXCLUDING the two chain edges meeting there — i.e. the SEAM
329/// (same face on both segments) or the SPOKE (face changes) that the
330/// support curve must cross.  `None` when the chain simply flows through
331/// (same face, no seam — e.g. a stadium cap rim vertex).
332pub(super) fn cross_edge_at(
333    solid: &BrepSolid,
334    face_before: &FaceRecord,
335    loop_before: usize,
336    face_after: &FaceRecord,
337    loop_after: usize,
338    vertex: u64,
339    before_edge: u64,
340    after_edge: u64,
341) -> Result<Option<u64>, String> {
342    let mut found: Option<u64> = None;
343    let mut consider = |face: &FaceRecord, loop_index: usize| -> Result<(), String> {
344        for coedge in &face.loops[loop_index].coedges {
345            if coedge.edge_id == before_edge || coedge.edge_id == after_edge {
346                continue;
347            }
348            let edge = solid
349                .edges
350                .iter()
351                .find(|edge| edge.id == coedge.edge_id)
352                .ok_or("blend: loop references missing edge")?;
353            if edge.start_vertex_id == vertex || edge.end_vertex_id == vertex {
354                if found.is_some() && found != Some(edge.id) {
355                    return Err("blend: multiple cross edges at a chain vertex".into());
356                }
357                found = Some(edge.id);
358            }
359        }
360        Ok(())
361    };
362    consider(face_before, loop_before)?;
363    if !(face_after.id == face_before.id && loop_after == loop_before) {
364        consider(face_after, loop_after)?;
365    }
366    Ok(found)
367}
368
369/// The nearest periodic image of a seam boundary to `value` — the boundary a
370/// crossing endpoint must be pinned to, read off its ADJACENT INTERIOR sample.
371fn nearest_seam_image(value: f64, low: f64, high: f64) -> f64 {
372    let period = high - low;
373    low + ((value - low) / period).round() * period
374}
375
376/// The mate-face pcurve of a support PIECE, built by projecting the piece's
377/// 3D curve onto the (analytic) mate surface and fitting.  Because a chain
378/// support rides EXACTLY on its mate carrier, the projection is exact; the
379/// periodic track is unwrapped so it never jumps a period across the carrier
380/// seam, and crossing endpoints are pinned ONTO the seam so the piece and the
381/// trimmed seam edge close the loop in parameter space.
382///
383/// The carrier is closed in EITHER direction, and the crossed seam is not
384/// always the u meridian.  A cylinder/cone/sphere is closed in u only, so its
385/// seam is the u0 ≡ u1 meridian — but a TORUS is biperiodic, and a tube welded
386/// through a plane crosses the torus' v0 ≡ v1 PARALLEL (its outer equator)
387/// instead.  Pinning u there drags the endpoint a half-carrier away from its
388/// own 3D vertex (the collar loop then closes on a uv corner that is nowhere
389/// near the vertex, and the mate face's trim is garbage), so the pinned AXIS is
390/// chosen GEOMETRICALLY: whichever closed direction's seam actually passes
391/// through the endpoint.  A carrier closed in u only can only ever take the u
392/// branch, exactly as before.
393pub(super) fn project_piece_pcurve(
394    piece: &NurbsCurve,
395    surface: &NurbsSurface,
396    start_is_crossing: bool,
397    end_is_crossing: bool,
398) -> Result<NurbsCurve, String> {
399    let [u0, u1] = surface.domain_u()?;
400    let [v0, v1] = surface.domain_v()?;
401    let (_, closed_v) = surface.closed_directions()?;
402    let period = u1 - u0;
403    let period_v = v1 - v0;
404    let [d0, d1] = piece.domain()?;
405    // Dense reference projection; the pcurve is fit from an adaptively
406    // thinned subset kept as coarse as tolerance allows.
407    const DENSE: usize = 96;
408    let mut proj_u = Vec::with_capacity(DENSE + 1);
409    let mut proj_v = Vec::with_capacity(DENSE + 1);
410    let mut point3 = Vec::with_capacity(DENSE + 1);
411    let mut params = Vec::with_capacity(DENSE + 1);
412    let mut previous_u: Option<f64> = None;
413    for section in 0..=DENSE {
414        let t = d0 + (d1 - d0) * section as f64 / DENSE as f64;
415        let point = piece.evaluate(t)?;
416        let projection = crate::project_point_to_surface(surface, point)?;
417        let mut u = projection.u;
418        if let Some(previous) = previous_u {
419            while u - previous > period * 0.5 {
420                u -= period;
421            }
422            while previous - u > period * 0.5 {
423                u += period;
424            }
425        }
426        previous_u = Some(u);
427        proj_u.push(u);
428        proj_v.push(projection.v);
429        point3.push(point);
430        params.push(section as f64 / DENSE as f64);
431    }
432    // A biperiodic carrier needs the SAME unwrap in v, or a track that walks up
433    // to the v seam comes back as 0 at the far end and the fit swings across
434    // the whole carrier.  Anchor the chain on the first INTERIOR sample and
435    // pull index 0 onto it afterwards: an endpoint sitting exactly ON the seam
436    // inverts to either boundary at the solver's whim, and letting that
437    // coin flip anchor the chain would shift the whole piece a period.
438    if closed_v {
439        for index in 2..=DENSE {
440            while proj_v[index] - proj_v[index - 1] > period_v * 0.5 {
441                proj_v[index] -= period_v;
442            }
443            while proj_v[index - 1] - proj_v[index] > period_v * 0.5 {
444                proj_v[index] += period_v;
445            }
446        }
447        while proj_v[0] - proj_v[1] > period_v * 0.5 {
448            proj_v[0] -= period_v;
449        }
450        while proj_v[1] - proj_v[0] > period_v * 0.5 {
451            proj_v[0] += period_v;
452        }
453    }
454    // Interior mean decides which meridian a crossing endpoint sits on, and
455    // whether the whole track needs a full-period shift into the domain.
456    let mean_u: f64 = proj_u[1..DENSE].iter().sum::<f64>() / (DENSE - 1) as f64;
457    let seam_u = if mean_u - u0 > u1 - mean_u { u1 } else { u0 };
458    // Pin a crossing endpoint onto the seam it actually crosses.  Each
459    // candidate keeps the OTHER coordinate as projected, so the losing axis
460    // moves the endpoint bodily off its own 3D vertex — the residual names the
461    // winner with no surface-type special-casing.  u keeps the whole-track
462    // mean; a v run can legitimately cover a full period, so its boundary is
463    // read off the ADJACENT INTERIOR sample instead.  Both pins land in the
464    // track's own (pre-shift) period, so the shift below carries them along
465    // with the rest of the track.
466    let pinned = |index: usize,
467                  neighbour: usize,
468                  proj_u: &[f64],
469                  proj_v: &[f64]|
470     -> Result<(f64, f64), String> {
471        let point = point3[index];
472        let u_error = surface
473            .evaluate(seam_u, proj_v[index])?
474            .sub(point)
475            .length();
476        if closed_v {
477            let seam_v = nearest_seam_image(proj_v[neighbour], v0, v1);
478            let v_error = surface
479                .evaluate(proj_u[index], seam_v)?
480                .sub(point)
481                .length();
482            if v_error < u_error {
483                return Ok((proj_u[index], seam_v));
484            }
485        }
486        Ok((seam_u, proj_v[index]))
487    };
488    if start_is_crossing {
489        let (u, v) = pinned(0, 1, &proj_u, &proj_v)?;
490        proj_u[0] = u;
491        proj_v[0] = v;
492    }
493    if end_is_crossing {
494        let (u, v) = pinned(DENSE, DENSE - 1, &proj_u, &proj_v)?;
495        proj_u[DENSE] = u;
496        proj_v[DENSE] = v;
497    }
498    let mut shift = 0.0;
499    while mean_u + shift > u1 + 1e-9 {
500        shift -= period;
501    }
502    while mean_u + shift < u0 - 1e-9 {
503        shift += period;
504    }
505    for u in proj_u.iter_mut() {
506        *u += shift;
507    }
508    if closed_v {
509        let mean_v: f64 = proj_v[1..DENSE].iter().sum::<f64>() / (DENSE - 1) as f64;
510        let mut shift_v = 0.0;
511        while mean_v + shift_v > v1 + 1e-9 {
512            shift_v -= period_v;
513        }
514        while mean_v + shift_v < v0 - 1e-9 {
515            shift_v += period_v;
516        }
517        for v in proj_v.iter_mut() {
518            *v += shift_v;
519        }
520    }
521    // Coarsest interpolation whose fitted pcurve stays inside HALF the
522    // validator's pcurve/edge tolerance everywhere along the dense track.
523    let tolerance = 0.002;
524    let mut best: Option<NurbsCurve> = None;
525    for sections in [8usize, 12, 16, 24, 32, 48, 64, 96] {
526        if sections > DENSE {
527            break;
528        }
529        let indices: Vec<usize> = (0..=sections)
530            .map(|k| (k * DENSE / sections).min(DENSE))
531            .collect();
532        let points: Vec<Vec4> = indices
533            .iter()
534            .map(|&i| Vec4::from_point(Vec3::new(proj_u[i], proj_v[i], 0.0), 1.0))
535            .collect();
536        let knot_params: Vec<f64> = indices.iter().map(|&i| params[i]).collect();
537        let curve = fit::interpolate_homogeneous(&points, FIT_DEGREE, &knot_params)?;
538        let mut max_deviation: f64 = 0.0;
539        for i in 0..=DENSE {
540            let uv = curve.evaluate(params[i])?;
541            let on_surface = surface.evaluate(uv.x, uv.y)?;
542            max_deviation = max_deviation.max(on_surface.sub(point3[i]).length());
543        }
544        best = Some(curve);
545        if max_deviation <= tolerance {
546            break;
547        }
548    }
549    best.ok_or_else(|| "blend: piece pcurve projection failed".to_string())
550}