@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,1929 @@
1
+ // Deterministic, texture-free procedural debris meshes. The output uses
2
+ // MeshStandardMaterial + baked vertex colors so host apps can either export
3
+ // it directly or pass it through ToonLab's environment material adapter.
4
+
5
+ import * as THREE from 'three';
6
+ import { mergeGeometries } from 'three/examples/jsm/utils/BufferGeometryUtils.js';
7
+
8
+ import { createDebrisSettings } from './debrisSettings.js';
9
+ import {
10
+ createJawField,
11
+ createLongBoneField,
12
+ createMasonryChunkField,
13
+ createSkullField,
14
+ createStoneField,
15
+ meshDebrisField,
16
+ } from './debrisFields.js';
17
+ import {
18
+ debrisTextureAuto,
19
+ ensureDebrisUvs,
20
+ getCustomDebrisTexture,
21
+ getDebrisDetailTexture,
22
+ } from './debrisTextures.js';
23
+ import { hashCombine } from '../rockgen/noise/prng.js';
24
+ import { simplexNoise3 } from '../rockgen/noise/simplexNoise3.js';
25
+ import { fbm3 } from '../rockgen/noise/valueNoise3.js';
26
+ import {
27
+ createBranchingTreeSkeleton,
28
+ createBranchTubeGeometry,
29
+ } from '../vegetation/stylizedTree.js';
30
+
31
+ function lowbias32(value) {
32
+ let hash = value >>> 0;
33
+ hash = Math.imul(hash ^ (hash >>> 16), 0x7feb352d) >>> 0;
34
+ hash = Math.imul(hash ^ (hash >>> 15), 0x846ca68b) >>> 0;
35
+ return (hash ^ (hash >>> 16)) >>> 0;
36
+ }
37
+
38
+ export function createDebrisRandom(seed) {
39
+ let a = lowbias32(seed);
40
+ let b = lowbias32(seed + 1);
41
+ let c = lowbias32(seed + 2);
42
+ let d = lowbias32(seed + 3);
43
+ for (let warm = 0; warm < 8; warm += 1) {
44
+ const t = (a + b + d) >>> 0;
45
+ d = (d + 1) >>> 0;
46
+ a = b ^ (b >>> 9);
47
+ b = (c + (c << 3)) >>> 0;
48
+ c = ((c << 21) | (c >>> 11)) >>> 0;
49
+ c = (c + t) >>> 0;
50
+ }
51
+ return () => {
52
+ const t = (a + b + d) >>> 0;
53
+ d = (d + 1) >>> 0;
54
+ a = b ^ (b >>> 9);
55
+ b = (c + (c << 3)) >>> 0;
56
+ c = ((c << 21) | (c >>> 11)) >>> 0;
57
+ c = (c + t) >>> 0;
58
+ return t / 4294967296;
59
+ };
60
+ }
61
+
62
+ const signed = (random) => random() * 2 - 1;
63
+ const mix = (a, b, amount) => a + (b - a) * amount;
64
+
65
+ function paletteColor(settings, slot) {
66
+ const values = slot === 2
67
+ ? settings.surface.accentColor
68
+ : slot === 1
69
+ ? settings.surface.secondaryColor
70
+ : settings.surface.primaryColor;
71
+ return new THREE.Color(values[0], values[1], values[2]);
72
+ }
73
+
74
+ function paintGeometry(geometry, settings, random, slot = 0) {
75
+ if (!geometry.attributes.normal) geometry.computeVertexNormals();
76
+ const normal = geometry.attributes.normal;
77
+ const colors = new Float32Array(geometry.attributes.position.count * 3);
78
+ const base = paletteColor(settings, slot);
79
+ for (let index = 0; index < geometry.attributes.position.count; index += 1) {
80
+ const drift = 1 + signed(random) * settings.surface.variation;
81
+ const upward = Math.max(0, normal.getY(index));
82
+ const edge = 1 + upward * settings.surface.edgeLight * 0.28;
83
+ const color = base.clone().multiplyScalar(drift * edge);
84
+ colors[index * 3] = Math.min(color.r, 1);
85
+ colors[index * 3 + 1] = Math.min(color.g, 1);
86
+ colors[index * 3 + 2] = Math.min(color.b, 1);
87
+ }
88
+ geometry.setAttribute('color', new THREE.BufferAttribute(colors, 3));
89
+ return geometry;
90
+ }
91
+
92
+ function createMaterial(settings, { doubleSided = false, metalness = 0 } = {}) {
93
+ const material = new THREE.MeshStandardMaterial({
94
+ color: 0xffffff,
95
+ metalness,
96
+ roughness: settings.surface.roughness,
97
+ side: doubleSided ? THREE.DoubleSide : THREE.FrontSide,
98
+ vertexColors: true,
99
+ });
100
+ material.name = 'Debris Toon Source';
101
+ material.userData.envRole = 'standard';
102
+ return material;
103
+ }
104
+
105
+ function addMesh(parent, geometry, settings, random, {
106
+ doubleSided = false, metalness = 0, name = 'Debris piece', slot = 0,
107
+ } = {}) {
108
+ paintGeometry(geometry, settings, random, slot);
109
+ const mesh = new THREE.Mesh(geometry, createMaterial(settings, { doubleSided, metalness }));
110
+ mesh.name = name;
111
+ mesh.castShadow = true;
112
+ mesh.receiveShadow = true;
113
+ parent.add(mesh);
114
+ return mesh;
115
+ }
116
+
117
+ // Piece placement by arrangement mode. Pieces build along the X axis, so
118
+ // 'bundle' aligns them side by side like gathered firewood (the Megascans
119
+ // "bunch of sticks" read), 'heap' stacks a dense center-biased mound,
120
+ // 'patch' spreads a flat ground layer, and 'scatter' keeps the loose
121
+ // natural throw.
122
+ function scatterObject(object, settings, random, index, { stacked = 0, tilt = 0.28 } = {}) {
123
+ const { arrangement, messiness, spread } = settings.asset;
124
+ // Messiness scales every chaos source; 0.5 reproduces the base look.
125
+ const chaos = mix(0.35, 1.65, messiness);
126
+ const rotation = settings.asset.rotationJitter * chaos;
127
+ object.scale.multiplyScalar(mix(1 - 0.3 * messiness, 1 + 0.35 * messiness, random()));
128
+ if (arrangement === 'bundle') {
129
+ const gauge = Math.max(stacked, 0.05);
130
+ const row = index % 3;
131
+ const layer = Math.floor(index / 3);
132
+ object.position.set(
133
+ signed(random) * Math.max(spread, 0.2) * 0.18,
134
+ layer * gauge * 0.85,
135
+ (row - 1) * gauge * 1.05 + (layer % 2) * gauge * 0.5 + signed(random) * gauge * 0.2,
136
+ );
137
+ // Mostly parallel; the odd piece thrown across sells "gathered".
138
+ const crossing = random() < 0.14 ? (random() < 0.5 ? 1 : -1) * mix(0.35, 0.8, random()) : 0;
139
+ object.rotation.set(
140
+ signed(random) * 0.04 * rotation,
141
+ signed(random) * 0.09 * rotation + crossing,
142
+ signed(random) * 0.05 * rotation,
143
+ );
144
+ return;
145
+ }
146
+ if (arrangement === 'heap') {
147
+ // Mild center bias over a real footprint: too tight and the support
148
+ // settle stacks everything into a tower instead of a mound.
149
+ const radius = random() ** 0.9 * spread * 0.6;
150
+ const angle = random() * Math.PI * 2;
151
+ // Layered fill (3 pieces per layer) keeps late pieces from spawning
152
+ // high over empty air; per-piece lift-only settle handles sinkers.
153
+ const layer = Math.floor(index / 3);
154
+ object.position.set(
155
+ Math.cos(angle) * radius,
156
+ layer * Math.max(stacked, 0.03) * 0.5 + random() * Math.max(stacked, 0.03) * 0.2,
157
+ Math.sin(angle) * radius,
158
+ );
159
+ // Tumble respects the piece's tilt hint: chunks roll freely, but
160
+ // elongated pieces (logs, planks, bones) stay near-horizontal — real
161
+ // piles don't balance logs on end.
162
+ const tumble = Math.min(tilt * 2.2, 1.2) * rotation;
163
+ object.rotation.set(
164
+ signed(random) * tumble,
165
+ random() * Math.PI * 2,
166
+ signed(random) * tumble,
167
+ );
168
+ return;
169
+ }
170
+ if (arrangement === 'patch') {
171
+ const radius = Math.sqrt(random()) * spread;
172
+ const angle = random() * Math.PI * 2;
173
+ object.position.set(Math.cos(angle) * radius, 0, Math.sin(angle) * radius);
174
+ object.rotation.set(
175
+ signed(random) * tilt * 0.4 * rotation,
176
+ random() * Math.PI * 2,
177
+ signed(random) * tilt * 0.4 * rotation,
178
+ );
179
+ object.scale.multiplyScalar(mix(0.6, 1.3, random()));
180
+ return;
181
+ }
182
+ const radius = Math.sqrt(random()) * spread;
183
+ const angle = random() * Math.PI * 2;
184
+ object.position.set(
185
+ Math.cos(angle) * radius,
186
+ index === 0 ? 0 : random() * stacked,
187
+ Math.sin(angle) * radius,
188
+ );
189
+ object.rotation.set(
190
+ signed(random) * tilt * rotation,
191
+ random() * Math.PI * 2 * rotation,
192
+ signed(random) * tilt * rotation,
193
+ );
194
+ }
195
+
196
+ // Rests a piece on the ground plane. `drop` also pulls floating pieces
197
+ // DOWN to contact (not just lifting sinkers) — pieces float otherwise,
198
+ // because spines/joints rarely put the geometry's lowest point at y=0.
199
+ function settleOnGround(object, clearance = 0.012, { drop = false } = {}) {
200
+ object.updateMatrixWorld(true);
201
+ const bounds = new THREE.Box3().setFromObject(object, true);
202
+ if (!Number.isFinite(bounds.min.y)) return;
203
+ if (drop || bounds.min.y < clearance) {
204
+ object.position.y += clearance - bounds.min.y;
205
+ object.updateMatrixWorld(true);
206
+ }
207
+ }
208
+
209
+ // Physical plausibility pass: flat and elongated pieces (planks, chips,
210
+ // sheets, sticks, leaves, bones) cannot balance on a corner — dropped on
211
+ // flat ground they rotate until they LIE FLAT. Pieces are measured level
212
+ // (yaw only): if the piece is much wider than tall, its pitch/roll gets
213
+ // clamped to a few degrees; chunky pieces (rocks, skulls) keep their
214
+ // tumble. Without this, corner-grazing AABB contact reads as floating.
215
+ function stabilizeFlatPieces(pieces) {
216
+ const box = new THREE.Box3();
217
+ for (const piece of pieces) {
218
+ const pitch = piece.rotation.x;
219
+ const roll = piece.rotation.z;
220
+ piece.rotation.x = 0;
221
+ piece.rotation.z = 0;
222
+ piece.updateMatrixWorld(true);
223
+ box.setFromObject(piece, true);
224
+ if (!Number.isFinite(box.min.y)) continue;
225
+ const height = box.max.y - box.min.y;
226
+ // Judge against the LONGEST plan axis: a 3-unit branch with bushy
227
+ // prongs is still a stick and must lie flat — only pieces that are
228
+ // genuinely as tall as they are long (rocks, skulls, cones) may rest
229
+ // tilted.
230
+ const footprint = Math.max(box.max.x - box.min.x, box.max.z - box.min.z);
231
+ if (height < footprint * 0.45) {
232
+ piece.rotation.x = Math.min(Math.max(pitch, -0.08), 0.08);
233
+ piece.rotation.z = Math.min(Math.max(roll, -0.08), 0.08);
234
+ } else {
235
+ piece.rotation.x = pitch;
236
+ piece.rotation.z = roll;
237
+ }
238
+ piece.updateMatrixWorld(true);
239
+ }
240
+ }
241
+
242
+ // Ground-contact settling for a whole composition. Scatter/patch pieces
243
+ // ALWAYS drop to the ground plane — mutual interpenetration reads as
244
+ // natural ground clutter, whereas resting pieces on each other's mostly-
245
+ // empty bounding boxes reads as floating. Heaps and bundles stack, but
246
+ // only when the CENTERS of two pieces overlap (boxes shrunk 30% toward
247
+ // their middles), so nothing hovers on a phantom AABB corner.
248
+ const SETTLE_UP = new THREE.Vector3(0, 1, 0);
249
+ const SETTLE_DOWN = new THREE.Vector3(0, -1, 0);
250
+
251
+ function settleComposition(pieces, { clearance = 0.012, stacking = false } = {}) {
252
+ const raycaster = new THREE.Raycaster();
253
+ const origin = new THREE.Vector3();
254
+ const placedMeshes = [];
255
+ const entries = pieces
256
+ .map((piece) => {
257
+ piece.updateMatrixWorld(true);
258
+ return { box: new THREE.Box3().setFromObject(piece, true), piece };
259
+ })
260
+ .filter((entry) => Number.isFinite(entry.box.min.y))
261
+ .sort((a, b) => a.box.min.y - b.box.min.y);
262
+
263
+ for (const { box, piece } of entries) {
264
+ let drop = null;
265
+ if (stacking && placedMeshes.length > 0) {
266
+ // Real contact: sample a grid of columns over the piece footprint.
267
+ // In each column, find the piece's actual UNDERSIDE (upward ray into
268
+ // itself) and the actual TOP SURFACE of whatever lies below
269
+ // (downward ray onto placed pieces). The piece drops by the smallest
270
+ // gap — first true geometric contact, never a phantom AABB corner.
271
+ const samples = 4;
272
+ const insetX = (box.max.x - box.min.x) * 0.12;
273
+ const insetZ = (box.max.z - box.min.z) * 0.12;
274
+ for (let gx = 0; gx < samples; gx += 1) {
275
+ for (let gz = 0; gz < samples; gz += 1) {
276
+ const x = box.min.x + insetX + (gx / (samples - 1)) * (box.max.x - box.min.x - insetX * 2);
277
+ const z = box.min.z + insetZ + (gz / (samples - 1)) * (box.max.z - box.min.z - insetZ * 2);
278
+ origin.set(x, box.min.y - 0.5, z);
279
+ raycaster.set(origin, SETTLE_UP);
280
+ const selfHit = raycaster.intersectObject(piece, true)[0];
281
+ if (!selfHit) continue;
282
+ const underside = selfHit.point.y;
283
+ origin.set(x, box.max.y + 0.5, z);
284
+ raycaster.set(origin, SETTLE_DOWN);
285
+ const supportHit = raycaster.intersectObjects(placedMeshes, true)[0];
286
+ const support = supportHit && supportHit.point.y <= underside + 1e-4
287
+ ? supportHit.point.y
288
+ : supportHit
289
+ ? underside // already interpenetrating at this column: can't drop
290
+ : 0;
291
+ const gap = underside - Math.max(support, 0) - clearance;
292
+ drop = drop === null ? gap : Math.min(drop, gap);
293
+ }
294
+ }
295
+ }
296
+ if (drop !== null) {
297
+ // Drop-only: settling never pushes a piece upward.
298
+ piece.position.y -= Math.max(drop, 0);
299
+ } else {
300
+ // Ground contact for scatter/patch (and the first stacked piece).
301
+ piece.position.y += clearance - box.min.y;
302
+ }
303
+ piece.updateMatrixWorld(true);
304
+ piece.traverse((object) => {
305
+ if (object.isMesh) placedMeshes.push(object);
306
+ });
307
+ }
308
+ }
309
+
310
+ function orientAlong(mesh, start, end) {
311
+ const direction = new THREE.Vector3().subVectors(end, start);
312
+ mesh.position.copy(start).addScaledVector(direction, 0.5);
313
+ mesh.quaternion.setFromUnitVectors(new THREE.Vector3(0, 1, 0), direction.clone().normalize());
314
+ return direction.length();
315
+ }
316
+
317
+ function tube(points, radius, radialSegments = 7) {
318
+ const curve = new THREE.CatmullRomCurve3(points, false, 'centripetal');
319
+ return new THREE.TubeGeometry(curve, Math.max(6, points.length * 3), radius, radialSegments, false);
320
+ }
321
+
322
+ // Coherent lumpy solid: vertices displace by simplex noise sampled at
323
+ // their direction, so neighbouring vertices move together and the result
324
+ // reads as one weathered chunk. Uncorrelated per-vertex jitter (the old
325
+ // approach) reads as crumpled tinfoil at any detail level.
326
+ function irregularGeometry(radius, random, angularity = 0.7, detail = 1, seed = 0) {
327
+ const geometry = new THREE.IcosahedronGeometry(radius, detail);
328
+ const noiseSeed = hashCombine(seed >>> 0, Math.floor(random() * 0xffff));
329
+ const position = geometry.attributes.position;
330
+ for (let index = 0; index < position.count; index += 1) {
331
+ const length = new THREE.Vector3().fromBufferAttribute(position, index).length() || 1;
332
+ const dx = position.getX(index) / length;
333
+ const dy = position.getY(index) / length;
334
+ const dz = position.getZ(index) / length;
335
+ const lump = 0.65 * simplexNoise3(noiseSeed, dx * 1.35, dy * 1.35, dz * 1.35)
336
+ + 0.35 * simplexNoise3(hashCombine(noiseSeed, 7), dx * 3.1, dy * 3.1, dz * 3.1);
337
+ const factor = 1 + lump * mix(0.1, 0.34, angularity);
338
+ position.setXYZ(index, dx * radius * factor, dy * radius * factor, dz * radius * factor);
339
+ }
340
+ geometry.computeVertexNormals();
341
+ return geometry;
342
+ }
343
+
344
+ function addSplinters(parent, point, direction, settings, random, amount, radius) {
345
+ const count = Math.round(amount * 4);
346
+ for (let index = 0; index < count; index += 1) {
347
+ const length = radius * mix(1.8, 4.2, random());
348
+ const start = point.clone().add(new THREE.Vector3(
349
+ signed(random) * radius * 0.34,
350
+ signed(random) * radius * 0.34,
351
+ signed(random) * radius * 0.34,
352
+ ));
353
+ const end = start.clone().add(direction.clone().normalize().multiplyScalar(length));
354
+ end.add(new THREE.Vector3(signed(random), signed(random) * 0.35, signed(random)).multiplyScalar(radius * 0.8));
355
+ const middle = start.clone().lerp(end, 0.55).add(new THREE.Vector3(
356
+ signed(random) * radius * 0.28,
357
+ random() * radius * 0.24,
358
+ signed(random) * radius * 0.28,
359
+ ));
360
+ const swept = createBranchTubeGeometry({
361
+ irregularity: 0.12,
362
+ points: [start.toArray(), middle.toArray(), end.toArray()],
363
+ radialSegments: 5,
364
+ radiusEnd: radius * 0.018,
365
+ radiusStart: radius * mix(0.1, 0.18, random()),
366
+ ringSpacing: Math.max(radius * 0.35, 0.025),
367
+ seed: settings.asset.seed + index * 23 + Math.floor(random() * 997),
368
+ });
369
+ if (swept) addMesh(parent, swept.geometry, settings, random, {
370
+ name: 'Tree Lab broken wood fiber', slot: index % 3 === 0 ? 2 : 1,
371
+ });
372
+ }
373
+ }
374
+
375
+ // Sharp elbow breaks at old fork points: rotate the polyline tail around
376
+ // a joint by a hard angle. Smooth `crookedness` wander can never produce
377
+ // the angular direction breaks that make scanned dead branches read.
378
+ function applyKinks(points, kinks, random) {
379
+ if (kinks <= 0.02) return points;
380
+ const axis = new THREE.Vector3();
381
+ const pivot = new THREE.Vector3();
382
+ const tail = new THREE.Vector3();
383
+ for (let joint = 1; joint < points.length - 1; joint += 1) {
384
+ if (random() > kinks * 0.55) continue;
385
+ const angle = (random() < 0.5 ? -1 : 1) * mix(0.25, 0.95, random()) * kinks;
386
+ axis.set(signed(random) * 0.4, 1, signed(random) * 0.4).normalize();
387
+ pivot.copy(points[joint]);
388
+ for (let after = joint + 1; after < points.length; after += 1) {
389
+ tail.copy(points[after]).sub(pivot).applyAxisAngle(axis, angle);
390
+ points[after].copy(tail).add(pivot);
391
+ }
392
+ }
393
+ return points;
394
+ }
395
+
396
+ // Bark condition: coherent lengthwise mask splits each piece into
397
+ // bark-dark and weathered-pale zones (bark-on vs bark-stripped is most of
398
+ // the visual variety in scanned driftwood). Runs on baked vertex colors,
399
+ // so pooled/shared geometry stays untouched elsewhere.
400
+ function applyBarkStripping(geometry, settings, seed, amount) {
401
+ if (amount <= 0.02 || !geometry.attributes.color) return;
402
+ const position = geometry.attributes.position;
403
+ const colors = geometry.attributes.color;
404
+ const pale = paletteColor(settings, 2).lerp(new THREE.Color(1, 1, 1), 0.25);
405
+ const maskSeed = hashCombine(seed >>> 0, 57);
406
+ const swatch = new THREE.Color();
407
+ for (let index = 0; index < position.count; index += 1) {
408
+ const mask = 0.5 + 0.5 * simplexNoise3(
409
+ maskSeed,
410
+ position.getX(index) * 1.6,
411
+ position.getY(index) * 3.2,
412
+ position.getZ(index) * 3.2,
413
+ );
414
+ if (mask >= amount) continue;
415
+ const strength = Math.min((amount - mask) / 0.18, 1);
416
+ swatch.setRGB(colors.getX(index), colors.getY(index), colors.getZ(index)).lerp(pale, strength * 0.85);
417
+ colors.setXYZ(index, swatch.r, swatch.g, swatch.b);
418
+ }
419
+ colors.needsUpdate = true;
420
+ }
421
+
422
+ // Root stump: central mass with radiating twisted roots — the Megascans
423
+ // "Coast Rauk Root Dead" silhouette. Roots are Tree Lab sweeps curling
424
+ // down and outward, a few upturned at the tips.
425
+ function createRootStumpPiece(settings, random, index) {
426
+ const piece = new THREE.Group();
427
+ piece.name = 'Procedural root stump';
428
+ const { barkStripped, branchiness, crookedness, kinks, length, thickness } = settings.shape;
429
+ const size = length * 0.5;
430
+ const seed = settings.asset.seed + index * 97;
431
+
432
+ const core = addMesh(piece, irregularGeometry(size * 0.3, random, 0.55, 2, seed), settings, random, {
433
+ name: 'Root core', slot: 0,
434
+ });
435
+ core.position.y = size * 0.26;
436
+ core.scale.set(1.15, 0.72, 1.05);
437
+
438
+ // Snapped trunk stub rising from the core.
439
+ const stub = createBranchTubeGeometry({
440
+ irregularity: 0.3,
441
+ points: [
442
+ [0, size * 0.2, 0],
443
+ [signed(random) * size * 0.1, size * mix(0.5, 0.75, random()), signed(random) * size * 0.1],
444
+ ],
445
+ radialSegments: 8,
446
+ radiusEnd: thickness * mix(0.9, 1.2, random()),
447
+ radiusStart: thickness * 1.6,
448
+ ringSpacing: size / 8,
449
+ seed: seed + 1,
450
+ });
451
+ if (stub) {
452
+ const mesh = addMesh(piece, stub.geometry, settings, random, { name: 'Trunk stub', slot: 0 });
453
+ applyBarkStripping(mesh.geometry, settings, seed + 1, barkStripped);
454
+ }
455
+
456
+ const rootCount = 7 + Math.round(branchiness * 6);
457
+ for (let root = 0; root < rootCount; root += 1) {
458
+ const azimuth = (root / rootCount) * Math.PI * 2 + signed(random) * 0.6;
459
+ const dirX = Math.cos(azimuth);
460
+ const dirZ = Math.sin(azimuth);
461
+ const reach = size * mix(0.5, 1.35, random());
462
+ const upturn = random() < 0.3 ? size * mix(0.15, 0.4, random()) : 0;
463
+ const points = [
464
+ new THREE.Vector3(dirX * size * 0.16, size * mix(0.16, 0.3, random()), dirZ * size * 0.16),
465
+ new THREE.Vector3(
466
+ dirX * reach * 0.45 + signed(random) * crookedness * size * 0.2,
467
+ size * mix(0.04, 0.14, random()),
468
+ dirZ * reach * 0.45 + signed(random) * crookedness * size * 0.2,
469
+ ),
470
+ new THREE.Vector3(
471
+ dirX * reach + signed(random) * crookedness * size * 0.15,
472
+ 0.012 + upturn,
473
+ dirZ * reach + signed(random) * crookedness * size * 0.15,
474
+ ),
475
+ ];
476
+ applyKinks(points, kinks, random);
477
+ const swept = createBranchTubeGeometry({
478
+ irregularity: 0.24 + crookedness * 0.14,
479
+ points: points.map((point) => point.toArray()),
480
+ radialSegments: 6,
481
+ radiusEnd: thickness * 0.06,
482
+ radiusStart: thickness * mix(0.5, 0.75, random()),
483
+ ringSpacing: Math.max(reach / 9, 0.03),
484
+ seed: seed + 3 + root * 11,
485
+ });
486
+ if (!swept) continue;
487
+ const mesh = addMesh(piece, swept.geometry, settings, random, {
488
+ name: 'Dead root', slot: root % 3 === 0 ? 1 : 0,
489
+ });
490
+ applyBarkStripping(mesh.geometry, settings, seed + 3 + root * 11, barkStripped);
491
+ // Rootlets on some roots.
492
+ if (random() < 0.4) {
493
+ const at = points[1];
494
+ const rootlet = createBranchTubeGeometry({
495
+ irregularity: 0.2,
496
+ points: [
497
+ at.toArray(),
498
+ [at.x + signed(random) * reach * 0.3, Math.max(at.y * 0.5, 0.01), at.z + signed(random) * reach * 0.3],
499
+ ],
500
+ radialSegments: 5,
501
+ radiusEnd: thickness * 0.04,
502
+ radiusStart: thickness * 0.22,
503
+ ringSpacing: Math.max(reach / 10, 0.03),
504
+ seed: seed + 200 + root,
505
+ });
506
+ if (rootlet) addMesh(piece, rootlet.geometry, settings, random, { name: 'Rootlet', slot: 1 });
507
+ }
508
+ }
509
+ scatterObject(piece, settings, random, index, { stacked: size * 0.4, tilt: 0.25 });
510
+ settleOnGround(piece);
511
+ return piece;
512
+ }
513
+
514
+ // Bark chip: a small curved ragged slab — bark side dark, inner wood
515
+ // pale — matching the scanned bark collections. Cylindrical curl across
516
+ // the width remembers the trunk it flaked off.
517
+ function createBarkChipPiece(settings, random, index) {
518
+ const piece = new THREE.Group();
519
+ piece.name = 'Procedural bark chip';
520
+ const { length, thickness } = settings.shape;
521
+ const chipLength = length * mix(0.16, 0.32, random());
522
+ const chipWidth = chipLength * mix(0.35, 0.6, random());
523
+ const chipThickness = Math.max(thickness * 0.22, 0.008);
524
+ const seed = hashCombine(settings.asset.seed, 1300 + index * 53);
525
+ const curlRadius = chipWidth / mix(0.5, 1.6, random());
526
+ const edgeSeed = hashCombine(seed, 3);
527
+ const cells = 10;
528
+
529
+ const inside = (x, z) => {
530
+ const u = x / chipLength;
531
+ const v = z / chipWidth;
532
+ const radial = Math.max(Math.abs(u), Math.abs(v) * 1.15) * 2;
533
+ const wobble = 0.78 + 0.3 * simplexNoise3(edgeSeed, u * 3.2, 0, v * 3.2);
534
+ return radial < wobble;
535
+ };
536
+ const bark = paletteColor(settings, 0);
537
+ const inner = paletteColor(settings, 2).lerp(new THREE.Color(1, 1, 1), 0.18);
538
+ const positions = [];
539
+ const colors = [];
540
+ const deform = (x, z) => {
541
+ const arc = z / curlRadius;
542
+ return [x, (1 - Math.cos(arc)) * curlRadius + chipThickness, Math.sin(arc) * curlRadius];
543
+ };
544
+ const pushVertex = (x, z, lift, color) => {
545
+ const [dx, dy, dz] = deform(x, z);
546
+ positions.push(dx, dy + lift, dz);
547
+ const drift = 1 + signed(random) * settings.surface.variation;
548
+ colors.push(
549
+ Math.min(color.r * drift, 1),
550
+ Math.min(color.g * drift, 1),
551
+ Math.min(color.b * drift, 1),
552
+ );
553
+ };
554
+ for (let cz = 0; cz < cells; cz += 1) {
555
+ for (let cx = 0; cx < cells; cx += 1) {
556
+ const x0 = (cx / cells - 0.5) * chipLength;
557
+ const x1 = ((cx + 1) / cells - 0.5) * chipLength;
558
+ const z0 = (cz / cells - 0.5) * chipWidth;
559
+ const z1 = ((cz + 1) / cells - 0.5) * chipWidth;
560
+ if (!inside((x0 + x1) / 2, (z0 + z1) / 2)) continue;
561
+ // Bark top, pale inner-wood bottom.
562
+ pushVertex(x0, z0, chipThickness, bark); pushVertex(x0, z1, chipThickness, bark); pushVertex(x1, z1, chipThickness, bark);
563
+ pushVertex(x0, z0, chipThickness, bark); pushVertex(x1, z1, chipThickness, bark); pushVertex(x1, z0, chipThickness, bark);
564
+ pushVertex(x0, z0, 0, inner); pushVertex(x1, z0, 0, inner); pushVertex(x1, z1, 0, inner);
565
+ pushVertex(x0, z0, 0, inner); pushVertex(x1, z1, 0, inner); pushVertex(x0, z1, 0, inner);
566
+ }
567
+ }
568
+ if (positions.length === 0) return piece;
569
+ const geometry = new THREE.BufferGeometry();
570
+ geometry.setAttribute('position', new THREE.BufferAttribute(new Float32Array(positions), 3));
571
+ geometry.setAttribute('color', new THREE.BufferAttribute(new Float32Array(colors), 3));
572
+ geometry.computeVertexNormals();
573
+ const mesh = new THREE.Mesh(geometry, createMaterial(settings, { doubleSided: true }));
574
+ mesh.name = 'Bark chip';
575
+ mesh.castShadow = true;
576
+ mesh.receiveShadow = true;
577
+ piece.add(mesh);
578
+ scatterObject(piece, settings, random, index, { stacked: chipThickness * 6, tilt: 0.5 });
579
+ settleOnGround(piece);
580
+ return piece;
581
+ }
582
+
583
+ // Broken plank, per DebrisMaker2's recipe: a thick slab whose ragged ends
584
+ // are CARVED (cells discarded past a noisy break line — raggedness only
585
+ // along the length, like grain snapping), knotholes punched through,
586
+ // then lengthwise warp + bend + twist. Emitted as one slab mesh: top and
587
+ // bottom vertex layers plus side skirts around every boundary cell.
588
+ function createPlankPiece(settings, random, index) {
589
+ const piece = new THREE.Group();
590
+ piece.name = 'Procedural broken plank';
591
+ const { branchiness, crookedness, length, splinters, thickness } = settings.shape;
592
+ const plankLength = length * mix(0.8, 1.15, random());
593
+ const plankWidth = Math.max(thickness * mix(2.2, 3.2, random()), plankLength * 0.08);
594
+ const plankThickness = Math.max(thickness * 0.55, 0.02);
595
+ const seed = hashCombine(settings.asset.seed, 1000 + index * 43);
596
+ const cellsX = 36;
597
+ const cellsZ = 8;
598
+
599
+ // Break lines: each end snaps along a jagged noise curve; `splinters`
600
+ // deepens the bite. Knotholes: 0-2 grain-elongated discs.
601
+ const breakSeed = hashCombine(seed, 3);
602
+ const endBite = [mix(0.02, 0.16, splinters) * (0.4 + random()), mix(0.02, 0.16, splinters) * (0.4 + random())];
603
+ const holes = [];
604
+ const holeCount = Math.round(branchiness * 2 * random());
605
+ for (let i = 0; i < holeCount; i += 1) {
606
+ holes.push([
607
+ signed(random) * plankLength * 0.3,
608
+ signed(random) * plankWidth * 0.22,
609
+ plankWidth * mix(0.1, 0.2, random()),
610
+ ]);
611
+ }
612
+ const inside = (x, z) => {
613
+ const across = z / plankWidth;
614
+ const jag0 = simplexNoise3(breakSeed, 0.5, across * 6, 0) * 0.5 + 0.5;
615
+ if (x < -plankLength * (0.5 - endBite[0] * jag0)) return false;
616
+ const jag1 = simplexNoise3(breakSeed, 7.5, across * 6, 0) * 0.5 + 0.5;
617
+ if (x > plankLength * (0.5 - endBite[1] * jag1)) return false;
618
+ for (const [hx, hz, radius] of holes) {
619
+ if (((x - hx) / 1.6) ** 2 + (z - hz) ** 2 < radius * radius) return false;
620
+ }
621
+ return true;
622
+ };
623
+
624
+ // Warp/bend/twist. Boards are STIFF: DM2's warp is ~0.5% of length —
625
+ // anything stronger reads as rubber, not lumber.
626
+ const warpSeed = hashCombine(seed, 11);
627
+ const bendAngle = signed(random) * crookedness * 0.2;
628
+ const twistAngle = signed(random) * crookedness * 0.55;
629
+ const deform = (x, y, z) => {
630
+ let py = y + crookedness * plankThickness * 0.6
631
+ * simplexNoise3(warpSeed, x * 1.2 / plankLength, 0, z * 0.6 / plankWidth);
632
+ py += Math.sin((x / plankLength) * Math.PI) * bendAngle * plankLength * 0.1;
633
+ const angle = twistAngle * (x / plankLength);
634
+ const cos = Math.cos(angle);
635
+ const sin = Math.sin(angle);
636
+ return [x, py * cos - z * sin + plankThickness, py * sin + z * cos];
637
+ };
638
+
639
+ const positions = [];
640
+ const cellAt = new Array(cellsX * cellsZ).fill(false);
641
+ const cellCorner = (cx, cz) => [
642
+ (cx / cellsX - 0.5) * plankLength,
643
+ (cz / cellsZ - 0.5) * plankWidth,
644
+ ];
645
+ for (let cz = 0; cz < cellsZ; cz += 1) {
646
+ for (let cx = 0; cx < cellsX; cx += 1) {
647
+ const [x0, z0] = cellCorner(cx, cz);
648
+ const [x1, z1] = cellCorner(cx + 1, cz + 1);
649
+ if (!inside((x0 + x1) / 2, (z0 + z1) / 2)) continue;
650
+ cellAt[cz * cellsX + cx] = true;
651
+ const half = plankThickness / 2;
652
+ const quad = (a, b, c, d) => positions.push(...a, ...b, ...c, ...a, ...c, ...d);
653
+ quad(deform(x0, half, z0), deform(x0, half, z1), deform(x1, half, z1), deform(x1, half, z0));
654
+ quad(deform(x0, -half, z0), deform(x1, -half, z0), deform(x1, -half, z1), deform(x0, -half, z1));
655
+ }
656
+ }
657
+ // Side skirts wherever a kept cell borders a dropped one.
658
+ const has = (cx, cz) => cx >= 0 && cz >= 0 && cx < cellsX && cz < cellsZ && cellAt[cz * cellsX + cx];
659
+ for (let cz = 0; cz < cellsZ; cz += 1) {
660
+ for (let cx = 0; cx < cellsX; cx += 1) {
661
+ if (!has(cx, cz)) continue;
662
+ const [x0, z0] = cellCorner(cx, cz);
663
+ const [x1, z1] = cellCorner(cx + 1, cz + 1);
664
+ const half = plankThickness / 2;
665
+ const wall = (ax, az, bx, bz) => {
666
+ positions.push(
667
+ ...deform(ax, half, az), ...deform(bx, half, bz), ...deform(bx, -half, bz),
668
+ ...deform(ax, half, az), ...deform(bx, -half, bz), ...deform(ax, -half, az),
669
+ );
670
+ };
671
+ if (!has(cx - 1, cz)) wall(x0, z1, x0, z0);
672
+ if (!has(cx + 1, cz)) wall(x1, z0, x1, z1);
673
+ if (!has(cx, cz - 1)) wall(x0, z0, x1, z0);
674
+ if (!has(cx, cz + 1)) wall(x1, z1, x0, z1);
675
+ }
676
+ }
677
+ if (positions.length === 0) return piece;
678
+ const geometry = new THREE.BufferGeometry();
679
+ geometry.setAttribute('position', new THREE.BufferAttribute(new Float32Array(positions), 3));
680
+ geometry.computeVertexNormals();
681
+ addMesh(piece, geometry, settings, random, { name: 'Broken plank', slot: index % 3 === 0 ? 1 : 0 });
682
+ scatterObject(piece, settings, random, index, { stacked: plankThickness * 3, tilt: 0.35 });
683
+ settleOnGround(piece);
684
+ return piece;
685
+ }
686
+
687
+ // Sawn-end disc with baked growth rings: pale cut face, darker concentric
688
+ // rings, bark-colored rim. This is THE firewood read — a log without ring
689
+ // ends is just a fat lumpy branch.
690
+ function buildLogEndCapGeometry(radius, settings, random) {
691
+ const rings = 4;
692
+ const sectors = 14;
693
+ const face = paletteColor(settings, 2).multiplyScalar(1.1);
694
+ const ringLine = paletteColor(settings, 1).multiplyScalar(0.85);
695
+ const bark = paletteColor(settings, 0);
696
+ const positions = new Float32Array((rings * sectors + 1) * 3);
697
+ const colors = new Float32Array((rings * sectors + 1) * 3);
698
+ const swatch = new THREE.Color();
699
+ const writeColor = (offset, color) => {
700
+ colors[offset] = Math.min(color.r, 1);
701
+ colors[offset + 1] = Math.min(color.g, 1);
702
+ colors[offset + 2] = Math.min(color.b, 1);
703
+ };
704
+ positions[2] = radius * 0.06;
705
+ writeColor(0, face);
706
+ const wobbleSeed = Math.floor(random() * 0xffff);
707
+ for (let ring = 1; ring <= rings; ring += 1) {
708
+ const t = ring / rings;
709
+ for (let sector = 0; sector < sectors; sector += 1) {
710
+ const angle = (sector / sectors) * Math.PI * 2;
711
+ const wobble = 1 + 0.05 * simplexNoise3(wobbleSeed, Math.cos(angle) * 1.2, t, Math.sin(angle) * 1.2);
712
+ const r = radius * t * wobble;
713
+ const vertex = 1 + (ring - 1) * sectors + sector;
714
+ positions[vertex * 3] = Math.cos(angle) * r;
715
+ positions[vertex * 3 + 1] = Math.sin(angle) * r;
716
+ positions[vertex * 3 + 2] = radius * 0.06 * (1 - t * t);
717
+ swatch.copy(face);
718
+ if (ring === rings) swatch.copy(bark);
719
+ else if (ring % 2 === 0) swatch.lerp(ringLine, 0.65);
720
+ writeColor(vertex * 3, swatch);
721
+ }
722
+ }
723
+ const indices = [];
724
+ for (let sector = 0; sector < sectors; sector += 1) {
725
+ indices.push(0, 1 + sector, 1 + ((sector + 1) % sectors));
726
+ }
727
+ for (let ring = 0; ring < rings - 1; ring += 1) {
728
+ for (let sector = 0; sector < sectors; sector += 1) {
729
+ const a = 1 + ring * sectors + sector;
730
+ const b = 1 + ring * sectors + ((sector + 1) % sectors);
731
+ indices.push(a, a + sectors, b, b, a + sectors, b + sectors);
732
+ }
733
+ }
734
+ const geometry = new THREE.BufferGeometry();
735
+ geometry.setAttribute('position', new THREE.BufferAttribute(positions, 3));
736
+ geometry.setAttribute('color', new THREE.BufferAttribute(colors, 3));
737
+ geometry.setIndex(indices);
738
+ geometry.computeVertexNormals();
739
+ return geometry;
740
+ }
741
+
742
+ // Sawn firewood log: slim straight core (length ~4.5x diameter — fat
743
+ // stubby sweeps read as crumpled blobs under the toon shader), bark as
744
+ // lengthwise ridges via the sweep's cross-section profile (DM2's
745
+ // stretched bark noise), and growth-ring caps aligned to the true end
746
+ // tangents.
747
+ function createLogPiece(settings, random, index) {
748
+ const piece = new THREE.Group();
749
+ piece.name = 'Procedural sawn log';
750
+ const { branchiness, crookedness, length, thickness } = settings.shape;
751
+ const radius = thickness * mix(1.15, 1.45, random());
752
+ const logLength = Math.max(length * mix(0.55, 0.75, random()), radius * 3.6);
753
+ const bow = crookedness * logLength * 0.03;
754
+ const points = [
755
+ [-logLength / 2, radius, 0],
756
+ [0, radius + bow, signed(random) * crookedness * radius * 0.15],
757
+ [logLength / 2, radius, 0],
758
+ ];
759
+ // Bark ridges: a wavy radius profile constant along the run.
760
+ const profileSeed = hashCombine(settings.asset.seed, 1100 + index * 71);
761
+ const ridgePhase = random() * Math.PI * 2;
762
+ const profile = [];
763
+ for (let k = 0; k < 16; k += 1) {
764
+ const angle = (k / 16) * Math.PI * 2;
765
+ profile.push(
766
+ 1
767
+ + 0.05 * Math.sin(angle * 5 + ridgePhase)
768
+ + 0.05 * simplexNoise3(profileSeed, Math.cos(angle) * 1.4, 0, Math.sin(angle) * 1.4),
769
+ );
770
+ }
771
+ const swept = createBranchTubeGeometry({
772
+ irregularity: 0.05,
773
+ points,
774
+ profile,
775
+ radialSegments: 12,
776
+ radiusEnd: radius * 0.97,
777
+ radiusStart: radius,
778
+ ringSpacing: Math.max(logLength / 6, 0.05),
779
+ seed: settings.asset.seed + index * 67,
780
+ });
781
+ if (swept) addMesh(piece, swept.geometry, settings, random, { name: 'Log bark', slot: 0 });
782
+ for (const side of [-1, 1]) {
783
+ const capGeometry = buildLogEndCapGeometry(radius * 1.02, settings, random);
784
+ const cap = new THREE.Mesh(capGeometry, createMaterial(settings, { doubleSided: true }));
785
+ cap.name = 'Sawn end';
786
+ cap.castShadow = true;
787
+ cap.receiveShadow = true;
788
+ const end = points[side < 0 ? 0 : 2];
789
+ const inner = points[1];
790
+ const tangent = new THREE.Vector3(end[0] - inner[0], end[1] - inner[1], end[2] - inner[2]).normalize();
791
+ cap.position.set(end[0], end[1], end[2]).addScaledVector(tangent, -radius * 0.02);
792
+ cap.quaternion.setFromUnitVectors(new THREE.Vector3(0, 0, 1), tangent);
793
+ piece.add(cap);
794
+ }
795
+ const knotCount = Math.round(branchiness * 2 * random());
796
+ for (let knot = 0; knot < knotCount; knot += 1) {
797
+ const stubLength = radius * mix(0.3, 0.5, random());
798
+ const stub = addMesh(piece, new THREE.CylinderGeometry(radius * 0.13, radius * 0.2, stubLength, 6), settings, random, {
799
+ name: 'Branch stub', slot: 1,
800
+ });
801
+ const t = mix(0.25, 0.75, random());
802
+ const angle = random() * Math.PI;
803
+ stub.position.set(
804
+ (t - 0.5) * logLength,
805
+ radius + Math.cos(angle) * radius,
806
+ Math.sin(angle) * radius * (random() < 0.5 ? -1 : 1),
807
+ );
808
+ stub.rotation.x = Math.PI / 2 - angle;
809
+ }
810
+ scatterObject(piece, settings, random, index, { stacked: radius * 1.1, tilt: 0.2 });
811
+ settleOnGround(piece);
812
+ return piece;
813
+ }
814
+
815
+ function createWoodPiece(settings, random, index) {
816
+ if (settings.asset.variant === 'planks') return createPlankPiece(settings, random, index);
817
+ if (settings.asset.variant === 'logs') return createLogPiece(settings, random, index);
818
+ if (settings.asset.variant === 'rootStump') return createRootStumpPiece(settings, random, index);
819
+ if (settings.asset.variant === 'barkChips') return createBarkChipPiece(settings, random, index);
820
+ const piece = new THREE.Group();
821
+ piece.name = 'Procedural wood piece';
822
+ const { branchiness, crookedness, kinks, length, splinters, thickness } = settings.shape;
823
+ const twig = settings.asset.variant === 'twigPile';
824
+ const pieceLength = length * (twig ? mix(0.55, 1, random()) : mix(0.82, 1.12, random()));
825
+ const radius = thickness * (twig ? mix(0.55, 0.9, random()) : mix(0.85, 1.15, random()));
826
+ const points = [];
827
+ const segments = twig ? 4 : 6;
828
+ for (let step = 0; step < segments; step += 1) {
829
+ const t = step / (segments - 1);
830
+ points.push(new THREE.Vector3(
831
+ (t - 0.5) * pieceLength,
832
+ radius + Math.sin(t * Math.PI) * crookedness * pieceLength * 0.08 + signed(random) * crookedness * radius,
833
+ signed(random) * crookedness * pieceLength * 0.12,
834
+ ));
835
+ }
836
+ applyKinks(points, kinks, random);
837
+ if (twig) {
838
+ const swept = createBranchTubeGeometry({
839
+ irregularity: 0.18 + crookedness * 0.12,
840
+ points: points.map((point) => point.toArray()),
841
+ radialSegments: 6,
842
+ radiusEnd: radius * 0.22,
843
+ radiusStart: radius * 1.08,
844
+ ringSpacing: Math.max(0.035, radius * 0.7),
845
+ seed: settings.asset.seed + index * 19,
846
+ });
847
+ if (swept) addMesh(piece, swept.geometry, settings, random, { name: 'Tree Lab twig sweep', slot: 0 });
848
+ } else {
849
+ // This is the same central-leader branch builder Tree Lab uses. A
850
+ // horizontal hand-drawn spine becomes the fallen limb; Tree Lab grows
851
+ // radius-continuous child forks and tapered terminal twigs from it.
852
+ const grown = createBranchingTreeSkeleton({
853
+ branchAngle: mix(48, 76, branchiness),
854
+ branchStart: mix(0.18, 0.32, random()),
855
+ canopyScale: 1,
856
+ childrenCount: Math.max(1, Math.round(1 + branchiness * 4)),
857
+ forceStrength: 0,
858
+ gnarliness: 0.06 + crookedness * 0.22,
859
+ leafSpacing: 10,
860
+ lengthRatio: 0.26 + branchiness * 0.16,
861
+ levels: branchiness > 0.67 ? 2 : 1,
862
+ maxAttachments: 1,
863
+ maxBranches: branchiness > 0.67 ? 18 : Math.max(3, Math.round(2 + branchiness * 5)),
864
+ radialSegments: 8,
865
+ radiusRatio: 0.58,
866
+ seed: settings.asset.seed + index * 37,
867
+ tipRadius: Math.max(radius * 0.075, 0.006),
868
+ trunk: { gnarl: crookedness * 0.25, radiusBottom: radius * 1.08 },
869
+ trunkSpine: points.map((point) => point.toArray()),
870
+ });
871
+ const grownMesh = addMesh(piece, grown.geometry, settings, random, {
872
+ name: settings.asset.variant === 'driftwood' ? 'Tree Lab driftwood branch' : 'Tree Lab fallen branch',
873
+ slot: 0,
874
+ });
875
+ applyBarkStripping(grownMesh.geometry, settings, settings.asset.seed + index * 37, settings.shape.barkStripped);
876
+
877
+ // Knuckles use the same embedded-bulge language as Tree Lab wood
878
+ // details. They break the silhouette without faking branches as pipes.
879
+ const knotCount = Math.round(branchiness * 3);
880
+ for (let knotIndex = 0; knotIndex < knotCount; knotIndex += 1) {
881
+ const pointIndex = 1 + Math.floor(random() * (points.length - 2));
882
+ const knot = addMesh(piece, new THREE.IcosahedronGeometry(radius * mix(0.55, 0.82, random()), 1), settings, random, {
883
+ name: 'Branch knuckle', slot: knotIndex % 3 === 0 ? 1 : 0,
884
+ });
885
+ knot.position.copy(points[pointIndex]);
886
+ knot.scale.set(1.25, 0.82, 1.05);
887
+ }
888
+ }
889
+ if (splinters > 0.08) {
890
+ // The detached base is the visibly broken end; terminal forks keep the
891
+ // natural taper produced by Tree Lab's branch builder.
892
+ const brokenDirection = points[0].clone().sub(points[1]);
893
+ if (!twig) {
894
+ const cap = addMesh(piece, new THREE.CircleGeometry(radius * 1.07, 8), settings, random, {
895
+ doubleSided: true, name: 'Broken branch end', slot: 2,
896
+ });
897
+ cap.position.copy(points[0]);
898
+ cap.quaternion.setFromUnitVectors(new THREE.Vector3(0, 0, 1), brokenDirection.clone().normalize());
899
+ }
900
+ addSplinters(piece, points[0], brokenDirection, settings, random, splinters, radius);
901
+ // Structural prongs: big tapered forks at the break, not just fibers —
902
+ // the splayed multi-prong driftwood read.
903
+ if (!twig && splinters > 0.35) {
904
+ const prongCount = 1 + Math.round(splinters * 2);
905
+ const direction = brokenDirection.clone().normalize();
906
+ for (let prong = 0; prong < prongCount; prong += 1) {
907
+ const prongLength = radius * mix(3, 6, random());
908
+ const cone = new THREE.Vector3(
909
+ signed(random) * 0.5, mix(0.1, 0.5, random()), signed(random) * 0.5,
910
+ ).multiplyScalar(0.6);
911
+ const end = points[0].clone()
912
+ .addScaledVector(direction, prongLength)
913
+ .addScaledVector(cone, prongLength * 0.6);
914
+ const middle = points[0].clone().lerp(end, 0.5).add(new THREE.Vector3(
915
+ signed(random) * radius * 0.5, random() * radius * 0.6, signed(random) * radius * 0.5,
916
+ ));
917
+ const sweptProng = createBranchTubeGeometry({
918
+ irregularity: 0.2,
919
+ points: [points[0].toArray(), middle.toArray(), end.toArray()],
920
+ radialSegments: 6,
921
+ radiusEnd: radius * 0.03,
922
+ radiusStart: radius * mix(0.28, 0.42, random()),
923
+ ringSpacing: Math.max(prongLength / 8, 0.03),
924
+ seed: settings.asset.seed + index * 37 + prong * 13 + 5,
925
+ });
926
+ if (!sweptProng) continue;
927
+ const prongMesh = addMesh(piece, sweptProng.geometry, settings, random, {
928
+ name: 'Broken prong', slot: prong % 2 === 0 ? 1 : 0,
929
+ });
930
+ applyBarkStripping(prongMesh.geometry, settings, settings.asset.seed + prong * 91, settings.shape.barkStripped);
931
+ }
932
+ }
933
+ }
934
+ scatterObject(piece, settings, random, index, { stacked: twig ? radius * 5 : radius * 2, tilt: 0.4 });
935
+ settleOnGround(piece);
936
+ return piece;
937
+ }
938
+
939
+ // SDF bone forms are meshed once per unique variant and shared across
940
+ // instances (Quixel packs ship 2-4 scan variants and scatter them; nobody
941
+ // notices at debris scale, and it keeps slider drags interactive). The
942
+ // per-instance read comes from rotation, mirroring, scale, and a small
943
+ // material tint.
944
+ const BONE_FIELD_BUILDERS = Object.freeze({
945
+ jawBone: { builder: createJawField, name: 'SDF jaw bone', unique: 2 },
946
+ longBone: { builder: createLongBoneField, name: 'SDF long bone', unique: 3 },
947
+ skull: { builder: createSkullField, name: 'SDF skull', unique: 2 },
948
+ });
949
+
950
+ function buildBonePool(settings, count) {
951
+ const entry = BONE_FIELD_BUILDERS[settings.asset.variant] ?? BONE_FIELD_BUILDERS.longBone;
952
+ const geometries = [];
953
+ const uniqueCount = Math.min(count, entry.unique);
954
+ for (let unique = 0; unique < uniqueCount; unique += 1) {
955
+ const fieldSeed = hashCombine(settings.asset.seed, 977 + unique * 131);
956
+ const field = entry.builder(settings.shape, fieldSeed);
957
+ // High cavity tint: toon banding compresses shading, so sockets and
958
+ // grooves need their darkening baked strongly into vertex color.
959
+ const geometry = meshDebrisField(field, settings.surface, fieldSeed, { cavityTint: 0.95 });
960
+ if (geometry) geometries.push(geometry);
961
+ }
962
+ return geometries.length > 0 ? { geometries, name: entry.name } : null;
963
+ }
964
+
965
+ function createPooledPiece(pool, geometry, settings, random, index, { mirror = false, stacked, tilt }) {
966
+ const piece = new THREE.Group();
967
+ piece.name = pool.name;
968
+ const mesh = new THREE.Mesh(geometry, createMaterial(settings));
969
+ mesh.name = pool.name;
970
+ mesh.castShadow = true;
971
+ mesh.receiveShadow = true;
972
+ // Per-instance variation without re-meshing: value tint, size jitter,
973
+ // and Z-mirroring (chiral bones come from both sides of the animal).
974
+ mesh.material.color.setScalar(1 + signed(random) * settings.surface.variation * 0.4);
975
+ mesh.scale.setScalar(mix(0.86, 1.14, random()));
976
+ if (mirror && random() < 0.5) mesh.scale.z *= -1;
977
+ piece.add(mesh);
978
+ scatterObject(piece, settings, random, index, { stacked, tilt });
979
+ settleOnGround(piece);
980
+ return piece;
981
+ }
982
+
983
+ function samplePolyline(points, t) {
984
+ const scaled = Math.min(Math.max(t, 0), 1) * (points.length - 1);
985
+ const index = Math.min(Math.floor(scaled), points.length - 2);
986
+ const local = scaled - index;
987
+ const a = points[index];
988
+ const b = points[index + 1];
989
+ return [
990
+ a[0] + (b[0] - a[0]) * local,
991
+ a[1] + (b[1] - a[1]) * local,
992
+ a[2] + (b[2] - a[2]) * local,
993
+ ];
994
+ }
995
+
996
+ // Antlers are branches: a curling main beam swept with Tree Lab's tube
997
+ // builder (organic cross-section, taper) and forward-curving tines that
998
+ // shorten toward the tip.
999
+ function createAntler(settings, random, index) {
1000
+ const piece = new THREE.Group();
1001
+ piece.name = 'Procedural antler';
1002
+ const { curvature, jointSize, length, thickness } = settings.shape;
1003
+ const sweepAngle = 0.7 + curvature * 1.6;
1004
+ const arcRadius = length / sweepAngle;
1005
+ const wobblePhase = random() * Math.PI * 2;
1006
+ const beamPoints = [];
1007
+ for (let step = 0; step < 7; step += 1) {
1008
+ const t = step / 6;
1009
+ const angle = sweepAngle * t;
1010
+ beamPoints.push([
1011
+ arcRadius * Math.sin(angle),
1012
+ thickness + arcRadius * (1 - Math.cos(angle)),
1013
+ length * 0.05 * (0.4 + curvature) * Math.sin(t * Math.PI * 2 + wobblePhase),
1014
+ ]);
1015
+ }
1016
+ const beam = createBranchTubeGeometry({
1017
+ irregularity: 0.18,
1018
+ points: beamPoints,
1019
+ radialSegments: 7,
1020
+ radiusEnd: thickness * 0.2,
1021
+ radiusStart: thickness * 1.05,
1022
+ ringSpacing: Math.max(length / 26, 0.03),
1023
+ seed: settings.asset.seed + index * 53,
1024
+ });
1025
+ if (beam) addMesh(piece, beam.geometry, settings, random, { name: 'Antler beam', slot: 0 });
1026
+ const burr = addMesh(piece, irregularGeometry(thickness * 1.45, random, 0.5, 1, settings.asset.seed + index), settings, random, {
1027
+ name: 'Antler burr', slot: 1,
1028
+ });
1029
+ burr.position.set(beamPoints[0][0], beamPoints[0][1], beamPoints[0][2]);
1030
+ burr.scale.set(1, 0.55, 1);
1031
+ const tineCount = 2 + Math.round(jointSize * 2.5);
1032
+ for (let tine = 0; tine < tineCount; tine += 1) {
1033
+ const along = 0.16 + 0.62 * (tine / Math.max(tineCount - 1, 1));
1034
+ const base = samplePolyline(beamPoints, along);
1035
+ const tineLength = length * mix(0.2, 0.34, random()) * (1 - along * 0.45);
1036
+ const side = (tine % 2 ? -1 : 1) * mix(0.2, 0.5, random());
1037
+ const mid = [
1038
+ base[0] - tineLength * 0.12,
1039
+ base[1] + tineLength * 0.5,
1040
+ base[2] + side * tineLength * 0.4,
1041
+ ];
1042
+ const end = [
1043
+ base[0] - tineLength * 0.38,
1044
+ base[1] + tineLength * 0.92,
1045
+ base[2] + side * tineLength * 0.55,
1046
+ ];
1047
+ const swept = createBranchTubeGeometry({
1048
+ irregularity: 0.14,
1049
+ points: [base, mid, end],
1050
+ radialSegments: 6,
1051
+ radiusEnd: thickness * 0.07,
1052
+ radiusStart: thickness * mix(0.45, 0.62, 1 - along),
1053
+ ringSpacing: Math.max(tineLength / 8, 0.02),
1054
+ seed: settings.asset.seed + index * 53 + tine * 7 + 1,
1055
+ });
1056
+ if (swept) addMesh(piece, swept.geometry, settings, random, {
1057
+ name: 'Antler tine', slot: 1,
1058
+ });
1059
+ }
1060
+ scatterObject(piece, settings, random, index, { stacked: thickness, tilt: 0.6 });
1061
+ settleOnGround(piece);
1062
+ return piece;
1063
+ }
1064
+
1065
+ // Masonry fragments are mini-rocks: base solid + planar fracture cuts +
1066
+ // cellular facet creases, meshed once per unique variant and shared.
1067
+ function buildStonePool(settings, count) {
1068
+ const geometries = [];
1069
+ const uniqueCount = Math.min(count, settings.asset.variant === 'gems' ? 3 : 4);
1070
+ for (let unique = 0; unique < uniqueCount; unique += 1) {
1071
+ const fieldSeed = hashCombine(settings.asset.seed, 1201 + unique * 157);
1072
+ const field = createStoneField(settings.shape, settings.asset.variant, fieldSeed);
1073
+ const geometry = meshDebrisField(field, settings.surface, fieldSeed, {
1074
+ cavityTint: settings.asset.variant === 'gems' ? 0.2 : 0.5,
1075
+ });
1076
+ if (geometry) geometries.push(geometry);
1077
+ }
1078
+ return geometries.length > 0 ? { geometries, name: `SDF ${settings.asset.variant}` } : null;
1079
+ }
1080
+
1081
+ const MASONRY_VARIANTS = new Set(['rubble', 'bricks', 'shards']);
1082
+
1083
+ // The merged stone type exposes unified sliders; masonry builders keep
1084
+ // their historical parameter names via this adapter.
1085
+ function masonryShapeAdapter(shape) {
1086
+ return { ...shape, angularity: shape.sharpness, fracture: shape.detail };
1087
+ }
1088
+
1089
+ function buildMasonryPool(settings, count) {
1090
+ const variant = settings.asset.variant;
1091
+ const kinds = variant === 'bricks'
1092
+ ? [['brick', 3]]
1093
+ : variant === 'shards'
1094
+ ? [['shard', Math.min(count, 4)]]
1095
+ : [['chunk', Math.min(count, 3)], ['brick', 2]];
1096
+ const pool = { brick: [], chunk: [], shard: [] };
1097
+ for (const [kind, uniqueCount] of kinds) {
1098
+ for (let unique = 0; unique < uniqueCount; unique += 1) {
1099
+ const fieldSeed = hashCombine(settings.asset.seed, 449 + unique * 173 + kind.length);
1100
+ const field = createMasonryChunkField(masonryShapeAdapter(settings.shape), kind, fieldSeed);
1101
+ const geometry = meshDebrisField(field, settings.surface, fieldSeed, { cavityTint: 0.45 });
1102
+ if (geometry) pool[kind].push(geometry);
1103
+ }
1104
+ }
1105
+ return pool.brick.length + pool.chunk.length + pool.shard.length > 0 ? pool : null;
1106
+ }
1107
+
1108
+ function createMasonryPiece(settings, random, index, pool) {
1109
+ const { brickRatio, chunkSize, stacking } = settings.shape;
1110
+ const variant = settings.asset.variant;
1111
+ const wantBrick = variant === 'bricks' || (variant === 'rubble' && random() < brickRatio);
1112
+ const kind = variant === 'shards' ? 'shard' : wantBrick && pool.brick.length > 0 ? 'brick' : 'chunk';
1113
+ const geometries = pool[kind].length > 0 ? pool[kind] : pool.chunk.length > 0 ? pool.chunk : pool.brick;
1114
+ const geometry = geometries[(index + Math.floor(random() * geometries.length)) % geometries.length];
1115
+ return createPooledPiece(
1116
+ { name: kind === 'brick' ? 'Broken brick' : kind === 'shard' ? 'Stone shard' : 'Concrete chunk' },
1117
+ geometry, settings, random, index,
1118
+ { mirror: true, stacked: chunkSize * stacking * 1.5, tilt: 0.8 },
1119
+ );
1120
+ }
1121
+
1122
+ // Torn metal sheet, following DebrisMaker2's corrugated-metal recipe:
1123
+ // the silhouette is CARVED (corner slices + noisy edge bites + punched
1124
+ // rust-hole clusters remove triangles, leaving raw jagged borders), the
1125
+ // corrugation is a rounded sine with amplitude ~= 0.27x wavelength, and
1126
+ // crumpling is layered AFTER corrugation as low-frequency coherent noise
1127
+ // plus a few straight crease folds. A rectangle with sine bumps reads as
1128
+ // a rubber mat; a torn outline is what says "sheet metal".
1129
+ function buildTornSheetGeometry(settings, random, seed) {
1130
+ const { bend, corrugation, rust, sheetSize } = settings.shape;
1131
+ const width = sheetSize * mix(0.8, 1.25, random());
1132
+ const length = width * mix(1.25, 1.85, random());
1133
+ const cellsX = 30;
1134
+ const cellsZ = 42;
1135
+
1136
+ // Silhouette cutters, all in the sheet's XZ plane.
1137
+ const slices = [];
1138
+ const sliceCount = 1 + Math.floor(random() * 3);
1139
+ for (let i = 0; i < sliceCount; i += 1) {
1140
+ const theta = random() * Math.PI * 2;
1141
+ const nx = Math.cos(theta);
1142
+ const nz = Math.sin(theta);
1143
+ const support = Math.abs(nx) * width * 0.5 + Math.abs(nz) * length * 0.5;
1144
+ slices.push([nx, nz, support * (1 - mix(0.1, 0.4, random()))]);
1145
+ }
1146
+ const bites = [];
1147
+ const biteCount = 1 + Math.floor(random() * 2);
1148
+ for (let i = 0; i < biteCount; i += 1) {
1149
+ const edge = random() * Math.PI * 2;
1150
+ bites.push([
1151
+ Math.cos(edge) * width * 0.5,
1152
+ Math.sin(edge) * length * 0.5,
1153
+ width * mix(0.14, 0.32, random()),
1154
+ hashCombine(seed, 41 + i),
1155
+ ]);
1156
+ }
1157
+ const holes = [];
1158
+ const clusterCount = Math.round(rust * 3);
1159
+ for (let cluster = 0; cluster < clusterCount; cluster += 1) {
1160
+ const cx = signed(random) * width * 0.32;
1161
+ const cz = signed(random) * length * 0.34;
1162
+ const count = 3 + Math.floor(random() * 5);
1163
+ for (let i = 0; i < count; i += 1) {
1164
+ holes.push([
1165
+ cx + signed(random) * width * 0.1,
1166
+ cz + signed(random) * width * 0.1,
1167
+ width * mix(0.02, 0.055, random()),
1168
+ ]);
1169
+ }
1170
+ }
1171
+ const inside = (x, z) => {
1172
+ for (const [nx, nz, d] of slices) if (nx * x + nz * z > d) return false;
1173
+ for (const [bx, bz, radius, biteSeed] of bites) {
1174
+ const wobble = 1 + 0.45 * simplexNoise3(biteSeed, x * 3 / width, 0, z * 3 / width);
1175
+ if ((x - bx) ** 2 + (z - bz) ** 2 < (radius * wobble) ** 2) return false;
1176
+ }
1177
+ for (const [hx, hz, radius] of holes) {
1178
+ if ((x - hx) ** 2 + (z - hz) ** 2 < radius * radius) return false;
1179
+ }
1180
+ return true;
1181
+ };
1182
+
1183
+ // Height field: rounded-sine corrugation + coherent crumple + creases.
1184
+ const waveCount = 4 + corrugation * 8;
1185
+ const wavelength = width / waveCount;
1186
+ const amplitude = corrugation * wavelength * 0.27;
1187
+ const crumpleSeed = hashCombine(seed, 7);
1188
+ const creases = [];
1189
+ const creaseCount = 1 + Math.round(bend * 2);
1190
+ for (let i = 0; i < creaseCount; i += 1) {
1191
+ const theta = random() * Math.PI * 2;
1192
+ creases.push([
1193
+ Math.cos(theta), Math.sin(theta),
1194
+ signed(random) * width * 0.25,
1195
+ (i % 2 ? -1 : 1) * bend * sheetSize * mix(0.04, 0.09, random()),
1196
+ ]);
1197
+ }
1198
+ const heightAt = (x, z) => {
1199
+ let y = amplitude * Math.sin((x / wavelength) * Math.PI * 2);
1200
+ y += bend * sheetSize * 0.1 * fbm3(crumpleSeed, x * 1.5 / sheetSize, 0, z * 1.5 / sheetSize, 2, 2, 0.5);
1201
+ for (const [nx, nz, offset, lift] of creases) {
1202
+ const distance = Math.abs(nx * x + nz * z - offset);
1203
+ y += lift * Math.max(0, 1 - distance / (sheetSize * 0.45));
1204
+ }
1205
+ return y;
1206
+ };
1207
+
1208
+ const positions = [];
1209
+ const colors = [];
1210
+ const primary = paletteColor(settings, 1);
1211
+ const rustColor = paletteColor(settings, 2);
1212
+ const rustSeed = hashCombine(seed, 19);
1213
+ const swatch = new THREE.Color();
1214
+ const pushVertex = (x, z) => {
1215
+ positions.push(x, heightAt(x, z), z);
1216
+ // Rust patches: coherent-noise mask blending toward the oxidized
1217
+ // accent, denser as `rust` rises (DM2 pairs holes with rust zones).
1218
+ const mask = 0.5 + 0.5 * simplexNoise3(rustSeed, x * 2.6 / width, 0, z * 2.6 / width);
1219
+ swatch.copy(primary);
1220
+ if (mask < rust * 0.62) swatch.lerp(rustColor, mix(0.45, 0.9, 1 - mask / Math.max(rust * 0.62, 0.001)));
1221
+ swatch.multiplyScalar(1 + signed(random) * settings.surface.variation * 0.5);
1222
+ colors.push(Math.min(swatch.r, 1), Math.min(swatch.g, 1), Math.min(swatch.b, 1));
1223
+ };
1224
+ for (let cz = 0; cz < cellsZ; cz += 1) {
1225
+ for (let cx = 0; cx < cellsX; cx += 1) {
1226
+ const x0 = (cx / cellsX - 0.5) * width;
1227
+ const x1 = ((cx + 1) / cellsX - 0.5) * width;
1228
+ const z0 = (cz / cellsZ - 0.5) * length;
1229
+ const z1 = ((cz + 1) / cellsZ - 0.5) * length;
1230
+ const xm = (x0 + x1) / 2;
1231
+ const zm = (z0 + z1) / 2;
1232
+ if (!inside(xm, zm)) continue;
1233
+ pushVertex(x0, z0); pushVertex(x0, z1); pushVertex(x1, z1);
1234
+ pushVertex(x0, z0); pushVertex(x1, z1); pushVertex(x1, z0);
1235
+ }
1236
+ }
1237
+ if (positions.length === 0) return null;
1238
+ const geometry = new THREE.BufferGeometry();
1239
+ geometry.setAttribute('position', new THREE.BufferAttribute(new Float32Array(positions), 3));
1240
+ geometry.setAttribute('color', new THREE.BufferAttribute(new Float32Array(colors), 3));
1241
+ geometry.computeVertexNormals();
1242
+ return geometry;
1243
+ }
1244
+
1245
+ function createSheet(settings, random, index) {
1246
+ const piece = new THREE.Group();
1247
+ const geometry = buildTornSheetGeometry(settings, random, hashCombine(settings.asset.seed, 600 + index * 37));
1248
+ if (!geometry) return piece;
1249
+ const mesh = new THREE.Mesh(geometry, createMaterial(settings, { doubleSided: true, metalness: 0.72 }));
1250
+ mesh.name = 'Torn sheet metal';
1251
+ mesh.castShadow = true;
1252
+ mesh.receiveShadow = true;
1253
+ piece.add(mesh);
1254
+ scatterObject(piece, settings, random, index, { stacked: settings.shape.sheetSize * 0.12, tilt: 0.48 });
1255
+ settleOnGround(piece);
1256
+ return piece;
1257
+ }
1258
+
1259
+ // Shrapnel fragment, following DebrisMaker2's recipe: a flat plate whose
1260
+ // silhouette is carved by a noisy blob, then folded hard (30-200 deg)
1261
+ // around an arbitrary in-plane axis — the fold is what reads as torn,
1262
+ // twisted metal.
1263
+ function createShrapnelPiece(settings, random, index) {
1264
+ const piece = new THREE.Group();
1265
+ const { sheetSize } = settings.shape;
1266
+ const size = sheetSize * mix(0.3, 0.55, random());
1267
+ const seed = hashCombine(settings.asset.seed, 700 + index * 41);
1268
+ const cells = 16;
1269
+ const blobSeed = hashCombine(seed, 3);
1270
+ const inside = (x, z) => {
1271
+ const angle = Math.atan2(z, x);
1272
+ const edge = size * 0.5 * (1 + 0.55 * simplexNoise3(blobSeed, Math.cos(angle) * 1.3, 0, Math.sin(angle) * 1.3));
1273
+ return Math.sqrt(x * x + z * z) < edge;
1274
+ };
1275
+ const foldTheta = random() * Math.PI * 2;
1276
+ const foldN = [Math.cos(foldTheta), Math.sin(foldTheta)];
1277
+ const foldAngle = mix(0.5, 2.6, random());
1278
+ const foldRadius = size / foldAngle;
1279
+ const noiseSeed = hashCombine(seed, 11);
1280
+ const positions = [];
1281
+ const pushVertex = (x, z) => {
1282
+ // Cylindrical bend about the fold axis.
1283
+ const along = foldN[0] * x + foldN[1] * z;
1284
+ const arc = along / foldRadius;
1285
+ const bentAlong = Math.sin(arc) * foldRadius;
1286
+ const y = (1 - Math.cos(arc)) * foldRadius
1287
+ + size * 0.05 * simplexNoise3(noiseSeed, x * 3 / size, 0, z * 3 / size);
1288
+ const across = -foldN[1] * x + foldN[0] * z;
1289
+ positions.push(
1290
+ foldN[0] * bentAlong - foldN[1] * across,
1291
+ y,
1292
+ foldN[1] * bentAlong + foldN[0] * across,
1293
+ );
1294
+ };
1295
+ for (let cz = 0; cz < cells; cz += 1) {
1296
+ for (let cx = 0; cx < cells; cx += 1) {
1297
+ const x0 = (cx / cells - 0.5) * size;
1298
+ const x1 = ((cx + 1) / cells - 0.5) * size;
1299
+ const z0 = (cz / cells - 0.5) * size;
1300
+ const z1 = ((cz + 1) / cells - 0.5) * size;
1301
+ if (!inside((x0 + x1) / 2, (z0 + z1) / 2)) continue;
1302
+ pushVertex(x0, z0); pushVertex(x0, z1); pushVertex(x1, z1);
1303
+ pushVertex(x0, z0); pushVertex(x1, z1); pushVertex(x1, z0);
1304
+ }
1305
+ }
1306
+ if (positions.length === 0) return piece;
1307
+ const geometry = new THREE.BufferGeometry();
1308
+ geometry.setAttribute('position', new THREE.BufferAttribute(new Float32Array(positions), 3));
1309
+ geometry.computeVertexNormals();
1310
+ addMesh(piece, geometry, settings, random, {
1311
+ doubleSided: true,
1312
+ metalness: 0.78,
1313
+ name: 'Shrapnel fragment',
1314
+ slot: random() < settings.shape.rust ? 2 : 1,
1315
+ });
1316
+ scatterObject(piece, settings, random, index, { stacked: size * 0.2, tilt: 0.85 });
1317
+ settleOnGround(piece);
1318
+ return piece;
1319
+ }
1320
+
1321
+ function createCan(settings, random, index) {
1322
+ const piece = new THREE.Group();
1323
+ const size = settings.shape.sheetSize * mix(0.7, 1.05, random());
1324
+ const height = size * mix(0.8, 1.35, random());
1325
+ const radius = size * 0.28;
1326
+ // Crush dents: coherent radial noise displacement (uniform squash alone
1327
+ // reads as a clean cylinder, not a kicked can).
1328
+ const bodyGeometry = new THREE.CylinderGeometry(radius, radius * mix(0.9, 1.05, random()), height, 10, 4);
1329
+ const dentSeed = hashCombine(settings.asset.seed, 900 + index * 31);
1330
+ const bodyPosition = bodyGeometry.attributes.position;
1331
+ for (let vertex = 0; vertex < bodyPosition.count; vertex += 1) {
1332
+ const px = bodyPosition.getX(vertex);
1333
+ const py = bodyPosition.getY(vertex);
1334
+ const pz = bodyPosition.getZ(vertex);
1335
+ const radial = Math.hypot(px, pz) || 1;
1336
+ const dent = settings.shape.bend * 0.3
1337
+ * Math.min(simplexNoise3(dentSeed, px * 2.4 / radius, py * 1.2 / radius, pz * 2.4 / radius), 0.15);
1338
+ bodyPosition.setX(vertex, px * (1 + dent * radius / radial));
1339
+ bodyPosition.setZ(vertex, pz * (1 + dent * radius / radial));
1340
+ }
1341
+ bodyGeometry.computeVertexNormals();
1342
+ const body = addMesh(piece, bodyGeometry, settings, random, {
1343
+ metalness: 0.74, name: 'Crushed can', slot: random() < settings.shape.rust ? 2 : 1,
1344
+ });
1345
+ body.position.y = radius;
1346
+ body.rotation.z = Math.PI / 2 + signed(random) * settings.shape.bend * 0.35;
1347
+ body.scale.y = 1 - settings.shape.bend * mix(0.08, 0.35, random());
1348
+ for (const side of [-1, 1]) {
1349
+ const ring = addMesh(piece, new THREE.TorusGeometry(radius, radius * 0.07, 5, 10), settings, random, {
1350
+ metalness: 0.8, name: 'Can rim', slot: 1,
1351
+ });
1352
+ ring.rotation.y = Math.PI / 2;
1353
+ ring.position.x = side * height * body.scale.y * 0.5;
1354
+ ring.position.y = radius;
1355
+ }
1356
+ scatterObject(piece, settings, random, index, { stacked: radius * 0.7, tilt: 0.75 });
1357
+ return piece;
1358
+ }
1359
+
1360
+ function createWire(settings, random, index) {
1361
+ const piece = new THREE.Group();
1362
+ const size = settings.shape.sheetSize;
1363
+ const points = [];
1364
+ for (let step = 0; step < 10; step += 1) {
1365
+ const t = step / 9;
1366
+ const angle = t * Math.PI * mix(2.5, 5.5, random());
1367
+ points.push(new THREE.Vector3(
1368
+ (t - 0.5) * size,
1369
+ 0.04 + Math.sin(angle) * size * 0.12,
1370
+ Math.cos(angle) * size * 0.2,
1371
+ ));
1372
+ }
1373
+ addMesh(piece, tube(points, size * 0.025, 5), settings, random, {
1374
+ metalness: 0.8, name: 'Curled wire', slot: random() < settings.shape.rust ? 2 : 0,
1375
+ });
1376
+ scatterObject(piece, settings, random, index, { stacked: size * 0.08, tilt: 0.7 });
1377
+ return piece;
1378
+ }
1379
+
1380
+ function createMetalPiece(settings, random, index) {
1381
+ if (settings.asset.variant === 'sheets') return createSheet(settings, random, index);
1382
+ if (settings.asset.variant === 'cans') return createCan(settings, random, index);
1383
+ if (random() < settings.shape.wireChance) return createWire(settings, random, index);
1384
+ const roll = random();
1385
+ if (roll < 0.4) return createShrapnelPiece(settings, random, index);
1386
+ if (roll < 0.72) return createSheet(settings, random, index);
1387
+ return createCan(settings, random, index);
1388
+ }
1389
+
1390
+ // Leaf silhouettes as width profiles along the mid-vein (t = stem -> tip).
1391
+ // DebrisMaker2 ships scanned meshes per species; at toon scale a lobed
1392
+ // width profile carries the same read for free.
1393
+ const LEAF_PROFILES = [
1394
+ { lobeDepth: 0, lobes: 0, tip: 0.85 }, // ovate (birch/elm)
1395
+ { lobeDepth: 0.38, lobes: 3, tip: 0.7 }, // maple-like
1396
+ { lobeDepth: 0.26, lobes: 5, tip: 0.95 }, // oak-like
1397
+ ];
1398
+
1399
+ // Deform pipeline order copied from DebrisMaker2 Leaves: crease (V-fold
1400
+ // along the mid-vein), skew, twist around the stem axis, then coherent
1401
+ // noise. Flat fans read as paper cutouts; the crease+twist combination is
1402
+ // what makes a dry curled leaf.
1403
+ function buildLeafGeometry(settings, random, seed) {
1404
+ const { curl, dryness, leafSize } = settings.shape;
1405
+ const size = leafSize * mix(0.7, 1.25, random());
1406
+ const length = size * mix(1.5, 1.9, random());
1407
+ const profile = LEAF_PROFILES[Math.floor(random() * LEAF_PROFILES.length)];
1408
+ const segsL = 9;
1409
+ const segsW = 4;
1410
+
1411
+ const crease = Math.tan(mix(0.25, 0.85, curl * mix(0.6, 1.3, random())));
1412
+ const twist = signed(random) * mix(0.4, 1.6, curl) * mix(0.5, 1.1, dryness);
1413
+ const skew = signed(random) * 0.22;
1414
+ const tipCurl = curl * size * mix(0.3, 0.6, random());
1415
+ const noiseSeed = hashCombine(seed, 5);
1416
+
1417
+ const positions = new Float32Array((segsL + 1) * (segsW + 1) * 3);
1418
+ let write = 0;
1419
+ for (let i = 0; i <= segsL; i += 1) {
1420
+ const t = i / segsL;
1421
+ const reach = Math.min(t / profile.tip, 1);
1422
+ let halfWidth = size * 0.5 * Math.sin(Math.PI * reach ** 0.85) ** 0.9;
1423
+ if (profile.lobes > 0) {
1424
+ halfWidth *= 1 + profile.lobeDepth * Math.sin(profile.lobes * Math.PI * t);
1425
+ }
1426
+ for (let j = 0; j <= segsW; j += 1) {
1427
+ const u = j / segsW - 0.5;
1428
+ let x = u * 2 * halfWidth;
1429
+ let y = Math.abs(x) * crease
1430
+ + tipCurl * t * t
1431
+ + size * 0.08 * dryness * simplexNoise3(noiseSeed, x * 2.4 / size, 0, t * 2.4);
1432
+ const z = (t - 0.2) * length;
1433
+ // Twist around the stem axis grows toward the tip.
1434
+ const angle = twist * t;
1435
+ const cos = Math.cos(angle);
1436
+ const sin = Math.sin(angle);
1437
+ const tx = x * cos - y * sin;
1438
+ y = x * sin + y * cos;
1439
+ x = tx + t * length * skew;
1440
+ positions[write] = x;
1441
+ positions[write + 1] = y + 0.012;
1442
+ positions[write + 2] = z;
1443
+ write += 3;
1444
+ }
1445
+ }
1446
+ const indices = [];
1447
+ for (let i = 0; i < segsL; i += 1) {
1448
+ for (let j = 0; j < segsW; j += 1) {
1449
+ const a = i * (segsW + 1) + j;
1450
+ const b = a + segsW + 1;
1451
+ indices.push(a, b, a + 1, a + 1, b, b + 1);
1452
+ }
1453
+ }
1454
+ const geometry = new THREE.BufferGeometry();
1455
+ geometry.setAttribute('position', new THREE.BufferAttribute(positions, 3));
1456
+ geometry.setIndex(indices);
1457
+ geometry.computeVertexNormals();
1458
+ return geometry;
1459
+ }
1460
+
1461
+ function createLeaf(settings, random, index) {
1462
+ const piece = new THREE.Group();
1463
+ const size = settings.shape.leafSize;
1464
+ const geometry = buildLeafGeometry(settings, random, hashCombine(settings.asset.seed, 800 + index * 29));
1465
+ addMesh(piece, geometry, settings, random, {
1466
+ doubleSided: true, name: 'Dry leaf', slot: random() < settings.shape.dryness ? (random() < 0.45 ? 2 : 1) : 0,
1467
+ });
1468
+ scatterObject(piece, settings, random, index, { stacked: size * 0.15, tilt: 0.38 });
1469
+ return piece;
1470
+ }
1471
+
1472
+ // Real pinecone construction: woody scales wind around a spindle core in
1473
+ // golden-angle spiral phyllotaxis (each scale ~137.5deg around and a step
1474
+ // up from the last), tilting from downswept at the base to closed at the
1475
+ // tip. Dozens of overlapping shields make the read; neat sparse rings of
1476
+ // cones never will. Everything merges into ONE geometry so a cone is one
1477
+ // draw call and can be pooled/instanced.
1478
+ const GOLDEN_ANGLE = Math.PI * (3 - Math.sqrt(5));
1479
+
1480
+ function buildPineconeGeometry(settings, random) {
1481
+ const size = settings.shape.leafSize;
1482
+ const openness = mix(0.55, 1, settings.shape.curl);
1483
+ const coneLength = size * 1.6;
1484
+ const coreRadius = size * 0.34;
1485
+ const profileAt = (t) => 0.22 + 0.78 * Math.sin(Math.PI * t ** 0.75) ** 0.9;
1486
+
1487
+ const parts = [];
1488
+ const corePoints = [];
1489
+ for (let step = 0; step <= 8; step += 1) {
1490
+ const t = step / 8;
1491
+ corePoints.push(new THREE.Vector2(
1492
+ Math.max(coreRadius * profileAt(t) * 0.85, 0.001),
1493
+ (t - 0.5) * coneLength,
1494
+ ));
1495
+ }
1496
+ const core = new THREE.LatheGeometry(corePoints, 8);
1497
+ paintGeometry(core, settings, random, 0);
1498
+ parts.push(core);
1499
+
1500
+ const scaleCount = 52;
1501
+ const secondary = paletteColor(settings, 1);
1502
+ const apexDir = new THREE.Vector3();
1503
+ const quaternion = new THREE.Quaternion();
1504
+ const twist = new THREE.Quaternion();
1505
+ const matrix = new THREE.Matrix4();
1506
+ const position = new THREE.Vector3();
1507
+ const scaleVec = new THREE.Vector3();
1508
+ for (let index = 0; index < scaleCount; index += 1) {
1509
+ const t = 0.04 + 0.92 * (index / (scaleCount - 1));
1510
+ const theta = index * GOLDEN_ANGLE + random() * 0.07;
1511
+ const ringRadius = coreRadius * profileAt(t);
1512
+ const scaleLength = size * 0.34 * (0.6 + 0.5 * Math.sin(Math.PI * t ** 0.9));
1513
+ // Tilt from the axis: base scales sweep down and open, tip scales
1514
+ // close up. `openness` (curl) flares the whole cone.
1515
+ const tilt = mix(1.5, 0.55, t) * openness;
1516
+
1517
+ const scale = new THREE.ConeGeometry(scaleLength * 0.56, scaleLength, 4, 1);
1518
+ paintGeometry(scale, settings, random, 0);
1519
+ // Woody apophysis read: lighten the outer half of each scale toward
1520
+ // the secondary tan, brightest at the very tip.
1521
+ const colors = scale.attributes.color;
1522
+ const verts = scale.attributes.position;
1523
+ for (let v = 0; v < verts.count; v += 1) {
1524
+ const along = verts.getY(v) / scaleLength + 0.5;
1525
+ if (along > 0.45) {
1526
+ const lightness = (along - 0.45) / 0.55;
1527
+ colors.setXYZ(
1528
+ v,
1529
+ mix(colors.getX(v), secondary.r * 1.08, lightness),
1530
+ mix(colors.getY(v), secondary.g * 1.08, lightness),
1531
+ mix(colors.getZ(v), secondary.b * 1.08, lightness),
1532
+ );
1533
+ }
1534
+ }
1535
+
1536
+ apexDir.set(Math.sin(tilt) * Math.cos(theta), Math.cos(tilt), -Math.sin(tilt) * Math.sin(theta));
1537
+ quaternion.setFromUnitVectors(new THREE.Vector3(0, 1, 0), apexDir);
1538
+ twist.setFromAxisAngle(apexDir, signed(random) * 0.15);
1539
+ quaternion.premultiply(twist);
1540
+ position
1541
+ .set(Math.cos(theta) * ringRadius * 0.7, (t - 0.5) * coneLength, -Math.sin(theta) * ringRadius * 0.7)
1542
+ .addScaledVector(apexDir, scaleLength * 0.42);
1543
+ scaleVec.set(1, 1, 0.28);
1544
+ matrix.compose(position, quaternion, scaleVec);
1545
+ scale.applyMatrix4(matrix);
1546
+ parts.push(scale);
1547
+ }
1548
+
1549
+ const merged = mergeGeometries(parts, false);
1550
+ for (const part of parts) part.dispose();
1551
+ merged.computeBoundingBox();
1552
+ return merged;
1553
+ }
1554
+
1555
+ function buildPineconePool(settings, count) {
1556
+ const geometries = [];
1557
+ const uniqueCount = Math.min(Math.max(count, 1), 3);
1558
+ for (let unique = 0; unique < uniqueCount; unique += 1) {
1559
+ const rng = createDebrisRandom(hashCombine(settings.asset.seed, 503 + unique * 149));
1560
+ const scaled = createDebrisSettings(settings);
1561
+ scaled.shape.leafSize = settings.shape.leafSize * mix(0.78, 1.2, rng());
1562
+ geometries.push(buildPineconeGeometry(scaled, rng));
1563
+ }
1564
+ return { geometries, name: 'Pinecone' };
1565
+ }
1566
+
1567
+ // Scallop fan shell as ONE parametric surface: a domed fan whose radial
1568
+ // ribs and scalloped rim are baked into the surface itself (ridges glued
1569
+ // onto a hemisphere read as a toy). Umbo (hinge) at the origin, fan
1570
+ // opening toward +Z.
1571
+ function createShell(settings, random, index) {
1572
+ const piece = new THREE.Group();
1573
+ const size = settings.shape.leafSize * mix(0.78, 1.2, random());
1574
+ const ribCount = 8 + Math.floor(random() * 4);
1575
+ const arc = Math.PI * mix(0.62, 0.74, random());
1576
+ const domeHeight = size * (0.24 + settings.shape.curl * 0.2);
1577
+ const ribDepth = mix(0.045, 0.08, random());
1578
+ const angularSegs = 26;
1579
+ const radialSegs = 7;
1580
+
1581
+ const positions = new Float32Array((angularSegs + 1) * (radialSegs + 1) * 3);
1582
+ let write = 0;
1583
+ for (let a = 0; a <= angularSegs; a += 1) {
1584
+ const theta = (a / angularSegs - 0.5) * arc;
1585
+ const rib = Math.cos(ribCount * (theta / arc) * Math.PI * 2);
1586
+ const rimRadius = size * (1 + ribDepth * 0.7 * rib);
1587
+ for (let k = 0; k <= radialSegs; k += 1) {
1588
+ const t = k / radialSegs;
1589
+ const radius = t * rimRadius;
1590
+ // Dome profile with ribs growing toward the rim.
1591
+ const y = domeHeight * Math.sin((Math.PI / 2) * t) * (1 - t * 0.25)
1592
+ * (1 + ribDepth * 2.6 * rib * t)
1593
+ + 0.01;
1594
+ positions[write] = Math.sin(theta) * radius;
1595
+ positions[write + 1] = y;
1596
+ positions[write + 2] = Math.cos(theta) * radius - size * 0.12;
1597
+ write += 3;
1598
+ }
1599
+ }
1600
+ const indices = [];
1601
+ for (let a = 0; a < angularSegs; a += 1) {
1602
+ for (let k = 0; k < radialSegs; k += 1) {
1603
+ const i0 = a * (radialSegs + 1) + k;
1604
+ const i1 = i0 + radialSegs + 1;
1605
+ indices.push(i0, i1, i0 + 1, i0 + 1, i1, i1 + 1);
1606
+ }
1607
+ }
1608
+ const geometry = new THREE.BufferGeometry();
1609
+ geometry.setAttribute('position', new THREE.BufferAttribute(positions, 3));
1610
+ geometry.setIndex(indices);
1611
+ geometry.computeVertexNormals();
1612
+ addMesh(piece, geometry, settings, random, {
1613
+ doubleSided: true, name: 'Fan shell', slot: random() < 0.3 ? 2 : 1,
1614
+ });
1615
+ scatterObject(piece, settings, random, index, { stacked: size * 0.12, tilt: 0.32 });
1616
+ return piece;
1617
+ }
1618
+
1619
+ function createOrganicPiece(settings, random, index, pineconePool) {
1620
+ const size = settings.shape.leafSize;
1621
+ const wantCone = settings.asset.variant === 'pinecones'
1622
+ || (settings.asset.variant === 'leafLitter' && random() < settings.shape.coneRatio * 0.28);
1623
+ if (wantCone && pineconePool) {
1624
+ const geometry = pineconePool.geometries[index % pineconePool.geometries.length];
1625
+ return createPooledPiece(pineconePool, geometry, settings, random, index, {
1626
+ mirror: true, stacked: size * 0.2, tilt: 0.85,
1627
+ });
1628
+ }
1629
+ if (settings.asset.variant === 'shells') return createShell(settings, random, index);
1630
+ return createLeaf(settings, random, index);
1631
+ }
1632
+
1633
+ function createCharcoalChunk(parent, settings, random, index, footprint, moundHeight = 0) {
1634
+ const radius = footprint * mix(0.055, 0.15, random());
1635
+ const geometry = irregularGeometry(radius, random, 0.92, 1, settings.asset.seed + index * 17);
1636
+ geometry.scale(mix(0.8, 1.8, random()), mix(0.35, 0.8, random()), mix(0.65, 1.25, random()));
1637
+ const mesh = addMesh(parent, geometry, settings, random, {
1638
+ name: 'Charcoal fragment', slot: random() < settings.shape.embers ? 2 : 0,
1639
+ });
1640
+ const radiusFromCenter = Math.sqrt(random()) * footprint;
1641
+ const angle = random() * Math.PI * 2;
1642
+ // Rest chunks ON the mound (matching its falloff profile) so they read
1643
+ // as half-sunk remains instead of vanishing inside the ash.
1644
+ const radial = radiusFromCenter / Math.max(footprint, 0.01);
1645
+ const moundSurface = moundHeight * Math.max(0, 1 - radial * radial) ** 1.4 * 0.85;
1646
+ mesh.position.set(
1647
+ Math.cos(angle) * radiusFromCenter,
1648
+ moundSurface + radius * 0.3 + index * 0.0001,
1649
+ Math.sin(angle) * radiusFromCenter,
1650
+ );
1651
+ mesh.rotation.set(signed(random), random() * Math.PI * 2, signed(random));
1652
+ }
1653
+
1654
+ // Ash mound: a polar heightfield — squashed-cosine falloff profile with
1655
+ // coherent lumps and a wobbled rim, like a real poured pile — instead of
1656
+ // the old scaled hemisphere (which reads as a lens, not powder). Vertex
1657
+ // colors bake fine light ash at the crown, base grey on the slopes, and
1658
+ // the scorched-rim darkening the `rim` setting always promised.
1659
+ function createAshMoundGeometry(settings, seed) {
1660
+ const { footprint, moundHeight, rim } = settings.shape;
1661
+ const { accentColor, edgeLight, primaryColor, secondaryColor } = settings.surface;
1662
+ const rings = 18;
1663
+ const sectors = 44;
1664
+ const lumpSeed = hashCombine(seed, 313);
1665
+ const rimSeed = hashCombine(seed, 631);
1666
+ const vertexCount = rings * sectors + 1;
1667
+ const positions = new Float32Array(vertexCount * 3);
1668
+ const colors = new Float32Array(vertexCount * 3);
1669
+ const primary = new THREE.Color(...primaryColor);
1670
+ const secondary = new THREE.Color(...secondaryColor);
1671
+ // Scorch is carbon, not ember: a fixed near-black, never the accent red.
1672
+ const char = new THREE.Color(0.02, 0.018, 0.016);
1673
+ const swatch = new THREE.Color();
1674
+
1675
+ const heightAt = (x, z, radial) => {
1676
+ const profile = Math.max(0, 1 - radial * radial) ** 1.4;
1677
+ const lumps = 1 + 0.4 * fbm3(lumpSeed, (x / footprint) * 1.8, 0, (z / footprint) * 1.8, 3, 2, 0.5);
1678
+ return Math.max(moundHeight * profile * lumps, 0.004);
1679
+ };
1680
+
1681
+ const writeVertex = (vertex, x, y, z, radial) => {
1682
+ positions[vertex * 3] = x;
1683
+ positions[vertex * 3 + 1] = y;
1684
+ positions[vertex * 3 + 2] = z;
1685
+ // Crown = fine pale ash, slopes = base grey, rim = scorch band.
1686
+ swatch.copy(secondary).lerp(primary, Math.min(radial * 1.35, 1));
1687
+ const scorch = rim * Math.max(0, radial - 0.68) / 0.32;
1688
+ swatch.lerp(char, Math.min(scorch, 1));
1689
+ const crest = 1 + Math.max(0, 1 - radial) * edgeLight * 0.3;
1690
+ colors[vertex * 3] = Math.min(swatch.r * crest, 1);
1691
+ colors[vertex * 3 + 1] = Math.min(swatch.g * crest, 1);
1692
+ colors[vertex * 3 + 2] = Math.min(swatch.b * crest, 1);
1693
+ };
1694
+
1695
+ writeVertex(0, 0, heightAt(0, 0, 0), 0, 0);
1696
+ for (let ring = 0; ring < rings; ring += 1) {
1697
+ const radial = (ring + 1) / rings;
1698
+ for (let sector = 0; sector < sectors; sector += 1) {
1699
+ const angle = (sector / sectors) * Math.PI * 2;
1700
+ const cos = Math.cos(angle);
1701
+ const sin = Math.sin(angle);
1702
+ const wobble = 1 + 0.09 * simplexNoise3(rimSeed, cos * 1.4, radial * 0.8, sin * 1.4);
1703
+ const radius = radial * footprint * wobble;
1704
+ const x = cos * radius;
1705
+ const z = sin * radius;
1706
+ const y = ring === rings - 1 ? 0.003 : heightAt(x, z, radial);
1707
+ writeVertex(1 + ring * sectors + sector, x, y, z, radial);
1708
+ }
1709
+ }
1710
+
1711
+ const indices = [];
1712
+ for (let sector = 0; sector < sectors; sector += 1) {
1713
+ indices.push(0, 1 + sector, 1 + ((sector + 1) % sectors));
1714
+ }
1715
+ for (let ring = 0; ring < rings - 1; ring += 1) {
1716
+ for (let sector = 0; sector < sectors; sector += 1) {
1717
+ const a = 1 + ring * sectors + sector;
1718
+ const b = 1 + ring * sectors + ((sector + 1) % sectors);
1719
+ const c = a + sectors;
1720
+ const d = b + sectors;
1721
+ indices.push(a, c, b, b, c, d);
1722
+ }
1723
+ }
1724
+
1725
+ const geometry = new THREE.BufferGeometry();
1726
+ geometry.setAttribute('position', new THREE.BufferAttribute(positions, 3));
1727
+ geometry.setAttribute('color', new THREE.BufferAttribute(colors, 3));
1728
+ geometry.setIndex(indices);
1729
+ geometry.computeVertexNormals();
1730
+ return geometry;
1731
+ }
1732
+
1733
+ function createAshAsset(settings, random) {
1734
+ const root = new THREE.Group();
1735
+ root.name = 'Procedural ash and char';
1736
+ const { footprint, moundHeight } = settings.shape;
1737
+ if (settings.asset.variant !== 'charcoal') {
1738
+ const mound = new THREE.Mesh(
1739
+ createAshMoundGeometry(settings, settings.asset.seed),
1740
+ createMaterial(settings),
1741
+ );
1742
+ mound.name = 'Ash mound';
1743
+ mound.castShadow = true;
1744
+ mound.receiveShadow = true;
1745
+ root.add(mound);
1746
+ }
1747
+ if (settings.asset.variant === 'campfire') {
1748
+ // Half-burnt firewood: crooked tapered sweeps (Tree Lab tube builder)
1749
+ // leaned across the mound at irregular angles, ends poking out of the
1750
+ // ash — not four identical arcs on a carousel.
1751
+ for (let logIndex = 0; logIndex < 4; logIndex += 1) {
1752
+ const across = footprint * mix(0.5, 0.72, random());
1753
+ const sag = moundHeight * mix(0.35, 0.65, random());
1754
+ const drift = signed(random) * footprint * 0.14;
1755
+ const swept = createBranchTubeGeometry({
1756
+ irregularity: 0.24,
1757
+ points: [
1758
+ [-across, moundHeight * mix(0.1, 0.3, random()), drift],
1759
+ [signed(random) * footprint * 0.12, sag + moundHeight * 0.35, -drift * 0.6],
1760
+ [across * mix(0.75, 1, random()), moundHeight * mix(0.15, 0.45, random()), signed(random) * footprint * 0.1],
1761
+ ],
1762
+ radialSegments: 6,
1763
+ radiusEnd: footprint * mix(0.045, 0.07, random()),
1764
+ radiusStart: footprint * mix(0.075, 0.1, random()),
1765
+ ringSpacing: Math.max(footprint / 14, 0.03),
1766
+ seed: settings.asset.seed + logIndex * 61,
1767
+ });
1768
+ if (!swept) continue;
1769
+ // Logs stay char-dark; ember accents belong to the charcoal chunks,
1770
+ // not to a whole glowing log.
1771
+ const log = addMesh(root, swept.geometry, settings, random, {
1772
+ name: 'Charred firewood', slot: 0,
1773
+ });
1774
+ log.rotation.y = (logIndex / 4) * Math.PI * 2 + signed(random) * 0.5;
1775
+ }
1776
+ }
1777
+ // Sawdust reads as powder with a few embedded slivers, not a char bed.
1778
+ const chunkShare = settings.asset.variant === 'sawdust' ? 0.35 : 1;
1779
+ const chunkCount = Math.max(2, Math.round(settings.asset.count * mix(0.45, 1.15, settings.shape.charcoal) * chunkShare));
1780
+ const chunkFootprint = footprint * (settings.asset.variant === 'charcoal' ? 1 : 0.75);
1781
+ const chunkMound = settings.asset.variant === 'charcoal' ? 0 : moundHeight;
1782
+ for (let index = 0; index < chunkCount; index += 1) {
1783
+ createCharcoalChunk(root, settings, random, index, chunkFootprint, chunkMound);
1784
+ }
1785
+ return root;
1786
+ }
1787
+
1788
+ function countStats(root) {
1789
+ let meshCount = 0;
1790
+ let triangleCount = 0;
1791
+ let vertexCount = 0;
1792
+ root.traverse((object) => {
1793
+ if (!object.isMesh) return;
1794
+ meshCount += 1;
1795
+ const geometry = object.geometry;
1796
+ vertexCount += geometry.attributes.position?.count ?? 0;
1797
+ triangleCount += geometry.index
1798
+ ? geometry.index.count / 3
1799
+ : (geometry.attributes.position?.count ?? 0) / 3;
1800
+ });
1801
+ return { meshCount, triangleCount: Math.round(triangleCount), vertexCount };
1802
+ }
1803
+
1804
+ // Universal Damage dial: shifts each type's own wear parameters around
1805
+ // their authored values (0.5 = neutral, 0 = pristine, 1 = wrecked). The
1806
+ // recipe keeps the user's per-field values; this derives the effective
1807
+ // shape fed to the builders.
1808
+ const DAMAGE_KEYS = Object.freeze({
1809
+ ash: ['charcoal', 'rim'],
1810
+ bone: ['damage'],
1811
+ metal: ['bend', 'rust'],
1812
+ organic: ['curl', 'dryness'],
1813
+ stone: ['detail'],
1814
+ wood: ['barkStripped', 'splinters'],
1815
+ });
1816
+
1817
+ function applyDamageDial(settings) {
1818
+ const shift = (settings.asset.damage - 0.5) * 0.8;
1819
+ if (Math.abs(shift) < 0.01) return settings;
1820
+ const keys = DAMAGE_KEYS[settings.asset.type] ?? [];
1821
+ for (const key of keys) {
1822
+ if (typeof settings.shape[key] !== 'number') continue;
1823
+ settings.shape[key] = Math.min(Math.max(settings.shape[key] + shift, 0), 1);
1824
+ }
1825
+ return settings;
1826
+ }
1827
+
1828
+ export function createDebrisAsset(inputSettings = {}) {
1829
+ const settings = applyDamageDial(createDebrisSettings(inputSettings));
1830
+ const random = createDebrisRandom(settings.asset.seed);
1831
+ const root = new THREE.Group();
1832
+ root.name = `Debris ${settings.asset.type} ${settings.asset.variant}`;
1833
+ root.userData.debrisRecipe = settings;
1834
+
1835
+ if (settings.asset.type === 'ash') {
1836
+ root.add(createAshAsset(settings, random));
1837
+ } else {
1838
+ const pieces = [];
1839
+ const effectiveCount = settings.asset.type === 'organic'
1840
+ ? Math.min(32, Math.max(1, Math.round(settings.asset.count * settings.shape.coverage)))
1841
+ : settings.asset.count;
1842
+ // Unique SDF geometries are meshed once up front and shared across
1843
+ // instances; per-piece variation comes from the scatter transform.
1844
+ const bonePool = settings.asset.type === 'bone' && settings.asset.variant !== 'antler'
1845
+ ? buildBonePool(settings, effectiveCount)
1846
+ : null;
1847
+ const isMasonryVariant = settings.asset.type === 'stone' && MASONRY_VARIANTS.has(settings.asset.variant);
1848
+ const masonryPool = isMasonryVariant ? buildMasonryPool(settings, effectiveCount) : null;
1849
+ const stonePool = settings.asset.type === 'stone' && !isMasonryVariant
1850
+ ? buildStonePool(settings, effectiveCount)
1851
+ : null;
1852
+ const pineconePool = settings.asset.type === 'organic'
1853
+ && (settings.asset.variant === 'pinecones'
1854
+ || (settings.asset.variant === 'leafLitter' && settings.shape.coneRatio > 0))
1855
+ ? buildPineconePool(settings, effectiveCount)
1856
+ : null;
1857
+ for (let index = 0; index < effectiveCount; index += 1) {
1858
+ let piece;
1859
+ if (settings.asset.type === 'wood') piece = createWoodPiece(settings, random, index);
1860
+ else if (settings.asset.type === 'bone') {
1861
+ if (settings.asset.variant === 'antler') piece = createAntler(settings, random, index);
1862
+ else if (bonePool) {
1863
+ const geometry = bonePool.geometries[index % bonePool.geometries.length];
1864
+ piece = createPooledPiece(bonePool, geometry, settings, random, index, {
1865
+ mirror: true,
1866
+ stacked: settings.shape.thickness * 1.5,
1867
+ tilt: settings.asset.variant === 'longBone' ? 0.65 : 0.3,
1868
+ });
1869
+ } else piece = createAntler(settings, random, index);
1870
+ } else if (masonryPool) {
1871
+ piece = createMasonryPiece(settings, random, index, masonryPool);
1872
+ } else if (stonePool) {
1873
+ const geometry = stonePool.geometries[index % stonePool.geometries.length];
1874
+ piece = createPooledPiece(stonePool, geometry, settings, random, index, {
1875
+ mirror: true,
1876
+ stacked: settings.shape.chunkSize * 0.8,
1877
+ tilt: 0.8,
1878
+ });
1879
+ } else if (settings.asset.type === 'metal') piece = createMetalPiece(settings, random, index);
1880
+ else piece = createOrganicPiece(settings, random, index, pineconePool);
1881
+ root.add(piece);
1882
+ pieces.push(piece);
1883
+ }
1884
+ // Universal ground contact: flat/elongated pieces lie flat (no
1885
+ // corner-balancing), then every piece rests on the ground
1886
+ // (scatter/patch) or on the ground/another piece (heap/bundle).
1887
+ const stacking = settings.asset.arrangement === 'heap' || settings.asset.arrangement === 'bundle';
1888
+ if (!stacking) stabilizeFlatPieces(pieces);
1889
+ settleComposition(pieces, { stacking });
1890
+ }
1891
+
1892
+ // Procedural detail texture pass: every mesh gets UVs (box projection
1893
+ // where the builder didn't provide them) and the type's tileable detail
1894
+ // map. The map multiplies baked vertex colors, so the palette sliders
1895
+ // stay authoritative; textures are cache-owned — dispose must not
1896
+ // touch material.map.
1897
+ const auto = debrisTextureAuto(settings.asset.type, settings.asset.variant);
1898
+ const style = settings.surface.textureStyle === 'auto' ? auto.style : settings.surface.textureStyle;
1899
+ const scale = settings.surface.textureScale;
1900
+ // A user-uploaded texture overrides the procedural detail map entirely.
1901
+ const customTexture = getCustomDebrisTexture(settings.surface.customTexture, scale);
1902
+ const detailTexture = customTexture
1903
+ ?? getDebrisDetailTexture(auto.kind, settings.asset.seed, style, scale);
1904
+ const fiberTexture = !customTexture && auto.kind === 'leaf'
1905
+ ? getDebrisDetailTexture('organicFiber', settings.asset.seed, 'fiber', scale)
1906
+ : null;
1907
+ root.traverse((object) => {
1908
+ if (!object.isMesh) return;
1909
+ ensureDebrisUvs(object.geometry);
1910
+ object.material.map = fiberTexture && object.name === 'Pinecone' ? fiberTexture : detailTexture;
1911
+ object.material.needsUpdate = true;
1912
+ });
1913
+
1914
+ root.scale.setScalar(settings.asset.scale);
1915
+ root.userData.stats = countStats(root);
1916
+ return root;
1917
+ }
1918
+
1919
+ export function disposeDebrisAsset(root) {
1920
+ const materials = new Set();
1921
+ root?.traverse((object) => {
1922
+ if (!object.isMesh) return;
1923
+ object.geometry?.dispose();
1924
+ for (const material of Array.isArray(object.material) ? object.material : [object.material]) {
1925
+ if (material) materials.add(material);
1926
+ }
1927
+ });
1928
+ for (const material of materials) material.dispose?.();
1929
+ }