teleportxr 1.0.96 → 1.0.97

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.
package/client/client.js CHANGED
@@ -431,6 +431,11 @@ class Client {
431
431
  {
432
432
  this.SendNode(uid);
433
433
  }
434
+ var remove_nodes_uids=this.geometryService.GetRemoveNodesToSend();
435
+ if (remove_nodes_uids.length)
436
+ {
437
+ this.SendRemoveNodes(remove_nodes_uids);
438
+ }
434
439
  var mesh_uids=this.geometryService.GetMeshesToSend();
435
440
  for (const uid of mesh_uids)
436
441
  {
@@ -599,7 +604,41 @@ class Client {
599
604
  if(sendSuccess)
600
605
  this.geometryService.EncodedResource(uid);
601
606
  }
602
- //! For a cubemap resource, return the URL of the variant matching this client's axes
607
+ //! Tell the client to destroy the given nodes (RemoveNodes payload). If the
608
+ //! geometry channel is not open the uids are re-queued so the next tick retries.
609
+ SendRemoveNodes(uids)
610
+ {
611
+ if(!this.webRtcConnection)
612
+ {
613
+ console.error("Client "+this.clientID+", SendRemoveNodes: this.webRtcConnection is null");
614
+ for (const uid of uids)
615
+ this.geometryService.removedNodesToSend.add(uid);
616
+ return;
617
+ }
618
+ if(!this.webRtcConnection.isGeometryOpen())
619
+ {
620
+ // Channel not yet open; re-queue so it retries on the next tick.
621
+ for (const uid of uids)
622
+ this.geometryService.removedNodesToSend.add(uid);
623
+ return;
624
+ }
625
+ const buffer = new ArrayBuffer(8+1+2+uids.length*8);
626
+ const size=node_encoder.encodeRemoveNodes(uids,buffer);
627
+ const view2 = new DataView(buffer, 0, size);
628
+ const sendSuccess = this.webRtcConnection.sendGeometry(view2);
629
+ console.log("[T+"+this.elapsedMsSinceStart()+"ms, conn+"+this.elapsedMsSinceConnected()+"ms] Sending RemoveNodes ["+uids.join(", ")+"] to Client "+this.clientID+", size: "+size+" bytes — "+
630
+ (sendSuccess ? "OK" : "FAILED"));
631
+ if(sendSuccess)
632
+ {
633
+ for (const uid of uids)
634
+ this.geometryService.EncodedResource(uid);
635
+ }
636
+ else
637
+ {
638
+ for (const uid of uids)
639
+ this.geometryService.removedNodesToSend.add(uid);
640
+ }
641
+ }
603
642
  //! standard (e.g. /envCloudyCubemap.ktx2 -> /envCloudyCubemap_ogl.ktx2). Returns undefined
604
643
  //! to mean "use the resource's base url" — for non-cubemaps, unknown axes standards, or
605
644
  //! when the variant file is missing under the public path (so streaming never breaks).
@@ -13,6 +13,7 @@ class ClientManager
13
13
  this.clients= new Map();
14
14
  this.addClientNodeAndReturnOriginUid=null;
15
15
  this.onClientPostCreate=null;
16
+ this.onClientDisconnect=null;
16
17
  this.geometryIntervalId=0;
17
18
  let unixt_us=core.getStartTimeUnixUs();
18
19
  console.log("Start Time: "+unixt_us+" us = "+core.unixTimeToUTCString(unixt_us)+"\n");
@@ -81,6 +82,11 @@ class ClientManager
81
82
  RemoveClient(clientID){
82
83
  if(this.clients.has(clientID)) {
83
84
  this.clients.delete(clientID);
85
+ // Notify the host application after removal, so per-client state
86
+ // (e.g. avatar nodes) can be torn down while this client is gone
87
+ // from the map but the remaining clients are still iterable.
88
+ if(this.onClientDisconnect!=null)
89
+ this.onClientDisconnect(clientID);
84
90
  if(this.clients.size==0)
85
91
  this.StopStreaming();
86
92
  }
@@ -106,6 +112,10 @@ class ClientManager
106
112
  {
107
113
  this.onClientPostCreate=cb;
108
114
  }
115
+ SetClientDisconnectionCallback(cb)
116
+ {
117
+ this.onClientDisconnect=cb;
118
+ }
109
119
  // This is a callback, signaling service calls this when the client has signalled.
110
120
  newClient(clientID, signalingClient) {
111
121
  // then we tell the client manager to start this client.
@@ -88,6 +88,9 @@ class GeometryService {
88
88
  this.streamedAnimations = new Map();
89
89
  this.streamedTextCanvases = new Map();
90
90
  this.streamedFontAtlases = new Map();
91
+ // Nodes that were streamed to this client and have since been unstreamed.
92
+ // Drained by GetRemoveNodesToSend() and sent as a RemoveNodes payload.
93
+ this.removedNodesToSend = new Set();
91
94
 
92
95
  this.backgroundTextureUid = 0;
93
96
  // ten seconds for timeout. Tweak this.
@@ -128,6 +131,11 @@ class GeometryService {
128
131
  return;
129
132
  }
130
133
  res.clientNeeds.set(index, false);
134
+ // If the node was actually sent to this client, the client must be
135
+ // told to destroy it via a RemoveNodes payload.
136
+ if (res.WasSentToClient(this.clientID)) {
137
+ this.removedNodesToSend.add(uid);
138
+ }
131
139
  }
132
140
  // Should certainly be in this set:
133
141
  this.nodesToStreamEventually.delete(uid);
@@ -136,6 +144,14 @@ class GeometryService {
136
144
  // TODO: now reduce the counts for all the dependent resources.
137
145
  console.log("Unstreaming node ", uid," for client ", this.clientID);
138
146
  }
147
+ //! Drain the set of nodes the client must destroy. Returns an array of uids;
148
+ //! the caller re-queues via UnstreamNode semantics if the send fails.
149
+ GetRemoveNodesToSend() {
150
+ if (this.removedNodesToSend.size == 0) return [];
151
+ var uids = Array.from(this.removedNodesToSend);
152
+ this.removedNodesToSend.clear();
153
+ return uids;
154
+ }
139
155
  StreamOrUnstream(resourceMap, uid, diff) {
140
156
  // exclude "undefined"
141
157
  if (!uid) return;
package/core/core.js CHANGED
@@ -340,6 +340,7 @@ const GeometryPayloadType =
340
340
  TexturePointer: 10,
341
341
  MeshPointer: 11,
342
342
  MaterialPointer: 12,
343
+ RemoveNodes: 13,
343
344
  };
344
345
 
345
346
  class DisplayInfo {
package/package.json CHANGED
@@ -16,7 +16,7 @@
16
16
  },
17
17
  "name": "teleportxr",
18
18
  "description": "Teleport Spatial Server on node.js",
19
- "version": "1.0.96",
19
+ "version": "1.0.97",
20
20
  "repository": {
21
21
  "type": "git",
22
22
  "url": "git+https://github.com/simul/teleport-nodejs.git"
@@ -29,4 +29,23 @@ function encodeNode(node,buffer,fromAxes,toAxes)
29
29
  dataView.setBigUint64(0,BigInt(byteOffset-8),core.endian);
30
30
  return byteOffset;
31
31
  }
32
- module.exports= {encodeNode};
32
+
33
+ // Encode a RemoveNodes payload: uint8 payload type, uint16 count, then count
34
+ // uint64 node uids (matches GeometryEncoder::encodeRemoveNodes on the C++ server
35
+ // and GeometryDecoder::decodeRemoveNodes on the client). Returns the size written.
36
+ function encodeRemoveNodes(node_uids,buffer)
37
+ {
38
+ var byteOffset=0;
39
+ const dataView = new DataView(buffer);
40
+ byteOffset=putPlaceholderSize(dataView);
41
+
42
+ byteOffset=core.put_uint8(dataView,byteOffset,core.GeometryPayloadType.RemoveNodes);
43
+ byteOffset=core.put_uint16(dataView,byteOffset,node_uids.length);
44
+ for (var i=0;i<node_uids.length;i++)
45
+ {
46
+ byteOffset=core.put_uint64(dataView,byteOffset,node_uids[i]);
47
+ }
48
+ dataView.setBigUint64(0,BigInt(byteOffset-8),core.endian);
49
+ return byteOffset;
50
+ }
51
+ module.exports= {encodeNode,encodeRemoveNodes};
package/scene/scene.js CHANGED
@@ -52,6 +52,13 @@ class Scene {
52
52
  this.nodes.set(uid, newNode);
53
53
  return uid;
54
54
  }
55
+ //! Remove a node from the scene (e.g. a session-scoped avatar node whose
56
+ //! owning client has disconnected). Returns true if the node was present.
57
+ //! Callers must UnstreamNode(uid) for any clients that received it, so they
58
+ //! are sent a RemoveNodes payload.
59
+ RemoveNode(uid) {
60
+ return this.nodes.delete(uid);
61
+ }
55
62
  GetAllNodeUids() {
56
63
  let node_uids = Array.from(this.nodes.keys());
57
64
  return node_uids;
@@ -0,0 +1,85 @@
1
+ 'use strict';
2
+ // Tests for the RemoveNodes geometry payload: the encoder's byte layout must
3
+ // match the C++ wire format (GeometryEncoder::encodeRemoveNodes /
4
+ // GeometryDecoder::decodeRemoveNodes), and GeometryService.UnstreamNode must
5
+ // queue a removal only for nodes that were actually sent to the client.
6
+
7
+ const test = require('node:test');
8
+ const assert = require('node:assert');
9
+ const core = require('../core/core.js');
10
+ const { encodeRemoveNodes } = require('../protocol/encoders/node_encoder.js');
11
+ const { GeometryService } = require('../client/geometry_service');
12
+
13
+ test('encodeRemoveNodes produces size-prefixed payload matching the C++ wire format', () => {
14
+ const uids = [5n, 7n];
15
+ const buffer = new ArrayBuffer(8 + 1 + 2 + uids.length * 8);
16
+ const size = encodeRemoveNodes(uids, buffer);
17
+ assert.strictEqual(size, 8 + 1 + 2 + 16);
18
+
19
+ const dataView = new DataView(buffer);
20
+ // Size prefix: count of bytes following the size field.
21
+ assert.strictEqual(dataView.getBigUint64(0, core.endian), BigInt(1 + 2 + 16));
22
+ // Payload type.
23
+ assert.strictEqual(dataView.getUint8(8), core.GeometryPayloadType.RemoveNodes);
24
+ assert.strictEqual(core.GeometryPayloadType.RemoveNodes, 13,
25
+ 'RemoveNodes must keep the C++ enum value (after MaterialPointer=12)');
26
+ // Count and uids.
27
+ assert.strictEqual(dataView.getUint16(9, core.endian), 2);
28
+ assert.strictEqual(dataView.getBigUint64(11, core.endian), 5n);
29
+ assert.strictEqual(dataView.getBigUint64(19, core.endian), 7n);
30
+ });
31
+
32
+ test('encodeRemoveNodes handles a single uid', () => {
33
+ const buffer = new ArrayBuffer(8 + 1 + 2 + 8);
34
+ const size = encodeRemoveNodes([42n], buffer);
35
+ const dataView = new DataView(buffer);
36
+ assert.strictEqual(size, 19);
37
+ assert.strictEqual(dataView.getUint16(9, core.endian), 1);
38
+ assert.strictEqual(dataView.getBigUint64(11, core.endian), 42n);
39
+ });
40
+
41
+ function makeService(clientID) {
42
+ // Reset the static trackedResources map between tests so uids don't leak
43
+ // across cases (same pattern as test_geometry_service_send_gate.js).
44
+ GeometryService.trackedResources = new Map();
45
+ return new GeometryService(clientID);
46
+ }
47
+
48
+ test('UnstreamNode queues a removal for a node that was sent to the client', () => {
49
+ const clientID = 201;
50
+ const svc = makeService(clientID);
51
+ svc.StreamNode(9n);
52
+ // Simulate a successful send.
53
+ GeometryService.GetOrCreateTrackedResource(9n).Sent(clientID, 123n);
54
+
55
+ svc.UnstreamNode(9n);
56
+ assert.deepStrictEqual(svc.GetRemoveNodesToSend(), [9n]);
57
+ // Drained: a second call returns nothing.
58
+ assert.deepStrictEqual(svc.GetRemoveNodesToSend(), []);
59
+ });
60
+
61
+ test('UnstreamNode does not queue a removal for a node that was never sent', () => {
62
+ const svc = makeService(202);
63
+ svc.StreamNode(11n);
64
+ // Not sent (e.g. geometry channel never opened).
65
+ svc.UnstreamNode(11n);
66
+ assert.deepStrictEqual(svc.GetRemoveNodesToSend(), []);
67
+ });
68
+
69
+ test('UnstreamNode on a node the client never needed is a no-op', () => {
70
+ const svc = makeService(203);
71
+ GeometryService.GetOrCreateTrackedResource(13n).Sent(203, 1n);
72
+ // StreamNode was never called, so clientNeeds is false: nothing to do.
73
+ svc.UnstreamNode(13n);
74
+ assert.deepStrictEqual(svc.GetRemoveNodesToSend(), []);
75
+ });
76
+
77
+ test('UnstreamNode twice only queues one removal', () => {
78
+ const clientID = 204;
79
+ const svc = makeService(clientID);
80
+ svc.StreamNode(15n);
81
+ GeometryService.GetOrCreateTrackedResource(15n).Sent(clientID, 1n);
82
+ svc.UnstreamNode(15n);
83
+ svc.UnstreamNode(15n);
84
+ assert.deepStrictEqual(svc.GetRemoveNodesToSend(), [15n]);
85
+ });