@realitycollective/service-framework-three 1.0.1-preview.1 → 1.0.1-preview.3

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -6,10 +6,27 @@ The format follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), and
6
6
 
7
7
  ## [1.0.1]
8
8
 
9
- Packaging, tooling and documentation. No runtime behaviour changed, and no public API was added, removed or altered.
9
+ Packaging, tooling and documentation, plus additive runtime API: the engine-free adapter contracts move into the core, the IWSDK adapter derives capabilities from the live session and gains a session facet, and the three.js and Babylon.js packages gain runtime adapters so a three.js, Babylon or desktop app reaches the same seam. Nothing that existed was removed, and one interface changed shape: `AdapterCapabilities` gains a required `environmentBlendMode` key, so code that builds the whole object by hand - an adapter of your own, or a test fake - has one more field to supply. Code that only reads capabilities is unaffected.
10
10
 
11
11
  ### Added
12
12
 
13
+ - `@realitycollective/service-framework` now exports `RuntimeAdapter`, `FrameInfo`, `AdapterCapabilities`, `DEFAULT_CAPABILITIES`, `FrameListener`, `CapabilitiesListener`, `Unsubscribe`, `SnapshotService`, `ServiceContext`, `SnapshotListener` and `MockRuntimeAdapter`. All eleven arrived in `@realitycollective/service-framework-iwsdk`, and none of them ever touched IWSDK: the adapter contract is the seam every host binding needs, `SnapshotService` is a plain `BaseService` subclass, and the mock adapter is how any service is unit-tested headless. An app on three.js or Babylon.js had to depend on the IWSDK package to reach them. They now live in the core, and the IWSDK package re-exports every one of them unchanged, so existing imports keep resolving.
14
+ - `deriveCapabilities(session)` in `@realitycollective/service-framework`, with its structural input types `CapabilitySessionLike` and `CapabilityInputSourceLike`. It reads `immersive`, `handTracking`, `planeDetection` and `passthrough` off a live XR session, and is the derivation `IWSDKAdapter` had been doing privately. A second adapter would have re-implemented the same four rules and then drifted from them, so they live in the core once and every host binding reports the same flags for the same session. `IWSDKAdapter` now calls it and reports exactly what it reported before, and `IWSDKSessionLike` is `CapabilitySessionLike` with `inputSources` required, so nothing changes for a consumer.
15
+ - `WebXRRuntimeAdapter` in `@realitycollective/service-framework-three`, with the structural host types it is written against: `WebXRManagerLike` (`renderer.xr`), `WebXRSystemLike` (`navigator.xr`), `WebXRSessionLike`, `WebXRRuntimeAdapterOptions`, `WebXRManagerEventType`, `WebXRSessionEventType` and `WebXREventListener`. Until now only an IWSDK app could get a `RuntimeAdapter`, so a service written against that seam had no host on a plain three.js page or a desktop build. The adapter orchestrates the entry points the platform already provides - `navigator.xr` to negotiate a session, `renderer.xr` for the session the renderer presents, `setAnimationLoop` for frames - and publishes them through the same interface. It renders nothing, plays nothing and owns no scene state. Given a `host` it owns the animation loop, and given a `scheduler` as well it emits `renderTick` with `source: "three"` exactly as `ThreeRenderLoopBridge` does, so an app needs one loop owner rather than two; given no host, the app drives `emitFrame` itself. `sessionInit` supplies the `XRSessionInit` per mode. Capabilities come from the core's `deriveCapabilities` and are re-derived on the renderer's `sessionstart` and `sessionend` and the session's `inputsourceschange`, with the same override layer, `refreshCapabilities()` and `clearCapabilityOverrides()` the IWSDK adapter has. The same adapter serves a desktop build with no headset: `session.request` returns `{ ok: false, reason: "unsupported" }` where there is no `navigator.xr` or the mode is not supported, and capabilities stay at the all-false defaults.
16
+ - `FIRST_FRAME_DELTA_MS` from the three.js package - the 16 ms the bridge and the adapter both report for a first frame that has no previous timestamp.
17
+ - `BabylonRuntimeAdapter` in `@realitycollective/service-framework-babylon`, with the structural host types it is written against: `BabylonXRExperienceLike` (`WebXRDefaultExperience.baseExperience`), `BabylonSessionManagerLike`, `BabylonXRSessionLike`, `BabylonObservableLike`, `BabylonObserverLike`, `BabylonRuntimeAdapterOptions`, `BabylonXRSessionEventType`, `BabylonXREventListener` and `BabylonWebXRState`, plus the `BABYLON_WEBXR_STATE` and `DEFAULT_REFERENCE_SPACE_TYPE` constants. The package shipped a render-loop bridge and nothing else, so a Babylon app could emit `renderTick` but could not host a service written against `RuntimeAdapter`. The adapter orchestrates the entry points Babylon already provides - the experience helper to negotiate a session, the session manager for the live `XRSession`, `runRenderLoop` for frames - and publishes them through the same interface, with the same members and the same semantics as `WebXRRuntimeAdapter`, so a consumer moving between renderers sees no difference at this seam. Given a `host` it owns the render loop, and given a `scheduler` as well it emits `renderTick` with `source: "babylon"` exactly as `BabylonRenderLoopBridge` does, so an app needs one loop owner rather than two; given no host, the app drives `emitFrame` itself. `referenceSpaceType` defaults to `"local-floor"`, and `sessionInit` supplies the session creation options per mode. Capabilities come from the core's `deriveCapabilities` and are re-derived on `onXRSessionInit`, `onXRSessionEnded` and the session's `inputsourceschange`, with the same override layer, `refreshCapabilities()` and `clearCapabilityOverrides()` the other adapters have. A session started outside the adapter - by Babylon's own enter-XR UI, for instance - is followed through `onStateChangedObservable`, so the facet is correct either way. The same adapter serves a desktop build with no headset: `session.request` returns `{ ok: false, reason: "unsupported" }` where there is no experience or the mode is not supported, and capabilities stay at the all-false defaults. `@babylonjs/core` is still neither imported nor installed: every Babylon shape is structural, and anything a version might move or drop - the session manager, the observables, `isSessionSupportedAsync` - is optional and read through a guard.
18
+ - `FIRST_FRAME_DELTA_MS` from the Babylon.js package - the same 16 ms, now shared by `BabylonRenderLoopBridge` and `BabylonRuntimeAdapter` instead of sitting inline in the bridge.
19
+ - Capability derivation in `IWSDKAdapter`. It reads the live session on construction and on every visibility change: `immersive` when a session exists, `handTracking` from the `hand-tracking` enabled feature or any input source carrying a hand, `planeDetection` from the `plane-detection` enabled feature, and `passthrough` when `environmentBlendMode` is present and is not `opaque`. Subscribers are notified only when a flag actually changes. `setCapabilities` becomes a manual override layer on top, dropped by the new `clearCapabilityOverrides()` or by the new `dispose()`. `refreshCapabilities()` re-derives on demand, for a host whose visibility signal cannot push. Before this, the adapter reported all-false until the app called `setCapabilities` by hand.
20
+ - `inputsourceschange` handling in `IWSDKAdapter`, closing a parity gap with the three.js and Babylon.js adapters, which both bound the event already. The IWSDK adapter re-derived only on the world's visibility signal and at session start and end, so an app that wanted `handTracking` to update when the player put the controllers down had to attach the listener itself. It now attaches one listener to the live session, moves it when a session is replaced, and drops it when the session goes or the adapter is disposed. `IWSDKSessionLike` gains optional `addEventListener` and `removeEventListener` for it, with the two new types `IWSDKSessionEventType` and `IWSDKSessionEventListener`. Both members are optional, so a world faked in a test still satisfies the type, and the adapter guards for their absence: such a host pushes nothing and `refreshCapabilities()` remains the way to nudge it. IWSDK's own world carries a real `XRSession`, so the event arrives there.
21
+ - `requiredFeatures` and `optionalFeatures` on `SessionRequestOptions`, both optional arrays of WebXR feature strings, plus the `mergeSessionInit(init, options)` helper and its `SessionInitLike` type in the core. `SessionRequestOptions` carried only `timeoutMs`, so an app that swapped from `"immersive-vr"` to `"immersive-ar"` mid-session got whatever defaults the host binding was built with, which were chosen for the mode it was leaving. Each binding now folds the request's features over its own: the host's entries come first, the request's are appended, and a feature named twice appears once. A request that names none passes the host's init through untouched, identity included. The three.js and Babylon.js bindings share `mergeSessionInit` rather than writing the same merge twice.
22
+ - `toIWSDKFeatures(options)`, `IWSDK_FEATURE_KEYS` and `IWSDKFeatureMapping` in `@realitycollective/service-framework-iwsdk`, with the structural `IWSDKXRFeatureOptionsLike`, `IWSDKFeatureFlagLike` and `IWSDKDepthSensingFlagLike` types and a `features` member on `IWSDKXROptionsLike`. IWSDK does not take WebXR feature strings: `launchXR` takes an `XROptions` whose `features` is a structured object, one key per feature. The adapter therefore maps `hand-tracking`, `anchors`, `hit-test`, `plane-detection`, `mesh-detection`, `depth-sensing`, `layers` and `unbounded` onto `handTracking`, `anchors`, `hitTest`, `planeDetection`, `meshDetection`, `depthSensing`, `layers` and `unbounded`, setting a required feature as `{ required: true }` and an optional one as `true`; a feature named in both lists comes out required. A string IWSDK has no key for - `local-floor` and `bounded-floor`, which it configures through `referenceSpace`, or `dom-overlay`, which it does not model - is dropped from the request rather than thrown, because the session is still one the host can serve. `toIWSDKFeatures` is exported so an app can read the `unmapped` list and decide for itself before asking. `launchXR` merges what it is given over the world's `xrDefaults`, so a request naming no features leaves the app's defaults alone.
23
+ - `environmentBlendMode` on `AdapterCapabilities`, typed `EnvironmentBlendMode | null` where `EnvironmentBlendMode` is the new `"opaque" | "alpha-blend" | "additive"` union, plus `environmentBlendMode: null` in `DEFAULT_CAPABILITIES`. `deriveCapabilities` reduced the session's blend mode to the boolean `passthrough` and threw the string away, which says whether the world shows through but not how. The two passthrough modes behave oppositely: `"alpha-blend"` is video passthrough, as on a Quest, and composites normally, so black stays black; `"additive"` is a see-through optical display and adds the rendered image to the light already reaching the eye, so black is fully transparent. A service that dims the world must draw brighter on an additive display, not darker, and could not tell the two apart. The key is required rather than optional, mirroring the `presence` decision in `@realitycollective/webxr-input`, so the conformance suite's capability-key check stays meaningful. A value WebXR does not define derives as `null` rather than passing through, so a consumer switching on the mode never meets a string it has no rule for. `passthrough` keeps its exact meaning, still `true` for any blend mode other than `"opaque"`, including one that derives as `null`. Every adapter reports the new key and every adapter's change detection compares it.
24
+ - `IWSDKXROptionsLike` is now exported from `@realitycollective/service-framework-iwsdk`. It was already the declared parameter type of `IWSDKWorldLike.launchXR`, so a consumer typing a world by hand could not name it.
25
+ - An optional session facet on `RuntimeAdapter`: `session?: SessionFacet`, with `getState`, `request(mode, options)`, `end()`, `onStateChange` and `onVisibilityChange`, plus the `SessionMode`, `SessionState`, `SessionResult`, `SessionFailureReason`, `SessionVisibility` and `SessionRequestOptions` types and the `DEFAULT_SESSION_TIMEOUT_MS` constant. A request resolves with a result rather than throwing, because a host that cannot start a session is a normal runtime condition. `IWSDKAdapter` implements it over the world's `launchXR` and `exitXR`; `MockRuntimeAdapter` implements it in memory, driven by `simulateSessionStart()`, `simulateSessionEnd()` and `simulateVisibility()`.
26
+ - `RUNTIME_ADAPTER_FACETS`, the list of optional facets an adapter can carry, and the `RuntimeAdapterFacet` type. A conformance test walks the list, so a facet added without a mock implementation fails the suite instead of being found by a consumer.
27
+ - `renderTick` under IWSDK. `makeServiceBridgeSystem` now emits the scheduler's `renderTick` channel on every focused frame, with `source: "iwsdk"`, alongside the adapter's `onFrame` fan-out it already drove. A service written against the scheduler now runs under IWSDK exactly as it does under the three.js and Babylon.js bridges. IWSDK reports its frame delta in seconds and the scheduler's `LifecycleContext` is in milliseconds, so the bridge converts.
28
+ - Structural contracts for the parts of an IWSDK world the adapter now reads: `IWSDKSessionLike`, `IWSDKInputSourceLike`, an optional `session` and `launchXR` / `exitXR` on `IWSDKWorldLike`, and an optional `subscribe` on `IWSDKSignalLike`. Every addition is optional, so a world carrying nothing but the visibility signal still type-checks and still works. `@iwsdk/core` remains undeclared as a dependency of any kind.
29
+ - A shared runtime-adapter conformance suite, run against `MockRuntimeAdapter`, `IWSDKAdapter`, `WebXRRuntimeAdapter` and `BabylonRuntimeAdapter`, and published as `runtimeAdapterContractCases()` from `@realitycollective/service-framework` with the `RuntimeAdapterContractCase`, `RuntimeAdapterSubject` and `RuntimeAdapterDriver` types. It began as an in-repo vitest helper, which meant an adapter written outside this repository could not prove it conformed: the checks existed but were not shipped. Following `inputProviderContractCases()` in `@realitycollective/webxr-input`, the suite is now data rather than a runner - each case is a `{ name, run }` pair that returns silently on success and throws a plain `Error` on failure - so an adapter repository hosts the whole thing in three lines of its own runner. Some cases are asynchronous, so a runner must await what `run` returns, and each case needs a fresh subject because the session cases drive a session through its whole lifecycle. A subject whose adapter has no session facet passes the session cases without running them. Test coverage now measures `@realitycollective/service-framework-iwsdk`, `@realitycollective/service-framework-three` and `@realitycollective/service-framework-babylon` as well, at the same 100% thresholds as the core and client packages.
13
30
  - Updated documentation packs for all projects.
