@vctrl/hooks 0.9.4 → 0.11.1

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 (71) hide show
  1. package/CHANGELOG.md +210 -0
  2. package/README.md +202 -383
  3. package/package.json +66 -55
  4. package/project.json +55 -0
  5. package/src/index.ts +38 -0
  6. package/src/use-export-model/use-export-model.ts +113 -0
  7. package/src/use-load-model/event-system.ts +46 -0
  8. package/src/use-load-model/index.ts +15 -0
  9. package/src/use-load-model/model-context.tsx +174 -0
  10. package/src/use-load-model/state.ts +75 -0
  11. package/src/use-load-model/types.ts +284 -0
  12. package/src/use-load-model/use-load-model.ts +682 -0
  13. package/src/use-load-model/utils/calculate-referenced-bytes.ts +123 -0
  14. package/src/use-load-model/utils/index.ts +7 -0
  15. package/src/use-load-model/utils/read-directory.ts +35 -0
  16. package/src/use-load-model/utils/reconstruct-files.ts +85 -0
  17. package/src/use-load-model/utils/resolve-scene-payload.ts +151 -0
  18. package/{use-optimize-model/index.d.ts → src/use-optimize-model/index.ts} +2 -1
  19. package/src/use-optimize-model/state.ts +40 -0
  20. package/src/use-optimize-model/types.ts +37 -0
  21. package/src/use-optimize-model/use-calc-optimization-info.ts +97 -0
  22. package/src/use-optimize-model/use-optimize-model.ts +467 -0
  23. package/src/use-optimize-model/utils/index.ts +29 -0
  24. package/src/use-optimize-model/utils/texture-optimization.ts +292 -0
  25. package/src/use-optimize-model/utils/validation.ts +62 -0
  26. package/src/utils/server-communication.ts +351 -0
  27. package/tsconfig.json +21 -0
  28. package/tsconfig.lib.json +16 -0
  29. package/vite.config.ts +77 -0
  30. package/GLTFExporter-Q5UJ5eb1.js +0 -1265
  31. package/GLTFExporter-ltc8rmzz.cjs +0 -18
  32. package/index.cjs.js +0 -1
  33. package/index.d.ts +0 -3
  34. package/index.es.js +0 -12
  35. package/model-context-Du5WQddA.cjs +0 -9
  36. package/model-context-fEKN9ymq.js +0 -3443
  37. package/use-export-model/types.d.ts +0 -19
  38. package/use-export-model/use-export-model.d.ts +0 -14
  39. package/use-export-model/utils/data-uri-to-blob.d.ts +0 -9
  40. package/use-export-model/utils/export-handlers.d.ts +0 -23
  41. package/use-export-model/utils/file-helpers.d.ts +0 -17
  42. package/use-export-model/utils/index.d.ts +0 -3
  43. package/use-export-model-BfzYIkcD.cjs +0 -1
  44. package/use-export-model-ccF7tK6m.js +0 -76
  45. package/use-export-model.cjs.js +0 -1
  46. package/use-export-model.es.js +0 -4
  47. package/use-load-model/event-system.d.ts +0 -7
  48. package/use-load-model/file-type-hooks/index.d.ts +0 -2
  49. package/use-load-model/file-type-hooks/use-load-binary.d.ts +0 -11
  50. package/use-load-model/file-type-hooks/use-load-gltf.d.ts +0 -5
  51. package/use-load-model/index.d.ts +0 -3
  52. package/use-load-model/loaders/create-gltf-loader.d.ts +0 -3
  53. package/use-load-model/loaders/create-usdz-loader.d.ts +0 -3
  54. package/use-load-model/loaders/index.d.ts +0 -2
  55. package/use-load-model/model-context.d.ts +0 -78
  56. package/use-load-model/state.d.ts +0 -19
  57. package/use-load-model/types.d.ts +0 -51
  58. package/use-load-model/use-load-model.d.ts +0 -74
  59. package/use-load-model/utils/array-buffer-to-base64.d.ts +0 -2
  60. package/use-load-model/utils/index.d.ts +0 -2
  61. package/use-load-model/utils/read-directory.d.ts +0 -8
  62. package/use-load-model.cjs.js +0 -1
  63. package/use-load-model.es.js +0 -8
  64. package/use-optimize-model/state.d.ts +0 -13
  65. package/use-optimize-model/types.d.ts +0 -37
  66. package/use-optimize-model/use-optimize-model.d.ts +0 -29
  67. package/use-optimize-model-BXWR9d6e.js +0 -127
  68. package/use-optimize-model-EEn8K4nu.cjs +0 -1
  69. package/use-optimize-model.cjs.js +0 -1
  70. package/use-optimize-model.es.js +0 -4
  71. /package/{use-export-model/index.d.ts → src/use-export-model/index.ts} +0 -0
