@navaramap/core 0.2.1 → 0.3.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.
@@ -0,0 +1,387 @@
1
+ import { type Events } from "@navaramap/engine";
2
+
3
+ import { generate_id_from_entity, isEntityEvent } from "../id";
4
+
5
+ // The WASM `Events` class exposes each stack only through a move-out
6
+ // `take_<key>()` method (reading a getter would deep-clone the array and every
7
+ // element). Stack keys and value types are derived from those methods.
8
+ export type JsEvents = {
9
+ [
10
+ K in keyof Events as K extends `take_${infer P}` ? P : never
11
+ ]: Events[K] extends (...args: never[]) => infer R ? R : never;
12
+ };
13
+ export type JsEventsKey = keyof JsEvents;
14
+
15
+ /** One element of a stack (the value itself for the `Option` stacks). */
16
+ export type JsEventValue<K extends JsEventsKey> = JsEvents[K] extends unknown[]
17
+ ? JsEvents[K][number]
18
+ : JsEvents[K];
19
+
20
+ /**
21
+ * One kind of frontend job: the engine emits `requested` when it wants the
22
+ * frontend to run async work for an entity (fetch, worker task, mesh build),
23
+ * `changed` while the entity is alive, and `removed` when the entity is
24
+ * despawned — unconditionally, including while the job is still in flight.
25
+ *
26
+ * The dispatcher turns these into calls on this handler and owns the job
27
+ * table; the handler never sees ordering, dedupe or abort bookkeeping.
28
+ */
29
+ export type JobHandler<Req, Chg, Rem, Product> = {
30
+ /** Used in error logs only. */
31
+ name: string;
32
+ events: {
33
+ requested: JsEventsKey;
34
+ changed?: JsEventsKey;
35
+ removed: JsEventsKey;
36
+ };
37
+ /**
38
+ * Called right away for every admitted `requested` event. After each
39
+ * `await`, check `abort.signal.aborted` before touching shared state or
40
+ * reporting to the engine: a removed job must not report. Resolving
41
+ * `undefined` means "done, no product" (an empty geometry, a fetch whose
42
+ * result went straight into the buffer store, ...); the job is then
43
+ * forgotten, like one whose `start` failed.
44
+ */
45
+ start(ev: Req, abort: AbortController): Promise<Product | undefined>;
46
+ /**
47
+ * Applies a `changed` snapshot to a live product. Calls on one job never
48
+ * overlap: a change arriving while `start` or a previous change is in
49
+ * flight waits, and only the latest waiting one is applied.
50
+ */
51
+ change?(product: Product, ev: Chg): void | Promise<void>;
52
+ /**
53
+ * Called exactly once per `removed` event, when nothing is in flight for
54
+ * the job any more: immediately for a live or unknown job (`product` is
55
+ * `undefined` for an unknown one or one whose `start` failed), or once the
56
+ * aborted `start` / `change` settles. The job's abort signal has fired by
57
+ * then, so continuations still running after `start` see the removal.
58
+ * `ev` is `undefined` only when the dispatcher itself is disposed while
59
+ * the job is in flight.
60
+ */
61
+ teardown(product: Product | undefined, ev: Rem | undefined): void;
62
+ /** Reports a rejected / thrown `start` to the engine (kind specific); a
63
+ * kind without it gets the error logged instead. Not called once the job
64
+ * was removed or the dispatcher disposed; an abort the handler fires on
65
+ * its own still reports. */
66
+ failed?(ev: Req, error: unknown): void;
67
+ };
68
+
69
+ type Job = {
70
+ abort: AbortController;
71
+ product?: unknown;
72
+ /** Whether `start` or a `change` is outstanding; at most one is. */
73
+ inFlight: boolean;
74
+ /** The `removed` event that arrived while something was in flight. Kept
75
+ * alive (not freed) until `teardown` has consumed it. */
76
+ removedEv?: object;
77
+ /** Latest `changed` event received while something was in flight. */
78
+ pendingChange?: object;
79
+ };
80
+
81
+ type Registered = {
82
+ handler: JobHandler<unknown, unknown, unknown, unknown>;
83
+ jobs: Map<string, Job>;
84
+ /** Jobs of this kind with a call outstanding. */
85
+ inFlight: number;
86
+ };
87
+
88
+ /** A registered kind, as returned by {@link JobDispatcher.register}. */
89
+ export type RegisteredJobKind = {
90
+ /** Jobs of this kind whose `start` or `change` has not settled yet. */
91
+ inFlight(): number;
92
+ };
93
+
94
+ type SyncHandler = { key: JsEventsKey; handle: (ev: unknown) => void };
95
+
96
+ export type JobDispatcherOptions = {
97
+ /** Called whenever a job's `start` settles with a product, i.e. something
98
+ * new is on screen. */
99
+ onSettled?: () => void;
100
+ };
101
+
102
+ const maybeFree = (ev: unknown) => {
103
+ if (
104
+ ev &&
105
+ typeof ev === "object" &&
106
+ "free" in ev &&
107
+ typeof ev.free === "function"
108
+ ) {
109
+ ev.free();
110
+ }
111
+ };
112
+
113
+ const isThenable = (v: unknown): v is Promise<void> =>
114
+ typeof (v as { then?: unknown } | undefined)?.then === "function";
115
+
116
+ const idOf = (ev: unknown): string | undefined =>
117
+ isEntityEvent(ev) ? generate_id_from_entity(ev) : undefined;
118
+
119
+ const takeStack = <K extends JsEventsKey>(
120
+ events: Events,
121
+ key: K,
122
+ ): (JsEventValue<K> & object)[] => {
123
+ const take = events[`take_${key}` as keyof Events] as unknown as (
124
+ this: Events,
125
+ ) => JsEvents[K];
126
+ const taken = take.call(events);
127
+ if (taken === undefined || taken === null) return [];
128
+ return (Array.isArray(taken) ? taken : [taken]) as (JsEventValue<K> &
129
+ object)[];
130
+ };
131
+
132
+ /**
133
+ * Consumes the per-frame engine events and dispatches them to the registered
134
+ * handlers with no queueing: every `requested` starts its job in the same
135
+ * tick (admission and priority are the engine's job), every `removed` aborts
136
+ * or tears down, every `changed` applies or waits for the in-flight `start`.
137
+ * Taken WASM event objects are freed here, once the handler that received
138
+ * them has settled, so handlers never call `free()` on an event.
139
+ */
140
+ export class JobDispatcher {
141
+ private readonly sync: SyncHandler[] = [];
142
+ private readonly registered: Registered[] = [];
143
+ private readonly onSettled?: () => void;
144
+ private disposed = false;
145
+ private inFlightJobs = 0;
146
+
147
+ constructor(options?: JobDispatcherOptions) {
148
+ this.onSettled = options?.onSettled;
149
+ }
150
+
151
+ register<Req, Chg, Rem, Product>(
152
+ handler: JobHandler<Req, Chg, Rem, Product>,
153
+ ): RegisteredJobKind {
154
+ const r: Registered = {
155
+ handler: handler as JobHandler<unknown, unknown, unknown, unknown>,
156
+ jobs: new Map(),
157
+ inFlight: 0,
158
+ };
159
+ this.registered.push(r);
160
+ return { inFlight: () => r.inFlight };
161
+ }
162
+
163
+ /** A synchronous, stateless handler; events are freed right after it. */
164
+ registerSync<K extends JsEventsKey>(
165
+ key: K,
166
+ handle: (ev: JsEventValue<K>) => void,
167
+ ) {
168
+ this.sync.push({ key, handle: handle as (ev: unknown) => void });
169
+ }
170
+
171
+ /** Jobs whose `start` or `change` has not settled yet. */
172
+ inFlight(): number {
173
+ return this.inFlightJobs;
174
+ }
175
+
176
+ tick(events: Events | undefined) {
177
+ if (!events || this.disposed) return;
178
+
179
+ for (const { key, handle } of this.sync) {
180
+ for (const ev of takeStack(events, key)) {
181
+ try {
182
+ handle(ev);
183
+ } catch (error) {
184
+ console.error(`Event handler for "${key}" failed:`, error);
185
+ } finally {
186
+ maybeFree(ev);
187
+ }
188
+ }
189
+ }
190
+
191
+ for (const r of this.registered) {
192
+ const { events: keys } = r.handler;
193
+ const removed = takeStack(events, keys.removed);
194
+ const requested = takeStack(events, keys.requested);
195
+ const changed = keys.changed ? takeStack(events, keys.changed) : [];
196
+
197
+ // An add and a remove of the same entity in one frame cancel out.
198
+ const removedIds = new Set<string>();
199
+ for (const ev of removed) {
200
+ const id = idOf(ev);
201
+ if (id !== undefined) removedIds.add(id);
202
+ }
203
+
204
+ for (const ev of requested) {
205
+ const id = idOf(ev);
206
+ if (id === undefined || removedIds.has(id) || r.jobs.has(id)) {
207
+ maybeFree(ev);
208
+ continue;
209
+ }
210
+ this.start(r, id, ev);
211
+ }
212
+ for (const ev of removed) {
213
+ this.remove(r, idOf(ev), ev);
214
+ }
215
+ for (const ev of changed) {
216
+ this.change(r, idOf(ev), ev);
217
+ }
218
+ }
219
+ }
220
+
221
+ /** Aborts every in-flight job and forgets the table. Live products are the
222
+ * owner's to dispose (they are indexed outside the dispatcher). */
223
+ dispose() {
224
+ this.disposed = true;
225
+ for (const r of this.registered) {
226
+ for (const job of r.jobs.values()) {
227
+ job.abort.abort();
228
+ }
229
+ r.jobs.clear();
230
+ r.inFlight = 0;
231
+ }
232
+ this.inFlightJobs = 0;
233
+ }
234
+
235
+ private start(r: Registered, id: string, ev: object) {
236
+ const job: Job = {
237
+ abort: new AbortController(),
238
+ inFlight: true,
239
+ };
240
+ this.inFlightJobs++;
241
+ r.inFlight++;
242
+ r.jobs.set(id, job);
243
+
244
+ let promise: Promise<unknown>;
245
+ try {
246
+ promise = Promise.resolve(r.handler.start(ev, job.abort));
247
+ } catch (error) {
248
+ promise = Promise.reject(error);
249
+ }
250
+ promise.then(
251
+ (product) => {
252
+ job.product = product;
253
+ this.settleStart(r, id, job, ev);
254
+ },
255
+ (error) => {
256
+ // A kind with a `failed` hook treats failures as routine (a 404 tile
257
+ // is reported to the engine, not logged); only unexpected ones log.
258
+ if (job.removedEv === undefined && !this.disposed) {
259
+ if (r.handler.failed) {
260
+ try {
261
+ r.handler.failed(ev, error);
262
+ } catch (e) {
263
+ console.error(`Job "${r.handler.name}" failed handler threw:`, e);
264
+ }
265
+ } else {
266
+ console.error(`Job "${r.handler.name}" failed to start:`, error);
267
+ }
268
+ }
269
+ this.settleStart(r, id, job, ev);
270
+ },
271
+ );
272
+ }
273
+
274
+ private settleStart(r: Registered, id: string, job: Job, ev: object) {
275
+ maybeFree(ev);
276
+ const product = job.product;
277
+ if (this.finish(r, id, job)) return;
278
+ if (product !== undefined) this.onSettled?.();
279
+ }
280
+
281
+ private remove(r: Registered, id: string | undefined, ev: object) {
282
+ const job = id === undefined ? undefined : r.jobs.get(id);
283
+ if (!job) {
284
+ this.teardown(r, undefined, ev);
285
+ maybeFree(ev);
286
+ return;
287
+ }
288
+ job.abort.abort();
289
+ if (job.inFlight) {
290
+ maybeFree(job.removedEv);
291
+ job.removedEv = ev;
292
+ return;
293
+ }
294
+ r.jobs.delete(id as string);
295
+ this.teardown(r, job.product, ev);
296
+ maybeFree(ev);
297
+ }
298
+
299
+ private change(r: Registered, id: string | undefined, ev: object) {
300
+ const job = id === undefined ? undefined : r.jobs.get(id);
301
+ if (!job || !r.handler.change || job.removedEv !== undefined) {
302
+ maybeFree(ev);
303
+ return;
304
+ }
305
+ if (job.inFlight) {
306
+ maybeFree(job.pendingChange);
307
+ job.pendingChange = ev;
308
+ return;
309
+ }
310
+ if (job.product === undefined) {
311
+ maybeFree(ev);
312
+ return;
313
+ }
314
+ this.applyChange(r, id as string, job, ev);
315
+ }
316
+
317
+ private applyChange(r: Registered, id: string, job: Job, ev: object) {
318
+ let result: void | Promise<void>;
319
+ try {
320
+ result = r.handler.change?.(job.product, ev);
321
+ } catch (error) {
322
+ console.error(`Job "${r.handler.name}" failed to change:`, error);
323
+ maybeFree(ev);
324
+ return;
325
+ }
326
+ // A synchronous change (a uniform or material update, the bulk case) is
327
+ // settled here; only one that returned a promise holds the job in flight.
328
+ if (!isThenable(result)) {
329
+ maybeFree(ev);
330
+ return;
331
+ }
332
+ job.inFlight = true;
333
+ this.inFlightJobs++;
334
+ r.inFlight++;
335
+ result
336
+ .catch((error) => {
337
+ console.error(`Job "${r.handler.name}" failed to change:`, error);
338
+ })
339
+ .finally(() => {
340
+ maybeFree(ev);
341
+ this.finish(r, id, job);
342
+ });
343
+ }
344
+
345
+ /**
346
+ * The async call on `job` settled. Runs the deferred teardown when a
347
+ * `removed` arrived (or the dispatcher was disposed) meanwhile, otherwise
348
+ * applies the change that waited; returns whether the job is gone.
349
+ */
350
+ private finish(r: Registered, id: string, job: Job): boolean {
351
+ job.inFlight = false;
352
+ if (!this.disposed) {
353
+ this.inFlightJobs--;
354
+ r.inFlight--;
355
+ }
356
+ if (job.removedEv === undefined && !this.disposed) {
357
+ const change = job.pendingChange;
358
+ job.pendingChange = undefined;
359
+ if (job.product === undefined) {
360
+ // Nothing to change or tear down: a later `removed` is handled as for
361
+ // an unknown id. Some entities are despawned without one, so keeping
362
+ // the entry would leak it.
363
+ maybeFree(change);
364
+ r.jobs.delete(id);
365
+ } else if (change !== undefined) {
366
+ this.applyChange(r, id, job, change);
367
+ }
368
+ return false;
369
+ }
370
+
371
+ if (r.jobs.get(id) === job) r.jobs.delete(id);
372
+ maybeFree(job.pendingChange);
373
+ job.pendingChange = undefined;
374
+ this.teardown(r, job.product, job.removedEv);
375
+ maybeFree(job.removedEv);
376
+ job.removedEv = undefined;
377
+ return true;
378
+ }
379
+
380
+ private teardown(r: Registered, product: unknown, ev: object | undefined) {
381
+ try {
382
+ r.handler.teardown(product, ev);
383
+ } catch (error) {
384
+ console.error(`Job "${r.handler.name}" failed to tear down:`, error);
385
+ }
386
+ }
387
+ }
@@ -1,2 +1 @@
1
- export * from "./EventManager";
2
- export * from "./TransactionManager";
1
+ export * from "./JobDispatcher";
package/src/event/mock.ts CHANGED
@@ -6,7 +6,7 @@ import type {
6
6
  } from "@navaramap/engine";
