Skip to main content

brep_kernel/edit/direct_edit/
face_offset_freeform.rs

1use super::*;
2use crate::{KernelRefusal, KernelStage, OrRefuse};
3
4/// Offset a free-form NURBS face whose boundaries have no other incident face.
5/// The fitted offset must pass a dense same-parameter residual check. Intrinsic
6/// iso boundaries use exact iso-curves; other self-incident trims use
7/// [`crate::image_curve`]. Rebuilt topology and volume orientation are validated.
8/// Neighbour-trimmed faces require general intersection and are refused.
9pub fn offset_freeform_face(
10    solid: &BrepSolid,
11    face_id: u64,
12    distance: f64,
13) -> Result<BrepSolid, KernelRefusal> {
14    if !distance.is_finite() {
15        return Err(KernelRefusal::input(
16            KernelStage::Collect,
17            "distance",
18            "offset_freeform_face: distance must be finite",
19        ));
20    }
21    let scale = solid_model_scale(solid);
22    let tolerance = (scale * 1e-7).max(1e-9);
23    // Free-form offsets are approximation-bounded, not exact. Keep the maximum
24    // positional drift below 0.05% of model scale and reject rougher fits.
25    let residual_tolerance = (scale * 5e-4).max(5e-6);
26    // Curve-fit accuracy is separate from the surface and identity tolerances.
27    let fit_tolerance = crate::KernelTolerances::for_scale(scale, 1e-7).intersection_fit;
28    let (shell_index, face_index) = find_face(solid, face_id)
29        .ok_or_else(|| {
30            KernelRefusal::input(
31                KernelStage::Collect,
32                "face_id",
33                format!("offset_freeform_face: no face {face_id}"),
34            )
35        })?;
36    let face = &solid.shells[shell_index].faces[face_index];
37    if face.surface.analytic().is_some() {
38        return Err(KernelRefusal::input(
39            KernelStage::Collect,
40            "analytic_carrier",
41            "offset_freeform_face: the pushed carrier is analytic",
42        ));
43    }
44
45    let mut faces_of_edge: HashMap<u64, Vec<u64>> = HashMap::default();
46    for shell in &solid.shells {
47        for candidate in &shell.faces {
48            for loop_record in &candidate.loops {
49                for coedge in &loop_record.coedges {
50                    faces_of_edge
51                        .entry(coedge.edge_id)
52                        .or_default()
53                        .push(candidate.id);
54                }
55            }
56        }
57    }
58    for loop_record in &face.loops {
59        for coedge in &loop_record.coedges {
60            let incident = faces_of_edge
61                .get(&coedge.edge_id)
62                .cloned()
63                .unwrap_or_default();
64            if incident.iter().any(|candidate| *candidate != face_id) {
65                return Err(KernelRefusal::unsupported(
66                    KernelStage::Classify,
67                    "neighbour_trimmed",
68                    "offset_freeform_face: neighbour-trimmed NURBS faces require general \
69                            surface intersection and are deferred — refusing",
70                ));
71            }
72        }
73    }
74
75    // A push past the face's concave curvature radius folds its offset through
76    // the evolute however well that offset is FITTED, so the fit's own residual
77    // gate below cannot be what refuses it: it used to, only while the fit was
78    // coarse. This lane cannot carve, so ANY fold inside the face's own trim
79    // refuses — the trim-scoped scan the shell's collapse probe and `thicken`'s
80    // sheet gate read, at thicken's fold factor. `offset_surface(face, -d)` moves
81    // along the face's outward normal, which is `same_sense · (Su × Sv)`.
82    let displacement = if face.same_sense { distance } else { -distance };
83    let region = crate::offset_regularity::TrimRegion::from_face(face)?;
84    let mut scan = crate::offset_regularity::scan_offset_regularity(
85        &face.surface,
86        &region,
87        &[displacement],
88        crate::thicken::FOLD_FACTOR,
89        crate::offset_regularity::ScanBudget::SHEET,
90    )?;
91    if let Some(worst) = scan.worst.filter(|worst| worst.factor <= crate::thicken::FOLD_FACTOR) {
92        return Err(KernelRefusal::unsupported(
93            KernelStage::Classify,
94            "fold",
95            format!(
96            "offset_freeform_face: a push by {distance} folds the offset through the face's evolute \
97             — inside its trim the curvature radius {:.6} at (u={:.6}, v={:.6}) is not larger \
98             than the push (fold factor {:.3e}), and a push cannot carve the fold away",
99            worst.radius(),
100            worst.u,
101            worst.v,
102            worst.factor
103        ),
104        ));
105    }
106    // The certainty step: nothing collapsed is the census; a scan that could
107    // not clear every risky cell, or ran out of descent budget, has not seen
108    // enough to certify the face, and this lane cannot carve either way.
109    let certainty = scan.certify(&face.surface, &region, &[displacement], crate::thicken::FOLD_FACTOR);
110    if let crate::offset_regularity::Certainty::Folded(worst) = certainty {
111        // The certainty step's own refinement reached the fold the census had
112        // not: the same refusal, at the sample it found.
113        return Err(KernelRefusal::unsupported(
114            KernelStage::Classify,
115            "fold",
116            format!(
117                "offset_freeform_face: a push by {distance} folds the offset through the face's evolute \
118                 — inside its trim the curvature radius {:.6} at (u={:.6}, v={:.6}) is not larger \
119                 than the push (fold factor {:.3e}, found between the scan's samples), and a push \
120                 cannot carve the fold away",
121                worst.radius(),
122                worst.u,
123                worst.v,
124                worst.factor
125            ),
126        ));
127    }
128    if let crate::offset_regularity::Certainty::Uncertain {
129        unresolved,
130        budget_exhausted,
131        least,
132    } = certainty
133    {
134        return Err(KernelRefusal::unsupported(
135            KernelStage::Classify,
136            "fold_uncertain",
137            format!(
138                "offset_freeform_face: a push by {distance} cannot be certified regular over the \
139                 face's trim — none of {} samples collapsed, but {} cell(s) were still risky at \
140                 the scan's finest spacing{}{}; a push cannot carve a fold it cannot place",
141                scan.sampled,
142                unresolved,
143                if budget_exhausted { " and the refinement budget ran out" } else { "" },
144                least
145                    .map(|least| format!(
146                        " (least fold factor {:.3e} at (u={:.6}, v={:.6}))",
147                        least.factor, least.u, least.v
148                    ))
149                    .unwrap_or_default()
150            ),
151        ));
152    }
153    let offset = crate::offset_surface(face, -distance, 0.0)?;
154    // The residual gate asks the same question the evaluator answers: is the
155    // FITTED surface still at `distance` along the pointwise offset's normal?
156    let offsets = OffsetEvaluator::new(
157        "push_freeform",
158        &face.surface,
159        OffsetNormal::Face {
160            same_sense: face.same_sense,
161        },
162    );
163    let [u0, u1] = face.surface.domain_u().or_refuse(KernelStage::Refine, "domain_u")?;
164    let [v0, v1] = face.surface.domain_v().or_refuse(KernelStage::Refine, "domain_v")?;
165    let target = distance.abs();
166    for iu in 0..15 {
167        for iv in 0..15 {
168            let u = u0 + (u1 - u0) * (iu as f64 + 0.31) / 15.0;
169            let v = v0 + (v1 - v0) * (iv as f64 + 0.43) / 15.0;
170            let source_point = face.surface.evaluate(u, v).or_refuse(KernelStage::Refine, "evaluate")?;
171            let offset_point = offset.evaluate(u, v).or_refuse(KernelStage::Refine, "evaluate")?;
172            let delta = offset_point.sub(source_point);
173            let normal = offsets.normal(u, v).or_refuse(KernelStage::Refine, "normal")?;
174            let distance_error = (delta.length() - target).abs();
175            let normal_error = if delta.length() > tolerance {
176                1.0 - delta.normalized().or_refuse(KernelStage::Refine, "normalized")?.dot(normal).abs()
177            } else {
178                0.0
179            };
180            if distance_error > residual_tolerance || normal_error > 2e-3 {
181                return Err(KernelRefusal::non_convergence(
182                    KernelStage::Refine,
183                    "offset_fit",
184                    format!(
185                    "offset_freeform_face: offset fit exceeds tolerance \
186                     (distance error {distance_error:.3e}, normal error {normal_error:.3e})"
187                ),
188                ));
189            }
190        }
191    }
192
193    let edge_by_id: HashMap<u64, &EdgeRecord> =
194        solid.edges.iter().map(|edge| (edge.id, edge)).collect();
195    let mut rebuilt_edges: HashMap<u64, (NurbsCurve, f64, f64)> = HashMap::default();
196    let mut rebuilt_vertices: HashMap<u64, Vec3> = HashMap::default();
197    for loop_record in &face.loops {
198        for coedge in &loop_record.coedges {
199            if rebuilt_edges.contains_key(&coedge.edge_id) {
200                continue;
201            }
202            let edge = *edge_by_id
203                .get(&coedge.edge_id)
204                .ok_or_else(|| {
205                    KernelRefusal::internal(
206                        KernelStage::Fragment,
207                        "missing_edge",
208                        format!("offset_freeform_face: missing edge {}", coedge.edge_id),
209                    )
210                })?;
211            let [q0, q1] = coedge.pcurve.domain().or_refuse(KernelStage::Fragment, "domain")?;
212            let a = coedge.pcurve.evaluate(q0).or_refuse(KernelStage::Fragment, "evaluate")?;
213            let b = coedge.pcurve.evaluate(q1).or_refuse(KernelStage::Fragment, "evaluate")?;
214            let m = coedge.pcurve.evaluate(0.5 * (q0 + q1)).or_refuse(KernelStage::Fragment, "evaluate")?;
215            let uv_tolerance = 1e-7;
216            // The two iso lanes are untouched: an intrinsic seam or pole row is
217            // an exact iso of the same-basis offset, and staying on that lane
218            // keeps every solid this path already builds BIT-IDENTICAL.
219            let iso = if (a.x - b.x).abs() <= uv_tolerance && (a.x - m.x).abs() <= uv_tolerance {
220                Some((offset.iso_curve_u(a.x).or_refuse(KernelStage::Fragment, "iso_curve_u")?, a.y, b.y))
221            } else if (a.y - b.y).abs() <= uv_tolerance && (a.y - m.y).abs() <= uv_tolerance {
222                Some((offset.iso_curve_v(a.y).or_refuse(KernelStage::Fragment, "iso_curve_v")?, a.x, b.x))
223            } else {
224                None
225            };
226            // A general intrinsic trim used to be refused here. It is the image
227            // of its own pcurve on the offset carrier — exactly, because this
228            // path admits only edges with no OTHER incident face, so the
229            // boundary is definitional rather than an intersection with a fixed
230            // neighbour. `image_curve` returns `(t0, t1)` under the validator's
231            // fraction contract, which the `forward` / `start <= end` handling
232            // below consumes unchanged.
233            let (base, mut start, mut end) = match iso {
234                Some(triple) => triple,
235                None => {
236                    let image = crate::image_curve(
237                        &offset,
238                        &coedge.pcurve,
239                        fit_tolerance,
240                        "offset_freeform_face",
241                    ).or_refuse(KernelStage::Fragment, "image_curve")?;
242                    (image.curve, image.t0, image.t1)
243                }
244            };
245            if !coedge.forward {
246                std::mem::swap(&mut start, &mut end);
247            }
248            let [d0, d1] = base.domain().or_refuse(KernelStage::Fragment, "domain")?;
249            let (curve, t0, t1) = if start <= end {
250                (base, start, end)
251            } else {
252                (base.reversed().or_refuse(KernelStage::Fragment, "reversed")?, d0 + d1 - start, d0 + d1 - end)
253            };
254            let start_point = curve.evaluate(t0).or_refuse(KernelStage::Fragment, "evaluate")?;
255            let end_point = curve.evaluate(t1).or_refuse(KernelStage::Fragment, "evaluate")?;
256            for (vertex_id, point) in [
257                (edge.start_vertex_id, start_point),
258                (edge.end_vertex_id, end_point),
259            ] {
260                if let Some(known) = rebuilt_vertices.get(&vertex_id) {
261                    if known.sub(point).length() > residual_tolerance {
262                        return Err(KernelRefusal::internal(
263                            KernelStage::Fragment,
264                            "vertex_agreement",
265                            format!(
266                            "offset_freeform_face: intrinsic boundaries disagree at vertex {vertex_id}"
267                        ),
268                        ));
269                    }
270                } else {
271                    rebuilt_vertices.insert(vertex_id, point);
272                }
273            }
274            rebuilt_edges.insert(edge.id, (curve, t0, t1));
275        }
276    }
277
278    finish_intrinsic_face_offset(
279        solid,
280        (shell_index, face_index),
281        offset,
282        &rebuilt_edges,
283        &rebuilt_vertices,
284        "offset_freeform_face",
285    )
286    
287}
288