@@ -0,0 +1,292 @@
1
+ /* vectreal-core | vctrl/hooks
2
+ Copyright (C) 2024 Moritz Becker
3
+
4
+ This program is free software: you can redistribute it and/or modify
5
+ it under the terms of the GNU Affero General Public License as published by
6
+ the Free Software Foundation, either version 3 of the License, or
7
+ (at your option) any later version.
8
+
9
+ This program is distributed in the hope that it will be useful,
10
+ but WITHOUT ANY WARRANTY; without even the implied warranty of
11
+ MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
12
+ GNU Affero General Public License for more details.
13
+
14
+ You should have received a copy of the GNU Affero General Public License
15
+ along with this program. If not, see <http://www.gnu.org/licenses/>. */
16
+
17
+ import { validateServerResponse } from './validation'
18
+ import { ServerCommunicationService } from '../../utils/server-communication'
19
+
20
+ import type {
21
+ ModelOptimizer,
22
+ TextureCompressOptions,
23
+ TextureDescriptor
24
+ } from '@vctrl/core/model-optimizer'
25
+
26
+ const DEFAULT_TIMEOUT_MS = 20_000
27
+ const DEFAULT_MAX_RETRIES = 2
28
+ const DEFAULT_MAX_TEXTURE_UPLOAD_BYTES = 50 * 1024 * 1024
29
+ const DEFAULT_MAX_CONCURRENT_REQUESTS = 4
30
+ const TRANSIENT_STATUS_CODES = new Set([408, 425, 429, 500, 502, 503, 504])
31
+
32
+ const wait = async (ms: number): Promise<void> => {
33
+ await new Promise((resolve) => setTimeout(resolve, ms))
34
+ }
35
+
36
+ const resolveTextureMimeType = (response: Response): string => {
37
+ const contentType = response.headers.get('content-type')
38
+ if (!contentType) {
39
+ return 'application/octet-stream'
40
+ }
41
+
42
+ return contentType.split(';')[0]?.trim() || 'application/octet-stream'
43
+ }
44
+
45
+ const resolveCanonicalTextureFileName = (response: Response): string => {
46
+ const responseFileName =
47
+ response.headers.get('X-Texture-File-Name') ||
48
+ response.headers.get('X-Texture-Name')
49
+
50
+ if (!responseFileName || responseFileName.trim().length === 0) {
51
+ throw new Error(
52
+ 'Optimized texture response is missing a canonical file name'
53
+ )
54
+ }
55
+
56
+ return responseFileName.trim()
57
+ }
58
+
59
+ const requestSingleTextureOptimization = async (
60
+ endpoint: string,
61
+ headers: HeadersInit,
62
+ body: ArrayBuffer,
63
+ requestTimeoutMs: number,
64
+ maxRetries: number
65
+ ): Promise<Response> => {
66
+ let attempt = 0
67
+
68
+ while (true) {
69
+ const controller = new AbortController()
70
+ const timeoutId = setTimeout(() => controller.abort(), requestTimeoutMs)
71
+
72
+ try {
73
+ const response = await fetch(endpoint, {
74
+ method: 'POST',
75
+ headers,
76
+ body,
77
+ signal: controller.signal
78
+ })
79
+
80
+ if (
81
+ !response.ok &&
82
+ TRANSIENT_STATUS_CODES.has(response.status) &&
83
+ attempt < maxRetries
84
+ ) {
85
+ attempt += 1
86
+ await wait(200 * attempt + Math.floor(Math.random() * 120))
87
+ continue
88
+ }
89
+
90
+ return response
91
+ } catch (error) {
92
+ if (attempt >= maxRetries) {
93
+ throw error
94
+ }
95
+
96
+ attempt += 1
97
+ await wait(200 * attempt + Math.floor(Math.random() * 120))
98
+ } finally {
99
+ clearTimeout(timeoutId)
100
+ }
101
+ }
102
+ }
103
+
104
+ const optimizeTexture = async (
105
+ optimizer: ModelOptimizer,
106
+ texture: TextureDescriptor,
107
+ options: TextureCompressOptions,
108
+ serverOptions: ReturnType<
109
+ typeof ServerCommunicationService.createDefaultServerOptions
110
+ >
111
+ ): Promise<void> => {
112
+ const payload = optimizer.getTexturePayload(texture.index)
113
+ const maxTextureUploadBytes =
114
+ options.maxTextureUploadBytes ?? DEFAULT_MAX_TEXTURE_UPLOAD_BYTES
115
+
116
+ if (payload.image.byteLength > maxTextureUploadBytes) {
117
+ throw new Error(
118
+ `Texture ${texture.index} payload (${payload.image.byteLength} bytes) exceeds maxTextureUploadBytes (${maxTextureUploadBytes} bytes). Increase maxTextureUploadBytes to allow optimizing larger source textures.`
119
+ )
120
+ }
121
+
122
+ const requestBytes = new Uint8Array(payload.image.byteLength)
123
+ requestBytes.set(payload.image)
124
+ const requestBody = requestBytes.buffer
125
+ const { serverOptions: _, ...restOptions } = options
126
+ const headers = ServerCommunicationService.createRequestHeaders(
127
+ serverOptions,
128
+ {
129
+ 'Content-Type': 'application/octet-stream',
130
+ 'X-Texture-Index': String(texture.index),
131
+ 'X-Texture-Name': payload.fileName,
132
+ 'X-Texture-File-Name': payload.fileName,
133
+ 'X-Texture-Mime-Type': payload.mimeType,
134
+ 'X-Optimize-Options': JSON.stringify(restOptions)
135
+ }
136
+ )
137
+
138
+ const response = await requestSingleTextureOptimization(
139
+ serverOptions.endpoint,
140
+ headers,
141
+ requestBody,
142
+ options.requestTimeoutMs ?? DEFAULT_TIMEOUT_MS,
143
+ options.maxRetries ?? DEFAULT_MAX_RETRIES
144
+ )
145
+
146
+ if (!response.ok) {
147
+ await ServerCommunicationService.handleServerResponseError(response)
148
+ }
149
+
150
+ await validateServerResponse(response)
151
+
152
+ const responseTextureIndex = Number.parseInt(
153
+ response.headers.get('X-Texture-Index') || '',
154
+ 10
155
+ )
156
+
157
+ if (
158
+ !Number.isFinite(responseTextureIndex) ||
159
+ responseTextureIndex !== texture.index
160
+ ) {
161
+ throw new Error(
162
+ `Texture identity mismatch. Expected ${texture.index}, received ${response.headers.get('X-Texture-Index')}`
163
+ )
164
+ }
165
+
166
+ const optimizedTextureBytes = new Uint8Array(await response.arrayBuffer())
167
+ if (optimizedTextureBytes.byteLength === 0) {
168
+ throw new Error(`Empty optimized payload for texture ${texture.index}`)
169
+ }
170
+
171
+ const canonicalFileName = resolveCanonicalTextureFileName(response)
172
+
173
+ optimizer.replaceTexturePayload(
174
+ texture.index,
175
+ optimizedTextureBytes,
176
+ resolveTextureMimeType(response),
177
+ canonicalFileName
178
+ )
179
+ }
180
+
181
+ /**
182
+ * Performs server-side texture optimization.
183
+ */
184
+ export const performServerSideTextureOptimization = async (
185
+ optimizer: ModelOptimizer,
186
+ options: TextureCompressOptions
187
+ ): Promise<void> => {
188
+ const serverOptions = ServerCommunicationService.createDefaultServerOptions(
189
+ options.serverOptions
190
+ )
191
+ const textures = optimizer.listTextureDescriptors()
192
+ const failures: Array<{ index: number; reason: string }> = []
193
+ let successCount = 0
194
+ const maxConcurrentRequests =
195
+ options.maxConcurrentRequests ?? DEFAULT_MAX_CONCURRENT_REQUESTS
196
+
197
+ try {
198
+ for (
199
+ let start = 0;
200
+ start < textures.length;
201
+ start += maxConcurrentRequests
202
+ ) {
203
+ const chunk = textures.slice(start, start + maxConcurrentRequests)
204
+ const settled = await Promise.allSettled(
205
+ chunk.map(async (texture) => {
206
+ await optimizeTexture(optimizer, texture, options, serverOptions)
207
+ return texture.index
208
+ })
209
+ )
210
+
211
+ for (const [index, outcome] of settled.entries()) {
212
+ if (outcome.status === 'fulfilled') {
213
+ successCount += 1
214
+ continue
215
+ }
216
+
217
+ failures.push({
218
+ index: chunk[index].index,
219
+ reason:
220
+ outcome.reason instanceof Error
221
+ ? outcome.reason.message
222
+ : String(outcome.reason)
223
+ })
224
+ }
225
+ }
226
+
227
+ if (successCount > 0) {
228
+ optimizer.addAppliedOptimization('texture compression')
229
+ }
230
+
231
+ if (failures.length > 0) {
232
+ const failureSummary = failures
233
+ .map((failure) => `#${failure.index}: ${failure.reason}`)
234
+ .join('; ')
235
+ const prefix =
236
+ successCount === 0
237
+ ? 'Texture optimization failed for all textures.'
238
+ : `Texture optimization failed for ${failures.length} of ${textures.length} textures.`
239
+
240
+ throw new Error(`${prefix} ${failureSummary}`)
241
+ }
242
+ } catch (err) {
243
+ console.error('Server-side texture compression failed:', err)
244
+ throw new Error(
245
+ `Server-side texture compression failed: ${err}. Client-side texture compression is not supported in browser environments.`,
246
+ { cause: err }
247
+ )
248
+ }
249
+ }
250
+
251
+ /**
252
+ * Handles client-side texture optimization (throws error as not supported in browser).
253
+ */
254
+ export const performClientSideTextureOptimization = (): never => {
255
+ console.warn(
256
+ 'Client-side texture compression is not supported in browser environments. ' +
257
+ 'Please enable server-side compression by setting serverOptions.enabled = true.'
258
+ )
259
+
260
+ throw new Error(
261
+ 'Texture compression requires server-side processing. Enable serverOptions.enabled = true in your options.'
262
+ )
263
+ }
264
+
265
+ /**
266
+ * Determines if server-side optimization should be used.
267
+ */
268
+ export const shouldUseServerOptimization = (
269
+ options?: TextureCompressOptions
270
+ ): boolean => {
271
+ return options?.serverOptions?.enabled ?? false
272
+ }
273
+
274
+ /**
275
+ * Main texture optimization function that orchestrates the process.
276
+ */
277
+ export const optimizeTextures = async (
278
+ optimizer: ModelOptimizer,
279
+ options?: TextureCompressOptions
280
+ ): Promise<void> => {
281
+ if (!optimizer.hasModel()) {
282
+ return
283
+ }
284
+
285
+ const useServer = shouldUseServerOptimization(options)
286
+
287
+ if (useServer && options) {
288
+ await performServerSideTextureOptimization(optimizer, options)
289
+ } else {
290
+ performClientSideTextureOptimization()
291
+ }
292
+ }
@@ -0,0 +1,62 @@
1
+ /* vectreal-core | vctrl/hooks
2
+ Copyright (C) 2024 Moritz Becker
3
+
4
+ This program is free software: you can redistribute it and/or modify
5
+ it under the terms of the GNU Affero General Public License as published by
6
+ the Free Software Foundation, either version 3 of the License, or
7
+ (at your option) any later version.
8
+
9
+ This program is distributed in the hope that it will be useful,
10
+ but WITHOUT ANY WARRANTY; without even the implied warranty of
11
+ MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
12
+ GNU Affero General Public License for more details.
13
+
14
+ You should have received a copy of the GNU Affero General Public License
15
+ along with this program. If not, see <http://www.gnu.org/licenses/>. */
16
+
17
+ /**
18
+ * Validates that the response contains the expected content type.
19
+ */
20
+ export const validateResponseContentType = (response: Response): void => {
21
+ const contentType = response.headers.get('content-type')
22
+
23
+ if (!contentType) {
24
+ throw new Error('Missing content type from server response')
25
+ }
26
+
27
+ if (contentType.includes('application/json')) {
28
+ throw new Error('Unexpected JSON success response from texture optimizer')
29
+ }
30
+
31
+ if (
32
+ !contentType.startsWith('image/') &&
33
+ !contentType.includes('application/octet-stream')
34
+ ) {
35
+ throw new Error(`Unexpected content type from server: ${contentType}`)
36
+ }
37
+ }
38
+
39
+ /**
40
+ * Validates a server response and throws appropriate errors.
41
+ */
42
+ export const validateServerResponse = async (
43
+ response: Response
44
+ ): Promise<void> => {
45
+ // First check if the response was successful
46
+ if (!response.ok) {
47
+ return // Error handling is done elsewhere
48
+ }
49
+
50
+ // Check content type for successful responses
51
+ const contentType = response.headers.get('content-type')
52
+
53
+ if (contentType?.includes('application/json')) {
54
+ // Server returned JSON error despite 200 status
55
+ const errorData = await response.json()
56
+ throw new Error(
57
+ errorData.error || errorData.details || 'Server returned error'
58
+ )
59
+ }
60
+
61
+ validateResponseContentType(response)
62
+ }
@@ -0,0 +1,351 @@
1
+ /* vectreal-core | vctrl/hooks
2
+ Copyright (C) 2024 Moritz Becker
3
+
4
+ This program is free software: you can redistribute it and/or modify
5
+ it under the terms of the GNU Affero General Public License as published by
6
+ the Free Software Foundation, either version 3 of the License, or
7
+ (at your option) any later version.
8
+
9
+ This program is distributed in the hope that it will be useful,
10
+ but WITHOUT ANY WARRANTY; without even the implied warranty of
11
+ MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
12
+ GNU Affero General Public License for more details.
13
+
14
+ You should have received a copy of the GNU Affero General Public License
15
+ along with this program. If not, see <http://www.gnu.org/licenses/>. */
16
+
17
+ import type {
18
+ ApiEnvelope,
19
+ ServerOptions,
20
+ TextureBinaryPayload,
21
+ TextureCompressOptions
22
+ } from '@vctrl/core'
23
+
24
+ /**
25
+ * Configuration for a server request.
26
+ */
27
+ export interface ServerRequestConfig {
28
+ /** Server endpoint URL or path */
29
+ endpoint: string
30
+ /** HTTP method (GET, POST, etc.) */
31
+ method?: 'GET' | 'POST' | 'PUT' | 'DELETE' | 'PATCH'
32
+ /** Request body (FormData, JSON, or other) */
33
+ body?: FormData | Record<string, unknown> | string
34
+ /** Optional server options (headers, auth, etc.) */
35
+ serverOptions?: ServerOptions
36
+ /** Content type for JSON bodies (default: 'application/json') */
37
+ contentType?: string
38
+ }
39
+
40
+ /**
41
+ * Unified server communication service for handling HTTP requests.
42
+ * Provides a consistent interface for API calls with built-in error handling,
43
+ * authentication, and response parsing.
44
+ *
45
+ * @example
46
+ * ```typescript
47
+ * // Simple GET request
48
+ * const data = await ServerCommunicationService.request<SceneData>({
49
+ * endpoint: '/api/load-scene',
50
+ * method: 'GET'
51
+ * })
52
+ *
53
+ * // POST with FormData
54
+ * const formData = new FormData()
55
+ * formData.append('model', file)
56
+ * const result = await ServerCommunicationService.request({
57
+ * endpoint: '/api/optimize',
58
+ * method: 'POST',
59
+ * body: formData,
60
+ * serverOptions: { apiKey: 'secret' }
61
+ * })
62
+ *
63
+ * // POST with JSON
64
+ * const response = await ServerCommunicationService.request({
65
+ * endpoint: '/api/save',
66
+ * method: 'POST',
67
+ * body: { sceneId: '123', settings: {...} },
68
+ * serverOptions: { headers: { 'X-Custom': 'value' } }
69
+ * })
70
+ * ```
71
+ */
72
+ export class ServerCommunicationService {
73
+ private static extractApiData<T>(response: ApiEnvelope<T> | T): T {
74
+ if (!response || typeof response !== 'object') {
75
+ return response as T
76
+ }
77
+
78
+ const envelope = response as ApiEnvelope<T>
79
+
80
+ if (envelope.error) {
81
+ throw new Error(envelope.error)
82
+ }
83
+
84
+ if (envelope.success === false) {
85
+ throw new Error('Request failed')
86
+ }
87
+
88
+ if ('data' in envelope) {
89
+ if (typeof envelope.data === 'undefined') {
90
+ throw new Error('Server response did not include data payload')
91
+ }
92
+
93
+ return envelope.data
94
+ }
95
+
96
+ return response as T
97
+ }
98
+
99
+ /**
100
+ * Creates default server options with required endpoint.
101
+ * Merges provided options with defaults.
102
+ */
103
+ static createDefaultServerOptions(
104
+ serverOptions?: ServerOptions
105
+ ): ServerOptions & Required<Pick<ServerOptions, 'endpoint'>> {
106
+ return {
107
+ endpoint: '/api/optimize-textures',
108
+ ...serverOptions
109
+ }
110
+ }
111
+
112
+ /**
113
+ * Creates request headers for server communication.
114
+ * Includes authentication token if apiKey is provided.
115
+ */
116
+ static createRequestHeaders(
117
+ serverOptions?: ServerOptions,
118
+ additionalHeaders?: HeadersInit
119
+ ): HeadersInit {
120
+ const headers: HeadersInit = {
121
+ ...(serverOptions?.apiKey
122
+ ? { Authorization: `Bearer ${serverOptions.apiKey}` }
123
+ : {}),
124
+ ...serverOptions?.headers,
125
+ ...additionalHeaders
126
+ }
127
+
128
+ return headers
129
+ }
130
+
131
+ /**
132
+ * Extracts error message from a failed response.
133
+ * Attempts to parse JSON error data, falls back to text.
134
+ */
135
+ static async extractErrorMessage(response: Response): Promise<string> {
136
+ let errorMessage = `Server responded with ${response.status}: ${response.statusText}`
137
+
138
+ try {
139
+ const contentType = response.headers.get('content-type')
140
+ if (contentType?.includes('application/json')) {
141
+ const errorData = await response.json()
142
+ errorMessage = errorData.error || errorData.details || errorMessage
143
+ } else {
144
+ const errorText = await response.text()
145
+ if (errorText) errorMessage += ` - ${errorText}`
146
+ }
147
+ } catch {
148
+ // Fallback to basic error message if can't parse response
149
+ }
150
+
151
+ return errorMessage
152
+ }
153
+
154
+ /**
155
+ * Handles server response errors and throws appropriate errors.
156
+ */
157
+ static async handleServerResponseError(response: Response): Promise<never> {
158
+ const errorMessage =
159
+ await ServerCommunicationService.extractErrorMessage(response)
160
+ throw new Error(errorMessage)
161
+ }
162
+
163
+ /**
164
+ * Prepares FormData for a single texture optimization request.
165
+ */
166
+ static prepareTextureOptimizationFormData(
167
+ texture: TextureBinaryPayload,
168
+ options?: TextureCompressOptions
169
+ ): FormData {
170
+ const requestData = new FormData()
171
+
172
+ requestData.append(
173
+ 'texture',
174
+ new Blob([new Uint8Array(texture.image)], {
175
+ type: texture.mimeType || 'application/octet-stream'
176
+ }),
177
+ `${texture.name || `texture-${texture.index}`}`
178
+ )
179
+ requestData.append('textureIndex', String(texture.index))
180
+ requestData.append('textureName', texture.name)
181
+ requestData.append('textureMimeType', texture.mimeType)
182
+
183
+ if (options) {
184
+ const { serverOptions: _, ...restOptions } = options
185
+ requestData.append('options', JSON.stringify(restOptions))
186
+ }
187
+
188
+ return requestData
189
+ }
190
+
191
+ /**
192
+ * Performs a generic HTTP request with error handling.
193
+ * Returns parsed response data.
194
+ *
195
+ * @template T - Expected response data type
196
+ * @param config - Request configuration
197
+ * @returns Promise resolving to parsed response data
198
+ * @throws Error if request fails or response is not ok
199
+ */
200
+ static async request<T = unknown>(config: ServerRequestConfig): Promise<T> {
201
+ const {
202
+ endpoint,
203
+ method = 'GET',
204
+ body,
205
+ serverOptions,
206
+ contentType = 'application/json'
207
+ } = config
208
+
209
+ // Prepare headers
210
+ const headers: HeadersInit =
211
+ ServerCommunicationService.createRequestHeaders(serverOptions)
212
+
213
+ // Prepare request body
214
+ let requestBody: FormData | string | undefined
215
+
216
+ if (body instanceof FormData) {
217
+ // Don't set Content-Type for FormData - browser will set it with boundary
218
+ requestBody = body
219
+ } else if (typeof body === 'object' && body !== null) {
220
+ // JSON body
221
+ ;(headers as Record<string, string>)['Content-Type'] = contentType
222
+ requestBody = JSON.stringify(body)
223
+ } else if (typeof body === 'string') {
224
+ requestBody = body
225
+ }
226
+
227
+ // Perform request
228
+ const response = await fetch(endpoint, {
229
+ method,
230
+ headers,
231
+ body: requestBody
232
+ })
233
+
234
+ // Handle errors
235
+ if (!response.ok) {
236
+ await ServerCommunicationService.handleServerResponseError(response)
237
+ }
238
+
239
+ // Parse response
240
+ const responseContentType = response.headers.get('content-type')
241
+
242
+ if (responseContentType?.includes('application/json')) {
243
+ return (await response.json()) as T
244
+ }
245
+
246
+ // For binary responses (e.g., model files)
247
+ if (
248
+ responseContentType?.includes('model/') ||
249
+ responseContentType?.includes('application/octet-stream')
250
+ ) {
251
+ const arrayBuffer = await response.arrayBuffer()
252
+ return new Uint8Array(arrayBuffer) as T
253
+ }
254
+
255
+ // Default to text
256
+ return (await response.text()) as T
257
+ }
258
+
259
+ /**
260
+ * Performs a GET request.
261
+ * Convenience method for common GET operations.
262
+ */
263
+ static async get<T = unknown>(
264
+ endpoint: string,
265
+ serverOptions?: ServerOptions
266
+ ): Promise<T> {
267
+ return ServerCommunicationService.request<T>({
268
+ endpoint,
269
+ method: 'GET',
270
+ serverOptions
271
+ })
272
+ }
273
+
274
+ /**
275
+ * Performs a POST request with JSON body.
276
+ * Convenience method for common POST operations.
277
+ */
278
+ static async post<T = unknown>(
279
+ endpoint: string,
280
+ body: Record<string, unknown>,
281
+ serverOptions?: ServerOptions
282
+ ): Promise<T> {
283
+ return ServerCommunicationService.request<T>({
284
+ endpoint,
285
+ method: 'POST',
286
+ body,
287
+ serverOptions
288
+ })
289
+ }
290
+
291
+ /**
292
+ * Performs a POST request with FormData body.
293
+ * Convenience method for file uploads.
294
+ */
295
+ static async postFormData<T = unknown>(
296
+ endpoint: string,
297
+ formData: FormData,
298
+ serverOptions?: ServerOptions
299
+ ): Promise<T> {
300
+ return ServerCommunicationService.request<T>({
301
+ endpoint,
302
+ method: 'POST',
303
+ body: formData,
304
+ serverOptions
305
+ })
306
+ }
307
+
308
+ /**
309
+ * Fetches scene data from the server.
310
+ * Specialized method for loading 3D scenes with their associated settings.
311
+ *
312
+ * This method handles the vectreal-platform API format which uses:
313
+ * - FormData with 'action' and 'sceneId' parameters
314
+ * - POST request to the scene-settings endpoint
315
+ * - Returns scene data including GLTF JSON and asset data
316
+ *
317
+ * @param sceneId - The unique identifier of the scene to load
318
+ * @param serverOptions - Optional server configuration (endpoint, auth, headers)
319
+ * @returns Promise resolving to the scene data
320
+ * @throws Error if the request fails or scene doesn't exist
321
+ *
322
+ * @example
323
+ * ```typescript
324
+ * const sceneData = await ServerCommunicationService.loadScene('abc-123', {
325
+ * endpoint: '/api/scenes/abc-123',
326
+ * apiKey: 'optional-auth-token'
327
+ * })
328
+ * ```
329
+ */
330
+ static async loadScene<T = unknown>(
331
+ sceneId: string,
332
+ serverOptions?: ServerOptions
333
+ ): Promise<T> {
334
+ const formData = new FormData()
335
+ formData.append('action', 'get-scene-settings')
336
+ formData.append('sceneId', sceneId)
337
+
338
+ const endpoint = serverOptions?.endpoint || `/api/scenes/${sceneId}`
339
+
340
+ const response = await ServerCommunicationService.request<
341
+ ApiEnvelope<T> | T
342
+ >({
343
+ endpoint,
344
+ method: 'POST',
345
+ body: formData,
346
+ serverOptions
347
+ })
348
+
349
+ return ServerCommunicationService.extractApiData(response)
350
+ }
351
+ }