1use glam::{Vec2, Vec3, Vec4};
6use loam_shape::{
7 Isovolume, LineMesh, NotVisualizable, PointMesh, Shape, TriangleMesh, Visualizable,
8};
9
10use super::field::DistanceField2D;
11use super::hull::{centroid, convex_hull, double_area, reduce_sides, MAX_HULL_SIDES};
12use super::{GlyphParams, BLANK_DISTANCE};
13
14const LIPSCHITZ_SCALE: f32 = std::f32::consts::FRAC_1_SQRT_2;
16
17const COVER_PADDING_CELLS: f32 = 2.0;
18
19const RING_MERGE_FRACTION: f32 = 1.0e-4;
20
21const SLIVER_AREA_FRACTION: f32 = 1.0e-6;
22
23const WIREFRAME_WIDTH_PX: f32 = 1.0;
24
25const POINT_MARKER_PX: f32 = 2.0;
26
27#[derive(Clone, Debug)]
29pub(super) struct Piece {
30 vertices: [Vec2; 4],
31 len: usize,
32 cut: Option<usize>,
33}
34
35impl Piece {
36 fn ring(&self) -> &[Vec2] {
37 &self.vertices[..self.len]
38 }
39}
40
41#[derive(Clone, Debug)]
42pub struct GlyphSolid {
43 ch: char,
44 pen_origin: Vec2,
45 advance: f32,
46 half_depth: f32,
47 slab_center: f32,
48 slab_half: f32,
49 color: [f32; 4],
50 collider_cell: f32,
51 field: Option<DistanceField2D>,
52 pieces: Vec<Piece>,
53 cover: Option<Isovolume<2>>,
54 hull: Vec<Vec2>,
55}
56
57impl GlyphSolid {
58 pub(super) fn new(
59 ch: char,
60 pen_origin: Vec2,
61 advance: f32,
62 params: &GlyphParams,
63 field: Option<DistanceField2D>,
64 ) -> Self {
65 let collider_cell = params.em_size / params.collider_resolution as f32;
66 let pieces = field.as_ref().map(extract_pieces).unwrap_or_default();
67 let cover = field
68 .as_ref()
69 .map(|field| extract_cover(field, collider_cell));
70 let hull = cover.as_ref().map(rigid_ring).unwrap_or_default();
71 Self {
72 ch,
73 pen_origin,
74 advance,
75 half_depth: 0.5 * params.depth,
76 slab_center: 0.5 * (params.slab.0 + params.slab.1),
77 slab_half: 0.5 * (params.slab.1 - params.slab.0),
78 color: params.color,
79 collider_cell,
80 field,
81 pieces,
82 cover,
83 hull,
84 }
85 }
86
87 pub fn ch(&self) -> char {
88 self.ch
89 }
90
91 pub fn pen_origin(&self) -> Vec2 {
92 self.pen_origin
93 }
94
95 pub fn advance(&self) -> f32 {
96 self.advance
97 }
98
99 pub fn is_blank(&self) -> bool {
100 self.field.is_none()
101 }
102
103 pub fn field(&self) -> Option<&DistanceField2D> {
104 self.field.as_ref()
105 }
106
107 pub fn piece_count(&self) -> usize {
108 self.pieces.len()
109 }
110
111 pub fn collider_cover(&self) -> Option<&Isovolume<2>> {
113 self.cover.as_ref()
114 }
115
116 pub fn collider_count(&self) -> usize {
117 self.cover.as_ref().map_or(0, Isovolume::piece_count)
118 }
119
120 pub fn collider_margin(&self) -> f32 {
122 2.0 * self.collider_cell
123 }
124
125 pub fn distance_2d(&self, p: Vec2) -> f32 {
127 self.field
128 .as_ref()
129 .map_or(BLANK_DISTANCE, |field| field.sample(p))
130 }
131
132 pub fn distance_4d(&self, p: Vec4) -> f32 {
134 let cross_section = self.distance_2d(Vec2::new(p.x, p.y));
135 let extruded = extend(cross_section, p.z, 0.0, self.half_depth);
136 extend(extruded, p.w, self.slab_center, self.slab_half)
137 }
138
139 pub fn colliders_4d(&self) -> Vec<(Vec4, Shape)> {
141 let Some(cover) = &self.cover else {
142 return Vec::new();
143 };
144 (0..cover.piece_count())
145 .map(|index| {
146 let (lo, hi) = cover.piece_bounds(index);
147 let centre = Vec4::new(
148 0.5 * (lo[0] + hi[0]),
149 0.5 * (lo[1] + hi[1]),
150 0.0,
151 self.slab_center,
152 );
153 let half = Vec4::new(
154 0.5 * (hi[0] - lo[0]),
155 0.5 * (hi[1] - lo[1]),
156 self.half_depth,
157 self.slab_half,
158 );
159 let mut vertices = Vec::with_capacity(16);
160 for sw in [-1.0f32, 1.0] {
161 for sz in [-1.0f32, 1.0] {
162 for sy in [-1.0f32, 1.0] {
163 for sx in [-1.0f32, 1.0] {
164 vertices.push(half * Vec4::new(sx, sy, sz, sw));
165 }
166 }
167 }
168 }
169 (centre, Shape::ConvexPolytope4D { vertices })
170 })
171 .collect()
172 }
173
174 pub fn rigid_hull_sides(&self) -> usize {
175 self.hull.len()
176 }
177
178 pub fn rigid_hull_4d(&self) -> Option<(Vec4, Shape)> {
180 if self.hull.is_empty() {
181 return None;
182 }
183 let centre_2d = centroid(&self.hull);
184 let centre = Vec4::new(centre_2d.x, centre_2d.y, 0.0, self.slab_center);
185 let mut vertices = Vec::with_capacity(4 * self.hull.len());
186 for sw in [-1.0f32, 1.0] {
187 for sz in [-1.0f32, 1.0] {
188 for p in &self.hull {
189 let offset = *p - centre_2d;
190 vertices.push(Vec4::new(
191 offset.x,
192 offset.y,
193 sz * self.half_depth,
194 sw * self.slab_half,
195 ));
196 }
197 }
198 }
199 Some((centre, Shape::ConvexPolytope4D { vertices }))
200 }
201}
202
203fn rigid_ring(cover: &Isovolume<2>) -> Vec<Vec2> {
204 let mut corners = Vec::with_capacity(4 * cover.piece_count());
205 for index in 0..cover.piece_count() {
206 let (lo, hi) = cover.piece_bounds(index);
207 for y in [lo[1], hi[1]] {
208 for x in [lo[0], hi[0]] {
209 corners.push(Vec2::new(x, y));
210 }
211 }
212 }
213 let mut ring = convex_hull(corners);
214 reduce_sides(&mut ring, MAX_HULL_SIDES);
215 ring
216}
217
218fn extract_cover(field: &DistanceField2D, cell: f32) -> Isovolume<2> {
219 let (cells_x, cells_y) = field.cell_counts();
220 let padding = Vec2::splat(COVER_PADDING_CELLS * cell);
221 let lo = field.corner(0, 0) - padding;
222 let span = field.corner(cells_x, cells_y) + padding - lo;
223 let counts = (span / cell).ceil().max(Vec2::ONE);
224 let resolution = counts.max_element() as usize;
225 Isovolume::extract(
226 lo.to_array(),
227 (lo + counts * cell).to_array(),
228 resolution,
229 |p| LIPSCHITZ_SCALE * field.sample(Vec2::from_array(p)),
230 )
231}
232
233pub fn append_field_prism(
235 field: &DistanceField2D,
236 half_depth: f32,
237 color: [f32; 4],
238 mesh: &mut TriangleMesh<3>,
239) -> bool {
240 let mut emitted = false;
241 for_each_piece(field, |piece| {
242 append_prism(std::slice::from_ref(&piece), half_depth, color, mesh);
243 emitted = true;
244 });
245 emitted
246}
247
248fn append_prism(pieces: &[Piece], half_depth: f32, color: [f32; 4], mesh: &mut TriangleMesh<3>) {
249 for piece in pieces {
250 let n = piece.ring().len();
251 let back = mesh.vertices.len() as u32;
252 let front = back + n as u32;
253 for z in [-half_depth, half_depth] {
254 for q in piece.ring() {
255 mesh.vertices.push([q.x, q.y, z]);
256 mesh.colors.push(color);
257 }
258 }
259 for k in 1..n as u32 - 1 {
260 mesh.indices.push([front, front + k, front + k + 1]);
261 mesh.indices.push([back, back + k + 1, back + k]);
262 }
263 if let Some(cut) = piece.cut {
264 let a = cut as u32;
265 let b = ((cut + 1) % n) as u32;
266 mesh.indices.push([back + a, back + b, front + b]);
267 mesh.indices.push([back + a, front + b, front + a]);
268 }
269 }
270}
271
272impl Visualizable<3> for GlyphSolid {
273 fn to_triangles(&self) -> Result<TriangleMesh<3>, NotVisualizable> {
274 if self.pieces.is_empty() {
275 return Err(NotVisualizable::Degenerate);
276 }
277 let mut mesh = TriangleMesh::default();
278 append_prism(&self.pieces, self.half_depth, self.color, &mut mesh);
279 Ok(mesh)
280 }
281
282 fn to_lines(&self) -> Result<LineMesh<3>, NotVisualizable> {
283 if self.pieces.is_empty() {
284 return Err(NotVisualizable::Degenerate);
285 }
286 let mut mesh = LineMesh::default();
287 let mut push = |from: Vec3, to: Vec3| {
288 mesh.segments.push((from.to_array(), to.to_array()));
289 mesh.colors.push((self.color, self.color));
290 mesh.widths.push(WIREFRAME_WIDTH_PX);
291 };
292 for piece in &self.pieces {
293 let Some(cut) = piece.cut else { continue };
294 let a = piece.ring()[cut];
295 let b = piece.ring()[(cut + 1) % piece.ring().len()];
296 let (a_back, a_front) = (at_z(a, -self.half_depth), at_z(a, self.half_depth));
297 let (b_back, b_front) = (at_z(b, -self.half_depth), at_z(b, self.half_depth));
298 push(a_back, b_back);
299 push(a_front, b_front);
300 push(a_back, a_front);
301 push(b_back, b_front);
302 }
303 Ok(mesh)
304 }
305
306 fn to_points(&self) -> Result<PointMesh<3>, NotVisualizable> {
307 if self.pieces.is_empty() {
308 return Err(NotVisualizable::Degenerate);
309 }
310 let mut mesh = PointMesh::default();
311 for piece in &self.pieces {
312 let Some(cut) = piece.cut else { continue };
313 let a = piece.ring()[cut];
314 for z in [-self.half_depth, self.half_depth] {
315 mesh.positions.push(at_z(a, z).to_array());
316 mesh.colors.push(self.color);
317 mesh.sizes.push(POINT_MARKER_PX);
318 }
319 }
320 Ok(mesh)
321 }
322}
323
324fn at_z(p: Vec2, z: f32) -> Vec3 {
325 Vec3::new(p.x, p.y, z)
326}
327
328fn extend(d: f32, x: f32, center: f32, half: f32) -> f32 {
330 let axial = (x - center).abs() - half;
331 Vec2::new(d, axial).max(Vec2::ZERO).length() + d.max(axial).min(0.0)
332}
333
334fn extract_pieces(field: &DistanceField2D) -> Vec<Piece> {
335 let mut pieces = Vec::new();
336 for_each_piece(field, |piece| pieces.push(piece));
337 pieces
338}
339
340fn for_each_piece(field: &DistanceField2D, mut emit: impl FnMut(Piece)) {
341 let (cells_x, cells_y) = field.cell_counts();
342 let cell = field.cell_size();
343
344 for j in 0..cells_y {
345 for i in 0..cells_x {
346 let indices = [(i, j), (i + 1, j), (i + 1, j + 1), (i, j + 1)];
347 let corners: [Corner; 4] = std::array::from_fn(|k| {
348 let (ci, cj) = indices[k];
349 Corner {
350 position: field.corner(ci, cj),
351 distance: field.at(ci, cj),
352 }
353 });
354 let triangles: [[usize; 3]; 2] = if (i + j) % 2 == 0 {
355 [[0, 1, 2], [0, 2, 3]]
356 } else {
357 [[0, 1, 3], [1, 2, 3]]
358 };
359 for tri in triangles {
360 let corners = [
361 corners[tri[0]].clone(),
362 corners[tri[1]].clone(),
363 corners[tri[2]].clone(),
364 ];
365 if let Some(piece) = clip_triangle(&corners, cell) {
366 emit(piece);
367 }
368 }
369 }
370 }
371}
372
373#[derive(Clone, Debug)]
374struct Corner {
375 position: Vec2,
376 distance: f32,
377}
378
379fn clip_triangle(tri: &[Corner; 3], cell: f32) -> Option<Piece> {
380 let mut ring = [Vec2::ZERO; 4];
381 let mut on_isoline = [false; 4];
382 let mut len = 0;
383 for k in 0..3 {
384 let a = &tri[k];
385 let b = &tri[(k + 1) % 3];
386 if a.distance <= 0.0 {
387 ring[len] = a.position;
388 on_isoline[len] = a.distance == 0.0;
389 len += 1;
390 }
391 if (a.distance <= 0.0) != (b.distance <= 0.0) {
392 let t = a.distance / (a.distance - b.distance);
393 ring[len] = a.position + (b.position - a.position) * t;
394 on_isoline[len] = true;
395 len += 1;
396 }
397 }
398 let merge2 = (cell * RING_MERGE_FRACTION).powi(2);
399 let mut kept = 0;
400 for index in 0..len {
401 if kept > 0 && ring[kept - 1].distance_squared(ring[index]) <= merge2 {
402 on_isoline[kept - 1] |= on_isoline[index];
403 } else {
404 ring[kept] = ring[index];
405 on_isoline[kept] = on_isoline[index];
406 kept += 1;
407 }
408 }
409 while kept >= 2 && ring[0].distance_squared(ring[kept - 1]) <= merge2 {
410 kept -= 1;
411 on_isoline[0] |= on_isoline[kept];
412 }
413 if kept < 3 || double_area(&ring[..kept]) <= SLIVER_AREA_FRACTION * cell * cell {
414 return None;
415 }
416 let cut = (0..kept).find(|&k| on_isoline[k] && on_isoline[(k + 1) % kept]);
417 Some(Piece {
418 vertices: ring,
419 len: kept,
420 cut,
421 })
422}
423
424#[cfg(test)]
425mod tests {
426 use glam::Vec4Swizzles;
427
428 use super::super::outline::Contour;
429 use super::*;
430
431 fn square_field(half: f32, cells: u32) -> DistanceField2D {
432 let contour = Contour {
433 points: vec![
434 Vec2::new(-half, -half),
435 Vec2::new(half, -half),
436 Vec2::new(half, half),
437 Vec2::new(-half, half),
438 ],
439 };
440 DistanceField2D::bake(&[contour], 2.0 * half / cells as f32).expect("bake")
441 }
442
443 fn square_solid(half: f32, resolution: u32, depth: f32, slab: (f32, f32)) -> GlyphSolid {
444 collider_solid(half, resolution, resolution, depth, slab)
445 }
446
447 fn fixture_params(
448 em: f32,
449 collider_cells: u32,
450 depth: f32,
451 slab: (f32, f32),
452 ) -> super::GlyphParams {
453 super::GlyphParams {
454 em_size: em,
455 depth,
456 slab,
457 collider_resolution: collider_cells,
458 ..super::GlyphParams::default()
459 }
460 }
461
462 fn collider_solid(
463 half: f32,
464 cells: u32,
465 collider_cells: u32,
466 depth: f32,
467 slab: (f32, f32),
468 ) -> GlyphSolid {
469 GlyphSolid::new(
470 'X',
471 Vec2::ZERO,
472 2.0 * half,
473 &fixture_params(2.0 * half, collider_cells, depth, slab),
474 Some(square_field(half, cells)),
475 )
476 }
477
478 fn diamond_solid(half: f32, cells: u32, collider_cells: u32) -> GlyphSolid {
479 diamond_hull_solid(half, cells, collider_cells, 0.4, (-0.2, 0.2))
480 }
481
482 fn diamond_hull_solid(
483 half: f32,
484 cells: u32,
485 collider_cells: u32,
486 depth: f32,
487 slab: (f32, f32),
488 ) -> GlyphSolid {
489 let contour = Contour {
490 points: vec![
491 Vec2::new(-half, 0.0),
492 Vec2::new(0.0, -half),
493 Vec2::new(half, 0.0),
494 Vec2::new(0.0, half),
495 ],
496 };
497 let field = DistanceField2D::bake(&[contour], 2.0 * half / cells as f32).expect("bake");
498 GlyphSolid::new(
499 'X',
500 Vec2::ZERO,
501 2.0 * half,
502 &fixture_params(2.0 * half, collider_cells, depth, slab),
503 Some(field),
504 )
505 }
506
507 fn annulus_solid(outer: f32, inner: f32, cells: u32, collider_cells: u32) -> GlyphSolid {
508 let ring = |half: f32, counter_clockwise: bool| {
509 let mut points = vec![
510 Vec2::new(-half, -half),
511 Vec2::new(half, -half),
512 Vec2::new(half, half),
513 Vec2::new(-half, half),
514 ];
515 if !counter_clockwise {
516 points.reverse();
517 }
518 Contour { points }
519 };
520 let field = DistanceField2D::bake(
521 &[ring(outer, true), ring(inner, false)],
522 2.0 * outer / cells as f32,
523 )
524 .expect("bake");
525 GlyphSolid::new(
526 'O',
527 Vec2::ZERO,
528 2.0 * outer,
529 &fixture_params(2.0 * outer, collider_cells, 0.4, (-0.2, 0.2)),
530 Some(field),
531 )
532 }
533
534 #[test]
535 fn every_piece_is_convex_and_counter_clockwise() {
536 let solid = square_solid(1.0, 17, 0.5, (-0.25, 0.25));
537 assert!(solid.piece_count() > 0);
538 for piece in &solid.pieces {
539 let n = piece.ring().len();
540 assert!((3..=4).contains(&n), "ring has {n} vertices");
541 assert!(double_area(piece.ring()) > 0.0);
542 for k in 0..n {
543 let a = piece.ring()[k];
544 let b = piece.ring()[(k + 1) % n];
545 let c = piece.ring()[(k + 2) % n];
546 let cross = (b - a).perp_dot(c - b);
547 assert!(cross >= -1.0e-6, "reflex vertex at {k}: cross = {cross}");
548 }
549 }
550 }
551
552 #[test]
553 fn piece_areas_sum_to_the_cross_section_area() {
554 for resolution in [16, 32, 64] {
555 let solid = square_solid(1.0, resolution, 0.5, (0.0, 1.0));
556 let area: f32 = solid
557 .pieces
558 .iter()
559 .map(|p| 0.5 * double_area(p.ring()))
560 .sum();
561 let cell = solid.field().unwrap().cell_size();
562 assert!(
563 (area - 4.0).abs() < 8.0 * cell,
564 "resolution {resolution}: area {area} off by more than {} ",
565 8.0 * cell
566 );
567 }
568 }
569
570 #[test]
571 fn extruded_mesh_volume_matches_area_times_depth() {
572 let depth = 0.5;
573 let solid = square_solid(1.0, 32, depth, (-1.0, 1.0));
574 let mesh = solid.to_triangles().expect("mesh");
575 let mut volume = 0.0;
576 for [i0, i1, i2] in &mesh.indices {
577 let v0 = Vec3::from_array(mesh.vertices[*i0 as usize]);
578 let v1 = Vec3::from_array(mesh.vertices[*i1 as usize]);
579 let v2 = Vec3::from_array(mesh.vertices[*i2 as usize]);
580 volume += v0.dot(v1.cross(v2)) / 6.0;
581 }
582 let cell = solid.field().unwrap().cell_size();
583 let expected = 4.0 * depth;
584 assert!(
585 (volume - expected).abs() < 8.0 * cell * depth,
586 "volume {volume} vs expected {expected}"
587 );
588 }
589
590 #[test]
591 fn mesh_buffers_are_consistent() {
592 let solid = square_solid(1.0, 20, 0.4, (0.0, 1.0));
593 let mesh = solid.to_triangles().expect("mesh");
594 assert_eq!(mesh.colors.len(), mesh.vertices.len());
595 for [i0, i1, i2] in &mesh.indices {
596 for index in [i0, i1, i2] {
597 assert!((*index as usize) < mesh.vertices.len());
598 }
599 }
600 let lines = solid.to_lines().expect("lines");
601 assert_eq!(lines.colors.len(), lines.segments.len());
602 assert_eq!(lines.widths.len(), lines.segments.len());
603 let points = solid.to_points().expect("points");
604 assert_eq!(points.colors.len(), points.positions.len());
605 assert_eq!(points.sizes.len(), points.positions.len());
606 }
607
608 #[test]
609 fn colliders_are_origin_centred_boxes_spanning_depth_and_slab() {
610 let slab = (-0.75, 0.25);
611 let depth = 0.6;
612 let solid = collider_solid(1.0, 24, 12, depth, slab);
613 let cover = solid.collider_cover().expect("cover");
614 let colliders = solid.colliders_4d();
615 assert_eq!(colliders.len(), solid.collider_count());
616 assert_eq!(colliders.len(), cover.piece_count());
617 assert!(!colliders.is_empty());
618
619 for (index, (centre, collider)) in colliders.iter().enumerate() {
620 let Shape::ConvexPolytope4D { vertices } = collider else {
621 panic!("expected ConvexPolytope4D, got {:?}", collider.kind());
622 };
623 assert_eq!(vertices.len(), 16);
624 let sum: Vec4 = vertices.iter().copied().sum();
625 assert!(sum.length() < 1e-5, "hull is not origin-centred");
626
627 let (lo, hi) = cover.piece_bounds(index);
628 let expect = Vec4::new(
629 0.5 * (lo[0] + hi[0]),
630 0.5 * (lo[1] + hi[1]),
631 0.0,
632 0.5 * (slab.0 + slab.1),
633 );
634 assert!(
635 centre.distance(expect) < 1e-6,
636 "centre {centre} vs {expect}"
637 );
638 let half = Vec4::new(
639 0.5 * (hi[0] - lo[0]),
640 0.5 * (hi[1] - lo[1]),
641 0.5 * depth,
642 0.5 * (slab.1 - slab.0),
643 );
644 for v in vertices {
645 assert!((v.abs() - half).abs().max_element() < 1e-6);
646 }
647 }
648 }
649
650 #[test]
651 fn every_point_the_field_calls_solid_lies_inside_a_collider_box() {
652 let solid = collider_solid(1.0, 32, 12, 0.5, (-0.25, 0.25));
653 let cover = solid.collider_cover().expect("cover");
654 const PROBES: usize = 201;
655 let coord = |i: usize| -1.3 + 2.6 * (i as f32 + 0.317) / PROBES as f32;
656 let mut interior = 0;
657 for j in 0..PROBES {
658 for i in 0..PROBES {
659 let p = Vec2::new(coord(i), coord(j));
660 if solid.distance_2d(p) > 0.0 {
661 continue;
662 }
663 interior += 1;
664 assert!(
665 cover.contains(p.to_array()),
666 "interior probe {p} is outside every collider box"
667 );
668 }
669 }
670 assert!(interior > 10_000, "only {interior} interior probes");
671 assert!(!cover.clipped(), "the cover ran off its sampling domain");
672 }
673
674 #[test]
675 fn no_collider_corner_exceeds_the_stated_margin() {
676 let solid = collider_solid(1.0, 32, 12, 0.5, (-0.25, 0.25));
677 let cover = solid.collider_cover().expect("cover");
678 let margin = solid.collider_margin();
679 let mut worst = f32::NEG_INFINITY;
680 for index in 0..cover.piece_count() {
681 let (lo, hi) = cover.piece_bounds(index);
682 for y in [lo[1], hi[1]] {
683 for x in [lo[0], hi[0]] {
684 worst = worst.max(solid.distance_2d(Vec2::new(x, y)));
685 }
686 }
687 }
688 assert!(worst <= margin, "corner reaches {worst}, past {margin}");
689 }
690
691 #[test]
692 fn a_counter_stays_open_past_the_margin() {
693 let solid = annulus_solid(1.0, 0.6, 48, 16);
694 let cover = solid.collider_cover().expect("cover");
695 let margin = solid.collider_margin();
696 assert!(
697 solid.distance_2d(Vec2::ZERO) > margin,
698 "fixture hole is smaller than the margin"
699 );
700
701 const PROBES: usize = 121;
702 let coord = |i: usize| -0.6 + 1.2 * (i as f32 + 0.317) / PROBES as f32;
703 let mut clear = 0;
704 for j in 0..PROBES {
705 for i in 0..PROBES {
706 let p = Vec2::new(coord(i), coord(j));
707 if solid.distance_2d(p) <= margin {
708 continue;
709 }
710 clear += 1;
711 assert!(!cover.contains(p.to_array()), "counter filled at {p}");
712 }
713 }
714 assert!(clear > 5_000, "only {clear} probes deep inside the counter");
715 }
716
717 #[test]
718 fn a_cover_cell_is_marked_out_to_a_full_cell_of_clearance() {
719 let (mut band, mut clear) = (0, 0);
720 for collider_cells in [11u32, 12, 13, 16, 17] {
721 let solid = diamond_solid(1.0, 48, collider_cells);
722 let cover = solid.collider_cover().expect("cover");
723 let cell = cover.cell_size();
724 let margin = solid.collider_margin();
725 assert!(
726 (2.0 * cell - margin).abs() <= 4.0 * f32::EPSILON * margin,
727 "stated margin {margin} is not two extractor cells of {cell}"
728 );
729
730 let anchor = Vec2::from_array(cover.piece_bounds(0).0);
731 let tolerance = 1.0e-4 * cell;
732 let half_threshold = std::f32::consts::FRAC_1_SQRT_2 * cell;
733
734 for j in -40..=40 {
735 for i in -40..=40 {
736 let centre = anchor + (Vec2::new(i as f32, j as f32) + Vec2::splat(0.5)) * cell;
737 let d = solid.distance_2d(centre);
738 if d <= cell - tolerance {
739 assert!(
740 cover.contains(centre.to_array()),
741 "cell centre {centre} at d = {d} is unmarked inside one cell"
742 );
743 if d > half_threshold + tolerance {
744 band += 1;
745 }
746 } else if d >= cell + tolerance {
747 clear += 1;
748 assert!(
749 !cover.contains(centre.to_array()),
750 "cell centre {centre} at d = {d} is marked past one cell"
751 );
752 }
753 }
754 }
755 }
756 assert!(
757 band > 0,
758 "no cell centre lands between the scaled and unscaled thresholds, \
759 so the sqrt(2) correction is untested by these fixtures"
760 );
761 assert!(
762 clear > 0,
763 "only {clear} cell centres clear of the threshold"
764 );
765 }
766
767 #[test]
768 fn the_rigid_hull_is_one_origin_centred_prism_about_its_centre_of_mass() {
769 let slab = (-0.75, 0.25);
770 let depth = 0.6;
771 let solid = diamond_hull_solid(1.0, 48, 16, depth, slab);
772 let sides = solid.rigid_hull_sides();
773 let (centre, shape) = solid.rigid_hull_4d().expect("hull");
774 let Shape::ConvexPolytope4D { vertices } = shape else {
775 panic!("hull is not 4D convex");
776 };
777 assert_eq!(vertices.len(), 4 * sides);
778 assert!(vertices.len() <= 32);
779
780 let local: Vec<Vec2> = vertices[..sides].iter().map(|v| v.xy()).collect();
781 let local_centroid = centroid(&local);
782 assert!(
783 local_centroid.length() < 1e-5,
784 "hull is not centred on its centre of mass: {local_centroid}"
785 );
786 assert_eq!(centre.z, 0.0);
787 assert!((centre.w - 0.5 * (slab.0 + slab.1)).abs() < 1e-6);
788 assert!(centre.xy().length() < solid.collider_margin());
789
790 for v in &vertices {
791 assert!((v.z.abs() - 0.5 * depth).abs() < 1e-6);
792 assert!((v.w.abs() - 0.5 * (slab.1 - slab.0)).abs() < 1e-6);
793 }
794 for k in 0..sides {
795 for copy in 1..4 {
796 assert_eq!(vertices[copy * sides + k].xy(), vertices[k].xy());
797 }
798 }
799 }
800
801 #[test]
802 fn the_rigid_hull_contains_the_whole_cover() {
803 let solid = diamond_hull_solid(1.0, 48, 16, 0.4, (-0.2, 0.2));
804 let cover = solid.collider_cover().expect("cover");
805 let ring = &solid.hull;
806 let n = ring.len();
807 for index in 0..cover.piece_count() {
808 let (lo, hi) = cover.piece_bounds(index);
809 for y in [lo[1], hi[1]] {
810 for x in [lo[0], hi[0]] {
811 let p = Vec2::new(x, y);
812 for k in 0..n {
813 let a = ring[k];
814 let b = ring[(k + 1) % n];
815 assert!(
816 (b - a).perp_dot(p - a) >= -1e-5,
817 "box corner {p} lies outside hull edge {k}"
818 );
819 }
820 }
821 }
822 }
823 }
824
825 #[test]
826 fn the_rigid_hull_fills_a_counter_the_cover_keeps_open() {
827 let solid = annulus_solid(1.0, 0.6, 48, 16);
828 let cover = solid.collider_cover().expect("cover");
829 assert!(!cover.contains([0.0, 0.0]), "fixture counter is not open");
830
831 let ring = &solid.hull;
832 let n = ring.len();
833 let covered = (0..n).all(|k| {
834 let a = ring[k];
835 let b = ring[(k + 1) % n];
836 (b - a).perp_dot(-a) >= 0.0
837 });
838 assert!(covered, "the hull left the annulus centre outside");
839 }
840
841 #[test]
842 fn distance_4d_extends_the_cross_section_on_both_interval_axes() {
843 let depth = 0.5;
844 let slab = (-1.0, 1.0);
845 let solid = square_solid(1.0, 32, depth, slab);
846
847 let centre = solid.distance_4d(Vec4::ZERO);
848 assert!((centre + 0.25).abs() < 0.05, "centre distance {centre}");
849
850 let above = solid.distance_4d(Vec4::new(0.0, 0.0, 0.5 * depth + 2.0, 0.0));
851 assert!((above - 2.0).abs() < 1e-5, "above {above}");
852
853 let beyond = solid.distance_4d(Vec4::new(0.0, 0.0, 0.0, slab.1 + 3.0));
854 assert!((beyond - 3.0).abs() < 1e-5, "beyond {beyond}");
855
856 let corner = solid.distance_4d(Vec4::new(0.0, 0.0, 0.5 * depth + 3.0, slab.1 + 4.0));
857 assert!((corner - 5.0).abs() < 1e-5, "corner {corner}");
858 }
859
860 #[test]
861 fn blank_glyph_reports_degenerate_and_emits_no_colliders() {
862 let blank = GlyphSolid::new(
863 ' ',
864 Vec2::ZERO,
865 0.25,
866 &fixture_params(1.0, 20, 0.5, (0.0, 1.0)),
867 None,
868 );
869 assert!(blank.rigid_hull_4d().is_none());
870 assert!(blank.is_blank());
871 assert_eq!(blank.piece_count(), 0);
872 assert_eq!(blank.collider_count(), 0);
873 assert!(blank.collider_cover().is_none());
874 assert!(blank.colliders_4d().is_empty());
875 assert_eq!(
876 Visualizable::<3>::to_triangles(&blank).unwrap_err(),
877 NotVisualizable::Degenerate
878 );
879 assert_eq!(blank.distance_2d(Vec2::ZERO), BLANK_DISTANCE);
880 }
881
882 #[test]
883 fn cut_edge_exists_exactly_when_the_triangle_is_clipped() {
884 let inside = [
885 Corner {
886 position: Vec2::ZERO,
887 distance: -1.0,
888 },
889 Corner {
890 position: Vec2::X,
891 distance: -1.0,
892 },
893 Corner {
894 position: Vec2::Y,
895 distance: -1.0,
896 },
897 ];
898 let whole = clip_triangle(&inside, 1.0).expect("whole triangle");
899 assert_eq!(whole.ring().len(), 3);
900 assert!(whole.cut.is_none());
901
902 let straddling = [
903 Corner {
904 position: Vec2::ZERO,
905 distance: -1.0,
906 },
907 Corner {
908 position: Vec2::X,
909 distance: 1.0,
910 },
911 Corner {
912 position: Vec2::Y,
913 distance: 1.0,
914 },
915 ];
916 let clipped = clip_triangle(&straddling, 1.0).expect("clipped triangle");
917 assert_eq!(clipped.ring().len(), 3);
918 let cut = clipped.cut.expect("cut edge");
919 let a = clipped.ring()[cut];
920 let b = clipped.ring()[(cut + 1) % 3];
921 assert!(
922 a.distance(Vec2::new(0.5, 0.0))
923 .min(a.distance(Vec2::new(0.0, 0.5)))
924 < 1e-6
925 );
926 assert!(
927 b.distance(Vec2::new(0.5, 0.0))
928 .min(b.distance(Vec2::new(0.0, 0.5)))
929 < 1e-6
930 );
931 }
932
933 #[test]
934 fn outside_and_sliver_triangles_produce_no_piece() {
935 let outside = [
936 Corner {
937 position: Vec2::ZERO,
938 distance: 1.0,
939 },
940 Corner {
941 position: Vec2::X,
942 distance: 1.0,
943 },
944 Corner {
945 position: Vec2::Y,
946 distance: 1.0,
947 },
948 ];
949 assert!(clip_triangle(&outside, 1.0).is_none());
950
951 let grazing = [
952 Corner {
953 position: Vec2::ZERO,
954 distance: 0.0,
955 },
956 Corner {
957 position: Vec2::X,
958 distance: 1.0,
959 },
960 Corner {
961 position: Vec2::Y,
962 distance: 1.0,
963 },
964 ];
965 assert!(clip_triangle(&grazing, 1.0).is_none());
966 }
967}