diff --git a/AGENTS.md b/AGENTS.md index d6716cf..efe030e 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -21,6 +21,8 @@ Keep this example small, browser-native, and easy to inspect. ## Runtime and dependencies - GitHub Pages output must be self-contained at runtime. Keep Three.js and the built SDK copied into the assembled artifact by `scripts/assemble-site.mjs`. +- Keep application-side tracking buffering bounded: one pending frame per Scene, continuous state latest-wins, transient cluster/zone edges conflated rather than queued as full frames. +- Keep Three.js point-cloud buffers owned by the renderer. Do not attach SDK point arrays directly because they may be views into a complete decompressed WebSocket payload; reuse capacity and dispose replaced geometries. - Avoid adding dependencies for behavior that can stay simple and local. ## Validation diff --git a/README.md b/README.md index 176b87d..2c2e5c5 100644 --- a/README.md +++ b/README.md @@ -10,13 +10,13 @@ The example is deliberately both a **reference integration** and a **debug viewe The page includes a **Simulate data** toggle, so the UI and rendering can be tested without an Augmenta server. The synthetic setup uses the same World → Scene → Zone hierarchy as a live setup. While active, the button reads **Simulating** and clicking it again stops the simulation. -Connection settings, display toggles, the preferred Scene, sidebar state, section/Advanced fold state, and the current camera position/orbit target are stored in browser `localStorage` and restored on refresh. The bottom-left QR code opens the same page with the current Augmenta address, port, protocol and downsample embedded in the URL. Those shared URL values are a tab-local override. Editing one connection field updates only that field in this browser's saved defaults; the other QR-provided values stay temporary and independent. Transient runtime state such as connection status, simulation state, received tracking data, and debug contents is intentionally not persisted. +Connection settings, display toggles, the preferred Scene, sidebar state, main-section fold state, the Connection Advanced fold state, and the current camera position/orbit target are stored in browser `localStorage` and restored on refresh. The bottom-left QR code opens the same page with the current Augmenta address, port, protocol and downsample embedded in the URL. Those shared URL values are a tab-local override. Editing one connection field updates only that field in this browser's saved defaults; the other QR-provided values stay temporary and independent. Transient runtime state such as connection status, simulation state, received tracking data, and debug contents is intentionally not persisted. > GitHub Pages is served over HTTPS. The example still tries `ws://` first, then `wss://`, because local Augmenta outputs commonly expose a plain WebSocket endpoint. ## What it shows -The viewer requests the richest practical uncompressed stream from the Augmenta WebSocket Output: +The viewer requests the richest practical Zstd-compressed stream from the Augmenta WebSocket Output: - automatic protocol selection: try V3 first, then reconnect with the server-reported V2/V3 parser when needed; - clusters and stable IDs / UUIDs; @@ -39,7 +39,9 @@ Three.js renders: - a Pleiades-style orbit camera, 1 × 1 m floor grid and axes; - the Pleiades-style view cube: a bare draggable cube in perspective and an **Ortho** panel with Front/Back/Left/Right/Top/Bottom shortcuts in orthographic mode. The panel × returns to the remembered perspective view. Its labels follow the Three.js world axes directly: Front = +Z, Back = -Z, Right = +X, Left = -X, Top = +Y, Bottom = -Y. -Mouse navigation follows the Pleiades viewer philosophy: left-drag orbits, right-drag pans parallel to the floor, and middle-drag/wheel zooms. The perspective camera can continue roughly 90° past the horizontal floor view, stopping only near the opposite pole, while broad finite distance/zoom limits prevent runaway navigation. The ViewCube itself can also be dragged to orbit the camera directly with the same horizontal/vertical drag directions as the main Three.js orbit controls; a short click on a cube face switches to the matching orthographic view, while pointer capture begins only after the gesture becomes a drag. The ViewCube follows one simple projection rule: perspective uses only the bare cube, while orthographic mode uses the full **Ortho** panel and view shortcuts. With the menu unfolded, the perspective cube remains visible; with the menu folded, it appears only after the camera actually starts moving during a drag and fades out gently afterward, so a simple click never flashes the cube. The Ortho panel stays visible regardless of menu state. Switching between orthographic presets updates the camera and selected button immediately, while the cube keeps a short 3D rotation animation for orientation feedback. Entering Ortho from perspective follows Pleiades' direct position/target framing motion, with the same 320 ms cubic-bezier timing as the cube and a projection-scale interpolation that starts from the current perspective framing. While Ortho is active, **Tab** cycles through the method views in their displayed order — Top, Front, Right, Left, Back, Bottom — while **Shift+Tab** goes in reverse; **Escape** exits Ortho without folding the main menu. There is no separate ISO shortcut anymore: the panel × exits orthographic mode and returns to the remembered perspective composition. Dragging out of Ortho is different by design: it keeps the current camera radius, target and direction and matches the orthographic framing with perspective optical zoom. This mirrors Pleiades' radius-based orthographic framing and avoids moving the camera in or out during the projection switch. In Ortho, pan and zoom stay orthographic. Dragging the ViewCube or main 3D view out of Ortho converts the current view to matching perspective framing as soon as the drag becomes a real orbit, then continues the same drag in perspective. Folding the menu still hides the QR code. The Ortho panel × returns to the last perspective composition (including its orbit, pan and zoom) rather than resetting it; **Reset camera** explicitly replaces that remembered perspective view with the automatic home framing, so a later full setup can frame its new bounds normally. The selected projection, orthographic zoom, camera position and orbit target are persisted after navigation and restored on refresh; setup updates do not overwrite a restored/user-controlled view. Without a saved/user-controlled view, a successful connection setup initializes the same centered framing used by **Reset camera**. **Simulate data**, **Reset camera**, or a left-button double-click reframes the displayed setup. The sidebar uses two lightweight levels of disclosure: each main section can be folded to a compact status row, and each section can expose a denser **Advanced** subsection for less-used controls. In **Connection**, the server address, Connect/Simulate controls, and live connection log stay primary while Port, Protocol and Point downsample live under Advanced. **Display** keeps the Scene plus common visibility toggles primary, with **Reset camera** beside the Scene label and Velocity vectors under Advanced. **Live debug data** stays readable as the main content with a lightweight **Clear data** action above the inspector. While the Live debug data section is folded, only its compact summary is refreshed; the detailed debug HTML is not rebuilt until the section is opened again. While interacting with Frame / Objects / Zones / Control disclosures, debug DOM refreshes briefly pause so a live 4 Hz update cannot replace the clicked disclosure between pointer-down and click. The Display panel exposes **All scenes** plus every Scene received in the current World, with independent **Scenes** and **Zones** visibility toggles. On desktop, the translucent panel overlays the 3D view: use the edge arrow to hide/show it, or drag its left edge to resize it. On phone/touch layouts, the same control becomes a small horizontal handle at the viewer/menu boundary; folding the menu expands the viewer to the full viewport while keeping the handle available to reopen it. **Escape** folds the panel. **H** toggles the viewer title/subtitle as a transient display shortcut; unlike the other UI/display preferences, this title visibility is intentionally not persisted. On desktop, after 3 seconds without mouse movement or keyboard input, the handle fades whether the panel is unfolded or folded; activity brings it back quickly. On phone/touch layouts, the handle stays visible so the folded menu can always be reopened. When folded, the sidebar and QR code are hidden and removed from interaction; the ViewCube follows the contextual perspective/orthographic rules above. The camera projection follows the panel width so the orbit target remains centered in the unobscured part of the view. +Mouse navigation follows the Pleiades viewer philosophy: left-drag orbits, right-drag pans parallel to the floor, and middle-drag/wheel zooms. The perspective camera can continue roughly 90° past the horizontal floor view, stopping only near the opposite pole, while broad finite distance/zoom limits prevent runaway navigation. The ViewCube itself can also be dragged to orbit the camera directly with the same horizontal/vertical drag directions as the main Three.js orbit controls; a short click on a cube face switches to the matching orthographic view, while pointer capture begins only after the gesture becomes a drag. The ViewCube follows one simple projection rule: perspective uses only the bare cube, while orthographic mode uses the full **Ortho** panel and view shortcuts. With the menu unfolded, the perspective cube remains visible; with the menu folded, it appears only after the camera actually starts moving during a drag and fades out gently afterward, so a simple click never flashes the cube. The Ortho panel stays visible regardless of menu state. Switching between orthographic presets updates the camera and selected button immediately, while the cube keeps a short 3D rotation animation for orientation feedback. Entering Ortho from perspective follows Pleiades' direct position/target framing motion, with the same 320 ms cubic-bezier timing as the cube and a projection-scale interpolation that starts from the current perspective framing. While Ortho is active, **Tab** cycles through the method views in their displayed order — Top, Front, Right, Left, Back, Bottom — while **Shift+Tab** goes in reverse; **Escape** exits Ortho without folding the main menu. There is no separate ISO shortcut anymore: the panel × exits orthographic mode and returns to the remembered perspective composition. Dragging out of Ortho is different by design: it keeps the current camera radius, target and direction and matches the orthographic framing with perspective optical zoom. This mirrors Pleiades' radius-based orthographic framing and avoids moving the camera in or out during the projection switch. In Ortho, pan and zoom stay orthographic. Dragging the ViewCube or main 3D view out of Ortho converts the current view to matching perspective framing as soon as the drag becomes a real orbit, then continues the same drag in perspective. Folding the menu still hides the QR code. The Ortho panel × returns to the last perspective composition (including its orbit, pan and zoom) rather than resetting it; **Reset camera** explicitly replaces that remembered perspective view with the automatic home framing, so a later full setup can frame its new bounds normally. The selected projection, orthographic zoom, camera position and orbit target are persisted after navigation and restored on refresh; setup updates do not overwrite a restored/user-controlled view. Without a saved/user-controlled view, a successful connection setup initializes the same centered framing used by **Reset camera**. **Simulate data**, **Reset camera**, or a left-button double-click reframes the displayed setup. The sidebar keeps the main sections foldable to compact status rows. **Connection** also exposes an **Advanced** subsection for less-used transport controls. In **Connection**, the server address, Connect/Simulate controls, and live connection log stay primary while Port, Protocol and Point downsample live under Advanced. **Display** keeps the Scene plus all visibility toggles primary, including **Velocity vectors**, with **Reset camera** beside the Scene label. **Live debug data** stays readable as the main content with a lightweight **Clear data** action above the inspector. While the Live debug data section is folded, only its compact summary is refreshed; the detailed debug HTML is not rebuilt until the section is opened again. While interacting with Frame / Objects / Zones / Control disclosures, debug DOM refreshes briefly pause so a live 4 Hz update cannot replace the clicked disclosure between pointer-down and click. The Display panel exposes **All scenes** plus every Scene received in the current World, with independent **Scenes** and **Zones** visibility toggles. On desktop, the translucent panel overlays the 3D view: use the edge arrow to hide/show it, or drag its left edge to resize it. On phone/touch layouts, the same control becomes a small horizontal handle at the viewer/menu boundary; folding the menu expands the viewer to the full viewport while keeping the handle available to reopen it. **Escape** folds the panel. **H** toggles the viewer title/subtitle as a transient display shortcut; unlike the other UI/display preferences, this title visibility is intentionally not persisted. On desktop, after 3 seconds without mouse movement or keyboard input, the handle fades whether the panel is unfolded or folded; activity brings it back quickly. On phone/touch layouts, the handle stays visible so the folded menu can always be reopened. When folded, the sidebar and QR code are hidden and removed from interaction; the ViewCube follows the contextual perspective/orthographic rules above. The camera projection follows the panel width so the orbit target remains centered in the unobscured part of the view. + +Tracking-frame delivery is also bounded at the renderer boundary. The WebSocket/SDK parser can receive data faster than the browser display loop, so the viewer keeps at most one pending frame per Scene. Continuous state is latest-wins, while cluster transition states and zone enter/leave counters are conflated into the newest pending frame. Replaced frames never keep their old point clouds alive. Point-cloud rendering uses owned reusable GPU buffers with draw ranges, so SDK views into decompressed WebSocket payloads are not retained by Three.js and small point-count fluctuations do not force a buffer allocation every frame. Partial setup updates are merged into the cached hierarchy before rendering, so a Scene update that omits its Zone children does not erase them. Updates that race ahead of the initial full setup are ignored rather than promoted to an incomplete root. When a specific Scene is selected, live setup updates still render from the World root so ancestor transforms remain intact. Zone-event caches are pruned against the latest setup hierarchy so renamed/removed addresses do not accumulate stale visual state. A zone stops rendering 50 ms after its live event heartbeat disappears, so disabled zones vanish quickly while their setup definition remains available. Scene selection and scene-size debug information therefore always use the latest valid merged setup. @@ -104,7 +106,7 @@ The example requests: streamClusters: true, streamClusterPoints: true, streamZonePoints: true, - useCompression: false, + useCompression: true, displayPointIntensity: true, boxRotationMode: RotationMode.Quaternions, axisTransform: { @@ -118,7 +120,7 @@ The example requests: } ``` -Compression is disabled because the browser example intentionally stays dependency-free. Applications that need compressed streams can provide the SDK with a Zstd decompressor. +Compression is enabled by default. The example initializes `zstddec` before opening the Augmenta WebSocket and injects its synchronous decoder into the JavaScript SDK. The GitHub Pages artifact vendors the decoder (including its WebAssembly payload), so the deployed viewer remains self-contained at runtime. The JS SDK parses velocity directly from the binary cluster property at the same offset as the C++ and C# SDKs. It does not reconstruct velocity from positions; if Augmenta sends a zero vector, the example intentionally displays that zero vector rather than inventing a replacement. @@ -150,6 +152,7 @@ This separation keeps the SDK reusable by non-Three.js applications and keeps re │ ├── connection.js # WebSocket/retry/protocol controller │ ├── setup-store.js # Setup cache + partial update merging │ ├── motion.js # Consumer-side velocity → speed helper +│ ├── frame-buffer.js # Bounded latest-frame conflation + transient edges │ ├── qr.js # QR rendering + copy interaction │ ├── view-cube.js # ViewCube UI, Ortho shortcuts + keyboard navigation │ ├── view-transition.js # Shared camera/ViewCube transition timing + easing @@ -162,6 +165,9 @@ This separation keeps the SDK reusable by non-Three.js applications and keeps re │ └── styles.css # Debug UI ├── tests/ │ ├── connection-targets.test.mjs # Hostname/IP fallback ordering +│ ├── frame-buffer.test.mjs # Latest-state + transient-event conflation +│ ├── main-ui-state.test.mjs # Removed UI-state regression guard +│ ├── ui-contract.test.mjs # UI map/DOM selector smoke tests │ ├── demo.test.mjs # Synthetic World/Scene hierarchy │ ├── setup-store.test.mjs # Partial setup update regression tests │ ├── share-link.test.mjs # Shared connection URL regression tests diff --git a/src/frame-buffer.js b/src/frame-buffer.js new file mode 100644 index 0000000..d22cdbd --- /dev/null +++ b/src/frame-buffer.js @@ -0,0 +1,154 @@ +import { + ClusterProperty, + ClusterState, + DataBlob, + ObjectPacket, + ZoneEventPacket +} from 'augmenta-client-sdk'; + +const MAX_ZONE_EDGE_COUNT = 255; + +function objectIdentity(object) { + const uuid = object.getUUID(); + if (uuid) return `uuid:${uuid}`; + + const id = object.getID(); + return id === undefined ? undefined : `id:${id}`; +} + +function isTransientClusterState(state) { + return state === ClusterState.Entered + || state === ClusterState.WillLeave + || state === ClusterState.Ghost; +} + +function cloneClusterWithState(cluster, state) { + return new ClusterProperty( + state, + cluster.getCentroid(), + cluster.getVelocity(), + cluster.getBoundingBoxCenter(), + cluster.getBoundingBoxSize(), + cluster.getWeight(), + cluster.boundingBoxRotation, + cluster.getLookAt() + ); +} + +function objectWithClusterState(object, state) { + const cluster = cloneClusterWithState(object.getCluster(), state); + return new ObjectPacket( + object.getID(), + cluster, + object.hasPointCloud() ? object.getPointCloud() : undefined, + object.getUUID() + ); +} + +function transientOnlyObject(object) { + return new ObjectPacket( + object.getID(), + object.getCluster(), + undefined, + object.getUUID() + ); +} + +function saturatingAdd(a, b) { + return Math.min(MAX_ZONE_EDGE_COUNT, Math.max(0, Number(a) || 0) + Math.max(0, Number(b) || 0)); +} + +/** + * Conflate two tracking frames from the same scene. + * + * Continuous state comes from the newest frame. Cluster transition states and + * zone enter/leave counters from the replaced frame are preserved without + * retaining its point clouds. + */ +export function conflateTrackingFrames(previous, incoming) { + if (!previous) return incoming; + if (!incoming) return previous; + + const previousScene = previous.getSceneInfo().getAddress() || ''; + const incomingScene = incoming.getSceneInfo().getAddress() || ''; + if (previousScene !== incomingScene) return incoming; + + const objects = [...incoming.getObjects()]; + const incomingByIdentity = new Map(); + + objects.forEach((object, index) => { + const key = objectIdentity(object); + if (key) incomingByIdentity.set(key, index); + }); + + for (const previousObject of previous.getObjects()) { + if (!previousObject.hasCluster()) continue; + + const previousState = previousObject.getCluster().getState(); + if (!isTransientClusterState(previousState)) continue; + + const key = objectIdentity(previousObject); + if (!key) continue; + + const incomingIndex = incomingByIdentity.get(key); + if (incomingIndex === undefined) { + // Keep the edge state for one render tick, but explicitly drop the old + // point cloud so conflation never retains a stale heavy buffer. + objects.push(transientOnlyObject(previousObject)); + continue; + } + + const incomingObject = objects[incomingIndex]; + if (!incomingObject.hasCluster()) continue; + + const incomingState = incomingObject.getCluster().getState(); + if (!isTransientClusterState(incomingState)) { + // Keep the newest transform/velocity/point cloud while carrying the + // transition state across the skipped frame. + objects[incomingIndex] = objectWithClusterState(incomingObject, previousState); + } + } + + const zoneEvents = [...incoming.getZoneEvents()]; + const incomingZoneIndex = new Map( + zoneEvents.map((event, index) => [event.getEmitterZoneAddress(), index]) + ); + + for (const previousEvent of previous.getZoneEvents()) { + const enters = previousEvent.getEnters(); + const leaves = previousEvent.getLeaves(); + if (enters <= 0 && leaves <= 0) continue; + + const address = previousEvent.getEmitterZoneAddress(); + const incomingIndex = incomingZoneIndex.get(address); + + if (incomingIndex === undefined) { + zoneEvents.push(new ZoneEventPacket( + address, + enters, + leaves, + previousEvent.getPresence(), + previousEvent.getDensity(), + [] + )); + continue; + } + + const current = zoneEvents[incomingIndex]; + zoneEvents[incomingIndex] = new ZoneEventPacket( + address, + saturatingAdd(enters, current.getEnters()), + saturatingAdd(leaves, current.getLeaves()), + current.getPresence(), + current.getDensity(), + current.getProperties() + ); + } + + return new DataBlob( + incoming.getSceneInfo(), + objects, + zoneEvents, + incoming.timestamp + ); +} diff --git a/src/viewer.js b/src/viewer.js index 94de0ae..28ee9b0 100644 --- a/src/viewer.js +++ b/src/viewer.js @@ -5,6 +5,7 @@ import { speedFromVelocity } from './motion.js'; import { createZoneRenderer } from './zones.js'; import { collectZoneAddresses } from './zone-state.js'; import { VIEW_TRANSITION, viewTransitionEase } from './view-transition.js'; +import { conflateTrackingFrames } from './frame-buffer.js'; const FLOOR_Y = 0; const PANEL_INSET_ANIMATION_DURATION_MS = 220; @@ -707,7 +708,11 @@ export function createViewer(host, { onFramePresented } = {}) { function queueFrame(frame) { const sceneAddress = frame.getSceneInfo().getAddress() || ''; - pendingFrames.set(sceneAddress, frame); + const previous = pendingFrames.get(sceneAddress); + pendingFrames.set( + sceneAddress, + previous ? conflateTrackingFrames(previous, frame) : frame + ); } function flushPendingFrames() { @@ -939,22 +944,51 @@ export function createViewer(host, { onFramePresented } = {}) { function updatePoints(view, cloud, clusterBacked) { const data = cloud.getPointsData(); - const position = view.points.geometry.getAttribute('position'); + const requiredFloats = data.length; + let geometry = view.points.geometry; + let position = geometry.getAttribute('position'); + + if (!position || position.array.length < requiredFloats) { + // Own the renderer buffer instead of attaching the SDK Float32Array + // directly. SDK point arrays can be views into a complete decompressed + // WebSocket frame; retaining them would keep that whole frame alive. + const capacity = pointBufferCapacity(requiredFloats); + const nextGeometry = new THREE.BufferGeometry(); + const nextPosition = new THREE.BufferAttribute(new Float32Array(capacity), 3); + nextPosition.setUsage(THREE.DynamicDrawUsage); + nextGeometry.setAttribute('position', nextPosition); + + if (geometry.boundingSphere) { + nextGeometry.boundingSphere = geometry.boundingSphere.clone(); + } + + view.points.geometry = nextGeometry; + geometry.dispose(); + geometry = nextGeometry; + position = nextPosition; + } - if (position && position.array.length === data.length) { - position.array.set(data); + if (position && requiredFloats > 0) { + position.array.set(data, 0); position.needsUpdate = true; - } else { - const nextPosition = new THREE.BufferAttribute(data, 3); - nextPosition.setUsage(THREE.DynamicDrawUsage); - view.points.geometry.setAttribute('position', nextPosition); } + geometry.setDrawRange(0, requiredFloats / 3); - view.hasPointCloud = data.length > 0; + view.hasPointCloud = requiredFloats > 0; view.pointCloudKind = clusterBacked ? 'cluster' : 'general'; applyPointVisibility(view); } + function pointBufferCapacity(requiredFloats) { + if (requiredFloats <= 0) return 0; + + // Grow in small point-count chunks so variable clouds reuse their GPU + // buffer without reallocating on every minor size fluctuation. + const chunkPoints = 256; + const requiredPoints = Math.ceil(requiredFloats / 3); + return Math.ceil(requiredPoints / chunkPoints) * chunkPoints * 3; + } + function applyPointVisibility(view) { const enabled = view.pointCloudKind === 'cluster' ? visibility.clusterPoints diff --git a/tests/frame-buffer.test.mjs b/tests/frame-buffer.test.mjs new file mode 100644 index 0000000..3330738 --- /dev/null +++ b/tests/frame-buffer.test.mjs @@ -0,0 +1,108 @@ +import assert from 'node:assert/strict'; +import test from 'node:test'; +import { + ClusterProperty, + ClusterState, + DataBlob, + ObjectPacket, + PointCloudProperty, + SceneInfoPacket, + ZoneEventPacket, + ZoneEventProperty, + ZonePropertyType +} from 'augmenta-client-sdk'; +import { conflateTrackingFrames } from '../src/frame-buffer.js'; + +function cluster(state, x = 0) { + return new ClusterProperty( + state, + [x, 0, 0], + [x, 0, 0], + [x, 0, 0], + [1, 1, 1], + 1, + [0, 0, 0, 1], + [0, 0, 1] + ); +} + +function frame({ objects = [], zones = [] } = {}) { + return new DataBlob(new SceneInfoPacket('/Scene'), objects, zones, 123); +} + +test('conflation keeps newest continuous object data while preserving Entered', () => { + const oldCloud = new PointCloudProperty(new Float32Array([1, 2, 3, 4, 5, 6])); + const newCloud = new PointCloudProperty(new Float32Array([7, 8, 9])); + + const previous = frame({ + objects: [new ObjectPacket(7, cluster(ClusterState.Entered, 1), oldCloud, 'uuid-7')] + }); + const incoming = frame({ + objects: [new ObjectPacket(7, cluster(ClusterState.Updated, 2), newCloud, 'uuid-7')] + }); + + const merged = conflateTrackingFrames(previous, incoming); + assert.equal(merged.getObjectCount(), 1); + + const object = merged.getObjects()[0]; + assert.equal(object.getCluster().getState(), ClusterState.Entered); + assert.deepEqual(object.getCluster().getCentroid(), [2, 0, 0]); + assert.equal(object.getPointCloud(), newCloud); + assert.notEqual(object.getPointCloud(), oldCloud); +}); + +test('conflation preserves a leaving object without retaining its old point cloud', () => { + const previous = frame({ + objects: [ + new ObjectPacket( + 9, + cluster(ClusterState.WillLeave, 3), + new PointCloudProperty(new Float32Array(30_000)), + 'uuid-9' + ) + ] + }); + + const merged = conflateTrackingFrames(previous, frame()); + assert.equal(merged.getObjectCount(), 1); + assert.equal(merged.getObjects()[0].getCluster().getState(), ClusterState.WillLeave); + assert.equal(merged.getObjects()[0].hasPointCloud(), false); +}); + +test('conflation accumulates zone edges but keeps newest continuous zone values', () => { + const oldProperties = [ + new ZoneEventProperty(ZonePropertyType.Slider, { value: 0.2 }) + ]; + const newProperties = [ + new ZoneEventProperty(ZonePropertyType.Slider, { value: 0.8 }) + ]; + + const previous = frame({ + zones: [new ZoneEventPacket('/Scene/Zone', 2, 1, 1, 0.25, oldProperties)] + }); + const incoming = frame({ + zones: [new ZoneEventPacket('/Scene/Zone', 3, 4, 5, 0.75, newProperties)] + }); + + const merged = conflateTrackingFrames(previous, incoming); + const zone = merged.getZoneEvents()[0]; + + assert.equal(zone.getEnters(), 5); + assert.equal(zone.getLeaves(), 5); + assert.equal(zone.getPresence(), 5); + assert.equal(zone.getDensity(), 0.75); + assert.equal(zone.getProperties()[0].getSliderParameters().value, 0.8); +}); + +test('zone edge counters saturate at the protocol byte range', () => { + const previous = frame({ + zones: [new ZoneEventPacket('/Scene/Zone', 250, 250, 1, 1, [])] + }); + const incoming = frame({ + zones: [new ZoneEventPacket('/Scene/Zone', 20, 20, 1, 1, [])] + }); + + const zone = conflateTrackingFrames(previous, incoming).getZoneEvents()[0]; + assert.equal(zone.getEnters(), 255); + assert.equal(zone.getLeaves(), 255); +}); diff --git a/tests/ui-contract.test.mjs b/tests/ui-contract.test.mjs new file mode 100644 index 0000000..7433509 --- /dev/null +++ b/tests/ui-contract.test.mjs @@ -0,0 +1,34 @@ +import assert from 'node:assert/strict'; +import { readFileSync } from 'node:fs'; +import test from 'node:test'; + +const mainSource = readFileSync(new URL('../src/main.js', import.meta.url), 'utf8'); +const indexSource = readFileSync(new URL('../index.html', import.meta.url), 'utf8'); + +test('every ui. reference is declared in the main UI map', () => { + const objectMatch = mainSource.match(/const ui = \{([\s\S]*?)\n\};/); + assert.ok(objectMatch, 'main.js must define the ui map'); + + const declared = new Set( + [...objectMatch[1].matchAll(/\b([A-Za-z_$][\w$]*):\s*\$\(/g)] + .map((match) => match[1]) + ); + const used = new Set( + [...mainSource.matchAll(/\bui\.([A-Za-z_$][\w$]*)/g)] + .map((match) => match[1]) + ); + + const missing = [...used].filter((name) => !declared.has(name)); + assert.deepEqual(missing, []); +}); + +test('every hard-coded #id selector in the main UI map exists in index.html', () => { + const ids = [...mainSource.matchAll(/\$\('#([^']+)'\)/g)].map((match) => match[1]); + + const missing = ids.filter((id) => { + const escaped = id.replace(/[.*+?^$()|[\]\\]/g, '\\$&'); + return !new RegExp('id=["\\\']' + escaped + '["\\\']').test(indexSource); + }); + + assert.deepEqual(missing, []); +});