Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,7 @@ jobs:
runs-on: ubuntu-latest
strategy:
matrix:
node-version: [22, 24]
node-version: [18, 20, 22, 24]
steps:
- uses: actions/checkout@v7
- uses: actions/setup-node@v7
Expand Down
2 changes: 1 addition & 1 deletion CONTRIBUTING.md
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@ Keep changes small, protocol-focused and backward-compatible whenever possible.
## Development

```bash
npm install
npm ci
npm run check
```

Expand Down
10 changes: 5 additions & 5 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@ Supported:

- Augmenta WebSocket protocol V2 binary data;
- current Pleiades V3 bundle/object/scene extensions, including timestamps and UUID-based object packets;
- explicit protocol-version guard: only verified V2/V3 layouts are accepted;
- clusters, point clouds and scene information;
- zone enter/leave/presence/density events;
- zone slider, XY pad and optional zone point-cloud properties;
Expand All @@ -33,8 +34,7 @@ Legacy binary protocol V1 is intentionally not implemented in this first version

## TODO before 1.0

- Validate V2/V3 end-to-end against live Pleiades streams and keep captured binary fixtures.
- Expand regression coverage for standalone point clouds, cluster + point-cloud packets, zone properties, and malformed/truncated packets.
- Validate the committed V2/V3 protocol-writer fixtures against live Pleiades captures.
- Decide whether the WebSocket convenience client should handle protocol negotiation/fallback automatically.
- Exercise the SDK in browser, Node.js, and Max/MSP / Max for Live integrations.
- Finalize npm publishing, release notes/changelog, and stable 1.0 documentation.
Expand All @@ -50,7 +50,7 @@ npm install augmenta-client-sdk
From this repository during development:

