minecraft-renderer 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (187) hide show
  1. package/README.md +297 -0
  2. package/dist/index.html +83 -0
  3. package/dist/static/image/arrow.6f27b59f.png +0 -0
  4. package/dist/static/image/blocksAtlasLatest.7850afa3.png +0 -0
  5. package/dist/static/image/blocksAtlasLegacy.5c76823d.png +0 -0
  6. package/dist/static/image/itemsAtlasLatest.36036f95.png +0 -0
  7. package/dist/static/image/itemsAtlasLegacy.dcb1b58d.png +0 -0
  8. package/dist/static/image/tipped_arrow.6f27b59f.png +0 -0
  9. package/dist/static/js/365.f05233ab.js +8462 -0
  10. package/dist/static/js/365.f05233ab.js.LICENSE.txt +52 -0
  11. package/dist/static/js/async/738.efa27644.js +1 -0
  12. package/dist/static/js/index.092ec5be.js +56 -0
  13. package/dist/static/js/lib-polyfill.98986ac5.js +1 -0
  14. package/dist/static/js/lib-react.5c9129e0.js +2 -0
  15. package/dist/static/js/lib-react.5c9129e0.js.LICENSE.txt +39 -0
  16. package/package.json +104 -0
  17. package/src/assets/destroy_stage_0.png +0 -0
  18. package/src/assets/destroy_stage_1.png +0 -0
  19. package/src/assets/destroy_stage_2.png +0 -0
  20. package/src/assets/destroy_stage_3.png +0 -0
  21. package/src/assets/destroy_stage_4.png +0 -0
  22. package/src/assets/destroy_stage_5.png +0 -0
  23. package/src/assets/destroy_stage_6.png +0 -0
  24. package/src/assets/destroy_stage_7.png +0 -0
  25. package/src/assets/destroy_stage_8.png +0 -0
  26. package/src/assets/destroy_stage_9.png +0 -0
  27. package/src/examples/README.md +146 -0
  28. package/src/examples/appViewerExample.ts +205 -0
  29. package/src/examples/initialMenuStart.ts +161 -0
  30. package/src/graphicsBackend/appViewer.ts +297 -0
  31. package/src/graphicsBackend/config.ts +119 -0
  32. package/src/graphicsBackend/index.ts +10 -0
  33. package/src/graphicsBackend/playerState.ts +61 -0
  34. package/src/graphicsBackend/types.ts +143 -0
  35. package/src/index.ts +97 -0
  36. package/src/lib/DebugGui.ts +190 -0
  37. package/src/lib/animationController.ts +85 -0
  38. package/src/lib/buildSharedConfig.mjs +1 -0
  39. package/src/lib/cameraBobbing.ts +94 -0
  40. package/src/lib/canvas2DOverlay.example.ts +361 -0
  41. package/src/lib/canvas2DOverlay.quickstart.ts +242 -0
  42. package/src/lib/canvas2DOverlay.ts +381 -0
  43. package/src/lib/cleanupDecorator.ts +29 -0
  44. package/src/lib/createPlayerObject.ts +55 -0
  45. package/src/lib/frameTimingCollector.ts +164 -0
  46. package/src/lib/guiRenderer.ts +283 -0
  47. package/src/lib/items.ts +140 -0
  48. package/src/lib/mesherlogReader.ts +131 -0
  49. package/src/lib/moreBlockDataGenerated.json +714 -0
  50. package/src/lib/preflatMap.json +1741 -0
  51. package/src/lib/simpleUtils.ts +40 -0
  52. package/src/lib/smoothSwitcher.ts +168 -0
  53. package/src/lib/spiral.ts +29 -0
  54. package/src/lib/ui/newStats.ts +120 -0
  55. package/src/lib/utils/proxy.ts +23 -0
  56. package/src/lib/utils/skins.ts +63 -0
  57. package/src/lib/utils.ts +76 -0
  58. package/src/lib/workerProxy.ts +342 -0
  59. package/src/lib/worldrendererCommon.ts +1088 -0
  60. package/src/mesher/mesher.ts +253 -0
  61. package/src/mesher/models.ts +769 -0
  62. package/src/mesher/modelsGeometryCommon.ts +142 -0
  63. package/src/mesher/shared.ts +80 -0
  64. package/src/mesher/standaloneRenderer.ts +270 -0
  65. package/src/mesher/test/a.ts +3 -0
  66. package/src/mesher/test/mesherTester.ts +76 -0
  67. package/src/mesher/test/playground.ts +19 -0
  68. package/src/mesher/test/test-perf.ts +74 -0
  69. package/src/mesher/test/tests.test.ts +56 -0
  70. package/src/mesher/world.ts +294 -0
  71. package/src/mesher/worldConstants.ts +1 -0
  72. package/src/modules/index.ts +11 -0
  73. package/src/modules/starfield.ts +313 -0
  74. package/src/modules/types.ts +110 -0
  75. package/src/playerState/playerState.ts +78 -0
  76. package/src/playerState/types.ts +36 -0
  77. package/src/playground/allEntitiesDebug.ts +170 -0
  78. package/src/playground/baseScene.ts +587 -0
  79. package/src/playground/mobileControls.tsx +268 -0
  80. package/src/playground/playground.html +83 -0
  81. package/src/playground/playground.ts +11 -0
  82. package/src/playground/playgroundUi.tsx +140 -0
  83. package/src/playground/reactUtils.ts +71 -0
  84. package/src/playground/scenes/allEntities.ts +13 -0
  85. package/src/playground/scenes/entities.ts +37 -0
  86. package/src/playground/scenes/floorRandom.ts +33 -0
  87. package/src/playground/scenes/frequentUpdates.ts +148 -0
  88. package/src/playground/scenes/geometryExport.ts +142 -0
  89. package/src/playground/scenes/index.ts +12 -0
  90. package/src/playground/scenes/lightingStarfield.ts +40 -0
  91. package/src/playground/scenes/main.ts +313 -0
  92. package/src/playground/scenes/railsCobweb.ts +14 -0
  93. package/src/playground/scenes/rotationIssue.ts +7 -0
  94. package/src/playground/scenes/slabsOptimization.ts +16 -0
  95. package/src/playground/scenes/transparencyIssue.ts +11 -0
  96. package/src/playground/shared.ts +79 -0
  97. package/src/resourcesManager/index.ts +5 -0
  98. package/src/resourcesManager/resourcesManager.ts +314 -0
  99. package/src/shims/minecraftData.ts +41 -0
  100. package/src/sign-renderer/index.html +21 -0
  101. package/src/sign-renderer/index.ts +216 -0
  102. package/src/sign-renderer/noop.js +1 -0
  103. package/src/sign-renderer/playground.ts +38 -0
  104. package/src/sign-renderer/tests.test.ts +69 -0
  105. package/src/sign-renderer/vite.config.ts +10 -0
  106. package/src/three/appShared.ts +75 -0
  107. package/src/three/bannerRenderer.ts +275 -0
  108. package/src/three/cameraShake.ts +120 -0
  109. package/src/three/cinimaticScript.ts +350 -0
  110. package/src/three/documentRenderer.ts +491 -0
  111. package/src/three/entities.ts +1580 -0
  112. package/src/three/entity/EntityMesh.ts +707 -0
  113. package/src/three/entity/animations.js +171 -0
  114. package/src/three/entity/armorModels.json +204 -0
  115. package/src/three/entity/armorModels.ts +36 -0
  116. package/src/three/entity/entities.json +6230 -0
  117. package/src/three/entity/exportedModels.js +38 -0
  118. package/src/three/entity/externalTextures.json +1 -0
  119. package/src/three/entity/models/allay.obj +325 -0
  120. package/src/three/entity/models/arrow.obj +60 -0
  121. package/src/three/entity/models/axolotl.obj +509 -0
  122. package/src/three/entity/models/blaze.obj +601 -0
  123. package/src/three/entity/models/boat.obj +417 -0
  124. package/src/three/entity/models/camel.obj +1061 -0
  125. package/src/three/entity/models/cat.obj +509 -0
  126. package/src/three/entity/models/chicken.obj +371 -0
  127. package/src/three/entity/models/cod.obj +371 -0
  128. package/src/three/entity/models/creeper.obj +279 -0
  129. package/src/three/entity/models/dolphin.obj +371 -0
  130. package/src/three/entity/models/ender_dragon.obj +2993 -0
  131. package/src/three/entity/models/enderman.obj +325 -0
  132. package/src/three/entity/models/endermite.obj +187 -0
  133. package/src/three/entity/models/fox.obj +463 -0
  134. package/src/three/entity/models/frog.obj +739 -0
  135. package/src/three/entity/models/ghast.obj +463 -0
  136. package/src/three/entity/models/goat.obj +601 -0
  137. package/src/three/entity/models/guardian.obj +1015 -0
  138. package/src/three/entity/models/horse.obj +1061 -0
  139. package/src/three/entity/models/llama.obj +509 -0
  140. package/src/three/entity/models/minecart.obj +233 -0
  141. package/src/three/entity/models/parrot.obj +509 -0
  142. package/src/three/entity/models/piglin.obj +739 -0
  143. package/src/three/entity/models/pillager.obj +371 -0
  144. package/src/three/entity/models/rabbit.obj +555 -0
  145. package/src/three/entity/models/sheep.obj +555 -0
  146. package/src/three/entity/models/shulker.obj +141 -0
  147. package/src/three/entity/models/sniffer.obj +693 -0
  148. package/src/three/entity/models/spider.obj +509 -0
  149. package/src/three/entity/models/tadpole.obj +95 -0
  150. package/src/three/entity/models/turtle.obj +371 -0
  151. package/src/three/entity/models/vex.obj +325 -0
  152. package/src/three/entity/models/villager.obj +509 -0
  153. package/src/three/entity/models/warden.obj +463 -0
  154. package/src/three/entity/models/witch.obj +647 -0
  155. package/src/three/entity/models/wolf.obj +509 -0
  156. package/src/three/entity/models/zombie_villager.obj +463 -0
  157. package/src/three/entity/objModels.js +1 -0
  158. package/src/three/fireworks.ts +661 -0
  159. package/src/three/fireworksRenderer.ts +434 -0
  160. package/src/three/globals.d.ts +7 -0
  161. package/src/three/graphicsBackend.ts +274 -0
  162. package/src/three/graphicsBackendOffThread.ts +107 -0
  163. package/src/three/hand.ts +89 -0
  164. package/src/three/holdingBlock.ts +926 -0
  165. package/src/three/index.ts +20 -0
  166. package/src/three/itemMesh.ts +427 -0
  167. package/src/three/modules.d.ts +14 -0
  168. package/src/three/panorama.ts +308 -0
  169. package/src/three/panoramaShared.ts +1 -0
  170. package/src/three/renderSlot.ts +82 -0
  171. package/src/three/skyboxRenderer.ts +406 -0
  172. package/src/three/starField.ts +13 -0
  173. package/src/three/threeJsMedia.ts +731 -0
  174. package/src/three/threeJsMethods.ts +15 -0
  175. package/src/three/threeJsParticles.ts +160 -0
  176. package/src/three/threeJsSound.ts +95 -0
  177. package/src/three/threeJsUtils.ts +90 -0
  178. package/src/three/waypointSprite.ts +435 -0
  179. package/src/three/waypoints.ts +163 -0
  180. package/src/three/world/cursorBlock.ts +172 -0
  181. package/src/three/world/vr.ts +257 -0
  182. package/src/three/worldGeometryExport.ts +259 -0
  183. package/src/three/worldGeometryHandler.ts +279 -0
  184. package/src/three/worldRendererThree.ts +1381 -0
  185. package/src/worldView/index.ts +6 -0
  186. package/src/worldView/types.ts +66 -0
  187. package/src/worldView/worldView.ts +424 -0
