@deck.gl-community/three 9.2.5 → 9.2.8

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.
@@ -11,7 +11,8 @@ import {
11
11
  createOakCanopyMesh,
12
12
  createPalmCanopyMesh,
13
13
  createBirchCanopyMesh,
14
- createCherryCanopyMesh
14
+ createCherryCanopyMesh,
15
+ createCropMesh
15
16
  } from './tree-geometry';
16
17
 
17
18
  // ---------------------------------------------------------------------------
@@ -24,6 +25,31 @@ export type TreeType = 'pine' | 'oak' | 'palm' | 'birch' | 'cherry';
24
25
  /** Season that drives default canopy colour when no explicit colour is supplied. */
25
26
  export type Season = 'spring' | 'summer' | 'autumn' | 'winter';
26
27
 
28
+ /**
29
+ * Crop configuration for a single tree.
30
+ *
31
+ * Pass this from `getCrop` to render small spherical crop points on the tree
32
+ * and/or scattered on the ground around it. Works for fruit, nuts, or flowers
33
+ * (flowering stage is expressed simply as a flower-coloured crop config).
34
+ *
35
+ * Positions are randomised deterministically from the tree's geographic
36
+ * coordinates, so they are stable across re-renders.
37
+ */
38
+ export type CropConfig = {
39
+ /** Colour of each crop sphere [r, g, b, a]. */
40
+ color: Color;
41
+ /** Number of crop spheres placed in the outer canopy volume (live/in-tree crops). */
42
+ count: number;
43
+ /**
44
+ * Number of crop spheres scattered on the ground within the canopy footprint
45
+ * (dropped/fallen crops).
46
+ * @default 0
47
+ */
48
+ droppedCount?: number;
49
+ /** Radius of each individual crop sphere in metres. */
50
+ radius: number;
51
+ };
52
+
27
53
  // ---------------------------------------------------------------------------
28
54
  // Default colours
29
55
  // ---------------------------------------------------------------------------
@@ -85,8 +111,151 @@ const CANOPY_MESHES: Record<TreeType, ReturnType<typeof createTrunkMesh>> = {
85
111
  cherry: createCherryCanopyMesh()
86
112
  };
87
113
 
114
+ const CROP_MESH = createCropMesh();
115
+
88
116
  const ALL_TREE_TYPES: TreeType[] = ['pine', 'oak', 'palm', 'birch', 'cherry'];
89
117
 
