@deck.gl-community/three 9.2.5 → 9.3.0-beta.1

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,156 @@ 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 unknown as number[];
241
+ const droppedColor: Color = [
242
+ c[0],
243
+ c[1],
244
+ c[2],
245
+ Math.round((c[3] ?? 255) * 0.45)
246
+ ] as unknown as Color;
247
+
248
+ for (let i = 0; i < droppedCount; i++) {
249
+ const theta = rng() * Math.PI * 2;
250
+ // sqrt for uniform-area disk sampling
251
+ const dist = Math.sqrt(rng()) * footprintRadius;
252
+
253
+ const dx = dist * Math.cos(theta);
254
+ const dy = dist * Math.sin(theta);
255
+
256
+ out.push({
257
+ position: [lng + dx * dLng, lat + dy * DEG_PER_METER_LAT, elevation + 0.05],
258
+ color: droppedColor,
259
+ scale: cropConfig.radius
260
+ });
261
+ }
262
+ }
263
+
90
264
  // ---------------------------------------------------------------------------
91
265
  // Props
92
266
  // ---------------------------------------------------------------------------
@@ -163,6 +337,23 @@ type _TreeLayerProps<DataT> = {
163
337
  */
164
338
  getBranchLevels?: (d: DataT) => number;
165
339
 
340
+ /**
341
+ * Optional crop configuration for this tree.
342
+ *
343
+ * Return a `CropConfig` to render small spherical crop points in the outer
344
+ * canopy volume (live crops) and/or scattered on the ground around the trunk
345
+ * (dropped crops). Return `null` to show no crops for this tree.
346
+ *
347
+ * The same accessor can express fruit, nuts, or flowering stage — pass
348
+ * flower-coloured points (e.g. `[255, 200, 220, 255]`) for a blossom effect.
349
+ *
350
+ * Crop positions are randomised deterministically from the tree's geographic
351
+ * coordinates; they are stable across re-renders.
352
+ *
353
+ * @default null (no crops)
354
+ */
355
+ getCrop?: (d: DataT) => CropConfig | null;
356
+
166
357
  /**
167
358
  * Global size multiplier applied to all dimensions.
168
359
  * @default 1
@@ -184,6 +375,7 @@ const defaultProps: DefaultProps<TreeLayerProps<unknown>> = {
184
375
  getCanopyColor: {type: 'accessor', value: (_d: any) => null},
185
376
  getSeason: {type: 'accessor', value: (_d: any) => 'summer' as Season},
186
377
  getBranchLevels: {type: 'accessor', value: (_d: any) => 3},
378
+ getCrop: {type: 'accessor', value: (_d: any) => null},
187
379
  sizeScale: {type: 'number', value: 1, min: 0}
188
380
  };
189
381
 
@@ -194,6 +386,8 @@ const defaultProps: DefaultProps<TreeLayerProps<unknown>> = {
194
386
  type TreeLayerState = {
195
387
  grouped: Record<TreeType, unknown[]>;
196
388
  pineMeshes: Record<number, ReturnType<typeof createPineCanopyMesh>>;
389
+ liveCropPoints: CropPoint[];
390
+ droppedCropPoints: CropPoint[];
197
391
  };
198
392
 
199
393
  // ---------------------------------------------------------------------------
@@ -216,6 +410,7 @@ type TreeLayerState = {
216
410
  * - Trunk and canopy radii (`getTrunkRadius`, `getCanopyRadius`)
217
411
  * - Explicit or season-driven colours (`getTrunkColor`, `getCanopyColor`, `getSeason`)
218
412
  * - Pine tier density (`getBranchLevels`)
413
+ * - Crop / fruit / flower visualisation (`getCrop`)
219
414
  * - Global scale factor (`sizeScale`)
220
415
  */
221
416
  export class TreeLayer<DataT = unknown, ExtraPropsT extends {} = {}> extends CompositeLayer<
@@ -229,13 +424,27 @@ export class TreeLayer<DataT = unknown, ExtraPropsT extends {} = {}> extends Com
229
424
  initializeState() {
230
425
  this.state = {
231
426
  grouped: {pine: [], oak: [], palm: [], birch: [], cherry: []},
232
- pineMeshes: {}
427
+ pineMeshes: {},
428
+ liveCropPoints: [],
429
+ droppedCropPoints: []
233
430
  };
234
431
  }
235
432
 
236
- updateState({props, changeFlags}) {
237
- if (changeFlags.dataChanged || changeFlags.updateTriggersChanged) {
238
- const {data, getTreeType, getBranchLevels} = props;
433
+ updateState({props, oldProps, changeFlags}) {
434
+ if (changeFlags.dataChanged || changeFlags.propsChanged || changeFlags.updateTriggersChanged) {
435
+ const {
436
+ data,
437
+ getTreeType,
438
+ getBranchLevels,
439
+ getCrop,
440
+ getPosition,
441
+ getElevation,
442
+ getHeight,
443
+ getTrunkHeightFraction,
444
+ getCanopyRadius,
445
+ sizeScale
446
+ } = props;
447
+
239
448
  const grouped: Record<TreeType, DataT[]> = {
240
449
  pine: [],
241
450
  oak: [],
@@ -244,24 +453,71 @@ export class TreeLayer<DataT = unknown, ExtraPropsT extends {} = {}> extends Com
244
453
  cherry: []
245
454
  };
246
455
 
247
- // Build per-level pine mesh cache
248
456
  const pineMeshes: Record<number, ReturnType<typeof createPineCanopyMesh>> = {};
457
+ const liveCropPoints: CropPoint[] = [];
458
+ const droppedCropPoints: CropPoint[] = [];
249
459
 
250
460
  for (const d of data as DataT[]) {
251
461
  const type = getTreeType(d) as TreeType;
252
462
  if (grouped[type]) grouped[type].push(d);
463
+
253
464
  if (type === 'pine') {
254
465
  const levels = Math.max(1, Math.min(5, Math.round(getBranchLevels(d) as number)));
255
466
  pineMeshes[levels] ??= createPineCanopyMesh(levels);
256
467
  }
468
+
469
+ const cropConfig = getCrop(d);
470
+ if (cropConfig) {
471
+ const pos = getPosition(d);
472
+ const lng = pos[0];
473
+ const lat = pos[1];
474
+ const elev = getElevation(d) || 0;
475
+ const h = getHeight(d) * sizeScale;
476
+ const f = getTrunkHeightFraction(d);
477
+ const r = getCanopyRadius(d) * sizeScale;
478
+
479
+ // Scale crop radius in lock-step with all other dimensions
480
+ const scaledCropConfig: CropConfig = {
481
+ ...cropConfig,
482
+ radius: cropConfig.radius * sizeScale
483
+ };
484
+ expandLiveCropPoints({
485
+ lng,
486
+ lat,
487
+ elevation: elev,
488
+ height: h,
489
+ trunkFraction: f,
490
+ canopyRadius: r,
491
+ cropConfig: scaledCropConfig,
492
+ out: liveCropPoints
493
+ });
494
+ expandDroppedCropPoints({
495
+ lng,
496
+ lat,
497
+ elevation: elev,
498
+ canopyRadius: r,
499
+ cropConfig: scaledCropConfig,
500
+ out: droppedCropPoints
501
+ });
502
+ }
257
503
  }
258
504
 
259
- this.setState({grouped, pineMeshes});
505
+ this.setState({grouped, pineMeshes, liveCropPoints, droppedCropPoints});
260
506
  }
261
507
  }
262
508
 
263
- /** Build a single canopy sub-layer for one tree type. */
264
- private _buildCanopyLayer(type: TreeType) {
509
+ /**
510
+ * Build a single canopy sub-layer.
511
+ *
512
+ * Takes explicit `mesh`, `data`, and `layerId` so that pine trees can be
513
+ * split into one sub-layer per level count (each with its own mesh).
514
+ */
515
+ private _buildCanopyLayer(
516
+ type: TreeType,
517
+ mesh: ReturnType<typeof createTrunkMesh>,
518
+ data: unknown[],
519
+ layerId: string
520
+ ): SimpleMeshLayer {
265
521
  const {
266
522
  getPosition,
267
523
  getElevation,
@@ -271,32 +527,42 @@ export class TreeLayer<DataT = unknown, ExtraPropsT extends {} = {}> extends Com
271
527
  getCanopyColor,
272
528
  getSeason,
273
529
  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
- }
530
+ } = this.props;
282
531
 
283
532
  return new SimpleMeshLayer(
284
533
  this.getSubLayerProps({
285
- id: `canopy-${type}`,
286
- data: grouped[type],
534
+ id: layerId,
535
+ data,
287
536
  mesh,
288
537
  getPosition: (d) => {
289
538
  const pos = getPosition(d);
290
539
  const elevation = getElevation(d) || 0;
291
540
  const h = getHeight(d) * sizeScale;
292
541
  const f = getTrunkHeightFraction(d);
293
- return [pos[0], pos[1], elevation + h * f];
542
+ const canopyH = h * (1 - f);
543
+ return [pos[0], pos[1], elevation + h * f - canopyH * CANOPY_TRUNK_OVERLAP];
294
544
  },
295
545
  getScale: (d) => {
546
+ const pos = getPosition(d);
296
547
  const h = getHeight(d) * sizeScale;
297
548
  const f = getTrunkHeightFraction(d);
298
549
  const r = getCanopyRadius(d) * sizeScale;
299
- return [r, r, h * (1 - f)];
550
+ // Per-tree asymmetric XY scale from position hash — no two canopies
551
+ // are the same oval, giving organic variety with zero extra draw calls.
552
+ const seed = positionSeed(pos[0], pos[1]);
553
+ const sx = 1 + ((seed & 0xffff) / 65535 - 0.5) * 0.6;
554
+ const sy = 1 + (((seed >>> 16) & 0xffff) / 65535 - 0.5) * 0.6;
555
+ return [r * sx, r * sy, h * (1 - f)];
556
+ },
557
+ getOrientation: (d) => {
558
+ // Random bearing + slight pitch per tree so spherical canopies show
559
+ // visible lean variety at typical farm viewing angles (30–60° pitch).
560
+ // pitch [0]: ±12°, yaw [1]: full 360°, roll [2]: 0
561
+ const pos = getPosition(d);
562
+ const seed = positionSeed(pos[0], pos[1]);
563
+ const yaw = (((seed ^ (seed >>> 13)) & 0xffff) / 65535) * 360;
564
+ const pitch = (((seed ^ (seed >>> 7)) & 0xff) / 255 - 0.5) * 24;
565
+ return [pitch, yaw, 0];
300
566
  },
301
567
  getColor: (d) => {
302
568
  const explicit = getCanopyColor(d);
@@ -305,7 +571,12 @@ export class TreeLayer<DataT = unknown, ExtraPropsT extends {} = {}> extends Com
305
571
  return DEFAULT_CANOPY_COLORS[type][season];
306
572
  },
307
573
  pickable: this.props.pickable,
308
- material: {ambient: 0.4, diffuse: 0.7, shininess: 12}
574
+ material: {ambient: 0.55, diffuse: 0.55, shininess: 0},
575
+ updateTriggers: {
576
+ getPosition: sizeScale,
577
+ getScale: sizeScale,
578
+ getOrientation: sizeScale
579
+ }
309
580
  })
310
581
  );
311
582
  }
@@ -319,10 +590,11 @@ export class TreeLayer<DataT = unknown, ExtraPropsT extends {} = {}> extends Com
319
590
  getTrunkHeightFraction,
320
591
  getTrunkRadius,
321
592
  getTrunkColor,
593
+ getBranchLevels,
322
594
  sizeScale
323
595
  } = this.props;
324
596
 
325
- const {grouped} = this.state;
597
+ const {grouped, pineMeshes, liveCropPoints, droppedCropPoints} = this.state;
326
598
 
327
599
  // -----------------------------------------------------------------------
328
600
  // 1. Trunk layer — one layer for ALL tree types, shared cylinder geometry
@@ -349,17 +621,73 @@ export class TreeLayer<DataT = unknown, ExtraPropsT extends {} = {}> extends Com
349
621
  return DEFAULT_TRUNK_COLORS[type] ?? DEFAULT_TRUNK_COLORS.pine;
350
622
  },
351
623
  pickable: this.props.pickable,
352
- material: {ambient: 0.35, diffuse: 0.6, shininess: 8}
624
+ material: {ambient: 0.45, diffuse: 0.55, shininess: 4},
625
+ updateTriggers: {getScale: sizeScale}
353
626
  })
