Files
dyolink/backend/src/common/fdi.ts

136 lines
3.9 KiB
TypeScript
Raw Normal View History

/**
* 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 (1121, 4131) 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;
}
feat(backend): assemble resolved extraction from voice intents Composes the tooth, span, prosthesis, catalog and date resolvers into the payload the review sheet renders. Connected spans expand: "a bridge from 14 to 16" selects 15, which was never spoken. Overlapping spans merge into one bridge, group teeth sort along the arch (16-15-14, and 11 beside 21 across the midline), and a span collapsing to a single tooth degrades to a single group without losing that tooth — there is no such thing as a one-tooth bridge. A cross-arch span is impossible and is reported rather than guessed at. Prosthesis expands a default across the selection then applies per-tooth overrides, because "همه زیرکونیا، ۲۶ پی‌اف‌ام" is how clinicians actually speak. Completeness is computed here so an unshippable map surfaces at review rather than failing later at dispatch. Everything the model names is checked against the catalog we supplied it, and anything rejected is reported rather than dropped — a hallucinated lab id must not look identical to "no lab was spoken", since silence and a wrong lab lead to very different corrective actions. Also fixed, from review of this commit: - an empty prosthesis object no longer fabricates an "incomplete, cannot ship" warning on a plain restoration - an override naming a tooth outside the selection now reports tooth_not_selected rather than malformed; the clinician was understood, the tooth just is not on this detail - a due object with no `kind` is treated as no deadline rather than a blank "heard but lost" row; an unrecognised kind is still flagged, and named Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-20 17:41:41 +03:30
/**
* Sort teeth along the arch, not lexically a bridge reads 16-15-14, and 11 sits beside
* 21 across the midline. Teeth from another arch (or unknown) 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));
}