@@ -52,8 +52,11 @@ const SKELETON: BoneSpec[] = [
5252 { id : "knee_right" , parent : "hip_right" , offset : [ 0 , - 0.45 , 0 ] , radius : 0.05 } ,
5353 { id : "ankle_right" , parent : "knee_right" , offset : [ 0 , - 0.43 , 0 ] , radius : 0.04 } ,
5454
55- // Fingers — short single-segment digits off each wrist, splayed in X and
56- // angled slightly forward (+Z, palm-side). One curl DOF each (flex).
55+ // Fingers — one curl DOF each (flex), splayed in X and angled slightly
56+ // forward (+Z, palm-side). Offsets are the FINGERTIP position; the bone is
57+ // placed partway along at the knuckle (KNUCKLE_T) so the wrist-drawn segment
58+ // becomes the rigid palm/metacarpal and the bone carries its own digit mesh —
59+ // otherwise curling a finger rotates an empty node and the hand never moves.
5760 { id : "thumb_left" , parent : "wrist_left" , offset : [ 0.035 , - 0.04 , 0.025 ] , radius : 0.014 } ,
5861 { id : "index_left" , parent : "wrist_left" , offset : [ 0.025 , - 0.085 , 0.012 ] , radius : 0.013 } ,
5962 { id : "middle_left" , parent : "wrist_left" , offset : [ 0.008 , - 0.092 , 0.012 ] , radius : 0.013 } ,
@@ -67,6 +70,13 @@ const SKELETON: BoneSpec[] = [
6770 { id : "pinky_right" , parent : "wrist_right" , offset : [ 0.028 , - 0.075 , 0.012 ] , radius : 0.012 } ,
6871] ;
6972
73+ /** Fraction of the wrist→fingertip span where the knuckle (finger bone) sits. */
74+ const KNUCKLE_T = 0.55 ;
75+
76+ function isFinger ( id : string ) : boolean {
77+ return / ^ ( t h u m b | i n d e x | m i d d l e | r i n g | p i n k y ) _ / . test ( id ) ;
78+ }
79+
7080/** Build the mannequin. `material` lets the playground theme it. */
7181export function buildMannequin ( material ?: THREE . Material ) : Mannequin {
7282 const mat =
@@ -86,23 +96,37 @@ export function buildMannequin(material?: THREE.Material): Mannequin {
8696 for ( const spec of SKELETON ) {
8797 const bone = new THREE . Object3D ( ) ;
8898 bone . name = spec . id ;
89- bone . position . set ( ...spec . offset ) ;
99+ // Finger bones sit at the knuckle; the offset names the fingertip.
100+ const offset = new THREE . Vector3 ( ...spec . offset ) ;
101+ if ( isFinger ( spec . id ) ) offset . multiplyScalar ( KNUCKLE_T ) ;
102+ bone . position . copy ( offset ) ;
90103
91104 const parent = spec . parent ? bones . get ( spec . parent ) : root ;
92105 ( parent ?? root ) . add ( bone ) ;
93106 bones . set ( spec . id , bone ) ;
94107
95108 // Draw the segment from the parent joint to this joint, on the parent.
96109 if ( spec . parent && spec . radius ) {
97- const length = Math . hypot ( ...spec . offset ) ;
98- const seg = makeSegment ( length , spec . radius , mat ) ;
99- orientSegment ( seg , new THREE . Vector3 ( ...spec . offset ) ) ;
110+ const seg = makeSegment ( offset . length ( ) , spec . radius , mat ) ;
111+ orientSegment ( seg , offset ) ;
100112 bones . get ( spec . parent ) ! . add ( seg ) ;
101113 }
102114 }
103115
116+ // Digit meshes ON the finger bones, spanning knuckle → fingertip, so a
117+ // finger curl visibly folds at the knuckle.
118+ for ( const spec of SKELETON ) {
119+ if ( ! isFinger ( spec . id ) || ! spec . radius ) continue ;
120+ const full = new THREE . Vector3 ( ...spec . offset ) ;
121+ const span = full . clone ( ) . multiplyScalar ( 1 - KNUCKLE_T ) ;
122+ const digit = makeSegment ( span . length ( ) , spec . radius * 0.92 , mat ) ;
123+ orientSegment ( digit , span ) ;
124+ bones . get ( spec . id ) ! . add ( digit ) ;
125+ }
126+
104127 // Head sphere + small hand/foot caps for readability.
105128 addBall ( bones . get ( "head" ) ! , 0.12 , mat ) ;
129+ addFace ( bones . get ( "head" ) ! ) ;
106130 addBall ( bones . get ( "wrist_left" ) ! , 0.05 , mat ) ;
107131 addBall ( bones . get ( "wrist_right" ) ! , 0.05 , mat ) ;
108132 addFoot ( bones . get ( "ankle_left" ) ! , mat ) ;
@@ -139,6 +163,32 @@ function addBall(bone: THREE.Object3D, diameter: number, mat: THREE.Material): v
139163 bone . add ( new THREE . Mesh ( geo , mat ) ) ;
140164}
141165
166+ /**
167+ * A minimal face — nose wedge + two eye studs — on the head's front (+Z).
168+ * The bare sphere hid which way the figure faces, making neck rotations and
169+ * turns unreadable; darker studs poke just past the head surface so facing
170+ * reads at a glance from any camera angle.
171+ */
172+ function addFace ( head : THREE . Object3D ) : void {
173+ const mat = new THREE . MeshStandardMaterial ( { color : 0x39424e , roughness : 0.6 } ) ;
174+ const face = new THREE . Group ( ) ;
175+ face . name = "face" ;
176+
177+ // Head ball radius is 0.06 (see addBall(head, 0.12)).
178+ const nose = new THREE . Mesh ( new THREE . ConeGeometry ( 0.013 , 0.035 , 10 ) , mat ) ;
179+ nose . rotation . x = Math . PI / 2 ; // cone +Y → +Z
180+ nose . position . set ( 0 , - 0.004 , 0.064 ) ;
181+ face . add ( nose ) ;
182+
183+ for ( const sx of [ - 1 , 1 ] ) {
184+ const eye = new THREE . Mesh ( new THREE . SphereGeometry ( 0.0095 , 10 , 8 ) , mat ) ;
185+ eye . position . set ( sx * 0.024 , 0.016 , 0.054 ) ;
186+ face . add ( eye ) ;
187+ }
188+
189+ head . add ( face ) ;
190+ }
191+
142192function addFoot ( bone : THREE . Object3D , mat : THREE . Material ) : void {
143193 const geo = new THREE . BoxGeometry ( 0.07 , 0.04 , 0.16 ) ;
144194 const mesh = new THREE . Mesh ( geo , mat ) ;
0 commit comments