@woosh/meep-engine 2.47.23 → 2.47.26

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (31) hide show
  1. package/build/meep.cjs +544 -342
  2. package/build/meep.min.js +1 -1
  3. package/build/meep.module.js +544 -342
  4. package/editor/tools/SelectionTool.js +17 -17
  5. package/editor/view/EditorView.js +4 -5
  6. package/editor/view/ecs/EntityList.js +0 -2
  7. package/package.json +1 -1
  8. package/src/core/geom/3d/topology/samples/sampleFloodFill.js +8 -8
  9. package/src/core/json/abstractJSONSerializer.js +1 -1
  10. package/src/engine/ecs/EntityComponentDataset.js +28 -3
  11. package/src/engine/ecs/EntityManager.js +264 -221
  12. package/src/engine/ecs/System.d.ts +2 -0
  13. package/src/engine/ecs/System.js +37 -30
  14. package/src/engine/ecs/gui/menu/radial/RadialContextMenu.js +0 -4
  15. package/src/engine/ecs/systems/TimerSystem.js +61 -41
  16. package/src/engine/ecs/terrain/util/obtainTerrain.js +2 -3
  17. package/src/engine/graphics/GraphicsEngine.js +4 -3
  18. package/src/engine/graphics/ecs/camera/Camera.js +37 -0
  19. package/src/engine/graphics/ecs/camera/filter/setup_filtered_camera_controller.js +78 -0
  20. package/src/engine/graphics/ecs/camera/pp/NOTES.md +48 -0
  21. package/src/engine/graphics/ecs/camera/pp/PerfectPanner.js +164 -0
  22. package/src/engine/graphics/ecs/camera/pp/Perfect_Panning.png +0 -0
  23. package/src/engine/graphics/ecs/camera/pp/Zooming.png +0 -0
  24. package/src/engine/graphics/ecs/camera/pp/pan_a.png +0 -0
  25. package/src/engine/graphics/ecs/camera/pp/pan_b.png +0 -0
  26. package/src/engine/graphics/ecs/mesh/MeshSystem.js +1 -1
  27. package/src/engine/graphics/make_ray_from_viewport_position.js +28 -0
  28. package/src/engine/input/devices/PointerDevice.js +32 -0
  29. package/src/engine/intelligence/behavior/decorator/RepeatBehavior.js +17 -8
  30. package/src/engine/intelligence/behavior/decorator/RepeatBehaviorSerializationAdapter.js +2 -2
  31. package/src/engine/intelligence/behavior/primitive/SucceedingBehavior.js +9 -5
@@ -8,61 +8,81 @@ import Timer from '../components/Timer.js';
8
8
 
9
9
 
