@@ -2,109 +2,190 @@ import {Camera, CanvasTexture, Mesh,Object3D, Shape,ShapeGeometry, LinearFilter,
2
2
import { Damper } from './Damper.js' ;
3
3
import { ModelScene } from './ModelScene.js' ;
4
4
import { PlacementBox } from './PlacementBox.js' ;
5
+ // SVG strings for the icons are defined here to avoid io and better performance for xr.
6
+ const CLOSE_ICON_SVG = `<svg xmlns="http://www.w3.org/2000/svg" width="24px" height="24px" viewBox="0 0 24 24" fill="#e8eaed">
7
+ <path d="M6.4,19L5,17.6L10.6,12L5,6.4L6.4,5L12,10.6L17.6,5L19,6.4L13.4,12L19,17.6L17.6,19L12,13.4L6.4,19Z"/>
8
+ </svg>` ;
5
9
6
- const MAX_OPACITY = 1 ;
7
- const PANEL_WIDTH = 0.15 ;
8
- const PANEL_HEIGHT = 0.08 ;
9
- const PANEL_CORNER_RADIUS = 0.02 ;
10
+ const VIEW_REAL_SIZE_ICON_SVG = `<svg xmlns="http://www.w3.org/2000/svg" width="24px" height="24px" viewBox="0 0 24 24" fill="#e8eaed">
11
+ <path d="M7,17V9H5V7H9V17H7ZM11,17V15H13V17H11ZM16,17V9H14V7H18V17H16ZM11,13V11H13V13H11Z"/>
12
+ </svg>` ;
13
+
14
+ const REPLAY_ICON_SVG = `<svg xmlns="http://www.w3.org/2000/svg" width="24px" height="24px" viewBox="0 0 24 24" fill="#E1E2E8">
15
+ <defs>
16
+ <clipPath id="clip0">
17
+ <path d="M0,0h24v24h-24z"/>
18
+ </clipPath>
19
+ </defs>
20
+ <g clip-path="url(#clip0)">
21
+ <path d="M12,22C10.75,22 9.575,21.767 8.475,21.3C7.392,20.817 6.442,20.175 5.625,19.375C4.825,18.558 4.183,17.608 3.7,16.525C3.233,15.425 3,14.25 3,13H5C5,14.95 5.675,16.608 7.025,17.975C8.392,19.325 10.05,20 12,20C13.95,20 15.6,19.325 16.95,17.975C18.317,16.608 19,14.95 19,13C19,11.05 18.317,9.4 16.95,8.05C15.6,6.683 13.95,6 12,6H11.85L13.4,7.55L12,9L8,5L12,1L13.4,2.45L11.85,4H12C13.25,4 14.417,4.242 15.5,4.725C16.6,5.192 17.55,5.833 18.35,6.65C19.167,7.45 19.808,8.4 20.275,9.5C20.758,10.583 21,11.75 21,13C21,14.25 20.758,15.425 20.275,16.525C19.808,17.608 19.167,18.558 18.35,19.375C17.55,20.175 16.6,20.817 15.5,21.3C14.417,21.767 13.25,22 12,22Z"/>
22
+ </g>
23
+ </svg>` ;
24
+
25
+ // Panel configuration
26
+ const PANEL_CONFIG = {
27
+ width : 0.16 ,
28
+ height : 0.07 ,
29
+ cornerRadius : 0.03 ,
30
+ opacity : 1 ,
31
+ color : 0x000000
32
+ } as const ;
33
+
34
+ // Button configuration
35
+ const BUTTON_CONFIG = {
36
+ size : 0.05 , // Fixed size for all buttons
37
+ zOffset : 0.01 , // Distance from panel surface
38
+ spacing : 0.07 // Space between button centers
39
+ } as const ;
40
+
41
+ // Icon configuration
42
+ const ICON_CONFIG = {
43
+ canvasSize : 128 ,
44
+ filter : LinearFilter
45
+ } as const ;
10
46
11
47
export class XRMenuPanel extends Object3D {
12
- private panelMesh : Mesh ;
13
- private exitButton : Mesh ;
14
- private toggleButton : Mesh ;
48
+ private panelMesh ! : Mesh ;
49
+ private exitButton ! : Mesh ;
50
+ private toggleButton ! : Mesh ;
15
51
private goalOpacity : number ;
16
52
private opacityDamper : Damper ;
17
53
private isActualSize : boolean = false ; // Start with normalized size
18
54
55
+ // Cache for pre-rendered textures
56
+ private static readonly iconTextures = new Map < string , CanvasTexture > ( ) ;
57
+
19
58
constructor ( ) {
20
59
super ( ) ;
21
- const panelShape = new Shape ( ) ;
22
- const w = PANEL_WIDTH , h = PANEL_HEIGHT , r = PANEL_CORNER_RADIUS ;
23
- // straight horizontal bottom edge and a rounded bottom-right corner with a radius of r
24
- panelShape . moveTo ( - w / 2 + r , - h / 2 ) ;
25
- panelShape . lineTo ( w / 2 - r , - h / 2 ) ;
26
- panelShape . quadraticCurveTo ( w / 2 , - h / 2 , w / 2 , - h / 2 + r ) ;
27
- // the right most line and the rounded up-right
28
- panelShape . lineTo ( w / 2 , h / 2 - r ) ;
29
- panelShape . quadraticCurveTo ( w / 2 , h / 2 , w / 2 - r , h / 2 ) ;
30
- // the horizontal top edge and rounded up-left
31
- panelShape . lineTo ( - w / 2 + r , h / 2 ) ;
32
- panelShape . quadraticCurveTo ( - w / 2 , h / 2 , - w / 2 , h / 2 - r ) ;
33
- // the left line and bottom left corner
34
- panelShape . lineTo ( - w / 2 , - h / 2 + r ) ;
35
- panelShape . quadraticCurveTo ( - w / 2 , - h / 2 , - w / 2 + r , - h / 2 ) ;
60
+
61
+ // Pre-render all icons
62
+ this . preRenderIcons ( ) ;
63
+
64
+ this . createPanel ( ) ;
65
+ this . createButtons ( ) ;
66
+
67
+ this . opacityDamper = new Damper ( ) ;
68
+ this . goalOpacity = PANEL_CONFIG . opacity ;
69
+ }
70
+
71
+ private createPanel ( ) : void {
72
+ const panelShape = this . createPanelShape ( ) ;
36
73
const geometry = new ShapeGeometry ( panelShape ) ;
37
74
const material = new MeshBasicMaterial ( {
38
- color : 0x000000 ,
39
- opacity : MAX_OPACITY ,
75
+ color : PANEL_CONFIG . color ,
76
+ opacity : PANEL_CONFIG . opacity ,
40
77
transparent : true
41
78
} ) ;
42
79
this . panelMesh = new Mesh ( geometry , material ) ;
43
80
this . panelMesh . name = 'MenuPanel' ;
44
81
this . add ( this . panelMesh ) ;
82
+ }
45
83
84
+ private createButtons ( ) : void {
46
85
// Create exit button
47
- this . exitButton = this . createButton ( 'X ' ) ;
86
+ this . exitButton = this . createButton ( 'close ' ) ;
48
87
this . exitButton . name = 'ExitButton' ;
49
- this . exitButton . position . set ( 0.035 , 0 , 0.01 ) ; // Keep X button position
88
+ this . exitButton . position . set ( BUTTON_CONFIG . spacing / 2 , 0 , BUTTON_CONFIG . zOffset ) ;
50
89
this . add ( this . exitButton ) ;
51
90
52
- // Create toggle button with adjusted position for larger text
53
- this . toggleButton = this . createButton ( '1:1' , {
54
- width : 0.06 , // Slightly wider for the longer text
55
- height : 0.05 // Keep same height as X button
56
- } ) ;
91
+ // Create toggle button
92
+ this . toggleButton = this . createButton ( 'view-real-size' ) ;
57
93
this . toggleButton . name = 'ToggleButton' ;
58
- this . toggleButton . position . set ( - 0.03 , 0 , 0.01 ) ; // Move slightly more to the left to account for wider button
94
+ this . toggleButton . position . set ( - BUTTON_CONFIG . spacing / 2 , 0 , BUTTON_CONFIG . zOffset ) ;
59
95
this . add ( this . toggleButton ) ;
60
-
61
- this . opacityDamper = new Damper ( ) ;
62
- this . goalOpacity = MAX_OPACITY ;
63
- }
96
+ }
64
97
65
- createButton ( label : string , options ?: {
66
- width ?: number ;
67
- height ?: number ;
68
- fontSize ?: number ;
69
- textColor ?: string ;
70
- backgroundColor ?: string ;
71
- fontFamily ?: string ;
72
- } ) : Mesh {
73
- const {
74
- width = 0.05 ,
75
- height = 0.05 ,
76
- fontSize = 80 ,
77
- textColor = '#cccccc' ,
78
- backgroundColor = 'transparent' ,
79
- fontFamily = 'sans-serif'
80
- } = options || { } ;
98
+ private createPanelShape ( ) : Shape {
99
+ const shape = new Shape ( ) ;
100
+ const { width : w , height : h , cornerRadius : r } = PANEL_CONFIG ;
101
+
102
+ // Create rounded rectangle path
103
+ shape . moveTo ( - w / 2 + r , - h / 2 ) ;
104
+ shape . lineTo ( w / 2 - r , - h / 2 ) ;
105
+ shape . quadraticCurveTo ( w / 2 , - h / 2 , w / 2 , - h / 2 + r ) ;
106
+ shape . lineTo ( w / 2 , h / 2 - r ) ;
107
+ shape . quadraticCurveTo ( w / 2 , h / 2 , w / 2 - r , h / 2 ) ;
108
+ shape . lineTo ( - w / 2 + r , h / 2 ) ;
109
+ shape . quadraticCurveTo ( - w / 2 , h / 2 , - w / 2 , h / 2 - r ) ;
110
+ shape . lineTo ( - w / 2 , - h / 2 + r ) ;
111
+ shape . quadraticCurveTo ( - w / 2 , - h / 2 , - w / 2 + r , - h / 2 ) ;
81
112
82
- const canvasSize = 128 ;
113
+ return shape ;
114
+ }
115
+
116
+ private preRenderIcons ( ) : void {
117
+ const iconSvgs = [
118
+ { key : 'close' , svg : CLOSE_ICON_SVG } ,
119
+ { key : 'view-real-size' , svg : VIEW_REAL_SIZE_ICON_SVG } ,
120
+ { key : 'replay' , svg : REPLAY_ICON_SVG }
121
+ ] ;
122
+
123
+ iconSvgs . forEach ( ( { key, svg } ) => {
124
+ if ( ! XRMenuPanel . iconTextures . has ( key ) ) {
125
+ this . createTextureFromSvg ( svg , key ) ;
126
+ }
127
+ } ) ;
128
+ }
129
+
130
+ private createTextureFromSvg ( svgContent : string , key : string ) : void {
83
131
const canvas = document . createElement ( 'canvas' ) ;
84
- canvas . width = canvasSize ;
85
- canvas . height = canvasSize ;
132
+ canvas . width = ICON_CONFIG . canvasSize ;
133
+ canvas . height = ICON_CONFIG . canvasSize ;
86
134
const ctx = canvas . getContext ( '2d' ) ! ;
87
135
88
- // Background
89
- if ( backgroundColor !== 'transparent' ) {
90
- ctx . fillStyle = backgroundColor ;
91
- ctx . fillRect ( 0 , 0 , canvasSize , canvasSize ) ;
136
+ // Create an image from SVG content
137
+ const img = new Image ( ) ;
138
+ const svgBlob = new Blob ( [ svgContent ] , { type : 'image/svg+xml' } ) ;
139
+ const url = URL . createObjectURL ( svgBlob ) ;
140
+
141
+ img . onload = ( ) => {
142
+ ctx . drawImage ( img , 0 , 0 , ICON_CONFIG . canvasSize , ICON_CONFIG . canvasSize ) ;
143
+
144
+ const texture = new CanvasTexture ( canvas ) ;
145
+ texture . needsUpdate = true ;
146
+ texture . minFilter = ICON_CONFIG . filter ;
147
+
148
+ XRMenuPanel . iconTextures . set ( key , texture ) ;
149
+ URL . revokeObjectURL ( url ) ;
150
+ } ;
151
+ img . src = url ;
152
+ }
153
+
154
+ createButton ( iconKey : string ) : Mesh {
155
+ // Create a placeholder mesh
156
+ const material = new MeshBasicMaterial ( { transparent : true } ) ;
157
+ const geometry = new PlaneGeometry ( BUTTON_CONFIG . size , BUTTON_CONFIG . size ) ;
158
+ const mesh = new Mesh ( geometry , material ) ;
159
+
160
+ // Try to get cached texture, or create a fallback
161
+ const cachedTexture = XRMenuPanel . iconTextures . get ( iconKey ) ;
162
+ if ( cachedTexture ) {
163
+ ( mesh . material as MeshBasicMaterial ) . map = cachedTexture ;
164
+ ( mesh . material as MeshBasicMaterial ) . needsUpdate = true ;
165
+ } else {
166
+ // RACE CONDITION FIX: Texture creation is async (img.onload), but button creation is sync
167
+ // This fallback handles the case where buttons are created before textures finish loading
168
+ this . createTextureFromSvg ( iconKey === 'close' ? CLOSE_ICON_SVG :
169
+ iconKey === 'view-real-size' ? VIEW_REAL_SIZE_ICON_SVG :
170
+ REPLAY_ICON_SVG , iconKey ) ;
171
+
172
+ // Polling mechanism: Wait for async texture creation to complete
173
+ // This prevents white squares from appearing on first load
174
+ const checkTexture = ( ) => {
175
+ const texture = XRMenuPanel . iconTextures . get ( iconKey ) ;
176
+ if ( texture ) {
177
+ // Texture is ready - apply it to the mesh
178
+ ( mesh . material as MeshBasicMaterial ) . map = texture ;
179
+ ( mesh . material as MeshBasicMaterial ) . needsUpdate = true ;
180
+ } else {
181
+ // Texture not ready yet - check again in 10ms
182
+ setTimeout ( checkTexture , 10 ) ;
183
+ }
184
+ } ;
185
+ checkTexture ( ) ;
92
186
}
93
-
94
- // Text
95
- ctx . fillStyle = textColor ;
96
- ctx . font = `bold ${ fontSize } px ${ fontFamily } ` ;
97
- ctx . textAlign = 'center' ;
98
- ctx . textBaseline = 'middle' ;
99
- ctx . fillText ( label , canvasSize / 2 , canvasSize / 2 ) ;
100
-
101
- const texture = new CanvasTexture ( canvas ) ;
102
- texture . needsUpdate = true ;
103
- texture . minFilter = LinearFilter ;
104
-
105
- const material = new MeshBasicMaterial ( { map : texture , transparent : true } ) ;
106
- const geometry = new PlaneGeometry ( width , height ) ;
107
- return new Mesh ( geometry , material ) ;
187
+
188
+ return mesh ;
108
189
}
109
190
110
191
exitButtonControllerIntersection ( scene : ModelScene , controller : XRTargetRaySpace ) {
@@ -128,34 +209,24 @@ export class XRMenuPanel extends Object3D {
128
209
}
129
210
130
211
this . isActualSize = ! this . isActualSize ;
131
- const newLabel = this . isActualSize ? '@' : '1:1' ;
132
- this . updateScaleModeButtonLabel ( newLabel ) ;
212
+ // Toggle between view real size icon and replay icon
213
+ // When isActualSize is true, show replay icon (to reset)
214
+ // When isActualSize is false, show view real size icon (to go to actual size)
215
+ const iconKey = this . isActualSize ? 'replay' : 'view-real-size' ;
216
+ this . updateScaleModeButtonLabel ( iconKey ) ;
133
217
134
218
const targetScale = this . isActualSize ? 1.0 : initialModelScale ;
135
219
const goalScale = Math . max ( minScale , Math . min ( maxScale , targetScale ) ) ;
136
220
137
221
return goalScale ;
138
222
}
139
223
140
- private updateScaleModeButtonLabel ( label : string ) {
141
- const canvasSize = 128 ;
142
- const canvas = document . createElement ( 'canvas' ) ;
143
- canvas . width = canvasSize ;
144
- canvas . height = canvasSize ;
145
- const ctx = canvas . getContext ( '2d' ) ! ;
146
-
147
- ctx . fillStyle = '#cccccc' ;
148
- ctx . font = 'bold 80px sans-serif' ;
149
- ctx . textAlign = 'center' ;
150
- ctx . textBaseline = 'middle' ;
151
- ctx . fillText ( label , canvasSize / 2 , canvasSize / 2 ) ;
152
-
153
- const texture = new CanvasTexture ( canvas ) ;
154
- texture . needsUpdate = true ;
155
- texture . minFilter = LinearFilter ;
156
-
157
- ( this . toggleButton . material as MeshBasicMaterial ) . map = texture ;
158
- ( this . toggleButton . material as MeshBasicMaterial ) . needsUpdate = true ;
224
+ private updateScaleModeButtonLabel ( iconKey : string ) {
225
+ const cachedTexture = XRMenuPanel . iconTextures . get ( iconKey ) ;
226
+ if ( cachedTexture ) {
227
+ ( this . toggleButton . material as MeshBasicMaterial ) . map = cachedTexture ;
228
+ ( this . toggleButton . material as MeshBasicMaterial ) . needsUpdate = true ;
229
+ }
159
230
}
160
231
161
232
updatePosition ( camera : Camera , placementBox : PlacementBox ) {
@@ -188,7 +259,7 @@ export class XRMenuPanel extends Object3D {
188
259
* Set the box's visibility; it will fade in and out.
189
260
*/
190
261
set show ( visible : boolean ) {
191
- this . goalOpacity = visible ? MAX_OPACITY : 0 ;
262
+ this . goalOpacity = visible ? PANEL_CONFIG . opacity : 0 ;
192
263
}
193
264
194
265
/**
0 commit comments