118
+ /**
119
+ * Fraction of canopy height by which the canopy mesh is lowered into the trunk.
120
+ * Hides the trunk-top disk that would otherwise peek above the canopy base.
121
+ * 0.22 means the canopy base sits 22% of canopy-height below the trunk top.
122
+ */
123
+ const CANOPY_TRUNK_OVERLAP = 0.22;
124
+
125
+ // ---------------------------------------------------------------------------
126
+ // Crop helpers
127
+ // ---------------------------------------------------------------------------
128
+
129
+ /** splitmix32 — fast, high-quality seeded PRNG returning values in [0, 1). */
130
+ function createRng(seed: number): () => number {
131
+ let s = seed >>> 0;
132
+ return (): number => {
133
+ s = (s + 0x9e3779b9) | 0;
134
+ let t = s ^ (s >>> 16);
135
+ t = Math.imul(t, 0x21f0aaad);
136
+ t ^= t >>> 15;
137
+ t = Math.imul(t, 0x735a2d97);
138
+ return ((t ^ (t >>> 15)) >>> 0) / 4294967296;
139
+ };
140
+ }
141
+
142
+ /** Deterministic integer seed derived from a geographic position. */
143
+ function positionSeed(lng: number, lat: number): number {
144
+ return ((Math.round(lng * 10000) * 92821) ^ (Math.round(lat * 10000) * 65537)) >>> 0;
145
+ }
146
+
147
+ const DEG_PER_METER_LAT = 1 / 111320;
148
+
149
+ function lngDegreesPerMeter(latDeg: number): number {
150
+ return 1 / (111320 * Math.cos((latDeg * Math.PI) / 180));
151
+ }
152
+
153
+ /** Internal flat record for a single rendered crop sphere. */
154
+ type CropPoint = {
155
+ position: [number, number, number];
156
+ color: Color;
157
+ scale: number;
158
+ };
159
+
160
+ /**
161
+ * Expand live crop positions so they straddle the canopy surface.
162
+ *
163
+ * The canopy mesh is a SphereGeometry(0.5, …) which, after SimpleMeshLayer
164
+ * applies getScale = [r, r, H], has a true XY radius of 0.5 * r and a true
165
+ * Z half-height of 0.5 * H. Crops are placed at 85–110 % of those real
166
+ * dimensions so most of each sphere sits just outside the canopy surface.
167
+ *
168
+ * Positions are seeded from the tree's geographic coordinates so they are
169
+ * stable across re-renders.
170
+ */
171
+ function expandLiveCropPoints(opts: {
172
+ lng: number;
173
+ lat: number;
174
+ elevation: number;
175
+ height: number;
176
+ trunkFraction: number;
177
+ canopyRadius: number;
178
+ cropConfig: CropConfig;
179
+ out: CropPoint[];
180
+ }): void {
181
+ const {lng, lat, elevation, height, trunkFraction, canopyRadius, cropConfig, out} = opts;
182
+ if (cropConfig.count <= 0) return;
183
+
184
+ // Actual canopy sphere radii after SimpleMeshLayer scaling.
185
+ // SphereGeometry has unit radius 0.5, so world radius = 0.5 * getScale component.
186
+ const rxy = canopyRadius * 0.5;
187
+ const canopyH = height * (1 - trunkFraction);
188
+ const rz = canopyH * 0.5;
189
+ // Canopy position is lowered by CANOPY_TRUNK_OVERLAP to hide the trunk-top disk
190
+ const canopyCenterZ = elevation + height * trunkFraction - canopyH * CANOPY_TRUNK_OVERLAP + rz;
191
+
192
+ const dLng = lngDegreesPerMeter(lat);
193
+ const rng = createRng(positionSeed(lng, lat));
194
+
195
+ for (let i = 0; i < cropConfig.count; i++) {
196
+ const theta = rng() * Math.PI * 2;
197
+ // Exclude top and bottom caps so crops never crown the canopy or hang below.
198
+ // cos(phi) in [-0.80, 0.80] → phi from ~37° to ~143° (equatorial band).
199
+ const cosPhi = -0.8 + rng() * 1.6;
200
+ const sinPhi = Math.sqrt(Math.max(0, 1 - cosPhi * cosPhi));
201
+ // 90–102 % of canopy radius: crops sit just at/inside the surface, tips barely poke out
202
+ const radFrac = 0.9 + rng() * 0.12;
203
+
204
+ const dx = rxy * radFrac * sinPhi * Math.cos(theta);
205
+ const dy = rxy * radFrac * sinPhi * Math.sin(theta);
206
+ const dz = rz * radFrac * cosPhi;
207
+
208
+ out.push({
209
+ position: [lng + dx * dLng, lat + dy * DEG_PER_METER_LAT, canopyCenterZ + dz],
210
+ color: cropConfig.color,
211
+ scale: cropConfig.radius
212
+ });
213
+ }
214
+ }
215
+
216
+ /**
217
+ * Expand dropped crop positions uniformly across the ground disk within the
218
+ * canopy footprint. Uses a separate seed offset from live crops so that
219
+ * changing `count` does not affect dropped positions.
220
+ */
221
+ function expandDroppedCropPoints(opts: {
222
+ lng: number;
223
+ lat: number;
224
+ elevation: number;
225
+ canopyRadius: number;
226
+ cropConfig: CropConfig;
227
+ out: CropPoint[];
228
+ }): void {
229
+ const {lng, lat, elevation, canopyRadius, cropConfig, out} = opts;
230
+ const droppedCount = cropConfig.droppedCount ?? 0;
231
+ if (droppedCount <= 0) return;
232
+
233
+ // Actual canopy footprint radius (see note in expandLiveCropPoints)
234
+ const footprintRadius = canopyRadius * 0.5;
235
+ const dLng = lngDegreesPerMeter(lat);
236
+ // XOR with a constant so the dropped sequence is independent of the live one
237
+ const rng = createRng(positionSeed(lng, lat) ^ 0x1a2b3c4d);
238
+
239
+ // Dropped crops are semi-transparent so they read as fallen/decaying
240
+ const c = cropConfig.color as number[];
241
+ const droppedColor: Color = [c[0], c[1], c[2], Math.round((c[3] ?? 255) * 0.45)] as Color;
242
+
243
+ for (let i = 0; i < droppedCount; i++) {
244
+ const theta = rng() * Math.PI * 2;
245
+ // sqrt for uniform-area disk sampling
246
+ const dist = Math.sqrt(rng()) * footprintRadius;
247
+
248
+ const dx = dist * Math.cos(theta);
249
+ const dy = dist * Math.sin(theta);
250
+
251
+ out.push({
252
+ position: [lng + dx * dLng, lat + dy * DEG_PER_METER_LAT, elevation + 0.05],
253
+ color: droppedColor,
254
+ scale: cropConfig.radius
255
+ });
256
+ }
257
+ }
258
+
90
259
  // ---------------------------------------------------------------------------
