|
| 1 | +import type { |
| 2 | + CameraOrigin, |
| 3 | + IRenderer, |
| 4 | + TrameTriggerSender, |
| 5 | + VisorCameraState, |
| 6 | +} from './renderer/IRenderer'; |
| 7 | + |
| 8 | +/** |
| 9 | + * The `sync_camera` trigger: the client's half of the server-tracked camera. |
| 10 | + * |
| 11 | + * This module exists apart from `VisorFrontend` so that it can be tested. A |
| 12 | + * `VisorFrontend` cannot be constructed under jest -- it needs a real scene |
| 13 | + * graph node, the module-global spectrum manager and a renderer that accepts |
| 14 | + * `attachSceneGraph`, and it ends in `Object.freeze` -- so a listener body |
| 15 | + * written inline there would be pinned by nothing, and the payload shape is |
| 16 | + * precisely the part no server-side gate can check. `CameraGestureTracker` |
| 17 | + * was split out for the same reason in the previous increment. |
| 18 | + * |
| 19 | + * What reaches here is already debounced: `addCameraSettledListener` fires |
| 20 | + * once per settle window, never once per camera event, and it carries that |
| 21 | + * window's origin. The camera is therefore read exactly once per settle -- |
| 22 | + * the read is a multi-await round trip to the wasm camera, and paying it per |
| 23 | + * event is what the debounce exists to avoid. |
| 24 | + */ |
| 25 | + |
| 26 | +/** The payload of the `sync_camera` trigger. Mirrors the server's model. */ |
| 27 | +export type SyncCameraPayload = Readonly<{ |
| 28 | + origin: CameraOrigin; |
| 29 | + camera: Readonly<{ |
| 30 | + position: readonly number[]; |
| 31 | + focalPoint: readonly number[]; |
| 32 | + viewUp: readonly number[]; |
| 33 | + clippingRange: readonly number[]; |
| 34 | + parallelProjection: boolean; |
| 35 | + viewAngle: number; |
| 36 | + parallelScale: number; |
| 37 | + }>; |
| 38 | +}>; |
| 39 | + |
| 40 | +/** |
| 41 | + * The part of a renderer this module uses. Narrower than `IRenderer` so the |
| 42 | + * unit test can supply exactly these two members without a cast that would |
| 43 | + * defeat the type check it is here to get. |
| 44 | + */ |
| 45 | +export type CameraSyncSource = Pick<IRenderer, 'addCameraSettledListener' | 'getCameraStateAsync'>; |
| 46 | + |
| 47 | +/** |
| 48 | + * Build the trigger payload from a settled camera snapshot. |
| 49 | + * |
| 50 | + * The seven applied fields are named one by one, deliberately, rather than |
| 51 | + * spread from the snapshot. `VisorCameraState` carries five further derived |
| 52 | + * display fields -- `distance`, `orthographic`, `orthographicScale`, |
| 53 | + * `unitsPerPixel`, `viewPortHeight` -- which are meaningless to the server's |
| 54 | + * record. Spreading would put all twelve on the wire, and the server would |
| 55 | + * accept it silently: pydantic ignores unknown keys, so every server-side |
| 56 | + * gate would stay green while the wire contract quietly became "whatever the |
| 57 | + * client's snapshot type happens to hold today". Naming the seven is the only |
| 58 | + * place that shape is decided, which is why the test asserts the key set. |
| 59 | + * |
| 60 | + * `parallelProjection` is carried verbatim. `getCameraStateAsync` has already |
| 61 | + * narrowed the wasm camera's raw value to a boolean; this module does not |
| 62 | + * re-derive it. |
| 63 | + * |
| 64 | + * `origin` is carried verbatim too, for both values. The client never |
| 65 | + * suppresses a report it believes is an echo -- the server decides, and logs |
| 66 | + * what it dropped. |
| 67 | + */ |
| 68 | +export function buildSyncCameraPayload( |
| 69 | + origin: CameraOrigin, |
| 70 | + camera: VisorCameraState |
| 71 | +): SyncCameraPayload { |
| 72 | + return { |
| 73 | + origin, |
| 74 | + camera: { |
| 75 | + position: camera.position, |
| 76 | + focalPoint: camera.focalPoint, |
| 77 | + viewUp: camera.viewUp, |
| 78 | + clippingRange: camera.clippingRange, |
| 79 | + parallelProjection: camera.parallelProjection, |
| 80 | + viewAngle: camera.viewAngle, |
| 81 | + parallelScale: camera.parallelScale, |
| 82 | + }, |
| 83 | + }; |
| 84 | +} |
| 85 | + |
| 86 | +/** |
| 87 | + * Subscribe to settled camera windows and report each one to the server. |
| 88 | + * |
| 89 | + * Returns the remover from `addCameraSettledListener`, **verbatim**. The |
| 90 | + * caller owns it and must call it when the frontend holding it is replaced: |
| 91 | + * the `VtkScene` survives a client rebuild, so a frontend that is discarded |
| 92 | + * without releasing leaves its subscription live and every later gesture is |
| 93 | + * reported once per rebuild that has ever happened. Each of those reports is |
| 94 | + * individually valid, which is why no gate can see the fault. |
| 95 | + * |
| 96 | + * The sender is required. A frontend with no transport is not a state this |
| 97 | + * path supports: it would subscribe, read the camera on every settle, build a |
| 98 | + * payload and drop it, which is indistinguishable at runtime from a working |
| 99 | + * wire that the server is ignoring. |
| 100 | + * |
| 101 | + * A failed send is logged and swallowed, never rethrown. The settle callback |
| 102 | + * returns `void` and is invoked from a timer, so a rejection escaping it has |
| 103 | + * no caller to receive it and would surface as an unhandled rejection. The |
| 104 | + * log prefix is fixed and greppable because it is the only signal that a |
| 105 | + * report was lost -- the view looks identical either way. |
| 106 | + */ |
| 107 | +export function attachCameraSyncReporter( |
| 108 | + renderer: CameraSyncSource, |
| 109 | + send: TrameTriggerSender |
| 110 | +): () => void { |
| 111 | + return renderer.addCameraSettledListener((origin: CameraOrigin) => { |
| 112 | + void (async () => { |
| 113 | + try { |
| 114 | + const camera = await renderer.getCameraStateAsync(); |
| 115 | + await send('sync_camera', buildSyncCameraPayload(origin, camera)); |
| 116 | + } catch (err) { |
| 117 | + console.error(`[VISOR] sync_camera trigger send failed: origin='${origin}'`, err); |
| 118 | + } |
| 119 | + })(); |
| 120 | + }); |
| 121 | +} |
0 commit comments