1use ogeom_algo::{
21 Built, History, attach_pcurve, edge_vertices, make_edge_between, make_face_on, make_wire,
22};
23use ogeom_core::{OgeomResult, Tolerances, ogeom_bail};
24use ogeom_geom::{
25 BSpline2d, BSplineCurve, BSplineSurface, Continuity, Curve, Curve2d as _, Curve3d as _, Line2d,
26 PlanarCurve, Surface as _, SurfaceGeometry,
27};
28use ogeom_math::{
29 Axis2, ControlGrid, Direction, Direction2, KnotVector, Point, Point2, Vector, Vector2, Weighted,
30};
31use ogeom_topo::{
32 EdgeRepr, Location, Model, NodeData, Orientation, Shape, ShapeType, explore_unique,
33};
34
35#[derive(Debug, Clone, Copy, PartialEq, Eq)]
38pub enum End {
39 Start,
41 End,
43}
44
45const TANGENCY_BUDGET: f64 = 1e-5;
48
49const CURVATURE_BUDGET: f64 = 1e-4;
53
54const REFINEMENTS: usize = 8;
57
58const STATIONS_PER_SPAN: usize = 4;
60
61pub fn make_blend_surface(
110 model: &mut Model,
111 first: (&Shape, &Shape),
112 second: (&Shape, &Shape),
113 continuity: (Continuity, Continuity),
114 tol: Tolerances,
115) -> OgeomResult<Built> {
116 let orders = (order_of(continuity.0)?, order_of(continuity.1)?);
117 if first.0.is_same(second.0) {
118 ogeom_bail!(
119 Construction,
120 "a blend bridges two different edges; both are the same one"
121 );
122 }
123 let (near_edge, near_face) = unplaced(model, first.0, first.1, tol)?;
124 let (far_edge, far_face) = unplaced(model, second.0, second.1, tol)?;
125 let mut near = read_side(model, &near_edge, &near_face, orders.0, tol)?;
126 let mut far = read_side(model, &far_edge, &far_face, orders.1, tol)?;
127
128 let (a0, a1) = (near.spline.start(tol)?, near.spline.end(tol)?);
131 let (b0, b1) = (far.spline.start(tol)?, far.spline.end(tol)?);
132 far.reversed = a0.distance(b1) + a1.distance(b0) < a0.distance(b0) + a1.distance(b1);
133 if far.reversed {
134 let (knots, control) =
135 ogeom_math::bspline::reverse(far.spline.knots(), far.spline.control_points());
136 far.spline = BSplineCurve::rational(knots, control)?;
137 }
138 let (b0, b1) = (far.spline.start(tol)?, far.spline.end(tol)?);
139 if a0.distance(b0).min(a1.distance(b1)) <= tol.confusion() {
140 ogeom_bail!(
141 Construction,
142 "the edges meet at an end; a blend between them would have no side there"
143 );
144 }
145
146 compatible(&mut near.spline, &mut far.spline, tol)?;
147 let mut rounds = 0;
148 let patch = loop {
149 let patch = hermite(&near, &far, tol)?;
150 let near_worst = measure(&patch, &near, &far, 0.0, tol)?;
151 let far_worst = measure(&patch, &near, &far, 1.0, tol)?;
152 let angle = near_worst.0.max(far_worst.0);
153 let bend = near_worst.1.max(far_worst.1);
154 if angle <= TANGENCY_BUDGET && bend <= CURVATURE_BUDGET {
155 break patch;
156 }
157 if rounds == REFINEMENTS {
158 ogeom_bail!(
159 Construction,
160 "the blend meets its faces {angle} radians off tangent and {bend} off their \
161 curvature (as a fraction of the gap's inverse width) after refining its knots \
162 {REFINEMENTS} times, outside the budget of {TANGENCY_BUDGET} and \
163 {CURVATURE_BUDGET}"
164 );
165 }
166 refine(&mut near.spline, &mut far.spline, tol)?;
167 rounds += 1;
168 };
169
170 let sides_of = [
171 Curve::BSpline(patch.iso_u_curve(0.0, tol)?),
172 Curve::BSpline(patch.iso_u_curve(1.0, tol)?),
173 ];
174 let surface_id = model
175 .geometry_mut()
176 .add_surface(SurfaceGeometry::BSpline(patch));
177 let ends_of = |side: &Side| {
178 if side.reversed {
179 (side.vertices.1.clone(), side.vertices.0.clone())
180 } else {
181 side.vertices.clone()
182 }
183 };
184 let (near_ends, far_ends) = (ends_of(&near), ends_of(&far));
185 let [at_start, at_end] = sides_of;
186 let side_start = side_edge(
187 model,
188 at_start,
189 0.0,
190 (&near_ends.0, &far_ends.0),
191 surface_id,
192 tol,
193 )?;
194 let side_end = side_edge(
195 model,
196 at_end,
197 1.0,
198 (&near_ends.1, &far_ends.1),
199 surface_id,
200 tol,
201 )?;
202 for (side, v) in [(&near, 0.0), (&far, 1.0)] {
203 let (s0, s1) = if side.reversed {
204 (1.0, 0.0)
205 } else {
206 (0.0, 1.0)
207 };
208 let knots = KnotVector::new(
209 vec![side.range.0, side.range.0, side.range.1, side.range.1],
210 1,
211 )?;
212 let trim = BSpline2d::new(knots, vec![Point2::new(s0, v), Point2::new(s1, v)], tol)?;
213 attach_pcurve(
214 model,
215 &side.edge,
216 trim.into(),
217 surface_id,
218 Location::identity(),
219 side.range,
220 )?;
221 }
222
223 let ring = [
224 near.edge.clone(),
225 side_end.clone(),
226 if far.reversed {
227 far.edge.clone()
228 } else {
229 far.edge.reversed()
230 },
231 side_start.reversed(),
232 ];
233 let wire = make_wire(model, &ring, tol)?.shape;
234 let face = make_face_on(model, surface_id, &[wire], tol)?.shape;
235
236 let mut history = History::new();
237 for (edge, twin) in [(first.0, &near_edge), (second.0, &far_edge)] {
238 history.generate(edge, face.clone());
239 history.generate(edge, side_start.clone());
240 history.generate(edge, side_end.clone());
241 if !twin.is_same(edge) {
242 history.generate(edge, twin.clone());
243 }
244 }
245 Ok(Built::new(face, history))
246}
247
248fn unplaced(
252 model: &mut Model,
253 edge: &Shape,
254 face: &Shape,
255 tol: Tolerances,
256) -> OgeomResult<(Shape, Shape)> {
257 if model.kind_of(face)? != ShapeType::Face {
258 return Ok((edge.clone(), face.clone()));
259 }
260 let baked = ogeom_algo::baked_where_placed(model, face, tol)?;
261 if baked.history.is_empty() {
262 return Ok((edge.clone(), face.clone()));
263 }
264 let Some(twin) = baked.history.modified(edge).first().cloned() else {
265 ogeom_bail!(Construction, "the edge is not on the face given with it");
266 };
267 let twin = if edge.orientation() == Orientation::Reversed {
268 twin.reversed()
269 } else {
270 twin
271 };
272 Ok((twin, baked.shape))
273}
274
275pub fn make_blend_curve(
294 model: &mut Model,
295 a: (&Shape, End),
296 b: (&Shape, End),
297 continuity: (Continuity, Continuity),
298 tol: Tolerances,
299) -> OgeomResult<Built> {
300 let orders = (order_of(continuity.0)?, order_of(continuity.1)?);
301 let (from, near) = joint(model, a.0, a.1, orders.0, tol)?;
302 let (to, far) = joint(model, b.0, b.1, orders.1, tol)?;
303 let width = near.point.distance(far.point);
304 if width <= tol.confusion() || from.is_same(&to) {
305 ogeom_bail!(
306 Construction,
307 "the two ends are one point; there is no gap to bridge"
308 );
309 }
310 let control = near.column(&far, width);
311 let degree = control.len() - 1;
312 let mut knots = vec![0.0; degree + 1];
313 knots.extend(std::iter::repeat_n(1.0, degree + 1));
314 let curve = BSplineCurve::new(KnotVector::new(knots, degree)?, control, tol)?;
315 let edge = make_edge_between(model, Curve::BSpline(curve), (0.0, 1.0), &from, &to, tol)?.shape;
316 let mut history = History::new();
317 history.generate(a.0, edge.clone());
318 history.generate(b.0, edge.clone());
319 Ok(Built::new(edge, history))
320}
321
322fn order_of(continuity: Continuity) -> OgeomResult<usize> {
324 match continuity {
325 Continuity::C0 => Ok(0),
326 Continuity::G1 => Ok(1),
327 Continuity::G2 => Ok(2),
328 Continuity::C1 | Continuity::C2 => ogeom_bail!(
329 Construction,
330 "a blend's join is geometric: parametric continuity would tie it to the two \
331 sides' parameterizations; ask G1 or G2"
332 ),
333 Continuity::CInfinity => ogeom_bail!(
334 Construction,
335 "a blend is built to G2 at most; a join smooth to every order is not offered"
336 ),
337 }
338}
339
340struct Side {
343 edge: Shape,
345 vertices: (Shape, Shape),
347 surface: SurfaceGeometry,
349 trim: (PlanarCurve, (f64, f64)),
351 curve: Curve,
353 range: (f64, f64),
354 spline: BSplineCurve,
356 reversed: bool,
358 off: f64,
361 order: usize,
363}
364
365struct Station {
367 normal: Vector,
369 off: Vector,
372 bend: Vector,
375 along: Vector,
377}
378
379fn read_side(
381 model: &Model,
382 edge: &Shape,
383 face: &Shape,
384 order: usize,
385 tol: Tolerances,
386) -> OgeomResult<Side> {
387 if model.kind_of(edge)? != ShapeType::Edge {
388 ogeom_bail!(
389 Construction,
390 "a blend surface bridges edges; got a {:?}",
391 model.kind_of(edge)?
392 );
393 }
394 if model.kind_of(face)? != ShapeType::Face {
395 ogeom_bail!(
396 Construction,
397 "each edge is given with the face it bounds; got a {:?}",
398 model.kind_of(face)?
399 );
400 }
401 let Some(NodeData::Face(face_data)) = model.node(face).map(|n| n.data()) else {
402 ogeom_bail!(Dangling, "face is not in this model");
403 };
404 let surface_id = face_data.surface;
405 if !explore_unique(model, face, ShapeType::Edge)?
406 .iter()
407 .any(|e| e.is_same(edge))
408 {
409 ogeom_bail!(Construction, "the edge is not on the face given with it");
410 }
411 let Some(surface) = model.geometry().surface(surface_id).cloned() else {
412 ogeom_bail!(Dangling, "face refers to a surface not in this model");
413 };
414 let Some(NodeData::Edge(data)) = model.node(edge).map(|n| n.data()) else {
415 ogeom_bail!(Dangling, "edge is not in this model");
416 };
417 if data.degenerate {
418 ogeom_bail!(
419 Construction,
420 "a degenerate edge has no extent to blend from"
421 );
422 }
423 let Some(EdgeRepr::Curve3d {
424 curve,
425 location,
426 range,
427 }) = data.curve3d()
428 else {
429 ogeom_bail!(Construction, "the edge has no curve in space");
430 };
431 if !location.is_identity() {
432 ogeom_bail!(
433 Construction,
434 "a placed edge is not blended; bake its placement into its geometry first"
435 );
436 }
437 let range = *range;
438 let Some(curve) = model.geometry().curve(*curve).cloned() else {
439 ogeom_bail!(Dangling, "curve is not in this model");
440 };
441 let trim = match data.pcurve_for(surface_id, edge.location()) {
442 Some(EdgeRepr::PCurve { curve, range, .. }) => {
443 let Some(pcurve) = model.geometry().pcurve(*curve).cloned() else {
444 ogeom_bail!(Dangling, "pcurve is not in this model");
445 };
446 (pcurve, *range)
447 }
448 Some(EdgeRepr::Seam { .. }) => ogeom_bail!(
449 Construction,
450 "the edge is a seam of its face; there is no one side to blend from"
451 ),
452 _ => ogeom_bail!(
453 Construction,
454 "the edge has no trim on its face's surface, so the face's side of it is unknown"
455 ),
456 };
457 let edge = edge.oriented(Orientation::Forward);
458 let Some((start, end)) = edge_vertices(model, &edge)? else {
459 ogeom_bail!(Construction, "the edge has no vertices");
460 };
461 if start.is_same(&end)
462 || curve
463 .point_at(range.0, tol)?
464 .distance(curve.point_at(range.1, tol)?)
465 <= tol.confusion()
466 {
467 ogeom_bail!(
468 Construction,
469 "the edge is closed; a blend's sides run from the ends of its edges"
470 );
471 }
472
473 let spline = match curve.to_bspline_over(range, tol) {
474 Ok(spline) if !spline.is_periodic() => spline,
475 _ => ogeom_bail!(
476 Construction,
477 "the edge's curve has no exact B-spline form to share with the blend"
478 ),
479 };
480 for k in 0..=8 {
481 let f = f64::from(k) / 8.0;
482 let on_edge = curve.point_at((range.1 - range.0).mul_add(f, range.0), tol)?;
483 if spline.point_at(f, tol)?.distance(on_edge) > tol.confusion() {
484 ogeom_bail!(
485 Construction,
486 "the edge's curve runs on a parameter its B-spline form does not share (as a \
487 circle's or an ellipse's does), so the blend cannot share the edge exactly"
488 );
489 }
490 }
491
492 let mut side = Side {
493 edge,
494 vertices: (start, end),
495 surface,
496 trim,
497 curve,
498 range,
499 spline,
500 reversed: false,
501 off: 1.0,
502 order,
503 };
504 side.off = off_sign(model, face, &side, tol)?;
505 Ok(side)
506}
507
508fn off_sign(model: &Model, face: &Shape, side: &Side, tol: Tolerances) -> OgeomResult<f64> {
512 let rings = ogeom_mesh::face_boundary(model, face, ogeom_mesh::Deflection::default(), tol)?;
513 let (mut lo, mut hi) = (
514 Point2::new(f64::INFINITY, f64::INFINITY),
515 Point2::new(f64::NEG_INFINITY, f64::NEG_INFINITY),
516 );
517 for p in rings.iter().flatten() {
518 lo = Point2::new(lo.x.min(p.x), lo.y.min(p.y));
519 hi = Point2::new(hi.x.max(p.x), hi.y.max(p.y));
520 }
521 let size = lo.distance(hi);
522 let (pcurve, prange) = &side.trim;
523 let p = f64::midpoint(prange.0, prange.1);
524 let uv = pcurve.point_at(p, tol)?;
525 let heading = pcurve.d1_at(p, tol)?;
526 let across = Vector2::new(-heading.y, heading.x);
527 let Ok(across) = across.normalized(tol) else {
528 ogeom_bail!(
529 Construction,
530 "the edge's trim stands still at its middle, so the face's side of it is unknown"
531 );
532 };
533 let mut outward = None;
534 for step in [1e-2, 1e-3, 1e-4] {
535 let h = across * (step * size);
536 let (left, right) = (
537 ogeom_mesh::inside_boundary(&rings, uv + h),
538 ogeom_mesh::inside_boundary(&rings, uv - h),
539 );
540 if left != right {
541 outward = Some(if left { -1.0 } else { 1.0 });
542 break;
543 }
544 }
545 let Some(outward) = outward else {
546 ogeom_bail!(
547 Construction,
548 "the face's side of the edge cannot be told from its boundary"
549 );
550 };
551 let (du, dv) = side.surface.d1_at(uv.x, uv.y, tol)?;
552 let off_chart = (du * across.x + dv * across.y) * outward;
553 let t = (side.range.0 + side.range.1) * 0.5;
554 let normal = face_normal(&side.surface, uv, tol)?;
555 let crossed = normal.cross(side.curve.d1_at(t, tol)?);
556 let agree = crossed.dot(off_chart);
557 if agree.abs() <= f64::EPSILON * crossed.magnitude() * off_chart.magnitude() {
558 ogeom_bail!(
559 Construction,
560 "the face's side of the edge cannot be told from its boundary"
561 );
562 }
563 Ok(agree.signum())
564}
565
566fn face_normal(surface: &SurfaceGeometry, uv: Point2, tol: Tolerances) -> OgeomResult<Vector> {
568 match surface.normal_at(uv.x, uv.y, tol) {
569 Ok(n) => Ok(n.vector()),
570 Err(_) => ogeom_bail!(
571 Construction,
572 "the face has no normal at a point of the edge, so there is no tangent plane to \
573 meet"
574 ),
575 }
576}
577
578impl Side {
579 fn parameters(&self, s: f64) -> (f64, f64) {
581 let f = if self.reversed { 1.0 - s } else { s };
582 let (t0, t1) = self.range;
583 let (p0, p1) = self.trim.1;
584 ((t1 - t0).mul_add(f, t0), (p1 - p0).mul_add(f, p0))
585 }
586
587 fn station(&self, s: f64, tol: Tolerances) -> OgeomResult<Station> {
589 let (t, p) = self.parameters(s);
590 let uv = self.trim.0.point_at(p, tol)?;
591 let normal = face_normal(&self.surface, uv, tol)?;
592 let along = self.curve.d1_at(t, tol)?;
593 let Ok(off) = (normal.cross(along) * self.off).normalized(tol) else {
594 ogeom_bail!(
595 Construction,
596 "the edge stands still at a point, so it has no direction to blend square to"
597 );
598 };
599 let bend = if self.order >= 2 {
600 let Ok(curvature) = self.surface.curvature_at(uv.x, uv.y, tol) else {
601 ogeom_bail!(
602 Construction,
603 "the face has no curvature at a point of the edge to match"
604 );
605 };
606 let Some(k) = curvature.normal_curvature(off) else {
607 ogeom_bail!(
608 Construction,
609 "the face has no curvature at a point of the edge to match"
610 );
611 };
612 curvature.normal.vector() * k
613 } else {
614 Vector::ZERO
615 };
616 Ok(Station {
617 normal,
618 off,
619 bend,
620 along,
621 })
622 }
623}
624
625struct Hermite {
627 point: Point,
628 out: Vector,
630 bend: Vector,
632 order: usize,
633}
634
635impl Hermite {
636 fn column(&self, far: &Self, width: f64) -> Vec<Point> {
639 let n = self.order + far.order + 1;
640 #[expect(clippy::cast_precision_loss, reason = "a degree of at most five")]
641 let (n1, n2) = (n as f64, (n * n.saturating_sub(1)) as f64);
642 let mut rows = vec![Point::ORIGIN; n + 1];
643 rows[0] = self.point;
644 rows[n] = far.point;
645 for (side, at, step) in [(self, 0usize, 1isize), (far, n, -1isize)] {
646 let index = |k: isize| at.wrapping_add_signed(step * k);
647 if side.order >= 1 {
648 rows[index(1)] = side.point + side.out * (width / n1);
649 }
650 if side.order >= 2 {
651 rows[index(2)] = rows[index(1)]
652 + (rows[index(1)] - side.point)
653 + side.bend * (width * width / n2);
654 }
655 }
656 rows
657 }
658}
659
660fn compatible(a: &mut BSplineCurve, b: &mut BSplineCurve, tol: Tolerances) -> OgeomResult<()> {
663 while a.degree() < b.degree() {
664 *a = a.elevated(tol)?;
665 }
666 while b.degree() < a.degree() {
667 *b = b.elevated(tol)?;
668 }
669 let merge = |into: &BSplineCurve, from: &BSplineCurve| -> OgeomResult<BSplineCurve> {
670 let mut out = into.clone();
671 for (k, m) in from.knots().distinct() {
672 let have = out.knots().distinct();
673 let (value, held) = have
674 .iter()
675 .find(|(h, _)| (h - k).abs() <= tol.parametric())
676 .map_or((k, 0), |(h, c)| (*h, *c));
677 if held < m {
678 out = out.with_knot_inserted(value, m - held, tol)?;
679 }
680 }
681 Ok(out)
682 };
683 *b = merge(b, a)?;
684 *a = merge(a, b)?;
685 let (ka, kb) = (a.knots().knots(), b.knots().knots());
686 if ka.len() != kb.len()
687 || ka
688 .iter()
689 .zip(kb)
690 .any(|(x, y)| (x - y).abs() > tol.parametric())
691 {
692 ogeom_bail!(
693 Invariant,
694 "the two edges' B-spline forms did not come to share their knots"
695 );
696 }
697 Ok(())
698}
699
700fn refine(a: &mut BSplineCurve, b: &mut BSplineCurve, tol: Tolerances) -> OgeomResult<()> {
702 let distinct = a.knots().distinct();
703 for pair in distinct.windows(2) {
704 let middle = f64::midpoint(pair[0].0, pair[1].0);
705 *a = a.with_knot_inserted(middle, 1, tol)?;
706 *b = b.with_knot_inserted(middle, 1, tol)?;
707 }
708 Ok(())
709}
710
711fn greville(knots: &KnotVector) -> Vec<f64> {
714 let p = knots.degree();
715 let k = knots.knots();
716 #[expect(
717 clippy::cast_precision_loss,
718 reason = "a degree, far below the mantissa"
719 )]
720 let scale = 1.0 / p as f64;
721 (0..knots.control_point_count())
722 .map(|i| k[i + 1..=i + p].iter().sum::<f64>() * scale)
723 .collect()
724}
725
726fn hermite(near: &Side, far: &Side, tol: Tolerances) -> OgeomResult<BSplineSurface> {
736 let n = near.order + far.order + 1;
737 let knots = near.spline.knots().clone();
738 let abscissae = greville(&knots);
739 let (ca, cb) = (near.spline.control_points(), far.spline.control_points());
740 let count = ca.len();
741 let mut widths = Vec::with_capacity(count);
742 for s in &abscissae {
743 widths.push(
744 near.spline
745 .point_at(*s, tol)?
746 .distance(far.spline.point_at(*s, tol)?),
747 );
748 }
749 #[expect(clippy::cast_precision_loss, reason = "a degree of at most five")]
750 let (n1, n2) = (n as f64, (n * (n - 1)) as f64);
751 let crossing = |side: &Side, control: &[Weighted<Point>]| -> OgeomResult<[Vec<Vector>; 2]> {
754 if side.order == 0 {
755 return Ok([vec![Vector::ZERO; count], vec![Vector::ZERO; count]]);
756 }
757 let mut first = Vec::with_capacity(count);
758 let mut second = Vec::with_capacity(count);
759 for (s, width) in abscissae.iter().zip(&widths) {
760 let station = side.station(*s, tol)?;
761 first.push(station.off * (width / n1));
762 second.push(if side.order >= 2 {
763 station.bend * (width * width / n2)
764 } else {
765 Vector::ZERO
766 });
767 }
768 let weights: Vec<f64> = control.iter().map(|c| c.weight).collect();
769 let matrix = collocation(&knots, &weights, &abscissae, tol)?;
770 Ok([solve(&matrix, &first)?, solve(&matrix, &second)?])
771 };
772 let [near_first, near_second] = crossing(near, ca)?;
773 let [far_first, far_second] = crossing(far, cb)?;
774 let mut grid = Vec::with_capacity(count * (n + 1));
775 for i in 0..count {
776 let (pa, pb) = (ca[i].point(), cb[i].point());
777 let mut column = vec![Point::ORIGIN; n + 1];
778 column[0] = pa;
779 column[n] = pb;
780 if near.order >= 1 {
781 column[1] = pa + near_first[i];
782 }
783 if near.order >= 2 {
784 column[2] = pa + near_first[i] * 2.0 + near_second[i];
785 }
786 if far.order >= 1 {
787 column[n - 1] = pb + far_first[i];
788 }
789 if far.order >= 2 {
790 column[n - 2] = pb + far_first[i] * 2.0 + far_second[i];
791 }
792 for (j, point) in column.into_iter().enumerate() {
793 let weight = if j <= near.order {
796 ca[i].weight
797 } else {
798 cb[i].weight
799 };
800 grid.push(Weighted::new(point, weight, tol)?);
801 }
802 }
803 let mut v_knots = vec![0.0; n + 1];
804 v_knots.extend(std::iter::repeat_n(1.0, n + 1));
805 BSplineSurface::rational(
806 knots,
807 KnotVector::new(v_knots, n)?,
808 ControlGrid::new(grid, count, n + 1)?,
809 )
810}
811
812fn collocation(
815 knots: &KnotVector,
816 weights: &[f64],
817 at: &[f64],
818 tol: Tolerances,
819) -> OgeomResult<Vec<Vec<f64>>> {
820 let p = knots.degree();
821 let mut rows = Vec::with_capacity(at.len());
822 for u in at {
823 let span = knots.span(*u, tol)?;
824 let basis = knots.basis(span, *u);
825 let mut row = vec![0.0; weights.len()];
826 let mut total = 0.0;
827 for (r, value) in basis.iter().enumerate() {
828 let i = span - p + r;
829 row[i] = value * weights[i];
830 total += row[i];
831 }
832 for value in &mut row {
833 *value /= total;
834 }
835 rows.push(row);
836 }
837 Ok(rows)
838}
839
840fn solve(matrix: &[Vec<f64>], rhs: &[Vector]) -> OgeomResult<Vec<Vector>> {
843 let size = rhs.len();
844 let mut a: Vec<Vec<f64>> = matrix.to_vec();
845 let mut b: Vec<Vector> = rhs.to_vec();
846 for col in 0..size {
847 let pivot = (col..size)
848 .max_by(|x, y| a[*x][col].abs().total_cmp(&a[*y][col].abs()))
849 .unwrap_or(col);
850 if a[pivot][col].abs() <= f64::EPSILON {
851 ogeom_bail!(
852 Numeric,
853 "the crossing derivatives cannot be interpolated along the edge"
854 );
855 }
856 a.swap(col, pivot);
857 b.swap(col, pivot);
858 let (above, below) = a.split_at_mut(col + 1);
859 let leading = &above[col];
860 for (offset, target) in below.iter_mut().enumerate() {
861 let factor = target[col] / leading[col];
862 if factor == 0.0 {
863 continue;
864 }
865 for (t, l) in target[col..].iter_mut().zip(&leading[col..]) {
866 *t -= factor * l;
867 }
868 let pivot_value = b[col];
869 b[col + 1 + offset] -= pivot_value * factor;
870 }
871 }
872 let mut x = vec![Vector::ZERO; size];
873 for row in (0..size).rev() {
874 let mut sum = b[row];
875 for k in row + 1..size {
876 sum -= x[k] * a[row][k];
877 }
878 x[row] = sum * (1.0 / a[row][row]);
879 }
880 Ok(x)
881}
882
883fn measure(
888 patch: &BSplineSurface,
889 near: &Side,
890 far: &Side,
891 v: f64,
892 tol: Tolerances,
893) -> OgeomResult<(f64, f64)> {
894 let side = if v == 0.0 { near } else { far };
895 if side.order == 0 {
896 return Ok((0.0, 0.0));
897 }
898 let (mut angle, mut bend) = (0.0f64, 0.0f64);
899 let knots = side.spline.knots().distinct();
900 for pair in knots.windows(2) {
901 for k in 0..=STATIONS_PER_SPAN {
902 #[expect(clippy::cast_precision_loss, reason = "a station index")]
903 let f = k as f64 / STATIONS_PER_SPAN as f64;
904 let s = (pair[1].0 - pair[0].0).mul_add(f, pair[0].0);
905 let station = side.station(s, tol)?;
906 let Ok(blend_normal) = patch.normal_at(s, v, tol) else {
907 ogeom_bail!(
908 Construction,
909 "the blend folds onto the edge, so it has no tangent plane there"
910 );
911 };
912 let blend_normal = blend_normal.vector();
913 let along = blend_normal.dot(station.normal);
914 angle = angle.max(along.abs().min(1.0).acos());
915 if side.order >= 2 {
916 let width = near
917 .spline
918 .point_at(s, tol)?
919 .distance(far.spline.point_at(s, tol)?);
920 let Ok(curvature) = patch.curvature_at(s, v, tol) else {
921 ogeom_bail!(
922 Construction,
923 "the blend has no curvature at a point of the edge to match"
924 );
925 };
926 let kf = station.bend.dot(station.normal);
927 let Some(kb) =
928 curvature.normal_curvature(curvature.normal.vector().cross(station.along))
929 else {
930 ogeom_bail!(
931 Construction,
932 "the blend has no curvature at a point of the edge to match"
933 );
934 };
935 let kb = if curvature.normal.vector().dot(station.normal) < 0.0 {
936 -kb
937 } else {
938 kb
939 };
940 bend = bend.max((kb - kf).abs() * width);
941 }
942 }
943 }
944 Ok((angle, bend))
945}
946
947fn side_edge(
950 model: &mut Model,
951 curve: Curve,
952 at: f64,
953 (from, to): (&Shape, &Shape),
954 surface: ogeom_topo::SurfaceId,
955 tol: Tolerances,
956) -> OgeomResult<Shape> {
957 let edge = make_edge_between(model, curve, (0.0, 1.0), from, to, tol)?.shape;
958 let trim = Line2d::over(
959 Axis2 {
960 location: Point2::new(at, 0.0),
961 direction: Direction2::new(Vector2::new(0.0, 1.0), tol)?,
962 },
963 0.0,
964 1.0,
965 )?;
966 attach_pcurve(
967 model,
968 &edge,
969 trim.into(),
970 surface,
971 Location::identity(),
972 (0.0, 1.0),
973 )?;
974 if let Some(NodeData::Edge(data)) = model.node_mut(&edge).map(|n| n.data_mut()) {
975 data.assert_same_parameter(true);
976 }
977 Ok(edge)
978}
979
980fn joint(
984 model: &Model,
985 edge: &Shape,
986 end: End,
987 order: usize,
988 tol: Tolerances,
989) -> OgeomResult<(Shape, Hermite)> {
990 if model.kind_of(edge)? != ShapeType::Edge {
991 ogeom_bail!(
992 Construction,
993 "a blend curve bridges the ends of edges; got a {:?}",
994 model.kind_of(edge)?
995 );
996 }
997 if !edge.location().is_identity() {
998 ogeom_bail!(
999 Construction,
1000 "a placed edge is not blended; bake its placement into its geometry first"
1001 );
1002 }
1003 let Some(NodeData::Edge(data)) = model.node(edge).map(|n| n.data()) else {
1004 ogeom_bail!(Dangling, "edge is not in this model");
1005 };
1006 if data.degenerate {
1007 ogeom_bail!(Construction, "a degenerate edge has no tangent to continue");
1008 }
1009 let Some(EdgeRepr::Curve3d {
1010 curve,
1011 location,
1012 range,
1013 }) = data.curve3d()
1014 else {
1015 ogeom_bail!(Construction, "the edge has no curve in space");
1016 };
1017 if !location.is_identity() {
1018 ogeom_bail!(
1019 Construction,
1020 "a placed edge is not blended; bake its placement into its geometry first"
1021 );
1022 }
1023 let Some(curve) = model.geometry().curve(*curve) else {
1024 ogeom_bail!(Dangling, "curve is not in this model");
1025 };
1026 let Some((start, finish)) = edge_vertices(model, edge)? else {
1027 ogeom_bail!(Construction, "the edge has no vertices");
1028 };
1029 let backwards = edge.orientation() == Orientation::Reversed;
1030 let (vertex, at_range_end) = match end {
1033 End::Start => (start, backwards),
1034 End::End => (finish, !backwards),
1035 };
1036 let t = if at_range_end { range.1 } else { range.0 };
1037 let d = curve.derivatives_at(t, 2, tol)?;
1038 let speed = d[1].magnitude();
1039 let Ok(tangent) = Direction::new(d[1], tol) else {
1040 ogeom_bail!(
1041 Construction,
1042 "the edge stands still at that end, so it has no tangent to continue"
1043 );
1044 };
1045 let tangent = tangent.vector();
1046 let bend = (d[2] - tangent * d[2].dot(tangent)) * (1.0 / (speed * speed));
1047 Ok((
1048 vertex,
1049 Hermite {
1050 point: curve.point_at(t, tol)?,
1051 out: if at_range_end { tangent } else { -tangent },
1052 bend,
1053 order,
1054 },
1055 ))
1056}