10
10
  class TimerSystem extends System {
11
- constructor() {
12
- super();
13
-
14
- this.dependencies = [Timer];
15
- }
16
-
17
- update(timeDelta) {
18
- const entityManager = this.entityManager;
19
- const dataset = entityManager.dataset;
20
-
21
- if (dataset === null) {
11
+ /**
12
+ *
13
+ * @type {number}
14
+ */
15
+ #timeDelta = 0;
16
+ /**
17
+ *
18
+ * @type {EntityComponentDataset|null}
19
+ */
20
+ #dataset = null;
21
+
22
+ dependencies = [Timer];
23
+
24
+
25
+ /**
26
+ *
27
+ * @param {Timer} timer
28
+ * @param {number} entity
29
+ */
30
+ #visit(timer, entity) {
31
+ if (!timer.active) {
22
32
  return;
23
33
  }
24
34
 
25
- dataset.traverseComponents(Timer, function (timer, entity) {
26
- if (!timer.active) {
27
- return;
28
- }
29
-
30
- let budget = timer.counter + timeDelta;
31
- const timeout = timer.timeout;
35
+ const timeDelta = this.#timeDelta;
36
+ const dataset = this.#dataset;
32
37
 
33
- while (budget > timeout) {
34
- budget -= timeout;
38
+ let budget = timer.counter + timeDelta;
39
+ const timeout = timer.timeout;
35
40
 
36
- const functions = timer.actions;
41
+ while (budget > timeout) {
42
+ budget -= timeout;
37
43
 
38
- const n = functions.length;
44
+ const functions = timer.actions;
39
45
 
40
- for (let i = 0; i < n; i++) {
41
- const action = functions[i];
46
+ const n = functions.length;
42
47
 
43
- try {
44
- action();
45
- } catch (e) {
46
- console.error(`entity '${entity}' Timer action[${i}] exception:`, e);
47
- }
48
+ for (let i = 0; i < n; i++) {
49
+ const action = functions[i];
48
50
 
51
+ try {
52
+ action();
53
+ } catch (e) {
54
+ console.error(`entity '${entity}' Timer action[${i}] exception:`, e);
49
55
  }
50
56
 
51
- entityManager.sendEvent(entity, "timer-timeout", timer);
52
- if (++timer.ticks > timer.repeat) {
53
- //already performed too many cycles
54
- timer.active = false;
55
- return; //bail out
56
- }
57
+ }
57
58
 
58
- if (timeout === 0) {
59
- // prevent infinite loop
60
- break;
61
- }
59
+ dataset.sendEvent(entity, "timer-timeout", timer);
60
+ if (++timer.ticks > timer.repeat) {
61
+ //already performed too many cycles
62
+ timer.active = false;
63
+ return; //bail out
62
64
  }
63
- timer.counter = budget;
64
65
 
65
- });
66
+ if (timeout === 0) {
67
+ // prevent infinite loop
68
+ break;
69
+ }
70
+ }
71
+ timer.counter = budget;
72
+ }
73
+
74
+ update(timeDelta) {
75
+ const entityManager = this.entityManager;
76
+ const dataset = entityManager.dataset;
77
+
78
+ if (dataset === null) {
79
+ return;
80
+ }
81
+
82
+ this.#dataset = dataset;
83
+ this.#timeDelta = timeDelta;
84
+
85
+ dataset.traverseComponents(Timer, this.#visit, this);
66
86
 
67
87
  }
68
88
  }
@@ -1,5 +1,6 @@
1
1
  import { assert } from "../../../../core/assert.js";
2
2
  import Terrain from "../ecs/Terrain.js";
3
+ import { noop } from "../../../../core/function/Functions.js";
3
4
 
4
5
  /**
5
6
  *
@@ -7,7 +8,7 @@ import Terrain from "../ecs/Terrain.js";
7
8
  * @param {function(terrain:Terrain,entity:number)} [callback]
8
9
  * @returns {Terrain|null}
9
10
  */
10
- export function obtainTerrain(ecd, callback) {
11
+ export function obtainTerrain(ecd, callback=noop) {
11
12
  assert.notEqual(ecd, null, 'ecd is null');
12
13
  assert.notEqual(ecd, undefined, 'ecd is undefined');
13
14
 
@@ -15,9 +16,7 @@ export function obtainTerrain(ecd, callback) {
15
16
 
16
17
  const terrain = object.component;
17
18
 
18
- if (terrain !== null && typeof callback === "function") {
19
19
  callback(terrain, object.entity);
20
- }
21
20
 
22
21
  return terrain;
23
22
  }
@@ -359,7 +359,7 @@ GraphicsEngine.prototype.start = function () {
359
359
  /**
360
360
  * @see https://registry.khronos.org/webgl/specs/latest/1.0/#5.2
361
361
  */
362
- powerPreference:"high-performance"
362
+ powerPreference: "high-performance"
363
363
  };
364
364
 
365
365
  const webGLRenderer = this.renderer = new WebGLRenderer(rendererParameters);
@@ -490,8 +490,9 @@ GraphicsEngine.prototype.normalizeViewportPoint = function (input, result) {
490
490
 
491
491
  const viewportSize = this.viewport.size;
492
492
 
493
- const _x = input.x;
494
- const _y = input.y;
493
+ // shift by pixel center
494
+ const _x = input.x + 0.5;
495
+ const _y = input.y + 0.5;
495
496
 
496
497
  result.x = (_x / viewportSize.x) * 2 - 1;
497
498
  result.y = -(_y / viewportSize.y) * 2 + 1;
@@ -14,6 +14,7 @@ import { frustum_from_camera } from "./frustum_from_camera.js";
14
14
  import { invertQuaternionOrientation } from "./InvertQuaternionOrientation.js";
15
15
  import { v3_distance_above_plane } from "../../../../core/geom/v3_distance_above_plane.js";
16
16
  import { ProjectionType } from "./ProjectionType.js";
17
+ import { computePlaneRayIntersection } from "../../../../core/geom/Plane.js";
17
18
 
18
19
  /**
19
20
  * @class
@@ -222,6 +223,42 @@ export class Camera {
222
223
  return true;
223
224
  }
224
225
 
226
+ /**
227
+ *
228
+ * @param {Vector3} out
229
+ * @param {number} origin_x
230
+ * @param {number} origin_y
231
+ * @param {number} origin_z
232
+ * @param {number} direction_x
233
+ * @param {number} direction_y
234
+ * @param {number} direction_z
235
+ * @param {number} plane_index
236
+ */
237
+ rayPlaneIntersection(
238
+ out,
239
+ origin_x, origin_y, origin_z,
240
+ direction_x, direction_y, direction_z,
241
+ plane_index
242
+ ) {
243
+
244
+ assert.isNonNegativeInteger(plane_index, 'plane_index');
245
+ assert.lessThanOrEqual(plane_index, 5, `plane_index must be <= 5, was ${plane_index}`)
246
+
247
+ frustum_from_camera(this.object, scratch_frustum);
248
+
249
+ const plane = scratch_frustum.planes[plane_index];
250
+
251
+ assert.defined(plane, 'plane');
252
+
253
+ computePlaneRayIntersection(
254
+ out,
255
+ origin_x, origin_y, origin_z,
256
+ direction_x, direction_y, direction_z,
257
+ plane.normal.x, plane.normal.y, plane.normal.z,
258
+ plane.constant
259
+ );
260
+ }
261
+
225
262
  /**
226
263
  *
227
264
  * @param {number} x
@@ -0,0 +1,78 @@
1
+ import { assert } from "../../../../../core/assert.js";
2
+ import TopDownCameraController from "../topdown/TopDownCameraController.js";
3
+ import EntityBuilder from "../../../../ecs/EntityBuilder.js";
4
+ import { BehaviorComponent } from "../../../../intelligence/behavior/ecs/BehaviorComponent.js";
5
+ import { RepeatBehavior } from "../../../../intelligence/behavior/decorator/RepeatBehavior.js";
6
+ import { ActionBehavior } from "../../../../intelligence/behavior/primitive/ActionBehavior.js";
7
+ import { SerializationMetadata } from "../../../../ecs/components/SerializationMetadata.js";
8
+ import { clamp01 } from "../../../../../core/math/clamp01.js";
9
+ import { lerp } from "../../../../../core/math/lerp.js";
10
+
11
+ /**
12
+ *
13
+ * @param {TopDownCameraController} target
14
+ * @param {EntityComponentDataset} ecd
15
+ * @param {number} [responsiveness] higher value results in sharper movement when following
16
+ * @param {number} [inertia]
17
+ * @returns {{controller:TopDownCameraController, entity:EntityBuilder}}
18
+ */
19
+ export function setup_filtered_camera_controller({
20
+ target,
21
+ ecd,
22
+ responsiveness = 10,
23
+ inertia = 0.1
24
+ }) {
25
+
26
+ assert.defined(target, 'target');
27
+ assert.defined(ecd, 'ecd');
28
+ assert.isNumber(responsiveness, 'responsiveness');
29
+
30
+ const result = new TopDownCameraController();
31
+ result.copy(target);
32
+
33
+ const remembered_value = new TopDownCameraController();
34
+ remembered_value.copy(result);
35
+
36
+ const entityBuilder = new EntityBuilder();
37
+
38
+ entityBuilder
39
+ .add(BehaviorComponent.fromOne(
40
+ RepeatBehavior.from(
41
+ new ActionBehavior(time_delta => {
42
+ if (!remembered_value.equals(target)) {
43
+
44
+ // external change detected, override filtered result
45
+ result.copy(target);
46
+
47
+
48
+ } else {
49
+
50
+ const d = clamp01(1 - Math.exp(-responsiveness * time_delta));
51
+
52
+ target.distance = lerp(target.distance, result.distance, d);
53
+
54
+ target.pitch = result.pitch;
55
+ target.yaw = result.yaw;
56
+ target.roll = result.roll;
57
+
58
+ target.distanceMin = lerp(target.distanceMin, result.distanceMin, d);
59
+ target.distanceMax = lerp(target.distanceMax, result.distanceMax, d);
60
+
61
+ target.target.copy(result.target);
62
+ }
63
+
64
+
65
+ // remember current value
66
+ remembered_value.copy(target);
67
+ })
68
+ )
69
+ ))
70
+ .add(SerializationMetadata.Transient)
71
+ .build(ecd);
72
+
73
+
74
+ return {
75
+ controller: result,
76
+ entity: entityBuilder
77
+ };
78
+ }
@@ -0,0 +1,48 @@
1
+ Perfect panning implementation, based on [this](https://prideout.net/blog/perfect_panning/). Below is a copy of the original article
2
+
3
+ ---
4
+
5
+ Perfect Panning
6
+ ===============
7
+
8
+ This is a quick note about _through the lens_ camera control. The user drags the mouse around in their viewport, which causes the camera to slide around within the plane that is parallel to the near and far clipping planes.
9
+
10
+ This is super easy to implement, but it’s somewhat tricky if you want the user to feel like they’re grabbing something in the scene – especially if you consider the distortion caused by perspective projection.
11
+
12
+ For example, the following screenshots show a terrain that has been dragged towards the southwest. Since the mountain top appears to move faster than the coastline, how do you make the 3D scene “stick” to the mouse cursor?
13
+
14
+ ![](pan_a.png)
15
+
16
+ ![](pan_b.png)
17
+
18
+ To solve this problem the right way, it helps to leverage a raycaster, even if your actual rendering is done with OpenGL. I also find it helpful to pretend that the mouse cursor lives within the near or far planes of the viewing frustum.
19
+
20
+ So, if you cast a ray from the camera’s position to the cursor’s position, you’ll hit the point in the scene that needs to be stable during the pan. In the following diagram, we’ve panned the camera from **A** to **B** such that point **C** has remained stable. The cursor at the start of the pan was projected onto the far plane at **E**, and the cursor’s current position corresponds to point **D**.
21
+
22
+ ![](Perfect_Panning.png)
23
+
24
+ We wish to solve for **B**, given that **A**, **C**, and **E** are known quantities, assuming that your game engine can perform basic raycasts.
25
+
26
+ What’s less obvious is that **D** is also a known quantity (sort of). At any point in time during the drag, you can project the current mouse position onto the current far plane and get **D**. If you stash **A**, **C**, **E** at the start of the pan and never forget them, then you won’t accumulate error while the user pans.
27
+
28
+ The next thing to notice is that **ΔCAB** is similar to **ΔCED**. Applying the law of similar triangles yields:
29
+
30
+ |A-B| / |A-C| == |E-D| / |E-C|
31
+
32
+ This allows you to solve for **|A-B|**:
33
+
34
+ |A-B| = |A-C| * |E-D| / |E-C|
35
+
36
+ Thus, we have a “perfect panning” algorithm:
37
+
38
+ After every mouse-move event, translate the mouse-down camera position **A** in the direction of **(E-D)** by the distance **|A-B|**, as computed above.
39
+
40
+ It’s even easier to implement perfect zooming, when you wish to zoom in at the mouse cursor while keeping the scene’s geometry stable at the point. Simply move the camera along the ray that goes from the camera’s position to the cursor’s position. It might also be desirable to adjust the speed of the zoom according to the distance between the camera and the nearest point in the scene that the ray hits. Here’s a diagram just for completeness:
41
+
42
+ ![](Zooming.png)
43
+
44
+ * * *
45
+
46
+ For complete C code implementing the techniques described in this post, look for “map mode” in [par\_camera\_control.h](https://github.com/prideout/par/blob/master/par_camera_control.h), one of my single-file libraries on GitHub.
47
+
48
+ [![](https://prideout.net/assets/PublishedLogo.svg)](https://prideout.net)
@@ -0,0 +1,164 @@
1
+ import Vector3 from "../../../../../core/geom/Vector3.js";
2
+ import { obtainTerrain } from "../../../../ecs/terrain/util/obtainTerrain.js";
3
+ import { SurfacePoint3 } from "../../../../../core/geom/3d/SurfacePoint3.js";
4
+ import { make_ray_from_viewport_position } from "../../../make_ray_from_viewport_position.js";
5
+ import { CameraSystem } from "../CameraSystem.js";
6
+ import Vector2 from "../../../../../core/geom/Vector2.js";
7
+ import { Transform } from "../../../../ecs/transform/Transform.js";
8
+
9
+ const NEAR_PLANE_INDEX = 5;
10
+ const FAR_PLANE_INDEX = 4;
11
+
12
+ export class PerfectPanner {
13
+
14
+ /**
15
+ * Point in world space that was under our cursor when we started panning
16
+ * @type {Vector3}
17
+ */
18
+ #grab_world_reference = new Vector3();
19
+ #grab_near_projection = new Vector3();
20
+ #grab_far_projection = new Vector3();
21
+ /**
22
+ * Camera position at the start
23
+ * @type {Vector3}
24
+ */
25
+ #grab_eye_position = new Vector3();
26
+
27
+ /**
28
+ *
29
+ * @type {Engine|null}
30
+ */
31
+ #engine = null;
32
+
33
+
34
+ /**
35
+ *
36
+ * @type {Camera|null}
37
+ */
38
+ #camera = null;
39
+ /**
40
+ *
41
+ * @type {Transform|null}
42
+ */
43
+ #camera_transform = null;
44
+
45
+ /**
46
+ *
47
+ * Translation relative to starting point
48
+ * @type {Vector3}
49
+ */
50
+ #translation = new Vector3();
51
+
52
+ get engine() {
53
+ return this.#engine;
54
+ }
55
+
56
+ /**
57
+ *
58
+ * @param {Engine} v
59
+ */
60
+ set engine(v) {
61
+ this.#engine = v;
62
+ }
63
+
64
+ /**
65
+ * @returns {Vector3}
66
+ */
67
+ get translation() {
68
+ return this.#translation;
69
+ }
70
+
71
+ /**
72
+ *
73
+ * @param {Vector3} out
74
+ * @param {number} viewport_x
75
+ * @param {number} viewport_y
76
+ * @param {number} plane
77
+ */
78
+ #projection_viewport_onto_plane(out, viewport_x, viewport_y, plane) {
79
+ const engine = this.#engine;
80
+
81
+ const ray = make_ray_from_viewport_position(engine, new Vector2(viewport_x, viewport_y));
82
+
83
+ this.#camera.rayPlaneIntersection(
84
+ out,
85
+ ray.origin.x, ray.origin.y, ray.origin.z,
86
+ ray.direction.x, ray.direction.y, ray.direction.z,
87
+ plane
88
+ );
89
+ }
90
+
91
+ /**
92
+ *
93
+ * @param {Vector2} screen_position
94
+ */
95
+ update(screen_position) {
96
+
97
+ const C = this.#grab_world_reference;
98
+ const A = this.#grab_eye_position;
99
+ const E = this.#grab_far_projection;
100
+
101
+ //
102
+ const AC = new Vector3();
103
+ AC.subVectors(A, C);
104
+
105
+ const EC = new Vector3();
106
+ EC.subVectors(E, C);
107
+
108
+
109
+ const D = new Vector3();
110
+
111
+ this.#projection_viewport_onto_plane(D, screen_position.x, screen_position.y, FAR_PLANE_INDEX);
112
+
113
+ const translation = new Vector3();
114
+ translation.subVectors(E, D);
115
+ translation.multiplyScalar(AC.length() / EC.length());
116
+
117
+ this.#translation.copy(translation);
118
+ }
119
+
120
+ start(screen_position) {
121
+
122
+ // obtain reference point
123
+
124
+ const engine = this.#engine;
125
+
126
+ const ecd = engine.entityManager.dataset;
127
+
128
+ if (ecd === null) {
129
+ throw new Error('No dataset');
130
+ }
131
+
132
+ // bind camera
133
+ const camera = CameraSystem.getFirstActiveCamera(ecd);
134
+ this.#camera = camera.component;
135
+ this.#camera_transform = ecd.getComponent(camera.entity, Transform);
136
+
137
+
138
+ this.#projection_viewport_onto_plane(this.#grab_near_projection, screen_position.x, screen_position.y, NEAR_PLANE_INDEX);
139
+ this.#projection_viewport_onto_plane(this.#grab_far_projection, screen_position.x, screen_position.y, FAR_PLANE_INDEX);
140
+
141
+ const ray = make_ray_from_viewport_position(engine, screen_position);
142
+
143
+ const terrain = obtainTerrain(ecd);
144
+
145
+ const hit = new SurfacePoint3();
146
+
147
+ if (terrain.raycastFirstSync(hit,
148
+ ray.origin.x, ray.origin.y, ray.origin.z,
149
+ ray.direction.x, ray.direction.y, ray.direction.z
150
+ )) {
151
+ // got terrain hit
152
+ this.#grab_world_reference.copy(hit.position);
153
+ }
154
+
155
+ this.#grab_eye_position.copy(this.#camera_transform.position);
156
+
157
+ this.#translation.set(0, 0, 0);
158
+ }
159
+
160
+ end() {
161
+
162
+ }
163
+
164
+ }
@@ -424,7 +424,7 @@ export class MeshSystem extends System {
424
424
  }
425
425
  }
426
426
 
427
- em.getComponentAsync(entity, Transform, component.internalApplyTransform, component);
427
+ dataset.getComponentAsync(entity, Transform, component.internalApplyTransform, component);
428
428
  }
429
429
 
430
430
  update(timeDelta) {
@@ -0,0 +1,28 @@
1
+ import Vector2 from "../../core/geom/Vector2.js";
2
+ import Vector3 from "../../core/geom/Vector3.js";
3
+
4
+ /**
5
+ *
6
+ * @param {Engine} engine
7
+ * @param {Vector2} [position] if not specified, current pointer position will be used
8
+ * @returns {{origin:Vector3, direction:Vector3}}
9
+ */
10
+ export function make_ray_from_viewport_position(engine, position) {
11
+
12
+ let _p = position;
13
+
14
+ if (position === undefined) {
15
+ _p = engine.devices.pointer.position;
16
+ }
17
+
18
+ const ndc = new Vector2();
19
+
20
+ engine.graphics.normalizeViewportPoint(_p, ndc);
21
+
22
+ const origin = new Vector3();
23
+ const direction = new Vector3();
24
+
25
+ engine.graphics.viewportProjectionRay(ndc.x, ndc.y, origin, direction);
26
+
27
+ return { origin, direction };
28
+ }
@@ -8,6 +8,7 @@ import { assert } from "../../../core/assert.js";
8
8
  import { MouseEvents } from "./events/MouseEvents.js";
9
9
  import { TouchEvents } from "./events/TouchEvents.js";
10
10
  import { sign } from "../../../core/math/sign.js";
11
+ import { InputDeviceSwitch } from "./InputDeviceSwitch.js";
11
12
 
12
13
  /**
13
14
  * @see https://developer.mozilla.org/en-US/docs/Web/API/MouseEvent/buttons
@@ -371,6 +372,13 @@ export class PointerDevice {
371
372
  */
372
373
  isRunning = false;
373
374
 
375
+ /**
376
+ * The MouseEvent.buttons is a 32bit field, which means we can encode up to 32 buttons
377
+ * @readonly
378
+ * @type {InputDeviceSwitch[]}
379
+ */
380
+ buttons = new Array(32);
381
+
374
382
  /**
375
383
  *
376
384
  * @param {EventTarget} domElement html element
@@ -379,6 +387,10 @@ export class PointerDevice {
379
387
  constructor(domElement) {
380
388
  assert.defined(domElement, "domElement");
381
389
 
390
+ // initialize buttons
391
+ for (let i = 0; i < this.buttons.length; i++) {
392
+ this.buttons[i] = new InputDeviceSwitch();
393
+ }
382
394
 
383
395
  /**
384
396
  *
@@ -420,6 +432,16 @@ export class PointerDevice {
420
432
  #eventHandlerMouseDown = (event) => {
421
433
  this.readPointerPositionFromEvent(this.position, event);
422
434
  this.on.down.send2(this.position, event);
435
+
436
+ // update button state and dispatch specific signal
437
+ const button_index = event.button;
438
+
439
+ const button = this.buttons[button_index];
440
+
441
+ if (button !== undefined) {
442
+ button.is_down = true;
443
+ button.down.send0();
444
+ }
423
445
  }
424
446
 
425
447
  /**
@@ -447,6 +469,16 @@ export class PointerDevice {
447
469
  #eventHandlerGlobalMouseUp = (event) => {
448
470
  this.readPointerPositionFromEvent(this.position, event);
449
471
  this.#globalUp.send2(this.position, event);
472
+
473
+ // update button state and dispatch specific signal
474
+ const button_index = event.button;
475
+
476
+ const button = this.buttons[button_index];
477
+
478
+ if (button !== undefined) {
479
+ button.is_down = false;
480
+ button.up.send0();
481
+ }
450
482
  }
451
483
 
452
484
  /**