From 4b4c197c036b1373ef2e5648810e128e076ada40 Mon Sep 17 00:00:00 2001 From: Amin Mousavi Date: Thu, 20 Aug 2026 17:05:54 +0330 Subject: [PATCH] feat(backend): extract shared FDI tooth geometry MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) --- backend/src/common/fdi.spec.ts | 125 ++++++++++++++++++ backend/src/common/fdi.ts | 120 +++++++++++++++++ .../src/modules/treatments/treatment.utils.ts | 15 +-- 3 files changed, 252 insertions(+), 8 deletions(-) create mode 100644 backend/src/common/fdi.spec.ts create mode 100644 backend/src/common/fdi.ts diff --git a/backend/src/common/fdi.spec.ts b/backend/src/common/fdi.spec.ts new file mode 100644 index 0000000..b3128b2 --- /dev/null +++ b/backend/src/common/fdi.spec.ts @@ -0,0 +1,125 @@ +import { + areArchNeighbors, + FDI_TOOTH_IDS, + isFdiTooth, + sameArch, + teethBetweenInclusive, + toFdi, +} from './fdi'; + +describe('FDI geometry', () => { + describe('toFdi — the patient-right convention', () => { + // A mirrored quadrant produces a *valid* code for the wrong tooth, so no schema + // check can catch it. These four cases are the guard. + it('maps upper + patient right to quadrant 1', () => { + expect(toFdi('upper', 'patient_right', 6)).toBe('16'); + expect(toFdi('upper', 'patient_right', 1)).toBe('11'); + }); + + it('maps upper + patient left to quadrant 2', () => { + expect(toFdi('upper', 'patient_left', 6)).toBe('26'); + expect(toFdi('upper', 'patient_left', 8)).toBe('28'); + }); + + it('maps lower + patient left to quadrant 3', () => { + expect(toFdi('lower', 'patient_left', 6)).toBe('36'); + }); + + it('maps lower + patient right to quadrant 4', () => { + expect(toFdi('lower', 'patient_right', 6)).toBe('46'); + expect(toFdi('lower', 'patient_right', 8)).toBe('48'); + }); + + it('never clamps an out-of-range position', () => { + expect(toFdi('upper', 'patient_right', 9)).toBeNull(); + expect(toFdi('upper', 'patient_right', 0)).toBeNull(); + expect(toFdi('upper', 'patient_right', -1)).toBeNull(); + expect(toFdi('upper', 'patient_right', 1.5)).toBeNull(); + expect(toFdi('upper', 'patient_right', Number.NaN)).toBeNull(); + }); + }); + + describe('isFdiTooth', () => { + it('accepts all 32 permanent teeth', () => { + expect(FDI_TOOTH_IDS.size).toBe(32); + for (const tooth of FDI_TOOTH_IDS) expect(isFdiTooth(tooth)).toBe(true); + }); + + it('rejects deciduous teeth — the chart has no primary dentition', () => { + for (const tooth of ['51', '55', '61', '71', '85']) { + expect(isFdiTooth(tooth)).toBe(false); + } + }); + + it('rejects garbage', () => { + for (const value of [ + '', + '1', + '19', + '10', + '29', + '99', + 14, + null, + undefined, + {}, + ]) { + expect(isFdiTooth(value)).toBe(false); + } + }); + }); + + describe('adjacency', () => { + it('treats neighbours within a quadrant as adjacent', () => { + expect(areArchNeighbors('14', '15')).toBe(true); + expect(areArchNeighbors('15', '14')).toBe(true); + }); + + it('treats the midline pairs as adjacent', () => { + expect(areArchNeighbors('11', '21')).toBe(true); + expect(areArchNeighbors('41', '31')).toBe(true); + }); + + it('rejects non-neighbours and cross-arch pairs', () => { + expect(areArchNeighbors('14', '16')).toBe(false); + expect(areArchNeighbors('18', '28')).toBe(false); + expect(areArchNeighbors('14', '44')).toBe(false); + expect(areArchNeighbors('14', '14')).toBe(false); + }); + }); + + describe('sameArch', () => { + it('groups by arch, not by quadrant', () => { + expect(sameArch('18', '28')).toBe(true); + expect(sameArch('48', '38')).toBe(true); + expect(sameArch('18', '48')).toBe(false); + expect(sameArch('14', '99')).toBe(false); + }); + }); + + describe('teethBetweenInclusive', () => { + it('returns the span in arch order regardless of argument order', () => { + expect(teethBetweenInclusive('14', '16')).toEqual(['16', '15', '14']); + expect(teethBetweenInclusive('16', '14')).toEqual(['16', '15', '14']); + }); + + it('spans the midline', () => { + expect(teethBetweenInclusive('12', '22')).toEqual([ + '12', + '11', + '21', + '22', + ]); + }); + + it('returns a single tooth for identical endpoints', () => { + expect(teethBetweenInclusive('14', '14')).toEqual(['14']); + }); + + it('returns null across arches or for unknown teeth', () => { + expect(teethBetweenInclusive('14', '44')).toBeNull(); + expect(teethBetweenInclusive('14', '99')).toBeNull(); + expect(teethBetweenInclusive('99', '14')).toBeNull(); + }); + }); +}); diff --git a/backend/src/common/fdi.ts b/backend/src/common/fdi.ts new file mode 100644 index 0000000..0112596 --- /dev/null +++ b/backend/src/common/fdi.ts @@ -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 = 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; +} diff --git a/backend/src/modules/treatments/treatment.utils.ts b/backend/src/modules/treatments/treatment.utils.ts index 9a0e4b3..e22942e 100644 --- a/backend/src/modules/treatments/treatment.utils.ts +++ b/backend/src/modules/treatments/treatment.utils.ts @@ -1,9 +1,4 @@ -const FDI_TOOTH_IDS = new Set([ - '11', '12', '13', '14', '15', '16', '17', '18', - '21', '22', '23', '24', '25', '26', '27', '28', - '31', '32', '33', '34', '35', '36', '37', '38', - '41', '42', '43', '44', '45', '46', '47', '48', -]); +import { FDI_TOOTH_IDS } from '../../common/fdi'; export function normalizeTeeth(teeth: unknown): string[] { if (!Array.isArray(teeth)) { @@ -35,7 +30,10 @@ export function normalizeToothSelectionGroups( for (const row of value) { if (!row || typeof row !== 'object') continue; const rec = row as Record; - const groupId = typeof rec.groupId === 'string' && rec.groupId.trim() ? rec.groupId.trim() : ''; + const groupId = + typeof rec.groupId === 'string' && rec.groupId.trim() + ? rec.groupId.trim() + : ''; if (!groupId) continue; const kind = rec.kind === 'connected' ? 'connected' : 'single'; const teeth = normalizeTeeth(rec.teeth); @@ -64,7 +62,8 @@ export function generateTreatmentTitle( } const parts = cases.map((c) => { - const label = c.treatmentType.charAt(0).toUpperCase() + c.treatmentType.slice(1); + const label = + c.treatmentType.charAt(0).toUpperCase() + c.treatmentType.slice(1); if (c.teeth.length > 0) { return `${label} ${c.teeth.join(', ')}`; }