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..5c12cad --- /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 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; +- `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 @@ + + +
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