feat(backend): extract shared FDI tooth geometry
Voice extraction needs quadrant mapping and adjacency server-side, and treatment.utils.ts already held a private copy of the tooth set. Lift it into common/fdi.ts rather than create a second source of truth; treatment.utils now imports it, behaviour unchanged (existing suites still pass). toFdi() is the single place the patient-right convention lives: quadrant 1 is the patient's upper right, so upper+patient_right -> 1x, upper+patient_left -> 2x, lower+patient_left -> 3x, lower+patient_right -> 4x. Getting this backwards mirrors every quadrant and yields a valid-looking code for the wrong tooth, which no schema check can catch — so all four quadrants are pinned by tests, along with out-of-range positions never being clamped and deciduous teeth being rejected outright (the chart is permanent dentition only). Adjacency mirrors the frontend's arch-order rule, so the midline pairs 11-21 and 41-31 count as neighbours exactly as the chart treats them. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
120
backend/src/common/fdi.ts
Normal file
120
backend/src/common/fdi.ts
Normal file
@@ -0,0 +1,120 @@
|
||||
/**
|
||||
* 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;
|
||||
}
|
||||
Reference in New Issue
Block a user