brep_kernel/geometry/image_curve.rs
1//! The 3D IMAGE of a parameter-space curve on a surface: `t ↦ S(p(t))`.
2//!
3//! Four offset/push sites used to refuse any boundary whose pcurve was not an
4//! iso-parameter line, because an iso-curve extraction was the only way they
5//! knew to rematerialize a boundary in 3D. OCCT has no such restriction: the
6//! iso extraction is an *optimisation with a verified fallback*, and the
7//! general case is a sampled approximation of the composed curve-on-surface
8//! (`GeomLib::BuildCurve3d`, `GeomLib.cxx:960-1071`; the self-verifying iso
9//! shortcut is `GeomLib::buildC3dOnIsoLine`, `:2878-3018`). This module is
10//! that ladder, written for our representation and gated on our own measured
11//! deviation. All four refusals were the same missing capability — the 3D
12//! image of a general pcurve on a NURBS surface — and not a restriction
13//! inherent to offsetting: OCCT installs the original pcurve on the offset face
14//! verbatim, leaving only the 3D curve to be approximated.
15//!
16//! # The parametrisation contract
17//!
18//! [`BrepSolid::validate`]'s coedge/edge coincidence check
19//! (`brep/topology/validate.rs`'s `coedge_sample`) compares the two
20//! representations at MATCHED FRACTIONS: fraction `f` of the pcurve's own
21//! domain against fraction `f` of the edge's `[t0, t1]`. Every tier here
22//! therefore returns the parameter pair `(t0, t1)` for which
23//!
24//! ```text
25//! curve(t0 + (t1 - t0)·f) ≈ surface(pcurve(q0 + (q1 - q0)·f))
26//! ```
27//!
28//! for all `f ∈ [0, 1]`, where `[q0, q1]` is the pcurve's domain. `t0 > t1`
29//! is legal and means the extracted curve runs against the pcurve — callers
30//! that need an increasing edge range reverse the curve and reflect the
31//! parameters, exactly as the existing iso branches do.
32//!
33//! # The tiers, and what each validates
34//!
35//! 1. [`ImageCurveTier::Affine`] — an affine (degree 1 × 1, equal-weight,
36//! parallelogram) surface. An affine map commutes with the rational
37//! evaluation, so mapping the pcurve's HOMOGENEOUS control points through
38//! the frame is exact for ANY rational pcurve: a rational circle in `(u,v)`
39//! becomes the exact 3D circle, sharing degree, knots and weights.
40//! 2. [`ImageCurveTier::Iso`] — the pcurve holds one coordinate constant. The
41//! image is the surface's iso-curve in the other direction, whose own
42//! parameter IS that coordinate, so `(t0, t1)` are simply the varying
43//! coordinate's endpoint values. This is exact only when the pcurve is also
44//! AFFINE in its parameter; a constant-`u` pcurve with a non-linear `v(t)`
45//! passes a constant-coordinate test yet breaks the fraction contract. The
46//! tier therefore measures itself and falls through when it misses.
47//! 3. [`ImageCurveTier::Approximated`] — the general case. The composed curve
48//! is sampled (seeded at the pcurve's own knots, where its smoothness
49//! breaks), interpolated at those same parameters so the contract holds by
50//! construction at the nodes, then measured strictly BETWEEN the nodes —
51//! interpolation is exact at its own nodes, so an on-node check would
52//! always pass and prove nothing. Intervals that miss are bisected and the
53//! fit repeated. If the loop cannot reach the bar the tier REFUSES, with
54//! the measured deviation in the message: a wrong curve is worse than a
55//! refusal.
56//!
57//! Tiers 1 and 2 verify with the same off-node sweep tier 3 uses, and fall
58//! THROUGH to the next tier on failure rather than refusing, so a shortcut that
59//! does not apply costs accuracy nowhere.
60
61use crate::curve::{NurbsCurve, Vec4};
62use crate::fit::interpolate_curve;
63use crate::surface::NurbsSurface;
64use crate::Vec3;
65
66/// Which lane produced an [`ImageCurve`].
67#[derive(Clone, Copy, Debug, Eq, PartialEq)]
68pub enum ImageCurveTier {
69 /// Exact homogeneous mapping through an affine surface's frame.
70 Affine,
71 /// Verified iso-curve extraction.
72 Iso,
73 /// Sampled-and-fitted approximation of the composed curve-on-surface.
74 Approximated,
75}
76
77/// The 3D image of a pcurve on a surface, plus the parameter range that
78/// matches the pcurve's own domain fraction for fraction.
79#[derive(Clone, Debug)]
80pub struct ImageCurve {
81 pub curve: NurbsCurve,
82 /// Curve parameter at the pcurve's domain START. May exceed `t1`.
83 pub t0: f64,
84 /// Curve parameter at the pcurve's domain END.
85 pub t1: f64,
86 pub tier: ImageCurveTier,
87 /// Measured `max ‖curve(t) − surface(pcurve(q))‖` over the off-node sweep.
88 pub deviation: f64,
89}
90
91/// Number of off-node samples used to verify an exact/iso tier, matching
92/// OCCT's `THE_ISOLINE_CHECK_SEGMENTS` (`GeomLib.cxx:131`) density floor.
93const VERIFY_SAMPLES: usize = 24;
94
95/// Off-node probes per interval in the approximation loop. Five interior
96/// fractions at `k/6` never coincide with an interval end or midpoint, so a
97/// bisection always lands on a previously unprobed parameter.
98const PROBES_PER_INTERVAL: usize = 5;
99
100/// Refinement rounds before the approximation tier gives up.
101const MAX_ROUNDS: usize = 14;
102
103/// Sample ceiling for the approximation tier.
104const MAX_SAMPLES: usize = 4096;
105
106/// Exact 3D image of a pcurve on an AFFINE sheet: an affine map applied to
107/// the homogeneous control points commutes with the rational evaluation, so
108/// the image shares the pcurve's degree / knots / weights and is
109/// parametrized identically — a rational circle pcurve maps to the exact 3D
110/// circle.
111pub fn affine_image_curve(
112 sheet: &NurbsSurface,
113 pcurve: &NurbsCurve,
114) -> Result<NurbsCurve, String> {
115 let [u0, _] = sheet.domain_u()?;
116 let [v0, _] = sheet.domain_v()?;
117 let frame = sheet.derivatives(u0, v0, 1)?;
118 let origin = frame[0][0];
119 let du = frame[1][0];
120 let dv = frame[0][1];
121 let control_points = pcurve
122 .control_points
123 .iter()
124 .map(|control| {
125 let position = origin
126 .scale(control.w)
127 .add(du.scale(control.x - control.w * u0))
128 .add(dv.scale(control.y - control.w * v0));
129 Vec4 {
130 x: position.x,
131 y: position.y,
132 z: position.z,
133 w: control.w,
134 }
135 })
136 .collect();
137 NurbsCurve::new(pcurve.degree, pcurve.knots.clone(), control_points)
138}
139
140/// The composed curve-on-surface `q ↦ S(p(q))`.
141///
142/// `evaluate_extended` matches what the validator's `coedge_sample` does: a
143/// pcurve that grazes or straddles a periodic seam carries parameters just past
144/// the domain, and the surface WRAPS there. Clamping instead would read as a
145/// gross deviation and turn a legitimate seam-straddling trim into a refusal.
146fn compose(surface: &NurbsSurface, pcurve: &NurbsCurve, q: f64) -> Result<Vec3, String> {
147 let uv = pcurve.evaluate(q)?;
148 surface.evaluate_extended(uv.x, uv.y)
149}
150
151/// Worst `‖curve(t0 + (t1−t0)·f) − S(p(q0 + (q1−q0)·f))‖` over `count`
152/// interior fractions, none of which is an endpoint.
153fn sweep_deviation(
154 surface: &NurbsSurface,
155 pcurve: &NurbsCurve,
156 curve: &NurbsCurve,
157 t0: f64,
158 t1: f64,
159 count: usize,
160) -> Result<f64, String> {
161 let [q0, q1] = pcurve.domain()?;
162 let mut worst = 0.0f64;
163 for index in 0..=count {
164 let fraction = index as f64 / count as f64;
165 let target = compose(surface, pcurve, q0 + (q1 - q0) * fraction)?;
166 let value = curve.evaluate(t0 + (t1 - t0) * fraction)?;
167 worst = worst.max(value.sub(target).length());
168 }
169 Ok(worst)
170}
171
172/// Is `pcurve` a constant-`u` or constant-`v` line whose varying coordinate is
173/// AFFINE in the curve parameter? Returns `(constant is u, varying value at
174/// q0, varying value at q1)`.
175///
176/// Both halves matter. Constancy alone picks the iso-curve; affinity is what
177/// makes the iso-curve's own parameter a fraction-for-fraction stand-in for the
178/// pcurve parameter. The check is structural first (degree 1, two poles, equal
179/// weights — exactly linear, the shape a boolean's straight trim and a seam
180/// both take) and sampled second, so a degree-elevated but geometrically linear
181/// pcurve is still recognised.
182fn iso_line(pcurve: &NurbsCurve, eps_u: f64, eps_v: f64) -> Result<Option<(bool, f64, f64)>, String> {
183 let [q0, q1] = pcurve.domain()?;
184 let first = pcurve.evaluate(q0)?;
185 let last = pcurve.evaluate(q1)?;
186 let constant_u = (first.x - last.x).abs() <= eps_u;
187 let constant_v = (first.y - last.y).abs() <= eps_v;
188 // A pcurve constant in BOTH directions is a point; there is no iso-curve to
189 // extract and no direction to trim along.
190 if constant_u == constant_v {
191 return Ok(None);
192 }
193 let span = q1 - q0;
194 for index in 1..8 {
195 let fraction = index as f64 / 8.0;
196 let uv = pcurve.evaluate(q0 + span * fraction)?;
197 let (held, varying, expected, eps_held, eps_vary) = if constant_u {
198 (
199 uv.x - first.x,
200 uv.y,
201 first.y + (last.y - first.y) * fraction,
202 eps_u,
203 eps_v,
204 )
205 } else {
206 (
207 uv.y - first.y,
208 uv.x,
209 first.x + (last.x - first.x) * fraction,
210 eps_v,
211 eps_u,
212 )
213 };
214 if held.abs() > eps_held || (varying - expected).abs() > eps_vary {
215 return Ok(None);
216 }
217 }
218 Ok(Some(if constant_u {
219 (true, first.y, last.y)
220 } else {
221 (false, first.x, last.x)
222 }))
223}
224
225/// The constant coordinate an iso extraction should be taken at.
226fn iso_constant(pcurve: &NurbsCurve, constant_u: bool) -> Result<f64, String> {
227 let [q0, q1] = pcurve.domain()?;
228 let first = pcurve.evaluate(q0)?;
229 let last = pcurve.evaluate(q1)?;
230 Ok(if constant_u {
231 0.5 * (first.x + last.x)
232 } else {
233 0.5 * (first.y + last.y)
234 })
235}
236
237/// Parametric detection bands for [`iso_line`], derived from the surface's own
238/// domain so a `[0,1]²` patch and a `[0,2π]²` torus are judged alike.
239fn iso_epsilons(surface: &NurbsSurface) -> Result<(f64, f64), String> {
240 let [u0, u1] = surface.domain_u()?;
241 let [v0, v1] = surface.domain_v()?;
242 Ok((1e-7 * (u1 - u0).abs(), 1e-7 * (v1 - v0).abs()))
243}
244
245/// Seed parameters for the approximation tier: the pcurve's ends, its distinct
246/// interior knots (where the composed curve's smoothness breaks — the cheap
247/// analogue of OCCT's C2/C3 discontinuity split at `GeomLib.cxx:1035-1042`) and
248/// a uniform floor so a single-span pcurve still starts with real structure.
249fn seed_parameters(pcurve: &NurbsCurve) -> Result<Vec<f64>, String> {
250 let [q0, q1] = pcurve.domain()?;
251 let span = q1 - q0;
252 let mut parameters = vec![q0, q1];
253 for knot in &pcurve.knots {
254 if *knot > q0 && *knot < q1 {
255 parameters.push(*knot);
256 }
257 }
258 for index in 1..8 {
259 parameters.push(q0 + span * index as f64 / 8.0);
260 }
261 parameters.sort_by(|a, b| a.partial_cmp(b).unwrap_or(std::cmp::Ordering::Equal));
262 parameters.dedup_by(|a, b| (*a - *b).abs() <= span.abs() * 1e-9);
263 Ok(parameters)
264}
265
266/// Fit the composed curve at `parameters`, then measure it strictly BETWEEN
267/// them. Returns the fitted curve, the worst off-node deviation, and the
268/// per-interval worst deviations (one shorter than `parameters`).
269fn fit_and_measure(
270 surfaces: &[&NurbsSurface],
271 pcurve: &NurbsCurve,
272 parameters: &[f64],
273 which: usize,
274) -> Result<(NurbsCurve, f64, Vec<f64>), String> {
275 let surface = surfaces[which];
276 let points = parameters
277 .iter()
278 .map(|q| compose(surface, pcurve, *q))
279 .collect::<Result<Vec<_>, String>>()?;
280 let degree = 3.min(points.len() - 1);
281 let curve = interpolate_curve(&points, degree, parameters)?;
282 let mut worst = 0.0f64;
283 let mut per_interval = Vec::with_capacity(parameters.len() - 1);
284 for window in parameters.windows(2) {
285 let (a, b) = (window[0], window[1]);
286 let mut local = 0.0f64;
287 for probe in 1..=PROBES_PER_INTERVAL {
288 let q = a + (b - a) * probe as f64 / (PROBES_PER_INTERVAL + 1) as f64;
289 let target = compose(surface, pcurve, q)?;
290 local = local.max(curve.evaluate(q)?.sub(target).length());
291 }
292 worst = worst.max(local);
293 per_interval.push(local);
294 }
295 Ok((curve, worst, per_interval))
296}
297
298/// The approximation tier, shared by the single- and paired-surface entry
299/// points. Every surface in `surfaces` is fitted over ONE parameter set, so
300/// the results share degree, knots and (unit) weights — the basis identity
301/// `thicken`'s `ruled_wall` requires of a bottom/top image pair.
302fn approximate(
303 surfaces: &[&NurbsSurface],
304 pcurve: &NurbsCurve,
305 tolerance: f64,
306 site: &str,
307) -> Result<Vec<ImageCurve>, String> {
308 let [q0, q1] = pcurve.domain()?;
309 let mut parameters = seed_parameters(pcurve)?;
310 let mut best = f64::INFINITY;
311 for _ in 0..MAX_ROUNDS {
312 let mut fits = Vec::with_capacity(surfaces.len());
313 let mut worst = 0.0f64;
314 let mut per_interval = vec![0.0f64; parameters.len() - 1];
315 for which in 0..surfaces.len() {
316 let (curve, sheet_worst, sheet_intervals) =
317 fit_and_measure(surfaces, pcurve, ¶meters, which)?;
318 worst = worst.max(sheet_worst);
319 for (slot, value) in per_interval.iter_mut().zip(&sheet_intervals) {
320 *slot = slot.max(*value);
321 }
322 fits.push(curve);
323 }
324 best = best.min(worst);
325 if worst <= tolerance {
326 return Ok(fits
327 .into_iter()
328 .map(|curve| ImageCurve {
329 curve,
330 t0: q0,
331 t1: q1,
332 tier: ImageCurveTier::Approximated,
333 deviation: worst,
334 })
335 .collect());
336 }
337 // Bisect every interval that missed the bar. Refining only the single
338 // worst interval converges linearly in the number of fits; refining all
339 // failing intervals halves the whole failing region per round.
340 let mut refined = Vec::with_capacity(parameters.len() * 2);
341 for (index, window) in parameters.windows(2).enumerate() {
342 refined.push(window[0]);
343 if per_interval[index] > tolerance {
344 refined.push(0.5 * (window[0] + window[1]));
345 }
346 }
347 refined.push(parameters[parameters.len() - 1]);
348 if refined.len() == parameters.len() || refined.len() > MAX_SAMPLES {
349 break;
350 }
351 parameters = refined;
352 }
353 Err(format!(
354 "{site}: the 3D image of a general pcurve could not be fitted to tolerance \
355 (worst off-node deviation {best:.3e} > {tolerance:.3e} after {} samples) — refusing",
356 parameters.len()
357 ))
358}
359
360/// Build the 3D image of an ARBITRARY pcurve on `surface`.
361///
362/// See the module docs for the tier ladder and the parametrisation contract.
363/// `tolerance` is the caller's own spatial accuracy bar for a committed edge;
364/// it is the acceptance criterion for every tier, and the approximation tier
365/// refuses rather than returning a fit that misses it. `site` names the caller
366/// in the refusal message.
367pub fn image_curve(
368 surface: &NurbsSurface,
369 pcurve: &NurbsCurve,
370 tolerance: f64,
371 site: &str,
372) -> Result<ImageCurve, String> {
373 let [q0, q1] = pcurve.domain()?;
374 if !(q1 - q0).is_finite() || (q1 - q0).abs() <= 0.0 {
375 return Err(format!("{site}: pcurve has an empty parameter domain"));
376 }
377
378 if surface.is_affine()? {
379 let curve = affine_image_curve(surface, pcurve)?;
380 let deviation = sweep_deviation(surface, pcurve, &curve, q0, q1, VERIFY_SAMPLES)?;
381 if deviation <= tolerance {
382 return Ok(ImageCurve {
383 curve,
384 t0: q0,
385 t1: q1,
386 tier: ImageCurveTier::Affine,
387 deviation,
388 });
389 }
390 }
391
392 let (eps_u, eps_v) = iso_epsilons(surface)?;
393 if let Some((constant_u, start, end)) = iso_line(pcurve, eps_u, eps_v)? {
394 let constant = iso_constant(pcurve, constant_u)?;
395 let curve = if constant_u {
396 surface.iso_curve_u(constant)?
397 } else {
398 surface.iso_curve_v(constant)?
399 };
400 let deviation = sweep_deviation(surface, pcurve, &curve, start, end, VERIFY_SAMPLES)?;
401 if deviation <= tolerance {
402 return Ok(ImageCurve {
403 curve,
404 t0: start,
405 t1: end,
406 tier: ImageCurveTier::Iso,
407 deviation,
408 });
409 }
410 }
411
412 Ok(approximate(&[surface], pcurve, tolerance, site)?
413 .pop()
414 .expect("one surface in, one image out"))
415}
416
417/// The images of ONE pcurve on TWO surfaces, guaranteed to share a basis.
418///
419/// `thicken`'s side wall is `ruled_wall(bottom, top)`, which refuses unless the
420/// two boundary curves agree in degree, knots and weights. Fitting the two
421/// sheets independently would let their adaptive sample sets diverge, so the
422/// approximation tier fits both over ONE parameter set refined against the
423/// worse of the two. The tier is chosen once and applies to both.
424pub fn image_curve_pair(
425 first: &NurbsSurface,
426 second: &NurbsSurface,
427 pcurve: &NurbsCurve,
428 tolerance: f64,
429 site: &str,
430) -> Result<(ImageCurve, ImageCurve), String> {
431 let [q0, q1] = pcurve.domain()?;
432 if !(q1 - q0).is_finite() || (q1 - q0).abs() <= 0.0 {
433 return Err(format!("{site}: pcurve has an empty parameter domain"));
434 }
435
436 if first.is_affine()? && second.is_affine()? {
437 let a = affine_image_curve(first, pcurve)?;
438 let b = affine_image_curve(second, pcurve)?;
439 let deviation = sweep_deviation(first, pcurve, &a, q0, q1, VERIFY_SAMPLES)?
440 .max(sweep_deviation(second, pcurve, &b, q0, q1, VERIFY_SAMPLES)?);
441 if deviation <= tolerance {
442 return Ok((
443 ImageCurve {
444 curve: a,
445 t0: q0,
446 t1: q1,
447 tier: ImageCurveTier::Affine,
448 deviation,
449 },
450 ImageCurve {
451 curve: b,
452 t0: q0,
453 t1: q1,
454 tier: ImageCurveTier::Affine,
455 deviation,
456 },
457 ));
458 }
459 }
460
461 let (eps_u, eps_v) = iso_epsilons(first)?;
462 if let Some((constant_u, start, end)) = iso_line(pcurve, eps_u, eps_v)? {
463 let constant = iso_constant(pcurve, constant_u)?;
464 let (a, b) = if constant_u {
465 (first.iso_curve_u(constant)?, second.iso_curve_u(constant)?)
466 } else {
467 (first.iso_curve_v(constant)?, second.iso_curve_v(constant)?)
468 };
469 let deviation = sweep_deviation(first, pcurve, &a, start, end, VERIFY_SAMPLES)?
470 .max(sweep_deviation(second, pcurve, &b, start, end, VERIFY_SAMPLES)?);
471 if deviation <= tolerance {
472 return Ok((
473 ImageCurve {
474 curve: a,
475 t0: start,
476 t1: end,
477 tier: ImageCurveTier::Iso,
478 deviation,
479 },
480 ImageCurve {
481 curve: b,
482 t0: start,
483 t1: end,
484 tier: ImageCurveTier::Iso,
485 deviation,
486 },
487 ));
488 }
489 }
490
491 let mut images = approximate(&[first, second], pcurve, tolerance, site)?;
492 let second_image = images.pop().expect("two surfaces in, two images out");
493 let first_image = images.pop().expect("two surfaces in, two images out");
494 Ok((first_image, second_image))
495}
496
497// BREP private tests: 2d2c706c699b9ed2