354
627
  );
355
628
 
356
629
  // -----------------------------------------------------------------------
357
- // 2. Canopy layers — one per tree type, only for trees of that type
630
+ // 2. Canopy layers
631
+ // Non-pine: one sub-layer per species.
632
+ // Pine: one sub-layer per branch-level count, each using its own mesh,
633
+ // so trees with 2/3/4 tiers never share a mismatched mesh.
358
634
  // -----------------------------------------------------------------------
359
- const canopyLayers = ALL_TREE_TYPES.filter((type) => grouped[type].length > 0).map((type) =>
360
- this._buildCanopyLayer(type)
635
+ const nonPineCanopies = ALL_TREE_TYPES.filter((t) => t !== 'pine' && grouped[t].length > 0).map(
636
+ (t) => this._buildCanopyLayer(t, CANOPY_MESHES[t], grouped[t], `canopy-${t}`)
361
637
  );
362
638
 
363
- return [trunkLayer, ...canopyLayers];
639
+ const pineCanopies = Object.entries(pineMeshes).flatMap(([levelStr, mesh]) => {
640
+ const levels = Number(levelStr);
641
+ const pineData = grouped.pine.filter(
642
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
643
+ (d) => Math.max(1, Math.min(5, Math.round(getBranchLevels(d as any)))) === levels
644
+ );
645
+ return pineData.length > 0
646
+ ? [this._buildCanopyLayer('pine', mesh, pineData, `canopy-pine-${levels}`)]
647
+ : [];
648
+ });
649
+
650
+ const canopyLayers = [...nonPineCanopies, ...pineCanopies];
651
+
652
+ // -----------------------------------------------------------------------
653
+ // 3. Crop layers — live (in canopy) and dropped (on ground)
654
+ // -----------------------------------------------------------------------
655
+ const cropLayers = [];
656
+
657
+ if (liveCropPoints.length > 0) {
658
+ cropLayers.push(
659
+ new SimpleMeshLayer(
660
+ this.getSubLayerProps({
661
+ id: 'live-crops',
662
+ data: liveCropPoints,
663
+ mesh: CROP_MESH,
664
+ getPosition: (d: CropPoint) => d.position,
665
+ getScale: (d: CropPoint) => [d.scale, d.scale, d.scale],
666
+ getColor: (d: CropPoint) => d.color,
667
+ pickable: false,
668
+ material: {ambient: 0.5, diffuse: 0.8, shininess: 40}
669
+ })
670
+ )
671
+ );
672
+ }
673
+
674
+ if (droppedCropPoints.length > 0) {
675
+ cropLayers.push(
676
+ new SimpleMeshLayer(
677
+ this.getSubLayerProps({
678
+ id: 'dropped-crops',
679
+ data: droppedCropPoints,
680
+ mesh: CROP_MESH,
681
+ getPosition: (d: CropPoint) => d.position,
682
+ getScale: (d: CropPoint) => [d.scale, d.scale, d.scale],
683
+ getColor: (d: CropPoint) => d.color,
684
+ pickable: false,
685
+ material: {ambient: 0.6, diffuse: 0.5, shininess: 10}
686
+ })
687
+ )
688
+ );
689
+ }
690
+
691
+ return [trunkLayer, ...canopyLayers, ...cropLayers];
364
692
  }
365
693
  }