@geoblocks/cesium-walk 0.0.0-stage → 0.1.0

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.
package/LICENSE ADDED
@@ -0,0 +1,29 @@
1
+ BSD 3-Clause License
2
+
3
+ Copyright (c) 2024-present, camptocamp SA
4
+ All rights reserved.
5
+
6
+ Redistribution and use in source and binary forms, with or without
7
+ modification, are permitted provided that the following conditions are met:
8
+
9
+ 1. Redistributions of source code must retain the above copyright notice, this
10
+ list of conditions and the following disclaimer.
11
+
12
+ 2. Redistributions in binary form must reproduce the above copyright notice,
13
+ this list of conditions and the following disclaimer in the documentation
14
+ and/or other materials provided with the distribution.
15
+
16
+ 3. Neither the name of the copyright holder nor the names of its
17
+ contributors may be used to endorse or promote products derived from
18
+ this software without specific prior written permission.
19
+
20
+ THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS"
21
+ AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE
22
+ IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE
23
+ DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE
24
+ FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL
25
+ DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR
26
+ SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER
27
+ CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY,
28
+ OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE
29
+ OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
package/README.md CHANGED
@@ -1,3 +1,109 @@
1
- # Temporary Holding Version
1
+ # Cesium Walk
2
2
 
3
- This version is a temporary placeholder for this package. An operational version to replace this has been submitted for review and is awaiting a staged release.
3
+ This package provides a camera mode for CesiumJS that lets the user walk around the scene
4
+ with the keyboard or a gamepad. The camera is kept at a fixed height above the terrain.
5
+
6
+ ## Controls
7
+
8
+ | Action | Keyboard | Gamepad (standard mapping) |
9
+ |---|---|---|
10
+ | Walk | W, A, S, D or the arrows | left stick |
11
+ | Sprint, forward | Shift, held | Cross / A (button 0), held |
12
+ | Jump | Space | Square / X (button 2) |
13
+
14
+ The keys are matched by their position (`event.code`), so other keyboard layouts walk with the
15
+ keys in the same place.
16
+
17
+ These are the default bindings, `WALK_BINDINGS`, read with [cesium-input](../cesium-input). The walker
18
+ reads its intent from `walkMode.input` on each tick, `{move: {x, y}, sprint, jump}`, so the controls can
19
+ be remapped, or come from anywhere else, while the walk is not active:
20
+
21
+ ```javascript
22
+ import Controls from '@geoblocks/cesium-input';
23
+ import CesiumWalk, {WALK_BINDINGS} from '@geoblocks/cesium-walk';
24
+
25
+ // J jumps instead of Space
26
+ walkMode.input = new Controls({...WALK_BINDINGS, jump: {type: 'button', keys: ['KeyJ'], buttons: [2]}});
27
+ // touch controls, an AI, a replay: `move` no longer than 1, x right and y forward
28
+ walkMode.input = {read: () => ({move: touchStick.vector, sprint: false, jump: touchButton.tapped})};
29
+ ```
30
+
31
+ ## Installation
32
+
33
+ ```bash
34
+ npm i --save @geoblocks/cesium-walk
35
+ ```
36
+
37
+ ## Demo
38
+
39
+ [Demo](https://geoblocks.github.io/cesium-helpers/cesium-walk.html)
40
+
41
+ ## Usage
42
+
43
+ ```javascript
44
+ import {Viewer} from '@cesium/engine';
45
+ import CesiumWalk from '@geoblocks/cesium-walk';
46
+
47
+ const viewer = new Viewer(...);
48
+ // speed in meters per second, height above the terrain in meters
49
+ const walkMode = new CesiumWalk(viewer, 1.6, 2.0);
50
+
51
+ // ...
52
+ walkMode.active = true;
53
+ ```
54
+
55
+ `walkMode.sprintSpeed` (default three times the speed) and `walkMode.jumpSpeed` (default 4 m/s)
56
+ can be changed at any time, as can these, all off or as on Earth by default:
57
+
58
+ - `walkMode.gravity`, in m/s² (default 9.81): the Moon's 1.62, or a game's snappier jumps.
59
+ - `walkMode.acceleration`, in m/s² (default `Infinity`, at once): how fast the walker reaches its
60
+ speed, stops and turns.
61
+ - `walkMode.stepHeight`, in meters (default `Infinity`, always followed): the deepest drop of the
62
+ ground stepped down; off a deeper one, a ledge or a roof, the walker falls.
63
+
64
+ In the air, jumping or falling, the walker keeps its height when the ground changes under it, and lands
65
+ on the ground it meets.
66
+ `walkMode.sprinting` tells whether the walker is sprinting. Set `walkMode.headBob = true` for the camera to bob at each step and dip on landing, as in GTA 5's
67
+ first person. Set `walkMode.collision = true` for the walker to stop at the obstacles in view at eye
68
+ height (buildings, trees, any opaque 3D tiles or primitives) and to slide along them; walking backward or
69
+ sideways, the obstacles out of view do not stop it. Translucent primitives, of an alpha below 0.995, are
70
+ not obstacles. The obstacles come from [cesium-obstacles](../cesium-obstacles), read in the depth
71
+ of the last frame, which makes the CPU wait for the GPU. For a world known on the CPU, set
72
+ `walkMode.obstacleAhead` to a function returning the obstacle ahead in a direction, `{point, normal}`
73
+ with the normal toward the walker, or `undefined`: a ray cast stops the walker in every direction,
74
+ without waiting.
75
+
76
+ The walker stands on the terrain by default. To walk on something else, a model's floors, stairs and
77
+ tunnels, set `walkMode.groundHeight` to a function returning the ground height in meters above the
78
+ ellipsoid at a `Cartographic`, or `undefined` while unknown.
79
+
80
+ ## Walking on a mesh
81
+
82
+ `@geoblocks/cesium-walk/walk-mesh.js` makes triangles you know on the CPU, a building's floors, stairs and
83
+ walls, into both what is seen and what is walked on, with no wait for the GPU:
84
+
85
+ ```javascript
86
+ import CesiumWalk from '@geoblocks/cesium-walk';
87
+ import {createWalkMesh, walkOn} from '@geoblocks/cesium-walk/walk-mesh.js';
88
+
89
+ import {Group} from '@geoblocks/cesium-walk/mesh-builder.js';
90
+
91
+ // groups of triangles of one color each, in meters, z up, in the frame of a model matrix
92
+ const floor = new Group([0.8, 0.8, 0.8, 1]);
93
+ floor.polygon([[0, 0], [10, 0], [10, 6], [0, 6]], [], () => 0);
94
+ const walls = new Group([0.9, 0.9, 0.85, 1]);
95
+ walls.box(0, 0, 0, 10, 0.2, 2.5);
96
+ const mesh = await createWalkMesh([floor.walkGroup, walls.walkGroup], modelMatrix);
97
+ viewer.scene.primitives.add(mesh.primitive);
98
+
99
+ const walkMode = new CesiumWalk(viewer);
100
+ // stand on the highest floor below the knees, climb steps up to 0.4 m, stop at the walls 0.5 m ahead
101
+ walkOn(walkMode, mesh, {eye: 1.6, step: 0.4, reach: 0.5});
102
+ ```
103
+
104
+ A group can be walked on and not seen (`show: false`), seen and not walked on (`collide: false`), cast no
105
+ shadow (`shadows: false`), glow (`emissive`), carry texture coordinates for a custom shader (`uv`,
106
+ `customShader`) or a baked light's atlas (`lightmap`). The faces flatter than 60° are floors, the others
107
+ walls. `Group` (`mesh-builder.js`) builds the groups face by face: `face`, `polygon` (with holes), `sides`, `box`;
108
+ `charts: true` records each face, to lay them out in an atlas. `mesh.floorBelow(x, y, z)` and `mesh.raycast(origin, direction, maxDistance)` answer in the
109
+ mesh's frame and on the globe.
package/cesium-walk.js ADDED
@@ -0,0 +1,384 @@
1
+ import {Cartesian3, Math as CesiumMath} from "@cesium/core";
2
+ import Controls from "@geoblocks/cesium-input";
3
+ import {ObstacleProbe, blockedStep} from "@geoblocks/cesium-obstacles";
4
+
5
+ // in seconds: a frame after a backgrounded tab must not walk or fall far
6
+ const MAX_DELTA = 0.1;
7
+ // the head bob: a dip of DIP meters at each step of STEP_LENGTH meters, twice as deep when sprinting,
8
+ // and of LANDING meters when landing from a jump; both settle in about BOB_TIME seconds
9
+ const STEP_LENGTH = 0.8;
10
+ const DIP = 0.03;
11
+ const LANDING = 0.15;
12
+ const BOB_TIME = 0.15;
13
+ // in meters: nearer its height, the camera stays, so that a walker standing still renders no frame
14
+ const SETTLED = 1e-3;
15
+
16
+ // the default bindings: W, A, S, D or the arrows to walk, Shift to sprint and Space to jump; on a gamepad, the
17
+ // left stick, at any speed and in any direction, Cross to sprint and Square to jump, as in GTA 5. Keys by
18
+ // event.code, so that other keyboard layouts walk with the keys in the same place
19
+ /** @satisfies {Record<string, import("@geoblocks/cesium-input").Binding>} */
20
+ export const WALK_BINDINGS = {
21
+ move: {type: "vector", keys: {up: ["KeyW", "ArrowUp"], down: ["KeyS", "ArrowDown"], left: ["KeyA", "ArrowLeft"], right: ["KeyD", "ArrowRight"]}, stick: "left"},
22
+ sprint: {type: "button", keys: ["ShiftLeft", "ShiftRight"], buttons: [0]},
23
+ jump: {type: "button", keys: ["Space"], buttons: [2]},
24
+ };
25
+
26
+ /** @typedef {{move: {x: number, y: number}, sprint: boolean, jump: boolean}} WalkIntent */
27
+
28
+ // the camera controller's settings off while walking: its navigation, and its collision detection, which lifts
29
+ // the camera onto the 3D tiles under it, a roof or a coarse tile still loading over the street, whenever the
30
+ // camera moves; the walk keeps its own height
31
+ const CONTROLLER_SETTINGS = /** @type {const} */ (["enableTranslate", "enableZoom", "enableRotate", "enableTilt", "enableCollisionDetection"]);
32
+
33
+ const normalScratch = new Cartesian3();
34
+ const forwardScratch = new Cartesian3();
35
+ const stepScratch = new Cartesian3();
36
+ const allowedScratch = new Cartesian3();
37
+ const targetScratch = new Cartesian3();
38
+ const changeScratch = new Cartesian3();
39
+
40
+ /**
41
+ * One step of a jump or a fall under gravity.
42
+ * @param {number} offset Height above the walk height, in meters.
43
+ * @param {number} velocity Upward speed, in meters per second.
44
+ * @param {number} dt Seconds.
45
+ * @param {number} gravity In meters per second squared.
46
+ * @return {[number, number]} The next offset and velocity, [0, 0] once landed.
47
+ */
48
+ function jumpStep(offset, velocity, dt, gravity) {
49
+ const next = velocity - gravity * dt;
50
+ const height = offset + ((velocity + next) / 2) * dt;
51
+ return height <= 0 && next <= 0 ? [0, 0] : [height, next];
52
+ }
53
+
54
+ /**
55
+ * Whether a jump starts: on a new press only, so that holding the button does not jump again on landing.
56
+ * @param {boolean} pressed
57
+ * @param {boolean} wasPressed
58
+ * @param {boolean} grounded
59
+ * @return {boolean}
60
+ */
61
+ function jumpStart(pressed, wasPressed, grounded) {
62
+ return pressed && !wasPressed && grounded;
63
+ }
64
+
65
+ export default class CesiumWalk {
66
+ /**
67
+ * @param {import('@cesium/engine').CesiumWidget} viewer
68
+ * @param {number} [speed=1.6] Walk speed in meters per second.
69
+ * @param {number} [height=2.0] Height of the camera above the terrain.
70
+ */
71
+ constructor(viewer, speed = 1.6, height = 2.0) {
72
+ /**
73
+ * @type {import('@cesium/engine').CesiumWidget}
74
+ */
75
+ this.viewer = viewer;
76
+
77
+ /**
78
+ * @type {number}
79
+ */
80
+ this.speed = speed;
81
+
82
+ /**
83
+ * Speed while sprinting, in meters per second.
84
+ * @type {number}
85
+ */
86
+ this.sprintSpeed = 3 * speed;
87
+
88
+ /**
89
+ * Upward speed at the start of a jump, in meters per second.
90
+ * @type {number}
91
+ */
92
+ this.jumpSpeed = 4;
93
+
94
+ /**
95
+ * The gravity of jumps and falls, in meters per second squared.
96
+ * @type {number}
97
+ */
98
+ this.gravity = 9.81;
99
+
100
+ /**
101
+ * How fast the walker reaches its speed, stops and turns, in meters per second squared; at once by
102
+ * default.
103
+ * @type {number}
104
+ */
105
+ this.acceleration = Infinity;
106
+
107
+ /**
108
+ * The deepest drop of the ground the walker steps down, in meters: deeper, off a ledge or a roof, it
109
+ * falls. By default it follows the ground whatever the drop.
110
+ * @type {number}
111
+ */
112
+ this.stepHeight = Infinity;
113
+
114
+ /**
115
+ * @type {number}
116
+ */
117
+ this.height = height;
118
+
119
+ /**
120
+ * @type {boolean}
121
+ */
122
+ this.active_ = false;
123
+
124
+ /**
125
+ * @type {number}
126
+ */
127
+ this.lastTick_ = 0;
128
+
129
+ /**
130
+ * Where the walker's intent comes from, read on each tick: {move: {x, y}, sprint, jump}, move no longer
131
+ * than 1, x right and y forward. The keyboard and the first gamepad by default, with WALK_BINDINGS; any
132
+ * object with a read() method otherwise: touch controls, an AI, a replay. The walk turns its active on
133
+ * and off with its own: replace it while the walk is not active.
134
+ * @type {{read(): WalkIntent, active?: boolean}}
135
+ */
136
+ this.input = new Controls(WALK_BINDINGS);
137
+
138
+ // the walker's velocity, level, on the globe
139
+ this.velocity_ = new Cartesian3();
140
+
141
+ // the ground under the walker on the last tick, which it keeps its height above while in the air
142
+ /** @type {number | undefined} */
143
+ this.ground_ = undefined;
144
+
145
+ // the jump or the fall: height above the walk height and upward speed; whether the jump input was down
146
+ // last tick
147
+ this.jumpOffset_ = 0;
148
+ this.jumpVelocity_ = 0;
149
+ this.jumpHeld_ = false;
150
+
151
+ /**
152
+ * Whether the camera bobs at each step and dips on landing, as in GTA 5's first person.
153
+ * @type {boolean}
154
+ */
155
+ this.headBob = false;
156
+
157
+ /**
158
+ * Whether the walker stops at the obstacles in view at eye height: opaque 3D tiles or primitives.
159
+ * @type {boolean}
160
+ */
161
+ this.collision = false;
162
+
163
+ this.obstacleProbe_ = new ObstacleProbe(viewer.scene);
164
+
165
+ /**
166
+ * The obstacle ahead of the walker in a direction, a point of its plane and its normal toward the walker,
167
+ * if any; asked for each step while collision is on. By default the obstacles in view, read in the depth
168
+ * of the last frame, which waits for the GPU; for a world known on the CPU, a ray cast in every direction.
169
+ * @type {(toward: Cartesian3, key: string) => import('@geoblocks/cesium-obstacles').Obstacle | undefined}
170
+ */
171
+ this.obstacleAhead = (toward, key) => this.obstacleProbe_.ahead(toward, key);
172
+
173
+ /**
174
+ * The height of the ground under a position, in meters above the ellipsoid, or undefined when unknown
175
+ * yet. The terrain by default; a model's floors otherwise, for a walker on several levels.
176
+ * @type {(position: import('@cesium/core').Cartographic) => number | undefined}
177
+ */
178
+ this.groundHeight = position => viewer.scene.globe.getHeight(position);
179
+
180
+ // whether the walker sprinted on the last tick
181
+ this.sprinting_ = false;
182
+
183
+ // the camera controller's settings before walking
184
+ /** @type {Partial<Record<typeof CONTROLLER_SETTINGS[number], boolean>>} */
185
+ this.controllerSettings_ = {};
186
+
187
+ // the head bob: how deep, 0 standing to 2 sprinting, along the steps; the landing dip, 0 to 1
188
+ this.bob_ = 0;
189
+ this.stepPhase_ = 0;
190
+ this.landing_ = 0;
191
+
192
+ this.handleTickFunction_ = this.handleTick_.bind(this);
193
+ }
194
+
195
+ get active() {
196
+ return this.active_;
197
+ }
198
+
199
+ /**
200
+ * Whether the walker is sprinting.
201
+ * @return {boolean}
202
+ */
203
+ get sprinting() {
204
+ return this.active_ && this.sprinting_;
205
+ }
206
+
207
+ set active(active) {
208
+ if (active === this.active_) {
209
+ return;
210
+ }
211
+ this.active_ = active;
212
+ const controller = this.viewer.scene.screenSpaceCameraController;
213
+ if (this.active_) {
214
+ for (const setting of CONTROLLER_SETTINGS) {
215
+ this.controllerSettings_[setting] = controller[setting];
216
+ controller[setting] = false;
217
+ }
218
+ this.lastTick_ = performance.now();
219
+ this.input.active = true;
220
+ this.viewer.clock.onTick.addEventListener(this.handleTickFunction_);
221
+ this.clampCameraToTerrain_();
222
+ } else {
223
+ this.input.active = false;
224
+ this.viewer.clock.onTick.removeEventListener(this.handleTickFunction_);
225
+ this.jumpOffset_ = 0;
226
+ this.jumpVelocity_ = 0;
227
+ this.ground_ = undefined;
228
+ Cartesian3.clone(Cartesian3.ZERO, this.velocity_);
229
+ this.bob_ = 0;
230
+ this.landing_ = 0;
231
+ Object.assign(controller, this.controllerSettings_);
232
+ }
233
+ }
234
+
235
+ handleTick_() {
236
+ const timestamp = performance.now();
237
+ const deltaTime = Math.min((timestamp - this.lastTick_) / 1000, MAX_DELTA);
238
+ this.lastTick_ = timestamp;
239
+
240
+ const {move, sprint, jump} = this.input.read();
241
+ let forward = move.y;
242
+ let right = move.x;
243
+ // walking diagonally is not faster
244
+ const magnitude = Math.hypot(forward, right);
245
+ if (magnitude > 1) {
246
+ forward /= magnitude;
247
+ right /= magnitude;
248
+ }
249
+
250
+ const grounded = this.jumpOffset_ === 0 && this.jumpVelocity_ === 0;
251
+ if (jumpStart(jump, this.jumpHeld_, grounded)) {
252
+ this.jumpVelocity_ = this.jumpSpeed;
253
+ }
254
+ this.jumpHeld_ = jump;
255
+ const airborne = this.jumpOffset_ > 0 || this.jumpVelocity_ > 0;
256
+
257
+ const moving = forward !== 0 || right !== 0;
258
+ // forward, no more sideways than diagonally, as in GTA 5
259
+ this.sprinting_ = sprint && forward > 0 && forward >= Math.abs(right);
260
+ const stopped = Cartesian3.equals(this.velocity_, Cartesian3.ZERO);
261
+ if (!moving && stopped && !airborne && this.bob_ === 0 && this.landing_ === 0) {
262
+ // standing: only the terrain refining under the walker moves it
263
+ this.clampCameraToTerrain_();
264
+ return;
265
+ }
266
+
267
+ const camera = this.viewer.camera;
268
+ const speed = this.sprinting_ ? this.sprintSpeed : this.speed;
269
+ // walk along the view direction projected onto the ground plane,
270
+ // so that looking down does not push the camera into the terrain
271
+ const normal = this.surfaceNormal_();
272
+ const along = Cartesian3.multiplyByScalar(
273
+ normal,
274
+ Cartesian3.dot(camera.direction, normal),
275
+ forwardScratch
276
+ );
277
+ Cartesian3.subtract(camera.direction, along, along);
278
+ const level = Cartesian3.magnitude(along) > CesiumMath.EPSILON6;
279
+ if (level) {
280
+ Cartesian3.normalize(along, along);
281
+ }
282
+ // the velocity, toward the intended one as fast as the acceleration allows
283
+ const target = Cartesian3.multiplyByScalar(camera.right, right * speed, targetScratch);
284
+ if (level) {
285
+ Cartesian3.add(target, Cartesian3.multiplyByScalar(along, forward * speed, changeScratch), target);
286
+ }
287
+ const change = Cartesian3.subtract(target, this.velocity_, changeScratch);
288
+ const needed = Cartesian3.magnitude(change);
289
+ const allowed = this.acceleration * deltaTime;
290
+ if (needed <= allowed) {
291
+ Cartesian3.clone(target, this.velocity_);
292
+ } else {
293
+ Cartesian3.add(this.velocity_, Cartesian3.multiplyByScalar(change, allowed / needed, change), this.velocity_);
294
+ }
295
+ const forwardSpeed = level ? Cartesian3.dot(this.velocity_, along) : 0;
296
+ const rightSpeed = Cartesian3.dot(this.velocity_, camera.right);
297
+ if (forwardSpeed !== 0) {
298
+ this.step_(along, forwardSpeed * deltaTime, "forward");
299
+ }
300
+ if (rightSpeed !== 0) {
301
+ this.step_(camera.right, rightSpeed * deltaTime, "right");
302
+ }
303
+ if (airborne) {
304
+ [this.jumpOffset_, this.jumpVelocity_] = jumpStep(this.jumpOffset_, this.jumpVelocity_, deltaTime, this.gravity);
305
+ if (this.headBob && this.jumpOffset_ === 0) {
306
+ this.landing_ = 1;
307
+ }
308
+ }
309
+ // the head bob, which settles once turned off
310
+ const ease = 1 - Math.exp(-deltaTime / BOB_TIME);
311
+ const bob = this.headBob && moving && !airborne ? (this.sprinting_ ? 2 : 1) : 0;
312
+ this.bob_ += (bob - this.bob_) * ease;
313
+ this.landing_ -= this.landing_ * ease;
314
+ // the last thousandth in one step, so that the camera stops moving
315
+ if (bob === 0 && this.bob_ < 1e-3) {
316
+ this.bob_ = 0;
317
+ }
318
+ if (this.landing_ < 1e-3) {
319
+ this.landing_ = 0;
320
+ }
321
+ this.stepPhase_ += (Cartesian3.magnitude(this.velocity_) * deltaTime / STEP_LENGTH) * Math.PI;
322
+ this.clampCameraToTerrain_();
323
+ }
324
+
325
+ /**
326
+ * Moves the camera a step, stopping at obstacles or sliding along them.
327
+ * @param {Cartesian3} direction The unit direction of the step, level.
328
+ * @param {number} step The length of the step, negative backward, in meters.
329
+ * @param {string} kind Which steps these are, for the obstacle looked for last time.
330
+ */
331
+ step_(direction, step, kind) {
332
+ const camera = this.viewer.camera;
333
+ const toward = Cartesian3.multiplyByScalar(direction, Math.sign(step), stepScratch);
334
+ const obstacle = this.collision ? this.obstacleAhead(toward, `${kind}${Math.sign(step)}`) : undefined;
335
+ const allowed = blockedStep(toward, Math.abs(step), camera.positionWC, obstacle, allowedScratch);
336
+ if (allowed.length > 0) {
337
+ camera.move(allowed.direction, allowed.length);
338
+ }
339
+ }
340
+
341
+ clampCameraToTerrain_() {
342
+ const camera = this.viewer.camera;
343
+ const terrainHeight = this.groundHeight(camera.positionCartographic);
344
+ if (terrainHeight === undefined) {
345
+ // ground not loaded yet at this position
346
+ return;
347
+ }
348
+ if (this.ground_ !== undefined) {
349
+ // in the air, or off a drop deeper than a step, the walker keeps its height and falls the rest
350
+ const drop = this.ground_ - terrainHeight;
351
+ if (this.jumpOffset_ > 0 || this.jumpVelocity_ !== 0 || drop > this.stepHeight) {
352
+ this.jumpOffset_ += drop;
353
+ if (this.jumpOffset_ <= 0) {
354
+ // on the ground again: landed, or lifted by the ground while still going up
355
+ this.jumpOffset_ = 0;
356
+ if (this.jumpVelocity_ <= 0) {
357
+ this.jumpVelocity_ = 0;
358
+ if (this.headBob) {
359
+ this.landing_ = 1;
360
+ }
361
+ }
362
+ }
363
+ }
364
+ }
365
+ this.ground_ = terrainHeight;
366
+ const cameraHeight = camera.positionCartographic.height;
367
+
368
+ // move along the surface normal, not the camera up vector, which is
369
+ // tilted when the camera is pitched
370
+ const bob = DIP * this.bob_ * Math.abs(Math.sin(this.stepPhase_)) + LANDING * this.landing_;
371
+ const offset = terrainHeight + this.height + this.jumpOffset_ - bob - cameraHeight;
372
+ if (Math.abs(offset) > SETTLED) {
373
+ camera.move(this.surfaceNormal_(), offset);
374
+ }
375
+ }
376
+
377
+ /**
378
+ * @return {import('@cesium/core').Cartesian3} The ellipsoid surface normal at the camera position.
379
+ */
380
+ surfaceNormal_() {
381
+ const scene = this.viewer.scene;
382
+ return scene.globe.ellipsoid.geodeticSurfaceNormal(scene.camera.position, normalScratch);
383
+ }
384
+ }
@@ -0,0 +1,109 @@
1
+ import {Cartesian2, PolygonPipeline} from "@cesium/core";
2
+
3
+ // Triangles of one color, built face by face, for createWalkMesh (walk-mesh.js).
4
+
5
+ /**
6
+ * @typedef {object} Chart A face's place among a group's vertices, for laying it out in an atlas.
7
+ * @property {number} start Its first vertex.
8
+ * @property {number} count Its vertices.
9
+ * @property {number[]} normal Its normal, not of unit length.
10
+ */
11
+
12
+ /** The normal of a flat ring of corners, by Newell's method, of any length. */
13
+ function newell(/** @type {number[][]} */ corners) {
14
+ const n = [0, 0, 0];
15
+ corners.forEach(([x, y, z], i) => {
16
+ const [nx, ny, nz] = corners[(i + 1) % corners.length];
17
+ n[0] += (y - ny) * (z + nz);
18
+ n[1] += (z - nz) * (x + nx);
19
+ n[2] += (x - nx) * (y + ny);
20
+ });
21
+ return n;
22
+ }
23
+
24
+ export class Group {
25
+ /**
26
+ * Triangles of one color, in meters, z up.
27
+ * @param {number[]} color Linear, and its alpha.
28
+ * @param {{charts?: boolean} & Record<string, any>} [options] The walk group's (show, collide, shadows, emissive,
29
+ * uv, customShader, ...), passed on; charts: each face recorded, for an atlas.
30
+ */
31
+ constructor(color, {charts = false, ...options} = {}) {
32
+ this.color = color;
33
+ this.options = options;
34
+ /** @type {number[]} */
35
+ this.positions = [];
36
+ /** @type {number[]} */
37
+ this.indices = [];
38
+ /** @type {Chart[] | undefined} */
39
+ this.charts = charts ? [] : undefined;
40
+ }
41
+
42
+ /**
43
+ * A planar convex face, its corners in order.
44
+ * @param {...number[]} corners [x, y, z]
45
+ */
46
+ face(...corners) {
47
+ const first = this.positions.length / 3;
48
+ corners.forEach((corner) => this.positions.push(...corner));
49
+ for (let i = 1; i + 1 < corners.length; i++) {
50
+ this.indices.push(first, first + i, first + i + 1);
51
+ }
52
+ this.charts?.push({start: first, count: corners.length, normal: newell(corners)});
53
+ }
54
+
55
+ /**
56
+ * A flat polygon with holes, its height at each corner.
57
+ * @param {number[][]} outline [x, y]
58
+ * @param {number[][][]} holes
59
+ * @param {(x: number, y: number) => number} z
60
+ */
61
+ polygon(outline, holes, z) {
62
+ const points = [outline, ...holes].flat();
63
+ /** @type {number[]} */
64
+ const starts = [];
65
+ let start = outline.length;
66
+ for (const hole of holes) {
67
+ starts.push(start);
68
+ start += hole.length;
69
+ }
70
+ const first = this.positions.length / 3;
71
+ points.forEach(([x, y]) => this.positions.push(x, y, z(x, y)));
72
+ PolygonPipeline.triangulate(points.map(([x, y]) => new Cartesian2(x, y)), starts).forEach((i) => this.indices.push(first + i));
73
+ this.charts?.push({start: first, count: points.length, normal: newell(outline.map(([x, y]) => [x, y, z(x, y)]))});
74
+ }
75
+
76
+ /**
77
+ * The faces of a ring between two heights.
78
+ * @param {number[][]} ring [x, y]
79
+ * @param {number} z0
80
+ * @param {number} z1
81
+ */
82
+ sides(ring, z0, z1) {
83
+ ring.forEach((a, i) => {
84
+ const b = ring[(i + 1) % ring.length];
85
+ this.face([...a, z0], [...b, z0], [...b, z1], [...a, z1]);
86
+ });
87
+ }
88
+
89
+ /**
90
+ * An axis-aligned box.
91
+ * @param {number} x0
92
+ * @param {number} y0
93
+ * @param {number} z0
94
+ * @param {number} x1
95
+ * @param {number} y1
96
+ * @param {number} z1
97
+ */
98
+ box(x0, y0, z0, x1, y1, z1) {
99
+ this.sides([[x0, y0], [x1, y0], [x1, y1], [x0, y1]], z0, z1);
100
+ this.face([x0, y0, z1], [x1, y0, z1], [x1, y1, z1], [x0, y1, z1]);
101
+ this.face([x0, y0, z0], [x1, y0, z0], [x1, y1, z0], [x0, y1, z0]);
102
+ }
103
+
104
+ /** The group for createWalkMesh: its color, vertices and triangles, its options, its charts if recorded. */
105
+ get walkGroup() {
106
+ return {color: this.color, vertices: new Float32Array(this.positions), triangles: new Uint32Array(this.indices), ...this.options,
107
+ ...(this.charts ? {charts: this.charts} : {})};
108
+ }
109
+ }
package/package.json CHANGED
@@ -1,6 +1,35 @@
1
1
  {
2
2
  "name": "@geoblocks/cesium-walk",
3
- "version": "0.0.0-stage",
4
- "stub": true,
5
- "description": "Temporary package placeholder for staged publishing"
6
- }
3
+ "version": "0.1.0",
4
+ "license": "BSD-3-Clause",
5
+ "type": "module",
6
+ "repository": {
7
+ "type": "git",
8
+ "url": "https://github.com/geoblocks/cesium-helpers/",
9
+ "directory": "packages/cesium-walk"
10
+ },
11
+ "main": "cesium-walk.js",
12
+ "module": "cesium-walk.js",
13
+ "sideEffects": false,
14
+ "exports": {
15
+ ".": "./cesium-walk.js",
16
+ "./walk-mesh.js": "./walk-mesh.js",
17
+ "./mesh-builder.js": "./mesh-builder.js"
18
+ },
19
+ "files": [
20
+ "cesium-walk.js",
21
+ "walk-mesh.js",
22
+ "mesh-builder.js"
23
+ ],
24
+ "publishConfig": {
25
+ "access": "public"
26
+ },
27
+ "dependencies": {
28
+ "@geoblocks/cesium-input": "^0.1.0",
29
+ "@geoblocks/cesium-obstacles": "^0.1.0"
30
+ },
31
+ "peerDependencies": {
32
+ "@cesium/core": ">=0.1.0",
33
+ "@cesium/engine": ">=10"
34
+ }
35
+ }
package/walk-mesh.js ADDED
@@ -0,0 +1,443 @@
1
+ import {Cartesian3, Cartographic, Ellipsoid, Matrix4} from "@cesium/core";
2
+ import {Axis, Model, PrimitiveCollection, ShadowMode} from "@cesium/engine";
3
+
4
+ // Triangles to walk on, in meters, z up, in the frame of a model matrix: their models, and the floors and
5
+ // walls a walker meets, without reading the depth, which waits for the GPU.
6
+
7
+ // the ground's cells, in meters
8
+ const CELL = 2;
9
+ // the faces flatter than this, of the cosine of their slope, are floors, stairs and ramps
10
+ const FLOOR = 0.5;
11
+
12
+ const originScratch = new Cartesian3();
13
+ const directionScratch = new Cartesian3();
14
+ const upScratch = new Cartesian3();
15
+ const rayScratch = new Cartesian3();
16
+ const rayOriginScratch = new Cartesian3();
17
+ const kneesScratch = new Cartographic();
18
+ const floorScratch = new Cartesian3();
19
+ const floorCartographicScratch = new Cartographic();
20
+
21
+ /**
22
+ * @typedef {object} WalkGroup Triangles of one color.
23
+ * @property {number[]} color The base color, linear, and its alpha: translucent below 1.
24
+ * @property {Float32Array} vertices x, y, z, in meters, z up.
25
+ * @property {Uint32Array} triangles
26
+ * @property {boolean} [show] false: walked on, not seen (an invisible edge).
27
+ * @property {boolean} [collide] false: seen, not walked on (the battens, the water).
28
+ * @property {boolean} [shadows] false: casts no shadow (the glass, the water).
29
+ * @property {number} [emissive] The share of its color it gives off: the light indoors, which Cesium does not
30
+ * bounce off the walls.
31
+ * @property {boolean} [uv] Whether its vertices carry their x and y as texture coordinates, in meters, for a
32
+ * shader drawing on the floors.
33
+ * @property {Float32Array} [lightmap] Its vertices' coordinates in a baked light's atlas, u and v each, written as
34
+ * the second texture coordinates.
35
+ * @property {import("@cesium/engine").CustomShader} [customShader]
36
+ */
37
+
38
+ /**
39
+ * The flat shaded model of the triangles, untextured, in glTF's y up, quantized (KHR_mesh_quantization):
40
+ * the positions in 16 bits, scaled back to meters by the node; the normals in 8 bits; each vertex padded to
41
+ * 4 bytes, 12 in all, as against 24 in floats. Exported for the tests.
42
+ * @param {Float32Array} vertices
43
+ * @param {Uint32Array} triangles
44
+ * @param {number[]} color
45
+ * @param {number} emissive
46
+ * @param {boolean} uv Whether to give the vertices their x and y as texture coordinates, in floats.
47
+ * @param {Float32Array | undefined} lightmap The vertices' second texture coordinates, in floats, if any.
48
+ * @return {ArrayBuffer} The binary glTF.
49
+ */
50
+ export function toGlb(vertices, triangles, color, emissive, uv, lightmap) {
51
+ const count = triangles.length;
52
+ // the extent of the map, in glTF's axes
53
+ const toGltf = (/** @type {number} */ v) => [vertices[v], vertices[v + 2], -vertices[v + 1]];
54
+ const low = [Infinity, Infinity, Infinity];
55
+ const high = [-Infinity, -Infinity, -Infinity];
56
+ for (let v = 0; v < vertices.length; v += 3) {
57
+ toGltf(v).forEach((value, axis) => {
58
+ low[axis] = Math.min(low[axis], value);
59
+ high[axis] = Math.max(high[axis], value);
60
+ });
61
+ }
62
+ // the node maps -32767..32767 back to the widest extent, the same on every axis, which leaves the
63
+ // normals as they are
64
+ const translation = low.map((value, axis) => (value + high[axis]) / 2);
65
+ // a single point: any step keeps it at the node's translation
66
+ const step = Math.max(...low.map((value, axis) => high[axis] - value)) / 65534 || 1;
67
+ const scale = [step, step, step];
68
+ const positions = new Int16Array(count * 4);
69
+ const normals = new Int8Array(count * 4);
70
+ const texCoords = new Float32Array(uv ? count * 2 : 0);
71
+ const lightmapCoords = new Float32Array(lightmap ? count * 2 : 0);
72
+ const min = [Infinity, Infinity, Infinity];
73
+ const max = [-Infinity, -Infinity, -Infinity];
74
+ for (let i = 0; i < count; i += 3) {
75
+ const corners = [0, 1, 2].map(k => toGltf(triangles[i + k] * 3));
76
+ const [a, b, c] = corners.map(([x, y, z]) => new Cartesian3(x, y, z));
77
+ const normal = Cartesian3.cross(Cartesian3.subtract(b, a, new Cartesian3()), Cartesian3.subtract(c, a, new Cartesian3()), new Cartesian3());
78
+ if (Cartesian3.magnitudeSquared(normal) > 0) {
79
+ Cartesian3.normalize(normal, normal);
80
+ }
81
+ corners.forEach((corner, k) => {
82
+ const quantized = corner.map((value, axis) => Math.round((value - translation[axis]) / step));
83
+ positions.set(quantized, (i + k) * 4);
84
+ normals.set([normal.x, normal.y, normal.z].map(n => Math.round(n * 127)), (i + k) * 4);
85
+ for (let axis = 0; axis < 3; axis++) {
86
+ min[axis] = Math.min(min[axis], quantized[axis]);
87
+ max[axis] = Math.max(max[axis], quantized[axis]);
88
+ }
89
+ if (uv) {
90
+ const v = triangles[i + k] * 3;
91
+ texCoords.set([vertices[v], vertices[v + 1]], (i + k) * 2);
92
+ }
93
+ if (lightmap) {
94
+ const v = triangles[i + k];
95
+ lightmapCoords.set([lightmap[v * 2], lightmap[v * 2 + 1]], (i + k) * 2);
96
+ }
97
+ });
98
+ }
99
+ // the float attributes after the positions and normals, each its own buffer view
100
+ const floats = /** @type {[string, Float32Array][]} */ ([...(uv ? [['TEXCOORD_0', texCoords]] : []), ...(lightmap ? [['TEXCOORD_1', lightmapCoords]] : [])]);
101
+ const floatsLength = floats.reduce((n, [, array]) => n + array.byteLength, 0);
102
+ let floatOffset = positions.byteLength + normals.byteLength;
103
+ const json = new TextEncoder().encode(JSON.stringify({
104
+ asset: {version: '2.0'},
105
+ extensionsUsed: ['KHR_mesh_quantization', ...(emissive > 1 ? ['KHR_materials_emissive_strength'] : [])],
106
+ extensionsRequired: ['KHR_mesh_quantization'],
107
+ scene: 0,
108
+ scenes: [{nodes: [0]}],
109
+ nodes: [{mesh: 0, translation, scale}],
110
+ meshes: [{primitives: [{attributes: {POSITION: 0, NORMAL: 1, ...Object.fromEntries(floats.map(([name], k) => [name, 2 + k]))}, material: 0}]}],
111
+ materials: [{
112
+ pbrMetallicRoughness: {baseColorFactor: color, metallicFactor: 0, roughnessFactor: 1},
113
+ // glTF caps the factor at 1, the strength carries the rest
114
+ emissiveFactor: color.slice(0, 3).map(c => Math.min(Math.max(c * Math.min(emissive, 1), 0), 1)),
115
+ ...(emissive > 1 ? {extensions: {KHR_materials_emissive_strength: {emissiveStrength: emissive}}} : {}),
116
+ doubleSided: true,
117
+ ...(color[3] < 1 ? {alphaMode: 'BLEND'} : {}),
118
+ }],
119
+ buffers: [{byteLength: positions.byteLength + normals.byteLength + floatsLength}],
120
+ bufferViews: [
121
+ {buffer: 0, byteOffset: 0, byteLength: positions.byteLength, byteStride: 8, target: 34962},
122
+ {buffer: 0, byteOffset: positions.byteLength, byteLength: normals.byteLength, byteStride: 4, target: 34962},
123
+ ...floats.map(([, array]) => {
124
+ const view = {buffer: 0, byteOffset: floatOffset, byteLength: array.byteLength, target: 34962};
125
+ floatOffset += array.byteLength;
126
+ return view;
127
+ }),
128
+ ],
129
+ accessors: [
130
+ {bufferView: 0, componentType: 5122, count, type: 'VEC3', min, max},
131
+ {bufferView: 1, componentType: 5120, normalized: true, count, type: 'VEC3'},
132
+ ...floats.map((_, k) => ({bufferView: 2 + k, componentType: 5126, count, type: 'VEC2'})),
133
+ ],
134
+ }));
135
+ const jsonLength = Math.ceil(json.length / 4) * 4;
136
+ const binLength = positions.byteLength + normals.byteLength + floatsLength;
137
+ const glb = new ArrayBuffer(12 + 8 + jsonLength + 8 + binLength);
138
+ const view = new DataView(glb);
139
+ view.setUint32(0, 0x46546c67, true);
140
+ view.setUint32(4, 2, true);
141
+ view.setUint32(8, glb.byteLength, true);
142
+ view.setUint32(12, jsonLength, true);
143
+ view.setUint32(16, 0x4e4f534a, true);
144
+ new Uint8Array(glb, 20, jsonLength).fill(0x20).set(json);
145
+ view.setUint32(20 + jsonLength, binLength, true);
146
+ view.setUint32(24 + jsonLength, 0x004e4942, true);
147
+ new Int16Array(glb, 28 + jsonLength, count * 4).set(positions);
148
+ new Int8Array(glb, 28 + jsonLength + positions.byteLength, count * 4).set(normals);
149
+ let offset = 28 + jsonLength + positions.byteLength + normals.byteLength;
150
+ for (const [, array] of floats) {
151
+ new Float32Array(glb, offset, array.length).set(array);
152
+ offset += array.byteLength;
153
+ }
154
+ return glb;
155
+ }
156
+
157
+ /**
158
+ * @param {WalkGroup[]} groups
159
+ * @param {Matrix4} modelMatrix From the triangles' frame to the globe.
160
+ */
161
+ export async function createWalkMesh(groups, modelMatrix) {
162
+ // a single point, or nothing, has nothing to draw
163
+ const visible = groups.filter(group => group.show !== false && group.triangles.length > 0 &&
164
+ group.vertices.some((value, k) => value !== group.vertices[k % 3]));
165
+ const urls = visible.map(group => URL.createObjectURL(new Blob([toGlb(group.vertices, group.triangles, group.color, group.emissive ?? 0, group.uv ?? false, group.lightmap)], {type: 'model/gltf-binary'})));
166
+ const loaded = await Promise.allSettled(visible.map(async (group, k) => {
167
+ // with glTF's y up only: Cesium would also turn the model's +z to +x, a quarter turn off the frame's axes
168
+ const model = await Model.fromGltfAsync({
169
+ url: urls[k],
170
+ modelMatrix,
171
+ forwardAxis: Axis.X,
172
+ shadows: group.shadows === false ? ShadowMode.RECEIVE_ONLY : ShadowMode.ENABLED,
173
+ ...(group.customShader ? {customShader: group.customShader} : {}),
174
+ });
175
+ model.readyEvent.addEventListener(() => URL.revokeObjectURL(urls[k]));
176
+ model.errorEvent.addEventListener(() => URL.revokeObjectURL(urls[k]));
177
+ return model;
178
+ }));
179
+ // in the groups' order
180
+ const primitive = new PrimitiveCollection();
181
+ for (const result of loaded) {
182
+ if (result.status === 'fulfilled') {
183
+ primitive.add(result.value);
184
+ }
185
+ }
186
+ const failed = loaded.find(result => result.status === 'rejected');
187
+ if (failed) {
188
+ primitive.destroy();
189
+ urls.forEach(url => URL.revokeObjectURL(url));
190
+ throw failed.reason;
191
+ }
192
+
193
+ // the groups walked on as one: their vertices one after the other, their triangles renumbered
194
+ const colliding = groups.filter(group => group.collide !== false);
195
+ const vertices = new Float32Array(colliding.reduce((n, group) => n + group.vertices.length, 0));
196
+ const triangles = new Uint32Array(colliding.reduce((n, group) => n + group.triangles.length, 0));
197
+ let vertexOffset = 0;
198
+ let triangleOffset = 0;
199
+ for (const group of colliding) {
200
+ vertices.set(group.vertices, vertexOffset);
201
+ for (let k = 0; k < group.triangles.length; k++) {
202
+ triangles[triangleOffset + k] = group.triangles[k] + vertexOffset / 3;
203
+ }
204
+ vertexOffset += group.vertices.length;
205
+ triangleOffset += group.triangles.length;
206
+ }
207
+ const toLocal = Matrix4.inverse(modelMatrix, new Matrix4());
208
+ // the normals' matrix, the inverse transpose
209
+ const toNormal = Matrix4.transpose(toLocal, new Matrix4());
210
+
211
+ // the triangles, and the floors among them, by the cells they overlap
212
+ /** @type {Map<string, number[]>} */
213
+ const cells = new Map();
214
+ /** @type {Map<string, number[]>} */
215
+ const allCells = new Map();
216
+ const isFloor = new Uint8Array(triangles.length / 3);
217
+ const vertex = (/** @type {number} */ index) => vertices.subarray(index * 3, index * 3 + 3);
218
+ const add = (/** @type {Map<string, number[]>} */ map, /** @type {string} */ key, /** @type {number} */ i) => {
219
+ const list = map.get(key);
220
+ if (list) {
221
+ list.push(i);
222
+ } else {
223
+ map.set(key, [i]);
224
+ }
225
+ };
226
+ for (let i = 0; i < triangles.length; i += 3) {
227
+ const [a, b, c] = [vertex(triangles[i]), vertex(triangles[i + 1]), vertex(triangles[i + 2])];
228
+ const nx = (b[1] - a[1]) * (c[2] - a[2]) - (b[2] - a[2]) * (c[1] - a[1]);
229
+ const ny = (b[2] - a[2]) * (c[0] - a[0]) - (b[0] - a[0]) * (c[2] - a[2]);
230
+ const nz = (b[0] - a[0]) * (c[1] - a[1]) - (b[1] - a[1]) * (c[0] - a[0]);
231
+ isFloor[i / 3] = Math.abs(nz) >= FLOOR * Math.hypot(nx, ny, nz) ? 1 : 0;
232
+ for (let x = Math.floor(Math.min(a[0], b[0], c[0]) / CELL); x <= Math.floor(Math.max(a[0], b[0], c[0]) / CELL); x++) {
233
+ for (let y = Math.floor(Math.min(a[1], b[1], c[1]) / CELL); y <= Math.floor(Math.max(a[1], b[1], c[1]) / CELL); y++) {
234
+ const key = `${x},${y}`;
235
+ add(allCells, key, i);
236
+ if (isFloor[i / 3]) {
237
+ add(cells, key, i);
238
+ }
239
+ }
240
+ }
241
+ }
242
+
243
+ /**
244
+ * The highest floor at a position of the frame, not above it.
245
+ * @param {number} x
246
+ * @param {number} y
247
+ * @param {number} z
248
+ * @return {number | undefined} Its height, in meters.
249
+ */
250
+ const floorBelow = (x, y, z) => {
251
+ let highest;
252
+ for (const i of cells.get(`${Math.floor(x / CELL)},${Math.floor(y / CELL)}`) ?? []) {
253
+ const a = triangles[i] * 3, b = triangles[i + 1] * 3, c = triangles[i + 2] * 3;
254
+ const ux = vertices[b] - vertices[a], uy = vertices[b + 1] - vertices[a + 1];
255
+ const vx = vertices[c] - vertices[a], vy = vertices[c + 1] - vertices[a + 1];
256
+ const d = ux * vy - vx * uy;
257
+ if (d === 0) {
258
+ continue;
259
+ }
260
+ const s = ((x - vertices[a]) * vy - vx * (y - vertices[a + 1])) / d;
261
+ const t = (ux * (y - vertices[a + 1]) - (x - vertices[a]) * uy) / d;
262
+ if (s < 0 || t < 0 || s + t > 1) {
263
+ continue;
264
+ }
265
+ const height = vertices[a + 2] + s * (vertices[b + 2] - vertices[a + 2]) + t * (vertices[c + 2] - vertices[a + 2]);
266
+ if (height <= z && (highest === undefined || height > highest)) {
267
+ highest = height;
268
+ }
269
+ }
270
+ return highest;
271
+ };
272
+
273
+ // the ray each triangle was last tested against, so that a triangle over several cells is tested once
274
+ const tested = new Uint32Array(triangles.length / 3);
275
+ let ray = 0;
276
+ /**
277
+ * The nearest triangle along a ray, in meters, by Möller and Trumbore's test, through the cells the
278
+ * ray crosses, nearest first.
279
+ * @param {Cartesian3} o The origin.
280
+ * @param {Cartesian3} d The unit direction.
281
+ * @param {number} far The longest distance.
282
+ * @param {boolean} walls Whether to skip the floors.
283
+ * @return {{distance: number, triangle: number} | undefined}
284
+ */
285
+ const nearestHit = (o, d, far, walls) => {
286
+ ray++;
287
+ let best;
288
+ let x = Math.floor(o.x / CELL);
289
+ let y = Math.floor(o.y / CELL);
290
+ const stepX = Math.sign(d.x);
291
+ const stepY = Math.sign(d.y);
292
+ // the distances along the ray to the next cell borders, and between borders
293
+ let nextX = stepX === 0 ? Infinity : ((x + (stepX > 0 ? 1 : 0)) * CELL - o.x) / d.x;
294
+ let nextY = stepY === 0 ? Infinity : ((y + (stepY > 0 ? 1 : 0)) * CELL - o.y) / d.y;
295
+ const deltaX = stepX === 0 ? Infinity : CELL / Math.abs(d.x);
296
+ const deltaY = stepY === 0 ? Infinity : CELL / Math.abs(d.y);
297
+ let entry = 0;
298
+ while (entry <= far) {
299
+ const exit = Math.min(nextX, nextY, far);
300
+ for (const i of allCells.get(`${x},${y}`) ?? []) {
301
+ if (tested[i / 3] === ray || (walls && isFloor[i / 3])) {
302
+ continue;
303
+ }
304
+ tested[i / 3] = ray;
305
+ const a = triangles[i] * 3, b = triangles[i + 1] * 3, c = triangles[i + 2] * 3;
306
+ const e1x = vertices[b] - vertices[a], e1y = vertices[b + 1] - vertices[a + 1], e1z = vertices[b + 2] - vertices[a + 2];
307
+ const e2x = vertices[c] - vertices[a], e2y = vertices[c + 1] - vertices[a + 1], e2z = vertices[c + 2] - vertices[a + 2];
308
+ const px = d.y * e2z - d.z * e2y, py = d.z * e2x - d.x * e2z, pz = d.x * e2y - d.y * e2x;
309
+ const det = e1x * px + e1y * py + e1z * pz;
310
+ if (Math.abs(det) < 1e-9) {
311
+ continue;
312
+ }
313
+ const sx = o.x - vertices[a], sy = o.y - vertices[a + 1], sz = o.z - vertices[a + 2];
314
+ const u = (sx * px + sy * py + sz * pz) / det;
315
+ if (u < 0 || u > 1) {
316
+ continue;
317
+ }
318
+ const qx = sy * e1z - sz * e1y, qy = sz * e1x - sx * e1z, qz = sx * e1y - sy * e1x;
319
+ const v = (d.x * qx + d.y * qy + d.z * qz) / det;
320
+ if (v < 0 || u + v > 1) {
321
+ continue;
322
+ }
323
+ const t = (e2x * qx + e2y * qy + e2z * qz) / det;
324
+ if (t >= 0 && t <= far && (!best || t < best.distance)) {
325
+ best = {distance: t, triangle: i};
326
+ }
327
+ }
328
+ // a hit within this cell is the nearest; one beyond it may still be beaten in the next cells
329
+ if (best && best.distance <= exit) {
330
+ return best;
331
+ }
332
+ if (exit >= far) {
333
+ return best;
334
+ }
335
+ entry = exit;
336
+ if (nextX < nextY) {
337
+ x += stepX;
338
+ nextX += deltaX;
339
+ } else {
340
+ y += stepY;
341
+ nextY += deltaY;
342
+ }
343
+ }
344
+ return best;
345
+ };
346
+
347
+ return {
348
+ primitive,
349
+ floorBelow,
350
+ /**
351
+ * The first surface along a ray on the globe.
352
+ * @param {Cartesian3} origin
353
+ * @param {Cartesian3} direction A unit vector.
354
+ * @param {number} maxDistance In meters.
355
+ * @param {{walls?: boolean}} [options] walls: skip the floors, stairs and ramps, which the walker climbs.
356
+ * @return {{position: Cartesian3, normal: Cartesian3, distance: number} | undefined} Where the
357
+ * ray stops, the surface's normal toward the ray's origin, and the distance in meters.
358
+ */
359
+ raycast: (origin, direction, maxDistance, {walls = false} = {}) => {
360
+ const o = Matrix4.multiplyByPoint(toLocal, origin, originScratch);
361
+ const d = Matrix4.multiplyByPointAsVector(toLocal, direction, directionScratch);
362
+ // the frame's units in a meter along the ray, should the matrix scale
363
+ const perMeter = Cartesian3.magnitude(d);
364
+ Cartesian3.divideByScalar(d, perMeter, d);
365
+ const hit = nearestHit(o, d, maxDistance * perMeter, walls);
366
+ if (!hit) {
367
+ return undefined;
368
+ }
369
+ const position = Matrix4.multiplyByPoint(modelMatrix, Cartesian3.add(o, Cartesian3.multiplyByScalar(d, hit.distance, rayScratch), rayScratch), new Cartesian3());
370
+ const i = hit.triangle;
371
+ const [a, b, c] = [vertex(triangles[i]), vertex(triangles[i + 1]), vertex(triangles[i + 2])];
372
+ const e1 = new Cartesian3(b[0] - a[0], b[1] - a[1], b[2] - a[2]);
373
+ const e2 = new Cartesian3(c[0] - a[0], c[1] - a[1], c[2] - a[2]);
374
+ const normal = Matrix4.multiplyByPointAsVector(toNormal, Cartesian3.cross(e1, e2, e1), new Cartesian3());
375
+ Cartesian3.normalize(normal, normal);
376
+ if (Cartesian3.dot(normal, direction) > 0) {
377
+ Cartesian3.negate(normal, normal);
378
+ }
379
+ return {position, normal, distance: Cartesian3.distance(origin, position)};
380
+ },
381
+ /**
382
+ * @param {number} x
383
+ * @param {number} y
384
+ * @param {number} z
385
+ * @param {Cartesian3} [result]
386
+ * @return {Cartesian3} A position of the frame on the globe.
387
+ */
388
+ position: (x, y, z, result = new Cartesian3()) => Matrix4.multiplyByPoint(modelMatrix, Cartesian3.fromElements(x, y, z, result), result),
389
+ /**
390
+ * @param {Cartesian3} position
391
+ * @param {Cartesian3} [result]
392
+ * @return {Cartesian3} A position on the globe in the frame.
393
+ */
394
+ local: (/** @type {Cartesian3} */ position, result = new Cartesian3()) => Matrix4.multiplyByPoint(toLocal, position, result),
395
+ };
396
+ }
397
+
398
+ /**
399
+ * The heights above the floor the walls are looked for at: the knees, the waist and the eyes.
400
+ * @param {number} eye
401
+ * @param {number} step
402
+ * @return {number[]} In meters, from the lowest.
403
+ */
404
+ export function rayHeights(eye, step) {
405
+ return [step + 0.05, (step + eye) / 2, eye];
406
+ }
407
+
408
+ /**
409
+ * Walks a CesiumWalk on a mesh: it stands on the highest floor below its knees, up the stairs, under the
410
+ * arches, else on what it stood on before (the terrain); the walls ahead stop it, from rays cast at the knees,
411
+ * the waist and the eyes, the floors and stairs left to the ground below, in every direction.
412
+ * @param {import('./cesium-walk.js').default} walk
413
+ * @param {Awaited<ReturnType<typeof createWalkMesh>>} mesh
414
+ * @param {{eye: number, step: number, reach: number}} options In meters: the eyes above the floor, the
415
+ * highest step, how far ahead the walls are looked for.
416
+ */
417
+ export function walkOn(walk, mesh, {eye, step, reach}) {
418
+ const camera = walk.viewer.scene.camera;
419
+ const fallback = walk.groundHeight;
420
+ const heights = rayHeights(eye, step);
421
+ const walls = {walls: true};
422
+ walk.obstacleAhead = toward => {
423
+ const eyes = camera.positionWC;
424
+ const up = Ellipsoid.WGS84.geodeticSurfaceNormal(eyes, upScratch);
425
+ let nearest;
426
+ for (const height of heights) {
427
+ const origin = Cartesian3.add(eyes, Cartesian3.multiplyByScalar(up, height - eye, rayOriginScratch), rayOriginScratch);
428
+ const hit = mesh.raycast(origin, toward, reach, walls);
429
+ if (hit && (!nearest || hit.distance < nearest.distance)) {
430
+ nearest = hit;
431
+ }
432
+ }
433
+ return nearest && {point: nearest.position, normal: nearest.normal};
434
+ };
435
+ walk.groundHeight = cartographic => {
436
+ const knees = Cartographic.clone(cartographic, kneesScratch);
437
+ knees.height -= eye - step;
438
+ const {x, y, z} = mesh.local(Cartographic.toCartesian(knees, Ellipsoid.WGS84, floorScratch), floorScratch);
439
+ const floor = mesh.floorBelow(x, y, z);
440
+ return floor === undefined ? fallback(cartographic) :
441
+ Cartographic.fromCartesian(mesh.position(x, y, floor, floorScratch), Ellipsoid.WGS84, floorCartographicScratch).height;
442
+ };
443
+ }