@@ -24,7 +24,11 @@ export const modelViewerState = proxy({
2424 height : number
2525 scaled ?: boolean
2626 onlyInitialScale ?: boolean
27- followCursor ?: boolean
27+ }
28+ followCursor ?: boolean
29+ followCursorCenter ?: {
30+ x : number
31+ y : number
2832 }
2933 modelCustomization ?: { [ modelUrl : string ] : { color ?: string , opacity ?: number , metalness ?: number , roughness ?: number , rotation ?: { x ?: number , y ?: number , z ?: number } } }
3034 resetRotationOnReleae ?: boolean
@@ -33,6 +37,7 @@ export const modelViewerState = proxy({
3337 playModelAnimation ?: string
3438 playModelAnimationSpeed ?: number
3539 playModelAnimationLoop ?: boolean
40+ followCursorCenterDebug ?: boolean
3641 }
3742} )
3843globalThis . modelViewerState = modelViewerState
@@ -105,8 +110,32 @@ export default () => {
105110 globalThis . sceneRef = sceneRef
106111
107112 // Cursor following state
108- const cursorPosition = useRef ( { x : 0 , y : 0 } )
113+ const cursorPosition = useRef < { x : number , y : number } > ( { x : 0 , y : 0 } ) // window clientX/clientY in px
109114 const isFollowingCursor = useRef ( false )
115+ const getUiScaleFactor = ( scaled ?: boolean , onlyInitialScale ?: boolean ) => {
116+ return scaled ? ( onlyInitialScale ? initialScale : currentScaling . scale ) : 1
117+ }
118+ const windowRef = useRef < HTMLDivElement > ( null )
119+ // Shared helper to compute normalized cursor from window clientX/Y taking scale & center into account
120+ const computeNormalizedFromClient = ( clientX : number , clientY : number ) => {
121+ const { positioning, followCursorCenter } = modelViewerState . model !
122+ const { windowWidth, windowHeight } = positioning
123+ const rect = windowRef . current ?. getBoundingClientRect ( )
124+ const effectiveScale = rect ? ( rect . width / windowWidth ) : getUiScaleFactor ( positioning . scaled , positioning . onlyInitialScale )
125+
126+ const centerPxX = ( followCursorCenter ?. x ?? ( windowWidth / 2 ) ) * effectiveScale
127+ const centerPxY = ( followCursorCenter ?. y ?? ( windowHeight / 2 ) ) * effectiveScale
128+
129+ const localX = rect ? ( clientX - rect . left ) : clientX
130+ const localY = rect ? ( clientY - rect . top ) : clientY
131+
132+ const denomX = rect ? ( rect . width / 2 ) : ( window . innerWidth / 2 )
133+ const denomY = rect ? ( rect . height / 2 ) : ( window . innerHeight / 2 )
134+ const normalizedX = ( localX - centerPxX ) / denomX
135+ const normalizedY = ( localY - centerPxY ) / denomY
136+ return { normalizedX, normalizedY }
137+ }
138+
110139
111140 // Model management state
112141 const loadedModels = useRef < Map < string , THREE . Object3D > > ( new Map ( ) )
@@ -383,17 +412,19 @@ export default () => {
383412 const { playerObject } = sceneRef . current
384413 const { x, y } = cursorPosition . current
385414
386- // Convert 0-1 cursor position to normalized coordinates (-1 to 1)
387- const normalizedX = x * 2 - 1
388- const normalizedY = y * 2 - 1 // Inverted: top of screen = negative pitch, bottom = positive pitch
415+ // Convert clientX/clientY to normalized coordinates centered by followCursorCenter
416+ const { normalizedX, normalizedY } = computeNormalizedFromClient ( x , y )
389417
390418 // Calculate head rotation based on cursor position
391- // Limit head movement to realistic angles
392- const maxHeadYaw = Math . PI / 3 // 60 degrees
393- const maxHeadPitch = Math . PI / 4 // 45 degrees
419+ // Limit head movement to ±60 degrees
420+ const maxHeadYaw = Math . PI * ( 60 / 180 )
421+ const maxHeadPitch = Math . PI * ( 60 / 180 )
422+
423+ const clampedX = THREE . MathUtils . clamp ( normalizedX , - 1 , 1 )
424+ const clampedY = THREE . MathUtils . clamp ( normalizedY , - 1 , 1 )
394425
395- const headYaw = normalizedX * maxHeadYaw
396- const headPitch = normalizedY * maxHeadPitch
426+ const headYaw = clampedX * maxHeadYaw
427+ const headPitch = clampedY * maxHeadPitch
397428
398429 // Apply head rotation with smooth interpolation
399430 const lerpFactor = 0.1 // Smooth interpolation factor
@@ -445,6 +476,8 @@ export default () => {
445476 const { playerObject, wrapper } = createPlayerObject ( {
446477 scale : 1 // Start with base scale, will adjust below
447478 } )
479+ playerObject . ears . visible = false
480+ playerObject . cape . visible = false
448481
449482 // Enable shadows for player object
450483 wrapper . traverse ( ( child ) => {
@@ -489,7 +522,7 @@ export default () => {
489522 } )
490523
491524 // Set up cursor following if enabled
492- if ( model . positioning . followCursor ) {
525+ if ( model . followCursor ) {
493526 isFollowingCursor . current = true
494527 }
495528 }
@@ -499,12 +532,12 @@ export default () => {
499532 let lastCursorUpdate = 0
500533 let waitingRender = false
501534 const handleWindowPointerMove = ( event : PointerEvent ) => {
502- if ( ! model . positioning . followCursor ) return
535+ if ( ! model . followCursor ) return
503536
504- // Track cursor position as 0-1 across the entire window
537+ // Track cursor position as window clientX/clientY in px
505538 const newPosition = {
506- x : event . clientX / window . innerWidth ,
507- y : event . clientY / window . innerHeight
539+ x : event . clientX ,
540+ y : event . clientY
508541 }
509542 cursorPosition . current = newPosition
510543 globalThis . cursorPosition = newPosition // Expose for debug
@@ -520,7 +553,7 @@ export default () => {
520553 }
521554
522555 // Add window event listeners
523- if ( model . positioning . followCursor ) {
556+ if ( model . followCursor ) {
524557 window . addEventListener ( 'pointermove' , handleWindowPointerMove )
525558 isFollowingCursor . current = true
526559 }
@@ -538,7 +571,7 @@ export default () => {
538571 if ( ! model . continiousRender ) {
539572 controls . removeEventListener ( 'change' , render )
540573 }
541- if ( model . positioning . followCursor ) {
574+ if ( model . followCursor ) {
542575 window . removeEventListener ( 'pointermove' , handleWindowPointerMove )
543576 }
544577 if ( rafIdRef . current !== undefined ) cancelAnimationFrame ( rafIdRef . current )
@@ -628,6 +661,7 @@ export default () => {
628661 } }
629662 >
630663 < div
664+ ref = { windowRef }
631665 className = 'overlay-model-viewer-window'
632666 style = { {
633667 width : windowWidth ,
@@ -636,6 +670,29 @@ export default () => {
636670 pointerEvents : 'none' ,
637671 } }
638672 >
673+ { model . followCursor && model . followCursorCenterDebug ? (
674+ ( ( ) => {
675+ const { followCursorCenter } = model
676+ const cx = ( followCursorCenter ?. x ?? ( windowWidth / 2 ) )
677+ const cy = ( followCursorCenter ?. y ?? ( windowHeight / 2 ) )
678+ const size = 6
679+ return (
680+ < div
681+ className = 'overlay-model-viewer-follow-cursor-center-debug'
682+ style = { {
683+ position : 'absolute' ,
684+ left : cx - ( size / 2 ) ,
685+ top : cy - ( size / 2 ) ,
686+ width : size ,
687+ height : size ,
688+ backgroundColor : 'red' ,
689+ pointerEvents : 'none' ,
690+ zIndex : 1000 ,
691+ } }
692+ />
693+ )
694+ } ) ( )
695+ ) : null }
639696 < div
640697 ref = { containerRef }
641698 className = 'overlay-model-viewer'
0 commit comments