marchingSquares.test.ts 3.7 KB

12345678910111213141516171819202122232425262728293031323334353637383940414243444546474849505152535455565758596061626364656667686970717273747576777879808182838485
  1. import { describe, expect, it } from 'vitest';
  2. import { CHUNK_SIZE } from '../src/shared/constants';
  3. import { TerrainBitmap } from '../src/shared/terrain/bitmap';
  4. import { extractContours, type Contour, type Point } from '../src/shared/terrain/marchingSquares';
  5. import { simplifyContour, simplifyPolyline } from '../src/shared/terrain/simplify';
  6. import { bitmapFrom } from './helpers';
  7. const whole = (bm: TerrainBitmap): Contour[] => extractContours(bm, 0, 0, bm.width, bm.height);
  8. /** Signed area (shoelace). With y down, solid-on-the-left loops have a consistent sign. */
  9. function signedArea(points: Point[]): number {
  10. let a = 0;
  11. for (let i = 0; i < points.length; i++) {
  12. const p = points[i];
  13. const q = points[(i + 1) % points.length];
  14. a += p.x * q.y - q.x * p.y;
  15. }
  16. return a / 2;
  17. }
  18. describe('marching squares', () => {
  19. it('empty and full-interior regions produce no contour', () => {
  20. expect(whole(new TerrainBitmap(8, 8))).toEqual([]);
  21. });
  22. it('a single pixel yields a closed diamond around its center', () => {
  23. const contours = whole(bitmapFrom(['...', '.1.', '...']));
  24. expect(contours).toHaveLength(1);
  25. expect(contours[0].closed).toBe(true);
  26. expect(contours[0].points).toHaveLength(4);
  27. for (const p of contours[0].points) expect(Math.abs(p.x - 1.5) + Math.abs(p.y - 1.5)).toBeCloseTo(0.5);
  28. });
  29. it('a block yields one loop; a hole yields a second loop with opposite winding', () => {
  30. const solid = whole(bitmapFrom(['......', '.1111.', '.1111.', '.1111.', '......']));
  31. expect(solid).toHaveLength(1);
  32. const ring = whole(bitmapFrom(['.......', '.11111.', '.1...1.', '.1...1.', '.11111.', '.......']));
  33. expect(ring).toHaveLength(2);
  34. const areas = ring.map((c) => signedArea(c.points));
  35. expect(Math.sign(areas[0])).toBe(-Math.sign(areas[1]));
  36. expect(Math.sign(signedArea(solid[0].points))).toBe(Math.sign(Math.abs(areas[0]) > Math.abs(areas[1]) ? areas[0] : areas[1]));
  37. });
  38. it('solid touching the map border is closed by the border', () => {
  39. const contours = whole(bitmapFrom(['11', '11']));
  40. expect(contours).toHaveLength(1);
  41. expect(contours[0].closed).toBe(true);
  42. });
  43. it('diagonal pixels (saddle) stay disconnected, matching 4-connectivity', () => {
  44. const contours = whole(bitmapFrom(['....', '.1..', '..1.', '....']));
  45. expect(contours).toHaveLength(2);
  46. });
  47. it('chunk contours join exactly at chunk seams', () => {
  48. const w = CHUNK_SIZE * 2;
  49. const h = CHUNK_SIZE;
  50. const bm = new TerrainBitmap(w, h);
  51. for (let y = 20; y < 40; y++) for (let x = 40; x < 90; x++) bm.data[y * w + x] = 1;
  52. const left = extractContours(bm, 0, 0, CHUNK_SIZE, h);
  53. const right = extractContours(bm, CHUNK_SIZE, 0, w, h);
  54. expect(left.every((c) => !c.closed)).toBe(true);
  55. expect(right.every((c) => !c.closed)).toBe(true);
  56. const ends = (cs: Contour[]): string[] =>
  57. cs.flatMap((c) => [c.points[0], c.points[c.points.length - 1]]).map((p) => `${p.x},${p.y}`).sort();
  58. expect(ends(left)).toEqual(ends(right));
  59. });
  60. });
  61. describe('Douglas-Peucker', () => {
  62. it('collapses collinear points and keeps corners', () => {
  63. const line = Array.from({ length: 10 }, (_, i) => ({ x: i, y: 0 }));
  64. expect(simplifyPolyline(line, 0.5)).toEqual([line[0], line[9]]);
  65. const corner = [...line, { x: 9, y: 5 }];
  66. expect(simplifyPolyline(corner, 0.5)).toHaveLength(3);
  67. });
  68. it('simplifies a rectangle contour to its corners (plus the diagonal cut corners)', () => {
  69. const [loop] = whole(bitmapFrom(['..........', '.11111111.', '.11111111.', '.11111111.', '..........']));
  70. const simple = simplifyContour(loop, 0.8);
  71. expect(simple.closed).toBe(true);
  72. expect(simple.points.length).toBeLessThanOrEqual(8);
  73. expect(simple.points.length).toBeGreaterThanOrEqual(4);
  74. });
  75. });