brep_kernel/edit/direct_edit/
face_offset_freeform.rs1use super::*;
2
3pub 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 let residual_tolerance = (scale * 5e-4).max(5e-6);
30 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 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 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 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}