14
31
  - Improved release scripting and validation to improve delivery coherance.
15
32
  - `scripts/release.config.json` - names the core package and the publish order, so the release tooling is identical across every Reality Collective TypeScript repository.
@@ -18,6 +35,9 @@ Packaging, tooling and documentation. No runtime behaviour changed, and no publi
18
35
 
19
36
  ### Changed
20
37
 
38
+ - The runtime adapter now abstracts session lifecycle. It previously did not, on the stated grounds that IWSDK already owns sessions, input and rendering. That held while every consumer was an IWSDK app. It stopped holding when a reference client needed to request a session, end one and read session visibility: with no seam for it, the client reached past the adapter into the host, which is the coupling the adapter exists to prevent. Sessions are now an optional facet, so a host that owns them can expose them and a host that does not can leave the property off. Input and rendering are still not abstracted.
39
+ - `IWSDKAdapter.setCapabilities` now notifies subscribers only when at least one flag actually changes value. It previously notified on every call, including calls that set what was already set. The signature is unchanged; a consumer that relied on a callback per call will see fewer callbacks.
40
+ - `ThreeRenderLoopBridge` moved out of the three.js package's `index.ts` into `three-render-loop-bridge.ts`, and `index.ts` is now a barrel that re-exports it unchanged beside the new adapter. Import paths are unaffected. The file-wide v8 coverage exclusion the package carried went with it: the bridge is tested against a fake host, and the whole package is measured.
21
41
  - CI and deployment merged into one workflow. They previously ran concurrently and repeated the same install, build, typecheck and test on every pull request. The deploy jobs now consume the artifacts the build job already produced.
22
42
  - CI runs on every pull request regardless of target branch, and reports through merge queues.
23
43
  - Published sourcemaps embed their sources (`inlineSources`), so stepping into the framework works for consumers. Declaration maps are no longer emitted, because they can only resolve against a `src` directory that is not shipped. Each package is roughly 12% smaller as a result.
package/README.md CHANGED
@@ -1,14 +1,14 @@
1
1
  # @realitycollective/service-framework-three
2
2
 
3
- **three.js render-loop bridge** for the Reality Collective TypeScript Service Framework - connects `renderer.setAnimationLoop()` to the framework's `renderTick` scheduler channel.
3
+ **three.js bindings** for the Reality Collective TypeScript Service Framework - a render-loop bridge that connects `renderer.setAnimationLoop()` to the framework's `renderTick` scheduler channel, and a WebXR runtime adapter that gives a three.js or desktop app the same `RuntimeAdapter` surface the IWSDK binding has.
4
4
 
5
5
  ```sh
6
6
  npm install @realitycollective/service-framework @realitycollective/service-framework-three
7
7
  ```
8
8
 
9
- Services written against `BaseService<TConfig>` run unchanged on three.js, Babylon.js or IWSDK - only the bridge differs.
9
+ Services written against `BaseService<TConfig>` run unchanged on three.js, Babylon.js or IWSDK - only the bridge differs. `three` is not a dependency of this package: every host type is structural, so the bindings build and unit-test with no renderer, no WebXR and no headset.
10
10
 
11
- ## Usage
11
+ ## Render loop
12
12
 
