Skip to main content

brep_kernel/edit/direct_edit/
face_offset_freeform.rs

1use super::*;
2
3/// Offset a free-form NURBS face whose boundaries have no other incident face.
4/// The fitted offset must pass a dense same-parameter residual check. Intrinsic
5/// iso boundaries use exact iso-curves; other self-incident trims use
6/// [`crate::image_curve`]. Rebuilt topology and volume orientation are validated.
7/// Neighbour-trimmed faces require general intersection and are refused.
8pub fn offset_freeform_face(
9    solid: &BrepSolid,
10    face_id: u64,
11    distance: f64,
12) -> Result<BrepSolid, String> {
13    if !distance.is_finite() {
14        return Err("offset_freeform_face: distance must be finite".into());
15    }
16    let scale = solid_model_scale(solid);
17    let tolerance = (scale * 1e-7).max(1e-9);
18    // Free-form offsets are approximation-bounded, not exact. Keep the maximum
19    // positional drift below 0.05% of model scale and reject rougher fits.
20    let residual_tolerance = (scale * 5e-4).max(5e-6);
21    // Curve-fit accuracy is separate from the surface and identity tolerances.
22    let fit_tolerance = crate::KernelTolerances::for_scale(scale, 1e-7).intersection_fit;
23    let (shell_index, face_index) = find_face(solid, face_id)
24        .ok_or_else(|| format!("offset_freeform_face: no face {face_id}"))?;
25    let face = &solid.shells[shell_index].faces[face_index];
26    if face.surface.analytic().is_some() {
27        return Err("offset_freeform_face: the pushed carrier is analytic".into());
28    }
29
30    let mut faces_of_edge: HashMap<u64, Vec<u64>> = HashMap::default();
31    for shell in &solid.shells {
32        for candidate in &shell.faces {
33            for loop_record in &candidate.loops {
34                for coedge in &loop_record.coedges {
35                    faces_of_edge
36                        .entry(coedge.edge_id)
37                        .or_default()
38                        .push(candidate.id);
39                }
40            }
41        }
42    }
43    for loop_record in &face.loops {
44        for coedge in &loop_record.coedges {
45            let incident = faces_of_edge
46                .get(&coedge.edge_id)
47                .cloned()
48                .unwrap_or_default();
49            if incident.iter().any(|candidate| *candidate != face_id) {
50                return Err(
51                    "offset_freeform_face: neighbour-trimmed NURBS faces require general \
52                            surface intersection and are deferred — refusing"
53                        .into(),
54                );
55            }
56        }
57    }
58
59    // A push past the face's concave curvature radius folds its offset through
60    // the evolute however well that offset is FITTED, so the fit's own residual
61    // gate below cannot be what refuses it: it used to, only while the fit was
62    // coarse. This lane cannot carve, so ANY fold inside the face's own trim
63    // refuses — the trim-scoped scan the shell's collapse probe and `thicken`'s
64    // sheet gate read, at thicken's fold factor. `offset_surface(face, -d)` moves
65    // along the face's outward normal, which is `same_sense · (Su × Sv)`.
66    let displacement = if face.same_sense { distance } else { -distance };
67    let region = crate::offset_regularity::TrimRegion::from_face(face)?;
68    let scan = crate::offset_regularity::scan_offset_regularity(
69        &face.surface,
70        &region,
71        &[displacement],
72        crate::thicken::FOLD_FACTOR,
73        crate::offset_regularity::ScanBudget::SHEET,
74    )?;
75    if let Some(worst) = scan.worst.filter(|worst| worst.factor <= crate::thicken::FOLD_FACTOR) {
76        return Err(format!(
77            "offset_freeform_face: a push by {distance} folds the offset through the face's evolute \
78             — inside its trim the curvature radius {:.6} at (u={:.6}, v={:.6}) is not larger \
79             than the push (fold factor {:.3e}), and a push cannot carve the fold away",
80            worst.radius(),
81            worst.u,
82            worst.v,
83            worst.factor
84        ));
85    }
86    let offset = crate::offset_surface(face, -distance, 0.0)?;
87    // The residual gate asks the same question the evaluator answers: is the
88    // FITTED surface still at `distance` along the pointwise offset's normal?
89    let offsets = OffsetEvaluator::new(
90        "push_freeform",
91        &face.surface,
92        OffsetNormal::Face {
93            same_sense: face.same_sense,
94        },
95    );
96    let [u0, u1] = face.surface.domain_u()?;
97    let [v0, v1] = face.surface.domain_v()?;
98    let target = distance.abs();
99    for iu in 0..15 {
100        for iv in 0..15 {
101            let u = u0 + (u1 - u0) * (iu as f64 + 0.31) / 15.0;
102            let v = v0 + (v1 - v0) * (iv as f64 + 0.43) / 15.0;
103            let source_point = face.surface.evaluate(u, v)?;
104            let offset_point = offset.evaluate(u, v)?;
105            let delta = offset_point.sub(source_point);
106            let normal = offsets.normal(u, v)?;
107            let distance_error = (delta.length() - target).abs();
108            let normal_error = if delta.length() > tolerance {
109                1.0 - delta.normalized()?.dot(normal).abs()
110            } else {
111                0.0
112            };
113            if distance_error > residual_tolerance || normal_error > 2e-3 {
114                return Err(format!(
115                    "offset_freeform_face: offset fit exceeds tolerance \
116                     (distance error {distance_error:.3e}, normal error {normal_error:.3e})"
117                ));
118            }
119        }
120    }
121
122    let edge_by_id: HashMap<u64, &EdgeRecord> =
123        solid.edges.iter().map(|edge| (edge.id, edge)).collect();
124    let mut rebuilt_edges: HashMap<u64, (NurbsCurve, f64, f64)> = HashMap::default();
125    let mut rebuilt_vertices: HashMap<u64, Vec3> = HashMap::default();
126    for loop_record in &face.loops {
127        for coedge in &loop_record.coedges {
128            if rebuilt_edges.contains_key(&coedge.edge_id) {
129                continue;
130            }
131            let edge = *edge_by_id
132                .get(&coedge.edge_id)
133                .ok_or_else(|| format!("offset_freeform_face: missing edge {}", coedge.edge_id))?;
134            let [q0, q1] = coedge.pcurve.domain()?;
135            let a = coedge.pcurve.evaluate(q0)?;
136            let b = coedge.pcurve.evaluate(q1)?;
137            let m = coedge.pcurve.evaluate(0.5 * (q0 + q1))?;
138            let uv_tolerance = 1e-7;
139            // The two iso lanes are untouched: an intrinsic seam or pole row is
140            // an exact iso of the same-basis offset, and staying on that lane
141            // keeps every solid this path already builds BIT-IDENTICAL.
142            let iso = if (a.x - b.x).abs() <= uv_tolerance && (a.x - m.x).abs() <= uv_tolerance {
143                Some((offset.iso_curve_u(a.x)?, a.y, b.y))
144            } else if (a.y - b.y).abs() <= uv_tolerance && (a.y - m.y).abs() <= uv_tolerance {
145                Some((offset.iso_curve_v(a.y)?, a.x, b.x))
146            } else {
147                None
148            };
149            // A general intrinsic trim used to be refused here. It is the image
150            // of its own pcurve on the offset carrier — exactly, because this
151            // path admits only edges with no OTHER incident face, so the
152            // boundary is definitional rather than an intersection with a fixed
153            // neighbour. `image_curve` returns `(t0, t1)` under the validator's
154            // fraction contract, which the `forward` / `start <= end` handling
155            // below consumes unchanged.
156            let (base, mut start, mut end) = match iso {
157                Some(triple) => triple,
158                None => {
159                    let image = crate::image_curve(
160                        &offset,
161                        &coedge.pcurve,
162                        fit_tolerance,
163                        "offset_freeform_face",
164                    )?;
165                    (image.curve, image.t0, image.t1)
166                }
167            };
168            if !coedge.forward {
169                std::mem::swap(&mut start, &mut end);
170            }
171            let [d0, d1] = base.domain()?;
172            let (curve, t0, t1) = if start <= end {
173                (base, start, end)
174            } else {
175                (base.reversed()?, d0 + d1 - start, d0 + d1 - end)
176            };
177            let start_point = curve.evaluate(t0)?;
178            let end_point = curve.evaluate(t1)?;
179            for (vertex_id, point) in [
180                (edge.start_vertex_id, start_point),
181                (edge.end_vertex_id, end_point),
182            ] {
183                if let Some(known) = rebuilt_vertices.get(&vertex_id) {
184                    if known.sub(point).length() > residual_tolerance {
185                        return Err(format!(
186                            "offset_freeform_face: intrinsic boundaries disagree at vertex {vertex_id}"
187                        ));
188                    }
189                } else {
190                    rebuilt_vertices.insert(vertex_id, point);
191                }
192            }
193            rebuilt_edges.insert(edge.id, (curve, t0, t1));
194        }
195    }
196
197    finish_intrinsic_face_offset(
198        solid,
199        (shell_index, face_index),
200        offset,
201        &rebuilt_edges,
202        &rebuilt_vertices,
203        "offset_freeform_face",
204    )
205}
206