@call-me-sensei/toonlab 0.1.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.
Files changed (146) hide show
  1. package/ATTRIBUTION.md +40 -0
  2. package/LICENSE +21 -0
  3. package/README.md +125 -0
  4. package/package.json +97 -0
  5. package/src/character/characterRig.js +353 -0
  6. package/src/character/freestyleSwimClip.js +541 -0
  7. package/src/character/index.js +4 -0
  8. package/src/character/modelLoader.js +433 -0
  9. package/src/core/materialRoles.js +438 -0
  10. package/src/core/presetDocuments.js +110 -0
  11. package/src/core/shaderBackend.js +24 -0
  12. package/src/debrisgen/debrisFields.js +544 -0
  13. package/src/debrisgen/debrisGenerator.js +1929 -0
  14. package/src/debrisgen/debrisPalettes.js +71 -0
  15. package/src/debrisgen/debrisPhysics.js +198 -0
  16. package/src/debrisgen/debrisPresets.js +155 -0
  17. package/src/debrisgen/debrisSettings.js +333 -0
  18. package/src/debrisgen/debrisTextures.js +380 -0
  19. package/src/debrisgen/index.js +5 -0
  20. package/src/debug/fieldValues.js +95 -0
  21. package/src/debug/index.js +7 -0
  22. package/src/debug/settingsPanel.js +170 -0
  23. package/src/environment/environmentAmbientProbe.js +160 -0
  24. package/src/environment/environmentMaterialAdapter.js +336 -0
  25. package/src/environment/environmentMaterialClassifier.js +178 -0
  26. package/src/environment/environmentPlanarReflection.js +201 -0
  27. package/src/environment/environmentPresets.js +443 -0
  28. package/src/environment/environmentRigs.js +684 -0
  29. package/src/environment/environmentSettings.js +504 -0
  30. package/src/environment/environmentShaderMaterials.js +318 -0
  31. package/src/environment/environmentSunShadowPass.js +268 -0
  32. package/src/environment/environmentTextureResolver.js +161 -0
  33. package/src/environment/environmentTimeOfDay.js +167 -0
  34. package/src/environment/environmentVertexAo.js +188 -0
  35. package/src/environment/index.js +8 -0
  36. package/src/environment/scanAssetStylize.js +103 -0
  37. package/src/index.js +21 -0
  38. package/src/loaders/index.js +1 -0
  39. package/src/post/index.js +2 -0
  40. package/src/post/postProcessing.js +1087 -0
  41. package/src/rockgen/export/glbExport.js +177 -0
  42. package/src/rockgen/heightfield/heightfieldErosion.js +7 -0
  43. package/src/rockgen/heightfield/heightfieldPatch.js +183 -0
  44. package/src/rockgen/heightfield/stylizedErosionSim.js +356 -0
  45. package/src/rockgen/index.js +14 -0
  46. package/src/rockgen/mesh/meshAttributes.js +374 -0
  47. package/src/rockgen/mesh/meshDocument.js +158 -0
  48. package/src/rockgen/mesh/surfaceNets.js +329 -0
  49. package/src/rockgen/noise/cellularNoise3.js +92 -0
  50. package/src/rockgen/noise/prng.js +62 -0
  51. package/src/rockgen/noise/simplexNoise3.js +89 -0
  52. package/src/rockgen/noise/valueNoise3.js +87 -0
  53. package/src/rockgen/rockDocument.js +255 -0
  54. package/src/rockgen/rockHelpers.js +8 -0
  55. package/src/rockgen/rockgenPresets.js +559 -0
  56. package/src/rockgen/rockgenSettings.js +756 -0
  57. package/src/rockgen/sdf/fieldCompiler.js +544 -0
  58. package/src/rockgen/sdf/sculptEdits.js +66 -0
  59. package/src/rockgen/sdf/sdfModifiers.js +45 -0
  60. package/src/rockgen/sdf/sdfOps.js +37 -0
  61. package/src/rockgen/sdf/sdfPrimitives.js +88 -0
  62. package/src/shaders-tsl/anime.js +963 -0
  63. package/src/shaders-tsl/chunks/character-color.js +64 -0
  64. package/src/shaders-tsl/chunks/character-highlights.js +174 -0
  65. package/src/shaders-tsl/chunks/character-lighting.js +309 -0
  66. package/src/shaders-tsl/chunks/character-material-maps.js +146 -0
  67. package/src/shaders-tsl/chunks/character-roles.js +52 -0
  68. package/src/shaders-tsl/chunks/character-scene-lights.js +270 -0
  69. package/src/shaders-tsl/chunks/character-shadow-color.js +61 -0
  70. package/src/shaders-tsl/chunks/character-skinning.js +140 -0
  71. package/src/shaders-tsl/chunks/environment-color.js +47 -0
  72. package/src/shaders-tsl/chunks/environment-debug.js +79 -0
  73. package/src/shaders-tsl/chunks/environment-lighting.js +260 -0
  74. package/src/shaders-tsl/chunks/environment-sun-shadow.js +87 -0
  75. package/src/shaders-tsl/chunks/foliage-fog.js +60 -0
  76. package/src/shaders-tsl/chunks/pass-depth-color.js +89 -0
  77. package/src/shaders-tsl/chunks/stylized-cloud-shadow.js +70 -0
  78. package/src/shaders-tsl/chunks/water-color.js +190 -0
  79. package/src/shaders-tsl/chunks/water-common.js +124 -0
  80. package/src/shaders-tsl/chunks/water-foam.js +93 -0
  81. package/src/shaders-tsl/chunks/water-lighting.js +116 -0
  82. package/src/shaders-tsl/chunks/water-ripple.js +64 -0
  83. package/src/shaders-tsl/chunks/water-waves.js +100 -0
  84. package/src/shaders-tsl/environment-ao-overlay.js +60 -0
  85. package/src/shaders-tsl/environment.js +680 -0
  86. package/src/shaders-tsl/flower.js +267 -0
  87. package/src/shaders-tsl/grass.js +193 -0
  88. package/src/shaders-tsl/post-composite.js +461 -0
  89. package/src/shaders-tsl/sky.js +175 -0
  90. package/src/shaders-tsl/tree-leaf.js +264 -0
  91. package/src/shaders-tsl/water-breaker.js +423 -0
  92. package/src/shaders-tsl/water-kelp.js +171 -0
  93. package/src/shaders-tsl/water-rain.js +107 -0
  94. package/src/shaders-tsl/water-simulation.js +140 -0
  95. package/src/shaders-tsl/water-splash.js +298 -0
  96. package/src/shaders-tsl/water.js +509 -0
  97. package/src/sky/index.js +2 -0
  98. package/src/sky/stylizedSky.js +341 -0
  99. package/src/toon/characterRenderPasses.js +646 -0
  100. package/src/toon/index.js +4 -0
  101. package/src/toon/settings/alphaSettings.js +170 -0
  102. package/src/toon/settings/averageShadowSettings.js +181 -0
  103. package/src/toon/settings/baseTextureSettings.js +133 -0
  104. package/src/toon/settings/celShadeSettings.js +96 -0
  105. package/src/toon/settings/contactShadowSettings.js +114 -0
  106. package/src/toon/settings/eyeHighlightSettings.js +187 -0
  107. package/src/toon/settings/faceLightingSettings.js +135 -0
  108. package/src/toon/settings/furSettings.js +112 -0
  109. package/src/toon/settings/glitterSettings.js +102 -0
  110. package/src/toon/settings/hairHighlightSettings.js +325 -0
  111. package/src/toon/settings/indirectLightSettings.js +244 -0
  112. package/src/toon/settings/localLightSettings.js +176 -0
  113. package/src/toon/settings/materialMapSettings.js +299 -0
  114. package/src/toon/settings/outlineSettings.js +263 -0
  115. package/src/toon/settings/perspectiveRemovalSettings.js +60 -0
  116. package/src/toon/settings/rimLightSettings.js +268 -0
  117. package/src/toon/settings/sceneShadowSettings.js +147 -0
  118. package/src/toon/settings/selfShadowSettings.js +221 -0
  119. package/src/toon/settings/shadowColorSettings.js +238 -0
  120. package/src/toon/settings/skinToneSettings.js +150 -0
  121. package/src/toon/settings/specularSettings.js +314 -0
  122. package/src/toon/settings/stickerSettings.js +102 -0
  123. package/src/toon/toonMaterialAdapter.js +2235 -0
  124. package/src/toon/toonSettings.js +860 -0
  125. package/src/vegetation/flowerSpecies.js +260 -0
  126. package/src/vegetation/index.js +10 -0
  127. package/src/vegetation/stylizedBush.js +136 -0
  128. package/src/vegetation/stylizedFlower.js +213 -0
  129. package/src/vegetation/stylizedFlowers.js +297 -0
  130. package/src/vegetation/stylizedGrass.js +491 -0
  131. package/src/vegetation/stylizedTree.js +2449 -0
  132. package/src/vegetation/stylizedTreeFoliage.js +691 -0
  133. package/src/vegetation/treeExport.js +300 -0
  134. package/src/vegetation/treeRecipe.js +647 -0
  135. package/src/water/index.js +2 -0
  136. package/src/water/water.js +20 -0
  137. package/src/water/waterBreakerSystem.js +591 -0
  138. package/src/water/waterInteraction.js +193 -0
  139. package/src/water/waterMaterial.js +263 -0
  140. package/src/water/waterRain.js +136 -0
  141. package/src/water/waterRippleSimulation.js +227 -0
  142. package/src/water/waterScenePasses.js +415 -0
  143. package/src/water/waterSettings.js +1138 -0
  144. package/src/water/waterSplashSystem.js +251 -0
  145. package/src/water/waterSurface.js +475 -0
  146. package/src/water/waterVegetation.js +83 -0
