/** * 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. */ import { toLatinDigits } from './digits'; 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) — the drawn * layout, which mirrors the patient's own sides. Never read a 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); } /** * Clean up a tooth code the model echoed back. It is reading Persian speech, so it can hand * back "۲۶" or "2 6" from digit-by-digit dictation; neither matches literally, and the * near-miss does not fail loudly — the tooth just turns into "not understood". */ export function normalizeFdiCode(value: unknown): string { if (typeof value !== 'string') return ''; return toLatinDigits(value).replace(/\s+/g, ''); } 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. * * The single place the patient-right convention lives. Getting it backwards mirrors every * quadrant into a valid-looking code for the wrong tooth, which no schema check can catch. */ 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; } /** * Along the arch, not lexically — a bridge reads 16-15-14, and 11 sits beside 21 across the * midline. Teeth from another arch sort to the end, stably. */ export function sortInArchOrder(teeth: readonly string[]): string[] { if (teeth.length === 0) return []; const arch = teeth.map((t) => archOrder(t)).find((a) => a !== null) ?? null; if (!arch) return [...teeth]; const indexOf = (tooth: string) => { const i = arch.indexOf(tooth); return i === -1 ? Number.MAX_SAFE_INTEGER : i; }; return [...teeth].sort((a, b) => indexOf(a) - indexOf(b)); }