1use super::*;
2use crate::{classify_point, PointClass, SolidClassifier};
3use serde::{Deserialize, Serialize};
4
5pub(super) fn ruled_between(bottom: &NurbsCurve, top: &NurbsCurve) -> Result<NurbsSurface, String> {
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("ruled_between: rows are not representation-compatible".into());
15 }
16 let grid = bottom
17 .control_points
18 .iter()
19 .zip(&top.control_points)
20 .map(|(a, b)| vec![*a, *b])
21 .collect();
22 NurbsSurface::new(
23 bottom.degree,
24 1,
25 bottom.knots.clone(),
26 vec![0.0, 0.0, 1.0, 1.0],
27 grid,
28 )
29}
30
31pub(super) fn arc_window_subrange(row: &NurbsCurve, start: Vec3, end: Vec3) -> Result<NurbsCurve, String> {
36 let [d0, d1] = row.domain()?;
37 let span = d1 - d0;
38 let u0 = project_point_to_curve(row, start)?.u;
39 let u1 = project_point_to_curve(row, end)?.u;
40 if u1 <= u0 + 1e-12 {
41 return Err("draftExtrude: a drafted arc's boundary trim inverted".into());
42 }
43 let epsilon = span * 1e-9;
44 let mut current = row.clone();
45 if u0 > d0 + epsilon {
46 current = current.split(u0)?.1;
47 }
48 let domain = current.domain()?;
49 if u1 < domain[1] - epsilon && u1 > domain[0] + epsilon {
50 current = current.split(u1)?.0;
51 }
52 Ok(current)
53}
54
55fn wall_gradient(
61 seg: &SegGeom,
62 point: Vec3,
63 zh: Vec3,
64 height: f64,
65 signed_d: f64,
66) -> Result<Vec3, String> {
67 match seg {
68 SegGeom::Line { dir, normal, .. } => dir
69 .cross(normal.scale(signed_d).add(zh.scale(height)))
70 .normalized(),
71 SegGeom::Arc {
72 center, turn, ..
73 } => {
74 let rel = point.sub(*center);
75 let radial = rel.sub(zh.scale(rel.dot(zh)));
76 let rho = radial.normalized()?;
77 rho.add(zh.scale(signed_d * turn / height)).normalized()
78 }
79 }
80}
81
82#[allow(clippy::too_many_arguments)]
92pub(super) fn junction_edge_curve(
93 prev: &SegGeom,
94 next: &SegGeom,
95 a: Vec3,
96 m: Vec3,
97 b: Vec3,
98 zh: Vec3,
99 height: f64,
100 signed_d: f64,
101) -> Result<NurbsCurve, String> {
102 let chord = b.sub(a);
103 let length = chord.length();
104 if length <= 1e-12 {
105 return Err("draftExtrude: a junction edge collapsed to a point".into());
106 }
107 let along = m.sub(a).dot(chord) / (length * length);
108 let deviation = m.sub(a).sub(chord.scale(along)).length();
109 if deviation <= length * 1e-9 {
110 return make_line(a, b);
111 }
112 let e1 = chord.scale(1.0 / length);
114 let plane_normal = chord.cross(m.sub(a)).normalized()?;
115 let e2 = plane_normal.cross(e1).normalized()?;
116 let orient = |tangent: Vec3| {
117 if tangent.dot(zh) < 0.0 {
118 tangent.scale(-1.0)
119 } else {
120 tangent
121 }
122 };
123 let t0 = orient(wall_gradient(prev, a, zh, height, signed_d)?
124 .cross(wall_gradient(next, a, zh, height, signed_d)?));
125 let t2 = orient(wall_gradient(prev, b, zh, height, signed_d)?
126 .cross(wall_gradient(next, b, zh, height, signed_d)?));
127 let d0 = (t0.dot(e1), t0.dot(e2));
128 let d2 = (t2.dot(e1), t2.dot(e2));
129 let denom = d0.0 * d2.1 - d0.1 * d2.0;
130 let scale0 = d0.0.hypot(d0.1);
131 let scale2 = d2.0.hypot(d2.1);
132 if denom.abs() <= 1e-14 * scale0 * scale2 {
133 return Err("draftExtrude: junction end tangents are parallel — no conic apex".into());
134 }
135 let s = length * d2.1 / denom;
137 let apex = (s * d0.0, s * d0.1);
138 if apex.1.abs() <= f64::EPSILON * length {
139 return Err("draftExtrude: junction conic apex is degenerate".into());
140 }
141 let mq = (m.sub(a).dot(e1), m.sub(a).dot(e2));
143 let beta = mq.1 / apex.1;
144 let gamma = (mq.0 - beta * apex.0) / length;
145 let alpha = 1.0 - beta - gamma;
146 if !(alpha > 0.0 && beta > 0.0 && gamma > 0.0) {
147 return Err(format!(
148 "draftExtrude: junction conic witness fell outside its control triangle \
149 (α={alpha:.3e} β={beta:.3e} γ={gamma:.3e})"
150 ));
151 }
152 let weight = beta / (2.0 * (alpha * gamma).sqrt());
153 let apex_3d = a.add(e1.scale(apex.0)).add(e2.scale(apex.1));
154 NurbsCurve::new(
155 2,
156 vec![0.0, 0.0, 0.0, 1.0, 1.0, 1.0],
157 vec![
158 Vec4::from_point(a, 1.0),
159 Vec4::from_point(apex_3d, weight),
160 Vec4::from_point(b, 1.0),
161 ],
162 )
163}
164
165fn circumcircle(a: Vec3, b: Vec3, c: Vec3, ex: Vec3, ey: Vec3, np: Vec3) -> Option<(Vec3, f64)> {
168 let (ax, ay) = (a.dot(ex), a.dot(ey));
169 let (bx, by) = (b.dot(ex), b.dot(ey));
170 let (cx, cy) = (c.dot(ex), c.dot(ey));
171 let d = 2.0 * (ax * (by - cy) + bx * (cy - ay) + cx * (ay - by));
172 if d.abs() < 1e-12 {
173 return None;
174 }
175 let a2 = ax * ax + ay * ay;
176 let b2 = bx * bx + by * by;
177 let c2 = cx * cx + cy * cy;
178 let ux = (a2 * (by - cy) + b2 * (cy - ay) + c2 * (ay - by)) / d;
179 let uy = (a2 * (cx - bx) + b2 * (ax - cx) + c2 * (bx - ax)) / d;
180 let plane_off = a.dot(np);
181 let center = ex.scale(ux).add(ey.scale(uy)).add(np.scale(plane_off));
182 let radius = a.sub(center).length();
183 Some((center, radius))
184}
185
186fn intersect_offset_line_circle(
190 line_point: Vec3,
191 line_dir: Vec3,
192 center: Vec3,
193 radius: f64,
194 near: Vec3,
195) -> Result<Vec3, String> {
196 let dir = line_dir.normalized()?;
197 let f = line_point.sub(center);
198 let b = f.dot(dir);
199 let c = f.dot(f) - radius * radius;
200 let disc = b * b - c;
201 if disc < -1e-9 {
202 return Err("an offset line and arc no longer meet (offset too large)".into());
203 }
204 let root = disc.max(0.0).sqrt();
205 let p1 = line_point.add(dir.scale(-b + root));
206 let p2 = line_point.add(dir.scale(-b - root));
207 Ok(if p1.sub(near).length() <= p2.sub(near).length() {
208 p1
209 } else {
210 p2
211 })
212}
213
214fn intersect_offset_circles(
218 c1: Vec3,
219 r1: f64,
220 c2: Vec3,
221 r2: f64,
222 plane_normal: Vec3,
223 near: Vec3,
224) -> Result<Vec3, String> {
225 let between = c2.sub(c1);
226 let d = between.length();
227 if d < 1e-9 {
228 return Err("concentric offset arcs do not meet".into());
229 }
230 let axis = between.scale(1.0 / d);
231 let a = (d * d + r1 * r1 - r2 * r2) / (2.0 * d);
232 let h2 = r1 * r1 - a * a;
233 if h2 < -1e-9 {
234 return Err("offset arcs no longer meet (offset too large)".into());
235 }
236 let h = h2.max(0.0).sqrt();
237 let base = c1.add(axis.scale(a));
238 let perp = plane_normal.cross(axis).normalized()?;
239 let p1 = base.add(perp.scale(h));
240 let p2 = base.sub(perp.scale(h));
241 Ok(if p1.sub(near).length() <= p2.sub(near).length() {
242 p1
243 } else {
244 p2
245 })
246}
247
248pub(super) enum SegGeom {
252 Line {
253 start: Vec3,
254 end: Vec3,
255 dir: Vec3,
257 normal: Vec3,
259 },
260 Arc {
261 center: Vec3,
262 radius: f64,
263 turn: f64,
266 arc_normal: Vec3,
268 start: Vec3,
269 end: Vec3,
270 },
271}
272
273impl SegGeom {
274 fn start(&self) -> Vec3 {
275 match self {
276 SegGeom::Line { start, .. } | SegGeom::Arc { start, .. } => *start,
277 }
278 }
279
280 fn end(&self) -> Vec3 {
281 match self {
282 SegGeom::Line { end, .. } | SegGeom::Arc { end, .. } => *end,
283 }
284 }
285
286 fn offset_point(&self, point: Vec3, signed_d: f64) -> Result<Vec3, String> {
290 match self {
291 SegGeom::Line { normal, .. } => Ok(point.add(normal.scale(signed_d))),
292 SegGeom::Arc {
293 center,
294 radius,
295 turn,
296 ..
297 } => {
298 let r_offset = radius - signed_d * turn;
299 if r_offset <= 1e-6 {
300 return Err("offset: distance is too large — a concave arc collapses".into());
301 }
302 Ok(center.add(point.sub(*center).scale(r_offset / radius)))
303 }
304 }
305 }
306}
307
308pub(super) fn classify_profile_segments(
314 profile: &[NurbsCurve],
315 plane_normal: Vec3,
316) -> Result<Vec<SegGeom>, String> {
317 let tol = 1e-6;
318 if profile.is_empty() {
319 return Err("offset: profile has no segments".into());
320 }
321 let np = plane_normal.normalized()?;
322 let ex = np.perpendicular()?;
323 let ey = np.cross(ex).normalized()?;
324 let mut segs = Vec::with_capacity(profile.len());
325 for curve in profile {
326 let [t0, t1] = curve.domain()?;
327 let start = curve.evaluate(t0)?;
328 let end = curve.evaluate(t1)?;
329 let chord = end.sub(start);
330 let chord_len = chord.length();
331 if chord_len <= tol {
332 return Err("offset: profile has a degenerate (zero-length) segment".into());
333 }
334 let dir = chord.scale(1.0 / chord_len);
335 let mut is_line = true;
337 for k in 1..8 {
338 let point = curve.evaluate(t0 + (t1 - t0) * k as f64 / 8.0)?;
339 let rel = point.sub(start);
340 let perpendicular = rel.sub(dir.scale(rel.dot(dir))).length();
341 if perpendicular > tol * 10.0 {
342 is_line = false;
343 break;
344 }
345 }
346 if is_line {
347 segs.push(SegGeom::Line {
348 start,
349 end,
350 dir,
351 normal: np.cross(dir).normalized()?,
352 });
353 continue;
354 }
355 let mid = curve.evaluate((t0 + t1) * 0.5)?;
357 let (center, radius) = circumcircle(start, mid, end, ex, ey, np).ok_or_else(|| {
358 "offset: only straight lines and circular arcs are supported".to_string()
359 })?;
360 for k in 0..=8 {
361 let point = curve.evaluate(t0 + (t1 - t0) * k as f64 / 8.0)?;
362 if (point.sub(center).length() - radius).abs() > tol * 10.0 {
363 return Err("offset: only straight lines and circular arcs are supported".into());
364 }
365 }
366 let bend = np.dot(mid.sub(start).cross(end.sub(mid)));
368 let (arc_normal, turn) = if bend >= 0.0 {
369 (np, 1.0)
370 } else {
371 (np.scale(-1.0), -1.0)
372 };
373 segs.push(SegGeom::Arc {
374 center,
375 radius,
376 turn,
377 arc_normal,
378 start,
379 end,
380 });
381 }
382 Ok(segs)
383}
384
385pub(super) fn offset_junction(
396 prev: &SegGeom,
397 next: &SegGeom,
398 plane_normal: Vec3,
399 signed_d: f64,
400) -> Result<Vec3, String> {
401 let vertex = prev.end();
402 if signed_d == 0.0 {
403 return Ok(vertex);
404 }
405 let prev_offset = prev.offset_point(prev.end(), signed_d)?;
406 let next_offset = next.offset_point(next.start(), signed_d)?;
407 if prev_offset.sub(next_offset).length() <= 1e-6 {
408 return Ok(prev_offset.add(next_offset).scale(0.5));
409 }
410 match (prev, next) {
411 (SegGeom::Line { normal: na, .. }, SegGeom::Line { normal: nb, .. }) => {
412 let denom = 1.0 + na.dot(*nb);
413 if denom.abs() < 1e-6 {
414 return Err("offset: degenerate (near-reversal) polyline corner".into());
415 }
416 Ok(vertex.add(na.add(*nb).scale(signed_d / denom)))
417 }
418 (
419 SegGeom::Line { dir, .. },
420 SegGeom::Arc {
421 center,
422 radius,
423 turn,
424 ..
425 },
426 ) => intersect_offset_line_circle(
427 prev_offset,
428 *dir,
429 *center,
430 radius - signed_d * turn,
431 vertex,
432 ),
433 (
434 SegGeom::Arc {
435 center,
436 radius,
437 turn,
438 ..
439 },
440 SegGeom::Line { dir, .. },
441 ) => intersect_offset_line_circle(
442 next_offset,
443 *dir,
444 *center,
445 radius - signed_d * turn,
446 vertex,
447 ),
448 (
449 SegGeom::Arc {
450 center: c1,
451 radius: r1,
452 turn: turn1,
453 ..
454 },
455 SegGeom::Arc {
456 center: c2,
457 radius: r2,
458 turn: turn2,
459 ..
460 },
461 ) => intersect_offset_circles(
462 *c1,
463 r1 - signed_d * turn1,
464 *c2,
465 r2 - signed_d * turn2,
466 plane_normal,
467 vertex,
468 ),
469 }
470}
471
472fn offset_profile_segments(
483 profile: &[NurbsCurve],
484 plane_normal: Vec3,
485 signed_d: f64,
486 closed: bool,
487) -> Result<Vec<NurbsCurve>, String> {
488 let tol = 1e-6;
489 let np = plane_normal.normalized()?;
490 let segs = classify_profile_segments(profile, np)?;
491 let n = segs.len();
492
493 let mut offsets: Vec<(Vec3, Vec3)> = segs
495 .iter()
496 .map(|seg| {
497 Ok((
498 seg.offset_point(seg.start(), signed_d)?,
499 seg.offset_point(seg.end(), signed_d)?,
500 ))
501 })
502 .collect::<Result<_, String>>()?;
503 let junctions = if closed { n } else { n.saturating_sub(1) };
504 for i in 0..junctions {
505 let j = (i + 1) % n;
506 let point = offset_junction(&segs[i], &segs[j], np, signed_d)?;
507 offsets[i].1 = point;
508 offsets[j].0 = point;
509 }
510
511 let mut out = Vec::with_capacity(n);
513 for (seg, (off_start, off_end)) in segs.iter().zip(&offsets) {
514 match seg {
515 SegGeom::Line { .. } => out.push(make_line(*off_start, *off_end)?),
516 SegGeom::Arc {
517 center, arc_normal, ..
518 } => {
519 let radial = off_start.sub(*center);
520 let r2 = radial.length();
521 if r2 <= tol {
522 return Err("offset: reconstructed arc has a zero radius".into());
523 }
524 let ax = radial.scale(1.0 / r2);
525 let ay = arc_normal.cross(ax).normalized()?;
526 let ve = off_end.sub(*center);
527 let mut angle = ve.dot(ay).atan2(ve.dot(ax));
528 if angle <= 1e-9 {
529 angle += std::f64::consts::TAU;
530 }
531 out.push(make_arc(*center, ax, ay, r2, 0.0, angle)?);
532 }
533 }
534 }
535 Ok(out)
536}
537
538#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Deserialize, Serialize)]
545#[serde(rename_all = "SCREAMING_SNAKE_CASE")]
546pub enum RibExtrusion {
547 #[default]
553 ParallelToSketch,
554 NormalToSketch,
558}
559
560pub fn rib_from_profile(
594 solid: &BrepSolid,
595 profile: &[NurbsCurve],
596 thickness: f64,
597 extrude_dir: Vec3,
598 plane_normal: Option<Vec3>,
599 extrusion: RibExtrusion,
600 names: &RibNames,
601) -> Result<BrepSolid, String> {
602 let tolerance = 1e-6;
603 if profile.is_empty() {
604 return Err("rib: profile needs at least 1 curve forming an open chain".into());
605 }
606 if names.segments.len() != profile.len() {
607 return Err(format!(
608 "rib: {} segment names for {} profile curves",
609 names.segments.len(),
610 profile.len()
611 ));
612 }
613 if !(thickness > 0.0) {
614 return Err("rib: thickness must be positive".into());
615 }
616
617 let mut vertices = Vec::with_capacity(profile.len() + 1);
620 let mut samples = Vec::new();
621 for (index, curve) in profile.iter().enumerate() {
622 let [start, end] = curve.domain()?;
623 let v_start = curve.evaluate(start)?;
624 let v_end = curve.evaluate(end)?;
625 if v_end.sub(v_start).length() <= tolerance {
626 return Err("rib: profile has a degenerate (zero-length) segment".into());
627 }
628 if index == 0 {
629 vertices.push(v_start);
630 } else if v_start.sub(*vertices.last().unwrap()).length() > tolerance {
631 return Err(format!(
632 "rib: profile chain is not connected at curve {index}"
633 ));
634 }
635 vertices.push(v_end);
636 let first_k = if index == 0 { 0 } else { 1 };
640 for k in first_k..=8 {
641 samples.push(curve.evaluate(start + (end - start) * k as f64 / 8.0)?);
642 }
643 }
644 let count = vertices.len();
645 if count < 2 {
646 return Err("rib: profile needs at least 2 distinct vertices".into());
647 }
648
649 if vertices[count - 1].sub(vertices[0]).length() <= tolerance {
652 return Err("rib: profile chain is closed; rib expects an open chain".into());
653 }
654
655 let np = match plane_normal {
662 Some(supplied) => supplied
663 .normalized()
664 .map_err(|_| "rib: the supplied profile plane normal is degenerate".to_string())?,
665 None => {
666 let mut normal = Vec3::default();
667 for i in 1..samples.len() - 1 {
668 let a = samples[i].sub(samples[i - 1]);
669 let b = samples[i + 1].sub(samples[i]);
670 normal = normal.add(a.cross(b));
671 }
672 normal.normalized().map_err(|_| {
673 "rib: profile is collinear and no profile plane was supplied; cannot determine \
674 its plane"
675 .to_string()
676 })?
677 }
678 };
679 let origin = vertices[0];
680 if samples
681 .iter()
682 .any(|point| point.sub(origin).dot(np).abs() > tolerance * 100.0)
683 {
684 return Err(if plane_normal.is_some() {
685 "rib: profile does not lie in the supplied plane".into()
686 } else {
687 "rib: profile is not planar".to_string()
688 });
689 }
690
691 let reach = sweep_reach(solid, &vertices)?;
695
696 let mut slab = match extrusion {
699 RibExtrusion::ParallelToSketch => {
700 let along = extrude_dir.sub(np.scale(extrude_dir.dot(np)));
703 let along = along.normalized().map_err(|_| {
704 "rib: a Parallel-to-Sketch rib grows INSIDE its sketch plane, but the requested \
705 direction is perpendicular to it"
706 .to_string()
707 })?;
708 parallel_slab(profile, &vertices, np, along, thickness, reach)?
709 }
710 RibExtrusion::NormalToSketch => {
711 let along = extrude_dir
712 .normalized()
713 .map_err(|_| "rib: extrude direction is degenerate".to_string())?;
714 normal_slab(profile, np, along, thickness, reach)?
715 }
716 };
717
718 let along = match extrusion {
721 RibExtrusion::ParallelToSketch => {
722 let along = extrude_dir.sub(np.scale(extrude_dir.dot(np)));
723 along.normalized()?
724 }
725 RibExtrusion::NormalToSketch => extrude_dir.normalized()?,
726 };
727
728 let roles = slab_face_names(names, extrusion, profile.len());
734 let slab_faces: usize = slab.shells.iter().map(|shell| shell.faces.len()).sum();
735 if slab_faces != roles.len() {
736 return Err(format!(
737 "rib: the slab builder produced {slab_faces} faces, expected {}",
738 roles.len()
739 ));
740 }
741 let (part, part_names) = tokenised(solid, TARGET_TOKEN);
742 for (index, face) in slab.shells.iter_mut().flat_map(|shell| shell.faces.iter_mut()).enumerate() {
743 face.name = Some(format!("{SOURCE_TOKEN}{RIB_TOKEN}{index}"));
744 }
745 let sources = FaceSources {
746 part: part_names,
747 rib: roles,
748 };
749
750 let seeds = chain_probe_seeds(profile)?;
751 let rib = up_to_next(&part, &slab, &seeds, along, reach)
752 .map_err(|error| sources.detokenise(&error))?;
753 let Some(rib) = rib else {
754 return Ok(solid.clone());
758 };
759
760 let mut fused = boolean_operation(
761 &part,
762 &rib,
763 BooleanOperation::Union,
764 &BooleanOptions::default(),
765 )
766 .map_err(|error| {
767 format!(
768 "rib: union of the rib into the part failed: {}",
769 sources.detokenise(&error.to_string())
770 )
771 })?;
772 let chord = vertices[count - 1].sub(vertices[0]).normalized()?;
773 let frame = RibFrame {
774 origin: vertices[0],
775 thickness: match extrusion {
776 RibExtrusion::ParallelToSketch => np,
777 RibExtrusion::NormalToSketch => np.cross(chord).normalized()?,
778 },
779 chord,
780 growth: along,
781 quantum: 1e-7 * reach.max(1.0),
782 };
783 restamp_rib_faces(&mut fused, &sources, &names.feature, &frame);
784 crate::feature_pipeline::features::common::stamp_derived_edge_names(&mut fused);
785 Ok(fused)
786}
787
788fn sweep_reach(solid: &BrepSolid, chain: &[Vec3]) -> Result<f64, String> {
792 let mut min = Vec3::new(f64::INFINITY, f64::INFINITY, f64::INFINITY);
793 let mut max = Vec3::new(f64::NEG_INFINITY, f64::NEG_INFINITY, f64::NEG_INFINITY);
794 let mut extend = |point: Vec3| {
795 min = Vec3::new(min.x.min(point.x), min.y.min(point.y), min.z.min(point.z));
796 max = Vec3::new(max.x.max(point.x), max.y.max(point.y), max.z.max(point.z));
797 };
798 for vertex in &solid.vertices {
799 extend(vertex.point);
800 }
801 for face in solid.shells.iter().flat_map(|shell| &shell.faces) {
802 for row in &face.surface.control_points {
803 for point in row {
804 extend(point.point()?);
805 }
806 }
807 }
808 for point in chain {
809 extend(*point);
810 }
811 let diagonal = max.sub(min).length();
812 if !(diagonal > 0.0) || !diagonal.is_finite() {
813 return Err("rib: the target solid has no extent to grow the rib against".into());
814 }
815 Ok(diagonal * 2.0)
816}
817
818fn chain_probe_seeds(profile: &[NurbsCurve]) -> Result<Vec<Vec3>, String> {
826 let mut seeds = Vec::with_capacity(profile.len());
827 for curve in profile {
828 let [start, end] = curve.domain()?;
829 seeds.push(curve.evaluate(start + (end - start) * 0.5)?);
830 }
831 if seeds.is_empty() {
832 return Err("rib: profile has no points to grow from".into());
833 }
834 Ok(seeds)
835}
836
837fn parallel_slab(
844 profile: &[NurbsCurve],
845 vertices: &[Vec3],
846 np: Vec3,
847 along: Vec3,
848 thickness: f64,
849 reach: f64,
850) -> Result<BrepSolid, String> {
851 let offset = along.scale(reach);
852 let chain_start = vertices[0];
853 let chain_end = *vertices.last().expect("chain has vertices");
854 let mut region: Vec<NurbsCurve> = Vec::with_capacity(profile.len() * 2 + 2);
855 for curve in profile {
856 region.push(curve.clone());
857 }
858 region.push(make_line(chain_end, chain_end.add(offset))?);
859 for curve in profile.iter().rev() {
860 region.push(super::extrude::translated_curve(&curve.reversed()?, offset)?);
861 }
862 region.push(make_line(chain_start.add(offset), chain_start)?);
863
864 let base = region
867 .iter()
868 .map(|curve| super::extrude::translated_curve(curve, np.scale(-thickness * 0.5)))
869 .collect::<Result<Vec<_>, String>>()?;
870 extrude_profile_brep(&base, np, thickness)
871 .map_err(|error| format!("rib: sweeping the profile inside its plane failed: {error}"))
872}
873
874fn normal_slab(
878 profile: &[NurbsCurve],
879 np: Vec3,
880 along: Vec3,
881 thickness: f64,
882 reach: f64,
883) -> Result<BrepSolid, String> {
884 let half = thickness * 0.5;
885 let left = offset_profile_segments(profile, np, half, false)
886 .map_err(|error| format!("rib: {error}"))?;
887 let right = offset_profile_segments(profile, np, -half, false)
888 .map_err(|error| format!("rib: {error}"))?;
889 let left_first = &left[0];
890 let left_last = &left[left.len() - 1];
891 let right_first = &right[0];
892 let right_last = &right[right.len() - 1];
893 let left_start = left_first.evaluate(left_first.domain()?[0])?;
894 let left_end = left_last.evaluate(left_last.domain()?[1])?;
895 let right_start = right_first.evaluate(right_first.domain()?[0])?;
896 let right_end = right_last.evaluate(right_last.domain()?[1])?;
897 let mut thin_loop: Vec<NurbsCurve> = Vec::with_capacity(left.len() + right.len() + 2);
898 for curve in &left {
899 thin_loop.push(curve.clone());
900 }
901 thin_loop.push(make_line(left_end, right_end)?);
902 for curve in right.iter().rev() {
903 thin_loop.push(curve.reversed()?);
904 }
905 thin_loop.push(make_line(right_start, left_start)?);
906 extrude_profile_brep(&thin_loop, along, reach)
907 .map_err(|error| format!("rib: extrude of the thickened profile failed: {error}"))
908}
909
910fn up_to_next(
925 solid: &BrepSolid,
926 slab: &BrepSolid,
927 seeds: &[Vec3],
928 along: Vec3,
929 reach: f64,
930) -> Result<Option<BrepSolid>, String> {
931 let free = match boolean_operation(
932 slab,
933 solid,
934 BooleanOperation::Subtract,
935 &BooleanOptions::default(),
936 ) {
937 Ok(free) => free,
938 Err(error) => {
942 return Err(format!(
945 "rib: RIB_UP_TO_NEXT_UNBOUNDED — the rib never reaches the part, so it has \
946 nothing to stop against (SolidWorks' Up To Next requires every part of a rib \
947 to meet a face); check the rib's direction — the cut reported: {error}"
948 ))
949 }
950 };
951 if free.shells.is_empty() {
952 return Ok(None);
953 }
954
955 let classifier = SolidClassifier::new(solid, 1e-6)?;
959 let steps = 64;
960 let mut probes = Vec::new();
961 for seed in seeds {
962 for step in 1..=steps {
963 let point = seed.add(along.scale(reach * step as f64 / steps as f64 * 0.5));
964 if classifier.classify(point)?.class == PointClass::Out {
965 probes.push(point);
966 break;
967 }
968 }
969 }
970 if probes.is_empty() {
971 return Ok(None);
973 }
974
975 let mut kept: Option<BrepSolid> = None;
976 for shell in &free.shells {
977 let piece = solid_from_shell(&free, shell);
978 let grown_here = probes
979 .iter()
980 .map(|probe| classify_point(*probe, &piece, 1e-6))
981 .collect::<Result<Vec<_>, String>>()?
982 .into_iter()
983 .any(|classification| classification.class == PointClass::In);
984 if !grown_here {
985 continue;
986 }
987 let overrun = piece
990 .vertices
991 .iter()
992 .map(|vertex| vertex.point.sub(seeds[0]).dot(along))
993 .fold(f64::NEG_INFINITY, f64::max);
994 if overrun >= reach * 0.99 {
995 return Err(
1005 "rib: RIB_UP_TO_NEXT_UNBOUNDED — part of the rib never lands on the part, so \
1006 it has no face to stop against (SolidWorks' Up To Next requires the whole rib \
1007 to terminate on a face). Turn the rib around with `direction`, or move the \
1008 profile so its sweep meets the part"
1009 .into(),
1010 );
1011 }
1012 kept = Some(match kept {
1013 None => piece,
1014 Some(previous) => boolean_operation(
1015 &previous,
1016 &piece,
1017 BooleanOperation::Union,
1018 &BooleanOptions::default(),
1019 )
1020 .map_err(|error| format!("rib: joining the rib's own pieces failed: {error}"))?,
1021 });
1022 }
1023 Ok(kept)
1024}
1025
1026fn solid_from_shell(source: &BrepSolid, shell: &ShellRecord) -> BrepSolid {
1030 let edge_ids: std::collections::HashSet<u64> = shell
1031 .faces
1032 .iter()
1033 .flat_map(|face| &face.loops)
1034 .flat_map(|loop_record| &loop_record.coedges)
1035 .map(|coedge| coedge.edge_id)
1036 .collect();
1037 let edges: Vec<EdgeRecord> = source
1038 .edges
1039 .iter()
1040 .filter(|edge| edge_ids.contains(&edge.id))
1041 .cloned()
1042 .collect();
1043 let vertex_ids: std::collections::HashSet<u64> = edges
1044 .iter()
1045 .flat_map(|edge| [edge.start_vertex_id, edge.end_vertex_id])
1046 .collect();
1047 BrepSolid {
1048 id: source.id,
1049 vertices: source
1050 .vertices
1051 .iter()
1052 .filter(|vertex| vertex_ids.contains(&vertex.id))
1053 .cloned()
1054 .collect(),
1055 edges,
1056 shells: vec![shell.clone()],
1057 genus: 0,
1058 }
1059}
1060
1061#[derive(Clone, Debug, PartialEq, Eq)]
1083pub struct RibNames {
1084 pub feature: String,
1085 pub segments: Vec<String>,
1086}
1087
1088impl RibNames {
1089 pub fn positional(feature: &str, count: usize) -> Self {
1091 Self {
1092 feature: feature.to_string(),
1093 segments: (0..count).map(|index| format!("SEG{index}")).collect(),
1094 }
1095 }
1096}
1097
1098fn slab_face_names(names: &RibNames, extrusion: RibExtrusion, count: usize) -> Vec<String> {
1106 let id = &names.feature;
1107 let segment = |index: usize, role: &str| format!("{id}:{}_{role}", names.segments[index]);
1108 let mut faces = Vec::with_capacity(2 * count + 4);
1109 let (near, far, base, top) = match extrusion {
1110 RibExtrusion::ParallelToSketch => ("TOP", "FAR", format!("{id}:A"), format!("{id}:B")),
1111 RibExtrusion::NormalToSketch => ("B", "A", format!("{id}:TOP"), format!("{id}:FAR")),
1112 };
1113 faces.extend((0..count).map(|index| segment(index, near)));
1114 faces.push(format!("{id}:END"));
1115 faces.extend((0..count).rev().map(|index| segment(index, far)));
1116 faces.push(format!("{id}:START"));
1117 faces.push(base);
1118 faces.push(top);
1119 faces
1120}
1121
1122const SOURCE_TOKEN: char = '\u{1}';
1124const TARGET_TOKEN: char = 'T';
1125const RIB_TOKEN: char = 'R';
1126
1127fn tokenised(solid: &BrepSolid, kind: char) -> (BrepSolid, Vec<Option<String>>) {
1130 let mut copy = solid.clone();
1131 let mut names = Vec::new();
1132 for face in copy.shells.iter_mut().flat_map(|shell| shell.faces.iter_mut()) {
1133 names.push(face.name.take());
1134 face.name = Some(format!("{SOURCE_TOKEN}{kind}{}", names.len() - 1));
1135 }
1136 (copy, names)
1137}
1138
1139struct FaceSources {
1141 part: Vec<Option<String>>,
1142 rib: Vec<String>,
1143}
1144
1145impl FaceSources {
1146 fn parse(name: &str) -> Option<(char, usize)> {
1149 let mut chars = name.strip_prefix(SOURCE_TOKEN)?.chars();
1150 let kind = chars.next()?;
1151 let digits: String = chars.take_while(|c| c.is_ascii_digit()).collect();
1152 Some((kind, digits.parse().ok()?))
1153 }
1154
1155 fn name(&self, kind: char, index: usize) -> Option<&str> {
1156 match kind {
1157 TARGET_TOKEN => self.part.get(index)?.as_deref(),
1158 RIB_TOKEN => self.rib.get(index).map(String::as_str),
1159 _ => None,
1160 }
1161 }
1162
1163 fn detokenise(&self, text: &str) -> String {
1165 let mut out = String::with_capacity(text.len());
1166 let mut chars = text.chars().peekable();
1167 while let Some(c) = chars.next() {
1168 if c != SOURCE_TOKEN {
1169 out.push(c);
1170 continue;
1171 }
1172 let kind = chars.next();
1173 let mut digits = String::new();
1174 while let Some(digit) = chars.peek().copied().filter(char::is_ascii_digit) {
1175 digits.push(digit);
1176 chars.next();
1177 }
1178 match (kind, digits.parse::<usize>()) {
1179 (Some(kind), Ok(index)) => out.push_str(self.name(kind, index).unwrap_or("<unnamed>")),
1180 _ => {
1181 out.extend(kind);
1182 out.push_str(&digits);
1183 }
1184 }
1185 }
1186 out
1187 }
1188}
1189
1190struct RibFrame {
1194 origin: Vec3,
1195 thickness: Vec3,
1196 chord: Vec3,
1197 growth: Vec3,
1198 quantum: f64,
1201}
1202
1203fn restamp_rib_faces(fused: &mut BrepSolid, sources: &FaceSources, feature: &str, frame: &RibFrame) {
1224 use std::collections::{BTreeMap, HashMap};
1225 let points: HashMap<u64, Vec3> = fused.vertices.iter().map(|vertex| (vertex.id, vertex.point)).collect();
1226 let ends: HashMap<u64, (u64, u64)> = fused
1227 .edges
1228 .iter()
1229 .map(|edge| (edge.id, (edge.start_vertex_id, edge.end_vertex_id)))
1230 .collect();
1231 let quantise = |value: f64| (value / frame.quantum).round() as i64;
1232 let mut groups: BTreeMap<(char, usize), Vec<((i64, i64, i64), (usize, usize))>> = BTreeMap::new();
1234 for (shell_index, shell) in fused.shells.iter().enumerate() {
1235 for (face_index, face) in shell.faces.iter().enumerate() {
1236 let Some(source) = face.name.as_deref().and_then(FaceSources::parse) else {
1237 continue;
1238 };
1239 let mut ids: Vec<u64> = face
1240 .loops
1241 .iter()
1242 .flat_map(|loop_record| &loop_record.coedges)
1243 .filter_map(|coedge| ends.get(&coedge.edge_id))
1244 .flat_map(|(start, end)| [*start, *end])
1245 .collect();
1246 ids.sort_unstable();
1247 ids.dedup();
1248 let mut centre = Vec3::default();
1249 for id in &ids {
1250 if let Some(point) = points.get(id) {
1251 centre = centre.add(*point);
1252 }
1253 }
1254 let centre = centre.scale(1.0 / ids.len().max(1) as f64).sub(frame.origin);
1255 let key = (
1256 quantise(centre.dot(frame.thickness)),
1257 quantise(centre.dot(frame.chord)),
1258 quantise(centre.dot(frame.growth)),
1259 );
1260 groups.entry(source).or_default().push((key, (shell_index, face_index)));
1261 }
1262 }
1263 for ((kind, index), mut fragments) in groups {
1264 let original = sources.name(kind, index).map(str::to_string);
1265 let side = |key: &(i64, i64, i64)| match key.0.signum() {
1266 -1 => ('A', 0),
1267 0 => ('M', 1),
1268 _ => ('B', 2),
1269 };
1270 let named: Vec<((usize, usize), Option<String>)> = match &original {
1271 None => fragments.into_iter().map(|(_, at)| (at, None)).collect(),
1272 Some(name) if fragments.len() == 1 => vec![(fragments[0].1, Some(name.clone()))],
1273 Some(name) if kind == RIB_TOKEN => {
1274 fragments.sort_by_key(|(key, _)| (key.1, key.2, key.0));
1275 fragments
1276 .into_iter()
1277 .enumerate()
1278 .map(|(rank, (_, at))| {
1279 (at, Some(if rank == 0 { name.clone() } else { format!("{name}_{}", rank + 1) }))
1280 })
1281 .collect()
1282 }
1283 Some(name) => {
1284 fragments.sort_by_key(|(key, _)| (side(key).1, key.1, key.2, key.0));
1285 let mut per_side: BTreeMap<char, usize> = BTreeMap::new();
1286 fragments
1287 .into_iter()
1288 .enumerate()
1289 .map(|(rank, (key, at))| {
1290 let letter = side(&key).0;
1291 let ordinal = per_side.entry(letter).or_insert(0);
1292 *ordinal += 1;
1293 let spelled = if rank == 0 {
1294 name.clone()
1295 } else {
1296 format!("{feature}:{name}_{letter}{ordinal}")
1297 };
1298 (at, Some(spelled))
1299 })
1300 .collect()
1301 }
1302 };
1303 for ((shell_index, face_index), name) in named {
1304 fused.shells[shell_index].faces[face_index].name = name;
1305 }
1306 }
1307}