Skip to main content

brep_kernel/edit/direct_edit/
face_offset_torus.rs

1use super::*;
2
3/// Push a full toroidal face by changing its tube radius exactly.
4///
5/// This first torus slice deliberately accepts only an untrimmed torus whose
6/// boundary edges are intrinsic periodic seams (both incidences belong to the
7/// pushed face). Boolean-trimmed torus rims need neighbour re-intersection and
8/// are refused before mutation. The offset carrier preserves the source knot
9/// vectors, weights, and `(u,v)` parameterization, so existing pcurves remain
10/// valid and the two 3D seam curves can be rematerialized as exact iso-curves.
11///
12/// An intrinsic boundary that is NOT an iso line is no longer refused:
13/// [`crate::image_curve`] builds its 3D image on the offset torus. That lane is
14/// unreached by the corpus today — the neighbour-trim gate above rejects every
15/// trimmed torus first (refusal census §3.6) — so it is a latent restriction
16/// removed, not a measured behaviour change.
17pub fn offset_torus_face(
18    solid: &BrepSolid,
19    face_id: u64,
20    distance: f64,
21) -> Result<BrepSolid, String> {
22    if !distance.is_finite() {
23        return Err("offset_torus_face: distance must be finite".into());
24    }
25    let scale = solid_model_scale(solid);
26    let tolerance = (scale * 1e-7).max(1e-9);
27    // Bar for the general 3D-image ladder: the kernel's single named
28    // fit-accuracy field, not the identity band `tolerance` above.
29    let fit_tolerance = crate::KernelTolerances::for_scale(scale, 1e-7).intersection_fit;
30    let (shell_index, face_index) =
31        find_face(solid, face_id).ok_or_else(|| format!("offset_torus_face: no face {face_id}"))?;
32    let face = &solid.shells[shell_index].faces[face_index];
33    let Some(AnalyticSurface::Torus {
34        frame,
35        major_radius,
36        minor_radius,
37    }) = face.surface.analytic()
38    else {
39        return Err("offset_torus_face: the pushed face is not a torus".into());
40    };
41
42    // Positive push follows the face's outward normal. For a cavity torus the
43    // topological normal opposes the tube radial, so positive distance shrinks
44    // the carrier instead of growing it.
45    let [u0, u1] = face.surface.domain_u()?;
46    let [v0, v1] = face.surface.domain_v()?;
47    let (um, vm) = (0.5 * (u0 + u1), 0.5 * (v0 + v1));
48    let point = face.surface.evaluate(um, vm)?;
49    let mut normal = face.surface.normal(um, vm)?;
50    if !face.same_sense {
51        normal = normal.scale(-1.0);
52    }
53    let relative = point.sub(frame.origin);
54    let axial = relative.dot(frame.axis);
55    let radial_direction = relative.sub(frame.axis.scale(axial)).normalized()?;
56    let tube_center = frame
57        .origin
58        .add(frame.axis.scale(axial))
59        .add(radial_direction.scale(*major_radius));
60    let tube_radial = point.sub(tube_center);
61    let outward_sign = if normal.dot(tube_radial) >= 0.0 {
62        1.0
63    } else {
64        -1.0
65    };
66    let minor_new = *minor_radius + distance * outward_sign;
67    if minor_new <= tolerance {
68        return Err("offset_torus_face: the push collapses the tube radius — refusing".into());
69    }
70    if minor_new >= *major_radius - tolerance {
71        return Err("offset_torus_face: the push creates a horn/spindle torus — refusing".into());
72    }
73
74    // A trimmed rim has another incident face. Keep that harder neighbour-heal
75    // slice out of this exact full-torus path.
76    let mut faces_of_edge: HashMap<u64, Vec<u64>> = HashMap::default();
77    for shell in &solid.shells {
78        for candidate in &shell.faces {
79            for loop_record in &candidate.loops {
80                for coedge in &loop_record.coedges {
81                    faces_of_edge
82                        .entry(coedge.edge_id)
83                        .or_default()
84                        .push(candidate.id);
85                }
86            }
87        }
88    }
89    for loop_record in &face.loops {
90        for coedge in &loop_record.coedges {
91            let incident = faces_of_edge
92                .get(&coedge.edge_id)
93                .cloned()
94                .unwrap_or_default();
95            if incident.iter().any(|candidate| *candidate != face_id) {
96                return Err(
97                    "offset_torus_face: a neighbour-trimmed torus is deferred; only the \
98                            full torus is supported in this slice"
99                        .into(),
100                );
101            }
102        }
103    }
104
105    // offset_surface uses the shell convention positive=opposite outward, so
106    // negate the direct-edit distance. For an analytic torus the Greville fit
107    // reproduces the exact rational torus with unchanged parameterization.
108    let offset = crate::offset_surface(face, -distance, 0.0)?;
109    match offset.analytic() {
110        Some(AnalyticSurface::Torus {
111            frame: out_frame,
112            major_radius: out_major,
113            minor_radius: out_minor,
114        }) if out_frame.origin.sub(frame.origin).length() <= tolerance
115            && out_frame.axis.dot(frame.axis).abs() >= 1.0 - 1e-9
116            && (*out_major - *major_radius).abs() <= tolerance
117            && (*out_minor - minor_new).abs() <= tolerance => {}
118        other => {
119            return Err(format!(
120                "offset_torus_face: offset carrier was not the expected exact torus: {other:?}"
121            ))
122        }
123    }
124
125    let edge_by_id: HashMap<u64, &EdgeRecord> =
126        solid.edges.iter().map(|edge| (edge.id, edge)).collect();
127    let mut rebuilt_edges: HashMap<u64, (NurbsCurve, f64, f64)> = HashMap::default();
128    let mut rebuilt_vertices: HashMap<u64, Vec3> = HashMap::default();
129    for loop_record in &face.loops {
130        for coedge in &loop_record.coedges {
131            if rebuilt_edges.contains_key(&coedge.edge_id) {
132                continue;
133            }
134            let edge = *edge_by_id
135                .get(&coedge.edge_id)
136                .ok_or_else(|| format!("offset_torus_face: missing edge {}", coedge.edge_id))?;
137            let [q0, q1] = coedge.pcurve.domain()?;
138            let a = coedge.pcurve.evaluate(q0)?;
139            let b = coedge.pcurve.evaluate(q1)?;
140            let m = coedge.pcurve.evaluate(0.5 * (q0 + q1))?;
141            let uv_tolerance = 1e-8;
142            // The two iso lanes are untouched: a periodic seam is an exact iso
143            // of the same-basis offset torus, so every solid this path already
144            // builds stays BIT-IDENTICAL.
145            let iso = if (a.x - b.x).abs() <= uv_tolerance && (a.x - m.x).abs() <= uv_tolerance {
146                Some((offset.iso_curve_u(a.x)?, a.y, b.y))
147            } else if (a.y - b.y).abs() <= uv_tolerance && (a.y - m.y).abs() <= uv_tolerance {
148                Some((offset.iso_curve_v(a.y)?, a.x, b.x))
149            } else {
150                None
151            };
152            // A general intrinsic trim used to be refused here. This path
153            // admits only edges with no OTHER incident face, so the boundary is
154            // definitional and its image on the offset torus IS the new edge.
155            let (base, mut start, mut end) = match iso {
156                Some(triple) => triple,
157                None => {
158                    let image = crate::image_curve(
159                        &offset,
160                        &coedge.pcurve,
161                        fit_tolerance,
162                        "offset_torus_face",
163                    )?;
164                    (image.curve, image.t0, image.t1)
165                }
166            };
167            if !coedge.forward {
168                std::mem::swap(&mut start, &mut end);
169            }
170            let [d0, d1] = base.domain()?;
171            let (curve, t0, t1) = if start <= end {
172                (base, start, end)
173            } else {
174                (base.reversed()?, d0 + d1 - start, d0 + d1 - end)
175            };
176            let start_point = curve.evaluate(t0)?;
177            let end_point = curve.evaluate(t1)?;
178            for (vertex_id, vertex_point) in [
179                (edge.start_vertex_id, start_point),
180                (edge.end_vertex_id, end_point),
181            ] {
182                if let Some(known) = rebuilt_vertices.get(&vertex_id) {
183                    if known.sub(vertex_point).length() > tolerance {
184                        return Err(format!(
185                            "offset_torus_face: periodic seams disagree at vertex {vertex_id}"
186                        ));
187                    }
188                } else {
189                    rebuilt_vertices.insert(vertex_id, vertex_point);
190                }
191            }
192            rebuilt_edges.insert(edge.id, (curve, t0, t1));
193        }
194    }
195
196    let mut result = solid.clone();
197    result.shells[shell_index].faces[face_index].surface = offset;
198    for edge in &mut result.edges {
199        if let Some((curve, t0, t1)) = rebuilt_edges.get(&edge.id) {
200            edge.curve = curve.clone();
201            edge.t0 = *t0;
202            edge.t1 = *t1;
203        }
204    }
205    for vertex in &mut result.vertices {
206        if let Some(point) = rebuilt_vertices.get(&vertex.id) {
207            vertex.point = *point;
208        }
209    }
210    let issues = result.validate();
211    if !issues.is_empty() {
212        return Err(format!(
213            "offset_torus_face: pushed solid failed validation: {issues:?}"
214        ));
215    }
216    if let (Ok(before), Ok(after)) = (solid_signed_volume(solid), solid_signed_volume(&result)) {
217        if before * after <= 0.0 {
218            return Err("offset_torus_face: the push inverts the solid — refusing".into());
219        }
220    }
221    Ok(result)
222}
223
224#[cfg(test)]
225mod tests {
226    use super::*;
227
228    fn torus_face(solid: &BrepSolid) -> u64 {
229        solid
230            .shells
231            .iter()
232            .flat_map(|shell| &shell.faces)
233            .find(|face| matches!(face.surface.analytic(), Some(AnalyticSurface::Torus { .. })))
234            .expect("torus face")
235            .id
236    }
237
238    fn torus_radii(solid: &BrepSolid) -> (f64, f64) {
239        solid
240            .shells
241            .iter()
242            .flat_map(|shell| &shell.faces)
243            .find_map(|face| match face.surface.analytic() {
244                Some(AnalyticSurface::Torus {
245                    major_radius,
246                    minor_radius,
247                    ..
248                }) => Some((*major_radius, *minor_radius)),
249                _ => None,
250            })
251            .expect("torus radii")
252    }
253
254    #[test]
255    fn push_full_torus_changes_minor_radius_exactly() {
256        for (distance, expected_minor) in [(0.75_f64, 2.75_f64), (-0.75, 1.25)] {
257            let torus = crate::make_torus_brep(Vec3::default(), Vec3::new(0.0, 0.0, 1.0), 6.0, 2.0)
258                .unwrap();
259            let pushed = offset_torus_face(&torus, torus_face(&torus), distance)
260                .unwrap_or_else(|error| panic!("push torus by {distance}: {error}"));
261            assert!(pushed.validate().is_empty());
262            let (major, minor) = torus_radii(&pushed);
263            assert!((major - 6.0).abs() < 1e-8);
264            assert!((minor - expected_minor).abs() < 1e-8);
265            let expected_volume =
266                2.0 * std::f64::consts::PI.powi(2) * major * expected_minor.powi(2);
267            let volume = solid_signed_volume(&pushed).unwrap().abs();
268            assert!((volume - expected_volume).abs() < 1e-2);
269        }
270    }
271
272    #[test]
273    fn push_full_torus_collapse_and_spindle_refuse() {
274        let torus =
275            crate::make_torus_brep(Vec3::default(), Vec3::new(0.0, 0.0, 1.0), 6.0, 2.0).unwrap();
276        let face = torus_face(&torus);
277        let collapse = offset_torus_face(&torus, face, -2.0).expect_err("collapse must refuse");
278        assert!(collapse.contains("collapse"));
279        let spindle = offset_torus_face(&torus, face, 4.0).expect_err("spindle must refuse");
280        assert!(spindle.contains("horn/spindle"));
281    }
282
283    #[test]
284    fn push_toroidal_cavity_positive_shrinks_void() {
285        let block = crate::make_box_brep(Vec3::new(-10.0, -10.0, -5.0), 20.0, 20.0, 10.0).unwrap();
286        let tool =
287            crate::make_torus_brep(Vec3::default(), Vec3::new(0.0, 0.0, 1.0), 4.0, 1.5).unwrap();
288        let cavity = crate::boolean_operation(
289            &block,
290            &tool,
291            crate::BooleanOperation::Subtract,
292            &crate::BooleanOptions::default(),
293        )
294        .unwrap();
295        let before = solid_signed_volume(&cavity).unwrap().abs();
296        let pushed = offset_torus_face(&cavity, torus_face(&cavity), 0.25)
297            .unwrap_or_else(|error| panic!("push torus cavity: {error}"));
298        assert!(pushed.validate().is_empty());
299        let (_, minor) = torus_radii(&pushed);
300        assert!((minor - 1.25).abs() < 1e-8, "cavity minor radius {minor}");
301        assert!(solid_signed_volume(&pushed).unwrap().abs() > before);
302    }
303}