@@ -0,0 +1,731 @@
1
+ import * as THREE from 'three'
2
+ import { WorldRendererThree } from './worldRendererThree'
3
+ import { ThreeJsSound } from './threeJsSound'
4
+
5
+ type ControlModeConfig = {
6
+ mouseButton: 'both' | 'left' | 'right'
7
+ controlMode: 'play_pause' | 'play_if_ended' | 'toggle_mute'
8
+ }
9
+
10
+ interface MediaProperties {
11
+ position: { x: number, y: number, z: number }
12
+ size: { width: number, height: number }
13
+ src: string
14
+ rotation?: 0 | 1 | 2 | 3 // 0-3 for 0°, 90°, 180°, 270°
15
+ doubleSide?: boolean
16
+ background?: number // Hexadecimal color (e.g., 0x000000 for black)
17
+ opacity?: number // 0-1 value for transparency
18
+ uvMapping?: { startU: number, endU: number, startV: number, endV: number }
19
+ allowOrigins?: string[] | boolean
20
+ loop?: boolean
21
+ volume?: number
22
+ autoPlay?: boolean
23
+ imageOverride?: boolean
24
+ allowLighting?: boolean
25
+ controlMode?: ControlModeConfig
26
+ }
27
+
28
+ interface MediaData {
29
+ mesh: THREE.Object3D
30
+ props: MediaProperties
31
+ video: HTMLVideoElement | undefined
32
+ pausedBecuaseHidden: boolean
33
+ texture: THREE.Texture
34
+ updateUVMapping: (config: {
35
+ startU: number
36
+ endU: number
37
+ startV: number
38
+ endV: number
39
+ }) => void
40
+ positionalAudio?: THREE.PositionalAudio
41
+ hadAutoPlayError?: boolean
42
+ ended?: boolean
43
+ handleError: (err: Error) => void
44
+ destroyed?: boolean
45
+ }
46
+
47
+ export class ThreeJsMedia {
48
+ customMedia = new Map<string, MediaData>()
49
+
50
+ constructor(private readonly worldRenderer: WorldRendererThree) {
51
+ this.worldRenderer.onWorldSwitched.push(() => {
52
+ this.onWorldGone()
53
+ })
54
+
55
+ this.worldRenderer.onRender.push(() => {
56
+ this.render()
57
+ })
58
+
59
+ this.worldRenderer.onReactiveConfigUpdated('volume', () => {
60
+ const { volume } = this.worldRenderer.worldRendererConfig
61
+ for (const [id, videoData] of this.customMedia.entries()) {
62
+ if (videoData.positionalAudio) {
63
+ videoData.positionalAudio.setVolume((videoData.props.volume ?? 1) * volume)
64
+ }
65
+ }
66
+ })
67
+ }
68
+
69
+ onWorldGone() {
70
+ for (const [id, videoData] of this.customMedia.entries()) {
71
+ this.destroyMedia(id)
72
+ }
73
+ }
74
+
75
+ onWorldStop() {
76
+ for (const [id, videoData] of this.customMedia.entries()) {
77
+ this.setVideoPlaying(id, false)
78
+ }
79
+ }
80
+
81
+ private createErrorTexture(width: number, height: number, background = 0xff_ff_ff, error = 'Failed to load'): THREE.CanvasTexture {
82
+ const canvas = new OffscreenCanvas(100, 100)
83
+ const MAX_DIMENSION = 100
84
+
85
+ canvas.width = MAX_DIMENSION
86
+ canvas.height = MAX_DIMENSION
87
+
88
+ const ctx = canvas.getContext('2d')
89
+ if (!ctx) return new THREE.CanvasTexture(canvas)
90
+
91
+ // Clear with transparent background
92
+ ctx.clearRect(0, 0, canvas.width, canvas.height)
93
+
94
+ // Add background color
95
+ ctx.fillStyle = `rgba(${background >> 16 & 255}, ${background >> 8 & 255}, ${background & 255}, 1)`
96
+ ctx.fillRect(0, 0, canvas.width, canvas.height)
97
+
98
+ // Add red text with size relative to canvas dimensions
99
+ ctx.fillStyle = '#ff0000'
100
+ ctx.font = 'bold 10px sans-serif'
101
+ ctx.textAlign = 'center'
102
+ ctx.textBaseline = 'middle'
103
+ ctx.fillText(error, canvas.width / 2, canvas.height / 2, canvas.width)
104
+
105
+ const texture = new THREE.CanvasTexture(canvas)
106
+ texture.minFilter = THREE.LinearFilter
107
+ texture.magFilter = THREE.LinearFilter
108
+ return texture
109
+ }
110
+
111
+ private createBackgroundTexture(width: number, height: number, color = 0x00_00_00, opacity = 1): THREE.CanvasTexture {
112
+ const canvas = new OffscreenCanvas(1, 1)
113
+ canvas.width = 1
114
+ canvas.height = 1
115
+
116
+ const ctx = canvas.getContext('2d')
117
+ if (!ctx) return new THREE.CanvasTexture(canvas)
118
+
119
+ // Convert hex color to rgba
120
+ const r = (color >> 16) & 255
121
+ const g = (color >> 8) & 255
122
+ const b = color & 255
123
+
124
+ ctx.fillStyle = `rgba(${r}, ${g}, ${b}, ${opacity})`
125
+ ctx.fillRect(0, 0, 1, 1)
126
+
127
+ const texture = new THREE.CanvasTexture(canvas)
128
+ texture.minFilter = THREE.NearestFilter
129
+ texture.magFilter = THREE.NearestFilter
130
+ return texture
131
+ }
132
+
133
+ validateOrigin(src: string, allowOrigins: string[] | boolean) {
134
+ if (allowOrigins === true) return true
135
+ if (allowOrigins === false) return false
136
+ const url = new URL(src)
137
+ return allowOrigins.some(origin => url.origin.endsWith(origin))
138
+ }
139
+
140
+ onPageInteraction() {
141
+ for (const [id, videoData] of this.customMedia.entries()) {
142
+ if (videoData.hadAutoPlayError) {
143
+ videoData.hadAutoPlayError = false
144
+ this.playVideo(id)
145
+ }
146
+ }
147
+ }
148
+
149
+ addMedia(id: string, props: MediaProperties) {
150
+ // if (!props.imageOverride && this.customMedia.has(id)) {
151
+ // console.warn('Media already exists, destroying it', id)
152
+ // debugger
153
+ // }
154
+ const originalProps = structuredClone(props)
155
+ this.destroyMedia(id)
156
+
157
+ let headCheck = false
158
+ if (props.src.startsWith('https://disk.yandex.ru/i/')) {
159
+ headCheck = true
160
+ props.src = `/ya-image?url=${props.src}`
161
+ }
162
+
163
+ const { scene } = this.worldRenderer
164
+
165
+ const originSecurityError = props.allowOrigins !== undefined && !this.validateOrigin(props.src, props.allowOrigins)
166
+ if (originSecurityError) {
167
+ console.warn('Remote resource blocked due to security policy', props.src, 'allowed origins:', props.allowOrigins, 'you can control it with `remoteContentNotSameOrigin` option')
168
+ props.src = ''
169
+ }
170
+
171
+ // Check content type for Yandex Disk URLs
172
+ const isImage = props.src.endsWith('.png') || props.src.endsWith('.jpg') || props.src.endsWith('.jpeg') || props.imageOverride
173
+ if (headCheck && !props.imageOverride) {
174
+ // Fetch headers only to check content type
175
+ fetch(props.src, { method: 'HEAD' })
176
+ .then(response => {
177
+ const contentType = response.headers.get('content-type')
178
+ if (contentType?.startsWith('image/')) {
179
+ // If it's a video, recreate the media with video element
180
+ this.destroyMedia(id)
181
+ this.addMedia(id, { ...originalProps, src: props.src, imageOverride: true })
182
+ }
183
+ })
184
+ .catch(console.error)
185
+ }
186
+
187
+ let video: HTMLVideoElement | undefined
188
+ let positionalAudio: THREE.PositionalAudio | undefined
189
+ if (!isImage) {
190
+ video = document.createElement('video')
191
+ video.src = props.src.endsWith('.gif') ? props.src.replace('.gif', '.mp4') : props.src
192
+ video.loop = props.loop ?? true
193
+ const volume = (props.volume ?? 1) * this.worldRenderer.worldRendererConfig.volume
194
+ video.volume = Math.min(volume, 1)
195
+ video.playsInline = true
196
+ video.crossOrigin = 'anonymous'
197
+
198
+ // Create positional audio
199
+ const soundSystem = this.worldRenderer.soundSystem as ThreeJsSound
200
+ soundSystem.initAudioListener()
201
+ if (!soundSystem.audioListener) throw new Error('Audio listener not initialized')
202
+ positionalAudio = new THREE.PositionalAudio(soundSystem.audioListener)
203
+ positionalAudio.setRefDistance(6)
204
+ positionalAudio.setVolume(volume)
205
+ scene.add(positionalAudio)
206
+ positionalAudio.position.set(props.position.x, props.position.y, props.position.z)
207
+
208
+ // Connect video to positional audio
209
+ positionalAudio.setMediaElementSource(video)
210
+ positionalAudio.connect()
211
+
212
+ video.addEventListener('pause', () => {
213
+ positionalAudio?.pause()
214
+ globalThis.tempSendVideoStop?.(id, 'paused', video!.currentTime)
215
+ })
216
+ video.addEventListener('play', () => {
217
+ positionalAudio?.play()
218
+ globalThis.tempSendVideoPlay?.(id)
219
+ })
220
+ video.addEventListener('seeked', () => {
221
+ if (positionalAudio && video) {
222
+ positionalAudio.offset = video.currentTime
223
+ }
224
+ })
225
+ video.addEventListener('stalled', () => {
226
+ globalThis.tempSendVideoStop?.(id, 'stalled', video!.currentTime)
227
+ })
228
+ video.addEventListener('waiting', () => {
229
+ globalThis.tempSendVideoStop?.(id, 'waiting', video!.currentTime)
230
+ })
231
+ video.addEventListener('error', ({ error }) => {
232
+ globalThis.tempSendVideoStop?.(id, `error: ${error}`, video!.currentTime)
233
+ })
234
+ video.addEventListener('ended', () => {
235
+ globalThis.tempSendVideoStop?.(id, 'ended', video!.currentTime)
236
+ if (!props.loop) {
237
+ video!.currentTime = 0
238
+ videoData.ended = true
239
+ }
240
+ })
241
+ }
242
+
243
+
244
+ // Create background texture first
245
+ const backgroundTexture = this.createBackgroundTexture(
246
+ props.size.width,
247
+ props.size.height,
248
+ props.background,
249
+ // props.opacity ?? 1
250
+ )
251
+
252
+ const handleError = (text?: string) => {
253
+ const errorTexture = this.createErrorTexture(props.size.width, props.size.height, props.background, text)
254
+ material.map = errorTexture
255
+ material.needsUpdate = true
256
+ }
257
+
258
+ // Create a plane geometry with configurable UV mapping
259
+ const geometry = new THREE.PlaneGeometry(1, 1)
260
+
261
+ // Create material with initial properties using background texture
262
+ const MaterialClass = props.allowLighting ? THREE.MeshLambertMaterial : THREE.MeshBasicMaterial
263
+ const material = new MaterialClass({
264
+ map: backgroundTexture,
265
+ transparent: true,
266
+ side: props.doubleSide ? THREE.DoubleSide : THREE.FrontSide,
267
+ alphaTest: 0.1
268
+ })
269
+
270
+ const texture = video
271
+ ? new THREE.VideoTexture(video)
272
+ : new THREE.TextureLoader().load(props.src, () => {
273
+ if (this.customMedia.get(id)?.texture === texture) {
274
+ material.map = texture
275
+ material.needsUpdate = true
276
+ }
277
+ }, undefined, () => handleError()) // todo cache
278
+ texture.minFilter = THREE.NearestFilter
279
+ texture.magFilter = THREE.NearestFilter
280
+ // texture.format = THREE.RGBAFormat
281
+ // texture.colorSpace = THREE.SRGBColorSpace
282
+ texture.generateMipmaps = false
283
+
284
+ // Create inner mesh for offsets
285
+ const mesh = new THREE.Mesh(geometry, material)
286
+
287
+ const { mesh: panel } = this.positionMeshExact(mesh, THREE.MathUtils.degToRad((props.rotation ?? 0) * 90), props.position, props.size.width, props.size.height)
288
+
289
+ scene.add(panel)
290
+
291
+ if (video) {
292
+ // Update texture in animation loop regardless of autoPlay
293
+ mesh.onBeforeRender = () => {
294
+ if (video.readyState === video.HAVE_ENOUGH_DATA && (!video.paused || !videoData?.hadAutoPlayError)) {
295
+ if (material.map !== texture) {
296
+ material.map = texture
297
+ material.needsUpdate = true
298
+ }
299
+ texture.needsUpdate = true
300
+
301
+ // Sync audio position with video position
302
+ if (positionalAudio) {
303
+ positionalAudio.position.copy(panel.position)
304
+ positionalAudio.rotation.copy(panel.rotation)
305
+ }
306
+ }
307
+ }
308
+ }
309
+
310
+ // UV mapping configuration
311
+ const updateUVMapping = (config: { startU: number, endU: number, startV: number, endV: number }) => {
312
+ const uvs = geometry.attributes.uv.array as Float32Array
313
+ uvs[0] = config.startU
314
+ uvs[1] = config.startV
315
+ uvs[2] = config.endU
316
+ uvs[3] = config.startV
317
+ uvs[4] = config.endU
318
+ uvs[5] = config.endV
319
+ uvs[6] = config.startU
320
+ uvs[7] = config.endV
321
+ geometry.attributes.uv.needsUpdate = true
322
+ }
323
+
324
+ // Apply initial UV mapping if provided
325
+ if (props.uvMapping) {
326
+ updateUVMapping(props.uvMapping)
327
+ }
328
+
329
+ const videoData: MediaData = {
330
+ mesh: panel,
331
+ video,
332
+ texture,
333
+ updateUVMapping,
334
+ positionalAudio,
335
+ props: originalProps,
336
+ hadAutoPlayError: false,
337
+ pausedBecuaseHidden: false,
338
+ ended: false,
339
+ handleError(err: Error) {
340
+ if (videoData.destroyed) return
341
+ console.error(`Failed to play video ${id}:`, err)
342
+ // TODO!
343
+ const t = /* translate ?? */(txt => txt)
344
+ handleError(err.name === 'NotAllowedError' || err.name === 'AbortError' ? t('Waiting for user interaction') : t('Failed to auto play'))
345
+ }
346
+ }
347
+ // Store video data
348
+ this.customMedia.set(id, videoData)
349
+
350
+ if (video && props.autoPlay) {
351
+ // Start playing the video
352
+ this.playVideo(id, true)
353
+ }
354
+
355
+ return id
356
+ }
357
+
358
+ playVideo(id: string, fromAutoPlay = false) {
359
+ const videoData = this.customMedia.get(id)
360
+ if (videoData?.video) {
361
+ // TODO! resolve issue with time
362
+ if (!fromAutoPlay && videoData.positionalAudio && videoData.video.currentTime < 1) {
363
+ // workaround: audio has to be recreated
364
+ const prevTime = videoData.video.currentTime
365
+ this.addMedia(id, videoData.props)
366
+ videoData.video.currentTime = prevTime
367
+ if (!videoData.props.autoPlay) {
368
+ this.playVideo(id, true)
369
+ }
370
+ return
371
+ }
372
+
373
+ void videoData.video.play()
374
+ .then(() => {
375
+ videoData.hadAutoPlayError = false
376
+ console.log(`Playing video ${id}`)
377
+ })
378
+ .catch(err => {
379
+ if (videoData.pausedBecuaseHidden) return
380
+ if (err.name === 'NotAllowedError' || err.name === 'AbortError' || err.message?.includes('not allowed') && !videoData.pausedBecuaseHidden) {
381
+ videoData.hadAutoPlayError = true
382
+ }
383
+ videoData.handleError(err)
384
+ })
385
+ }
386
+ }
387
+
388
+ render() {
389
+ for (const [id, videoData] of this.customMedia.entries()) {
390
+ const currentVisible = videoData.mesh.visible
391
+ videoData.mesh.visible = this.worldRenderer.shouldObjectVisible(videoData.mesh) && !videoData.mesh['forceHide']
392
+ if (currentVisible !== videoData.mesh.visible && videoData.video) {
393
+ const isNowVisible = videoData.mesh.visible
394
+ if (isNowVisible) {
395
+ if (videoData.pausedBecuaseHidden) {
396
+ this.playVideo(id)
397
+ videoData.pausedBecuaseHidden = false
398
+ }
399
+ } else if (!videoData.video.paused) {
400
+ videoData.video.pause()
401
+ videoData.pausedBecuaseHidden = true
402
+ }
403
+ }
404
+ }
405
+ }
406
+
407
+ setVideoPlaying(id: string, playing: boolean) {
408
+ const videoData = this.customMedia.get(id)
409
+ if (videoData?.video) {
410
+ if (playing) {
411
+ this.playVideo(id)
412
+ } else {
413
+ videoData.video.pause()
414
+ }
415
+ }
416
+ }
417
+
418
+ setVideoSeeking(id: string, seconds: number) {
419
+ const videoData = this.customMedia.get(id)
420
+ if (videoData?.video) {
421
+ videoData.video.currentTime = seconds
422
+ }
423
+ }
424
+
425
+ setVideoVolume(id: string, volume: number) {
426
+ const videoData = this.customMedia.get(id)
427
+ if (videoData?.video) {
428
+ videoData.video.volume = volume
429
+ }
430
+ }
431
+
432
+ setVideoSpeed(id: string, speed: number) {
433
+ const videoData = this.customMedia.get(id)
434
+ if (videoData?.video) {
435
+ videoData.video.playbackRate = speed
436
+ }
437
+ }
438
+
439
+ setControlMode(id: string, mouseButton: 'both' | 'left' | 'right', controlMode: 'play_pause' | 'play_if_ended') {
440
+ const videoData = this.customMedia.get(id)
441
+ if (videoData?.video) {
442
+ videoData.props.controlMode = {
443
+ mouseButton,
444
+ controlMode
445
+ }
446
+ }
447
+ }
448
+
449
+ destroyMedia(id: string) {
450
+ const { scene } = this.worldRenderer
451
+ const mediaData = this.customMedia.get(id)
452
+ if (mediaData) {
453
+ mediaData.destroyed = true
454
+
455
+ if (mediaData.video) {
456
+ mediaData.video.pause()
457
+ mediaData.video.src = ''
458
+ mediaData.video.remove()
459
+ }
460
+ if (mediaData.positionalAudio) {
461
+ // mediaData.positionalAudio.stop()
462
+ // mediaData.positionalAudio.disconnect()
463
+ scene.remove(mediaData.positionalAudio)
464
+ }
465
+ scene.remove(mediaData.mesh)
466
+ mediaData.texture.dispose()
467
+
468
+ // Get the inner mesh from the group
469
+ const mesh = mediaData.mesh.children[0] as THREE.Mesh
470
+ if (mesh) {
471
+ mesh.geometry.dispose()
472
+ if (mesh.material instanceof THREE.Material) {
473
+ mesh.material.dispose()
474
+ }
475
+ }
476
+
477
+ this.customMedia.delete(id)
478
+ }
479
+ }
480
+
481
+ /**
482
+ * Positions a mesh exactly at startPosition and extends it along the rotation direction
483
+ * with the specified width and height
484
+ *
485
+ * @param mesh The mesh to position
486
+ * @param rotation Rotation in radians (applied to Y axis)
487
+ * @param startPosition The exact starting position (corner) of the mesh
488
+ * @param width Width of the mesh
489
+ * @param height Height of the mesh
490
+ * @param depth Depth of the mesh (default: 1)
491
+ * @returns The positioned mesh for chaining
492
+ */
493
+ positionMeshExact(
494
+ mesh: THREE.Mesh,
495
+ rotation: number,
496
+ startPosition: { x: number, y: number, z: number },
497
+ width: number,
498
+ height: number,
499
+ depth = 1
500
+ ) {
501
+ // avoid z-fighting with the ground plane
502
+ if (rotation === 0) {
503
+ startPosition.z += 0.001
504
+ }
505
+ if (rotation === Math.PI / 2) {
506
+ startPosition.x -= 0.001
507
+ }
508
+ if (rotation === Math.PI) {
509
+ startPosition.z -= 0.001
510
+ }
511
+ if (rotation === 3 * Math.PI / 2) {
512
+ startPosition.x += 0.001
513
+ }
514
+
515
+ // rotation normalize coordinates
516
+ if (rotation === 0) {
517
+ startPosition.z += 1
518
+ }
519
+ if (rotation === Math.PI) {
520
+ startPosition.x += 1
521
+ }
522
+ if (rotation === 3 * Math.PI / 2) {
523
+ startPosition.z += 1
524
+ startPosition.x += 1
525
+ }
526
+
527
+
528
+ // First, clean up any previous transformations
529
+ mesh.matrix.identity()
530
+ mesh.position.set(0, 0, 0)
531
+ mesh.rotation.set(0, 0, 0)
532
+ mesh.scale.set(1, 1, 1)
533
+
534
+ // By default, PlaneGeometry creates a plane in the XY plane (facing +Z)
535
+ // We need to set up the proper orientation for our use case
536
+ // Rotate the plane to face the correct direction based on the rotation parameter
537
+ mesh.rotateY(rotation)
538
+ if (rotation === Math.PI / 2 || rotation === 3 * Math.PI / 2) {
539
+ mesh.rotateZ(-Math.PI)
540
+ mesh.rotateX(-Math.PI)
541
+ }
542
+
543
+ // Scale it to the desired size
544
+ mesh.scale.set(width, height, depth)
545
+
546
+ // For a PlaneGeometry, if we want the corner at the origin, we need to offset
547
+ // by half the dimensions after scaling
548
+ mesh.geometry.translate(0.5, 0.5, 0)
549
+ mesh.geometry.attributes.position.needsUpdate = true
550
+
551
+ // Now place the mesh at the start position
552
+ mesh.position.set(startPosition.x, startPosition.y, startPosition.z)
553
+
554
+ // Create a group to hold our mesh and markers
555
+ const debugGroup = new THREE.Group()
556
+ debugGroup.add(mesh)
557
+
558
+ // Add a marker at the starting position (should be exactly at pos)
559
+ const startMarker = new THREE.Mesh(
560
+ new THREE.BoxGeometry(0.1, 0.1, 0.1),
561
+ new THREE.MeshBasicMaterial({ color: 0xff_00_00 })
562
+ )
563
+ startMarker.position.copy(new THREE.Vector3(startPosition.x, startPosition.y, startPosition.z))
564
+ debugGroup.add(startMarker)
565
+
566
+ // Add a marker at the end position (width units away in the rotated direction)
567
+ const endX = startPosition.x + Math.cos(rotation) * width
568
+ const endZ = startPosition.z + Math.sin(rotation) * width
569
+ const endYMarker = new THREE.Mesh(
570
+ new THREE.BoxGeometry(0.1, 0.1, 0.1),
571
+ new THREE.MeshBasicMaterial({ color: 0x00_00_ff })
572
+ )
573
+ endYMarker.position.set(startPosition.x, startPosition.y + height, startPosition.z)
574
+ debugGroup.add(endYMarker)
575
+
576
+ // Add a marker at the width endpoint
577
+ const endWidthMarker = new THREE.Mesh(
578
+ new THREE.BoxGeometry(0.1, 0.1, 0.1),
579
+ new THREE.MeshBasicMaterial({ color: 0xff_ff_00 })
580
+ )
581
+ endWidthMarker.position.set(endX, startPosition.y, endZ)
582
+ debugGroup.add(endWidthMarker)
583
+
584
+ // Add a marker at the corner diagonal endpoint (both width and height)
585
+ const endCornerMarker = new THREE.Mesh(
586
+ new THREE.BoxGeometry(0.1, 0.1, 0.1),
587
+ new THREE.MeshBasicMaterial({ color: 0xff_00_ff })
588
+ )
589
+ endCornerMarker.position.set(endX, startPosition.y + height, endZ)
590
+ debugGroup.add(endCornerMarker)
591
+
592
+ // Also add a visual helper to show the rotation direction
593
+ const directionHelper = new THREE.ArrowHelper(
594
+ new THREE.Vector3(Math.cos(rotation), 0, Math.sin(rotation)),
595
+ new THREE.Vector3(startPosition.x, startPosition.y, startPosition.z),
596
+ 1,
597
+ 0xff_00_00
598
+ )
599
+ debugGroup.add(directionHelper)
600
+
601
+ return {
602
+ mesh,
603
+ debugGroup
604
+ }
605
+ }
606
+
607
+ createTestCanvasTexture() {
608
+ const canvas = new OffscreenCanvas(100, 100)
609
+ canvas.width = 100
610
+ canvas.height = 100
611
+ const ctx = canvas.getContext('2d')
612
+ if (!ctx) return null
613
+ ctx.font = '10px Arial'
614
+ ctx.fillStyle = 'red'
615
+ ctx.fillText('Hello World', 0, 10) // at
616
+ return new THREE.CanvasTexture(canvas)
617
+ }
618
+
619
+ /**
620
+ * Creates a test mesh that demonstrates the exact positioning
621
+ */
622
+ // addTestMeshExact(rotationNum: number) {
623
+ // const pos = window.cursorBlockRel().position
624
+ // console.log('Creating exact positioned test mesh at:', pos)
625
+
626
+ // // Create a plane mesh with a wireframe to visualize boundaries
627
+ // const plane = new THREE.Mesh(
628
+ // new THREE.PlaneGeometry(1, 1),
629
+ // new THREE.MeshBasicMaterial({
630
+ // // side: THREE.DoubleSide,
631
+ // map: this.createTestCanvasTexture()
632
+ // })
633
+ // )
634
+
635
+ // const width = 2
636
+ // const height = 1
637
+ // const rotation = THREE.MathUtils.degToRad(rotationNum * 90) // 90 degrees in radians
638
+
639
+ // // Position the mesh exactly where we want it
640
+ // const { debugGroup } = this.positionMeshExact(plane, rotation, pos, width, height)
641
+
642
+ // this.worldRenderer.scene.add(debugGroup)
643
+ // console.log('Exact test mesh added with dimensions:', width, height, 'and rotation:', rotation)
644
+ // }
645
+
646
+ lastCheck = 0
647
+ THROTTLE_TIME = 100
648
+ tryIntersectMedia() {
649
+ // hack: need to optimize this by pulling only in distance of interaction instead and throttle
650
+ if (Date.now() - this.lastCheck < this.THROTTLE_TIME) return
651
+ if (this.customMedia.size === 0) {
652
+ this.worldRenderer.reactiveState.world.intersectMedia = null
653
+ this.worldRenderer['debugVideo'] = null
654
+ this.worldRenderer.cursorBlock.cursorLinesHidden = false
655
+ return
656
+ }
657
+ this.lastCheck = Date.now()
658
+
659
+ const { camera, scene } = this.worldRenderer
660
+ const raycaster = new THREE.Raycaster()
661
+
662
+ // Get mouse position at center of screen
663
+ const mouse = new THREE.Vector2(0, 0)
664
+
665
+ // Update the raycaster
666
+ raycaster.setFromCamera(mouse, camera)
667
+
668
+ // Check intersection with all objects in scene
669
+ const intersects = raycaster.intersectObjects(scene.children, true)
670
+ if (intersects.length > 0) {
671
+ const intersection = intersects[0]
672
+ const intersectedObject = intersection.object
673
+
674
+ // Find if this object belongs to any media
675
+ for (const [id, videoData] of this.customMedia.entries()) {
676
+ // Check if the intersected object is part of our media mesh
677
+ if (intersectedObject === videoData.mesh ||
678
+ videoData.mesh.children.includes(intersectedObject)) {
679
+ const { uv } = intersection
680
+ if (uv) {
681
+ const result = {
682
+ id,
683
+ x: uv.x,
684
+ y: uv.y
685
+ }
686
+ this.worldRenderer.reactiveState.world.intersectMedia = result
687
+ this.worldRenderer['debugVideo'] = videoData
688
+ this.worldRenderer.cursorBlock.cursorLinesHidden = true
689
+ return
690
+ }
691
+ }
692
+ }
693
+ }
694
+
695
+ // No media intersection found
696
+ this.worldRenderer.reactiveState.world.intersectMedia = null
697
+ this.worldRenderer['debugVideo'] = null
698
+ this.worldRenderer.cursorBlock.cursorLinesHidden = false
699
+ }
700
+
701
+ handleUserClick(button: 'left' | 'right') {
702
+ const intersecting = this.worldRenderer.reactiveState.world.intersectMedia
703
+ if (intersecting) {
704
+ const { id, x, y } = intersecting
705
+ const videoData = this.customMedia.get(id)
706
+ const controlMode = videoData?.props.controlMode
707
+ if (videoData?.video && (controlMode?.mouseButton === 'both' || controlMode?.mouseButton === button)) {
708
+ switch (controlMode?.controlMode) {
709
+ case 'play_pause': {
710
+ this.setVideoPlaying(id, videoData.video.paused)
711
+
712
+ break
713
+ }
714
+ case 'play_if_ended': {
715
+ if (videoData.ended) {
716
+ this.setVideoPlaying(id, true)
717
+ }
718
+
719
+ break
720
+ }
721
+ case 'toggle_mute': {
722
+ videoData.video.muted = !videoData.video.muted
723
+
724
+ break
725
+ }
726
+ // No default
727
+ }
728
+ }
729
+ }
730
+ }
731
+ }