feature: FDI tooth chart UI updated.

This commit is contained in:
2026-05-08 03:58:53 +03:30
parent c2d490a5e7
commit 1a11323b74
7 changed files with 581 additions and 149 deletions

View File

@@ -1,10 +1,20 @@
'use client';
import { useId } from 'react';
import { useId, useState } from 'react';
import { FDI_LOWER_LEFT_TO_RIGHT, FDI_UPPER_LEFT_TO_RIGHT, getToothShapeKind } from '@/components/treatment/fdiToothMeta';
import { ToothGlyph } from '@/components/ui/treatment/ToothGlyph';
import { Dropdown } from '@/components/ui/common/Dropdown';
import { FDI_CHART_DESIGNS, type FdiChartDesignId } from '@/components/ui/treatment/fdiChartDesigns';
import type { FdiToothId } from '@/types/treatment';
const TOOTH_NUMBER_GAP = 'mt-1';
/** Six teeth each side of the midline (anteriors): slightly larger glyph + wrapper. */
const MIDLINE_SIX = new Set<FdiToothId>([
'13', '12', '11', '21', '22', '23',
'43', '42', '41', '31', '32', '33',
]);
function quadrantMirrored(fdi: FdiToothId): boolean {
const q = fdi[0];
return q === '2' || q === '3';
@@ -18,36 +28,123 @@ interface FdiToothChartProps {
export function FdiToothChart({ selected, onToggle, disabled }: FdiToothChartProps) {
const uid = useId().replace(/:/g, '');
const archPeak = 10;
const [chartDesign, setChartDesign] = useState<FdiChartDesignId>('clinical');
const Row = ({ teeth }: { teeth: FdiToothId[] }) => (
<div className="flex flex-wrap justify-center gap-x-1 gap-y-2">
const TOOTH_TWEAKS: Record<FdiToothId, { glyph: string; offset: number; rotate: number }> = {
'18': { glyph: 'w-[1.9rem] h-[4.5rem]', offset: 7, rotate: -11 },
'17': { glyph: 'w-[2rem] h-[4.75rem]', offset: 8, rotate: -8 },
'16': { glyph: 'w-[2.1rem] h-[4.95rem]', offset: 9, rotate: -6 },
'15': { glyph: 'w-[1.8rem] h-[4.8rem]', offset: 9, rotate: -4 },
'14': { glyph: 'w-[1.8rem] h-[4.85rem]', offset: 9, rotate: -2 },
'13': { glyph: 'w-[1.9rem] h-[5.28rem]', offset: 8, rotate: -1 },
'12': { glyph: 'w-[1.8rem] h-[5.02rem]', offset: 7, rotate: -1 },
'11': { glyph: 'w-[1.8rem] h-[5.08rem]', offset: 7, rotate: -0.5 },
'21': { glyph: 'w-[1.8rem] h-[5.08rem]', offset: 7, rotate: 0.5 },
'22': { glyph: 'w-[1.8rem] h-[5.02rem]', offset: 7, rotate: 1 },
'23': { glyph: 'w-[1.9rem] h-[5.28rem]', offset: 8, rotate: 1 },
'24': { glyph: 'w-[1.8rem] h-[4.85rem]', offset: 9, rotate: 2 },
'25': { glyph: 'w-[1.8rem] h-[4.8rem]', offset: 9, rotate: 4 },
'26': { glyph: 'w-[2.1rem] h-[4.95rem]', offset: 9, rotate: 6 },
'27': { glyph: 'w-[2rem] h-[4.75rem]', offset: 8, rotate: 8 },
'28': { glyph: 'w-[1.9rem] h-[4.5rem]', offset: 7, rotate: 11 },
'48': { glyph: 'w-[1.9rem] h-[4.55rem]', offset: -7, rotate: -11 },
'47': { glyph: 'w-[2rem] h-[4.8rem]', offset: -8, rotate: -8 },
'46': { glyph: 'w-[2.15rem] h-[5rem]', offset: -9, rotate: -6 },
'45': { glyph: 'w-[1.82rem] h-[4.85rem]', offset: -9, rotate: -4 },
'44': { glyph: 'w-[1.82rem] h-[4.9rem]', offset: -9, rotate: -2 },
'43': { glyph: 'w-[1.88rem] h-[5.34rem]', offset: -8, rotate: -1 },
'42': { glyph: 'w-[1.76rem] h-[5.04rem]', offset: -7, rotate: -1 },
'41': { glyph: 'w-[1.74rem] h-[4.98rem]', offset: -7, rotate: -0.5 },
'31': { glyph: 'w-[1.74rem] h-[4.98rem]', offset: -7, rotate: 0.5 },
'32': { glyph: 'w-[1.76rem] h-[5.04rem]', offset: -7, rotate: 1 },
'33': { glyph: 'w-[1.88rem] h-[5.34rem]', offset: -8, rotate: 1 },
'34': { glyph: 'w-[1.82rem] h-[4.9rem]', offset: -9, rotate: 2 },
'35': { glyph: 'w-[1.82rem] h-[4.85rem]', offset: -9, rotate: 4 },
'36': { glyph: 'w-[2.15rem] h-[5rem]', offset: -9, rotate: 6 },
'37': { glyph: 'w-[2rem] h-[4.8rem]', offset: -8, rotate: 8 },
'38': { glyph: 'w-[1.9rem] h-[4.55rem]', offset: -7, rotate: 11 },
};
const toothSizeClass = (fdi: FdiToothId) => {
const kind = getToothShapeKind(fdi);
const mid = MIDLINE_SIX.has(fdi);
switch (kind) {
case 'incisor':
return mid
? { wrapper: 'w-[2.1rem]', glyph: 'w-[1.68rem] h-[4.78rem]' }
: { wrapper: 'w-[2rem]', glyph: 'w-[1.6rem] h-[4.6rem]' };
case 'canine':
return mid
? { wrapper: 'w-[2.2rem]', glyph: 'w-[1.78rem] h-[5.05rem]' }
: { wrapper: 'w-[2.1rem]', glyph: 'w-[1.7rem] h-[4.9rem]' };
case 'premolar':
return mid
? { wrapper: 'w-[2.34rem]', glyph: 'w-[1.93rem] h-[5.02rem]' }
: { wrapper: 'w-[2.25rem]', glyph: 'w-[1.85rem] h-[4.85rem]' };
case 'molar':
return mid
? { wrapper: 'w-[2.52rem]', glyph: 'w-[2.12rem] h-[5.1rem]' }
: { wrapper: 'w-[2.45rem]', glyph: 'w-[2.05rem] h-[4.95rem]' };
default:
return { wrapper: 'w-[2.25rem]', glyph: 'w-[1.85rem] h-[4.85rem]' };
}
};
const archOffset = (index: number, count: number, upper?: boolean) => {
const center = (count - 1) / 2;
const dist = Math.abs(index - center) / center;
const curve = (1 - Math.pow(dist, 1.45)) * archPeak;
return upper ? curve : -curve;
};
const archRotate = (index: number, count: number) => {
const center = (count - 1) / 2;
const normalized = (index - center) / center;
return normalized * 6;
};
const Row = ({ teeth, upper }: { teeth: FdiToothId[]; upper?: boolean }) => (
<div className="flex flex-wrap justify-center gap-x-1 gap-y-1">
{teeth.map((fdi, i) => {
const kind = getToothShapeKind(fdi);
const gid = `${uid}-g-${fdi}-${i}`;
const isSel = selected.has(fdi);
const size = toothSizeClass(fdi);
const tweak = TOOTH_TWEAKS[fdi];
const offsetY = tweak ? tweak.offset : archOffset(i, teeth.length, upper);
const rotate = tweak ? tweak.rotate : archRotate(i, teeth.length);
const alignItems = upper ? 'items-end' : 'items-start';
return (
<div key={fdi} className="flex flex-col items-center w-[2.25rem]">
<button
type="button"
disabled={disabled}
onClick={() => onToggle(fdi)}
className={`
rounded-[var(--radius-sm)] p-0.5 transition-transform
focus:outline-none focus-visible:ring-2 focus-visible:ring-primary/50
${disabled ? 'opacity-50 cursor-not-allowed' : 'hover:scale-105 active:scale-95'}
`}
aria-pressed={isSel}
aria-label={`FDI tooth ${fdi}${isSel ? ', selected' : ''}`}
>
<ToothGlyph kind={kind} gradientId={gid} selected={isSel} mirrored={quadrantMirrored(fdi)} />
</button>
<span
className={`text-[10px] tabular-nums leading-none mt-0.5 font-medium ${
isSel ? 'text-primary' : 'text-text-muted'
}`}
>
{fdi}
</span>
<div key={fdi} className={`flex flex-col items-center ${size.wrapper}`}>
<div className={`h-[5.9rem] flex ${alignItems} justify-center`}>
<button
type="button"
disabled={disabled}
onClick={() => onToggle(fdi)}
style={{ transform: `translateY(${offsetY}px) rotate(${rotate}deg)` }}
className={`
rounded-[var(--radius-sm)] p-0.5 transition-transform
focus:outline-none focus-visible:ring-2 focus-visible:ring-primary/50
${disabled ? 'opacity-50 cursor-not-allowed' : 'hover:scale-105 active:scale-95'}
`}
aria-pressed={isSel}
aria-label={`FDI tooth ${fdi}${isSel ? ', selected' : ''}`}
>
<ToothGlyph
variant={chartDesign}
fdi={fdi}
kind={kind}
gradientId={gid}
selected={isSel}
upper={upper}
mirrored={quadrantMirrored(fdi)}
upsideDown={upper}
className={tweak?.glyph ?? size.glyph}
/>
</button>
</div>
</div>
);
})}
@@ -56,28 +153,88 @@ export function FdiToothChart({ selected, onToggle, disabled }: FdiToothChartPro
return (
<div className="surface-card p-4 space-y-6">
<div className="flex items-start justify-between gap-3 flex-wrap">
<div className="flex flex-col gap-3 sm:flex-row sm:items-start sm:justify-between">
<div>
<h3 className="text-sm font-semibold text-text-primary">FDI tooth chart</h3>
<p className="text-xs text-text-muted mt-0.5">
Tap teeth to multi-select (FDI). Selection applies to the active treatment record until you save.
</p>
</div>
<p className="text-xs text-text-secondary tabular-nums">
Selected: {selected.size === 0 ? '—' : [...selected].sort().join(', ')}
</p>
<div className="flex flex-col gap-2 sm:items-end w-full sm:max-w-xs">
<Dropdown
label="Chart style (temporary)"
value={chartDesign}
onChange={(e) => setChartDesign(e.target.value as FdiChartDesignId)}
>
{FDI_CHART_DESIGNS.map((d) => (
<option key={d.id} value={d.id}>
{d.label}
</option>
))}
</Dropdown>
<p className="text-xs text-text-secondary tabular-nums text-right">
Selected: {selected.size === 0 ? '—' : [...selected].sort().join(', ')}
</p>
</div>
</div>
<div className="space-y-6">
<div>
<p className="text-[11px] uppercase tracking-wide text-text-muted mb-2 text-center">Upper arch</p>
<Row teeth={FDI_UPPER_LEFT_TO_RIGHT} />
</div>
<div className="border-t border-border/60 pt-4">
<p className="text-[11px] uppercase tracking-wide text-text-muted mb-2 text-center">Lower arch</p>
<Row teeth={FDI_LOWER_LEFT_TO_RIGHT} />
<p className="text-[11px] uppercase tracking-wide text-text-muted mb-1 text-center">Upper arch</p>
<div className="relative isolate py-1">
<div
className="pointer-events-none absolute left-1/2 top-3 bottom-3 w-px -translate-x-1/2 bg-border/70"
aria-hidden
/>
<div className="relative z-10 space-y-0">
<Row teeth={FDI_UPPER_LEFT_TO_RIGHT} upper />
<div className={`flex justify-center gap-x-1 ${TOOTH_NUMBER_GAP}`}>
{FDI_UPPER_LEFT_TO_RIGHT.map((fdi) => {
const size = toothSizeClass(fdi);
const isSel = selected.has(fdi);
return (
<span
key={`u-${fdi}`}
className={`text-[10px] tabular-nums text-center leading-none ${size.wrapper} ${
isSel ? 'text-primary' : 'text-text-muted'
}`}
>
{fdi}
</span>
);
})}
</div>
<div
className="my-4 h-px w-full max-w-[min(100%,42rem)] mx-auto bg-border/70"
role="separator"
aria-hidden
/>
<div className="my-4 pt-1">
<div className="flex justify-center gap-x-1">
{FDI_LOWER_LEFT_TO_RIGHT.map((fdi) => {
const size = toothSizeClass(fdi);
const isSel = selected.has(fdi);
return (
<span
key={`l-${fdi}`}
className={`text-[10px] tabular-nums text-center leading-none ${size.wrapper} ${
isSel ? 'text-primary' : 'text-text-muted'
}`}
>
{fdi}
</span>
);
})}
</div>
<div className={TOOTH_NUMBER_GAP}>
<Row teeth={FDI_LOWER_LEFT_TO_RIGHT} />
</div>
</div>
</div>
</div>
<p className="text-[11px] uppercase tracking-wide text-text-muted mt-1 text-center">Lower arch</p>
</div>
);
}

View File

@@ -1,140 +1,150 @@
'use client';
import { memo } from 'react';
import { memo, type ReactNode } from 'react';
import type { FdiToothId } from '@/types/treatment';
import type { ToothShapeKind } from '@/components/treatment/fdiToothMeta';
import type { FdiChartDesignId } from '@/components/ui/treatment/fdiChartDesigns';
import { getToothGlyphPaint } from '@/components/ui/treatment/toothGlyphPaint';
import type { ToothGlyphPaint } from '@/components/ui/treatment/toothGlyphPaint';
import { getToothPathModel, type ToothPathModel } from '@/components/ui/treatment/toothPathModel';
import { getTextbookToothPathModel } from '@/components/ui/treatment/toothPathModelTextbook';
import { wireframeInnerScale, wireframeOriginY } from '@/components/ui/treatment/toothGlyphStructural';
interface ToothGlyphProps {
fdi: FdiToothId;
kind: ToothShapeKind;
gradientId: string;
selected: boolean;
upper?: boolean;
mirrored?: boolean;
upsideDown?: boolean;
className?: string;
variant?: FdiChartDesignId;
}
/** Stylized single tooth for FDI chart (outline + radial fill). Crown above, root(s) below. */
function filledSilhouette(
model: ToothPathModel,
crownFill: string,
rootFill: string,
stroke: string,
strokeW: number,
): ReactNode {
return (
<>
<path d={model.crown} fill={crownFill} stroke={stroke} strokeWidth={strokeW} strokeLinejoin="round" />
{model.roots.map((d, i) => (
<path key={i} d={d} fill={rootFill} stroke={stroke} strokeWidth={strokeW} strokeLinejoin="round" />
))}
</>
);
}
function detailOnly(model: ToothPathModel, stroke: string, strokeW: number): ReactNode {
if (!model.detailStroke) return null;
const { d, widthScale } = model.detailStroke;
return <path d={d} fill="none" stroke={stroke} strokeWidth={strokeW * widthScale} strokeLinecap="round" />;
}
function renderClinicalFill(model: ToothPathModel, paint: ToothGlyphPaint): ReactNode {
const { crownFill, rootFill, stroke, strokeW } = paint;
return (
<>
{filledSilhouette(model, crownFill, rootFill, stroke, strokeW)}
{detailOnly(model, stroke, strokeW)}
</>
);
}
function renderWireframe(model: ToothPathModel, paint: ToothGlyphPaint, kind: ToothShapeKind): ReactNode {
const { stroke, strokeW } = paint;
const outer = paint.wireOuter ?? '#f8fafc';
const innerCol = paint.wireInner ?? '#64748b';
const s = wireframeInnerScale(kind);
const crownOy = wireframeOriginY('crown', kind);
const rootOy = wireframeOriginY('root', kind);
const rootScale = (i: number) => s - (model.roots.length > 1 ? i * 0.018 : 0);
return (
<>
<g>
<path d={model.crown} fill="none" stroke={outer} strokeWidth={strokeW + 2.2} strokeLinejoin="round" />
<path d={model.crown} fill="none" stroke={stroke} strokeWidth={strokeW} strokeLinejoin="round" />
<g transform={`translate(18 ${crownOy}) scale(${s}) translate(-18 ${-crownOy})`}>
<path d={model.crown} fill="none" stroke={innerCol} strokeWidth="0.6" strokeLinejoin="round" opacity={0.7} />
</g>
</g>
{model.roots.map((d, i) => (
<g key={i}>
<path d={d} fill="none" stroke={outer} strokeWidth={strokeW + 2.2} strokeLinejoin="round" />
<path d={d} fill="none" stroke={stroke} strokeWidth={strokeW} strokeLinejoin="round" />
<g transform={`translate(18 ${rootOy}) scale(${rootScale(i)}) translate(-18 ${-rootOy})`}>
<path d={d} fill="none" stroke={innerCol} strokeWidth="0.6" strokeLinejoin="round" opacity={0.65} />
</g>
</g>
))}
{detailOnly(model, stroke, strokeW)}
</>
);
}
/** Clinical uses FDI-specific paths; wireframe uses textbook silhouettes + line sculpture (original combo). */
export const ToothGlyph = memo(function ToothGlyph({
fdi,
kind,
gradientId,
selected,
upper,
mirrored,
upsideDown,
className = 'w-8 h-[4.85rem]',
variant = 'clinical',
}: ToothGlyphProps) {
const stroke = selected ? 'var(--color-primary)' : '#1e293b';
const strokeW = selected ? 2.4 : 1.35;
const filter = selected ? 'drop-shadow(0 0 6px rgba(9, 169, 188, 0.65))' : undefined;
const lastDigit = Number(fdi[1]);
const model: ToothPathModel | null =
variant === 'clinical'
? getToothPathModel(fdi, kind, upper)
: getTextbookToothPathModel(kind, upper, lastDigit);
const paint = getToothGlyphPaint(variant, gradientId, selected);
const { showClinicalGradient, filter, structuralMode } = paint;
const inner = () => {
switch (kind) {
case 'incisor':
return (
<>
<path
d="M10 7h16q3 0 3 3v22q0 3-3 3h-16q-3 0-3-3v-22q0-3 3-3z"
fill={`url(#${gradientId})`}
stroke={stroke}
strokeWidth={strokeW}
strokeLinejoin="round"
/>
<path
d="M14 35v26q4 10 8 0v-26"
fill={`url(#${gradientId})`}
stroke={stroke}
strokeWidth={strokeW}
strokeLinejoin="round"
/>
</>
);
case 'canine':
return (
<>
<path
d="M18 6 L26 14 L24 35 H12 L10 14 Z"
fill={`url(#${gradientId})`}
stroke={stroke}
strokeWidth={strokeW}
strokeLinejoin="round"
/>
<path
d="M14 35 L12 62 Q18 74 24 62 L22 35 Z"
fill={`url(#${gradientId})`}
stroke={stroke}
strokeWidth={strokeW}
strokeLinejoin="round"
/>
</>
);
case 'premolar':
return (
<>
<path
d="M8 12 Q18 6 28 12 L30 22 Q31 28 26 34 H10 Q5 28 6 22 Z"
fill={`url(#${gradientId})`}
stroke={stroke}
strokeWidth={strokeW}
strokeLinejoin="round"
/>
<path
d="M11 34 L10 58 Q18 68 26 58 L25 34 Q18 30 11 34 Z"
fill={`url(#${gradientId})`}
stroke={stroke}
strokeWidth={strokeW}
strokeLinejoin="round"
/>
</>
);
case 'molar':
return (
<>
<path
d="M6 14 Q10 8 14 10 Q18 7 22 10 Q26 8 30 14 L31 26 Q32 32 27 36 H9 Q4 32 5 26 Z"
fill={`url(#${gradientId})`}
stroke={stroke}
strokeWidth={strokeW}
strokeLinejoin="round"
/>
<path
d="M10 36 L8 56 Q14 66 18 58 Q22 66 28 56 L26 36 Q18 32 10 36 Z"
fill={`url(#${gradientId})`}
stroke={stroke}
strokeWidth={strokeW}
strokeLinejoin="round"
/>
<path
d="M12 58 Q18 72 24 58"
fill="none"
stroke={stroke}
strokeWidth={strokeW * 0.85}
strokeLinecap="round"
/>
</>
);
default:
return null;
}
};
if (!model) {
return (
<svg viewBox="0 0 36 78" className={`${className} shrink-0`} aria-hidden>
<text x="4" y="40" fontSize="8" fill="currentColor" opacity="0.35">
?
</text>
</svg>
);
}
const body = structuralMode === 'wireframe' ? renderWireframe(model, paint, kind) : renderClinicalFill(model, paint);
return (
<svg
viewBox="0 0 36 78"
className="w-8 h-[4.85rem] shrink-0"
className={`${className} shrink-0`}
style={{ filter }}
aria-hidden
>
<defs>
<radialGradient id={gradientId} cx="45%" cy="35%" r="65%">
<stop
offset="0%"
stopColor={selected ? '#cffafe' : '#ffffff'}
/>
<stop
offset="55%"
stopColor={selected ? '#5eead4' : '#e0f2fe'}
/>
<stop
offset="100%"
stopColor={selected ? '#0e7490' : '#93c5fd'}
/>
</radialGradient>
</defs>
<g transform={mirrored ? 'translate(36 0) scale(-1 1)' : undefined}>{inner()}</g>
{showClinicalGradient && (
<defs>
<radialGradient id={gradientId} cx="45%" cy="35%" r="65%">
<stop offset="0%" stopColor={selected ? '#cffafe' : '#ffffff'} />
<stop offset="55%" stopColor={selected ? '#5eead4' : '#e0f2fe'} />
<stop offset="100%" stopColor={selected ? '#0e7490' : '#93c5fd'} />
</radialGradient>
</defs>
)}
<g
transform={[
mirrored ? 'translate(36 0) scale(-1 1)' : '',
upsideDown ? 'translate(0 78) scale(1 -1)' : '',
]
.filter(Boolean)
.join(' ') || undefined}
>
{body}
</g>
</svg>
);
});

View File

@@ -0,0 +1,6 @@
export const FDI_CHART_DESIGNS = [
{ id: 'clinical', label: 'Clinical (gradient + roots)' },
{ id: 'wireframeSculpt', label: 'Wireframe shells (line sculpture)' },
] as const;
export type FdiChartDesignId = (typeof FDI_CHART_DESIGNS)[number]['id'];

View File

@@ -0,0 +1,53 @@
import type { FdiChartDesignId } from '@/components/ui/treatment/fdiChartDesigns';
export type ToothStructuralMode = 'fill' | 'wireframe';
export interface ToothGlyphPaint {
crownFill: string;
rootFill: string;
stroke: string;
strokeW: number;
showClinicalGradient: boolean;
structuralMode: ToothStructuralMode;
filter?: string;
wireOuter?: string;
wireInner?: string;
}
export function getToothGlyphPaint(
variant: FdiChartDesignId,
gradientId: string,
selected: boolean,
): ToothGlyphPaint {
const strokeSel = 'var(--color-primary)';
const strokeDefault = '#1e293b';
if (variant === 'clinical') {
const g = `url(#${gradientId})`;
return {
crownFill: g,
rootFill: g,
stroke: selected ? strokeSel : strokeDefault,
strokeW: selected ? 2.4 : 1.35,
showClinicalGradient: true,
structuralMode: 'fill',
filter: selected ? 'drop-shadow(0 0 6px rgba(9, 169, 188, 0.65))' : undefined,
};
}
if (variant === 'wireframeSculpt') {
return {
crownFill: 'none',
rootFill: 'none',
stroke: selected ? strokeSel : '#0f172a',
strokeW: selected ? 1.65 : 1.05,
showClinicalGradient: false,
structuralMode: 'wireframe',
wireOuter: selected ? '#ecfeff' : '#f8fafc',
wireInner: '#64748b',
};
}
const _exhaustive: never = variant;
return _exhaustive;
}

View File

@@ -0,0 +1,15 @@
import type { ToothShapeKind } from '@/components/treatment/fdiToothMeta';
/** Inner “shell” contours: scaled copies of closed paths (wireframe). */
export function wireframeInnerScale(kind: ToothShapeKind): number {
if (kind === 'molar') return 0.9;
if (kind === 'premolar') return 0.88;
if (kind === 'canine') return 0.87;
return 0.85;
}
/** Approximate transform origin per region for scaled wireframe rings. */
export function wireframeOriginY(part: 'crown' | 'root', kind: ToothShapeKind): number {
if (part === 'crown') return kind === 'canine' ? 16 : 18;
return kind === 'molar' ? 50 : kind === 'premolar' ? 46 : 48;
}

View File

@@ -0,0 +1,113 @@
import type { FdiToothId } from '@/types/treatment';
import type { ToothShapeKind } from '@/components/treatment/fdiToothMeta';
export interface ToothPathModel {
crown: string;
roots: string[];
/** Occlusal groove etc. — open path, stroke only */
detailStroke?: { d: string; widthScale: number };
}
/** FDI-specific closed paths for the clinical (gradient) chart. Wireframe uses `getTextbookToothPathModel`. */
export function getToothPathModel(fdi: FdiToothId, kind: ToothShapeKind, upper?: boolean): ToothPathModel | null {
const lastDigit = Number(fdi[1]);
const isThirdMolar = lastDigit === 8;
const isSecondMolar = lastDigit === 7;
const isFirstMolar = lastDigit === 6;
const isCentralIncisor = lastDigit === 1;
const isLateralIncisor = lastDigit === 2;
switch (kind) {
case 'incisor':
if (isLateralIncisor) {
return {
crown: 'M12 9 C14 6, 22 6, 24 9 C26 12, 26 25, 23 30 C21 32, 15 32, 13 30 C10 25, 10 12, 12 9 Z',
roots: ['M15.5 30 C14.5 40, 14.5 51, 16 60 C17 65, 19 65, 20 60 C21.5 51, 21.5 40, 20.5 30 Z'],
};
}
return {
crown: 'M11 8 C13 5, 23 5, 25 8 C27 12, 27 26, 24 31 C22 33, 14 33, 12 31 C9 26, 9 12, 11 8 Z',
roots: ['M15 31 C14 42, 14 53, 16 63 C17 68, 19 68, 20 63 C22 53, 22 42, 21 31 Z'],
};
case 'canine':
return {
crown: 'M18 6 C22 6, 25 10, 26 15 C26 20, 24 30, 22 33 C20 35, 16 35, 14 33 C12 30, 10 20, 10 15 C11 10, 14 6, 18 6 Z',
roots: ['M16 33 C14 44, 13 55, 14 66 C15 72, 18 75, 20 66 C21 55, 20 44, 20 33 Z'],
};
case 'premolar':
if (upper && lastDigit === 4) {
return {
crown: 'M9 12 C11 8, 15 7, 18 9 C21 7, 25 8, 27 12 C29 16, 29 25, 26 31 C24 34, 12 34, 10 31 C7 25, 7 16, 9 12 Z',
roots: [
'M13 31 C12 39, 12 48, 13 56 C14 62, 16 64, 17 58 C18 54, 18 48, 18 41 Z',
'M23 31 C24 39, 24 48, 23 56 C22 62, 20 64, 19 58 C18 54, 18 48, 18 41 Z',
],
};
}
return {
crown: 'M9 12 C11 8, 15 7, 18 9 C21 7, 25 8, 27 12 C29 16, 29 26, 26 31 C24 34, 12 34, 10 31 C7 26, 7 16, 9 12 Z',
roots: ['M16 31 C15 42, 15 53, 16 62 C17 67, 19 67, 20 62 C21 53, 21 42, 20 31 Z'],
};
case 'molar':
if (isThirdMolar) {
const crown =
'M9 15 C11 11, 14 10, 17 11 C20 9, 24 10, 26 14 C28 18, 28 26, 25 31 C23 33, 11 33, 9 31 C6 26, 6 19, 9 15 Z';
if (upper) {
return {
crown,
roots: [
'M14 32 C13 39, 13 47, 14 54 C15 59, 17 61, 18 55 C19 50, 19 41, 18 33 Z',
'M22 32 C23 39, 23 47, 22 54 C21 59, 19 61, 18 55 C17 50, 17 41, 18 33 Z',
],
};
}
return {
crown,
roots: [
'M14 32 C12 39, 12 47, 13 55 C14 61, 16 64, 18 57 C19 52, 19 44, 18 36 Z',
'M22 32 C24 39, 24 47, 23 55 C22 61, 20 64, 18 57 C17 52, 17 44, 18 36 Z',
],
};
}
if (isSecondMolar && upper) {
return {
crown: 'M8 14 C10 9, 13 8, 16 10 C18 8, 21 8, 23 10 C26 8, 29 9, 30 14 C31 18, 31 27, 28 32 C26 35, 10 35, 8 32 C5 27, 5 18, 8 14 Z',
roots: [
'M11 35 C9 43, 9 50, 10 57 C11 62, 13 64, 14 58 C15 52, 15 44, 14 36 Z',
'M18 35 C17 44, 17 52, 18 60 C19 66, 20 66, 21 60 C22 52, 22 44, 21 35 Z',
'M25 35 C27 43, 27 50, 26 57 C25 62, 23 64, 22 58 C21 52, 21 44, 22 36 Z',
],
};
}
if (isFirstMolar && !upper) {
return {
crown: 'M7 14 C9 9, 13 8, 16 10 C18 8, 21 8, 23 10 C26 8, 30 9, 31 14 C32 18, 32 27, 29 32 C27 35, 9 35, 7 32 C4 27, 4 18, 7 14 Z',
roots: [
'M11 35 C9 43, 9 50, 10 58 C11 64, 14 67, 16 60 C17 56, 17 50, 17 44 Z',
'M25 35 C27 43, 27 50, 26 58 C25 64, 22 67, 20 60 C19 56, 19 50, 19 44 Z',
],
detailStroke: { d: 'M15 61 Q18 66 21 61', widthScale: 0.85 },
};
}
if (upper) {
return {
crown: 'M7 14 C9 9, 13 8, 16 10 C18 8, 21 8, 23 10 C26 8, 30 9, 31 14 C32 18, 32 27, 29 32 C27 35, 9 35, 7 32 C4 27, 4 18, 7 14 Z',
roots: [
'M11 35 C9 43, 9 50, 10 57 C11 62, 13 64, 14 58 C15 52, 15 44, 14 36 Z',
'M18 35 C17 44, 17 52, 18 60 C19 66, 20 66, 21 60 C22 52, 22 44, 21 35 Z',
'M25 35 C27 43, 27 50, 26 57 C25 62, 23 64, 22 58 C21 52, 21 44, 22 36 Z',
],
};
}
return {
crown: 'M7 14 C9 9, 13 8, 16 10 C18 8, 21 8, 23 10 C26 8, 30 9, 31 14 C32 18, 32 27, 29 32 C27 35, 9 35, 7 32 C4 27, 4 18, 7 14 Z',
roots: [
'M11 35 C9 43, 9 50, 10 58 C11 64, 14 67, 16 60 C17 56, 17 50, 17 44 Z',
'M25 35 C27 43, 27 50, 26 58 C25 64, 22 67, 20 60 C19 56, 19 50, 19 44 Z',
],
detailStroke: { d: 'M15 61 Q18 66 21 61', widthScale: 0.85 },
};
default:
return null;
}
}

View File

@@ -0,0 +1,78 @@
import type { ToothShapeKind } from '@/components/treatment/fdiToothMeta';
import type { ToothPathModel } from '@/components/ui/treatment/toothPathModel';
/**
* Textbook-style silhouettes (chisel incisor, spear canine, bicuspid premolar, blocky molar).
* Used **only** for “Wireframe shells (line sculpture)” so shapes read like the first presentation,
* distinct from the clinical FDI-accurate paths.
*/
export function getTextbookToothPathModel(
kind: ToothShapeKind,
upper: boolean | undefined,
lastDigit: number,
): ToothPathModel {
switch (kind) {
case 'incisor': {
const lateral = lastDigit === 2;
if (lateral) {
return {
crown:
'M13 8 L23 8 L24 11 L24.5 26 Q23 30.5 18 31.5 Q13 30.5 11.5 26 L12 11 Z',
roots: ['M15.5 31.5 L20.5 31.5 L20 48 L18.5 69 L17.5 69 L16 48 Z'],
};
}
return {
crown:
'M12 8 L24 8 L25 11 L25.5 27 Q24 31 18 32 Q12 31 10.5 27 L11 11 Z',
roots: ['M15 32 L21 32 L20.2 50 L18 70 L17 70 L15.8 50 Z'],
};
}
case 'canine':
return {
crown:
'M18 6 L22 11 L24.5 18 L24 28 L21.5 32 L14.5 32 L12 28 L11.5 18 L14 11 Z',
roots: ['M15 32 L21 32 L20.5 52 L19 73 L17 73 L15.5 52 Z'],
};
case 'premolar':
return {
crown:
'M8 14 L13 9 Q15.5 7.5 18 9.5 Q20.5 7.5 23 9 L28 14 L29.5 25 L27 32 L9 32 L6.5 25 Z',
roots: [
'M13 31.5 L16.5 31.5 L15 52 L12.5 70 L10.5 69 L12 52 Z',
'M19.5 31.5 L23 31.5 L24.5 52 L25.5 69 L23.5 70 L22 52 Z',
],
};
case 'molar': {
const crown =
'M6 14 L8 9 L12.5 7.5 Q18 9.5 23.5 7.5 L28 9 L30 14 L31 25 L29 32.5 L7 32.5 L5 25 Z';
const upperMolar = upper === true && lastDigit >= 6 && lastDigit <= 8;
if (upperMolar) {
return {
crown,
roots: [
'M8.5 32.5 L12 32.5 L10.5 54 L9 71 L7 69.5 L8.5 54 Z',
'M15.5 32.5 L20.5 32.5 L19 56 L18 72 L16.5 72 L15.5 56 Z',
'M24 32.5 L27.5 32.5 L27.5 54 L29 71 L27 69.5 L26 54 Z',
],
detailStroke: {
d: 'M11 10.5 Q18 12.5 25 10.5 M10 18 Q18 20 26 18',
widthScale: 0.55,
},
};
}
return {
crown,
roots: [
'M9 32.5 L15 32.5 L13 56 L10.5 71 L8 69.5 L10 56 Z',
'M21 32.5 L27 32.5 L26 56 L27.5 71 L25 69.5 L24 56 Z',
],
detailStroke: {
d: 'M12 11 Q18 13.5 24 11 M11 19 Q18 21.5 25 19 M16 62 Q18 65 20 62',
widthScale: 0.5,
},
};
}
default:
return { crown: 'M10 10 L26 10 L26 60 L10 60 Z', roots: [] };
}
}