{FDI_LOWER_LEFT_TO_RIGHT.map((fdi) => {
const size = toothSizeClass(fdi);
const isSel = selected.has(fdi);
return (
{fdi}
diff --git a/frontend/src/components/ui/treatment/ToothGlyph.tsx b/frontend/src/components/ui/treatment/ToothGlyph.tsx
index 2e0f7be..f4ea2ec 100644
--- a/frontend/src/components/ui/treatment/ToothGlyph.tsx
+++ b/frontend/src/components/ui/treatment/ToothGlyph.tsx
@@ -1,9 +1,13 @@
'use client';
-import { memo, type ReactNode } from 'react';
+import { memo, type CSSProperties, type ReactNode } from 'react';
import type { FdiToothId } from '@/types/treatment';
import type { ToothShapeKind } from '@/components/treatment/fdiToothMeta';
import { getToothPathModel, type ToothPathModel } from '@/components/treatment/toothPathModel';
+import {
+ getRealisticToothAsset,
+ type RealisticToothAsset,
+} from '@/components/treatment/realisticToothAssets';
interface ToothGlyphProps {
fdi: FdiToothId;
@@ -14,6 +18,7 @@ interface ToothGlyphProps {
mirrored?: boolean;
upsideDown?: boolean;
className?: string;
+ style?: CSSProperties;
/** When set, selected tooth glow + fill use this color instead of primary. */
accentColor?: string;
}
@@ -34,19 +39,40 @@ function rgbaFromHex(hex: string, alpha: number): string {
return `rgba(${rgb.r}, ${rgb.g}, ${rgb.b}, ${alpha})`;
}
+function mixHex(hex: string, toward: string, amount: number): string {
+ const a = hexToRgb(hex);
+ const b = hexToRgb(toward);
+ if (!a || !b) return hex;
+ const t = Math.min(1, Math.max(0, amount));
+ const r = Math.round(a.r + (b.r - a.r) * t);
+ const g = Math.round(a.g + (b.g - a.g) * t);
+ const bl = Math.round(a.b + (b.b - a.b) * t);
+ return `#${[r, g, bl].map((n) => n.toString(16).padStart(2, '0')).join('')}`;
+}
+
function filledSilhouette(
- model: ToothPathModel,
+ model: Pick
,
crownFill: string,
rootFill: string,
stroke: string,
strokeW: number,
+ rootsFirst = false,
): ReactNode {
- return (
+ const crown = (
+
+ );
+ const roots = model.roots.map((d, i) => (
+
+ ));
+ return rootsFirst ? (
<>
-
- {model.roots.map((d, i) => (
-
- ))}
+ {roots}
+ {crown}
+ >
+ ) : (
+ <>
+ {crown}
+ {roots}
>
);
}
@@ -74,6 +100,27 @@ function renderClinicalFill(
);
}
+function renderRealisticFill(
+ asset: RealisticToothAsset,
+ selected: boolean,
+ crownGradId: string,
+ rootGradId: string,
+ accentColor?: string,
+): ReactNode {
+ const stroke = selected
+ ? (accentColor ?? 'var(--color-primary)')
+ : 'var(--tooth-stroke)';
+ const strokeW = selected ? 1.05 : 0.55;
+ return filledSilhouette(
+ asset,
+ `url(#${crownGradId})`,
+ `url(#${rootGradId})`,
+ stroke,
+ strokeW,
+ true,
+ );
+}
+
/** FDI chart tooth: clinical gradient + roots only. */
export const ToothGlyph = memo(function ToothGlyph({
fdi,
@@ -85,17 +132,78 @@ export const ToothGlyph = memo(function ToothGlyph({
upsideDown,
className = 'w-8 h-[4.85rem]',
accentColor,
+ style,
}: ToothGlyphProps) {
- const model: ToothPathModel | null = getToothPathModel(fdi, kind, upper);
+ const realistic = getRealisticToothAsset(fdi);
const filter = selected
? accentColor
? `drop-shadow(0 0 6px ${rgbaFromHex(accentColor, 0.75)})`
: 'drop-shadow(0 0 6px rgba(9, 169, 188, 0.65))'
- : undefined;
+ : 'drop-shadow(0 1px 1.5px rgba(15, 23, 42, 0.18))';
+ const svgStyle: CSSProperties = { filter, ...style };
+
+ if (realistic) {
+ const crownGradId = `${gradientId}-crown`;
+ const rootGradId = `${gradientId}-root`;
+ // Align crown edge toward the FDI numbers (upper: bottom of box, lower: top).
+ const preserveAspectRatio = upper ? 'xMidYMax meet' : 'xMidYMin meet';
+
+ const crownStops = selected
+ ? accentColor
+ ? { hi: '#ffffff', mid: accentColor, shade: mixHex(accentColor, '#0f172a', 0.22) }
+ : { hi: '#ffffff', mid: '#5eead4', shade: '#0e7490' }
+ : {
+ hi: 'var(--tooth-crown-hi)',
+ mid: 'var(--tooth-crown-mid)',
+ shade: 'var(--tooth-crown-shade)',
+ };
+
+ const rootStops = selected
+ ? accentColor
+ ? {
+ hi: mixHex(accentColor, '#ffffff', 0.2),
+ mid: mixHex(accentColor, '#0f172a', 0.12),
+ shade: mixHex(accentColor, '#0f172a', 0.32),
+ }
+ : { hi: '#99f6e4', mid: '#14b8a6', shade: '#0f766e' }
+ : {
+ hi: 'var(--tooth-root-hi)',
+ mid: 'var(--tooth-root-mid)',
+ shade: 'var(--tooth-root-shade)',
+ };
+
+ return (
+
+ );
+ }
+
+ const model: ToothPathModel | null = getToothPathModel(fdi, kind, upper);
if (!model) {
return (
-