@webspatial/core-sdk 1.1.0 → 1.2.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.
@@ -0,0 +1,542 @@
1
+ import { beforeEach, describe, expect, it, vi } from 'vitest'
2
+
3
+ class DOMMatrixPolyfill {
4
+ private tx = 0
5
+ private ty = 0
6
+ private tz = 0
7
+ private sx = 1
8
+ private sy = 1
9
+ private sz = 1
10
+
11
+ translate(x = 0, y = 0, z = 0) {
12
+ this.tx += x
13
+ this.ty += y
14
+ this.tz += z
15
+ return this
16
+ }
17
+
18
+ rotate() {
19
+ return this
20
+ }
21
+
22
+ scale(x = 1, y = 1, z = 1) {
23
+ this.sx *= x
24
+ this.sy *= y
25
+ this.sz *= z
26
+ return this
27
+ }
28
+
29
+ inverse() {
30
+ return new DOMMatrixPolyfill()
31
+ }
32
+
33
+ transformPoint(p: { x: number; y: number; z?: number }) {
34
+ return {
35
+ x: p.x * this.sx + this.tx,
36
+ y: p.y * this.sy + this.ty,
37
+ z: (p.z ?? 0) * this.sz + this.tz,
38
+ }
39
+ }
40
+
41
+ toFloat64Array() {
42
+ const out = new Float64Array(16)
43
+ out[0] = this.sx
44
+ out[5] = this.sy
45
+ out[10] = this.sz
46
+ out[12] = this.tx
47
+ out[13] = this.ty
48
+ out[14] = this.tz
49
+ out[15] = 1
50
+ return out
51
+ }
52
+ }
53
+
54
+ ;(globalThis as any).DOMMatrix = DOMMatrixPolyfill
55
+
56
+ const platformSpy = {
57
+ callJSB: vi.fn(),
58
+ callWebSpatialProtocol: vi.fn(),
59
+ callWebSpatialProtocolSync: vi.fn(),
60
+ }
61
+
62
+ vi.mock('./platform-adapter', () => ({
63
+ createPlatform: () => platformSpy,
64
+ }))
65
+
66
+ function ok(data: any = {}) {
67
+ return Promise.resolve({
68
+ success: true,
69
+ data,
70
+ errorCode: '',
71
+ errorMessage: '',
72
+ })
73
+ }
74
+
75
+ function parseQuery(q?: string) {
76
+ const sp = new URLSearchParams(q ?? '')
77
+ const out: Record<string, string> = {}
78
+ for (const [k, v] of sp.entries()) out[k] = v
79
+ return out
80
+ }
81
+
82
+ describe('JSBCommand', () => {
83
+ beforeEach(() => {
84
+ platformSpy.callJSB.mockReset()
85
+ platformSpy.callWebSpatialProtocol.mockReset()
86
+ platformSpy.callWebSpatialProtocolSync.mockReset()
87
+ platformSpy.callJSB.mockImplementation(() => ok({ id: 'id-1' }))
88
+ platformSpy.callWebSpatialProtocol.mockImplementation(() =>
89
+ ok({ windowProxy: {}, id: 'spatial-1' }),
90
+ )
91
+ platformSpy.callWebSpatialProtocolSync.mockImplementation(() => ({
92
+ success: true,
93
+ data: { windowProxy: {}, id: 'spatial-1' },
94
+ errorCode: '',
95
+ errorMessage: '',
96
+ }))
97
+ })
98
+
99
+ it('serializes params and calls platform.callJSB', async () => {
100
+ const mod = await import('./JSBCommand')
101
+ const { FocusScene, InspectCommand, DestroyCommand, UpdateSceneConfig } =
102
+ mod
103
+
104
+ await new FocusScene('scene-1').execute()
105
+ expect(platformSpy.callJSB).toHaveBeenCalledWith(
106
+ 'FocusScene',
107
+ JSON.stringify({ id: 'scene-1' }),
108
+ )
109
+
110
+ await new InspectCommand().execute()
111
+ expect(platformSpy.callJSB).toHaveBeenCalledWith(
112
+ 'Inspect',
113
+ JSON.stringify({ id: '' }),
114
+ )
115
+
116
+ await new DestroyCommand('obj-1').execute()
117
+ expect(platformSpy.callJSB).toHaveBeenCalledWith(
118
+ 'Destroy',
119
+ JSON.stringify({ id: 'obj-1' }),
120
+ )
121
+
122
+ await new UpdateSceneConfig({ type: 'window' } as any).execute()
123
+ expect(platformSpy.callJSB).toHaveBeenCalledWith(
124
+ 'UpdateSceneConfig',
125
+ JSON.stringify({ config: { type: 'window' } }),
126
+ )
127
+ })
128
+
129
+ it('builds element commands payloads', async () => {
130
+ const mod = await import('./JSBCommand')
131
+ const {
132
+ UpdateSpatializedElementTransform,
133
+ UpdateSpatialized2DElementProperties,
134
+ UpdateSpatializedDynamic3DElementProperties,
135
+ UpdateSpatializedStatic3DElementProperties,
136
+ AddSpatializedElementToSpatialScene,
137
+ AddSpatializedElementToSpatialized2DElement,
138
+ UpdateUnlitMaterialProperties,
139
+ } = mod
140
+
141
+ const obj = { id: 'so-1' } as any
142
+ const ele = { id: 'ele-1' } as any
143
+ const matrix = new DOMMatrixPolyfill().translate(1, 2, 3)
144
+
145
+ await new UpdateSpatializedElementTransform(obj, matrix as any).execute()
146
+ const last = platformSpy.callJSB.mock.calls.at(-1)
147
+ expect(last?.[0]).toBe('UpdateSpatializedElementTransform')
148
+ expect(JSON.parse(last?.[1])).toEqual({
149
+ id: 'so-1',
150
+ matrix: Array.from(matrix.toFloat64Array()),
151
+ })
152
+
153
+ await new UpdateSpatialized2DElementProperties(obj, {
154
+ a: 1,
155
+ } as any).execute()
156
+ expect(platformSpy.callJSB).toHaveBeenCalledWith(
157
+ 'UpdateSpatialized2DElementProperties',
158
+ JSON.stringify({ id: 'so-1', a: 1 }),
159
+ )
160
+
161
+ await new UpdateSpatializedDynamic3DElementProperties(obj, {
162
+ b: 2,
163
+ } as any).execute()
164
+ expect(platformSpy.callJSB).toHaveBeenCalledWith(
165
+ 'UpdateSpatializedDynamic3DElementProperties',
166
+ JSON.stringify({ id: 'so-1', b: 2 }),
167
+ )
168
+
169
+ await new UpdateSpatializedStatic3DElementProperties(obj, {
170
+ c: 3,
171
+ } as any).execute()
172
+ expect(platformSpy.callJSB).toHaveBeenCalledWith(
173
+ 'UpdateSpatializedStatic3DElementProperties',
174
+ JSON.stringify({ id: 'so-1', c: 3 }),
175
+ )
176
+
177
+ await new AddSpatializedElementToSpatialScene(ele).execute()
178
+ expect(platformSpy.callJSB).toHaveBeenCalledWith(
179
+ 'AddSpatializedElementToSpatialScene',
180
+ JSON.stringify({ spatializedElementId: 'ele-1' }),
181
+ )
182
+
183
+ await new AddSpatializedElementToSpatialized2DElement(obj, ele).execute()
184
+ expect(platformSpy.callJSB).toHaveBeenCalledWith(
185
+ 'AddSpatializedElementToSpatialized2DElement',
186
+ JSON.stringify({ id: 'so-1', spatializedElementId: 'ele-1' }),
187
+ )
188
+
189
+ await new UpdateUnlitMaterialProperties(obj, { color: '#fff' }).execute()
190
+ expect(platformSpy.callJSB).toHaveBeenCalledWith(
191
+ 'UpdateUnlitMaterialProperties',
192
+ JSON.stringify({ id: 'so-1', color: '#fff' }),
193
+ )
194
+ })
195
+
196
+ it('creates query string and calls WebSpatialProtocol', async () => {
197
+ const mod = await import('./JSBCommand')
198
+ const { createSpatialSceneCommand, createSpatialized2DElementCommand } = mod
199
+
200
+ await new createSpatialized2DElementCommand().execute()
201
+ expect(platformSpy.callWebSpatialProtocol).toHaveBeenCalledWith(
202
+ 'createSpatialized2DElement',
203
+ '',
204
+ undefined,
205
+ undefined,
206
+ )
207
+
208
+ const cmd = new createSpatialSceneCommand(
209
+ 'https://example.com/a?b=c',
210
+ { type: 'window', defaultSize: { width: 1, height: 2 } } as any,
211
+ '_self',
212
+ 'popup=1',
213
+ )
214
+ await cmd.execute()
215
+ const call = platformSpy.callWebSpatialProtocol.mock.calls.at(-1)
216
+ expect(call?.[0]).toBe('createSpatialScene')
217
+ expect(call?.[2]).toBe('_self')
218
+ expect(call?.[3]).toBe('popup=1')
219
+ const q = parseQuery(call?.[1])
220
+ expect(q.url).toBe('https://example.com/a?b=c')
221
+ expect(JSON.parse(q.config)).toEqual({
222
+ type: 'window',
223
+ defaultSize: { width: 1, height: 2 },
224
+ })
225
+
226
+ cmd.executeSync()
227
+ expect(platformSpy.callWebSpatialProtocolSync).toHaveBeenCalled()
228
+ })
229
+ })
230
+
231
+ describe('SpatialObject', () => {
232
+ beforeEach(() => {
233
+ platformSpy.callJSB.mockReset()
234
+ platformSpy.callJSB.mockImplementation((cmd: string) => {
235
+ if (cmd === 'Inspect') return ok({ inspected: true })
236
+ if (cmd === 'Destroy') return ok({ destroyed: true })
237
+ return ok({ id: 'id-1' })
238
+ })
239
+ })
240
+
241
+ it('inspects and destroys', async () => {
242
+ const { SpatialObject } = await import('./SpatialObject')
243
+
244
+ class TestObj extends SpatialObject {
245
+ onDestroySpy = vi.fn()
246
+ protected override onDestroy() {
247
+ this.onDestroySpy()
248
+ }
249
+ }
250
+
251
+ const o = new TestObj('obj-1')
252
+ await expect(o.inspect()).resolves.toEqual({ inspected: true })
253
+
254
+ await expect(o.destroy()).resolves.toEqual({ destroyed: true })
255
+ expect(o.isDestroyed).toBe(true)
256
+ expect(o.onDestroySpy).toHaveBeenCalledTimes(1)
257
+
258
+ await o.destroy()
259
+ expect(
260
+ platformSpy.callJSB.mock.calls.filter(c => c[0] === 'Destroy'),
261
+ ).toHaveLength(1)
262
+ })
263
+ })
264
+
265
+ describe('realityCreator', () => {
266
+ beforeEach(() => {
267
+ platformSpy.callJSB.mockReset()
268
+ platformSpy.callJSB.mockImplementation((cmd: string) => {
269
+ if (cmd === 'CreateSpatialEntity') return ok({ id: 'ent-1' })
270
+ if (cmd === 'CreateGeometry') return ok({ id: 'geo-1' })
271
+ if (cmd === 'CreateUnlitMaterial') return ok({ id: 'mat-1' })
272
+ if (cmd === 'CreateModelComponent') return ok({ id: 'cmp-1' })
273
+ if (cmd === 'CreateSpatialModelEntity') return ok({ id: 'ment-1' })
274
+ if (cmd === 'CreateModelAsset') return ok({ id: 'asset-1' })
275
+ return ok({ id: 'id-1' })
276
+ })
277
+ })
278
+
279
+ it('creates entities, geometry, materials, components, assets', async () => {
280
+ const creator = await import('./reality/realityCreator')
281
+ const geo = await import('./reality/geometry/SpatialBoxGeometry')
282
+ const mat = await import('./reality/material/SpatialUnlitMaterial')
283
+ const cmp = await import('./reality/component/ModelComponent')
284
+ const asset = await import('./reality/resource/SpatialModelAsset')
285
+ const ent = await import('./reality/entity/SpatialEntity')
286
+ const modelEnt = await import('./reality/entity/SpatialModelEntity')
287
+
288
+ await expect(
289
+ creator.createSpatialEntity({ name: 'n' }),
290
+ ).resolves.toBeInstanceOf(ent.SpatialEntity)
291
+
292
+ await expect(
293
+ creator.createSpatialGeometry(
294
+ geo.SpatialBoxGeometry as any,
295
+ { width: 1 } as any,
296
+ ),
297
+ ).resolves.toBeInstanceOf(geo.SpatialBoxGeometry)
298
+
299
+ await expect(
300
+ creator.createSpatialUnlitMaterial({ color: '#fff' }),
301
+ ).resolves.toBeInstanceOf(mat.SpatialUnlitMaterial)
302
+
303
+ await expect(
304
+ creator.createModelComponent({
305
+ mesh: { id: 'geo-1' } as any,
306
+ materials: [{ id: 'mat-1' }] as any,
307
+ }),
308
+ ).resolves.toBeInstanceOf(cmp.ModelComponent)
309
+
310
+ await expect(
311
+ creator.createModelAsset({ url: 'https://example.com/a.glb' }),
312
+ ).resolves.toBeInstanceOf(asset.SpatialModelAsset)
313
+
314
+ await expect(
315
+ creator.createSpatialModelEntity(
316
+ { modelAssetId: 'asset-1' },
317
+ { name: 'u' },
318
+ ),
319
+ ).resolves.toBeInstanceOf(modelEnt.SpatialModelEntity)
320
+ })
321
+
322
+ it('throws on creator failures', async () => {
323
+ platformSpy.callJSB.mockResolvedValueOnce({
324
+ success: false,
325
+ data: undefined,
326
+ errorCode: 'E',
327
+ errorMessage: 'bad',
328
+ })
329
+ const creator = await import('./reality/realityCreator')
330
+ await expect(creator.createSpatialEntity({ name: 'n' })).rejects.toThrow(
331
+ /createSpatialEntity failed/,
332
+ )
333
+ })
334
+ })
335
+
336
+ describe('SpatialEntity', () => {
337
+ beforeEach(() => {
338
+ platformSpy.callJSB.mockReset()
339
+ platformSpy.callJSB.mockImplementation((cmd: string) => {
340
+ if (
341
+ cmd === 'SetParentToEntity' ||
342
+ cmd === 'UpdateEntityProperties' ||
343
+ cmd === 'UpdateEntityEvent' ||
344
+ cmd.startsWith('ConvertFrom')
345
+ ) {
346
+ return ok({})
347
+ }
348
+ return ok({ id: 'id-1' })
349
+ })
350
+ })
351
+
352
+ it('manages parent-child relationships', async () => {
353
+ const { SpatialEntity } = await import('./reality/entity/SpatialEntity')
354
+
355
+ const parent = new SpatialEntity('p')
356
+ const child = new SpatialEntity('c')
357
+ await parent.addEntity(child)
358
+
359
+ expect(parent.children.map(e => e.id)).toEqual(['c'])
360
+ expect(child.parent).toBe(parent)
361
+
362
+ await child.removeFromParent()
363
+ expect(parent.children).toHaveLength(0)
364
+ expect(child.parent).toBe(null)
365
+ })
366
+
367
+ it('updates transform state and calls JSB', async () => {
368
+ const { SpatialEntity } = await import('./reality/entity/SpatialEntity')
369
+ const e = new SpatialEntity('e')
370
+ await e.updateTransform({ position: { x: 1, y: 2, z: 3 } })
371
+ expect(e.position).toEqual({ x: 1, y: 2, z: 3 })
372
+ expect(platformSpy.callJSB).toHaveBeenCalledWith(
373
+ 'UpdateEntityProperties',
374
+ expect.any(String),
375
+ )
376
+ })
377
+
378
+ it('dispatches and bubbles events', async () => {
379
+ const { SpatialEntity } = await import('./reality/entity/SpatialEntity')
380
+ const parent = new SpatialEntity('p')
381
+ const child = new SpatialEntity('c')
382
+ parent.children.push(child)
383
+ child.parent = parent
384
+
385
+ const calls: string[] = []
386
+ parent.events.spatialtap = () => calls.push('parent')
387
+ child.events.spatialtap = () => calls.push('child')
388
+
389
+ child.dispatchEvent(new CustomEvent('spatialtap', { bubbles: true }))
390
+ expect(calls).toEqual(['child', 'parent'])
391
+ })
392
+
393
+ it('handles events from SpatialWebEvent injection', async () => {
394
+ const { SpatialWebEvent } = await import('./SpatialWebEvent')
395
+ const { SpatialEntity } = await import('./reality/entity/SpatialEntity')
396
+ const { SpatialWebMsgType } = await import('./WebMsgCommand')
397
+
398
+ SpatialWebEvent.init()
399
+ const e = new SpatialEntity('e')
400
+ const cb = vi.fn()
401
+ e.events.spatialtap = cb
402
+ window.__SpatialWebEvent({
403
+ id: 'e',
404
+ data: {
405
+ type: SpatialWebMsgType.spatialtap,
406
+ detail: { location3D: { x: 1, y: 2, z: 3 } },
407
+ },
408
+ })
409
+ expect(cb).toHaveBeenCalledTimes(1)
410
+ })
411
+
412
+ it('swallows updateEntityEvent failures in addEvent', async () => {
413
+ const { SpatialEntity } = await import('./reality/entity/SpatialEntity')
414
+ const consoleSpy = vi.spyOn(console, 'error').mockImplementation(() => {})
415
+ platformSpy.callJSB.mockImplementationOnce(() =>
416
+ Promise.reject(new Error('x')),
417
+ )
418
+
419
+ const e = new SpatialEntity('e')
420
+ await e.addEvent('spatialtap' as any, () => {})
421
+ expect(Object.keys(e.events)).toHaveLength(0)
422
+ expect(consoleSpy).toHaveBeenCalled()
423
+ consoleSpy.mockRestore()
424
+ })
425
+ })
426
+
427
+ describe('SpatializedElement', () => {
428
+ beforeEach(() => {
429
+ platformSpy.callJSB.mockReset()
430
+ platformSpy.callJSB.mockImplementation((cmd: string) => {
431
+ if (cmd === 'UpdateSpatializedElementTransform') return ok({})
432
+ if (cmd === 'Destroy') return ok({})
433
+ return ok({ id: 'id-1' })
434
+ })
435
+ })
436
+
437
+ it('handles transform and cubeInfo events and updates internal state', async () => {
438
+ const { SpatialWebEvent } = await import('./SpatialWebEvent')
439
+ const { SpatialWebMsgType } = await import('./WebMsgCommand')
440
+ const { SpatializedElement } = await import('./SpatializedElement')
441
+
442
+ SpatialWebEvent.init()
443
+
444
+ class TestElement extends SpatializedElement {
445
+ updateProperties = vi.fn().mockResolvedValue({
446
+ success: true,
447
+ data: undefined,
448
+ errorCode: '',
449
+ errorMessage: '',
450
+ })
451
+ }
452
+
453
+ const e = new TestElement('el2')
454
+ window.__SpatialWebEvent({
455
+ id: 'el2',
456
+ data: {
457
+ type: SpatialWebMsgType.cubeInfo,
458
+ size: { width: 1, height: 2, depth: 3 },
459
+ origin: { x: 4, y: 5, z: 6 },
460
+ },
461
+ })
462
+ expect(e.cubeInfo?.front).toBe(9)
463
+
464
+ window.__SpatialWebEvent({
465
+ id: 'el2',
466
+ data: {
467
+ type: SpatialWebMsgType.transform,
468
+ detail: {
469
+ column0: [1, 0, 0],
470
+ column1: [0, 1, 0],
471
+ column2: [0, 0, 1],
472
+ column3: [10, 20, 30],
473
+ },
474
+ },
475
+ })
476
+ expect(e.transform).toBeDefined()
477
+ expect(e.transformInv).toBeDefined()
478
+ })
479
+
480
+ it('updates flags via gesture handler setters and updates transform via JSB', async () => {
481
+ const { SpatialWebEvent } = await import('./SpatialWebEvent')
482
+ const { SpatializedElement } = await import('./SpatializedElement')
483
+
484
+ SpatialWebEvent.init()
485
+
486
+ class TestElement extends SpatializedElement {
487
+ updateProperties = vi.fn().mockResolvedValue({
488
+ success: true,
489
+ data: undefined,
490
+ errorCode: '',
491
+ errorMessage: '',
492
+ })
493
+ }
494
+
495
+ const e = new TestElement('el3')
496
+ e.onSpatialTap = () => {}
497
+ ;(e as any).onSpatialTap = undefined
498
+ expect(e.updateProperties).toHaveBeenCalledWith({ enableTapGesture: true })
499
+ expect(e.updateProperties).toHaveBeenCalledWith({ enableTapGesture: false })
500
+
501
+ await e.updateTransform(new DOMMatrixPolyfill() as any)
502
+ expect(platformSpy.callJSB).toHaveBeenCalledWith(
503
+ 'UpdateSpatializedElementTransform',
504
+ expect.any(String),
505
+ )
506
+ })
507
+
508
+ it('removes event receiver on destroy', async () => {
509
+ const { SpatialWebEvent } = await import('./SpatialWebEvent')
510
+ const { SpatializedElement } = await import('./SpatializedElement')
511
+
512
+ SpatialWebEvent.init()
513
+
514
+ class TestElement extends SpatializedElement {
515
+ updateProperties = vi.fn().mockResolvedValue({
516
+ success: true,
517
+ data: undefined,
518
+ errorCode: '',
519
+ errorMessage: '',
520
+ })
521
+ }
522
+
523
+ const e = new TestElement('el4')
524
+ expect(SpatialWebEvent.eventReceiver.el4).toBeDefined()
525
+ await e.destroy()
526
+ expect(SpatialWebEvent.eventReceiver.el4).toBeUndefined()
527
+ })
528
+ })
529
+
530
+ describe('CubeInfo', () => {
531
+ it('exposes derived bounds', async () => {
532
+ const { CubeInfo } = await import('./types/types')
533
+ const c = new CubeInfo(
534
+ { width: 2, height: 3, depth: 4 },
535
+ { x: 1, y: 2, z: 3 },
536
+ )
537
+ expect(c.left).toBe(1)
538
+ expect(c.right).toBe(3)
539
+ expect(c.bottom).toBe(5)
540
+ expect(c.front).toBe(7)
541
+ })
542
+ })
@@ -2,15 +2,43 @@ import { isSSREnv } from '../ssr-polyfill'
2
2
  import { PlatformAbility } from './interface'