13
13
  ```ts
14
14
  import { ManualScheduler, ServiceManager, createServiceProfile } from "@realitycollective/service-framework";
@@ -29,6 +29,101 @@ bridge.start();
29
29
 
30
30
  `host` is anything with `setAnimationLoop` (the `AnimationLoopHostLike` interface), so a `WebGLRenderer`, `WebGPURenderer` or a test double all work.
31
31
 
32
+ ## WebXR runtime adapter
33
+
34
+ `RuntimeAdapter` is the seam a service depends on to reach its host: per-frame updates, XR capability flags, and session lifecycle. `WebXRRuntimeAdapter` implements it over the entry points a browser already provides - `navigator.xr` to negotiate a session, `renderer.xr` for the session the renderer presents, and `setAnimationLoop` for frames. It orchestrates those; it renders nothing, plays nothing and owns no scene state.
35
+
36
+ ```ts
37
+ import { ManualScheduler, ServiceManager } from "@realitycollective/service-framework";
38
+ import { WebXRRuntimeAdapter } from "@realitycollective/service-framework-three";
39
+ import { WebGLRenderer } from "three";
40
+
41
+ const manager = new ServiceManager({ scheduler: new ManualScheduler() });
42
+ const renderer = new WebGLRenderer({ canvas });
43
+ renderer.xr.enabled = true;
44
+
45
+ const adapter = new WebXRRuntimeAdapter({
46
+ xr: renderer.xr,
47
+ host: renderer,
48
+ scheduler: manager.scheduler,
49
+ sessionInit: () => ({ optionalFeatures: ["hand-tracking"] })
50
+ });
51
+
52
+ adapter.start();
53
+
54
+ document.querySelector("#enter-vr")?.addEventListener("click", async () => {
55
+ const result = await adapter.session.request("immersive-vr");
56
+
57
+ if (!result.ok) {
58
+ // "unsupported" | "denied" | "timeout" | "error" - a normal runtime
59
+ // outcome, so it comes back as a result rather than a thrown error.
60
+ showEnterVrError(result.reason);
61
+ }
62
+ });
63
+ ```
64
+
65
+ Pass the adapter to your services (through their configuration, or a token you register it under) and they read `adapter.onFrame`, `adapter.getCapabilities` and `adapter.session` without knowing three.js exists.
66
+
67
+ ### Owning the loop
68
+
69
+ Given a `host`, the adapter owns the animation loop: `start()` binds it, `stop()` releases it, and each callback becomes one `FrameInfo` - `timestamp` in milliseconds, `delta` in seconds. Given a `scheduler` as well, the same callback also emits `renderTick` with `source: "three"` and `deltaTime` in milliseconds, exactly as `ThreeRenderLoopBridge` does, so an app needs one loop owner rather than two. Use the adapter or the bridge, not both.
70
+
71
+ three.js routes `setAnimationLoop` through the session's own `requestAnimationFrame` while presenting, so one call covers both the 2D page and the headset.
72
+
73
+ Omit `host` to keep the loop yourself and call `adapter.emitFrame(timestamp, delta)` per frame.
74
+
75
+ ### Sessions
76
+
77
+ `adapter.session` is the optional session facet from `RuntimeAdapter`. `request` never rejects - a headset that is absent, refused or broken is a normal runtime condition, so every outcome is a result:
78
+
79
+ | Outcome | When |
80
+ | --- | --- |
81
+ | `{ ok: true }` | the session started, or one was already active |
82
+ | `unsupported` | there is no `navigator.xr`, or `isSessionSupported(mode)` resolved false |
83
+ | `denied` | the request failed with `NotAllowedError` or `SecurityError` - the user, the prompt or the permissions policy refused |
84
+ | `timeout` | nothing arrived within `timeoutMs` (default 10000) |
85
+ | `error` | anything else, with the original error attached |
86
+
87
+ `getState()` walks `"none"` -> `"requesting"` -> `"active"` -> `"ending"` -> `"none"`. `end()` calls `session.end()` and resolves once the session is gone. `onVisibilityChange` maps the session's `visibilitychange` onto `"visible"`, `"visible-blurred"` and `"hidden"`, and reports `"non-immersive"` whenever there is no session. A value the adapter does not recognise is reported as `"hidden"`, because treating an unknown state as visible would keep game logic running when it should not.
88
+
89
+ `sessionInit` supplies the `XRSessionInit` per mode - required and optional features - and is called once per request. The default sends none.
90
+
91
+ One request can add features of its own through `SessionRequestOptions`, which matters when an app swaps mode mid-session and the host's defaults were chosen for the mode it is leaving:
92
+
93
+ ```typescript
94
+ await adapter.session.request("immersive-ar", {
95
+ requiredFeatures: ["hit-test"],
96
+ optionalFeatures: ["plane-detection"],
97
+ });
98
+ ```
99
+
100
+ They are merged over what `sessionInit` returned rather than replacing it: host entries come first, the request's are appended, and a feature named twice appears once. A request that names none passes the hook's result through untouched. The merge is the core's `mergeSessionInit`, shared with the Babylon binding so the two cannot drift.
101
+
102
+ ### Capabilities
103
+
104
+ The adapter derives `immersive`, `handTracking`, `planeDetection`, `passthrough` and `environmentBlendMode` from the live session with the core's shared `deriveCapabilities`, which is the same derivation the IWSDK adapter uses. It re-derives when the renderer raises `sessionstart` or `sessionend` and when the session raises `inputsourceschange`, and notifies subscribers only when a flag actually changes. `refreshCapabilities()` re-reads the renderer on demand, for a host that changes what it presents without raising anything.
105
+
106
+ `setCapabilities({ ... })` is a manual override layer on top: an override wins for as long as it is set, survives every later derivation, and is dropped by `clearCapabilityOverrides()` or `dispose()`.
107
+
108
+ ### The same adapter on the desktop
109
+
110
+ A desktop build with no headset needs no special case. `request` returns `{ ok: false, reason: "unsupported" }` where the browser has no `navigator.xr` or does not support the mode, capabilities stay at the all-false defaults, and frames keep arriving from the same loop. Services gate on `getCapabilities()` and run either way.
111
+
112
+ ### API surface
113
+
114
+ | Symbol | Kind | Purpose |
115
+ | --- | --- | --- |
116
+ | `ThreeRenderLoopBridge` | class | `renderer.setAnimationLoop()` to `renderTick`; `start`, `stop`, `dispose`. |
117
+ | `ThreeRenderLoopBridgeOptions` | interface | `{ scheduler, host }`. |
118
+ | `AnimationLoopHostLike` | interface | Anything with `setAnimationLoop`. |
119
+ | `FIRST_FRAME_DELTA_MS` | const | 16 - the delta reported for the first frame. |
120
+ | `WebXRRuntimeAdapter` | class | `RuntimeAdapter` over WebXR; `start`, `stop`, `emitFrame`, `getSession`, `refreshCapabilities`, `setCapabilities`, `clearCapabilityOverrides`, `session`, `dispose`. |
121
+ | `WebXRRuntimeAdapterOptions` | interface | `{ xr, xrSystem?, host?, scheduler?, sessionInit? }`. |
122
+ | `WebXRManagerLike` | interface | The slice of `renderer.xr` the adapter drives. |
123
+ | `WebXRSystemLike` | interface | The slice of `navigator.xr` it negotiates through. |
124
+ | `WebXRSessionLike` | interface | The slice of `XRSession` it reads. |
125
+ | `WebXRManagerEventType` / `WebXRSessionEventType` / `WebXREventListener` | types | The host events it subscribes to. |
126
+
32
127
  ## Live examples
33
128
 
34
129
  - Weather client walkthrough: **[service-framework-weather.pages.dev](https://service-framework-weather.pages.dev)**
package/dist/index.d.ts CHANGED
@@ -1,18 +1,9 @@
1
- import type { IScheduler } from "@realitycollective/service-framework";
2
- export interface AnimationLoopHostLike {
3
- setAnimationLoop(callback: ((timestamp: number) => void) | null): void;
4
- }
5
- export interface ThreeRenderLoopBridgeOptions {
6
- readonly scheduler: IScheduler;
7
- readonly host: AnimationLoopHostLike;
8
- }
9
- export declare class ThreeRenderLoopBridge {
10
- private readonly options;
11
- private animationLoopBound;
12
- private frame;
13
- private lastTimestamp;
14
- constructor(options: ThreeRenderLoopBridgeOptions);
15
- start(): void;
16
- stop(): void;
17
- dispose(): void;
18
- }
1
+ /**
2
+ * Public entry point of the three.js binding: the render-loop bridge, and the
3
+ * WebXR runtime adapter that gives a three.js or desktop app the same
4
+ * `RuntimeAdapter` surface the IWSDK binding has.
5
+ */
6
+ export { FIRST_FRAME_DELTA_MS, ThreeRenderLoopBridge } from "./three-render-loop-bridge.js";
7
+ export type { AnimationLoopHostLike, ThreeRenderLoopBridgeOptions } from "./three-render-loop-bridge.js";
8
+ export { WebXRRuntimeAdapter } from "./webxr-runtime-adapter.js";
9
+ export type { WebXREventListener, WebXRManagerEventType, WebXRManagerLike, WebXRRuntimeAdapterOptions, WebXRSessionEventType, WebXRSessionLike, WebXRSystemLike } from "./webxr-runtime-adapter.js";
package/dist/index.js CHANGED
@@ -1,36 +1,8 @@
1
- export class ThreeRenderLoopBridge {
2
- options;
3
- animationLoopBound = false;
4
- frame = 0;
5
- lastTimestamp = 0;
6
- constructor(options) {
7
- this.options = options;
8
- }
9
- start() {
10
- if (this.animationLoopBound) {
11
- return;
12
- }
13
- this.animationLoopBound = true;
14
- this.options.host.setAnimationLoop((timestamp) => {
15
- const context = {
16
- timestamp,
17
- deltaTime: this.lastTimestamp === 0 ? 16 : timestamp - this.lastTimestamp,
18
- frame: ++this.frame,
19
- source: "three"
20
- };
21
- this.lastTimestamp = timestamp;
22
- this.options.scheduler.emit("renderTick", context);
23
- });
24
- }
25
- stop() {
26
- if (!this.animationLoopBound) {
27
- return;
28
- }
29
- this.animationLoopBound = false;
30
- this.options.host.setAnimationLoop(null);
31
- }
32
- dispose() {
33
- this.stop();
34
- }
35
- }
1
+ /**
2
+ * Public entry point of the three.js binding: the render-loop bridge, and the
3
+ * WebXR runtime adapter that gives a three.js or desktop app the same
4
+ * `RuntimeAdapter` surface the IWSDK binding has.
5
+ */
6
+ export { FIRST_FRAME_DELTA_MS, ThreeRenderLoopBridge } from "./three-render-loop-bridge.js";
7
+ export { WebXRRuntimeAdapter } from "./webxr-runtime-adapter.js";
36
8
  //# sourceMappingURL=index.js.map
package/dist/index.js.map CHANGED
@@ -1 +1 @@
1
- {"version":3,"file":"index.js","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AAYA,MAAM,OAAO,qBAAqB;IAMb;IALX,kBAAkB,GAAG,KAAK,CAAC;IAC3B,KAAK,GAAG,CAAC,CAAC;IACV,aAAa,GAAG,CAAC,CAAC;IAE1B,YACmB,OAAqC;QAArC,YAAO,GAAP,OAAO,CAA8B;IACrD,CAAC;IAEG,KAAK;QACV,IAAI,IAAI,CAAC,kBAAkB,EAAE,CAAC;YAC5B,OAAO;QACT,CAAC;QAED,IAAI,CAAC,kBAAkB,GAAG,IAAI,CAAC;QAE/B,IAAI,CAAC,OAAO,CAAC,IAAI,CAAC,gBAAgB,CAAC,CAAC,SAAS,EAAE,EAAE;YAC/C,MAAM,OAAO,GAAqB;gBAChC,SAAS;gBACT,SAAS,EAAE,IAAI,CAAC,aAAa,KAAK,CAAC,CAAC,CAAC,CAAC,EAAE,CAAC,CAAC,CAAC,SAAS,GAAG,IAAI,CAAC,aAAa;gBACzE,KAAK,EAAE,EAAE,IAAI,CAAC,KAAK;gBACnB,MAAM,EAAE,OAAO;aAChB,CAAC;YAEF,IAAI,CAAC,aAAa,GAAG,SAAS,CAAC;YAC/B,IAAI,CAAC,OAAO,CAAC,SAAS,CAAC,IAAI,CAAC,YAAY,EAAE,OAAO,CAAC,CAAC;QACrD,CAAC,CAAC,CAAC;IACL,CAAC;IAEM,IAAI;QACT,IAAI,CAAC,IAAI,CAAC,kBAAkB,EAAE,CAAC;YAC7B,OAAO;QACT,CAAC;QAED,IAAI,CAAC,kBAAkB,GAAG,KAAK,CAAC;QAChC,IAAI,CAAC,OAAO,CAAC,IAAI,CAAC,gBAAgB,CAAC,IAAI,CAAC,CAAC;IAC3C,CAAC;IAEM,OAAO;QACZ,IAAI,CAAC,IAAI,EAAE,CAAC;IACd,CAAC;CACF","sourcesContent":["/* v8 ignore file */\nimport type { IScheduler, LifecycleContext } from \"@realitycollective/service-framework\";\n\nexport interface AnimationLoopHostLike {\n setAnimationLoop(callback: ((timestamp: number) => void) | null): void;\n}\n\nexport interface ThreeRenderLoopBridgeOptions {\n readonly scheduler: IScheduler;\n readonly host: AnimationLoopHostLike;\n}\n\nexport class ThreeRenderLoopBridge {\n private animationLoopBound = false;\n private frame = 0;\n private lastTimestamp = 0;\n\n public constructor(\n private readonly options: ThreeRenderLoopBridgeOptions\n ) {}\n\n public start(): void {\n if (this.animationLoopBound) {\n return;\n }\n\n this.animationLoopBound = true;\n\n this.options.host.setAnimationLoop((timestamp) => {\n const context: LifecycleContext = {\n timestamp,\n deltaTime: this.lastTimestamp === 0 ? 16 : timestamp - this.lastTimestamp,\n frame: ++this.frame,\n source: \"three\"\n };\n\n this.lastTimestamp = timestamp;\n this.options.scheduler.emit(\"renderTick\", context);\n });\n }\n\n public stop(): void {\n if (!this.animationLoopBound) {\n return;\n }\n\n this.animationLoopBound = false;\n this.options.host.setAnimationLoop(null);\n }\n\n public dispose(): void {\n this.stop();\n }\n}\n"]}
1
+ {"version":3,"file":"index.js","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AAAA;;;;GAIG;AACH,OAAO,EAAE,oBAAoB,EAAE,qBAAqB,EAAE,MAAM,+BAA+B,CAAC;AAM5F,OAAO,EAAE,mBAAmB,EAAE,MAAM,4BAA4B,CAAC","sourcesContent":["/**\n * Public entry point of the three.js binding: the render-loop bridge, and the\n * WebXR runtime adapter that gives a three.js or desktop app the same\n * `RuntimeAdapter` surface the IWSDK binding has.\n */\nexport { FIRST_FRAME_DELTA_MS, ThreeRenderLoopBridge } from \"./three-render-loop-bridge.js\";\nexport type {\n AnimationLoopHostLike,\n ThreeRenderLoopBridgeOptions\n} from \"./three-render-loop-bridge.js\";\n\nexport { WebXRRuntimeAdapter } from \"./webxr-runtime-adapter.js\";\nexport type {\n WebXREventListener,\n WebXRManagerEventType,\n WebXRManagerLike,\n WebXRRuntimeAdapterOptions,\n WebXRSessionEventType,\n WebXRSessionLike,\n WebXRSystemLike\n} from \"./webxr-runtime-adapter.js\";\n"]}
@@ -0,0 +1,28 @@
1
+ /**
2
+ * three.js render-loop bridge: connects `renderer.setAnimationLoop()` to the
3
+ * framework's `renderTick` scheduler channel.
4
+ *
5
+ * The host is structural - anything with `setAnimationLoop` - so this package
6
+ * never imports `three` and builds with no renderer, no WebGL and no DOM.
7
+ */
8
+ import type { IScheduler } from "@realitycollective/service-framework";
9
+ /** The slice of a three.js renderer the bridge and the adapter drive. */
10
+ export interface AnimationLoopHostLike {
11
+ setAnimationLoop(callback: ((timestamp: number) => void) | null): void;
12
+ }
13
+ export interface ThreeRenderLoopBridgeOptions {
14
+ readonly scheduler: IScheduler;
15
+ readonly host: AnimationLoopHostLike;
16
+ }
17
+ /** Delta reported for the first frame, where there is no previous timestamp. */
18
+ export declare const FIRST_FRAME_DELTA_MS = 16;
19
+ export declare class ThreeRenderLoopBridge {
20
+ private readonly options;
21
+ private animationLoopBound;
22
+ private frame;
23
+ private lastTimestamp;
24
+ constructor(options: ThreeRenderLoopBridgeOptions);
25
+ start(): void;
26
+ stop(): void;
27
+ dispose(): void;
28
+ }
@@ -0,0 +1,38 @@
1
+ /** Delta reported for the first frame, where there is no previous timestamp. */
2
+ export const FIRST_FRAME_DELTA_MS = 16;
3
+ export class ThreeRenderLoopBridge {
4
+ options;
5
+ animationLoopBound = false;
6
+ frame = 0;
7
+ lastTimestamp = 0;
8
+ constructor(options) {
9
+ this.options = options;
10
+ }
11
+ start() {
12
+ if (this.animationLoopBound) {
13
+ return;
14
+ }
15
+ this.animationLoopBound = true;
16
+ this.options.host.setAnimationLoop((timestamp) => {
17
+ const context = {
18
+ timestamp,
19
+ deltaTime: this.lastTimestamp === 0 ? FIRST_FRAME_DELTA_MS : timestamp - this.lastTimestamp,
20
+ frame: ++this.frame,
21
+ source: "three"
22
+ };
23
+ this.lastTimestamp = timestamp;
24
+ this.options.scheduler.emit("renderTick", context);
25
+ });
26
+ }
27
+ stop() {
28
+ if (!this.animationLoopBound) {
29
+ return;
30
+ }
31
+ this.animationLoopBound = false;
32
+ this.options.host.setAnimationLoop(null);
33
+ }
34
+ dispose() {
35
+ this.stop();
36
+ }
37
+ }
38
+ //# sourceMappingURL=three-render-loop-bridge.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"three-render-loop-bridge.js","sourceRoot":"","sources":["../src/three-render-loop-bridge.ts"],"names":[],"mappings":"AAmBA,gFAAgF;AAChF,MAAM,CAAC,MAAM,oBAAoB,GAAG,EAAE,CAAC;AAEvC,MAAM,OAAO,qBAAqB;IAMb;IALX,kBAAkB,GAAG,KAAK,CAAC;IAC3B,KAAK,GAAG,CAAC,CAAC;IACV,aAAa,GAAG,CAAC,CAAC;IAE1B,YACmB,OAAqC;QAArC,YAAO,GAAP,OAAO,CAA8B;IACrD,CAAC;IAEG,KAAK;QACV,IAAI,IAAI,CAAC,kBAAkB,EAAE,CAAC;YAC5B,OAAO;QACT,CAAC;QAED,IAAI,CAAC,kBAAkB,GAAG,IAAI,CAAC;QAE/B,IAAI,CAAC,OAAO,CAAC,IAAI,CAAC,gBAAgB,CAAC,CAAC,SAAS,EAAE,EAAE;YAC/C,MAAM,OAAO,GAAqB;gBAChC,SAAS;gBACT,SAAS,EAAE,IAAI,CAAC,aAAa,KAAK,CAAC,CAAC,CAAC,CAAC,oBAAoB,CAAC,CAAC,CAAC,SAAS,GAAG,IAAI,CAAC,aAAa;gBAC3F,KAAK,EAAE,EAAE,IAAI,CAAC,KAAK;gBACnB,MAAM,EAAE,OAAO;aAChB,CAAC;YAEF,IAAI,CAAC,aAAa,GAAG,SAAS,CAAC;YAC/B,IAAI,CAAC,OAAO,CAAC,SAAS,CAAC,IAAI,CAAC,YAAY,EAAE,OAAO,CAAC,CAAC;QACrD,CAAC,CAAC,CAAC;IACL,CAAC;IAEM,IAAI;QACT,IAAI,CAAC,IAAI,CAAC,kBAAkB,EAAE,CAAC;YAC7B,OAAO;QACT,CAAC;QAED,IAAI,CAAC,kBAAkB,GAAG,KAAK,CAAC;QAChC,IAAI,CAAC,OAAO,CAAC,IAAI,CAAC,gBAAgB,CAAC,IAAI,CAAC,CAAC;IAC3C,CAAC;IAEM,OAAO;QACZ,IAAI,CAAC,IAAI,EAAE,CAAC;IACd,CAAC;CACF","sourcesContent":["/**\n * three.js render-loop bridge: connects `renderer.setAnimationLoop()` to the\n * framework's `renderTick` scheduler channel.\n *\n * The host is structural - anything with `setAnimationLoop` - so this package\n * never imports `three` and builds with no renderer, no WebGL and no DOM.\n */\nimport type { IScheduler, LifecycleContext } from \"@realitycollective/service-framework\";\n\n/** The slice of a three.js renderer the bridge and the adapter drive. */\nexport interface AnimationLoopHostLike {\n setAnimationLoop(callback: ((timestamp: number) => void) | null): void;\n}\n\nexport interface ThreeRenderLoopBridgeOptions {\n readonly scheduler: IScheduler;\n readonly host: AnimationLoopHostLike;\n}\n\n/** Delta reported for the first frame, where there is no previous timestamp. */\nexport const FIRST_FRAME_DELTA_MS = 16;\n\nexport class ThreeRenderLoopBridge {\n private animationLoopBound = false;\n private frame = 0;\n private lastTimestamp = 0;\n\n public constructor(\n private readonly options: ThreeRenderLoopBridgeOptions\n ) {}\n\n public start(): void {\n if (this.animationLoopBound) {\n return;\n }\n\n this.animationLoopBound = true;\n\n this.options.host.setAnimationLoop((timestamp) => {\n const context: LifecycleContext = {\n timestamp,\n deltaTime: this.lastTimestamp === 0 ? FIRST_FRAME_DELTA_MS : timestamp - this.lastTimestamp,\n frame: ++this.frame,\n source: \"three\"\n };\n\n this.lastTimestamp = timestamp;\n this.options.scheduler.emit(\"renderTick\", context);\n });\n }\n\n public stop(): void {\n if (!this.animationLoopBound) {\n return;\n }\n\n this.animationLoopBound = false;\n this.options.host.setAnimationLoop(null);\n }\n\n public dispose(): void {\n this.stop();\n }\n}\n"]}
@@ -0,0 +1,180 @@
1
+ /**
2
+ * WebXR implementation of {@link RuntimeAdapter}, for a three.js app or any
3
+ * other page that owns its own renderer.
4
+ *
5
+ * The IWSDK binding gets its adapter from IWSDK. Everything else - a plain
6
+ * three.js app, a desktop build with no headset - had nothing, so a service
7
+ * written against `RuntimeAdapter` could not be hosted there. This adapter
8
+ * closes that gap: it orchestrates the entry points the platform already
9
+ * provides (`navigator.xr` for session negotiation, `renderer.xr` for the
10
+ * session the renderer presents, `setAnimationLoop` for frames) and publishes
11
+ * them through the same seam. It renders nothing, plays nothing and owns no
12
+ * scene state.
13
+ *
14
+ * Every host type here is structural, so this package still imports neither
15
+ * `three` nor any WebXR type, and the adapter unit-tests headless.
16
+ */
17
+ import { type AdapterCapabilities, type CapabilitiesListener, type CapabilitySessionLike, type FrameListener, type IScheduler, type RuntimeAdapter, type SessionFacet, type SessionMode, type Unsubscribe } from "@realitycollective/service-framework";
18
+ import { type AnimationLoopHostLike } from "./three-render-loop-bridge.js";
19
+ /** The events an `XRSession` raises that this adapter listens for. */
20
+ export type WebXRSessionEventType = "end" | "visibilitychange" | "inputsourceschange";
21
+ /** The events three.js's `WebXRManager` raises that this adapter listens for. */
22
+ export type WebXRManagerEventType = "sessionstart" | "sessionend";
23
+ /** Host event callback. The adapter reads the host, not the event object. */
24
+ export type WebXREventListener = (event?: unknown) => void;
25
+ /**
26
+ * The slice of an `XRSession` the adapter reads. It extends the core's
27
+ * {@link CapabilitySessionLike}, so a live session goes straight to
28
+ * `deriveCapabilities` with no mapping.
29
+ */
30
+ export interface WebXRSessionLike extends CapabilitySessionLike {
31
+ /** Ends the session. The `end` event is what the adapter acts on. */
32
+ end(): Promise<void>;
33
+ /** `"visible"`, `"visible-blurred"` or `"hidden"` on a live session. */
34
+ readonly visibilityState?: string;
35
+ addEventListener(type: WebXRSessionEventType, listener: WebXREventListener): void;
36
+ removeEventListener(type: WebXRSessionEventType, listener: WebXREventListener): void;
37
+ }
38
+ /** The slice of `navigator.xr` the session facet negotiates through. */
39
+ export interface WebXRSystemLike {
40
+ isSessionSupported(mode: string): Promise<boolean>;
41
+ requestSession(mode: string, init?: unknown): Promise<WebXRSessionLike>;
42
+ }
43
+ /** The slice of a three.js `renderer.xr` (`WebXRManager`) the adapter drives. */
44
+ export interface WebXRManagerLike {
45
+ /** Hands the renderer the session it should present. */
46
+ setSession(session: WebXRSessionLike): Promise<void> | void;
47
+ /** The session the renderer is presenting, or null in 2D. */
48
+ getSession(): WebXRSessionLike | null;
49
+ addEventListener(type: WebXRManagerEventType, listener: WebXREventListener): void;
50
+ removeEventListener(type: WebXRManagerEventType, listener: WebXREventListener): void;
51
+ }
52
+ export interface WebXRRuntimeAdapterOptions {
53
+ /** `renderer.xr` - the renderer's WebXR manager. */
54
+ readonly xr: WebXRManagerLike;
55
+ /**
56
+ * `navigator.xr`. Defaults to the global when omitted, and to `null` where
57
+ * there is no global, which is what a Node test sees. A null system reports
58
+ * every session request as `"unsupported"`.
59
+ */
60
+ readonly xrSystem?: WebXRSystemLike | null;
61
+ /**
62
+ * The renderer, or anything else with `setAnimationLoop`. Given one, the
63
+ * adapter owns the loop: {@link WebXRRuntimeAdapter.start} binds it and each
64
+ * callback becomes a frame. Omit it to drive frames yourself with
65
+ * {@link WebXRRuntimeAdapter.emitFrame}.
66
+ */
67
+ readonly host?: AnimationLoopHostLike;
68
+ /**
69
+ * Given a scheduler, each owned frame also emits the `renderTick` channel
70
+ * with `source: "three"`, exactly as `ThreeRenderLoopBridge` does, so an app
71
+ * needs one loop owner rather than two.
72
+ */
73
+ readonly scheduler?: IScheduler;
74
+ /**
75
+ * Supplies the `XRSessionInit` for a mode - required and optional features.
76
+ * Called once per request; the default sends no init at all.
77
+ */
78
+ readonly sessionInit?: (mode: SessionMode) => unknown;
79
+ }
80
+ export declare class WebXRRuntimeAdapter implements RuntimeAdapter {
81
+ private readonly frameListeners;
82
+ private readonly capabilitiesListeners;
83
+ private readonly stateListeners;
84
+ private readonly visibilityListeners;
85
+ private readonly endWaiters;
86
+ private readonly xr;
87
+ private readonly xrSystem;
88
+ private readonly host;
89
+ private readonly scheduler;
90
+ private readonly sessionInit;
91
+ private derived;
92
+ private overrides;
93
+ private capabilities;
94
+ private sessionState;
95
+ private boundSession;
96
+ private sessionListeners;
97
+ private animationLoopBound;
98
+ private frame;
99
+ private lastTimestamp;
100
+ private readonly onManagerSessionStart;
101
+ private readonly onManagerSessionEnd;
102
+ /** Session lifecycle over `navigator.xr` and the renderer's XR manager. */
103
+ readonly session: SessionFacet;
104
+ constructor(options: WebXRRuntimeAdapterOptions);
105
+ onFrame(listener: FrameListener): Unsubscribe;
106
+ getCapabilities(): AdapterCapabilities;
107
+ onCapabilitiesChange(listener: CapabilitiesListener): Unsubscribe;
108
+ /** The session the renderer is presenting, or null in 2D. */
109
+ getSession(): WebXRSessionLike | null;
110
+ /**
111
+ * Bind the animation loop, if this adapter was given a host. Each callback
112
+ * becomes one {@link FrameInfo} and, where a scheduler was supplied, one
113
+ * `renderTick`. With no host this does nothing: the app owns the loop and
114
+ * calls {@link WebXRRuntimeAdapter.emitFrame} itself.
115
+ *
116
+ * three.js routes `setAnimationLoop` through the session's own
117
+ * `requestAnimationFrame` while presenting, so one call covers both the 2D
118
+ * page and the headset.
119
+ */
120
+ start(): void;
121
+ /** Release the animation loop. Safe to call when it was never bound. */
122
+ stop(): void;
123
+ /** Push one frame to every subscriber. Call this when you own the loop. */
124
+ emitFrame(timestamp: number, delta: number): void;
125
+ /**
126
+ * Re-read the renderer's session and publish any capability change. The
127
+ * adapter does this itself on every session and input-source event; call it
128
+ * directly after the host enables a feature mid-session, or after a host that
129
+ * raises no events changes what it presents.
130
+ */
131
+ refreshCapabilities(): void;
132
+ /**
133
+ * Force capability flags regardless of what the session reports. Overrides
134
+ * are a layer on top of the derived values: they win for as long as they are
135
+ * set, survive every later derivation, and are dropped only by
136
+ * {@link WebXRRuntimeAdapter.clearCapabilityOverrides} or
137
+ * {@link WebXRRuntimeAdapter.dispose}.
138
+ */
139
+ setCapabilities(capabilities: Partial<AdapterCapabilities>): void;
140
+ /** Drop every manual override and fall back to the derived capabilities. */
141
+ clearCapabilityOverrides(): void;
142
+ /**
143
+ * Release everything the adapter holds: the animation loop, both sets of host
144
+ * event listeners, any in-flight `end()`, the manual overrides and every
145
+ * listener. It does not end the session - the app owns that decision.
146
+ */
147
+ dispose(): void;
148
+ private handleAnimationFrame;
149
+ /**
150
+ * Subscribe to one session's own events. The listeners close over the session
151
+ * they belong to, so nothing here has to re-check which session is live.
152
+ */
153
+ private attachSession;
154
+ private detachSession;
155
+ private handleSessionStart;
156
+ /**
157
+ * One session ending raises both the session's `end` event and the manager's
158
+ * `sessionend`, so this runs twice per session and is written to be
159
+ * idempotent.
160
+ */
161
+ private handleSessionEnd;
162
+ private updateDerived;
163
+ private publishCapabilities;
164
+ private requestSession;
165
+ /**
166
+ * Negotiate the session and hand it to the renderer. Nothing here throws at
167
+ * the caller: a headset that is absent, refused or broken is a normal runtime
168
+ * condition, so every outcome comes back as a {@link SessionResult}.
169
+ *
170
+ * The request's own features are merged over the `sessionInit` hook's result
171
+ * by the core's `mergeSessionInit`, so they add to the app's defaults rather
172
+ * than replacing them. A request that names none passes the hook's result
173
+ * through untouched.
174
+ */
175
+ private openSession;
176
+ private endSession;
177
+ private settleEndWaiters;
178
+ private notifyVisibility;
179
+ private setSessionState;
180
+ }
@@ -0,0 +1,393 @@
1
+ /**
2
+ * WebXR implementation of {@link RuntimeAdapter}, for a three.js app or any
3
+ * other page that owns its own renderer.
4
+ *
5
+ * The IWSDK binding gets its adapter from IWSDK. Everything else - a plain
6
+ * three.js app, a desktop build with no headset - had nothing, so a service
7
+ * written against `RuntimeAdapter` could not be hosted there. This adapter
8
+ * closes that gap: it orchestrates the entry points the platform already
9
+ * provides (`navigator.xr` for session negotiation, `renderer.xr` for the
10
+ * session the renderer presents, `setAnimationLoop` for frames) and publishes
11
+ * them through the same seam. It renders nothing, plays nothing and owns no
12
+ * scene state.
13
+ *
14
+ * Every host type here is structural, so this package still imports neither
15
+ * `three` nor any WebXR type, and the adapter unit-tests headless.
16
+ */
17
+ import { DEFAULT_CAPABILITIES, DEFAULT_SESSION_TIMEOUT_MS, deriveCapabilities, mergeSessionInit, } from "@realitycollective/service-framework";
18
+ import { FIRST_FRAME_DELTA_MS } from "./three-render-loop-bridge.js";
19
+ const SESSION_VISIBILITY_VALUES = new Set([
20
+ "visible",
21
+ "visible-blurred",
22
+ "hidden",
23
+ ]);
24
+ /**
25
+ * Map `XRSession.visibilityState` onto the facet's vocabulary. An unrecognised
26
+ * value is reported as `"hidden"`, because treating an unknown state as visible
27
+ * would keep game logic running when it should not.
28
+ */
29
+ function toSessionVisibility(value) {
30
+ if (typeof value === "string" && SESSION_VISIBILITY_VALUES.has(value)) {
31
+ return value;
32
+ }
33
+ return "hidden";
34
+ }
35
+ /**
36
+ * WebXR reports a blocked request through the error name: `NotAllowedError`
37
+ * when the user or the permission prompt refused, `SecurityError` when the
38
+ * permissions policy did. Both are a refusal rather than a fault.
39
+ */
40
+ function toFailureReason(error) {
41
+ const name = error?.name;
42
+ if (name === "NotAllowedError" || name === "SecurityError") {
43
+ return "denied";
44
+ }
45
+ return "error";
46
+ }
47
+ /** `navigator.xr`, read defensively: there is no navigator in a Node test. */
48
+ function defaultXRSystem() {
49
+ const globalNavigator = globalThis.navigator;
50
+ return globalNavigator?.xr ?? null;
51
+ }
52
+ export class WebXRRuntimeAdapter {
53
+ frameListeners = new Set();
54
+ capabilitiesListeners = new Set();
55
+ stateListeners = new Set();
56
+ visibilityListeners = new Set();
57
+ endWaiters = new Set();
58
+ xr;
59
+ xrSystem;
60
+ host;
61
+ scheduler;
62
+ sessionInit;
63
+ derived = DEFAULT_CAPABILITIES;
64
+ overrides = {};
65
+ capabilities = DEFAULT_CAPABILITIES;
66
+ sessionState = "none";
67
+ boundSession = null;
68
+ sessionListeners = [];
69
+ animationLoopBound = false;
70
+ frame = 0;
71
+ lastTimestamp = 0;
72
+ onManagerSessionStart = () => {
73
+ this.handleSessionStart();
74
+ };
75
+ onManagerSessionEnd = () => {
76
+ this.handleSessionEnd();
77
+ };
78
+ /** Session lifecycle over `navigator.xr` and the renderer's XR manager. */
79
+ session = {
80
+ getState: () => this.sessionState,
81
+ request: (mode, options) => this.requestSession(mode, options),
82
+ end: () => this.endSession(),
83
+ onStateChange: (listener) => {
84
+ this.stateListeners.add(listener);
85
+ return () => {
86
+ this.stateListeners.delete(listener);
87
+ };
88
+ },
89
+ onVisibilityChange: (listener) => {
90
+ this.visibilityListeners.add(listener);
91
+ return () => {
92
+ this.visibilityListeners.delete(listener);
93
+ };
94
+ },
95
+ };
96
+ constructor(options) {
97
+ this.xr = options.xr;
98
+ this.xrSystem = options.xrSystem === undefined ? defaultXRSystem() : options.xrSystem;
99
+ this.host = options.host;
100
+ this.scheduler = options.scheduler;
101
+ this.sessionInit = options.sessionInit;
102
+ this.xr.addEventListener("sessionstart", this.onManagerSessionStart);
103
+ this.xr.addEventListener("sessionend", this.onManagerSessionEnd);
104
+ const session = this.xr.getSession();
105
+ if (session) {
106
+ this.attachSession(session);
107
+ this.sessionState = "active";
108
+ }
109
+ this.derived = deriveCapabilities(session);
110
+ this.capabilities = this.derived;
111
+ }
112
+ onFrame(listener) {
113
+ this.frameListeners.add(listener);
114
+ return () => {
115
+ this.frameListeners.delete(listener);
116
+ };
117
+ }
118
+ getCapabilities() {
119
+ return this.capabilities;
120
+ }
121
+ onCapabilitiesChange(listener) {
122
+ this.capabilitiesListeners.add(listener);
123
+ return () => {
124
+ this.capabilitiesListeners.delete(listener);
125
+ };
126
+ }
127
+ /** The session the renderer is presenting, or null in 2D. */
128
+ getSession() {
129
+ return this.boundSession;
130
+ }
131
+ /**
132
+ * Bind the animation loop, if this adapter was given a host. Each callback
133
+ * becomes one {@link FrameInfo} and, where a scheduler was supplied, one
134
+ * `renderTick`. With no host this does nothing: the app owns the loop and
135
+ * calls {@link WebXRRuntimeAdapter.emitFrame} itself.
136
+ *
137
+ * three.js routes `setAnimationLoop` through the session's own
138
+ * `requestAnimationFrame` while presenting, so one call covers both the 2D
139
+ * page and the headset.
140
+ */
141
+ start() {
142
+ if (this.animationLoopBound) {
143
+ return;
144
+ }
145
+ this.animationLoopBound = true;
146
+ this.host?.setAnimationLoop((timestamp) => this.handleAnimationFrame(timestamp));
147
+ }
148
+ /** Release the animation loop. Safe to call when it was never bound. */
149
+ stop() {
150
+ if (!this.animationLoopBound) {
151
+ return;
152
+ }
153
+ this.animationLoopBound = false;
154
+ this.host?.setAnimationLoop(null);
155
+ }
156
+ /** Push one frame to every subscriber. Call this when you own the loop. */
157
+ emitFrame(timestamp, delta) {
158
+ const frame = { timestamp, delta };
159
+ this.frameListeners.forEach((listener) => listener(frame));
160
+ }
161
+ /**
162
+ * Re-read the renderer's session and publish any capability change. The
163
+ * adapter does this itself on every session and input-source event; call it
164
+ * directly after the host enables a feature mid-session, or after a host that
165
+ * raises no events changes what it presents.
166
+ */
167
+ refreshCapabilities() {
168
+ const session = this.xr.getSession();
169
+ if (session) {
170
+ this.attachSession(session);
171
+ this.updateDerived(session);
172
+ return;
173
+ }
174
+ if (this.boundSession) {
175
+ this.handleSessionEnd();
176
+ return;
177
+ }
178
+ this.updateDerived(null);
179
+ }
180
+ /**
181
+ * Force capability flags regardless of what the session reports. Overrides
182
+ * are a layer on top of the derived values: they win for as long as they are
183
+ * set, survive every later derivation, and are dropped only by
184
+ * {@link WebXRRuntimeAdapter.clearCapabilityOverrides} or
185
+ * {@link WebXRRuntimeAdapter.dispose}.
186
+ */
187
+ setCapabilities(capabilities) {
188
+ this.overrides = { ...this.overrides, ...capabilities };
189
+ this.publishCapabilities();
190
+ }
191
+ /** Drop every manual override and fall back to the derived capabilities. */
192
+ clearCapabilityOverrides() {
193
+ this.overrides = {};
194
+ this.publishCapabilities();
195
+ }
196
+ /**
197
+ * Release everything the adapter holds: the animation loop, both sets of host
198
+ * event listeners, any in-flight `end()`, the manual overrides and every
199
+ * listener. It does not end the session - the app owns that decision.
200
+ */
201
+ dispose() {
202
+ this.stop();
203
+ this.xr.removeEventListener("sessionstart", this.onManagerSessionStart);
204
+ this.xr.removeEventListener("sessionend", this.onManagerSessionEnd);
205
+ this.detachSession();
206
+ this.settleEndWaiters();
207
+ this.overrides = {};
208
+ this.frameListeners.clear();
209
+ this.capabilitiesListeners.clear();
210
+ this.stateListeners.clear();
211
+ this.visibilityListeners.clear();
212
+ }
213
+ handleAnimationFrame(timestamp) {
214
+ const deltaMs = this.lastTimestamp === 0 ? FIRST_FRAME_DELTA_MS : timestamp - this.lastTimestamp;
215
+ this.lastTimestamp = timestamp;
216
+ this.frame += 1;
217
+ // `FrameInfo.delta` is seconds; the scheduler's `LifecycleContext` is in
218
+ // milliseconds, which is the unit `ThreeRenderLoopBridge` already emits.
219
+ this.emitFrame(timestamp, deltaMs / 1000);
220
+ const context = {
221
+ timestamp,
222
+ deltaTime: deltaMs,
223
+ frame: this.frame,
224
+ source: "three",
225
+ };
226
+ this.scheduler?.emit("renderTick", context);
227
+ }
228
+ /**
229
+ * Subscribe to one session's own events. The listeners close over the session
230
+ * they belong to, so nothing here has to re-check which session is live.
231
+ */
232
+ attachSession(session) {
233
+ if (session === this.boundSession) {
234
+ return;
235
+ }
236
+ this.detachSession();
237
+ this.boundSession = session;
238
+ this.sessionListeners = [
239
+ { type: "end", listener: () => this.handleSessionEnd() },
240
+ {
241
+ type: "visibilitychange",
242
+ listener: () => this.notifyVisibility(toSessionVisibility(session.visibilityState)),
243
+ },
244
+ { type: "inputsourceschange", listener: () => this.updateDerived(session) },
245
+ ];
246
+ for (const entry of this.sessionListeners) {
247
+ session.addEventListener(entry.type, entry.listener);
248
+ }
249
+ }
250
+ detachSession() {
251
+ const session = this.boundSession;
252
+ if (!session) {
253
+ return;
254
+ }
255
+ for (const entry of this.sessionListeners) {
256
+ session.removeEventListener(entry.type, entry.listener);
257
+ }
258
+ this.sessionListeners = [];
259
+ this.boundSession = null;
260
+ }
261
+ handleSessionStart() {
262
+ const session = this.xr.getSession();
263
+ if (!session) {
264
+ return;
265
+ }
266
+ this.attachSession(session);
267
+ this.setSessionState("active");
268
+ this.updateDerived(session);
269
+ this.notifyVisibility(toSessionVisibility(session.visibilityState));
270
+ }
271
+ /**
272
+ * One session ending raises both the session's `end` event and the manager's
273
+ * `sessionend`, so this runs twice per session and is written to be
274
+ * idempotent.
275
+ */
276
+ handleSessionEnd() {
277
+ if (!this.boundSession && this.sessionState === "none") {
278
+ return;
279
+ }
280
+ this.detachSession();
281
+ this.setSessionState("none");
282
+ this.updateDerived(null);
283
+ this.notifyVisibility("non-immersive");
284
+ this.settleEndWaiters();
285
+ }
286
+ updateDerived(session) {
287
+ this.derived = deriveCapabilities(session);
288
+ this.publishCapabilities();
289
+ }
290
+ publishCapabilities() {
291
+ const next = { ...this.derived, ...this.overrides };
292
+ const current = this.capabilities;
293
+ if (next.immersive === current.immersive &&
294
+ next.handTracking === current.handTracking &&
295
+ next.planeDetection === current.planeDetection &&
296
+ next.passthrough === current.passthrough &&
297
+ next.environmentBlendMode === current.environmentBlendMode) {
298
+ return;
299
+ }
300
+ this.capabilities = next;
301
+ this.capabilitiesListeners.forEach((listener) => listener(next));
302
+ }
303
+ async requestSession(mode, options) {
304
+ if (this.sessionState === "active") {
305
+ return { ok: true };
306
+ }
307
+ const system = this.xrSystem;
308
+ if (!system) {
309
+ return { ok: false, reason: "unsupported" };
310
+ }
311
+ const timeoutMs = options?.timeoutMs ?? DEFAULT_SESSION_TIMEOUT_MS;
312
+ this.setSessionState("requesting");
313
+ let timer;
314
+ const timeout = new Promise((resolve) => {
315
+ timer = setTimeout(() => resolve({ ok: false, reason: "timeout" }), timeoutMs);
316
+ });
317
+ const result = await Promise.race([this.openSession(system, mode, options), timeout]);
318
+ clearTimeout(timer);
319
+ if (!result.ok) {
320
+ this.setSessionState("none");
321
+ return result;
322
+ }
323
+ this.setSessionState("active");
324
+ return result;
325
+ }
326
+ /**
327
+ * Negotiate the session and hand it to the renderer. Nothing here throws at
328
+ * the caller: a headset that is absent, refused or broken is a normal runtime
329
+ * condition, so every outcome comes back as a {@link SessionResult}.
330
+ *
331
+ * The request's own features are merged over the `sessionInit` hook's result
332
+ * by the core's `mergeSessionInit`, so they add to the app's defaults rather
333
+ * than replacing them. A request that names none passes the hook's result
334
+ * through untouched.
335
+ */
336
+ async openSession(system, mode, options) {
337
+ try {
338
+ const supported = await system.isSessionSupported(mode);
339
+ if (!supported) {
340
+ return { ok: false, reason: "unsupported" };
341
+ }
342
+ const init = mergeSessionInit(this.sessionInit?.(mode), options);
343
+ const session = await system.requestSession(mode, init);
344
+ await this.xr.setSession(session);
345
+ // A manager that raises `sessionstart` has already attached this session;
346
+ // one that does not is attached here, so both host styles behave alike.
347
+ this.attachSession(session);
348
+ this.updateDerived(session);
349
+ return { ok: true };
350
+ }
351
+ catch (error) {
352
+ return { ok: false, reason: toFailureReason(error), error };
353
+ }
354
+ }
355
+ async endSession() {
356
+ const session = this.boundSession;
357
+ if (!session) {
358
+ return;
359
+ }
360
+ this.setSessionState("ending");
361
+ const ended = new Promise((resolve) => {
362
+ this.endWaiters.add(resolve);
363
+ });
364
+ try {
365
+ await session.end();
366
+ }
367
+ catch {
368
+ // Ending is best effort. The `end` event, not this promise, is what the
369
+ // adapter acts on, and a host that refuses still has to be reported.
370
+ }
371
+ // The event has normally arrived by now and this is a no-op. A host that
372
+ // raises none would otherwise leave the caller waiting forever, so the
373
+ // settled `end()` call is the fallback signal.
374
+ this.handleSessionEnd();
375
+ await ended;
376
+ }
377
+ settleEndWaiters() {
378
+ const waiters = Array.from(this.endWaiters);
379
+ this.endWaiters.clear();
380
+ waiters.forEach((resolve) => resolve());
381
+ }
382
+ notifyVisibility(visibility) {
383
+ this.visibilityListeners.forEach((listener) => listener(visibility));
384
+ }
385
+ setSessionState(state) {
386
+ if (this.sessionState === state) {
387
+ return;
388
+ }
389
+ this.sessionState = state;
390
+ this.stateListeners.forEach((listener) => listener(state));
391
+ }
392
+ }
393
+ //# sourceMappingURL=webxr-runtime-adapter.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"webxr-runtime-adapter.js","sourceRoot":"","sources":["../src/webxr-runtime-adapter.ts"],"names":[],"mappings":"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","sourcesContent":["/**\n * WebXR implementation of {@link RuntimeAdapter}, for a three.js app or any\n * other page that owns its own renderer.\n *\n * The IWSDK binding gets its adapter from IWSDK. Everything else - a plain\n * three.js app, a desktop build with no headset - had nothing, so a service\n * written against `RuntimeAdapter` could not be hosted there. This adapter\n * closes that gap: it orchestrates the entry points the platform already\n * provides (`navigator.xr` for session negotiation, `renderer.xr` for the\n * session the renderer presents, `setAnimationLoop` for frames) and publishes\n * them through the same seam. It renders nothing, plays nothing and owns no\n * scene state.\n *\n * Every host type here is structural, so this package still imports neither\n * `three` nor any WebXR type, and the adapter unit-tests headless.\n */\nimport {\n DEFAULT_CAPABILITIES,\n DEFAULT_SESSION_TIMEOUT_MS,\n deriveCapabilities,\n mergeSessionInit,\n type AdapterCapabilities,\n type CapabilitiesListener,\n type CapabilitySessionLike,\n type FrameInfo,\n type FrameListener,\n type IScheduler,\n type LifecycleContext,\n type RuntimeAdapter,\n type SessionFacet,\n type SessionFailureReason,\n type SessionMode,\n type SessionRequestOptions,\n type SessionResult,\n type SessionState,\n type SessionVisibility,\n type Unsubscribe,\n} from \"@realitycollective/service-framework\";\nimport { FIRST_FRAME_DELTA_MS, type AnimationLoopHostLike } from \"./three-render-loop-bridge.js\";\n\n/** The events an `XRSession` raises that this adapter listens for. */\nexport type WebXRSessionEventType = \"end\" | \"visibilitychange\" | \"inputsourceschange\";\n\n/** The events three.js's `WebXRManager` raises that this adapter listens for. */\nexport type WebXRManagerEventType = \"sessionstart\" | \"sessionend\";\n\n/** Host event callback. The adapter reads the host, not the event object. */\nexport type WebXREventListener = (event?: unknown) => void;\n\n/**\n * The slice of an `XRSession` the adapter reads. It extends the core's\n * {@link CapabilitySessionLike}, so a live session goes straight to\n * `deriveCapabilities` with no mapping.\n */\nexport interface WebXRSessionLike extends CapabilitySessionLike {\n /** Ends the session. The `end` event is what the adapter acts on. */\n end(): Promise<void>;\n /** `\"visible\"`, `\"visible-blurred\"` or `\"hidden\"` on a live session. */\n readonly visibilityState?: string;\n addEventListener(type: WebXRSessionEventType, listener: WebXREventListener): void;\n removeEventListener(type: WebXRSessionEventType, listener: WebXREventListener): void;\n}\n\n/** The slice of `navigator.xr` the session facet negotiates through. */\nexport interface WebXRSystemLike {\n isSessionSupported(mode: string): Promise<boolean>;\n requestSession(mode: string, init?: unknown): Promise<WebXRSessionLike>;\n}\n\n/** The slice of a three.js `renderer.xr` (`WebXRManager`) the adapter drives. */\nexport interface WebXRManagerLike {\n /** Hands the renderer the session it should present. */\n setSession(session: WebXRSessionLike): Promise<void> | void;\n /** The session the renderer is presenting, or null in 2D. */\n getSession(): WebXRSessionLike | null;\n addEventListener(type: WebXRManagerEventType, listener: WebXREventListener): void;\n removeEventListener(type: WebXRManagerEventType, listener: WebXREventListener): void;\n}\n\nexport interface WebXRRuntimeAdapterOptions {\n /** `renderer.xr` - the renderer's WebXR manager. */\n readonly xr: WebXRManagerLike;\n /**\n * `navigator.xr`. Defaults to the global when omitted, and to `null` where\n * there is no global, which is what a Node test sees. A null system reports\n * every session request as `\"unsupported\"`.\n */\n readonly xrSystem?: WebXRSystemLike | null;\n /**\n * The renderer, or anything else with `setAnimationLoop`. Given one, the\n * adapter owns the loop: {@link WebXRRuntimeAdapter.start} binds it and each\n * callback becomes a frame. Omit it to drive frames yourself with\n * {@link WebXRRuntimeAdapter.emitFrame}.\n */\n readonly host?: AnimationLoopHostLike;\n /**\n * Given a scheduler, each owned frame also emits the `renderTick` channel\n * with `source: \"three\"`, exactly as `ThreeRenderLoopBridge` does, so an app\n * needs one loop owner rather than two.\n */\n readonly scheduler?: IScheduler;\n /**\n * Supplies the `XRSessionInit` for a mode - required and optional features.\n * Called once per request; the default sends no init at all.\n */\n readonly sessionInit?: (mode: SessionMode) => unknown;\n}\n\ntype SessionStateListener = (state: SessionState) => void;\ntype SessionVisibilityListener = (visibility: SessionVisibility) => void;\n\nconst SESSION_VISIBILITY_VALUES: ReadonlySet<string> = new Set([\n \"visible\",\n \"visible-blurred\",\n \"hidden\",\n]);\n\n/**\n * Map `XRSession.visibilityState` onto the facet's vocabulary. An unrecognised\n * value is reported as `\"hidden\"`, because treating an unknown state as visible\n * would keep game logic running when it should not.\n */\nfunction toSessionVisibility(value: unknown): SessionVisibility {\n if (typeof value === \"string\" && SESSION_VISIBILITY_VALUES.has(value)) {\n return value as SessionVisibility;\n }\n\n return \"hidden\";\n}\n\n/**\n * WebXR reports a blocked request through the error name: `NotAllowedError`\n * when the user or the permission prompt refused, `SecurityError` when the\n * permissions policy did. Both are a refusal rather than a fault.\n */\nfunction toFailureReason(error: unknown): SessionFailureReason {\n const name = (error as { readonly name?: unknown } | null | undefined)?.name;\n\n if (name === \"NotAllowedError\" || name === \"SecurityError\") {\n return \"denied\";\n }\n\n return \"error\";\n}\n\n/** `navigator.xr`, read defensively: there is no navigator in a Node test. */\nfunction defaultXRSystem(): WebXRSystemLike | null {\n const globalNavigator = (globalThis as { navigator?: { xr?: WebXRSystemLike } }).navigator;\n\n return globalNavigator?.xr ?? null;\n}\n\nexport class WebXRRuntimeAdapter implements RuntimeAdapter {\n private readonly frameListeners = new Set<FrameListener>();\n private readonly capabilitiesListeners = new Set<CapabilitiesListener>();\n private readonly stateListeners = new Set<SessionStateListener>();\n private readonly visibilityListeners = new Set<SessionVisibilityListener>();\n private readonly endWaiters = new Set<() => void>();\n\n private readonly xr: WebXRManagerLike;\n private readonly xrSystem: WebXRSystemLike | null;\n private readonly host: AnimationLoopHostLike | undefined;\n private readonly scheduler: IScheduler | undefined;\n private readonly sessionInit: ((mode: SessionMode) => unknown) | undefined;\n\n private derived: AdapterCapabilities = DEFAULT_CAPABILITIES;\n private overrides: Partial<AdapterCapabilities> = {};\n private capabilities: AdapterCapabilities = DEFAULT_CAPABILITIES;\n private sessionState: SessionState = \"none\";\n private boundSession: WebXRSessionLike | null = null;\n private sessionListeners: {\n readonly type: WebXRSessionEventType;\n readonly listener: WebXREventListener;\n }[] = [];\n private animationLoopBound = false;\n private frame = 0;\n private lastTimestamp = 0;\n\n private readonly onManagerSessionStart: WebXREventListener = () => {\n this.handleSessionStart();\n };\n\n private readonly onManagerSessionEnd: WebXREventListener = () => {\n this.handleSessionEnd();\n };\n\n /** Session lifecycle over `navigator.xr` and the renderer's XR manager. */\n public readonly session: SessionFacet = {\n getState: () => this.sessionState,\n request: (mode, options) => this.requestSession(mode, options),\n end: () => this.endSession(),\n onStateChange: (listener) => {\n this.stateListeners.add(listener);\n return () => {\n this.stateListeners.delete(listener);\n };\n },\n onVisibilityChange: (listener) => {\n this.visibilityListeners.add(listener);\n return () => {\n this.visibilityListeners.delete(listener);\n };\n },\n };\n\n public constructor(options: WebXRRuntimeAdapterOptions) {\n this.xr = options.xr;\n this.xrSystem = options.xrSystem === undefined ? defaultXRSystem() : options.xrSystem;\n this.host = options.host;\n this.scheduler = options.scheduler;\n this.sessionInit = options.sessionInit;\n\n this.xr.addEventListener(\"sessionstart\", this.onManagerSessionStart);\n this.xr.addEventListener(\"sessionend\", this.onManagerSessionEnd);\n\n const session = this.xr.getSession();\n\n if (session) {\n this.attachSession(session);\n this.sessionState = \"active\";\n }\n\n this.derived = deriveCapabilities(session);\n this.capabilities = this.derived;\n }\n\n public onFrame(listener: FrameListener): Unsubscribe {\n this.frameListeners.add(listener);\n return () => {\n this.frameListeners.delete(listener);\n };\n }\n\n public getCapabilities(): AdapterCapabilities {\n return this.capabilities;\n }\n\n public onCapabilitiesChange(listener: CapabilitiesListener): Unsubscribe {\n this.capabilitiesListeners.add(listener);\n return () => {\n this.capabilitiesListeners.delete(listener);\n };\n }\n\n /** The session the renderer is presenting, or null in 2D. */\n public getSession(): WebXRSessionLike | null {\n return this.boundSession;\n }\n\n /**\n * Bind the animation loop, if this adapter was given a host. Each callback\n * becomes one {@link FrameInfo} and, where a scheduler was supplied, one\n * `renderTick`. With no host this does nothing: the app owns the loop and\n * calls {@link WebXRRuntimeAdapter.emitFrame} itself.\n *\n * three.js routes `setAnimationLoop` through the session's own\n * `requestAnimationFrame` while presenting, so one call covers both the 2D\n * page and the headset.\n */\n public start(): void {\n if (this.animationLoopBound) {\n return;\n }\n\n this.animationLoopBound = true;\n this.host?.setAnimationLoop((timestamp) => this.handleAnimationFrame(timestamp));\n }\n\n /** Release the animation loop. Safe to call when it was never bound. */\n public stop(): void {\n if (!this.animationLoopBound) {\n return;\n }\n\n this.animationLoopBound = false;\n this.host?.setAnimationLoop(null);\n }\n\n /** Push one frame to every subscriber. Call this when you own the loop. */\n public emitFrame(timestamp: number, delta: number): void {\n const frame: FrameInfo = { timestamp, delta };\n this.frameListeners.forEach((listener) => listener(frame));\n }\n\n /**\n * Re-read the renderer's session and publish any capability change. The\n * adapter does this itself on every session and input-source event; call it\n * directly after the host enables a feature mid-session, or after a host that\n * raises no events changes what it presents.\n */\n public refreshCapabilities(): void {\n const session = this.xr.getSession();\n\n if (session) {\n this.attachSession(session);\n this.updateDerived(session);\n return;\n }\n\n if (this.boundSession) {\n this.handleSessionEnd();\n return;\n }\n\n this.updateDerived(null);\n }\n\n /**\n * Force capability flags regardless of what the session reports. Overrides\n * are a layer on top of the derived values: they win for as long as they are\n * set, survive every later derivation, and are dropped only by\n * {@link WebXRRuntimeAdapter.clearCapabilityOverrides} or\n * {@link WebXRRuntimeAdapter.dispose}.\n */\n public setCapabilities(capabilities: Partial<AdapterCapabilities>): void {\n this.overrides = { ...this.overrides, ...capabilities };\n this.publishCapabilities();\n }\n\n /** Drop every manual override and fall back to the derived capabilities. */\n public clearCapabilityOverrides(): void {\n this.overrides = {};\n this.publishCapabilities();\n }\n\n /**\n * Release everything the adapter holds: the animation loop, both sets of host\n * event listeners, any in-flight `end()`, the manual overrides and every\n * listener. It does not end the session - the app owns that decision.\n */\n public dispose(): void {\n this.stop();\n this.xr.removeEventListener(\"sessionstart\", this.onManagerSessionStart);\n this.xr.removeEventListener(\"sessionend\", this.onManagerSessionEnd);\n this.detachSession();\n this.settleEndWaiters();\n\n this.overrides = {};\n this.frameListeners.clear();\n this.capabilitiesListeners.clear();\n this.stateListeners.clear();\n this.visibilityListeners.clear();\n }\n\n private handleAnimationFrame(timestamp: number): void {\n const deltaMs =\n this.lastTimestamp === 0 ? FIRST_FRAME_DELTA_MS : timestamp - this.lastTimestamp;\n\n this.lastTimestamp = timestamp;\n this.frame += 1;\n\n // `FrameInfo.delta` is seconds; the scheduler's `LifecycleContext` is in\n // milliseconds, which is the unit `ThreeRenderLoopBridge` already emits.\n this.emitFrame(timestamp, deltaMs / 1000);\n\n const context: LifecycleContext = {\n timestamp,\n deltaTime: deltaMs,\n frame: this.frame,\n source: \"three\",\n };\n\n this.scheduler?.emit(\"renderTick\", context);\n }\n\n /**\n * Subscribe to one session's own events. The listeners close over the session\n * they belong to, so nothing here has to re-check which session is live.\n */\n private attachSession(session: WebXRSessionLike): void {\n if (session === this.boundSession) {\n return;\n }\n\n this.detachSession();\n this.boundSession = session;\n this.sessionListeners = [\n { type: \"end\", listener: () => this.handleSessionEnd() },\n {\n type: \"visibilitychange\",\n listener: () => this.notifyVisibility(toSessionVisibility(session.visibilityState)),\n },\n { type: \"inputsourceschange\", listener: () => this.updateDerived(session) },\n ];\n\n for (const entry of this.sessionListeners) {\n session.addEventListener(entry.type, entry.listener);\n }\n }\n\n private detachSession(): void {\n const session = this.boundSession;\n\n if (!session) {\n return;\n }\n\n for (const entry of this.sessionListeners) {\n session.removeEventListener(entry.type, entry.listener);\n }\n\n this.sessionListeners = [];\n this.boundSession = null;\n }\n\n private handleSessionStart(): void {\n const session = this.xr.getSession();\n\n if (!session) {\n return;\n }\n\n this.attachSession(session);\n this.setSessionState(\"active\");\n this.updateDerived(session);\n this.notifyVisibility(toSessionVisibility(session.visibilityState));\n }\n\n /**\n * One session ending raises both the session's `end` event and the manager's\n * `sessionend`, so this runs twice per session and is written to be\n * idempotent.\n */\n private handleSessionEnd(): void {\n if (!this.boundSession && this.sessionState === \"none\") {\n return;\n }\n\n this.detachSession();\n this.setSessionState(\"none\");\n this.updateDerived(null);\n this.notifyVisibility(\"non-immersive\");\n this.settleEndWaiters();\n }\n\n private updateDerived(session: CapabilitySessionLike | null): void {\n this.derived = deriveCapabilities(session);\n this.publishCapabilities();\n }\n\n private publishCapabilities(): void {\n const next: AdapterCapabilities = { ...this.derived, ...this.overrides };\n const current = this.capabilities;\n\n if (\n next.immersive === current.immersive &&\n next.handTracking === current.handTracking &&\n next.planeDetection === current.planeDetection &&\n next.passthrough === current.passthrough &&\n next.environmentBlendMode === current.environmentBlendMode\n ) {\n return;\n }\n\n this.capabilities = next;\n this.capabilitiesListeners.forEach((listener) => listener(next));\n }\n\n private async requestSession(\n mode: SessionMode,\n options?: SessionRequestOptions,\n ): Promise<SessionResult> {\n if (this.sessionState === \"active\") {\n return { ok: true };\n }\n\n const system = this.xrSystem;\n\n if (!system) {\n return { ok: false, reason: \"unsupported\" };\n }\n\n const timeoutMs = options?.timeoutMs ?? DEFAULT_SESSION_TIMEOUT_MS;\n this.setSessionState(\"requesting\");\n\n let timer: ReturnType<typeof setTimeout> | undefined;\n const timeout = new Promise<SessionResult>((resolve) => {\n timer = setTimeout(() => resolve({ ok: false, reason: \"timeout\" }), timeoutMs);\n });\n\n const result = await Promise.race([this.openSession(system, mode, options), timeout]);\n clearTimeout(timer);\n\n if (!result.ok) {\n this.setSessionState(\"none\");\n return result;\n }\n\n this.setSessionState(\"active\");\n return result;\n }\n\n /**\n * Negotiate the session and hand it to the renderer. Nothing here throws at\n * the caller: a headset that is absent, refused or broken is a normal runtime\n * condition, so every outcome comes back as a {@link SessionResult}.\n *\n * The request's own features are merged over the `sessionInit` hook's result\n * by the core's `mergeSessionInit`, so they add to the app's defaults rather\n * than replacing them. A request that names none passes the hook's result\n * through untouched.\n */\n private async openSession(\n system: WebXRSystemLike,\n mode: SessionMode,\n options?: SessionRequestOptions,\n ): Promise<SessionResult> {\n try {\n const supported = await system.isSessionSupported(mode);\n\n if (!supported) {\n return { ok: false, reason: \"unsupported\" };\n }\n\n const init = mergeSessionInit(this.sessionInit?.(mode), options);\n const session = await system.requestSession(mode, init);\n await this.xr.setSession(session);\n\n // A manager that raises `sessionstart` has already attached this session;\n // one that does not is attached here, so both host styles behave alike.\n this.attachSession(session);\n this.updateDerived(session);\n\n return { ok: true };\n } catch (error) {\n return { ok: false, reason: toFailureReason(error), error };\n }\n }\n\n private async endSession(): Promise<void> {\n const session = this.boundSession;\n\n if (!session) {\n return;\n }\n\n this.setSessionState(\"ending\");\n\n const ended = new Promise<void>((resolve) => {\n this.endWaiters.add(resolve);\n });\n\n try {\n await session.end();\n } catch {\n // Ending is best effort. The `end` event, not this promise, is what the\n // adapter acts on, and a host that refuses still has to be reported.\n }\n\n // The event has normally arrived by now and this is a no-op. A host that\n // raises none would otherwise leave the caller waiting forever, so the\n // settled `end()` call is the fallback signal.\n this.handleSessionEnd();\n\n await ended;\n }\n\n private settleEndWaiters(): void {\n const waiters = Array.from(this.endWaiters);\n this.endWaiters.clear();\n waiters.forEach((resolve) => resolve());\n }\n\n private notifyVisibility(visibility: SessionVisibility): void {\n this.visibilityListeners.forEach((listener) => listener(visibility));\n }\n\n private setSessionState(state: SessionState): void {\n if (this.sessionState === state) {\n return;\n }\n\n this.sessionState = state;\n this.stateListeners.forEach((listener) => listener(state));\n }\n}\n"]}
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@realitycollective/service-framework-three",
3
- "version": "1.0.1-preview.1",
3
+ "version": "1.0.1-preview.3",
4
4
  "description": "three.js render-loop bindings for the Reality Collective TypeScript Service Framework.",
5
5
  "author": "Reality Collective",
6
6
  "license": "MIT",
@@ -35,7 +35,7 @@
35
35
  "url": "https://github.com/realitycollective/com.realitycollective.service-framework.ts/issues"
36
36
  },
37
37
  "dependencies": {
38
- "@realitycollective/service-framework": "^1.0.1-preview.1"
38
+ "@realitycollective/service-framework": "^1.0.1-preview.3"
39
39
  },
40
40
  "scripts": {
41
41
  "build": "tsc -p tsconfig.build.json",