1use crate::error::{Error, Result};
8use crate::mesh::Mesh;
9use crate::profile::{Profile2D, Profile2DWithVoids, Triangulation, VoidInfo};
10use crate::extrusion_generic::{
11 apply_transform_generic, create_cap_mesh, create_side_walls, extrude_rings_into,
12 outward_is_right_hand, Ring,
13};
14use nalgebra::{Matrix4, Point2, Point3, Vector3};
15
16#[inline]
18pub fn extrude_profile(
19 profile: &Profile2D,
20 depth: f64,
21 transform: Option<Matrix4<f64>>,
22) -> Result<Mesh> {
23 let mut mesh = Mesh::new();
24 extrude_rings_into(&profile.outer, &profile.holes, depth, transform, &mut mesh)?;
25 Ok(mesh)
26}
27
28#[inline]
38pub fn extrude_profile_watertight(
39 profile: &Profile2D,
40 depth: f64,
41 transform: Option<Matrix4<f64>>,
42) -> Result<Mesh> {
43 if depth <= 0.0 {
44 return Err(Error::InvalidExtrusion(
45 "Depth must be positive".to_string(),
46 ));
47 }
48 if profile.outer.len() < 3 {
49 return Err(Error::InvalidExtrusion(
50 "Outer boundary needs >= 3 vertices".to_string(),
51 ));
52 }
53 if profile.holes.iter().any(|h| h.len() < 3) {
58 return Err(Error::InvalidExtrusion(
59 "Hole ring needs >= 3 vertices".to_string(),
60 ));
61 }
62 let (points, indices) =
66 match crate::cdt::triangulate_constrained(&profile.outer, &profile.holes) {
67 Some(t) => t,
68 None => {
69 let mut all: Vec<nalgebra::Point2<f64>> = profile.outer.clone();
70 for h in &profile.holes {
71 all.extend_from_slice(h);
72 }
73 let idx = crate::triangulation::triangulate_polygon_with_holes(
74 &profile.outer,
75 &profile.holes,
76 )
77 .map_err(|e| Error::InvalidExtrusion(format!("cap triangulation failed: {e}")))?;
78 (all, idx)
79 }
80 };
81 let tri = Triangulation { points, indices };
82
83 let mut mesh = Mesh::new();
84 create_cap_mesh(&tri, 0.0, Vector3::new(0.0, 0.0, -1.0), &mut mesh);
85 create_cap_mesh(&tri, depth, Vector3::new(0.0, 0.0, 1.0), &mut mesh);
86 create_side_walls(&profile.outer, 0.0, depth, Ring::Outer, &mut mesh);
87 for hole in &profile.holes {
88 create_side_walls(hole, 0.0, depth, Ring::Hole, &mut mesh);
89 }
90 crate::mesh_orient::orient_mesh_outward(&mut mesh);
98 if let Some(mat) = transform {
99 apply_transform(&mut mesh, &mat);
100 }
101 Ok(mesh)
102}
103
104#[inline]
118pub fn extrude_profile_with_voids(
119 profile_with_voids: &Profile2DWithVoids,
120 depth: f64,
121 transform: Option<Matrix4<f64>>,
122) -> Result<Mesh> {
123 if depth <= 0.0 {
124 return Err(Error::InvalidExtrusion(
125 "Depth must be positive".to_string(),
126 ));
127 }
128
129 let profile_with_holes = profile_with_voids.profile_with_through_holes();
131
132 let cap_profile = |at_bottom: bool| {
136 let mut p = profile_with_holes.clone();
137 for v in profile_with_voids.partial_voids() {
138 let reaches = if at_bottom {
139 v.depth_start <= PARTIAL_VOID_EPSILON
140 } else {
141 v.depth_end >= depth - PARTIAL_VOID_EPSILON
142 };
143 if reaches && v.contour.len() >= 3 {
144 p.add_hole(v.contour.clone());
145 }
146 }
147 p
148 };
149 let bottom_triangulation = cap_profile(true).triangulate()?;
150 let triangulation = cap_profile(false).triangulate()?;
151
152 let partial_void_count = profile_with_voids.partial_voids().count();
154
155 let vertex_count = triangulation.points.len() * 2;
156 let side_vertex_count = profile_with_holes.outer.len() * 2
157 + profile_with_holes
158 .holes
159 .iter()
160 .map(|h| h.len() * 2)
161 .sum::<usize>();
162 let partial_void_vertices = partial_void_count * 100; let total_vertices = vertex_count + side_vertex_count + partial_void_vertices;
164
165 let mut mesh = Mesh::with_capacity(
166 total_vertices,
167 triangulation.indices.len() * 2 + profile_with_holes.outer.len() * 6,
168 );
169
170 create_cap_mesh(&bottom_triangulation, 0.0, Vector3::new(0.0, 0.0, -1.0), &mut mesh);
172 create_cap_mesh(
173 &triangulation,
174 depth,
175 Vector3::new(0.0, 0.0, 1.0),
176 &mut mesh,
177 );
178
179 create_side_walls(&profile_with_holes.outer, 0.0, depth, Ring::Outer, &mut mesh);
181
182 for hole in &profile_with_holes.holes {
184 create_side_walls(hole, 0.0, depth, Ring::Hole, &mut mesh);
185 }
186
187 for void in profile_with_voids.partial_voids() {
189 create_partial_void_geometry(void, depth, &mut mesh)?;
190 }
191
192 if let Some(mat) = transform {
194 apply_transform(&mut mesh, &mat);
195 }
196
197 Ok(mesh)
198}
199
200const PARTIAL_VOID_EPSILON: f64 = 0.001;
202
203fn create_partial_void_geometry(void: &VoidInfo, total_depth: f64, mesh: &mut Mesh) -> Result<()> {
209 if void.contour.len() < 3 {
210 return Ok(());
211 }
212 let void_triangulation = match Profile2D::new(void.contour.clone()).triangulate() {
213 Ok(t) => t,
214 Err(_) => return Ok(()), };
216 if void.depth_start > PARTIAL_VOID_EPSILON {
218 create_cap_mesh(&void_triangulation, void.depth_start, Vector3::new(0.0, 0.0, 1.0), mesh);
219 }
220 if void.depth_end < total_depth - PARTIAL_VOID_EPSILON {
222 let first = mesh.indices.len();
223 create_cap_mesh(&void_triangulation, void.depth_end, Vector3::new(0.0, 0.0, -1.0), mesh);
224 for tri in mesh.indices[first..].chunks_exact_mut(3) {
225 tri.swap(1, 2);
226 }
227 }
228 if void.depth_end - void.depth_start > PARTIAL_VOID_EPSILON {
229 create_side_walls(&void.contour, void.depth_start, void.depth_end, Ring::Hole, mesh);
230 }
231 Ok(())
232}
233
234#[inline]
246pub fn extrude_profile_lofted(
247 start: &Profile2D,
248 end: &Profile2D,
249 depth: f64,
250 transform: Option<Matrix4<f64>>,
251) -> Result<Mesh> {
252 if depth <= 0.0 {
253 return Err(Error::InvalidExtrusion(
254 "Depth must be positive".to_string(),
255 ));
256 }
257 if start.outer.len() < 3 || end.outer.len() < 3 {
258 return Err(Error::InvalidProfile(
259 "Lofted extrusion requires both profiles to have ≥3 vertices".to_string(),
260 ));
261 }
262
263 let (outer_start, outer_end) = match_loop_lengths(&start.outer, &end.outer);
265 let n = outer_start.len();
266 debug_assert_eq!(n, outer_end.len());
267
268 let lofted_hole_pairs: Vec<(Vec<Point2<f64>>, Vec<Point2<f64>>)> = start
273 .holes
274 .iter()
275 .zip(end.holes.iter())
276 .filter(|(s, e)| s.len() >= 3 && e.len() >= 3)
277 .map(|(s, e)| match_loop_lengths(s, e))
278 .collect();
279
280 let mut start_profile = Profile2D::new(outer_start.clone());
282 let mut end_profile = Profile2D::new(outer_end.clone());
283 for (sh, eh) in &lofted_hole_pairs {
284 start_profile.add_hole(sh.clone());
285 end_profile.add_hole(eh.clone());
286 }
287
288 let start_tri = start_profile.triangulate()?;
290 let end_tri = end_profile.triangulate()?;
291 let cap_vertex_count = start_tri.points.len() + end_tri.points.len();
292 let cap_index_count = start_tri.indices.len() + end_tri.indices.len();
293 let side_vertex_count =
294 n * 4 + lofted_hole_pairs.iter().map(|(s, _)| s.len() * 4).sum::<usize>();
295 let mut mesh = Mesh::with_capacity(
296 cap_vertex_count + side_vertex_count,
297 cap_index_count + n * 6,
298 );
299 create_cap_mesh(&start_tri, 0.0, Vector3::new(0.0, 0.0, -1.0), &mut mesh);
300 create_cap_mesh(&end_tri, depth, Vector3::new(0.0, 0.0, 1.0), &mut mesh);
301 create_lofted_side_walls(&outer_start, &outer_end, depth, Ring::Outer, &mut mesh);
302 for (sh, eh) in &lofted_hole_pairs {
303 create_lofted_side_walls(sh, eh, depth, Ring::Hole, &mut mesh);
304 }
305 if let Some(mat) = transform {
306 apply_transform(&mut mesh, &mat);
307 }
308 Ok(mesh)
309}
310
311fn match_loop_lengths(
314 a: &[Point2<f64>],
315 b: &[Point2<f64>],
316) -> (Vec<Point2<f64>>, Vec<Point2<f64>>) {
317 if a.len() == b.len() {
318 return (a.to_vec(), b.to_vec());
319 }
320 if a.len() > b.len() {
321 (a.to_vec(), resample_loop(b, a.len()))
322 } else {
323 (resample_loop(a, b.len()), b.to_vec())
324 }
325}
326
327fn resample_loop(loop_pts: &[Point2<f64>], target: usize) -> Vec<Point2<f64>> {
329 let n = loop_pts.len();
330 if n == 0 || target == 0 {
331 return Vec::new();
332 }
333 let mut cum = Vec::with_capacity(n + 1);
335 cum.push(0.0);
336 for i in 0..n {
337 let p0 = &loop_pts[i];
338 let p1 = &loop_pts[(i + 1) % n];
339 let d = ((p1.x - p0.x).powi(2) + (p1.y - p0.y).powi(2)).sqrt();
340 cum.push(cum[i] + d);
341 }
342 let total = *cum.last().unwrap();
343 if total <= 0.0 {
344 return loop_pts.to_vec();
345 }
346 let mut out = Vec::with_capacity(target);
347 for k in 0..target {
348 let s = (k as f64 / target as f64) * total;
349 let mut i = 0;
351 while i + 1 < cum.len() && cum[i + 1] <= s {
352 i += 1;
353 }
354 if i >= n {
355 i = n - 1;
356 }
357 let seg_len = cum[i + 1] - cum[i];
358 let t = if seg_len > 0.0 { (s - cum[i]) / seg_len } else { 0.0 };
359 let p0 = &loop_pts[i];
360 let p1 = &loop_pts[(i + 1) % n];
361 out.push(Point2::new(
362 p0.x + (p1.x - p0.x) * t,
363 p0.y + (p1.y - p0.y) * t,
364 ));
365 }
366 out
367}
368
369fn create_lofted_side_walls(
374 bottom: &[Point2<f64>],
375 top: &[Point2<f64>],
376 depth: f64,
377 ring: Ring,
378 mesh: &mut Mesh,
379) {
380 let n = bottom.len();
381 if n < 2 || top.len() != n {
382 return;
383 }
384 let outward_is_right_hand = outward_is_right_hand(bottom, ring);
387 let winding_sign = if outward_is_right_hand { 1.0 } else { -1.0 };
388 let base_index = mesh.vertex_count() as u32;
389 let mut quad_count = 0u32;
390 for i in 0..n {
391 let j = (i + 1) % n;
392 let p0 = &bottom[i];
393 let p1 = &bottom[j];
394 let q0 = &top[i];
395 let q1 = &top[j];
396 let v0 = Point3::new(p0.x, p0.y, 0.0);
397 let v1 = Point3::new(p1.x, p1.y, 0.0);
398 let v2 = Point3::new(q1.x, q1.y, depth);
399 let v3 = Point3::new(q0.x, q0.y, depth);
400 let edge_a = v1 - v0;
401 let edge_b = v3 - v0;
402 let normal = match edge_a.cross(&edge_b).try_normalize(1e-10) {
403 Some(n) => n * winding_sign,
404 None => continue,
405 };
406 let idx = base_index + (quad_count * 4);
407 mesh.add_vertex(v0, normal);
408 mesh.add_vertex(v1, normal);
409 mesh.add_vertex(v2, normal);
410 mesh.add_vertex(v3, normal);
411 if outward_is_right_hand {
412 mesh.add_triangle(idx, idx + 1, idx + 2);
413 mesh.add_triangle(idx, idx + 2, idx + 3);
414 } else {
415 mesh.add_triangle(idx, idx + 2, idx + 1);
416 mesh.add_triangle(idx, idx + 3, idx + 2);
417 }
418 quad_count += 1;
419 }
420}
421
422
423#[inline]
425pub fn apply_transform(mesh: &mut Mesh, transform: &Matrix4<f64>) {
426 apply_transform_generic(mesh, transform);
427}
428
429
430#[cfg(test)]
436#[path = "extrusion_watertight_tests.rs"]
437mod watertight_tests;
438
439#[cfg(test)]
440#[path = "extrusion_byte_identity_tests.rs"]
441mod byte_identity_tests;
442
443#[cfg(test)]
444mod tests {
445 use super::*;
446 use crate::extrusion_generic::is_approximately_circular_profile;
447 use crate::profile::create_rectangle;
448
449 #[test]
450 fn test_extrude_rectangle() {
451 let profile = create_rectangle(10.0, 5.0);
452 let mesh = extrude_profile(&profile, 20.0, None).unwrap();
453
454 assert!(mesh.vertex_count() > 0);
456 assert!(mesh.triangle_count() > 0);
457
458 let (min, max) = mesh.bounds();
460 assert!((min.x - -5.0).abs() < 0.01);
461 assert!((max.x - 5.0).abs() < 0.01);
462 assert!((min.y - -2.5).abs() < 0.01);
463 assert!((max.y - 2.5).abs() < 0.01);
464 assert!((min.z - 0.0).abs() < 0.01);
465 assert!((max.z - 20.0).abs() < 0.01);
466 }
467
468 #[test]
469 fn test_extrude_with_transform() {
470 let profile = create_rectangle(10.0, 5.0);
471
472 let transform = Matrix4::new_translation(&Vector3::new(100.0, 200.0, 300.0));
474
475 let mesh = extrude_profile(&profile, 20.0, Some(transform)).unwrap();
476
477 let (min, max) = mesh.bounds();
479 assert!((min.x - 95.0).abs() < 0.01); assert!((max.x - 105.0).abs() < 0.01); assert!((min.y - 197.5).abs() < 0.01); assert!((max.y - 202.5).abs() < 0.01); assert!((min.z - 300.0).abs() < 0.01); assert!((max.z - 320.0).abs() < 0.01); }
486
487 fn assert_side_walls_outward(mesh: &Mesh) {
490 let vc = mesh.vertex_count();
491 let (mut cx, mut cy) = (0.0f32, 0.0f32);
492 for i in 0..vc {
493 cx += mesh.positions[i * 3];
494 cy += mesh.positions[i * 3 + 1];
495 }
496 cx /= vc as f32;
497 cy /= vc as f32;
498
499 let mut checked = 0;
500 for i in 0..vc {
501 let nz = mesh.normals[i * 3 + 2];
502 if nz.abs() >= 0.5 {
503 continue; }
505 let nx = mesh.normals[i * 3];
506 let ny = mesh.normals[i * 3 + 1];
507 let rx = mesh.positions[i * 3] - cx;
508 let ry = mesh.positions[i * 3 + 1] - cy;
509 let dot = nx * rx + ny * ry;
510 assert!(
511 dot > 0.0,
512 "side-wall normal points inward at vertex {i}: n=({nx},{ny}) r=({rx},{ry}) dot={dot}"
513 );
514 checked += 1;
515 }
516 assert!(checked > 0, "expected side-wall vertices to check");
517 }
518
519 #[test]
520 fn test_side_wall_normals_outward_ccw_profile() {
521 let profile = create_rectangle(10.0, 6.0);
524 let mesh = extrude_profile(&profile, 4.0, None).unwrap();
525 assert_side_walls_outward(&mesh);
526 }
527
528 #[test]
529 fn test_side_wall_normals_outward_cw_profile() {
530 let mut pts = create_rectangle(10.0, 6.0).outer;
533 pts.reverse();
534 let profile = Profile2D::new(pts);
535 let mesh = extrude_profile(&profile, 4.0, None).unwrap();
536 assert_side_walls_outward(&mesh);
537 }
538
539 #[test]
540 fn test_extrude_circle() {
541 use crate::profile::create_circle;
542 use crate::tessellation::TessellationQuality;
543
544 let profile = create_circle(5.0, None, TessellationQuality::Medium);
545 let mesh = extrude_profile(&profile, 10.0, None).unwrap();
546
547 assert!(mesh.vertex_count() > 0);
548 assert!(mesh.triangle_count() > 0);
549
550 let (min, max) = mesh.bounds();
552 assert!((min.x - -5.0).abs() < 0.1);
553 assert!((max.x - 5.0).abs() < 0.1);
554 assert!((min.y - -5.0).abs() < 0.1);
555 assert!((max.y - 5.0).abs() < 0.1);
556 }
557
558 #[test]
559 fn test_extrude_hollow_circle() {
560 use crate::profile::create_circle;
561 use crate::tessellation::TessellationQuality;
562
563 let profile = create_circle(10.0, Some(5.0), TessellationQuality::Medium);
564 let mesh = extrude_profile(&profile, 15.0, None).unwrap();
565
566 assert!(mesh.triangle_count() > 20);
568 }
569
570 #[test]
571 fn test_invalid_depth() {
572 let profile = create_rectangle(10.0, 5.0);
573 let result = extrude_profile(&profile, -1.0, None);
574 assert!(result.is_err());
575 }
576
577 #[test]
578 fn test_circular_profile_detection() {
579 use crate::profile::create_circle;
580 use crate::tessellation::TessellationQuality;
581
582 let circle = create_circle(10.0, None, TessellationQuality::Medium);
586 assert!(
587 circle.outer.len() >= 20,
588 "test setup expects ≥20 segments to pass the heuristic threshold; got {}",
589 circle.outer.len()
590 );
591 let mut cx = 0.0;
592 let mut cy = 0.0;
593 for p in &circle.outer {
594 cx += p.x;
595 cy += p.y;
596 }
597 cx /= circle.outer.len() as f64;
598 cy /= circle.outer.len() as f64;
599
600 assert!(is_approximately_circular_profile(&circle.outer, cx, cy));
601 }
602
603 #[test]
604 fn test_rectangular_profile_not_detected_as_circular() {
605 let rect = create_rectangle(10.0, 5.0);
606 let mut cx = 0.0;
607 let mut cy = 0.0;
608 for p in &rect.outer {
609 cx += p.x;
610 cy += p.y;
611 }
612 cx /= rect.outer.len() as f64;
613 cy /= rect.outer.len() as f64;
614
615 assert!(!is_approximately_circular_profile(&rect.outer, cx, cy));
616 }
617
618 #[test]
619 fn test_lofted_extrusion_rectangle_to_rectangle() {
620 let start = create_rectangle(200.0, 200.0);
625 let end = create_rectangle(200.0, 600.0);
626 let mesh = extrude_profile_lofted(&start, &end, 2000.0, None).unwrap();
627
628 assert!(!mesh.is_empty());
629 let (min, max) = mesh.bounds();
630 assert!((min.x - -100.0).abs() < 0.01, "min.x = {}", min.x);
631 assert!((max.x - 100.0).abs() < 0.01, "max.x = {}", max.x);
632 assert!((min.y - -300.0).abs() < 0.01, "min.y = {}", min.y);
634 assert!((max.y - 300.0).abs() < 0.01, "max.y = {}", max.y);
635 assert!((min.z - 0.0).abs() < 0.01);
636 assert!((max.z - 2000.0).abs() < 0.01);
637 }
638
639 #[test]
640 fn test_lofted_side_walls_not_vertical_when_profiles_differ() {
641 let start = create_rectangle(200.0, 200.0);
644 let end = create_rectangle(200.0, 600.0);
645 let mesh = extrude_profile_lofted(&start, &end, 2000.0, None).unwrap();
646
647 let mut has_sloped_normal = false;
650 for chunk in mesh.normals.chunks_exact(3) {
651 if chunk[2].abs() > 1e-3 && chunk[2].abs() < 0.9999 {
652 has_sloped_normal = true;
653 break;
654 }
655 }
656 assert!(
657 has_sloped_normal,
658 "expected at least one side-wall normal with sloped Z component"
659 );
660 }
661
662 #[test]
663 fn test_lofted_outer_normals_match_uniform_extrusion_convention() {
664 let rect = create_rectangle(200.0, 200.0);
669 let lofted = extrude_profile_lofted(&rect, &rect, 1000.0, None).unwrap();
670 let uniform = extrude_profile(&rect, 1000.0, None).unwrap();
671
672 fn radial_sign_at(mesh: &Mesh, x: f32, y: f32) -> Option<(f32, f32)> {
675 for i in 0..mesh.vertex_count() {
676 let px = mesh.positions[i * 3];
677 let py = mesh.positions[i * 3 + 1];
678 let pz = mesh.positions[i * 3 + 2];
679 if (px - x).abs() < 0.5 && (py - y).abs() < 0.5 && pz < 0.5 {
680 let nx = mesh.normals[i * 3];
681 let ny = mesh.normals[i * 3 + 1];
682 if nx.abs() > 0.1 || ny.abs() > 0.1 {
683 return Some((nx, ny));
684 }
685 }
686 }
687 None
688 }
689 let lofted_n = radial_sign_at(&lofted, -100.0, -100.0)
690 .expect("lofted: no side-wall vertex at (-100, -100, 0)");
691 let uniform_n = radial_sign_at(&uniform, -100.0, -100.0)
692 .expect("uniform: no side-wall vertex at (-100, -100, 0)");
693 assert!(
694 lofted_n.0.signum() == uniform_n.0.signum()
695 || lofted_n.1.signum() == uniform_n.1.signum(),
696 "lofted normal {:?} disagrees with uniform normal {:?}",
697 lofted_n,
698 uniform_n,
699 );
700 }
701
702 #[test]
703 fn test_lofted_extrusion_invalid_depth() {
704 let start = create_rectangle(10.0, 10.0);
705 let end = create_rectangle(10.0, 20.0);
706 assert!(extrude_profile_lofted(&start, &end, 0.0, None).is_err());
707 assert!(extrude_profile_lofted(&start, &end, -1.0, None).is_err());
708 }
709
710 #[test]
711 fn probe_lofted_hole_normal_vs_uniform_hole_normal() {
712 let mut outer = create_rectangle(200.0, 200.0);
715 let mut hole = vec![
716 Point2::new(-50.0, -50.0),
717 Point2::new(-50.0, 50.0),
718 Point2::new(50.0, 50.0),
719 Point2::new(50.0, -50.0),
720 ];
721 hole.reverse(); outer.add_hole(hole.clone());
723
724 let uniform = extrude_profile(&outer, 1000.0, None).unwrap();
725 let lofted = extrude_profile_lofted(&outer, &outer, 1000.0, None).unwrap();
726
727 fn radial_sign_at(mesh: &Mesh, x: f32, y: f32) -> Option<(f32, f32)> {
728 for i in 0..mesh.vertex_count() {
729 let px = mesh.positions[i * 3];
730 let py = mesh.positions[i * 3 + 1];
731 let pz = mesh.positions[i * 3 + 2];
732 if (px - x).abs() < 0.5 && (py - y).abs() < 0.5 && pz < 0.5 {
733 let nx = mesh.normals[i * 3];
734 let ny = mesh.normals[i * 3 + 1];
735 if nx.abs() > 0.1 || ny.abs() > 0.1 {
736 return Some((nx, ny));
737 }
738 }
739 }
740 None
741 }
742 let u = radial_sign_at(&uniform, -50.0, -50.0).expect("uniform hole vertex");
743 let l = radial_sign_at(&lofted, -50.0, -50.0).expect("lofted hole vertex");
744 let dot = u.0 * l.0 + u.1 * l.1;
749 assert!(
750 dot > 0.0,
751 "lofted hole normal {:?} disagrees with uniform hole normal {:?} (dot={dot})",
752 l, u
753 );
754 }
755
756 #[test]
757 fn test_resample_loop_preserves_total_length() {
758 let rect = create_rectangle(10.0, 4.0);
759 let resampled = resample_loop(&rect.outer, 16);
760 assert_eq!(resampled.len(), 16);
761 let total: f64 = (0..resampled.len())
762 .map(|i| {
763 let p0 = &resampled[i];
764 let p1 = &resampled[(i + 1) % resampled.len()];
765 ((p1.x - p0.x).powi(2) + (p1.y - p0.y).powi(2)).sqrt()
766 })
767 .sum();
768 assert!(total > 26.5 && total <= 28.0 + 1e-6, "resampled perimeter = {}", total);
771 }
772}