121 lines
3.3 KiB
TypeScript
121 lines
3.3 KiB
TypeScript
|
|
/**
|
|||
|
|
* 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<string> = new Set<string>([
|
|||
|
|
...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;
|
|||
|
|
}
|