1+ import * as THREE from "https://unpkg.com/three@0.160.0/build/three.module.js" ;
2+ import { GLTFLoader } from "https://unpkg.com/three@0.160.0/examples/jsm/loaders/GLTFLoader.js" ;
3+
4+ export class AthleteRenderer {
5+ static loader = new GLTFLoader ( ) ;
6+
7+ static cache = {
8+ man : null ,
9+ woman : null
10+ } ;
11+
12+ static loadModels ( manUrl , womanUrl ) {
13+ return Promise . all ( [
14+ this . _load ( manUrl ) . then ( m => ( this . cache . man = m ) ) ,
15+ this . _load ( womanUrl ) . then ( m => ( this . cache . woman = m ) )
16+ ] ) ;
17+ }
18+
19+ static _load ( url ) {
20+ return new Promise ( ( resolve , reject ) => {
21+ this . loader . load ( url , gltf => resolve ( gltf . scene ) , undefined , reject ) ;
22+ } ) ;
23+ }
24+
25+ static addHuman ( scene , athlete , pos , gender = "man" ) {
26+ const base = this . cache [ gender ] . clone ( true ) ;
27+
28+ const group = new THREE . Group ( ) ;
29+ group . position . set ( pos . x , pos . podiumHeight , 0 ) ;
30+
31+ const height = athlete . height || 170 ;
32+ const weight = athlete . weight || 65 ;
33+
34+ const heightScale = THREE . MathUtils . clamp ( height / 175 , 0.85 , 1.2 ) ;
35+ const weightScale = THREE . MathUtils . clamp ( weight / 70 , 0.85 , 1.25 ) ;
36+
37+ base . scale . setScalar ( heightScale ) ;
38+ base . scale . x *= weightScale ;
39+ base . scale . z *= weightScale ;
40+ base . position . y = 0 ;
41+
42+ base . traverse ( ( obj ) => {
43+ if ( ! obj . isMesh ) return ;
44+ const mat = obj . material ;
45+ mat . transparent = false ;
46+ mat . opacity = 1 ;
47+ mat . side = THREE . DoubleSide ;
48+ if ( mat . map ) mat . map . colorSpace = THREE . SRGBColorSpace ;
49+ if ( mat . emissiveMap ) mat . emissiveMap . colorSpace = THREE . SRGBColorSpace ;
50+ } ) ;
51+
52+ base . traverse ( ( obj ) => {
53+ if ( ! obj . isMesh ) return ;
54+ const name = obj . name . toLowerCase ( ) ;
55+ if ( name . includes ( "cloth" ) || name . includes ( "shirt" ) || name . includes ( "body" ) ) {
56+ obj . material = new THREE . MeshStandardMaterial ( {
57+ color : 0xf2f2f2 ,
58+ roughness : 0.8
59+ } ) ;
60+ }
61+ } ) ;
62+
63+ group . add ( base ) ;
64+
65+ // Real bounding box so all positions adapt to any model scale
66+ const tempBox = new THREE . Box3 ( ) . setFromObject ( group ) ;
67+ const modelWorldHeight = tempBox . max . y - tempBox . min . y ;
68+
69+ const chestWorldY = tempBox . min . y + modelWorldHeight * 0.72 ;
70+ const neckWorldY = tempBox . min . y + modelWorldHeight * 0.88 ;
71+ const frontWorldZ = tempBox . max . z + 0.02 ;
72+
73+ // Group-local Y (group sits at pos.podiumHeight)
74+ const chestLocalY = chestWorldY - pos . podiumHeight ;
75+ const neckLocalY = neckWorldY - pos . podiumHeight ;
76+
77+ const medalRadius = modelWorldHeight * 0.032 ;
78+ const strapRadius = medalRadius * 0.12 ;
79+ const neckHalfWidth = modelWorldHeight * 0.045 ; // where straps leave the neck
80+ const neckZ = frontWorldZ - medalRadius * 1.5 ; // straps start slightly behind
81+
82+ // Two diagonal ribbon straps forming a V around the neck
83+ const strapMat = new THREE . MeshStandardMaterial ( { color : 0xcc1111 , roughness : 0.65 } ) ;
84+ const medalCenter = new THREE . Vector3 ( 0 , chestLocalY , frontWorldZ ) ;
85+
86+ [
87+ new THREE . Vector3 ( - neckHalfWidth , neckLocalY , neckZ ) ,
88+ new THREE . Vector3 ( neckHalfWidth , neckLocalY , neckZ )
89+ ] . forEach ( strapStart => {
90+ this . _addSegment ( group , strapStart , medalCenter , strapRadius , strapMat ) ;
91+ } ) ;
92+
93+ // Medal disc (drawn after straps so it sits on top)
94+ const medalMat = new THREE . MeshStandardMaterial ( {
95+ color : this . _medalColor ( athlete . medal ) ,
96+ metalness : 0.75 ,
97+ roughness : 0.25
98+ } ) ;
99+ const medalDisc = new THREE . Mesh (
100+ new THREE . CylinderGeometry ( medalRadius , medalRadius , medalRadius * 0.25 , 32 ) ,
101+ medalMat
102+ ) ;
103+ medalDisc . position . set ( 0 , chestLocalY , frontWorldZ ) ;
104+ medalDisc . rotation . x = Math . PI / 2 ;
105+ group . add ( medalDisc ) ;
106+
107+ scene . add ( group ) ;
108+ return group ;
109+ }
110+
111+ // Draws a cylinder aligned between two arbitrary 3D points
112+ static _addSegment ( group , from , to , radius , material ) {
113+ const dir = new THREE . Vector3 ( ) . subVectors ( to , from ) ;
114+ const length = dir . length ( ) ;
115+ const mid = new THREE . Vector3 ( ) . lerpVectors ( from , to , 0.5 ) ;
116+
117+ const mesh = new THREE . Mesh (
118+ new THREE . CylinderGeometry ( radius , radius , length , 8 ) ,
119+ material
120+ ) ;
121+ mesh . position . copy ( mid ) ;
122+ // CylinderGeometry is Y-up by default; rotate to align with dir
123+ mesh . quaternion . setFromUnitVectors ( new THREE . Vector3 ( 0 , 1 , 0 ) , dir . clone ( ) . normalize ( ) ) ;
124+ group . add ( mesh ) ;
125+ }
126+
127+ static _medalColor ( medal ) {
128+ switch ( medal ) {
129+ case "Gold" : return 0xf4c542 ;
130+ case "Silver" : return 0xcfd5db ;
131+ case "Bronze" : return 0xd88c32 ;
132+ default : return 0xffffff ;
133+ }
134+ }
135+ }
0 commit comments