/** * FDI tooth geometry — permanent dentition only. * * Mirrors `frontend/src/components/treatment/fdiToothMeta.ts` and the adjacency rules in * `toothSelectionGroups.ts`. Adjacency is defined by position in the arch order, so the * midline pairs (11–21, 41–31) are neighbours, exactly as the chart treats them. */ export type Arch = 'upper' | 'lower'; /** Which side of the *patient*, not of the screen. Quadrant 1 is the patient's upper right. */ export type PatientSide = 'patient_right' | 'patient_left'; /** * Upper arch in chart order: patient's RIGHT (18) → midline → patient's LEFT (28). * That is the drawn left-to-right layout, which is the mirror of the patient's own sides. * Do not read a tooth position off this array by index — use `toFdi()`, which owns the * side convention. */ export const FDI_UPPER_ARCH_ORDER = [ '18', '17', '16', '15', '14', '13', '12', '11', '21', '22', '23', '24', '25', '26', '27', '28', ] as const; /** Lower arch, same chart ordering: patient's RIGHT (48) → midline → patient's LEFT (38). */ export const FDI_LOWER_ARCH_ORDER = [ '48', '47', '46', '45', '44', '43', '42', '41', '31', '32', '33', '34', '35', '36', '37', '38', ] as const; export const FDI_TOOTH_IDS: ReadonlySet = new Set([ ...FDI_UPPER_ARCH_ORDER, ...FDI_LOWER_ARCH_ORDER, ]); export function isFdiTooth(value: unknown): value is string { return typeof value === 'string' && FDI_TOOTH_IDS.has(value); } function archOrder(tooth: string): readonly string[] | null { if ((FDI_UPPER_ARCH_ORDER as readonly string[]).includes(tooth)) return FDI_UPPER_ARCH_ORDER; if ((FDI_LOWER_ARCH_ORDER as readonly string[]).includes(tooth)) return FDI_LOWER_ARCH_ORDER; return null; } export function sameArch(a: string, b: string): boolean { const archA = archOrder(a); const archB = archOrder(b); return Boolean(archA && archB && archA === archB); } export function areArchNeighbors(a: string, b: string): boolean { const arch = archOrder(a); if (!arch || !sameArch(a, b)) return false; return Math.abs(arch.indexOf(a) - arch.indexOf(b)) === 1; } /** Inclusive span between two teeth of the same arch, in arch order. Null if not comparable. */ export function teethBetweenInclusive(a: string, b: string): string[] | null { const arch = archOrder(a); if (!arch || !sameArch(a, b)) return null; const i = arch.indexOf(a); const j = arch.indexOf(b); if (i < 0 || j < 0) return null; const [from, to] = i <= j ? [i, j] : [j, i]; return [...arch.slice(from, to + 1)]; } /** * Arch + patient side + position (1 = central incisor … 8 = third molar) → FDI code. * * This function is the single place the patient-right convention lives. Getting it * backwards mirrors every quadrant and produces a valid-looking code for the wrong tooth, * which no schema check can catch — hence the exhaustive test coverage. */ export function toFdi( arch: Arch, side: PatientSide, position: number, ): string | null { if (!Number.isInteger(position) || position < 1 || position > 8) return null; let quadrant: number; if (arch === 'upper') { quadrant = side === 'patient_right' ? 1 : 2; } else { quadrant = side === 'patient_left' ? 3 : 4; } const code = `${quadrant}${position}`; return FDI_TOOTH_IDS.has(code) ? code : null; }