@@ -0,0 +1,2449 @@
1
+ import * as THREE from 'three';
2
+ import { mergeGeometries } from 'three/examples/jsm/utils/BufferGeometryUtils.js';
3
+
4
+ import {
5
+ TREE_FOLIAGE_BLOBS,
6
+ createCanopyBlobs,
7
+ createLeafSpriteTexture,
8
+ createTreeFoliageGeometry,
9
+ createTreeFoliageMaterials,
10
+ deriveCanopyPalette,
11
+ resolveCanopyColor,
12
+ setCanopyCloudShadow,
13
+ setCanopySceneShadow,
14
+ setCanopySun,
15
+ setCanopyWind,
16
+ tickCanopyTime,
17
+ } from './stylizedTreeFoliage.js';
18
+
19
+ // Modern anime-style stylized trees, fully parameterized for drop-in use:
20
+ //
21
+ // const tree = new StylizedTree({ size: 2, canopyColor: 0x4da258 });
22
+ // scene.add(tree);
23
+ // tree.update(delta); // each frame (wind/flutter)
24
+ // tree.setSun({ direction, color, sky }); // match your lighting
25
+ //
26
+ // Every visual knob is exposed with tuned defaults: trunk shape (bend, lean,
27
+ // twist, gnarl — crank gnarl/twist for bonsai-like trunks), branches, canopy
28
+ // blobs/cards/density, palette, wind. Trunk generation is deterministic per
29
+ // seed, so the same options always produce the same tree.
30
+
31
+ /**
32
+ * Converts a drawn closed outline (normalized -0.5..0.5 points) into a
33
+ * radius-per-angle profile for createBranchTubeGeometry: 24 samples of
34
+ * centroid distance, normalized to mean 1 and softly clamped so drawn
35
+ * trunk cross-sections stay buildable.
36
+ */
37
+ export function polarProfileFromOutline(outline, samples = 24) {
38
+ if (!Array.isArray(outline) || outline.length < 3) return null;
39
+ let cx = 0;
40
+ let cy = 0;
41
+ for (const [x, y] of outline) {
42
+ cx += x;
43
+ cy += y;
44
+ }
45
+ cx /= outline.length;
46
+ cy /= outline.length;
47
+ // Max centroid distance per angular slot (handles concave outlines by
48
+ // taking the reachable silhouette).
49
+ const slots = new Array(samples).fill(0);
50
+ for (const [x, y] of outline) {
51
+ const angle = Math.atan2(y - cy, x - cx);
52
+ const slot = Math.floor(((angle + Math.PI * 2) % (Math.PI * 2)) / (Math.PI * 2) * samples) % samples;
53
+ slots[slot] = Math.max(slots[slot], Math.hypot(x - cx, y - cy));
54
+ }
55
+ // Fill empty slots from neighbors, then normalize to mean 1.
56
+ for (let i = 0; i < samples; i += 1) {
57
+ if (slots[i] === 0) {
58
+ slots[i] = slots[(i + samples - 1) % samples] || slots[(i + 1) % samples] || 0.3;
59
+ }
60
+ }
61
+ const mean = slots.reduce((sum, value) => sum + value, 0) / samples;
62
+ return slots.map((value) => Math.min(Math.max(value / mean, 0.45), 1.9));
63
+ }
64
+
65
+ // Leaf-sprite cache per silhouette: rebuilds are debounced-frequent in the
66
+ // designer and the sprite is deterministic per shape, so one texture per
67
+ // distinct shape/outline is plenty.
68
+ const leafSpriteCache = new Map();
69
+ function leafSpriteForShape(leafShape) {
70
+ const shape = leafShape?.preset ?? 'teardrop';
71
+ const outline = shape === 'custom' ? leafShape?.outline ?? null : null;
72
+ const key = shape + (outline ? JSON.stringify(outline) : '');
73
+ if (!leafSpriteCache.has(key)) {
74
+ leafSpriteCache.set(key, createLeafSpriteTexture({ customOutline: outline, shape }));
75
+ }
76
+ return leafSpriteCache.get(key);
77
+ }
78
+
79
+ function seededRandom(seed) {
80
+ return (k) => {
81
+ const value = Math.sin(seed * 12.9898 + k * 78.233) * 43758.5453;
82
+ return value - Math.floor(value);
83
+ };
84
+ }
85
+
86
+ // Curved trunk along a seeded 3D spine. Returns { geometry, canopyAnchor }:
87
+ // the anchor is the spine's top, so the crown follows the trunk's lean.
88
+ //
89
+ // Shape parameters (all in meters / radians, defaults give a gentle lean):
90
+ // bend — mid-trunk bow that returns toward center (S-curve amplitude)
91
+ // lean — drift that accumulates toward the top (tree grows off vertical)
92
+ // bendDirection — world heading of the bow in radians (default: seeded)
93
+ // leanOffset — lean heading relative to the bow; Math.PI pulls the top
94
+ // exactly opposite the mid-bow, guaranteeing a serpentine S-trunk
95
+ // (the Liyue gingko silhouette) instead of leaving it to seed luck
96
+ // twist — Y-rotation of the cross-section over the full height; spirals
97
+ // the bark texture like wrung wood
98
+ // gnarl — extra high-frequency 3D wiggle + radius bulges; 0 is a clean
99
+ // park tree, 1+ reads like an old bonsai
100
+ // gnarlFrequencyXRange / gnarlFrequencyZRange — seeded min/max wave count
101
+ // of the gnarl wiggle over the trunk height, per horizontal axis
102
+ // gnarlAmplitude — meters of wiggle (and radius bulge fraction) per unit
103
+ // of gnarl
104
+ // radialGnarlFrequency — wave count of the radius bulges (old-wood
105
+ // knuckles) over the trunk height
106
+ export function createTreeTrunkGeometry({
107
+ height = 1.55,
108
+ radiusBottom = 0.19,
109
+ radiusTop = 0.085,
110
+ radialSegments = 10,
111
+ heightSegments = 14,
112
+ bend = 0.12,
113
+ lean = 0.16,
114
+ twist = 0,
115
+ gnarl = 0,
116
+ gnarlFrequencyXRange = [4.2, 7.6],
117
+ gnarlFrequencyZRange = [3.1, 6.7],
118
+ gnarlAmplitude = 0.16,
119
+ radialGnarlFrequency = 9.3,
120
+ bendDirection = null,
121
+ leanOffset = null,
122
+ branchCount = 2,
123
+ branchLength = 0.55,
124
+ branchRadius = 0.055,
125
+ seed = 1,
126
+ } = {}) {
127
+ const rand = seededRandom(seed);
128
+ const bendHeading = bendDirection ?? rand(1) * Math.PI * 2;
129
+ const leanHeading = bendHeading + (leanOffset ?? (rand(3) - 0.5) * 2.6);
130
+ const bendX = Math.cos(bendHeading);
131
+ const bendZ = Math.sin(bendHeading);
132
+ const leanX = Math.cos(leanHeading);
133
+ const leanZ = Math.sin(leanHeading);
134
+ const gnarlPhaseX = rand(7) * Math.PI * 2;
135
+ const gnarlPhaseZ = rand(8) * Math.PI * 2;
136
+ const gnarlFreqX = THREE.MathUtils.lerp(
137
+ gnarlFrequencyXRange[0], gnarlFrequencyXRange[1], rand(9));
138
+ const gnarlFreqZ = THREE.MathUtils.lerp(
139
+ gnarlFrequencyZRange[0], gnarlFrequencyZRange[1], rand(10));
140
+
141
+ // Mid-trunk bow returning toward center + accumulating lean + gnarl
142
+ // wiggle on two independent horizontal axes (a 3D snake, not a 2D arc).
143
+ // sin^2 bow: zero slope at the ground, so the base always leaves the dirt
144
+ // vertical and the curve builds above the root flare.
145
+ const bow = (t) => Math.sin(t * Math.PI) ** 2;
146
+ const spineX = (t) => bendX * bow(t) * bend +
147
+ leanX * t * t * lean +
148
+ Math.sin(t * gnarlFreqX + gnarlPhaseX) * gnarl * gnarlAmplitude * Math.min(1, t * 3);
149
+ const spineZ = (t) => bendZ * bow(t) * bend +
150
+ leanZ * t * t * lean +
151
+ Math.sin(t * gnarlFreqZ + gnarlPhaseZ) * gnarl * gnarlAmplitude * Math.min(1, t * 3);
152
+ // Taper with optional gnarl bulges (old-wood knuckles).
153
+ const radiusAt = (t) => THREE.MathUtils.lerp(radiusBottom, radiusTop, t) *
154
+ (1 + Math.sin(t * radialGnarlFrequency + gnarlPhaseX) * gnarl * gnarlAmplitude);
155
+
156
+ const trunk = new THREE.CylinderGeometry(1, 1, height, radialSegments, heightSegments);
157
+ trunk.translate(0, height / 2, 0);
158
+ const positions = trunk.attributes.position;
159
+ const vertex = new THREE.Vector3();
160
+ for (let i = 0; i < positions.count; i += 1) {
161
+ vertex.fromBufferAttribute(positions, i);
162
+ const t = THREE.MathUtils.clamp(vertex.y / height, 0, 1);
163
+ const radius = radiusAt(t);
164
+ const spin = twist * t;
165
+ const x = vertex.x * radius;
166
+ const z = vertex.z * radius;
167
+ vertex.x = x * Math.cos(spin) - z * Math.sin(spin) + spineX(t);
168
+ vertex.z = x * Math.sin(spin) + z * Math.cos(spin) + spineZ(t);
169
+ positions.setXYZ(i, vertex.x, vertex.y, vertex.z);
170
+ }
171
+ trunk.computeVertexNormals();
172
+
173
+ const pieces = [trunk];
174
+ for (let i = 0; i < branchCount; i += 1) {
175
+ const t = 0.6 + (i / Math.max(branchCount - 1, 1)) * 0.24;
176
+ const length = branchLength * (0.8 + rand(20 + i) * 0.4);
177
+ const radius = branchRadius * (0.85 + rand(30 + i) * 0.3);
178
+ const tilt = (i % 2 === 0 ? 1 : -1) * (0.7 + (rand(40 + i) - 0.5) * 0.3);
179
+ const branch = new THREE.CylinderGeometry(radius * 0.55, radius, length, 7);
180
+ branch.translate(0, length / 2, 0);
181
+ branch.rotateZ(tilt);
182
+ branch.rotateY(seed * 2.1 + i * 2.4);
183
+ branch.translate(spineX(t), t * height, spineZ(t));
184
+ pieces.push(branch);
185
+ }
186
+
187
+ const merged = mergeGeometries(pieces);
188
+ pieces.forEach((piece) => piece.dispose());
189
+ return {
190
+ geometry: merged,
191
+ canopyAnchor: new THREE.Vector3(spineX(1), height + 0.42, spineZ(1)),
192
+ };
193
+ }
194
+
195
+ // Tree skeleton grown by SPACE COLONIZATION (Runions et al. 2007) — the same
196
+ // family of growth algorithms behind SpeedTree/UE5-style foliage. Attraction
197
+ // points fill the crown volume (the blob layout); the trunk grows toward
198
+ // them, forking and curving organically wherever points pull in different
199
+ // directions; branch radii follow the pipe model (a parent's cross-section
200
+ // carries its children's), so the trunk tapers into limbs into twigs instead
201
+ // of a straight pole with stubs. Returns:
202
+ // geometry — merged bark mesh (trunk + every limb and twig)
203
+ // canopyAnchor — crown center in trunk space (where the canopy mesh goes)
204
+ // attachments — twig tips in CANOPY-LOCAL space; feed to
205
+ // createTreeFoliageGeometry so each leaf tuft grows off wood
206
+ // Trunk style (bend/lean/twist/gnarl from TREE_TRUNK_STYLES) is applied as a
207
+ // post-growth deform of the whole skeleton, so bonsai twists carry the crown
208
+ // with them.
209
+ export function createTreeSkeleton({
210
+ trunk = {},
211
+ blobs = TREE_FOLIAGE_BLOBS,
212
+ canopyScale = 0.85,
213
+ // Growth controls (trunk-space meters). Tuned for MAJOR LIMBS ONLY:
214
+ // Modern anime-style trees show a trunk forking into a few clean limbs that
215
+ // vanish into a solid leaf mass — never an interior twig lattice (interior
216
+ // twigs only poke through the foliage as dark clutter).
217
+ attractionCount = 90,
218
+ segmentLength = 0.3,
219
+ influenceRadius = 1.2,
220
+ killRadius = 0.42,
221
+ maxSteps = 48,
222
+ maxNodes = 140,
223
+ // Bark mesh controls.
224
+ radialSegments = 8,
225
+ tipRadius = 0.03,
226
+ minLimbRadius = 0.028, // twigs thinner than this are left to the leaves
227
+ attachmentTwigRadius = 0.09, // nodes thinner than this sprout leaf tufts
228
+ // 'canopy' (default) dresses every crown-interior limb so the crown reads
229
+ // as one solid leaf mass. 'tips' puts leaves ONLY at the branch ends —
230
+ // limbs grow farther out and stay bare, ending in bushes (the Sumeru
231
+ // bare-branch silhouette). Pair with the canopy's shellFill: false.
232
+ leafPlacement = 'canopy',
233
+ // How deep into each blob attraction points sample (fraction of blob
234
+ // radius). Default keeps limbs buried in the leaf mass; 'tips' reaches
235
+ // near the shell so bare limbs stretch visibly before their end bush.
236
+ attractionReach = null,
237
+ seed = 1,
238
+ } = {}) {
239
+ const {
240
+ height = 1.55,
241
+ radiusBottom = 0.19,
242
+ bend = 0.12,
243
+ lean = 0.16,
244
+ twist = 0,
245
+ gnarl = 0,
246
+ bendDirection: bendHeadingOption = null,
247
+ leanOffset = null,
248
+ } = trunk;
249
+ const rand = seededRandom(seed * 1.93 + 4.7);
250
+ const anchorY = height + 0.42;
251
+
252
+ // Attraction points: uniform inside the blob volumes, in trunk space.
253
+ const blobWeights = blobs.map((blob) => blob.radius ** 3);
254
+ const totalWeight = blobWeights.reduce((sum, w) => sum + w, 0);
255
+ const points = [];
256
+ for (let i = 0; i < attractionCount; i += 1) {
257
+ let pick = rand(i * 3.1) * totalWeight;
258
+ let blobIndex = 0;
259
+ while (pick > blobWeights[blobIndex] && blobIndex < blobs.length - 1) {
260
+ pick -= blobWeights[blobIndex];
261
+ blobIndex += 1;
262
+ }
263
+ const blob = blobs[blobIndex];
264
+ const theta = rand(i * 3.1 + 1) * Math.PI * 2;
265
+ const cosPhi = rand(i * 3.1 + 2) * 2 - 1;
266
+ const sinPhi = Math.sqrt(Math.max(0, 1 - cosPhi * cosPhi));
267
+ // Sample well inside the blob so limbs never poke out of the leaf mass
268
+ // (near the shell in tips mode, so bare limbs reach out to their bush).
269
+ const reach = attractionReach ?? (leafPlacement === 'tips' ? 0.92 : 0.65);
270
+ const radius = blob.radius * reach * Math.cbrt(rand(i * 7.7));
271
+ points.push(new THREE.Vector3(
272
+ (Math.cos(theta) * sinPhi * radius + blob.offset[0]) * canopyScale,
273
+ (cosPhi * radius + blob.offset[1]) * canopyScale + anchorY,
274
+ (Math.sin(theta) * sinPhi * radius + blob.offset[2]) * canopyScale,
275
+ ));
276
+ }
277
+
278
+ // Grow. Each node: { position, direction, parent, childCount }.
279
+ const nodes = [{
280
+ position: new THREE.Vector3(0, 0, 0),
281
+ direction: new THREE.Vector3(0, 1, 0),
282
+ parent: -1,
283
+ childCount: 0,
284
+ }];
285
+ const centroid = new THREE.Vector3();
286
+ const accumulator = nodes.map(() => new THREE.Vector3());
287
+ const spawn = (parentIndex, direction, stepKey) => {
288
+ const parent = nodes[parentIndex];
289
+ // Smoothness is a style choice: gnarl 0 grows clean elegant curves
290
+ // (swooping Liyue-style trunks), higher gnarl grows knotted wood.
291
+ const jitter = new THREE.Vector3(
292
+ rand(stepKey) - 0.5, (rand(stepKey + 1) - 0.5) * 0.5, rand(stepKey + 2) - 0.5,
293
+ ).multiplyScalar(0.08 + gnarl * 0.55);
294
+ const grown = direction.clone().add(jitter).normalize();
295
+ nodes.push({
296
+ position: parent.position.clone().addScaledVector(grown, segmentLength),
297
+ direction: grown,
298
+ parent: parentIndex,
299
+ childCount: 0,
300
+ });
301
+ parent.childCount += 1;
302
+ accumulator.push(new THREE.Vector3());
303
+ };
304
+
305
+ for (let step = 0; step < maxSteps && points.length > 6 && nodes.length < maxNodes; step += 1) {
306
+ accumulator.forEach((a) => a.set(0, 0, 0));
307
+ const influenced = new Array(nodes.length).fill(0);
308
+ let anyInfluence = false;
309
+ for (const point of points) {
310
+ let nearest = -1;
311
+ let nearestDistance = influenceRadius;
312
+ for (let n = 0; n < nodes.length; n += 1) {
313
+ const distance = point.distanceTo(nodes[n].position);
314
+ if (distance < nearestDistance) {
315
+ nearestDistance = distance;
316
+ nearest = n;
317
+ }
318
+ }
319
+ if (nearest >= 0) {
320
+ accumulator[nearest].add(
321
+ point.clone().sub(nodes[nearest].position).normalize());
322
+ influenced[nearest] += 1;
323
+ anyInfluence = true;
324
+ }
325
+ }
326
+
327
+ if (!anyInfluence) {
328
+ // Bootstrap: no point in reach yet — extend the highest tip toward the
329
+ // remaining crown mass (this is what forms the lower bole).
330
+ centroid.set(0, 0, 0);
331
+ points.forEach((p) => centroid.add(p));
332
+ centroid.divideScalar(points.length);
333
+ let top = 0;
334
+ nodes.forEach((node, index) => {
335
+ if (node.position.y > nodes[top].position.y) top = index;
336
+ });
337
+ spawn(top, centroid.clone().sub(nodes[top].position).normalize(), step * 13.7);
338
+ continue;
339
+ }
340
+
341
+ const nodeCount = nodes.length;
342
+ for (let n = 0; n < nodeCount && nodes.length < maxNodes; n += 1) {
343
+ if (!influenced[n]) continue;
344
+ // Slight upward bias keeps growth arching instead of drooping.
345
+ const direction = accumulator[n].divideScalar(influenced[n])
346
+ .addScaledVector(nodes[n].direction, 0.35)
347
+ .add(new THREE.Vector3(0, 0.08, 0));
348
+ spawn(n, direction.normalize(), step * 13.7 + n * 3.3);
349
+ }
350
+
351
+ for (let p = points.length - 1; p >= 0; p -= 1) {
352
+ for (let n = nodeCount; n < nodes.length; n += 1) {
353
+ if (points[p].distanceTo(nodes[n].position) < killRadius) {
354
+ points.splice(p, 1);
355
+ break;
356
+ }
357
+ }
358
+ }
359
+ }
360
+
361
+ // Pipe-model radii: tips are thin; a parent's cross-section carries the
362
+ // sum of its children's. Normalized so the root hits radiusBottom.
363
+ const radii = new Float32Array(nodes.length).fill(0);
364
+ for (let n = nodes.length - 1; n >= 0; n -= 1) {
365
+ if (radii[n] === 0) radii[n] = tipRadius;
366
+ const parent = nodes[n].parent;
367
+ if (parent >= 0) {
368
+ radii[parent] = (radii[parent] ** 2.4 + radii[n] ** 2.4) ** (1 / 2.4);
369
+ }
370
+ }
371
+ const rootScale = radiusBottom / Math.max(radii[0], 1e-4);
372
+ radii[0] = radiusBottom;
373
+ // Strict taper: every child is thinner than its parent, so the trunk
374
+ // always narrows toward the top and no branch ends in a thick stump.
375
+ // (nodes are topologically ordered — a parent always precedes its children)
376
+ for (let n = 1; n < nodes.length; n += 1) {
377
+ radii[n] = THREE.MathUtils.clamp(
378
+ radii[n] * rootScale, tipRadius * 0.8, radii[nodes[n].parent] * 0.9);
379
+ }
380
+
381
+ // Post-growth style deform: bow + lean + twist over height, so bonsai
382
+ // trunks corkscrew and the crown rides along.
383
+ const bendDirection = bendHeadingOption ?? rand(1) * Math.PI * 2;
384
+ const leanDirection = bendDirection + (leanOffset ?? (rand(3) - 0.5) * 2.6);
385
+ const deform = (position) => {
386
+ // Clamped at the anchor height: nodes inside the crown displace exactly
387
+ // like the crown itself, so limbs can never shear out of the leaf mass.
388
+ const t = THREE.MathUtils.clamp(position.y / anchorY, 0, 1);
389
+ const spin = twist * t;
390
+ const x = position.x * Math.cos(spin) - position.z * Math.sin(spin);
391
+ const z = position.x * Math.sin(spin) + position.z * Math.cos(spin);
392
+ // sin^2 bow (vertical at the ground) + accumulating lean.
393
+ const bowAmount = Math.sin(t * Math.PI) ** 2 * bend;
394
+ position.x = x + Math.cos(bendDirection) * bowAmount +
395
+ Math.cos(leanDirection) * t * t * lean;
396
+ position.z = z + Math.sin(bendDirection) * bowAmount +
397
+ Math.sin(leanDirection) * t * t * lean;
398
+ return position;
399
+ };
400
+ const anchor = deform(new THREE.Vector3(0, anchorY, 0));
401
+
402
+ // Bark mesh: one tapered tube per edge, ends overlapped to hide joints.
403
+ // Built in growth space, then every VERTEX runs through the style deform —
404
+ // with several rings per tube a strong swooping S bends the wood smoothly
405
+ // instead of kinking a polyline at the node joints.
406
+ const up = new THREE.Vector3(0, 1, 0);
407
+ // Where does VISIBLE wood end? A node whose children are all below
408
+ // minLimbRadius is a wood tip even though the skeleton continues — its
409
+ // subtree gets no tubes. Those ends must taper (not stop at a sawn-off
410
+ // cap) and, in tips mode, they are where the bushes belong.
411
+ const hasWood = nodes.map((_, n) => n > 0 && radii[n] >= minLimbRadius);
412
+ const hasWoodenChild = new Array(nodes.length).fill(false);
413
+ for (let n = 1; n < nodes.length; n += 1) {
414
+ if (hasWood[n]) hasWoodenChild[nodes[n].parent] = true;
415
+ }
416
+ const pieces = [];
417
+ for (let n = 1; n < nodes.length; n += 1) {
418
+ const node = nodes[n];
419
+ const parent = nodes[node.parent];
420
+ const direction = node.position.clone().sub(parent.position);
421
+ const length = direction.length();
422
+ if (length < 1e-4) continue;
423
+ direction.divideScalar(length);
424
+
425
+ if (hasWood[n]) {
426
+ const overlap = radii[n] * 0.8;
427
+ // Wood ends (no tube continues past this node) taper to a point
428
+ // instead of a sawn-off cap.
429
+ const topRadius = hasWoodenChild[n] ? radii[n] : tipRadius * 0.4;
430
+ const heightSegments = THREE.MathUtils.clamp(
431
+ Math.round((length + overlap) / 0.1), 2, 6);
432
+ const segment = new THREE.CylinderGeometry(
433
+ topRadius, radii[node.parent], length + overlap, radialSegments, heightSegments);
434
+ segment.translate(0, (length + overlap) / 2, 0);
435
+ // Tile bark along the branch instead of stretching one texture per
436
+ // segment (keeps bark grain consistent from bole to twig).
437
+ const uv = segment.attributes.uv;
438
+ for (let i = 0; i < uv.count; i += 1) {
439
+ uv.setY(i, uv.getY(i) * (length + overlap) * 2.2);
440
+ }
441
+ segment.applyQuaternion(new THREE.Quaternion().setFromUnitVectors(up, direction));
442
+ segment.translate(parent.position.x, parent.position.y, parent.position.z);
443
+ pieces.push(segment);
444
+ }
445
+ }
446
+ const geometry = mergeGeometries(pieces);
447
+ pieces.forEach((piece) => piece.dispose());
448
+ const barkPositions = geometry.attributes.position;
449
+ const barkVertex = new THREE.Vector3();
450
+ for (let i = 0; i < barkPositions.count; i += 1) {
451
+ barkVertex.fromBufferAttribute(barkPositions, i);
452
+ deform(barkVertex);
453
+ barkPositions.setXYZ(i, barkVertex.x, barkVertex.y, barkVertex.z);
454
+ }
455
+ geometry.computeVertexNormals();
456
+
457
+ // Leaves dress (in deformed space): thin limb sections, every terminal
458
+ // node (a thick limb that stops growing must still end in a puff, never a
459
+ // bare stub), and ANY wood inside the crown volume — a thick limb arcing
460
+ // through the canopy gets covered like everything else. Deduped so puffs
461
+ // stay chunky rather than smeared.
462
+ nodes.forEach((node) => deform(node.position));
463
+ const attachments = [];
464
+ const blobLocals = blobs.map((blob) => ({
465
+ center: new THREE.Vector3(blob.offset[0], blob.offset[1], blob.offset[2]),
466
+ radius: blob.radius,
467
+ }));
468
+ for (let n = 1; n < nodes.length; n += 1) {
469
+ const node = nodes[n];
470
+ const parent = nodes[node.parent];
471
+ const direction = node.position.clone().sub(parent.position);
472
+ if (direction.lengthSq() < 1e-8) continue;
473
+ direction.normalize();
474
+ const local = node.position.clone().sub(anchor).divideScalar(canopyScale);
475
+ const insideCrown = blobLocals.some(
476
+ (blob) => local.distanceTo(blob.center) < blob.radius * 0.85);
477
+ // 'tips': branch ends carry leaves and mid-limb wood stays bare — but
478
+ // only OUTSIDE the crown volume. Interior limb runs cresting through the
479
+ // upper canopy read as floating debris between the clouds, so they get
480
+ // dressed like canopy mode; the low sitting limbs stay naked. The end
481
+ // that matters is the end of VISIBLE wood: a bush on a culled
482
+ // (tube-less) twig node would float a full segment past the bark.
483
+ const wantsLeaves = leafPlacement === 'tips'
484
+ ? hasWood[n] && (!hasWoodenChild[n] || insideCrown)
485
+ : (radii[n] <= attachmentTwigRadius || node.childCount === 0 || insideCrown);
486
+ if (wantsLeaves) {
487
+ // Tips mode spaces bushes far apart so bare limb runs stay visible
488
+ // between them instead of the clouds merging into one solid crown.
489
+ const spacing = leafPlacement === 'tips' ? 0.85 : 0.4;
490
+ const near = attachments.find((a) => a.position.distanceTo(local) < spacing);
491
+ if (!near) {
492
+ attachments.push({ position: local, direction, merged: 1 });
493
+ } else if (leafPlacement === 'tips' && !hasWoodenChild[n]) {
494
+ // A crowded tip must still end inside foliage — dropping it leaves
495
+ // its bark stub floating bare past the neighbor's cloud. Merge it
496
+ // into that cloud by re-centering on the running average of tips.
497
+ // (Crowded INTERIOR nodes are simply dropped — pulling a shared
498
+ // bush off the tips it guards would expose them instead.)
499
+ near.merged += 1;
500
+ near.position.lerp(local, 1 / near.merged);
501
+ near.direction.lerp(direction, 1 / near.merged).normalize();
502
+ }
503
+ }
504
+ }
505
+ // Degenerate seeds can theoretically end twigless; keep the contract.
506
+ if (!attachments.length) {
507
+ attachments.push({ position: new THREE.Vector3(0, 0, 0), direction: up.clone() });
508
+ }
509
+
510
+ return { geometry, canopyAnchor: anchor, attachments };
511
+ }
512
+
513
+ // Tapered bark tube swept along an arbitrary 3D polyline (hand-drawn branch
514
+ // strokes from Tree Lab, scripted limbs, roots). Returns
515
+ // { geometry, tip, tipTangent } — tip/tipTangent are where a leaf tuft
516
+ // belongs — or null for degenerate input.
517
+ // points — [[x, y, z], ...] in tree-local space (pre-`size` scale)
518
+ export function createBranchTubeGeometry({
519
+ points = [],
520
+ radiusStart = 0.07,
521
+ radiusEnd = 0.02,
522
+ radialSegments = 7,
523
+ ringSpacing = 0.09,
524
+ // Organic cross-section: two low harmonics warp each ring away from a
525
+ // perfect circle (real trunks never are), drifting in phase along the
526
+ // run for a gentle twist. 0 restores exact circles.
527
+ irregularity = 0.14,
528
+ // Grounded stems: widen the first rings into a root flare so the base
529
+ // reads as growing FROM the ground instead of resting on it.
530
+ flareBase = false,
531
+ // Custom cross-section: radius multiplier per ring angle (from a drawn
532
+ // outline via polarProfileFromOutline). Overrides the circular profile;
533
+ // the organic warp still applies on top.
534
+ profile = null,
535
+ seed = 5,
536
+ } = {}) {
537
+ // Near-duplicate consecutive points produce degenerate tangents (the real
538
+ // sweep failure mode) — drop them before curve construction.
539
+ const filtered = [];
540
+ for (const point of points) {
541
+ const vector = Array.isArray(point)
542
+ ? new THREE.Vector3(point[0], point[1], point[2])
543
+ : new THREE.Vector3(point.x, point.y, point.z);
544
+ if (!filtered.length || filtered[filtered.length - 1].distanceToSquared(vector) > 1e-6) {
545
+ filtered.push(vector);
546
+ }
547
+ }
548
+ if (filtered.length < 2) return null;
549
+
550
+ // Centripetal parameterization avoids the cusps/loops uniform Catmull-Rom
551
+ // produces on unevenly spaced sketch points; three's computeFrenetFrames
552
+ // already minimizes rotation between rings, so no hand-rolled RMF.
553
+ const curve = new THREE.CatmullRomCurve3(filtered, false, 'centripetal');
554
+ const length = curve.getLength();
555
+ if (length < 1e-3) return null;
556
+ const segments = Math.max(3, Math.ceil(length / ringSpacing));
557
+ const frames = curve.computeFrenetFrames(segments, false);
558
+
559
+ const ringVertices = radialSegments + 1; // duplicated seam column for UVs
560
+ // Grid vertices + 2 cap centers (base + tip): tubes must read as SOLID
561
+ // wood — an open base ring shows the hollow interior the moment the
562
+ // camera looks up the trunk.
563
+ const gridCount = (segments + 1) * ringVertices;
564
+ const positions = new Float32Array((gridCount + 2) * 3);
565
+ const normals = new Float32Array((gridCount + 2) * 3);
566
+ const uvs = new Float32Array((gridCount + 2) * 2);
567
+
568
+ const phase1 = seed * 1.7;
569
+ const phase2 = seed * 2.9 + 1.1;
570
+ const center = new THREE.Vector3();
571
+ const radial = new THREE.Vector3();
572
+ for (let i = 0; i <= segments; i += 1) {
573
+ const t = i / segments;
574
+ curve.getPointAt(t, center);
575
+ // Taper along the run, closing to a near-point tip (the trunk-tip
576
+ // convention) instead of a sawn-off cap.
577
+ let radius = i === segments
578
+ ? radiusEnd * 0.3
579
+ : THREE.MathUtils.lerp(radiusStart, radiusEnd, t);
580
+ if (flareBase) {
581
+ const flareT = Math.min(t / 0.16, 1);
582
+ radius *= 1 + 0.45 * (1 - flareT * flareT * (3 - 2 * flareT));
583
+ }
584
+ for (let j = 0; j <= radialSegments; j += 1) {
585
+ const theta = (j / radialSegments) * Math.PI * 2;
586
+ // Seam column (j === radialSegments) must warp exactly like j === 0.
587
+ const warpTheta = (j % radialSegments) / radialSegments * Math.PI * 2;
588
+ let warp = 1 + irregularity * (
589
+ 0.6 * Math.sin(3 * warpTheta + phase1 + t * 2.1)
590
+ + 0.4 * Math.sin(5 * warpTheta + phase2 - t * 1.4));
591
+ if (profile) {
592
+ const slot = (warpTheta / (Math.PI * 2)) * profile.length;
593
+ const i0 = Math.floor(slot) % profile.length;
594
+ const i1 = (i0 + 1) % profile.length;
595
+ warp *= THREE.MathUtils.lerp(profile[i0], profile[i1], slot - Math.floor(slot));
596
+ }
597
+ radial.copy(frames.normals[i]).multiplyScalar(Math.cos(theta))
598
+ .addScaledVector(frames.binormals[i], Math.sin(theta));
599
+ const out = (i * ringVertices + j) * 3;
600
+ const r = radius * warp;
601
+ positions[out] = center.x + radial.x * r;
602
+ positions[out + 1] = center.y + radial.y * r;
603
+ positions[out + 2] = center.z + radial.z * r;
604
+ normals[out] = radial.x;
605
+ normals[out + 1] = radial.y;
606
+ normals[out + 2] = radial.z;
607
+ const uvOut = (i * ringVertices + j) * 2;
608
+ uvs[uvOut] = j / radialSegments;
609
+ // Tile bark along the branch (same 2.2/meter convention as the
610
+ // skeleton's limb tubes) instead of stretching one texture per branch.
611
+ uvs[uvOut + 1] = t * length * 2.2;
612
+ }
613
+ }
614
+
615
+ // Cap centers: base (t=0) and tip (t=1), normals along ∓tangent.
616
+ const baseCenterIndex = gridCount;
617
+ const tipCenterIndex = gridCount + 1;
618
+ const baseCenter = curve.getPointAt(0);
619
+ const tipCenter = curve.getPointAt(1);
620
+ const baseTangent = curve.getTangentAt(0);
621
+ const tipTangentVector = curve.getTangentAt(1);
622
+ positions.set([baseCenter.x, baseCenter.y, baseCenter.z], baseCenterIndex * 3);
623
+ positions.set([tipCenter.x, tipCenter.y, tipCenter.z], tipCenterIndex * 3);
624
+ normals.set([-baseTangent.x, -baseTangent.y, -baseTangent.z], baseCenterIndex * 3);
625
+ normals.set([tipTangentVector.x, tipTangentVector.y, tipTangentVector.z], tipCenterIndex * 3);
626
+ uvs.set([0.5, 0], baseCenterIndex * 2);
627
+ uvs.set([0.5, 1], tipCenterIndex * 2);
628
+
629
+ const indexList = [];
630
+ for (let i = 0; i < segments; i += 1) {
631
+ for (let j = 0; j < radialSegments; j += 1) {
632
+ const a = i * ringVertices + j;
633
+ const b = a + ringVertices;
634
+ indexList.push(a, b, a + 1, b, b + 1, a + 1);
635
+ }
636
+ }
637
+ // Cap fans (base winds toward -tangent, tip toward +tangent).
638
+ for (let j = 0; j < radialSegments; j += 1) {
639
+ indexList.push(baseCenterIndex, j + 1, j);
640
+ const tipRing = segments * ringVertices;
641
+ indexList.push(tipCenterIndex, tipRing + j, tipRing + j + 1);
642
+ }
643
+
644
+ const geometry = new THREE.BufferGeometry();
645
+ geometry.setIndex(new THREE.BufferAttribute(new Uint32Array(indexList), 1));
646
+ geometry.setAttribute('position', new THREE.BufferAttribute(positions, 3));
647
+ geometry.setAttribute('normal', new THREE.BufferAttribute(normals, 3));
648
+ geometry.setAttribute('uv', new THREE.BufferAttribute(uvs, 2));
649
+ return {
650
+ geometry,
651
+ tip: curve.getPointAt(1),
652
+ tipTangent: curve.getTangentAt(1),
653
+ };
654
+ }
655
+
656
+ // Recursive central-leader tree skeleton (open broadleaf silhouettes):
657
+ // the trunk runs the full height as a leader, child branches sprout along
658
+ // it at golden-angle azimuths, and each level subdivides into shorter,
659
+ // thinner children. Foliage attachments are distributed along the OUTER
660
+ // branches (not one puff per crown), giving the open, airy broadleaf
661
+ // silhouette that blob crowns can't make. Same return contract as
662
+ // createTreeSkeleton — { geometry, canopyAnchor, attachments } — so
663
+ // StylizedTree swaps generators via skeleton.generator.
664
+ // levels — recursion depth (1..4)
665
+ // childrenPerBranch— average children sprouting along each parent
666
+ // lengthRatio — child length as a fraction of its parent
667
+ // radiusRatio — child radius as a fraction of its parent
668
+ // spreadAngle — radians a child angles away from its parent
669
+ // upBias — gravitropism per growth step (negative droops)
670
+ // Recursive branching skeleton for open, realistic silhouettes, built on
671
+ // classic procedural-botany techniques (random-walk branch wander, tropism
672
+ // growth forces, stratified attachment sampling — the Weber & Penn lineage)
673
+ // and restyled for toon foliage. Branches
674
+ // are tubes of rings whose orientation evolves per section, which is where
675
+ // the organic quality comes from:
676
+ // 1. gnarliness — a random walk on the ring orientation whose amplitude
677
+ // grows as branches thin (max(1, sqrt(r0/r))): trunks stay stately,
678
+ // twigs wander.
679
+ // 2. growth force — every section steers toward a global direction with
680
+ // compliance 1/radius, clamped so it never overshoots. Positive
681
+ // strength sweeps tips skyward into a rounded broadleaf crown;
682
+ // negative droops them (pines, willows).
683
+ // 3. terminal continuation — a parent's last ring spawns the next level
684
+ // IN PLACE with the same segment count, so the trunk flows into a
685
+ // leader instead of ending in a stump; only the deepest level pinches.
686
+ // 4. stratified children/leaves — attach points are jittered within even
687
+ // slots along the parent (and around it, with a shuffled azimuth
688
+ // permutation), starting at a bare `branchStart` fraction: even
689
+ // coverage, no clumps, no spirals.
690
+ // Foliage is OUR leaf-card system: attachments stratified along the deepest
691
+ // branches plus every terminal tip.
692
+ // conifer — evergreen behavior: full taper, child length scaled by
693
+ // (1 - attach fraction) → the layered cone silhouette
694
+ // trunkSpine — optional hand-drawn trunk polyline (tree-local): level-0
695
+ // rings follow the doodle, children grow off it procedurally
696
+ export function createBranchingTreeSkeleton({
697
+ trunk = {},
698
+ seed = 1,
699
+ canopyScale = 0.85,
700
+ levels = 3,
701
+ childrenCount = 6,
702
+ branchAngle = 55,
703
+ branchStart = 0.4,
704
+ lengthRatio = 0.45,
705
+ radiusRatio = 0.7,
706
+ gnarliness = 0.15,
707
+ forceStrength = 0.02,
708
+ conifer = false,
709
+ trunkSpine = null,
710
+ radialSegments = 8,
711
+ tipRadius = 0.012,
712
+ maxBranches = 420,
713
+ leafSpacing = 0.3,
714
+ leafStart = 0.15,
715
+ maxAttachments = 380,
716
+ } = {}) {
717
+ const { height = 1.55, radiusBottom = 0.19, gnarl = 0, lean = 0 } = trunk;
718
+ const rand = seededRandom(seed * 4.87 + 2.3);
719
+ let randKey = 0;
720
+ const next = () => rand((randKey += 1) * 1.93);
721
+ const range = (max, min = 0) => min + next() * (max - min);
722
+
723
+ const maxLevel = THREE.MathUtils.clamp(Math.round(levels), 1, 4);
724
+ const spinePoints = Array.isArray(trunkSpine) && trunkSpine.length >= 2
725
+ ? trunkSpine.map((p) => new THREE.Vector3(p[0], p[1], p[2]))
726
+ : null;
727
+ const trunkLength = spinePoints
728
+ ? spinePoints.reduce((sum, p, i) => (i ? sum + p.distanceTo(spinePoints[i - 1]) : 0), 0)
729
+ : height;
730
+
731
+ // Per-level tables derived from the flat sliders (recipes stay compact;
732
+ // the curves echo real broadleaf proportions).
733
+ const degToRad = THREE.MathUtils.degToRad;
734
+ const lengths = [trunkLength, trunkLength * lengthRatio,
735
+ trunkLength * lengthRatio * 0.75, trunkLength * lengthRatio * 0.3];
736
+ const childCounts = [Math.max(1, Math.round(childrenCount)),
737
+ Math.max(2, Math.round(childrenCount * 0.6)), 3, 0];
738
+ const angles = [0, degToRad(branchAngle),
739
+ degToRad(Math.min(85, branchAngle * 1.05)), degToRad(branchAngle * 0.6)];
740
+ const starts = [0, THREE.MathUtils.clamp(branchStart, 0, 0.9), 0.25, 0.15];
741
+ const gnarlLevels = [gnarliness * 0.25 + gnarl * 0.08, gnarliness,
742
+ gnarliness * 1.2, gnarliness * 0.7];
743
+ const tapers = conifer ? [1, 1, 1, 1] : [0.72, 0.68, 0.78, 0.88];
744
+ const sectionCounts = [10, 7, 5, 4];
745
+ const segmentCounts = [Math.max(6, radialSegments),
746
+ Math.max(5, radialSegments - 2), 4, 4];
747
+
748
+ // Shared tube builder state (one merged geometry for all branches).
749
+ const positions = [];
750
+ const normals = [];
751
+ const uvs = [];
752
+ const indices = [];
753
+ const rawAttachments = [];
754
+ let branchBudget = maxBranches;
755
+
756
+ const UP = new THREE.Vector3(0, 1, 0);
757
+ const workVector = new THREE.Vector3();
758
+ const workAxis = new THREE.Vector3();
759
+ const workQuaternion = new THREE.Quaternion();
760
+
761
+ // { origin, quaternion, length, radius, level, segments } — BFS like EZ.
762
+ const queue = [];
763
+
764
+ const buildBranch = (branch) => {
765
+ const level = branch.level;
766
+ const table = Math.min(level, 3);
767
+ const sectionCount = sectionCounts[table];
768
+ const segments = branch.segments ?? segmentCounts[table];
769
+ const taper = tapers[table];
770
+ const sectionLength = branch.length / sectionCount;
771
+ const isLeafLevel = level >= maxLevel;
772
+
773
+ const origin = branch.origin.clone();
774
+ const orientation = branch.quaternion.clone();
775
+ const rings = [];
776
+ const vertexBase = positions.length / 3;
777
+ let travelled = 0;
778
+
779
+ // Hand-drawn trunk: rings follow the doodle polyline instead of the
780
+ // procedural walk; children still attach along it like any branch.
781
+ const spine = level === 0 ? spinePoints : null;
782
+ const spineSampler = spine ? (fraction) => {
783
+ const total = trunkLength * fraction;
784
+ let acc = 0;
785
+ for (let i = 1; i < spine.length; i += 1) {
786
+ const span = spine[i].distanceTo(spine[i - 1]);
787
+ if (acc + span >= total || i === spine.length - 1) {
788
+ const local = THREE.MathUtils.clamp((total - acc) / Math.max(span, 1e-6), 0, 1);
789
+ return {
790
+ point: spine[i - 1].clone().lerp(spine[i], local),
791
+ tangent: spine[i].clone().sub(spine[i - 1]).normalize(),
792
+ };
793
+ }
794
+ acc += span;
795
+ }
796
+ return { point: spine[spine.length - 1].clone(), tangent: UP.clone() };
797
+ } : null;
798
+
799
+ for (let i = 0; i <= sectionCount; i += 1) {
800
+ const t = i / sectionCount;
801
+ let ringRadius = i === sectionCount && isLeafLevel
802
+ ? tipRadius * 0.3
803
+ : Math.max(tipRadius * 0.5, branch.radius * (1 - taper * t));
804
+
805
+ if (spineSampler) {
806
+ const sample = spineSampler(t);
807
+ origin.copy(sample.point);
808
+ workQuaternion.setFromUnitVectors(UP, sample.tangent);
809
+ orientation.copy(workQuaternion);
810
+ }
811
+ rings.push({
812
+ origin: origin.clone(),
813
+ quaternion: orientation.clone(),
814
+ radius: ringRadius,
815
+ });
816
+
817
+ // Ring vertices: pure radial normals, bark v tiles with arc length.
818
+ for (let j = 0; j <= segments; j += 1) {
819
+ const angle = (j / segments) * Math.PI * 2;
820
+ workVector.set(Math.cos(angle), 0, Math.sin(angle));
821
+ workVector.applyQuaternion(orientation);
822
+ normals.push(workVector.x, workVector.y, workVector.z);
823
+ positions.push(
824
+ origin.x + workVector.x * ringRadius,
825
+ origin.y + workVector.y * ringRadius,
826
+ origin.z + workVector.z * ringRadius,
827
+ );
828
+ uvs.push(j / segments, travelled * 2.2);
829
+ }
830
+
831
+ if (i === sectionCount) break;
832
+ travelled += sectionLength;
833
+
834
+ if (!spineSampler) {
835
+ // Advance the growth state — the core growth loop.
836
+ workVector.set(0, sectionLength, 0).applyQuaternion(orientation);
837
+ origin.add(workVector);
838
+
839
+ // 1. Gnarliness random walk, amplified as the branch thins.
840
+ const wobble = gnarlLevels[table] *
841
+ Math.max(1, Math.sqrt(radiusBottom / Math.max(ringRadius, 1e-4)));
842
+ workQuaternion.setFromAxisAngle(
843
+ workAxis.set(1, 0, 0), range(wobble, -wobble));
844
+ orientation.multiply(workQuaternion);
845
+ workQuaternion.setFromAxisAngle(
846
+ workAxis.set(0, 0, 1), range(wobble, -wobble));
847
+ orientation.multiply(workQuaternion);
848
+
849
+ // 2. Growth force: steer toward straight up with 1/radius
850
+ // compliance, clamped so thin twigs never overshoot. Negative
851
+ // strength pushes away (droop).
852
+ workVector.copy(UP).applyQuaternion(orientation);
853
+ workAxis.crossVectors(workVector, UP);
854
+ const sinFull = workAxis.length();
855
+ if (sinFull > 1e-6) {
856
+ const fullAngle = Math.atan2(sinFull, workVector.dot(UP));
857
+ const step = THREE.MathUtils.clamp(
858
+ forceStrength * (radiusBottom / Math.max(ringRadius, 1e-3)),
859
+ -fullAngle, fullAngle);
860
+ workQuaternion.setFromAxisAngle(workAxis.divideScalar(sinFull), step);
861
+ orientation.premultiply(workQuaternion);
862
+ }
863
+ }
864
+ }
865
+
866
+ // Quad strips between consecutive rings.
867
+ for (let i = 0; i < sectionCount; i += 1) {
868
+ for (let j = 0; j < segments; j += 1) {
869
+ const a = vertexBase + i * (segments + 1) + j;
870
+ const b = a + segments + 1;
871
+ indices.push(a, b, a + 1, b, b + 1, a + 1);
872
+ }
873
+ }
874
+
875
+ // Ring sampling for children/leaves (position + orientation + radius
876
+ // interpolated between the bracketing rings).
877
+ const ringAt = (fraction) => {
878
+ const scaled = fraction * sectionCount;
879
+ const index = Math.min(Math.floor(scaled), sectionCount - 1);
880
+ const alpha = scaled - index;
881
+ const a = rings[index];
882
+ const b = rings[index + 1];
883
+ return {
884
+ origin: a.origin.clone().lerp(b.origin, alpha),
885
+ quaternion: a.quaternion.clone().slerp(b.quaternion, alpha),
886
+ radius: THREE.MathUtils.lerp(a.radius, b.radius, alpha),
887
+ };
888
+ };
889
+
890
+ if (isLeafLevel) {
891
+ // Leaves live on the deepest level: stratified along [leafStart, 1]
892
+ // plus one at the pinched tip — the open along-the-branch foliage.
893
+ const usable = branch.length * (1 - leafStart);
894
+ const leafCount = THREE.MathUtils.clamp(Math.round(usable / leafSpacing), 1, 4);
895
+ for (let i = 0; i < leafCount; i += 1) {
896
+ if (rawAttachments.length >= maxAttachments) break;
897
+ const fraction = leafStart + ((i + next()) / leafCount) * (1 - leafStart);
898
+ const ring = ringAt(Math.min(fraction, 1));
899
+ rawAttachments.push({
900
+ position: ring.origin,
901
+ direction: UP.clone().applyQuaternion(ring.quaternion),
902
+ });
903
+ }
904
+ if (rawAttachments.length < maxAttachments) {
905
+ const tip = rings[rings.length - 1];
906
+ rawAttachments.push({
907
+ position: tip.origin.clone(),
908
+ direction: UP.clone().applyQuaternion(tip.quaternion),
909
+ });
910
+ }
911
+ return;
912
+ }
913
+
914
+ // Terminal continuation: the next level takes over from the last ring
915
+ // in place, same segment count — the trunk flows into a leader.
916
+ const last = rings[rings.length - 1];
917
+ if (branchBudget > 0) {
918
+ branchBudget -= 1;
919
+ queue.push({
920
+ origin: last.origin.clone(),
921
+ quaternion: last.quaternion.clone(),
922
+ length: lengths[Math.min(level + 1, 3)] * (conifer ? 0.5 : 1),
923
+ radius: last.radius,
924
+ level: level + 1,
925
+ segments,
926
+ });
927
+ }
928
+
929
+ // Lateral children: stratified heights along [start, 1], stratified
930
+ // azimuth slots decorrelated by a Fisher-Yates shuffle.
931
+ const childLevel = level + 1;
932
+ const count = childCounts[Math.min(level, 3)];
933
+ if (!count) return;
934
+ const startFraction = starts[Math.min(childLevel, 3)];
935
+ const heightStep = (1 - startFraction) / count;
936
+ const radialOffset = next();
937
+ const slots = Array.from({ length: count }, (_, i) => i);
938
+ for (let i = slots.length - 1; i > 0; i -= 1) {
939
+ const j = Math.floor(next() * (i + 1));
940
+ [slots[i], slots[j]] = [slots[j], slots[i]];
941
+ }
942
+ for (let i = 0; i < count; i += 1) {
943
+ if (branchBudget <= 0) break;
944
+ const attachFraction = startFraction + (i + next()) * heightStep;
945
+ const ring = ringAt(Math.min(attachFraction, 1));
946
+ const azimuth = Math.PI * 2 *
947
+ (radialOffset + (slots[i] + range(0.5, -0.5)) / count);
948
+ const pitch = angles[Math.min(childLevel, 3)] * (0.9 + next() * 0.2);
949
+ const childQuaternion = ring.quaternion.clone()
950
+ .multiply(new THREE.Quaternion().setFromAxisAngle(UP, azimuth))
951
+ .multiply(new THREE.Quaternion().setFromAxisAngle(
952
+ workAxis.set(1, 0, 0), pitch));
953
+ // Conifer crowns: children shorten toward the top → layered cone.
954
+ const childLength = lengths[Math.min(childLevel, 3)] *
955
+ (conifer ? (1 - attachFraction) : 1) * (0.85 + next() * 0.3);
956
+ if (childLength < 0.08) continue;
957
+ branchBudget -= 1;
958
+ queue.push({
959
+ origin: ring.origin,
960
+ quaternion: childQuaternion,
961
+ length: childLength,
962
+ radius: Math.max(tipRadius, ring.radius * radiusRatio),
963
+ level: childLevel,
964
+ segments: null,
965
+ });
966
+ }
967
+ };
968
+
969
+ // Trunk: optional initial lean carries the classic trunk styles over.
970
+ const rootQuaternion = new THREE.Quaternion();
971
+ if (!spinePoints && lean) {
972
+ const heading = next() * Math.PI * 2;
973
+ rootQuaternion.setFromAxisAngle(
974
+ new THREE.Vector3(Math.cos(heading), 0, Math.sin(heading)), lean * 0.45);
975
+ }
976
+ queue.push({
977
+ origin: new THREE.Vector3(0, 0, 0),
978
+ quaternion: rootQuaternion,
979
+ length: trunkLength,
980
+ radius: radiusBottom,
981
+ level: 0,
982
+ segments: null,
983
+ });
984
+ while (queue.length) buildBranch(queue.shift());
985
+
986
+ const geometry = new THREE.BufferGeometry();
987
+ geometry.setIndex(indices);
988
+ geometry.setAttribute('position', new THREE.Float32BufferAttribute(positions, 3));
989
+ geometry.setAttribute('normal', new THREE.Float32BufferAttribute(normals, 3));
990
+ geometry.setAttribute('uv', new THREE.Float32BufferAttribute(uvs, 2));
991
+
992
+ const anchor = new THREE.Vector3();
993
+ rawAttachments.forEach((attachment) => anchor.add(attachment.position));
994
+ anchor.divideScalar(Math.max(rawAttachments.length, 1));
995
+ const attachments = rawAttachments.map((attachment) => ({
996
+ position: attachment.position.clone().sub(anchor).divideScalar(canopyScale),
997
+ direction: attachment.direction,
998
+ }));
999
+ if (!attachments.length) {
1000
+ attachments.push({ position: new THREE.Vector3(), direction: new THREE.Vector3(0, 1, 0) });
1001
+ }
1002
+ return { geometry, canopyAnchor: anchor, attachments };
1003
+ }
1004
+
1005
+ // A few ready-made trunk personalities. Spread one into trunk options and
1006
+ // override from there: { ...TREE_TRUNK_STYLES.gnarled, seed: 4 }.
1007
+ export const TREE_TRUNK_STYLES = Object.freeze({
1008
+ straight: { bend: 0.04, lean: 0.05, twist: 0, gnarl: 0 },
1009
+ leaning: { bend: 0.12, lean: 0.22, twist: 0, gnarl: 0 },
1010
+ curved: { bend: 0.2, lean: 0.12, twist: 0.4, gnarl: 0.25 },
1011
+ gnarled: { bend: 0.16, lean: 0.18, twist: 1.2, gnarl: 0.8 },
1012
+ bonsai: { bend: 0.26, lean: 0.3, twist: 2.2, gnarl: 1.25, height: 1.2, radiusBottom: 0.24 },
1013
+ // The dramatic Liyue silhouette: one smooth serpentine trunk — a hard
1014
+ // mid-bow one way, the top swept far back the other (leanOffset PI pins
1015
+ // the reversal), crown carried well off the base.
1016
+ swooping: { bend: 0.6, lean: 0.85, twist: 0.5, gnarl: 0, leanOffset: Math.PI,
1017
+ height: 1.8, radiusBottom: 0.24 },
1018
+ });
1019
+
1020
+ // Ready-made example recipes, ordered least → most complex configuration.
1021
+ // Used by Tree Lab and the playground scene's showcase row; each is a
1022
+ // complete options object for `new StylizedTree(...)`.
1023
+ export const STYLIZED_TREE_EXAMPLES = Object.freeze([
1024
+ // 1. Baseline: straight trunk, default crown, one flat color.
1025
+ { seed: 3, size: 1.7, canopyColor: 0x4da258, leafDensity: 1,
1026
+ trunk: TREE_TRUNK_STYLES.straight },
1027
+ // 2. Leaning trunk, same simple crown.
1028
+ { seed: 8, size: 1.8, canopyColor: 0x54a85e, leafDensity: 1,
1029
+ trunk: TREE_TRUNK_STYLES.leaning },
1030
+ // 3. Slight see-through: gap pockets open, branches peek through.
1031
+ { seed: 5, size: 1.9, canopyColor: 0x5eb063, leafDensity: 0.85,
1032
+ trunk: TREE_TRUNK_STYLES.leaning },
1033
+ // 4. Curved trunk + its own irregular crown layout.
1034
+ { seed: 11, size: 2.0, canopyColor: 0x58ab5c, leafDensity: 0.95,
1035
+ trunk: TREE_TRUNK_STYLES.curved },
1036
+ // 5. Color picked from a list, per-seed (forest variation from one spec).
1037
+ { seed: 17, size: 2.0, canopyColor: [0x4da258, 0x7fb84e, 0x9cbf46], leafDensity: 0.95,
1038
+ trunk: TREE_TRUNK_STYLES.curved },
1039
+ // 6. Wide-and-shallow crown (X reach 1.6, Z reach 0.7).
1040
+ { seed: 9, size: 2.0, canopyColor: 0x6db54f, leafDensity: 0.95,
1041
+ canopyWidth: 1.6, canopyDepth: 0.7, trunk: TREE_TRUNK_STYLES.leaning },
1042
+ // 7. Autumn blend: seeded mix between two colors.
1043
+ { seed: 21, size: 2.1, canopyColor: { from: 0xe8a33c, to: 0xd96f29 }, leafDensity: 0.9,
1044
+ trunk: TREE_TRUNK_STYLES.curved },
1045
+ // 8. Gnarled old tree: knotted growth, sparser crown shows the wood.
1046
+ { seed: 14, size: 2.0, canopyColor: 0x8f9e44, leafDensity: 0.72,
1047
+ trunk: TREE_TRUNK_STYLES.gnarled },
1048
+ // 9. Bonsai: corkscrew twist, flat wide pads, HSL-range blossom color.
1049
+ { seed: 26, size: 1.7, leafDensity: 0.8, canopyWidth: 1.4, canopyDepth: 1.2,
1050
+ canopyColor: { hue: [0.9, 1.0], saturation: [0.45, 0.6], lightness: [0.62, 0.72] },
1051
+ trunk: TREE_TRUNK_STYLES.bonsai },
1052
+ // 10. The Liyue golden gingko: fat-based serpentine trunk (bow right,
1053
+ // top swept hard left, S locked by leanOffset), extra-wide crown,
1054
+ // pinned pale-gold highlight tone. bendDirection 0 keeps the S in the
1055
+ // X-Y plane so the silhouette reads head-on in the showcase row.
1056
+ { seed: 12, size: 2.4, canopyColor: 0xf5c531, canopyPalette: { crown: 0xffe98a },
1057
+ leafDensity: 0.95, canopyWidth: 1.5,
1058
+ skeleton: { radialSegments: 10 },
1059
+ trunk: { ...TREE_TRUNK_STYLES.swooping, bend: 0.5, lean: 0.95,
1060
+ bendDirection: 0, height: 2.0, radiusBottom: 0.28 } },
1061
+ // 11. Sumeru-style: long bare pale limbs reaching out of the crown with
1062
+ // violet leaf bushes only at the branch ends (leafPlacement 'tips').
1063
+ { seed: 31, size: 2.3, pale: true, canopyColor: 0x8578e6,
1064
+ canopyPalette: { crown: 0xbdb2ff },
1065
+ leafDensity: 0.9, canopyWidth: 1.45, leafPlacement: 'tips',
1066
+ trunkReceiveShadow: false,
1067
+ skeleton: { attractionCount: 70, influenceRadius: 1.35 },
1068
+ trunk: { ...TREE_TRUNK_STYLES.curved, bend: 0.3, lean: 0.35, gnarl: 0.45,
1069
+ height: 1.9, radiusBottom: 0.26 } },
1070
+ // 12. MASSIVE climbable Sumeru tree: thick bare limbs long and low enough
1071
+ // to stand or sit on (scenes read `climbable: true` and collide the
1072
+ // wood as a trimesh instead of a trunk capsule), sparse skeleton so
1073
+ // the pale limbs stay on show, foliage clouds only at the limb ends.
1074
+ { seed: 46, size: 4.0, pale: true, climbable: true,
1075
+ canopyColor: 0x8578e6, canopyPalette: { crown: 0xbdb2ff },
1076
+ leafDensity: 0.92, canopyWidth: 1.75, canopyDepth: 1.2,
1077
+ leafPlacement: 'tips', trunkReceiveShadow: false,
1078
+ skeleton: { attractionCount: 55, influenceRadius: 1.7, killRadius: 0.55,
1079
+ segmentLength: 0.36, attractionReach: 0.95, radialSegments: 14,
1080
+ tipRadius: 0.05, minLimbRadius: 0.04, maxNodes: 130 },
1081
+ canopy: { cardsPerCluster: 12, clusterRadius: 0.62 },
1082
+ trunk: { ...TREE_TRUNK_STYLES.leaning, bend: 0.24, lean: 0.42,
1083
+ height: 1.4, radiusBottom: 0.48 } },
1084
+ ]);
1085
+
1086
+ // Centered X offsets for a showcase row: cumulative spacing from each tree's
1087
+ // approximate crown footprint, so a massive example doesn't swallow its
1088
+ // neighbors the way fixed spacing would.
1089
+ export function layoutTreeRow(configs, { margin = 1.6 } = {}) {
1090
+ const footprints = configs.map((config) =>
1091
+ (config.size ?? 1) * (config.canopyWidth ?? 1) * 2.3 + 1.4);
1092
+ const offsets = [];
1093
+ let cursor = 0;
1094
+ footprints.forEach((footprint, index) => {
1095
+ if (index > 0) cursor += (footprints[index - 1] + footprint) / 2 + margin;
1096
+ offsets.push(cursor);
1097
+ });
1098
+ const center = cursor / 2;
1099
+ return offsets.map((offset) => offset - center);
1100
+ }
1101
+
1102
+ // Recipe documents: a plant serialized as { schema, version, type, options }.
1103
+ // The options are exactly what the constructor takes, so a recipe rebuilds
1104
+ // the identical plant (generation is deterministic per seed). Defined here —
1105
+ // not in treeRecipe.js — so toJSON() below has no circular import.
1106
+ export const TREE_RECIPE_SCHEMA = 'treeRecipe';
1107
+ export const TREE_RECIPE_VERSION = 1;
1108
+
1109
+ // Recursively convert constructor options to plain JSON data: THREE.Color →
1110
+ // '#hex' string (resolveCanopyColor accepts it back), vectors → arrays,
1111
+ // functions dropped.
1112
+ function toSerializable(value) {
1113
+ if (value === null || typeof value !== 'object') {
1114
+ return typeof value === 'function' ? undefined : value;
1115
+ }
1116
+ if (value.isColor) return `#${value.getHexString(THREE.SRGBColorSpace)}`;
1117
+ if (value.isVector2 || value.isVector3 || value.isVector4) return value.toArray();
1118
+ if (Array.isArray(value)) return value.map((entry) => toSerializable(entry));
1119
+ const out = {};
1120
+ for (const [key, entry] of Object.entries(value)) {
1121
+ const converted = toSerializable(entry);
1122
+ if (converted !== undefined) out[key] = converted;
1123
+ }
1124
+ return out;
1125
+ }
1126
+
1127
+ // Whitelist copy of StylizedTree/StylizedBush constructor options with the
1128
+ // live objects stripped (trunkMaterial, foliage.leafMap/sharedUniforms) —
1129
+ // everything a recipe file may carry.
1130
+ export function serializableTreeOptions(options = {}) {
1131
+ const { trunkMaterial, foliage, ...rest } = options;
1132
+ void trunkMaterial;
1133
+ const out = toSerializable(rest);
1134
+ if (foliage) {
1135
+ const { leafMap, sharedUniforms, ...foliageRest } = foliage;
1136
+ void leafMap;
1137
+ void sharedUniforms;
1138
+ const serializedFoliage = toSerializable(foliageRest);
1139
+ if (Object.keys(serializedFoliage).length) out.foliage = serializedFoliage;
1140
+ }
1141
+ return out;
1142
+ }
1143
+
1144
+ // ---------------------------------------------------------------------------
1145
+ // Tree settings: DEFAULT_STYLIZED_TREE_SETTINGS / createStylizedTreeSettings mirror the
1146
+ // StylizedTree constructor options as a grouped settings object (tree, trunk,
1147
+ // skeleton, canopy, foliage), following the toonSettings pattern. Values not
1148
+ // listed in the defaults (canopy.blobs, foliage.leafMap, foliage.
1149
+ // sharedUniforms, ...) pass through createStylizedTreeSettings untouched, so every
1150
+ // legacy option keeps working.
1151
+
1152
+ function cleanObject(value) {
1153
+ return value && typeof value === 'object' && !Array.isArray(value) ? value : {};
1154
+ }
1155
+
1156
+ function finiteNumber(value, fallback, { min = -Infinity, max = Infinity } = {}) {
1157
+ const number = Number(value);
1158
+ if (!Number.isFinite(number)) return fallback;
1159
+ return Math.min(max, Math.max(min, number));
1160
+ }
1161
+
1162
+ function integerNumber(value, fallback, options) {
1163
+ return Math.round(finiteNumber(value, fallback, options));
1164
+ }
1165
+
1166
+ // Numbers where `null` is a meaningful "seeded / automatic" default.
1167
+ function nullableNumber(value, fallback, options) {
1168
+ if (value === undefined) return fallback;
1169
+ if (value === null) return null;
1170
+ const number = Number(value);
1171
+ return Number.isFinite(number) ? finiteNumber(number, fallback, options) : fallback;
1172
+ }
1173
+
1174
+ function booleanOption(value, fallback) {
1175
+ return value === undefined ? fallback : Boolean(value);
1176
+ }
1177
+
1178
+ function colorArray(value, fallback) {
1179
+ if (value?.isColor) return [value.r, value.g, value.b];
1180
+ if (Array.isArray(value) && value.length >= 3) {
1181
+ const next = value.slice(0, 3).map(Number);
1182
+ return next.every(Number.isFinite) ? next : fallback.slice();
1183
+ }
1184
+ if (typeof value === 'number' || typeof value === 'string') {
1185
+ try {
1186
+ const color = new THREE.Color(value);
1187
+ return [color.r, color.g, color.b];
1188
+ } catch {
1189
+ return fallback.slice();
1190
+ }
1191
+ }
1192
+ return fallback.slice();
1193
+ }
1194
+
1195
+ function vectorArray(value, fallback, size) {
1196
+ const keys = ['x', 'y', 'z', 'w'];
1197
+ const read = (index) => {
1198
+ if (Array.isArray(value)) return Number(value[index]);
1199
+ if (value && typeof value === 'object') return Number(value[keys[index]]);
1200
+ return NaN;
1201
+ };
1202
+ const next = Array.from({ length: size }, (_, index) => read(index));
1203
+ return next.every(Number.isFinite) ? next : fallback.slice(0, size);
1204
+ }
1205
+
1206
+ /**
1207
+ * Default StylizedTree settings, grouped as { tree, trunk, skeleton, canopy,
1208
+ * foliage }. Every value equals the historical hardcoded/parameter default,
1209
+ * so `new StylizedTree()` renders identically to previous releases.
1210
+ *
1211
+ * Groups `trunk`, `skeleton`, and `canopy` bake geometry and are
1212
+ * construction-only; the `foliage` group (and the tree palette/
1213
+ * trunkReceiveShadow) can be re-applied at runtime via
1214
+ * `StylizedTree#applySettings`.
1215
+ */
1216
+ export const DEFAULT_STYLIZED_TREE_SETTINGS = Object.freeze({
1217
+ tree: Object.freeze({
1218
+ // 0x4da258 as an sRGB triplet; accepts any resolveCanopyColor spec.
1219
+ canopyColor: Object.freeze([0x4d / 255, 0xa2 / 255, 0x58 / 255]),
1220
+ canopyDepth: 1,
1221
+ canopyLayout: Object.freeze({}),
1222
+ canopyPalette: Object.freeze({}),
1223
+ canopyScale: 1,
1224
+ canopyWidth: 1,
1225
+ leafDensity: 1,
1226
+ leafPlacement: 'canopy',
1227
+ seed: 1,
1228
+ size: 1,
1229
+ trunkReceiveShadow: true,
1230
+ }),
1231
+ trunk: Object.freeze({
1232
+ bend: 0.12,
1233
+ bendDirection: null,
1234
+ branchCount: 2,
1235
+ branchLength: 0.55,
1236
+ branchRadius: 0.055,
1237
+ gnarl: 0,
1238
+ gnarlAmplitude: 0.16,
1239
+ gnarlFrequencyXRange: Object.freeze([4.2, 7.6]),
1240
+ gnarlFrequencyZRange: Object.freeze([3.1, 6.7]),
1241
+ height: 1.55,
1242
+ heightSegments: 14,
1243
+ lean: 0.16,
1244
+ leanOffset: null,
1245
+ radialGnarlFrequency: 9.3,
1246
+ radialSegments: 10,
1247
+ radiusBottom: 0.19,
1248
+ radiusTop: 0.085,
1249
+ twist: 0,
1250
+ }),
1251
+ skeleton: Object.freeze({
1252
+ attachmentTwigRadius: 0.09,
1253
+ attractionCount: 90,
1254
+ attractionReach: null,
1255
+ branchAngle: 55,
1256
+ branchStart: 0.4,
1257
+ childrenCount: 6,
1258
+ conifer: false,
1259
+ generator: 'limbs',
1260
+ influenceRadius: 1.2,
1261
+ killRadius: 0.42,
1262
+ forceStrength: 0.02,
1263
+ gnarliness: 0.15,
1264
+ lengthRatio: 0.45,
1265
+ levels: 3,
1266
+ maxNodes: 140,
1267
+ maxSteps: 48,
1268
+ minLimbRadius: 0.028,
1269
+ radialSegments: 8,
1270
+ radiusRatio: 0.7,
1271
+ segmentLength: 0.3,
1272
+ tipRadius: 0.03,
1273
+ }),
1274
+ canopy: Object.freeze({
1275
+ cardCount: 170,
1276
+ cardSizeRange: Object.freeze([1.0, 1.6]),
1277
+ cardsPerCluster: 5,
1278
+ clusterRadius: 0.48,
1279
+ shellFill: true,
1280
+ }),
1281
+ foliage: Object.freeze({
1282
+ alphaCutoff: 0.3,
1283
+ backlitStrength: 0.35,
1284
+ cloudShadowCoverage: 0.45,
1285
+ cloudShadowScale: 0.012,
1286
+ cloudShadowStrength: 0,
1287
+ cloudShadowVelocity: Object.freeze([0.02, 0.006]),
1288
+ sceneShadowStrength: 0.55,
1289
+ skyColor: Object.freeze([0.62, 0.78, 0.95]),
1290
+ sunColor: Object.freeze([1.0, 0.96, 0.84]),
1291
+ sunDirection: Object.freeze([0.35, 0.72, 0.42]),
1292
+ windDirection: Object.freeze([1, 0.3]),
1293
+ windSpeed: 1.0,
1294
+ windStrength: 0.05,
1295
+ }),
1296
+ });
1297
+
1298
+ /**
1299
+ * Validates and merges partial tree options over
1300
+ * {@link DEFAULT_STYLIZED_TREE_SETTINGS}. Accepts both the legacy flat constructor
1301
+ * shape (`{ size, seed, trunk: {...}, ... }`) and the grouped settings shape
1302
+ * (`{ tree: { size, seed }, trunk: {...}, ... }`); flat keys and the `tree`
1303
+ * group are the same fields. Unknown keys inside trunk/skeleton/canopy/
1304
+ * foliage pass through untouched (blobs, leafMap, sharedUniforms, ...), so
1305
+ * existing callers keep working. `createStylizedTreeSettings()` deep-equals the
1306
+ * defaults object.
1307
+ *
1308
+ * @param {Object} [options] Partial settings or legacy constructor options.
1309
+ * @returns {Object} A complete, plain grouped tree settings object.
1310
+ */
1311
+ export function createStylizedTreeSettings(options = {}) {
1312
+ const source = cleanObject(options);
1313
+ const treeSource = { ...source, ...cleanObject(source.tree) };
1314
+ const trunkSource = cleanObject(source.trunk);
1315
+ const skeletonSource = cleanObject(source.skeleton);
1316
+ const canopySource = cleanObject(source.canopy);
1317
+ const foliageSource = cleanObject(source.foliage);
1318
+ const base = DEFAULT_STYLIZED_TREE_SETTINGS;
1319
+
1320
+ return {
1321
+ tree: {
1322
+ canopyColor: treeSource.canopyColor !== undefined
1323
+ ? treeSource.canopyColor
1324
+ : [...base.tree.canopyColor],
1325
+ canopyDepth: finiteNumber(treeSource.canopyDepth, base.tree.canopyDepth, { min: 0.01 }),
1326
+ canopyLayout: { ...cleanObject(treeSource.canopyLayout) },
1327
+ canopyPalette: { ...cleanObject(treeSource.canopyPalette) },
1328
+ canopyScale: finiteNumber(treeSource.canopyScale, base.tree.canopyScale, { min: 0.01 }),
1329
+ canopyWidth: finiteNumber(treeSource.canopyWidth, base.tree.canopyWidth, { min: 0.01 }),
1330
+ leafDensity: finiteNumber(treeSource.leafDensity, base.tree.leafDensity, { min: 0.05, max: 2 }),
1331
+ leafPlacement: treeSource.leafPlacement === 'tips' ? 'tips' : base.tree.leafPlacement,
1332
+ seed: finiteNumber(treeSource.seed, base.tree.seed),
1333
+ size: finiteNumber(treeSource.size, base.tree.size, { min: 0.01 }),
1334
+ trunkReceiveShadow: booleanOption(treeSource.trunkReceiveShadow, base.tree.trunkReceiveShadow),
1335
+ },
1336
+ trunk: {
1337
+ ...trunkSource,
1338
+ bend: finiteNumber(trunkSource.bend, base.trunk.bend),
1339
+ bendDirection: nullableNumber(trunkSource.bendDirection, base.trunk.bendDirection),
1340
+ branchCount: integerNumber(trunkSource.branchCount, base.trunk.branchCount, { min: 0 }),
1341
+ branchLength: finiteNumber(trunkSource.branchLength, base.trunk.branchLength, { min: 0 }),
1342
+ branchRadius: finiteNumber(trunkSource.branchRadius, base.trunk.branchRadius, { min: 0 }),
1343
+ gnarl: finiteNumber(trunkSource.gnarl, base.trunk.gnarl, { min: 0 }),
1344
+ gnarlAmplitude: finiteNumber(trunkSource.gnarlAmplitude, base.trunk.gnarlAmplitude, { min: 0 }),
1345
+ gnarlFrequencyXRange: vectorArray(trunkSource.gnarlFrequencyXRange, base.trunk.gnarlFrequencyXRange, 2),
1346
+ gnarlFrequencyZRange: vectorArray(trunkSource.gnarlFrequencyZRange, base.trunk.gnarlFrequencyZRange, 2),
1347
+ height: finiteNumber(trunkSource.height, base.trunk.height, { min: 0.01 }),
1348
+ heightSegments: integerNumber(trunkSource.heightSegments, base.trunk.heightSegments, { min: 1 }),
1349
+ lean: finiteNumber(trunkSource.lean, base.trunk.lean),
1350
+ leanOffset: nullableNumber(trunkSource.leanOffset, base.trunk.leanOffset),
1351
+ radialGnarlFrequency: finiteNumber(trunkSource.radialGnarlFrequency, base.trunk.radialGnarlFrequency, { min: 0 }),
1352
+ radialSegments: integerNumber(trunkSource.radialSegments, base.trunk.radialSegments, { min: 3 }),
1353
+ radiusBottom: finiteNumber(trunkSource.radiusBottom, base.trunk.radiusBottom, { min: 0.001 }),
1354
+ radiusTop: finiteNumber(trunkSource.radiusTop, base.trunk.radiusTop, { min: 0.001 }),
1355
+ twist: finiteNumber(trunkSource.twist, base.trunk.twist),
1356
+ },
1357
+ skeleton: {
1358
+ ...skeletonSource,
1359
+ attachmentTwigRadius: finiteNumber(skeletonSource.attachmentTwigRadius, base.skeleton.attachmentTwigRadius, { min: 0 }),
1360
+ attractionCount: integerNumber(skeletonSource.attractionCount, base.skeleton.attractionCount, { min: 1 }),
1361
+ attractionReach: nullableNumber(skeletonSource.attractionReach, base.skeleton.attractionReach, { min: 0, max: 1 }),
1362
+ branchAngle: finiteNumber(skeletonSource.branchAngle, base.skeleton.branchAngle, { min: 10, max: 130 }),
1363
+ branchStart: finiteNumber(skeletonSource.branchStart, base.skeleton.branchStart, { min: 0, max: 0.9 }),
1364
+ childrenCount: finiteNumber(skeletonSource.childrenCount, base.skeleton.childrenCount, { min: 1, max: 90 }),
1365
+ conifer: booleanOption(skeletonSource.conifer, base.skeleton.conifer),
1366
+ forceStrength: finiteNumber(skeletonSource.forceStrength, base.skeleton.forceStrength, { min: -0.08, max: 0.15 }),
1367
+ gnarliness: finiteNumber(skeletonSource.gnarliness, base.skeleton.gnarliness, { min: 0, max: 0.6 }),
1368
+ generator: ['branching', 'drawn'].includes(skeletonSource.generator)
1369
+ ? skeletonSource.generator : base.skeleton.generator,
1370
+ lengthRatio: finiteNumber(skeletonSource.lengthRatio, base.skeleton.lengthRatio, { min: 0.15, max: 0.95 }),
1371
+ levels: integerNumber(skeletonSource.levels, base.skeleton.levels, { min: 1, max: 4 }),
1372
+ radiusRatio: finiteNumber(skeletonSource.radiusRatio, base.skeleton.radiusRatio, { min: 0.3, max: 0.9 }),
1373
+ influenceRadius: finiteNumber(skeletonSource.influenceRadius, base.skeleton.influenceRadius, { min: 0.01 }),
1374
+ killRadius: finiteNumber(skeletonSource.killRadius, base.skeleton.killRadius, { min: 0.01 }),
1375
+ maxNodes: integerNumber(skeletonSource.maxNodes, base.skeleton.maxNodes, { min: 2 }),
1376
+ maxSteps: integerNumber(skeletonSource.maxSteps, base.skeleton.maxSteps, { min: 1 }),
1377
+ minLimbRadius: finiteNumber(skeletonSource.minLimbRadius, base.skeleton.minLimbRadius, { min: 0 }),
1378
+ radialSegments: integerNumber(skeletonSource.radialSegments, base.skeleton.radialSegments, { min: 3 }),
1379
+ segmentLength: finiteNumber(skeletonSource.segmentLength, base.skeleton.segmentLength, { min: 0.01 }),
1380
+ tipRadius: finiteNumber(skeletonSource.tipRadius, base.skeleton.tipRadius, { min: 0.001 }),
1381
+ },
1382
+ canopy: {
1383
+ ...canopySource,
1384
+ cardCount: integerNumber(canopySource.cardCount, base.canopy.cardCount, { min: 0 }),
1385
+ cardSizeRange: vectorArray(canopySource.cardSizeRange, base.canopy.cardSizeRange, 2),
1386
+ cardsPerCluster: integerNumber(canopySource.cardsPerCluster, base.canopy.cardsPerCluster, { min: 1 }),
1387
+ clusterRadius: finiteNumber(canopySource.clusterRadius, base.canopy.clusterRadius, { min: 0.01 }),
1388
+ shellFill: booleanOption(canopySource.shellFill, base.canopy.shellFill),
1389
+ },
1390
+ foliage: {
1391
+ ...foliageSource,
1392
+ alphaCutoff: finiteNumber(foliageSource.alphaCutoff, base.foliage.alphaCutoff, { min: 0, max: 1 }),
1393
+ backlitStrength: finiteNumber(foliageSource.backlitStrength, base.foliage.backlitStrength, { min: 0 }),
1394
+ cloudShadowCoverage: finiteNumber(foliageSource.cloudShadowCoverage, base.foliage.cloudShadowCoverage, { min: 0, max: 1 }),
1395
+ cloudShadowScale: finiteNumber(foliageSource.cloudShadowScale, base.foliage.cloudShadowScale, { min: 0.0001 }),
1396
+ cloudShadowStrength: finiteNumber(foliageSource.cloudShadowStrength, base.foliage.cloudShadowStrength, { min: 0, max: 1 }),
1397
+ cloudShadowVelocity: vectorArray(foliageSource.cloudShadowVelocity, base.foliage.cloudShadowVelocity, 2),
1398
+ sceneShadowStrength: finiteNumber(foliageSource.sceneShadowStrength, base.foliage.sceneShadowStrength, { min: 0, max: 1 }),
1399
+ skyColor: colorArray(foliageSource.skyColor, base.foliage.skyColor),
1400
+ sunColor: colorArray(foliageSource.sunColor, base.foliage.sunColor),
1401
+ sunDirection: vectorArray(foliageSource.sunDirection, base.foliage.sunDirection, 3),
1402
+ windDirection: vectorArray(foliageSource.windDirection, base.foliage.windDirection, 2),
1403
+ windSpeed: finiteNumber(foliageSource.windSpeed, base.foliage.windSpeed),
1404
+ windStrength: finiteNumber(foliageSource.windStrength, base.foliage.windStrength, { min: 0 }),
1405
+ },
1406
+ };
1407
+ }
1408
+
1409
+ /**
1410
+ * Panel group metadata for the tree settings, in display order. Group ids
1411
+ * match the {@link DEFAULT_STYLIZED_TREE_SETTINGS} top-level keys.
1412
+ */
1413
+ export const STYLIZED_TREE_SETTING_GROUPS = Object.freeze([
1414
+ Object.freeze({
1415
+ description: 'Overall scale, seed, crown reach, leaf coverage, and canopy palette. Everything except the palette and trunk shadow flag bakes geometry at construction.',
1416
+ id: 'tree',
1417
+ label: 'Tree',
1418
+ }),
1419
+ Object.freeze({
1420
+ description: 'Trunk silhouette (bend, lean, twist, gnarl) shared by the skeleton grower and the classic curved-trunk generator. Construction-only.',
1421
+ id: 'trunk',
1422
+ label: 'Trunk',
1423
+ }),
1424
+ Object.freeze({
1425
+ description: 'Space-colonization limb growth and bark mesh controls. Construction-only.',
1426
+ id: 'skeleton',
1427
+ label: 'Skeleton',
1428
+ }),
1429
+ Object.freeze({
1430
+ description: 'Leaf-card canopy geometry: card counts, tuft clusters, and shell fill. Construction-only.',
1431
+ id: 'canopy',
1432
+ label: 'Canopy Cards',
1433
+ }),
1434
+ Object.freeze({
1435
+ description: 'Leaf material response: wind, sun, alpha cutout, scene and cloud shadows. Applies at runtime via applySettings.',
1436
+ id: 'foliage',
1437
+ label: 'Foliage Material',
1438
+ }),
1439
+ ]);
1440
+
1441
+ const STYLIZED_TREE_FIELD_DEFINITIONS = Object.freeze({
1442
+ tree: {
1443
+ size: {
1444
+ description: 'Overall tree multiplier (1 ≈ 3 m tree, 2 ≈ 6 m, 3+ large). Construction-only: also densifies canopy cards so leaves stay leaf-sized.',
1445
+ label: 'Size',
1446
+ range: { max: 6, min: 0.2, step: 0.05 },
1447
+ type: 'number',
1448
+ },
1449
+ seed: {
1450
+ description: 'Deterministic generation seed; the same options and seed always grow the same tree. Construction-only.',
1451
+ label: 'Seed',
1452
+ range: { max: 999, min: 1, step: 1 },
1453
+ type: 'number',
1454
+ },
1455
+ canopyColor: {
1456
+ description: 'Canopy base color; the lit/shadow/crown palette derives from it. Also accepts richer resolveCanopyColor specs (color lists, {from,to} blends, HSL ranges) resolved per seed.',
1457
+ label: 'Canopy Color',
1458
+ type: 'color',
1459
+ },
1460
+ canopyPalette: {
1461
+ description: 'Optional explicit { lit, shadow, crown } tone overrides; unset tones derive from the canopy color.',
1462
+ label: 'Canopy Palette',
1463
+ serializable: false,
1464
+ type: 'object',
1465
+ },
1466
+ canopyWidth: {
1467
+ description: 'X-axis crown reach multiplier. Construction-only: shapes the blob layout.',
1468
+ label: 'Canopy Width',
1469
+ range: { max: 2.5, min: 0.3, step: 0.05 },
1470
+ type: 'number',
1471
+ },
1472
+ canopyDepth: {
1473
+ description: 'Z-axis crown reach multiplier. Construction-only: shapes the blob layout.',
1474
+ label: 'Canopy Depth',
1475
+ range: { max: 2.5, min: 0.3, step: 0.05 },
1476
+ type: 'number',
1477
+ },
1478
+ canopyLayout: {
1479
+ description: 'Optional createCanopyBlobs overrides (lobeCount, spread, flatten, coreRadius, ...). Construction-only.',
1480
+ label: 'Canopy Layout',
1481
+ serializable: false,
1482
+ type: 'object',
1483
+ },
1484
+ leafDensity: {
1485
+ description: 'Crown leaf coverage. Below ~0.9 see-through gap pockets open and branches read through; above 1 packs extra cards (and fatter tufts) for lush crowns. Construction-only.',
1486
+ label: 'Leaf Density',
1487
+ range: { max: 2, min: 0.05, step: 0.01 },
1488
+ type: 'number',
1489
+ },
1490
+ canopyScale: {
1491
+ description: 'Canopy-only scale relative to the trunk. Construction-only.',
1492
+ label: 'Canopy Scale',
1493
+ range: { max: 3, min: 0.2, step: 0.05 },
1494
+ type: 'number',
1495
+ },
1496
+ leafPlacement: {
1497
+ description: 'canopy: solid leaf mass hiding interior wood. tips: bushes only at branch ends with bare limbs between them (Sumeru silhouette). Construction-only.',
1498
+ label: 'Leaf Placement',
1499
+ optionLabels: Object.freeze({ canopy: 'Solid Canopy', tips: 'Branch Tips' }),
1500
+ options: Object.freeze(['canopy', 'tips']),
1501
+ type: 'select',
1502
+ },
1503
+ trunkReceiveShadow: {
1504
+ description: 'Whether the bark receives shadow maps. Massive pale-limbed trees read better with this off.',
1505
+ label: 'Trunk Receive Shadow',
1506
+ type: 'boolean',
1507
+ },
1508
+ },
1509
+ trunk: {
1510
+ height: {
1511
+ description: 'Trunk height in meters (before the overall size multiplier). Construction-only.',
1512
+ label: 'Height',
1513
+ range: { max: 3, min: 0.4, step: 0.05 },
1514
+ type: 'number',
1515
+ },
1516
+ radiusBottom: {
1517
+ description: 'Trunk radius at the root flare in meters. Construction-only.',
1518
+ label: 'Radius Bottom',
1519
+ range: { max: 0.6, min: 0.05, step: 0.005 },
1520
+ type: 'number',
1521
+ },
1522
+ radiusTop: {
1523
+ description: 'Trunk radius at the top in meters. Classic trunk generator (createTreeTrunkGeometry) only. Construction-only.',
1524
+ label: 'Radius Top',
1525
+ range: { max: 0.3, min: 0.02, step: 0.005 },
1526
+ type: 'number',
1527
+ },
1528
+ bend: {
1529
+ description: 'Mid-trunk bow amplitude that returns toward center (S-curve) in meters. Construction-only.',
1530
+ label: 'Bend',
1531
+ range: { max: 0.8, min: 0, step: 0.01 },
1532
+ type: 'number',
1533
+ },
1534
+ lean: {
1535
+ description: 'Off-vertical drift that accumulates toward the top, in meters. Construction-only.',
1536
+ label: 'Lean',
1537
+ range: { max: 1.2, min: 0, step: 0.01 },
1538
+ type: 'number',
1539
+ },
1540
+ twist: {
1541
+ description: 'Y-rotation of the cross-section over the full height in radians; spirals the bark like wrung wood. Construction-only.',
1542
+ label: 'Twist',
1543
+ range: { max: 4, min: -4, step: 0.05 },
1544
+ type: 'number',
1545
+ },
1546
+ gnarl: {
1547
+ description: 'High-frequency wiggle and radius bulges: 0 is a clean park tree, 1+ reads like an old bonsai. Construction-only.',
1548
+ label: 'Gnarl',
1549
+ range: { max: 2, min: 0, step: 0.01 },
1550
+ type: 'number',
1551
+ },
1552
+ gnarlFrequencyXRange: {
1553
+ description: 'Seeded min/max wave count of the gnarl wiggle over the trunk height on the X axis. Classic trunk generator only. Construction-only.',
1554
+ label: 'Gnarl Frequency X Range',
1555
+ type: 'vector2',
1556
+ },
1557
+ gnarlFrequencyZRange: {
1558
+ description: 'Seeded min/max wave count of the gnarl wiggle over the trunk height on the Z axis. Classic trunk generator only. Construction-only.',
1559
+ label: 'Gnarl Frequency Z Range',
1560
+ type: 'vector2',
1561
+ },
1562
+ gnarlAmplitude: {
1563
+ description: 'Meters of gnarl wiggle (and radius bulge fraction) per unit of gnarl. Classic trunk generator only. Construction-only.',
1564
+ label: 'Gnarl Amplitude',
1565
+ range: { max: 0.5, min: 0, step: 0.005 },
1566
+ type: 'number',
1567
+ },
1568
+ radialGnarlFrequency: {
1569
+ description: 'Wave count of the gnarl radius bulges (old-wood knuckles) over the trunk height. Classic trunk generator only. Construction-only.',
1570
+ label: 'Radial Gnarl Frequency',
1571
+ range: { max: 20, min: 0, step: 0.1 },
1572
+ type: 'number',
1573
+ },
1574
+ bendDirection: {
1575
+ description: 'World heading of the bow in radians; null/unset picks a seeded heading. Construction-only.',
1576
+ label: 'Bend Direction',
1577
+ range: { max: 6.283, min: -6.283, step: 0.01 },
1578
+ type: 'number',
1579
+ },
1580
+ leanOffset: {
1581
+ description: 'Lean heading relative to the bow in radians (PI pins a serpentine S-trunk); null/unset picks a seeded offset. Construction-only.',
1582
+ label: 'Lean Offset',
1583
+ range: { max: 6.283, min: -6.283, step: 0.01 },
1584
+ type: 'number',
1585
+ },
1586
+ radialSegments: {
1587
+ description: 'Cross-section segment count of the trunk tube. Classic trunk generator only. Construction-only.',
1588
+ label: 'Radial Segments',
1589
+ range: { max: 16, min: 3, step: 1 },
1590
+ type: 'number',
1591
+ },
1592
+ heightSegments: {
1593
+ description: 'Vertical segment count of the trunk tube. Classic trunk generator only. Construction-only.',
1594
+ label: 'Height Segments',
1595
+ range: { max: 24, min: 2, step: 1 },
1596
+ type: 'number',
1597
+ },
1598
+ branchCount: {
1599
+ description: 'Number of stub branches near the top. Classic trunk generator only. Construction-only.',
1600
+ label: 'Branch Count',
1601
+ range: { max: 6, min: 0, step: 1 },
1602
+ type: 'number',
1603
+ },
1604
+ branchLength: {
1605
+ description: 'Base branch length in meters. Classic trunk generator only. Construction-only.',
1606
+ label: 'Branch Length',
1607
+ range: { max: 1.5, min: 0, step: 0.01 },
1608
+ type: 'number',
1609
+ },
1610
+ branchRadius: {
1611
+ description: 'Base branch radius in meters. Classic trunk generator only. Construction-only.',
1612
+ label: 'Branch Radius',
1613
+ range: { max: 0.2, min: 0, step: 0.005 },
1614
+ type: 'number',
1615
+ },
1616
+ },
1617
+ skeleton: {
1618
+ generator: {
1619
+ description: 'limbs: space-colonization growth toward the crown blobs (solid anime-style crowns). branching: recursive central-leader branching (open, realistic broadleaf/conifer silhouettes). drawn: no procedural wood at all — the tree is exactly the hand-drawn branchSpines (Tree Lab sketch mode). Construction-only.',
1620
+ label: 'Generator',
1621
+ optionLabels: Object.freeze({ limbs: 'Grown Limbs', branching: 'Recursive Branching', drawn: 'Hand-Drawn' }),
1622
+ options: Object.freeze(['limbs', 'branching', 'drawn']),
1623
+ type: 'select',
1624
+ },
1625
+ levels: {
1626
+ description: 'Recursion depth of the branching generator; each level subdivides into thinner children. Branching generator only. Construction-only.',
1627
+ label: 'Branch Levels',
1628
+ range: { max: 4, min: 1, step: 1 },
1629
+ type: 'number',
1630
+ },
1631
+ childrenCount: {
1632
+ description: 'Child branches sprouting along the trunk (deeper levels derive from it). Conifers use high counts (60-90) for dense whorled fronds. Branching generator only. Construction-only.',
1633
+ label: 'Children',
1634
+ range: { max: 90, min: 1, step: 1 },
1635
+ type: 'number',
1636
+ },
1637
+ branchAngle: {
1638
+ description: 'Child pitch away from the parent axis, in degrees. Past 90 points branches below horizontal (conifer fronds ~110). Branching generator only. Construction-only.',
1639
+ label: 'Branch Angle',
1640
+ range: { max: 130, min: 10, step: 1 },
1641
+ type: 'number',
1642
+ },
1643
+ branchStart: {
1644
+ description: 'Fraction of the trunk kept bare before children begin — real trees hold their crown off the ground. Branching generator only. Construction-only.',
1645
+ label: 'Branch Start',
1646
+ range: { max: 0.9, min: 0, step: 0.01 },
1647
+ type: 'number',
1648
+ },
1649
+ lengthRatio: {
1650
+ description: 'Child branch length as a fraction of the trunk (deeper levels shorten from it). Branching generator only. Construction-only.',
1651
+ label: 'Length Ratio',
1652
+ range: { max: 0.95, min: 0.15, step: 0.01 },
1653
+ type: 'number',
1654
+ },
1655
+ radiusRatio: {
1656
+ description: 'Child radius as a fraction of the parent\\u2019s radius at the attach point — radius continuity is what makes forks read as one tree. Branching generator only. Construction-only.',
1657
+ label: 'Radius Ratio',
1658
+ range: { max: 0.9, min: 0.3, step: 0.01 },
1659
+ type: 'number',
1660
+ },
1661
+ gnarliness: {
1662
+ description: 'Random-walk curvature per growth section, amplified as branches thin: trunks stay stately, twigs wander. Branching generator only. Construction-only.',
1663
+ label: 'Gnarliness',
1664
+ range: { max: 0.6, min: 0, step: 0.01 },
1665
+ type: 'number',
1666
+ },
1667
+ forceStrength: {
1668
+ description: 'Growth force: every section steers toward vertical with 1/radius compliance. Positive sweeps tips skyward (broadleaf crowns); negative droops them (pines, willows). Branching generator only. Construction-only.',
1669
+ label: 'Growth Force',
1670
+ range: { max: 0.15, min: -0.08, step: 0.005 },
1671
+ type: 'number',
1672
+ },
1673
+ conifer: {
1674
+ description: 'Evergreen behavior: branches taper fully and children shorten toward the top \\u2014 the layered cone silhouette. Pair with high Children, Branch Angle ~110, negative Growth Force. Branching generator only. Construction-only.',
1675
+ label: 'Conifer',
1676
+ type: 'boolean',
1677
+ },
1678
+ attractionCount: {
1679
+ description: 'Number of crown attraction points the limbs grow toward; more points grow more, finer limbs. Construction-only.',
1680
+ label: 'Attraction Count',
1681
+ range: { max: 200, min: 10, step: 1 },
1682
+ type: 'number',
1683
+ },
1684
+ segmentLength: {
1685
+ description: 'Growth step length in meters; shorter steps grow smoother, curvier limbs. Construction-only.',
1686
+ label: 'Segment Length',
1687
+ range: { max: 0.8, min: 0.1, step: 0.01 },
1688
+ type: 'number',
1689
+ },
1690
+ influenceRadius: {
1691
+ description: 'How far an attraction point can pull on a growing limb, in meters. Construction-only.',
1692
+ label: 'Influence Radius',
1693
+ range: { max: 2.5, min: 0.3, step: 0.05 },
1694
+ type: 'number',
1695
+ },
1696
+ killRadius: {
1697
+ description: 'Distance at which a limb consumes an attraction point and stops growing toward it. Construction-only.',
1698
+ label: 'Kill Radius',
1699
+ range: { max: 1, min: 0.1, step: 0.01 },
1700
+ type: 'number',
1701
+ },
1702
+ maxSteps: {
1703
+ description: 'Growth iteration cap. Construction-only.',
1704
+ label: 'Max Steps',
1705
+ range: { max: 96, min: 4, step: 1 },
1706
+ type: 'number',
1707
+ },
1708
+ maxNodes: {
1709
+ description: 'Skeleton node cap; lower keeps trees to a few clean limbs. Construction-only.',
1710
+ label: 'Max Nodes',
1711
+ range: { max: 400, min: 20, step: 1 },
1712
+ type: 'number',
1713
+ },
1714
+ radialSegments: {
1715
+ description: 'Cross-section segment count of each bark tube. Construction-only.',
1716
+ label: 'Radial Segments',
1717
+ range: { max: 16, min: 3, step: 1 },
1718
+ type: 'number',
1719
+ },
1720
+ tipRadius: {
1721
+ description: 'Radius of the thinnest twigs in meters; pipe-model radii grow from here toward the root. Construction-only.',
1722
+ label: 'Tip Radius',
1723
+ range: { max: 0.15, min: 0.005, step: 0.001 },
1724
+ type: 'number',
1725
+ },
1726
+ minLimbRadius: {
1727
+ description: 'Limbs thinner than this get no bark tube and are left to the leaves. Construction-only.',
1728
+ label: 'Min Limb Radius',
1729
+ range: { max: 0.15, min: 0, step: 0.001 },
1730
+ type: 'number',
1731
+ },
1732
+ attachmentTwigRadius: {
1733
+ description: 'Wood thinner than this sprouts leaf tufts in canopy mode. Construction-only.',
1734
+ label: 'Attachment Twig Radius',
1735
+ range: { max: 0.3, min: 0, step: 0.005 },
1736
+ type: 'number',
1737
+ },
1738
+ attractionReach: {
1739
+ description: 'How deep into each crown blob attraction points sample (fraction of blob radius); null/unset is automatic (0.65 canopy mode, 0.92 tips mode). Construction-only.',
1740
+ label: 'Attraction Reach',
1741
+ range: { max: 1, min: 0, step: 0.01 },
1742
+ type: 'number',
1743
+ },
1744
+ },
1745
+ canopy: {
1746
+ cardCount: {
1747
+ description: 'Base leaf-card count before density and coverage scaling; few LARGE overlapping cards keep the crown one fluffy mass. Construction-only.',
1748
+ label: 'Card Count',
1749
+ range: { max: 600, min: 20, step: 1 },
1750
+ type: 'number',
1751
+ },
1752
+ cardSizeRange: {
1753
+ description: 'Min/max leaf-cluster card size in meters. Construction-only.',
1754
+ label: 'Card Size Range',
1755
+ type: 'vector2',
1756
+ },
1757
+ cardsPerCluster: {
1758
+ description: 'Cards per leaf tuft around each branch attachment. Construction-only. (In tips placement the built-in default becomes 9.)',
1759
+ label: 'Cards Per Cluster',
1760
+ range: { max: 20, min: 1, step: 1 },
1761
+ type: 'number',
1762
+ },
1763
+ clusterRadius: {
1764
+ description: 'Radius in meters of each leaf tuft around its branch end. Construction-only. (In tips placement the built-in default becomes 0.62.)',
1765
+ label: 'Cluster Radius',
1766
+ range: { max: 1.5, min: 0.1, step: 0.01 },
1767
+ type: 'number',
1768
+ },
1769
+ shellFill: {
1770
+ description: 'Fill the blob shells between tufts so the crown reads as one solid mass; off leaves bare wood between end bushes. Construction-only. (Tips placement turns this off by default.)',
1771
+ label: 'Shell Fill',
1772
+ type: 'boolean',
1773
+ },
1774
+ },
1775
+ foliage: {
1776
+ alphaCutoff: {
1777
+ description: 'Alpha-cutout threshold for the leaf sprite; low enough that mipmap-averaged alpha does not erode distant crowns.',
1778
+ label: 'Alpha Cutoff',
1779
+ range: { max: 1, min: 0, step: 0.01 },
1780
+ type: 'number',
1781
+ },
1782
+ windDirection: {
1783
+ description: 'Horizontal (XZ) heading the canopy flutter drifts toward.',
1784
+ label: 'Wind Direction',
1785
+ type: 'vector2',
1786
+ },
1787
+ windSpeed: {
1788
+ description: 'How fast the leaf-card flutter oscillates.',
1789
+ label: 'Wind Speed',
1790
+ range: { max: 4, min: 0, step: 0.01 },
1791
+ type: 'number',
1792
+ },
1793
+ windStrength: {
1794
+ description: 'How far leaf cards sway with the wind.',
1795
+ label: 'Wind Strength',
1796
+ range: { max: 0.5, min: 0, step: 0.005 },
1797
+ type: 'number',
1798
+ },
1799
+ sunDirection: {
1800
+ description: 'World-space direction toward the sun. Match your main directional light.',
1801
+ label: 'Sun Direction',
1802
+ type: 'vector3',
1803
+ },
1804
+ sunColor: {
1805
+ description: 'Sunlight tint applied to lit leaf cards.',
1806
+ label: 'Sun Color',
1807
+ type: 'color',
1808
+ },
1809
+ skyColor: {
1810
+ description: 'Ambient sky tint mixed into shaded leaf cards.',
1811
+ label: 'Sky Color',
1812
+ type: 'color',
1813
+ },
1814
+ sceneShadowStrength: {
1815
+ description: 'How strongly renderer shadow maps shift the crown toward its shadow palette. 0 disables.',
1816
+ label: 'Scene Shadow Strength',
1817
+ range: { max: 1, min: 0, step: 0.01 },
1818
+ type: 'number',
1819
+ },
1820
+ backlitStrength: {
1821
+ description: 'Translucent glow on leaves between the camera and the sun.',
1822
+ label: 'Backlit Strength',
1823
+ range: { max: 2, min: 0, step: 0.01 },
1824
+ type: 'number',
1825
+ },
1826
+ cloudShadowStrength: {
1827
+ description: 'How strongly drifting procedural cloud shadows darken the crown. 0 disables the effect.',
1828
+ label: 'Cloud Shadow Strength',
1829
+ range: { max: 1, min: 0, step: 0.01 },
1830
+ type: 'number',
1831
+ },
1832
+ cloudShadowCoverage: {
1833
+ description: 'Fraction of the world covered by cloud shadow at any moment.',
1834
+ label: 'Cloud Shadow Coverage',
1835
+ range: { max: 1, min: 0, step: 0.01 },
1836
+ type: 'number',
1837
+ },
1838
+ cloudShadowScale: {
1839
+ description: 'World-to-noise scale of the cloud shadow pattern; smaller values give larger cloud shapes.',
1840
+ label: 'Cloud Shadow Scale',
1841
+ range: { max: 0.1, min: 0.001, step: 0.001 },
1842
+ type: 'number',
1843
+ },
1844
+ cloudShadowVelocity: {
1845
+ description: 'Cloud shadow drift in noise-space units per second (world drift = velocity / scale).',
1846
+ label: 'Cloud Shadow Velocity',
1847
+ type: 'vector2',
1848
+ },
1849
+ },
1850
+ });
1851
+
1852
+ function createTreeFieldMetadata(group, key, field) {
1853
+ const defaultValue = DEFAULT_STYLIZED_TREE_SETTINGS[group.id][key];
1854
+ return Object.freeze({
1855
+ defaultValue: Array.isArray(defaultValue) ? [...defaultValue] : defaultValue,
1856
+ description: field.description,
1857
+ group: group.id,
1858
+ id: `${group.id}.${key}`,
1859
+ key,
1860
+ label: field.label,
1861
+ optionLabels: field.optionLabels ?? null,
1862
+ options: field.options ?? null,
1863
+ range: field.range ?? null,
1864
+ serializable: field.serializable ?? true,
1865
+ type: field.type,
1866
+ });
1867
+ }
1868
+
1869
+ /**
1870
+ * Field metadata (id/group/key/label/description/type/range/defaultValue/
1871
+ * serializable) per settings group, in the shape consumed by
1872
+ * `createSettingsPanel`. Group ids and keys match
1873
+ * {@link DEFAULT_STYLIZED_TREE_SETTINGS}.
1874
+ */
1875
+ export const STYLIZED_TREE_SETTING_FIELD_SCHEMA = Object.freeze(
1876
+ Object.fromEntries(
1877
+ STYLIZED_TREE_SETTING_GROUPS.map((group) => [
1878
+ group.id,
1879
+ Object.freeze(
1880
+ Object.fromEntries(
1881
+ Object.entries(STYLIZED_TREE_FIELD_DEFINITIONS[group.id] ?? {})
1882
+ .map(([key, field]) => [key, createTreeFieldMetadata(group, key, field)]),
1883
+ ),
1884
+ ),
1885
+ ]),
1886
+ ),
1887
+ );
1888
+
1889
+ function sameSettingValue(a, b) {
1890
+ if (Array.isArray(a) && Array.isArray(b)) {
1891
+ return a.length === b.length && a.every((value, index) => value === b[index]);
1892
+ }
1893
+ return a === b;
1894
+ }
1895
+
1896
+ // Complete tree: curved trunk + leaf-card canopy in one Object3D.
1897
+ // All options forwarded, all optional:
1898
+ // size — overall multiplier (1 ≈ 3 m tree, 2 ≈ 6 m, 3+ large)
1899
+ // canopyColor — any resolveCanopyColor spec: one color, a list to pick
1900
+ // from, { from, to } blend, or HSL ranges — resolved
1901
+ // deterministically per seed, so a forest built from one
1902
+ // spec gets stable per-tree variation
1903
+ // canopyPalette— pin { lit, shadow, crown } tones explicitly (partial ok)
1904
+ // canopyWidth / canopyDepth — X / Z crown reach multipliers (a 1.6 / 0.7
1905
+ // tree is wide from the front but shallow from the side)
1906
+ // leafDensity — 0..1 crown coverage; below ~0.9 see-through gaps open
1907
+ // leafPlacement— 'canopy' (default): solid leaf mass hiding interior wood;
1908
+ // 'tips': bushes only at the branch ends, long bare limbs
1909
+ // on show between them (Sumeru-style silhouettes)
1910
+ // trunk — createTreeTrunkGeometry options (or a TREE_TRUNK_STYLES spread)
1911
+ // skeleton — createTreeSkeleton options (limbsPerBlob, limbRadius, ...)
1912
+ // canopy — createTreeFoliageGeometry options (blobs, cardCount, ...)
1913
+ // foliage — createTreeFoliageMaterials options (cutoff, wind, sun, ...)
1914
+ // trunkMaterial— any THREE material; default is a warm MeshToonMaterial
1915
+ export class StylizedTree extends THREE.Group {
1916
+ constructor(options = {}) {
1917
+ super();
1918
+ // Sketch-tool data rides beside the grouped settings (all plain JSON,
1919
+ // serialized by toJSON, so hand-drawn recipes stay deterministic):
1920
+ // branchSpines — [{ points, radiusStart, radiusEnd, leafTip }]:
1921
+ // bark tubes swept along drawn curves (tree-local,
1922
+ // pre-size); leafTip !== false grows a leaf tuft at
1923
+ // the end via the mandatory-attachment pass
1924
+ // extraBlobs — [{ offset, radius }] canopy-local blobs appended
1925
+ // to the layout (closed-silhouette fills)
1926
+ // extraAttachments — [{ position, direction }] canopy-local leaf tufts
1927
+ // along an open drawn stroke
1928
+ // branchOverrides — { [attachmentIndex]: { cardsPerCluster?,
1929
+ // clusterRadius?, densityScale? } }: per-branch
1930
+ // foliage tuft overrides (Tree Lab's branch
1931
+ // inspector). Indices follow generation order, so
1932
+ // they are stable per seed + skeleton settings.
1933
+ // leafShape — { preset: 'teardrop'|'round'|'maple'|'gingko'|
1934
+ // 'needle'|'custom', outline?: [[x,y],...] }: the
1935
+ // single-leaf silhouette the crown sprite (and the
1936
+ // designer's falling-leaf particles) stamp from.
1937
+ // Plain JSON; an explicit foliage.leafMap wins.
1938
+ // roots — { preset: 'none'|'small'|'medium'|'large' }:
1939
+ // procedural surface roots radiating from the base
1940
+ // collar (a complete tree meets the ground).
1941
+ const {
1942
+ trunkMaterial = null,
1943
+ branchSpines = [],
1944
+ extraBlobs = [],
1945
+ extraAttachments = [],
1946
+ branchOverrides = null,
1947
+ leafShape = null,
1948
+ roots = null,
1949
+ trunkProfile = null,
1950
+ woodDetails = null,
1951
+ } = cleanObject(options);
1952
+ const settings = createStylizedTreeSettings(options);
1953
+ const {
1954
+ size,
1955
+ seed,
1956
+ canopyColor,
1957
+ canopyPalette,
1958
+ canopyWidth,
1959
+ canopyDepth,
1960
+ leafDensity,
1961
+ canopyScale,
1962
+ leafPlacement,
1963
+ // Massive pale-limbed trees read better without the canopy shadow-mapping
1964
+ // onto their own wood (the anime look keeps exposed limbs bright).
1965
+ trunkReceiveShadow,
1966
+ // createCanopyBlobs options (lobeCount, spread, flatten, coreRadius, ...)
1967
+ // so the crown layout stays declarative in recipes; pinning explicit
1968
+ // blobs via canopy.blobs still wins.
1969
+ canopyLayout,
1970
+ } = settings.tree;
1971
+ this.name = 'StylizedTree';
1972
+ // Kept for toJSON() so the tree can serialize itself into a recipe.
1973
+ // Not cloned; mutating it after construction is undefined behavior.
1974
+ this.config = options;
1975
+ // Fully-resolved grouped settings (see DEFAULT_STYLIZED_TREE_SETTINGS); the
1976
+ // runtime-applicable slice can be re-tuned via applySettings().
1977
+ this.settings = settings;
1978
+
1979
+ // Every tree gets its own irregular, wider-than-tall crown layout unless
1980
+ // the caller pins an explicit blob set. Sketch blobs extend the layout,
1981
+ // steering both the skeleton growth and the leaf-card fill.
1982
+ const blobs = [
1983
+ ...(settings.canopy.blobs ??
1984
+ createCanopyBlobs({ seed, width: canopyWidth, depth: canopyDepth, ...canopyLayout })),
1985
+ ...extraBlobs,
1986
+ ];
1987
+ // skeleton.generator picks the wood: 'limbs' grows toward the crown
1988
+ // blobs (solid anime crowns), 'branching' recurses a central leader
1989
+ // (open realistic silhouettes), 'drawn' skips procedural wood
1990
+ // entirely — the tree is exactly the hand-drawn branchSpines.
1991
+ const generator = settings.skeleton.generator;
1992
+ const trunkResult = generator === 'branching'
1993
+ ? createBranchingTreeSkeleton({
1994
+ seed,
1995
+ trunk: settings.trunk,
1996
+ canopyScale,
1997
+ ...settings.skeleton,
1998
+ })
1999
+ : generator === 'drawn'
2000
+ ? { geometry: null, canopyAnchor: null, attachments: [] }
2001
+ : createTreeSkeleton({
2002
+ seed,
2003
+ trunk: settings.trunk,
2004
+ canopyScale,
2005
+ blobs,
2006
+ leafPlacement,
2007
+ ...settings.skeleton,
2008
+ });
2009
+
2010
+ // Hand-drawn branches: sweep a bark tube along each spine; leaf-tipped
2011
+ // spines contribute a mandatory tuft attachment so leaves engulf the
2012
+ // drawn branch end automatically. Swept before attachment conversion
2013
+ // because in drawn mode the crown anchor derives from the spine tips.
2014
+ const spineTubes = [];
2015
+ // grow:true spines (Grow from Doodle on an existing tree) sprout a mini
2016
+ // EZ-style skeleton ALONG the stroke — sub-branches + foliage — instead
2017
+ // of a bare tube, composing with whatever generator built the trunk.
2018
+ const grownSpines = [];
2019
+ for (const spine of branchSpines) {
2020
+ if (spine.grow) {
2021
+ grownSpines.push(createBranchingTreeSkeleton({
2022
+ trunk: { radiusBottom: Math.max(spine.radiusStart ?? 0.06, 0.02) },
2023
+ trunkSpine: spine.points,
2024
+ seed: seed * 3.7 + grownSpines.length * 11.3,
2025
+ canopyScale: 1,
2026
+ levels: 2,
2027
+ childrenCount: 3,
2028
+ branchStart: 0.3,
2029
+ branchAngle: settings.skeleton.branchAngle,
2030
+ lengthRatio: settings.skeleton.lengthRatio,
2031
+ radiusRatio: settings.skeleton.radiusRatio,
2032
+ gnarliness: settings.skeleton.gnarliness,
2033
+ forceStrength: settings.skeleton.forceStrength,
2034
+ radialSegments: settings.skeleton.radialSegments,
2035
+ tipRadius: settings.skeleton.tipRadius,
2036
+ maxBranches: 60,
2037
+ maxAttachments: 70,
2038
+ }));
2039
+ continue;
2040
+ }
2041
+ // Grounded stems grow FROM the earth: sink the base below grade (no
2042
+ // visible cap, ever) and flare the first rings like a real root
2043
+ // collar. Branch spines starting on wood are untouched.
2044
+ const grounded = spine.points[0][1] <= 0.02;
2045
+ const points = grounded
2046
+ ? [
2047
+ [spine.points[0][0], -0.12, spine.points[0][2]],
2048
+ ...spine.points,
2049
+ ]
2050
+ : spine.points;
2051
+ const tube = createBranchTubeGeometry({
2052
+ radialSegments: settings.skeleton.radialSegments,
2053
+ ...spine,
2054
+ flareBase: grounded,
2055
+ points,
2056
+ // A drawn trunk cross-section applies to grounded stems only.
2057
+ ...(grounded && trunkProfile?.outline
2058
+ ? { profile: polarProfileFromOutline(trunkProfile.outline) }
2059
+ : {}),
2060
+ });
2061
+ if (tube) spineTubes.push({ spine, tube });
2062
+ }
2063
+ // Drawn mode anchors the canopy at the ORIGIN (canopy-local equals
2064
+ // tree-local): scribbled foliage and tuft positions stay stable no
2065
+ // matter what wood is drawn later.
2066
+ const canopyAnchor = trunkResult.canopyAnchor ?? new THREE.Vector3(0, 0, 0);
2067
+ const attachments = [...trunkResult.attachments];
2068
+ for (const { spine, tube } of spineTubes) {
2069
+ if (spine.leafTip !== false) {
2070
+ attachments.push({
2071
+ position: tube.tip.clone().sub(canopyAnchor).divideScalar(canopyScale),
2072
+ direction: tube.tipTangent,
2073
+ });
2074
+ }
2075
+ }
2076
+ for (const grown of grownSpines) {
2077
+ for (const attachment of grown.attachments) {
2078
+ attachments.push({
2079
+ position: attachment.position.clone().add(grown.canopyAnchor)
2080
+ .sub(canopyAnchor).divideScalar(canopyScale),
2081
+ direction: attachment.direction,
2082
+ });
2083
+ }
2084
+ }
2085
+ for (const extra of extraAttachments) {
2086
+ attachments.push({
2087
+ position: new THREE.Vector3(...extra.position),
2088
+ direction: extra.direction
2089
+ ? new THREE.Vector3(...extra.direction).normalize()
2090
+ : new THREE.Vector3(0, 1, 0),
2091
+ });
2092
+ }
2093
+
2094
+ const woodPieces = [
2095
+ ...(trunkResult.geometry ? [trunkResult.geometry] : []),
2096
+ ...spineTubes.map(({ tube }) => tube.geometry),
2097
+ ...grownSpines.map((grown) => grown.geometry),
2098
+ ];
2099
+
2100
+ // Surface roots: seeded tubes arcing out from the base collar and
2101
+ // burying their tips. Scaled off the trunk radius so they stay
2102
+ // proportionate for every tree size.
2103
+ const ROOT_PRESETS = {
2104
+ large: { count: 7, length: 5.5, radius: 0.62 },
2105
+ medium: { count: 5, length: 4.2, radius: 0.52 },
2106
+ small: { count: 4, length: 3, radius: 0.42 },
2107
+ };
2108
+ // Hand-drawn top-down layout: each drawn path IS one root — direction,
2109
+ // bend, and length exactly as authored ([-1,1] plan space around the
2110
+ // trunk; 1.0 spans ~6 trunk radii, matching the preset footprint).
2111
+ if (roots?.preset === 'custom' && Array.isArray(roots.paths) && roots.paths.length) {
2112
+ const groundedSpines = branchSpines.filter((spine) => spine.points[0][1] <= 0.02);
2113
+ const baseRadius = generator === 'drawn'
2114
+ ? Math.max(0.08, ...groundedSpines.map((spine) => spine.radiusStart), 0.08)
2115
+ : settings.trunk.radiusBottom;
2116
+ const baseX = generator === 'drawn' && groundedSpines.length
2117
+ ? groundedSpines[0].points[0][0] : 0;
2118
+ const baseZ = generator === 'drawn' && groundedSpines.length
2119
+ ? groundedSpines[0].points[0][2] : 0;
2120
+ const collarY = Math.min(baseRadius * 0.5, 0.14);
2121
+ const planScale = baseRadius * 6;
2122
+ for (let i = 0; i < roots.paths.length; i += 1) {
2123
+ const path = roots.paths[i];
2124
+ // Anchor at the collar regardless of where the stroke began, then
2125
+ // follow the drawn plan; height eases collar -> grade -> buried tip.
2126
+ const points = [[baseX, collarY, baseZ]];
2127
+ for (let j = 0; j < path.length; j += 1) {
2128
+ const t = (j + 1) / path.length;
2129
+ points.push([
2130
+ baseX + path[j][0] * planScale,
2131
+ collarY * Math.max(0, 1 - t * 2.2) - 0.18 * Math.max(0, t - 0.55) / 0.45,
2132
+ baseZ + path[j][1] * planScale,
2133
+ ]);
2134
+ }
2135
+ const tube = createBranchTubeGeometry({
2136
+ flareBase: false,
2137
+ irregularity: 0.2,
2138
+ points,
2139
+ radialSegments: 7,
2140
+ radiusEnd: baseRadius * 0.5 * 0.4,
2141
+ radiusStart: baseRadius * 0.5,
2142
+ seed: seed + i * 13,
2143
+ });
2144
+ if (tube) woodPieces.push(tube.geometry);
2145
+ }
2146
+ }
2147
+ const rootSpec = ROOT_PRESETS[roots?.preset];
2148
+ if (rootSpec) {
2149
+ const groundedSpines = branchSpines.filter((spine) => spine.points[0][1] <= 0.02);
2150
+ const baseRadius = generator === 'drawn'
2151
+ ? Math.max(0.08, ...groundedSpines.map((spine) => spine.radiusStart))
2152
+ : settings.trunk.radiusBottom;
2153
+ const baseX = generator === 'drawn' && groundedSpines.length
2154
+ ? groundedSpines[0].points[0][0] : 0;
2155
+ const baseZ = generator === 'drawn' && groundedSpines.length
2156
+ ? groundedSpines[0].points[0][2] : 0;
2157
+ const rootRng = seededRandom(seed + 31);
2158
+ // Roots emerge AT THE BOTTOM of the trunk (the collar sits just above
2159
+ // grade regardless of trunk thickness) and hug the ground on the way
2160
+ // out, tips buried.
2161
+ const collarY = Math.min(baseRadius * 0.5, 0.14);
2162
+ for (let i = 0; i < rootSpec.count; i += 1) {
2163
+ const azimuth = (i / rootSpec.count) * Math.PI * 2 + rootRng(i) * 0.8;
2164
+ const length = baseRadius * rootSpec.length * (0.75 + rootRng(i + 40) * 0.5);
2165
+ // Serpentine drift so roots snake instead of radiating like spokes.
2166
+ const drift = (rootRng(i + 70) - 0.5) * 0.9;
2167
+ const pointAt = (t, y) => {
2168
+ const bend = azimuth + drift * t * t;
2169
+ return [
2170
+ baseX + Math.cos(bend) * (baseRadius * 0.2 + length * t),
2171
+ y,
2172
+ baseZ + Math.sin(bend) * (baseRadius * 0.2 + length * t),
2173
+ ];
2174
+ };
2175
+ const tube = createBranchTubeGeometry({
2176
+ flareBase: false,
2177
+ irregularity: 0.2,
2178
+ points: [
2179
+ pointAt(0, collarY),
2180
+ pointAt(0.2, collarY * 0.45),
2181
+ pointAt(0.4, 0.0),
2182
+ pointAt(0.6, -0.03),
2183
+ pointAt(0.8, -0.07),
2184
+ pointAt(1, -0.18),
2185
+ ],
2186
+ radialSegments: 7,
2187
+ radiusEnd: baseRadius * rootSpec.radius * 0.4,
2188
+ radiusStart: baseRadius * rootSpec.radius,
2189
+ seed: seed + i * 13,
2190
+ });
2191
+ if (tube) woodPieces.push(tube.geometry);
2192
+ }
2193
+ }
2194
+
2195
+ // Wood details: seeded knots (squashed bulges) and scar welts (tall thin
2196
+ // ridges) embedded in the lower trunk, so trunks never look
2197
+ // factory-perfect. Centers sit inside the trunk surface — details can
2198
+ // bulge, never float.
2199
+ const knotAmount = Math.max(0, Math.min(1, woodDetails?.knots ?? 0));
2200
+ const scarAmount = Math.max(0, Math.min(1, woodDetails?.scars ?? 0));
2201
+ if ((knotAmount > 0 || scarAmount > 0) && woodPieces.length) {
2202
+ const groundedSpines = branchSpines.filter((spine) => spine.points[0][1] <= 0.02);
2203
+ const detailBaseRadius = generator === 'drawn'
2204
+ ? Math.max(0.08, ...groundedSpines.map((spine) => spine.radiusStart), 0.08)
2205
+ : settings.trunk.radiusBottom;
2206
+ const detailBaseX = generator === 'drawn' && groundedSpines.length
2207
+ ? groundedSpines[0].points[0][0] : 0;
2208
+ const detailBaseZ = generator === 'drawn' && groundedSpines.length
2209
+ ? groundedSpines[0].points[0][2] : 0;
2210
+ const trunkSpan = generator === 'drawn' && groundedSpines.length
2211
+ ? Math.max(0.6, ...groundedSpines[0].points.map((point) => point[1]))
2212
+ : settings.trunk.height;
2213
+ const detailRng = seededRandom(seed + 57);
2214
+ const placeDetail = (index, scaleVec) => {
2215
+ const azimuth = detailRng(index) * Math.PI * 2;
2216
+ const t = 0.08 + detailRng(index + 100) * 0.45;
2217
+ const y = detailBaseRadius * 0.4 + t * trunkSpan * 0.5;
2218
+ // Local trunk radius estimate (taper + base flare); embed the center
2219
+ // at 80% of it so the bump always pokes out of, and stays glued to,
2220
+ // the bark.
2221
+ const localRadius = detailBaseRadius * (1 - 0.3 * t)
2222
+ * (y < detailBaseRadius ? 1.18 : 1);
2223
+ const size = localRadius * (0.35 + detailRng(index + 200) * 0.3);
2224
+ const bump = new THREE.SphereGeometry(size, 8, 6);
2225
+ bump.scale(...scaleVec);
2226
+ const normal = new THREE.Vector3(Math.cos(azimuth), 0.12, Math.sin(azimuth)).normalize();
2227
+ bump.lookAt(normal);
2228
+ bump.rotateZ(detailRng(index + 300) * Math.PI);
2229
+ bump.translate(
2230
+ detailBaseX + Math.cos(azimuth) * localRadius * 0.8,
2231
+ y,
2232
+ detailBaseZ + Math.sin(azimuth) * localRadius * 0.8,
2233
+ );
2234
+ woodPieces.push(bump);
2235
+ };
2236
+ const knotCount = Math.round(knotAmount * 8);
2237
+ for (let i = 0; i < knotCount; i += 1) placeDetail(i, [1, 1, 0.75]);
2238
+ const scarCount = Math.round(scarAmount * 5);
2239
+ for (let i = 0; i < scarCount; i += 1) placeDetail(i + 50, [0.35, 2.4, 0.4]);
2240
+ }
2241
+ let trunkGeometry;
2242
+ if (!woodPieces.length) {
2243
+ // Blank hand-drawn tree: a sapling stub so there's something to see
2244
+ // before the first stroke.
2245
+ trunkGeometry = new THREE.CylinderGeometry(0.02, 0.035, 0.3, 6);
2246
+ trunkGeometry.translate(0, 0.15, 0);
2247
+ } else if (woodPieces.length === 1) {
2248
+ trunkGeometry = woodPieces[0];
2249
+ } else {
2250
+ trunkGeometry = mergeGeometries(woodPieces);
2251
+ woodPieces.forEach((piece) => piece.dispose());
2252
+ }
2253
+ // Only forward canopy values that differ from the defaults, so the
2254
+ // tips-placement geometry presets below keep winning unless the caller
2255
+ // explicitly overrides them (exactly the legacy sparse-options behavior).
2256
+ const canopyOverrides = {};
2257
+ for (const [key, value] of Object.entries(settings.canopy)) {
2258
+ if (!(key in DEFAULT_STYLIZED_TREE_SETTINGS.canopy) ||
2259
+ !sameSettingValue(value, DEFAULT_STYLIZED_TREE_SETTINGS.canopy[key])) {
2260
+ canopyOverrides[key] = value;
2261
+ }
2262
+ }
2263
+ const canopyGeometry = createTreeFoliageGeometry({
2264
+ seed: seed * 7.31 + 1.7,
2265
+ leafDensity,
2266
+ attachments,
2267
+ // The group is scaled by `size`; cards must stay leaf-sized, so the
2268
+ // geometry densifies instead (see coverageScale).
2269
+ coverageScale: size * canopyScale,
2270
+ ...(leafPlacement === 'tips'
2271
+ ? { shellFill: false, cardsPerCluster: 9, clusterRadius: 0.62 }
2272
+ : {}),
2273
+ // Branching trees carry their foliage on the branches:
2274
+ // no blob-shell fill, small tufts. Explicit canopy overrides still win.
2275
+ // Branching trees carry their foliage on the branches (no blob-shell
2276
+ // crown) — EXCEPT scribbled foliage areas (extraBlobs, e.g. converted
2277
+ // leaf doodles), which become the fill layout so painted leaves render.
2278
+ ...(generator === 'branching'
2279
+ ? {
2280
+ shellFill: extraBlobs.length > 0,
2281
+ shellBudget: extraBlobs.length ? extraBlobs.length * 8 : null,
2282
+ cardsPerCluster: 2, clusterRadius: 0.17, cardSizeRange: [0.27, 0.45],
2283
+ }
2284
+ : {}),
2285
+ ...canopyOverrides,
2286
+ // Hand-drawn trees have no generated crown. Scribbled foliage blobs
2287
+ // (extraBlobs) become the whole layout and are shell-filled — leaves
2288
+ // appear exactly where scribbled. Without scribbles, leaves grow only
2289
+ // at drawn branch tips, and not at all before any strokes.
2290
+ ...(generator === 'drawn'
2291
+ ? (extraBlobs.length
2292
+ ? { shellFill: true, shellBudget: extraBlobs.length * 8 }
2293
+ : { shellFill: false, ...(attachments.length ? {} : { cardCount: 0 }) })
2294
+ : {}),
2295
+ attachmentOverrides: branchOverrides,
2296
+ blobs: (generator === 'drawn' || generator === 'branching') && extraBlobs.length
2297
+ ? extraBlobs : blobs,
2298
+ });
2299
+ const materials = createTreeFoliageMaterials({
2300
+ color: canopyColor,
2301
+ palette: canopyPalette,
2302
+ seed,
2303
+ ...(leafShape && !settings.foliage.leafMap
2304
+ ? { leafMap: leafSpriteForShape(leafShape) }
2305
+ : {}),
2306
+ ...settings.foliage,
2307
+ });
2308
+
2309
+ this.trunkMesh = new THREE.Mesh(
2310
+ trunkGeometry,
2311
+ trunkMaterial ?? new THREE.MeshToonMaterial({ color: 0xc9ab8a }),
2312
+ );
2313
+ this.trunkMesh.castShadow = true;
2314
+ this.trunkMesh.receiveShadow = trunkReceiveShadow;
2315
+
2316
+ this.canopyMesh = new THREE.Mesh(canopyGeometry, materials.material);
2317
+ this.canopyMesh.customDepthMaterial = materials.depthMaterial;
2318
+ this.canopyMesh.castShadow = true;
2319
+ this.canopyMesh.receiveShadow = true;
2320
+ this.canopyMesh.frustumCulled = false;
2321
+ this.canopyMesh.position.copy(canopyAnchor);
2322
+ this.canopyMesh.scale.setScalar(canopyScale);
2323
+ this.canopyMesh.userData.environmentShaderExclude = true;
2324
+ // Branch-end tuft anchors in canopyMesh-local space, index-aligned with
2325
+ // the aAttachment card attribute — the designer's branch picker uses
2326
+ // both to map a clicked leaf back to its branch.
2327
+ this.foliageAttachments = attachments;
2328
+
2329
+ this.add(this.trunkMesh, this.canopyMesh);
2330
+ this.scale.setScalar(size);
2331
+ }
2332
+
2333
+ /**
2334
+ * Runtime re-tune: merges partial grouped settings ({ tree, trunk,
2335
+ * skeleton, canopy, foliage }) into the current settings and pushes every
2336
+ * runtime-applicable value into the live materials:
2337
+ *
2338
+ * - `foliage.*` — wind, sun, alpha cutoff, backlit, scene/cloud shadows
2339
+ * (shared with the shadow-depth material).
2340
+ * - `tree.canopyColor` / `tree.canopyPalette` — the lit/shadow/crown
2341
+ * palette is re-derived and written into the canopy uniforms.
2342
+ * - `tree.trunkReceiveShadow` — toggles bark shadow receiving.
2343
+ *
2344
+ * Geometry-baked groups (`trunk`, `skeleton`, `canopy`) and the tree
2345
+ * topology fields (`size`, `seed`, `canopyWidth/Depth/Scale/Layout`,
2346
+ * `leafDensity`, `leafPlacement`) are construction-only: new values are
2347
+ * stored on `this.settings` but the meshes are not regrown — build a new
2348
+ * StylizedTree for those. Note that the convenience setters (setWind,
2349
+ * setSun, ...) write uniforms directly without updating `this.settings`.
2350
+ *
2351
+ * @param {Object} [options] Partial grouped settings, same shape as
2352
+ * {@link DEFAULT_STYLIZED_TREE_SETTINGS}.
2353
+ * @returns {Object} The updated settings object.
2354
+ */
2355
+ applySettings(options = {}) {
2356
+ const source = cleanObject(options);
2357
+ const merged = {};
2358
+ for (const groupId of Object.keys(DEFAULT_STYLIZED_TREE_SETTINGS)) {
2359
+ merged[groupId] = { ...this.settings[groupId] };
2360
+ for (const [key, value] of Object.entries(cleanObject(source[groupId]))) {
2361
+ if (value !== undefined) merged[groupId][key] = value;
2362
+ }
2363
+ }
2364
+ const settings = createStylizedTreeSettings(merged);
2365
+ this.settings = settings;
2366
+
2367
+ const foliage = settings.foliage;
2368
+ const uniforms = this.canopyMesh.material.uniforms;
2369
+ uniforms.uWindDirection.value.set(foliage.windDirection[0], foliage.windDirection[1]);
2370
+ uniforms.uWindSpeed.value = foliage.windSpeed;
2371
+ uniforms.uWindStrength.value = foliage.windStrength;
2372
+ uniforms.uAlphaCutoff.value = foliage.alphaCutoff;
2373
+ uniforms.uSunDirection.value.set(...foliage.sunDirection);
2374
+ uniforms.uSunColor.value.setRGB(...foliage.sunColor, THREE.SRGBColorSpace);
2375
+ uniforms.uSkyColor.value.setRGB(...foliage.skyColor, THREE.SRGBColorSpace);
2376
+ uniforms.uSceneShadowStrength.value = foliage.sceneShadowStrength;
2377
+ uniforms.uBacklitStrength.value = foliage.backlitStrength;
2378
+ uniforms.uCloudShadowStrength.value = foliage.cloudShadowStrength;
2379
+ uniforms.uCloudShadowCoverage.value = foliage.cloudShadowCoverage;
2380
+ uniforms.uCloudShadowScale.value = foliage.cloudShadowScale;
2381
+ uniforms.uCloudShadowVelocity.value.set(
2382
+ foliage.cloudShadowVelocity[0], foliage.cloudShadowVelocity[1]);
2383
+
2384
+ // Re-derive the three-tone palette from the (possibly updated) canopy
2385
+ // color/palette pins. Constructor-only foliage.color/palette overrides
2386
+ // are not tracked here; prefer tree.canopyColor / tree.canopyPalette.
2387
+ const palette = deriveCanopyPalette(
2388
+ resolveCanopyColor(settings.tree.canopyColor, settings.tree.seed),
2389
+ settings.tree.canopyPalette,
2390
+ );
2391
+ uniforms.uLitColor.value.copy(palette.lit);
2392
+ uniforms.uShadowColor.value.copy(palette.shadow);
2393
+ uniforms.uCrownColor.value.copy(palette.crown);
2394
+
2395
+ if (this.trunkMesh.receiveShadow !== settings.tree.trunkReceiveShadow) {
2396
+ this.trunkMesh.receiveShadow = settings.tree.trunkReceiveShadow;
2397
+ this.trunkMesh.material.needsUpdate = true;
2398
+ }
2399
+ return this.settings;
2400
+ }
2401
+
2402
+ setSun(options) {
2403
+ setCanopySun(this.canopyMesh.material.uniforms, options ?? {});
2404
+ return this;
2405
+ }
2406
+
2407
+ setWind(options) {
2408
+ setCanopyWind(this.canopyMesh.material.uniforms, options);
2409
+ return this;
2410
+ }
2411
+
2412
+ // How strongly scene shadows shift the crown toward its shadow palette.
2413
+ setSceneShadow(options) {
2414
+ setCanopySceneShadow(this.canopyMesh.material.uniforms, options);
2415
+ return this;
2416
+ }
2417
+
2418
+ // Drifting procedural cloud shadows across the crown. strength 0 disables.
2419
+ setCloudShadow(options) {
2420
+ setCanopyCloudShadow(this.canopyMesh.material.uniforms, options);
2421
+ return this;
2422
+ }
2423
+
2424
+ update(delta) {
2425
+ tickCanopyTime(this.canopyMesh.material.uniforms, delta);
2426
+ return this;
2427
+ }
2428
+
2429
+ // Recipe document that rebuilds this exact tree (generation is
2430
+ // deterministic per seed): new StylizedTree(tree.toJSON().options).
2431
+ // Deliberately shadows Object3D.toJSON — the recipe IS the serialization
2432
+ // for procedural plants; ObjectLoader round-trips are not supported.
2433
+ toJSON() {
2434
+ return {
2435
+ schema: TREE_RECIPE_SCHEMA,
2436
+ version: TREE_RECIPE_VERSION,
2437
+ type: 'tree',
2438
+ options: serializableTreeOptions(this.config),
2439
+ };
2440
+ }
2441
+
2442
+ dispose() {
2443
+ this.trunkMesh.geometry.dispose();
2444
+ this.trunkMesh.material.dispose();
2445
+ this.canopyMesh.geometry.dispose();
2446
+ this.canopyMesh.material.dispose();
2447
+ this.canopyMesh.customDepthMaterial?.dispose();
2448
+ }
2449
+ }