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 surface = crate::blend::rows::surface_from_rows(degree, &cr, &cs, mid.as_ref(), true)?;
141
142    // Single-face sides (e.g. one cap around the whole rim) get ONE uv
143    // fit in the rows' own normalized space — identical parameterization
144    // to cr/cs, so the closed support piece and its pcurve agree exactly.
145    let single_face = |side: usize| -> bool {
146        let first_id = segments[0].mate(side).face.id;
147        segments
148            .iter()
149            .all(|segment| segment.mate(side).face.id == first_id)
150    };
151    let mut whole_pcurves: [Option<NurbsCurve>; 2] = [None, None];
152    for side in 0..2 {
153        if !single_face(side) {
154            continue;
155        }
156        let mut row = Vec::new();
157        let select = |station: &Station| -> [f64; 2] {
158            if side == 0 {
159                station.uv1
160            } else {
161                station.uv2
162            }
163        };
164        let mut push_uv = |station: &Station| {
165            let uv = select(station);
166            row.push(Vec4::from_point(Vec3::new(uv[0], uv[1], 0.0), 1.0));
167        };
168        for offset in (1..=degree).rev() {
169            push_uv(&global[count - offset].1.station);
170        }
171        for (_, sample) in &global {
172            push_uv(&sample.station);
173        }
174        push_uv(&global[0].1.station);
175        for offset in 1..=degree {
176            push_uv(&global[offset].1.station);
177        }
178        whole_pcurves[side] = Some(fit_row(&row)?);
179    }
180
181    // Per-face pcurve fits over each segment's full sample window
182    // (in-segment + overshoot), in global parameters.
183    let mut pcurves1 = Vec::with_capacity(segments.len());
184    let mut pcurves2 = Vec::with_capacity(segments.len());
185    for segment in 0..segments.len() {
186        let mut window: Vec<&ChainSample> = samples
187            .iter()
188            .filter(|sample| sample.segment == segment)
189            .collect();
190        window.sort_by(|a, b| a.parameter.total_cmp(&b.parameter));
191        let params: Vec<f64> = window.iter().map(|sample| sample.parameter).collect();
192        let low = params[0];
193        let high = *params.last().unwrap();
194        let normalized: Vec<f64> = params
195            .iter()
196            .map(|parameter| (parameter - low) / (high - low))
197            .collect();
198        let fit_uv = |select: &dyn Fn(&Station) -> [f64; 2]| -> Result<NurbsCurve, String> {
199            let points: Vec<Vec4> = window
200                .iter()
201                .map(|sample| {
202                    let uv = select(&sample.station);
203                    Vec4::from_point(Vec3::new(uv[0], uv[1], 0.0), 1.0)
204                })
205                .collect();
206            let curve = fit::interpolate_homogeneous(&points, degree, &normalized)?;
207            // Re-express in GLOBAL parameters: the curve's [0,1] domain
208            // corresponds to [low, high]; keep as-is and remember the
209            // affine map through the window bounds.
210            Ok(curve)
211        };
212        pcurves1.push((low, high, fit_uv(&|station| station.uv1)?));
213        pcurves2.push((low, high, fit_uv(&|station| station.uv2)?));
214    }
215
216    chain_surgery(
217        solid,
218        segments,
219        ChainRows {
220            surface,
221            cr,
222            cs,
223            u_domain,
224            fit_low: low,
225            fit_range: range,
226            pcurves1,
227            pcurves2,
228            whole_pcurves,
229        },
230        name,
231    )
232}
233
234pub(super) struct ChainRows {
235    pub(super) surface: NurbsSurface,
236    pub(super) cr: NurbsCurve,
237    pub(super) cs: NurbsCurve,
238    pub(super) u_domain: [f64; 2],
239    /// Affine map from the rows' fit space to GLOBAL chain parameters:
240    /// global = fit_low + p * fit_range (the global fit normalised its
241    /// wrap-extended parameters onto [0, 1] before interpolating).
242    pub(super) fit_low: f64,
243    pub(super) fit_range: f64,
244    /// Per-segment (window_low, window_high, uv curve over [0,1]) in
245    /// GLOBAL parameters.
246    pub(super) pcurves1: Vec<(f64, f64, NurbsCurve)>,
247    pub(super) pcurves2: Vec<(f64, f64, NurbsCurve)>,
248    /// Whole-chain uv fits (rows' fit space) for single-face sides.
249    pub(super) whole_pcurves: [Option<NurbsCurve>; 2],
250}
251
252impl ChainRows {
253    pub(super) fn to_global(&self, fit_parameter: f64) -> f64 {
254        self.fit_low + fit_parameter * self.fit_range
255    }
256}
257
258/// Extract the pcurve portion for a global window [a, b] from a
259/// segment's fitted uv curve, re-parameterised so its domain maps
260/// affinely onto the portion (matching the split support edge).
261pub(super) fn pcurve_portion(fitted: &(f64, f64, NurbsCurve), a: f64, b: f64) -> Result<NurbsCurve, String> {
262    let (low, high, curve) = fitted;
263    // The chain parameterisation wraps with period 1; pick the period
264    // copy of the window that overlaps this segment's fit range.
265    let to_local = |value: f64| {
266        let mut best = value;
267        for candidate in [value, value + 1.0, value - 1.0] {
268            if candidate >= low - 1e-9 && candidate <= high + 1e-9 {
269                best = candidate;
270                break;
271            }
272        }
273        ((best - low) / (high - low)).clamp(0.0, 1.0)
274    };
275    let mut local_a = to_local(a);
276    let mut local_b = to_local(b);
277    if local_a > local_b {
278        std::mem::swap(&mut local_a, &mut local_b);
279    }
280    let epsilon = 1e-9;
281    let (_, tail) = if local_a > epsilon {
282        curve.split(local_a)?
283    } else {
284        (curve.clone(), curve.clone())
285    };
286    let tail = if local_a > epsilon {
287        tail
288    } else {
289        curve.clone()
290    };
291    let portion = if local_b < 1.0 - epsilon {
292        tail.split(local_b)?.0
293    } else {
294        tail
295    };
296    Ok(portion)
297}
298
299/// The single boundary edge of a side's mate face(s) incident to a chain
300/// vertex, EXCLUDING the two chain edges meeting there — i.e. the SEAM
301/// (same face on both segments) or the SPOKE (face changes) that the
302/// support curve must cross.  `None` when the chain simply flows through
303/// (same face, no seam — e.g. a stadium cap rim vertex).
304pub(super) fn cross_edge_at(
305    solid: &BrepSolid,
306    face_before: &FaceRecord,
307    loop_before: usize,
308    face_after: &FaceRecord,
309    loop_after: usize,
310    vertex: u64,
311    before_edge: u64,
312    after_edge: u64,
313) -> Result<Option<u64>, String> {
314    let mut found: Option<u64> = None;
315    let mut consider = |face: &FaceRecord, loop_index: usize| -> Result<(), String> {
316        for coedge in &face.loops[loop_index].coedges {
317            if coedge.edge_id == before_edge || coedge.edge_id == after_edge {
318                continue;
319            }
320            let edge = solid
321                .edges
322                .iter()
323                .find(|edge| edge.id == coedge.edge_id)
324                .ok_or("blend: loop references missing edge")?;
325            if edge.start_vertex_id == vertex || edge.end_vertex_id == vertex {
326                if found.is_some() && found != Some(edge.id) {
327                    return Err("blend: multiple cross edges at a chain vertex".into());
328                }
329                found = Some(edge.id);
330            }
331        }
332        Ok(())
333    };
334    consider(face_before, loop_before)?;
335    if !(face_after.id == face_before.id && loop_after == loop_before) {
336        consider(face_after, loop_after)?;
337    }
338    Ok(found)
339}
340
341/// The nearest periodic image of a seam boundary to `value` — the boundary a
342/// crossing endpoint must be pinned to, read off its ADJACENT INTERIOR sample.
343fn nearest_seam_image(value: f64, low: f64, high: f64) -> f64 {
344    let period = high - low;
345    low + ((value - low) / period).round() * period
346}
347
348/// The mate-face pcurve of a support PIECE, built by projecting the piece's
349/// 3D curve onto the (analytic) mate surface and fitting.  Because a chain
350/// support rides EXACTLY on its mate carrier, the projection is exact; the
351/// periodic track is unwrapped so it never jumps a period across the carrier
352/// seam, and crossing endpoints are pinned ONTO the seam so the piece and the
353/// trimmed seam edge close the loop in parameter space.
354///
355/// The carrier is closed in EITHER direction, and the crossed seam is not
356/// always the u meridian.  A cylinder/cone/sphere is closed in u only, so its
357/// seam is the u0 ≡ u1 meridian — but a TORUS is biperiodic, and a tube welded
358/// through a plane crosses the torus' v0 ≡ v1 PARALLEL (its outer equator)
359/// instead.  Pinning u there drags the endpoint a half-carrier away from its
360/// own 3D vertex (the collar loop then closes on a uv corner that is nowhere
361/// near the vertex, and the mate face's trim is garbage), so the pinned AXIS is
362/// chosen GEOMETRICALLY: whichever closed direction's seam actually passes
363/// through the endpoint.  A carrier closed in u only can only ever take the u
364/// branch, exactly as before.
365pub(super) fn project_piece_pcurve(
366    piece: &NurbsCurve,
367    surface: &NurbsSurface,
368    start_is_crossing: bool,
369    end_is_crossing: bool,
370) -> Result<NurbsCurve, String> {
371    let [u0, u1] = surface.domain_u()?;
372    let [v0, v1] = surface.domain_v()?;
373    let (_, closed_v) = surface.closed_directions()?;
374    let period = u1 - u0;
375    let period_v = v1 - v0;
376    let [d0, d1] = piece.domain()?;
377    // Dense reference projection; the pcurve is fit from an adaptively
378    // thinned subset kept as coarse as tolerance allows.
379    const DENSE: usize = 96;
380    let mut proj_u = Vec::with_capacity(DENSE + 1);
381    let mut proj_v = Vec::with_capacity(DENSE + 1);
382    let mut point3 = Vec::with_capacity(DENSE + 1);
383    let mut params = Vec::with_capacity(DENSE + 1);
384    let mut previous_u: Option<f64> = None;
385    for section in 0..=DENSE {
386        let t = d0 + (d1 - d0) * section as f64 / DENSE as f64;
387        let point = piece.evaluate(t)?;
388        let projection = crate::project_point_to_surface(surface, point)?;
389        let mut u = projection.u;
390        if let Some(previous) = previous_u {
391            while u - previous > period * 0.5 {
392                u -= period;
393            }
394            while previous - u > period * 0.5 {
395                u += period;
396            }
397        }
398        previous_u = Some(u);
399        proj_u.push(u);
400        proj_v.push(projection.v);
401        point3.push(point);
402        params.push(section as f64 / DENSE as f64);
403    }
404    // A biperiodic carrier needs the SAME unwrap in v, or a track that walks up
405    // to the v seam comes back as 0 at the far end and the fit swings across
406    // the whole carrier.  Anchor the chain on the first INTERIOR sample and
407    // pull index 0 onto it afterwards: an endpoint sitting exactly ON the seam
408    // inverts to either boundary at the solver's whim, and letting that
409    // coin flip anchor the chain would shift the whole piece a period.
410    if closed_v {
411        for index in 2..=DENSE {
412            while proj_v[index] - proj_v[index - 1] > period_v * 0.5 {
413                proj_v[index] -= period_v;
414            }
415            while proj_v[index - 1] - proj_v[index] > period_v * 0.5 {
416                proj_v[index] += period_v;
417            }
418        }
419        while proj_v[0] - proj_v[1] > period_v * 0.5 {
420            proj_v[0] -= period_v;
421        }
422        while proj_v[1] - proj_v[0] > period_v * 0.5 {
423            proj_v[0] += period_v;
424        }
425    }
426    // Interior mean decides which meridian a crossing endpoint sits on, and
427    // whether the whole track needs a full-period shift into the domain.
428    let mean_u: f64 = proj_u[1..DENSE].iter().sum::<f64>() / (DENSE - 1) as f64;
429    let seam_u = if mean_u - u0 > u1 - mean_u { u1 } else { u0 };
430    // Pin a crossing endpoint onto the seam it actually crosses.  Each
431    // candidate keeps the OTHER coordinate as projected, so the losing axis
432    // moves the endpoint bodily off its own 3D vertex — the residual names the
433    // winner with no surface-type special-casing.  u keeps the whole-track
434    // mean; a v run can legitimately cover a full period, so its boundary is
435    // read off the ADJACENT INTERIOR sample instead.  Both pins land in the
436    // track's own (pre-shift) period, so the shift below carries them along
437    // with the rest of the track.
438    let pinned = |index: usize,
439                  neighbour: usize,
440                  proj_u: &[f64],
441                  proj_v: &[f64]|
442     -> Result<(f64, f64), String> {
443        let point = point3[index];
444        let u_error = surface
445            .evaluate(seam_u, proj_v[index])?
446            .sub(point)
447            .length();
448        if closed_v {
449            let seam_v = nearest_seam_image(proj_v[neighbour], v0, v1);
450            let v_error = surface
451                .evaluate(proj_u[index], seam_v)?
452                .sub(point)
453                .length();
454            if v_error < u_error {
455                return Ok((proj_u[index], seam_v));
456            }
457        }
458        Ok((seam_u, proj_v[index]))
459    };
460    if start_is_crossing {
461        let (u, v) = pinned(0, 1, &proj_u, &proj_v)?;
462        proj_u[0] = u;
463        proj_v[0] = v;
464    }
465    if end_is_crossing {
466        let (u, v) = pinned(DENSE, DENSE - 1, &proj_u, &proj_v)?;
467        proj_u[DENSE] = u;
468        proj_v[DENSE] = v;
469    }
470    let mut shift = 0.0;
471    while mean_u + shift > u1 + 1e-9 {
472        shift -= period;
473    }
474    while mean_u + shift < u0 - 1e-9 {
475        shift += period;
476    }
477    for u in proj_u.iter_mut() {
478        *u += shift;
479    }
480    if closed_v {
481        let mean_v: f64 = proj_v[1..DENSE].iter().sum::<f64>() / (DENSE - 1) as f64;
482        let mut shift_v = 0.0;
483        while mean_v + shift_v > v1 + 1e-9 {
484            shift_v -= period_v;
485        }
486        while mean_v + shift_v < v0 - 1e-9 {
487            shift_v += period_v;
488        }
489        for v in proj_v.iter_mut() {
490            *v += shift_v;
491        }
492    }
493    // Coarsest interpolation whose fitted pcurve stays inside HALF the
494    // validator's pcurve/edge tolerance everywhere along the dense track.
495    let tolerance = 0.002;
496    let mut best: Option<NurbsCurve> = None;
497    for sections in [8usize, 12, 16, 24, 32, 48, 64, 96] {
498        if sections > DENSE {
499            break;
500        }
501        let indices: Vec<usize> = (0..=sections)
502            .map(|k| (k * DENSE / sections).min(DENSE))
503            .collect();
504        let points: Vec<Vec4> = indices
505            .iter()
506            .map(|&i| Vec4::from_point(Vec3::new(proj_u[i], proj_v[i], 0.0), 1.0))
507            .collect();
508        let knot_params: Vec<f64> = indices.iter().map(|&i| params[i]).collect();
509        let curve = fit::interpolate_homogeneous(&points, FIT_DEGREE, &knot_params)?;
510        let mut max_deviation: f64 = 0.0;
511        for i in 0..=DENSE {
512            let uv = curve.evaluate(params[i])?;
513            let on_surface = surface.evaluate(uv.x, uv.y)?;
514            max_deviation = max_deviation.max(on_surface.sub(point3[i]).length());
515        }
516        best = Some(curve);
517        if max_deviation <= tolerance {
518            break;
519        }
520    }
521    best.ok_or_else(|| "blend: piece pcurve projection failed".to_string())
522}