7
7
  import { noop } from "lodash-es";
8
8
 
9
- import type { JsEvents, JsEventsKey } from "./EventManager";
9
+ import type { JsEvents, JsEventsKey } from "./JobDispatcher";
10
10
 
11
11
  export const makeRenderableFeatures = <
12
12
  Ev extends
@@ -19,7 +19,7 @@ export const makeRenderableFeatures = <
19
19
  };
20
20
 
21
21
  // The real `Events` exposes each stack only via a `take_<key>()` method
22
- // (see EventManager.pushEvents); mimic that shape for any key.
22
+ // (see JobDispatcher.tick); mimic that shape for any key.
23
23
  export const makeEvent = (events: { [K in JsEventsKey]?: JsEvents[K] }) =>
24
24
  new Proxy(
25
25
  {},
@@ -1,6 +1,6 @@
1
1
  import type { Globe as GlobeWasm } from "@navaramap/engine";
2
2
 
3
- import { ColorMap, Color } from "../color";
3
+ import { Color } from "../color";
4
4
  import type { RemoveFreeRecursively } from "../types";
5
5
  /**
6
6
  * Handler for accessing individual Globe properties from WASM.
@@ -15,7 +15,6 @@ export type GlobeHandler = {
15
15
  getUseNormal: () => boolean | undefined;
16
16
  getOpacity: () => number | undefined;
17
17
  getWireframe: () => boolean | undefined;
18
- getElevationColormap: () => Float32Array | undefined;
19
18
  setTransparent: (value: boolean) => void;
20
19
  setMaxSse: (value: number) => void;
21
20
  setSegments: (value: number) => void;
@@ -24,23 +23,14 @@ export type GlobeHandler = {
24
23
  setUseNormal: (value: boolean) => void;
25
24
  setOpacity: (value: number) => void;
26
25
  setWireframe: (value: boolean) => void;
27
- setElevationColormap: (value: ColorMap) => void;
28
26
  };
29
27
 
30
28
  // `isGeographicTiling` is engine-set output (the tiling scheme is dictated by
31
29
  // the terrain source, not configurable), so it is excluded from both the
32
30
  // options and the wrapper below.
33
31
  export type GlobeOptions = Partial<
34
- Omit<
35
- GlobeWasm,
36
- | "constructor"
37
- | "free"
38
- | "elevationColormap"
39
- | "color"
40
- | "isGeographicTiling"
41
- >
32
+ Omit<GlobeWasm, "constructor" | "free" | "color" | "isGeographicTiling">
42
33
  > & {
43
- elevationColormap?: ColorMap;
44
34
  color?: Color;
45
35
  };
46
36
 
@@ -52,10 +42,9 @@ export type GlobeOptions = Partial<
52
42
  */
53
43
  export class Globe implements Omit<
54
44
  RemoveFreeRecursively<GlobeWasm>,
55
- "elevationColormap" | "color" | "isGeographicTiling"
45
+ "color" | "isGeographicTiling"
56
46
  > {
57
47
  private handler: GlobeHandler;
58
- private _elevationColormap?: ColorMap;
59
48
 
60
49
  constructor(handler: GlobeHandler, options?: GlobeOptions) {
61
50
  this.handler = handler;
@@ -87,9 +76,6 @@ export class Globe implements Omit<
87
76
  if (options?.wireframe != null) {
88
77
  this.wireframe = options.wireframe;
89
78
  }
90
- if (options?.elevationColormap != null) {
91
- this.elevationColormap = options.elevationColormap;
92
- }
93
79
  }
94
80
 
95
81
  get maxSse(): number {
@@ -154,13 +140,4 @@ export class Globe implements Omit<
154
140
  set wireframe(value: boolean) {
155
141
  this.handler.setWireframe(value);
156
142
  }
157
-
158
- get elevationColormap(): ColorMap | undefined {
159
- return this._elevationColormap;
160
- }
161
-
162
- set elevationColormap(value: ColorMap) {
163
- this._elevationColormap = value;
164
- this.handler.setElevationColormap(value);
165
- }
166
143
  }
@@ -17,6 +17,7 @@ export class ReturnedConstructedTerrainMeshLike implements RemoveFreeRecursively
17
17
  skirt_uvs: Float32Array | undefined;
18
18
  skirt_indices: Uint32Array | undefined;
19
19
  skirt_normals: Float32Array | undefined;
20
+ skirt_heights: Float32Array | undefined;
20
21
  watermask: Uint8Array | undefined;
21
22
 
22
23
  // The transfer* methods return arrays copied out of WASM memory (owned by
@@ -40,6 +41,7 @@ export class ReturnedConstructedTerrainMeshLike implements RemoveFreeRecursively
40
41
  this.skirt_uvs = t.transferSkirtUvs();
41
42
  this.skirt_indices = t.transferSkirtIndices();
42
43
  this.skirt_normals = t.transferSkirtNormals();
44
+ this.skirt_heights = t.transferSkirtHeights();
43
45
  }
44
46
  if (t.hasWatermask()) {
45
47
  this.watermask = t.transferWatermask();
@@ -73,6 +75,9 @@ export class ReturnedConstructedTerrainMeshLike implements RemoveFreeRecursively
73
75
  transferSkirtNormals(): Float32Array | undefined {
74
76
  throw new Error();
75
77
  }
78
+ transferSkirtHeights(): Float32Array | undefined {
79
+ throw new Error();
80
+ }
76
81
  transferWatermask(): Uint8Array | undefined {
77
82
  throw new Error();
78
83
  }
@@ -22,17 +22,19 @@ export class ConstructedPolylineGeometryLike {
22
22
  end_high_size: number | undefined;
23
23
  end_low: Float32Array | undefined;
24
24
  end_low_size: number | undefined;
25
- start_normals: Float32Array | undefined;
26
- start_normals_size: number | undefined;
27
- end_normal_and_texture_coordinate_normalization_x: Float32Array | undefined;
28
- end_normal_and_texture_coordinate_normalization_x_size: number | undefined;
29
- right_normal_and_texture_coordinate_normalization_y: Float32Array;
30
- right_normal_and_texture_coordinate_normalization_y_size: number;
25
+ start_normal_and_length_high: Float32Array | undefined;
26
+ start_normal_and_length_high_size: number | undefined;
27
+ end_normal_and_segment_length: Float32Array | undefined;
28
+ end_normal_and_segment_length_size: number | undefined;
29
+ right_normal_and_line_length: Float32Array;
30
+ right_normal_and_line_length_size: number;
31
31
  batch_id: Float32Array | undefined;
32
32
  batch_id_size: number | undefined;
33
33
  batch_index: Uint32Array | undefined;
34
34
  batch_index_size: number | undefined;
35
35
  indices: Uint32Array;
36
+ line_segments: Float32Array | undefined;
37
+ line_distance_and_ends: Float32Array | undefined;
36
38
 
37
39
  constructor(t: ConstructedPolylineGeometry) {
38
40
  const extent = t.extent;
@@ -56,22 +58,27 @@ export class ConstructedPolylineGeometryLike {
56
58
  this.end_high_size = t.end_high_size();
57
59
  this.end_low = t.end_low()?.slice();
58
60
  this.end_low_size = t.end_low_size();
59
- this.start_normals = t.start_normals()?.slice();
60
- this.start_normals_size = t.start_normals_size();
61
- this.end_normal_and_texture_coordinate_normalization_x = t
62
- .end_normal_and_texture_coordinate_normalization_x()
61
+ this.start_normal_and_length_high = t
62
+ .start_normal_and_length_high()
63
63
  ?.slice();
64
- this.end_normal_and_texture_coordinate_normalization_x_size =
65
- t.end_normal_and_texture_coordinate_normalization_x_size();
66
- this.right_normal_and_texture_coordinate_normalization_y = t
67
- .right_normal_and_texture_coordinate_normalization_y()
64
+ this.start_normal_and_length_high_size =
65
+ t.start_normal_and_length_high_size();
66
+ this.end_normal_and_segment_length = t
67
+ .end_normal_and_segment_length()
68
+ ?.slice();
69
+ this.end_normal_and_segment_length_size =
70
+ t.end_normal_and_segment_length_size();
71
+ this.right_normal_and_line_length = t
72
+ .right_normal_and_line_length()
68
73
  .slice();
69
- this.right_normal_and_texture_coordinate_normalization_y_size =
70
- t.right_normal_and_texture_coordinate_normalization_y_size();
74
+ this.right_normal_and_line_length_size =
75
+ t.right_normal_and_line_length_size();
71
76
  this.batch_id = t.batch_id()?.slice();
72
77
  this.batch_id_size = t.batch_id_size();
73
78
  this.batch_index = t.batch_index()?.slice();
74
79
  this.batch_index_size = t.batch_index_size();
75
80
  this.indices = t.indices().slice();
81
+ this.line_segments = t.line_segments()?.slice();
82
+ this.line_distance_and_ends = t.line_distance_and_ends()?.slice();
76
83
  }
77
84
  }