diff --git a/.editorconfig b/.editorconfig
new file mode 100644
index 0000000..1014ba7
--- /dev/null
+++ b/.editorconfig
@@ -0,0 +1,12 @@
+root = true
+
+[*]
+charset = utf-8
+end_of_line = lf
+insert_final_newline = true
+indent_style = space
+indent_size = 2
+trim_trailing_whitespace = true
+
+[*.md]
+trim_trailing_whitespace = false
diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml
new file mode 100644
index 0000000..e9f6052
--- /dev/null
+++ b/.github/workflows/ci.yml
@@ -0,0 +1,48 @@
+name: Test
+
+on:
+ pull_request:
+
+permissions:
+ contents: read
+
+jobs:
+ test:
+ runs-on: ubuntu-latest
+ steps:
+ - name: Checkout
+ uses: actions/checkout@v7
+ with:
+ submodules: recursive
+
+ - name: Setup Node.js
+ uses: actions/setup-node@v7
+ with:
+ node-version: 22
+ cache: npm
+ cache-dependency-path: vendor/AugmentaClientSDK-JS/package-lock.json
+
+ - name: Build and test Augmenta JS SDK
+ shell: bash
+ run: |
+ npm ci --prefix vendor/AugmentaClientSDK-JS --no-audit --no-fund
+ npm test --prefix vendor/AugmentaClientSDK-JS
+
+
+ - name: Validate example JavaScript
+ shell: bash
+ run: |
+ for file in src/*.js tests/*.mjs scripts/*.mjs; do
+ node --input-type=module --check < "$file"
+ done
+
+ - name: Test example
+ shell: bash
+ run: |
+ mkdir -p node_modules
+ ln -s ../vendor/AugmentaClientSDK-JS node_modules/augmenta-client-sdk
+ node --test tests/*.test.mjs
+
+ - name: Assemble Pages artifact
+ shell: bash
+ run: node scripts/assemble-site.mjs
diff --git a/.github/workflows/pages.yml b/.github/workflows/pages.yml
new file mode 100644
index 0000000..72f6618
--- /dev/null
+++ b/.github/workflows/pages.yml
@@ -0,0 +1,71 @@
+name: Deploy GitHub Pages
+
+on:
+ push:
+ branches: [main]
+ workflow_dispatch:
+
+permissions:
+ contents: read
+ pages: write
+ id-token: write
+
+concurrency:
+ group: pages
+ cancel-in-progress: true
+
+jobs:
+ deploy:
+ environment:
+ name: github-pages
+ url: ${{ steps.deployment.outputs.page_url }}
+ runs-on: ubuntu-latest
+ steps:
+ - name: Checkout
+ uses: actions/checkout@v7
+ with:
+ submodules: recursive
+
+ - name: Setup Node.js
+ uses: actions/setup-node@v7
+ with:
+ node-version: 22
+ cache: npm
+ cache-dependency-path: vendor/AugmentaClientSDK-JS/package-lock.json
+
+ - name: Build and test Augmenta JS SDK
+ shell: bash
+ run: |
+ npm ci --prefix vendor/AugmentaClientSDK-JS --no-audit --no-fund
+ npm test --prefix vendor/AugmentaClientSDK-JS
+
+
+ - name: Validate example JavaScript
+ shell: bash
+ run: |
+ for file in src/*.js tests/*.mjs scripts/*.mjs; do
+ node --input-type=module --check < "$file"
+ done
+
+ - name: Test example
+ shell: bash
+ run: |
+ mkdir -p node_modules
+ ln -s ../vendor/AugmentaClientSDK-JS node_modules/augmenta-client-sdk
+ node --test tests/*.test.mjs
+
+ - name: Assemble static site
+ shell: bash
+ run: node scripts/assemble-site.mjs
+
+ - name: Configure Pages
+ uses: actions/configure-pages@v6
+
+ - name: Upload Pages artifact
+ uses: actions/upload-pages-artifact@v5
+ with:
+ path: _site
+
+ - name: Deploy to GitHub Pages
+ id: deployment
+ uses: actions/deploy-pages@v5
diff --git a/.gitignore b/.gitignore
new file mode 100644
index 0000000..ad42ce8
--- /dev/null
+++ b/.gitignore
@@ -0,0 +1,14 @@
+# OS / editors
+.DS_Store
+Thumbs.db
+.vscode/
+.idea/
+
+# Generated output
+_site/
+.pages-runtime/
+node_modules/
+
+# Local logs
+*.log
+npm-debug.log*
diff --git a/.gitmodules b/.gitmodules
new file mode 100644
index 0000000..d4413f9
--- /dev/null
+++ b/.gitmodules
@@ -0,0 +1,3 @@
+[submodule "vendor/AugmentaClientSDK-JS"]
+ path = vendor/AugmentaClientSDK-JS
+ url = https://github.com/Augmenta-tech/AugmentaClientSDK-JS.git
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 0e4102c..d7a67f0 100644
--- a/README.md
+++ b/README.md
@@ -1,2 +1,17 @@
-# WebDemo
-ThreeJS Web Demos
+# Augmenta Web Demo
+
+Interactive browser demo for Augmenta real-time spatial tracking.
+
+The app connects directly to the Augmenta WebSocket output, renders a smooth 2D front-view data visualization, exposes live debug data, and includes a local synthetic stream for demos without an Augmenta server.
+
+## Visual direction
+
+The default renderer combines colored point-cloud silhouettes, tracked bounds, motion cues, trails, a technical data grid, and subtle glow. Rendering options are available in the **Visuals** section.
+
+## Run locally
+
+Serve the repository from a local HTTP server. The app is static and is also designed for GitHub Pages deployment.
+
+## Data
+
+The Augmenta JavaScript SDK is included as a git submodule under `vendor/AugmentaClientSDK-JS`.
diff --git a/THIRD_PARTY_LICENSES b/THIRD_PARTY_LICENSES
new file mode 100644
index 0000000..5b2ac62
--- /dev/null
+++ b/THIRD_PARTY_LICENSES
@@ -0,0 +1,68 @@
+THIRD-PARTY SOFTWARE
+====================
+
+1. three.js 0.186.1
+------------------
+
+Project: https://threejs.org/
+Source: https://github.com/mrdoob/three.js
+License: MIT
+
+The MIT License
+
+Copyright © 2010-2026 three.js authors
+
+Permission is hereby granted, free of charge, to any person obtaining a copy
+of this software and associated documentation files (the "Software"), to deal
+in the Software without restriction, including without limitation the rights
+to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
+copies of the Software, and to permit persons to whom the Software is
+furnished to do so, subject to the following conditions:
+
+The above copyright notice and this permission notice shall be included in
+all copies or substantial portions of the Software.
+
+THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
+THE SOFTWARE.
+
+
+2. Augmenta Client JavaScript SDK
+---------------------------------
+
+The repository contains the Augmenta Client JavaScript SDK as a Git submodule
+under vendor/AugmentaClientSDK-JS. The SDK is governed by the license included
+inside that submodule and is not relicensed by this notice.
+
+
+3. qrcode-generator 2.0.4
+-------------------------
+
+Project: https://github.com/kazuhikoarase/qrcode-generator
+License: MIT
+
+MIT License
+
+Copyright (c) 2009 Kazuhiko Arase
+
+Permission is hereby granted, free of charge, to any person obtaining a copy
+of this software and associated documentation files (the "Software"), to deal
+in the Software without restriction, including without limitation the rights
+to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
+copies of the Software, and to permit persons to whom the Software is
+furnished to do so, subject to the following conditions:
+
+The above copyright notice and this permission notice shall be included in all
+copies or substantial portions of the Software.
+
+THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
+SOFTWARE.
diff --git a/augmenta-favicon.png b/augmenta-favicon.png
new file mode 100644
index 0000000..1fffef2
Binary files /dev/null and b/augmenta-favicon.png differ
diff --git a/index.html b/index.html
new file mode 100644
index 0000000..deb8eea
--- /dev/null
+++ b/index.html
@@ -0,0 +1,250 @@
+
+
+
+
+
+
+
+
+
+
+ Augmenta Web Demo
+
+
+
+
+
+
+
+
+
+
+
+
+
+
Augmenta Web Demo
+
Real-time spatial data visualizer
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/scripts/assemble-site.mjs b/scripts/assemble-site.mjs
new file mode 100644
index 0000000..f53c053
--- /dev/null
+++ b/scripts/assemble-site.mjs
@@ -0,0 +1,78 @@
+import {
+ copyFileSync,
+ cpSync,
+ existsSync,
+ mkdirSync,
+ readFileSync,
+ readdirSync,
+ rmSync,
+ writeFileSync
+} from 'node:fs';
+import { join } from 'node:path';
+
+const root = process.cwd();
+const out = join(root, '_site');
+const rev = (process.env.GITHUB_SHA || 'local').slice(0, 12);
+const sdkOut = join(out, 'vendor', 'AugmentaClientSDK-JS', rev, 'dist', 'esm');
+
+function requirePath(path, label) {
+ if (!existsSync(path)) {
+ throw new Error(`Missing ${label}: ${path}`);
+ }
+}
+
+requirePath(join(root, 'vendor', 'AugmentaClientSDK-JS', 'dist', 'esm'), 'built Augmenta SDK');
+requirePath(join(root, 'vendor', 'qrcode-generator', 'qrcode.js'), 'vendored QR generator');
+
+rmSync(out, { recursive: true, force: true });
+mkdirSync(join(out, 'src'), { recursive: true });
+mkdirSync(sdkOut, { recursive: true });
+mkdirSync(join(out, 'vendor', 'qrcode-generator'), { recursive: true });
+
+copyFileSync(join(root, 'index.html'), join(out, 'index.html'));
+copyFileSync(join(root, 'augmenta-favicon.png'), join(out, 'augmenta-favicon.png'));
+
+for (const name of readdirSync(join(root, 'src'))) {
+ if (!name.endsWith('.js') && name !== 'styles.css') continue;
+ copyFileSync(join(root, 'src', name), join(out, 'src', name));
+}
+
+// Put the SDK under a revisioned directory rather than versioning only its
+// entrypoint with a query string. Its internal relative imports then inherit
+// the revisioned path too, so a deployment cannot mix old/new SDK modules.
+cpSync(
+ join(root, 'vendor', 'AugmentaClientSDK-JS', 'dist', 'esm'),
+ sdkOut,
+ { recursive: true }
+);
+copyFileSync(
+ join(root, 'vendor', 'qrcode-generator', 'qrcode.js'),
+ join(out, 'vendor', 'qrcode-generator', 'qrcode.js')
+);
+
+
+const indexPath = join(out, 'index.html');
+let html = readFileSync(indexPath, 'utf8');
+html = html
+ .replace(' \n', '')
+ .replace(
+ './vendor/AugmentaClientSDK-JS/dist/esm/index.js',
+ `./vendor/AugmentaClientSDK-JS/${rev}/dist/esm/index.js`
+ )
+ .replaceAll('./src/styles.css', `./src/styles.css?v=${rev}`)
+ .replaceAll('./src/qr.js', `./src/qr.js?v=${rev}`)
+ .replaceAll('./src/main.js', `./src/main.js?v=${rev}`);
+
+writeFileSync(indexPath, html);
+
+for (const name of readdirSync(join(out, 'src')).filter((name) => name.endsWith('.js'))) {
+ const path = join(out, 'src', name);
+ const source = readFileSync(path, 'utf8').replace(
+ /(from\s+['"])(\.\.?(?:\/[^'"]+)+\.js)(['"])/g,
+ `$1$2?v=${rev}$3`
+ );
+ writeFileSync(path, source);
+}
+
+writeFileSync(join(out, '.nojekyll'), '');
+console.log(`Assembled self-contained Pages artifact for revision ${rev}`);
diff --git a/src/app-info.js b/src/app-info.js
new file mode 100644
index 0000000..49a6ced
--- /dev/null
+++ b/src/app-info.js
@@ -0,0 +1 @@
+export const APP_VERSION = '0.1.0';
diff --git a/src/connection.js b/src/connection.js
new file mode 100644
index 0000000..bbfb8a9
--- /dev/null
+++ b/src/connection.js
@@ -0,0 +1,350 @@
+import {
+ AugmentaWebSocketClient, AxisMode, CoordinateSpace, OriginMode, RotationMode
+} from 'augmenta-client-sdk';
+import { APP_VERSION } from './app-info.js';
+
+const RECONNECT_DELAY_MS = 1000;
+const CONNECTION_ATTEMPT_TIMEOUT_MS = 2500;
+
+// Three.js is Y-up, right-handed and metre-based. Ask Augmenta/Pleiades to
+// deliver tracking and setup data directly in that convention.
+const THREE_JS_AXIS_TRANSFORM = Object.freeze({
+ axis: AxisMode.YUpRightHanded,
+ origin: OriginMode.BottomLeft,
+ flipX: false,
+ flipY: false,
+ flipZ: false,
+ coordinateSpace: CoordinateSpace.Absolute
+});
+
+export function createConnectionController({
+ getSettings,
+ onState,
+ onControl,
+ onSetup,
+ onUpdate,
+ onData
+}) {
+ let client;
+ let reconnectTimer;
+ let wantsConnection = false;
+ let socketOpen = false;
+ let retrying = false;
+ let autoNegotiatedVersion;
+ let activeVersion;
+ let phase = 'idle';
+ let note = '';
+ let targetPlan = [];
+ let targetIndex = 0;
+ let targetKey = '';
+ let attemptTimer;
+
+ function getState() {
+ return { wantsConnection, socketOpen, retrying, activeVersion, phase, note };
+ }
+
+ function publish(nextPhase, nextNote) {
+ phase = nextPhase;
+ note = nextNote;
+ onState?.(getState());
+ }
+
+ function clearReconnectTimer() {
+ if (reconnectTimer) window.clearTimeout(reconnectTimer);
+ reconnectTimer = undefined;
+ }
+
+ function clearAttemptTimer() {
+ if (attemptTimer) window.clearTimeout(attemptTimer);
+ attemptTimer = undefined;
+ }
+
+ function ensureTargetPlan(address, port) {
+ const key = String(address ?? '').trim() + '\n' + String(port ?? '').trim();
+ if (targetPlan.length === 0 || key !== targetKey) {
+ targetPlan = buildConnectionTargets(address, port);
+ targetIndex = 0;
+ targetKey = key;
+ }
+ return targetPlan;
+ }
+
+ function stopTransport(reason = 'User disconnect') {
+ clearReconnectTimer();
+ clearAttemptTimer();
+ socketOpen = false;
+ const current = client;
+ client = undefined;
+ current?.disconnect(1000, reason);
+ }
+
+ function selectedVersion(protocol) {
+ if (protocol === 'auto') return autoNegotiatedVersion ?? 3;
+ return Number(protocol);
+ }
+
+ function scheduleReconnect(message = 'Connection closed.', { nextTarget = false } = {}) {
+ if (!wantsConnection) return;
+ clearReconnectTimer();
+ clearAttemptTimer();
+ retrying = true;
+ socketOpen = false;
+
+ let delay = RECONNECT_DELAY_MS;
+ if (nextTarget && targetPlan.length > 0) {
+ targetIndex = (targetIndex + 1) % targetPlan.length;
+ const wrapped = targetIndex === 0;
+ delay = wrapped ? RECONNECT_DELAY_MS : 0;
+ publish(
+ 'retrying',
+ wrapped
+ ? `${message} No address/WebSocket variant connected; retrying automatically…`
+ : `${message} Trying ${targetPlan[targetIndex].label}…`
+ );
+ } else {
+ publish('retrying', `${message} Retrying automatically…`);
+ }
+
+ reconnectTimer = window.setTimeout(() => {
+ reconnectTimer = undefined;
+ attemptConnection();
+ }, delay);
+ }
+
+ function restartForProtocol(version) {
+ if (!wantsConnection || version === activeVersion) return;
+ retrying = false;
+ autoNegotiatedVersion = version;
+ stopTransport('Protocol negotiation');
+ publish('connecting', `Server uses protocol V${version}. Reconnecting with the matching parser…`);
+ reconnectTimer = window.setTimeout(() => {
+ reconnectTimer = undefined;
+ attemptConnection();
+ }, 0);
+ }
+
+ function attemptConnection() {
+ if (!wantsConnection) return;
+
+ clearReconnectTimer();
+
+ let target;
+ let protocol;
+ let downSample;
+ try {
+ const settings = getSettings();
+ const targets = ensureTargetPlan(settings.address, settings.port);
+ target = targets[targetIndex];
+ protocol = String(settings.protocol);
+ downSample = Math.max(1, Math.floor(Number(settings.downsample) || 1));
+ } catch (error) {
+ wantsConnection = false;
+ retrying = false;
+ autoNegotiatedVersion = undefined;
+ stopTransport('Invalid connection settings');
+ publish('error', error instanceof Error ? error.message : 'Invalid server address.');
+ return;
+ }
+
+ const version = selectedVersion(protocol);
+ activeVersion = version;
+ socketOpen = false;
+
+ if (!retrying) {
+ publish('connecting', `Connecting to ${target.label} with protocol V${version}…`);
+ }
+
+ const connection = new AugmentaWebSocketClient(target.url, {
+ clientName: 'Augmenta Three.js Debug Viewer',
+ applicationName: 'Augmenta ThreeJS Example',
+ applicationVersion: APP_VERSION,
+ options: {
+ version,
+ downSample,
+ streamClouds: true,
+ streamClusters: true,
+ streamClusterPoints: true,
+ streamZonePoints: true,
+ useCompression: false,
+ displayPointIntensity: true,
+ // Quaternions preserve Pleiades' exact OBB orientation. The viewer
+ // performs the left-handed -> right-handed basis reflection explicitly.
+ boxRotationMode: RotationMode.Quaternions,
+ axisTransform: THREE_JS_AXIS_TRANSFORM
+ }
+ });
+
+ client = connection;
+ clearAttemptTimer();
+ attemptTimer = window.setTimeout(() => {
+ if (client !== connection || socketOpen) return;
+ client = undefined;
+ connection.disconnect(1000, 'Connection attempt timed out');
+ scheduleReconnect(`Could not connect to ${target.label}.`, { nextTarget: true });
+ }, CONNECTION_ATTEMPT_TIMEOUT_MS);
+
+ connection.on('open', () => {
+ if (client !== connection || !wantsConnection) return;
+ clearAttemptTimer();
+ retrying = false;
+ socketOpen = true;
+ publish(
+ 'connected',
+ `Connected to ${target.label}. Protocol V${activeVersion}; uncompressed debug stream.`
+ );
+ });
+
+ connection.on('close', () => {
+ if (client !== connection) return;
+ clearAttemptTimer();
+ const wasOpen = socketOpen;
+ client = undefined;
+ socketOpen = false;
+ scheduleReconnect(
+ wasOpen ? 'Connection closed.' : `Could not connect to ${target.label}.`,
+ { nextTarget: !wasOpen }
+ );
+ });
+
+ connection.on('error', (error) => {
+ if (client !== connection) return;
+ console.error('Augmenta WebSocket/data error', error);
+
+ if (socketOpen) {
+ publish(
+ 'connected',
+ error instanceof Error
+ ? `Connected, but a message could not be parsed: ${error.message}`
+ : 'Connected, but a WebSocket/data error occurred.'
+ );
+ return;
+ }
+
+ retrying = true;
+ publish('retrying', 'Connection failed; retrying automatically…');
+ });
+
+ connection.on('controlMessage', (message) => {
+ if (client === connection) onControl?.(message);
+ });
+
+ connection.on('setup', (message) => {
+ if (client !== connection) return;
+ const serverVersion = message.getServerProtocolVersion();
+ if (
+ protocol === 'auto'
+ && Number.isInteger(serverVersion)
+ && serverVersion >= 2
+ && serverVersion < activeVersion
+ ) {
+ restartForProtocol(serverVersion);
+ return;
+ }
+ onSetup?.(message);
+ });
+
+ connection.on('update', (message) => {
+ if (client === connection) onUpdate?.(message);
+ });
+
+ connection.on('data', (frame) => {
+ if (client === connection) onData?.(frame);
+ });
+
+ try {
+ connection.connect();
+ } catch (error) {
+ clearAttemptTimer();
+ if (client === connection) client = undefined;
+ console.error('Augmenta connection failed', error);
+ scheduleReconnect(
+ error instanceof Error ? error.message : `Could not connect to ${target.label}.`,
+ { nextTarget: true }
+ );
+ }
+ }
+
+ function start() {
+ autoNegotiatedVersion = undefined;
+ wantsConnection = true;
+ retrying = false;
+ attemptConnection();
+ }
+
+ function stop() {
+ wantsConnection = false;
+ retrying = false;
+ autoNegotiatedVersion = undefined;
+ stopTransport();
+ publish('idle', 'Connection stopped. Scene and zones are kept visible.');
+ }
+
+ function restart(reason) {
+ if (!wantsConnection) return;
+ retrying = false;
+ autoNegotiatedVersion = undefined;
+ stopTransport(reason);
+ attemptConnection();
+ }
+
+ return { getState, start, stop, restart };
+}
+
+export function buildConnectionTargets(address, portValue) {
+ const host = normalizeServerHost(address);
+ const port = Number(portValue);
+
+ if (!host) throw new Error('Enter an IP address or hostname.');
+ if (!Number.isInteger(port) || port < 1 || port > 65535) {
+ throw new Error('Enter a valid port between 1 and 65535.');
+ }
+
+ const hosts = hostCandidates(host);
+ const schemes = ['ws', 'wss'];
+
+ return schemes.flatMap((scheme) => hosts.map((candidateHost) => {
+ // IPv6 literals need brackets in a WebSocket URL; IPv4/mDNS names do not.
+ const urlHost = candidateHost.includes(':') && !candidateHost.startsWith('[')
+ ? `[${candidateHost}]`
+ : candidateHost;
+ return {
+ label: `${scheme}://${urlHost}:${port}`,
+ url: `${scheme}://${urlHost}:${port}`
+ };
+ }));
+}
+
+function hostCandidates(host) {
+ if (/^localhost$/i.test(host)) {
+ // Pleiades commonly listens on IPv4 while browsers/OSes may resolve
+ // localhost to ::1 first. Prefer the known IPv4 loopback, then keep both
+ // hostname/IPv6 fallbacks.
+ return ['127.0.0.1', 'localhost', '::1'];
+ }
+ if (host.includes('.') || host.includes(':')) return [host];
+
+ // Keep the literal hostname first, then try mDNS before router-provided
+ // local domains.
+ return [
+ host,
+ `${host}.local`,
+ `${host}.home`,
+ `${host}.home.arpa`
+ ];
+}
+
+function normalizeServerHost(value) {
+ const host = String(value ?? '').trim();
+ if (!host) return '';
+
+ if (host.includes('://') || /[/?#]/.test(host)) {
+ throw new Error('Enter an IP address or hostname only.');
+ }
+
+ const colonCount = (host.match(/:/g) || []).length;
+ if (colonCount === 1 && !host.startsWith('[')) {
+ throw new Error('Enter the port in the Port field.');
+ }
+
+ return host;
+}
diff --git a/src/debug.js b/src/debug.js
new file mode 100644
index 0000000..8eed4ab
--- /dev/null
+++ b/src/debug.js
@@ -0,0 +1,264 @@
+import { ClusterState, ShapeType, ZonePropertyType } from 'augmenta-client-sdk';
+import { speedFromVelocity } from './motion.js';
+
+const DEBUG_RENDER_INTERVAL_MS = 250;
+const DEBUG_INTERACTION_SETTLE_MS = DEBUG_RENDER_INTERVAL_MS * 2;
+const INTENSITY_SAMPLE_LIMIT = 2048;
+
+export function createDebugPanel(summary, content) {
+ let lastRender = 0;
+ let cachedControl;
+ let cachedControlHtml = '';
+ let pendingOpenSections;
+ let pendingScrollTop;
+ let interactionHoldUntil = 0;
+
+ function holdInteraction() {
+ interactionHoldUntil = performance.now() + DEBUG_INTERACTION_SETTLE_MS;
+ }
+
+ content.addEventListener('pointerdown', holdInteraction, { passive: true });
+ content.addEventListener('click', holdInteraction);
+ content.addEventListener('keydown', (event) => {
+ if (event.key === 'Enter' || event.key === ' ') holdInteraction();
+ });
+
+ function render(
+ frame,
+ control,
+ fps,
+ sceneSize,
+ zoneNameForAddress,
+ zoneShapeForAddress,
+ force = false,
+ renderContent = true
+ ) {
+ const now = performance.now();
+ if (!force && now - lastRender < DEBUG_RENDER_INTERVAL_MS) return;
+ lastRender = now;
+ if (!frame && !control) return;
+
+ summary.textContent = frame
+ ? `${frame.getObjectCount()} objects · ${frame.getZoneEventCount()} zones · ${fps} fps`
+ : 'Waiting for tracking data';
+
+ if (!renderContent || (!force && now < interactionHoldUntil)) return;
+
+ const blocks = [];
+ if (frame) {
+ blocks.push(
+ frameBlock(sceneSize),
+ objectsBlock(frame),
+ zonesBlock(frame, zoneNameForAddress, zoneShapeForAddress)
+ );
+ }
+ if (control) {
+ if (control !== cachedControl) {
+ cachedControl = control;
+ cachedControlHtml = controlBlock(control);
+ }
+ blocks.push(cachedControlHtml);
+ }
+
+ const openSections = pendingOpenSections ?? new Map(
+ [...content.querySelectorAll('details[data-debug-section]')]
+ .map((details) => [details.dataset.debugSection, details.open])
+ );
+ pendingOpenSections = undefined;
+
+ content.innerHTML = blocks.join('');
+
+ for (const details of content.querySelectorAll('details[data-debug-section]')) {
+ const previous = openSections.get(details.dataset.debugSection);
+ if (previous !== undefined) details.open = previous;
+ }
+
+ content.style.minHeight = '';
+ if (pendingScrollTop !== undefined) {
+ const scrollParent = content.closest('#sidebar');
+ if (scrollParent) scrollParent.scrollTop = pendingScrollTop;
+ pendingScrollTop = undefined;
+ }
+ }
+
+ function clear(preserveLayout = false) {
+ lastRender = 0;
+ cachedControl = undefined;
+ cachedControlHtml = '';
+
+ if (preserveLayout) {
+ pendingOpenSections = new Map(
+ [...content.querySelectorAll('details[data-debug-section]')]
+ .map((details) => [details.dataset.debugSection, details.open])
+ );
+ pendingScrollTop = content.closest('#sidebar')?.scrollTop;
+ content.style.minHeight = `${content.offsetHeight}px`;
+ } else {
+ pendingOpenSections = undefined;
+ pendingScrollTop = undefined;
+ content.style.minHeight = '';
+ }
+
+ summary.textContent = 'No data yet';
+ content.innerHTML = 'Connect to Augmenta or run the demo to inspect the stream.
';
+ }
+
+ return { render, clear };
+}
+
+function frameBlock(sceneSize) {
+ return `Frame
scene size ${sceneSizeText(sceneSize)}
`;
+}
+
+function objectsBlock(frame) {
+ const objects = frame.getObjects();
+ if (!objects.length) return 'Objects (0)
No tracked objects in this frame.
';
+
+ const rows = objects.map((object) => {
+ let cluster = 'No cluster property';
+ if (object.hasCluster()) {
+ const c = object.getCluster();
+ const velocity = c.getVelocity();
+ const speed = speedFromVelocity(velocity);
+ cluster = `state ${esc(ClusterState[c.getState()] ?? c.getState())}
centroid ${esc(vec(c.getCentroid()))}
velocity ${esc(vec(velocity))}
speed ${fmt(speed)} m/s
box center ${esc(vec(c.getBoundingBoxCenter()))}
box size ${esc(vec(c.getBoundingBoxSize()))}
rotation q ${esc(vec(c.getBoundingBoxRotationQuaternions()))}
look at ${esc(vec(c.getLookAt()))}`;
+ }
+
+ let points = '—';
+ if (object.hasPointCloud()) {
+ const cloud = object.getPointCloud();
+ const data = cloud.getPointsData();
+ const sampleCount = Math.min(5, cloud.getPointCount());
+ const sampleLines = [];
+ for (let i = 0; i < sampleCount; i++) {
+ sampleLines.push(`p${i + 1} ${esc(vec(data.slice(i * 3, i * 3 + 3)))}`);
+ }
+
+ points = [`${cloud.getPointCount()} pts`, ...sampleLines].join('
');
+ const intensity = cloud.getIntensityData();
+ const stats = intensityStats(intensity);
+ if (stats) {
+ points += `
${stats.sampled ? 'intensity sample' : 'intensity'} ${fmt(stats.min)} / ${fmt(stats.avg)} / ${fmt(stats.max)}`;
+ }
+ }
+
+ return `${esc(object.getID() ?? '—')} ${esc(object.getUUID() ?? '—')} | ${cluster} | ${points} |
`;
+ }).join('');
+
+ return `Objects (${objects.length})
| ID / UUID | Cluster | Point cloud |
${rows}
`;
+}
+
+function zonesBlock(frame, zoneNameForAddress, zoneShapeForAddress) {
+ const zones = frame.getZoneEvents();
+ if (!zones.length) return 'Zones (0)
No zone data in this frame.
';
+
+ const rows = zones.map((zone) => {
+ const address = zone.getEmitterZoneAddress();
+ const shape = zoneShapeForAddress?.(address);
+ const propertyRows = [];
+ if (shape !== undefined) propertyRows.push(`shape: ${esc(shapeName(shape))}`);
+
+ propertyRows.push(...zone.getProperties().map((p) => {
+ const name = ZonePropertyType[p.getType()] ?? p.getType();
+ if (p.isSlider()) return `${name}: ${fmt(p.getSliderParameters().value)}`;
+ if (p.isXYPad()) {
+ const value = p.getXYPadParameters();
+ return `${name}: [${fmt(value.x)}, ${fmt(value.y)}]`;
+ }
+ if (p.isPointCloud()) {
+ const cloud = p.getPointCloudParameters();
+ return `${name}: ${cloudSummary(cloud)}`;
+ }
+ return name;
+ }));
+
+ const props = propertyRows.join('
') || '—';
+ const name = zoneNameForAddress?.(address) || address || '—';
+ return `| ${esc(name)} | presence ${zone.getPresence()} enter ${zone.getEnters()} leave ${zone.getLeaves()} | ${props} |
`;
+ }).join('');
+
+ return `Zones (${zones.length})
| Zone | Occupancy | Properties |
${rows}
`;
+}
+
+function controlBlock(message) {
+ const lines = [];
+ walk(message.getRootObject(), 0, lines);
+ const type = message.isSetup() ? 'setup' : message.isUpdate() ? 'update' : 'unknown';
+ return `Last control message · ${esc(type)}
status ${esc(message.getStatus())}\nserver protocol ${esc(message.getServerProtocolVersion())}\nerror ${esc(message.getErrorMessage() || '—')}\n\n${esc(lines.join('\n'))}
`;
+}
+
+function walk(container, depth, lines) {
+ const indent = ' '.repeat(depth);
+ let extra = '';
+ if (container.isScene()) extra = ` size=${vec(container.getSceneParameters().size)}`;
+ if (container.isZone()) extra = ` ${zoneParameters(container.getZoneParameters())}`;
+ lines.push(`${indent}${container.getType()} ${container.getName() || '(unnamed)'}\n${indent} address=${container.getAddress() || '—'} pos=${vec(container.getPosition())} rot°=${vec(container.getRotation())} color=${vec(container.getColor())}${extra}`);
+ for (const child of container.getChildren()) walk(child, depth + 1, lines);
+}
+
+function zoneParameters(params) {
+ const shape = shapeName(params.getShapeType());
+ if (params.isBox()) return `shape=${shape} size=${vec(params.getBoxShapeParameters().size)}`;
+ if (params.isCylinder()) {
+ const { radius, height } = params.getCylinderShapeParameters();
+ return `shape=${shape} radius=${fmt(radius)} height=${fmt(height)}`;
+ }
+ if (params.isSphere()) return `shape=${shape} radius=${fmt(params.getSphereShapeParameters().radius)}`;
+ return `shape=${shape}`;
+}
+
+function cloudSummary(cloud) {
+ const sample = Array.from(cloud.getPointsData().slice(0, 15));
+ let value = `${cloud.getPointCount()} pts; sample ${vec(sample)}`;
+ const intensity = cloud.getIntensityData();
+ const stats = intensityStats(intensity);
+ if (stats) {
+ value += `; ${stats.sampled ? 'intensity sample' : 'intensity'} min/avg/max ${fmt(stats.min)} / ${fmt(stats.avg)} / ${fmt(stats.max)}`;
+ }
+ return value;
+}
+
+function intensityStats(values) {
+ if (!values?.length) return undefined;
+
+ const sampleCount = Math.min(values.length, INTENSITY_SAMPLE_LIMIT);
+ let min = Infinity;
+ let max = -Infinity;
+ let sum = 0;
+
+ for (let i = 0; i < sampleCount; i++) {
+ const index = sampleCount === values.length
+ ? i
+ : Math.floor(i * values.length / sampleCount);
+ const value = values[index];
+ min = Math.min(min, value);
+ max = Math.max(max, value);
+ sum += value;
+ }
+
+ return {
+ min,
+ max,
+ avg: sum / sampleCount,
+ sampled: sampleCount < values.length
+ };
+}
+
+function sceneSizeText(size) {
+ if (!size || size.length < 3) return '—';
+
+ // The stream is requested Y-up. Present dimensions as width × depth × height.
+ const [x, y, z] = size;
+ return [x, z, y].map((value) => `${compact(Math.abs(value))}m`).join(' × ');
+}
+
+function compact(value) {
+ if (!Number.isFinite(value)) return '—';
+ return Number.isInteger(value) ? String(value) : Number(value).toFixed(2).replace(/0+$/, '').replace(/\.$/, '');
+}
+
+function shapeName(value) { return typeof value === 'string' ? value : ShapeType[value] ?? String(value); }
+function fmt(value, digits = 3) { return Number.isFinite(value) ? Number(value).toFixed(digits) : '—'; }
+function vec(values, digits = 3) { return `[${Array.from(values || [], (v) => fmt(v, digits)).join(', ')}]`; }
+function esc(value) {
+ return String(value ?? '').replaceAll('&', '&').replaceAll('<', '<').replaceAll('>', '>').replaceAll('"', '"').replaceAll("'", ''');
+}
diff --git a/src/demo.js b/src/demo.js
new file mode 100644
index 0000000..2dcdfc1
--- /dev/null
+++ b/src/demo.js
@@ -0,0 +1,64 @@
+import {
+ ClusterProperty, ClusterState, Container, ContainerType, ControlMessage, ControlMessageStatus,
+ ControlMessageType, DataBlob, ObjectPacket, PointCloudProperty, SceneInfoPacket, ShapeType,
+ ZoneEventPacket, ZoneEventProperty, ZoneParameters, ZonePropertyType
+} from 'augmenta-client-sdk';
+
+// Synthetic packets mimic the live stream consumed by the viewer: positions
+// are Y-up/right-handed, while setup Euler rotations and OBB quaternions retain
+// the Pleiades wire conventions corrected by viewer.js.
+export function makeDemoSetup() {
+ const zoneA = new Container(ContainerType.Zone, 'Welcome zone', '/Demo/Welcome', [2.9, 0.01, -2.8], [0, -12, 0], [0.42, 0.69, 1, 1], new ZoneParameters(ShapeType.Box, { size: [2.8, 0.05, 2.4] }), []);
+ const zoneB = new Container(ContainerType.Zone, 'Interaction zone', '/Demo/Interaction', [6.7, 0, -5.3], [0, 0, 0], [0.75, 0.48, 1, 1], new ZoneParameters(ShapeType.Cylinder, { radius: 1.2, height: 1.6 }, 'y'), []);
+ const scene = new Container(ContainerType.Scene, 'Web demo scene', '/Demo', [-5, 0, 4], [0, 0, 0], [0.35, 0.4, 0.5, 1], { size: [10, 4, 8] }, [zoneA, zoneB]);
+ const world = new Container(ContainerType.World, 'World', '', [0, 0, 0], [0, 0, 0], [0, 0, 0, 0], {}, [scene]);
+ return new ControlMessage(ControlMessageType.Setup, world, ControlMessageStatus.Ok, '', 3);
+}
+
+export function makeDemoFrame(t) {
+ const objects = [0, 1].map((index) => demoObject(index, t));
+ const zones = [
+ new ZoneEventPacket('/Demo/Welcome', 0, 0, 1, 0.31, [new ZoneEventProperty(ZonePropertyType.XYPad, { x: (Math.sin(t * 0.8) + 1) / 2, y: (Math.cos(t * 0.6) + 1) / 2 })]),
+ new ZoneEventPacket('/Demo/Interaction', 0, 0, 2, 0.67, [new ZoneEventProperty(ZonePropertyType.Slider, { value: (Math.sin(t) + 1) / 2 })])
+ ];
+ const timestamp = Math.floor(t * 1000);
+ return new DataBlob(new SceneInfoPacket('/Demo', timestamp), objects, zones, timestamp);
+}
+
+function demoObject(index, t) {
+ const angularSpeed = 0.45 + index * 0.08;
+ const radiusX = 1.5 + index * 0.45;
+ const radiusZ = 1.25 + index * 0.35;
+ const phase = t * angularSpeed + index * 2.1;
+ const centroid = [Math.cos(phase) * radiusX, 0.9, Math.sin(phase) * radiusZ];
+ const velocity = [
+ -Math.sin(phase) * radiusX * angularSpeed,
+ 0,
+ Math.cos(phase) * radiusZ * angularSpeed
+ ];
+ const size = [0.5 + index * 0.05, 1.65 + index * 0.08, 0.48 + index * 0.04];
+ const yaw = phase * 0.35;
+ const cluster = new ClusterProperty(
+ index === 2 && Math.sin(t * 0.7) < -0.75 ? ClusterState.Ghost : ClusterState.Updated,
+ centroid, velocity, [centroid[0], size[1] / 2, centroid[2]], size, 0.82 + index * 0.06,
+ [0, -Math.sin(yaw / 2), 0, Math.cos(yaw / 2)],
+ [Math.sin(yaw), 0, Math.cos(yaw)]
+ );
+ return new ObjectPacket(index + 1, cluster, demoCloud(centroid, size, index, t), `00000000-0000-4000-8000-00000000000${index + 1}`);
+}
+
+function demoCloud(centroid, size, index, t) {
+ const count = 260;
+ const points = new Float32Array(count * 3);
+ const intensity = new Float32Array(count);
+ for (let i = 0; i < count; i++) {
+ const angle = (i / count) * Math.PI * 10 + t * 0.25;
+ const h = ((i * 37) % count) / (count - 1);
+ const radius = (0.18 + ((i * 17) % 31) / 31 * 0.16) * (0.9 + index * 0.05);
+ points[i * 3] = centroid[0] + Math.cos(angle) * radius;
+ points[i * 3 + 1] = h * size[1];
+ points[i * 3 + 2] = centroid[2] + Math.sin(angle) * radius;
+ intensity[i] = 0.2 + 0.8 * Math.abs(Math.sin(angle * 0.3));
+ }
+ return new PointCloudProperty(points, intensity);
+}
diff --git a/src/main.js b/src/main.js
new file mode 100644
index 0000000..036c087
--- /dev/null
+++ b/src/main.js
@@ -0,0 +1,787 @@
+import { APP_VERSION } from './app-info.js';
+import { createConnectionController } from './connection.js';
+import { createSetupStore } from './setup-store.js';
+import { createViewer } from './viewer.js';
+import { createDebugPanel } from './debug.js';
+import { makeDemoFrame, makeDemoSetup } from './demo.js';
+import {
+ normalizeConnectionOptions,
+ readConnectionOptionsFromUrl,
+ resolveConnectionOptions
+} from './share-link.js';
+import { refreshConnectionQr } from './qr.js';
+
+const DISCONNECT_CLEANUP_DELAY_MS = 500;
+const CAMERA_PREFERENCE_SAVE_DELAY_MS = 250;
+const SIDEBAR_HANDLE_IDLE_DELAY_MS = 3000;
+const SIDEBAR_MIN_WIDTH = 320;
+const SIDEBAR_MAX_WIDTH = 450;
+const SIDEBAR_VIEWPORT_MARGIN = 160;
+const MOBILE_MEDIA_QUERY = '(max-width: 900px), (pointer: coarse) and (max-width: 1100px)';
+const SETTINGS_STORAGE_KEY = 'augmenta-web-demo-settings:v1';
+
+const $ = (selector) => document.querySelector(selector);
+
+function refreshFavicon() {
+ const faviconUrl = new URL('./augmenta-favicon.png?v=4', document.baseURI).href;
+ document.querySelectorAll('link[rel~="icon"]').forEach((link) => {
+ if (link.href !== faviconUrl) link.href = faviconUrl;
+ });
+}
+
+refreshFavicon();
+
+const ui = {
+ app: $('#app'), sidebar: $('#sidebar'), sidebarResizer: $('#sidebar-resizer'), serverAddress: $('#server-address'), port: $('#port'), protocol: $('#protocol'), downsample: $('#downsample'), connect: $('#connect'),
+ demo: $('#demo'), status: $('#status'), note: $('#connection-note'), summary: $('#summary'),
+ debug: $('#debug-content'), clear: $('#clear'), resetCamera: $('#reset-camera'), scenes: $('#scenes'),
+ sidebarToggle: $('#sidebar-toggle'), viewerTitle: $('.viewer-title'), connectionQrVisibility: $('.connection-qr-visibility'),
+ connectionSection: $('#connection-section'), connectionAdvanced: $('#connection-advanced'), connectionAdvancedSummary: $('#connection-advanced-summary'),
+ displaySection: $('#display-section'), displayAdvanced: $('#display-advanced'), displaySectionSummary: $('#display-section-summary'),
+ debugSection: $('#debug-section'), visualsSection: $('#visuals-section'), visualsSectionSummary: $('#visuals-section-summary'), appVersion: $('#app-version'),
+ showClusters: $('#show-clusters'), showPoints: $('#show-points'), showScene: $('#show-scene'), showZones: $('#show-zones'), showVectors: $('#show-vectors'),
+ visualParticles: $('#visual-particles'), visualTrails: $('#visual-trails'), visualGrid: $('#visual-grid'), visualBounds: $('#visual-bounds'), visualGlow: $('#visual-glow'), visualLabels: $('#visual-labels'), visualPalette: $('#visual-palette')
+};
+
+ui.appVersion.textContent = `Version ${APP_VERSION}`;
+
+const viewer = createViewer($('#canvas-host'));
+const debug = createDebugPanel(ui.summary, ui.debug);
+
+let disconnectCleanupTimer;
+let cameraPreferenceSaveTimer;
+let sidebarHandleIdleTimer;
+let localConnectionPreferences = {};
+let demoTimer;
+let lastFrame;
+let lastControl;
+let frameTimes = [];
+let scenes = [];
+let preferredSceneAddress = 'all';
+const setupStore = createSetupStore();
+
+function resetSidebarHandleIdle() {
+ if (sidebarHandleIdleTimer) window.clearTimeout(sidebarHandleIdleTimer);
+ sidebarHandleIdleTimer = undefined;
+ ui.app.classList.remove('sidebar-handle-idle');
+
+ sidebarHandleIdleTimer = window.setTimeout(() => {
+ sidebarHandleIdleTimer = undefined;
+ ui.app.classList.add('sidebar-handle-idle');
+ }, SIDEBAR_HANDLE_IDLE_DELAY_MS);
+}
+
+function isTextEntryTarget(target) {
+ return target instanceof Element
+ && Boolean(target.closest('input, textarea, select, [contenteditable="true"]'));
+}
+
+function handleGlobalShortcut(event) {
+ if (event.defaultPrevented) return;
+
+ if (event.key === 'Escape') {
+ if (!ui.app.classList.contains('sidebar-hidden')) {
+ event.preventDefault();
+ setSidebarHidden(true);
+ savePreferences();
+ }
+ return;
+ }
+
+ if (
+ event.ctrlKey
+ || event.metaKey
+ || event.altKey
+ || isTextEntryTarget(event.target)
+ ) {
+ return;
+ }
+
+ if (event.key.toLowerCase() === 'h') {
+ event.preventDefault();
+ ui.viewerTitle.hidden = !ui.viewerTitle.hidden;
+ }
+}
+
+window.addEventListener('mousemove', resetSidebarHandleIdle, { passive: true });
+window.addEventListener('keydown', (event) => {
+ resetSidebarHandleIdle();
+ handleGlobalShortcut(event);
+});
+
+function isObject(value) {
+ return typeof value === 'object' && value !== null && !Array.isArray(value);
+}
+
+function loadPreferences() {
+ try {
+ const value = JSON.parse(window.localStorage.getItem(SETTINGS_STORAGE_KEY));
+ return isObject(value) ? value : {};
+ } catch {
+ return {};
+ }
+}
+
+function savePreferences() {
+ const sidebarWidth = Number.parseFloat(
+ getComputedStyle(ui.app).getPropertyValue('--sidebar-width')
+ );
+
+ const value = {
+ connection: localConnectionPreferences,
+ display: {
+ clusters: ui.showClusters.checked,
+ points: ui.showPoints.checked,
+ sceneBounds: ui.showScene.checked,
+ zones: ui.showZones.checked,
+ vectors: ui.showVectors.checked,
+ scene: preferredSceneAddress
+ },
+ visuals: {
+ particles: ui.visualParticles.checked,
+ trails: ui.visualTrails.checked,
+ grid: ui.visualGrid.checked,
+ bounds: ui.visualBounds.checked,
+ glow: ui.visualGlow.checked,
+ labels: ui.visualLabels.checked,
+ palette: ui.visualPalette.value
+ },
+ ui: {
+ sidebarHidden: ui.app.classList.contains('sidebar-hidden'),
+ sidebarWidth: Number.isFinite(sidebarWidth) ? sidebarWidth : SIDEBAR_MAX_WIDTH,
+ cameraView: viewer.getCameraView(),
+ sections: {
+ connection: ui.connectionSection.open,
+ connectionAdvanced: ui.connectionAdvanced.open,
+ display: ui.displaySection.open,
+ displayAdvanced: ui.displayAdvanced.open,
+ visuals: ui.visualsSection.open,
+ debug: ui.debugSection.open
+ }
+ }
+ };
+
+ try {
+ window.localStorage.setItem(SETTINGS_STORAGE_KEY, JSON.stringify(value));
+ } catch {
+ // Storage can be unavailable in private/restricted browser contexts.
+ }
+}
+
+function applyConnectionSettings(settings) {
+ if (typeof settings.address === 'string') ui.serverAddress.value = settings.address;
+ if (typeof settings.port === 'string') ui.port.value = settings.port;
+ if (typeof settings.protocol === 'string') ui.protocol.value = settings.protocol;
+ if (typeof settings.downsample === 'string') ui.downsample.value = settings.downsample;
+}
+
+function rememberLocalConnectionSetting(key, value) {
+ localConnectionPreferences = normalizeConnectionOptions({
+ ...localConnectionPreferences,
+ [key]: value
+ });
+}
+
+function restorePreferences() {
+ const savedPreferences = loadPreferences();
+ const connection = isObject(savedPreferences.connection) ? savedPreferences.connection : {};
+ const sharedConnection = readConnectionOptionsFromUrl(window.location.href);
+ const display = isObject(savedPreferences.display) ? savedPreferences.display : {};
+ const visuals = isObject(savedPreferences.visuals) ? savedPreferences.visuals : {};
+ const uiPreferences = isObject(savedPreferences.ui) ? savedPreferences.ui : {};
+ const sectionPreferences = isObject(uiPreferences.sections) ? uiPreferences.sections : {};
+
+ applyConnectionSettings(normalizeConnectionOptions(connection));
+ // Persist only this browser's own connection defaults. Shared/QR values are
+ // a tab-local override and must not leak back into localStorage just because
+ // camera/sidebar state is saved.
+ localConnectionPreferences = normalizeConnectionOptions(getConnectionSettings());
+ applyConnectionSettings(resolveConnectionOptions(localConnectionPreferences, sharedConnection));
+
+ if (typeof display.clusters === 'boolean') ui.showClusters.checked = display.clusters;
+ if (typeof display.points === 'boolean') ui.showPoints.checked = display.points;
+ if (typeof display.sceneBounds === 'boolean') {
+ ui.showScene.checked = display.sceneBounds;
+ } else if (typeof display.zones === 'boolean') {
+ // Backward compatibility with the previous combined "Scene & zones" toggle.
+ ui.showScene.checked = display.zones;
+ }
+ if (typeof display.zones === 'boolean') ui.showZones.checked = display.zones;
+ if (typeof display.vectors === 'boolean') ui.showVectors.checked = display.vectors;
+ if (typeof display.scene === 'string' && display.scene) preferredSceneAddress = display.scene;
+
+ if (typeof visuals.particles === 'boolean') ui.visualParticles.checked = visuals.particles;
+ if (typeof visuals.trails === 'boolean') ui.visualTrails.checked = visuals.trails;
+ if (typeof visuals.grid === 'boolean') ui.visualGrid.checked = visuals.grid;
+ if (typeof visuals.bounds === 'boolean') ui.visualBounds.checked = visuals.bounds;
+ if (typeof visuals.glow === 'boolean') ui.visualGlow.checked = visuals.glow;
+ if (typeof visuals.labels === 'boolean') ui.visualLabels.checked = visuals.labels;
+ if (typeof visuals.palette === 'string') ui.visualPalette.value = visuals.palette;
+
+ const sidebarWidth = Number(uiPreferences.sidebarWidth);
+ if (Number.isFinite(sidebarWidth)) setSidebarWidth(sidebarWidth);
+ setSidebarHidden(uiPreferences.sidebarHidden === true, false);
+
+ for (const [key, element] of [
+ ['connection', ui.connectionSection],
+ ['connectionAdvanced', ui.connectionAdvanced],
+ ['display', ui.displaySection],
+ ['displayAdvanced', ui.displayAdvanced],
+ ['visuals', ui.visualsSection],
+ ['debug', ui.debugSection]
+ ]) {
+ if (typeof sectionPreferences[key] === 'boolean') {
+ element.open = sectionPreferences[key];
+ }
+ }
+
+ viewer.setCameraView(uiPreferences.cameraView);
+}
+
+function scheduleCameraPreferenceSave() {
+ if (cameraPreferenceSaveTimer) {
+ window.clearTimeout(cameraPreferenceSaveTimer);
+ }
+ cameraPreferenceSaveTimer = window.setTimeout(() => {
+ cameraPreferenceSaveTimer = undefined;
+ savePreferences();
+ }, CAMERA_PREFERENCE_SAVE_DELAY_MS);
+}
+
+function setStatus(text, kind = 'idle') {
+ ui.status.textContent = text;
+ ui.status.className = `status ${kind}`;
+}
+
+function handleConnectionState(state) {
+ const labels = {
+ idle: 'Idle',
+ connecting: 'Connecting',
+ retrying: 'Retrying',
+ connected: 'Connected',
+ error: 'Error'
+ };
+ const kinds = {
+ idle: 'idle',
+ connecting: 'connecting',
+ retrying: 'connecting',
+ connected: 'connected',
+ error: 'error'
+ };
+
+ setStatus(labels[state.phase] ?? 'Idle', kinds[state.phase] ?? 'idle');
+ ui.note.textContent = state.note;
+ ui.connect.textContent = !state.wantsConnection
+ ? 'Connect'
+ : state.socketOpen
+ ? 'Connected'
+ : state.retrying
+ ? 'Retrying…'
+ : 'Connecting…';
+ ui.connect.classList.toggle('active', state.socketOpen);
+ ui.connect.setAttribute('aria-pressed', String(state.wantsConnection));
+ ui.connect.title = state.wantsConnection ? 'Click to stop the connection' : 'Connect to Augmenta';
+
+ if (state.phase === 'connected') clearDisconnectCleanupTimer();
+ else if (state.phase === 'retrying') scheduleDisconnectCleanup();
+}
+
+function updateSimulationButton() {
+ const running = Boolean(demoTimer);
+ ui.demo.textContent = running ? 'Simulating' : 'Simulate data';
+ ui.demo.classList.toggle('active', running);
+ ui.demo.setAttribute('aria-pressed', String(running));
+ ui.demo.title = running ? 'Click to stop simulation' : 'Simulate Augmenta data locally';
+}
+
+function fps() { return frameTimes.length; }
+
+function trackFrame(frame) {
+ const scene = selectedScene();
+ if (scene) {
+ const frameSceneAddress = frame.getSceneInfo().getAddress();
+ if (frameSceneAddress && frameSceneAddress !== scene.getAddress()) return;
+ }
+
+ lastFrame = frame;
+ const now = performance.now();
+ frameTimes.push(now);
+ frameTimes = frameTimes.filter((time) => time >= now - 1000);
+ viewer.renderFrame(frame);
+ renderDebug();
+}
+
+function clearDisconnectCleanupTimer() {
+ if (disconnectCleanupTimer) window.clearTimeout(disconnectCleanupTimer);
+ disconnectCleanupTimer = undefined;
+}
+
+function scheduleDisconnectCleanup() {
+ clearDisconnectCleanupTimer();
+ disconnectCleanupTimer = window.setTimeout(() => {
+ disconnectCleanupTimer = undefined;
+ const state = connection.getState();
+ if (state.socketOpen || !state.wantsConnection) return;
+ clearTracking();
+ clearDebugData();
+ }, DISCONNECT_CLEANUP_DELAY_MS);
+}
+
+function stopConnection() {
+ connection.stop();
+ clearTracking();
+ clearDebugData();
+}
+
+function stopSimulation() {
+ if (demoTimer) window.clearInterval(demoTimer);
+ demoTimer = undefined;
+ updateSimulationButton();
+}
+
+function clearTracking() {
+ viewer.clearTracking();
+ lastFrame = undefined;
+ frameTimes = [];
+}
+
+function clearDebugData(preserveLayout = false) {
+ lastFrame = undefined;
+ lastControl = undefined;
+ frameTimes = [];
+ debug.clear(preserveLayout);
+}
+
+function selectedScene() {
+ const address = ui.scenes.value;
+ return address === 'all' ? undefined : scenes.find((scene) => scene.getAddress() === address);
+}
+
+function sceneSizeForFrame(frame) {
+ const address = frame?.getSceneInfo().getAddress();
+ const scene = scenes.find((candidate) => candidate.getAddress() === address)
+ ?? selectedScene()
+ ?? (scenes.length === 1 ? scenes[0] : undefined);
+ return scene?.getSceneParameters().size;
+}
+
+function renderDebug(force = false) {
+ debug.render(
+ lastFrame,
+ lastControl,
+ fps(),
+ sceneSizeForFrame(lastFrame),
+ zoneNameForAddress,
+ zoneShapeForAddress,
+ force,
+ ui.debugSection.open
+ );
+}
+
+function zoneShapeForAddress(address) {
+ const zone = setupStore.getByAddress(address);
+ if (!zone?.isZone?.()) return undefined;
+ return zone.getZoneParameters().getShapeType();
+}
+
+function zoneNameForAddress(address) {
+ const knownName = setupStore.getByAddress(address)?.getName();
+ if (knownName) return knownName;
+
+ // A zone rename changes its control address immediately, while the current
+ // Pleiades WebSocket setup is only refreshed on reconnect. Keep the debug
+ // table readable until then by showing the leaf short name, not the full path.
+ const parts = String(address ?? '').split('/').filter((part) => part && part !== 'children');
+ return parts.at(-1) || address || '—';
+}
+
+function updateDisplaySectionSummary() {
+ ui.displaySectionSummary.textContent = ui.scenes.selectedOptions[0]?.textContent
+ ?? (ui.scenes.disabled ? 'No scenes' : 'All scenes');
+}
+
+function updateConnectionAdvancedSummary() {
+ const protocolLabel = ui.protocol.selectedOptions[0]?.textContent?.split(' — ')[0]
+ ?? ui.protocol.value;
+ ui.connectionAdvancedSummary.textContent =
+ `${ui.port.value || '—'} · ${protocolLabel} · ×${ui.downsample.value || '—'}`;
+}
+
+function syncSceneSelector() {
+ const previous = ui.scenes.value;
+ scenes = setupStore.getScenes();
+
+ ui.scenes.innerHTML = [
+ '',
+ ...scenes.map((scene, index) => {
+ const name = scene.getName() || scene.getAddress() || `Scene ${index + 1}`;
+ return ``;
+ })
+ ].join('');
+
+ const requested = previous !== 'all' ? previous : preferredSceneAddress;
+ const stillAvailable = requested === 'all'
+ || scenes.some((scene) => scene.getAddress() === requested);
+ ui.scenes.value = stillAvailable ? requested : 'all';
+ ui.scenes.disabled = scenes.length === 0;
+ updateDisplaySectionSummary();
+}
+
+function setSetup(root) {
+ setupStore.setRoot(root);
+ syncSceneSelector();
+ renderSelectedScenes();
+}
+
+function renderSelectedScenes() {
+ const root = setupStore.getRoot();
+ if (!root) return;
+ clearTracking();
+ debug.clear();
+ viewer.renderSetup(root, selectedScene()?.getAddress());
+}
+
+function applySetupUpdate(container) {
+ const root = setupStore.applyUpdate(container);
+ if (!root) return;
+
+ syncSceneSelector();
+ viewer.renderSetup(root, selectedScene()?.getAddress());
+ renderDebug(true);
+}
+
+function escapeOption(value) {
+ return String(value ?? '')
+ .replaceAll('&', '&')
+ .replaceAll('<', '<')
+ .replaceAll('>', '>')
+ .replaceAll('"', '"');
+}
+
+function getConnectionSettings() {
+ return {
+ address: ui.serverAddress.value,
+ port: ui.port.value,
+ protocol: ui.protocol.value,
+ downsample: ui.downsample.value
+ };
+}
+
+function handleSetup(message) {
+ setSetup(message.getRootObject());
+ if (!viewer.isCameraUserControlled()) viewer.resetCamera();
+}
+
+const connection = createConnectionController({
+ getSettings: getConnectionSettings,
+ onState: handleConnectionState,
+ onControl: (message) => {
+ lastControl = message;
+ renderDebug(true);
+ },
+ onSetup: handleSetup,
+ onUpdate: (message) => applySetupUpdate(message.getRootObject()),
+ onData: trackFrame
+});
+
+function toggleConnection() {
+ if (connection.getState().wantsConnection) {
+ stopConnection();
+ return;
+ }
+
+ stopSimulation();
+ clearTracking();
+ clearDebugData();
+ connection.start();
+}
+
+function startSimulation() {
+ stopConnection();
+ clearTracking();
+ viewer.clearSetup();
+ setStatus('Simulating', 'demo');
+ ui.note.textContent = 'Local synthetic stream using the Augmenta SDK data model. Click Simulating to stop.';
+ lastControl = makeDemoSetup();
+ setSetup(lastControl.getRootObject());
+ viewer.resetCamera();
+ const start = performance.now();
+ const tick = () => trackFrame(makeDemoFrame((performance.now() - start) / 1000));
+ tick();
+ demoTimer = window.setInterval(tick, 33);
+ updateSimulationButton();
+}
+
+function toggleSimulation() {
+ if (!demoTimer) {
+ startSimulation();
+ return;
+ }
+
+ stopSimulation();
+ setStatus('Idle');
+ ui.note.textContent = 'Simulation stopped.';
+}
+
+function applyVisibility() {
+ viewer.setVisibility({
+ clusters: ui.showClusters.checked,
+ points: ui.showPoints.checked,
+ scene: ui.showScene.checked,
+ zones: ui.showZones.checked,
+ vectors: ui.showVectors.checked
+ });
+}
+
+function applyVisualOptions() {
+ viewer.setVisualOptions({
+ particles: ui.visualParticles.checked,
+ trails: ui.visualTrails.checked,
+ grid: ui.visualGrid.checked,
+ bounds: ui.visualBounds.checked,
+ glow: ui.visualGlow.checked,
+ labels: ui.visualLabels.checked,
+ palette: ui.visualPalette.value
+ });
+ ui.visualsSectionSummary.textContent = ui.visualPalette.selectedOptions[0]?.textContent ?? 'Punchy';
+}
+
+viewer.setCameraChangeHandler(scheduleCameraPreferenceSave);
+
+ui.connect.addEventListener('click', toggleConnection);
+ui.demo.addEventListener('click', toggleSimulation);
+ui.clear.addEventListener('click', () => clearDebugData(true));
+ui.resetCamera.addEventListener('click', viewer.resetCamera);
+ui.scenes.addEventListener('change', () => {
+ preferredSceneAddress = ui.scenes.value;
+ updateDisplaySectionSummary();
+ savePreferences();
+ renderSelectedScenes();
+});
+
+for (const section of [
+ ui.connectionSection,
+ ui.connectionAdvanced,
+ ui.displaySection,
+ ui.displayAdvanced,
+ ui.visualsSection
+]) {
+ section.addEventListener('toggle', savePreferences);
+}
+
+ui.debugSection.addEventListener('toggle', () => {
+ savePreferences();
+ if (ui.debugSection.open) renderDebug(true);
+});
+
+// The panel overlays the renderer. Shift the camera projection by the visible
+// panel width so the orbit target stays centered in the unobscured viewport.
+function syncPanelCamera(animate = false) {
+ const hidden = ui.app.classList.contains('sidebar-hidden');
+ const inset = isMobileLayout() || hidden ? 0 : ui.sidebar.getBoundingClientRect().width;
+ viewer.setRightInset(inset, animate);
+}
+
+function isMobileLayout() {
+ return window.matchMedia(MOBILE_MEDIA_QUERY).matches;
+}
+
+function updateSidebarTogglePresentation() {
+ const hidden = ui.app.classList.contains('sidebar-hidden');
+ const mobile = isMobileLayout();
+ const label = hidden ? (mobile ? 'Show menu' : 'Show panel') : (mobile ? 'Hide menu' : 'Hide panel');
+
+ ui.sidebarToggle.textContent = mobile
+ ? (hidden ? '⌃' : '⌄')
+ : (hidden ? '<' : '>');
+ ui.sidebarToggle.title = label;
+ ui.sidebarToggle.setAttribute('aria-label', label);
+ ui.sidebarToggle.setAttribute('aria-expanded', String(!hidden));
+}
+
+function syncSidebarAccessibility() {
+ const hidden = ui.app.classList.contains('sidebar-hidden');
+ const mobile = isMobileLayout();
+ const hideQr = hidden && !mobile;
+
+ ui.sidebar.inert = hidden;
+ ui.sidebar.toggleAttribute('aria-hidden', hidden);
+
+ ui.connectionQrVisibility.inert = hideQr;
+ ui.connectionQrVisibility.toggleAttribute('aria-hidden', hideQr);
+}
+
+function setSidebarHidden(hidden, animate = true) {
+ if (!animate) ui.app.classList.add('sidebar-no-transition');
+
+ ui.app.classList.toggle('sidebar-hidden', hidden);
+ updateSidebarTogglePresentation();
+ syncSidebarAccessibility();
+ syncPanelCamera(animate);
+
+ resetSidebarHandleIdle();
+
+ if (!animate) {
+ // Commit the restored layout while transitions are disabled. Re-enabling
+ // them afterwards keeps user-triggered open/close animation unchanged,
+ // without replaying it when a persisted hidden panel is loaded.
+ void ui.sidebar.offsetWidth;
+ ui.app.classList.remove('sidebar-no-transition');
+ }
+}
+
+ui.sidebarToggle.addEventListener('click', () => {
+ setSidebarHidden(!ui.app.classList.contains('sidebar-hidden'));
+ savePreferences();
+});
+
+function sidebarWidthBounds() {
+ return {
+ min: SIDEBAR_MIN_WIDTH,
+ max: Math.max(
+ SIDEBAR_MIN_WIDTH,
+ Math.min(SIDEBAR_MAX_WIDTH, window.innerWidth - SIDEBAR_VIEWPORT_MARGIN)
+ )
+ };
+}
+
+function setSidebarWidth(width) {
+ const { min, max } = sidebarWidthBounds();
+ const clamped = Math.min(max, Math.max(min, width));
+ ui.app.style.setProperty('--sidebar-width', `${clamped}px`);
+ ui.sidebarResizer.setAttribute('aria-valuenow', String(Math.round(clamped)));
+ syncPanelCamera(false);
+}
+
+function resizeSidebar(event) {
+ if (window.matchMedia(MOBILE_MEDIA_QUERY).matches) return;
+ setSidebarWidth(window.innerWidth - event.clientX);
+}
+
+ui.sidebarResizer.addEventListener('pointerdown', (event) => {
+ if (window.matchMedia(MOBILE_MEDIA_QUERY).matches || ui.app.classList.contains('sidebar-hidden')) return;
+ event.preventDefault();
+ ui.sidebarResizer.setPointerCapture(event.pointerId);
+ ui.app.classList.add('sidebar-resizing');
+});
+
+ui.sidebarResizer.addEventListener('pointermove', (event) => {
+ if (!ui.sidebarResizer.hasPointerCapture(event.pointerId)) return;
+ resizeSidebar(event);
+});
+
+function stopSidebarResize(event) {
+ if (ui.sidebarResizer.hasPointerCapture(event.pointerId)) {
+ ui.sidebarResizer.releasePointerCapture(event.pointerId);
+ savePreferences();
+ }
+ ui.app.classList.remove('sidebar-resizing');
+}
+ui.sidebarResizer.addEventListener('pointerup', stopSidebarResize);
+ui.sidebarResizer.addEventListener('pointercancel', stopSidebarResize);
+ui.sidebarResizer.addEventListener('keydown', (event) => {
+ if (!['ArrowLeft', 'ArrowRight'].includes(event.key)) return;
+ event.preventDefault();
+ const step = event.shiftKey ? 40 : 16;
+ const current = ui.sidebar.getBoundingClientRect().width;
+ setSidebarWidth(current + (event.key === 'ArrowLeft' ? step : -step));
+ savePreferences();
+});
+
+window.addEventListener('pagehide', savePreferences);
+
+window.addEventListener('resize', () => {
+ updateSidebarTogglePresentation();
+ syncSidebarAccessibility();
+ if (!isMobileLayout()) {
+ setSidebarWidth(ui.sidebar.getBoundingClientRect().width);
+ } else {
+ syncPanelCamera(false);
+ }
+});
+function restartConnection(reason) {
+ if (!connection.getState().wantsConnection) return;
+ clearTracking();
+ clearDebugData();
+ connection.restart(reason);
+}
+
+function handleServerFieldEnter(event) {
+ if (event.key !== 'Enter') return;
+ if (!connection.getState().wantsConnection) {
+ toggleConnection();
+ return;
+ }
+ restartConnection('Server address changed');
+}
+
+function saveConnectionField(key, value) {
+ rememberLocalConnectionSetting(key, value);
+ updateConnectionAdvancedSummary();
+ savePreferences();
+ refreshConnectionQr();
+}
+
+ui.serverAddress.addEventListener('keydown', handleServerFieldEnter);
+ui.port.addEventListener('keydown', handleServerFieldEnter);
+
+ui.serverAddress.addEventListener('input', () => {
+ saveConnectionField('address', ui.serverAddress.value);
+});
+ui.port.addEventListener('input', () => {
+ saveConnectionField('port', ui.port.value);
+});
+ui.downsample.addEventListener('input', () => {
+ saveConnectionField('downsample', ui.downsample.value);
+});
+
+ui.serverAddress.addEventListener('change', () => {
+ restartConnection('Server address changed');
+});
+ui.port.addEventListener('change', () => {
+ restartConnection('Server port changed');
+});
+ui.protocol.addEventListener('change', () => {
+ saveConnectionField('protocol', ui.protocol.value);
+ restartConnection('Protocol changed');
+});
+ui.downsample.addEventListener('change', () => {
+ restartConnection('Downsample changed');
+});
+[ui.showClusters, ui.showPoints, ui.showScene, ui.showZones, ui.showVectors]
+ .forEach((input) => input.addEventListener('change', () => {
+ applyVisibility();
+ savePreferences();
+ }));
+
+[ui.visualParticles, ui.visualTrails, ui.visualGrid, ui.visualBounds, ui.visualGlow, ui.visualLabels, ui.visualPalette]
+ .forEach((input) => input.addEventListener('change', () => {
+ applyVisualOptions();
+ savePreferences();
+ }));
+
+function syncPreferencesToUi() {
+ restorePreferences();
+ applyVisibility();
+ applyVisualOptions();
+ updateConnectionAdvancedSummary();
+ updateDisplaySectionSummary();
+ syncPanelCamera(false);
+ refreshConnectionQr();
+}
+
+syncPreferencesToUi();
+updateSimulationButton();
+connection.start();
+
+// Browsers can restore form controls after module initialization. Reapply the
+// persisted settings on pageshow so the visible controls always match the
+// values used by the viewer/connection logic.
+window.addEventListener('pageshow', () => {
+ refreshFavicon();
+ syncPreferencesToUi();
+ // Do not clear setupStore here: on fast local connections the initial
+ // Pleiades setup may already have arrived before pageshow. Clearing it would
+ // orphan subsequent partial updates from their Scene/parent transforms.
+ syncSceneSelector();
+});
diff --git a/src/motion.js b/src/motion.js
new file mode 100644
index 0000000..902b613
--- /dev/null
+++ b/src/motion.js
@@ -0,0 +1,6 @@
+// Velocity itself is provided by Augmenta through the SDK.
+// Speed is intentionally a consumer-side derived value, matching the current
+// Unity WebSocket and TouchDesigner C++ client integrations.
+export function speedFromVelocity(velocity) {
+ return Math.hypot(...velocity);
+}
diff --git a/src/qr.js b/src/qr.js
new file mode 100644
index 0000000..0fa3714
--- /dev/null
+++ b/src/qr.js
@@ -0,0 +1,137 @@
+import { buildConnectionShareUrl } from './share-link.js';
+import qrcode from '../vendor/qrcode-generator/qrcode.js';
+
+const QR_COPY_FEEDBACK_DELAY_MS = 1400;
+const $ = (selector) => document.querySelector(selector);
+
+const ui = {
+ serverAddress: $('#server-address'),
+ port: $('#port'),
+ protocol: $('#protocol'),
+ downsample: $('#downsample'),
+ connectionQr: $('#connection-qr'),
+ connectionQrCode: $('#connection-qr-code'),
+ connectionQrLabel: $('#connection-qr-label')
+};
+
+let qrCopyFeedbackTimer;
+let qrHovering = false;
+let initialized = false;
+
+function getConnectionSettings() {
+ return {
+ address: ui.serverAddress?.value ?? '',
+ port: ui.port?.value ?? '',
+ protocol: ui.protocol?.value ?? '',
+ downsample: ui.downsample?.value ?? ''
+ };
+}
+
+export function refreshConnectionQr() {
+ if (!ui.connectionQr || !ui.connectionQrCode) return;
+
+ const shareUrl = buildConnectionShareUrl(window.location.href, getConnectionSettings());
+ ui.connectionQr.href = shareUrl;
+
+ try {
+ const qr = qrcode(0, 'M');
+ qr.addData(shareUrl);
+ qr.make();
+ ui.connectionQrCode.innerHTML = qr.createSvgTag({
+ cellSize: 4,
+ margin: 8,
+ scalable: true
+ });
+ ui.connectionQr.hidden = false;
+ } catch {
+ // QR sharing is optional; keep the viewer usable if generation fails.
+ ui.connectionQr.hidden = true;
+ }
+}
+
+function copyTextFallback(text) {
+ const textarea = document.createElement('textarea');
+ textarea.value = text;
+ textarea.setAttribute('readonly', '');
+ textarea.style.position = 'fixed';
+ textarea.style.top = '-1000px';
+ textarea.style.opacity = '0';
+ document.body.appendChild(textarea);
+ textarea.focus();
+ textarea.select();
+
+ let copied = false;
+ try {
+ copied = document.execCommand('copy');
+ } catch {
+ copied = false;
+ }
+
+ textarea.remove();
+ return copied;
+}
+
+async function copyTextToClipboard(text) {
+ if (navigator.clipboard?.writeText) {
+ try {
+ await navigator.clipboard.writeText(text);
+ return true;
+ } catch {
+ // Fall back for browsers/contexts where Clipboard API permission fails.
+ }
+ }
+
+ return copyTextFallback(text);
+}
+
+function updateQrLabel() {
+ if (!ui.connectionQrLabel) return;
+ ui.connectionQrLabel.textContent = qrCopyFeedbackTimer
+ ? 'Link copied!'
+ : qrHovering
+ ? 'Copy link?'
+ : 'Launch it';
+}
+
+function showQrCopyFeedback() {
+ if (!ui.connectionQr) return;
+ if (qrCopyFeedbackTimer) window.clearTimeout(qrCopyFeedbackTimer);
+
+ ui.connectionQr.classList.remove('copy-confirmed');
+ void ui.connectionQr.offsetWidth;
+ ui.connectionQr.classList.add('copy-confirmed');
+
+ qrCopyFeedbackTimer = window.setTimeout(() => {
+ qrCopyFeedbackTimer = undefined;
+ ui.connectionQr.classList.remove('copy-confirmed');
+ updateQrLabel();
+ }, QR_COPY_FEEDBACK_DELAY_MS);
+
+ updateQrLabel();
+}
+
+async function handleConnectionQrClick(event) {
+ event.preventDefault();
+ if (ui.connectionQr && await copyTextToClipboard(ui.connectionQr.href)) {
+ showQrCopyFeedback();
+ }
+}
+
+function initializeConnectionQr() {
+ if (initialized || !ui.connectionQr) return;
+ initialized = true;
+
+ ui.connectionQr.addEventListener('click', handleConnectionQrClick);
+ ui.connectionQr.addEventListener('mouseenter', () => {
+ qrHovering = true;
+ updateQrLabel();
+ });
+ ui.connectionQr.addEventListener('mouseleave', () => {
+ qrHovering = false;
+ updateQrLabel();
+ });
+
+ refreshConnectionQr();
+}
+
+initializeConnectionQr();
diff --git a/src/setup-store.js b/src/setup-store.js
new file mode 100644
index 0000000..9fa7ed9
--- /dev/null
+++ b/src/setup-store.js
@@ -0,0 +1,172 @@
+import { Container } from 'augmenta-client-sdk';
+
+export function createSetupStore() {
+ let root;
+ let scenes = [];
+ let byAddress = new Map();
+
+ function refreshIndexes() {
+ scenes = [];
+ byAddress = new Map();
+ indexContainers(root, scenes, byAddress);
+ }
+
+ function setRoot(nextRoot) {
+ root = nextRoot;
+ refreshIndexes();
+ return root;
+ }
+
+ function applyUpdate(update) {
+ if (!update) return root;
+ if (!root) {
+ // Pleiades sends a full setup when a client registers. Any update that
+ // races ahead of that setup lacks reliable parent transforms, even when
+ // the changed object happens to be a Scene.
+ return root;
+ }
+
+ const merged = replaceInTree(root, update);
+ root = merged.changed ? merged.node : insertAtBestParent(root, update);
+ refreshIndexes();
+ return root;
+ }
+
+ function clear() {
+ root = undefined;
+ scenes = [];
+ byAddress = new Map();
+ }
+
+ return {
+ clear,
+ setRoot,
+ applyUpdate,
+ getRoot: () => root,
+ getScenes: () => scenes,
+ getByAddress: (address) => byAddress.get(address)
+ };
+}
+
+function indexContainers(container, scenes, byAddress) {
+ if (!container) return;
+
+ if (container.isScene()) scenes.push(container);
+ const address = container.getAddress();
+ if (address) byAddress.set(address, container);
+
+ for (const child of container.getChildren()) {
+ indexContainers(child, scenes, byAddress);
+ }
+}
+
+function replaceInTree(current, update) {
+ if (sameContainer(current, update)) {
+ return { node: mergeContainer(current, update), changed: true };
+ }
+
+ let changed = false;
+ const children = current.getChildren().map((child) => {
+ const result = replaceInTree(child, update);
+ changed ||= result.changed;
+ return result.node;
+ });
+
+ return {
+ node: changed ? cloneContainer(current, children) : current,
+ changed
+ };
+}
+
+function mergeContainer(previous, update) {
+ const updateChildren = update.getChildren();
+
+ // Pleiades serializes the complete subtree below an updated container. When
+ // children are present, treat that list as authoritative so renamed children
+ // do not leave stale old-address copies behind. Leaf/partial updates omit
+ // children, in which case the existing subtree is preserved.
+ return cloneContainer(
+ update,
+ updateChildren.length ? updateChildren : [...previous.getChildren()]
+ );
+}
+
+function insertAtBestParent(root, update) {
+ const updateAddress = update.getAddress();
+ if (!updateAddress) return root;
+
+ const parent = deepestAddressPrefix(root, updateAddress);
+ if (parent) {
+ return appendChild(root, parent.getAddress(), update).node;
+ }
+
+ // A new top-level Scene can legitimately arrive below a World whose own
+ // address is omitted from setup JSON.
+ if (root.isWorld() && update.isScene()) {
+ return cloneContainer(root, [...root.getChildren(), update]);
+ }
+
+ return root;
+}
+
+function deepestAddressPrefix(root, childAddress) {
+ let best;
+ let bestLength = -1;
+
+ function visit(container) {
+ const address = container.getAddress();
+ if (
+ address
+ && childAddress.startsWith(`${address}/`)
+ && address.length > bestLength
+ ) {
+ best = container;
+ bestLength = address.length;
+ }
+ for (const child of container.getChildren()) visit(child);
+ }
+
+ visit(root);
+ return best;
+}
+
+function appendChild(current, parentAddress, child) {
+ if (current.getAddress() === parentAddress) {
+ return {
+ node: cloneContainer(current, [...current.getChildren(), child]),
+ changed: true
+ };
+ }
+
+ let changed = false;
+ const children = current.getChildren().map((item) => {
+ const result = appendChild(item, parentAddress, child);
+ changed ||= result.changed;
+ return result.node;
+ });
+
+ return {
+ node: changed ? cloneContainer(current, children) : current,
+ changed
+ };
+}
+
+function sameContainer(a, b) {
+ const aAddress = a.getAddress();
+ const bAddress = b.getAddress();
+ if (aAddress || bAddress) return Boolean(aAddress && bAddress && aAddress === bAddress);
+ return Boolean(a.isWorld() && b.isWorld());
+}
+
+function cloneContainer(source, children) {
+ return new Container(
+ source.getType(),
+ source.getName(),
+ source.getAddress(),
+ source.getPosition(),
+ source.getRotation(),
+ source.getColor(),
+ source.parameters,
+ children
+ );
+}
diff --git a/src/share-link.js b/src/share-link.js
new file mode 100644
index 0000000..606756e
--- /dev/null
+++ b/src/share-link.js
@@ -0,0 +1,56 @@
+const CONNECTION_QUERY_KEYS = ['address', 'port', 'protocol', 'downsample'];
+
+export function normalizeConnectionOptions(settings) {
+ const options = {};
+
+ const address = String(settings?.address ?? '').trim();
+ if (address) options.address = address;
+
+ const port = Number(settings?.port);
+ if (Number.isInteger(port) && port >= 1 && port <= 65535) {
+ options.port = String(port);
+ }
+
+ const protocol = String(settings?.protocol ?? '');
+ if (['auto', '2', '3'].includes(protocol)) {
+ options.protocol = protocol;
+ }
+
+ const downsample = Number(settings?.downsample);
+ if (Number.isInteger(downsample) && downsample >= 1) {
+ options.downsample = String(downsample);
+ }
+
+ return options;
+}
+
+export function resolveConnectionOptions(localSettings, sharedSettings) {
+ return {
+ ...normalizeConnectionOptions(localSettings),
+ ...normalizeConnectionOptions(sharedSettings)
+ };
+}
+
+export function buildConnectionShareUrl(baseUrl, settings) {
+ const url = new URL(baseUrl);
+ url.hash = '';
+
+ for (const key of CONNECTION_QUERY_KEYS) url.searchParams.delete(key);
+
+ const options = normalizeConnectionOptions(settings);
+ for (const [key, value] of Object.entries(options)) {
+ url.searchParams.set(key, value);
+ }
+
+ return url.toString();
+}
+
+export function readConnectionOptionsFromUrl(urlValue) {
+ const url = new URL(urlValue);
+ return normalizeConnectionOptions({
+ address: url.searchParams.get('address'),
+ port: url.searchParams.get('port'),
+ protocol: url.searchParams.get('protocol'),
+ downsample: url.searchParams.get('downsample')
+ });
+}
diff --git a/src/styles.css b/src/styles.css
new file mode 100644
index 0000000..9e4a6c5
--- /dev/null
+++ b/src/styles.css
@@ -0,0 +1,888 @@
+:root {
+ --accent: #bfa6ff;
+ --accent-hover: #d5c7ff;
+ --accent-strong: #7359b8;
+ --accent-strong-hover: #8469cc;
+ --accent-focus: rgba(191, 166, 255, .18);
+ --accent-border: rgba(191, 166, 255, .76);
+
+ color-scheme: dark;
+ font-family: Inter, ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
+ background: #0c0f14;
+ color: #f5f7fb;
+ font-synthesis: none;
+}
+
+* { box-sizing: border-box; }
+html, body, #app { width: 100%; height: 100%; margin: 0; }
+body { overflow: hidden; }
+button, input, select { font: inherit; }
+
+#app {
+ --sidebar-width: 430px;
+ --sidebar-transition-duration: 220ms;
+ --sidebar-handle-fade-duration: 120ms;
+ --sidebar-handle-width: 32px;
+ --sidebar-handle-inline-padding: 4px;
+ position: relative;
+ display: grid;
+ grid-template-columns: minmax(0, 1fr);
+ overflow: hidden;
+ background: #0c0f14;
+}
+
+#viewer {
+ position: relative;
+ min-width: 0;
+ height: 100%;
+ overflow: hidden;
+ background: #0c0f14;
+}
+
+#canvas-host, #canvas-host canvas { width: 100%; height: 100%; display: block; }
+
+.viewer-overlay {
+ position: absolute;
+ top: 0;
+ left: 0;
+ right: 0;
+ padding: 22px 24px;
+ display: flex;
+ align-items: flex-start;
+ justify-content: space-between;
+ gap: 24px;
+ pointer-events: none;
+ background: linear-gradient(to bottom, rgba(9, 11, 15, .72), transparent);
+}
+
+.viewer-overlay h1 { margin: 0; font-size: 19px; font-weight: 650; letter-spacing: -.02em; }
+.viewer-overlay p { margin: 4px 0 0; color: #aeb6c5; font-size: 12px; }
+
+.connection-qr-visibility {
+ position: absolute;
+ z-index: 12;
+ left: 18px;
+ bottom: 18px;
+ opacity: 1;
+ transition: opacity var(--sidebar-transition-duration) ease;
+}
+
+.connection-qr {
+ display: grid;
+ justify-items: center;
+ gap: 6px;
+ padding: 7px;
+ border: 1px solid rgba(89, 99, 118, .5);
+ border-radius: 8px;
+ color: #9da7b9;
+ background: rgba(17, 20, 26, .78);
+ backdrop-filter: blur(8px);
+ -webkit-backdrop-filter: blur(8px);
+ text-decoration: none;
+ transform-origin: center;
+ transition:
+ border-color 120ms ease,
+ background 120ms ease,
+ color 120ms ease;
+}
+
+.connection-qr.copy-confirmed {
+ animation: connection-qr-copy-pop 360ms cubic-bezier(.2, .8, .2, 1);
+}
+
+@keyframes connection-qr-copy-pop {
+ 0% { transform: scale(1); }
+ 45% { transform: scale(1.08); }
+ 100% { transform: scale(1); }
+}
+
+.connection-qr:hover {
+ border-color: var(--accent-border);
+ color: var(--accent-hover);
+ background: rgba(28, 24, 38, .92);
+}
+
+.connection-qr-code {
+ display: block;
+ width: 98px;
+ height: 98px;
+ padding: 4px;
+ border-radius: 5px;
+ background: #ffffff;
+}
+
+.connection-qr-code svg {
+ display: block;
+ width: 100%;
+ height: 100%;
+}
+
+.connection-qr-label {
+ font-size: 9.5px;
+ font-weight: 600;
+ line-height: 1;
+}
+
+@media (prefers-reduced-motion: reduce) {
+ .connection-qr.copy-confirmed {
+ animation: none;
+ }
+}
+
+#sidebar {
+ position: absolute;
+ z-index: 15;
+ top: 0;
+ right: 0;
+ width: var(--sidebar-width);
+ min-width: 0;
+ height: 100%;
+ display: flex;
+ flex-direction: column;
+ overflow-y: auto;
+ border-left: 1px solid #252a35;
+ background: rgba(17, 20, 26, 0.84);
+ backdrop-filter: blur(12px);
+ -webkit-backdrop-filter: blur(12px);
+ scrollbar-color: #414858 transparent;
+ transform: translateX(0);
+ transition: transform var(--sidebar-transition-duration) ease;
+ will-change: transform;
+}
+
+#app.sidebar-hidden #sidebar {
+ transform: translateX(100%);
+ pointer-events: none;
+}
+
+.sidebar-footer {
+ margin-top: auto;
+ padding: 22px 18px 16px;
+ color: #8f99aa;
+ background: linear-gradient(
+ to bottom,
+ rgba(12, 15, 20, 0),
+ rgba(12, 15, 20, .58)
+ );
+}
+
+.sidebar-footer-app {
+ display: grid;
+ gap: 2px;
+}
+
+.sidebar-footer-title {
+ color: #8f99aa;
+ font-size: 11.5px;
+ font-weight: 650;
+ letter-spacing: .01em;
+}
+
+.sidebar-footer-subtitle {
+ color: #737d8e;
+ font-size: 9.5px;
+ line-height: 1.35;
+}
+
+.sidebar-footer-meta {
+ display: flex;
+ align-items: center;
+ justify-content: space-between;
+ gap: 12px;
+ margin-top: 10px;
+ font-size: 9.5px;
+}
+
+.sidebar-footer-link {
+ display: inline-flex;
+ align-items: center;
+ gap: 6px;
+ color: #9da7b9;
+ text-decoration: none;
+ transition: color 120ms ease;
+}
+
+.sidebar-footer-link:hover {
+ color: var(--accent-hover);
+}
+
+.sidebar-footer-link:focus-visible {
+ outline: 1px solid var(--accent);
+ outline-offset: 3px;
+ border-radius: 2px;
+}
+
+.sidebar-footer-link img {
+ display: block;
+ width: 16px;
+ height: 16px;
+ object-fit: contain;
+ opacity: .68;
+}
+
+/* Desktop resize strip: invisible until the panel edge is hovered/focused. */
+.sidebar-resizer {
+ position: absolute;
+ z-index: 19;
+ top: 0;
+ right: calc(var(--sidebar-width) - 6px);
+ width: 12px;
+ height: 100%;
+ cursor: col-resize;
+ touch-action: none;
+ outline: none;
+}
+
+.sidebar-resizer::after {
+ content: "";
+ position: absolute;
+ top: 50%;
+ left: 4px;
+ width: 4px;
+ height: 54px;
+ border-radius: 999px;
+ background: #6b7484;
+ opacity: 0;
+ transform: translateY(-50%);
+ transition: opacity 120ms ease, background 120ms ease;
+}
+
+.sidebar-resizer:hover::after,
+.sidebar-resizer:focus-visible::after,
+#app.sidebar-resizing .sidebar-resizer::after {
+ opacity: .9;
+ background: var(--accent);
+}
+
+#app.sidebar-hidden .sidebar-resizer {
+ display: none;
+}
+
+#app.sidebar-resizing,
+#app.sidebar-resizing * {
+ cursor: col-resize !important;
+ user-select: none !important;
+}
+
+#app.sidebar-resizing #sidebar,
+#app.sidebar-resizing .sidebar-toggle {
+ transition: none;
+}
+
+#app.sidebar-no-transition #sidebar,
+#app.sidebar-no-transition .sidebar-toggle,
+#app.sidebar-no-transition .connection-qr-visibility {
+ transition: none;
+}
+
+.sidebar-toggle {
+ position: absolute;
+ z-index: 20;
+ top: 50%;
+ right: var(--sidebar-width);
+ width: var(--sidebar-handle-width);
+ height: 64px;
+ min-height: 64px;
+ padding: 0 var(--sidebar-handle-inline-padding);
+ transform: translateY(-50%);
+ border-right: 0;
+ border-radius: 8px 0 0 8px;
+ color: #aeb8c9;
+ background: #171b23;
+ font-size: 18px;
+ line-height: 1;
+ transition:
+ right var(--sidebar-transition-duration) ease,
+ background 120ms ease,
+ color 120ms ease,
+ opacity var(--sidebar-handle-fade-duration) ease-out;
+}
+
+.sidebar-toggle:hover {
+ color: #eef2f8;
+ background: #222834;
+}
+
+#app.sidebar-hidden .sidebar-toggle {
+ right: 0;
+}
+
+@media (min-width: 901px) {
+ #app.sidebar-hidden .connection-qr-visibility {
+ opacity: 0;
+ pointer-events: none;
+ }
+}
+
+#app.sidebar-handle-idle {
+ --sidebar-handle-fade-duration: 1200ms;
+}
+
+#app.sidebar-handle-idle .sidebar-toggle {
+ opacity: 0;
+ pointer-events: none;
+}
+
+label { display: block; margin: 0 0 6px; color: #9da7b9; font-size: 11px; font-weight: 600; }
+input:not([type="checkbox"]), select {
+ width: 100%;
+ min-height: 38px;
+ padding: 8px 10px;
+ border: 1px solid #343b49;
+ border-radius: 7px;
+ outline: none;
+ color: #eef2f8;
+ background: #171b23;
+}
+input:not([type="checkbox"]):focus, select:focus { border-color: var(--accent); box-shadow: 0 0 0 2px var(--accent-focus); }
+
+.button-row { display: grid; grid-template-columns: minmax(0, 1fr) minmax(0, 1fr); gap: 9px; margin-top: 13px; }
+.button-row button { width: 100%; min-width: 0; }
+button {
+ min-height: 36px;
+ border: 1px solid #353c4b;
+ border-radius: 7px;
+ color: #e9edf5;
+ background: #1a1f29;
+ cursor: pointer;
+}
+button:hover { background: #222834; }
+button.primary { border-color: var(--accent); background: var(--accent-strong); }
+button.primary:hover { background: var(--accent-strong-hover); }
+button.active { border-color: #3f8d68; background: #1c4d36; }
+button.active:hover { background: #245f43; }
+button.subtle-button {
+ border-color: transparent;
+ color: #9da7b9;
+ background: transparent;
+ font-size: 12px;
+}
+button.subtle-button:hover,
+button.subtle-button.active:hover {
+ border-color: var(--accent-border);
+ color: var(--accent-hover);
+ background: var(--accent-focus);
+}
+button.subtle-button.active {
+ border-color: transparent;
+ color: #9da7b9;
+ background: transparent;
+}
+.status { justify-self: end; min-width: 76px; padding: 4px 8px; border-radius: 999px; text-align: center; font-size: 10px; font-weight: 700; letter-spacing: .03em; text-transform: uppercase; }
+.status.idle { color: #aeb6c5; background: #272d38; }
+.status.connecting { color: #f4ce78; background: #3a2f19; }
+.status.connected, .status.demo { color: #92efbb; background: #173627; }
+.status.error { color: #ff9b9b; background: #431e22; }
+
+.hint { margin: 12px 0 0; color: #737d8e; font-size: 10.5px; line-height: 1.45; }
+.connection-log { margin-top: 10px; }
+
+.field-subtitle {
+ margin: 5px 0 0;
+ color: #737d8e;
+ font-size: 9.5px;
+ font-weight: 400;
+ line-height: 1.25;
+}
+
+.display-scenes { margin-bottom: var(--section-space-relaxed); }
+.field-heading {
+ display: flex;
+ align-items: baseline;
+ justify-content: space-between;
+ gap: var(--section-space);
+ margin-bottom: 6px;
+}
+.field-heading label { margin: 0; }
+.display-scenes select { min-height: 34px; }
+.toggles { display: grid; grid-template-columns: 1fr 1fr; gap: 10px; }
+.toggle-column { display: grid; align-content: start; gap: var(--section-space); }
+.toggles label { margin: 0; display: flex; align-items: center; gap: 7px; font-weight: 500; color: #c5ccd8; }
+.toggles input {
+ width: 14px;
+ height: 14px;
+ min-height: 14px;
+ margin: 0;
+ padding: 0;
+ outline: none;
+ box-shadow: none;
+ accent-color: var(--accent);
+ -webkit-tap-highlight-color: transparent;
+}
+
+.debug-content { display: grid; gap: 9px; }
+.empty-state { padding: 22px 14px; border: 1px dashed #303746; border-radius: 8px; color: #778194; text-align: center; font-size: 11px; line-height: 1.5; }
+
+.debug-content details { border: 1px solid #2b313d; border-radius: 8px; background: #151922; overflow: hidden; }
+.debug-content summary { padding: 10px 11px; color: #dbe1eb; cursor: pointer; font-size: 11px; font-weight: 650; user-select: none; }
+.debug-content details[open] > summary { border-bottom: 1px solid #2b313d; }
+.debug-block { padding: 10px 11px; font-family: "SFMono-Regular", Consolas, "Liberation Mono", monospace; font-size: 10px; line-height: 1.55; color: #aeb8c9; white-space: pre-wrap; overflow-wrap: anywhere; }
+.debug-table { width: 100%; border-collapse: collapse; font-size: 10px; }
+.objects-table { table-layout: fixed; }
+.objects-table .id-column { width: 16%; }
+.objects-table .cluster-column { width: 55%; }
+.objects-table .points-column { width: 29%; }
+.debug-table th, .debug-table td { padding: 6px 8px; border-bottom: 1px solid #262c37; text-align: left; vertical-align: top; }
+.debug-table th { color: #7f8b9f; font-weight: 650; }
+.debug-table td {
+ color: #c4ccd8;
+ font-family: "SFMono-Regular", Consolas, monospace;
+ white-space: normal;
+ overflow-wrap: anywhere;
+ word-break: break-word;
+}
+.debug-table tr:last-child td { border-bottom: 0; }
+.muted { color: #6f798b; }
+
+/* Sidebar section hierarchy: compact top-level sections with denser Advanced controls. */
+.panel-section {
+ flex: 0 0 auto;
+ --section-title-size: 13px;
+ --advanced-title-size: 10.5px;
+ --disclosure-chevron-size: .54em;
+ --disclosure-chevron-stroke: .115em;
+ --section-space: 8px;
+ --section-space-relaxed: calc(var(--section-space) * 1.5);
+ --section-space-compact: calc(var(--section-space) * .5);
+
+ margin: 0;
+ padding: 0;
+ border: 0;
+ border-bottom: 1px solid #252a35;
+ border-radius: 0;
+ background: transparent;
+ overflow: visible;
+}
+
+.panel-section > summary {
+ display: grid;
+ grid-template-columns: 12px minmax(0, 1fr) auto;
+ align-items: center;
+ gap: 9px;
+ min-height: 42px;
+ margin: 0;
+ padding: 13px 18px;
+ border: 0;
+ color: #f5f7fb;
+ background: transparent;
+ font-size: var(--section-title-size);
+ cursor: pointer;
+ list-style: none;
+ user-select: none;
+}
+
+.panel-section > summary::-webkit-details-marker,
+.advanced-panel > summary::-webkit-details-marker {
+ display: none;
+}
+
+.panel-section > summary,
+.advanced-panel > summary {
+ -webkit-tap-highlight-color: transparent;
+}
+
+.panel-section > summary:hover {
+ background: rgba(255, 255, 255, .028);
+}
+
+.panel-section > summary:focus-visible {
+ outline: 1px solid #3b4351;
+ outline-offset: -1px;
+ background: #151922;
+}
+
+.panel-section[open] > summary {
+ border-bottom: 0;
+ padding-bottom: 12px;
+ background: #151922;
+}
+
+.panel-section[open] > summary:hover {
+ background: #181d26;
+}
+
+.panel-section:not([open]) > summary {
+ height: 44px;
+ min-height: 44px;
+ padding-top: 0;
+ padding-bottom: 0;
+}
+
+.section-chevron {
+ width: var(--disclosure-chevron-size);
+ height: var(--disclosure-chevron-size);
+ border-right: var(--disclosure-chevron-stroke) solid #7f899b;
+ border-bottom: var(--disclosure-chevron-stroke) solid #7f899b;
+ transform: rotate(-45deg);
+ transform-origin: center;
+ transition: transform 140ms ease, border-color 120ms ease;
+}
+
+.panel-section[open] > summary .section-chevron {
+ transform: rotate(45deg);
+ border-color: #aeb8c9;
+}
+
+.panel-heading-copy {
+ min-width: 0;
+ display: grid;
+ gap: 2px;
+}
+
+.panel-title {
+ font-size: 1em;
+ font-weight: 650;
+ letter-spacing: .01em;
+}
+
+.panel-subtitle {
+ color: #7f899b;
+ font-size: 11px;
+ font-weight: 400;
+}
+
+.panel-section:not([open]) > summary .panel-subtitle {
+ display: none;
+}
+
+.panel-section:not([open]) .panel-title {
+ overflow: hidden;
+ text-overflow: ellipsis;
+ white-space: nowrap;
+}
+
+.panel-summary-value {
+ justify-self: end;
+ max-width: 150px;
+ overflow: hidden;
+ color: #8f99aa;
+ font-size: 10.5px;
+ font-weight: 550;
+ text-overflow: ellipsis;
+ white-space: nowrap;
+}
+
+.panel-content {
+ padding: var(--section-space-relaxed) 18px var(--section-space);
+}
+
+.panel-content:has(> .panel-actions:first-child) {
+ padding-top: var(--section-space);
+}
+
+.advanced-panel {
+ margin-top: var(--section-space-relaxed);
+ margin-left: 0;
+ padding: 0;
+ border: 0;
+ border-radius: 0;
+ background: transparent;
+ overflow: visible;
+}
+
+.advanced-panel > summary {
+ display: grid;
+ grid-template-columns: 1em auto minmax(1.4em, 1fr) auto;
+ align-items: center;
+ gap: .55em;
+ min-height: 30px;
+ padding: var(--section-space-compact) 0;
+ border: 0;
+ color: #7f899b;
+ background: transparent;
+ cursor: pointer;
+ list-style: none;
+ font-size: var(--advanced-title-size);
+ user-select: none;
+ font-weight: 650;
+}
+
+.advanced-panel > summary::before,
+.advanced-panel > summary::after {
+ content: "";
+ grid-row: 1;
+ align-self: center;
+ height: 1px;
+ background: #2b313d;
+}
+
+.advanced-panel > summary::before {
+ grid-column: 1;
+}
+
+.advanced-panel > summary::after {
+ grid-column: 3;
+}
+
+.advanced-label {
+ grid-column: 2;
+ grid-row: 1;
+ display: inline-flex;
+ align-items: center;
+ gap: 7px;
+ white-space: nowrap;
+}
+
+.advanced-label::before {
+ content: "";
+ width: var(--disclosure-chevron-size);
+ height: var(--disclosure-chevron-size);
+ border-right: var(--disclosure-chevron-stroke) solid #737d8e;
+ border-bottom: var(--disclosure-chevron-stroke) solid #737d8e;
+ transform: rotate(-45deg);
+ transform-origin: center;
+ transition: transform 140ms ease, border-color 120ms ease;
+}
+
+.advanced-panel[open] > summary .advanced-label::before {
+ transform: rotate(45deg);
+ border-color: #9ba5b5;
+}
+
+.advanced-panel[open] > summary {
+ border-bottom: 0;
+}
+
+.advanced-summary-value {
+ grid-column: 4;
+ grid-row: 1;
+ align-self: center;
+ min-width: 0;
+ max-width: 130px;
+ overflow: hidden;
+ color: #697385;
+ font-size: 9.5px;
+ font-weight: 500;
+ text-overflow: ellipsis;
+ white-space: nowrap;
+}
+
+.advanced-content {
+ display: grid;
+ gap: var(--section-space);
+ padding: var(--section-space-compact) 0 0 14px;
+}
+
+.advanced-panel:last-child[open] > .advanced-content {
+ padding-bottom: var(--section-space-compact);
+}
+
+.advanced-grid {
+ display: grid;
+ gap: var(--section-space);
+}
+
+.connection-advanced-grid {
+ grid-template-columns: 72px minmax(0, 1fr) 112px;
+ align-items: start;
+}
+
+.advanced-panel label {
+ margin-bottom: 4px;
+ color: #8791a2;
+ font-size: 10px;
+}
+
+.advanced-panel input:not([type="checkbox"]),
+.advanced-panel select {
+ height: 32px;
+ min-height: 32px;
+ padding: 0 8px;
+ border-radius: 6px;
+ font-size: 11px;
+ line-height: 1.2;
+}
+
+.advanced-panel select {
+ line-height: normal;
+}
+
+.advanced-toggle {
+ margin: 0;
+ display: flex;
+ align-items: center;
+ gap: 7px;
+ font-weight: 500;
+ color: #aab3c2;
+}
+
+.advanced-toggle input {
+ width: 14px;
+ height: 14px;
+ min-height: 14px;
+ margin: 0;
+ padding: 0;
+ outline: none;
+ box-shadow: none;
+ accent-color: var(--accent);
+ -webkit-tap-highlight-color: transparent;
+}
+
+.toggles input:focus-visible,
+.advanced-toggle input:focus-visible {
+ outline: 2px solid var(--accent);
+ outline-offset: 2px;
+}
+
+.panel-section.debug-panel {
+ padding-bottom: 0;
+}
+
+.debug-panel > summary.section-summary {
+ position: sticky;
+ top: 0;
+ z-index: 3;
+}
+
+.panel-actions {
+ display: flex;
+ justify-content: flex-end;
+ margin: 0 0 var(--section-space);
+}
+
+.text-action {
+ min-height: auto;
+ padding: 0;
+ border: 0;
+ border-radius: 0;
+ color: #9da7b9;
+ background: transparent;
+ font-size: 10.5px;
+ text-decoration: none;
+}
+
+.text-action:hover {
+ color: var(--accent-hover);
+ background: transparent;
+}
+
+.text-action:focus-visible {
+ outline: 1px solid var(--accent);
+ outline-offset: 3px;
+}
+
+@media (max-width: 900px), (pointer: coarse) and (max-width: 1100px) {
+ body { overflow: auto; }
+
+ #app {
+ height: auto;
+ min-height: 100vh;
+ min-height: 100dvh;
+ grid-template-columns: 1fr;
+ grid-template-rows: 58vh auto;
+ grid-template-rows: 58dvh auto;
+ overflow: visible;
+ }
+
+ #app.sidebar-hidden {
+ height: 100vh;
+ height: 100dvh;
+ min-height: 100vh;
+ min-height: 100dvh;
+ grid-template-rows: 100vh 0;
+ grid-template-rows: 100dvh 0;
+ overflow: hidden;
+ }
+
+ #sidebar {
+ position: static;
+ width: auto;
+ height: auto;
+ overflow: visible;
+ border-left: 0;
+ border-top: 1px solid #252a35;
+ transform: none;
+ pointer-events: auto;
+ }
+
+ #app.sidebar-hidden #sidebar {
+ display: none;
+ }
+
+ .sidebar-resizer { display: none; }
+
+ .sidebar-toggle {
+ display: block;
+ top: 58vh;
+ top: 58dvh;
+ right: 12px;
+ bottom: auto;
+ width: 42px;
+ height: 28px;
+ min-height: 28px;
+ transform: translateY(-50%);
+ border: 1px solid #343b49;
+ border-radius: 999px;
+ background: rgba(23, 27, 35, .94);
+ font-size: 14px;
+ opacity: 1;
+ pointer-events: auto;
+ }
+
+ #app.sidebar-hidden .sidebar-toggle {
+ top: auto;
+ right: 12px;
+ bottom: max(12px, env(safe-area-inset-bottom));
+ transform: none;
+ }
+
+ #app.sidebar-handle-idle .sidebar-toggle {
+ opacity: 1;
+ pointer-events: auto;
+ }
+
+ #app.sidebar-hidden .connection-qr-visibility {
+ opacity: 1;
+ pointer-events: auto;
+ }
+
+ .viewer-overlay { padding: 16px; }
+ .connection-qr-visibility {
+ left: 12px;
+ bottom: 12px;
+ }
+ .connection-qr {
+ padding: 5px;
+ }
+ .connection-qr-code {
+ width: 80px;
+ height: 80px;
+ padding: 3px;
+ }
+ .connection-qr-label { font-size: 8.5px; }
+ .connection-advanced-grid {
+ grid-template-columns: 72px minmax(0, 1fr);
+ }
+ .connection-advanced-grid > :last-child {
+ grid-column: 1 / -1;
+ }
+}
+
+
+/* Web Demo visual controls */
+.visual-canvas {
+ width: 100%;
+ height: 100%;
+ display: block;
+ background: #090b11;
+}
+
+.visual-palette-field {
+ display: grid;
+ gap: 7px;
+ margin-top: 16px;
+}
+
+.visual-palette-field label {
+ color: #aeb6c5;
+ font-size: 11px;
+ font-weight: 600;
+}
+
+.visual-palette-field select {
+ width: 100%;
+}
+
+.visuals-panel .panel-subtitle {
+ color: #7f8999;
+}
diff --git a/src/viewer.js b/src/viewer.js
new file mode 100644
index 0000000..85ab4e9
--- /dev/null
+++ b/src/viewer.js
@@ -0,0 +1,479 @@
+const PALETTE = ['#32e6ff', '#ff4fd8', '#ffb020', '#8dff5a', '#7d7cff', '#ff5b64'];
+const BACKGROUND = '#090b11';
+const FADE_OUT_MS = 360;
+const DEFAULT_SCENE_SIZE = [10, 4, 8];
+
+export function createViewer(host) {
+ const canvas = document.createElement('canvas');
+ canvas.className = 'visual-canvas';
+ host.replaceChildren(canvas);
+
+ const ctx = canvas.getContext('2d', { alpha: false });
+ const trailsCanvas = document.createElement('canvas');
+ const trails = trailsCanvas.getContext('2d');
+ const objects = new Map();
+
+ let pixelRatio = 1;
+ let width = 1;
+ let height = 1;
+ let rightInset = 0;
+ let sceneSize = [...DEFAULT_SCENE_SIZE];
+ let sceneName = 'Scene';
+ let sceneAddress = '';
+ let setupZones = [];
+ let lastFrameTime = performance.now();
+
+ const visibility = {
+ clusters: true,
+ points: true,
+ scene: true,
+ zones: true,
+ vectors: true
+ };
+
+ const visuals = {
+ glow: true,
+ trails: true,
+ grid: true,
+ labels: true,
+ particles: true,
+ bounds: true,
+ palette: 'punchy'
+ };
+
+ function resize() {
+ const rect = host.getBoundingClientRect();
+ width = Math.max(1, Math.round(rect.width));
+ height = Math.max(1, Math.round(rect.height));
+ pixelRatio = Math.min(window.devicePixelRatio || 1, 2);
+
+ for (const target of [canvas, trailsCanvas]) {
+ target.width = Math.max(1, Math.round(width * pixelRatio));
+ target.height = Math.max(1, Math.round(height * pixelRatio));
+ }
+
+ ctx.setTransform(pixelRatio, 0, 0, pixelRatio, 0, 0);
+ trails.setTransform(pixelRatio, 0, 0, pixelRatio, 0, 0);
+ trails.clearRect(0, 0, width, height);
+ }
+
+ function renderSetup(root, selectedSceneAddress) {
+ const scenes = [];
+ const zones = [];
+
+ walkSetup(root, (container) => {
+ if (container.isScene?.()) scenes.push(container);
+ if (container.isZone?.()) zones.push(container);
+ });
+
+ const selected = scenes.find((scene) => scene.getAddress?.() === selectedSceneAddress)
+ ?? scenes[0];
+
+ if (selected) {
+ const size = selected.getSceneParameters?.()?.size;
+ if (Array.isArray(size) && size.length >= 2) sceneSize = size.map((v) => Number(v) || 0);
+ sceneName = selected.getName?.() || 'Scene';
+ sceneAddress = selected.getAddress?.() || '';
+ }
+
+ setupZones = zones.map((zone) => ({
+ name: zone.getName?.() || 'Zone',
+ position: safeVector(zone.getPosition?.(), [0, 0, 0])
+ }));
+ }
+
+ function clearSetup() {
+ sceneSize = [...DEFAULT_SCENE_SIZE];
+ sceneName = 'Scene';
+ sceneAddress = '';
+ setupZones = [];
+ }
+
+ function renderFrame(frame) {
+ const address = frame.getSceneInfo?.().getAddress?.() || '';
+ const now = performance.now();
+ const active = new Set();
+
+ frame.getObjects?.().forEach((object, index) => {
+ const id = object.getID?.();
+ const uuid = object.getUUID?.();
+ const key = `${address}|${uuid || `id:${id ?? index}`}`;
+ active.add(key);
+
+ let view = objects.get(key);
+ if (!view) {
+ view = createObjectView(key, id, uuid, objects.size);
+ objects.set(key, view);
+ }
+
+ view.lastSeen = now;
+ view.id = id;
+ view.uuid = uuid;
+
+ if (object.hasCluster?.()) {
+ const cluster = object.getCluster();
+ view.centerTarget = safeVector(cluster.getBoundingBoxCenter?.(), view.centerTarget);
+ view.sizeTarget = safeVector(cluster.getBoundingBoxSize?.(), view.sizeTarget);
+ view.centroidTarget = safeVector(cluster.getCentroid?.(), view.centerTarget);
+ view.velocityTarget = safeVector(cluster.getVelocity?.(), [0, 0, 0]);
+ view.lookAtTarget = safeVector(cluster.getLookAt?.(), [0, 0, 0]);
+ }
+
+ if (object.hasPointCloud?.()) {
+ const points = object.getPointCloud().getPointsData?.();
+ if (points?.length) view.points = points;
+ }
+ });
+
+ for (const [key, view] of objects) {
+ if (key.startsWith(`${address}|`) && !active.has(key)) view.lastSeen = Math.min(view.lastSeen, now - 1);
+ }
+ }
+
+ function createObjectView(key, id, uuid, index) {
+ return {
+ key,
+ id,
+ uuid,
+ color: PALETTE[index % PALETTE.length],
+ lastSeen: performance.now(),
+ center: [0, 0.8, 0],
+ centerTarget: [0, 0.8, 0],
+ size: [0.55, 1.7, 0.5],
+ sizeTarget: [0.55, 1.7, 0.5],
+ centroid: [0, 0.8, 0],
+ centroidTarget: [0, 0.8, 0],
+ velocity: [0, 0, 0],
+ velocityTarget: [0, 0, 0],
+ lookAtTarget: [0, 0, 1],
+ points: undefined
+ };
+ }
+
+ function animate(now) {
+ const dt = Math.min(Math.max(now - lastFrameTime, 0), 50);
+ lastFrameTime = now;
+ const easing = 1 - Math.exp(-dt / 65);
+
+ for (const [key, view] of objects) {
+ easeVector(view.center, view.centerTarget, easing);
+ easeVector(view.size, view.sizeTarget, easing);
+ easeVector(view.centroid, view.centroidTarget, easing);
+ easeVector(view.velocity, view.velocityTarget, easing);
+ if (now - view.lastSeen > FADE_OUT_MS) objects.delete(key);
+ }
+
+ draw(now);
+ requestAnimationFrame(animate);
+ }
+
+ function draw(now) {
+ drawBackground();
+ const projection = makeProjection();
+
+ if (visuals.trails) updateTrails(projection);
+ else trails.clearRect(0, 0, width, height);
+
+ if (visuals.grid) drawGrid(projection);
+ if (visibility.scene) drawSceneFrame(projection);
+ if (visuals.trails) ctx.drawImage(trailsCanvas, 0, 0, width, height);
+
+ const visibleObjects = [...objects.values()]
+ .filter((view) => now - view.lastSeen <= FADE_OUT_MS);
+
+ for (const view of visibleObjects) drawObject(view, projection, now);
+ if (visibility.zones) drawZones(projection);
+ drawHud(visibleObjects.length);
+ }
+
+ function drawBackground() {
+ ctx.fillStyle = BACKGROUND;
+ ctx.fillRect(0, 0, width, height);
+
+ const usableWidth = Math.max(1, width - rightInset);
+ const radial = ctx.createRadialGradient(
+ usableWidth * 0.5, height * 0.52, 0,
+ usableWidth * 0.5, height * 0.52, Math.max(usableWidth, height) * 0.72
+ );
+ radial.addColorStop(0, 'rgba(44, 53, 83, .22)');
+ radial.addColorStop(.48, 'rgba(25, 30, 48, .10)');
+ radial.addColorStop(1, 'rgba(9, 11, 17, 0)');
+ ctx.fillStyle = radial;
+ ctx.fillRect(0, 0, usableWidth, height);
+ }
+
+ function makeProjection() {
+ const usableWidth = Math.max(160, width - rightInset);
+ const marginX = Math.max(42, usableWidth * 0.08);
+ const top = Math.max(84, height * 0.14);
+ const bottom = height - Math.max(58, height * 0.11);
+ const sceneWidth = Math.max(Math.abs(sceneSize[0] || DEFAULT_SCENE_SIZE[0]), 1);
+ const sceneHeight = Math.max(Math.abs(sceneSize[1] || DEFAULT_SCENE_SIZE[1]), 1);
+ const scale = Math.min((usableWidth - marginX * 2) / sceneWidth, (bottom - top) / sceneHeight);
+ const centerX = usableWidth * 0.5;
+
+ return {
+ usableWidth,
+ marginX,
+ top,
+ bottom,
+ sceneWidth,
+ sceneHeight,
+ scale,
+ x: (worldX) => centerX + worldX * scale,
+ y: (worldY) => bottom - worldY * scale
+ };
+ }
+
+ function updateTrails(p) {
+ trails.save();
+ trails.globalCompositeOperation = 'destination-out';
+ trails.fillStyle = 'rgba(0,0,0,.085)';
+ trails.fillRect(0, 0, width, height);
+ trails.restore();
+
+ trails.save();
+ trails.globalCompositeOperation = 'lighter';
+ for (const view of objects.values()) {
+ if (!view.points?.length) continue;
+ drawPointCloud(trails, { ...view, color: paletteColor(view.color, visuals.palette) }, p, .12, 1.2, false);
+ }
+ trails.restore();
+ }
+
+ function drawGrid(p) {
+ ctx.save();
+ ctx.strokeStyle = 'rgba(139, 160, 205, .11)';
+ ctx.lineWidth = 1;
+
+ const columns = 12;
+ const rows = 8;
+ for (let i = 0; i <= columns; i++) {
+ const x = p.marginX + (p.usableWidth - p.marginX * 2) * (i / columns);
+ line(ctx, x, p.top, x, p.bottom);
+ }
+ for (let i = 0; i <= rows; i++) {
+ const y = p.top + (p.bottom - p.top) * (i / rows);
+ line(ctx, p.marginX, y, p.usableWidth - p.marginX, y);
+ }
+
+ ctx.strokeStyle = 'rgba(195, 215, 255, .26)';
+ ctx.lineWidth = 1.2;
+ line(ctx, p.marginX, p.bottom, p.usableWidth - p.marginX, p.bottom);
+ ctx.restore();
+ }
+
+ function drawSceneFrame(p) {
+ ctx.save();
+ ctx.strokeStyle = 'rgba(210, 222, 255, .2)';
+ ctx.lineWidth = 1;
+ ctx.setLineDash([6, 8]);
+ ctx.strokeRect(p.marginX, p.top, p.usableWidth - p.marginX * 2, p.bottom - p.top);
+ ctx.restore();
+ }
+
+ function drawObject(view, p, now) {
+ const alpha = Math.max(0, Math.min(1, (FADE_OUT_MS - (now - view.lastSeen)) / 120));
+ const color = paletteColor(view.color, visuals.palette);
+
+ if (visibility.points && visuals.particles && view.points?.length) {
+ drawPointCloud(ctx, {...view, color}, p, .84 * alpha, visuals.glow ? 2.1 : 1.65, visuals.glow);
+ }
+
+ if (visibility.clusters && visuals.bounds) {
+ const halfW = Math.max(Math.abs(view.size[0]), .08) * p.scale * .5;
+ const h = Math.max(Math.abs(view.size[1]), .15) * p.scale;
+ const cx = p.x(view.center[0]);
+ const floorY = p.y(Math.max(0, view.center[1] - Math.abs(view.size[1]) * .5));
+ const topY = floorY - h;
+
+ ctx.save();
+ ctx.strokeStyle = withAlpha(color, .88 * alpha);
+ ctx.lineWidth = 1.35;
+ if (visuals.glow) {
+ ctx.shadowColor = color;
+ ctx.shadowBlur = 10;
+ }
+ ctx.strokeRect(cx - halfW, topY, halfW * 2, h);
+ drawCorners(cx - halfW, topY, halfW * 2, h, color, alpha);
+ ctx.restore();
+
+ if (visuals.labels) drawObjectLabel(view, cx - halfW, topY, color, alpha);
+ }
+
+ if (visibility.vectors) drawMotion(view, p, color, alpha);
+ }
+
+ function drawPointCloud(target, view, p, alpha, radius, glow) {
+ const data = view.points;
+ if (!data?.length) return;
+
+ target.save();
+ target.fillStyle = withAlpha(view.color || '#ffffff', alpha);
+ if (glow) {
+ target.shadowColor = view.color || '#ffffff';
+ target.shadowBlur = 8;
+ }
+
+ const stride = data.length > 2400 ? 6 : 3;
+ for (let i = 0; i + 1 < data.length; i += stride) {
+ const x = p.x(data[i]);
+ const y = p.y(data[i + 1]);
+ if (x < 0 || x > p.usableWidth || y < 0 || y > height) continue;
+ target.beginPath();
+ target.arc(x, y, radius, 0, Math.PI * 2);
+ target.fill();
+ }
+ target.restore();
+ }
+
+ function drawCorners(x, y, w, h, color, alpha) {
+ const corner = Math.min(14, Math.max(7, Math.min(w, h) * .12));
+ ctx.save();
+ ctx.strokeStyle = withAlpha(color, alpha);
+ ctx.lineWidth = 2.3;
+ for (const [sx, sy] of [[1,1],[-1,1],[1,-1],[-1,-1]]) {
+ const px = sx > 0 ? x : x + w;
+ const py = sy > 0 ? y : y + h;
+ line(ctx, px, py, px + sx * corner, py);
+ line(ctx, px, py, px, py + sy * corner);
+ }
+ ctx.restore();
+ }
+
+ function drawObjectLabel(view, x, y, color, alpha) {
+ const label = view.id !== undefined ? `ID ${view.id}` : view.uuid?.slice(0, 8) || 'OBJECT';
+ ctx.save();
+ ctx.font = '600 10px Inter, system-ui, sans-serif';
+ ctx.fillStyle = withAlpha(color, .92 * alpha);
+ ctx.fillText(label, x, Math.max(18, y - 8));
+ ctx.restore();
+ }
+
+ function drawMotion(view, p, color, alpha) {
+ const vx = view.velocity[0] || 0;
+ const vy = view.velocity[1] || 0;
+ const magnitude = Math.hypot(vx, vy);
+ if (magnitude < .015) return;
+
+ const x = p.x(view.centroid[0]);
+ const y = p.y(view.centroid[1]);
+ const length = Math.min(54, 12 + magnitude * 28);
+ const nx = vx / magnitude;
+ const ny = -vy / magnitude;
+
+ ctx.save();
+ ctx.strokeStyle = withAlpha(color, .62 * alpha);
+ ctx.fillStyle = withAlpha(color, .62 * alpha);
+ ctx.lineWidth = 1.2;
+ line(ctx, x, y, x + nx * length, y + ny * length);
+ ctx.beginPath();
+ ctx.arc(x, y, 3, 0, Math.PI * 2);
+ ctx.fill();
+ ctx.restore();
+ }
+
+ function drawZones(p) {
+ if (!setupZones.length) return;
+ ctx.save();
+ ctx.font = '500 9px Inter, system-ui, sans-serif';
+ setupZones.slice(0, 8).forEach((zone) => {
+ const x = p.x(zone.position[0]);
+ const y = p.bottom + 18;
+ ctx.strokeStyle = 'rgba(191,166,255,.42)';
+ line(ctx, x, p.bottom - 7, x, p.bottom + 7);
+ ctx.fillStyle = 'rgba(191,166,255,.62)';
+ ctx.fillText(zone.name, x + 5, y);
+ });
+ ctx.restore();
+ }
+
+ function drawHud(people) {
+ ctx.save();
+ ctx.fillStyle = 'rgba(232, 239, 255, .74)';
+ ctx.font = '600 10px Inter, system-ui, sans-serif';
+ const dims = sceneSize.slice(0, 3).map((v) => Number(v).toFixed(1)).join(' × ');
+ ctx.fillText(`DIMENSIONS ${dims} m`, 24, 30);
+ ctx.fillText(`PEOPLE ${people}`, 24, 46);
+
+ ctx.textAlign = 'right';
+ ctx.fillStyle = 'rgba(183, 197, 224, .44)';
+ ctx.font = '500 9px Inter, system-ui, sans-serif';
+ ctx.fillText(sceneName || sceneAddress || 'AUGMENTA', Math.max(24, width - rightInset - 24), 30);
+ ctx.restore();
+ }
+
+ function setVisibility(next) {
+ Object.assign(visibility, next);
+ }
+
+ function setVisualOptions(next) {
+ Object.assign(visuals, next);
+ if (!visuals.trails) trails.clearRect(0, 0, width, height);
+ }
+
+ function clearTracking() {
+ objects.clear();
+ trails.clearRect(0, 0, width, height);
+ }
+
+ function setRightInset(value) {
+ rightInset = Math.max(0, Number(value) || 0);
+ }
+
+ const observer = new ResizeObserver(resize);
+ observer.observe(host);
+ resize();
+ requestAnimationFrame(animate);
+
+ return {
+ renderFrame,
+ renderSetup,
+ clearTracking,
+ clearSetup,
+ setRightInset,
+ setVisibility,
+ setVisualOptions,
+ resetCamera() { trails.clearRect(0, 0, width, height); },
+ getCameraView() { return undefined; },
+ setCameraView() { return false; },
+ setCameraChangeHandler() {},
+ isCameraUserControlled() { return false; }
+ };
+}
+
+function walkSetup(container, visit) {
+ if (!container) return;
+ visit(container);
+ for (const child of container.getChildren?.() || []) walkSetup(child, visit);
+}
+
+function safeVector(value, fallback) {
+ if (!Array.isArray(value) || value.length < 3) return [...fallback];
+ return value.slice(0, 3).map((v, i) => Number.isFinite(Number(v)) ? Number(v) : fallback[i]);
+}
+
+function easeVector(current, target, amount) {
+ for (let i = 0; i < current.length; i++) current[i] += (target[i] - current[i]) * amount;
+}
+
+function line(ctx, x1, y1, x2, y2) {
+ ctx.beginPath();
+ ctx.moveTo(x1, y1);
+ ctx.lineTo(x2, y2);
+ ctx.stroke();
+}
+
+function withAlpha(hex, alpha) {
+ const value = hex.replace('#', '');
+ const r = Number.parseInt(value.slice(0, 2), 16);
+ const g = Number.parseInt(value.slice(2, 4), 16);
+ const b = Number.parseInt(value.slice(4, 6), 16);
+ return `rgba(${r}, ${g}, ${b}, ${Math.max(0, Math.min(1, alpha))})`;
+}
+
+function paletteColor(color, mode) {
+ if (mode === 'ice') return '#58e7ff';
+ if (mode === 'mono') return '#f1f5ff';
+ return color;
+}
diff --git a/src/zone-state.js b/src/zone-state.js
new file mode 100644
index 0000000..57d9fad
--- /dev/null
+++ b/src/zone-state.js
@@ -0,0 +1,50 @@
+export function readZoneEventState(event) {
+ const state = {
+ address: event.getEmitterZoneAddress(),
+ presence: event.getPresence(),
+ slider: undefined,
+ xyPad: undefined
+ };
+
+ for (const property of event.getProperties()) {
+ if (property.isSlider()) {
+ state.slider = property.getSliderParameters().value;
+ } else if (property.isXYPad()) {
+ const value = property.getXYPadParameters();
+ state.xyPad = { x: value.x, y: value.y };
+ }
+ }
+
+ return state;
+}
+
+
+export function collectZoneAddresses(container, result = new Set()) {
+ if (!container) return result;
+
+ if (container.isZone()) {
+ const address = container.getAddress();
+ if (address) result.add(address);
+ }
+
+ for (const child of container.getChildren()) {
+ collectZoneAddresses(child, result);
+ }
+
+ return result;
+}
+
+
+export const ZONE_STREAM_STALE_MS = 250;
+
+export function isZoneStreamFresh(lastSeenAt, now, staleMs = ZONE_STREAM_STALE_MS) {
+ return Number.isFinite(lastSeenAt)
+ && Number.isFinite(now)
+ && now - lastSeenAt <= staleMs;
+}
+
+export function zonePresencePulseDirection(previousPresence, nextPresence) {
+ if (!Number.isFinite(previousPresence) || !Number.isFinite(nextPresence)) return 0;
+ if (nextPresence === previousPresence) return 0;
+ return nextPresence > previousPresence ? 1 : -1;
+}
diff --git a/src/zones.js b/src/zones.js
new file mode 100644
index 0000000..37d262e
--- /dev/null
+++ b/src/zones.js
@@ -0,0 +1,831 @@
+import * as THREE from 'three';
+import { LineMaterial } from 'three/addons/lines/LineMaterial.js';
+import { LineSegments2 } from 'three/addons/lines/LineSegments2.js';
+import { LineSegmentsGeometry } from 'three/addons/lines/LineSegmentsGeometry.js';
+import { ShapeType } from 'augmenta-client-sdk';
+import { isZoneStreamFresh, readZoneEventState, zonePresencePulseDirection } from './zone-state.js';
+
+const ZONE_PRESENCE_LABEL_GAP = 0.32;
+const ZONE_PRESENCE_LABEL_OPACITY = 0.78;
+const ZONE_PRESENCE_LABEL_PULSE_DURATION_MS = 220;
+const ZONE_PRESENCE_LABEL_PULSE_SCALE = 0.08;
+const ZONE_PRESENCE_LABEL_FLASH_DURATION_MS = 220;
+const ZONE_FILL_OPACITY = 0.025;
+const ZONE_VISUAL_COLOR = new THREE.Color(0x969ba3);
+const ZONE_OUTLINE_COLOR = ZONE_VISUAL_COLOR;
+const ZONE_ACTIVE_OUTLINE_COLOR = new THREE.Color(0xe4e7ec);
+const ZONE_IDLE_OUTLINE_OPACITY = 0.14;
+const ZONE_ACTIVE_OUTLINE_OPACITY = 0.62;
+const ZONE_ACTIVE_OUTLINE_PULSE_OPACITY = 0.72;
+const ZONE_OUTLINE_WIDTH = 1.7;
+const ZONE_ACTIVE_OUTLINE_WIDTH = 2.6;
+const ROUND_ZONE_EDGE_WIDTH = 1.0;
+const ROUND_ZONE_ACTIVE_EDGE_WIDTH = 2.1;
+const ZONE_SILHOUETTE_IDLE_WIDTH = 0.16;
+const ZONE_SILHOUETTE_ACTIVE_WIDTH = 0.23;
+const ZONE_OUTLINE_PULSE_DURATION_MS = 1400;
+const ZONE_XY_PAD_FILL_OPACITY = 0.18;
+const ZONE_XY_PAD_IDLE_FILL_OPACITY = 0.07;
+const ZONE_XY_PAD_AXIS_OPACITY = 0.78;
+const ZONE_XY_PAD_IDLE_AXIS_OPACITY = 0.28;
+const ZONE_XY_PAD_AXIS_WIDTH = 1.5;
+const ZONE_SLIDER_FILL_OPACITY = 0.22;
+const ZONE_SLIDER_IDLE_FILL_OPACITY = 0.08;
+const ZONE_VALUE_IDLE_COLOR = new THREE.Color(0xb0b5bd);
+const ROUND_OUTLINE_SEGMENTS = 32;
+
+export function createZoneRenderer() {
+ const views = new Map();
+ const presenceByAddress = new Map();
+ const xyPadByAddress = new Map();
+ const sliderByAddress = new Map();
+ const lastSeenByAddress = new Map();
+
+ function resetViews() {
+ views.clear();
+ }
+
+ function addZone(container, group) {
+ const params = container.getZoneParameters();
+ const geometry = zoneGeometry(params);
+ if (!geometry) return;
+
+ const visualGroup = new THREE.Group();
+ visualGroup.name = 'Zone visuals';
+ group.add(visualGroup);
+
+ const presenceGeometry = geometry.clone();
+ const outline = createZoneOutline(params, geometry, ZONE_OUTLINE_COLOR);
+ geometry.dispose();
+
+ if (params.isBox()) {
+ const size = params.getBoxShapeParameters().size;
+ // Box size is a magnitude; preserve the requested right-handed -Z
+ // direction for the local [0..size] Augmenta box volume.
+ outline.position.set(size[0] / 2, size[1] / 2, -size[2] / 2);
+ } else if (params.isCylinder()) {
+ outline.position.y = params.getCylinderShapeParameters().height / 2;
+ }
+
+ const presenceMesh = new THREE.Mesh(
+ presenceGeometry,
+ new THREE.MeshBasicMaterial({
+ color: ZONE_VISUAL_COLOR,
+ transparent: true,
+ opacity: ZONE_FILL_OPACITY,
+ side: THREE.DoubleSide,
+ depthTest: false,
+ depthWrite: false
+ })
+ );
+ presenceMesh.name = 'Zone presence';
+ presenceMesh.position.copy(outline.position);
+ presenceMesh.rotation.copy(outline.rotation);
+ presenceMesh.renderOrder = 5;
+
+ outline.renderOrder = 6;
+ visualGroup.add(presenceMesh, outline);
+
+ const xyPad = params.isBox() ? createBoxXYPad(params) : undefined;
+ if (xyPad) visualGroup.add(xyPad.group);
+
+ const slider = params.isCylinder() || params.isSphere()
+ ? createRoundZoneSlider(params, presenceGeometry)
+ : undefined;
+ if (slider) {
+ slider.mesh.position.copy(outline.position);
+ slider.mesh.rotation.copy(outline.rotation);
+ visualGroup.add(slider.mesh);
+ }
+
+ const address = container.getAddress();
+ if (!address) return;
+
+ const label = createZonePresenceLabel('', zoneLabelOutlineWidths(params));
+ label.visible = false;
+ positionZoneLabel(label, params);
+ visualGroup.add(label);
+
+ const lastSeenAt = lastSeenByAddress.get(address);
+ const view = {
+ address,
+ visualGroup,
+ lastSeenAt,
+ label,
+ labelText: '',
+ labelBaseScale: label.scale.clone(),
+ presence: 0,
+ presenceMesh,
+ outline,
+ xyPad,
+ slider,
+ presenceStartedAt: 0,
+ labelPulseStartedAt: 0,
+ labelFlashStartedAt: 0,
+ labelPulseDirection: 1,
+ labelHideAfterPulse: false
+ };
+ visualGroup.visible = isZoneStreamFresh(lastSeenAt, performance.now());
+
+ views.set(address, view);
+ setPresence(view, presenceByAddress.get(address) ?? 0);
+
+ const xyPadValue = xyPadByAddress.get(address);
+ if (xyPadValue) updateBoxXYPad(view.xyPad, xyPadValue.x, xyPadValue.y);
+
+ const sliderValue = sliderByAddress.get(address);
+ if (sliderValue !== undefined) updateRoundZoneSlider(view.slider, sliderValue);
+ }
+
+ function update(events, now = performance.now()) {
+ for (const event of events) {
+ const state = readZoneEventState(event);
+ const { address, presence } = state;
+
+ // Pleiades emits zone events only for zones currently processed/enabled.
+ // Keep a short heartbeat so a disabled or removed zone disappears even
+ // if its setup/control entry remains available.
+ lastSeenByAddress.set(address, now);
+
+ // Cache the complete event before touching the view. Setup/control
+ // messages can race live data, so a zone created on the next setup
+ // render must still recover its latest presence/slider/XY state.
+ presenceByAddress.set(address, presence);
+ if (state.slider !== undefined) sliderByAddress.set(address, state.slider);
+ if (state.xyPad) xyPadByAddress.set(address, state.xyPad);
+
+ const view = views.get(address);
+ if (!view) continue;
+
+ view.lastSeenAt = now;
+ view.visualGroup.visible = true;
+ setPresence(view, presence);
+ if (state.slider !== undefined) updateRoundZoneSlider(view.slider, state.slider);
+ if (state.xyPad) updateBoxXYPad(view.xyPad, state.xyPad.x, state.xyPad.y);
+ }
+ }
+
+ function clearPresence() {
+ presenceByAddress.clear();
+ xyPadByAddress.clear();
+ sliderByAddress.clear();
+ lastSeenByAddress.clear();
+
+ for (const view of views.values()) {
+ view.lastSeenAt = undefined;
+ view.visualGroup.visible = false;
+ setPresence(view, 0, false);
+ if (view.xyPad) view.xyPad.group.visible = false;
+ if (view.slider) view.slider.mesh.visible = false;
+ }
+ }
+
+ function pruneState(validAddresses) {
+ for (const cache of [
+ presenceByAddress,
+ xyPadByAddress,
+ sliderByAddress,
+ lastSeenByAddress
+ ]) {
+ for (const address of cache.keys()) {
+ if (!validAddresses.has(address)) cache.delete(address);
+ }
+ }
+ }
+
+ function animate(now) {
+ for (const view of views.values()) {
+ if (!isZoneStreamFresh(view.lastSeenAt, now)) {
+ // Hide stale zone visuals without resetting their last live state.
+ // Short packet gaps should not retrigger presence/label transitions
+ // when the stream resumes.
+ if (view.visualGroup.visible) view.visualGroup.visible = false;
+ continue;
+ }
+
+ if (!view.visualGroup.visible) view.visualGroup.visible = true;
+
+ if (view.presence > 0) {
+ const elapsed = Math.max(0, now - view.presenceStartedAt);
+ const phase = (elapsed % ZONE_OUTLINE_PULSE_DURATION_MS) / ZONE_OUTLINE_PULSE_DURATION_MS;
+ const pulse = 0.5 - 0.5 * Math.cos(phase * Math.PI * 2);
+ updateOutlineStyle(view, true, pulse);
+ }
+
+ if (view.labelPulseStartedAt > 0 && view.label.visible) {
+ const elapsed = now - view.labelPulseStartedAt;
+ if (elapsed < ZONE_PRESENCE_LABEL_PULSE_DURATION_MS) {
+ const phase = elapsed / ZONE_PRESENCE_LABEL_PULSE_DURATION_MS;
+ const pulse = Math.sin(Math.PI * phase);
+ view.label.scale.copy(view.labelBaseScale).multiplyScalar(
+ 1 + view.labelPulseDirection * pulse * ZONE_PRESENCE_LABEL_PULSE_SCALE
+ );
+ view.label.material.opacity = ZONE_PRESENCE_LABEL_OPACITY;
+ } else {
+ view.labelPulseStartedAt = 0;
+ view.label.scale.copy(view.labelBaseScale);
+ view.label.material.opacity = ZONE_PRESENCE_LABEL_OPACITY;
+ if (view.labelHideAfterPulse) {
+ view.label.visible = false;
+ view.labelHideAfterPulse = false;
+ }
+ }
+ }
+
+ if (
+ view.labelFlashStartedAt > 0
+ && now - view.labelFlashStartedAt >= ZONE_PRESENCE_LABEL_FLASH_DURATION_MS
+ ) {
+ view.labelFlashStartedAt = 0;
+ replaceZonePresenceLabelTexture(view.label, view.labelText, false, view.presence > 0);
+ }
+ }
+ }
+
+ return { addZone, update, clearPresence, pruneState, resetViews, animate };
+}
+
+function setPresence(view, presence, animateChange = true) {
+ const nextPresence = Number.isFinite(presence) ? Math.max(0, presence) : 0;
+ const previousPresence = view.presence;
+ const active = nextPresence > 0;
+ const now = performance.now();
+ const pulseDirection = animateChange
+ ? zonePresencePulseDirection(previousPresence, nextPresence)
+ : 0;
+ const presenceChanged = pulseDirection !== 0;
+
+ // Presence changes the outline only. The faint neutral fill stays constant.
+ view.presenceMesh.material.opacity = ZONE_FILL_OPACITY;
+ if (active && previousPresence <= 0) view.presenceStartedAt = now;
+ if (!active) view.presenceStartedAt = 0;
+ updateOutlineStyle(view, active, 0);
+ updateZoneValueStyle(view, active);
+
+ if (presenceChanged) {
+ view.labelPulseStartedAt = now;
+ view.labelPulseDirection = pulseDirection;
+ }
+
+ if (!active) {
+ view.labelFlashStartedAt = 0;
+ view.presence = nextPresence;
+
+ // Keep the zero label alive for the short downward pop, then hide it.
+ // Disconnect/reset paths pass animateChange=false and hide immediately.
+ if (presenceChanged && previousPresence > 0) {
+ const text = String(nextPresence);
+ replaceZonePresenceLabelTexture(view.label, text, false, false);
+ view.labelBaseScale.copy(view.label.scale);
+ view.labelText = text;
+ view.label.visible = true;
+ view.labelHideAfterPulse = true;
+ return;
+ }
+
+ view.label.visible = false;
+ view.labelPulseStartedAt = 0;
+ view.labelHideAfterPulse = false;
+ view.label.scale.copy(view.labelBaseScale);
+ view.label.material.opacity = ZONE_PRESENCE_LABEL_OPACITY;
+ return;
+ }
+
+ view.labelHideAfterPulse = false;
+ if (presenceChanged) view.labelFlashStartedAt = now;
+
+ const text = String(nextPresence);
+ if (view.labelText !== text || presenceChanged) {
+ replaceZonePresenceLabelTexture(view.label, text, presenceChanged, true);
+ view.labelBaseScale.copy(view.label.scale);
+ view.labelText = text;
+ }
+ view.label.visible = true;
+ view.presence = nextPresence;
+}
+
+function updateOutlineStyle(view, active, pulse) {
+ const color = active ? ZONE_ACTIVE_OUTLINE_COLOR : ZONE_OUTLINE_COLOR;
+ const opacity = active
+ ? THREE.MathUtils.lerp(
+ ZONE_ACTIVE_OUTLINE_OPACITY,
+ ZONE_ACTIVE_OUTLINE_PULSE_OPACITY,
+ pulse
+ )
+ : ZONE_IDLE_OUTLINE_OPACITY;
+
+ view.outline.traverse((object) => {
+ const material = object.material;
+ if (!material) return;
+
+ if (material.isShaderMaterial && material.uniforms?.outlineColor) {
+ material.uniforms.outlineColor.value.copy(color);
+ material.uniforms.outlineOpacity.value = opacity;
+ material.uniforms.edgeWidth.value = active
+ ? THREE.MathUtils.lerp(
+ ZONE_SILHOUETTE_ACTIVE_WIDTH,
+ ZONE_SILHOUETTE_ACTIVE_WIDTH * 1.06,
+ pulse
+ )
+ : ZONE_SILHOUETTE_IDLE_WIDTH;
+ } else if (material.isLineMaterial) {
+ material.color.copy(color);
+ material.opacity = opacity;
+ const idleWidth = object.userData.idleWidth ?? material.linewidth;
+ const activeWidth = object.userData.activeWidth ?? idleWidth;
+ material.linewidth = active
+ ? THREE.MathUtils.lerp(activeWidth, activeWidth * 1.05, pulse)
+ : idleWidth;
+ }
+ });
+}
+
+function updateZoneValueStyle(view, active) {
+ const color = active ? ZONE_ACTIVE_OUTLINE_COLOR : ZONE_VALUE_IDLE_COLOR;
+
+ if (view.xyPad) {
+ view.xyPad.axes.material.color.copy(color);
+ view.xyPad.axes.material.opacity = active
+ ? ZONE_XY_PAD_AXIS_OPACITY
+ : ZONE_XY_PAD_IDLE_AXIS_OPACITY;
+ view.xyPad.fill.material.color.copy(color);
+ view.xyPad.fill.material.opacity = active
+ ? ZONE_XY_PAD_FILL_OPACITY
+ : ZONE_XY_PAD_IDLE_FILL_OPACITY;
+ }
+
+ if (view.slider) {
+ const uniforms = view.slider.mesh.material.uniforms;
+ uniforms.fillColor.value.copy(color);
+ uniforms.fillOpacity.value = active
+ ? ZONE_SLIDER_FILL_OPACITY
+ : ZONE_SLIDER_IDLE_FILL_OPACITY;
+ }
+}
+
+function positionZoneLabel(label, params) {
+ let x = 0;
+ let y = -ZONE_PRESENCE_LABEL_GAP;
+ let z = 0;
+
+ if (params.isBox()) {
+ const size = params.getBoxShapeParameters().size;
+ x = size[0] / 2;
+ z = -size[2] / 2;
+ } else if (params.isSphere()) {
+ y = -Math.abs(params.getSphereShapeParameters().radius) - ZONE_PRESENCE_LABEL_GAP;
+ }
+
+ label.position.set(x, y, z);
+}
+
+function zoneGeometry(params) {
+ switch (params.getShapeType()) {
+ case ShapeType.Box:
+ return new THREE.BoxGeometry(...positiveSize(params.getBoxShapeParameters().size));
+
+ case ShapeType.Cylinder: {
+ const { radius, height } = params.getCylinderShapeParameters();
+ return new THREE.CylinderGeometry(
+ Math.max(Math.abs(radius), 0.001),
+ Math.max(Math.abs(radius), 0.001),
+ Math.max(Math.abs(height), 0.001),
+ 32
+ );
+ }
+
+ case ShapeType.Sphere:
+ return new THREE.SphereGeometry(
+ Math.max(Math.abs(params.getSphereShapeParameters().radius), 0.001),
+ 24,
+ 16
+ );
+
+ default:
+ return undefined;
+ }
+}
+
+function createZoneOutline(params, sourceGeometry, color) {
+ const outline = new THREE.Group();
+ outline.name = 'Zone outline';
+
+ // Round zones keep the camera-dependent silhouette. Boxes use explicit edges.
+ if (!params.isBox()) {
+ const silhouette = new THREE.Mesh(
+ sourceGeometry.clone(),
+ createSilhouetteMaterial(color)
+ );
+ silhouette.name = 'Zone silhouette';
+ silhouette.renderOrder = 6;
+ outline.add(silhouette);
+ }
+
+ if (params.isBox()) {
+ const edges = new THREE.EdgesGeometry(sourceGeometry);
+ const positions = Array.from(edges.attributes.position.array);
+ edges.dispose();
+
+ const boxEdges = createWideLineSegments(
+ positions,
+ color,
+ ZONE_OUTLINE_WIDTH,
+ 'Zone edges',
+ ZONE_IDLE_OUTLINE_OPACITY
+ );
+ boxEdges.userData.idleWidth = ZONE_OUTLINE_WIDTH;
+ boxEdges.userData.activeWidth = ZONE_ACTIVE_OUTLINE_WIDTH;
+ boxEdges.renderOrder = 7;
+ outline.add(boxEdges);
+ return outline;
+ }
+
+ let guidePositions = [];
+ if (params.isCylinder()) {
+ const { radius, height } = params.getCylinderShapeParameters();
+ guidePositions = cylinderRingPositions(
+ Math.max(Math.abs(radius), 0.001),
+ Math.max(Math.abs(height), 0.001)
+ );
+ } else if (params.isSphere()) {
+ guidePositions = sphereEquatorPositions(
+ Math.max(Math.abs(params.getSphereShapeParameters().radius), 0.001)
+ );
+ }
+
+ if (guidePositions.length) {
+ const guides = createWideLineSegments(
+ guidePositions,
+ color,
+ ROUND_ZONE_EDGE_WIDTH,
+ 'Zone guide',
+ ZONE_IDLE_OUTLINE_OPACITY
+ );
+ guides.userData.idleWidth = ROUND_ZONE_EDGE_WIDTH;
+ guides.userData.activeWidth = ROUND_ZONE_ACTIVE_EDGE_WIDTH;
+ guides.renderOrder = 7;
+ outline.add(guides);
+ }
+
+ return outline;
+}
+
+function createSilhouetteMaterial(color) {
+ return new THREE.ShaderMaterial({
+ uniforms: {
+ outlineColor: { value: color.clone() },
+ outlineOpacity: { value: ZONE_IDLE_OUTLINE_OPACITY },
+ edgeWidth: { value: ZONE_SILHOUETTE_IDLE_WIDTH }
+ },
+ vertexShader: `
+ varying vec3 vNormal;
+ varying vec3 vViewDirection;
+
+ void main() {
+ vec4 viewPosition = modelViewMatrix * vec4(position, 1.0);
+ vNormal = normalize(normalMatrix * normal);
+ vViewDirection = normalize(-viewPosition.xyz);
+ gl_Position = projectionMatrix * viewPosition;
+ }
+ `,
+ fragmentShader: `
+ uniform vec3 outlineColor;
+ uniform float outlineOpacity;
+ uniform float edgeWidth;
+ varying vec3 vNormal;
+ varying vec3 vViewDirection;
+
+ void main() {
+ float facing = abs(dot(normalize(vNormal), normalize(vViewDirection)));
+ float alpha = 1.0 - smoothstep(0.0, edgeWidth, facing);
+ if (alpha < 0.01) discard;
+ gl_FragColor = vec4(outlineColor, alpha * outlineOpacity);
+ }
+ `,
+ transparent: true,
+ depthTest: false,
+ depthWrite: false,
+ side: THREE.DoubleSide
+ });
+}
+
+function createWideLineSegments(positions, color, width, name, opacity = 0.99) {
+ const geometry = new LineSegmentsGeometry();
+ geometry.setPositions(positions);
+ geometry.computeBoundingBox();
+ geometry.computeBoundingSphere();
+
+ const material = new LineMaterial({
+ color: color.getHex(),
+ linewidth: width,
+ transparent: true,
+ opacity,
+ depthTest: false,
+ depthWrite: false
+ });
+ material.resolution.set(
+ Math.max(window.innerWidth, 1),
+ Math.max(window.innerHeight, 1)
+ );
+
+ const lines = new LineSegments2(geometry, material);
+ lines.name = name;
+ lines.renderOrder = 7;
+ return lines;
+}
+
+function cylinderRingPositions(radius, height) {
+ const positions = [];
+ const halfHeight = height / 2;
+
+ for (const y of [-halfHeight, halfHeight]) {
+ addCircleSegments(positions, ROUND_OUTLINE_SEGMENTS, (angle) => [
+ Math.cos(angle) * radius,
+ y,
+ Math.sin(angle) * radius
+ ]);
+ }
+
+ return positions;
+}
+
+function sphereEquatorPositions(radius) {
+ const positions = [];
+ addCircleSegments(positions, ROUND_OUTLINE_SEGMENTS, (angle) => [
+ Math.cos(angle) * radius,
+ 0,
+ Math.sin(angle) * radius
+ ]);
+ return positions;
+}
+
+function addCircleSegments(target, segments, pointAt) {
+ for (let i = 0; i < segments; i++) {
+ const a = i / segments * Math.PI * 2;
+ const b = (i + 1) / segments * Math.PI * 2;
+ target.push(...pointAt(a), ...pointAt(b));
+ }
+}
+
+function createBoxXYPad(params) {
+ const size = params.getBoxShapeParameters().size;
+ const width = Math.max(Math.abs(size[0]), 0.001);
+ const depth = Math.max(Math.abs(size[2]), 0.001);
+ const y = 0.008;
+
+ const axes = createWideLineSegments(
+ [0, y, 0, 0, y, -depth, 0, y, 0, width, y, 0],
+ ZONE_VALUE_IDLE_COLOR,
+ ZONE_XY_PAD_AXIS_WIDTH,
+ 'Zone XY pad axes'
+ );
+ axes.material.opacity = ZONE_XY_PAD_IDLE_AXIS_OPACITY;
+ axes.renderOrder = 8;
+
+ const fillGeometry = new THREE.BufferGeometry();
+ fillGeometry.setAttribute(
+ 'position',
+ new THREE.BufferAttribute(new Float32Array(12), 3)
+ );
+ fillGeometry.setIndex([0, 1, 2, 2, 1, 3]);
+
+ const fill = new THREE.Mesh(
+ fillGeometry,
+ new THREE.MeshBasicMaterial({
+ color: ZONE_VALUE_IDLE_COLOR,
+ transparent: true,
+ opacity: ZONE_XY_PAD_IDLE_FILL_OPACITY,
+ side: THREE.DoubleSide,
+ depthTest: false,
+ depthWrite: false
+ })
+ );
+ fill.name = 'Zone XY pad quadrant';
+ fill.renderOrder = 7;
+
+ const group = new THREE.Group();
+ group.name = 'Zone XY pad';
+ group.visible = false;
+ group.add(fill, axes);
+
+ return { group, axes, fill, width, depth, y };
+}
+
+function updateBoxXYPad(xyPad, rawX, rawY) {
+ if (!xyPad || !Number.isFinite(rawX) || !Number.isFinite(rawY)) return;
+
+ const x = THREE.MathUtils.clamp(rawX, 0, 1);
+ const y = THREE.MathUtils.clamp(rawY, 0, 1);
+ const px = x * xyPad.width;
+ const pz = -y * xyPad.depth;
+ const floorY = xyPad.y;
+
+ xyPad.axes.geometry.setPositions([
+ px, floorY, 0,
+ px, floorY, -xyPad.depth,
+ 0, floorY, pz,
+ xyPad.width, floorY, pz
+ ]);
+ xyPad.axes.geometry.computeBoundingBox();
+ xyPad.axes.geometry.computeBoundingSphere();
+
+ const positions = xyPad.fill.geometry.getAttribute('position');
+ positions.array.set([
+ 0, floorY, 0,
+ px, floorY, 0,
+ 0, floorY, pz,
+ px, floorY, pz
+ ]);
+ positions.needsUpdate = true;
+ xyPad.fill.geometry.computeBoundingBox();
+ xyPad.fill.geometry.computeBoundingSphere();
+ xyPad.group.visible = true;
+}
+
+function createRoundZoneSlider(params, sourceGeometry) {
+ const axisName = params.getLocalSliderAxis();
+ const axis = axisName === 'y'
+ ? new THREE.Vector3(0, 1, 0)
+ : axisName === 'z'
+ ? new THREE.Vector3(0, 0, 1)
+ : new THREE.Vector3(1, 0, 0);
+
+ let min = -0.5;
+ let max = 0.5;
+ if (params.isCylinder()) {
+ const { radius, height } = params.getCylinderShapeParameters();
+ const r = Math.max(Math.abs(radius), 0.001);
+ const h = Math.max(Math.abs(height), 0.001);
+ min = axisName === 'y' ? -h / 2 : -r;
+ max = axisName === 'y' ? h / 2 : r;
+ } else if (params.isSphere()) {
+ const radius = Math.max(Math.abs(params.getSphereShapeParameters().radius), 0.001);
+ min = -radius;
+ max = radius;
+ }
+
+ const material = new THREE.ShaderMaterial({
+ uniforms: {
+ fillColor: { value: ZONE_VALUE_IDLE_COLOR.clone() },
+ fillOpacity: { value: ZONE_SLIDER_IDLE_FILL_OPACITY },
+ sliderAxis: { value: axis },
+ sliderMin: { value: min },
+ sliderMax: { value: max },
+ sliderValue: { value: 0 }
+ },
+ vertexShader: `
+ varying vec3 vLocalPosition;
+
+ void main() {
+ vLocalPosition = position;
+ gl_Position = projectionMatrix * modelViewMatrix * vec4(position, 1.0);
+ }
+ `,
+ fragmentShader: `
+ uniform vec3 fillColor;
+ uniform float fillOpacity;
+ uniform vec3 sliderAxis;
+ uniform float sliderMin;
+ uniform float sliderMax;
+ uniform float sliderValue;
+ varying vec3 vLocalPosition;
+
+ void main() {
+ float span = max(sliderMax - sliderMin, 0.0001);
+ float coordinate = dot(vLocalPosition, sliderAxis);
+ float normalizedCoordinate = (coordinate - sliderMin) / span;
+ if (normalizedCoordinate > sliderValue) discard;
+ gl_FragColor = vec4(fillColor, fillOpacity);
+ }
+ `,
+ transparent: true,
+ depthTest: false,
+ depthWrite: false,
+ side: THREE.DoubleSide
+ });
+
+ const mesh = new THREE.Mesh(sourceGeometry.clone(), material);
+ mesh.name = 'Zone slider fill';
+ mesh.visible = false;
+ mesh.renderOrder = 6;
+
+ return { mesh };
+}
+
+function updateRoundZoneSlider(slider, rawValue) {
+ if (!slider || !Number.isFinite(rawValue)) return;
+
+ // The WebSocket slider value is normalized by default in Pleiades. Clamp to
+ // the drawable range so malformed/out-of-range values cannot overfill.
+ slider.mesh.material.uniforms.sliderValue.value = THREE.MathUtils.clamp(rawValue, 0, 1);
+ slider.mesh.visible = true;
+}
+
+function zoneLabelOutlineWidths(params) {
+ return params.isBox()
+ ? { idle: ZONE_OUTLINE_WIDTH, active: ZONE_ACTIVE_OUTLINE_WIDTH }
+ : { idle: ROUND_ZONE_EDGE_WIDTH, active: ROUND_ZONE_ACTIVE_EDGE_WIDTH };
+}
+
+function createZonePresenceLabel(text, outlineWidths) {
+ const texture = makeZonePresenceLabelTexture(text, false, false, outlineWidths);
+ const material = new THREE.SpriteMaterial({
+ map: texture,
+ transparent: true,
+ depthTest: false,
+ depthWrite: false,
+ opacity: ZONE_PRESENCE_LABEL_OPACITY
+ });
+ const sprite = new THREE.Sprite(material);
+ sprite.userData.outlineWidths = outlineWidths;
+ setZonePresenceLabelScale(sprite, texture);
+ sprite.renderOrder = 10;
+ return sprite;
+}
+
+function replaceZonePresenceLabelTexture(sprite, text, flash = false, active = true) {
+ sprite.material.map?.dispose();
+ const texture = makeZonePresenceLabelTexture(
+ text,
+ flash,
+ active,
+ sprite.userData.outlineWidths
+ );
+ sprite.material.map = texture;
+ setZonePresenceLabelScale(sprite, texture);
+ sprite.material.needsUpdate = true;
+}
+
+function setZonePresenceLabelScale(sprite, texture) {
+ const height = 0.24;
+ const width = height * (texture.image.width / texture.image.height);
+ sprite.scale.set(width, height, 1);
+}
+
+function makeZonePresenceLabelTexture(
+ text,
+ flash = false,
+ active = true,
+ outlineWidths = { idle: ZONE_OUTLINE_WIDTH, active: ZONE_ACTIVE_OUTLINE_WIDTH }
+) {
+ const canvas = document.createElement('canvas');
+ const ctx = canvas.getContext('2d');
+ if (!ctx) return new THREE.CanvasTexture(canvas);
+
+ const font = '700 36px Inter, Arial, sans-serif';
+ const height = 72;
+ const horizontalPadding = 28;
+ ctx.font = font;
+
+ const measuredWidth = Math.ceil(ctx.measureText(text || '0').width);
+ const width = Math.max(height, measuredWidth + horizontalPadding * 2);
+
+ canvas.width = width;
+ canvas.height = height;
+
+ // Canvas resize resets the context state.
+ ctx.font = font;
+ ctx.clearRect(0, 0, width, height);
+
+ const outlineWidth = active ? outlineWidths.active : outlineWidths.idle;
+ const borderInset = outlineWidth / 2;
+ roundedRect(
+ ctx,
+ borderInset,
+ borderInset,
+ width - outlineWidth,
+ height - outlineWidth,
+ 8
+ );
+
+ ctx.fillStyle = `#${ZONE_OUTLINE_COLOR.getHexString()}`;
+ ctx.globalAlpha = flash ? 0.82 : 0.66;
+ ctx.fill();
+
+ ctx.strokeStyle = `#${(active ? ZONE_ACTIVE_OUTLINE_COLOR : ZONE_OUTLINE_COLOR).getHexString()}`;
+ ctx.globalAlpha = flash ? 0.95 : active ? 0.8 : 0.55;
+ ctx.lineWidth = outlineWidth;
+ ctx.stroke();
+
+ ctx.globalAlpha = 1;
+ ctx.fillStyle = '#15181e';
+ ctx.textAlign = 'center';
+ ctx.textBaseline = 'middle';
+ ctx.fillText(text, width / 2, height / 2 + 1);
+
+ const texture = new THREE.CanvasTexture(canvas);
+ texture.colorSpace = THREE.SRGBColorSpace;
+ texture.minFilter = THREE.LinearFilter;
+ texture.magFilter = THREE.LinearFilter;
+ texture.generateMipmaps = false;
+ return texture;
+}
+
+function roundedRect(ctx, x, y, width, height, radius) {
+ ctx.beginPath();
+ ctx.roundRect(x, y, width, height, radius);
+}
+
+function positiveSize(size) {
+ return size.map((v) => Math.max(Math.abs(v), 0.001));
+}
diff --git a/tests/connection-targets.test.mjs b/tests/connection-targets.test.mjs
new file mode 100644
index 0000000..1010901
--- /dev/null
+++ b/tests/connection-targets.test.mjs
@@ -0,0 +1,52 @@
+import assert from 'node:assert/strict';
+import test from 'node:test';
+import { buildConnectionTargets } from '../src/connection.js';
+
+function urls(address) {
+ return buildConnectionTargets(address, 6060).map((target) => target.url);
+}
+
+test('simple hostnames try useful local-network suffixes before giving up', () => {
+ assert.deepEqual(urls('augmenta-WA12031'), [
+ 'ws://augmenta-WA12031:6060',
+ 'ws://augmenta-WA12031.local:6060',
+ 'ws://augmenta-WA12031.home:6060',
+ 'ws://augmenta-WA12031.home.arpa:6060',
+ 'wss://augmenta-WA12031:6060',
+ 'wss://augmenta-WA12031.local:6060',
+ 'wss://augmenta-WA12031.home:6060',
+ 'wss://augmenta-WA12031.home.arpa:6060'
+ ]);
+
+ assert.equal(urls('surface-david-2')[1], 'ws://surface-david-2.local:6060');
+ assert.equal(urls('surface-david-2')[2], 'ws://surface-david-2.home:6060');
+});
+
+test('localhost prefers IPv4 and keeps hostname/IPv6 fallbacks', () => {
+ assert.deepEqual(urls('localhost'), [
+ 'ws://127.0.0.1:6060',
+ 'ws://localhost:6060',
+ 'ws://[::1]:6060',
+ 'wss://127.0.0.1:6060',
+ 'wss://localhost:6060',
+ 'wss://[::1]:6060'
+ ]);
+});
+
+test('qualified hostnames and IP addresses are used as-is', () => {
+ assert.deepEqual(urls('surface-david-2.home'), [
+ 'ws://surface-david-2.home:6060',
+ 'wss://surface-david-2.home:6060'
+ ]);
+ assert.deepEqual(urls('192.168.1.200'), [
+ 'ws://192.168.1.200:6060',
+ 'wss://192.168.1.200:6060'
+ ]);
+});
+
+test('qualified hosts keep ws before wss', () => {
+ assert.deepEqual(urls('augmenta-WA12031.local'), [
+ 'ws://augmenta-WA12031.local:6060',
+ 'wss://augmenta-WA12031.local:6060'
+ ]);
+});
diff --git a/tests/demo.test.mjs b/tests/demo.test.mjs
new file mode 100644
index 0000000..63ce98f
--- /dev/null
+++ b/tests/demo.test.mjs
@@ -0,0 +1,16 @@
+import assert from 'node:assert/strict';
+import test from 'node:test';
+import { makeDemoSetup } from '../src/demo.js';
+
+test('demo setup uses the same World -> Scene hierarchy as live setup', () => {
+ const root = makeDemoSetup().getRootObject();
+
+ assert.equal(root.isWorld(), true);
+ assert.equal(root.getChildren().length, 1);
+
+ const scene = root.getChildren()[0];
+ assert.equal(scene.isScene(), true);
+ assert.equal(scene.getAddress(), '/Demo');
+ assert.equal(scene.getChildren().length, 2);
+ assert.equal(scene.getChildren().every((child) => child.isZone()), true);
+});
diff --git a/tests/setup-store.test.mjs b/tests/setup-store.test.mjs
new file mode 100644
index 0000000..cd18012
--- /dev/null
+++ b/tests/setup-store.test.mjs
@@ -0,0 +1,171 @@
+import assert from 'node:assert/strict';
+import test from 'node:test';
+import {
+ Container, ContainerType, ShapeType, ZoneParameters
+} from 'augmenta-client-sdk';
+import { createSetupStore } from '../src/setup-store.js';
+
+function zone(
+ address,
+ position = [1, 0, 1],
+ size = [2, 1, 2],
+ rotation = [0, 0, 0]
+) {
+ return new Container(
+ ContainerType.Zone,
+ 'Zone',
+ address,
+ position,
+ rotation,
+ [1, 1, 1, 1],
+ new ZoneParameters(ShapeType.Box, { size }),
+ []
+ );
+}
+
+function scene(address, size = [10, 3, 8], children = []) {
+ return new Container(
+ ContainerType.Scene,
+ 'Scene',
+ address,
+ [0, 0, 0],
+ [0, 0, 0],
+ [1, 1, 1, 1],
+ { size },
+ children
+ );
+}
+
+function world(children) {
+ return new Container(
+ ContainerType.World,
+ 'World',
+ '',
+ [0, 0, 0],
+ [0, 0, 0],
+ [1, 1, 1, 1],
+ {},
+ children
+ );
+}
+
+test('Scene updates preserve existing Zone children', () => {
+ const store = createSetupStore();
+ store.setRoot(world([scene('/world/scene', [10, 3, 8], [zone('/world/scene/zone')])]));
+
+ store.applyUpdate(scene('/world/scene', [12, 4, 9]));
+
+ const updatedScene = store.getScenes()[0];
+ assert.deepEqual(updatedScene.getSceneParameters().size, [12, 4, 9]);
+ assert.equal(updatedScene.getChildren().length, 1);
+ assert.equal(updatedScene.getChildren()[0].getAddress(), '/world/scene/zone');
+});
+
+test('Zone updates replace only the matching Zone', () => {
+ const store = createSetupStore();
+ store.setRoot(world([scene('/world/scene', [10, 3, 8], [
+ zone('/world/scene/a', [1, 0, 1]),
+ zone('/world/scene/b', [2, 0, 2])
+ ])]));
+
+ store.applyUpdate(zone('/world/scene/a', [5, 0, 6]));
+
+ const zones = store.getScenes()[0].getChildren();
+ assert.deepEqual(zones[0].getPosition(), [5, 0, 6]);
+ assert.deepEqual(zones[1].getPosition(), [2, 0, 2]);
+});
+
+test('Zone geometry updates propagate without changing siblings', () => {
+ const store = createSetupStore();
+ store.setRoot(world([scene('/world/scene', [10, 3, 8], [
+ zone('/world/scene/a'),
+ zone('/world/scene/b', [2, 0, 2])
+ ])]));
+
+ store.applyUpdate(zone(
+ '/world/scene/a',
+ [4, 0, 5],
+ [4, 2, 3],
+ [0, 35, 0]
+ ));
+
+ const zones = store.getScenes()[0].getChildren();
+ assert.deepEqual(zones[0].getPosition(), [4, 0, 5]);
+ assert.deepEqual(zones[0].getRotation(), [0, 35, 0]);
+ assert.deepEqual(zones[0].getZoneParameters().getBoxShapeParameters().size, [4, 2, 3]);
+ assert.deepEqual(zones[1].getPosition(), [2, 0, 2]);
+ assert.deepEqual(zones[1].getZoneParameters().getBoxShapeParameters().size, [2, 1, 2]);
+});
+
+test('New Zone updates attach to the deepest matching parent', () => {
+ const store = createSetupStore();
+ store.setRoot(world([scene('/world/scene')]));
+
+ store.applyUpdate(zone('/world/scene/new-zone', [3, 0, 4]));
+
+ const zones = store.getScenes()[0].getChildren();
+ assert.equal(zones.length, 1);
+ assert.equal(zones[0].getAddress(), '/world/scene/new-zone');
+});
+
+
+test('Address index follows merged setup updates', () => {
+ const store = createSetupStore();
+ store.setRoot(world([scene('/world/scene', [10, 3, 8], [
+ zone('/world/scene/a', [1, 0, 1])
+ ])]));
+
+ assert.equal(store.getByAddress('/world/scene/a').getName(), 'Zone');
+
+ store.applyUpdate(zone('/world/scene/a', [7, 0, 8]));
+
+ assert.deepEqual(
+ store.getByAddress('/world/scene/a').getPosition(),
+ [7, 0, 8]
+ );
+});
+
+test('Partial Zone updates are ignored until a hierarchy root exists', () => {
+ const store = createSetupStore();
+
+ assert.equal(store.applyUpdate(zone('/world/scene/a', [5, 0, 6])), undefined);
+ assert.equal(store.getRoot(), undefined);
+ assert.equal(store.getByAddress('/world/scene/a'), undefined);
+
+ store.setRoot(world([scene('/world/scene', [10, 3, 8], [
+ zone('/world/scene/a', [1, 0, 1])
+ ])]));
+
+ store.applyUpdate(zone('/world/scene/a', [5, 0, 6]));
+ assert.deepEqual(store.getByAddress('/world/scene/a').getPosition(), [5, 0, 6]);
+});
+
+test('Parent subtree updates replace renamed children without duplicates', () => {
+ const store = createSetupStore();
+ store.setRoot(world([scene('/children/scene', [10, 3, 8], [
+ zone('/children/scene/children/oldZone', [1, 0, 1])
+ ])]));
+
+ store.applyUpdate(scene('/children/scene', [10, 3, 8], [
+ zone('/children/scene/children/newZone', [1, 0, 1])
+ ]));
+
+ const children = store.getScenes()[0].getChildren();
+ assert.equal(children.length, 1);
+ assert.equal(children[0].getAddress(), '/children/scene/children/newZone');
+ assert.equal(store.getByAddress('/children/scene/children/oldZone'), undefined);
+});
+
+
+test('Partial Scene updates are ignored until a hierarchy root exists', () => {
+ const store = createSetupStore();
+
+ assert.equal(store.applyUpdate(scene('/world/scene', [12, 4, 9])), undefined);
+ assert.equal(store.getRoot(), undefined);
+ assert.equal(store.getScenes().length, 0);
+
+ store.setRoot(world([scene('/world/scene', [10, 3, 8])]));
+ store.applyUpdate(scene('/world/scene', [12, 4, 9]));
+
+ assert.deepEqual(store.getScenes()[0].getSceneParameters().size, [12, 4, 9]);
+});
diff --git a/tests/share-link.test.mjs b/tests/share-link.test.mjs
new file mode 100644
index 0000000..6dc2cbf
--- /dev/null
+++ b/tests/share-link.test.mjs
@@ -0,0 +1,117 @@
+import assert from 'node:assert/strict';
+import test from 'node:test';
+import {
+ buildConnectionShareUrl,
+ normalizeConnectionOptions,
+ readConnectionOptionsFromUrl,
+ resolveConnectionOptions
+} from '../src/share-link.js';
+
+test('connection share URL preserves the page and embeds current connection options', () => {
+ const url = buildConnectionShareUrl(
+ 'https://augmenta-tech.github.io/Augmenta-ThreeJS-example/?foo=bar#debug',
+ {
+ address: '192.168.1.42',
+ port: '6060',
+ protocol: '3',
+ downsample: '2'
+ }
+ );
+
+ const parsed = new URL(url);
+ assert.equal(parsed.origin + parsed.pathname, 'https://augmenta-tech.github.io/Augmenta-ThreeJS-example/');
+ assert.equal(parsed.searchParams.get('foo'), 'bar');
+ assert.equal(parsed.searchParams.get('address'), '192.168.1.42');
+ assert.equal(parsed.searchParams.get('port'), '6060');
+ assert.equal(parsed.searchParams.get('protocol'), '3');
+ assert.equal(parsed.searchParams.get('downsample'), '2');
+ assert.equal(parsed.hash, '');
+});
+
+test('connection options in a shared URL are validated', () => {
+ assert.deepEqual(
+ readConnectionOptionsFromUrl(
+ 'https://example.test/?address=augmenta-WA12031.local&port=6060&protocol=auto&downsample=4'
+ ),
+ {
+ address: 'augmenta-WA12031.local',
+ port: '6060',
+ protocol: 'auto',
+ downsample: '4'
+ }
+ );
+
+ assert.deepEqual(
+ readConnectionOptionsFromUrl(
+ 'https://example.test/?address=%20&port=70000&protocol=9&downsample=0'
+ ),
+ {}
+ );
+});
+
+test('regenerating a share URL replaces stale connection query values', () => {
+ const url = buildConnectionShareUrl(
+ 'https://example.test/?address=old&port=1&protocol=2&downsample=99',
+ {
+ address: '10.0.0.5',
+ port: 6060,
+ protocol: 'auto',
+ downsample: 1
+ }
+ );
+
+ assert.deepEqual(readConnectionOptionsFromUrl(url), {
+ address: '10.0.0.5',
+ port: '6060',
+ protocol: 'auto',
+ downsample: '1'
+ });
+});
+
+
+test('shared connection options override a local view without mutating local preferences', () => {
+ const local = {
+ address: 'augmenta-local.local',
+ port: '6060',
+ protocol: 'auto',
+ downsample: '1'
+ };
+ const shared = {
+ address: '192.168.1.42',
+ protocol: '3'
+ };
+
+ assert.deepEqual(resolveConnectionOptions(local, shared), {
+ address: '192.168.1.42',
+ port: '6060',
+ protocol: '3',
+ downsample: '1'
+ });
+ assert.deepEqual(local, {
+ address: 'augmenta-local.local',
+ port: '6060',
+ protocol: 'auto',
+ downsample: '1'
+ });
+});
+
+test('connection option normalization keeps only valid local values', () => {
+ assert.deepEqual(normalizeConnectionOptions({
+ address: ' augmenta-server ',
+ port: 6060,
+ protocol: '2',
+ downsample: 4
+ }), {
+ address: 'augmenta-server',
+ port: '6060',
+ protocol: '2',
+ downsample: '4'
+ });
+
+ assert.deepEqual(normalizeConnectionOptions({
+ address: ' ',
+ port: 0,
+ protocol: '7',
+ downsample: -1
+ }), {});
+});
diff --git a/tests/zone-slider-parser.test.mjs b/tests/zone-slider-parser.test.mjs
new file mode 100644
index 0000000..cd77e0d
--- /dev/null
+++ b/tests/zone-slider-parser.test.mjs
@@ -0,0 +1,68 @@
+import assert from 'node:assert/strict';
+import test from 'node:test';
+import {
+ Client,
+ ZonePropertyType
+} from '../vendor/AugmentaClientSDK-JS/dist/esm/index.js';
+
+const concat = (...parts) => {
+ const size = parts.reduce((total, part) => total + part.length, 0);
+ const output = new Uint8Array(size);
+ let offset = 0;
+ for (const part of parts) {
+ output.set(part, offset);
+ offset += part.length;
+ }
+ return output;
+};
+
+const i32 = (value) => {
+ const bytes = new Uint8Array(4);
+ new DataView(bytes.buffer).setInt32(0, value, true);
+ return bytes;
+};
+
+const f32 = (value) => {
+ const bytes = new Uint8Array(4);
+ new DataView(bytes.buffer).setFloat32(0, value, true);
+ return bytes;
+};
+
+const u8 = (value) => Uint8Array.of(value);
+const str = (value) => new TextEncoder().encode(value);
+
+function packet(type, payload) {
+ return concat(i32(5 + payload.length), u8(type), payload);
+}
+
+test('Pleiades multi-value slider property keeps the current average value', () => {
+ const address = str('/world/scene/zone');
+ // Pleiades writes slider average first, followed by max/min when
+ // "Send Min and Max slider values" is enabled.
+ const sliderPayload = concat(f32(0.42), f32(0.9), f32(0.1));
+ const slider = concat(
+ i32(5 + sliderPayload.length),
+ u8(ZonePropertyType.Slider),
+ sliderPayload
+ );
+
+ const zone = packet(1, concat(
+ i32(address.length), address,
+ u8(0), u8(0), i32(1), f32(0),
+ i32(1), slider
+ ));
+
+ const bundle = packet(255, concat(i32(1), zone));
+
+ const client = new Client();
+ client.initialize('slider-regression', {
+ version: 2,
+ useCompression: false
+ });
+
+ const event = client.parseDataBlob(bundle).getZoneEvents()[0];
+ const property = event.getProperties().find((item) => item.isSlider());
+
+ assert.ok(property);
+ assert.ok(Math.abs(property.getSliderParameters().value - 0.42) < 1e-6);
+});
diff --git a/tests/zone-state.test.mjs b/tests/zone-state.test.mjs
new file mode 100644
index 0000000..80b5d54
--- /dev/null
+++ b/tests/zone-state.test.mjs
@@ -0,0 +1,100 @@
+import assert from 'node:assert/strict';
+import test from 'node:test';
+import { collectZoneAddresses, isZoneStreamFresh, readZoneEventState, zonePresencePulseDirection, ZONE_STREAM_STALE_MS } from '../src/zone-state.js';
+
+function slider(value) {
+ return {
+ isSlider: () => true,
+ isXYPad: () => false,
+ getSliderParameters: () => ({ value })
+ };
+}
+
+function xyPad(x, y) {
+ return {
+ isSlider: () => false,
+ isXYPad: () => true,
+ getXYPadParameters: () => ({ x, y })
+ };
+}
+
+test('zone event state is complete before a renderer view exists', () => {
+ const state = readZoneEventState({
+ getEmitterZoneAddress: () => '/scene/zone',
+ getPresence: () => 3,
+ getProperties: () => [slider(0.42), xyPad(0.25, 0.75)]
+ });
+
+ assert.deepEqual(state, {
+ address: '/scene/zone',
+ presence: 3,
+ slider: 0.42,
+ xyPad: { x: 0.25, y: 0.75 }
+ });
+});
+
+test('missing optional zone properties stay undefined', () => {
+ const state = readZoneEventState({
+ getEmitterZoneAddress: () => '/scene/zone',
+ getPresence: () => 0,
+ getProperties: () => []
+ });
+
+ assert.equal(state.slider, undefined);
+ assert.equal(state.xyPad, undefined);
+});
+
+
+test('zone address collection follows the full setup hierarchy', () => {
+ const node = (address, zone, children = []) => ({
+ isZone: () => zone,
+ getAddress: () => address,
+ getChildren: () => children
+ });
+
+ const root = node('', false, [
+ node('/scene-a', false, [
+ node('/scene-a/zone-1', true),
+ node('/scene-a/group', false, [
+ node('/scene-a/group/zone-2', true)
+ ])
+ ]),
+ node('/scene-b', false, [
+ node('/scene-b/zone-3', true)
+ ])
+ ]);
+
+ assert.deepEqual(
+ [...collectZoneAddresses(root)].sort(),
+ ['/scene-a/group/zone-2', '/scene-a/zone-1', '/scene-b/zone-3']
+ );
+});
+
+
+test('zone stream stale grace period stays at 250 ms', () => {
+ assert.equal(ZONE_STREAM_STALE_MS, 250);
+});
+
+test('zone stream stays visible only within the stale grace period', () => {
+ const lastSeenAt = 1000;
+
+ assert.equal(isZoneStreamFresh(lastSeenAt, lastSeenAt), true);
+ assert.equal(
+ isZoneStreamFresh(lastSeenAt, lastSeenAt + ZONE_STREAM_STALE_MS),
+ true
+ );
+ assert.equal(
+ isZoneStreamFresh(lastSeenAt, lastSeenAt + ZONE_STREAM_STALE_MS + 1),
+ false
+ );
+ assert.equal(isZoneStreamFresh(undefined, lastSeenAt), false);
+});
+
+
+test('presence pop direction follows increases and decreases including zero', () => {
+ assert.equal(zonePresencePulseDirection(0, 1), 1);
+ assert.equal(zonePresencePulseDirection(1, 2), 1);
+ assert.equal(zonePresencePulseDirection(2, 1), -1);
+ assert.equal(zonePresencePulseDirection(1, 0), -1);
+ assert.equal(zonePresencePulseDirection(2, 2), 0);
+});
diff --git a/vendor/AugmentaClientSDK-JS b/vendor/AugmentaClientSDK-JS
new file mode 160000
index 0000000..115b77c
--- /dev/null
+++ b/vendor/AugmentaClientSDK-JS
@@ -0,0 +1 @@
+Subproject commit 115b77c159bf1b6893a0d60164f99d4901a18a31
diff --git a/vendor/qrcode-generator/qrcode.js b/vendor/qrcode-generator/qrcode.js
new file mode 100644
index 0000000..4445225
--- /dev/null
+++ b/vendor/qrcode-generator/qrcode.js
@@ -0,0 +1,2237 @@
+//---------------------------------------------------------------------
+//
+// QR Code Generator for JavaScript
+//
+// Copyright (c) 2009 Kazuhiko Arase
+//
+// URL: http://www.d-project.com/
+//
+// Licensed under the MIT license:
+// http://www.opensource.org/licenses/mit-license.php
+//
+// The word 'QR Code' is registered trademark of
+// DENSO WAVE INCORPORATED
+// http://www.denso-wave.com/qrcode/faqpatent-e.html
+//
+//---------------------------------------------------------------------
+
+//---------------------------------------------------------------------
+// qrcode
+//---------------------------------------------------------------------
+
+/**
+ * qrcode
+ * @param typeNumber 1 to 40
+ * @param errorCorrectionLevel 'L','M','Q','H'
+ */
+export const qrcode = function(typeNumber, errorCorrectionLevel) {
+
+ const PAD0 = 0xEC;
+ const PAD1 = 0x11;
+
+ let _typeNumber = typeNumber;
+ const _errorCorrectionLevel = QRErrorCorrectionLevel[errorCorrectionLevel];
+ let _modules = null;
+ let _moduleCount = 0;
+ let _dataCache = null;
+ const _dataList = [];
+
+ const _this = {};
+
+ const makeImpl = function(test, maskPattern) {
+
+ _moduleCount = _typeNumber * 4 + 17;
+ _modules = function(moduleCount) {
+ const modules = new Array(moduleCount);
+ for (let row = 0; row < moduleCount; row += 1) {
+ modules[row] = new Array(moduleCount);
+ for (let col = 0; col < moduleCount; col += 1) {
+ modules[row][col] = null;
+ }
+ }
+ return modules;
+ }(_moduleCount);
+
+ setupPositionProbePattern(0, 0);
+ setupPositionProbePattern(_moduleCount - 7, 0);
+ setupPositionProbePattern(0, _moduleCount - 7);
+ setupPositionAdjustPattern();
+ setupTimingPattern();
+ setupTypeInfo(test, maskPattern);
+
+ if (_typeNumber >= 7) {
+ setupTypeNumber(test);
+ }
+
+ if (_dataCache == null) {
+ _dataCache = createData(_typeNumber, _errorCorrectionLevel, _dataList);
+ }
+
+ mapData(_dataCache, maskPattern);
+ };
+
+ const setupPositionProbePattern = function(row, col) {
+
+ for (let r = -1; r <= 7; r += 1) {
+
+ if (row + r <= -1 || _moduleCount <= row + r) continue;
+
+ for (let c = -1; c <= 7; c += 1) {
+
+ if (col + c <= -1 || _moduleCount <= col + c) continue;
+
+ if ( (0 <= r && r <= 6 && (c == 0 || c == 6) )
+ || (0 <= c && c <= 6 && (r == 0 || r == 6) )
+ || (2 <= r && r <= 4 && 2 <= c && c <= 4) ) {
+ _modules[row + r][col + c] = true;
+ } else {
+ _modules[row + r][col + c] = false;
+ }
+ }
+ }
+ };
+
+ const getBestMaskPattern = function() {
+
+ let minLostPoint = 0;
+ let pattern = 0;
+
+ for (let i = 0; i < 8; i += 1) {
+
+ makeImpl(true, i);
+
+ const lostPoint = QRUtil.getLostPoint(_this);
+
+ if (i == 0 || minLostPoint > lostPoint) {
+ minLostPoint = lostPoint;
+ pattern = i;
+ }
+ }
+
+ return pattern;
+ };
+
+ const setupTimingPattern = function() {
+
+ for (let r = 8; r < _moduleCount - 8; r += 1) {
+ if (_modules[r][6] != null) {
+ continue;
+ }
+ _modules[r][6] = (r % 2 == 0);
+ }
+
+ for (let c = 8; c < _moduleCount - 8; c += 1) {
+ if (_modules[6][c] != null) {
+ continue;
+ }
+ _modules[6][c] = (c % 2 == 0);
+ }
+ };
+
+ const setupPositionAdjustPattern = function() {
+
+ const pos = QRUtil.getPatternPosition(_typeNumber);
+
+ for (let i = 0; i < pos.length; i += 1) {
+
+ for (let j = 0; j < pos.length; j += 1) {
+
+ const row = pos[i];
+ const col = pos[j];
+
+ if (_modules[row][col] != null) {
+ continue;
+ }
+
+ for (let r = -2; r <= 2; r += 1) {
+
+ for (let c = -2; c <= 2; c += 1) {
+
+ if (r == -2 || r == 2 || c == -2 || c == 2
+ || (r == 0 && c == 0) ) {
+ _modules[row + r][col + c] = true;
+ } else {
+ _modules[row + r][col + c] = false;
+ }
+ }
+ }
+ }
+ }
+ };
+
+ const setupTypeNumber = function(test) {
+
+ const bits = QRUtil.getBCHTypeNumber(_typeNumber);
+
+ for (let i = 0; i < 18; i += 1) {
+ const mod = (!test && ( (bits >> i) & 1) == 1);
+ _modules[Math.floor(i / 3)][i % 3 + _moduleCount - 8 - 3] = mod;
+ }
+
+ for (let i = 0; i < 18; i += 1) {
+ const mod = (!test && ( (bits >> i) & 1) == 1);
+ _modules[i % 3 + _moduleCount - 8 - 3][Math.floor(i / 3)] = mod;
+ }
+ };
+
+ const setupTypeInfo = function(test, maskPattern) {
+
+ const data = (_errorCorrectionLevel << 3) | maskPattern;
+ const bits = QRUtil.getBCHTypeInfo(data);
+
+ // vertical
+ for (let i = 0; i < 15; i += 1) {
+
+ const mod = (!test && ( (bits >> i) & 1) == 1);
+
+ if (i < 6) {
+ _modules[i][8] = mod;
+ } else if (i < 8) {
+ _modules[i + 1][8] = mod;
+ } else {
+ _modules[_moduleCount - 15 + i][8] = mod;
+ }
+ }
+
+ // horizontal
+ for (let i = 0; i < 15; i += 1) {
+
+ const mod = (!test && ( (bits >> i) & 1) == 1);
+
+ if (i < 8) {
+ _modules[8][_moduleCount - i - 1] = mod;
+ } else if (i < 9) {
+ _modules[8][15 - i - 1 + 1] = mod;
+ } else {
+ _modules[8][15 - i - 1] = mod;
+ }
+ }
+
+ // fixed module
+ _modules[_moduleCount - 8][8] = (!test);
+ };
+
+ const mapData = function(data, maskPattern) {
+
+ let inc = -1;
+ let row = _moduleCount - 1;
+ let bitIndex = 7;
+ let byteIndex = 0;
+ const maskFunc = QRUtil.getMaskFunction(maskPattern);
+
+ for (let col = _moduleCount - 1; col > 0; col -= 2) {
+
+ if (col == 6) col -= 1;
+
+ while (true) {
+
+ for (let c = 0; c < 2; c += 1) {
+
+ if (_modules[row][col - c] == null) {
+
+ let dark = false;
+
+ if (byteIndex < data.length) {
+ dark = ( ( (data[byteIndex] >>> bitIndex) & 1) == 1);
+ }
+
+ const mask = maskFunc(row, col - c);
+
+ if (mask) {
+ dark = !dark;
+ }
+
+ _modules[row][col - c] = dark;
+ bitIndex -= 1;
+
+ if (bitIndex == -1) {
+ byteIndex += 1;
+ bitIndex = 7;
+ }
+ }
+ }
+
+ row += inc;
+
+ if (row < 0 || _moduleCount <= row) {
+ row -= inc;
+ inc = -inc;
+ break;
+ }
+ }
+ }
+ };
+
+ const createBytes = function(buffer, rsBlocks) {
+
+ let offset = 0;
+
+ let maxDcCount = 0;
+ let maxEcCount = 0;
+
+ const dcdata = new Array(rsBlocks.length);
+ const ecdata = new Array(rsBlocks.length);
+
+ for (let r = 0; r < rsBlocks.length; r += 1) {
+
+ const dcCount = rsBlocks[r].dataCount;
+ const ecCount = rsBlocks[r].totalCount - dcCount;
+
+ maxDcCount = Math.max(maxDcCount, dcCount);
+ maxEcCount = Math.max(maxEcCount, ecCount);
+
+ dcdata[r] = new Array(dcCount);
+
+ for (let i = 0; i < dcdata[r].length; i += 1) {
+ dcdata[r][i] = 0xff & buffer.getBuffer()[i + offset];
+ }
+ offset += dcCount;
+
+ const rsPoly = QRUtil.getErrorCorrectPolynomial(ecCount);
+ const rawPoly = qrPolynomial(dcdata[r], rsPoly.getLength() - 1);
+
+ const modPoly = rawPoly.mod(rsPoly);
+ ecdata[r] = new Array(rsPoly.getLength() - 1);
+ for (let i = 0; i < ecdata[r].length; i += 1) {
+ const modIndex = i + modPoly.getLength() - ecdata[r].length;
+ ecdata[r][i] = (modIndex >= 0)? modPoly.getAt(modIndex) : 0;
+ }
+ }
+
+ let totalCodeCount = 0;
+ for (let i = 0; i < rsBlocks.length; i += 1) {
+ totalCodeCount += rsBlocks[i].totalCount;
+ }
+
+ const data = new Array(totalCodeCount);
+ let index = 0;
+
+ for (let i = 0; i < maxDcCount; i += 1) {
+ for (let r = 0; r < rsBlocks.length; r += 1) {
+ if (i < dcdata[r].length) {
+ data[index] = dcdata[r][i];
+ index += 1;
+ }
+ }
+ }
+
+ for (let i = 0; i < maxEcCount; i += 1) {
+ for (let r = 0; r < rsBlocks.length; r += 1) {
+ if (i < ecdata[r].length) {
+ data[index] = ecdata[r][i];
+ index += 1;
+ }
+ }
+ }
+
+ return data;
+ };
+
+ const createData = function(typeNumber, errorCorrectionLevel, dataList) {
+
+ const rsBlocks = QRRSBlock.getRSBlocks(typeNumber, errorCorrectionLevel);
+
+ const buffer = qrBitBuffer();
+
+ for (let i = 0; i < dataList.length; i += 1) {
+ const data = dataList[i];
+ buffer.put(data.getMode(), 4);
+ buffer.put(data.getLength(), QRUtil.getLengthInBits(data.getMode(), typeNumber) );
+ data.write(buffer);
+ }
+
+ // calc num max data.
+ let totalDataCount = 0;
+ for (let i = 0; i < rsBlocks.length; i += 1) {
+ totalDataCount += rsBlocks[i].dataCount;
+ }
+
+ if (buffer.getLengthInBits() > totalDataCount * 8) {
+ throw 'code length overflow. ('
+ + buffer.getLengthInBits()
+ + '>'
+ + totalDataCount * 8
+ + ')';
+ }
+
+ // end code
+ if (buffer.getLengthInBits() + 4 <= totalDataCount * 8) {
+ buffer.put(0, 4);
+ }
+
+ // padding
+ while (buffer.getLengthInBits() % 8 != 0) {
+ buffer.putBit(false);
+ }
+
+ // padding
+ while (true) {
+
+ if (buffer.getLengthInBits() >= totalDataCount * 8) {
+ break;
+ }
+ buffer.put(PAD0, 8);
+
+ if (buffer.getLengthInBits() >= totalDataCount * 8) {
+ break;
+ }
+ buffer.put(PAD1, 8);
+ }
+
+ return createBytes(buffer, rsBlocks);
+ };
+
+ _this.addData = function(data, mode) {
+
+ mode = mode || 'Byte';
+
+ let newData = null;
+
+ switch(mode) {
+ case 'Numeric' :
+ newData = qrNumber(data);
+ break;
+ case 'Alphanumeric' :
+ newData = qrAlphaNum(data);
+ break;
+ case 'Byte' :
+ newData = qr8BitByte(data);
+ break;
+ case 'Kanji' :
+ newData = qrKanji(data);
+ break;
+ default :
+ throw 'mode:' + mode;
+ }
+
+ _dataList.push(newData);
+ _dataCache = null;
+ };
+
+ _this.isDark = function(row, col) {
+ if (row < 0 || _moduleCount <= row || col < 0 || _moduleCount <= col) {
+ throw row + ',' + col;
+ }
+ return _modules[row][col];
+ };
+
+ _this.getModuleCount = function() {
+ return _moduleCount;
+ };
+
+ _this.make = function() {
+ if (_typeNumber < 1) {
+ let typeNumber = 1;
+
+ for (; typeNumber < 40; typeNumber++) {
+ const rsBlocks = QRRSBlock.getRSBlocks(typeNumber, _errorCorrectionLevel);
+ const buffer = qrBitBuffer();
+
+ for (let i = 0; i < _dataList.length; i++) {
+ const data = _dataList[i];
+ buffer.put(data.getMode(), 4);
+ buffer.put(data.getLength(), QRUtil.getLengthInBits(data.getMode(), typeNumber) );
+ data.write(buffer);
+ }
+
+ let totalDataCount = 0;
+ for (let i = 0; i < rsBlocks.length; i++) {
+ totalDataCount += rsBlocks[i].dataCount;
+ }
+
+ if (buffer.getLengthInBits() <= totalDataCount * 8) {
+ break;
+ }
+ }
+
+ _typeNumber = typeNumber;
+ }
+
+ makeImpl(false, getBestMaskPattern() );
+ };
+
+ _this.createTableTag = function(cellSize, margin) {
+
+ cellSize = cellSize || 2;
+ margin = (typeof margin == 'undefined')? cellSize * 4 : margin;
+
+ let qrHtml = '';
+
+ qrHtml += '';
+ qrHtml += '';
+
+ for (let r = 0; r < _this.getModuleCount(); r += 1) {
+
+ qrHtml += '';
+
+ for (let c = 0; c < _this.getModuleCount(); c += 1) {
+ qrHtml += ' | ';
+ }
+
+ qrHtml += '
';
+ }
+
+ qrHtml += '';
+ qrHtml += '
';
+
+ return qrHtml;
+ };
+
+ _this.createSvgTag = function(cellSize, margin, alt, title) {
+
+ let opts = {};
+ if (typeof arguments[0] == 'object') {
+ // Called by options.
+ opts = arguments[0];
+ // overwrite cellSize and margin.
+ cellSize = opts.cellSize;
+ margin = opts.margin;
+ alt = opts.alt;
+ title = opts.title;
+ }
+
+ cellSize = cellSize || 2;
+ margin = (typeof margin == 'undefined')? cellSize * 4 : margin;
+
+ // Compose alt property surrogate
+ alt = (typeof alt === 'string') ? {text: alt} : alt || {};
+ alt.text = alt.text || null;
+ alt.id = (alt.text) ? alt.id || 'qrcode-description' : null;
+
+ // Compose title property surrogate
+ title = (typeof title === 'string') ? {text: title} : title || {};
+ title.text = title.text || null;
+ title.id = (title.text) ? title.id || 'qrcode-title' : null;
+
+ const size = _this.getModuleCount() * cellSize + margin * 2;
+ let c, mc, r, mr, qrSvg='', rect;
+
+ rect = 'l' + cellSize + ',0 0,' + cellSize +
+ ' -' + cellSize + ',0 0,-' + cellSize + 'z ';
+
+ qrSvg += '';
+
+ return qrSvg;
+ };
+
+ _this.createDataURL = function(cellSize, margin) {
+
+ cellSize = cellSize || 2;
+ margin = (typeof margin == 'undefined')? cellSize * 4 : margin;
+
+ const size = _this.getModuleCount() * cellSize + margin * 2;
+ const min = margin;
+ const max = size - margin;
+
+ return createDataURL(size, size, function(x, y) {
+ if (min <= x && x < max && min <= y && y < max) {
+ const c = Math.floor( (x - min) / cellSize);
+ const r = Math.floor( (y - min) / cellSize);
+ return _this.isDark(r, c)? 0 : 1;
+ } else {
+ return 1;
+ }
+ } );
+ };
+
+ _this.createImgTag = function(cellSize, margin, alt) {
+
+ cellSize = cellSize || 2;
+ margin = (typeof margin == 'undefined')? cellSize * 4 : margin;
+
+ const size = _this.getModuleCount() * cellSize + margin * 2;
+
+ let img = '';
+ img += '
';
+
+ return img;
+ };
+
+ const escapeXml = function(s) {
+ let escaped = '';
+ for (let i = 0; i < s.length; i += 1) {
+ const c = s.charAt(i);
+ switch(c) {
+ case '<': escaped += '<'; break;
+ case '>': escaped += '>'; break;
+ case '&': escaped += '&'; break;
+ case '"': escaped += '"'; break;
+ default : escaped += c; break;
+ }
+ }
+ return escaped;
+ };
+
+ const _createHalfASCII = function(margin) {
+ const cellSize = 1;
+ margin = (typeof margin == 'undefined')? cellSize * 2 : margin;
+
+ const size = _this.getModuleCount() * cellSize + margin * 2;
+ const min = margin;
+ const max = size - margin;
+
+ let y, x, r1, r2, p;
+
+ const blocks = {
+ '██': '█',
+ '█ ': '▀',
+ ' █': '▄',
+ ' ': ' '
+ };
+
+ const blocksLastLineNoMargin = {
+ '██': '▀',
+ '█ ': '▀',
+ ' █': ' ',
+ ' ': ' '
+ };
+
+ let ascii = '';
+ for (y = 0; y < size; y += 2) {
+ r1 = Math.floor((y - min) / cellSize);
+ r2 = Math.floor((y + 1 - min) / cellSize);
+ for (x = 0; x < size; x += 1) {
+ p = '█';
+
+ if (min <= x && x < max && min <= y && y < max && _this.isDark(r1, Math.floor((x - min) / cellSize))) {
+ p = ' ';
+ }
+
+ if (min <= x && x < max && min <= y+1 && y+1 < max && _this.isDark(r2, Math.floor((x - min) / cellSize))) {
+ p += ' ';
+ }
+ else {
+ p += '█';
+ }
+
+ // Output 2 characters per pixel, to create full square. 1 character per pixels gives only half width of square.
+ ascii += (margin < 1 && y+1 >= max) ? blocksLastLineNoMargin[p] : blocks[p];
+ }
+
+ ascii += '\n';
+ }
+
+ if (size % 2 && margin > 0) {
+ return ascii.substring(0, ascii.length - size - 1) + Array(size+1).join('▀');
+ }
+
+ return ascii.substring(0, ascii.length-1);
+ };
+
+ _this.createASCII = function(cellSize, margin) {
+ cellSize = cellSize || 1;
+
+ if (cellSize < 2) {
+ return _createHalfASCII(margin);
+ }
+
+ cellSize -= 1;
+ margin = (typeof margin == 'undefined')? cellSize * 2 : margin;
+
+ const size = _this.getModuleCount() * cellSize + margin * 2;
+ const min = margin;
+ const max = size - margin;
+
+ let y, x, r, p;
+
+ const white = Array(cellSize+1).join('██');
+ const black = Array(cellSize+1).join(' ');
+
+ let ascii = '';
+ let line = '';
+ for (y = 0; y < size; y += 1) {
+ r = Math.floor( (y - min) / cellSize);
+ line = '';
+ for (x = 0; x < size; x += 1) {
+ p = 1;
+
+ if (min <= x && x < max && min <= y && y < max && _this.isDark(r, Math.floor((x - min) / cellSize))) {
+ p = 0;
+ }
+
+ // Output 2 characters per pixel, to create full square. 1 character per pixels gives only half width of square.
+ line += p ? white : black;
+ }
+
+ for (r = 0; r < cellSize; r += 1) {
+ ascii += line + '\n';
+ }
+ }
+
+ return ascii.substring(0, ascii.length-1);
+ };
+
+ _this.renderTo2dContext = function(context, cellSize) {
+ cellSize = cellSize || 2;
+ const length = _this.getModuleCount();
+ for (let row = 0; row < length; row++) {
+ for (let col = 0; col < length; col++) {
+ context.fillStyle = _this.isDark(row, col) ? 'black' : 'white';
+ context.fillRect(col * cellSize, row * cellSize, cellSize, cellSize);
+ }
+ }
+ }
+
+ return _this;
+};
+
+//---------------------------------------------------------------------
+// qrcode.stringToBytes
+//---------------------------------------------------------------------
+
+qrcode.stringToBytes = function(s) {
+ const bytes = [];
+ for (let i = 0; i < s.length; i += 1) {
+ const c = s.charCodeAt(i);
+ bytes.push(c & 0xff);
+ }
+ return bytes;
+};
+
+//---------------------------------------------------------------------
+// qrcode.createStringToBytes
+//---------------------------------------------------------------------
+
+/**
+ * @param unicodeData base64 string of byte array.
+ * [16bit Unicode],[16bit Bytes], ...
+ * @param numChars
+ */
+qrcode.createStringToBytes = function(unicodeData, numChars) {
+
+ // create conversion map.
+
+ const unicodeMap = function() {
+
+ const bin = base64DecodeInputStream(unicodeData);
+ const read = function() {
+ const b = bin.read();
+ if (b == -1) throw 'eof';
+ return b;
+ };
+
+ let count = 0;
+ const unicodeMap = {};
+ while (true) {
+ const b0 = bin.read();
+ if (b0 == -1) break;
+ const b1 = read();
+ const b2 = read();
+ const b3 = read();
+ const k = String.fromCharCode( (b0 << 8) | b1);
+ const v = (b2 << 8) | b3;
+ unicodeMap[k] = v;
+ count += 1;
+ }
+ if (count != numChars) {
+ throw count + ' != ' + numChars;
+ }
+
+ return unicodeMap;
+ }();
+
+ const unknownChar = '?'.charCodeAt(0);
+
+ return function(s) {
+ const bytes = [];
+ for (let i = 0; i < s.length; i += 1) {
+ const c = s.charCodeAt(i);
+ if (c < 128) {
+ bytes.push(c);
+ } else {
+ const b = unicodeMap[s.charAt(i)];
+ if (typeof b == 'number') {
+ if ( (b & 0xff) == b) {
+ // 1byte
+ bytes.push(b);
+ } else {
+ // 2bytes
+ bytes.push(b >>> 8);
+ bytes.push(b & 0xff);
+ }
+ } else {
+ bytes.push(unknownChar);
+ }
+ }
+ }
+ return bytes;
+ };
+};
+
+//---------------------------------------------------------------------
+// QRMode
+//---------------------------------------------------------------------
+
+const QRMode = {
+ MODE_NUMBER : 1 << 0,
+ MODE_ALPHA_NUM : 1 << 1,
+ MODE_8BIT_BYTE : 1 << 2,
+ MODE_KANJI : 1 << 3
+};
+
+//---------------------------------------------------------------------
+// QRErrorCorrectionLevel
+//---------------------------------------------------------------------
+
+const QRErrorCorrectionLevel = {
+ L : 1,
+ M : 0,
+ Q : 3,
+ H : 2
+};
+
+//---------------------------------------------------------------------
+// QRMaskPattern
+//---------------------------------------------------------------------
+
+const QRMaskPattern = {
+ PATTERN000 : 0,
+ PATTERN001 : 1,
+ PATTERN010 : 2,
+ PATTERN011 : 3,
+ PATTERN100 : 4,
+ PATTERN101 : 5,
+ PATTERN110 : 6,
+ PATTERN111 : 7
+};
+
+//---------------------------------------------------------------------
+// QRUtil
+//---------------------------------------------------------------------
+
+const QRUtil = function() {
+
+ const PATTERN_POSITION_TABLE = [
+ [],
+ [6, 18],
+ [6, 22],
+ [6, 26],
+ [6, 30],
+ [6, 34],
+ [6, 22, 38],
+ [6, 24, 42],
+ [6, 26, 46],
+ [6, 28, 50],
+ [6, 30, 54],
+ [6, 32, 58],
+ [6, 34, 62],
+ [6, 26, 46, 66],
+ [6, 26, 48, 70],
+ [6, 26, 50, 74],
+ [6, 30, 54, 78],
+ [6, 30, 56, 82],
+ [6, 30, 58, 86],
+ [6, 34, 62, 90],
+ [6, 28, 50, 72, 94],
+ [6, 26, 50, 74, 98],
+ [6, 30, 54, 78, 102],
+ [6, 28, 54, 80, 106],
+ [6, 32, 58, 84, 110],
+ [6, 30, 58, 86, 114],
+ [6, 34, 62, 90, 118],
+ [6, 26, 50, 74, 98, 122],
+ [6, 30, 54, 78, 102, 126],
+ [6, 26, 52, 78, 104, 130],
+ [6, 30, 56, 82, 108, 134],
+ [6, 34, 60, 86, 112, 138],
+ [6, 30, 58, 86, 114, 142],
+ [6, 34, 62, 90, 118, 146],
+ [6, 30, 54, 78, 102, 126, 150],
+ [6, 24, 50, 76, 102, 128, 154],
+ [6, 28, 54, 80, 106, 132, 158],
+ [6, 32, 58, 84, 110, 136, 162],
+ [6, 26, 54, 82, 110, 138, 166],
+ [6, 30, 58, 86, 114, 142, 170]
+ ];
+ const G15 = (1 << 10) | (1 << 8) | (1 << 5) | (1 << 4) | (1 << 2) | (1 << 1) | (1 << 0);
+ const G18 = (1 << 12) | (1 << 11) | (1 << 10) | (1 << 9) | (1 << 8) | (1 << 5) | (1 << 2) | (1 << 0);
+ const G15_MASK = (1 << 14) | (1 << 12) | (1 << 10) | (1 << 4) | (1 << 1);
+
+ const _this = {};
+
+ const getBCHDigit = function(data) {
+ let digit = 0;
+ while (data != 0) {
+ digit += 1;
+ data >>>= 1;
+ }
+ return digit;
+ };
+
+ _this.getBCHTypeInfo = function(data) {
+ let d = data << 10;
+ while (getBCHDigit(d) - getBCHDigit(G15) >= 0) {
+ d ^= (G15 << (getBCHDigit(d) - getBCHDigit(G15) ) );
+ }
+ return ( (data << 10) | d) ^ G15_MASK;
+ };
+
+ _this.getBCHTypeNumber = function(data) {
+ let d = data << 12;
+ while (getBCHDigit(d) - getBCHDigit(G18) >= 0) {
+ d ^= (G18 << (getBCHDigit(d) - getBCHDigit(G18) ) );
+ }
+ return (data << 12) | d;
+ };
+
+ _this.getPatternPosition = function(typeNumber) {
+ return PATTERN_POSITION_TABLE[typeNumber - 1];
+ };
+
+ _this.getMaskFunction = function(maskPattern) {
+
+ switch (maskPattern) {
+
+ case QRMaskPattern.PATTERN000 :
+ return function(i, j) { return (i + j) % 2 == 0; };
+ case QRMaskPattern.PATTERN001 :
+ return function(i, j) { return i % 2 == 0; };
+ case QRMaskPattern.PATTERN010 :
+ return function(i, j) { return j % 3 == 0; };
+ case QRMaskPattern.PATTERN011 :
+ return function(i, j) { return (i + j) % 3 == 0; };
+ case QRMaskPattern.PATTERN100 :
+ return function(i, j) { return (Math.floor(i / 2) + Math.floor(j / 3) ) % 2 == 0; };
+ case QRMaskPattern.PATTERN101 :
+ return function(i, j) { return (i * j) % 2 + (i * j) % 3 == 0; };
+ case QRMaskPattern.PATTERN110 :
+ return function(i, j) { return ( (i * j) % 2 + (i * j) % 3) % 2 == 0; };
+ case QRMaskPattern.PATTERN111 :
+ return function(i, j) { return ( (i * j) % 3 + (i + j) % 2) % 2 == 0; };
+
+ default :
+ throw 'bad maskPattern:' + maskPattern;
+ }
+ };
+
+ _this.getErrorCorrectPolynomial = function(errorCorrectLength) {
+ let a = qrPolynomial([1], 0);
+ for (let i = 0; i < errorCorrectLength; i += 1) {
+ a = a.multiply(qrPolynomial([1, QRMath.gexp(i)], 0) );
+ }
+ return a;
+ };
+
+ _this.getLengthInBits = function(mode, type) {
+
+ if (1 <= type && type < 10) {
+
+ // 1 - 9
+
+ switch(mode) {
+ case QRMode.MODE_NUMBER : return 10;
+ case QRMode.MODE_ALPHA_NUM : return 9;
+ case QRMode.MODE_8BIT_BYTE : return 8;
+ case QRMode.MODE_KANJI : return 8;
+ default :
+ throw 'mode:' + mode;
+ }
+
+ } else if (type < 27) {
+
+ // 10 - 26
+
+ switch(mode) {
+ case QRMode.MODE_NUMBER : return 12;
+ case QRMode.MODE_ALPHA_NUM : return 11;
+ case QRMode.MODE_8BIT_BYTE : return 16;
+ case QRMode.MODE_KANJI : return 10;
+ default :
+ throw 'mode:' + mode;
+ }
+
+ } else if (type < 41) {
+
+ // 27 - 40
+
+ switch(mode) {
+ case QRMode.MODE_NUMBER : return 14;
+ case QRMode.MODE_ALPHA_NUM : return 13;
+ case QRMode.MODE_8BIT_BYTE : return 16;
+ case QRMode.MODE_KANJI : return 12;
+ default :
+ throw 'mode:' + mode;
+ }
+
+ } else {
+ throw 'type:' + type;
+ }
+ };
+
+ _this.getLostPoint = function(qrcode) {
+
+ const moduleCount = qrcode.getModuleCount();
+
+ let lostPoint = 0;
+
+ // LEVEL1
+
+ for (let row = 0; row < moduleCount; row += 1) {
+ for (let col = 0; col < moduleCount; col += 1) {
+
+ let sameCount = 0;
+ const dark = qrcode.isDark(row, col);
+
+ for (let r = -1; r <= 1; r += 1) {
+
+ if (row + r < 0 || moduleCount <= row + r) {
+ continue;
+ }
+
+ for (let c = -1; c <= 1; c += 1) {
+
+ if (col + c < 0 || moduleCount <= col + c) {
+ continue;
+ }
+
+ if (r == 0 && c == 0) {
+ continue;
+ }
+
+ if (dark == qrcode.isDark(row + r, col + c) ) {
+ sameCount += 1;
+ }
+ }
+ }
+
+ if (sameCount > 5) {
+ lostPoint += (3 + sameCount - 5);
+ }
+ }
+ };
+
+ // LEVEL2
+
+ for (let row = 0; row < moduleCount - 1; row += 1) {
+ for (let col = 0; col < moduleCount - 1; col += 1) {
+ let count = 0;
+ if (qrcode.isDark(row, col) ) count += 1;
+ if (qrcode.isDark(row + 1, col) ) count += 1;
+ if (qrcode.isDark(row, col + 1) ) count += 1;
+ if (qrcode.isDark(row + 1, col + 1) ) count += 1;
+ if (count == 0 || count == 4) {
+ lostPoint += 3;
+ }
+ }
+ }
+
+ // LEVEL3
+
+ for (let row = 0; row < moduleCount; row += 1) {
+ for (let col = 0; col < moduleCount - 6; col += 1) {
+ if (qrcode.isDark(row, col)
+ && !qrcode.isDark(row, col + 1)
+ && qrcode.isDark(row, col + 2)
+ && qrcode.isDark(row, col + 3)
+ && qrcode.isDark(row, col + 4)
+ && !qrcode.isDark(row, col + 5)
+ && qrcode.isDark(row, col + 6) ) {
+ lostPoint += 40;
+ }
+ }
+ }
+
+ for (let col = 0; col < moduleCount; col += 1) {
+ for (let row = 0; row < moduleCount - 6; row += 1) {
+ if (qrcode.isDark(row, col)
+ && !qrcode.isDark(row + 1, col)
+ && qrcode.isDark(row + 2, col)
+ && qrcode.isDark(row + 3, col)
+ && qrcode.isDark(row + 4, col)
+ && !qrcode.isDark(row + 5, col)
+ && qrcode.isDark(row + 6, col) ) {
+ lostPoint += 40;
+ }
+ }
+ }
+
+ // LEVEL4
+
+ let darkCount = 0;
+
+ for (let col = 0; col < moduleCount; col += 1) {
+ for (let row = 0; row < moduleCount; row += 1) {
+ if (qrcode.isDark(row, col) ) {
+ darkCount += 1;
+ }
+ }
+ }
+
+ const ratio = Math.abs(100 * darkCount / moduleCount / moduleCount - 50) / 5;
+ lostPoint += ratio * 10;
+
+ return lostPoint;
+ };
+
+ return _this;
+}();
+
+//---------------------------------------------------------------------
+// QRMath
+//---------------------------------------------------------------------
+
+const QRMath = function() {
+
+ const EXP_TABLE = new Array(256);
+ const LOG_TABLE = new Array(256);
+
+ // initialize tables
+ for (let i = 0; i < 8; i += 1) {
+ EXP_TABLE[i] = 1 << i;
+ }
+ for (let i = 8; i < 256; i += 1) {
+ EXP_TABLE[i] = EXP_TABLE[i - 4]
+ ^ EXP_TABLE[i - 5]
+ ^ EXP_TABLE[i - 6]
+ ^ EXP_TABLE[i - 8];
+ }
+ for (let i = 0; i < 255; i += 1) {
+ LOG_TABLE[EXP_TABLE[i] ] = i;
+ }
+
+ const _this = {};
+
+ _this.glog = function(n) {
+
+ if (n < 1) {
+ throw 'glog(' + n + ')';
+ }
+
+ return LOG_TABLE[n];
+ };
+
+ _this.gexp = function(n) {
+
+ while (n < 0) {
+ n += 255;
+ }
+
+ while (n >= 256) {
+ n -= 255;
+ }
+
+ return EXP_TABLE[n];
+ };
+
+ return _this;
+}();
+
+//---------------------------------------------------------------------
+// qrPolynomial
+//---------------------------------------------------------------------
+
+const qrPolynomial = function(num, shift) {
+
+ if (typeof num.length == 'undefined') {
+ throw num.length + '/' + shift;
+ }
+
+ const _num = function() {
+ let offset = 0;
+ while (offset < num.length && num[offset] == 0) {
+ offset += 1;
+ }
+ const _num = new Array(num.length - offset + shift);
+ for (let i = 0; i < num.length - offset; i += 1) {
+ _num[i] = num[i + offset];
+ }
+ return _num;
+ }();
+
+ const _this = {};
+
+ _this.getAt = function(index) {
+ return _num[index];
+ };
+
+ _this.getLength = function() {
+ return _num.length;
+ };
+
+ _this.multiply = function(e) {
+
+ const num = new Array(_this.getLength() + e.getLength() - 1);
+
+ for (let i = 0; i < _this.getLength(); i += 1) {
+ for (let j = 0; j < e.getLength(); j += 1) {
+ num[i + j] ^= QRMath.gexp(QRMath.glog(_this.getAt(i) ) + QRMath.glog(e.getAt(j) ) );
+ }
+ }
+
+ return qrPolynomial(num, 0);
+ };
+
+ _this.mod = function(e) {
+
+ if (_this.getLength() - e.getLength() < 0) {
+ return _this;
+ }
+
+ const ratio = QRMath.glog(_this.getAt(0) ) - QRMath.glog(e.getAt(0) );
+
+ const num = new Array(_this.getLength() );
+ for (let i = 0; i < _this.getLength(); i += 1) {
+ num[i] = _this.getAt(i);
+ }
+
+ for (let i = 0; i < e.getLength(); i += 1) {
+ num[i] ^= QRMath.gexp(QRMath.glog(e.getAt(i) ) + ratio);
+ }
+
+ // recursive call
+ return qrPolynomial(num, 0).mod(e);
+ };
+
+ return _this;
+};
+
+//---------------------------------------------------------------------
+// QRRSBlock
+//---------------------------------------------------------------------
+
+const QRRSBlock = function() {
+
+ const RS_BLOCK_TABLE = [
+
+ // L
+ // M
+ // Q
+ // H
+
+ // 1
+ [1, 26, 19],
+ [1, 26, 16],
+ [1, 26, 13],
+ [1, 26, 9],
+
+ // 2
+ [1, 44, 34],
+ [1, 44, 28],
+ [1, 44, 22],
+ [1, 44, 16],
+
+ // 3
+ [1, 70, 55],
+ [1, 70, 44],
+ [2, 35, 17],
+ [2, 35, 13],
+
+ // 4
+ [1, 100, 80],
+ [2, 50, 32],
+ [2, 50, 24],
+ [4, 25, 9],
+
+ // 5
+ [1, 134, 108],
+ [2, 67, 43],
+ [2, 33, 15, 2, 34, 16],
+ [2, 33, 11, 2, 34, 12],
+
+ // 6
+ [2, 86, 68],
+ [4, 43, 27],
+ [4, 43, 19],
+ [4, 43, 15],
+
+ // 7
+ [2, 98, 78],
+ [4, 49, 31],
+ [2, 32, 14, 4, 33, 15],
+ [4, 39, 13, 1, 40, 14],
+
+ // 8
+ [2, 121, 97],
+ [2, 60, 38, 2, 61, 39],
+ [4, 40, 18, 2, 41, 19],
+ [4, 40, 14, 2, 41, 15],
+
+ // 9
+ [2, 146, 116],
+ [3, 58, 36, 2, 59, 37],
+ [4, 36, 16, 4, 37, 17],
+ [4, 36, 12, 4, 37, 13],
+
+ // 10
+ [2, 86, 68, 2, 87, 69],
+ [4, 69, 43, 1, 70, 44],
+ [6, 43, 19, 2, 44, 20],
+ [6, 43, 15, 2, 44, 16],
+
+ // 11
+ [4, 101, 81],
+ [1, 80, 50, 4, 81, 51],
+ [4, 50, 22, 4, 51, 23],
+ [3, 36, 12, 8, 37, 13],
+
+ // 12
+ [2, 116, 92, 2, 117, 93],
+ [6, 58, 36, 2, 59, 37],
+ [4, 46, 20, 6, 47, 21],
+ [7, 42, 14, 4, 43, 15],
+
+ // 13
+ [4, 133, 107],
+ [8, 59, 37, 1, 60, 38],
+ [8, 44, 20, 4, 45, 21],
+ [12, 33, 11, 4, 34, 12],
+
+ // 14
+ [3, 145, 115, 1, 146, 116],
+ [4, 64, 40, 5, 65, 41],
+ [11, 36, 16, 5, 37, 17],
+ [11, 36, 12, 5, 37, 13],
+
+ // 15
+ [5, 109, 87, 1, 110, 88],
+ [5, 65, 41, 5, 66, 42],
+ [5, 54, 24, 7, 55, 25],
+ [11, 36, 12, 7, 37, 13],
+
+ // 16
+ [5, 122, 98, 1, 123, 99],
+ [7, 73, 45, 3, 74, 46],
+ [15, 43, 19, 2, 44, 20],
+ [3, 45, 15, 13, 46, 16],
+
+ // 17
+ [1, 135, 107, 5, 136, 108],
+ [10, 74, 46, 1, 75, 47],
+ [1, 50, 22, 15, 51, 23],
+ [2, 42, 14, 17, 43, 15],
+
+ // 18
+ [5, 150, 120, 1, 151, 121],
+ [9, 69, 43, 4, 70, 44],
+ [17, 50, 22, 1, 51, 23],
+ [2, 42, 14, 19, 43, 15],
+
+ // 19
+ [3, 141, 113, 4, 142, 114],
+ [3, 70, 44, 11, 71, 45],
+ [17, 47, 21, 4, 48, 22],
+ [9, 39, 13, 16, 40, 14],
+
+ // 20
+ [3, 135, 107, 5, 136, 108],
+ [3, 67, 41, 13, 68, 42],
+ [15, 54, 24, 5, 55, 25],
+ [15, 43, 15, 10, 44, 16],
+
+ // 21
+ [4, 144, 116, 4, 145, 117],
+ [17, 68, 42],
+ [17, 50, 22, 6, 51, 23],
+ [19, 46, 16, 6, 47, 17],
+
+ // 22
+ [2, 139, 111, 7, 140, 112],
+ [17, 74, 46],
+ [7, 54, 24, 16, 55, 25],
+ [34, 37, 13],
+
+ // 23
+ [4, 151, 121, 5, 152, 122],
+ [4, 75, 47, 14, 76, 48],
+ [11, 54, 24, 14, 55, 25],
+ [16, 45, 15, 14, 46, 16],
+
+ // 24
+ [6, 147, 117, 4, 148, 118],
+ [6, 73, 45, 14, 74, 46],
+ [11, 54, 24, 16, 55, 25],
+ [30, 46, 16, 2, 47, 17],
+
+ // 25
+ [8, 132, 106, 4, 133, 107],
+ [8, 75, 47, 13, 76, 48],
+ [7, 54, 24, 22, 55, 25],
+ [22, 45, 15, 13, 46, 16],
+
+ // 26
+ [10, 142, 114, 2, 143, 115],
+ [19, 74, 46, 4, 75, 47],
+ [28, 50, 22, 6, 51, 23],
+ [33, 46, 16, 4, 47, 17],
+
+ // 27
+ [8, 152, 122, 4, 153, 123],
+ [22, 73, 45, 3, 74, 46],
+ [8, 53, 23, 26, 54, 24],
+ [12, 45, 15, 28, 46, 16],
+
+ // 28
+ [3, 147, 117, 10, 148, 118],
+ [3, 73, 45, 23, 74, 46],
+ [4, 54, 24, 31, 55, 25],
+ [11, 45, 15, 31, 46, 16],
+
+ // 29
+ [7, 146, 116, 7, 147, 117],
+ [21, 73, 45, 7, 74, 46],
+ [1, 53, 23, 37, 54, 24],
+ [19, 45, 15, 26, 46, 16],
+
+ // 30
+ [5, 145, 115, 10, 146, 116],
+ [19, 75, 47, 10, 76, 48],
+ [15, 54, 24, 25, 55, 25],
+ [23, 45, 15, 25, 46, 16],
+
+ // 31
+ [13, 145, 115, 3, 146, 116],
+ [2, 74, 46, 29, 75, 47],
+ [42, 54, 24, 1, 55, 25],
+ [23, 45, 15, 28, 46, 16],
+
+ // 32
+ [17, 145, 115],
+ [10, 74, 46, 23, 75, 47],
+ [10, 54, 24, 35, 55, 25],
+ [19, 45, 15, 35, 46, 16],
+
+ // 33
+ [17, 145, 115, 1, 146, 116],
+ [14, 74, 46, 21, 75, 47],
+ [29, 54, 24, 19, 55, 25],
+ [11, 45, 15, 46, 46, 16],
+
+ // 34
+ [13, 145, 115, 6, 146, 116],
+ [14, 74, 46, 23, 75, 47],
+ [44, 54, 24, 7, 55, 25],
+ [59, 46, 16, 1, 47, 17],
+
+ // 35
+ [12, 151, 121, 7, 152, 122],
+ [12, 75, 47, 26, 76, 48],
+ [39, 54, 24, 14, 55, 25],
+ [22, 45, 15, 41, 46, 16],
+
+ // 36
+ [6, 151, 121, 14, 152, 122],
+ [6, 75, 47, 34, 76, 48],
+ [46, 54, 24, 10, 55, 25],
+ [2, 45, 15, 64, 46, 16],
+
+ // 37
+ [17, 152, 122, 4, 153, 123],
+ [29, 74, 46, 14, 75, 47],
+ [49, 54, 24, 10, 55, 25],
+ [24, 45, 15, 46, 46, 16],
+
+ // 38
+ [4, 152, 122, 18, 153, 123],
+ [13, 74, 46, 32, 75, 47],
+ [48, 54, 24, 14, 55, 25],
+ [42, 45, 15, 32, 46, 16],
+
+ // 39
+ [20, 147, 117, 4, 148, 118],
+ [40, 75, 47, 7, 76, 48],
+ [43, 54, 24, 22, 55, 25],
+ [10, 45, 15, 67, 46, 16],
+
+ // 40
+ [19, 148, 118, 6, 149, 119],
+ [18, 75, 47, 31, 76, 48],
+ [34, 54, 24, 34, 55, 25],
+ [20, 45, 15, 61, 46, 16]
+ ];
+
+ const qrRSBlock = function(totalCount, dataCount) {
+ const _this = {};
+ _this.totalCount = totalCount;
+ _this.dataCount = dataCount;
+ return _this;
+ };
+
+ const _this = {};
+
+ const getRsBlockTable = function(typeNumber, errorCorrectionLevel) {
+
+ switch(errorCorrectionLevel) {
+ case QRErrorCorrectionLevel.L :
+ return RS_BLOCK_TABLE[(typeNumber - 1) * 4 + 0];
+ case QRErrorCorrectionLevel.M :
+ return RS_BLOCK_TABLE[(typeNumber - 1) * 4 + 1];
+ case QRErrorCorrectionLevel.Q :
+ return RS_BLOCK_TABLE[(typeNumber - 1) * 4 + 2];
+ case QRErrorCorrectionLevel.H :
+ return RS_BLOCK_TABLE[(typeNumber - 1) * 4 + 3];
+ default :
+ return undefined;
+ }
+ };
+
+ _this.getRSBlocks = function(typeNumber, errorCorrectionLevel) {
+
+ const rsBlock = getRsBlockTable(typeNumber, errorCorrectionLevel);
+
+ if (typeof rsBlock == 'undefined') {
+ throw 'bad rs block @ typeNumber:' + typeNumber +
+ '/errorCorrectionLevel:' + errorCorrectionLevel;
+ }
+
+ const length = rsBlock.length / 3;
+
+ const list = [];
+
+ for (let i = 0; i < length; i += 1) {
+
+ const count = rsBlock[i * 3 + 0];
+ const totalCount = rsBlock[i * 3 + 1];
+ const dataCount = rsBlock[i * 3 + 2];
+
+ for (let j = 0; j < count; j += 1) {
+ list.push(qrRSBlock(totalCount, dataCount) );
+ }
+ }
+
+ return list;
+ };
+
+ return _this;
+}();
+
+//---------------------------------------------------------------------
+// qrBitBuffer
+//---------------------------------------------------------------------
+
+const qrBitBuffer = function() {
+
+ const _buffer = [];
+ let _length = 0;
+
+ const _this = {};
+
+ _this.getBuffer = function() {
+ return _buffer;
+ };
+
+ _this.getAt = function(index) {
+ const bufIndex = Math.floor(index / 8);
+ return ( (_buffer[bufIndex] >>> (7 - index % 8) ) & 1) == 1;
+ };
+
+ _this.put = function(num, length) {
+ for (let i = 0; i < length; i += 1) {
+ _this.putBit( ( (num >>> (length - i - 1) ) & 1) == 1);
+ }
+ };
+
+ _this.getLengthInBits = function() {
+ return _length;
+ };
+
+ _this.putBit = function(bit) {
+
+ const bufIndex = Math.floor(_length / 8);
+ if (_buffer.length <= bufIndex) {
+ _buffer.push(0);
+ }
+
+ if (bit) {
+ _buffer[bufIndex] |= (0x80 >>> (_length % 8) );
+ }
+
+ _length += 1;
+ };
+
+ return _this;
+};
+
+//---------------------------------------------------------------------
+// qrNumber
+//---------------------------------------------------------------------
+
+const qrNumber = function(data) {
+
+ const _mode = QRMode.MODE_NUMBER;
+ const _data = data;
+
+ const _this = {};
+
+ _this.getMode = function() {
+ return _mode;
+ };
+
+ _this.getLength = function(buffer) {
+ return _data.length;
+ };
+
+ _this.write = function(buffer) {
+
+ const data = _data;
+
+ let i = 0;
+
+ while (i + 2 < data.length) {
+ buffer.put(strToNum(data.substring(i, i + 3) ), 10);
+ i += 3;
+ }
+
+ if (i < data.length) {
+ if (data.length - i == 1) {
+ buffer.put(strToNum(data.substring(i, i + 1) ), 4);
+ } else if (data.length - i == 2) {
+ buffer.put(strToNum(data.substring(i, i + 2) ), 7);
+ }
+ }
+ };
+
+ const strToNum = function(s) {
+ let num = 0;
+ for (let i = 0; i < s.length; i += 1) {
+ num = num * 10 + chatToNum(s.charAt(i) );
+ }
+ return num;
+ };
+
+ const chatToNum = function(c) {
+ if ('0' <= c && c <= '9') {
+ return c.charCodeAt(0) - '0'.charCodeAt(0);
+ }
+ throw 'illegal char :' + c;
+ };
+
+ return _this;
+};
+
+//---------------------------------------------------------------------
+// qrAlphaNum
+//---------------------------------------------------------------------
+
+const qrAlphaNum = function(data) {
+
+ const _mode = QRMode.MODE_ALPHA_NUM;
+ const _data = data;
+
+ const _this = {};
+
+ _this.getMode = function() {
+ return _mode;
+ };
+
+ _this.getLength = function(buffer) {
+ return _data.length;
+ };
+
+ _this.write = function(buffer) {
+
+ const s = _data;
+
+ let i = 0;
+
+ while (i + 1 < s.length) {
+ buffer.put(
+ getCode(s.charAt(i) ) * 45 +
+ getCode(s.charAt(i + 1) ), 11);
+ i += 2;
+ }
+
+ if (i < s.length) {
+ buffer.put(getCode(s.charAt(i) ), 6);
+ }
+ };
+
+ const getCode = function(c) {
+
+ if ('0' <= c && c <= '9') {
+ return c.charCodeAt(0) - '0'.charCodeAt(0);
+ } else if ('A' <= c && c <= 'Z') {
+ return c.charCodeAt(0) - 'A'.charCodeAt(0) + 10;
+ } else {
+ switch (c) {
+ case '\u0020' : return 36;
+ case '$' : return 37;
+ case '%' : return 38;
+ case '*' : return 39;
+ case '+' : return 40;
+ case '-' : return 41;
+ case '.' : return 42;
+ case '/' : return 43;
+ case ':' : return 44;
+ default :
+ throw 'illegal char :' + c;
+ }
+ }
+ };
+
+ return _this;
+};
+
+//---------------------------------------------------------------------
+// qr8BitByte
+//---------------------------------------------------------------------
+
+const qr8BitByte = function(data) {
+
+ const _mode = QRMode.MODE_8BIT_BYTE;
+ const _data = data;
+ const _bytes = qrcode.stringToBytes(data);
+
+ const _this = {};
+
+ _this.getMode = function() {
+ return _mode;
+ };
+
+ _this.getLength = function(buffer) {
+ return _bytes.length;
+ };
+
+ _this.write = function(buffer) {
+ for (let i = 0; i < _bytes.length; i += 1) {
+ buffer.put(_bytes[i], 8);
+ }
+ };
+
+ return _this;
+};
+
+//---------------------------------------------------------------------
+// qrKanji
+//---------------------------------------------------------------------
+
+const qrKanji = function(data) {
+
+ const _mode = QRMode.MODE_KANJI;
+ const _data = data;
+
+ const stringToBytes = qrcode.stringToBytes;
+ !function(c, code) {
+ // self test for sjis support.
+ const test = stringToBytes(c);
+ if (test.length != 2 || ( (test[0] << 8) | test[1]) != code) {
+ throw 'sjis not supported.';
+ }
+ }('\u53cb', 0x9746);
+
+ const _bytes = stringToBytes(data);
+
+ const _this = {};
+
+ _this.getMode = function() {
+ return _mode;
+ };
+
+ _this.getLength = function(buffer) {
+ return ~~(_bytes.length / 2);
+ };
+
+ _this.write = function(buffer) {
+
+ const data = _bytes;
+
+ let i = 0;
+
+ while (i + 1 < data.length) {
+
+ let c = ( (0xff & data[i]) << 8) | (0xff & data[i + 1]);
+
+ if (0x8140 <= c && c <= 0x9FFC) {
+ c -= 0x8140;
+ } else if (0xE040 <= c && c <= 0xEBBF) {
+ c -= 0xC140;
+ } else {
+ throw 'illegal char at ' + (i + 1) + '/' + c;
+ }
+
+ c = ( (c >>> 8) & 0xff) * 0xC0 + (c & 0xff);
+
+ buffer.put(c, 13);
+
+ i += 2;
+ }
+
+ if (i < data.length) {
+ throw 'illegal char at ' + (i + 1);
+ }
+ };
+
+ return _this;
+};
+
+//=====================================================================
+// GIF Support etc.
+//
+
+//---------------------------------------------------------------------
+// byteArrayOutputStream
+//---------------------------------------------------------------------
+
+const byteArrayOutputStream = function() {
+
+ const _bytes = [];
+
+ const _this = {};
+
+ _this.writeByte = function(b) {
+ _bytes.push(b & 0xff);
+ };
+
+ _this.writeShort = function(i) {
+ _this.writeByte(i);
+ _this.writeByte(i >>> 8);
+ };
+
+ _this.writeBytes = function(b, off, len) {
+ off = off || 0;
+ len = len || b.length;
+ for (let i = 0; i < len; i += 1) {
+ _this.writeByte(b[i + off]);
+ }
+ };
+
+ _this.writeString = function(s) {
+ for (let i = 0; i < s.length; i += 1) {
+ _this.writeByte(s.charCodeAt(i) );
+ }
+ };
+
+ _this.toByteArray = function() {
+ return _bytes;
+ };
+
+ _this.toString = function() {
+ let s = '';
+ s += '[';
+ for (let i = 0; i < _bytes.length; i += 1) {
+ if (i > 0) {
+ s += ',';
+ }
+ s += _bytes[i];
+ }
+ s += ']';
+ return s;
+ };
+
+ return _this;
+};
+
+//---------------------------------------------------------------------
+// base64EncodeOutputStream
+//---------------------------------------------------------------------
+
+const base64EncodeOutputStream = function() {
+
+ let _buffer = 0;
+ let _buflen = 0;
+ let _length = 0;
+ let _base64 = '';
+
+ const _this = {};
+
+ const writeEncoded = function(b) {
+ _base64 += String.fromCharCode(encode(b & 0x3f) );
+ };
+
+ const encode = function(n) {
+ if (n < 0) {
+ throw 'n:' + n;
+ } else if (n < 26) {
+ return 0x41 + n;
+ } else if (n < 52) {
+ return 0x61 + (n - 26);
+ } else if (n < 62) {
+ return 0x30 + (n - 52);
+ } else if (n == 62) {
+ return 0x2b;
+ } else if (n == 63) {
+ return 0x2f;
+ } else {
+ throw 'n:' + n;
+ }
+ };
+
+ _this.writeByte = function(n) {
+
+ _buffer = (_buffer << 8) | (n & 0xff);
+ _buflen += 8;
+ _length += 1;
+
+ while (_buflen >= 6) {
+ writeEncoded(_buffer >>> (_buflen - 6) );
+ _buflen -= 6;
+ }
+ };
+
+ _this.flush = function() {
+
+ if (_buflen > 0) {
+ writeEncoded(_buffer << (6 - _buflen) );
+ _buffer = 0;
+ _buflen = 0;
+ }
+
+ if (_length % 3 != 0) {
+ // padding
+ const padlen = 3 - _length % 3;
+ for (let i = 0; i < padlen; i += 1) {
+ _base64 += '=';
+ }
+ }
+ };
+
+ _this.toString = function() {
+ return _base64;
+ };
+
+ return _this;
+};
+
+//---------------------------------------------------------------------
+// base64DecodeInputStream
+//---------------------------------------------------------------------
+
+const base64DecodeInputStream = function(str) {
+
+ const _str = str;
+ let _pos = 0;
+ let _buffer = 0;
+ let _buflen = 0;
+
+ const _this = {};
+
+ _this.read = function() {
+
+ while (_buflen < 8) {
+
+ if (_pos >= _str.length) {
+ if (_buflen == 0) {
+ return -1;
+ }
+ throw 'unexpected end of file./' + _buflen;
+ }
+
+ const c = _str.charAt(_pos);
+ _pos += 1;
+
+ if (c == '=') {
+ _buflen = 0;
+ return -1;
+ } else if (c.match(/^\s$/) ) {
+ // ignore if whitespace.
+ continue;
+ }
+
+ _buffer = (_buffer << 6) | decode(c.charCodeAt(0) );
+ _buflen += 6;
+ }
+
+ const n = (_buffer >>> (_buflen - 8) ) & 0xff;
+ _buflen -= 8;
+ return n;
+ };
+
+ const decode = function(c) {
+ if (0x41 <= c && c <= 0x5a) {
+ return c - 0x41;
+ } else if (0x61 <= c && c <= 0x7a) {
+ return c - 0x61 + 26;
+ } else if (0x30 <= c && c <= 0x39) {
+ return c - 0x30 + 52;
+ } else if (c == 0x2b) {
+ return 62;
+ } else if (c == 0x2f) {
+ return 63;
+ } else {
+ throw 'c:' + c;
+ }
+ };
+
+ return _this;
+};
+
+//---------------------------------------------------------------------
+// gifImage (B/W)
+//---------------------------------------------------------------------
+
+const gifImage = function(width, height) {
+
+ const _width = width;
+ const _height = height;
+ const _data = new Array(width * height);
+
+ const _this = {};
+
+ _this.setPixel = function(x, y, pixel) {
+ _data[y * _width + x] = pixel;
+ };
+
+ _this.write = function(out) {
+
+ //---------------------------------
+ // GIF Signature
+
+ out.writeString('GIF87a');
+
+ //---------------------------------
+ // Screen Descriptor
+
+ out.writeShort(_width);
+ out.writeShort(_height);
+
+ out.writeByte(0x80); // 2bit
+ out.writeByte(0);
+ out.writeByte(0);
+
+ //---------------------------------
+ // Global Color Map
+
+ // black
+ out.writeByte(0x00);
+ out.writeByte(0x00);
+ out.writeByte(0x00);
+
+ // white
+ out.writeByte(0xff);
+ out.writeByte(0xff);
+ out.writeByte(0xff);
+
+ //---------------------------------
+ // Image Descriptor
+
+ out.writeString(',');
+ out.writeShort(0);
+ out.writeShort(0);
+ out.writeShort(_width);
+ out.writeShort(_height);
+ out.writeByte(0);
+
+ //---------------------------------
+ // Local Color Map
+
+ //---------------------------------
+ // Raster Data
+
+ const lzwMinCodeSize = 2;
+ const raster = getLZWRaster(lzwMinCodeSize);
+
+ out.writeByte(lzwMinCodeSize);
+
+ let offset = 0;
+
+ while (raster.length - offset > 255) {
+ out.writeByte(255);
+ out.writeBytes(raster, offset, 255);
+ offset += 255;
+ }
+
+ out.writeByte(raster.length - offset);
+ out.writeBytes(raster, offset, raster.length - offset);
+ out.writeByte(0x00);
+
+ //---------------------------------
+ // GIF Terminator
+ out.writeString(';');
+ };
+
+ const bitOutputStream = function(out) {
+
+ const _out = out;
+ let _bitLength = 0;
+ let _bitBuffer = 0;
+
+ const _this = {};
+
+ _this.write = function(data, length) {
+
+ if ( (data >>> length) != 0) {
+ throw 'length over';
+ }
+
+ while (_bitLength + length >= 8) {
+ _out.writeByte(0xff & ( (data << _bitLength) | _bitBuffer) );
+ length -= (8 - _bitLength);
+ data >>>= (8 - _bitLength);
+ _bitBuffer = 0;
+ _bitLength = 0;
+ }
+
+ _bitBuffer = (data << _bitLength) | _bitBuffer;
+ _bitLength = _bitLength + length;
+ };
+
+ _this.flush = function() {
+ if (_bitLength > 0) {
+ _out.writeByte(_bitBuffer);
+ }
+ };
+
+ return _this;
+ };
+
+ const getLZWRaster = function(lzwMinCodeSize) {
+
+ const clearCode = 1 << lzwMinCodeSize;
+ const endCode = (1 << lzwMinCodeSize) + 1;
+ let bitLength = lzwMinCodeSize + 1;
+
+ // Setup LZWTable
+ const table = lzwTable();
+
+ for (let i = 0; i < clearCode; i += 1) {
+ table.add(String.fromCharCode(i) );
+ }
+ table.add(String.fromCharCode(clearCode) );
+ table.add(String.fromCharCode(endCode) );
+
+ const byteOut = byteArrayOutputStream();
+ const bitOut = bitOutputStream(byteOut);
+
+ // clear code
+ bitOut.write(clearCode, bitLength);
+
+ let dataIndex = 0;
+
+ let s = String.fromCharCode(_data[dataIndex]);
+ dataIndex += 1;
+
+ while (dataIndex < _data.length) {
+
+ const c = String.fromCharCode(_data[dataIndex]);
+ dataIndex += 1;
+
+ if (table.contains(s + c) ) {
+
+ s = s + c;
+
+ } else {
+
+ bitOut.write(table.indexOf(s), bitLength);
+
+ if (table.size() < 0xfff) {
+
+ if (table.size() == (1 << bitLength) ) {
+ bitLength += 1;
+ }
+
+ table.add(s + c);
+ }
+
+ s = c;
+ }
+ }
+
+ bitOut.write(table.indexOf(s), bitLength);
+
+ // end code
+ bitOut.write(endCode, bitLength);
+
+ bitOut.flush();
+
+ return byteOut.toByteArray();
+ };
+
+ const lzwTable = function() {
+
+ const _map = {};
+ let _size = 0;
+
+ const _this = {};
+
+ _this.add = function(key) {
+ if (_this.contains(key) ) {
+ throw 'dup key:' + key;
+ }
+ _map[key] = _size;
+ _size += 1;
+ };
+
+ _this.size = function() {
+ return _size;
+ };
+
+ _this.indexOf = function(key) {
+ return _map[key];
+ };
+
+ _this.contains = function(key) {
+ return typeof _map[key] != 'undefined';
+ };
+
+ return _this;
+ };
+
+ return _this;
+};
+
+const createDataURL = function(width, height, getPixel) {
+ const gif = gifImage(width, height);
+ for (let y = 0; y < height; y += 1) {
+ for (let x = 0; x < width; x += 1) {
+ gif.setPixel(x, y, getPixel(x, y) );
+ }
+ }
+
+ const b = byteArrayOutputStream();
+ gif.write(b);
+
+ const base64 = base64EncodeOutputStream();
+ const bytes = b.toByteArray();
+ for (let i = 0; i < bytes.length; i += 1) {
+ base64.writeByte(bytes[i]);
+ }
+ base64.flush();
+
+ return 'data:image/gif;base64,' + base64;
+};
+
+export default qrcode;
+
+export const stringToBytes = qrcode.stringToBytes;