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::image_curve::{affine_image_curve, image_curve_pair};
33use crate::{make_line, NurbsCurve, NurbsSurface, Vec3};
34
35fn principal_curvatures(surface: &NurbsSurface, u: f64, v: f64) -> Result<(f64, f64), String> {
42 let derivatives = surface.derivatives(u, v, 2)?;
43 let su = derivatives[1][0];
44 let sv = derivatives[0][1];
45 let cross = su.cross(sv);
46 let cross_length = cross.length();
47 if cross_length <= 1e-12 {
48 return Err(format!(
49 "thickenSheet: degenerate parametrization at (u={u:.4}, v={v:.4})"
50 ));
51 }
52 let normal = cross.scale(1.0 / cross_length);
53 let e1 = su.dot(su);
54 let f1 = su.dot(sv);
55 let g1 = sv.dot(sv);
56 let l2 = derivatives[2][0].dot(normal);
57 let m2 = derivatives[1][1].dot(normal);
58 let n2 = derivatives[0][2].dot(normal);
59 let denominator = e1 * g1 - f1 * f1;
60 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();
63 Ok((
64 mean_double * 0.5 - discriminant,
65 mean_double * 0.5 + discriminant,
66 ))
67}
68
69fn ensure_offsets_regular(surface: &NurbsSurface, distances: &[f64]) -> Result<(), String> {
74 let [u0, u1] = surface.domain_u()?;
75 let [v0, v1] = surface.domain_v()?;
76 const SAMPLES: usize = 33;
77 for i in 0..SAMPLES {
78 let u = u0 + (u1 - u0) * i as f64 / (SAMPLES - 1) as f64;
79 for j in 0..SAMPLES {
80 let v = v0 + (v1 - v0) * j as f64 / (SAMPLES - 1) as f64;
81 let (kappa_min, kappa_max) = principal_curvatures(surface, u, v)?;
82 for &distance in distances {
83 if distance == 0.0 {
84 continue;
85 }
86 for kappa in [kappa_min, kappa_max] {
87 let factor = 1.0 - distance * kappa;
88 if factor <= 1e-6 {
89 let radius = 1.0 / kappa.abs().max(1e-300);
90 return Err(format!(
91 "thickenSheet: offset by {distance:.6} self-intersects — the \
92 sheet's concave curvature radius {radius:.6} at (u={u:.4}, \
93 v={v:.4}) is not larger than the offset distance"
94 ));
95 }
96 }
97 }
98 }
99 }
100 Ok(())
101}
102
103fn offset_sheet(surface: &NurbsSurface, distance: f64) -> Result<NurbsSurface, String> {
107 if distance == 0.0 {
108 return Ok(surface.clone());
109 }
110 let carrier = FaceRecord {
111 id: 1,
112 surface: surface.clone(),
113 same_sense: true,
114 loops: vec![],
115 name: None,
116 };
117 offset_surface(&carrier, -distance, 0.0)
118}
119
120fn ruled_wall(bottom: &NurbsCurve, top: &NurbsCurve) -> Result<NurbsSurface, String> {
126 if bottom.degree != top.degree
127 || bottom.knots.len() != top.knots.len()
128 || bottom
129 .knots
130 .iter()
131 .zip(&top.knots)
132 .any(|(a, b)| (a - b).abs() > 1e-12)
133 || bottom
134 .control_points
135 .iter()
136 .zip(&top.control_points)
137 .any(|(a, b)| (a.w - b.w).abs() > 1e-9)
138 {
139 return Err("thickenSheet: internal error — offset sheet basis mismatch".into());
140 }
141 let rows = bottom
142 .control_points
143 .iter()
144 .zip(&top.control_points)
145 .map(|(b, t)| vec![*b, *t])
146 .collect();
147 NurbsSurface::new(
148 bottom.degree,
149 1,
150 bottom.knots.clone(),
151 vec![0.0, 0.0, 1.0, 1.0],
152 rows,
153 )
154}
155
156const GAUSS_X: [f64; 8] = [
157 -0.9602898564975363,
158 -0.7966664774136267,
159 -0.525532409916329,
160 -0.18343464249564978,
161 0.18343464249564978,
162 0.525532409916329,
163 0.7966664774136267,
164 0.9602898564975363,
165];
166const GAUSS_W: [f64; 8] = [
167 0.10122853629037669,
168 0.22238103445337445,
169 0.31370664587788727,
170 0.362683783378362,
171 0.362683783378362,
172 0.31370664587788727,
173 0.22238103445337445,
174 0.10122853629037669,
175];
176
177fn pcurve_signed_area(curve: &NurbsCurve) -> Result<f64, String> {
182 let [q0, q1] = curve.domain()?;
183 let mut breaks = vec![q0];
184 for &knot in &curve.knots {
185 if knot > q0 + 1e-12 && knot < q1 - 1e-12 && (knot - breaks[breaks.len() - 1]).abs() > 1e-12
186 {
187 breaks.push(knot);
188 }
189 }
190 breaks.push(q1);
191 let mut area = 0.0;
192 for pair in breaks.windows(2) {
193 let half = (pair[1] - pair[0]) * 0.5;
194 let middle = (pair[1] + pair[0]) * 0.5;
195 for index in 0..GAUSS_X.len() {
196 let derivatives = curve.derivatives(middle + half * GAUSS_X[index], 1)?;
197 let point = derivatives[0];
198 let tangent = derivatives[1];
199 area += GAUSS_W[index] * half * 0.5 * (point.x * tangent.y - point.y * tangent.x);
200 }
201 }
202 Ok(area)
203}
204
205fn planar_gap(first: Vec3, second: Vec3) -> f64 {
208 let du = first.x - second.x;
209 let dv = first.y - second.y;
210 (du * du + dv * dv).sqrt()
211}
212
213struct BoundaryImages {
217 bottom: NurbsCurve,
218 top: NurbsCurve,
219 t0: f64,
220 t1: f64,
221 dir: bool,
223}
224
225fn boundary_images(
242 base_affine: bool,
243 bottom: &NurbsSurface,
244 top: &NurbsSurface,
245 pcurve: &NurbsCurve,
246 eps_u: f64,
247 eps_v: f64,
248 fit_tolerance: f64,
249) -> Result<BoundaryImages, String> {
250 if base_affine {
251 let [q0, q1] = pcurve.domain()?;
252 return Ok(BoundaryImages {
253 bottom: affine_image_curve(bottom, pcurve)?,
254 top: affine_image_curve(top, pcurve)?,
255 t0: q0,
256 t1: q1,
257 dir: true,
258 });
259 }
260 if pcurve.degree == 1 && pcurve.control_points.len() == 2 {
261 let first = pcurve.control_points[0];
262 let second = pcurve.control_points[1];
263 if (first.w - second.w).abs() <= 1e-12 {
264 let (ua, va) = (first.x / first.w, first.y / first.w);
265 let (ub, vb) = (second.x / second.w, second.y / second.w);
266 if (ua - ub).abs() <= eps_u && (va - vb).abs() > eps_v {
267 let u_constant = (ua + ub) * 0.5;
268 return Ok(BoundaryImages {
269 bottom: bottom.iso_curve_u(u_constant)?,
270 top: top.iso_curve_u(u_constant)?,
271 t0: va.min(vb),
272 t1: va.max(vb),
273 dir: vb > va,
274 });
275 }
276 if (va - vb).abs() <= eps_v && (ua - ub).abs() > eps_u {
277 let v_constant = (va + vb) * 0.5;
278 return Ok(BoundaryImages {
279 bottom: bottom.iso_curve_v(v_constant)?,
280 top: top.iso_curve_v(v_constant)?,
281 t0: ua.min(ub),
282 t1: ua.max(ub),
283 dir: ub > ua,
284 });
285 }
286 }
287 }
288 let (bottom_image, top_image) =
291 image_curve_pair(bottom, top, pcurve, fit_tolerance, "thickenSheet")?;
292 let forward = bottom_image.t0 <= bottom_image.t1;
293 Ok(BoundaryImages {
294 bottom: bottom_image.curve,
295 top: top_image.curve,
296 t0: bottom_image.t0.min(bottom_image.t1),
297 t1: bottom_image.t0.max(bottom_image.t1),
298 dir: forward,
299 })
300}
301
302pub fn thicken_trimmed_sheet(
339 surface: &NurbsSurface,
340 loops: &[Vec<NurbsCurve>],
341 thickness: f64,
342 symmetric: bool,
343) -> Result<BrepSolid, String> {
344 if !thickness.is_finite() || thickness.abs() <= 1e-12 {
345 return Err("thickenSheet: thickness must be a nonzero finite value".into());
346 }
347 if loops.is_empty() || loops.iter().any(|loop_curves| loop_curves.is_empty()) {
348 return Err("thickenSheet: at least one non-empty pcurve loop is required".into());
349 }
350 let (closed_u, closed_v) = surface.closed_directions()?;
351 if closed_u || closed_v {
352 return Err(
353 "thickenSheet: closed sheets are not supported (split the patch at its seam first)"
354 .into(),
355 );
356 }
357 let (distance_bottom, distance_top) = if symmetric {
358 (-thickness.abs() * 0.5, thickness.abs() * 0.5)
359 } else if thickness > 0.0 {
360 (0.0, thickness)
361 } else {
362 (thickness, 0.0)
363 };
364 ensure_offsets_regular(surface, &[distance_bottom, distance_top])?;
365
366 let bottom = offset_sheet(surface, distance_bottom)?;
367 let top = offset_sheet(surface, distance_top)?;
368 let [u0, u1] = surface.domain_u()?;
369 let [v0, v1] = surface.domain_v()?;
370 let uv_tolerance = 1e-7 * (u1 - u0).max(v1 - v0);
371 let minimum_area = 1e-10 * (u1 - u0) * (v1 - v0);
372 let eps_u = 1e-9 * (u1 - u0);
373 let eps_v = 1e-9 * (v1 - v0);
374 let base_affine = surface.is_affine()?;
375 let sheet_points = bottom
382 .control_points
383 .iter()
384 .flatten()
385 .map(|control| control.point())
386 .collect::<Result<Vec<_>, String>>()?;
387 let fit_tolerance =
388 crate::KernelTolerances::for_scale(crate::model_scale(sheet_points), 1e-7).intersection_fit;
389
390 let mut vertices: Vec<VertexRecord> = Vec::new();
391 let mut edges: Vec<EdgeRecord> = Vec::new();
392 let mut faces: Vec<FaceRecord> = Vec::new();
393 let mut top_cap_loops: Vec<LoopRecord> = Vec::new();
394 let mut bottom_cap_loops: Vec<LoopRecord> = Vec::new();
395 let mut bottom_junction_points: Vec<Vec3> = Vec::new();
396 let mut next_id = 1u64;
397
398 for (loop_index, loop_curves) in loops.iter().enumerate() {
399 let count = loop_curves.len();
400
401 let mut starts = Vec::with_capacity(count);
403 let mut ends = Vec::with_capacity(count);
404 for curve in loop_curves {
405 let [q0, q1] = curve.domain()?;
406 starts.push(curve.evaluate(q0)?);
407 ends.push(curve.evaluate(q1)?);
408 }
409 for index in 0..count {
410 let next_index = (index + 1) % count;
411 let gap = planar_gap(ends[index], starts[next_index]);
412 if gap > uv_tolerance {
413 return Err(format!(
414 "thickenSheet: loop {loop_index} is open — pcurve {index} ends at \
415 (u={:.6}, v={:.6}) but pcurve {next_index} starts at (u={:.6}, v={:.6}) \
416 (parameter-space gap {gap:.3e})",
417 ends[index].x, ends[index].y, starts[next_index].x, starts[next_index].y
418 ));
419 }
420 }
421 if count == 1 {
422 let [q0, q1] = loop_curves[0].domain()?;
423 let middle = loop_curves[0].evaluate((q0 + q1) * 0.5)?;
424 if planar_gap(middle, starts[0]) <= uv_tolerance {
425 return Err(format!(
426 "thickenSheet: loop {loop_index} is degenerate (zero parameter-space extent)"
427 ));
428 }
429 } else {
430 for index in 0..count {
431 if planar_gap(ends[index], starts[index]) <= uv_tolerance {
432 return Err(format!(
433 "thickenSheet: pcurve {index} of loop {loop_index} closes on itself \
434 inside a multi-curve loop (pinched loop)"
435 ));
436 }
437 }
438 }
439 let mut area = 0.0;
440 for curve in loop_curves {
441 area += pcurve_signed_area(curve)?;
442 }
443 if loop_index == 0 {
444 if area <= minimum_area {
445 return Err(format!(
446 "thickenSheet: outer loop must run counter-clockwise in (u, v) \
447 (signed area {area:.3e})"
448 ));
449 }
450 } else if area >= -minimum_area {
451 return Err(format!(
452 "thickenSheet: hole loop {loop_index} must run clockwise in (u, v) \
453 (signed area {area:.3e})"
454 ));
455 }
456
457 let mut bottom_vertex_ids = Vec::with_capacity(count);
459 let mut top_vertex_ids = Vec::with_capacity(count);
460 let mut bottom_points = Vec::with_capacity(count);
461 let mut top_points = Vec::with_capacity(count);
462 for start in &starts {
463 let bottom_point = bottom.evaluate(start.x, start.y)?;
464 let top_point = top.evaluate(start.x, start.y)?;
465 vertices.push(VertexRecord {
466 id: next_id,
467 point: bottom_point,
468 });
469 bottom_vertex_ids.push(next_id);
470 next_id += 1;
471 vertices.push(VertexRecord {
472 id: next_id,
473 point: top_point,
474 });
475 top_vertex_ids.push(next_id);
476 next_id += 1;
477 bottom_points.push(bottom_point);
478 top_points.push(top_point);
479 bottom_junction_points.push(bottom_point);
480 }
481
482 let mut images = Vec::with_capacity(count);
484 for curve in loop_curves {
485 images.push(boundary_images(
486 base_affine,
487 &bottom,
488 &top,
489 curve,
490 eps_u,
491 eps_v,
492 fit_tolerance,
493 )?);
494 }
495 let mut bottom_edge_ids = Vec::with_capacity(count);
496 let mut top_edge_ids = Vec::with_capacity(count);
497 for (index, image) in images.iter().enumerate() {
498 let next_index = (index + 1) % count;
499 let (start_j, end_j) = if image.dir {
500 (index, next_index)
501 } else {
502 (next_index, index)
503 };
504 edges.push(EdgeRecord {
505 id: next_id,
506 curve: image.bottom.clone(),
507 t0: image.t0,
508 t1: image.t1,
509 start_vertex_id: bottom_vertex_ids[start_j],
510 end_vertex_id: bottom_vertex_ids[end_j],
511 degenerate: false,
512 name: None,
513 });
514 bottom_edge_ids.push(next_id);
515 next_id += 1;
516 edges.push(EdgeRecord {
517 id: next_id,
518 curve: image.top.clone(),
519 t0: image.t0,
520 t1: image.t1,
521 start_vertex_id: top_vertex_ids[start_j],
522 end_vertex_id: top_vertex_ids[end_j],
523 degenerate: false,
524 name: None,
525 });
526 top_edge_ids.push(next_id);
527 next_id += 1;
528 }
529 let mut vertical_edge_ids = Vec::with_capacity(count);
530 for junction in 0..count {
531 edges.push(EdgeRecord {
532 id: next_id,
533 curve: make_line(bottom_points[junction], top_points[junction])?,
534 t0: 0.0,
535 t1: 1.0,
536 start_vertex_id: bottom_vertex_ids[junction],
537 end_vertex_id: top_vertex_ids[junction],
538 degenerate: false,
539 name: None,
540 });
541 vertical_edge_ids.push(next_id);
542 next_id += 1;
543 }
544
545 for (index, image) in images.iter().enumerate() {
555 let next_index = (index + 1) % count;
556 let wall = ruled_wall(&image.bottom, &image.top)?;
557 let (s_start, s_end) = if image.dir {
558 (image.t0, image.t1)
559 } else {
560 (image.t1, image.t0)
561 };
562 let mut coedges = Vec::with_capacity(4);
563 for (edge_id, forward, pcurve) in [
564 (
565 bottom_edge_ids[index],
566 image.dir,
567 parameter_line(s_start, 0.0, s_end, 0.0)?,
568 ),
569 (
570 vertical_edge_ids[next_index],
571 true,
572 parameter_line(s_end, 0.0, s_end, 1.0)?,
573 ),
574 (
575 top_edge_ids[index],
576 !image.dir,
577 parameter_line(s_end, 1.0, s_start, 1.0)?,
578 ),
579 (
580 vertical_edge_ids[index],
581 false,
582 parameter_line(s_start, 1.0, s_start, 0.0)?,
583 ),
584 ] {
585 coedges.push(CoedgeRecord {
586 id: next_id,
587 edge_id,
588 forward,
589 pcurve,
590 });
591 next_id += 1;
592 }
593 let loop_id = next_id;
594 next_id += 1;
595 faces.push(FaceRecord {
596 id: next_id,
597 surface: wall,
598 same_sense: image.dir,
599 loops: vec![LoopRecord {
600 id: loop_id,
601 coedges,
602 }],
603 name: None,
604 });
605 next_id += 1;
606 }
607
608 let mut top_coedges = Vec::with_capacity(count);
610 for (index, image) in images.iter().enumerate() {
611 top_coedges.push(CoedgeRecord {
612 id: next_id,
613 edge_id: top_edge_ids[index],
614 forward: image.dir,
615 pcurve: loop_curves[index].clone(),
616 });
617 next_id += 1;
618 }
619 top_cap_loops.push(LoopRecord {
620 id: next_id,
621 coedges: top_coedges,
622 });
623 next_id += 1;
624 let mut bottom_coedges = Vec::with_capacity(count);
625 for index in (0..count).rev() {
626 bottom_coedges.push(CoedgeRecord {
627 id: next_id,
628 edge_id: bottom_edge_ids[index],
629 forward: !images[index].dir,
630 pcurve: loop_curves[index].reversed()?,
631 });
632 next_id += 1;
633 }
634 bottom_cap_loops.push(LoopRecord {
635 id: next_id,
636 coedges: bottom_coedges,
637 });
638 next_id += 1;
639 }
640
641 let scale = crate::model_scale(bottom_junction_points.iter().copied());
649 for first in 0..bottom_junction_points.len() {
650 for second in first + 1..bottom_junction_points.len() {
651 if bottom_junction_points[first]
652 .sub(bottom_junction_points[second])
653 .length()
654 <= 1e-7 * scale
655 {
656 return Err(
657 "thickenSheet: sheet boundary is degenerate (coincident junction vertices)"
658 .into(),
659 );
660 }
661 }
662 }
663
664 faces.push(FaceRecord {
667 id: next_id,
668 surface: top,
669 same_sense: true,
670 loops: top_cap_loops,
671 name: None,
672 });
673 next_id += 1;
674 faces.push(FaceRecord {
675 id: next_id,
676 surface: bottom,
677 same_sense: false,
678 loops: bottom_cap_loops,
679 name: None,
680 });
681 next_id += 1;
682
683 let shell_id = next_id;
684 let solid = BrepSolid {
685 id: next_id + 1,
686 vertices,
687 edges,
688 shells: vec![ShellRecord {
689 id: shell_id,
690 faces,
691 }],
692 genus: loops.len() as i64 - 1,
693 };
694 let issues = solid.validate();
695 if !issues.is_empty() {
696 return Err(format!(
697 "thickenSheet: assembled solid failed validation: {issues:?}"
698 ));
699 }
700 let volume = crate::solid_signed_volume(&solid)?;
701 if volume <= 0.0 {
702 return Err(format!(
703 "thickenSheet: internal orientation error (signed volume {volume})"
704 ));
705 }
706 Ok(solid)
707}
708
709pub fn thicken_face_sheet(
720 surface: &NurbsSurface,
721 thickness: f64,
722 symmetric: bool,
723) -> Result<BrepSolid, String> {
724 if !thickness.is_finite() || thickness.abs() <= 1e-12 {
725 return Err("thickenSheet: thickness must be a nonzero finite value".into());
726 }
727 let [u0, u1] = surface.domain_u()?;
728 let [v0, v1] = surface.domain_v()?;
729 let rectangle = vec![
730 parameter_line(u0, v0, u1, v0)?,
731 parameter_line(u1, v0, u1, v1)?,
732 parameter_line(u1, v1, u0, v1)?,
733 parameter_line(u0, v1, u0, v0)?,
734 ];
735 thicken_trimmed_sheet(surface, &[rectangle], thickness, symmetric)
736}
737
738#[cfg(test)]
739mod tests {
740 use super::*;
741 use crate::{
742 make_circle, make_cylinder_surface, make_plane, make_revolution, solid_mass_properties,
743 Vec3,
744 };
745 use std::f64::consts::{FRAC_PI_2, PI};
746
747 fn quarter_cylinder(radius: f64, height: f64) -> NurbsSurface {
752 let generatrix =
753 crate::make_line(Vec3::new(radius, 0.0, 0.0), Vec3::new(radius, 0.0, height)).unwrap();
754 make_revolution(
755 Vec3::default(),
756 Vec3::new(0.0, 0.0, 1.0),
757 &generatrix,
758 FRAC_PI_2,
759 )
760 .unwrap()
761 }
762
763 fn z_range(solid: &BrepSolid) -> (f64, f64) {
764 solid
765 .vertices
766 .iter()
767 .fold((f64::INFINITY, f64::NEG_INFINITY), |(low, high), vertex| {
768 (low.min(vertex.point.z), high.max(vertex.point.z))
769 })
770 }
771
772 #[test]
775 fn planar_rectangle_thickens_to_exact_box() {
776 let sheet = make_plane(
777 Vec3::new(1.0, 2.0, 3.0),
778 Vec3::new(1.0, 0.0, 0.0),
779 Vec3::new(0.0, 1.0, 0.0),
780 4.0,
781 3.0,
782 )
783 .unwrap();
784 let solid = thicken_face_sheet(&sheet, 0.5, false).unwrap();
785 assert!(solid.validate().is_empty(), "{:?}", solid.validate());
786 assert_eq!(solid.vertices.len(), 8);
787 assert_eq!(solid.edges.len(), 12);
788 assert_eq!(solid.shells[0].faces.len(), 6);
789 assert_eq!(solid.genus, 0);
790 let volume = solid_mass_properties(&solid).unwrap().volume;
791 assert!(
792 (volume - 4.0 * 3.0 * 0.5).abs() < 1e-9,
793 "volume {volume} vs exact 6"
794 );
795 let (low, high) = z_range(&solid);
797 assert!((low - 3.0).abs() < 1e-12 && (high - 3.5).abs() < 1e-12);
798 }
799
800 #[test]
803 fn quarter_cylinder_patch_thickens_to_exact_shell_segment() {
804 let (radius, height, thickness) = (2.0, 5.0, 0.4);
805 let sheet = quarter_cylinder(radius, height);
806 let solid = thicken_face_sheet(&sheet, thickness, false).unwrap();
807 assert!(solid.validate().is_empty(), "{:?}", solid.validate());
808 assert_eq!(solid.vertices.len(), 8);
809 assert_eq!(solid.edges.len(), 12);
810 assert_eq!(solid.shells[0].faces.len(), 6);
811 let volume = solid_mass_properties(&solid).unwrap().volume;
812 let r_out = radius + thickness;
813 let expected = height * (FRAC_PI_2 / 2.0) * (r_out * r_out - radius * radius);
814 assert!(
815 (volume - expected).abs() < 1e-6 * expected,
816 "volume {volume} vs shell segment {expected}"
817 );
818 }
819
820 #[test]
824 fn symmetric_mode_splits_the_thickness_across_both_sides() {
825 let sheet = make_plane(
826 Vec3::new(1.0, 2.0, 3.0),
827 Vec3::new(1.0, 0.0, 0.0),
828 Vec3::new(0.0, 1.0, 0.0),
829 4.0,
830 3.0,
831 )
832 .unwrap();
833 let one_sided = thicken_face_sheet(&sheet, 0.5, false).unwrap();
834 let symmetric = thicken_face_sheet(&sheet, 0.5, true).unwrap();
835 assert!(
836 symmetric.validate().is_empty(),
837 "{:?}",
838 symmetric.validate()
839 );
840 let one_sided_volume = solid_mass_properties(&one_sided).unwrap().volume;
841 let symmetric_volume = solid_mass_properties(&symmetric).unwrap().volume;
842 assert!(
843 (one_sided_volume - symmetric_volume).abs() < 1e-9,
844 "{one_sided_volume} vs {symmetric_volume}"
845 );
846 let (low, high) = z_range(&symmetric);
848 assert!((low - 2.75).abs() < 1e-12 && (high - 3.25).abs() < 1e-12);
849
850 let (radius, height, thickness) = (2.0, 5.0, 0.4);
852 let shell = thicken_face_sheet(&quarter_cylinder(radius, height), thickness, true).unwrap();
853 assert!(shell.validate().is_empty(), "{:?}", shell.validate());
854 let volume = solid_mass_properties(&shell).unwrap().volume;
855 let r_in = radius - thickness / 2.0;
856 let r_out = radius + thickness / 2.0;
857 let expected = height * (FRAC_PI_2 / 2.0) * (r_out * r_out - r_in * r_in);
858 assert!(
859 (volume - expected).abs() < 1e-6 * expected,
860 "volume {volume} vs symmetric shell {expected}"
861 );
862 let radial = |point: Vec3| (point.x * point.x + point.y * point.y).sqrt();
865 for vertex in &shell.vertices {
866 let r = radial(vertex.point);
867 assert!(
868 (r - r_in).abs() < 1e-9 || (r - r_out).abs() < 1e-9,
869 "corner radius {r} is neither {r_in} nor {r_out}"
870 );
871 }
872 }
873
874 #[test]
880 fn refuses_thickness_beyond_the_concave_curvature_radius() {
881 let sheet = quarter_cylinder(2.0, 5.0);
882 let error = thicken_face_sheet(&sheet, -2.5, false).unwrap_err();
884 assert!(
885 error.contains("self-intersects"),
886 "unexpected refusal message: {error}"
887 );
888 assert!(thicken_face_sheet(&sheet, -2.0, false).is_err());
890 assert!(thicken_face_sheet(&sheet, 4.2, true).is_err());
892 let fat = thicken_face_sheet(&sheet, 3.0, true).unwrap();
894 let volume = solid_mass_properties(&fat).unwrap().volume;
895 let expected = 5.0 * (FRAC_PI_2 / 2.0) * (3.5f64 * 3.5 - 0.5 * 0.5);
896 assert!(
897 (volume - expected).abs() < 1e-6 * expected,
898 "volume {volume} vs fat shell {expected}"
899 );
900 let closed =
902 make_cylinder_surface(Vec3::default(), Vec3::new(0.0, 0.0, 1.0), 2.0, 5.0).unwrap();
903 let error = thicken_face_sheet(&closed, 0.5, false).unwrap_err();
904 assert!(error.contains("closed"), "unexpected message: {error}");
905 assert!(thicken_face_sheet(&sheet, 0.0, false).is_err());
907 }
908
909 fn rectangle_loop(width: f64, height: f64) -> Vec<NurbsCurve> {
912 vec![
913 parameter_line(0.0, 0.0, width, 0.0).unwrap(),
914 parameter_line(width, 0.0, width, height).unwrap(),
915 parameter_line(width, height, 0.0, height).unwrap(),
916 parameter_line(0.0, height, 0.0, 0.0).unwrap(),
917 ]
918 }
919
920 #[test]
924 fn planar_rectangle_with_circular_hole_thickens_to_washer_slab() {
925 let sheet = make_plane(
926 Vec3::new(1.0, 2.0, 3.0),
927 Vec3::new(1.0, 0.0, 0.0),
928 Vec3::new(0.0, 1.0, 0.0),
929 4.0,
930 3.0,
931 )
932 .unwrap();
933 let hole = make_circle(Vec3::new(2.0, 1.5, 0.0), Vec3::new(0.0, 0.0, -1.0), 0.8).unwrap();
935 let solid =
936 thicken_trimmed_sheet(&sheet, &[rectangle_loop(4.0, 3.0), vec![hole]], 0.5, false)
937 .unwrap();
938 assert!(solid.validate().is_empty(), "{:?}", solid.validate());
939 assert_eq!(solid.vertices.len(), 10);
942 assert_eq!(solid.edges.len(), 15);
943 assert_eq!(solid.shells[0].faces.len(), 7);
944 assert_eq!(solid.genus, 1, "one hole = one handle");
945 let volume = solid_mass_properties(&solid).unwrap().volume;
946 let expected = (4.0 * 3.0 - PI * 0.8 * 0.8) * 0.5;
947 assert!(
948 (volume - expected).abs() < 1e-6,
949 "volume {volume} vs washer slab {expected}"
950 );
951 let (low, high) = z_range(&solid);
953 assert!((low - 3.0).abs() < 1e-12 && (high - 3.5).abs() < 1e-12);
954 }
955
956 #[test]
960 fn planar_disk_thickens_to_exact_cylinder() {
961 let sheet = make_plane(
962 Vec3::new(-1.0, -2.0, 1.0),
963 Vec3::new(1.0, 0.0, 0.0),
964 Vec3::new(0.0, 1.0, 0.0),
965 4.0,
966 4.0,
967 )
968 .unwrap();
969 let disk = make_circle(Vec3::new(2.0, 2.0, 0.0), Vec3::new(0.0, 0.0, 1.0), 1.5).unwrap();
971 let solid = thicken_trimmed_sheet(&sheet, &[vec![disk]], 0.7, false).unwrap();
972 assert!(solid.validate().is_empty(), "{:?}", solid.validate());
973 assert_eq!(solid.vertices.len(), 2);
974 assert_eq!(solid.edges.len(), 3);
975 assert_eq!(solid.shells[0].faces.len(), 3);
976 assert_eq!(solid.genus, 0);
977 let volume = solid_mass_properties(&solid).unwrap().volume;
978 let expected = PI * 1.5 * 1.5 * 0.7;
979 assert!(
980 (volume - expected).abs() < 1e-6 * expected,
981 "volume {volume} vs cylinder {expected}"
982 );
983 }
984
985 #[test]
990 fn curved_sheet_sub_window_thickens_to_exact_shell_segment() {
991 let (radius, height, thickness) = (2.0, 5.0, 0.4);
992 let sheet = quarter_cylinder(radius, height);
993 let window = vec![
994 parameter_line(0.25, 0.2, 0.75, 0.2).unwrap(),
995 parameter_line(0.75, 0.2, 0.75, 0.9).unwrap(),
996 parameter_line(0.75, 0.9, 0.25, 0.9).unwrap(),
997 parameter_line(0.25, 0.9, 0.25, 0.2).unwrap(),
998 ];
999 let solid = thicken_trimmed_sheet(&sheet, &[window], thickness, false).unwrap();
1000 assert!(solid.validate().is_empty(), "{:?}", solid.validate());
1001 assert_eq!(solid.vertices.len(), 8);
1002 assert_eq!(solid.edges.len(), 12);
1003 assert_eq!(solid.shells[0].faces.len(), 6);
1004 assert_eq!(solid.genus, 0);
1005 let at = |u: f64| sheet.evaluate(u, 0.0).unwrap();
1008 let sweep = at(0.75).y.atan2(at(0.75).x) - at(0.25).y.atan2(at(0.25).x);
1009 let r_out = radius + thickness;
1010 let expected = (0.9 - 0.2) * height * (sweep / 2.0) * (r_out * r_out - radius * radius);
1011 let volume = solid_mass_properties(&solid).unwrap().volume;
1012 assert!(
1013 (volume - expected).abs() < 1e-6 * expected,
1014 "volume {volume} vs shell sub-segment {expected}"
1015 );
1016 for vertex in &solid.vertices {
1018 let r = (vertex.point.x * vertex.point.x + vertex.point.y * vertex.point.y).sqrt();
1019 assert!(
1020 (r - radius).abs() < 1e-9 || (r - r_out).abs() < 1e-9,
1021 "vertex radius {r} is neither {radius} nor {r_out}"
1022 );
1023 }
1024 }
1025
1026 #[test]
1030 fn refuses_open_and_misoriented_trim_loops() {
1031 let sheet = make_plane(
1032 Vec3::new(0.0, 0.0, 0.0),
1033 Vec3::new(1.0, 0.0, 0.0),
1034 Vec3::new(0.0, 1.0, 0.0),
1035 4.0,
1036 3.0,
1037 )
1038 .unwrap();
1039 let open_chain = vec![
1041 parameter_line(0.0, 0.0, 4.0, 0.0).unwrap(),
1042 parameter_line(4.0, 0.0, 4.0, 3.0).unwrap(),
1043 parameter_line(4.0, 3.0, 1.0, 1.0).unwrap(),
1044 ];
1045 let error = thicken_trimmed_sheet(&sheet, &[open_chain], 0.5, false).unwrap_err();
1046 assert!(error.contains("open"), "unexpected message: {error}");
1047 let clockwise = vec![
1049 parameter_line(0.0, 0.0, 0.0, 3.0).unwrap(),
1050 parameter_line(0.0, 3.0, 4.0, 3.0).unwrap(),
1051 parameter_line(4.0, 3.0, 4.0, 0.0).unwrap(),
1052 parameter_line(4.0, 0.0, 0.0, 0.0).unwrap(),
1053 ];
1054 let error = thicken_trimmed_sheet(&sheet, &[clockwise], 0.5, false).unwrap_err();
1055 assert!(
1056 error.contains("counter-clockwise"),
1057 "unexpected message: {error}"
1058 );
1059 let ccw_hole =
1061 make_circle(Vec3::new(2.0, 1.5, 0.0), Vec3::new(0.0, 0.0, 1.0), 0.8).unwrap();
1062 let error = thicken_trimmed_sheet(
1063 &sheet,
1064 &[rectangle_loop(4.0, 3.0), vec![ccw_hole]],
1065 0.5,
1066 false,
1067 )
1068 .unwrap_err();
1069 assert!(error.contains("clockwise"), "unexpected message: {error}");
1070 assert!(thicken_trimmed_sheet(&sheet, &[], 0.5, false).is_err());
1072 }
1073
1074 #[test]
1080 fn thickens_a_general_non_iso_trim_on_a_curved_sheet() {
1081 let curved = quarter_cylinder(2.0, 5.0);
1082 let diagonal = vec![
1083 parameter_line(0.2, 0.2, 0.8, 0.4).unwrap(),
1084 parameter_line(0.8, 0.4, 0.8, 0.8).unwrap(),
1085 parameter_line(0.8, 0.8, 0.2, 0.2).unwrap(),
1086 ];
1087 let solid = thicken_trimmed_sheet(&curved, &[diagonal], 0.3, false)
1088 .expect("a general trim on a curved sheet");
1089 let issues = solid.validate();
1090 assert!(issues.is_empty(), "validate: {issues:?}");
1091 assert_eq!(
1092 solid.shells[0].faces.len(),
1093 5,
1094 "2 caps + 3 walls, one per boundary pcurve"
1095 );
1096 let volume = crate::solid_signed_volume(&solid).unwrap().abs();
1097 assert!(volume > 0.0, "volume {volume}");
1098 }
1103
1104 #[test]
1110 fn thickens_a_circular_hole_in_a_curved_sheet() {
1111 let curved = quarter_cylinder(2.0, 5.0);
1112 let [u0, u1] = curved.domain_u().unwrap();
1113 let [v0, v1] = curved.domain_v().unwrap();
1114 let outer = vec![
1115 parameter_line(u0, v0, u1, v0).unwrap(),
1116 parameter_line(u1, v0, u1, v1).unwrap(),
1117 parameter_line(u1, v1, u0, v1).unwrap(),
1118 parameter_line(u0, v1, u0, v0).unwrap(),
1119 ];
1120 let hole = make_circle(
1123 Vec3::new(0.5 * (u0 + u1), 0.5 * (v0 + v1), 0.0),
1124 Vec3::new(0.0, 0.0, -1.0),
1125 0.25 * (u1 - u0).min(v1 - v0),
1126 )
1127 .unwrap();
1128 let solid = thicken_trimmed_sheet(&curved, &[outer, vec![hole]], 0.2, false)
1129 .expect("a circular hole in a curved sheet");
1130 let issues = solid.validate();
1131 assert!(issues.is_empty(), "validate: {issues:?}");
1132 assert_eq!(solid.shells[0].faces.len(), 7);
1134 }
1135}