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..90325da --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,52 @@ +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: Install Pages runtime dependencies + shell: bash + run: | + npm install --prefix .pages-runtime --no-save --no-package-lock --ignore-scripts three@0.186.1 + + - 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..447a0b0 --- /dev/null +++ b/.github/workflows/pages.yml @@ -0,0 +1,75 @@ +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: Install Pages runtime dependencies + shell: bash + run: | + npm install --prefix .pages-runtime --no-save --no-package-lock --ignore-scripts three@0.186.1 + + - 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..b58109b 100644 --- a/README.md +++ b/README.md @@ -1,2 +1,48 @@ -# WebDemo -ThreeJS Web Demos +# Augmenta Web Demo + +Browser-based Augmenta demo for real-time spatial interaction. + +This repository is intentionally based on the current +[Augmenta Three.js example](https://github.com/Augmenta-tech/Augmenta-ThreeJS-example) +so connection handling, setup parsing, scene selection, debug data, QR sharing, +sidebar behavior, persistence, mobile behavior, and GitHub Pages deployment stay +aligned with the reference implementation. + +The Web Demo adds an independent 2D data-visual layer on top of that base. Disable +**Visuals → Data visuals** at any time to reveal the original Three.js viewer. + +## Visuals + +The default visual mode is a front-facing technical/data-art view with: + +- colored point-cloud silhouettes +- tracked bounding boxes and centroids +- velocity cues +- motion trails +- scene grid and frame +- zone markers +- glow and labels +- Punchy, Cool, and Monochrome palettes + +The visual layer is implemented in `src/visuals.js` and does not replace the +reference Three.js renderer in `src/viewer.js`. + +## Run + +Clone with submodules, then serve the repository over HTTP: + +```bash +git clone --recurse-submodules https://github.com/Augmenta-tech/WebDemo.git +cd WebDemo +python -m http.server 8000 +``` + +Open `http://localhost:8000`. + +You can connect to an Augmenta WebSocket output or use **Simulate data** without +an Augmenta server. + +## Deployment + +GitHub Pages is deployed from `main` by the workflow in +`.github/workflows/pages.yml`. 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..565a83e --- /dev/null +++ b/index.html @@ -0,0 +1,249 @@ + + + + + + + + + + + Augmenta Web Demo + + + + + + + + +
+
+
+
+
+

Augmenta Web Demo

+

Real-time spatial interaction