91
260
  // Props
92
261
  // ---------------------------------------------------------------------------
@@ -163,6 +332,23 @@ type _TreeLayerProps<DataT> = {
163
332
  */
164
333
  getBranchLevels?: (d: DataT) => number;
165
334
 
335
+ /**
336
+ * Optional crop configuration for this tree.
337
+ *
338
+ * Return a `CropConfig` to render small spherical crop points in the outer
339
+ * canopy volume (live crops) and/or scattered on the ground around the trunk
340
+ * (dropped crops). Return `null` to show no crops for this tree.
341
+ *
342
+ * The same accessor can express fruit, nuts, or flowering stage — pass
343
+ * flower-coloured points (e.g. `[255, 200, 220, 255]`) for a blossom effect.
344
+ *
345
+ * Crop positions are randomised deterministically from the tree's geographic
346
+ * coordinates; they are stable across re-renders.
347
+ *
348
+ * @default null (no crops)
349
+ */
350
+ getCrop?: (d: DataT) => CropConfig | null;
351
+
166
352
  /**
167
353
  * Global size multiplier applied to all dimensions.
168
354
  * @default 1
@@ -184,6 +370,7 @@ const defaultProps: DefaultProps<TreeLayerProps<unknown>> = {
184
370
  getCanopyColor: {type: 'accessor', value: (_d: any) => null},
185
371
  getSeason: {type: 'accessor', value: (_d: any) => 'summer' as Season},
186
372
  getBranchLevels: {type: 'accessor', value: (_d: any) => 3},
373
+ getCrop: {type: 'accessor', value: (_d: any) => null},
187
374
  sizeScale: {type: 'number', value: 1, min: 0}
188
375
  };
189
376
 
@@ -194,6 +381,8 @@ const defaultProps: DefaultProps<TreeLayerProps<unknown>> = {
194
381
  type TreeLayerState = {
195
382
  grouped: Record<TreeType, unknown[]>;
196
383
  pineMeshes: Record<number, ReturnType<typeof createPineCanopyMesh>>;
384
+ liveCropPoints: CropPoint[];
385
+ droppedCropPoints: CropPoint[];
197
386
  };
198
387
 
199
388
  // ---------------------------------------------------------------------------
@@ -216,6 +405,7 @@ type TreeLayerState = {
216
405
  * - Trunk and canopy radii (`getTrunkRadius`, `getCanopyRadius`)
217
406
  * - Explicit or season-driven colours (`getTrunkColor`, `getCanopyColor`, `getSeason`)
218
407
  * - Pine tier density (`getBranchLevels`)
408
+ * - Crop / fruit / flower visualisation (`getCrop`)
219
409
  * - Global scale factor (`sizeScale`)
220
410
  */
221
411
  export class TreeLayer<DataT = unknown, ExtraPropsT extends {} = {}> extends CompositeLayer<
@@ -229,13 +419,27 @@ export class TreeLayer<DataT = unknown, ExtraPropsT extends {} = {}> extends Com
229
419
  initializeState() {
230
420
  this.state = {
231
421
  grouped: {pine: [], oak: [], palm: [], birch: [], cherry: []},
232
- pineMeshes: {}
422
+ pineMeshes: {},
423
+ liveCropPoints: [],
424
+ droppedCropPoints: []
233
425
  };
234
426
  }
235
427
 
236
- updateState({props, changeFlags}) {
237
- if (changeFlags.dataChanged || changeFlags.updateTriggersChanged) {
238
- const {data, getTreeType, getBranchLevels} = props;
428
+ updateState({props, oldProps, changeFlags}) {
429
+ if (changeFlags.dataChanged || changeFlags.propsChanged || changeFlags.updateTriggersChanged) {
430
+ const {
431
+ data,
432
+ getTreeType,
433
+ getBranchLevels,
434
+ getCrop,
435
+ getPosition,
436
+ getElevation,
437
+ getHeight,
438
+ getTrunkHeightFraction,
439
+ getCanopyRadius,
440
+ sizeScale
441
+ } = props;
442
+
239
443
  const grouped: Record<TreeType, DataT[]> = {
240
444
  pine: [],
241
445
  oak: [],
@@ -244,24 +448,71 @@ export class TreeLayer<DataT = unknown, ExtraPropsT extends {} = {}> extends Com
244
448
  cherry: []
245
449
  };
246
450
 
247
- // Build per-level pine mesh cache
248
451
  const pineMeshes: Record<number, ReturnType<typeof createPineCanopyMesh>> = {};
452
+ const liveCropPoints: CropPoint[] = [];
453
+ const droppedCropPoints: CropPoint[] = [];
249
454
 
250
455
  for (const d of data as DataT[]) {
251
456
  const type = getTreeType(d) as TreeType;
252
457
  if (grouped[type]) grouped[type].push(d);
458
+
253
459
  if (type === 'pine') {
254
460
  const levels = Math.max(1, Math.min(5, Math.round(getBranchLevels(d) as number)));
255
461
  pineMeshes[levels] ??= createPineCanopyMesh(levels);
256
462
  }
463
+
464
+ const cropConfig = getCrop(d);
465
+ if (cropConfig) {
466
+ const pos = getPosition(d);
467
+ const lng = pos[0];
468
+ const lat = pos[1];
469
+ const elev = getElevation(d) || 0;
470
+ const h = getHeight(d) * sizeScale;
471
+ const f = getTrunkHeightFraction(d);
472
+ const r = getCanopyRadius(d) * sizeScale;
473
+
474
+ // Scale crop radius in lock-step with all other dimensions
475
+ const scaledCropConfig: CropConfig = {
476
+ ...cropConfig,
477
+ radius: cropConfig.radius * sizeScale
478
+ };
479
+ expandLiveCropPoints({
480
+ lng,
481
+ lat,
482
+ elevation: elev,
483
+ height: h,
484
+ trunkFraction: f,
485
+ canopyRadius: r,
486
+ cropConfig: scaledCropConfig,
487
+ out: liveCropPoints
488
+ });
489
+ expandDroppedCropPoints({
490
+ lng,
491
+ lat,
492
+ elevation: elev,
493
+ canopyRadius: r,
494
+ cropConfig: scaledCropConfig,
495
+ out: droppedCropPoints
496
+ });
497
+ }
257
498
  }
258
499
 
259
- this.setState({grouped, pineMeshes});
500
+ this.setState({grouped, pineMeshes, liveCropPoints, droppedCropPoints});
260
501
  }
261
502
  }
262
503
 
263
- /** Build a single canopy sub-layer for one tree type. */
264
- private _buildCanopyLayer(type: TreeType) {
504
+ /**
505
+ * Build a single canopy sub-layer.
506
+ *
507
+ * Takes explicit `mesh`, `data`, and `layerId` so that pine trees can be
508
+ * split into one sub-layer per level count (each with its own mesh).
509
+ */
510
+ private _buildCanopyLayer(
511
+ type: TreeType,
512
+ mesh: ReturnType<typeof createTrunkMesh>,
513
+ data: unknown[],
514
+ layerId: string
515
+ ): SimpleMeshLayer {
265
516
  const {
266
517
  getPosition,
267
518
  getElevation,
@@ -271,32 +522,42 @@ export class TreeLayer<DataT = unknown, ExtraPropsT extends {} = {}> extends Com
271
522
  getCanopyColor,
272
523
  getSeason,
273
524
  sizeScale
274
- } = this.props; // eslint-disable-line max-len
275
- const {grouped, pineMeshes} = this.state;
276
-
277
- let mesh = CANOPY_MESHES[type];
278
- if (type === 'pine') {
279
- const firstLevel = Object.keys(pineMeshes)[0];
280
- if (firstLevel) mesh = pineMeshes[Number(firstLevel)];
281
- }
525
+ } = this.props;
282
526
 
283
527
  return new SimpleMeshLayer(
284
528
  this.getSubLayerProps({
285
- id: `canopy-${type}`,
286
- data: grouped[type],
529
+ id: layerId,
530
+ data,
287
531
  mesh,
288
532
  getPosition: (d) => {
289
533
  const pos = getPosition(d);
290
534
  const elevation = getElevation(d) || 0;
291
535
  const h = getHeight(d) * sizeScale;
292
536
  const f = getTrunkHeightFraction(d);
293
- return [pos[0], pos[1], elevation + h * f];
537
+ const canopyH = h * (1 - f);
538
+ return [pos[0], pos[1], elevation + h * f - canopyH * CANOPY_TRUNK_OVERLAP];
294
539
  },
295
540
  getScale: (d) => {
541
+ const pos = getPosition(d);
296
542
  const h = getHeight(d) * sizeScale;
297
543
  const f = getTrunkHeightFraction(d);
298
544
  const r = getCanopyRadius(d) * sizeScale;
299
- return [r, r, h * (1 - f)];
545
+ // Per-tree asymmetric XY scale from position hash — no two canopies
546
+ // are the same oval, giving organic variety with zero extra draw calls.
547
+ const seed = positionSeed(pos[0], pos[1]);
548
+ const sx = 1 + ((seed & 0xffff) / 65535 - 0.5) * 0.3;
549
+ const sy = 1 + (((seed >>> 16) & 0xffff) / 65535 - 0.5) * 0.3;
550
+ return [r * sx, r * sy, h * (1 - f)];
551
+ },
552
+ getOrientation: (d) => {
553
+ // Random bearing per tree: yaw (index 1) rotates around the vertical
554
+ // Z axis in deck.gl's [pitch, yaw, roll] convention.
555
+ // Pine tiers face different compass directions; bumpy canopies present
556
+ // a unique silhouette from every viewing angle.
557
+ const pos = getPosition(d);
558
+ const seed = positionSeed(pos[0], pos[1]);
559
+ const angle = (((seed ^ (seed >>> 13)) & 0xffff) / 65535) * 360;
560
+ return [0, angle, 0];
300
561
  },
301
562
  getColor: (d) => {
302
563
  const explicit = getCanopyColor(d);
@@ -305,7 +566,12 @@ export class TreeLayer<DataT = unknown, ExtraPropsT extends {} = {}> extends Com
305
566
  return DEFAULT_CANOPY_COLORS[type][season];
306
567
  },
307
568
  pickable: this.props.pickable,
308
- material: {ambient: 0.4, diffuse: 0.7, shininess: 12}
569
+ material: {ambient: 0.55, diffuse: 0.55, shininess: 0},
570
+ updateTriggers: {
571
+ getPosition: sizeScale,
572
+ getScale: sizeScale,
573
+ getOrientation: sizeScale
574
+ }
309
575
  })
310
576
  );
311
577
  }
@@ -319,10 +585,11 @@ export class TreeLayer<DataT = unknown, ExtraPropsT extends {} = {}> extends Com
319
585
  getTrunkHeightFraction,
320
586
  getTrunkRadius,
321
587
  getTrunkColor,
588
+ getBranchLevels,
322
589
  sizeScale
323
590
  } = this.props;
324
591
 
325
- const {grouped} = this.state;
592
+ const {grouped, pineMeshes, liveCropPoints, droppedCropPoints} = this.state;
326
593
 
327
594
  // -----------------------------------------------------------------------
328
595
  // 1. Trunk layer — one layer for ALL tree types, shared cylinder geometry
@@ -349,17 +616,73 @@ export class TreeLayer<DataT = unknown, ExtraPropsT extends {} = {}> extends Com
349
616
  return DEFAULT_TRUNK_COLORS[type] ?? DEFAULT_TRUNK_COLORS.pine;
350
617
  },
351
618
  pickable: this.props.pickable,
352
- material: {ambient: 0.35, diffuse: 0.6, shininess: 8}
619
+ material: {ambient: 0.45, diffuse: 0.55, shininess: 4},
620
+ updateTriggers: {getScale: sizeScale}
353
621
  })