```bash
npm install
npm ci
npm run build
npm test
```
Expand Down Expand Up @@ -189,8 +189,8 @@ const options = new ProtocolOptions({
2. **Same concepts across SDKs** — `Client`, `ProtocolOptions`, `DataBlob` and `ControlMessage` stay recognizable across C++, C# and JavaScript.
3. **Transport independent** — parsing is usable from a browser, Node.js, Max/MSP, tests or another transport.
4. **Small and predictable** — no framework and no runtime dependency.
5. **Forward-compatible parsing** — packet/property sizes are respected so unknown future properties can be skipped safely.
6. **Web-friendly data** — arrays and typed arrays are exposed directly and can be fed efficiently into rendering/application code.
5. **Forward-compatible parsing** — packet/property sizes are respected so unknown future properties/packet families can be skipped safely within their declared boundaries.
6. **Web-friendly data** — arrays and typed arrays are exposed directly and the point parser uses bulk typed-array paths for large clouds instead of one JavaScript read per coordinate.

## License

Expand Down
9 changes: 5 additions & 4 deletions docs/API.md
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@ Negotiates what an Augmenta WebSocket Output sends to the client.

Important options include:

- `version` — protocol version, V2 by default;
- `version` — protocol version, V2 by default; this beta accepts V2 and V3;
- `tags` — optional server-side Augmenta tags;
- `downSample` — point-cloud downsampling factor;
- `streamClouds` — raw scene point clouds;
Expand All @@ -16,7 +16,8 @@ Important options include:
- `boxRotationMode` — radians, degrees or quaternions;
- `axisTransform` — coordinate system transformation requested from the server;
- `useCompression` — request Zstd-compressed binary frames;
- `usePolling` — request data only when `poll` is sent.
- `usePolling` — request data only when `poll` is sent;
- `displayPointIntensity` — tells the parser to consume point-intensity values when the server stream contains them.

The core `ProtocolOptions` defaults follow the C++ SDK. The `AugmentaWebSocketClient` convenience transport defaults to uncompressed frames unless explicit `ProtocolOptions` are supplied, so a zero-configuration browser client does not require Zstd.

Expand Down Expand Up @@ -64,7 +65,7 @@ An object can contain:
- point-cloud data;
- both.

Cluster data includes state, centroid, velocity, bounding-box center/size/rotation, weight and look-at vector.
Cluster data includes state, centroid, velocity, bounding-box center/size/rotation, weight and look-at vector. `getBoundingBoxRotationEuler()` is only valid for degree/radian rotation modes; `getBoundingBoxRotationQuaternions()` is only valid for quaternion mode.

Point clouds expose packed XYZ coordinates through `Float32Array`. V3 object packets also expose the UUID sent by Pleiades and, for clusters, the readable ID carried in the cluster property.

Expand Down Expand Up @@ -93,4 +94,4 @@ Events:
- `update`
- `data`

The class accepts a custom `webSocketFactory`, which is useful for Node runtimes or Max/MSP environments that provide their own WebSocket package.
The class accepts a custom `webSocketFactory`, which is useful for Node runtimes or Max/MSP environments that provide their own WebSocket package. Delayed events from an older socket are ignored after reconnect, and `poll()` requires the socket to be open.
11 changes: 8 additions & 3 deletions docs/PROTOCOL.md
Original file line number Diff line number Diff line change
Expand Up @@ -28,13 +28,13 @@ Supported zone properties:

Pleiades currently extends V3 with UUID-based object packets, a readable cluster ID inside the cluster property, a server millisecond timestamp in the bundle header, and a scene timestamp. The JavaScript SDK parses those fields directly: `DataBlob.timestamp`, `SceneInfoPacket.timestamp`, `ObjectPacket.uuid` and the readable `ObjectPacket.id` when a cluster provides one.

## V1
## Supported version range

Legacy binary protocol V1 uses a different framing scheme and is not parsed by this first JavaScript SDK version. The client fails explicitly rather than silently interpreting V1 data with the V2 layout.
This beta accepts protocol V2 and V3. Legacy V1 uses a different framing scheme and is rejected explicitly. Versions newer than V3 are also rejected until their wire compatibility has been verified.

## Forward compatibility

The parser treats packet and property sizes emitted by Pleiades as authoritative. Known fields are parsed and unknown object/zone properties are skipped to their declared boundary. This avoids desynchronizing the rest of a bundle when a newer server adds data the current SDK does not yet understand.
The parser treats packet and property sizes emitted by Pleiades as authoritative. Known fields are parsed and unknown object/zone properties and unknown packet families are skipped to their declared boundary. Nested packets are constrained to their enclosing bundle/property boundary so malformed sizes cannot consume bytes outside their parent packet.

## Compression

Expand All @@ -43,3 +43,8 @@ The wire protocol can use Zstd compression. Compression is deliberately separate
## Cross-SDK parity

The public concepts intentionally remain close to the C++ and C# SDKs. When the wire protocol changes, protocol fixtures should be used to verify equivalent results across SDK implementations.


## Regression fixtures

`tests/fixtures` contains deterministic V2/V3 wire fixtures mirroring the current Pleiades `develop` writer layout. They cover standalone and tracked point clouds, intensity arrays, slider/XY/zone point-cloud properties, and V3 timestamps/UUIDs/readable IDs. Large-cloud and malformed-boundary cases are also exercised separately in the automated tests.
111 changes: 97 additions & 14 deletions src/binary.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@ export type BinaryData = ArrayBuffer | ArrayBufferView;
export type Decompressor = (data: Uint8Array) => Uint8Array;

const textDecoder = new TextDecoder();
const littleEndian = new Uint8Array(new Uint16Array([1]).buffer)[0] === 1;

function asUint8Array(data: BinaryData): Uint8Array {
if (data instanceof ArrayBuffer) return new Uint8Array(data);
Expand Down Expand Up @@ -66,8 +67,33 @@ class Reader {
if (!Number.isInteger(count) || count < 0) {
throw new RangeError(`Invalid float count while reading ${context}.`);
}
const output = new Float32Array(count);
for (let i = 0; i < count; i++) output[i] = this.f32(context);

const byteLength = count * Float32Array.BYTES_PER_ELEMENT;
if (!Number.isSafeInteger(byteLength)) {
throw new RangeError(`Invalid float byte length while reading ${context}.`);
}
this.ensure(byteLength, context);

const absoluteOffset = this.bytes.byteOffset + this.offset;
let output: Float32Array;

if (littleEndian && absoluteOffset % Float32Array.BYTES_PER_ELEMENT === 0) {
output = new Float32Array(this.bytes.buffer, absoluteOffset, count);
} else if (littleEndian) {
// Pleiades' compact packet headers often leave float payloads unaligned.
// Copy the byte range natively instead of issuing one DataView read per
// coordinate; this is substantially cheaper for large point clouds.
output = new Float32Array(
this.bytes.buffer.slice(absoluteOffset, absoluteOffset + byteLength)
);
} else {
output = new Float32Array(count);
for (let i = 0; i < count; i++) {
output[i] = this.view.getFloat32(this.offset + i * 4, true);
}
}

this.offset += byteLength;
return output;
}

Expand Down Expand Up @@ -105,11 +131,26 @@ interface ParsedBlobState {
timestamp?: number;
}

function ensureWithin(reader: Reader, end: number, size: number, context: string): void {
if (
!Number.isSafeInteger(size)
|| size < 0
|| reader.offset + size > end
|| end > reader.length
) {
throw new RangeError(`Malformed Augmenta packet while reading ${context}.`);
}
}

function parsePointCloud(reader: Reader, options: ProtocolOptions, end: number): PointCloudProperty {
ensureWithin(reader, end, 4, 'point count');
const pointCount = reader.i32('point count');
if (pointCount < 0) throw new RangeError('Malformed Augmenta point count.');

const points = reader.floats(pointCount * 3, 'point cloud coordinates');
const coordinateCount = pointCount * 3;
const coordinateBytes = coordinateCount * Float32Array.BYTES_PER_ELEMENT;
ensureWithin(reader, end, coordinateBytes, 'point cloud coordinates');
const points = reader.floats(coordinateCount, 'point cloud coordinates');
let intensity: Float32Array | undefined;
if (options.displayPointIntensity && reader.offset + pointCount * 4 <= end) {
intensity = reader.floats(pointCount, 'point cloud intensity');
Expand All @@ -120,14 +161,28 @@ function parsePointCloud(reader: Reader, options: ProtocolOptions, end: number):
: new PointCloudProperty(points, intensity);
}

function parseCluster(reader: Reader, options: ProtocolOptions): { cluster: ClusterProperty; readableID?: number } {
function parseCluster(
reader: Reader,
options: ProtocolOptions,
end: number
): { cluster: ClusterProperty; readableID?: number } {
const rotationCount = options.boxRotationMode === RotationMode.Quaternions ? 4 : 3;
const requiredBytes = (
4
+ 4 * 3 * 4
+ 4
+ rotationCount * 4
+ 3 * 4
+ (options.version >= 3 ? 4 : 0)
);
ensureWithin(reader, end, requiredBytes, 'cluster property');

const state = reader.i32('cluster state') as ClusterState;
const centroid = reader.vec3('cluster centroid');
const velocity = reader.vec3('cluster velocity');
const boundingBoxCenter = reader.vec3('bounding box center');
const boundingBoxSize = reader.vec3('bounding box size');
const weight = reader.f32('cluster weight');
const rotationCount = options.boxRotationMode === RotationMode.Quaternions ? 4 : 3;
const rotation = Array.from(reader.floats(rotationCount, 'bounding box rotation'));
const lookAt = reader.vec3('cluster look-at');

Expand All @@ -153,6 +208,8 @@ function formatUUID(bytes: Uint8Array): string {
}

function parseObject(reader: Reader, options: ProtocolOptions, packetEnd: number): ObjectPacket {
ensureWithin(reader, packetEnd, options.version >= 3 ? 20 : 8, 'object header');

let id: number | undefined;
let uuid: string | undefined;
if (options.version >= 3) {
Expand All @@ -169,6 +226,7 @@ function parseObject(reader: Reader, options: ProtocolOptions, packetEnd: number
let pointCloud: PointCloudProperty | undefined;

for (let i = 0; i < propertiesCount; i++) {
ensureWithin(reader, packetEnd, 8, 'object property header');
const propertyStart = reader.offset;
const propertySize = reader.i32('object property size');
const propertyType = reader.i32('object property type') as ObjectPropertyType;
Expand All @@ -180,7 +238,7 @@ function parseObject(reader: Reader, options: ProtocolOptions, packetEnd: number
if (propertyType === ObjectPropertyType.Points) {
pointCloud = parsePointCloud(reader, options, propertyEnd);
} else if (propertyType === ObjectPropertyType.Cluster) {
const parsedCluster = parseCluster(reader, options);
const parsedCluster = parseCluster(reader, options, propertyEnd);
cluster = parsedCluster.cluster;
if (parsedCluster.readableID !== undefined) id = parsedCluster.readableID;
}
Expand All @@ -195,8 +253,10 @@ function parseObject(reader: Reader, options: ProtocolOptions, packetEnd: number
}

function parseZoneEvent(reader: Reader, options: ProtocolOptions, packetEnd: number): ZoneEventPacket {
ensureWithin(reader, packetEnd, 4, 'zone address size');
const addressSize = reader.i32('zone address size');
if (addressSize < 0) throw new RangeError('Malformed Augmenta zone address size.');
ensureWithin(reader, packetEnd, addressSize + 14, 'zone header');
const address = reader.string(addressSize, 'zone address');
const enters = reader.u8('zone enters');
const leaves = reader.u8('zone leaves');
Expand All @@ -207,6 +267,7 @@ function parseZoneEvent(reader: Reader, options: ProtocolOptions, packetEnd: num

const properties: ZoneEventProperty[] = [];
for (let i = 0; i < propertiesCount; i++) {
ensureWithin(reader, packetEnd, 5, 'zone property header');
const propertyStart = reader.offset;
const propertySize = reader.i32('zone property size');
const propertyType = reader.u8('zone property type') as ZonePropertyType;
Expand All @@ -216,8 +277,10 @@ function parseZoneEvent(reader: Reader, options: ProtocolOptions, packetEnd: num
}

if (propertyType === ZonePropertyType.Slider) {
ensureWithin(reader, propertyEnd, 4, 'zone slider');
properties.push(new ZoneEventProperty(propertyType, { value: reader.f32('zone slider') }));
} else if (propertyType === ZonePropertyType.XYPad) {
ensureWithin(reader, propertyEnd, 8, 'zone XY pad');
properties.push(new ZoneEventProperty(propertyType, {
x: reader.f32('zone XY pad x'),
y: reader.f32('zone XY pad y')
Expand All @@ -232,40 +295,60 @@ function parseZoneEvent(reader: Reader, options: ProtocolOptions, packetEnd: num
return new ZoneEventPacket(address, enters, leaves, presence, density, properties);
}

function parseScene(reader: Reader, options: ProtocolOptions): SceneInfoPacket {
function parseScene(reader: Reader, options: ProtocolOptions, packetEnd: number): SceneInfoPacket {
ensureWithin(reader, packetEnd, 4, 'scene address size');
const addressSize = reader.i32('scene address size');
if (addressSize < 0) throw new RangeError('Malformed Augmenta scene address size.');
ensureWithin(
reader,
packetEnd,
addressSize + (options.version >= 3 ? 4 : 0),
'scene packet'
);
const address = reader.string(addressSize, 'scene address');
if (options.version >= 3) return new SceneInfoPacket(address, reader.i32('scene timestamp'));
return new SceneInfoPacket(address);
}

function parsePacket(reader: Reader, state: ParsedBlobState, options: ProtocolOptions): void {
function parsePacket(
reader: Reader,
state: ParsedBlobState,
options: ProtocolOptions,
parentEnd = reader.length
): void {
const packetStart = reader.offset;
ensureWithin(reader, parentEnd, 5, 'packet header');
const packetSize = reader.i32('packet size');
const type = reader.u8('packet type') as PacketType;
const packetEnd = packetStart + packetSize;

if (packetSize < 5 || packetEnd > reader.length) {
if (packetSize < 5 || packetEnd > parentEnd || packetEnd > reader.length) {
throw new RangeError('Malformed Augmenta packet size.');
}

if (type === PacketType.Bundle) {
ensureWithin(
reader,
packetEnd,
(options.version >= 3 ? 4 : 0) + 4,
'bundle header'
);
if (options.version >= 3) state.timestamp = reader.i32('bundle timestamp');
const packetCount = reader.i32('bundle packet count');
if (packetCount < 0) throw new RangeError('Malformed Augmenta bundle packet count.');
for (let i = 0; i < packetCount; i++) parsePacket(reader, state, options);
for (let i = 0; i < packetCount; i++) {
parsePacket(reader, state, options, packetEnd);
}
} else if (type === PacketType.Object) {
state.objects.push(parseObject(reader, options, packetEnd));
} else if (type === PacketType.ZoneEvent) {
state.zoneEvents.push(parseZoneEvent(reader, options, packetEnd));
} else if (type === PacketType.Scene) {
state.sceneInfo = parseScene(reader, options);
} else {
throw new Error(`Unknown Augmenta packet type ${type}.`);
state.sceneInfo = parseScene(reader, options, packetEnd);
}
// Unknown packet families are skipped to their declared boundary. Packet size
// is authoritative, matching the forward-compatible property behavior.

// Packet size comes from Pleiades and is authoritative. It lets clients ignore future fields safely.
reader.seek(packetEnd, 'packet');
}

Expand Down
4 changes: 3 additions & 1 deletion src/data.ts
Original file line number Diff line number Diff line change
Expand Up @@ -38,7 +38,9 @@ export class ClusterProperty {
getBoundingBoxSize(): Vector3 { return this.boundingBoxSize; }
getWeight(): number { return this.weight; }
getBoundingBoxRotationEuler(): Vector3 {
if (this.boundingBoxRotation.length < 3) throw new Error('Rotation data is unavailable.');
if (this.boundingBoxRotation.length !== 3) {
throw new Error('Rotation mode is not Euler.');
}
return [this.boundingBoxRotation[0]!, this.boundingBoxRotation[1]!, this.boundingBoxRotation[2]!];
}
getBoundingBoxRotationQuaternions(): Vector4 {
Expand Down
13 changes: 11 additions & 2 deletions src/options.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,6 @@
export const MIN_SUPPORTED_PROTOCOL_VERSION = 2;
export const MAX_SUPPORTED_PROTOCOL_VERSION = 3;

export enum RotationMode {
Radians = 'radians',
Degrees = 'degrees',
Expand Down Expand Up @@ -91,8 +94,14 @@ export class ProtocolOptions {
if (axisTransform) this.axisTransform = new AxisTransform(axisTransform);
if (tags) this.tags = [...tags];

if (!Number.isInteger(this.version) || this.version < 1) {
throw new RangeError('Protocol version must be a positive integer.');
if (
!Number.isInteger(this.version)
|| this.version < MIN_SUPPORTED_PROTOCOL_VERSION
|| this.version > MAX_SUPPORTED_PROTOCOL_VERSION
) {
throw new RangeError(
`Protocol version must be an integer between ${MIN_SUPPORTED_PROTOCOL_VERSION} and ${MAX_SUPPORTED_PROTOCOL_VERSION}.`
);
}
if (!Number.isInteger(this.downSample) || this.downSample < 1) {
throw new RangeError('downSample must be an integer greater than or equal to 1.');
Expand Down
Loading
Loading