1use super::*;
2
3pub(super) fn ruled_between(bottom: &NurbsCurve, top: &NurbsCurve) -> Result<NurbsSurface, String> {
8 if bottom.degree != top.degree
9 || bottom.control_points.len() != top.control_points.len()
10 || bottom.knots.len() != top.knots.len()
11 {
12 return Err("ruled_between: rows are not representation-compatible".into());
13 }
14 let grid = bottom
15 .control_points
16 .iter()
17 .zip(&top.control_points)
18 .map(|(a, b)| vec![*a, *b])
19 .collect();
20 NurbsSurface::new(
21 bottom.degree,
22 1,
23 bottom.knots.clone(),
24 vec![0.0, 0.0, 1.0, 1.0],
25 grid,
26 )
27}
28
29pub(super) fn arc_window_subrange(row: &NurbsCurve, start: Vec3, end: Vec3) -> Result<NurbsCurve, String> {
34 let [d0, d1] = row.domain()?;
35 let span = d1 - d0;
36 let u0 = project_point_to_curve(row, start)?.u;
37 let u1 = project_point_to_curve(row, end)?.u;
38 if u1 <= u0 + 1e-12 {
39 return Err("draftExtrude: a drafted arc's boundary trim inverted".into());
40 }
41 let epsilon = span * 1e-9;
42 let mut current = row.clone();
43 if u0 > d0 + epsilon {
44 current = current.split(u0)?.1;
45 }
46 let domain = current.domain()?;
47 if u1 < domain[1] - epsilon && u1 > domain[0] + epsilon {
48 current = current.split(u1)?.0;
49 }
50 Ok(current)
51}
52
53fn wall_gradient(
59 seg: &SegGeom,
60 point: Vec3,
61 zh: Vec3,
62 height: f64,
63 signed_d: f64,
64) -> Result<Vec3, String> {
65 match seg {
66 SegGeom::Line { dir, normal, .. } => dir
67 .cross(normal.scale(signed_d).add(zh.scale(height)))
68 .normalized(),
69 SegGeom::Arc {
70 center, turn, ..
71 } => {
72 let rel = point.sub(*center);
73 let radial = rel.sub(zh.scale(rel.dot(zh)));
74 let rho = radial.normalized()?;
75 rho.add(zh.scale(signed_d * turn / height)).normalized()
76 }
77 }
78}
79
80#[allow(clippy::too_many_arguments)]
90pub(super) fn junction_edge_curve(
91 prev: &SegGeom,
92 next: &SegGeom,
93 a: Vec3,
94 m: Vec3,
95 b: Vec3,
96 zh: Vec3,
97 height: f64,
98 signed_d: f64,
99) -> Result<NurbsCurve, String> {
100 let chord = b.sub(a);
101 let length = chord.length();
102 if length <= 1e-12 {
103 return Err("draftExtrude: a junction edge collapsed to a point".into());
104 }
105 let along = m.sub(a).dot(chord) / (length * length);
106 let deviation = m.sub(a).sub(chord.scale(along)).length();
107 if deviation <= length * 1e-9 {
108 return make_line(a, b);
109 }
110 let e1 = chord.scale(1.0 / length);
112 let plane_normal = chord.cross(m.sub(a)).normalized()?;
113 let e2 = plane_normal.cross(e1).normalized()?;
114 let orient = |tangent: Vec3| {
115 if tangent.dot(zh) < 0.0 {
116 tangent.scale(-1.0)
117 } else {
118 tangent
119 }
120 };
121 let t0 = orient(wall_gradient(prev, a, zh, height, signed_d)?
122 .cross(wall_gradient(next, a, zh, height, signed_d)?));
123 let t2 = orient(wall_gradient(prev, b, zh, height, signed_d)?
124 .cross(wall_gradient(next, b, zh, height, signed_d)?));
125 let d0 = (t0.dot(e1), t0.dot(e2));
126 let d2 = (t2.dot(e1), t2.dot(e2));
127 let denom = d0.0 * d2.1 - d0.1 * d2.0;
128 let scale0 = d0.0.hypot(d0.1);
129 let scale2 = d2.0.hypot(d2.1);
130 if denom.abs() <= 1e-14 * scale0 * scale2 {
131 return Err("draftExtrude: junction end tangents are parallel — no conic apex".into());
132 }
133 let s = length * d2.1 / denom;
135 let apex = (s * d0.0, s * d0.1);
136 if apex.1.abs() <= f64::EPSILON * length {
137 return Err("draftExtrude: junction conic apex is degenerate".into());
138 }
139 let mq = (m.sub(a).dot(e1), m.sub(a).dot(e2));
141 let beta = mq.1 / apex.1;
142 let gamma = (mq.0 - beta * apex.0) / length;
143 let alpha = 1.0 - beta - gamma;
144 if !(alpha > 0.0 && beta > 0.0 && gamma > 0.0) {
145 return Err(format!(
146 "draftExtrude: junction conic witness fell outside its control triangle \
147 (α={alpha:.3e} β={beta:.3e} γ={gamma:.3e})"
148 ));
149 }
150 let weight = beta / (2.0 * (alpha * gamma).sqrt());
151 let apex_3d = a.add(e1.scale(apex.0)).add(e2.scale(apex.1));
152 NurbsCurve::new(
153 2,
154 vec![0.0, 0.0, 0.0, 1.0, 1.0, 1.0],
155 vec![
156 Vec4::from_point(a, 1.0),
157 Vec4::from_point(apex_3d, weight),
158 Vec4::from_point(b, 1.0),
159 ],
160 )
161}
162
163fn circumcircle(a: Vec3, b: Vec3, c: Vec3, ex: Vec3, ey: Vec3, np: Vec3) -> Option<(Vec3, f64)> {
166 let (ax, ay) = (a.dot(ex), a.dot(ey));
167 let (bx, by) = (b.dot(ex), b.dot(ey));
168 let (cx, cy) = (c.dot(ex), c.dot(ey));
169 let d = 2.0 * (ax * (by - cy) + bx * (cy - ay) + cx * (ay - by));
170 if d.abs() < 1e-12 {
171 return None;
172 }
173 let a2 = ax * ax + ay * ay;
174 let b2 = bx * bx + by * by;
175 let c2 = cx * cx + cy * cy;
176 let ux = (a2 * (by - cy) + b2 * (cy - ay) + c2 * (ay - by)) / d;
177 let uy = (a2 * (cx - bx) + b2 * (ax - cx) + c2 * (bx - ax)) / d;
178 let plane_off = a.dot(np);
179 let center = ex.scale(ux).add(ey.scale(uy)).add(np.scale(plane_off));
180 let radius = a.sub(center).length();
181 Some((center, radius))
182}
183
184fn intersect_offset_line_circle(
188 line_point: Vec3,
189 line_dir: Vec3,
190 center: Vec3,
191 radius: f64,
192 near: Vec3,
193) -> Result<Vec3, String> {
194 let dir = line_dir.normalized()?;
195 let f = line_point.sub(center);
196 let b = f.dot(dir);
197 let c = f.dot(f) - radius * radius;
198 let disc = b * b - c;
199 if disc < -1e-9 {
200 return Err("an offset line and arc no longer meet (offset too large)".into());
201 }
202 let root = disc.max(0.0).sqrt();
203 let p1 = line_point.add(dir.scale(-b + root));
204 let p2 = line_point.add(dir.scale(-b - root));
205 Ok(if p1.sub(near).length() <= p2.sub(near).length() {
206 p1
207 } else {
208 p2
209 })
210}
211
212fn intersect_offset_circles(
216 c1: Vec3,
217 r1: f64,
218 c2: Vec3,
219 r2: f64,
220 plane_normal: Vec3,
221 near: Vec3,
222) -> Result<Vec3, String> {
223 let between = c2.sub(c1);
224 let d = between.length();
225 if d < 1e-9 {
226 return Err("concentric offset arcs do not meet".into());
227 }
228 let axis = between.scale(1.0 / d);
229 let a = (d * d + r1 * r1 - r2 * r2) / (2.0 * d);
230 let h2 = r1 * r1 - a * a;
231 if h2 < -1e-9 {
232 return Err("offset arcs no longer meet (offset too large)".into());
233 }
234 let h = h2.max(0.0).sqrt();
235 let base = c1.add(axis.scale(a));
236 let perp = plane_normal.cross(axis).normalized()?;
237 let p1 = base.add(perp.scale(h));
238 let p2 = base.sub(perp.scale(h));
239 Ok(if p1.sub(near).length() <= p2.sub(near).length() {
240 p1
241 } else {
242 p2
243 })
244}
245
246pub(super) enum SegGeom {
250 Line {
251 start: Vec3,
252 end: Vec3,
253 dir: Vec3,
255 normal: Vec3,
257 },
258 Arc {
259 center: Vec3,
260 radius: f64,
261 turn: f64,
264 arc_normal: Vec3,
266 start: Vec3,
267 end: Vec3,
268 },
269}
270
271impl SegGeom {
272 fn start(&self) -> Vec3 {
273 match self {
274 SegGeom::Line { start, .. } | SegGeom::Arc { start, .. } => *start,
275 }
276 }
277
278 fn end(&self) -> Vec3 {
279 match self {
280 SegGeom::Line { end, .. } | SegGeom::Arc { end, .. } => *end,
281 }
282 }
283
284 fn offset_point(&self, point: Vec3, signed_d: f64) -> Result<Vec3, String> {
288 match self {
289 SegGeom::Line { normal, .. } => Ok(point.add(normal.scale(signed_d))),
290 SegGeom::Arc {
291 center,
292 radius,
293 turn,
294 ..
295 } => {
296 let r_offset = radius - signed_d * turn;
297 if r_offset <= 1e-6 {
298 return Err("offset: distance is too large — a concave arc collapses".into());
299 }
300 Ok(center.add(point.sub(*center).scale(r_offset / radius)))
301 }
302 }
303 }
304}
305
306pub(super) fn classify_profile_segments(
312 profile: &[NurbsCurve],
313 plane_normal: Vec3,
314) -> Result<Vec<SegGeom>, String> {
315 let tol = 1e-6;
316 if profile.is_empty() {
317 return Err("offset: profile has no segments".into());
318 }
319 let np = plane_normal.normalized()?;
320 let ex = np.perpendicular()?;
321 let ey = np.cross(ex).normalized()?;
322 let mut segs = Vec::with_capacity(profile.len());
323 for curve in profile {
324 let [t0, t1] = curve.domain()?;
325 let start = curve.evaluate(t0)?;
326 let end = curve.evaluate(t1)?;
327 let chord = end.sub(start);
328 let chord_len = chord.length();
329 if chord_len <= tol {
330 return Err("offset: profile has a degenerate (zero-length) segment".into());
331 }
332 let dir = chord.scale(1.0 / chord_len);
333 let mut is_line = true;
335 for k in 1..8 {
336 let point = curve.evaluate(t0 + (t1 - t0) * k as f64 / 8.0)?;
337 let rel = point.sub(start);
338 let perpendicular = rel.sub(dir.scale(rel.dot(dir))).length();
339 if perpendicular > tol * 10.0 {
340 is_line = false;
341 break;
342 }
343 }
344 if is_line {
345 segs.push(SegGeom::Line {
346 start,
347 end,
348 dir,
349 normal: np.cross(dir).normalized()?,
350 });
351 continue;
352 }
353 let mid = curve.evaluate((t0 + t1) * 0.5)?;
355 let (center, radius) = circumcircle(start, mid, end, ex, ey, np).ok_or_else(|| {
356 "offset: only straight lines and circular arcs are supported".to_string()
357 })?;
358 for k in 0..=8 {
359 let point = curve.evaluate(t0 + (t1 - t0) * k as f64 / 8.0)?;
360 if (point.sub(center).length() - radius).abs() > tol * 10.0 {
361 return Err("offset: only straight lines and circular arcs are supported".into());
362 }
363 }
364 let bend = np.dot(mid.sub(start).cross(end.sub(mid)));
366 let (arc_normal, turn) = if bend >= 0.0 {
367 (np, 1.0)
368 } else {
369 (np.scale(-1.0), -1.0)
370 };
371 segs.push(SegGeom::Arc {
372 center,
373 radius,
374 turn,
375 arc_normal,
376 start,
377 end,
378 });
379 }
380 Ok(segs)
381}
382
383pub(super) fn offset_junction(
394 prev: &SegGeom,
395 next: &SegGeom,
396 plane_normal: Vec3,
397 signed_d: f64,
398) -> Result<Vec3, String> {
399 let vertex = prev.end();
400 if signed_d == 0.0 {
401 return Ok(vertex);
402 }
403 let prev_offset = prev.offset_point(prev.end(), signed_d)?;
404 let next_offset = next.offset_point(next.start(), signed_d)?;
405 if prev_offset.sub(next_offset).length() <= 1e-6 {
406 return Ok(prev_offset.add(next_offset).scale(0.5));
407 }
408 match (prev, next) {
409 (SegGeom::Line { normal: na, .. }, SegGeom::Line { normal: nb, .. }) => {
410 let denom = 1.0 + na.dot(*nb);
411 if denom.abs() < 1e-6 {
412 return Err("offset: degenerate (near-reversal) polyline corner".into());
413 }
414 Ok(vertex.add(na.add(*nb).scale(signed_d / denom)))
415 }
416 (
417 SegGeom::Line { dir, .. },
418 SegGeom::Arc {
419 center,
420 radius,
421 turn,
422 ..
423 },
424 ) => intersect_offset_line_circle(
425 prev_offset,
426 *dir,
427 *center,
428 radius - signed_d * turn,
429 vertex,
430 ),
431 (
432 SegGeom::Arc {
433 center,
434 radius,
435 turn,
436 ..
437 },
438 SegGeom::Line { dir, .. },
439 ) => intersect_offset_line_circle(
440 next_offset,
441 *dir,
442 *center,
443 radius - signed_d * turn,
444 vertex,
445 ),
446 (
447 SegGeom::Arc {
448 center: c1,
449 radius: r1,
450 turn: turn1,
451 ..
452 },
453 SegGeom::Arc {
454 center: c2,
455 radius: r2,
456 turn: turn2,
457 ..
458 },
459 ) => intersect_offset_circles(
460 *c1,
461 r1 - signed_d * turn1,
462 *c2,
463 r2 - signed_d * turn2,
464 plane_normal,
465 vertex,
466 ),
467 }
468}
469
470fn offset_profile_segments(
481 profile: &[NurbsCurve],
482 plane_normal: Vec3,
483 signed_d: f64,
484 closed: bool,
485) -> Result<Vec<NurbsCurve>, String> {
486 let tol = 1e-6;
487 let np = plane_normal.normalized()?;
488 let segs = classify_profile_segments(profile, np)?;
489 let n = segs.len();
490
491 let mut offsets: Vec<(Vec3, Vec3)> = segs
493 .iter()
494 .map(|seg| {
495 Ok((
496 seg.offset_point(seg.start(), signed_d)?,
497 seg.offset_point(seg.end(), signed_d)?,
498 ))
499 })
500 .collect::<Result<_, String>>()?;
501 let junctions = if closed { n } else { n.saturating_sub(1) };
502 for i in 0..junctions {
503 let j = (i + 1) % n;
504 let point = offset_junction(&segs[i], &segs[j], np, signed_d)?;
505 offsets[i].1 = point;
506 offsets[j].0 = point;
507 }
508
509 let mut out = Vec::with_capacity(n);
511 for (seg, (off_start, off_end)) in segs.iter().zip(&offsets) {
512 match seg {
513 SegGeom::Line { .. } => out.push(make_line(*off_start, *off_end)?),
514 SegGeom::Arc {
515 center, arc_normal, ..
516 } => {
517 let radial = off_start.sub(*center);
518 let r2 = radial.length();
519 if r2 <= tol {
520 return Err("offset: reconstructed arc has a zero radius".into());
521 }
522 let ax = radial.scale(1.0 / r2);
523 let ay = arc_normal.cross(ax).normalized()?;
524 let ve = off_end.sub(*center);
525 let mut angle = ve.dot(ay).atan2(ve.dot(ax));
526 if angle <= 1e-9 {
527 angle += std::f64::consts::TAU;
528 }
529 out.push(make_arc(*center, ax, ay, r2, 0.0, angle)?);
530 }
531 }
532 }
533 Ok(out)
534}
535
536pub fn rib_from_profile(
546 solid: &BrepSolid,
547 profile: &[NurbsCurve],
548 thickness: f64,
549 extrude_dir: Vec3,
550 depth: f64,
551 name: Option<&str>,
552) -> Result<BrepSolid, String> {
553 let _ = name;
556 let tolerance = 1e-6;
557 if profile.is_empty() {
558 return Err("rib: profile needs at least 1 curve forming an open chain".into());
559 }
560 if !(thickness > 0.0) {
561 return Err("rib: thickness must be positive".into());
562 }
563 if !(depth > 0.0) {
564 return Err("rib: depth must be positive".into());
565 }
566 let extrude_axis = extrude_dir
567 .normalized()
568 .map_err(|_| "rib: extrude direction is degenerate".to_string())?;
569
570 let mut vertices = Vec::with_capacity(profile.len() + 1);
573 let mut samples = Vec::new();
574 for (index, curve) in profile.iter().enumerate() {
575 let [start, end] = curve.domain()?;
576 let v_start = curve.evaluate(start)?;
577 let v_end = curve.evaluate(end)?;
578 if v_end.sub(v_start).length() <= tolerance {
579 return Err("rib: profile has a degenerate (zero-length) segment".into());
580 }
581 if index == 0 {
582 vertices.push(v_start);
583 } else if v_start.sub(*vertices.last().unwrap()).length() > tolerance {
584 return Err(format!(
585 "rib: profile chain is not connected at curve {index}"
586 ));
587 }
588 vertices.push(v_end);
589 let first_k = if index == 0 { 0 } else { 1 };
593 for k in first_k..=8 {
594 samples.push(curve.evaluate(start + (end - start) * k as f64 / 8.0)?);
595 }
596 }
597 let count = vertices.len();
598 if count < 2 {
599 return Err("rib: profile needs at least 2 distinct vertices".into());
600 }
601
602 if vertices[count - 1].sub(vertices[0]).length() <= tolerance {
605 return Err("rib: profile chain is closed; rib expects an open chain".into());
606 }
607
608 let mut normal = Vec3::default();
612 for i in 1..samples.len() - 1 {
613 let a = samples[i].sub(samples[i - 1]);
614 let b = samples[i + 1].sub(samples[i]);
615 normal = normal.add(a.cross(b));
616 }
617 let np = normal.normalized().map_err(|_| {
618 "rib: profile is collinear; cannot determine its plane (documented follow-up)".to_string()
619 })?;
620 let origin = vertices[0];
621 if samples
622 .iter()
623 .any(|point| point.sub(origin).dot(np).abs() > tolerance * 100.0)
624 {
625 return Err("rib: profile is not planar".into());
626 }
627
628 let half = thickness * 0.5;
633 let left = offset_profile_segments(profile, np, half, false)
634 .map_err(|error| format!("rib: {error}"))?;
635 let right = offset_profile_segments(profile, np, -half, false)
636 .map_err(|error| format!("rib: {error}"))?;
637 let left_first = &left[0];
638 let left_last = &left[left.len() - 1];
639 let right_first = &right[0];
640 let right_last = &right[right.len() - 1];
641 let left_start = left_first.evaluate(left_first.domain()?[0])?;
642 let left_end = left_last.evaluate(left_last.domain()?[1])?;
643 let right_start = right_first.evaluate(right_first.domain()?[0])?;
644 let right_end = right_last.evaluate(right_last.domain()?[1])?;
645 let mut thin_loop: Vec<NurbsCurve> = Vec::with_capacity(left.len() + right.len() + 2);
646 for curve in &left {
647 thin_loop.push(curve.clone());
648 }
649 thin_loop.push(make_line(left_end, right_end)?);
650 for curve in right.iter().rev() {
651 thin_loop.push(curve.reversed()?);
652 }
653 thin_loop.push(make_line(right_start, left_start)?);
654
655 let rib_body = extrude_profile_brep(&thin_loop, extrude_axis, depth)
657 .map_err(|error| format!("rib: extrude of the thickened profile failed: {error}"))?;
658 boolean_operation(
659 solid,
660 &rib_body,
661 BooleanOperation::Union,
662 &BooleanOptions::default(),
663 )
664 .map_err(|error| format!("rib: union of the rib into the part failed: {error}"))
665}