354
622
  );
355
623
 
356
624
  // -----------------------------------------------------------------------
357
- // 2. Canopy layers — one per tree type, only for trees of that type
625
+ // 2. Canopy layers
626
+ // Non-pine: one sub-layer per species.
627
+ // Pine: one sub-layer per branch-level count, each using its own mesh,
628
+ // so trees with 2/3/4 tiers never share a mismatched mesh.
358
629
  // -----------------------------------------------------------------------
359
- const canopyLayers = ALL_TREE_TYPES.filter((type) => grouped[type].length > 0).map((type) =>
360
- this._buildCanopyLayer(type)
630
+ const nonPineCanopies = ALL_TREE_TYPES.filter((t) => t !== 'pine' && grouped[t].length > 0).map(
631
+ (t) => this._buildCanopyLayer(t, CANOPY_MESHES[t], grouped[t], `canopy-${t}`)
361
632
  );
362
633
 
363
- return [trunkLayer, ...canopyLayers];
634
+ const pineCanopies = Object.entries(pineMeshes).flatMap(([levelStr, mesh]) => {
635
+ const levels = Number(levelStr);
636
+ const pineData = grouped.pine.filter(
637
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
638
+ (d) => Math.max(1, Math.min(5, Math.round(getBranchLevels(d as any)))) === levels
639
+ );
640
+ return pineData.length > 0
641
+ ? [this._buildCanopyLayer('pine', mesh, pineData, `canopy-pine-${levels}`)]
642
+ : [];
643
+ });
644
+
645
+ const canopyLayers = [...nonPineCanopies, ...pineCanopies];
646
+
647
+ // -----------------------------------------------------------------------
648
+ // 3. Crop layers — live (in canopy) and dropped (on ground)
649
+ // -----------------------------------------------------------------------
650
+ const cropLayers = [];
651
+
652
+ if (liveCropPoints.length > 0) {
653
+ cropLayers.push(
654
+ new SimpleMeshLayer(
655
+ this.getSubLayerProps({
656
+ id: 'live-crops',
657
+ data: liveCropPoints,
658
+ mesh: CROP_MESH,
659
+ getPosition: (d: CropPoint) => d.position,
660
+ getScale: (d: CropPoint) => [d.scale, d.scale, d.scale],
661
+ getColor: (d: CropPoint) => d.color,
662
+ pickable: false,
663
+ material: {ambient: 0.5, diffuse: 0.8, shininess: 40}
664
+ })
665
+ )
666
+ );
667
+ }
668
+
669
+ if (droppedCropPoints.length > 0) {
670
+ cropLayers.push(
671
+ new SimpleMeshLayer(
672
+ this.getSubLayerProps({
673
+ id: 'dropped-crops',
674
+ data: droppedCropPoints,
675
+ mesh: CROP_MESH,
676
+ getPosition: (d: CropPoint) => d.position,
677
+ getScale: (d: CropPoint) => [d.scale, d.scale, d.scale],
678
+ getColor: (d: CropPoint) => d.color,
679
+ pickable: false,
680
+ material: {ambient: 0.6, diffuse: 0.5, shininess: 10}
681
+ })
682
+ )
683
+ );
684
+ }
685
+
686
+ return [trunkLayer, ...canopyLayers, ...cropLayers];
364
687
  }
365
688
  }