3
3
  import { SSRPlatform } from './ssr/SSRPlatform'
4
4
 
5
+ function getWebSpatialVersion(ua: string): number[] | null {
6
+ const match = ua.match(/WebSpatial\/(\d+)\.(\d+)\.(\d+)/)
7
+ if (!match) {
8
+ return null
9
+ }
10
+ return [Number(match[1]), Number(match[2]), Number(match[3])]
11
+ }
12
+
13
+ function isVersionGreater(a: number[] | null, b: number[]): boolean {
14
+ if (!a) {
15
+ return false
16
+ }
17
+ for (let index = 0; index < 3; index += 1) {
18
+ const diff = a[index] - b[index]
19
+ if (diff > 0) {
20
+ return true
21
+ }
22
+ if (diff < 0) {
23
+ return false
24
+ }
25
+ }
26
+ return false
27
+ }
28
+
5
29
  export function createPlatform(): PlatformAbility {
6
30
  if (isSSREnv()) {
7
31
  return new SSRPlatform()
8
32
  }
9
-
33
+ const userAgent = window.navigator.userAgent
34
+ const webSpatialVersion = getWebSpatialVersion(userAgent)
10
35
  if (
11
- window.navigator.userAgent.includes('Android') ||
12
- window.navigator.userAgent.includes('Linux')
36
+ userAgent.includes('PicoWebApp') &&
37
+ isVersionGreater(webSpatialVersion, [0, 0, 1])
13
38
  ) {
39
+ const XRPlatform = require('./xr/XRPlatform').XRPlatform
40
+ return new XRPlatform()
41
+ } else if (userAgent.includes('Android') || userAgent.includes('Linux')) {
14
42
  const AndroidPlatform = require('./android/AndroidPlatform').AndroidPlatform
15
43
  return new AndroidPlatform()
16
44
  } else {
@@ -4,7 +4,6 @@ import {
4
4
  CommandResultSuccess,
5
5
  } from '../CommandResultUtils'
6
6
 
7
-
8
7
  type JSBError = {
9
8
  message: string
10
9
  }