1use crate::offset::offset_surface;
28use crate::sweep_topology::parameter_line;
29use crate::topology::{
30 BrepSolid, CoedgeRecord, EdgeRecord, FaceRecord, LoopRecord, ShellRecord, VertexRecord,
31};
32use crate::{make_line, NurbsCurve, NurbsSurface, Vec3, Vec4};
33
34fn principal_curvatures(surface: &NurbsSurface, u: f64, v: f64) -> Result<(f64, f64), String> {
41 let derivatives = surface.derivatives(u, v, 2)?;
42 let su = derivatives[1][0];
43 let sv = derivatives[0][1];
44 let cross = su.cross(sv);
45 let cross_length = cross.length();
46 if cross_length <= 1e-12 {
47 return Err(format!(
48 "thickenSheet: degenerate parametrization at (u={u:.4}, v={v:.4})"
49 ));
50 }
51 let normal = cross.scale(1.0 / cross_length);
52 let e1 = su.dot(su);
53 let f1 = su.dot(sv);
54 let g1 = sv.dot(sv);
55 let l2 = derivatives[2][0].dot(normal);
56 let m2 = derivatives[1][1].dot(normal);
57 let n2 = derivatives[0][2].dot(normal);
58 let denominator = e1 * g1 - f1 * f1;
59 let mean_double = (l2 * g1 - 2.0 * m2 * f1 + n2 * e1) / denominator; let gauss = (l2 * n2 - m2 * m2) / denominator; let discriminant = (mean_double * mean_double * 0.25 - gauss).max(0.0).sqrt();
62 Ok((
63 mean_double * 0.5 - discriminant,
64 mean_double * 0.5 + discriminant,
65 ))
66}
67
68fn ensure_offsets_regular(surface: &NurbsSurface, distances: &[f64]) -> Result<(), String> {
73 let [u0, u1] = surface.domain_u()?;
74 let [v0, v1] = surface.domain_v()?;
75 const SAMPLES: usize = 33;
76 for i in 0..SAMPLES {
77 let u = u0 + (u1 - u0) * i as f64 / (SAMPLES - 1) as f64;
78 for j in 0..SAMPLES {
79 let v = v0 + (v1 - v0) * j as f64 / (SAMPLES - 1) as f64;
80 let (kappa_min, kappa_max) = principal_curvatures(surface, u, v)?;
81 for &distance in distances {
82 if distance == 0.0 {
83 continue;
84 }
85 for kappa in [kappa_min, kappa_max] {
86 let factor = 1.0 - distance * kappa;
87 if factor <= 1e-6 {
88 let radius = 1.0 / kappa.abs().max(1e-300);
89 return Err(format!(
90 "thickenSheet: offset by {distance:.6} self-intersects — the \
91 sheet's concave curvature radius {radius:.6} at (u={u:.4}, \
92 v={v:.4}) is not larger than the offset distance"
93 ));
94 }
95 }
96 }
97 }
98 }
99 Ok(())
100}
101
102fn offset_sheet(surface: &NurbsSurface, distance: f64) -> Result<NurbsSurface, String> {
106 if distance == 0.0 {
107 return Ok(surface.clone());
108 }
109 let carrier = FaceRecord {
110 id: 1,
111 surface: surface.clone(),
112 same_sense: true,
113 loops: vec![],
114 name: None,
115 };
116 offset_surface(&carrier, -distance, 0.0)
117}
118
119fn ruled_wall(bottom: &NurbsCurve, top: &NurbsCurve) -> Result<NurbsSurface, String> {
125 if bottom.degree != top.degree
126 || bottom.knots.len() != top.knots.len()
127 || bottom
128 .knots
129 .iter()
130 .zip(&top.knots)
131 .any(|(a, b)| (a - b).abs() > 1e-12)
132 || bottom
133 .control_points
134 .iter()
135 .zip(&top.control_points)
136 .any(|(a, b)| (a.w - b.w).abs() > 1e-9)
137 {
138 return Err("thickenSheet: internal error — offset sheet basis mismatch".into());
139 }
140 let rows = bottom
141 .control_points
142 .iter()
143 .zip(&top.control_points)
144 .map(|(b, t)| vec![*b, *t])
145 .collect();
146 NurbsSurface::new(
147 bottom.degree,
148 1,
149 bottom.knots.clone(),
150 vec![0.0, 0.0, 1.0, 1.0],
151 rows,
152 )
153}
154
155const GAUSS_X: [f64; 8] = [
156 -0.9602898564975363,
157 -0.7966664774136267,
158 -0.525532409916329,
159 -0.18343464249564978,
160 0.18343464249564978,
161 0.525532409916329,
162 0.7966664774136267,
163 0.9602898564975363,
164];
165const GAUSS_W: [f64; 8] = [
166 0.10122853629037669,
167 0.22238103445337445,
168 0.31370664587788727,
169 0.362683783378362,
170 0.362683783378362,
171 0.31370664587788727,
172 0.22238103445337445,
173 0.10122853629037669,
174];
175
176fn pcurve_signed_area(curve: &NurbsCurve) -> Result<f64, String> {
181 let [q0, q1] = curve.domain()?;
182 let mut breaks = vec![q0];
183 for &knot in &curve.knots {
184 if knot > q0 + 1e-12 && knot < q1 - 1e-12 && (knot - breaks[breaks.len() - 1]).abs() > 1e-12
185 {
186 breaks.push(knot);
187 }
188 }
189 breaks.push(q1);
190 let mut area = 0.0;
191 for pair in breaks.windows(2) {
192 let half = (pair[1] - pair[0]) * 0.5;
193 let middle = (pair[1] + pair[0]) * 0.5;
194 for index in 0..GAUSS_X.len() {
195 let derivatives = curve.derivatives(middle + half * GAUSS_X[index], 1)?;
196 let point = derivatives[0];
197 let tangent = derivatives[1];
198 area += GAUSS_W[index] * half * 0.5 * (point.x * tangent.y - point.y * tangent.x);
199 }
200 }
201 Ok(area)
202}
203
204fn planar_gap(first: Vec3, second: Vec3) -> f64 {
207 let du = first.x - second.x;
208 let dv = first.y - second.y;
209 (du * du + dv * dv).sqrt()
210}
211
212struct BoundaryImages {
216 bottom: NurbsCurve,
217 top: NurbsCurve,
218 t0: f64,
219 t1: f64,
220 dir: bool,
222}
223
224fn affine_image_curve(sheet: &NurbsSurface, pcurve: &NurbsCurve) -> Result<NurbsCurve, String> {
230 let [u0, _] = sheet.domain_u()?;
231 let [v0, _] = sheet.domain_v()?;
232 let frame = sheet.derivatives(u0, v0, 1)?;
233 let origin = frame[0][0];
234 let du = frame[1][0];
235 let dv = frame[0][1];
236 let control_points = pcurve
237 .control_points
238 .iter()
239 .map(|control| {
240 let position = origin
241 .scale(control.w)
242 .add(du.scale(control.x - control.w * u0))
243 .add(dv.scale(control.y - control.w * v0));
244 Vec4 {
245 x: position.x,
246 y: position.y,
247 z: position.z,
248 w: control.w,
249 }
250 })
251 .collect();
252 NurbsCurve::new(pcurve.degree, pcurve.knots.clone(), control_points)
253}
254
255fn boundary_images(
265 base_affine: bool,
266 bottom: &NurbsSurface,
267 top: &NurbsSurface,
268 pcurve: &NurbsCurve,
269 eps_u: f64,
270 eps_v: f64,
271) -> Result<BoundaryImages, String> {
272 if base_affine {
273 let [q0, q1] = pcurve.domain()?;
274 return Ok(BoundaryImages {
275 bottom: affine_image_curve(bottom, pcurve)?,
276 top: affine_image_curve(top, pcurve)?,
277 t0: q0,
278 t1: q1,
279 dir: true,
280 });
281 }
282 if pcurve.degree == 1 && pcurve.control_points.len() == 2 {
283 let first = pcurve.control_points[0];
284 let second = pcurve.control_points[1];
285 if (first.w - second.w).abs() <= 1e-12 {
286 let (ua, va) = (first.x / first.w, first.y / first.w);
287 let (ub, vb) = (second.x / second.w, second.y / second.w);
288 if (ua - ub).abs() <= eps_u && (va - vb).abs() > eps_v {
289 let u_constant = (ua + ub) * 0.5;
290 return Ok(BoundaryImages {
291 bottom: bottom.iso_curve_u(u_constant)?,
292 top: top.iso_curve_u(u_constant)?,
293 t0: va.min(vb),
294 t1: va.max(vb),
295 dir: vb > va,
296 });
297 }
298 if (va - vb).abs() <= eps_v && (ua - ub).abs() > eps_u {
299 let v_constant = (va + vb) * 0.5;
300 return Ok(BoundaryImages {
301 bottom: bottom.iso_curve_v(v_constant)?,
302 top: top.iso_curve_v(v_constant)?,
303 t0: ua.min(ub),
304 t1: ua.max(ub),
305 dir: ub > ua,
306 });
307 }
308 }
309 }
310 Err(
311 "thickenSheet: pcurves on a curved sheet must be iso-parameter line segments \
312 (u = const or v = const) — general trims on curved sheets are not supported yet"
313 .into(),
314 )
315}
316
317pub fn thicken_trimmed_sheet(
346 surface: &NurbsSurface,
347 loops: &[Vec<NurbsCurve>],
348 thickness: f64,
349 symmetric: bool,
350) -> Result<BrepSolid, String> {
351 if !thickness.is_finite() || thickness.abs() <= 1e-12 {
352 return Err("thickenSheet: thickness must be a nonzero finite value".into());
353 }
354 if loops.is_empty() || loops.iter().any(|loop_curves| loop_curves.is_empty()) {
355 return Err("thickenSheet: at least one non-empty pcurve loop is required".into());
356 }
357 let (closed_u, closed_v) = surface.closed_directions()?;
358 if closed_u || closed_v {
359 return Err(
360 "thickenSheet: closed sheets are not supported (split the patch at its seam first)"
361 .into(),
362 );
363 }
364 let (distance_bottom, distance_top) = if symmetric {
365 (-thickness.abs() * 0.5, thickness.abs() * 0.5)
366 } else if thickness > 0.0 {
367 (0.0, thickness)
368 } else {
369 (thickness, 0.0)
370 };
371 ensure_offsets_regular(surface, &[distance_bottom, distance_top])?;
372
373 let bottom = offset_sheet(surface, distance_bottom)?;
374 let top = offset_sheet(surface, distance_top)?;
375 let [u0, u1] = surface.domain_u()?;
376 let [v0, v1] = surface.domain_v()?;
377 let uv_tolerance = 1e-7 * (u1 - u0).max(v1 - v0);
378 let minimum_area = 1e-10 * (u1 - u0) * (v1 - v0);
379 let eps_u = 1e-9 * (u1 - u0);
380 let eps_v = 1e-9 * (v1 - v0);
381 let base_affine = surface.is_affine()?;
382
383 let mut vertices: Vec<VertexRecord> = Vec::new();
384 let mut edges: Vec<EdgeRecord> = Vec::new();
385 let mut faces: Vec<FaceRecord> = Vec::new();
386 let mut top_cap_loops: Vec<LoopRecord> = Vec::new();
387 let mut bottom_cap_loops: Vec<LoopRecord> = Vec::new();
388 let mut bottom_junction_points: Vec<Vec3> = Vec::new();
389 let mut next_id = 1u64;
390
391 for (loop_index, loop_curves) in loops.iter().enumerate() {
392 let count = loop_curves.len();
393
394 let mut starts = Vec::with_capacity(count);
396 let mut ends = Vec::with_capacity(count);
397 for curve in loop_curves {
398 let [q0, q1] = curve.domain()?;
399 starts.push(curve.evaluate(q0)?);
400 ends.push(curve.evaluate(q1)?);
401 }
402 for index in 0..count {
403 let next_index = (index + 1) % count;
404 let gap = planar_gap(ends[index], starts[next_index]);
405 if gap > uv_tolerance {
406 return Err(format!(
407 "thickenSheet: loop {loop_index} is open — pcurve {index} ends at \
408 (u={:.6}, v={:.6}) but pcurve {next_index} starts at (u={:.6}, v={:.6}) \
409 (parameter-space gap {gap:.3e})",
410 ends[index].x, ends[index].y, starts[next_index].x, starts[next_index].y
411 ));
412 }
413 }
414 if count == 1 {
415 let [q0, q1] = loop_curves[0].domain()?;
416 let middle = loop_curves[0].evaluate((q0 + q1) * 0.5)?;
417 if planar_gap(middle, starts[0]) <= uv_tolerance {
418 return Err(format!(
419 "thickenSheet: loop {loop_index} is degenerate (zero parameter-space extent)"
420 ));
421 }
422 } else {
423 for index in 0..count {
424 if planar_gap(ends[index], starts[index]) <= uv_tolerance {
425 return Err(format!(
426 "thickenSheet: pcurve {index} of loop {loop_index} closes on itself \
427 inside a multi-curve loop (pinched loop)"
428 ));
429 }
430 }
431 }
432 let mut area = 0.0;
433 for curve in loop_curves {
434 area += pcurve_signed_area(curve)?;
435 }
436 if loop_index == 0 {
437 if area <= minimum_area {
438 return Err(format!(
439 "thickenSheet: outer loop must run counter-clockwise in (u, v) \
440 (signed area {area:.3e})"
441 ));
442 }
443 } else if area >= -minimum_area {
444 return Err(format!(
445 "thickenSheet: hole loop {loop_index} must run clockwise in (u, v) \
446 (signed area {area:.3e})"
447 ));
448 }
449
450 let mut bottom_vertex_ids = Vec::with_capacity(count);
452 let mut top_vertex_ids = Vec::with_capacity(count);
453 let mut bottom_points = Vec::with_capacity(count);
454 let mut top_points = Vec::with_capacity(count);
455 for start in &starts {
456 let bottom_point = bottom.evaluate(start.x, start.y)?;
457 let top_point = top.evaluate(start.x, start.y)?;
458 vertices.push(VertexRecord {
459 id: next_id,
460 point: bottom_point,
461 });
462 bottom_vertex_ids.push(next_id);
463 next_id += 1;
464 vertices.push(VertexRecord {
465 id: next_id,
466 point: top_point,
467 });
468 top_vertex_ids.push(next_id);
469 next_id += 1;
470 bottom_points.push(bottom_point);
471 top_points.push(top_point);
472 bottom_junction_points.push(bottom_point);
473 }
474
475 let mut images = Vec::with_capacity(count);
477 for curve in loop_curves {
478 images.push(boundary_images(
479 base_affine,
480 &bottom,
481 &top,
482 curve,
483 eps_u,
484 eps_v,
485 )?);
486 }
487 let mut bottom_edge_ids = Vec::with_capacity(count);
488 let mut top_edge_ids = Vec::with_capacity(count);
489 for (index, image) in images.iter().enumerate() {
490 let next_index = (index + 1) % count;
491 let (start_j, end_j) = if image.dir {
492 (index, next_index)
493 } else {
494 (next_index, index)
495 };
496 edges.push(EdgeRecord {
497 id: next_id,
498 curve: image.bottom.clone(),
499 t0: image.t0,
500 t1: image.t1,
501 start_vertex_id: bottom_vertex_ids[start_j],
502 end_vertex_id: bottom_vertex_ids[end_j],
503 degenerate: false,
504 name: None,
505 });
506 bottom_edge_ids.push(next_id);
507 next_id += 1;
508 edges.push(EdgeRecord {
509 id: next_id,
510 curve: image.top.clone(),
511 t0: image.t0,
512 t1: image.t1,
513 start_vertex_id: top_vertex_ids[start_j],
514 end_vertex_id: top_vertex_ids[end_j],
515 degenerate: false,
516 name: None,
517 });
518 top_edge_ids.push(next_id);
519 next_id += 1;
520 }
521 let mut vertical_edge_ids = Vec::with_capacity(count);
522 for junction in 0..count {
523 edges.push(EdgeRecord {
524 id: next_id,
525 curve: make_line(bottom_points[junction], top_points[junction])?,
526 t0: 0.0,
527 t1: 1.0,
528 start_vertex_id: bottom_vertex_ids[junction],
529 end_vertex_id: top_vertex_ids[junction],
530 degenerate: false,
531 name: None,
532 });
533 vertical_edge_ids.push(next_id);
534 next_id += 1;
535 }
536
537 for (index, image) in images.iter().enumerate() {
547 let next_index = (index + 1) % count;
548 let wall = ruled_wall(&image.bottom, &image.top)?;
549 let (s_start, s_end) = if image.dir {
550 (image.t0, image.t1)
551 } else {
552 (image.t1, image.t0)
553 };
554 let mut coedges = Vec::with_capacity(4);
555 for (edge_id, forward, pcurve) in [
556 (
557 bottom_edge_ids[index],
558 image.dir,
559 parameter_line(s_start, 0.0, s_end, 0.0)?,
560 ),
561 (
562 vertical_edge_ids[next_index],
563 true,
564 parameter_line(s_end, 0.0, s_end, 1.0)?,
565 ),
566 (
567 top_edge_ids[index],
568 !image.dir,
569 parameter_line(s_end, 1.0, s_start, 1.0)?,
570 ),
571 (
572 vertical_edge_ids[index],
573 false,
574 parameter_line(s_start, 1.0, s_start, 0.0)?,
575 ),
576 ] {
577 coedges.push(CoedgeRecord {
578 id: next_id,
579 edge_id,
580 forward,
581 pcurve,
582 });
583 next_id += 1;
584 }
585 let loop_id = next_id;
586 next_id += 1;
587 faces.push(FaceRecord {
588 id: next_id,
589 surface: wall,
590 same_sense: image.dir,
591 loops: vec![LoopRecord {
592 id: loop_id,
593 coedges,
594 }],
595 name: None,
596 });
597 next_id += 1;
598 }
599
600 let mut top_coedges = Vec::with_capacity(count);
602 for (index, image) in images.iter().enumerate() {
603 top_coedges.push(CoedgeRecord {
604 id: next_id,
605 edge_id: top_edge_ids[index],
606 forward: image.dir,
607 pcurve: loop_curves[index].clone(),
608 });
609 next_id += 1;
610 }
611 top_cap_loops.push(LoopRecord {
612 id: next_id,
613 coedges: top_coedges,
614 });
615 next_id += 1;
616 let mut bottom_coedges = Vec::with_capacity(count);
617 for index in (0..count).rev() {
618 bottom_coedges.push(CoedgeRecord {
619 id: next_id,
620 edge_id: bottom_edge_ids[index],
621 forward: !images[index].dir,
622 pcurve: loop_curves[index].reversed()?,
623 });
624 next_id += 1;
625 }
626 bottom_cap_loops.push(LoopRecord {
627 id: next_id,
628 coedges: bottom_coedges,
629 });
630 next_id += 1;
631 }
632
633 let scale = bottom_junction_points
637 .iter()
638 .fold(1.0f64, |value, point| value.max(point.length()));
639 for first in 0..bottom_junction_points.len() {
640 for second in first + 1..bottom_junction_points.len() {
641 if bottom_junction_points[first]
642 .sub(bottom_junction_points[second])
643 .length()
644 <= 1e-7 * scale
645 {
646 return Err(
647 "thickenSheet: sheet boundary is degenerate (coincident junction vertices)"
648 .into(),
649 );
650 }
651 }
652 }
653
654 faces.push(FaceRecord {
657 id: next_id,
658 surface: top,
659 same_sense: true,
660 loops: top_cap_loops,
661 name: None,
662 });
663 next_id += 1;
664 faces.push(FaceRecord {
665 id: next_id,
666 surface: bottom,
667 same_sense: false,
668 loops: bottom_cap_loops,
669 name: None,
670 });
671 next_id += 1;
672
673 let shell_id = next_id;
674 let solid = BrepSolid {
675 id: next_id + 1,
676 vertices,
677 edges,
678 shells: vec![ShellRecord {
679 id: shell_id,
680 faces,
681 }],
682 genus: loops.len() as i64 - 1,
683 };
684 let issues = solid.validate();
685 if !issues.is_empty() {
686 return Err(format!(
687 "thickenSheet: assembled solid failed validation: {issues:?}"
688 ));
689 }
690 let volume = crate::solid_signed_volume(&solid)?;
691 if volume <= 0.0 {
692 return Err(format!(
693 "thickenSheet: internal orientation error (signed volume {volume})"
694 ));
695 }
696 Ok(solid)
697}
698
699pub fn thicken_face_sheet(
710 surface: &NurbsSurface,
711 thickness: f64,
712 symmetric: bool,
713) -> Result<BrepSolid, String> {
714 if !thickness.is_finite() || thickness.abs() <= 1e-12 {
715 return Err("thickenSheet: thickness must be a nonzero finite value".into());
716 }
717 let [u0, u1] = surface.domain_u()?;
718 let [v0, v1] = surface.domain_v()?;
719 let rectangle = vec![
720 parameter_line(u0, v0, u1, v0)?,
721 parameter_line(u1, v0, u1, v1)?,
722 parameter_line(u1, v1, u0, v1)?,
723 parameter_line(u0, v1, u0, v0)?,
724 ];
725 thicken_trimmed_sheet(surface, &[rectangle], thickness, symmetric)
726}
727
728#[cfg(test)]
729mod tests {
730 use super::*;
731 use crate::{
732 make_circle, make_cylinder_surface, make_plane, make_revolution, solid_mass_properties,
733 Vec3,
734 };
735 use std::f64::consts::{FRAC_PI_2, PI};
736
737 fn quarter_cylinder(radius: f64, height: f64) -> NurbsSurface {
742 let generatrix =
743 crate::make_line(Vec3::new(radius, 0.0, 0.0), Vec3::new(radius, 0.0, height)).unwrap();
744 make_revolution(
745 Vec3::default(),
746 Vec3::new(0.0, 0.0, 1.0),
747 &generatrix,
748 FRAC_PI_2,
749 )
750 .unwrap()
751 }
752
753 fn z_range(solid: &BrepSolid) -> (f64, f64) {
754 solid
755 .vertices
756 .iter()
757 .fold((f64::INFINITY, f64::NEG_INFINITY), |(low, high), vertex| {
758 (low.min(vertex.point.z), high.max(vertex.point.z))
759 })
760 }
761
762 #[test]
765 fn planar_rectangle_thickens_to_exact_box() {
766 let sheet = make_plane(
767 Vec3::new(1.0, 2.0, 3.0),
768 Vec3::new(1.0, 0.0, 0.0),
769 Vec3::new(0.0, 1.0, 0.0),
770 4.0,
771 3.0,
772 )
773 .unwrap();
774 let solid = thicken_face_sheet(&sheet, 0.5, false).unwrap();
775 assert!(solid.validate().is_empty(), "{:?}", solid.validate());
776 assert_eq!(solid.vertices.len(), 8);
777 assert_eq!(solid.edges.len(), 12);
778 assert_eq!(solid.shells[0].faces.len(), 6);
779 assert_eq!(solid.genus, 0);
780 let volume = solid_mass_properties(&solid).unwrap().volume;
781 assert!(
782 (volume - 4.0 * 3.0 * 0.5).abs() < 1e-9,
783 "volume {volume} vs exact 6"
784 );
785 let (low, high) = z_range(&solid);
787 assert!((low - 3.0).abs() < 1e-12 && (high - 3.5).abs() < 1e-12);
788 }
789
790 #[test]
793 fn quarter_cylinder_patch_thickens_to_exact_shell_segment() {
794 let (radius, height, thickness) = (2.0, 5.0, 0.4);
795 let sheet = quarter_cylinder(radius, height);
796 let solid = thicken_face_sheet(&sheet, thickness, false).unwrap();
797 assert!(solid.validate().is_empty(), "{:?}", solid.validate());
798 assert_eq!(solid.vertices.len(), 8);
799 assert_eq!(solid.edges.len(), 12);
800 assert_eq!(solid.shells[0].faces.len(), 6);
801 let volume = solid_mass_properties(&solid).unwrap().volume;
802 let r_out = radius + thickness;
803 let expected = height * (FRAC_PI_2 / 2.0) * (r_out * r_out - radius * radius);
804 assert!(
805 (volume - expected).abs() < 1e-6 * expected,
806 "volume {volume} vs shell segment {expected}"
807 );
808 }
809
810 #[test]
814 fn symmetric_mode_splits_the_thickness_across_both_sides() {
815 let sheet = make_plane(
816 Vec3::new(1.0, 2.0, 3.0),
817 Vec3::new(1.0, 0.0, 0.0),
818 Vec3::new(0.0, 1.0, 0.0),
819 4.0,
820 3.0,
821 )
822 .unwrap();
823 let one_sided = thicken_face_sheet(&sheet, 0.5, false).unwrap();
824 let symmetric = thicken_face_sheet(&sheet, 0.5, true).unwrap();
825 assert!(
826 symmetric.validate().is_empty(),
827 "{:?}",
828 symmetric.validate()
829 );
830 let one_sided_volume = solid_mass_properties(&one_sided).unwrap().volume;
831 let symmetric_volume = solid_mass_properties(&symmetric).unwrap().volume;
832 assert!(
833 (one_sided_volume - symmetric_volume).abs() < 1e-9,
834 "{one_sided_volume} vs {symmetric_volume}"
835 );
836 let (low, high) = z_range(&symmetric);
838 assert!((low - 2.75).abs() < 1e-12 && (high - 3.25).abs() < 1e-12);
839
840 let (radius, height, thickness) = (2.0, 5.0, 0.4);
842 let shell = thicken_face_sheet(&quarter_cylinder(radius, height), thickness, true).unwrap();
843 assert!(shell.validate().is_empty(), "{:?}", shell.validate());
844 let volume = solid_mass_properties(&shell).unwrap().volume;
845 let r_in = radius - thickness / 2.0;
846 let r_out = radius + thickness / 2.0;
847 let expected = height * (FRAC_PI_2 / 2.0) * (r_out * r_out - r_in * r_in);
848 assert!(
849 (volume - expected).abs() < 1e-6 * expected,
850 "volume {volume} vs symmetric shell {expected}"
851 );
852 let radial = |point: Vec3| (point.x * point.x + point.y * point.y).sqrt();
855 for vertex in &shell.vertices {
856 let r = radial(vertex.point);
857 assert!(
858 (r - r_in).abs() < 1e-9 || (r - r_out).abs() < 1e-9,
859 "corner radius {r} is neither {r_in} nor {r_out}"
860 );
861 }
862 }
863
864 #[test]
870 fn refuses_thickness_beyond_the_concave_curvature_radius() {
871 let sheet = quarter_cylinder(2.0, 5.0);
872 let error = thicken_face_sheet(&sheet, -2.5, false).unwrap_err();
874 assert!(
875 error.contains("self-intersects"),
876 "unexpected refusal message: {error}"
877 );
878 assert!(thicken_face_sheet(&sheet, -2.0, false).is_err());
880 assert!(thicken_face_sheet(&sheet, 4.2, true).is_err());
882 let fat = thicken_face_sheet(&sheet, 3.0, true).unwrap();
884 let volume = solid_mass_properties(&fat).unwrap().volume;
885 let expected = 5.0 * (FRAC_PI_2 / 2.0) * (3.5f64 * 3.5 - 0.5 * 0.5);
886 assert!(
887 (volume - expected).abs() < 1e-6 * expected,
888 "volume {volume} vs fat shell {expected}"
889 );
890 let closed =
892 make_cylinder_surface(Vec3::default(), Vec3::new(0.0, 0.0, 1.0), 2.0, 5.0).unwrap();
893 let error = thicken_face_sheet(&closed, 0.5, false).unwrap_err();
894 assert!(error.contains("closed"), "unexpected message: {error}");
895 assert!(thicken_face_sheet(&sheet, 0.0, false).is_err());
897 }
898
899 fn rectangle_loop(width: f64, height: f64) -> Vec<NurbsCurve> {
902 vec![
903 parameter_line(0.0, 0.0, width, 0.0).unwrap(),
904 parameter_line(width, 0.0, width, height).unwrap(),
905 parameter_line(width, height, 0.0, height).unwrap(),
906 parameter_line(0.0, height, 0.0, 0.0).unwrap(),
907 ]
908 }
909
910 #[test]
914 fn planar_rectangle_with_circular_hole_thickens_to_washer_slab() {
915 let sheet = make_plane(
916 Vec3::new(1.0, 2.0, 3.0),
917 Vec3::new(1.0, 0.0, 0.0),
918 Vec3::new(0.0, 1.0, 0.0),
919 4.0,
920 3.0,
921 )
922 .unwrap();
923 let hole = make_circle(Vec3::new(2.0, 1.5, 0.0), Vec3::new(0.0, 0.0, -1.0), 0.8).unwrap();
925 let solid =
926 thicken_trimmed_sheet(&sheet, &[rectangle_loop(4.0, 3.0), vec![hole]], 0.5, false)
927 .unwrap();
928 assert!(solid.validate().is_empty(), "{:?}", solid.validate());
929 assert_eq!(solid.vertices.len(), 10);
932 assert_eq!(solid.edges.len(), 15);
933 assert_eq!(solid.shells[0].faces.len(), 7);
934 assert_eq!(solid.genus, 1, "one hole = one handle");
935 let volume = solid_mass_properties(&solid).unwrap().volume;
936 let expected = (4.0 * 3.0 - PI * 0.8 * 0.8) * 0.5;
937 assert!(
938 (volume - expected).abs() < 1e-6,
939 "volume {volume} vs washer slab {expected}"
940 );
941 let (low, high) = z_range(&solid);
943 assert!((low - 3.0).abs() < 1e-12 && (high - 3.5).abs() < 1e-12);
944 }
945
946 #[test]
950 fn planar_disk_thickens_to_exact_cylinder() {
951 let sheet = make_plane(
952 Vec3::new(-1.0, -2.0, 1.0),
953 Vec3::new(1.0, 0.0, 0.0),
954 Vec3::new(0.0, 1.0, 0.0),
955 4.0,
956 4.0,
957 )
958 .unwrap();
959 let disk = make_circle(Vec3::new(2.0, 2.0, 0.0), Vec3::new(0.0, 0.0, 1.0), 1.5).unwrap();
961 let solid = thicken_trimmed_sheet(&sheet, &[vec![disk]], 0.7, false).unwrap();
962 assert!(solid.validate().is_empty(), "{:?}", solid.validate());
963 assert_eq!(solid.vertices.len(), 2);
964 assert_eq!(solid.edges.len(), 3);
965 assert_eq!(solid.shells[0].faces.len(), 3);
966 assert_eq!(solid.genus, 0);
967 let volume = solid_mass_properties(&solid).unwrap().volume;
968 let expected = PI * 1.5 * 1.5 * 0.7;
969 assert!(
970 (volume - expected).abs() < 1e-6 * expected,
971 "volume {volume} vs cylinder {expected}"
972 );
973 }
974
975 #[test]
980 fn curved_sheet_sub_window_thickens_to_exact_shell_segment() {
981 let (radius, height, thickness) = (2.0, 5.0, 0.4);
982 let sheet = quarter_cylinder(radius, height);
983 let window = vec![
984 parameter_line(0.25, 0.2, 0.75, 0.2).unwrap(),
985 parameter_line(0.75, 0.2, 0.75, 0.9).unwrap(),
986 parameter_line(0.75, 0.9, 0.25, 0.9).unwrap(),
987 parameter_line(0.25, 0.9, 0.25, 0.2).unwrap(),
988 ];
989 let solid = thicken_trimmed_sheet(&sheet, &[window], thickness, false).unwrap();
990 assert!(solid.validate().is_empty(), "{:?}", solid.validate());
991 assert_eq!(solid.vertices.len(), 8);
992 assert_eq!(solid.edges.len(), 12);
993 assert_eq!(solid.shells[0].faces.len(), 6);
994 assert_eq!(solid.genus, 0);
995 let at = |u: f64| sheet.evaluate(u, 0.0).unwrap();
998 let sweep = at(0.75).y.atan2(at(0.75).x) - at(0.25).y.atan2(at(0.25).x);
999 let r_out = radius + thickness;
1000 let expected = (0.9 - 0.2) * height * (sweep / 2.0) * (r_out * r_out - radius * radius);
1001 let volume = solid_mass_properties(&solid).unwrap().volume;
1002 assert!(
1003 (volume - expected).abs() < 1e-6 * expected,
1004 "volume {volume} vs shell sub-segment {expected}"
1005 );
1006 for vertex in &solid.vertices {
1008 let r = (vertex.point.x * vertex.point.x + vertex.point.y * vertex.point.y).sqrt();
1009 assert!(
1010 (r - radius).abs() < 1e-9 || (r - r_out).abs() < 1e-9,
1011 "vertex radius {r} is neither {radius} nor {r_out}"
1012 );
1013 }
1014 }
1015
1016 #[test]
1020 fn refuses_open_and_misoriented_trim_loops() {
1021 let sheet = make_plane(
1022 Vec3::new(0.0, 0.0, 0.0),
1023 Vec3::new(1.0, 0.0, 0.0),
1024 Vec3::new(0.0, 1.0, 0.0),
1025 4.0,
1026 3.0,
1027 )
1028 .unwrap();
1029 let open_chain = vec![
1031 parameter_line(0.0, 0.0, 4.0, 0.0).unwrap(),
1032 parameter_line(4.0, 0.0, 4.0, 3.0).unwrap(),
1033 parameter_line(4.0, 3.0, 1.0, 1.0).unwrap(),
1034 ];
1035 let error = thicken_trimmed_sheet(&sheet, &[open_chain], 0.5, false).unwrap_err();
1036 assert!(error.contains("open"), "unexpected message: {error}");
1037 let clockwise = vec![
1039 parameter_line(0.0, 0.0, 0.0, 3.0).unwrap(),
1040 parameter_line(0.0, 3.0, 4.0, 3.0).unwrap(),
1041 parameter_line(4.0, 3.0, 4.0, 0.0).unwrap(),
1042 parameter_line(4.0, 0.0, 0.0, 0.0).unwrap(),
1043 ];
1044 let error = thicken_trimmed_sheet(&sheet, &[clockwise], 0.5, false).unwrap_err();
1045 assert!(
1046 error.contains("counter-clockwise"),
1047 "unexpected message: {error}"
1048 );
1049 let ccw_hole =
1051 make_circle(Vec3::new(2.0, 1.5, 0.0), Vec3::new(0.0, 0.0, 1.0), 0.8).unwrap();
1052 let error = thicken_trimmed_sheet(
1053 &sheet,
1054 &[rectangle_loop(4.0, 3.0), vec![ccw_hole]],
1055 0.5,
1056 false,
1057 )
1058 .unwrap_err();
1059 assert!(error.contains("clockwise"), "unexpected message: {error}");
1060 let curved = quarter_cylinder(2.0, 5.0);
1062 let diagonal = vec![
1063 parameter_line(0.2, 0.2, 0.8, 0.4).unwrap(),
1064 parameter_line(0.8, 0.4, 0.8, 0.8).unwrap(),
1065 parameter_line(0.8, 0.8, 0.2, 0.2).unwrap(),
1066 ];
1067 let error = thicken_trimmed_sheet(&curved, &[diagonal], 0.3, false).unwrap_err();
1068 assert!(
1069 error.contains("iso-parameter"),
1070 "unexpected message: {error}"
1071 );
1072 assert!(thicken_trimmed_sheet(&sheet, &[], 0.5, false).is_err());
1074 }
1075}