Skip to main content

brep_kernel/edit/direct_edit/
face_offset_freeform.rs

1use super::*;
2
3/// Push an untrimmed free-form NURBS face along its normal field.
4///
5/// General NURBS offsets are approximate. This path therefore admits only a
6/// face bounded entirely by its own INTRINSIC edges (no other incident face),
7/// checks the dense same-parameter offset residual, rematerializes every 3D
8/// boundary curve, and validates topology plus volume orientation.
9/// Neighbour-trimmed free-form faces still need general SSI and are refused
10/// before mutation.
11///
12/// An intrinsic boundary is usually an iso seam or a pole row, which takes the
13/// exact iso lane. One that is NOT — a general self-incident trim — is no
14/// longer refused: [`crate::image_curve`] builds its 3D image on the offset
15/// carrier. Because the offset shares the source basis and the boundary here is
16/// definitional (nothing fixed on the other side), the image IS the new edge.
17pub fn offset_freeform_face(
18    solid: &BrepSolid,
19    face_id: u64,
20    distance: f64,
21) -> Result<BrepSolid, String> {
22    if !distance.is_finite() {
23        return Err("offset_freeform_face: distance must be finite".into());
24    }
25    let scale = solid_model_scale(solid);
26    let tolerance = (scale * 1e-7).max(1e-9);
27    // Free-form offsets are approximation-bounded, not exact. Keep the maximum
28    // positional drift below 0.05% of model scale and reject rougher fits.
29    let residual_tolerance = (scale * 5e-4).max(5e-6);
30    // Bar for the general 3D-image ladder. This is a CURVE-fit accuracy target,
31    // a different question from `residual_tolerance` (which bounds the offset
32    // SURFACE against the true equidistant surface), so it takes the kernel's
33    // single named fit-accuracy field rather than reusing the surface bar.
34    let fit_tolerance = crate::KernelTolerances::for_scale(scale, 1e-7).intersection_fit;
35    let (shell_index, face_index) = find_face(solid, face_id)
36        .ok_or_else(|| format!("offset_freeform_face: no face {face_id}"))?;
37    let face = &solid.shells[shell_index].faces[face_index];
38    if face.surface.analytic().is_some() {
39        return Err("offset_freeform_face: the pushed carrier is analytic".into());
40    }
41
42    let mut faces_of_edge: HashMap<u64, Vec<u64>> = HashMap::default();
43    for shell in &solid.shells {
44        for candidate in &shell.faces {
45            for loop_record in &candidate.loops {
46                for coedge in &loop_record.coedges {
47                    faces_of_edge
48                        .entry(coedge.edge_id)
49                        .or_default()
50                        .push(candidate.id);
51                }
52            }
53        }
54    }
55    for loop_record in &face.loops {
56        for coedge in &loop_record.coedges {
57            let incident = faces_of_edge
58                .get(&coedge.edge_id)
59                .cloned()
60                .unwrap_or_default();
61            if incident.iter().any(|candidate| *candidate != face_id) {
62                return Err(
63                    "offset_freeform_face: neighbour-trimmed NURBS faces require general \
64                            surface intersection and are deferred — refusing"
65                        .into(),
66                );
67            }
68        }
69    }
70
71    let offset = crate::offset_surface(face, -distance, 0.0)?;
72    // The residual gate asks the same question the evaluator answers: is the
73    // FITTED surface still at `distance` along the pointwise offset's normal?
74    let offsets = OffsetEvaluator::new(
75        "push_freeform",
76        &face.surface,
77        OffsetNormal::Face {
78            same_sense: face.same_sense,
79        },
80    );
81    let [u0, u1] = face.surface.domain_u()?;
82    let [v0, v1] = face.surface.domain_v()?;
83    let target = distance.abs();
84    for iu in 0..15 {
85        for iv in 0..15 {
86            let u = u0 + (u1 - u0) * (iu as f64 + 0.31) / 15.0;
87            let v = v0 + (v1 - v0) * (iv as f64 + 0.43) / 15.0;
88            let source_point = face.surface.evaluate(u, v)?;
89            let offset_point = offset.evaluate(u, v)?;
90            let delta = offset_point.sub(source_point);
91            let normal = offsets.normal(u, v)?;
92            let distance_error = (delta.length() - target).abs();
93            let normal_error = if delta.length() > tolerance {
94                1.0 - delta.normalized()?.dot(normal).abs()
95            } else {
96                0.0
97            };
98            if distance_error > residual_tolerance || normal_error > 2e-3 {
99                return Err(format!(
100                    "offset_freeform_face: offset fit exceeds tolerance \
101                     (distance error {distance_error:.3e}, normal error {normal_error:.3e})"
102                ));
103            }
104        }
105    }
106
107    let edge_by_id: HashMap<u64, &EdgeRecord> =
108        solid.edges.iter().map(|edge| (edge.id, edge)).collect();
109    let mut rebuilt_edges: HashMap<u64, (NurbsCurve, f64, f64)> = HashMap::default();
110    let mut rebuilt_vertices: HashMap<u64, Vec3> = HashMap::default();
111    for loop_record in &face.loops {
112        for coedge in &loop_record.coedges {
113            if rebuilt_edges.contains_key(&coedge.edge_id) {
114                continue;
115            }
116            let edge = *edge_by_id
117                .get(&coedge.edge_id)
118                .ok_or_else(|| format!("offset_freeform_face: missing edge {}", coedge.edge_id))?;
119            let [q0, q1] = coedge.pcurve.domain()?;
120            let a = coedge.pcurve.evaluate(q0)?;
121            let b = coedge.pcurve.evaluate(q1)?;
122            let m = coedge.pcurve.evaluate(0.5 * (q0 + q1))?;
123            let uv_tolerance = 1e-7;
124            // The two iso lanes are untouched: an intrinsic seam or pole row is
125            // an exact iso of the same-basis offset, and staying on that lane
126            // keeps every solid this path already builds BIT-IDENTICAL.
127            let iso = if (a.x - b.x).abs() <= uv_tolerance && (a.x - m.x).abs() <= uv_tolerance {
128                Some((offset.iso_curve_u(a.x)?, a.y, b.y))
129            } else if (a.y - b.y).abs() <= uv_tolerance && (a.y - m.y).abs() <= uv_tolerance {
130                Some((offset.iso_curve_v(a.y)?, a.x, b.x))
131            } else {
132                None
133            };
134            // A general intrinsic trim used to be refused here. It is the image
135            // of its own pcurve on the offset carrier — exactly, because this
136            // path admits only edges with no OTHER incident face, so the
137            // boundary is definitional rather than an intersection with a fixed
138            // neighbour. `image_curve` returns `(t0, t1)` under the validator's
139            // fraction contract, which the `forward` / `start <= end` handling
140            // below consumes unchanged.
141            let (base, mut start, mut end) = match iso {
142                Some(triple) => triple,
143                None => {
144                    let image = crate::image_curve(
145                        &offset,
146                        &coedge.pcurve,
147                        fit_tolerance,
148                        "offset_freeform_face",
149                    )?;
150                    (image.curve, image.t0, image.t1)
151                }
152            };
153            if !coedge.forward {
154                std::mem::swap(&mut start, &mut end);
155            }
156            let [d0, d1] = base.domain()?;
157            let (curve, t0, t1) = if start <= end {
158                (base, start, end)
159            } else {
160                (base.reversed()?, d0 + d1 - start, d0 + d1 - end)
161            };
162            let start_point = curve.evaluate(t0)?;
163            let end_point = curve.evaluate(t1)?;
164            for (vertex_id, point) in [
165                (edge.start_vertex_id, start_point),
166                (edge.end_vertex_id, end_point),
167            ] {
168                if let Some(known) = rebuilt_vertices.get(&vertex_id) {
169                    if known.sub(point).length() > residual_tolerance {
170                        return Err(format!(
171                            "offset_freeform_face: intrinsic boundaries disagree at vertex {vertex_id}"
172                        ));
173                    }
174                } else {
175                    rebuilt_vertices.insert(vertex_id, point);
176                }
177            }
178            rebuilt_edges.insert(edge.id, (curve, t0, t1));
179        }
180    }
181
182    let mut result = solid.clone();
183    result.shells[shell_index].faces[face_index].surface = offset;
184    for edge in &mut result.edges {
185        if let Some((curve, t0, t1)) = rebuilt_edges.get(&edge.id) {
186            edge.curve = curve.clone();
187            edge.t0 = *t0;
188            edge.t1 = *t1;
189        }
190    }
191    for vertex in &mut result.vertices {
192        if let Some(point) = rebuilt_vertices.get(&vertex.id) {
193            vertex.point = *point;
194        }
195    }
196    let issues = result.validate();
197    if !issues.is_empty() {
198        return Err(format!(
199            "offset_freeform_face: pushed solid failed validation: {issues:?}"
200        ));
201    }
202    if let (Ok(before), Ok(after)) = (solid_signed_volume(solid), solid_signed_volume(&result)) {
203        if before * after <= 0.0 {
204            return Err("offset_freeform_face: the push inverts the solid — refusing".into());
205        }
206    }
207    Ok(result)
208}
209
210#[cfg(test)]
211mod tests {
212    use super::*;
213
214    fn ellipsoid() -> BrepSolid {
215        let sphere =
216            crate::make_sphere_brep(Vec3::default(), 3.0, Vec3::new(0.0, 0.0, 1.0)).unwrap();
217        let stretch = AffineTransform::new([
218            1.1, 0.0, 0.0, 0.0, 0.0, 1.0, 0.0, 0.0, 0.0, 0.0, 0.9, 0.0, 0.0, 0.0, 0.0, 1.0,
219        ])
220        .unwrap();
221        crate::transform_brep(&sphere, stretch, false).unwrap()
222    }
223
224    fn freeform_face(solid: &BrepSolid) -> u64 {
225        solid
226            .shells
227            .iter()
228            .flat_map(|shell| &shell.faces)
229            .find(|face| face.surface.analytic().is_none())
230            .expect("free-form face")
231            .id
232    }
233
234    #[test]
235    fn push_untrimmed_ellipsoid_offsets_freeform_carrier() {
236        let ellipsoid = ellipsoid();
237        let before = solid_signed_volume(&ellipsoid).unwrap().abs();
238        for distance in [0.05_f64, -0.05] {
239            let pushed = offset_freeform_face(&ellipsoid, freeform_face(&ellipsoid), distance)
240                .unwrap_or_else(|error| panic!("push ellipsoid by {distance}: {error}"));
241            let issues = pushed.validate();
242            assert!(issues.is_empty(), "validate {distance}: {issues:?}");
243            assert!(pushed
244                .shells
245                .iter()
246                .flat_map(|shell| &shell.faces)
247                .all(|face| face.surface.analytic().is_none()));
248            let after = solid_signed_volume(&pushed).unwrap().abs();
249            if distance > 0.0 {
250                assert!(after > before);
251            } else {
252                assert!(after < before);
253            }
254        }
255    }
256
257    #[test]
258    fn rough_freeform_offset_refuses_when_fit_exceeds_guard() {
259        let sphere =
260            crate::make_sphere_brep(Vec3::default(), 3.0, Vec3::new(0.0, 0.0, 1.0)).unwrap();
261        let stretch = AffineTransform::new([
262            1.5, 0.0, 0.0, 0.0, 0.0, 1.0, 0.0, 0.0, 0.0, 0.0, 0.75, 0.0, 0.0, 0.0, 0.0, 1.0,
263        ])
264        .unwrap();
265        let eccentric = crate::transform_brep(&sphere, stretch, false).unwrap();
266        let error = offset_freeform_face(&eccentric, freeform_face(&eccentric), 0.08)
267            .expect_err("rough approximate offset must refuse");
268        assert!(
269            error.contains("offset fit exceeds tolerance"),
270            "unexpected refusal: {error}"
271        );
272        assert!(eccentric.validate().is_empty(), "source remains unchanged");
273    }
274}