diff --git a/docs/RENDERER-CONTRACT.md b/docs/RENDERER-CONTRACT.md index 63584876f..566dabeac 100644 --- a/docs/RENDERER-CONTRACT.md +++ b/docs/RENDERER-CONTRACT.md @@ -53,7 +53,11 @@ The rules that hold everywhere: listeners, and the frame, still run. - **The frame order is fixed:** camera flight → input → camera `changed` → follow → `preUpdate` → move detection → `preRender` → render → - `postRender`. + `postRender`, the default renderer's own. A camera move made by follow or + in `preUpdate` is reported by `changed` on the next frame. +- **Leases:** a tool takes the pointer with `claimPointer`. With `grab`, it + takes only the presses it grabs on `down`; the rest, and wheel and pinch, + stay with the camera and everyone else. `src/render/contract/index.js` lists every member of every port in `PORTS`, along with `CAPABILITIES`, `FRAME_STEPS` and `QUALITY_KEYS`. diff --git a/src/render/conformance/checks/camera.js b/src/render/conformance/checks/camera.js index 622189a07..9a97556a1 100644 --- a/src/render/conformance/checks/camera.js +++ b/src/render/conformance/checks/camera.js @@ -242,6 +242,26 @@ export function cameraChecks() { [-1.2, -0.1], 'pitchRange is settable', ); + const { coast } = r.camera.controls; + for (const key of ['drag', 'zoom']) + ok( + Number.isFinite(coast[key]) && coast[key] >= 0, + `coast.${key} is seconds`, + ); + r.camera.controls.coast = { drag: 0.25, zoom: 0 }; + // Within rounding: an engine may keep it as a decay coefficient. + near( + r.camera.controls.coast.drag, + 0.25, + 1e-9, + 'coast.drag is settable', + ); + near( + r.camera.controls.coast.zoom, + 0, + 1e-9, + 'a coast of 0 stops at once', + ); r.destroy(); }, }, diff --git a/src/render/conformance/checks/ports.js b/src/render/conformance/checks/ports.js index 81fa85d66..122dcd918 100644 --- a/src/render/conformance/checks/ports.js +++ b/src/render/conformance/checks/ports.js @@ -275,7 +275,7 @@ export function portChecks() { 'credits.on(change)', (cb) => r.credits.on('change', cb), () => { - credits.push(r.credits.addStatic({ html: 'Conformance data' })); + credits.push(r.credits.addStatic({ text: 'Conformance data' })); }, ], [ @@ -1363,6 +1363,251 @@ export function portChecks() { deepEqual(seen, ['ambient'], 'ambient again'); }, ), + check( + 'input.lease.grab', + 'a lease with grab takes only the presses it grabs; wheel and the rest stay free', + async (h, r) => { + if (!h.pointer) return { skip: 'no pointer in this harness' }; + const heard = []; + for (const type of POINTER_TYPES) { + r.input.on(type, () => heard.push(`ambient:${type}`)); + } + const errors = []; + r.on('error', (e) => errors.push(e)); + let throwing = false; + const lease = r.input.claimPointer('draw', { + grab: (down) => { + if (throwing) throw new Error('conformance grab'); + return down.type === 'down' && down.position.x < 640; + }, + }); + for (const type of POINTER_TYPES) + r.input.on(type, () => heard.push(`tool:${type}`), { lease }); + const press = async (x) => { + heard.length = 0; + for (const type of ['down', 'move', 'up', 'click']) + await h.pointer(r, type, x, 360); + return heard.slice(); + }; + deepEqual( + await press(100), + [ + 'tool:down', + 'ambient:move', + 'tool:move', + 'ambient:up', + 'tool:up', + 'tool:click', + ], + 'a grabbed press is the tool’s; move and up go to both', + ); + heard.length = 0; + await h.pointer(r, 'wheel', 100, 360, { delta: 1 }); + deepEqual( + heard, + ['ambient:wheel'], + 'wheel is never grabbed, even after a grabbed press', + ); + heard.length = 0; + await h.pointer(r, 'pinch', 100, 360, { delta: 1.1 }); + deepEqual(heard, ['ambient:pinch'], 'nor is pinch'); + deepEqual( + await press(1000), + [ + 'ambient:down', + 'ambient:move', + 'tool:move', + 'ambient:up', + 'tool:up', + 'ambient:click', + ], + 'a press left alone goes to the others', + ); + ok(lease.current, 'the lease stays out'); + ok(!r.input.isPointerFree(), 'the pointer is not free'); + throwing = true; + deepEqual( + (await press(100)).filter((x) => x.endsWith(':down')), + ['ambient:down'], + 'a throwing grab leaves the press to the others', + ); + equal(errors.length, 1, 'and is reported'); + lease.release(); + }, + ), + check( + 'input.lease.press', + 'every event of a press goes where its down went, whatever the lease does meanwhile', + async (h, r) => { + if (!h.pointer) return { skip: 'no pointer in this harness' }; + const heard = []; + for (const type of ['down', 'click', 'dblclick']) + r.input.on(type, () => heard.push(`ambient:${type}`)); + const left = (down) => down.type === 'down' && down.position.x < 640; + const listen = (lease, name) => { + for (const type of ['down', 'up', 'click', 'dblclick']) + r.input.on(type, () => heard.push(`${name}:${type}`), { lease }); + }; + const at = async (x, ...types) => { + for (const type of types) await h.pointer(r, type, x, 360); + }; + + let lease = r.input.claimPointer('a', { grab: left }); + listen(lease, 'a'); + r.input.on('up', () => lease.release(), { lease }); + heard.length = 0; + await at(100, 'down', 'up', 'click'); + deepEqual( + heard, + ['a:down', 'a:up'], + 'a lease that ends on up takes the click with it: no one hears it', + ); + + heard.length = 0; + await at(1000, 'down'); + lease = r.input.claimPointer('b', { grab: () => true }); + listen(lease, 'b'); + await at(1000, 'up', 'click'); + deepEqual( + heard, + ['ambient:down', 'b:up', 'ambient:click'], + 'a press left to the others stays theirs when a lease is claimed mid-press (up goes to both)', + ); + + heard.length = 0; + await at(100, 'down', 'up', 'click', 'down', 'up', 'click', 'dblclick'); + deepEqual( + heard.filter((x) => !x.endsWith(':up')), + ['b:down', 'b:click', 'b:down', 'b:click', 'b:dblclick'], + 'a grabbed double-click is the lease’s', + ); + lease.release(); + + lease = r.input.claimPointer('c', { grab: left }); + listen(lease, 'c'); + heard.length = 0; + await at(100, 'down', 'up', 'click'); + await at(1000, 'click'); + deepEqual( + heard.filter((x) => !x.endsWith(':up')), + ['c:down', 'c:click', 'ambient:click'], + 'a click with no press of its own is decided afresh', + ); + lease.release(); + + let latest = r.input.claimPointer('d', { grab: () => true }); + r.input.on( + 'down', + () => { + heard.push('d:down'); + latest = r.input.claimPointer('e', { grab: () => false }); + }, + { lease: latest }, + ); + r.input.on('down', () => heard.push('latest:down'), { + lease: () => latest, + }); + heard.length = 0; + await at(100, 'down'); + deepEqual( + heard, + ['d:down'], + 'a lease claimed during delivery does not inherit the press', + ); + latest.release(); + + let replacement = null; + const first = r.input.claimPointer('f', { + grab: () => { + replacement = r.input.claimPointer('g'); + return true; + }, + }); + listen(first, 'f'); + heard.length = 0; + await at(100, 'down'); + listen(replacement, 'g'); + await at(100, 'up', 'click'); + deepEqual( + heard.filter((x) => !x.endsWith(':up')), + [], + 'a press grabbed by a lease its grab replaced belongs to that ended lease: no one', + ); + replacement.release(); + + const downs = []; + lease = r.input.claimPointer('h', { + grab: (down) => { + downs.push(down); + return down.button === 0 && down.position.x < 640; + }, + }); + listen(lease, 'h'); + const step = (type, x, button = 0) => + h.pointer(r, type, x, 360, { button }); + heard.length = 0; + await step('down', 100); + await step('move', 300); + await step('up', 300); + await step('move', 1000); + await step('click', 1000); + deepEqual( + heard.filter((x) => !x.endsWith(':up')), + ['h:down', 'ambient:click'], + 'after a drag, a later click is a press of its own', + ); + heard.length = 0; + await step('down', 100); + await step('move', 300); + await step('up', 300); + await step('click', 1000); + deepEqual( + heard.filter((x) => !x.endsWith(':up')), + ['h:down', 'ambient:click'], + 'a click straight after a drag, elsewhere, is a press of its own', + ); + heard.length = 0; + await step('down', 100, 0); + await step('down', 100, 2); + await step('up', 100, 0); + await step('click', 100, 0); + await step('up', 100, 2); + await step('click', 100, 2); + deepEqual( + heard.filter((x) => !x.endsWith(':up')), + ['h:down', 'ambient:down', 'h:click', 'ambient:click'], + 'each button’s press is its own', + ); + heard.length = 0; + await at(100, 'down', 'up', 'click'); + await at(1000, 'dblclick'); + deepEqual( + heard.filter((x) => !x.endsWith(':up')), + ['h:down', 'h:click', 'ambient:dblclick'], + 'a double-click elsewhere is not the click’s', + ); + heard.length = 0; + await at(100, 'down'); + await at(1000, 'dblclick'); + await at(100, 'up', 'click'); + deepEqual( + heard.filter((x) => !x.endsWith(':up')), + ['h:down', 'ambient:dblclick', 'h:click'], + 'a stray double-click during a press leaves the press its owner\u2019s', + ); + downs.length = 0; + await at(100, 'click'); + equal(downs.length, 1, 'an isolated click asks grab'); + equal(downs[0]?.type, 'down', 'as a down'); + ok( + !('pick' in downs[0]) && + !('drill' in downs[0]) && + !('ground' in downs[0]), + 'with no pick', + ); + lease.release(); + }, + ), check( 'input.clickPick', 'a click carries one pick shared by every subscriber', @@ -1476,6 +1721,64 @@ export function portChecks() { }, { capability: 'styles' }, ), + check( + 'effects.defineStyle', + 'Core defines a style: listed, set, replaced and removed', + (h, r) => { + const definition = { + id: 'conf:tint', + params: [{ key: 'amount', min: 0, max: 1, default: 0.5 }], + animated: false, + shader: { + glsl: `uniform sampler2D colorTexture; +uniform float intensity; +uniform float amount; +in vec2 v_textureCoordinates; +void main() { + vec4 c = texture(colorTexture, v_textureCoordinates); + out_FragColor = vec4(mix(c.rgb, c.rgb * vec3(1.0, amount, amount), intensity), 1.0); +}`, + wgsl: `@fragment +fn fs_main(@location(0) uv: vec2f) -> @location(0) vec4f { + let c = textureSample(colorTexture, colorSampler, uv); + let tinted = c.rgb * vec3f(1.0, params.amount, params.amount); + return vec4f(mix(c.rgb, tinted, params.intensity), 1.0); +}`, + }, + }; + const off = r.effects.defineStyle(definition); + equal(typeof off, 'function', 'an Off, with a GLSL and a WGSL source'); + const listed = r.effects.styles().filter((s) => s.id === 'conf:tint'); + equal(listed.length, 1, 'listed once'); + deepEqual(listed[0].params, definition.params, 'with its parameters'); + r.effects.setStyle('conf:tint', { amount: 0.2 }); + equal(r.effects.diagnostics().style, 'conf:tint', 'set'); + const offAgain = r.effects.defineStyle({ + ...definition, + animated: true, + }); + equal( + r.effects.styles().filter((s) => s.id === 'conf:tint').length, + 1, + 'a second definition replaces the first', + ); + equal( + r.effects.diagnostics().style, + 'conf:tint', + 'the style stays on through its replacement', + ); + off(); + ok( + r.effects.styles().some((s) => s.id === 'conf:tint'), + 'the replaced definition’s Off leaves the new one', + ); + offAgain(); + ok(!r.effects.styles().some((s) => s.id === 'conf:tint'), 'removed'); + equal(r.effects.diagnostics().style, null, 'and turned off'); + offAgain(); + }, + { capability: 'styles' }, + ), check( 'effects.withoutStyles', 'without styles, styles() is empty', @@ -1487,6 +1790,16 @@ export function portChecks() { if (!r) return { skip: 'this harness cannot drop capabilities' }; try { deepEqual(r.effects.styles(), [], 'styles()'); + equal( + r.effects.defineStyle({ + id: 'x', + params: [], + animated: false, + shader: { glsl: 'void main() {}' }, + }), + null, + 'defineStyle() answers null', + ); } finally { r.destroy(); } @@ -1499,10 +1812,17 @@ export function portChecks() { const events = []; r.credits.on('change', (list) => events.push(list.length)); const base = r.credits.list().length; - const off = r.credits.addStatic({ html: 'Conformance data' }); - ok( - r.credits.list().some((c) => c.html === 'Conformance data'), - 'listed', + const credit = { + text: 'Conformance ', + href: 'https://example.invalid/data', + logoUrl: 'https://example.invalid/logo.png', + inline: true, + }; + const off = r.credits.addStatic(credit); + deepEqual( + r.credits.list().find((c) => c.text === credit.text), + credit, + 'listed as given, text left as text', ); off(); equal(r.credits.list().length, base, 'removed'); diff --git a/src/render/contract/camera.d.ts b/src/render/contract/camera.d.ts index 9656320f1..90ba04d97 100644 --- a/src/render/contract/camera.d.ts +++ b/src/render/contract/camera.d.ts @@ -172,9 +172,13 @@ export interface CameraControls { enableZoom: boolean; minDistance: number; maxDistance: number; - inertiaSpin: number; - inertiaTranslate: number; - inertiaZoom: number; + /** + * How long a motion the user lets go of coasts: seconds for its speed to + * fall to 1/e. `drag` is moving the globe under the pointer and tilting + * (one motion in the default renderer), `zoom` is zooming; 0 stops at + * once. Set the whole object to change it. + */ + coast: { drag: number; zoom: number }; /** What the eye cannot pass below. */ collision: 'none' | 'terrain' | 'tiles' | 'both'; zoomFactor: number; diff --git a/src/render/contract/credits.d.ts b/src/render/contract/credits.d.ts index 7cdb6c0ec..f1546cdea 100644 --- a/src/render/contract/credits.d.ts +++ b/src/render/contract/credits.d.ts @@ -5,9 +5,14 @@ */ import type { Off } from './common'; +/** One credit, as plain data: Core renders and escapes it. */ export interface Credit { - /** Sanitised HTML: text, links and images only. */ - html: string; + /** Plain text, never markup. */ + text: string; + /** Where the credit links to. */ + href?: string; + /** A logo drawn before the text. */ + logoUrl?: string; /** Shown on the map itself, rather than in the expanded list. */ inline?: boolean; } diff --git a/src/render/contract/effects.d.ts b/src/render/contract/effects.d.ts index ccfbd3c34..42bf4ad81 100644 --- a/src/render/contract/effects.d.ts +++ b/src/render/contract/effects.d.ts @@ -1,7 +1,8 @@ /** * Full-screen effects: visual styles, bloom, sharpen and screen modulation. - * Each style is a plain full-screen pass over the frame's colour, so every - * renderer can provide the same set. + * Each style is a plain full-screen pass over the frame's colour. Core owns + * the styles and defines them on every renderer (`defineStyle`), so each + * renderer runs the same set; a renderer's own styles are extra ones. */ import type { Off } from './common'; @@ -13,6 +14,32 @@ export interface StyleDescriptor { animated: boolean; } +/** + * A style's shader. `glsl` is a GLSL ES 3.00 fragment shader with its + * `main()`, as GEV's styles are written, without `#version`, `precision` + * or an `out` declaration: the renderer declares `out_FragColor`, which it + * writes. It declares what it reads of `uniform sampler2D colorTexture`, + * `uniform vec2 colorTextureDimensions` (pixels), + * `in vec2 v_textureCoordinates`, `uniform float intensity` (0..1), + * `uniform float time` (seconds, for an animated style) and one + * `uniform float` per parameter under its key. Parameter keys are GLSL + * identifiers and never one of those names. + * `wgsl`, for a renderer that runs WGSL, is the same pass: a fragment entry + * `fs_main` taking `@location(0) uv: vec2f` and returning the colour at + * `@location(0)`. Group 0 binds `colorTexture` (`texture_2d`) at 0, + * `colorSampler` at 1 and, at 2, a uniform struct the shader declares as + * `params`: `intensity: f32`, `time: f32`, `dimensions: vec2f`, then one + * `f32` per parameter in the order `params` lists them. + */ +export interface StyleShader { + glsl: string; + wgsl?: string; +} + +export interface StyleDefinition extends StyleDescriptor { + shader: StyleShader; +} + /** A sweeping sector drawn over the frame (a sonar band), or null. */ export interface ModulationSector { /** Centre of the sweep, CSS pixels. */ @@ -32,8 +59,14 @@ export interface EffectsDiagnostics { } export interface EffectsPort { - /** `styles`. */ + /** `styles`: every style, defined by Core or the renderer's own. */ styles(): StyleDescriptor[]; + /** + * `styles`: add a style, replacing one with the same id. Null without + * the capability, or when the renderer runs none of the shader's + * languages; the Off removes it (turning it off when it is the style). + */ + defineStyle(definition: StyleDefinition): Off | null; setStyle(id: string | null, params?: Record): void; setStyleIntensity(id: string, intensity: number): void; setStyleParams(id: string, params: Record): void; diff --git a/src/render/contract/frame.d.ts b/src/render/contract/frame.d.ts index a407347ba..bb9d3fd34 100644 --- a/src/render/contract/frame.d.ts +++ b/src/render/contract/frame.d.ts @@ -10,7 +10,11 @@ * move detection (`moveStart` / `moveEnd`) → `preRender` → render → * `postRender`. `preUpdate` fires on every frame the loop ticks, including * frames that end up drawing nothing, so owners that write the camera there - * (a cockpit, a follow clamp) see each one. + * (a cockpit, a follow clamp) see each one. This is the default renderer's + * own order. `changed` is measured before follow and `preUpdate` move the + * camera, so a move made there is reported by `changed` on the next frame; + * move detection runs after them and sees it in the same frame. The + * default renderer's `postUpdate` has no place here: Core does not use it. */ import type { Off, Release } from './common'; diff --git a/src/render/contract/index.js b/src/render/contract/index.js index 382e0f470..dac76b864 100644 --- a/src/render/contract/index.js +++ b/src/render/contract/index.js @@ -198,9 +198,9 @@ export const PORTS = deepFreeze({ 'controls.enableZoom', 'controls.minDistance', 'controls.maxDistance', - 'controls.inertiaSpin', - 'controls.inertiaTranslate', - 'controls.inertiaZoom', + 'controls.coast', + 'controls.coast.drag', + 'controls.coast.zoom', 'controls.collision', 'controls.zoomFactor', 'controls.pitchRange', @@ -247,6 +247,7 @@ export const PORTS = deepFreeze({ effects: Object.fromEntries([ ...required( 'styles', + 'defineStyle', 'setStyle', 'setStyleIntensity', 'setStyleParams', diff --git a/src/render/contract/input.d.ts b/src/render/contract/input.d.ts index d1cc98013..8ea7222d9 100644 --- a/src/render/contract/input.d.ts +++ b/src/render/contract/input.d.ts @@ -3,9 +3,26 @@ * * A tool takes the pointer with a lease; while a lease is out, ambient * subscribers (those without a lease) get only `move` and `up`: no presses, - * clicks, double-clicks, wheel or pinch. Suspending camera - * input is separate and counted, and takes effect on the same pointerdown - * that asked for it. + * clicks, double-clicks, wheel or pinch. A lease with `grab` takes only the + * presses it grabs: `grab` is asked on each `down`, and a grabbed press (its + * `down`, and the `click` and `dblclick` it ends in) goes to the lease's + * listeners alone. Every other press, and wheel and pinch, go to ambient + * subscribers and the camera as if the pointer were free; `move` and `up` + * go to both. + * + * Every event of a press goes where its `down` went, even when the lease + * ends or changes before the press is over (an ended lease's to no one), so + * a claim never takes a press already under way. Each button's presses + * are separate. A press runs from its `down` through its `up`, the `click` + * straight after it where it went up, and a `dblclick` straight after that + * at the same spot; + * any other event after its `up` ends it. A click or double-click with no + * press of its own is decided as a press, `grab` seeing it as a `down` + * (without `pick`, `drill` or `ground`). + * + * Suspending camera input is separate and counted, and takes effect on the + * same pointerdown that asked for it, so a tool that grabs a press suspends + * the camera in its `down` listener. * * A click is delivered with its pick already made, once, and shared by * every subscriber (`PointerEv.pick`). A renderer whose picks answer a frame @@ -70,8 +87,19 @@ export interface InputPort { cb: (ev: PointerEv) => void, options?: { lease?: Lease | (() => Lease | null) }, ): Off; - /** Take the pointer for a tool. A new claim ends the previous lease. */ - claimPointer(owner: string): Lease; + /** + * Take the pointer for a tool. A new claim ends the previous lease. + * `grab` decides each press from its `down` event, synchronously: true + * makes it the tool's, anything else leaves it. A `down` carries no pick, + * so `grab` decides from the position, button and modifiers (and the + * tool's own hit test, through `query`). Omitted, every press is the + * tool's. A `grab` that throws is reported and leaves the press to the + * others. + */ + claimPointer( + owner: string, + options?: { grab?: (down: PointerEv) => boolean }, + ): Lease; isPointerFree(): boolean; /** Suspend user camera input until released. Holds are counted. */ suspendCameraControls(reason: string): Release; diff --git a/src/render/contract/renderer.d.ts b/src/render/contract/renderer.d.ts index 8b8ad2619..dc4a7a4d4 100644 --- a/src/render/contract/renderer.d.ts +++ b/src/render/contract/renderer.d.ts @@ -30,8 +30,6 @@ export interface RendererPlugin { export interface CreateOptions { /** The element the renderer fills. It creates its own canvas inside. */ container: HTMLElement; - /** Where Core renders credits, when it has a place for them. */ - creditContainer?: HTMLElement; /** Google Maps Platform key for photorealistic tiles. */ googleApiKey?: string; /** Short-lived Google session tokens, when the key is held server-side. */ diff --git a/src/render/stub/camera.js b/src/render/stub/camera.js index 1449b052d..1632dccf1 100644 --- a/src/render/stub/camera.js +++ b/src/render/stub/camera.js @@ -111,6 +111,7 @@ export function createStubCamera({ let moving = false; let lastMovedAt = 0; + let coast = Object.freeze({ drag: 0.4, zoom: 0.2 }); const controls = { get enabled() { return holds.size === 0; @@ -125,9 +126,18 @@ export function createStubCamera({ enableZoom: true, minDistance: 1, maxDistance: Infinity, - inertiaSpin: 0.9, - inertiaTranslate: 0.9, - inertiaZoom: 0.8, + // The default renderer's inertia as decay times: its speed falls as + // exp(-25 (1 - c) t), so c = 0.9 (spin, which drags and tilts in 3D) + // is 0.4 s and c = 0.8 (zoom) 0.2 s. Back: c = coast > 0 ? 1 - 1 / (25 coast) : 1. + get coast() { + return coast; + }, + set coast(value) { + for (const key of ['drag', 'zoom']) + if (!(Number.isFinite(value?.[key]) && value[key] >= 0)) + throw new TypeError(`coast.${key} must be seconds, 0 or more`); + coast = Object.freeze({ drag: value.drag, zoom: value.zoom }); + }, collision: 'none', zoomFactor: 5, pitchRange: [-Math.PI / 2, 0], diff --git a/src/render/stub/index.js b/src/render/stub/index.js index 24cbc854f..7fc55a68d 100644 --- a/src/render/stub/index.js +++ b/src/render/stub/index.js @@ -733,6 +733,16 @@ export function createStubRenderer(options = {}) { }; } + /** Each lease's `grab`, or null when it takes every press. */ + const leaseGrab = new WeakMap(); + /** + * The presses under way, by button, from their `down`: `owner` is the + * lease each went to, or null when it went to everyone else. A press's + * click and double-click go where its down went (an ended owner's to no + * one). + */ + const presses = new Map(); + const leaseOf = (entry) => typeof entry.lease === 'function' ? entry.lease() : entry.lease; @@ -746,9 +756,10 @@ export function createStubRenderer(options = {}) { if (i >= 0) pointerListeners.splice(i, 1); }; }, - claimPointer(owner) { + claimPointer(owner, opts = {}) { if (currentLease) currentLease.release(); let released = false; + const grab = typeof opts.grab === 'function' ? opts.grab : null; const lease = { owner: String(owner), get current() { @@ -761,6 +772,7 @@ export function createStubRenderer(options = {}) { }, }; currentLease = lease; + leaseGrab.set(lease, grab); return lease; }, isPointerFree: () => currentLease === null, @@ -774,8 +786,91 @@ export function createStubRenderer(options = {}) { ctrlWheelZoom: false, }; + /** Who a new press belongs to, decided from its `down`. */ + function pressFor(down) { + const lease = currentLease; + if (!lease) return { owner: null, clicked: false }; + const grab = leaseGrab.get(lease); + let grabbed = !grab; + if (grab) + try { + grabbed = grab(down) === true; + } catch (error) { + report(error); + } + return { owner: grabbed ? lease : null, clicked: false }; + } + + /** + * Where an event goes: to one lease's listeners (`lease`), to everyone + * without a lease (`ambient`), or both (`move` and `up`). + */ + function recipients(type, ev) { + const button = ev.button; + // A click comes straight after its press's up, and a double-click + // straight after that click, by the same button at the same spot; + // anything else after the up ends the press. + for (const [b, p] of presses) { + if (!p.up) continue; + const continues = + b === button && + ((type === 'click' && + !p.clicked && + Math.hypot(p.at.x - ev.position.x, p.at.y - ev.position.y) <= 4) || + (type === 'dblclick' && + p.clicked && + Math.hypot(p.at.x - ev.position.x, p.at.y - ev.position.y) <= 4)); + if (!continues) presses.delete(b); + } + if (type === 'up' && presses.has(button)) { + const p = presses.get(button); + p.up = true; + p.at = ev.position; + } + if (type === 'move' || type === 'up') + return { lease: currentLease, ambient: true }; + if (type === 'wheel' || type === 'pinch') { + const exclusive = currentLease && !leaseGrab.get(currentLease); + return exclusive + ? { lease: currentLease, ambient: false } + : { lease: null, ambient: true }; + } + // `grab` sees a click with no press of its own as the down it stands + // for: no pick, no gesture. + const asDown = () => ({ + ...pressFor({ + type: 'down', + position: ev.position, + button: ev.button, + modifiers: ev.modifiers, + timestamp: ev.timestamp, + pointerType: ev.pointerType, + }), + up: true, + }); + let current; + if (type === 'down') { + current = pressFor(ev); + presses.set(button, current); + } else { + // Only a press the loop above kept, at the right step, continues; + // anything else is a press of its own and leaves a held one alone. + const held = presses.get(button); + const continues = + held?.up && (type === 'click' ? !held.clicked : held.clicked); + current = continues ? held : asDown(); + if (type === 'click' && (continues || !held)) { + current.clicked = true; + presses.set(button, current); + } else if (continues) presses.delete(button); + } + current.at = ev.position; + return current.owner + ? { lease: current.owner, ambient: false } + : { lease: null, ambient: true }; + } + function dispatchPointer(type, x, y, extra = {}) { - const free = currentLease === null; let picked; const ev = { type, @@ -801,13 +896,13 @@ export function createStubRenderer(options = {}) { opts.limit === undefined ? hits().slice() : hits().slice(0, opts.limit); ev.ground = () => rayEllipsoid(pickRay(x, y)); } - const ambientAlways = type === 'move' || type === 'up'; + const to = recipients(type, ev); for (const entry of pointerListeners.slice()) { if (!entry.live || entry.type !== type) continue; if (entry.lease) { const lease = leaseOf(entry); - if (!lease?.current) continue; - } else if (!free && !ambientAlways) continue; + if (!to.lease || lease !== to.lease || !lease.current) continue; + } else if (!to.ambient) continue; try { entry.cb(ev); } catch (error) { @@ -982,6 +1077,8 @@ export function createStubRenderer(options = {}) { modulation: null, }; const effectListeners = []; + /** Styles Core defined, by id. */ + const defined = new Map(); const STYLES = Object.freeze([ { id: 'retro', @@ -997,7 +1094,40 @@ export function createStubRenderer(options = {}) { } const effects = { styles: () => - capabilities.has('styles') ? STYLES.map((s) => ({ ...s })) : [], + capabilities.has('styles') + ? [ + ...STYLES.filter((s) => !defined.has(s.id)), + ...defined.values(), + ].map(({ id, params, animated }) => ({ + id, + params: params.map((p) => ({ ...p })), + animated, + })) + : [], + defineStyle(definition) { + // The stub runs GLSL only, as the default renderer does. + if ( + !capabilities.has('styles') || + typeof definition?.shader?.glsl !== 'string' + ) + return null; + const entry = { + id: String(definition.id), + params: (definition.params ?? []).map((p) => ({ ...p })), + animated: Boolean(definition.animated), + }; + defined.set(entry.id, entry); + effectsChanged(); + let removed = false; + return () => { + if (removed) return; + removed = true; + if (defined.get(entry.id) !== entry) return; + defined.delete(entry.id); + if (effectState.style === entry.id) effectState.style = null; + effectsChanged(); + }; + }, setStyle(id, params) { effectState.style = id; if (params) effectState.params[id] = { ...params };