+
+
+ + +
+ + + + + + +
+ + + + diff --git a/scripts/assemble-site.mjs b/scripts/assemble-site.mjs new file mode 100644 index 0000000..15d83ae --- /dev/null +++ b/scripts/assemble-site.mjs @@ -0,0 +1,120 @@ +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 threeRoot = join(root, '.pages-runtime', 'node_modules', 'three'); +const sdkOut = join(out, 'vendor', 'AugmentaClientSDK-JS', rev, 'dist', 'esm'); + +function requirePath(path, label) { + if (!existsSync(path)) { + throw new Error(`Missing ${label}: ${path}`); + } +} + +function replaceRequired(source, search, replacement) { + if (!source.includes(search)) { + throw new Error(`Missing expected build token: ${search}`); + } + return source.replaceAll(search, replacement); +} + +requirePath(join(root, 'vendor', 'AugmentaClientSDK-JS', 'dist', 'esm'), 'built Augmenta SDK'); +requirePath(join(root, 'vendor', 'qrcode-generator', 'qrcode.js'), 'vendored QR generator'); +requirePath(join(threeRoot, 'build', 'three.module.js'), 'Three.js runtime'); + +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') +); + +// Keep the live Pages app runtime self-contained. A full refresh with an empty +// browser cache must not wait on jsDelivr before the app/QR can initialize. +const threeOut = join(out, 'vendor', 'three', rev); +mkdirSync(threeOut, { recursive: true }); +mkdirSync(join(threeOut, 'examples', 'jsm'), { recursive: true }); +cpSync(join(threeRoot, 'build'), join(threeOut, 'build'), { recursive: true }); +cpSync( + join(threeRoot, 'examples', 'jsm', 'controls'), + join(threeOut, 'examples', 'jsm', 'controls'), + { recursive: true } +); +cpSync( + join(threeRoot, 'examples', 'jsm', 'lines'), + join(threeOut, 'examples', 'jsm', 'lines'), + { recursive: true } +); + +const indexPath = join(out, 'index.html'); +let html = readFileSync(indexPath, 'utf8'); +html = replaceRequired( + html, + ' \n', + '' +); +html = replaceRequired( + html, + 'https://cdn.jsdelivr.net/npm/three@0.186.1/build/three.module.js', + `./vendor/three/${rev}/build/three.module.js` +); +html = replaceRequired( + html, + 'https://cdn.jsdelivr.net/npm/three@0.186.1/examples/jsm/', + `./vendor/three/${rev}/examples/jsm/` +); +html = replaceRequired( + html, + './vendor/AugmentaClientSDK-JS/dist/esm/index.js', + `./vendor/AugmentaClientSDK-JS/${rev}/dist/esm/index.js` +); +html = replaceRequired(html, './src/styles.css', `./src/styles.css?v=${rev}`); +html = replaceRequired(html, './src/qr.js', `./src/qr.js?v=${rev}`); +html = replaceRequired(html, './src/main.js', `./src/main.js?v=${rev}`); + +if (html.includes('cdn.jsdelivr.net')) { + throw new Error('Built Pages index still contains a runtime CDN dependency.'); +} +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})${rows}
ID / UUIDClusterPoint cloud
`; +} + +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})${rows}
ZoneOccupancyProperties
`; +} + +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..c76646f --- /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, 'Three.js 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, 2].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 = 120; + 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..de8e834 --- /dev/null +++ b/src/main.js @@ -0,0 +1,806 @@ +import { APP_VERSION } from './app-info.js'; +import { createConnectionController } from './connection.js'; +import { createSetupStore } from './setup-store.js'; +import { createViewer } from './viewer.js'; +import { createVisuals } from './visuals.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 FPS_WINDOW_MS = 1000; +const DEMO_FRAME_INTERVAL_MS = 33; +const SIDEBAR_MIN_WIDTH = 320; +const SIDEBAR_MAX_WIDTH = 450; +const SIDEBAR_VIEWPORT_MARGIN = 160; +const SIDEBAR_RESIZE_STEP_PX = 16; +const SIDEBAR_RESIZE_LARGE_STEP_PX = 40; +const MOBILE_MEDIA_QUERY = '(max-width: 900px), (pointer: coarse) and (max-width: 1100px)'; +const SETTINGS_STORAGE_KEY = 'augmenta-webdemo-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'), + visualEnabled: $('#visual-enabled'), visualTrails: $('#visual-trails'), visualGrid: $('#visual-grid'), visualGlow: $('#visual-glow'), visualLabels: $('#visual-labels'), visualPalette: $('#visual-palette') +}; + +ui.appVersion.textContent = `Version ${APP_VERSION}`; + +const viewer = createViewer($('#canvas-host')); +const visuals = createVisuals($('#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: { + enabled: ui.visualEnabled.checked, + trails: ui.visualTrails.checked, + grid: ui.visualGrid.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 visualPreferences = 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 visualPreferences.enabled === 'boolean') ui.visualEnabled.checked = visualPreferences.enabled; + if (typeof visualPreferences.trails === 'boolean') ui.visualTrails.checked = visualPreferences.trails; + if (typeof visualPreferences.grid === 'boolean') ui.visualGrid.checked = visualPreferences.grid; + if (typeof visualPreferences.glow === 'boolean') ui.visualGlow.checked = visualPreferences.glow; + if (typeof visualPreferences.labels === 'boolean') ui.visualLabels.checked = visualPreferences.labels; + if (typeof visualPreferences.palette === 'string') ui.visualPalette.value = visualPreferences.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 - FPS_WINDOW_MS); + viewer.renderFrame(frame); + visuals.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(); + visuals.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(); + const selectedSceneAddress = selectedScene()?.getAddress(); + viewer.renderSetup(root, selectedSceneAddress); + visuals.renderSetup(root, selectedSceneAddress); +} + +function applySetupUpdate(container) { + const root = setupStore.applyUpdate(container); + if (!root) return; + + syncSceneSelector(); + const selectedSceneAddress = selectedScene()?.getAddress(); + viewer.renderSetup(root, selectedSceneAddress); + visuals.renderSetup(root, selectedSceneAddress); + 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(); + visuals.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, DEMO_FRAME_INTERVAL_MS); + updateSimulationButton(); +} + +function toggleSimulation() { + if (!demoTimer) { + startSimulation(); + return; + } + + stopSimulation(); + setStatus('Idle'); + ui.note.textContent = 'Simulation stopped.'; +} + +function applyVisibility() { + const visibility = { + clusters: ui.showClusters.checked, + points: ui.showPoints.checked, + scene: ui.showScene.checked, + zones: ui.showZones.checked, + vectors: ui.showVectors.checked + }; + viewer.setVisibility(visibility); + visuals.setVisibility(visibility); +} + +function applyVisualOptions() { + visuals.setOptions({ + enabled: ui.visualEnabled.checked, + trails: ui.visualTrails.checked, + grid: ui.visualGrid.checked, + glow: ui.visualGlow.checked, + labels: ui.visualLabels.checked, + palette: ui.visualPalette.value + }); + ui.visualsSectionSummary.textContent = + ui.visualEnabled.checked + ? ui.visualPalette.selectedOptions[0]?.textContent ?? 'Punchy' + : 'Off'; +} + +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(); + visuals.reset(); +}); +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); + visuals.setRightInset(inset); +} + +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 (isMobileLayout()) return; + setSidebarWidth(window.innerWidth - event.clientX); +} + +ui.sidebarResizer.addEventListener('pointerdown', (event) => { + if (isMobileLayout() || 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 ? SIDEBAR_RESIZE_LARGE_STEP_PX : SIDEBAR_RESIZE_STEP_PX; + 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.visualEnabled, ui.visualTrails, ui.visualGrid, 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..3f7293e --- /dev/null +++ b/src/qr.js @@ -0,0 +1,135 @@ +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; + +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 (!ui.connectionQr) return; + + 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..ce8c8ca --- /dev/null +++ b/src/setup-store.js @@ -0,0 +1,165 @@ +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; + } + + return { + 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..bf976e7 --- /dev/null +++ b/src/styles.css @@ -0,0 +1,885 @@ +: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 { + position: relative; + width: 100%; + height: 100%; +} + +#canvas-host canvas { width: 100%; height: 100%; display: block; } + +#canvas-host .data-visuals { + position: absolute; + z-index: 2; + inset: 0; + pointer-events: none; +} + +.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; + } +} + + +.visual-palette-field { + display: grid; + gap: 6px; + margin-top: 14px; +} + +.visual-palette-field label { + color: #aeb6c5; + font-size: 11px; + font-weight: 600; +} diff --git a/src/viewer.js b/src/viewer.js new file mode 100644 index 0000000..d52b3cc --- /dev/null +++ b/src/viewer.js @@ -0,0 +1,813 @@ +import * as THREE from 'three'; +import { OrbitControls } from 'three/addons/controls/OrbitControls.js'; +import { ClusterState } from 'augmenta-client-sdk'; +import { speedFromVelocity } from './motion.js'; +import { createZoneRenderer } from './zones.js'; +import { collectZoneAddresses } from './zone-state.js'; + +const FLOOR_Y = 0; +const PANEL_INSET_ANIMATION_DURATION_MS = 220; +const MIN_GEOMETRY_SIZE = 0.001; +const MIN_ARROW_LENGTH_M = 0.001; +const MIN_VISIBLE_SPEED_MPS = 0.001; +const CAMERA_FLOOR_CLEARANCE_M = 0.02; +const GHOST_COLOR = new THREE.Color(0x8a909b); +const LOOK_AT_MARKER_OPACITY = 0.58; +const LOOK_AT_MARKER_GHOST_OPACITY = 0.24; +const LOCAL_BOX_Z = new THREE.Vector3(0, 0, 1); +const SESSION_COLOR_OFFSET = Math.floor(Math.random() * 1000); +const PALETTE = [ + 0x4cc9f0, // cyan + 0x4895ef, // blue + 0x4361ee, // indigo + 0x7c5cff, // violet + 0xb15cff, // purple + 0xf15bb5, // pink + 0xff6b6b, // coral + 0xff922b, // orange + 0xf9c74f, // gold + 0x90be6d, // green + 0x43aa8b, // teal + 0x2ec4b6 // aqua +]; + +export function createViewer(host) { + const scene = new THREE.Scene(); + scene.background = new THREE.Color(0x0c0f14); + scene.fog = new THREE.FogExp2(0x0c0f14, 0.014); + + const camera = new THREE.PerspectiveCamera(48, 1, 0.02, 500); + const renderer = new THREE.WebGLRenderer({ antialias: true }); + renderer.setPixelRatio(Math.min(window.devicePixelRatio, 2)); + renderer.outputColorSpace = THREE.SRGBColorSpace; + host.appendChild(renderer.domElement); + + const controls = new OrbitControls(camera, renderer.domElement); + configureControls(controls); + + const grid = new THREE.GridHelper(100, 100, 0x4d5668, 0x252b35); + grid.material.transparent = true; + grid.material.opacity = 0.52; + grid.material.depthWrite = false; + scene.add(grid, new THREE.AxesHelper(1)); + + const setupGroup = namedGroup(scene, 'Augmenta scene setup'); + const clusterGroup = namedGroup(scene, 'Tracked clusters'); + const pointGroup = namedGroup(scene, 'Point clouds'); + const vectorGroup = namedGroup(scene, 'Velocity vectors'); + const labelGroup = namedGroup(scene, 'Object IDs'); + + const visibility = { + clusters: true, + points: true, + scene: true, + zones: true, + vectors: true + }; + + const views = new Map(); + const zoneRenderer = createZoneRenderer(); + const unitBox = new THREE.BoxGeometry(1, 1, 1); + const unitBoxEdges = new THREE.EdgesGeometry(unitBox); + unitBox.dispose(); + // Integrate the look-at cue into the lower forward edge: a straight edge + // with a centered outward triangle (___/\\___). The apex is intentionally + // fairly sharp so direction remains obvious without adding a second arrow. + const unitLookAtMarker = new THREE.BufferGeometry().setFromPoints([ + new THREE.Vector3(-0.5, -0.495, 0.502), + new THREE.Vector3(-0.12, -0.495, 0.502), + new THREE.Vector3(0, -0.495, 0.70), + new THREE.Vector3(0.12, -0.495, 0.502), + new THREE.Vector3(0.5, -0.495, 0.502) + ]); + const centroidGeometry = new THREE.SphereGeometry(0.045, 12, 8); + + const lookAtDirection = new THREE.Vector3(); + const renderedPositiveZ = new THREE.Vector3(); + const velocityDirection = new THREE.Vector3(); + const homePosition = new THREE.Vector3(0, 2.5, 7.5); + const homeTarget = new THREE.Vector3(0, 1.2, 0); + let rightInset = 0; + let insetAnimationFrame; + let cameraChangeHandler; + let cameraUserControlled = false; + + function getCameraView() { + return { + position: camera.position.toArray(), + target: controls.target.toArray() + }; + } + + function setCameraView(view) { + const position = validVector3(view?.position); + const target = validVector3(view?.target); + if (!position || !target) return false; + + camera.position.fromArray(position); + controls.target.fromArray(target); + cameraUserControlled = true; + controls.update(); + return true; + } + + function setCameraChangeHandler(handler) { + cameraChangeHandler = typeof handler === 'function' ? handler : undefined; + } + + function isCameraUserControlled() { + return cameraUserControlled; + } + + controls.addEventListener('start', () => { + cameraUserControlled = true; + }); + controls.addEventListener('change', () => { + cameraChangeHandler?.(getCameraView()); + }); + + function resetCamera() { + cameraUserControlled = false; + camera.position.copy(homePosition); + controls.target.copy(homeTarget); + controls.update(); + } + + function updateCameraProjection() { + const width = Math.max(host.clientWidth, 1); + const height = Math.max(host.clientHeight, 1); + camera.clearViewOffset(); + + if (rightInset > 0 && width > rightInset + 80) { + const virtualWidth = width + rightInset; + camera.aspect = Math.max(virtualWidth / height, 0.1); + camera.setViewOffset(virtualWidth, height, rightInset, 0, width, height); + } else { + camera.aspect = Math.max(width / height, 0.1); + camera.updateProjectionMatrix(); + } + } + + function updateLineMaterialResolution( + width = Math.max(host.clientWidth, 1), + height = Math.max(host.clientHeight, 1) + ) { + setupGroup.traverse((object) => { + if (object.material?.isLineMaterial) object.material.resolution.set(width, height); + }); + } + + function resize() { + const width = Math.max(host.clientWidth, 1); + const height = Math.max(host.clientHeight, 1); + renderer.setSize(width, height, false); + updateLineMaterialResolution(width, height); + updateCameraProjection(); + } + + function setRightInset(value, animate = false) { + const target = Math.max(0, Number(value) || 0); + if (insetAnimationFrame) cancelAnimationFrame(insetAnimationFrame); + + if (!animate) { + rightInset = target; + updateCameraProjection(); + return; + } + + const start = rightInset; + const startedAt = performance.now(); + const duration = PANEL_INSET_ANIMATION_DURATION_MS; + + const tick = (now) => { + const t = Math.min((now - startedAt) / duration, 1); + const eased = 1 - Math.pow(1 - t, 3); + rightInset = start + (target - start) * eased; + updateCameraProjection(); + if (t < 1) insetAnimationFrame = requestAnimationFrame(tick); + else insetAnimationFrame = undefined; + }; + + insetAnimationFrame = requestAnimationFrame(tick); + } + + function renderFrame(frame) { + const sceneAddress = frame.getSceneInfo().getAddress() || ''; + const active = new Set(); + + frame.getObjects().forEach((object, index) => { + const id = object.getID(); + const uuid = object.getUUID(); + // IDs can repeat across scenes. Prefix by scene so "All scenes" can keep + // multiple live scenes visible without one frame deleting another. + const objectKey = uuid || `id:${id ?? index}`; + const key = `${sceneAddress}|${objectKey}`; + active.add(key); + + const view = views.get(key) || createObjectView(key, id, sceneAddress); + views.set(key, view); + + if (object.hasCluster()) { + updateLabel(view, id, uuid); + updateCluster(view, object.getCluster()); + } else { + view.label.visible = false; + hideCluster(view); + } + + if (object.hasPointCloud()) updatePoints(view, object.getPointCloud()); + else view.points.visible = false; + }); + + for (const [key, view] of views) { + if (view.sceneAddress === sceneAddress && !active.has(key)) { + disposeView(view); + views.delete(key); + } + } + + zoneRenderer.update(frame.getZoneEvents()); + } + + function createObjectView(key, id, sceneAddress) { + const color = objectColor(key, id); + const box = new THREE.LineSegments( + unitBoxEdges, + new THREE.LineBasicMaterial({ + color, + transparent: true, + opacity: 0.95 + }) + ); + + const lookAtMarker = new THREE.Line( + unitLookAtMarker, + new THREE.LineBasicMaterial({ + color, + transparent: true, + opacity: LOOK_AT_MARKER_OPACITY, + depthTest: false, + depthWrite: false + }) + ); + lookAtMarker.name = 'Look-at marker'; + lookAtMarker.userData.hasDirection = false; + lookAtMarker.visible = false; + lookAtMarker.renderOrder = 5; + box.add(lookAtMarker); + + const centroid = new THREE.Mesh( + centroidGeometry, + new THREE.MeshBasicMaterial({ color }) + ); + + const velocity = new THREE.ArrowHelper( + new THREE.Vector3(0, 0, 1), + new THREE.Vector3(), + MIN_ARROW_LENGTH_M, + color.getHex(), + 0.12, + 0.07 + ); + configureArrow(velocity); + + const points = new THREE.Points( + new THREE.BufferGeometry(), + new THREE.PointsMaterial({ + color, + size: 0.03, + sizeAttenuation: true, + transparent: true, + opacity: 0.92 + }) + ); + // Point-only packets have no cheap spatial bound. Avoid Three.js scanning + // every point to derive one; cluster-backed clouds get a cheap box bound. + points.frustumCulled = false; + + const label = createLabelSprite(id === undefined ? '' : String(id), color); + label.visible = id !== undefined; + + clusterGroup.add(box, centroid); + vectorGroup.add(velocity); + pointGroup.add(points); + labelGroup.add(label); + + return { + box, + lookAtMarker, + centroid, + velocity, + points, + label, + labelText: id === undefined ? '' : String(id), + color, + sceneAddress + }; + } + + function updateLabel(view, id, uuid) { + const text = id !== undefined ? String(id) : uuid ? uuid.slice(0, 8) : ''; + if (!text) { + view.label.visible = false; + return; + } + + if (view.labelText !== text) { + replaceLabelTexture(view.label, text, view.color); + view.labelText = text; + } + view.label.visible = true; + } + + function updateCluster(view, cluster) { + const center = cluster.getBoundingBoxCenter(); + const size = cluster.getBoundingBoxSize(); + const centroid = cluster.getCentroid(); + const velocity = cluster.getVelocity(); + const rotation = cluster.getBoundingBoxRotationQuaternions(); + + view.box.visible = true; + view.box.position.fromArray(center); + view.box.scale.set( + Math.max(Math.abs(size[0]), MIN_GEOMETRY_SIZE), + Math.max(Math.abs(size[1]), MIN_GEOMETRY_SIZE), + Math.max(Math.abs(size[2]), MIN_GEOMETRY_SIZE) + ); + // Pleiades sends its native Y-up/left-handed quaternion. Reflect it across + // Z to express the exact same orientation in Three.js' right-handed space. + setLeftHandedQuaternion(view.box.quaternion, rotation); + updateLookAtMarker(view, cluster.getLookAt()); + + view.centroid.visible = true; + view.centroid.position.fromArray(centroid); + + updateVelocity(view.velocity, center, velocity, view.color, velocityDirection); + + const state = cluster.getState(); + const color = state === ClusterState.Ghost ? GHOST_COLOR : view.color; + view.box.material.color.copy(color); + view.lookAtMarker.material.color.copy(color); + view.centroid.material.color.copy(color); + view.velocity.setColor(color); + view.points.material.color.copy(color); + + view.box.material.opacity = state === ClusterState.WillLeave ? 0.35 : 0.95; + view.lookAtMarker.material.opacity = state === ClusterState.Ghost + ? LOOK_AT_MARKER_GHOST_OPACITY + : state === ClusterState.WillLeave + ? LOOK_AT_MARKER_OPACITY * 0.55 + : LOOK_AT_MARKER_OPACITY; + view.points.material.opacity = state === ClusterState.Ghost ? 0.45 : 0.92; + + const pointBounds = view.points.geometry.boundingSphere ?? new THREE.Sphere(); + pointBounds.center.fromArray(center); + pointBounds.radius = Math.max( + Math.hypot(Math.abs(size[0]), Math.abs(size[1]), Math.abs(size[2])) * 0.5, + MIN_GEOMETRY_SIZE + ); + view.points.geometry.boundingSphere = pointBounds; + view.points.frustumCulled = true; + + const top = center[1] + Math.abs(size[1]) * 0.5 + 0.18; + view.label.position.set(center[0], Math.max(top, FLOOR_Y + 0.16), center[2]); + view.label.material.opacity = state === ClusterState.Ghost ? 0.55 : 1; + } + + function updateLookAtMarker(view, lookAt) { + if (!validVector3(lookAt)) { + view.lookAtMarker.userData.hasDirection = false; + view.lookAtMarker.visible = false; + return; + } + + lookAtDirection.fromArray(lookAt); + if (lookAtDirection.lengthSq() < 1e-8) { + view.lookAtMarker.userData.hasDirection = false; + view.lookAtMarker.visible = false; + return; + } + lookAtDirection.normalize(); + + // Pleiades streams look-at separately from the raw OBB quaternion. Compare + // both after the viewer's handedness conversion, then flip the bottom + // chevron so its tip points toward the corresponding local Z face. + renderedPositiveZ.copy(LOCAL_BOX_Z) + .applyQuaternion(view.box.quaternion) + .normalize(); + const side = renderedPositiveZ.dot(lookAtDirection) >= 0 ? 1 : -1; + + view.lookAtMarker.scale.set(1, 1, side); + view.lookAtMarker.userData.hasDirection = true; + view.lookAtMarker.visible = visibility.vectors; + } + + function updatePoints(view, cloud) { + const data = cloud.getPointsData(); + const position = view.points.geometry.getAttribute('position'); + + if (position && position.array.length === data.length) { + position.array.set(data); + position.needsUpdate = true; + } else { + const nextPosition = new THREE.BufferAttribute(data, 3); + nextPosition.setUsage(THREE.DynamicDrawUsage); + view.points.geometry.setAttribute('position', nextPosition); + } + + view.points.visible = data.length > 0; + } + + function hideCluster(view) { + view.box.visible = false; + view.centroid.visible = false; + view.velocity.visible = false; + view.points.frustumCulled = false; + } + + function renderSetup(root, selectedSceneAddress) { + clearGroup(setupGroup); + zoneRenderer.resetViews(); + addContainer(root, setupGroup, selectedSceneAddress, false); + zoneRenderer.pruneState(collectZoneAddresses(root)); + applySetupVisibility(); + updateLineMaterialResolution(); + updateHomeFromSetup(); + } + + function addContainer(container, parent, selectedSceneAddress, insideSelectedScene) { + const address = container.getAddress(); + const isSelectedScene = Boolean(selectedSceneAddress && address === selectedSceneAddress); + const renderContents = !selectedSceneAddress || insideSelectedScene || isSelectedScene; + + // Keep ancestors of the selected Scene so their transforms are preserved, + // but skip unrelated branches entirely. + if ( + selectedSceneAddress + && !renderContents + && !containsAddress(container, selectedSceneAddress) + ) { + return; + } + + const group = new THREE.Group(); + group.name = `augmenta:${address}`; + if (container.isZone()) group.userData.isZoneContainer = true; + + group.position.fromArray(container.getPosition()); + setSetupRotation(group, container.getRotation()); + parent.add(group); + + if (renderContents) { + if (container.isScene()) { + addSceneBox(container, group); + } else if (container.isZone()) { + zoneRenderer.addZone(container, group); + } + } + + for (const child of container.getChildren()) { + addContainer( + child, + group, + selectedSceneAddress, + renderContents + ); + } + } + + function containsAddress(container, targetAddress) { + if (container.getAddress() === targetAddress) return true; + return container.getChildren().some((child) => containsAddress(child, targetAddress)); + } + + function addSceneBox(container, group) { + const size = container.getSceneParameters().size; + const geometry = new THREE.BoxGeometry(...positiveSize(size)); + const edges = new THREE.LineSegments( + new THREE.EdgesGeometry(geometry), + new THREE.LineBasicMaterial({ + color: 0x7f858e, + transparent: true, + opacity: 0.55, + depthWrite: false + }) + ); + geometry.dispose(); + + edges.name = 'Scene bounds'; + // AxisTransform returns size as positive magnitudes. In Y-up/right-handed + // space the original +Z extent points toward local -Z. + edges.position.set(size[0] / 2, size[1] / 2, -size[2] / 2); + edges.renderOrder = 1; + group.add(edges); + } + + function updateHomeFromSetup() { + const bounds = new THREE.Box3().setFromObject(setupGroup); + if (bounds.isEmpty()) return; + + const size = bounds.getSize(new THREE.Vector3()); + const center = bounds.getCenter(new THREE.Vector3()); + const span = Math.max(size.x, size.y, size.z, 1); + const fov = THREE.MathUtils.degToRad(camera.fov); + const distance = Math.max(span / (2 * Math.tan(fov / 2)) * 1.35, 2); + + homeTarget.copy(center); + // A centered, slightly elevated front view similar to Pleiades' default, + // rather than an oblique corner view. + homePosition.copy(center).add( + new THREE.Vector3(0, distance * 0.14, distance * 1.08) + ); + + // Before the user has chosen a view, keep orbiting around the setup + // center. A restored/panned/orbited camera keeps its own target so setup + // refreshes cannot overwrite the persisted view. + if (!cameraUserControlled) { + controls.target.copy(center); + controls.update(); + } + } + + function applySetupVisibility() { + setupGroup.traverse((object) => { + if (object.name === 'Scene bounds') object.visible = visibility.scene; + if (object.userData?.isZoneContainer) object.visible = visibility.zones; + }); + } + + function setVisibility({ clusters, points, scene, zones, vectors }) { + visibility.clusters = clusters; + visibility.points = points; + visibility.scene = scene; + visibility.zones = zones; + visibility.vectors = vectors; + + clusterGroup.visible = clusters; + pointGroup.visible = points; + vectorGroup.visible = vectors; + for (const view of views.values()) { + view.lookAtMarker.visible = vectors && view.lookAtMarker.userData.hasDirection; + } + labelGroup.visible = clusters || points; + applySetupVisibility(); + } + + function clearTracking() { + for (const view of views.values()) disposeView(view); + views.clear(); + + zoneRenderer.clearPresence(); + } + + function clearSetup() { + clearGroup(setupGroup); + zoneRenderer.clearPresence(); + zoneRenderer.resetViews(); + homePosition.set(0, 2.5, 7.5); + homeTarget.set(0, 1.2, 0); + } + + function disposeView(view) { + clusterGroup.remove(view.box, view.centroid); + vectorGroup.remove(view.velocity); + pointGroup.remove(view.points); + labelGroup.remove(view.label); + + view.box.material.dispose(); + view.lookAtMarker.material.dispose(); + view.centroid.material.dispose(); + view.points.geometry.dispose(); + view.points.material.dispose(); + + disposeArrow(view.velocity); + disposeLabel(view.label); + } + + resetCamera(); + resize(); + new ResizeObserver(resize).observe(host); + renderer.domElement.addEventListener('dblclick', (event) => { + if (event.button === 0) resetCamera(); + }); + + renderer.setAnimationLoop(() => { + controls.update(); + zoneRenderer.animate(performance.now()); + + // Orbiting stays above the world floor. With screenSpacePanning=false, + // right-drag panning also remains parallel to the floor plane. + if (camera.position.y < FLOOR_Y + CAMERA_FLOOR_CLEARANCE_M) { + camera.position.y = FLOOR_Y + CAMERA_FLOOR_CLEARANCE_M; + } + + renderer.render(scene, camera); + }); + + return { + renderFrame, + renderSetup, + clearTracking, + clearSetup, + getCameraView, + isCameraUserControlled, + resetCamera, + setCameraChangeHandler, + setCameraView, + setRightInset, + setVisibility + }; +} + +function validVector3(value) { + return Array.isArray(value) + && value.length === 3 + && value.every((component) => Number.isFinite(component)) + ? value + : undefined; +} + +function setSetupRotation(object, mappedRotationDegrees) { + const [x, y, mappedZ] = mappedRotationDegrees.map(THREE.MathUtils.degToRad); + + // Setup JSON has already been component-mapped by Pleiades from Y-up/left- + // handed to Y-up/right-handed, so its Z Euler component is negated. Recover + // the native Pleiades Z->Y->X rotation, then reflect the orientation itself. + const leftHanded = new THREE.Quaternion().setFromEuler( + new THREE.Euler(x, y, -mappedZ, 'ZYX') + ); + setLeftHandedQuaternion(object.quaternion, [ + leftHanded.x, + leftHanded.y, + leftHanded.z, + leftHanded.w + ]); +} + +function setLeftHandedQuaternion(target, [x, y, z, w]) { + // Reflection M=diag(1,1,-1): R_rh = M * R_lh * M. + target.set(-x, -y, z, w).normalize(); +} + +function configureControls(controls) { + controls.enableDamping = true; + controls.dampingFactor = 0.08; + controls.screenSpacePanning = false; + controls.rotateSpeed = 0.6; + controls.panSpeed = 0.72; + controls.zoomSpeed = 0.9; + controls.minDistance = 0.05; + controls.maxDistance = 500; + controls.zoomToCursor = false; + controls.minPolarAngle = THREE.MathUtils.degToRad(2); + controls.maxPolarAngle = THREE.MathUtils.degToRad(88.5); + controls.mouseButtons.LEFT = THREE.MOUSE.ROTATE; + controls.mouseButtons.MIDDLE = THREE.MOUSE.DOLLY; + controls.mouseButtons.RIGHT = THREE.MOUSE.PAN; +} + +function configureArrow(arrow) { + arrow.line.material.transparent = true; + arrow.line.material.opacity = 0.95; + arrow.line.material.depthTest = false; + arrow.line.renderOrder = 8; + + arrow.cone.material.transparent = true; + arrow.cone.material.opacity = 0.95; + arrow.cone.material.depthTest = false; + arrow.cone.renderOrder = 8; +} + +function updateVelocity(arrow, origin, velocity, color, direction) { + const speed = speedFromVelocity(velocity); + + if (!Number.isFinite(speed) || speed < MIN_VISIBLE_SPEED_MPS) { + arrow.visible = false; + return; + } + + direction.fromArray(velocity).normalize(); + arrow.visible = true; + arrow.position.fromArray(origin); + arrow.setDirection(direction); + + const headLength = Math.min(Math.max(speed * 0.28, 0.08), 0.28); + const headWidth = Math.min(Math.max(headLength * 0.55, 0.05), 0.16); + arrow.setLength(speed, headLength, headWidth); + arrow.setColor(color); +} + +function disposeArrow(arrow) { + arrow.line.geometry.dispose(); + arrow.line.material.dispose(); + arrow.cone.geometry.dispose(); + arrow.cone.material.dispose(); + arrow.parent?.remove(arrow); +} + +function createLabelSprite(text, color) { + const material = new THREE.SpriteMaterial({ + map: makeLabelTexture(text, color), + transparent: true, + depthTest: false, + depthWrite: false + }); + const sprite = new THREE.Sprite(material); + sprite.scale.set(0.72, 0.28, 1); + sprite.renderOrder = 10; + return sprite; +} + +function replaceLabelTexture(sprite, text, color) { + sprite.material.map?.dispose(); + sprite.material.map = makeLabelTexture(text, color); + sprite.material.needsUpdate = true; +} + +function makeLabelTexture(text, color) { + const canvas = document.createElement('canvas'); + canvas.width = 256; + canvas.height = 96; + + const ctx = canvas.getContext('2d'); + if (!ctx) return new THREE.CanvasTexture(canvas); + + const cssColor = `#${color.getHexString()}`; + + ctx.clearRect(0, 0, canvas.width, canvas.height); + roundedRect(ctx, 24, 16, 208, 64, 22); + ctx.fillStyle = 'rgba(10, 13, 18, 0.88)'; + ctx.fill(); + + ctx.strokeStyle = cssColor; + ctx.lineWidth = 5; + ctx.stroke(); + + ctx.fillStyle = '#ffffff'; + ctx.font = '500 38px Inter, Arial, sans-serif'; + ctx.textAlign = 'center'; + ctx.textBaseline = 'middle'; + ctx.fillText(text, 128, 49); + + 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 disposeLabel(sprite) { + sprite.material.map?.dispose(); + sprite.material.dispose(); + sprite.parent?.remove(sprite); +} + +function namedGroup(scene, name) { + const group = new THREE.Group(); + group.name = name; + scene.add(group); + return group; +} + +function objectColor(key, id) { + const seed = Number.isInteger(id) ? id : hashString(key); + const index = Math.abs(seed * 5 + SESSION_COLOR_OFFSET) % PALETTE.length; + return new THREE.Color(PALETTE[index]); +} + +function hashString(value) { + let hash = 0; + for (const char of value) hash = ((hash << 5) - hash + char.charCodeAt(0)) | 0; + return hash; +} + +// Scene dimensions are magnitudes. Placement above preserves the requested +// Y-up/right-handed direction while this helper keeps geometry sizes valid. +function positiveSize(size) { + return size.map((v) => Math.max(Math.abs(v), MIN_GEOMETRY_SIZE)); +} + +function clearGroup(group) { + while (group.children.length) disposeObject(group.children[0]); +} + +function disposeObject(object) { + for (const child of [...object.children]) disposeObject(child); + object.geometry?.dispose(); + + if (object.material) { + const materials = Array.isArray(object.material) ? object.material : [object.material]; + for (const material of materials) { + material.map?.dispose(); + material.dispose(); + } + } + + object.parent?.remove(object); +} diff --git a/src/visuals.js b/src/visuals.js new file mode 100644 index 0000000..be83a30 --- /dev/null +++ b/src/visuals.js @@ -0,0 +1,578 @@ +const BACKGROUND = '#090b11'; +const STALE_MS = 360; +const EASING_MS = 70; +const MAX_POINTS = 1400; + +const PALETTES = { + punchy: ['#38e8ff', '#ff4fd8', '#a4ff56', '#ffb02e', '#7f7cff', '#ff5c69'], + cool: ['#49ecff', '#4d9cff', '#8c7dff', '#47ffd0', '#84b7ff', '#b58cff'], + mono: ['#f4f7ff', '#cfd7e8', '#ffffff', '#aab6cc', '#e8edfa', '#bdc7dc'] +}; + +export function createVisuals(host) { + const canvas = document.createElement('canvas'); + canvas.className = 'data-visuals'; + canvas.setAttribute('aria-hidden', 'true'); + host.appendChild(canvas); + + const trailCanvas = document.createElement('canvas'); + const ctx = canvas.getContext('2d', { alpha: false }); + const trail = trailCanvas.getContext('2d'); + + const objects = new Map(); + let width = 1; + let height = 1; + let pixelRatio = 1; + let rightInset = 0; + let lastDraw = performance.now(); + let scene = defaultScene(); + let zones = []; + + const visibility = { + clusters: true, + points: true, + scene: true, + zones: true, + vectors: true + }; + + const options = { + enabled: true, + trails: true, + grid: true, + glow: true, + labels: 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, trailCanvas]) { + 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); + trail.setTransform(pixelRatio, 0, 0, pixelRatio, 0, 0); + trail.clearRect(0, 0, width, height); + } + + function renderSetup(root, selectedSceneAddress) { + const scenes = []; + const nextZones = []; + + walk(root, (container) => { + if (container.isScene?.()) scenes.push(container); + if (container.isZone?.()) { + nextZones.push({ + name: container.getName?.() || 'Zone', + position: vector3(container.getPosition?.(), [0, 0, 0]) + }); + } + }); + + const selected = scenes.find((candidate) => ( + selectedSceneAddress && candidate.getAddress?.() === selectedSceneAddress + )) ?? scenes[0]; + + scene = selected ? sceneFromContainer(selected) : defaultScene(); + zones = nextZones; + } + + function renderFrame(frame) { + const sceneAddress = frame.getSceneInfo?.().getAddress?.() || ''; + const active = new Set(); + const now = performance.now(); + + frame.getObjects?.().forEach((object, index) => { + const id = object.getID?.(); + const uuid = object.getUUID?.(); + const key = `${sceneAddress}|${uuid || `id:${id ?? index}`}`; + active.add(key); + + let view = objects.get(key); + if (!view) { + view = createView(key, id, uuid); + objects.set(key, view); + } + + view.id = id; + view.uuid = uuid; + view.lastSeen = now; + + if (object.hasCluster?.()) { + const cluster = object.getCluster(); + view.centerTarget = vector3(cluster.getBoundingBoxCenter?.(), view.centerTarget); + view.sizeTarget = vector3(cluster.getBoundingBoxSize?.(), view.sizeTarget); + view.centroidTarget = vector3(cluster.getCentroid?.(), view.centerTarget); + view.velocityTarget = vector3(cluster.getVelocity?.(), [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(`${sceneAddress}|`) && !active.has(key)) { + view.lastSeen = Math.min(view.lastSeen, now - 1); + } + } + } + + function createView(key, id, uuid) { + return { + key, + id, + uuid, + colorIndex: hashString(key) % PALETTES.punchy.length, + lastSeen: performance.now(), + center: [0, 0.9, 0], + centerTarget: [0, 0.9, 0], + size: [0.55, 1.7, 0.55], + sizeTarget: [0.55, 1.7, 0.55], + centroid: [0, 0.9, 0], + centroidTarget: [0, 0.9, 0], + velocity: [0, 0, 0], + velocityTarget: [0, 0, 0], + points: undefined + }; + } + + function animate(now) { + const dt = Math.min(Math.max(now - lastDraw, 0), 50); + lastDraw = now; + const amount = 1 - Math.exp(-dt / EASING_MS); + + for (const [key, view] of objects) { + ease(view.center, view.centerTarget, amount); + ease(view.size, view.sizeTarget, amount); + ease(view.centroid, view.centroidTarget, amount); + ease(view.velocity, view.velocityTarget, amount); + if (now - view.lastSeen > STALE_MS) objects.delete(key); + } + + canvas.hidden = !options.enabled; + if (options.enabled) draw(now); + requestAnimationFrame(animate); + } + + function draw(now) { + const projection = makeProjection(); + + drawBackground(projection, now); + if (options.grid) drawGrid(projection, now); + if (visibility.scene) drawSceneFrame(projection); + + updateTrails(projection); + if (options.trails) ctx.drawImage(trailCanvas, 0, 0, width, height); + + const views = [...objects.values()]; + for (const view of views) drawObject(view, projection, now); + + if (visibility.zones) drawZones(projection); + drawHud(projection, views.length); + } + + function drawBackground(projection, now) { + ctx.fillStyle = BACKGROUND; + ctx.fillRect(0, 0, width, height); + + const radial = ctx.createRadialGradient( + projection.centerX, + projection.top + projection.stageHeight * 0.55, + 0, + projection.centerX, + projection.top + projection.stageHeight * 0.55, + Math.max(projection.stageWidth, projection.stageHeight) * 0.85 + ); + radial.addColorStop(0, 'rgba(46, 59, 97, .24)'); + radial.addColorStop(.55, 'rgba(22, 28, 48, .09)'); + radial.addColorStop(1, 'rgba(9, 11, 17, 0)'); + ctx.fillStyle = radial; + ctx.fillRect(0, 0, projection.usableWidth, height); + + drawFloorWaves(projection, now); + } + + function drawFloorWaves(p, now) { + const palette = currentPalette(); + const startY = p.bottom - p.stageHeight * 0.18; + + ctx.save(); + ctx.lineWidth = 1; + + for (let row = 0; row < 12; row++) { + const t = row / 11; + const y = startY + t * (p.bottom - startY); + ctx.strokeStyle = colorAlpha(palette[row % palette.length], 0.035 + t * 0.055); + ctx.beginPath(); + + for (let column = 0; column <= 56; column++) { + const x = p.left + (column / 56) * p.stageWidth; + const wave = Math.sin(column * .58 + row * .72 + now * .0012) * (1.2 + t * 3.2); + if (column === 0) ctx.moveTo(x, y + wave); + else ctx.lineTo(x, y + wave); + } + ctx.stroke(); + } + + ctx.restore(); + } + + function drawGrid(p) { + ctx.save(); + ctx.strokeStyle = 'rgba(155, 177, 222, .10)'; + ctx.lineWidth = 1; + + for (let i = 0; i <= 12; i++) { + const x = p.left + p.stageWidth * (i / 12); + segment(ctx, x, p.top, x, p.bottom); + } + + for (let i = 0; i <= 8; i++) { + const y = p.top + p.stageHeight * (i / 8); + segment(ctx, p.left, y, p.right, y); + } + + ctx.restore(); + } + + function drawSceneFrame(p) { + ctx.save(); + ctx.strokeStyle = 'rgba(220, 230, 255, .26)'; + ctx.lineWidth = 1; + ctx.setLineDash([7, 8]); + ctx.strokeRect(p.left, p.top, p.stageWidth, p.stageHeight); + ctx.setLineDash([]); + + const corner = 16; + ctx.strokeStyle = 'rgba(233, 240, 255, .72)'; + ctx.lineWidth = 1.4; + cornerMark(ctx, p.left, p.top, 1, 1, corner); + cornerMark(ctx, p.right, p.top, -1, 1, corner); + cornerMark(ctx, p.left, p.bottom, 1, -1, corner); + cornerMark(ctx, p.right, p.bottom, -1, -1, corner); + ctx.restore(); + } + + function updateTrails(p) { + trail.save(); + trail.globalCompositeOperation = 'destination-out'; + trail.fillStyle = options.trails ? 'rgba(0, 0, 0, .09)' : 'rgba(0, 0, 0, 1)'; + trail.fillRect(0, 0, width, height); + trail.restore(); + + if (!options.trails || !visibility.points) return; + + trail.save(); + trail.globalCompositeOperation = 'lighter'; + for (const view of objects.values()) { + if (!view.points?.length) continue; + drawPointCloud(trail, view, p, .10, 1.15, false); + } + trail.restore(); + } + + function drawObject(view, p, now) { + const fade = Math.min(1, Math.max(0, (STALE_MS - (now - view.lastSeen)) / 120)); + const color = objectColor(view); + + if (visibility.points && view.points?.length) { + drawPointCloud(ctx, view, p, .88 * fade, options.glow ? 1.75 : 1.4, options.glow); + } + + if (visibility.clusters) drawBounds(view, p, color, fade); + if (visibility.vectors) drawVelocity(view, p, color, fade); + } + + function drawPointCloud(target, view, p, alpha, radius, glow) { + const data = view.points; + if (!data?.length) return; + + const color = objectColor(view); + const pointCount = Math.floor(data.length / 3); + const stride = Math.max(1, Math.ceil(pointCount / MAX_POINTS)) * 3; + + target.save(); + target.fillStyle = colorAlpha(color, alpha); + if (glow) { + target.shadowColor = color; + target.shadowBlur = 8; + } + + 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 < p.left || x > p.right || y < p.top || y > p.bottom) continue; + target.fillRect(x - radius * .5, y - radius * .5, radius, radius); + } + + target.restore(); + } + + function drawBounds(view, p, color, alpha) { + const centerX = p.x(view.center[0]); + const centerY = p.y(view.center[1]); + const boxWidth = Math.max(Math.abs(view.size[0]) * p.scaleX, 12); + const boxHeight = Math.max(Math.abs(view.size[1]) * p.scaleY, 20); + const x = centerX - boxWidth * .5; + const y = centerY - boxHeight * .5; + + ctx.save(); + ctx.strokeStyle = colorAlpha(color, .78 * alpha); + ctx.lineWidth = 1.2; + + if (options.glow) { + ctx.shadowColor = color; + ctx.shadowBlur = 10; + } + + ctx.strokeRect(x, y, boxWidth, boxHeight); + + const corner = Math.min(14, Math.max(7, Math.min(boxWidth, boxHeight) * .13)); + ctx.lineWidth = 2.1; + cornerMark(ctx, x, y, 1, 1, corner); + cornerMark(ctx, x + boxWidth, y, -1, 1, corner); + cornerMark(ctx, x, y + boxHeight, 1, -1, corner); + cornerMark(ctx, x + boxWidth, y + boxHeight, -1, -1, corner); + + const cx = p.x(view.centroid[0]); + const cy = p.y(view.centroid[1]); + ctx.fillStyle = colorAlpha(color, .9 * alpha); + ctx.beginPath(); + ctx.arc(cx, cy, 2.4, 0, Math.PI * 2); + ctx.fill(); + + if (options.labels) drawLabel(view, x, y, color, alpha); + ctx.restore(); + } + + function drawLabel(view, x, y, color, alpha) { + const id = view.id !== undefined ? `ID ${view.id}` : view.uuid?.slice(0, 8) || 'OBJECT'; + ctx.shadowBlur = 0; + ctx.font = '600 9px Inter, ui-sans-serif, system-ui, sans-serif'; + ctx.fillStyle = colorAlpha(color, .92 * alpha); + ctx.fillText(id, x, Math.max(16, y - 8)); + + ctx.font = '500 8px Inter, ui-sans-serif, system-ui, sans-serif'; + ctx.fillStyle = 'rgba(205, 216, 240, .48)'; + ctx.fillText( + `X ${view.center[0].toFixed(2)} Y ${view.center[1].toFixed(2)}`, + x, + Math.max(26, y + 5) + ); + } + + function drawVelocity(view, p, color, alpha) { + const vx = Number(view.velocity[0]) || 0; + const vy = Number(view.velocity[1]) || 0; + const speed = Math.hypot(vx, vy); + if (speed < .01) return; + + const x = p.x(view.centroid[0]); + const y = p.y(view.centroid[1]); + const length = Math.min(58, 12 + speed * 30); + const nx = vx / speed; + const ny = -vy / speed; + + ctx.save(); + ctx.strokeStyle = colorAlpha(color, .55 * alpha); + ctx.fillStyle = colorAlpha(color, .55 * alpha); + ctx.lineWidth = 1.1; + segment(ctx, x, y, x + nx * length, y + ny * length); + + ctx.translate(x + nx * length, y + ny * length); + ctx.rotate(Math.atan2(ny, nx)); + ctx.beginPath(); + ctx.moveTo(0, 0); + ctx.lineTo(-6, -3); + ctx.lineTo(-6, 3); + ctx.closePath(); + ctx.fill(); + ctx.restore(); + } + + function drawZones(p) { + if (!zones.length) return; + + ctx.save(); + ctx.font = '500 8px Inter, ui-sans-serif, system-ui, sans-serif'; + + for (const zone of zones.slice(0, 12)) { + const x = p.x(zone.position[0]); + if (x < p.left || x > p.right) continue; + ctx.strokeStyle = 'rgba(191, 166, 255, .40)'; + segment(ctx, x, p.bottom - 8, x, p.bottom + 8); + ctx.fillStyle = 'rgba(191, 166, 255, .55)'; + ctx.fillText(zone.name, x + 5, p.bottom - 10); + } + + ctx.restore(); + } + + function drawHud(p, count) { + ctx.save(); + ctx.font = '600 9px Inter, ui-sans-serif, system-ui, sans-serif'; + ctx.fillStyle = 'rgba(230, 237, 252, .74)'; + + const dimensions = scene.size.map((value) => Number(value).toFixed(1)).join(' × '); + ctx.fillText(`DIMENSIONS ${dimensions} m`, 24, 28); + ctx.fillText(`PEOPLE ${count}`, 24, 43); + + ctx.textAlign = 'right'; + ctx.fillStyle = 'rgba(193, 207, 235, .46)'; + ctx.fillText('AUGMENTA / LIVE DATA', p.right, 28); + ctx.restore(); + } + + function makeProjection() { + const usableWidth = Math.max(180, width - rightInset); + const left = Math.max(40, usableWidth * .075); + const right = usableWidth - Math.max(40, usableWidth * .075); + const top = Math.max(72, height * .12); + const bottom = height - Math.max(48, height * .09); + const stageWidth = Math.max(1, right - left); + const stageHeight = Math.max(1, bottom - top); + + return { + usableWidth, + left, + right, + top, + bottom, + stageWidth, + stageHeight, + centerX: (left + right) * .5, + scaleX: stageWidth / Math.max(scene.maxX - scene.minX, .001), + scaleY: stageHeight / Math.max(scene.maxY - scene.minY, .001), + x: (value) => left + ((value - scene.minX) / Math.max(scene.maxX - scene.minX, .001)) * stageWidth, + y: (value) => bottom - ((value - scene.minY) / Math.max(scene.maxY - scene.minY, .001)) * stageHeight + }; + } + + function currentPalette() { + return PALETTES[options.palette] || PALETTES.punchy; + } + + function objectColor(view) { + const palette = currentPalette(); + return palette[view.colorIndex % palette.length]; + } + + function setVisibility(next) { + Object.assign(visibility, next); + } + + function setOptions(next) { + Object.assign(options, next); + if (!options.trails) trail.clearRect(0, 0, width, height); + } + + function clearTracking() { + objects.clear(); + trail.clearRect(0, 0, width, height); + } + + function clearSetup() { + scene = defaultScene(); + zones = []; + } + + function reset() { + trail.clearRect(0, 0, width, height); + } + + function setRightInset(value) { + rightInset = Math.max(0, Number(value) || 0); + } + + new ResizeObserver(resize).observe(host); + resize(); + requestAnimationFrame(animate); + + return { + clearSetup, + clearTracking, + renderFrame, + renderSetup, + reset, + setOptions, + setRightInset, + setVisibility + }; +} + +function sceneFromContainer(container) { + const position = vector3(container.getPosition?.(), [0, 0, 0]); + const size = vector3(container.getSceneParameters?.()?.size, [10, 4, 8]); + + return { + minX: position[0], + maxX: position[0] + Math.max(Math.abs(size[0]), .001), + minY: position[1], + maxY: position[1] + Math.max(Math.abs(size[1]), .001), + size + }; +} + +function defaultScene() { + return { + minX: -5, + maxX: 5, + minY: 0, + maxY: 4, + size: [10, 4, 8] + }; +} + +function walk(container, visit) { + if (!container) return; + visit(container); + for (const child of container.getChildren?.() || []) walk(child, visit); +} + +function vector3(value, fallback) { + if (!value || value.length < 3) return [...fallback]; + return [0, 1, 2].map((index) => ( + Number.isFinite(Number(value[index])) ? Number(value[index]) : fallback[index] + )); +} + +function ease(current, target, amount) { + for (let i = 0; i < current.length; i++) current[i] += (target[i] - current[i]) * amount; +} + +function hashString(value) { + let hash = 2166136261; + for (const char of String(value)) { + hash ^= char.charCodeAt(0); + hash = Math.imul(hash, 16777619); + } + return hash >>> 0; +} + +function segment(context, x1, y1, x2, y2) { + context.beginPath(); + context.moveTo(x1, y1); + context.lineTo(x2, y2); + context.stroke(); +} + +function cornerMark(context, x, y, sx, sy, length) { + segment(context, x, y, x + sx * length, y); + segment(context, x, y, x, y + sy * length); +} + +function colorAlpha(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))})`; +} diff --git a/src/zone-state.js b/src/zone-state.js new file mode 100644 index 0000000..9d69499 --- /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 = 50; + +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..ad78692 --- /dev/null +++ b/src/zones.js @@ -0,0 +1,834 @@ +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; +const SPHERE_WIDTH_SEGMENTS = 24; +const SPHERE_HEIGHT_SEGMENTS = 16; +const MIN_GEOMETRY_SIZE = 0.001; + +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, false); + + 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), MIN_GEOMETRY_SIZE), + Math.max(Math.abs(radius), MIN_GEOMETRY_SIZE), + Math.max(Math.abs(height), MIN_GEOMETRY_SIZE), + ROUND_OUTLINE_SEGMENTS + ); + } + + case ShapeType.Sphere: + return new THREE.SphereGeometry( + Math.max(Math.abs(params.getSphereShapeParameters().radius), MIN_GEOMETRY_SIZE), + SPHERE_WIDTH_SEGMENTS, + SPHERE_HEIGHT_SEGMENTS + ); + + 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), MIN_GEOMETRY_SIZE), + Math.max(Math.abs(height), MIN_GEOMETRY_SIZE) + ); + } else if (params.isSphere()) { + guidePositions = sphereEquatorPositions( + Math.max(Math.abs(params.getSphereShapeParameters().radius), MIN_GEOMETRY_SIZE) + ); + } + + 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), MIN_GEOMETRY_SIZE); + const h = Math.max(Math.abs(height), MIN_GEOMETRY_SIZE); + 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), MIN_GEOMETRY_SIZE); + 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), MIN_GEOMETRY_SIZE)); +} 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..964a458 --- /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 50 ms', () => { + assert.equal(ZONE_STREAM_STALE_MS, 50); +}); + +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 += '' + + escapeXml(title.text) + '' : ''; + qrSvg += (alt.text) ? '' + + escapeXml(alt.text) + '' : ''; + qrSvg += ''; + qrSvg += ''; + 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 += '': 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;