1use super::*;
2use crate::{classify_point, PointClass, SolidClassifier};
3use serde::{Deserialize, Serialize};
4
5pub(super) fn ruled_between(bottom: &NurbsCurve, top: &NurbsCurve) -> Result<NurbsSurface, KernelRefusal> {
10 if bottom.degree != top.degree
11 || bottom.control_points.len() != top.control_points.len()
12 || bottom.knots.len() != top.knots.len()
13 {
14 return Err(KernelRefusal::internal(
15 KernelStage::Fragment,
16 "ruled_rows",
17 "ruled_between: rows are not representation-compatible",
18 ));
19 }
20 let grid = bottom
21 .control_points
22 .iter()
23 .zip(&top.control_points)
24 .map(|(a, b)| vec![*a, *b])
25 .collect();
26 NurbsSurface::new(
27 bottom.degree,
28 1,
29 bottom.knots.clone(),
30 vec![0.0, 0.0, 1.0, 1.0],
31 grid,
32 )
33 .or_refuse(KernelStage::Fragment, "surface_new")
34}
35
36pub(super) fn arc_window_subrange(row: &NurbsCurve, start: Vec3, end: Vec3) -> Result<NurbsCurve, KernelRefusal> {
41 let [d0, d1] = row.domain().or_refuse(KernelStage::Fragment, "domain")?;
42 let span = d1 - d0;
43 let u0 = project_point_to_curve(row, start).or_refuse(KernelStage::Fragment, "project_point")?.u;
44 let u1 = project_point_to_curve(row, end).or_refuse(KernelStage::Fragment, "project_point")?.u;
45 if u1 <= u0 + 1e-12 {
46 return Err(KernelRefusal::internal(
47 KernelStage::Fragment,
48 "arc_trim",
49 "draftExtrude: a drafted arc's boundary trim inverted",
50 ));
51 }
52 let epsilon = span * 1e-9;
53 let mut current = row.clone();
54 if u0 > d0 + epsilon {
55 current = current.split(u0).or_refuse(KernelStage::Fragment, "split")?.1;
56 }
57 let domain = current.domain().or_refuse(KernelStage::Fragment, "domain")?;
58 if u1 < domain[1] - epsilon && u1 > domain[0] + epsilon {
59 current = current.split(u1).or_refuse(KernelStage::Fragment, "split")?.0;
60 }
61 Ok(current)
62}
63
64fn wall_gradient(
70 seg: &SegGeom,
71 point: Vec3,
72 zh: Vec3,
73 height: f64,
74 signed_d: f64,
75) -> Result<Vec3, KernelRefusal> {
76 match seg {
77 SegGeom::Line { dir, normal, .. } => dir
78 .cross(normal.scale(signed_d).add(zh.scale(height)))
79 .normalized()
80 .or_refuse(KernelStage::Fragment, "normalized"),
81 SegGeom::Arc {
82 center, turn, ..
83 } => {
84 let rel = point.sub(*center);
85 let radial = rel.sub(zh.scale(rel.dot(zh)));
86 let rho = radial.normalized().or_refuse(KernelStage::Fragment, "normalized")?;
87 rho.add(zh.scale(signed_d * turn / height))
88 .normalized()
89 .or_refuse(KernelStage::Fragment, "normalized")
90 }
91 }
92}
93
94#[allow(clippy::too_many_arguments)]
104pub(super) fn junction_edge_curve(
105 prev: &SegGeom,
106 next: &SegGeom,
107 a: Vec3,
108 m: Vec3,
109 b: Vec3,
110 zh: Vec3,
111 height: f64,
112 signed_d: f64,
113) -> Result<NurbsCurve, KernelRefusal> {
114 let chord = b.sub(a);
115 let length = chord.length();
116 if length <= 1e-12 {
117 return Err(KernelRefusal::internal(
118 KernelStage::Fragment,
119 "junction_collapsed",
120 "draftExtrude: a junction edge collapsed to a point",
121 ));
122 }
123 let along = m.sub(a).dot(chord) / (length * length);
124 let deviation = m.sub(a).sub(chord.scale(along)).length();
125 if deviation <= length * 1e-9 {
126 return make_line(a, b).or_refuse(KernelStage::Fragment, "make_line");
127 }
128 let e1 = chord.scale(1.0 / length);
130 let plane_normal = chord
131 .cross(m.sub(a))
132 .normalized()
133 .or_refuse(KernelStage::Fragment, "normalized")?;
134 let e2 = plane_normal
135 .cross(e1)
136 .normalized()
137 .or_refuse(KernelStage::Fragment, "normalized")?;
138 let orient = |tangent: Vec3| {
139 if tangent.dot(zh) < 0.0 {
140 tangent.scale(-1.0)
141 } else {
142 tangent
143 }
144 };
145 let t0 = orient(wall_gradient(prev, a, zh, height, signed_d)?
146 .cross(wall_gradient(next, a, zh, height, signed_d)?));
147 let t2 = orient(wall_gradient(prev, b, zh, height, signed_d)?
148 .cross(wall_gradient(next, b, zh, height, signed_d)?));
149 let d0 = (t0.dot(e1), t0.dot(e2));
150 let d2 = (t2.dot(e1), t2.dot(e2));
151 let denom = d0.0 * d2.1 - d0.1 * d2.0;
152 let scale0 = d0.0.hypot(d0.1);
153 let scale2 = d2.0.hypot(d2.1);
154 if denom.abs() <= 1e-14 * scale0 * scale2 {
155 return Err(KernelRefusal::internal(
156 KernelStage::Fragment,
157 "conic_apex",
158 "draftExtrude: junction end tangents are parallel — no conic apex",
159 ));
160 }
161 let s = length * d2.1 / denom;
163 let apex = (s * d0.0, s * d0.1);
164 if apex.1.abs() <= f64::EPSILON * length {
165 return Err(KernelRefusal::internal(
166 KernelStage::Fragment,
167 "conic_apex_degenerate",
168 "draftExtrude: junction conic apex is degenerate",
169 ));
170 }
171 let mq = (m.sub(a).dot(e1), m.sub(a).dot(e2));
173 let beta = mq.1 / apex.1;
174 let gamma = (mq.0 - beta * apex.0) / length;
175 let alpha = 1.0 - beta - gamma;
176 if !(alpha > 0.0 && beta > 0.0 && gamma > 0.0) {
177 return Err(KernelRefusal::internal(
178 KernelStage::Fragment,
179 "conic_witness",
180 format!(
181 "draftExtrude: junction conic witness fell outside its control triangle \
182 (α={alpha:.3e} β={beta:.3e} γ={gamma:.3e})"
183 ),
184 ));
185 }
186 let weight = beta / (2.0 * (alpha * gamma).sqrt());
187 let apex_3d = a.add(e1.scale(apex.0)).add(e2.scale(apex.1));
188 NurbsCurve::new(
189 2,
190 vec![0.0, 0.0, 0.0, 1.0, 1.0, 1.0],
191 vec![
192 Vec4::from_point(a, 1.0),
193 Vec4::from_point(apex_3d, weight),
194 Vec4::from_point(b, 1.0),
195 ],
196 )
197 .or_refuse(KernelStage::Fragment, "curve_new")
198}
199
200fn circumcircle(a: Vec3, b: Vec3, c: Vec3, ex: Vec3, ey: Vec3, np: Vec3) -> Option<(Vec3, f64)> {
203 let (ax, ay) = (a.dot(ex), a.dot(ey));
204 let (bx, by) = (b.dot(ex), b.dot(ey));
205 let (cx, cy) = (c.dot(ex), c.dot(ey));
206 let d = 2.0 * (ax * (by - cy) + bx * (cy - ay) + cx * (ay - by));
207 if d.abs() < 1e-12 {
208 return None;
209 }
210 let a2 = ax * ax + ay * ay;
211 let b2 = bx * bx + by * by;
212 let c2 = cx * cx + cy * cy;
213 let ux = (a2 * (by - cy) + b2 * (cy - ay) + c2 * (ay - by)) / d;
214 let uy = (a2 * (cx - bx) + b2 * (ax - cx) + c2 * (bx - ax)) / d;
215 let plane_off = a.dot(np);
216 let center = ex.scale(ux).add(ey.scale(uy)).add(np.scale(plane_off));
217 let radius = a.sub(center).length();
218 Some((center, radius))
219}
220
221fn intersect_offset_line_circle(
225 line_point: Vec3,
226 line_dir: Vec3,
227 center: Vec3,
228 radius: f64,
229 near: Vec3,
230) -> Result<Vec3, KernelRefusal> {
231 let dir = line_dir.normalized().or_refuse(KernelStage::Classify, "normalized")?;
232 let f = line_point.sub(center);
233 let b = f.dot(dir);
234 let c = f.dot(f) - radius * radius;
235 let disc = b * b - c;
236 if disc < -1e-9 {
237 return Err(KernelRefusal::input(
238 KernelStage::Classify,
239 "offset_too_large",
240 "an offset line and arc no longer meet (offset too large)",
241 ));
242 }
243 let root = disc.max(0.0).sqrt();
244 let p1 = line_point.add(dir.scale(-b + root));
245 let p2 = line_point.add(dir.scale(-b - root));
246 Ok(if p1.sub(near).length() <= p2.sub(near).length() {
247 p1
248 } else {
249 p2
250 })
251}
252
253fn intersect_offset_circles(
257 c1: Vec3,
258 r1: f64,
259 c2: Vec3,
260 r2: f64,
261 plane_normal: Vec3,
262 near: Vec3,
263) -> Result<Vec3, KernelRefusal> {
264 let between = c2.sub(c1);
265 let d = between.length();
266 if d < 1e-9 {
267 return Err(KernelRefusal::internal(
268 KernelStage::Classify,
269 "concentric_arcs",
270 "concentric offset arcs do not meet",
271 ));
272 }
273 let axis = between.scale(1.0 / d);
274 let a = (d * d + r1 * r1 - r2 * r2) / (2.0 * d);
275 let h2 = r1 * r1 - a * a;
276 if h2 < -1e-9 {
277 return Err(KernelRefusal::input(
278 KernelStage::Classify,
279 "offset_arcs_too_large",
280 "offset arcs no longer meet (offset too large)",
281 ));
282 }
283 let h = h2.max(0.0).sqrt();
284 let base = c1.add(axis.scale(a));
285 let perp = plane_normal.cross(axis).normalized().or_refuse(KernelStage::Classify, "normalized")?;
286 let p1 = base.add(perp.scale(h));
287 let p2 = base.sub(perp.scale(h));
288 Ok(if p1.sub(near).length() <= p2.sub(near).length() {
289 p1
290 } else {
291 p2
292 })
293}
294
295pub(super) enum SegGeom {
299 Line {
300 start: Vec3,
301 end: Vec3,
302 dir: Vec3,
304 normal: Vec3,
306 },
307 Arc {
308 center: Vec3,
309 radius: f64,
310 turn: f64,
313 arc_normal: Vec3,
315 start: Vec3,
316 end: Vec3,
317 },
318}
319
320impl SegGeom {
321 fn start(&self) -> Vec3 {
322 match self {
323 SegGeom::Line { start, .. } | SegGeom::Arc { start, .. } => *start,
324 }
325 }
326
327 fn end(&self) -> Vec3 {
328 match self {
329 SegGeom::Line { end, .. } | SegGeom::Arc { end, .. } => *end,
330 }
331 }
332
333 fn offset_point(&self, point: Vec3, signed_d: f64) -> Result<Vec3, KernelRefusal> {
337 match self {
338 SegGeom::Line { normal, .. } => Ok(point.add(normal.scale(signed_d))),
339 SegGeom::Arc {
340 center,
341 radius,
342 turn,
343 ..
344 } => {
345 let r_offset = radius - signed_d * turn;
346 if r_offset <= 1e-6 {
347 return Err(KernelRefusal::input(
348 KernelStage::Classify,
349 "arc_collapse",
350 "offset: distance is too large — a concave arc collapses",
351 ));
352 }
353 Ok(center.add(point.sub(*center).scale(r_offset / radius)))
354 }
355 }
356 }
357}
358
359pub(super) fn classify_profile_segments(
365 profile: &[NurbsCurve],
366 plane_normal: Vec3,
367) -> Result<Vec<SegGeom>, KernelRefusal> {
368 let tol = 1e-6;
369 if profile.is_empty() {
370 return Err(KernelRefusal::input(
371 KernelStage::Collect,
372 "profile_empty",
373 "offset: profile has no segments",
374 ));
375 }
376 let np = plane_normal.normalized().or_refuse(KernelStage::Classify, "normalized")?;
377 let ex = np.perpendicular().or_refuse(KernelStage::Classify, "perpendicular")?;
378 let ey = np.cross(ex).normalized().or_refuse(KernelStage::Classify, "normalized")?;
379 let mut segs = Vec::with_capacity(profile.len());
380 for curve in profile {
381 let [t0, t1] = curve.domain().or_refuse(KernelStage::Classify, "domain")?;
382 let start = curve.evaluate(t0).or_refuse(KernelStage::Classify, "evaluate")?;
383 let end = curve.evaluate(t1).or_refuse(KernelStage::Classify, "evaluate")?;
384 let chord = end.sub(start);
385 let chord_len = chord.length();
386 if chord_len <= tol {
387 return Err(KernelRefusal::input(
388 KernelStage::Collect,
389 "segment_length",
390 "offset: profile has a degenerate (zero-length) segment",
391 ));
392 }
393 let dir = chord.scale(1.0 / chord_len);
394 let mut is_line = true;
396 for k in 1..8 {
397 let point = curve.evaluate(t0 + (t1 - t0) * k as f64 / 8.0).or_refuse(KernelStage::Classify, "evaluate")?;
398 let rel = point.sub(start);
399 let perpendicular = rel.sub(dir.scale(rel.dot(dir))).length();
400 if perpendicular > tol * 10.0 {
401 is_line = false;
402 break;
403 }
404 }
405 if is_line {
406 segs.push(SegGeom::Line {
407 start,
408 end,
409 dir,
410 normal: np.cross(dir).normalized().or_refuse(KernelStage::Classify, "normalized")?,
411 });
412 continue;
413 }
414 let mid = curve.evaluate((t0 + t1) * 0.5).or_refuse(KernelStage::Classify, "evaluate")?;
416 let (center, radius) = circumcircle(start, mid, end, ex, ey, np).ok_or_else(|| {
417 KernelRefusal::unsupported(
418 KernelStage::Classify,
419 "segment_kind",
420 "offset: only straight lines and circular arcs are supported",
421 )
422 })?;
423 for k in 0..=8 {
424 let point = curve.evaluate(t0 + (t1 - t0) * k as f64 / 8.0).or_refuse(KernelStage::Classify, "evaluate")?;
425 if (point.sub(center).length() - radius).abs() > tol * 10.0 {
426 return Err(KernelRefusal::unsupported(
427 KernelStage::Classify,
428 "segment_kind",
429 "offset: only straight lines and circular arcs are supported",
430 ));
431 }
432 }
433 let bend = np.dot(mid.sub(start).cross(end.sub(mid)));
435 let (arc_normal, turn) = if bend >= 0.0 {
436 (np, 1.0)
437 } else {
438 (np.scale(-1.0), -1.0)
439 };
440 segs.push(SegGeom::Arc {
441 center,
442 radius,
443 turn,
444 arc_normal,
445 start,
446 end,
447 });
448 }
449 Ok(segs)
450}
451
452pub(super) fn offset_junction(
463 prev: &SegGeom,
464 next: &SegGeom,
465 plane_normal: Vec3,
466 signed_d: f64,
467) -> Result<Vec3, KernelRefusal> {
468 let vertex = prev.end();
469 if signed_d == 0.0 {
470 return Ok(vertex);
471 }
472 let prev_offset = prev.offset_point(prev.end(), signed_d)?;
473 let next_offset = next.offset_point(next.start(), signed_d)?;
474 if prev_offset.sub(next_offset).length() <= 1e-6 {
475 return Ok(prev_offset.add(next_offset).scale(0.5));
476 }
477 match (prev, next) {
478 (SegGeom::Line { normal: na, .. }, SegGeom::Line { normal: nb, .. }) => {
479 let denom = 1.0 + na.dot(*nb);
480 if denom.abs() < 1e-6 {
481 return Err(KernelRefusal::input(
482 KernelStage::Classify,
483 "reversal_corner",
484 "offset: degenerate (near-reversal) polyline corner",
485 ));
486 }
487 Ok(vertex.add(na.add(*nb).scale(signed_d / denom)))
488 }
489 (
490 SegGeom::Line { dir, .. },
491 SegGeom::Arc {
492 center,
493 radius,
494 turn,
495 ..
496 },
497 ) => intersect_offset_line_circle(
498 prev_offset,
499 *dir,
500 *center,
501 radius - signed_d * turn,
502 vertex,
503 ),
504 (
505 SegGeom::Arc {
506 center,
507 radius,
508 turn,
509 ..
510 },
511 SegGeom::Line { dir, .. },
512 ) => intersect_offset_line_circle(
513 next_offset,
514 *dir,
515 *center,
516 radius - signed_d * turn,
517 vertex,
518 ),
519 (
520 SegGeom::Arc {
521 center: c1,
522 radius: r1,
523 turn: turn1,
524 ..
525 },
526 SegGeom::Arc {
527 center: c2,
528 radius: r2,
529 turn: turn2,
530 ..
531 },
532 ) => intersect_offset_circles(
533 *c1,
534 r1 - signed_d * turn1,
535 *c2,
536 r2 - signed_d * turn2,
537 plane_normal,
538 vertex,
539 ),
540 }
541}
542
543fn offset_profile_segments(
554 profile: &[NurbsCurve],
555 plane_normal: Vec3,
556 signed_d: f64,
557 closed: bool,
558) -> Result<Vec<NurbsCurve>, KernelRefusal> {
559 let tol = 1e-6;
560 let np = plane_normal.normalized().or_refuse(KernelStage::Fragment, "normalized")?;
561 let segs = classify_profile_segments(profile, np)?;
562 let n = segs.len();
563
564 let mut offsets: Vec<(Vec3, Vec3)> = segs
566 .iter()
567 .map(|seg| {
568 Ok((
569 seg.offset_point(seg.start(), signed_d)?,
570 seg.offset_point(seg.end(), signed_d)?,
571 ))
572 })
573 .collect::<Result<_, KernelRefusal>>()?;
574 let junctions = if closed { n } else { n.saturating_sub(1) };
575 for i in 0..junctions {
576 let j = (i + 1) % n;
577 let point = offset_junction(&segs[i], &segs[j], np, signed_d)?;
578 offsets[i].1 = point;
579 offsets[j].0 = point;
580 }
581
582 let mut out = Vec::with_capacity(n);
584 for (seg, (off_start, off_end)) in segs.iter().zip(&offsets) {
585 match seg {
586 SegGeom::Line { .. } => out.push(make_line(*off_start, *off_end).or_refuse(KernelStage::Fragment, "make_line")?),
587 SegGeom::Arc {
588 center, arc_normal, ..
589 } => {
590 let radial = off_start.sub(*center);
591 let r2 = radial.length();
592 if r2 <= tol {
593 return Err(KernelRefusal::internal(
594 KernelStage::Fragment,
595 "arc_radius",
596 "offset: reconstructed arc has a zero radius",
597 ));
598 }
599 let ax = radial.scale(1.0 / r2);
600 let ay = arc_normal.cross(ax).normalized().or_refuse(KernelStage::Fragment, "normalized")?;
601 let ve = off_end.sub(*center);
602 let mut angle = ve.dot(ay).atan2(ve.dot(ax));
603 if angle <= 1e-9 {
604 angle += std::f64::consts::TAU;
605 }
606 out.push(make_arc(*center, ax, ay, r2, 0.0, angle).or_refuse(KernelStage::Fragment, "make_arc")?);
607 }
608 }
609 }
610 Ok(out)
611}
612
613#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Deserialize, Serialize)]
620#[serde(rename_all = "SCREAMING_SNAKE_CASE")]
621pub enum RibExtrusion {
622 #[default]
628 ParallelToSketch,
629 NormalToSketch,
633}
634
635pub fn rib_from_profile(
669 solid: &BrepSolid,
670 profile: &[NurbsCurve],
671 thickness: f64,
672 extrude_dir: Vec3,
673 plane_normal: Option<Vec3>,
674 extrusion: RibExtrusion,
675 names: &RibNames,
676) -> Result<BrepSolid, KernelRefusal> {
677 let tolerance = 1e-6;
678 if profile.is_empty() {
679 return Err(KernelRefusal::input(
680 KernelStage::Collect,
681 "profile_count",
682 "rib: profile needs at least 1 curve forming an open chain",
683 ));
684 }
685 if names.segments.len() != profile.len() {
686 return Err(KernelRefusal::input(
687 KernelStage::Collect,
688 "segment_names",
689 format!(
690 "rib: {} segment names for {} profile curves",
691 names.segments.len(),
692 profile.len()
693 ),
694 ));
695 }
696 if !(thickness > 0.0) {
697 return Err(KernelRefusal::input(
698 KernelStage::Collect,
699 "thickness",
700 "rib: thickness must be positive",
701 ));
702 }
703
704 let mut vertices = Vec::with_capacity(profile.len() + 1);
707 let mut samples = Vec::new();
708 for (index, curve) in profile.iter().enumerate() {
709 let [start, end] = curve.domain().or_refuse(KernelStage::Collect, "domain")?;
710 let v_start = curve.evaluate(start).or_refuse(KernelStage::Collect, "evaluate")?;
711 let v_end = curve.evaluate(end).or_refuse(KernelStage::Collect, "evaluate")?;
712 if v_end.sub(v_start).length() <= tolerance {
713 return Err(KernelRefusal::input(
714 KernelStage::Collect,
715 "segment_length",
716 "rib: profile has a degenerate (zero-length) segment",
717 ));
718 }
719 if index == 0 {
720 vertices.push(v_start);
721 } else if v_start.sub(*vertices.last().unwrap()).length() > tolerance {
722 return Err(KernelRefusal::input(
723 KernelStage::Collect,
724 "chain_connected",
725 format!("rib: profile chain is not connected at curve {index}"),
726 ));
727 }
728 vertices.push(v_end);
729 let first_k = if index == 0 { 0 } else { 1 };
733 for k in first_k..=8 {
734 samples.push(curve.evaluate(start + (end - start) * k as f64 / 8.0).or_refuse(KernelStage::Collect, "evaluate")?);
735 }
736 }
737 let count = vertices.len();
738 if count < 2 {
739 return Err(KernelRefusal::input(
740 KernelStage::Collect,
741 "vertex_count",
742 "rib: profile needs at least 2 distinct vertices",
743 ));
744 }
745
746 if vertices[count - 1].sub(vertices[0]).length() <= tolerance {
749 return Err(KernelRefusal::input(
750 KernelStage::Collect,
751 "chain_closed",
752 "rib: profile chain is closed; rib expects an open chain",
753 ));
754 }
755
756 let np = match plane_normal {
763 Some(supplied) => supplied.normalized().map_err(|_| {
764 KernelRefusal::input(
765 KernelStage::Collect,
766 "plane_normal",
767 "rib: the supplied profile plane normal is degenerate",
768 )
769 })?,
770 None => {
771 let mut normal = Vec3::default();
772 for i in 1..samples.len() - 1 {
773 let a = samples[i].sub(samples[i - 1]);
774 let b = samples[i + 1].sub(samples[i]);
775 normal = normal.add(a.cross(b));
776 }
777 normal.normalized().map_err(|_| {
780 KernelRefusal::ill_posed(
781 KernelStage::Classify,
782 "plane_undetermined",
783 "rib: profile is collinear and no profile plane was supplied; cannot determine \
784 its plane",
785 )
786 })?
787 }
788 };
789 let origin = vertices[0];
790 if samples
791 .iter()
792 .any(|point| point.sub(origin).dot(np).abs() > tolerance * 100.0)
793 {
794 return Err(KernelRefusal::input(
795 KernelStage::Collect,
796 "profile_planar",
797 if plane_normal.is_some() {
798 "rib: profile does not lie in the supplied plane"
799 } else {
800 "rib: profile is not planar"
801 },
802 ));
803 }
804
805 let reach = sweep_reach(solid, &vertices)?;
809
810 let mut slab = match extrusion {
813 RibExtrusion::ParallelToSketch => {
814 let along = extrude_dir.sub(np.scale(extrude_dir.dot(np)));
817 let along = along.normalized().map_err(|_| {
818 KernelRefusal::input(
819 KernelStage::Collect,
820 "direction_in_plane",
821 "rib: a Parallel-to-Sketch rib grows INSIDE its sketch plane, but the requested \
822 direction is perpendicular to it",
823 )
824 })?;
825 parallel_slab(profile, &vertices, np, along, thickness, reach)?
826 }
827 RibExtrusion::NormalToSketch => {
828 let along = extrude_dir.normalized().map_err(|_| {
829 KernelRefusal::input(
830 KernelStage::Collect,
831 "direction",
832 "rib: extrude direction is degenerate",
833 )
834 })?;
835 normal_slab(profile, np, along, thickness, reach)?
836 }
837 };
838
839 let along = match extrusion {
842 RibExtrusion::ParallelToSketch => {
843 let along = extrude_dir.sub(np.scale(extrude_dir.dot(np)));
844 along.normalized().or_refuse(KernelStage::Collect, "normalized")?
845 }
846 RibExtrusion::NormalToSketch => extrude_dir.normalized().or_refuse(KernelStage::Collect, "normalized")?,
847 };
848
849 let roles = slab_face_names(names, extrusion, profile.len());
855 let slab_faces: usize = slab.shells.iter().map(|shell| shell.faces.len()).sum();
856 if slab_faces != roles.len() {
857 return Err(KernelRefusal::internal(
858 KernelStage::Fragment,
859 "slab_faces",
860 format!(
861 "rib: the slab builder produced {slab_faces} faces, expected {}",
862 roles.len()
863 ),
864 ));
865 }
866 let (part, part_names) = tokenised(solid, TARGET_TOKEN);
867 for (index, face) in slab.shells.iter_mut().flat_map(|shell| shell.faces.iter_mut()).enumerate() {
868 face.name = Some(format!("{SOURCE_TOKEN}{RIB_TOKEN}{index}"));
869 }
870 let sources = FaceSources {
871 part: part_names,
872 rib: roles,
873 };
874
875 let seeds = chain_probe_seeds(profile)?;
876 let rib = up_to_next(&part, &slab, &seeds, along, reach)
877 .map_err(|error| error.with_message(|error| sources.detokenise(error)))?;
878 let Some(rib) = rib else {
879 return Ok(solid.clone());
883 };
884
885 let mut fused = boolean_operation(
886 &part,
887 &rib,
888 BooleanOperation::Union,
889 &BooleanOptions::default(),
890 )
891 .map_err(|error| {
892 error.with_message(|error| {
893 format!(
894 "rib: union of the rib into the part failed: {}",
895 sources.detokenise(error)
896 )
897 })
898 })?;
899 let chord = vertices[count - 1].sub(vertices[0]).normalized().or_refuse(KernelStage::Collect, "normalized")?;
900 let frame = RibFrame {
901 origin: vertices[0],
902 thickness: match extrusion {
903 RibExtrusion::ParallelToSketch => np,
904 RibExtrusion::NormalToSketch => np.cross(chord).normalized().or_refuse(KernelStage::Collect, "normalized")?,
905 },
906 chord,
907 growth: along,
908 quantum: 1e-7 * reach.max(1.0),
909 };
910 restamp_rib_faces(&mut fused, &sources, &names.feature, &frame);
911 crate::feature_pipeline::features::common::stamp_derived_edge_names(&mut fused);
912 Ok(fused)
913}
914
915fn sweep_reach(solid: &BrepSolid, chain: &[Vec3]) -> Result<f64, KernelRefusal> {
919 let mut min = Vec3::new(f64::INFINITY, f64::INFINITY, f64::INFINITY);
920 let mut max = Vec3::new(f64::NEG_INFINITY, f64::NEG_INFINITY, f64::NEG_INFINITY);
921 let mut extend = |point: Vec3| {
922 min = Vec3::new(min.x.min(point.x), min.y.min(point.y), min.z.min(point.z));
923 max = Vec3::new(max.x.max(point.x), max.y.max(point.y), max.z.max(point.z));
924 };
925 for vertex in &solid.vertices {
926 extend(vertex.point);
927 }
928 for face in solid.shells.iter().flat_map(|shell| &shell.faces) {
929 for row in &face.surface.control_points {
930 for point in row {
931 extend(point.point().or_refuse(KernelStage::Collect, "control_point")?);
932 }
933 }
934 }
935 for point in chain {
936 extend(*point);
937 }
938 let diagonal = max.sub(min).length();
939 if !(diagonal > 0.0) || !diagonal.is_finite() {
940 return Err(KernelRefusal::input(
941 KernelStage::Collect,
942 "target_extent",
943 "rib: the target solid has no extent to grow the rib against",
944 ));
945 }
946 Ok(diagonal * 2.0)
947}
948
949fn chain_probe_seeds(profile: &[NurbsCurve]) -> Result<Vec<Vec3>, KernelRefusal> {
957 let mut seeds = Vec::with_capacity(profile.len());
958 for curve in profile {
959 let [start, end] = curve.domain().or_refuse(KernelStage::Collect, "domain")?;
960 seeds.push(curve.evaluate(start + (end - start) * 0.5).or_refuse(KernelStage::Collect, "evaluate")?);
961 }
962 if seeds.is_empty() {
963 return Err(KernelRefusal::input(
964 KernelStage::Collect,
965 "profile_empty",
966 "rib: profile has no points to grow from",
967 ));
968 }
969 Ok(seeds)
970}
971
972fn parallel_slab(
979 profile: &[NurbsCurve],
980 vertices: &[Vec3],
981 np: Vec3,
982 along: Vec3,
983 thickness: f64,
984 reach: f64,
985) -> Result<BrepSolid, KernelRefusal> {
986 let offset = along.scale(reach);
987 let chain_start = vertices[0];
988 let chain_end = *vertices.last().expect("chain has vertices");
989 let mut region: Vec<NurbsCurve> = Vec::with_capacity(profile.len() * 2 + 2);
990 for curve in profile {
991 region.push(curve.clone());
992 }
993 region.push(make_line(chain_end, chain_end.add(offset)).or_refuse(KernelStage::Fragment, "make_line")?);
994 for curve in profile.iter().rev() {
995 region.push(super::extrude::translated_curve(&curve.reversed().or_refuse(KernelStage::Fragment, "reversed")?, offset)?);
996 }
997 region.push(make_line(chain_start.add(offset), chain_start).or_refuse(KernelStage::Fragment, "make_line")?);
998
999 let base = region
1002 .iter()
1003 .map(|curve| super::extrude::translated_curve(curve, np.scale(-thickness * 0.5)))
1004 .collect::<Result<Vec<_>, KernelRefusal>>()?;
1005 extrude_profile_brep(&base, np, thickness).map_err(|error| {
1006 error.with_message(|error| {
1007 format!("rib: sweeping the profile inside its plane failed: {error}")
1008 })
1009 })
1010}
1011
1012fn normal_slab(
1016 profile: &[NurbsCurve],
1017 np: Vec3,
1018 along: Vec3,
1019 thickness: f64,
1020 reach: f64,
1021) -> Result<BrepSolid, KernelRefusal> {
1022 let half = thickness * 0.5;
1023 let left = offset_profile_segments(profile, np, half, false)
1024 .map_err(|error| error.with_message(|error| format!("rib: {error}")))?;
1025 let right = offset_profile_segments(profile, np, -half, false)
1026 .map_err(|error| error.with_message(|error| format!("rib: {error}")))?;
1027 let left_first = &left[0];
1028 let left_last = &left[left.len() - 1];
1029 let right_first = &right[0];
1030 let right_last = &right[right.len() - 1];
1031 let left_start = left_first.evaluate(left_first.domain().or_refuse(KernelStage::Fragment, "domain")?[0]).or_refuse(KernelStage::Fragment, "evaluate")?;
1032 let left_end = left_last.evaluate(left_last.domain().or_refuse(KernelStage::Fragment, "domain")?[1]).or_refuse(KernelStage::Fragment, "evaluate")?;
1033 let right_start = right_first.evaluate(right_first.domain().or_refuse(KernelStage::Fragment, "domain")?[0]).or_refuse(KernelStage::Fragment, "evaluate")?;
1034 let right_end = right_last.evaluate(right_last.domain().or_refuse(KernelStage::Fragment, "domain")?[1]).or_refuse(KernelStage::Fragment, "evaluate")?;
1035 let mut thin_loop: Vec<NurbsCurve> = Vec::with_capacity(left.len() + right.len() + 2);
1036 for curve in &left {
1037 thin_loop.push(curve.clone());
1038 }
1039 thin_loop.push(make_line(left_end, right_end).or_refuse(KernelStage::Fragment, "make_line")?);
1040 for curve in right.iter().rev() {
1041 thin_loop.push(curve.reversed().or_refuse(KernelStage::Fragment, "reversed")?);
1042 }
1043 thin_loop.push(make_line(right_start, left_start).or_refuse(KernelStage::Fragment, "make_line")?);
1044 extrude_profile_brep(&thin_loop, along, reach).map_err(|error| {
1045 error.with_message(|error| {
1046 format!("rib: extrude of the thickened profile failed: {error}")
1047 })
1048 })
1049}
1050
1051fn up_to_next(
1066 solid: &BrepSolid,
1067 slab: &BrepSolid,
1068 seeds: &[Vec3],
1069 along: Vec3,
1070 reach: f64,
1071) -> Result<Option<BrepSolid>, KernelRefusal> {
1072 let free = match boolean_operation(
1073 slab,
1074 solid,
1075 BooleanOperation::Subtract,
1076 &BooleanOptions::default(),
1077 ) {
1078 Ok(free) => free,
1079 Err(error) => {
1083 return Err(error.with_message(|error| {
1087 format!(
1088 "rib: RIB_UP_TO_NEXT_UNBOUNDED — the rib never reaches the part, so it has \
1089 nothing to stop against (SolidWorks' Up To Next requires every part of a rib \
1090 to meet a face); check the rib's direction — the cut reported: {error}"
1091 )
1092 }))
1093 }
1094 };
1095 if free.shells.is_empty() {
1096 return Ok(None);
1097 }
1098
1099 let classifier = SolidClassifier::new(solid, 1e-6).or_refuse(KernelStage::Select, "classifier")?;
1103 let steps = 64;
1104 let mut probes = Vec::new();
1105 for seed in seeds {
1106 for step in 1..=steps {
1107 let point = seed.add(along.scale(reach * step as f64 / steps as f64 * 0.5));
1108 if classifier.classify(point).or_refuse(KernelStage::Select, "classify")?.class == PointClass::Out {
1109 probes.push(point);
1110 break;
1111 }
1112 }
1113 }
1114 if probes.is_empty() {
1115 return Ok(None);
1117 }
1118
1119 let mut kept: Option<BrepSolid> = None;
1120 for shell in &free.shells {
1121 let piece = solid_from_shell(&free, shell);
1122 let grown_here = probes
1123 .iter()
1124 .map(|probe| classify_point(*probe, &piece, 1e-6))
1125 .collect::<Result<Vec<_>, String>>()
1126 .or_refuse(KernelStage::Select, "classify_point")?
1127 .into_iter()
1128 .any(|classification| classification.class == PointClass::In);
1129 if !grown_here {
1130 continue;
1131 }
1132 let overrun = piece
1135 .vertices
1136 .iter()
1137 .map(|vertex| vertex.point.sub(seeds[0]).dot(along))
1138 .fold(f64::NEG_INFINITY, f64::max);
1139 if overrun >= reach * 0.99 {
1140 return Err(KernelRefusal::input(
1150 KernelStage::Select,
1151 "up_to_next_unbounded",
1152 "rib: RIB_UP_TO_NEXT_UNBOUNDED — part of the rib never lands on the part, so \
1153 it has no face to stop against (SolidWorks' Up To Next requires the whole rib \
1154 to terminate on a face). Turn the rib around with `direction`, or move the \
1155 profile so its sweep meets the part",
1156 ));
1157 }
1158 kept = Some(match kept {
1159 None => piece,
1160 Some(previous) => boolean_operation(
1161 &previous,
1162 &piece,
1163 BooleanOperation::Union,
1164 &BooleanOptions::default(),
1165 )
1166 .map_err(|error| {
1167 error.with_message(|error| {
1168 format!("rib: joining the rib's own pieces failed: {error}")
1169 })
1170 })?,
1171 });
1172 }
1173 Ok(kept)
1174}
1175
1176fn solid_from_shell(source: &BrepSolid, shell: &ShellRecord) -> BrepSolid {
1180 let edge_ids: std::collections::HashSet<u64> = shell
1181 .faces
1182 .iter()
1183 .flat_map(|face| &face.loops)
1184 .flat_map(|loop_record| &loop_record.coedges)
1185 .map(|coedge| coedge.edge_id)
1186 .collect();
1187 let edges: Vec<EdgeRecord> = source
1188 .edges
1189 .iter()
1190 .filter(|edge| edge_ids.contains(&edge.id))
1191 .cloned()
1192 .collect();
1193 let vertex_ids: std::collections::HashSet<u64> = edges
1194 .iter()
1195 .flat_map(|edge| [edge.start_vertex_id, edge.end_vertex_id])
1196 .collect();
1197 BrepSolid {
1198 mass_properties_cache: Default::default(),
1199 cells: Default::default(),
1200 id: source.id,
1201 vertices: source
1202 .vertices
1203 .iter()
1204 .filter(|vertex| vertex_ids.contains(&vertex.id))
1205 .cloned()
1206 .collect(),
1207 edges,
1208 shells: vec![shell.clone()],
1209 genus: 0,
1210 kind: crate::BodyKind::Solid,
1211 }
1212}
1213
1214#[derive(Clone, Debug, PartialEq, Eq)]
1236pub struct RibNames {
1237 pub feature: String,
1238 pub segments: Vec<String>,
1239}
1240
1241impl RibNames {
1242 pub fn positional(feature: &str, count: usize) -> Self {
1244 Self {
1245 feature: feature.to_string(),
1246 segments: (0..count).map(|index| format!("SEG{index}")).collect(),
1247 }
1248 }
1249}
1250
1251fn slab_face_names(names: &RibNames, extrusion: RibExtrusion, count: usize) -> Vec<String> {
1259 let id = &names.feature;
1260 let segment = |index: usize, role: &str| format!("{id}:{}_{role}", names.segments[index]);
1261 let mut faces = Vec::with_capacity(2 * count + 4);
1262 let (near, far, base, top) = match extrusion {
1263 RibExtrusion::ParallelToSketch => ("TOP", "FAR", format!("{id}:A"), format!("{id}:B")),
1264 RibExtrusion::NormalToSketch => ("B", "A", format!("{id}:TOP"), format!("{id}:FAR")),
1265 };
1266 faces.extend((0..count).map(|index| segment(index, near)));
1267 faces.push(format!("{id}:END"));
1268 faces.extend((0..count).rev().map(|index| segment(index, far)));
1269 faces.push(format!("{id}:START"));
1270 faces.push(base);
1271 faces.push(top);
1272 faces
1273}
1274
1275const SOURCE_TOKEN: char = '\u{1}';
1277const TARGET_TOKEN: char = 'T';
1278const RIB_TOKEN: char = 'R';
1279
1280fn tokenised(solid: &BrepSolid, kind: char) -> (BrepSolid, Vec<Option<String>>) {
1283 let mut copy = solid.clone();
1284 let mut names = Vec::new();
1285 for face in copy.shells.iter_mut().flat_map(|shell| shell.faces.iter_mut()) {
1286 names.push(face.name.take());
1287 face.name = Some(format!("{SOURCE_TOKEN}{kind}{}", names.len() - 1));
1288 }
1289 (copy, names)
1290}
1291
1292struct FaceSources {
1294 part: Vec<Option<String>>,
1295 rib: Vec<String>,
1296}
1297
1298impl FaceSources {
1299 fn parse(name: &str) -> Option<(char, usize)> {
1302 let mut chars = name.strip_prefix(SOURCE_TOKEN)?.chars();
1303 let kind = chars.next()?;
1304 let digits: String = chars.take_while(|c| c.is_ascii_digit()).collect();
1305 Some((kind, digits.parse().ok()?))
1306 }
1307
1308 fn name(&self, kind: char, index: usize) -> Option<&str> {
1309 match kind {
1310 TARGET_TOKEN => self.part.get(index)?.as_deref(),
1311 RIB_TOKEN => self.rib.get(index).map(String::as_str),
1312 _ => None,
1313 }
1314 }
1315
1316 fn detokenise(&self, text: &str) -> String {
1318 let mut out = String::with_capacity(text.len());
1319 let mut chars = text.chars().peekable();
1320 while let Some(c) = chars.next() {
1321 if c != SOURCE_TOKEN {
1322 out.push(c);
1323 continue;
1324 }
1325 let kind = chars.next();
1326 let mut digits = String::new();
1327 while let Some(digit) = chars.peek().copied().filter(char::is_ascii_digit) {
1328 digits.push(digit);
1329 chars.next();
1330 }
1331 match (kind, digits.parse::<usize>()) {
1332 (Some(kind), Ok(index)) => out.push_str(self.name(kind, index).unwrap_or("<unnamed>")),
1333 _ => {
1334 out.extend(kind);
1335 out.push_str(&digits);
1336 }
1337 }
1338 }
1339 out
1340 }
1341}
1342
1343struct RibFrame {
1347 origin: Vec3,
1348 thickness: Vec3,
1349 chord: Vec3,
1350 growth: Vec3,
1351 quantum: f64,
1354}
1355
1356fn restamp_rib_faces(fused: &mut BrepSolid, sources: &FaceSources, feature: &str, frame: &RibFrame) {
1377 use std::collections::{BTreeMap, HashMap};
1378 let points: HashMap<u64, Vec3> = fused.vertices.iter().map(|vertex| (vertex.id, vertex.point)).collect();
1379 let ends: HashMap<u64, (u64, u64)> = fused
1380 .edges
1381 .iter()
1382 .map(|edge| (edge.id, (edge.start_vertex_id, edge.end_vertex_id)))
1383 .collect();
1384 let quantise = |value: f64| (value / frame.quantum).round() as i64;
1385 let mut groups: BTreeMap<(char, usize), Vec<((i64, i64, i64), (usize, usize))>> = BTreeMap::new();
1387 for (shell_index, shell) in fused.shells.iter().enumerate() {
1388 for (face_index, face) in shell.faces.iter().enumerate() {
1389 let Some(source) = face.name.as_deref().and_then(FaceSources::parse) else {
1390 continue;
1391 };
1392 let mut ids: Vec<u64> = face
1393 .loops
1394 .iter()
1395 .flat_map(|loop_record| &loop_record.coedges)
1396 .filter_map(|coedge| ends.get(&coedge.edge_id))
1397 .flat_map(|(start, end)| [*start, *end])
1398 .collect();
1399 ids.sort_unstable();
1400 ids.dedup();
1401 let mut centre = Vec3::default();
1402 for id in &ids {
1403 if let Some(point) = points.get(id) {
1404 centre = centre.add(*point);
1405 }
1406 }
1407 let centre = centre.scale(1.0 / ids.len().max(1) as f64).sub(frame.origin);
1408 let key = (
1409 quantise(centre.dot(frame.thickness)),
1410 quantise(centre.dot(frame.chord)),
1411 quantise(centre.dot(frame.growth)),
1412 );
1413 groups.entry(source).or_default().push((key, (shell_index, face_index)));
1414 }
1415 }
1416 for ((kind, index), mut fragments) in groups {
1417 let original = sources.name(kind, index).map(str::to_string);
1418 let side = |key: &(i64, i64, i64)| match key.0.signum() {
1419 -1 => ('A', 0),
1420 0 => ('M', 1),
1421 _ => ('B', 2),
1422 };
1423 let named: Vec<((usize, usize), Option<String>)> = match &original {
1424 None => fragments.into_iter().map(|(_, at)| (at, None)).collect(),
1425 Some(name) if fragments.len() == 1 => vec![(fragments[0].1, Some(name.clone()))],
1426 Some(name) if kind == RIB_TOKEN => {
1427 fragments.sort_by_key(|(key, _)| (key.1, key.2, key.0));
1428 fragments
1429 .into_iter()
1430 .enumerate()
1431 .map(|(rank, (_, at))| {
1432 (at, Some(if rank == 0 { name.clone() } else { format!("{name}_{}", rank + 1) }))
1433 })
1434 .collect()
1435 }
1436 Some(name) => {
1437 fragments.sort_by_key(|(key, _)| (side(key).1, key.1, key.2, key.0));
1438 let mut per_side: BTreeMap<char, usize> = BTreeMap::new();
1439 fragments
1440 .into_iter()
1441 .enumerate()
1442 .map(|(rank, (key, at))| {
1443 let letter = side(&key).0;
1444 let ordinal = per_side.entry(letter).or_insert(0);
1445 *ordinal += 1;
1446 let spelled = if rank == 0 {
1447 name.clone()
1448 } else {
1449 format!("{feature}:{name}_{letter}{ordinal}")
1450 };
1451 (at, Some(spelled))
1452 })
1453 .collect()
1454 }
1455 };
1456 for ((shell_index, face_index), name) in named {
1457 fused.shells[shell_index].faces[face_index].name = name;
1458 }
1459 }
1460}