mirror of
https://github.com/bilawalsidhu/gods-eye-view.git
synced 2026-11-08 04:14:02 +00:00
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:
1 parent
14f06df735
commit
e9c0dc5405
12 files changed
+591
-34
No files matched your search
@@ -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`.
|
||||
|
||||
@@ -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();
|
||||
},
|
||||
},
|
||||
|
||||
@@ -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');
|
||||
|
||||
Vendored
+7
-3
@@ -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;
|
||||
|
||||
Vendored
+7
-2
@@ -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;
|
||||
}
|
||||
|
||||
Vendored
+36
-3
@@ -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;
|
||||
|
||||
Vendored
+5
-1
@@ -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';
|
||||
|
||||
|
||||
@@ -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',
|
||||
|
||||
Vendored
+33
-5
@@ -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;
|
||||
|
||||
Vendored
-2
@@ -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. */
|
||||
|
||||
@@ -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
@@ -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 };
|
||||
|
||||
Reference in new issue
Block a user