From 0637f585a46e01b3aff63273ebb72bdb032300c8 Mon Sep 17 00:00:00 2001 From: David-Alexandre Chanel Date: Tue, 29 Sep 2026 12:03:59 +0200 Subject: [PATCH 1/2] feat: add functional JavaScript SDK base --- .editorconfig | 11 ++ .github/workflows/ci.yml | 23 +++ .gitignore | 7 + CONTRIBUTING.md | 24 +++ LICENSE | 182 ++++++++++++++++++++++ README.md | 190 +++++++++++++++++++++- THIRD_PARTY_LICENSES | 6 + docs/API.md | 96 ++++++++++++ docs/PROTOCOL.md | 45 ++++++ examples/browser/index.html | 8 + examples/browser/main.js | 18 +++ package.json | 55 +++++++ scripts/clean.mjs | 3 + scripts/postbuild.mjs | 5 + src/binary.ts | 303 ++++++++++++++++++++++++++++++++++++ src/client.ts | 125 +++++++++++++++ src/control.ts | 142 +++++++++++++++++ src/data.ts | 288 ++++++++++++++++++++++++++++++++++ src/index.ts | 5 + src/options.ts | 109 +++++++++++++ src/parser.ts | 3 + src/websocket.ts | 148 ++++++++++++++++++ tests/sdk.test.mjs | 285 +++++++++++++++++++++++++++++++++ tsconfig.base.json | 15 ++ tsconfig.cjs.json | 9 ++ tsconfig.esm.json | 10 ++ 26 files changed, 2114 insertions(+), 1 deletion(-) create mode 100644 .editorconfig create mode 100644 .github/workflows/ci.yml create mode 100644 .gitignore create mode 100644 CONTRIBUTING.md create mode 100644 LICENSE create mode 100644 THIRD_PARTY_LICENSES create mode 100644 docs/API.md create mode 100644 docs/PROTOCOL.md create mode 100644 examples/browser/index.html create mode 100644 examples/browser/main.js create mode 100644 package.json create mode 100644 scripts/clean.mjs create mode 100644 scripts/postbuild.mjs create mode 100644 src/binary.ts create mode 100644 src/client.ts create mode 100644 src/control.ts create mode 100644 src/data.ts create mode 100644 src/index.ts create mode 100644 src/options.ts create mode 100644 src/parser.ts create mode 100644 src/websocket.ts create mode 100644 tests/sdk.test.mjs create mode 100644 tsconfig.base.json create mode 100644 tsconfig.cjs.json create mode 100644 tsconfig.esm.json diff --git a/.editorconfig b/.editorconfig new file mode 100644 index 0000000..192c070 --- /dev/null +++ b/.editorconfig @@ -0,0 +1,11 @@ +root = true + +[*] +charset = utf-8 +end_of_line = lf +insert_final_newline = true +indent_style = space +indent_size = 2 + +[*.md] +trim_trailing_whitespace = false diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..1503eff --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,23 @@ +name: CI + +on: + push: + branches: [main] + pull_request: + +permissions: + contents: read + +jobs: + test: + runs-on: ubuntu-latest + strategy: + matrix: + node-version: [20, 22] + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-node@v4 + with: + node-version: ${{ matrix.node-version }} + - run: npm install --ignore-scripts + - run: npm run check diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..dcf22b5 --- /dev/null +++ b/.gitignore @@ -0,0 +1,7 @@ +node_modules/ +dist/ +*.tgz +.DS_Store +.vscode/ +.idea/ +coverage/ diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md new file mode 100644 index 0000000..04b9ee0 --- /dev/null +++ b/CONTRIBUTING.md @@ -0,0 +1,24 @@ +# Contributing + +Keep changes small, protocol-focused and backward-compatible whenever possible. + +## Development + +```bash +npm install +npm run check +``` + +`npm run check` builds both module formats, runs the parser/transport tests and verifies the npm package contents. + +## Protocol changes + +When changing parsing or serialization: + +1. verify the corresponding Pleiades WebSocket writer/reader behavior; +2. compare the C++ and C# SDK behavior; +3. add or update a focused test fixture/case; +4. preserve existing public names and behavior unless a breaking change is intentional; +5. document the supported protocol version in `docs/PROTOCOL.md`. + +Prefer small diffs over unrelated refactors. Avoid adding a runtime dependency unless it brings a clear cross-platform benefit that cannot be provided by the host application. diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..bed9262 --- /dev/null +++ b/LICENSE @@ -0,0 +1,182 @@ +AUGMENTA SOFTWARE DEVELOPMENT KIT (SDK) LICENSE AGREEMENT +======================================================== + +Version 2.0 +Effective date: 25 August 2026 + +This Software Development Kit License Agreement ("Agreement") is a legal agreement between you, either an individual or a legal entity ("You" or "Your"), and: + +Augmenta SAS +36 rue Emile Decorps +Villeurbanne, France + +("Augmenta", "We", "Us" or "Our"), governing Your use of the Augmenta Software Development Kit (the "SDK"). + +By downloading, installing, copying, accessing or using the SDK, You agree to be bound by this Agreement. If You accept this Agreement on behalf of an organization, You represent that You have authority to bind that organization. If You do not agree, do not download, install, copy, access or use the SDK. + +1. DEFINITIONS +-------------- + +1.1 "SDK" means the Augmenta Client SDK and the Augmenta-authored libraries, header files, sample code, documentation and tools supplied with it, excluding Third-Party Software to the extent separately licensed. + +1.2 "Application" means software developed by You that integrates or interoperates with Augmenta software or equipment using the SDK. + +1.3 "Augmenta Software" means software distributed by Augmenta under the Augmenta name with which the SDK is designed to interoperate. + +1.4 "Third-Party Software" means third-party or open-source software included in, linked with, distributed with or used by the SDK and governed by separate license terms. + +2. LICENSE GRANT +---------------- + +Subject to Your compliance with this Agreement, Augmenta grants You a limited, non-exclusive, worldwide, royalty-free license to: + +(a) use, reproduce and compile the SDK solely for the purpose of developing, testing, maintaining and supporting Applications that integrate or interoperate with Augmenta Software or equipment; + +(b) modify SDK sample code where reasonably necessary to develop Your Applications, provided that such modification does not grant You ownership of Augmenta-authored portions of the SDK; + +(c) distribute Your Applications in source or binary form; and + +(d) reproduce and distribute only those SDK runtime components, compiled SDK code or portions of SDK code that are reasonably necessary for Your Application to operate, solely as an integrated part of Your Application and not as a standalone SDK or development kit. + +The rights in this Section do not include a right to redistribute the SDK as a general-purpose library, software development kit, source-code package or substitute for the SDK itself. + +3. APPLICATIONS AND REDISTRIBUTION +---------------------------------- + +3.1 Your Applications. +You retain ownership of Your original Application code, subject to Augmenta's ownership of the SDK and the rights of third-party licensors. + +3.2 End-user terms. +You are responsible for providing appropriate terms for Your Application. Those terms must not purport to grant rights in the SDK broader than the rights You receive under this Agreement or restrict rights independently granted under applicable Third-Party Software licenses. + +3.3 Runtime redistribution. +Where You distribute SDK runtime components or compiled SDK code with an Application, You must: + +(a) distribute them only as part of the Application; +(b) retain applicable copyright, proprietary and third-party notices; +(c) include any third-party notices or license texts required by the applicable Third-Party Software licenses; and +(d) not represent that Augmenta sponsors, certifies or endorses Your Application unless Augmenta has expressly agreed otherwise in writing. + +4. RESTRICTIONS +--------------- + +Except to the extent expressly permitted by this Agreement, applicable Third-Party Software terms or mandatory law, You may not: + +(a) use the SDK to create or distribute a standalone SDK, library or development product that substitutes for the Augmenta SDK; + +(b) use the SDK primarily to replicate or commercially substitute for the core functionality of Augmenta Software rather than to integrate or interoperate with it; + +(c) sell, rent, lease, sublicense or distribute the SDK independently of an Application as permitted under Section 3; + +(d) remove, obscure or alter Augmenta copyright, trademark or other proprietary notices; + +(e) reverse engineer, decompile or disassemble Augmenta proprietary portions of the SDK except to the extent expressly permitted by mandatory law; or + +(f) use Augmenta trademarks or logos except as permitted by Section 5 or a separate written brand or partner agreement. + +Nothing in this Agreement restricts any interoperability, observation, study, testing, decompilation or other software right that applicable law requires to remain available. + +5. ATTRIBUTION AND BRANDING +--------------------------- + +5.1 Attribution. +Applications using the SDK must include reasonable attribution to Augmenta in documentation, an acknowledgements or third-party notices section, an about screen, or another location reasonably accessible to users. + +Where an Application lists libraries, SDKs or technologies used, the following attribution is sufficient: + + Augmenta SDK - https://augmenta.tech + +5.2 Trademarks. +This Agreement does not grant a general trademark or logo license. Any use of Augmenta logos or branding beyond nominative identification of compatibility or use of the SDK must comply with applicable Augmenta brand guidelines or separate written authorization. + +6. OWNERSHIP +------------ + +Augmenta and its licensors retain all right, title and interest in and to the Augmenta-authored portions of the SDK, including all applicable intellectual-property rights. + +Except for the limited rights expressly granted under this Agreement, no rights are transferred to You by implication, estoppel or otherwise. + +Feedback, suggestions or ideas You voluntarily provide regarding the SDK may be used by Augmenta without restriction or obligation, provided that this does not transfer ownership of Your Application or confidential information disclosed under a separate confidentiality agreement. + +7. THIRD-PARTY SOFTWARE +----------------------- + +The SDK includes or uses Third-Party Software. Third-Party Software is not relicensed under this Agreement and remains governed by its applicable license terms. + +Required third-party notices and license information are supplied in THIRD_PARTY_LICENSES and/or with the applicable Third-Party Software. + +If this Agreement conflicts with an applicable Third-Party Software license, that third-party license controls solely with respect to the relevant Third-Party Software. + +8. SUPPORT AND UPDATES +---------------------- + +Unless Augmenta separately agrees otherwise in writing, Augmenta is not required to provide support, maintenance, updates, upgrades or continued availability of the SDK. + +Augmenta may provide updated SDK versions from time to time. Unless an updated version is accompanied by different terms, the version of this Agreement supplied with that SDK version applies to that version. Updated terms do not retroactively alter rights already granted for an earlier SDK copy unless You validly agree otherwise or applicable law requires it. + +9. DISCLAIMER OF WARRANTY +------------------------- + +TO THE MAXIMUM EXTENT PERMITTED BY APPLICABLE LAW, THE SDK, DOCUMENTATION, SAMPLE CODE, UPDATES AND SUPPORT ARE PROVIDED "AS IS" AND "AS AVAILABLE" WITHOUT WARRANTIES OF ANY KIND. + +AUGMENTA DISCLAIMS IMPLIED WARRANTIES AND CONDITIONS, INCLUDING MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE, SATISFACTORY QUALITY, NON-INFRINGEMENT, ACCURACY, RELIABILITY AND UNINTERRUPTED OR ERROR-FREE OPERATION, TO THE EXTENT SUCH WARRANTIES OR CONDITIONS MAY LAWFULLY BE DISCLAIMED. + +Nothing in this Agreement excludes or restricts any warranty or statutory right that cannot lawfully be excluded or restricted. + +10. LIMITATION OF LIABILITY +--------------------------- + +TO THE MAXIMUM EXTENT PERMITTED BY APPLICABLE LAW, AUGMENTA WILL NOT BE LIABLE FOR INDIRECT, INCIDENTAL, SPECIAL, CONSEQUENTIAL, EXEMPLARY OR PUNITIVE DAMAGES, OR FOR LOSS OF PROFITS, REVENUE, BUSINESS, GOODWILL, OPPORTUNITY OR DATA, ARISING OUT OF OR RELATING TO THE SDK OR THIS AGREEMENT, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGES. + +TO THE MAXIMUM EXTENT PERMITTED BY APPLICABLE LAW, AUGMENTA'S TOTAL AGGREGATE LIABILITY ARISING OUT OF OR RELATING TO THE SDK OR THIS AGREEMENT WILL NOT EXCEED EUR 100, UNLESS A SEPARATE WRITTEN AGREEMENT BETWEEN YOU AND AUGMENTA ESTABLISHES A DIFFERENT LIABILITY CAP. + +Nothing in this Agreement excludes or limits liability to the extent such liability cannot lawfully be excluded or limited, and no limitation applies in a manner that applicable law prohibits with respect to an essential contractual obligation. + +11. TERM AND TERMINATION +------------------------ + +This Agreement continues until terminated. + +Augmenta may terminate the rights granted under this Agreement if You materially breach it and fail to cure the breach within any cure period required by applicable law or a separate written agreement. + +Upon termination, You must cease use of the SDK and cease new distribution of Augmenta proprietary SDK components, except to the extent otherwise required by mandatory law or expressly permitted by a separate written agreement. + +Termination does not revoke rights independently granted under Third-Party Software licenses. Sections concerning ownership, Third-Party Software, disclaimers, limitation of liability, governing law and provisions that by their nature should survive will survive termination. + +12. EXPORT CONTROLS AND LAWFUL USE +---------------------------------- + +You must use and distribute the SDK in compliance with applicable laws and regulations, including export controls, economic sanctions, intellectual-property laws and data-protection laws. + +13. GOVERNING LAW AND JURISDICTION +---------------------------------- + +This Agreement is governed by the laws of France, without regard to conflict-of-laws principles and without prejudice to mandatory protections applicable under law. + +Subject to mandatory applicable law and any different forum validly agreed in a separate written agreement, disputes arising out of or relating to this Agreement shall be brought before the competent courts of France. + +14. GENERAL PROVISIONS +---------------------- + +14.1 Entire agreement. +This Agreement constitutes the agreement governing the SDK except where a separate written agreement signed by Augmenta expressly governs the relevant matter. Applicable Third-Party Software license terms continue to govern the relevant third-party components. + +14.2 Severability. +If a provision is held invalid, unlawful or unenforceable, it will be enforced to the maximum extent permitted and the remaining provisions will remain effective. + +14.3 No waiver. +Failure or delay in exercising a right does not waive that right. + +14.4 Assignment. +You may not assign this Agreement or redistribute the SDK except as expressly permitted by this Agreement, a separate written agreement or mandatory law. Augmenta may assign this Agreement as part of a merger, reorganization, sale of business or transfer of the SDK or relevant assets, subject to applicable law. + +15. CONTACT +----------- + +Questions regarding this Agreement may be sent to: + +Augmenta SAS +36 rue Emile Decorps +Villeurbanne, France + +Email: legal@augmenta.tech diff --git a/README.md b/README.md index 8955fa1..4c9ccb1 100644 --- a/README.md +++ b/README.md @@ -1 +1,189 @@ -# AugmentaClientSDK-JS \ No newline at end of file +# Augmenta Client JavaScript SDK + +JavaScript/TypeScript client SDK for consuming the real-time stream output of an Augmenta server. + +The SDK follows the same philosophy as the Augmenta C++ and C# client SDKs: + +- keep the Augmenta protocol parser independent from networking; +- generate the registration and polling messages expected by the Augmenta WebSocket Output; +- parse binary tracking data into developer-friendly objects; +- parse setup/update control messages into a typed scene/zone hierarchy; +- let applications use their own transport when they need to; +- provide a small WebSocket convenience client for common web and Node.js use cases. + +The package has **no runtime dependency** and is designed for browsers, web applications, Node.js integrations and future Max/MSP / Max for Live clients. + +## Status + +This repository currently provides the V1 functional base of the JavaScript SDK. + +Supported: + +- Augmenta WebSocket protocol V2 binary data; +- current Pleiades V3 bundle/object/scene extensions, including timestamps and UUID-based object packets; +- clusters, point clouds and scene information; +- zone enter/leave/presence/density events; +- zone slider, XY pad and optional zone point-cloud properties; +- setup and update control messages; +- scene and zone hierarchy, position, rotation, color and supported shapes; +- browser/Node WebSocket convenience transport; +- ESM and CommonJS builds plus TypeScript declarations. + +Legacy binary protocol V1 is intentionally not implemented in this first version. + +## Install + +Once published to npm: + +```bash +npm install augmenta-client-sdk +``` + +From this repository during development: + +```bash +npm install +npm run build +npm test +``` + +## Core SDK usage + +The `Client` class does not own a WebSocket. This is the closest equivalent to the C++ and C# SDKs. + +```ts +import { Client, ProtocolOptions } from 'augmenta-client-sdk'; + +const options = new ProtocolOptions({ + streamClouds: false, + streamClusters: true, + streamClusterPoints: false, + streamZonePoints: false, + useCompression: false +}); + +const client = new Client(); +client.setApplicationName('My application'); +client.setApplicationVersion('1.0.0'); +client.initialize('My Augmenta client', options); + +const socket = new WebSocket('ws://augmenta-server:PORT'); +socket.binaryType = 'arraybuffer'; + +socket.addEventListener('open', () => { + socket.send(client.getRegisterMessage()); +}); + +socket.addEventListener('message', async (event) => { + if (typeof event.data === 'string') { + const message = client.parseControlMessage(event.data); + + if (message.isSetup()) { + console.log(message.getRootObject()); + } + return; + } + + const buffer = event.data instanceof Blob + ? await event.data.arrayBuffer() + : event.data; + + const frame = client.parseDataBlob(buffer); + + for (const object of frame.getObjects()) { + if (!object.hasCluster()) continue; + const cluster = object.getCluster(); + console.log(object.getID(), cluster.getCentroid()); + } + + for (const zone of frame.getZoneEvents()) { + console.log(zone.getEmitterZoneAddress(), zone.getPresence()); + } +}); +``` + +## WebSocket convenience client + +For normal browser applications, `AugmentaWebSocketClient` handles the transport and registration handshake while still exposing the underlying `Client`. + +```ts +import { AugmentaWebSocketClient } from 'augmenta-client-sdk'; + +const augmenta = new AugmentaWebSocketClient('ws://augmenta-server:PORT', { + clientName: 'Three.js installation', + applicationName: 'Interactive Room', + applicationVersion: '1.0.0', + options: { + streamClouds: false, + streamClusters: true, + streamClusterPoints: false, + useCompression: false + } +}); + +augmenta.on('setup', (message) => { + console.log('Augmenta hierarchy', message.getRootObject()); +}); + +augmenta.on('data', (frame) => { + for (const object of frame.getObjects()) { + if (!object.hasCluster()) continue; + const position = object.getCluster().getBoundingBoxCenter(); + // threeObject.position.set(position[0], position[1], position[2]); + } +}); + +augmenta.on('error', console.error); +augmenta.connect(); +``` + +Modern browsers provide `WebSocket` globally. Other runtimes can pass a `webSocketFactory` without changing the SDK core. + +## Compression + +Augmenta can Zstd-compress binary WebSocket frames. The C++ SDK enables compression by default, and this SDK keeps the same `ProtocolOptions` default. + +To keep the JavaScript package small, dependency-free and browser-neutral, decompression is injected by the host application: + +```ts +const client = new Client({ + decompressor: (compressed) => myZstdDecoder(compressed) +}); +``` + +For lightweight tracking clients such as web visualizations or Max for Live, disabling compression and raw point clouds is usually the simplest starting point: + +```ts +const options = new ProtocolOptions({ + useCompression: false, + streamClouds: false, + streamClusterPoints: false +}); +``` + +## Package outputs + +`npm run build` creates: + +- `dist/esm` — ES modules for browsers and modern bundlers; +- `dist/cjs` — CommonJS for Node.js-style integrations; +- `dist/types` — TypeScript declarations. + +## Documentation + +- [API overview](docs/API.md) +- [Protocol compatibility](docs/PROTOCOL.md) +- [Contributing](CONTRIBUTING.md) + +## Design principles + +1. **Protocol first** — the wire format emitted by Pleiades is the source of truth. +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. + +## License + +Use of this SDK is governed by the Augmenta Software Development Kit License Agreement in [LICENSE](LICENSE). diff --git a/THIRD_PARTY_LICENSES b/THIRD_PARTY_LICENSES new file mode 100644 index 0000000..ce775a4 --- /dev/null +++ b/THIRD_PARTY_LICENSES @@ -0,0 +1,6 @@ +THIRD-PARTY SOFTWARE +==================== + +The published Augmenta Client JavaScript SDK has no third-party runtime dependency. + +Development tooling declared in package.json is installed separately by developers and remains governed by its own license terms. diff --git a/docs/API.md b/docs/API.md new file mode 100644 index 0000000..b52aec1 --- /dev/null +++ b/docs/API.md @@ -0,0 +1,96 @@ +# API overview + +## `ProtocolOptions` + +Negotiates what an Augmenta WebSocket Output sends to the client. + +Important options include: + +- `version` — protocol version, V2 by default; +- `tags` — optional server-side Augmenta tagss; +- `downSample` — point-cloud downsampling factor; +- `streamClouds` — raw scene point clouds; +- `streamClusters` — tracked Augmenta objects; +- `streamClusterPoints` — points belonging to tracked clusters; +- `streamZonePoints` — Optional point clouds attached to zone events; +- `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. + +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. + +## `Client` + +Transport-independent protocol client. + +Main methods: + +- `initialize(clientName, options)` +- `shutdown()` +- `addTag(tag)` / `clearTags()` +- `setApplicationName(name)` +- `setApplicationVersion(version)` +- `setPluginVersion(version)` +- `getRegisterMessage()` +- `getPollMessage()` +- `parseControlMessage(text)` +- `parseDataBlob(binary)` + +## `ControlMessage` + +Represents JSON setup/update messages. + +A setup message exposes a root `Container`, which can recursively contain scenes, zones and generic containers. Common fields include name, address, position, rotation, color and children. + +Scene containers expose scene size. Zone containers expose the shape information currently provided by Pleiades: Box, Cylinder, Sphere, Path, Grid, Polygon and Segment. + +## `DataBlob` + +Represents one parsed binary Augmenta frame/bundle. + +It contains: + +- `SceneInfoPacket` +- zero or more `ObjectPacket` values +- zero or more `ZoneEventPacket` values +- V3 bundle timestamp when present; scene packets also expose their V3 timestamp + +## `ObjectPacket` + +An object can contain: + +- cluster data; +- point-cloud data; +- both. + +Cluster data includes state, centroid, velocity, bounding-box center/size/rotation, weight and look-at vector. + +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. + +## `ZoneEventPacket` + +Provides: + +- emitter zone address; +- enters; +- leaves; +- presence; +- density; +- optional slider, XY pad and point-cloud properties. + +## `AugmentaWebSocketClient` + +Convenience transport for environments with a WebSocket implementation. + +Events: + +- `open` +- `close` +- `error` +- `controlMessage` +- `setup` +- `update` +- `data` + +The class accepts a custom `webSocketFactory`, which is useful for Node runtimes or Max/MSP environments that provide their own WebSocket package. diff --git a/docs/PROTOCOL.md b/docs/PROTOCOL.md new file mode 100644 index 0000000..9fafee3 --- /dev/null +++ b/docs/PROTOCOL.md @@ -0,0 +1,45 @@ +# Protocol compatibility + +The SDK is implemented against the Augmenta WebSocket Output wire format produced by Pleiades and mirrors the parsing concepts of `AugmentaClientSDK-cpp` and `AugmentaClientSDK-CS`. + +## V2 + +V2 is the baseline protocol for this SDK and is covered by automated binary parsing tests. + +Supported packet families: + +- bundle; +- scene; +- object; +- zone event. + +Supported object properties: + +- points; +- cluster. + +Supported zone properties: + +- slider; +- XY pad; +- point cloud. + +## V3 + +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 + +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. + +## 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. + +## Compression + +The wire protocol can use Zstd compression. Compression is deliberately separated from parsing through a synchronous decompressor callback, keeping the SDK browser-neutral and free of runtime dependencies. + +## 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. diff --git a/examples/browser/index.html b/examples/browser/index.html new file mode 100644 index 0000000..845076f --- /dev/null +++ b/examples/browser/index.html @@ -0,0 +1,8 @@ + + + Augmenta JS SDK example + +
Connecting...
+ + + diff --git a/examples/browser/main.js b/examples/browser/main.js new file mode 100644 index 0000000..7541cf9 --- /dev/null +++ b/examples/browser/main.js @@ -0,0 +1,18 @@ +import { AugmentaWebSocketClient } from '../../dist/esm/index.js'; + +const status = document.querySelector('#status'); +const augmenta = new AugmentaWebSocketClient('ws://127.0.0.1:8080', { + clientName: 'Augmenta browser example', + options: { + useCompression: false, + streamClouds: false, + streamClusterPoints: false + } +}); + +augmenta.on('open', () => { status.textContent = 'Connected'; }); +augmenta.on('data', (frame) => { + status.textContent = `Tracked objects: ${frame.getObjectCount()}\nZone events: ${frame.getZoneEventCount()}`; +}); +augmenta.on('error', (error) => { console.error(error); status.textContent = 'Connection error'; }); +augmenta.connect(); diff --git a/package.json b/package.json new file mode 100644 index 0000000..94fa94a --- /dev/null +++ b/package.json @@ -0,0 +1,55 @@ +{ + "name": "augmenta-client-sdk", + "version": "0.1.0", + "description": "JavaScript/TypeScript client SDK for consuming Augmenta WebSocket output streams in web and Node.js applications.", + "license": "SEE LICENSE IN LICENSE", + "author": "Augmenta SAS", + "homepage": "https://augmenta.tech", + "repository": { + "type": "git", + "url": "https://github.com/Augmenta-tech/AugmentaClientSDK-JS.git" + }, + "bugs": { + "url": "https://github.com/Augmenta-tech/AugmentaClientSDK-JS/issues" + }, + "type": "module", + "main": "./dist/cjs/index.js", + "module": "./dist/esm/index.js", + "types": "./dist/types/index.d.ts", + "exports": { + ".": { + "types": "./dist/types/index.d.ts", + "import": "./dist/esm/index.js", + "require": "./dist/cjs/index.js" + } + }, + "files": [ + "dist", + "README.md", + "LICENSE", + "THIRD_PARTY_LICENSES" + ], + "sideEffects": false, + "scripts": { + "clean": "node ./scripts/clean.mjs", + "build": "npm run clean && tsc -p tsconfig.esm.json && tsc -p tsconfig.cjs.json && node ./scripts/postbuild.mjs", + "test": "npm run build && node --test tests/*.test.mjs", + "check": "npm run test && npm pack --dry-run" + }, + "devDependencies": { + "typescript": "5.8.3" + }, + "engines": { + "node": ">=18" + }, + "keywords": [ + "augmenta", + "websocket", + "tracking", + "spatial", + "realtime", + "threejs", + "max", + "max4live" + ] +} diff --git a/scripts/clean.mjs b/scripts/clean.mjs new file mode 100644 index 0000000..6c499a8 --- /dev/null +++ b/scripts/clean.mjs @@ -0,0 +1,3 @@ +import { rm } from 'node:fs/promises'; + +await rm(new URL('../dist', import.meta.url), { recursive: true, force: true }); diff --git a/scripts/postbuild.mjs b/scripts/postbuild.mjs new file mode 100644 index 0000000..b39a46c --- /dev/null +++ b/scripts/postbuild.mjs @@ -0,0 +1,5 @@ +import { mkdir, writeFile } from 'node:fs/promises'; + +const cjsDir = new URL('../dist/cjs/', import.meta.url); +await mkdir(cjsDir, { recursive: true }); +await writeFile(new URL('package.json', cjsDir), '{"type":"commonjs"}\n'); diff --git a/src/binary.ts b/src/binary.ts new file mode 100644 index 0000000..447d684 --- /dev/null +++ b/src/binary.ts @@ -0,0 +1,303 @@ +import { + ClusterProperty, + ClusterState, + DataBlob, + ObjectPacket, + PointCloudProperty, + SceneInfoPacket, + ZoneEventPacket, + ZoneEventProperty, + ZonePropertyType, + type Vector3 +} from './data.js'; +import { ProtocolOptions, RotationMode } from './options.js'; + +export type BinaryData = ArrayBuffer | ArrayBufferView; +export type Decompressor = (data: Uint8Array) => Uint8Array; + +const textDecoder = new TextDecoder(); + +function asUint8Array(data: BinaryData): Uint8Array { + if (data instanceof ArrayBuffer) return new Uint8Array(data); + return new Uint8Array(data.buffer, data.byteOffset, data.byteLength); +} + +class Reader { + readonly view: DataView; + offset = 0; + + constructor(public readonly bytes: Uint8Array) { + this.view = new DataView(bytes.buffer, bytes.byteOffset, bytes.byteLength); + } + + get length(): number { return this.bytes.byteLength; } + get remaining(): number { return this.length - this.offset; } + + ensure(size: number, context: string): void { + if (size < 0 || this.offset + size > this.length) { + throw new RangeError(`Malformed Augmenta packet while reading ${context}.`); + } + } + + u8(context = 'uint8'): number { + this.ensure(1, context); + return this.view.getUint8(this.offset++); + } + + i32(context = 'int32'): number { + this.ensure(4, context); + const value = this.view.getInt32(this.offset, true); + this.offset += 4; + return value; + } + + f32(context = 'float32'): number { + this.ensure(4, context); + const value = this.view.getFloat32(this.offset, true); + this.offset += 4; + return value; + } + + vec3(context = 'vec3'): Vector3 { + return [this.f32(context), this.f32(context), this.f32(context)]; + } + + floats(count: number, context: string): Float32Array { + 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); + return output; + } + + string(byteLength: number, context = 'string'): string { + this.ensure(byteLength, context); + const value = textDecoder.decode(this.bytes.subarray(this.offset, this.offset + byteLength)); + this.offset += byteLength; + return value; + } + + seek(offset: number, context = 'packet'): void { + if (!Number.isInteger(offset) || offset < 0 || offset > this.length) { + throw new RangeError(`Malformed Augmenta ${context} boundary.`); + } + this.offset = offset; + } +} + +enum PacketType { + Object = 0, + ZoneEvent = 1, + Scene = 2, + Bundle = 255 +} + +enum ObjectPropertyType { + Points = 0, + Cluster = 1 +} + +interface ParsedBlobState { + sceneInfo: SceneInfoPacket; + objects: ObjectPacket[]; + zoneEvents: ZoneEventPacket[]; + timestamp?: number; +} + +function parsePointCloud(reader: Reader, options: ProtocolOptions, end: number): PointCloudProperty { + 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'); + let intensity: Float32Array | undefined; + if (options.displayPointIntensity && reader.offset + pointCount * 4 <= end) { + intensity = reader.floats(pointCount, 'point cloud intensity'); + } + + return intensity === undefined + ? new PointCloudProperty(points) + : new PointCloudProperty(points, intensity); +} + +function parseCluster(reader: Reader, options: ProtocolOptions): { cluster: ClusterProperty; readableID?: number } { + 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'); + + const cluster = new ClusterProperty( + state, + centroid, + velocity, + boundingBoxCenter, + boundingBoxSize, + weight, + rotation, + lookAt + ); + if (options.version >= 3) { + return { cluster, readableID: reader.i32('cluster readable id') }; + } + return { cluster }; +} + +function formatUUID(bytes: Uint8Array): string { + const hex = Array.from(bytes, (byte) => byte.toString(16).padStart(2, '0')).join(''); + return `${hex.slice(0, 8)}-${hex.slice(8, 12)}-${hex.slice(12, 16)}-${hex.slice(16, 20)}-${hex.slice(20)}`; +} + +function parseObject(reader: Reader, options: ProtocolOptions, packetEnd: number): ObjectPacket { + let id: number | undefined; + let uuid: string | undefined; + if (options.version >= 3) { + reader.ensure(16, 'object UUID'); + uuid = formatUUID(reader.bytes.subarray(reader.offset, reader.offset + 16)); + reader.offset += 16; + } else { + id = reader.i32('object id'); + } + const propertiesCount = reader.i32('object property count'); + if (propertiesCount < 0) throw new RangeError('Malformed Augmenta object property count.'); + + let cluster: ClusterProperty | undefined; + let pointCloud: PointCloudProperty | undefined; + + for (let i = 0; i < propertiesCount; i++) { + const propertyStart = reader.offset; + const propertySize = reader.i32('object property size'); + const propertyType = reader.i32('object property type') as ObjectPropertyType; + const propertyEnd = propertyStart + propertySize; + if (propertySize < 8 || propertyEnd > packetEnd) { + throw new RangeError('Malformed Augmenta object property size.'); + } + + if (propertyType === ObjectPropertyType.Points) { + pointCloud = parsePointCloud(reader, options, propertyEnd); + } else if (propertyType === ObjectPropertyType.Cluster) { + const parsedCluster = parseCluster(reader, options); + cluster = parsedCluster.cluster; + if (parsedCluster.readableID !== undefined) id = parsedCluster.readableID; + } + + // Property size is authoritative. This also keeps newer/unknown properties forward-compatible. + reader.seek(propertyEnd, 'object property'); + } + + return uuid === undefined + ? new ObjectPacket(id, cluster, pointCloud) + : new ObjectPacket(id, cluster, pointCloud, uuid); +} + +function parseZoneEvent(reader: Reader, options: ProtocolOptions, packetEnd: number): ZoneEventPacket { + const addressSize = reader.i32('zone address size'); + if (addressSize < 0) throw new RangeError('Malformed Augmenta zone address size.'); + const address = reader.string(addressSize, 'zone address'); + const enters = reader.u8('zone enters'); + const leaves = reader.u8('zone leaves'); + const presence = reader.i32('zone presence'); + const density = reader.f32('zone density'); + const propertiesCount = reader.i32('zone property count'); + if (propertiesCount < 0) throw new RangeError('Malformed Augmenta zone property count.'); + + const properties: ZoneEventProperty[] = []; + for (let i = 0; i < propertiesCount; i++) { + const propertyStart = reader.offset; + const propertySize = reader.i32('zone property size'); + const propertyType = reader.u8('zone property type') as ZonePropertyType; + const propertyEnd = propertyStart + propertySize; + if (propertySize < 5 || propertyEnd > packetEnd) { + throw new RangeError('Malformed Augmenta zone property size.'); + } + + if (propertyType === ZonePropertyType.Slider) { + properties.push(new ZoneEventProperty(propertyType, { value: reader.f32('zone slider') })); + } else if (propertyType === ZonePropertyType.XYPad) { + properties.push(new ZoneEventProperty(propertyType, { + x: reader.f32('zone XY pad x'), + y: reader.f32('zone XY pad y') + })); + } else if (propertyType === ZonePropertyType.PointCloud) { + properties.push(new ZoneEventProperty(propertyType, parsePointCloud(reader, options, propertyEnd))); + } + + reader.seek(propertyEnd, 'zone property'); + } + + return new ZoneEventPacket(address, enters, leaves, presence, density, properties); +} + +function parseScene(reader: Reader, options: ProtocolOptions): SceneInfoPacket { + const addressSize = reader.i32('scene address size'); + if (addressSize < 0) throw new RangeError('Malformed Augmenta scene address size.'); + 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 { + const packetStart = reader.offset; + const packetSize = reader.i32('packet size'); + const type = reader.u8('packet type') as PacketType; + const packetEnd = packetStart + packetSize; + + if (packetSize < 5 || packetEnd > reader.length) { + throw new RangeError('Malformed Augmenta packet size.'); + } + + if (type === PacketType.Bundle) { + 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); + } 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}.`); + } + + // Packet size comes from Pleiades and is authoritative. It lets clients ignore future fields safely. + reader.seek(packetEnd, 'packet'); +} + +export function parseDataBlob( + data: BinaryData, + options: ProtocolOptions, + decompressor?: Decompressor +): DataBlob { + if (options.version < 2) { + throw new Error('AugmentaClientSDK-JS currently supports binary WebSocket protocol V2 and newer.'); + } + + let bytes = asUint8Array(data); + if (options.useCompression) { + if (!decompressor) { + throw new Error( + 'The Augmenta stream uses Zstd compression. Provide a synchronous decompressor or set useCompression to false.' + ); + } + bytes = decompressor(bytes); + } + + const reader = new Reader(bytes); + const state: ParsedBlobState = { + sceneInfo: new SceneInfoPacket(), + objects: [], + zoneEvents: [] + }; + parsePacket(reader, state, options); + + return state.timestamp === undefined + ? new DataBlob(state.sceneInfo, state.objects, state.zoneEvents) + : new DataBlob(state.sceneInfo, state.objects, state.zoneEvents, state.timestamp); +} + diff --git a/src/client.ts b/src/client.ts new file mode 100644 index 0000000..101284c --- /dev/null +++ b/src/client.ts @@ -0,0 +1,125 @@ +import { ControlMessage, DataBlob } from './data.js'; +import { ProtocolOptions, type ProtocolOptionsInit } from './options.js'; +import { parseControlMessage, parseDataBlob, type BinaryData, type Decompressor } from './parser.js'; + +export interface ClientInit { + decompressor?: Decompressor | undefined; +} + +/** + * Transport-independent Augmenta client. + * + * Like the C++ and C# SDKs, this class does not own a WebSocket. Feed incoming + * text/binary messages to the parser and send getRegisterMessage() yourself, + * or use AugmentaWebSocketClient for the convenience transport. + */ +export class Client { + private initialized = false; + private applicationName = '-'; + private applicationVersion = '-'; + private pluginVersion = '-'; + private name = ''; + private options = new ProtocolOptions(); + private tags: string[] = []; + private decompressor: Decompressor | undefined; + + constructor(init: ClientInit = {}) { + this.decompressor = init.decompressor; + } + + initialize(clientName: string, options: ProtocolOptions | ProtocolOptionsInit = new ProtocolOptions()): void { + if (!clientName) throw new Error('Augmenta client name must not be empty.'); + this.name = clientName; + this.options = options instanceof ProtocolOptions ? options.clone() : new ProtocolOptions(options); + this.tags = [...this.options.tags]; + this.initialized = true; + } + + shutdown(): void { + this.initialized = false; + this.name = ''; + this.options = new ProtocolOptions(); + this.tags = []; + } + + isInitialized(): boolean { return this.initialized; } + + clearTags(): void { + this.tags = []; + } + + addTag(tag: string): void { + if (!tag) return; + this.tags.push(tag); + } + + setApplicationName(appName: string): void { this.applicationName = appName || '-'; } + setApplicationVersion(appVersion: string): void { this.applicationVersion = appVersion || '-'; } + setPluginVersion(version: string): void { this.pluginVersion = version || '-'; } + setDecompressor(decompressor?: Decompressor | undefined): void { this.decompressor = decompressor; } + + getCurrentOptions(): ProtocolOptions { + this.assertInitialized(); + const current = this.options.clone(); + current.tags = [...this.tags]; + return current; + } + + getRegisterMessage(): string { + this.assertInitialized(); + const options = this.options; + + return JSON.stringify({ + register: { + name: this.name, + 'application-name': this.applicationName, + 'application-version': this.applicationVersion, + 'plugin-version': this.pluginVersion, + options: { + version: options.version, + tags: [...this.tags], + streamClouds: options.streamClouds, + streamClusters: options.streamClusters, + streamClusterPoints: options.streamClusterPoints, + streamZonePoints: options.streamZonePoints, + downSample: options.downSample, + boxRotationMode: options.boxRotationMode, + useCompression: options.useCompression, + usePolling: options.usePolling, + axisTransform: { + axis: options.axisTransform.axis, + origin: options.axisTransform.origin, + flipX: options.axisTransform.flipX, + flipY: options.axisTransform.flipY, + flipZ: options.axisTransform.flipZ, + coordinateSpace: options.axisTransform.coordinateSpace + } + } + } + }); + } + + getPollMessage(): string { + this.assertInitialized(); + if (!this.options.usePolling) { + throw new Error('Polling is disabled in the current protocol options.'); + } + return JSON.stringify({ poll: true }); + } + + parseDataBlob(blob: BinaryData): DataBlob { + this.assertInitialized(); + return parseDataBlob(blob, this.options, this.decompressor); + } + + parseControlMessage(rawMessage: string): ControlMessage { + this.assertInitialized(); + return parseControlMessage(rawMessage); + } + + private assertInitialized(): void { + if (!this.initialized) { + throw new Error('Augmenta Client is not initialized. Call initialize() first.'); + } + } +} diff --git a/src/control.ts b/src/control.ts new file mode 100644 index 0000000..e475547 --- /dev/null +++ b/src/control.ts @@ -0,0 +1,142 @@ +import { + Container, + ContainerType, + ControlMessage, + ControlMessageStatus, + ControlMessageType, + ShapeType, + ZoneParameters, + type Vector3, + type Vector4 +} from './data.js'; + +function isRecord(value: unknown): value is Record { + return typeof value === 'object' && value !== null && !Array.isArray(value); +} + +function numberValue(value: unknown, fallback = 0): number { + return typeof value === 'number' && Number.isFinite(value) ? value : fallback; +} + +function vec3(value: unknown): Vector3 { + const a = Array.isArray(value) ? value : []; + return [numberValue(a[0]), numberValue(a[1]), numberValue(a[2])]; +} + +function vec4(value: unknown): Vector4 { + const a = Array.isArray(value) ? value : []; + return [numberValue(a[0]), numberValue(a[1]), numberValue(a[2]), numberValue(a[3])]; +} + +function parseContainer(value: unknown): Container { + if (!isRecord(value)) return new Container(); + + const name = typeof value.name === 'string' ? value.name : ''; + const address = typeof value.address === 'string' ? value.address : ''; + const position = vec3(value.position); + const rotation = vec3(value.rotation); + const color = vec4(value.color); + const children = Array.isArray(value.children) ? value.children.map(parseContainer) : []; + const rawType = typeof value.type === 'string' ? value.type : ''; + + if (rawType === 'Zone') { + const shape = isRecord(value.shape) ? value.shape : {}; + const rawShapeType = typeof shape.type === 'string' ? shape.type : ''; + let shapeType = ShapeType.Unknown; + let shapeParameters: Record = {}; + + switch (rawShapeType) { + case 'Box': + shapeType = ShapeType.Box; + shapeParameters = { size: vec3(shape.boxSize) }; + break; + case 'Cylinder': + shapeType = ShapeType.Cylinder; + shapeParameters = { radius: numberValue(shape.radius), height: numberValue(shape.height) }; + break; + case 'Sphere': + shapeType = ShapeType.Sphere; + shapeParameters = { radius: numberValue(shape.radius) }; + break; + case 'Path': shapeType = ShapeType.Path; break; + case 'Grid': shapeType = ShapeType.Grid; break; + case 'Polygon': shapeType = ShapeType.Polygon; break; + case 'Segment': shapeType = ShapeType.Segment; break; + default: break; + } + + return new Container( + ContainerType.Zone, + name, + address, + position, + rotation, + color, + new ZoneParameters(shapeType, shapeParameters), + children + ); + } + + if (rawType === 'Scene') { + return new Container( + ContainerType.Scene, + name, + address, + position, + rotation, + color, + { size: vec3(value.size) }, + children + ); + } + + const type = rawType ? ContainerType.Container : ContainerType.Unknown; + return new Container(type, name, address, position, rotation, color, {}, children); +} + +export function parseControlMessage(rawMessage: string): ControlMessage { + const parsed: unknown = JSON.parse(rawMessage); + if (!isRecord(parsed)) throw new TypeError('Augmenta control message must be a JSON object.'); + + let status = ControlMessageStatus.Unknown; + let errorMessage = ''; + if (parsed.status === 'ok') status = ControlMessageStatus.Ok; + else if (parsed.status === 'error') { + status = ControlMessageStatus.Error; + errorMessage = typeof parsed.error === 'string' ? parsed.error : ''; + } + + const serverProtocolVersion = Number.isInteger(parsed.version) ? parsed.version as number : 2; + + if (isRecord(parsed.setup)) { + return new ControlMessage( + ControlMessageType.Setup, + parseContainer(parsed.setup.world), + status, + errorMessage, + serverProtocolVersion + ); + } + + if (Array.isArray(parsed.update) && parsed.update.length > 0) { + return new ControlMessage( + ControlMessageType.Update, + parseContainer(parsed.update[0]), + status, + errorMessage, + serverProtocolVersion + ); + } + + if (isRecord(parsed.update)) { + return new ControlMessage( + ControlMessageType.Update, + parseContainer(parsed.update), + status, + errorMessage, + serverProtocolVersion + ); + } + + return new ControlMessage(ControlMessageType.Unknown, new Container(), status, errorMessage, serverProtocolVersion); +} diff --git a/src/data.ts b/src/data.ts new file mode 100644 index 0000000..4f86ab4 --- /dev/null +++ b/src/data.ts @@ -0,0 +1,288 @@ +export type Vector3 = readonly [number, number, number]; +export type Vector4 = readonly [number, number, number, number]; + +export enum ClusterState { + Entered = 0, + Updated = 1, + WillLeave = 2, + Ghost = 3 +} + +export class SceneInfoPacket { + constructor( + public readonly address = '', + /** Present on protocol V3 scene packets. */ + public readonly timestamp?: number + ) {} + getAddress(): string { return this.address; } + getTimestamp(): number | undefined { return this.timestamp; } +} + +export class ClusterProperty { + constructor( + public readonly state: ClusterState, + public readonly centroid: Vector3, + public readonly velocity: Vector3, + public readonly boundingBoxCenter: Vector3, + public readonly boundingBoxSize: Vector3, + public readonly weight: number, + public readonly boundingBoxRotation: readonly number[], + public readonly lookAt: Vector3 + ) {} + + getState(): ClusterState { return this.state; } + getCentroid(): Vector3 { return this.centroid; } + getVelocity(): Vector3 { return this.velocity; } + getBoundingBoxCenter(): Vector3 { return this.boundingBoxCenter; } + getBoundingBoxSize(): Vector3 { return this.boundingBoxSize; } + getWeight(): number { return this.weight; } + getBoundingBoxRotationEuler(): Vector3 { + if (this.boundingBoxRotation.length < 3) throw new Error('Rotation data is unavailable.'); + return [this.boundingBoxRotation[0]!, this.boundingBoxRotation[1]!, this.boundingBoxRotation[2]!]; + } + getBoundingBoxRotationQuaternions(): Vector4 { + if (this.boundingBoxRotation.length !== 4) { + throw new Error('Rotation mode is not quaternion.'); + } + return [ + this.boundingBoxRotation[0]!, + this.boundingBoxRotation[1]!, + this.boundingBoxRotation[2]!, + this.boundingBoxRotation[3]! + ]; + } + getLookAt(): Vector3 { return this.lookAt; } +} + +/** XYZ points are packed as x,y,z,x,y,z... */ +export class PointCloudProperty { + constructor( + public readonly points: Float32Array, + public readonly intensity?: Float32Array + ) {} + + getPointCount(): number { return this.points.length / 3; } + getPointsData(): Float32Array { return this.points; } + getIntensityData(): Float32Array | undefined { return this.intensity; } + + getPoint(index: number): Vector3 { + if (!Number.isInteger(index) || index < 0 || index >= this.getPointCount()) { + throw new RangeError(`Point index ${index} is out of range.`); + } + const offset = index * 3; + return [this.points[offset]!, this.points[offset + 1]!, this.points[offset + 2]!]; + } +} + +export class ObjectPacket { + constructor( + public readonly id: number | undefined, + public readonly cluster?: ClusterProperty, + public readonly pointCloud?: PointCloudProperty, + /** Protocol V3 object UUID. */ + public readonly uuid?: string + ) {} + + hasCluster(): boolean { return this.cluster !== undefined; } + hasPointCloud(): boolean { return this.pointCloud !== undefined; } + getID(): number | undefined { return this.id; } + getUUID(): string | undefined { return this.uuid; } + getCluster(): ClusterProperty { + if (!this.cluster) throw new Error('Object does not contain cluster data.'); + return this.cluster; + } + getPointCloud(): PointCloudProperty { + if (!this.pointCloud) throw new Error('Object does not contain point-cloud data.'); + return this.pointCloud; + } +} + +export enum ZonePropertyType { + Slider = 0, + XYPad = 1, + PointCloud = 2 +} + +export interface SliderProperty { readonly value: number; } +export interface XYPadProperty { readonly x: number; readonly y: number; } + +export type ZonePropertyData = SliderProperty | XYPadProperty | PointCloudProperty; + +export class ZoneEventProperty { + constructor( + public readonly type: ZonePropertyType, + public readonly data: ZonePropertyData + ) {} + + getType(): ZonePropertyType { return this.type; } + isSlider(): boolean { return this.type === ZonePropertyType.Slider; } + isXYPad(): boolean { return this.type === ZonePropertyType.XYPad; } + isPointCloud(): boolean { return this.type === ZonePropertyType.PointCloud; } + getSliderParameters(): SliderProperty { + if (!this.isSlider()) throw new Error('Zone property is not a slider.'); + return this.data as SliderProperty; + } + getXYPadParameters(): XYPadProperty { + if (!this.isXYPad()) throw new Error('Zone property is not an XY pad.'); + return this.data as XYPadProperty; + } + getPointCloudParameters(): PointCloudProperty { + if (!this.isPointCloud()) throw new Error('Zone property is not a point cloud.'); + return this.data as PointCloudProperty; + } +} + +export class ZoneEventPacket { + constructor( + public readonly emitterZoneAddress: string, + public readonly enters: number, + public readonly leaves: number, + public readonly presence: number, + public readonly density: number, + public readonly properties: readonly ZoneEventProperty[] + ) {} + + getEmitterZoneAddress(): string { return this.emitterZoneAddress; } + getEnters(): number { return this.enters; } + getLeaves(): number { return this.leaves; } + getPresence(): number { return this.presence; } + getDensity(): number { return this.density; } + getPropertiesCount(): number { return this.properties.length; } + getProperties(): readonly ZoneEventProperty[] { return this.properties; } +} + +export class DataBlob { + constructor( + public readonly sceneInfo = new SceneInfoPacket(), + public readonly objects: readonly ObjectPacket[] = [], + public readonly zoneEvents: readonly ZoneEventPacket[] = [], + /** Present on protocol V3 bundle packets. Value is the server millisecond counter. */ + public readonly timestamp?: number + ) {} + + getObjectCount(): number { return this.objects.length; } + getObjects(): readonly ObjectPacket[] { return this.objects; } + getZoneEventCount(): number { return this.zoneEvents.length; } + getZoneEvents(): readonly ZoneEventPacket[] { return this.zoneEvents; } + /** Backward-compatible alias used by the C++ SDK. */ + getZones(): readonly ZoneEventPacket[] { return this.zoneEvents; } + getSceneInfo(): SceneInfoPacket { return this.sceneInfo; } +} + +export enum ControlMessageStatus { + Unknown = 'unknown', + Ok = 'ok', + Error = 'error' +} + +export enum ControlMessageType { + Unknown = 'unknown', + Update = 'update', + Setup = 'setup' +} + +export enum ContainerType { + Unknown = 'Unknown', + Container = 'Container', + Zone = 'Zone', + Scene = 'Scene' +} + +export enum ShapeType { + Unknown = 'Unknown', + Box = 'Box', + Cylinder = 'Cylinder', + Sphere = 'Sphere', + Path = 'Path', + Grid = 'Grid', + Polygon = 'Polygon', + Segment = 'Segment' +} + +export interface BoxShapeParameters { readonly size: Vector3; } +export interface CylinderShapeParameters { readonly radius: number; readonly height: number; } +export interface SphereShapeParameters { readonly radius: number; } +export interface EmptyShapeParameters {} +export type ShapeParameters = BoxShapeParameters | CylinderShapeParameters | SphereShapeParameters | EmptyShapeParameters; + +export class ZoneParameters { + constructor( + public readonly shapeType: ShapeType, + public readonly shapeParameters: ShapeParameters + ) {} + + getShapeType(): ShapeType { return this.shapeType; } + isBox(): boolean { return this.shapeType === ShapeType.Box; } + isCylinder(): boolean { return this.shapeType === ShapeType.Cylinder; } + isSphere(): boolean { return this.shapeType === ShapeType.Sphere; } + isPath(): boolean { return this.shapeType === ShapeType.Path; } + isGrid(): boolean { return this.shapeType === ShapeType.Grid; } + isPolygon(): boolean { return this.shapeType === ShapeType.Polygon; } + isSegment(): boolean { return this.shapeType === ShapeType.Segment; } + getBoxShapeParameters(): BoxShapeParameters { + if (!this.isBox()) throw new Error('Zone is not a box.'); + return this.shapeParameters as BoxShapeParameters; + } + getCylinderShapeParameters(): CylinderShapeParameters { + if (!this.isCylinder()) throw new Error('Zone is not a cylinder.'); + return this.shapeParameters as CylinderShapeParameters; + } + getSphereShapeParameters(): SphereShapeParameters { + if (!this.isSphere()) throw new Error('Zone is not a sphere.'); + return this.shapeParameters as SphereShapeParameters; + } +} + +export interface SceneParameters { readonly size: Vector3; } +export interface ContainerParameters {} +export type ContainerSpecificParameters = ZoneParameters | SceneParameters | ContainerParameters | undefined; + +export class Container { + constructor( + public readonly type = ContainerType.Unknown, + public readonly name = '', + public readonly address = '', + public readonly position: Vector3 = [0, 0, 0], + public readonly rotation: Vector3 = [0, 0, 0], + public readonly color: Vector4 = [0, 0, 0, 0], + public readonly parameters?: ContainerSpecificParameters, + public readonly children: readonly Container[] = [] + ) {} + + getType(): ContainerType { return this.type; } + isZone(): boolean { return this.type === ContainerType.Zone; } + isScene(): boolean { return this.type === ContainerType.Scene; } + isContainer(): boolean { return this.type === ContainerType.Container; } + hasChildren(): boolean { return this.children.length > 0; } + getChildren(): readonly Container[] { return this.children; } + getName(): string { return this.name; } + getAddress(): string { return this.address; } + getPosition(): Vector3 { return this.position; } + getRotation(): Vector3 { return this.rotation; } + getColor(): Vector4 { return this.color; } + getSceneParameters(): SceneParameters { + if (!this.isScene()) throw new Error('Container is not a scene.'); + return this.parameters as SceneParameters; + } + getZoneParameters(): ZoneParameters { + if (!this.isZone()) throw new Error('Container is not a zone.'); + return this.parameters as ZoneParameters; + } +} + +export class ControlMessage { + constructor( + public readonly type = ControlMessageType.Unknown, + public readonly rootObject = new Container(), + public readonly status = ControlMessageStatus.Unknown, + public readonly errorMessage = '', + public readonly serverProtocolVersion = 2 + ) {} + + isUpdate(): boolean { return this.type === ControlMessageType.Update; } + isSetup(): boolean { return this.type === ControlMessageType.Setup; } + getStatus(): ControlMessageStatus { return this.status; } + getErrorMessage(): string { return this.errorMessage; } + getServerProtocolVersion(): number { return this.serverProtocolVersion; } + getRootObject(): Container { return this.rootObject; } +} diff --git a/src/index.ts b/src/index.ts new file mode 100644 index 0000000..f5dd4e8 --- /dev/null +++ b/src/index.ts @@ -0,0 +1,5 @@ +export * from './options.js'; +export * from './data.js'; +export * from './parser.js'; +export * from './client.js'; +export * from './websocket.js'; diff --git a/src/options.ts b/src/options.ts new file mode 100644 index 0000000..c42f9a9 --- /dev/null +++ b/src/options.ts @@ -0,0 +1,109 @@ +export enum RotationMode { + Radians = 'radians', + Degrees = 'degrees', + Quaternions = 'quaternions' +} + +export enum AxisMode { + ZUpRightHanded = 'z_up_right', + ZUpLeftHanded = 'z_up_left', + YUpRightHanded = 'y_up_right', + YUpLeftHanded = 'y_up_left' +} + +export enum OriginMode { + BottomLeft = 'bottom_left', + BottomRight = 'bottom_right', + TopLeft = 'top_left', + TopRight = 'top_right' +} + +export enum CoordinateSpace { + Absolute = 'absolute', + Relative = 'relative', + Normalized = 'normalized' +} + +export interface AxisTransformInit { + axis?: AxisMode; + origin?: OriginMode; + flipX?: boolean; + flipY?: boolean; + flipZ?: boolean; + coordinateSpace?: CoordinateSpace; +} + +export class AxisTransform { + axis: AxisMode = AxisMode.ZUpRightHanded; + origin: OriginMode = OriginMode.BottomLeft; + flipX = false; + flipY = false; + flipZ = false; + coordinateSpace: CoordinateSpace = CoordinateSpace.Absolute; + + constructor(init: AxisTransformInit = {}) { + Object.assign(this, init); + } + + clone(): AxisTransform { + return new AxisTransform(this); + } +} + +export interface ProtocolOptionsInit { + version?: number; + tags?: string[]; + downSample?: number; + streamClouds?: boolean; + streamClusters?: boolean; + streamClusterPoints?: boolean; + streamZonePoints?: boolean; + boxRotationMode?: RotationMode; + axisTransform?: AxisTransformInit | AxisTransform; + useCompression?: boolean; + usePolling?: boolean; + displayPointIntensity?: boolean; +} + +/** + * Options negotiated with the Augmenta WebSocket Output. + * + * Defaults intentionally follow the C++ client SDK. Web clients that do not + * provide a Zstd decompressor should set `useCompression` to false. + */ +export class ProtocolOptions { + version = 2; + tags: string[] = []; + downSample = 1; + streamClouds = true; + streamClusters = true; + streamClusterPoints = true; + streamZonePoints = false; + boxRotationMode: RotationMode = RotationMode.Quaternions; + axisTransform = new AxisTransform(); + useCompression = true; + usePolling = false; + displayPointIntensity = false; + + constructor(init: ProtocolOptionsInit = {}) { + const { axisTransform, tags, ...rest } = init; + Object.assign(this, rest); + 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.downSample) || this.downSample < 1) { + throw new RangeError('downSample must be an integer greater than or equal to 1.'); + } + } + + clone(): ProtocolOptions { + return new ProtocolOptions({ + ...this, + tags: [...this.tags], + axisTransform: this.axisTransform.clone() + }); + } +} diff --git a/src/parser.ts b/src/parser.ts new file mode 100644 index 0000000..1ca8e52 --- /dev/null +++ b/src/parser.ts @@ -0,0 +1,3 @@ +export { parseDataBlob } from './binary.js'; +export type { BinaryData, Decompressor } from './binary.js'; +export { parseControlMessage } from './control.js'; diff --git a/src/websocket.ts b/src/websocket.ts new file mode 100644 index 0000000..af4902e --- /dev/null +++ b/src/websocket.ts @@ -0,0 +1,148 @@ +import { Client, type ClientInit } from './client.js'; +import { ControlMessage, DataBlob } from './data.js'; +import { ProtocolOptions, type ProtocolOptionsInit } from './options.js'; + +export interface WebSocketMessageEventLike { readonly data: unknown; } + +export interface WebSocketLike { + readonly readyState: number; + binaryType?: string; + send(data: string | ArrayBufferLike | ArrayBufferView | Blob): void; + close(code?: number, reason?: string): void; + addEventListener(type: 'open', listener: (event: unknown) => void): void; + addEventListener(type: 'message', listener: (event: WebSocketMessageEventLike) => void): void; + addEventListener(type: 'close', listener: (event: unknown) => void): void; + addEventListener(type: 'error', listener: (event: unknown) => void): void; +} + +export type WebSocketFactory = (url: string) => WebSocketLike; + +export interface AugmentaWebSocketClientInit extends ClientInit { + clientName?: string; + options?: ProtocolOptions | ProtocolOptionsInit; + applicationName?: string; + applicationVersion?: string; + pluginVersion?: string; + webSocketFactory?: WebSocketFactory; +} + +interface EventMap { + open: unknown; + close: unknown; + error: unknown; + controlMessage: ControlMessage; + setup: ControlMessage; + update: ControlMessage; + data: DataBlob; +} + +export type AugmentaWebSocketEvent = keyof EventMap; +export type AugmentaWebSocketListener = (value: EventMap[K]) => void; + +function defaultWebSocketFactory(url: string): WebSocketLike { + const WebSocketConstructor = globalThis.WebSocket; + if (typeof WebSocketConstructor !== 'function') { + throw new Error( + 'No global WebSocket implementation is available. Provide webSocketFactory (for example from your Node.js WebSocket library).' + ); + } + return new WebSocketConstructor(url) as unknown as WebSocketLike; +} + +function isArrayBufferView(value: unknown): value is ArrayBufferView { + return ArrayBuffer.isView(value); +} + +/** Convenience WebSocket transport for browsers and runtimes exposing WebSocket. */ +export class AugmentaWebSocketClient { + readonly client: Client; + private readonly url: string; + private readonly factory: WebSocketFactory; + private socket: WebSocketLike | undefined; + private listeners: { [K in AugmentaWebSocketEvent]?: Set<(value: EventMap[K]) => void> } = {}; + + constructor(url: string, init: AugmentaWebSocketClientInit = {}) { + if (!url) throw new Error('WebSocket URL must not be empty.'); + this.url = url; + this.factory = init.webSocketFactory ?? defaultWebSocketFactory; + this.client = new Client({ decompressor: init.decompressor }); + + const options = init.options instanceof ProtocolOptions + ? init.options + : new ProtocolOptions({ useCompression: false, ...init.options }); + this.client.initialize(init.clientName ?? 'Augmenta JS Client', options); + if (init.applicationName) this.client.setApplicationName(init.applicationName); + if (init.applicationVersion) this.client.setApplicationVersion(init.applicationVersion); + if (init.pluginVersion) this.client.setPluginVersion(init.pluginVersion); + } + + on(event: K, listener: AugmentaWebSocketListener): () => void { + let set = this.listeners[event] as Set> | undefined; + if (!set) { + set = new Set(); + (this.listeners as Record>)[event] = set as Set; + } + set.add(listener); + return () => set?.delete(listener); + } + + connect(): void { + if (this.socket) throw new Error('WebSocket client is already connected or connecting.'); + const socket = this.factory(this.url); + this.socket = socket; + if ('binaryType' in socket) socket.binaryType = 'arraybuffer'; + + socket.addEventListener('open', (event) => { + socket.send(this.client.getRegisterMessage()); + this.emit('open', event); + }); + + socket.addEventListener('message', (event) => { + void this.handleMessage(event.data).catch((error: unknown) => this.emit('error', error)); + }); + + socket.addEventListener('close', (event) => { + this.socket = undefined; + this.emit('close', event); + }); + + socket.addEventListener('error', (event) => this.emit('error', event)); + } + + disconnect(code?: number, reason?: string): void { + const socket = this.socket; + this.socket = undefined; + socket?.close(code, reason); + } + + poll(): void { + if (!this.socket) throw new Error('WebSocket client is not connected.'); + this.socket.send(this.client.getPollMessage()); + } + + getSocket(): WebSocketLike | undefined { return this.socket; } + + private async handleMessage(data: unknown): Promise { + if (typeof data === 'string') { + const message = this.client.parseControlMessage(data); + this.emit('controlMessage', message); + if (message.isSetup()) this.emit('setup', message); + else if (message.isUpdate()) this.emit('update', message); + return; + } + + let binary: ArrayBuffer | ArrayBufferView; + if (data instanceof ArrayBuffer) binary = data; + else if (isArrayBufferView(data)) binary = data; + else if (typeof Blob !== 'undefined' && data instanceof Blob) binary = await data.arrayBuffer(); + else throw new TypeError('Unsupported WebSocket message type.'); + + this.emit('data', this.client.parseDataBlob(binary)); + } + + private emit(event: K, value: EventMap[K]): void { + const set = this.listeners[event] as Set> | undefined; + if (!set) return; + for (const listener of set) listener(value); + } +} diff --git a/tests/sdk.test.mjs b/tests/sdk.test.mjs new file mode 100644 index 0000000..8ae67aa --- /dev/null +++ b/tests/sdk.test.mjs @@ -0,0 +1,285 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import { createRequire } from 'node:module'; +import { + AugmentaWebSocketClient, + AxisMode, + Client, + ClusterState, + ContainerType, + CoordinateSpace, + OriginMode, + ProtocolOptions, + RotationMode, + ShapeType, + ZonePropertyType +} from '../dist/esm/index.js'; + +const encoder = new TextEncoder(); +const concat = (...parts) => { + const length = parts.reduce((sum, part) => sum + part.length, 0); + const output = new Uint8Array(length); + let offset = 0; + for (const part of parts) { output.set(part, offset); offset += part.length; } + return output; +}; +const i32 = (value) => { const b = new Uint8Array(4); new DataView(b.buffer).setInt32(0, value, true); return b; }; +const f32 = (value) => { const b = new Uint8Array(4); new DataView(b.buffer).setFloat32(0, value, true); return b; }; +const u8 = (value) => Uint8Array.of(value); +const str = (value) => encoder.encode(value); +const packet = (type, payload) => concat(i32(5 + payload.length), u8(type), payload); + +function scenePacket(address = '/world/scene', timestamp) { + const addressBytes = str(address); + const payload = timestamp === undefined + ? concat(i32(addressBytes.length), addressBytes) + : concat(i32(addressBytes.length), addressBytes, i32(timestamp)); + return packet(2, payload); +} + +function objectPacket() { + const clusterPayload = concat( + i32(ClusterState.Updated), + f32(1), f32(2), f32(3), + f32(0.1), f32(0.2), f32(0.3), + f32(4), f32(5), f32(6), + f32(0.5), f32(1.5), f32(2.5), + f32(0.9), + f32(0), f32(0), f32(0), f32(1), + f32(1), f32(0), f32(0) + ); + const clusterProperty = concat(i32(8 + clusterPayload.length), i32(1), clusterPayload); + const unknownProperty = concat(i32(12), i32(99), i32(123456)); + return packet(0, concat(i32(42), i32(2), clusterProperty, unknownProperty)); +} + +function zonePacket() { + const address = str('/world/scene/zone'); + const slider = concat(i32(9), u8(ZonePropertyType.Slider), f32(0.75)); + return packet(1, concat( + i32(address.length), address, + u8(1), u8(0), i32(2), f32(0.4), + i32(1), slider + )); +} + +function bundleV2() { + const packets = [scenePacket(), objectPacket(), zonePacket()]; + return packet(255, concat(i32(packets.length), ...packets)); +} + +function v3ObjectPacket() { + const uuid = Uint8Array.from([0x00,0x11,0x22,0x33,0x44,0x55,0x66,0x77,0x88,0x99,0xaa,0xbb,0xcc,0xdd,0xee,0xff]); + const clusterPayload = concat( + i32(ClusterState.Entered), + f32(1), f32(2), f32(3), + f32(0), f32(0), f32(0), + f32(1), f32(1), f32(1), + f32(2), f32(2), f32(2), + f32(1), + f32(0), f32(0), f32(0), f32(1), + f32(0), f32(1), f32(0), + i32(77) + ); + const clusterProperty = concat(i32(8 + clusterPayload.length), i32(1), clusterPayload); + return packet(0, concat(uuid, i32(1), clusterProperty)); +} + +function bundleV3(timestamp = 123456, sceneTimestamp = 222333) { + const packets = [scenePacket('/v3/scene', sceneTimestamp), v3ObjectPacket()]; + return packet(255, concat(i32(timestamp), i32(packets.length), ...packets)); +} + +test('Client builds the same register-message contract as the C++ SDK', () => { + const client = new Client(); + client.setApplicationName('Example app'); + client.setApplicationVersion('1.2.3'); + client.setPluginVersion('4.5.6'); + client.initialize('Browser client', new ProtocolOptions({ + tags: ['public'], + streamClouds: false, + streamClusterPoints: false, + streamZonePoints: true, + useCompression: false, + axisTransform: { + axis: AxisMode.YUpLeftHanded, + origin: OriginMode.TopLeft, + coordinateSpace: CoordinateSpace.Normalized, + flipX: true + } + })); + + assert.deepEqual(JSON.parse(client.getRegisterMessage()), { + register: { + name: 'Browser client', + 'application-name': 'Example app', + 'application-version': '1.2.3', + 'plugin-version': '4.5.6', + options: { + version: 2, + tags: ['public'], + streamClouds: false, + streamClusters: true, + streamClusterPoints: false, + streamZonePoints: true, + downSample: 1, + boxRotationMode: RotationMode.Quaternions, + useCompression: false, + usePolling: false, + axisTransform: { + axis: AxisMode.YUpLeftHanded, + origin: OriginMode.TopLeft, + flipX: true, + flipY: false, + flipZ: false, + coordinateSpace: CoordinateSpace.Normalized + } + } + } + }); +}); + +test('Control setup messages expose scene and zone hierarchy', () => { + const client = new Client(); + client.initialize('test', { useCompression: false }); + const message = client.parseControlMessage(JSON.stringify({ + status: 'ok', + version: 2, + setup: { + world: { + name: 'World', + type: 'Container', + children: [{ + name: 'Scene', + type: 'Scene', + address: '/world/scene', + size: [10, 3, 8], + children: [{ + name: 'Zone', + type: 'Zone', + address: '/world/scene/zone', + position: [1, 2, 3], + rotation: [0, 45, 0], + shape: { type: 'Box', boxSize: [2, 1, 4] } + }] + }] + } + } + })); + + assert.equal(message.isSetup(), true); + assert.equal(message.getServerProtocolVersion(), 2); + const scene = message.getRootObject().getChildren()[0]; + assert.equal(scene.getType(), ContainerType.Scene); + assert.deepEqual(scene.getSceneParameters().size, [10, 3, 8]); + const zone = scene.getChildren()[0]; + assert.equal(zone.getType(), ContainerType.Zone); + assert.equal(zone.getZoneParameters().getShapeType(), ShapeType.Box); + assert.deepEqual(zone.getZoneParameters().getBoxShapeParameters().size, [2, 1, 4]); +}); + +test('V2 binary bundle parses scene, cluster and zone event', () => { + const client = new Client(); + client.initialize('test', { useCompression: false, boxRotationMode: RotationMode.Quaternions }); + const data = client.parseDataBlob(bundleV2()); + + assert.equal(data.getSceneInfo().getAddress(), '/world/scene'); + assert.equal(data.getObjectCount(), 1); + const object = data.getObjects()[0]; + assert.equal(object.getID(), 42); + assert.equal(object.getCluster().getState(), ClusterState.Updated); + assert.deepEqual(object.getCluster().getCentroid(), [1, 2, 3]); + assert.deepEqual(object.getCluster().getBoundingBoxRotationQuaternions(), [0, 0, 0, 1]); + + assert.equal(data.getZoneEventCount(), 1); + const zone = data.getZoneEvents()[0]; + assert.equal(zone.getEmitterZoneAddress(), '/world/scene/zone'); + assert.equal(zone.getEnters(), 1); + assert.equal(zone.getPresence(), 2); + assert.ok(Math.abs(zone.getDensity() - 0.4) < 1e-6); + assert.ok(Math.abs(zone.getProperties()[0].getSliderParameters().value - 0.75) < 1e-6); +}); + +test('V3 bundle follows the current Pleiades timestamp header', () => { + const client = new Client(); + client.initialize('test', { version: 3, useCompression: false }); + const data = client.parseDataBlob(bundleV3(7654321, 1234)); + assert.equal(data.timestamp, 7654321); + assert.equal(data.getSceneInfo().getAddress(), '/v3/scene'); + assert.equal(data.getSceneInfo().getTimestamp(), 1234); + assert.equal(data.getObjects()[0].getID(), 77); + assert.equal(data.getObjects()[0].getUUID(), '00112233-4455-6677-8899-aabbccddeeff'); +}); + +test('Compression is transport-agnostic through an injected synchronous decompressor', () => { + let called = false; + const client = new Client({ + decompressor: (data) => { called = true; return data; } + }); + client.initialize('test', { useCompression: true }); + const data = client.parseDataBlob(bundleV2()); + assert.equal(called, true); + assert.equal(data.getObjectCount(), 1); +}); + +test('WebSocket convenience client registers and emits parsed control messages', async () => { + class FakeSocket { + readyState = 1; + binaryType = ''; + sent = []; + listeners = new Map(); + send(data) { this.sent.push(data); } + close() { this.emit('close', {}); } + addEventListener(type, listener) { + const list = this.listeners.get(type) ?? []; + list.push(listener); + this.listeners.set(type, list); + } + emit(type, event) { for (const listener of this.listeners.get(type) ?? []) listener(event); } + } + + const socket = new FakeSocket(); + const augmenta = new AugmentaWebSocketClient('ws://localhost:8080', { + clientName: 'web-test', + options: { useCompression: false }, + webSocketFactory: () => socket + }); + + const setupPromise = new Promise((resolve) => augmenta.on('setup', resolve)); + augmenta.connect(); + socket.emit('open', {}); + assert.equal(JSON.parse(socket.sent[0]).register.name, 'web-test'); + + socket.emit('message', { data: JSON.stringify({ status: 'ok', version: 2, setup: { world: { name: 'World' } } }) }); + const setup = await setupPromise; + assert.equal(setup.isSetup(), true); +}); + +test('CommonJS build can be required', () => { + const require = createRequire(import.meta.url); + const sdk = require('../dist/cjs/index.js'); + const client = new sdk.Client(); + client.initialize('cjs-test', { useCompression: false }); + assert.equal(typeof client.getRegisterMessage(), 'string'); +}); + +test('WebSocket convenience defaults to uncompressed frames for zero-config web use', () => { + class FakeSocket { + readyState = 1; + sent = []; + listeners = new Map(); + send(data) { this.sent.push(data); } + close() {} + addEventListener(type, listener) { + const list = this.listeners.get(type) ?? []; + list.push(listener); + this.listeners.set(type, list); + } + emit(type, event) { for (const listener of this.listeners.get(type) ?? []) listener(event); } + } + const socket = new FakeSocket(); + const augmenta = new AugmentaWebSocketClient('ws://localhost', { webSocketFactory: () => socket }); + augmenta.connect(); + socket.emit('open', {}); + assert.equal(JSON.parse(socket.sent[0]).register.options.useCompression, false); +}); diff --git a/tsconfig.base.json b/tsconfig.base.json new file mode 100644 index 0000000..47804f4 --- /dev/null +++ b/tsconfig.base.json @@ -0,0 +1,15 @@ +{ + "compilerOptions": { + "target": "ES2020", + "moduleResolution": "Node", + "strict": true, + "noUncheckedIndexedAccess": true, + "exactOptionalPropertyTypes": true, + "forceConsistentCasingInFileNames": true, + "skipLibCheck": true, + "inlineSources": true, + "lib": ["ES2020", "DOM"], + "rootDir": "src" + }, + "include": ["src/**/*.ts"] +} diff --git a/tsconfig.cjs.json b/tsconfig.cjs.json new file mode 100644 index 0000000..09edb7e --- /dev/null +++ b/tsconfig.cjs.json @@ -0,0 +1,9 @@ +{ + "extends": "./tsconfig.base.json", + "compilerOptions": { + "module": "CommonJS", + "outDir": "dist/cjs", + "declaration": false, + "sourceMap": true + } +} diff --git a/tsconfig.esm.json b/tsconfig.esm.json new file mode 100644 index 0000000..4aab71d --- /dev/null +++ b/tsconfig.esm.json @@ -0,0 +1,10 @@ +{ + "extends": "./tsconfig.base.json", + "compilerOptions": { + "module": "ES2020", + "outDir": "dist/esm", + "declaration": true, + "declarationDir": "dist/types", + "sourceMap": true + } +} From 95314d7305af5198122db0af8f87f2540f2e168d Mon Sep 17 00:00:00 2001 From: David-Alexandre Chanel Date: Tue, 29 Sep 2026 12:05:43 +0200 Subject: [PATCH 2/2] docs: fix API overview wording --- docs/API.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/docs/API.md b/docs/API.md index b52aec1..5c12cad 100644 --- a/docs/API.md +++ b/docs/API.md @@ -7,12 +7,12 @@ Negotiates what an Augmenta WebSocket Output sends to the client. Important options include: - `version` — protocol version, V2 by default; -- `tags` — optional server-side Augmenta tagss; +- `tags` — optional server-side Augmenta tags; - `downSample` — point-cloud downsampling factor; - `streamClouds` — raw scene point clouds; - `streamClusters` — tracked Augmenta objects; - `streamClusterPoints` — points belonging to tracked clusters; -- `streamZonePoints` — Optional point clouds attached to zone events; +- `streamZonePoints` — optional point clouds attached to zone events; - `boxRotationMode` — radians, degrees or quaternions; - `axisTransform` — coordinate system transformation requested from the server; - `useCompression` — request Zstd-compressed binary frames;