Skip to main content

brep_kernel/edit/direct_edit/
face_offset_revolution.rs

1use super::*;
2
3/// Push a general surface of revolution with planar neighbours.
4///
5/// The meridian is densely offset and interpolated, then revolved through the
6/// original full or partial sweep. A dense normal-distance residual check
7/// bounds that approximation. Endpoint rims and start/end meridians are rebuilt,
8/// and planar axial/radial neighbours are re-trimmed around the new boundary.
9///
10/// A boundary whose pcurve is NEITHER a constant-u nor a constant-v line is no
11/// longer refused outright: its 3D image on the offset carrier comes from
12/// [`crate::image_curve`]. That transfer is only valid where the boundary is
13/// definitional, so a general image against a FIXED planar cap is measured
14/// against that cap's plane and refused — with the number — when it leaves it,
15/// because the honest answer there is a re-intersection this site does not do.
16pub fn offset_revolution_face(
17    solid: &BrepSolid,
18    face_id: u64,
19    distance: f64,
20) -> Result<BrepSolid, String> {
21    if !distance.is_finite() {
22        return Err("offset_revolution_face: distance must be finite".into());
23    }
24    let scale = solid_model_scale(solid);
25    let tolerance = (scale * 1e-7).max(1e-9);
26    let residual_tolerance = (scale * 1e-4).max(1e-6);
27    // Bar for the general 3D-image ladder: a CURVE-fit accuracy target, which
28    // is a different question from `residual_tolerance`'s bound on the offset
29    // SURFACE, so it takes the kernel's single named fit-accuracy field.
30    let fit_tolerance = crate::KernelTolerances::for_scale(scale, 1e-7).intersection_fit;
31    let (shell_index, face_index) = find_face(solid, face_id)
32        .ok_or_else(|| format!("offset_revolution_face: no face {face_id}"))?;
33    let face = &solid.shells[shell_index].faces[face_index];
34    let Some(AnalyticSurface::Revolution { sweep, .. }) = face.surface.analytic() else {
35        return Err("offset_revolution_face: the pushed face is not a general revolution".into());
36    };
37
38    let source_structure = crate::revolution_structure(&face.surface).ok_or_else(|| {
39        "offset_revolution_face: source carrier lost its revolution structure".to_string()
40    })?;
41    let [u0, u1] = face.surface.domain_u()?;
42    let [v0, v1] = face.surface.domain_v()?;
43    if (v0.abs() + (v1 - 1.0).abs()) > 1e-9 {
44        return Err(
45            "offset_revolution_face: a non-normalized generatrix domain is deferred".into(),
46        );
47    }
48    // Offset the meridian densely, then revolve it. A general curve offset is
49    // not exactly rational; using 33 cubic interpolation stations materially
50    // lowers the residual compared with fitting the whole surface back onto its
51    // original sparse control net, while retaining the [0,1] pcurve contract.
52    let u_probe = u0 + (u1 - u0) * 0.137;
53    // The dense meridian sample is a pointwise offset — the shared evaluator's
54    // `Face` lane, whose distance is signed ALONG the oriented normal exactly
55    // like direct-edit's own convention (audit §4.1: this side of the kernel
56    // grows the solid on a positive distance).
57    let offsets = OffsetEvaluator::new(
58        "push_revolution",
59        &face.surface,
60        OffsetNormal::Face {
61            same_sense: face.same_sense,
62        },
63    );
64    let mut meridian_points = Vec::with_capacity(33);
65    let mut meridian_parameters = Vec::with_capacity(33);
66    for index in 0..=32 {
67        let parameter = index as f64 / 32.0;
68        let v = v0 + (v1 - v0) * parameter;
69        let target_point = offsets.at(u_probe, v, distance)?.point;
70        let relative = target_point.sub(source_structure.frame.origin);
71        let axial = relative.dot(source_structure.frame.axis);
72        let radial = relative
73            .sub(source_structure.frame.axis.scale(axial))
74            .length();
75        meridian_points.push(
76            source_structure
77                .frame
78                .origin
79                .add(source_structure.frame.x_axis.scale(radial))
80                .add(source_structure.frame.axis.scale(axial)),
81        );
82        meridian_parameters.push(parameter);
83    }
84    let offset_generatrix = crate::interpolate_curve(&meridian_points, 3, &meridian_parameters)?;
85    let offset = crate::make_revolution(
86        source_structure.frame.origin,
87        source_structure.frame.axis,
88        &offset_generatrix,
89        *sweep,
90    )?;
91    let structure = crate::revolution_structure(&offset).ok_or_else(|| {
92        "offset_revolution_face: offset carrier lost its revolution structure — refusing"
93            .to_string()
94    })?;
95    if !matches!(offset.analytic(), Some(AnalyticSurface::Revolution { .. })) {
96        return Err(
97            "offset_revolution_face: offset carrier changed analytic kind — refusing".into(),
98        );
99    }
100
101    // A general interpolated offset is approximate. Admit it only when its
102    // same-parameter displacement stays normal and at the requested distance.
103    let target = distance.abs();
104    // Stagger away from repeated knots: derivatives at an exact full-period
105    // seam or a multiple internal knot are side-dependent and may be zero in
106    // the generic evaluator even though the carrier is regular there.
107    for iu in 0..13 {
108        for iv in 0..13 {
109            let u = u0 + (u1 - u0) * (iu as f64 + 0.37) / 13.0;
110            let v = v0 + (v1 - v0) * (iv as f64 + 0.41) / 13.0;
111            let source_point = face.surface.evaluate(u, v)?;
112            let offset_point = offset.evaluate(u, v)?;
113            let delta = offset_point.sub(source_point);
114            let normal = offsets
115                .normal(u, v)
116                .map_err(|error| format!("offset_revolution_face: sample normal: {error}"))?;
117            let distance_error = (delta.length() - target).abs();
118            let normal_error = if delta.length() > tolerance {
119                1.0 - delta
120                    .normalized()
121                    .map_err(|error| format!("offset_revolution_face: sample delta: {error}"))?
122                    .dot(normal)
123                    .abs()
124            } else {
125                0.0
126            };
127            if distance_error > residual_tolerance || normal_error > 5e-4 {
128                return Err(format!(
129                    "offset_revolution_face: offset fit exceeds tolerance \
130                     (distance error {distance_error:.3e}, normal error {normal_error:.3e})"
131                ));
132            }
133        }
134    }
135
136    let mut faces_of_edge: HashMap<u64, Vec<u64>> = HashMap::default();
137    for shell in &solid.shells {
138        for candidate in &shell.faces {
139            for loop_record in &candidate.loops {
140                for coedge in &loop_record.coedges {
141                    faces_of_edge
142                        .entry(coedge.edge_id)
143                        .or_default()
144                        .push(candidate.id);
145                }
146            }
147        }
148    }
149    let edge_by_id: HashMap<u64, &EdgeRecord> =
150        solid.edges.iter().map(|edge| (edge.id, edge)).collect();
151
152    // The offset generatrix endpoints revolve into the only two possible rims.
153    let [g0, g1] = structure.generatrix.domain()?;
154    let mut rim_candidates = Vec::new();
155    for parameter in [g0, g1] {
156        let point = structure.generatrix.evaluate(parameter)?;
157        let relative = point.sub(structure.frame.origin);
158        let axial = relative.dot(structure.frame.axis);
159        let radial = relative.sub(structure.frame.axis.scale(axial));
160        let radius = radial.length();
161        if radius <= tolerance {
162            return Err(format!(
163                "offset_revolution_face: an offset rim collapses onto the axis \
164                 (point {point:?}, frame {:?}) — refusing",
165                structure.frame
166            ));
167        }
168        rim_candidates.push(crate::make_arc(
169            structure
170                .frame
171                .origin
172                .add(structure.frame.axis.scale(axial)),
173            structure.frame.x_axis,
174            structure.frame.y_axis,
175            radius,
176            0.0,
177            structure.sweep,
178        )?);
179    }
180
181    let mut new_curves: HashMap<u64, NurbsCurve> = HashMap::default();
182    let mut new_vertices: HashMap<u64, Vec3> = HashMap::default();
183    let mut planar_neighbours: HashSet<u64> = HashSet::default();
184    for loop_record in &face.loops {
185        for coedge in &loop_record.coedges {
186            if new_curves.contains_key(&coedge.edge_id) {
187                continue;
188            }
189            let edge = *edge_by_id.get(&coedge.edge_id).ok_or_else(|| {
190                format!("offset_revolution_face: missing edge {}", coedge.edge_id)
191            })?;
192            let incident = faces_of_edge.get(&edge.id).cloned().unwrap_or_default();
193            // Kept for the general lane below: an edge shared with a FIXED
194            // planar cap has to stay in that cap's plane, and only this loop
195            // knows which plane that is.
196            let mut neighbour_plane = None;
197            if let Some(neighbour) = incident.iter().find(|candidate| **candidate != face_id) {
198                let (ns, nf) = find_face(solid, *neighbour).ok_or_else(|| {
199                    format!("offset_revolution_face: missing neighbour {neighbour}")
200                })?;
201                match plane_of_surface(
202                    &solid.shells[ns].faces[nf].surface,
203                    residual_tolerance,
204                    "offset_revolution_face",
205                ) {
206                    Err(_) => {
207                        return Err(
208                            "offset_revolution_face: only planar neighbours are supported \
209                                for a general revolution — refusing"
210                                .into(),
211                        );
212                    }
213                    Ok(plane) => neighbour_plane = Some(plane),
214                }
215                planar_neighbours.insert(*neighbour);
216            }
217
218            // Constant-v pcurve → a revolved endpoint rim. Constant-u → a
219            // start/end meridian (or the intrinsic full-sweep seam).
220            let [q0, q1] = coedge.pcurve.domain()?;
221            let uv0 = coedge.pcurve.evaluate(q0)?;
222            let uv1 = coedge.pcurve.evaluate(q1)?;
223            let uvm = coedge.pcurve.evaluate(0.5 * (q0 + q1))?;
224            let uv_tolerance = 1e-7;
225            let mut general_image = false;
226            let mut curve = if (uv0.y - uv1.y).abs() <= uv_tolerance
227                && (uv0.y - uvm.y).abs() <= uv_tolerance
228            {
229                let old_mid = edge.curve.evaluate(0.5 * (edge.t0 + edge.t1))?;
230                let mut best = rim_candidates[0].clone();
231                let mut best_distance = f64::INFINITY;
232                for candidate in &rim_candidates {
233                    let [c0, c1] = candidate.domain()?;
234                    let d = candidate.evaluate(0.5 * (c0 + c1))?.sub(old_mid).length();
235                    if d < best_distance {
236                        best_distance = d;
237                        best = candidate.clone();
238                    }
239                }
240                best
241            } else if (uv0.x - uv1.x).abs() <= uv_tolerance && (uv0.x - uvm.x).abs() <= uv_tolerance
242            {
243                offset.iso_curve_u(uv0.x)?
244            } else {
245                // A GENERAL boundary pcurve — what this site used to refuse.
246                // Its image on the offset carrier is built by the shared
247                // ladder, oriented off `coedge.forward` (the authoritative
248                // pcurve↔edge relation) rather than the iso lanes' geometric
249                // nearest-endpoint match, because a general image need not
250                // share an endpoint with the old curve at all.
251                general_image = true;
252                let image = crate::image_curve(
253                    &offset,
254                    &coedge.pcurve,
255                    fit_tolerance,
256                    "offset_revolution_face",
257                )?;
258                if coedge.forward {
259                    image.curve
260                } else {
261                    image.curve.reversed()?
262                }
263            };
264            // The transfer is only legitimate where the boundary is
265            // DEFINITIONAL. Against a FIXED planar cap the true new rim is
266            // `offset carrier ∩ cap plane`, and the image of the old pcurve
267            // leaves that plane by the offset's out-of-plane component. The
268            // validator's `pcurve_acceptance` band (2.5% of the model diagonal)
269            // is far too loose to catch that, so measure it here and refuse
270            // with the number rather than let a wrong rim through.
271            //
272            // Scoped to the general lane on purpose: the iso lanes rebuild
273            // their rims from the offset generatrix and carry their own,
274            // separately tracked, off-plane drift (refusal census §3.2). Making
275            // this gate global would change results this slice did not measure.
276            if general_image {
277                if let Some(plane) = neighbour_plane {
278                    let [g0, g1] = curve.domain()?;
279                    let mut worst = 0.0f64;
280                    for index in 0..=32 {
281                        let t = g0 + (g1 - g0) * index as f64 / 32.0;
282                        let point = curve.evaluate(t)?;
283                        worst = worst.max(point.sub(plane.origin).dot(plane.normal).abs());
284                    }
285                    if worst > residual_tolerance {
286                        return Err(format!(
287                            "offset_revolution_face: the general image of boundary edge {} \
288                             leaves its fixed planar neighbour by {worst:.3e} (limit \
289                             {residual_tolerance:.3e}) — re-intersecting the offset carrier \
290                             with a fixed neighbour is a separate capability; refusing",
291                            edge.id
292                        ));
293                    }
294                }
295            }
296            if !general_image {
297                let [c0, c1] = curve.domain()?;
298                let old_start = edge.curve.evaluate(edge.t0)?;
299                if curve.evaluate(c0)?.sub(old_start).length()
300                    > curve.evaluate(c1)?.sub(old_start).length()
301                {
302                    curve = curve.reversed()?;
303                }
304            }
305            let [d0, d1] = curve.domain()?;
306            let start = curve.evaluate(d0)?;
307            let end = curve.evaluate(d1)?;
308            new_vertices.insert(edge.start_vertex_id, start);
309            new_vertices.insert(edge.end_vertex_id, end);
310            new_curves.insert(edge.id, curve);
311        }
312    }
313
314    let mut result = solid.clone();
315    result.shells[shell_index].faces[face_index].surface = offset;
316    for edge in &mut result.edges {
317        if let Some(curve) = new_curves.get(&edge.id) {
318            let [d0, d1] = curve.domain()?;
319            edge.curve = curve.clone();
320            edge.t0 = d0;
321            edge.t1 = d1;
322        }
323    }
324    for vertex in &mut result.vertices {
325        if let Some(point) = new_vertices.get(&vertex.id) {
326            vertex.point = *point;
327        }
328    }
329    // A full-revolve planar disk/annulus may carry its own radial seam from an
330    // axis vertex to the shared circular rim. Moving the rim relocates one end
331    // of that seam, so rebuild the straight edge before re-trimming the cap.
332    let result_vertices: HashMap<u64, Vec3> = result
333        .vertices
334        .iter()
335        .map(|vertex| (vertex.id, vertex.point))
336        .collect();
337    let mut cap_seams = HashSet::default();
338    for neighbour in &planar_neighbours {
339        let (ns, nf) = find_face(&result, *neighbour)
340            .ok_or_else(|| format!("offset_revolution_face: missing cap {neighbour}"))?;
341        for loop_record in &result.shells[ns].faces[nf].loops {
342            for coedge in &loop_record.coedges {
343                let edge = *edge_by_id.get(&coedge.edge_id).ok_or_else(|| {
344                    format!(
345                        "offset_revolution_face: missing cap edge {}",
346                        coedge.edge_id
347                    )
348                })?;
349                let touches_moved = new_vertices.contains_key(&edge.start_vertex_id)
350                    || new_vertices.contains_key(&edge.end_vertex_id);
351                if touches_moved
352                    && !new_curves.contains_key(&edge.id)
353                    && !edge.degenerate
354                    && edge.curve.degree == 1
355                {
356                    cap_seams.insert(edge.id);
357                }
358            }
359        }
360    }
361    for seam_id in cap_seams {
362        let source = edge_by_id[&seam_id];
363        let curve = make_line(
364            result_vertices[&source.start_vertex_id],
365            result_vertices[&source.end_vertex_id],
366        )?;
367        let [d0, d1] = curve.domain()?;
368        if let Some(edge) = result.edges.iter_mut().find(|edge| edge.id == seam_id) {
369            edge.curve = curve;
370            edge.t0 = d0;
371            edge.t1 = d1;
372        }
373    }
374    let final_edges: HashMap<u64, EdgeRecord> = result
375        .edges
376        .iter()
377        .map(|edge| (edge.id, edge.clone()))
378        .collect();
379    for neighbour in planar_neighbours {
380        let (ns, nf) = find_face(&result, neighbour)
381            .ok_or_else(|| format!("offset_revolution_face: missing cap {neighbour}"))?;
382        let plane = plane_of_surface(
383            &result.shells[ns].faces[nf].surface,
384            residual_tolerance,
385            "offset_revolution_face",
386        )?;
387        retrim_planar_face(
388            &mut result.shells[ns].faces[nf],
389            &plane,
390            &final_edges,
391            scale,
392            "offset_revolution_face",
393        )
394        .map_err(|error| format!("offset_revolution_face: cap retrim: {error}"))?;
395    }
396    let issues = result.validate();
397    if !issues.is_empty() {
398        return Err(format!(
399            "offset_revolution_face: pushed solid failed validation: {issues:?}"
400        ));
401    }
402    if let (Ok(before), Ok(after)) = (solid_signed_volume(solid), solid_signed_volume(&result)) {
403        if before * after <= 0.0 {
404            return Err("offset_revolution_face: the push inverts the solid — refusing".into());
405        }
406    }
407    Ok(result)
408}
409
410// BREP private tests: e1a110296b52d090