Settle leases, styles, credits and controls in the renderer contract

- claimPointer takes a grab filter, asked on each down: a lease takes
  only the presses it grabs, and wheel, pinch and every other press stay
  with the camera and everyone else. Each event of a press goes where its
  down went, per button, so a claim never takes a press under way.
- Core owns the styles: effects.defineStyle passes a GLSL (and optionally
  WGSL) full-screen pass, with the shader interface written out.
- Credits are plain data ({ text, href, logoUrl, inline }) that Core
  renders; CreateOptions.creditContainer goes.
- controls.coast { drag, zoom } in seconds replaces the inertia trio; the
  stub's defaults are the default renderer's, converted.
- The frame order is the default renderer's own: a move made by follow or
  in preUpdate reaches changed on the next frame.
This commit is contained in:
Sameh Khamis committed 2026-11-05 13:11:59 -08:00
1 parent 14f06df735
commit e9c0dc5405
12 files changed
+591 -34

No files matched your search

+5 -1
View File
@@ -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`.
+20
View File
@@ -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();
},
},
+325 -5
View File
@@ -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 <data>',
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');
+7 -3
View File
@@ -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;
+7 -2
View File
@@ -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;
}
+36 -3
View File
@@ -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<f32>`) 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<string, number>): void;
setStyleIntensity(id: string, intensity: number): void;
setStyleParams(id: string, params: Record<string, number>): void;
+5 -1
View File
@@ -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';
+4 -3
View File
@@ -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',
+33 -5
View File
@@ -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;
-2
View File
@@ -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. */
+13 -3
View File
@@ -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],
+136 -6
View File
@@ -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 };