geometry.js 10 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241
  1. // ─── CHURCH ACOUSTIC GEOMETRY ENGINE ─────────────────────────────────────────
  2. // Pure functions — no React dependencies. All dimensions in mm unless noted.
  3. // Coordinate origin = centre floor datum (lowest point of the dish).
  4. //
  5. // KEY RULES:
  6. // • Ceiling is FLAT at hall.ceilFlat above the centre datum (main seating zone).
  7. // • Floor rises linearly from 0 mm at row1Radius outward to hall.dishRise at row8Back.
  8. // The communion table zone (r < row1Radius) is flat at 0 mm.
  9. // • Corner zone ceiling is structurally lower; absolute height =
  10. // hall.cornerCeilClearance + floorHeightAtRadius(r)
  11. // • Speaker height (absolute) = ceilAbsoluteAt(spk.radius) − spk.dropRod
  12. // • Ear height (absolute) = hall.earHeight + floorHeightAtRadius(row.radius)
  13. // ─── FLOOR ────────────────────────────────────────────────────────────────────
  14. /**
  15. * Floor height above centre datum at radius r.
  16. * Flat at 0 mm inside row1Radius (communion table zone).
  17. * Linear rise from 0 mm at row1Radius to dishRise at row8Back, extrapolated beyond.
  18. */
  19. export function floorHeightAtRadius(r, hall) {
  20. if (r <= hall.row1Radius) return 0;
  21. const span = hall.row8Back - hall.row1Radius; // 6201 mm for confirmed hall
  22. return Math.round(((r - hall.row1Radius) / span) * hall.dishRise);
  23. }
  24. // ─── CEILING ──────────────────────────────────────────────────────────────────
  25. /**
  26. * Boundary radius where the ceiling transitions from the flat main zone
  27. * to the structurally lower corner zone.
  28. */
  29. function cornerZoneStartR(hall) {
  30. // Corner row 1 front is cornerFrontOffset mm inside the building half-width
  31. return hall.hallWidth / 2 - hall.cornerFrontOffset; // ≈ 9837 mm
  32. }
  33. /**
  34. * Absolute ceiling height above centre datum at radius r.
  35. * Main zone → hall.ceilFlat (constant — it's a flat ceiling)
  36. * Corner zone → hall.cornerCeilClearance + floorHeightAtRadius(r)
  37. * (the soffit drops structurally at the perimeter)
  38. */
  39. export function ceilAbsoluteAt(r, hall) {
  40. if (r >= cornerZoneStartR(hall)) {
  41. return hall.cornerCeilClearance + floorHeightAtRadius(r, hall);
  42. }
  43. return hall.ceilFlat;
  44. }
  45. /**
  46. * Floor-to-ceiling clearance at radius r (what a listener experiences as room height).
  47. * Decreases toward the perimeter because the floor rises while the ceiling stays flat.
  48. */
  49. export function floorToCeilingAt(r, hall) {
  50. return ceilAbsoluteAt(r, hall) - floorHeightAtRadius(r, hall);
  51. }
  52. // ─── ROW GEOMETRY ─────────────────────────────────────────────────────────────
  53. /**
  54. * Build per-row geometry for all main rows and corner rows.
  55. * Each entry: { row, radius, frontEdge, floorRise, ceilHeight, earAFF, earToCell, isCorner }
  56. * radius — mid-bench ear position (radial)
  57. * ceilHeight — absolute ceiling above centre datum at that radius
  58. * earAFF — absolute ear height above centre datum
  59. * earToCell — ear-to-ceiling clearance (critical for flutter and ARTA window)
  60. */
  61. export function computeHallGeometry(hall) {
  62. const { rows, row1Radius, rowPitch, earHeight, hasCornerRows, cornerRows, cornerFrontOffset, hallWidth } = hall;
  63. const halfW = hallWidth / 2;
  64. const rowData = [];
  65. // Main circular rows 1–N
  66. for (let r = 1; r <= rows; r++) {
  67. const frontEdge = row1Radius + (r - 1) * rowPitch;
  68. const radius = frontEdge + Math.round(rowPitch / 2); // mid-bench
  69. const floorRise = floorHeightAtRadius(radius, hall);
  70. const ceilH = ceilAbsoluteAt(radius, hall);
  71. const earAFF = earHeight + floorRise;
  72. const earToCell = ceilH - earAFF;
  73. rowData.push({ row: r, radius, frontEdge, floorRise, ceilHeight: ceilH, earAFF, earToCell, isCorner: false });
  74. }
  75. // Corner rows (face inward from building perimeter)
  76. // Front of corner row 1 is cornerFrontOffset mm inside the half-width wall
  77. if (hasCornerRows) {
  78. const cRow1Front = halfW - cornerFrontOffset;
  79. for (let cr = 1; cr <= cornerRows; cr++) {
  80. const frontEdge = cRow1Front + (cr - 1) * rowPitch;
  81. const radius = frontEdge + Math.round(rowPitch / 2);
  82. const floorRise = floorHeightAtRadius(radius, hall);
  83. const ceilH = ceilAbsoluteAt(radius, hall);
  84. const earAFF = earHeight + floorRise;
  85. const earToCell = ceilH - earAFF;
  86. rowData.push({ row: `C${cr}`, radius, frontEdge, floorRise, ceilHeight: ceilH, earAFF, earToCell, isCorner: true, cornerRow: cr });
  87. }
  88. }
  89. return rowData;
  90. }
  91. // ─── COVERAGE ─────────────────────────────────────────────────────────────────
  92. const RINGS = ['centre', 'inner', 'outer', 'corner'];
  93. /**
  94. * For every row × speaker ring combination, compute:
  95. * slantM — 3-D slant distance speaker→ear (metres)
  96. * delay — propagation delay (ms)
  97. * haasExcess — ms beyond the nearest speaker to this row (Haas reference)
  98. * offAxisDeg — angle from directly below the speaker (0° = on-axis)
  99. * splAtEar — estimated SPL at ear using inverse-square + sensitivity + power
  100. * inCoverage — whether offAxisDeg ≤ 55° (MS6 rated coverage angle)
  101. * haasStatus — 'primary' | 'ok' | 'warn' | 'danger'
  102. */
  103. export function computeCoverage(hall, speakers, rowData) {
  104. const c = hall.speedOfSound;
  105. const results = [];
  106. for (const row of rowData) {
  107. // 1. Find minimum delay to this row (Haas reference = nearest speaker)
  108. let minDelay = Infinity;
  109. for (const ring of RINGS) {
  110. const spk = speakers[ring];
  111. if (!spk?.enabled) continue;
  112. const spkH = ceilAbsoluteAt(spk.radius, hall) - spk.dropRod;
  113. const vertDiff = spkH - row.earAFF;
  114. const horiz = Math.abs(row.radius - spk.radius);
  115. const delay = Math.sqrt(vertDiff ** 2 + horiz ** 2) / 1000 / c * 1000;
  116. if (delay < minDelay) minDelay = delay;
  117. }
  118. // 2. Per-ring metrics
  119. const rowResults = {
  120. row: row.row, radius: row.radius,
  121. earAFF: row.earAFF, ceilHeight: row.ceilHeight, earToCell: row.earToCell,
  122. speakers: {},
  123. };
  124. for (const ring of RINGS) {
  125. const spk = speakers[ring];
  126. if (!spk?.enabled) { rowResults.speakers[ring] = null; continue; }
  127. const spkH = ceilAbsoluteAt(spk.radius, hall) - spk.dropRod;
  128. const vertDiff = spkH - row.earAFF;
  129. const horiz = Math.abs(row.radius - spk.radius);
  130. const slant = Math.sqrt(vertDiff ** 2 + horiz ** 2);
  131. const slantM = slant / 1000;
  132. const delay = slantM / c * 1000;
  133. const haasExcess = Math.max(0, delay - minDelay);
  134. // 0° = directly below speaker; increases toward horizontal
  135. const offAxisDeg = Math.round(Math.atan2(horiz, Math.max(vertDiff, 1)) * 180 / Math.PI);
  136. // SPL = sensitivity + 20·log10(1/d) + 10·log10(W)
  137. const splAtEar = spk.sensitivity
  138. + 20 * Math.log10(1 / Math.max(slantM, 0.1))
  139. + 10 * Math.log10(Math.max(spk.power, 0.01));
  140. const inCoverage = offAxisDeg <= 55;
  141. let haasStatus = 'primary';
  142. if (haasExcess > 30) haasStatus = 'danger';
  143. else if (haasExcess > 10) haasStatus = 'warn';
  144. else if (haasExcess > 0) haasStatus = 'ok';
  145. rowResults.speakers[ring] = {
  146. slantM, delay, haasExcess, offAxisDeg,
  147. splAtEar: Math.round(splAtEar * 10) / 10,
  148. inCoverage, haasStatus, spkH, vertDiff,
  149. };
  150. }
  151. results.push(rowResults);
  152. }
  153. return results;
  154. }
  155. // ─── FLUTTER ECHO ─────────────────────────────────────────────────────────────
  156. /**
  157. * Flutter frequency = c / (2 × clearance).
  158. * Clearance = ceiling absolute height − ear absolute height.
  159. * As the floor rises toward the perimeter, ear moves closer to the flat ceiling
  160. * → higher flutter frequencies → harmonics land in speech band.
  161. *
  162. * Returns fundamentals at three positions plus harmonic series and tile diffraction.
  163. * (Dudley Wilkin: "standing waves from the flat floor in the centre of the dish and the ceiling")
  164. */
  165. export function computeFlutter(hall) {
  166. const hz = (clearMm) => Math.round((hall.speedOfSound * 1000) / (2 * Math.max(clearMm, 50)));
  167. // Centre zone: floor = 0, ear at earHeight above centre datum
  168. const centreClearMm = hall.ceilFlat - hall.earHeight;
  169. // Row 8 mid-bench: floor extrapolated slightly beyond row8Back
  170. const row8MidR = hall.row8Back - Math.round(hall.rowPitch / 2);
  171. const row8FloorRise = floorHeightAtRadius(row8MidR, hall);
  172. const row8ClearMm = hall.ceilFlat - (hall.earHeight + row8FloorRise);
  173. // Last corner row mid
  174. const halfW = hall.hallWidth / 2;
  175. const cRow1Front = halfW - hall.cornerFrontOffset;
  176. const cLastMidR = cRow1Front + (hall.cornerRows - 0.5) * hall.rowPitch;
  177. const cFloorRise = floorHeightAtRadius(cLastMidR, hall);
  178. const cCeilAbs = ceilAbsoluteAt(cLastMidR, hall);
  179. const cornerClearMm = cCeilAbs - (hall.earHeight + cFloorRise);
  180. const fundamentalCentre = hz(centreClearMm);
  181. // Harmonics 1–5 at centre (reference position cited by Dudley Wilkin)
  182. const harmonics = [1, 2, 3, 4, 5].map(n => Math.round(fundamentalCentre * n));
  183. // Tile diffraction grating: wavelength = tileGrid → f = c/λ
  184. const tileDiffraction = Math.round((hall.speedOfSound * 1000) / hall.tileGrid);
  185. return {
  186. fundamentalCentre,
  187. fundamentalRow8: hz(row8ClearMm),
  188. fundamentalCorner: hz(cornerClearMm),
  189. harmonics,
  190. tileDiffraction,
  191. centreClearMm,
  192. row8ClearMm,
  193. cornerClearMm,
  194. };
  195. }
  196. // ─── HAAS DISPLAY HELPERS ─────────────────────────────────────────────────────
  197. export const haasColor = (s) => ({
  198. primary: '#34d399',
  199. ok: '#4f9eff',
  200. warn: '#f0b429',
  201. danger: '#f87171',
  202. }[s] ?? '#475569');
  203. export const haasLabel = (s) => ({
  204. primary: 'Primary',
  205. ok: 'OK <10ms',
  206. warn: 'Warn 10–30ms',
  207. danger: 'Echo >30ms',
  208. }[s] ?? '—');