@call-me-sensei/toonlab 0.2.0 → 0.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (205) hide show
  1. package/AGENTS.md +127 -4
  2. package/ATTRIBUTION.md +41 -3
  3. package/README.md +234 -34
  4. package/docs/characters.md +144 -0
  5. package/docs/debug-panel.md +126 -0
  6. package/docs/docs.css +589 -0
  7. package/docs/environment.md +186 -0
  8. package/docs/getting-started.md +180 -0
  9. package/docs/index.html +14 -0
  10. package/docs/lab-architecture.md +100 -0
  11. package/docs/lighting.md +825 -0
  12. package/docs/main.jsx +739 -0
  13. package/docs/mcp.md +97 -0
  14. package/docs/post-processing.md +98 -0
  15. package/docs/settings-reference.md +1878 -0
  16. package/docs/shader-constants.md +77 -0
  17. package/docs/sky.md +182 -0
  18. package/docs/style-labs.md +309 -0
  19. package/docs/texture-lab.md +135 -0
  20. package/docs/toon-shading.md +183 -0
  21. package/docs/tsl-conventions.md +167 -0
  22. package/docs/vegetation-sky.md +275 -0
  23. package/docs/water.md +430 -0
  24. package/docs/weather.md +200 -0
  25. package/docs/world-scale.md +111 -0
  26. package/mcp/server.mjs +610 -0
  27. package/mcp/style-lab-tools.mjs +371 -0
  28. package/mcp/vite-plugin.mjs +174 -0
  29. package/mcp/workspace.mjs +397 -0
  30. package/package.json +64 -5
  31. package/src/ambientfx/INTEGRATION.md +164 -0
  32. package/src/ambientfx/ambientFxPresets.js +83 -0
  33. package/src/ambientfx/ambientFxSettings.js +368 -0
  34. package/src/ambientfx/emitters.js +169 -0
  35. package/src/ambientfx/index.js +5 -0
  36. package/src/ambientfx/particleBackbone.js +493 -0
  37. package/src/ambientfx/stylizedAmbientFx.js +450 -0
  38. package/src/assetlib/ambientcg.js +117 -0
  39. package/src/assetlib/assetRef.js +91 -0
  40. package/src/assetlib/importedEntry.js +40 -0
  41. package/src/assetlib/index.js +23 -0
  42. package/src/assetlib/kaykit.js +192 -0
  43. package/src/assetlib/kaykitStaticIndex.js +700 -0
  44. package/src/assetlib/loadImported.js +143 -0
  45. package/src/assetlib/opensource3d.js +113 -0
  46. package/src/assetlib/polyhaven.js +182 -0
  47. package/src/assetlib/polypizza.js +115 -0
  48. package/src/assetlib/smithsonian.js +214 -0
  49. package/src/assetlib/sources.js +279 -0
  50. package/src/assetlib/zip.js +58 -0
  51. package/src/biome/biomeGenerator.js +385 -0
  52. package/src/biome/biomeRuntime.js +299 -0
  53. package/src/biome/index.js +2 -0
  54. package/src/buildinggen/buildingAsset.js +44 -0
  55. package/src/buildinggen/buildingGrammar.js +311 -0
  56. package/src/buildinggen/buildingMesh.js +451 -0
  57. package/src/buildinggen/buildingPresets.js +46 -0
  58. package/src/buildinggen/buildingRecipe.js +100 -0
  59. package/src/buildinggen/buildingSettings.js +238 -0
  60. package/src/buildinggen/index.js +6 -0
  61. package/src/camera/cameraDirector.js +157 -0
  62. package/src/camera/cameraGenerator.js +367 -0
  63. package/src/camera/cameraRig.js +570 -0
  64. package/src/camera/cameraSettings.js +236 -0
  65. package/src/camera/index.js +7 -0
  66. package/src/catalog/builtinEntries.js +245 -0
  67. package/src/catalog/catalog.js +210 -0
  68. package/src/catalog/index.js +3 -0
  69. package/src/catalog/manifest.js +84 -0
  70. package/src/core/generation.js +529 -0
  71. package/src/environment/environmentRigs.js +21 -1
  72. package/src/environment/environmentSettings.js +4 -0
  73. package/src/environment/environmentSunShadowPass.js +8 -0
  74. package/src/fauna/INTEGRATION.md +174 -0
  75. package/src/fauna/boids.js +861 -0
  76. package/src/fauna/faunaBodies.js +492 -0
  77. package/src/fauna/faunaPresets.js +52 -0
  78. package/src/fauna/faunaSettings.js +525 -0
  79. package/src/fauna/index.js +5 -0
  80. package/src/fauna/stylizedFauna.js +395 -0
  81. package/src/game-feel/gameFeelGenerator.js +402 -0
  82. package/src/game-feel/gameFeelRuntime.js +549 -0
  83. package/src/game-feel/index.js +2 -0
  84. package/src/index.js +17 -6
  85. package/src/lighting/colorIntensity.js +177 -0
  86. package/src/lighting/index.js +161 -0
  87. package/src/lighting/lightDescriptors.js +249 -0
  88. package/src/lighting/lightingCapabilities.js +79 -0
  89. package/src/lighting/lightingDocuments.js +247 -0
  90. package/src/lighting/lightingFixtures.js +446 -0
  91. package/src/lighting/lightingGenerator.js +449 -0
  92. package/src/lighting/lightingPresets.js +319 -0
  93. package/src/lighting/lightingRuntime.js +723 -0
  94. package/src/lighting/lightingStyle.js +386 -0
  95. package/src/lighting/lightingSystem.js +774 -0
  96. package/src/lighting/unrealExport.js +186 -0
  97. package/src/lighting/utils.js +87 -0
  98. package/src/motion/index.js +5 -0
  99. package/src/motion/motionClip.js +441 -0
  100. package/src/motion/motionController.js +628 -0
  101. package/src/motion/motionDocuments.js +225 -0
  102. package/src/motion/motionGraph.js +307 -0
  103. package/src/motion/motionSettings.js +222 -0
  104. package/src/pathgen/index.js +7 -0
  105. package/src/pathgen/pathBridge.js +232 -0
  106. package/src/pathgen/pathPresets.js +35 -0
  107. package/src/pathgen/pathRibbon.js +410 -0
  108. package/src/pathgen/pathRouter.js +380 -0
  109. package/src/pathgen/pathSettings.js +335 -0
  110. package/src/pathgen/pathTextures.js +123 -0
  111. package/src/pathgen/stylizedPaths.js +453 -0
  112. package/src/post/index.js +1 -0
  113. package/src/post/postGenerator.js +177 -0
  114. package/src/post/postProcessing.js +41 -0
  115. package/src/propgen/generatorsWave1.js +379 -0
  116. package/src/propgen/generatorsWave2.js +462 -0
  117. package/src/propgen/index.js +5 -0
  118. package/src/propgen/propAsset.js +323 -0
  119. package/src/propgen/propParts.js +170 -0
  120. package/src/propgen/propPlacement.js +459 -0
  121. package/src/propgen/propPresets.js +82 -0
  122. package/src/propgen/propSettings.js +395 -0
  123. package/src/shaders-tsl/chunks/projected-water-caustics.js +242 -0
  124. package/src/shaders-tsl/chunks/vegetation-style.js +360 -0
  125. package/src/shaders-tsl/chunks/water-shore-state.js +31 -0
  126. package/src/shaders-tsl/chunks/water-waves.js +90 -11
  127. package/src/shaders-tsl/environment.js +15 -1
  128. package/src/shaders-tsl/flower.js +279 -30
  129. package/src/shaders-tsl/grass.js +60 -33
  130. package/src/shaders-tsl/sky.js +125 -31
  131. package/src/shaders-tsl/tree-leaf.js +61 -25
  132. package/src/shaders-tsl/water-breaker.js +7 -4
  133. package/src/shaders-tsl/water-shore-state-simulation.js +523 -0
  134. package/src/shaders-tsl/water.js +439 -49
  135. package/src/shaders-tsl/woody-surface.js +154 -0
  136. package/src/sky/sceneOverrideLayers.js +10 -0
  137. package/src/sky/skyQuality.js +26 -0
  138. package/src/sky/stylizedSky.js +753 -45
  139. package/src/soundscape/index.js +4 -0
  140. package/src/soundscape/soundscapeGenerator.js +179 -0
  141. package/src/soundscape/soundscapeRuntime.js +806 -0
  142. package/src/soundscape/soundscapeSettings.js +292 -0
  143. package/src/styles/index.js +13 -0
  144. package/src/styles/styleBundle.js +325 -0
  145. package/src/stylizedTerrain.js +32 -2
  146. package/src/stylizedWorld.js +423 -20
  147. package/src/texgen/evaluateTexture.js +675 -0
  148. package/src/texgen/index.js +60 -0
  149. package/src/texgen/noise2.js +210 -0
  150. package/src/texgen/textureAi.js +436 -0
  151. package/src/texgen/textureGenerators.js +516 -0
  152. package/src/texgen/texturePresets.js +490 -0
  153. package/src/texgen/textureSettings.js +342 -0
  154. package/src/texgen/textureThree.js +59 -0
  155. package/src/vegetation/flowerSpecies.js +15 -3
  156. package/src/vegetation/grassPalettes.js +153 -0
  157. package/src/vegetation/index.js +6 -0
  158. package/src/vegetation/stylizedBush.js +2 -0
  159. package/src/vegetation/stylizedFlower.js +82 -0
  160. package/src/vegetation/stylizedFlowers.js +48 -7
  161. package/src/vegetation/stylizedForest.js +29 -1
  162. package/src/vegetation/stylizedGrass.js +291 -56
  163. package/src/vegetation/stylizedTree.js +38 -2
  164. package/src/vegetation/stylizedTreeFoliage.js +2 -1
  165. package/src/vegetation/vegetationShaders.js +1110 -0
  166. package/src/vfxgen/INTEGRATION.md +145 -0
  167. package/src/vfxgen/core/burstBackbone.js +380 -0
  168. package/src/vfxgen/core/projectileCore.js +92 -0
  169. package/src/vfxgen/core/spriteShapes.js +98 -0
  170. package/src/vfxgen/core/trailRibbon.js +272 -0
  171. package/src/vfxgen/core/vfxRandom.js +29 -0
  172. package/src/vfxgen/effects/emitHelpers.js +37 -0
  173. package/src/vfxgen/effects/magicEffects.js +162 -0
  174. package/src/vfxgen/effects/movementEffects.js +87 -0
  175. package/src/vfxgen/effects/weaponEffects.js +118 -0
  176. package/src/vfxgen/index.js +18 -0
  177. package/src/vfxgen/moves/moveController.js +146 -0
  178. package/src/vfxgen/moves/moveLibrary.js +335 -0
  179. package/src/vfxgen/vfxPresets.js +98 -0
  180. package/src/vfxgen/vfxSettings.js +384 -0
  181. package/src/vfxgen/vfxSystem.js +449 -0
  182. package/src/vfxgen/weapons/stylizedWeapons.js +137 -0
  183. package/src/villagegen/index.js +4 -0
  184. package/src/villagegen/stylizedVillage.js +490 -0
  185. package/src/villagegen/villageArchetypes.js +160 -0
  186. package/src/villagegen/villageNames.js +40 -0
  187. package/src/villagegen/villageSites.js +105 -0
  188. package/src/water/sceneOverrideLayers.js +23 -0
  189. package/src/water/water.js +5 -0
  190. package/src/water/waterBreakerSystem.js +15 -1
  191. package/src/water/waterCurrentField.js +447 -0
  192. package/src/water/waterMaterial.js +12 -0
  193. package/src/water/waterNearshorePhase.js +320 -0
  194. package/src/water/waterScenePasses.js +83 -30
  195. package/src/water/waterSettings.js +325 -13
  196. package/src/water/waterShoreMaterial.js +322 -0
  197. package/src/water/waterShoreStateField.js +605 -0
  198. package/src/water/waterSurface.js +797 -28
  199. package/src/weather/index.js +6 -0
  200. package/src/weather/weatherPrecipitation.js +221 -0
  201. package/src/weather/weatherPresets.js +258 -0
  202. package/src/weather/weatherSettings.js +269 -0
  203. package/src/weather/weatherSystem.js +871 -0
  204. package/src/worldMinimap.js +62 -0
  205. package/src/worldPresets.js +4 -1
@@ -0,0 +1,410 @@
1
+ // Ribbon geometry for path routes: a skirted strip that follows the route's
2
+ // flattened height profile, painted per style (dirt / stone / planks) with
3
+ // vertex colors + a procedural detail map. The terrain mesh is never
4
+ // modified — the ribbon is a true overlay (lift + edge skirts tucked under
5
+ // the terrain), and the flattened profile is what `paths.heightAt` reports.
6
+
7
+ import * as THREE from 'three';
8
+
9
+ import { hashCombine } from '../rockgen/noise/prng.js';
10
+ import { valueNoise3 } from '../rockgen/noise/valueNoise3.js';
11
+
12
+ // Values are linear-space (THREE vertex colors), same convention as the
13
+ // debris palettes — earthy and saturated enough to survive the environment
14
+ // shader's ambient lift without washing to pink.
15
+ export const PATH_STYLE_PALETTES = Object.freeze({
16
+ dirt: Object.freeze({
17
+ base: Object.freeze([0.3, 0.21, 0.12]),
18
+ edge: Object.freeze([0.24, 0.18, 0.11]),
19
+ wear: Object.freeze([0.42, 0.31, 0.18]),
20
+ }),
21
+ stone: Object.freeze({
22
+ base: Object.freeze([0.4, 0.41, 0.39]),
23
+ edge: Object.freeze([0.3, 0.31, 0.3]),
24
+ wear: Object.freeze([0.52, 0.51, 0.47]),
25
+ }),
26
+ planks: Object.freeze({
27
+ base: Object.freeze([0.38, 0.26, 0.15]),
28
+ edge: Object.freeze([0.3, 0.2, 0.12]),
29
+ wear: Object.freeze([0.5, 0.36, 0.21]),
30
+ }),
31
+ });
32
+
33
+ /** Even resampling of a 2D polyline at `stepLength` meters (arclength). */
34
+ export function resamplePolyline(points, stepLength = 2) {
35
+ if (!Array.isArray(points) || points.length < 2) return [];
36
+ const step = Math.max(Number(stepLength) || 2, 0.5);
37
+ const samples = [{ s: 0, x: points[0].x, z: points[0].z }];
38
+ let total = 0; // arclength at the start of the current segment
39
+ let nextS = step; // arclength of the next sample to emit
40
+ for (let index = 0; index < points.length - 1; index += 1) {
41
+ const a = points[index];
42
+ const b = points[index + 1];
43
+ const span = Math.hypot(b.x - a.x, b.z - a.z);
44
+ if (span < 1e-6) continue;
45
+ while (nextS <= total + span) {
46
+ const t = (nextS - total) / span;
47
+ samples.push({ s: nextS, x: a.x + (b.x - a.x) * t, z: a.z + (b.z - a.z) * t });
48
+ nextS += step;
49
+ }
50
+ total += span;
51
+ }
52
+ const last = points[points.length - 1];
53
+ const tail = samples[samples.length - 1];
54
+ const closing = Math.hypot(last.x - tail.x, last.z - tail.z);
55
+ if (closing > step * 0.35) {
56
+ samples.push({ s: total, x: last.x, z: last.z });
57
+ } else if (samples.length > 1) {
58
+ tail.x = last.x;
59
+ tail.z = last.z;
60
+ tail.s = total;
61
+ }
62
+ return samples;
63
+ }
64
+
65
+ /**
66
+ * Builds per-sample route data: raw terrain height, flattened profile,
67
+ * bridge spans (open-water runs longer than `minSpan`), ford flags, and
68
+ * stair runs (steep dry stretches). This is the single source of truth the
69
+ * ribbon mesh, the bridges, `paths.heightAt`, and `paths.maskAt` all share.
70
+ */
71
+ export function buildRouteProfile({
72
+ points,
73
+ heightAt,
74
+ waterLevel = 0,
75
+ settings,
76
+ seed = 1,
77
+ }) {
78
+ const sample = typeof heightAt === 'function' ? heightAt : () => 0;
79
+ const { ribbon, bridge, stairs } = settings;
80
+ const samples = resamplePolyline(points, ribbon.stepLength);
81
+ if (samples.length < 2) return null;
82
+
83
+ for (const point of samples) {
84
+ point.raw = Number(sample(point.x, point.z)) || 0;
85
+ point.wet = point.raw <= waterLevel + 0.05;
86
+ }
87
+
88
+ // Flattened profile: moving average of raw height, deviation-clamped so
89
+ // the path can fill dips (embankment) but never cuts deep into a rise.
90
+ const window = Math.max(Math.round((ribbon.smoothing || 0) / Math.max(ribbon.stepLength, 0.5)), 0);
91
+ for (let index = 0; index < samples.length; index += 1) {
92
+ if (window === 0) { samples[index].profile = samples[index].raw; continue; }
93
+ let sum = 0;
94
+ let count = 0;
95
+ for (let k = -window; k <= window; k += 1) {
96
+ const at = Math.min(samples.length - 1, Math.max(0, index + k));
97
+ sum += samples[at].raw;
98
+ count += 1;
99
+ }
100
+ const smoothed = sum / count;
101
+ const deviation = Math.min(Math.max(smoothed - samples[index].raw, -0.45), 1.4);
102
+ samples[index].profile = samples[index].raw + deviation;
103
+ }
104
+
105
+ // Open-water runs → bridge spans (long) or fords (short). Bridges get an
106
+ // arched deck profile clamped above the waterline; fords wade the profile
107
+ // just above it.
108
+ const bridges = [];
109
+ let runStart = -1;
110
+ const flushRun = (endIndex) => {
111
+ if (runStart < 0) return;
112
+ const startIndex = Math.max(runStart - 1, 0);
113
+ const stopIndex = Math.min(endIndex, samples.length - 1);
114
+ const span = samples[stopIndex].s - samples[startIndex].s;
115
+ if (span >= bridge.minSpan) {
116
+ bridges.push({ endIndex: stopIndex, span, startIndex });
117
+ } else {
118
+ for (let index = startIndex; index <= stopIndex; index += 1) {
119
+ samples[index].profile = Math.max(samples[index].profile, waterLevel + 0.14);
120
+ samples[index].ford = true;
121
+ }
122
+ }
123
+ runStart = -1;
124
+ };
125
+ for (let index = 0; index < samples.length; index += 1) {
126
+ if (samples[index].wet) {
127
+ if (runStart < 0) runStart = index;
128
+ } else {
129
+ flushRun(index);
130
+ }
131
+ }
132
+ flushRun(samples.length - 1);
133
+
134
+ for (const crossing of bridges) {
135
+ const { startIndex, endIndex } = crossing;
136
+ const startY = samples[startIndex].profile;
137
+ const endY = samples[endIndex].profile;
138
+ const span = crossing.span;
139
+ const rise = Math.min(span * bridge.arc, 2.6);
140
+ const clearance = waterLevel + bridge.deckClearance;
141
+ for (let index = startIndex; index <= endIndex; index += 1) {
142
+ const t = (samples[index].s - samples[startIndex].s) / Math.max(span, 1e-6);
143
+ const base = startY + (endY - startY) * t;
144
+ const arch = base + Math.sin(t * Math.PI) * rise;
145
+ samples[index].profile = index === startIndex || index === endIndex
146
+ ? base
147
+ : Math.max(arch, clearance);
148
+ // Interior samples are deck-only; the bank samples stay in the ribbon
149
+ // so the dirt strip runs under the deck ends (no seam gap).
150
+ if (index > startIndex && index < endIndex) samples[index].bridge = true;
151
+ }
152
+ crossing.deckHeights = samples
153
+ .slice(startIndex, endIndex + 1)
154
+ .map((point) => point.profile);
155
+ }
156
+
157
+ // Stair runs: steep, dry, non-bridge stretches. Visual only — the profile
158
+ // stays a smooth ramp so character controllers need no step logic.
159
+ const stairRuns = [];
160
+ let stairStart = -1;
161
+ for (let index = 0; index < samples.length - 1; index += 1) {
162
+ const a = samples[index];
163
+ const b = samples[index + 1];
164
+ const slope = Math.abs(b.profile - a.profile) / Math.max(b.s - a.s, 1e-6);
165
+ const steep = slope >= stairs.slopeThreshold && !a.bridge && !b.bridge && !a.ford;
166
+ if (steep && stairStart < 0) stairStart = index;
167
+ if (!steep && stairStart >= 0) {
168
+ if (index - stairStart >= 1) stairRuns.push({ endIndex: index, startIndex: stairStart });
169
+ stairStart = -1;
170
+ }
171
+ }
172
+ if (stairStart >= 0 && samples.length - 1 - stairStart >= 1) {
173
+ stairRuns.push({ endIndex: samples.length - 1, startIndex: stairStart });
174
+ }
175
+
176
+ // Per-sample half-width with low-frequency wobble (hand-drawn look) and
177
+ // the side direction (perpendicular of the local tangent).
178
+ const halfBase = ribbon.width / 2;
179
+ for (let index = 0; index < samples.length; index += 1) {
180
+ const prev = samples[Math.max(index - 1, 0)];
181
+ const next = samples[Math.min(index + 1, samples.length - 1)];
182
+ const tx = next.x - prev.x;
183
+ const tz = next.z - prev.z;
184
+ const invLen = 1 / Math.max(Math.hypot(tx, tz), 1e-6);
185
+ samples[index].sideX = -tz * invLen;
186
+ samples[index].sideZ = tx * invLen;
187
+ const wobble = valueNoise3(hashCombine(seed, 811), samples[index].s * 0.045, 0.5, 0.5);
188
+ samples[index].half = samples[index].bridge
189
+ ? halfBase
190
+ : halfBase * (1 + ribbon.widthWobble * wobble);
191
+ }
192
+
193
+ return { bridges, samples, stairRuns };
194
+ }
195
+
196
+ const scratchColor = new THREE.Color();
197
+
198
+ function pushColor(colors, rgb, shade) {
199
+ scratchColor.setRGB(rgb[0], rgb[1], rgb[2]);
200
+ colors.push(
201
+ Math.min(scratchColor.r * shade, 1),
202
+ Math.min(scratchColor.g * shade, 1),
203
+ Math.min(scratchColor.b * shade, 1),
204
+ );
205
+ }
206
+
207
+ /**
208
+ * Ribbon mesh for one route (bridge spans skipped — those become bridge
209
+ * groups). Cross-section: skirt / edge / crown / edge / skirt; skirts drop
210
+ * below the neighbouring terrain so the visible edge is the organic
211
+ * intersection line, never a floating hem.
212
+ */
213
+ export function buildRibbonGeometry(routeData, {
214
+ heightAt,
215
+ settings,
216
+ style = 'dirt',
217
+ seed = 1,
218
+ }) {
219
+ const sample = typeof heightAt === 'function' ? heightAt : () => 0;
220
+ const { ribbon } = settings;
221
+ const palette = PATH_STYLE_PALETTES[style] ?? PATH_STYLE_PALETTES.dirt;
222
+ const positions = [];
223
+ const uvs = [];
224
+ const colors = [];
225
+ const indices = [];
226
+ const skirtDrop = 0.4;
227
+ const crown = Math.min(ribbon.width * 0.02, 0.05);
228
+ const vRepeat = 1 / Math.max(ribbon.width, 1); // ~square texture tiles
229
+
230
+ const { samples } = routeData;
231
+ let rowIndex = -1;
232
+ let previousRow = null;
233
+ for (let index = 0; index < samples.length; index += 1) {
234
+ const point = samples[index];
235
+ if (point.bridge) { previousRow = null; continue; }
236
+ const lift = ribbon.lift;
237
+ const y = point.profile + lift;
238
+ const half = point.half;
239
+ const skirt = half + ribbon.edgeSkirt;
240
+ const wear = valueNoise3(hashCombine(seed, 977), point.s * 0.11, 0.25, 0.75);
241
+
242
+ const across = [
243
+ { off: -skirt, u: 0, kind: 'skirt' },
244
+ { off: -half, u: 0.04, kind: 'edge' },
245
+ { off: 0, u: 0.5, kind: 'center' },
246
+ { off: half, u: 0.96, kind: 'edge' },
247
+ { off: skirt, u: 1, kind: 'skirt' },
248
+ ];
249
+ for (const slot of across) {
250
+ const x = point.x + point.sideX * slot.off;
251
+ const z = point.z + point.sideZ * slot.off;
252
+ let vy;
253
+ if (slot.kind === 'skirt') {
254
+ vy = Math.min((Number(sample(x, z)) || 0) - skirtDrop, y - 0.05);
255
+ } else if (slot.kind === 'center') {
256
+ vy = y + crown;
257
+ } else {
258
+ vy = y;
259
+ }
260
+ positions.push(x, vy, z);
261
+ uvs.push(slot.u, point.s * vRepeat);
262
+ if (slot.kind === 'skirt') {
263
+ pushColor(colors, palette.edge, 0.72);
264
+ } else if (slot.kind === 'edge') {
265
+ pushColor(colors, palette.edge, 0.94 + wear * 0.05);
266
+ } else {
267
+ const worn = wear * 0.5 + 0.5;
268
+ const rgb = [
269
+ palette.base[0] + (palette.wear[0] - palette.base[0]) * worn,
270
+ palette.base[1] + (palette.wear[1] - palette.base[1]) * worn,
271
+ palette.base[2] + (palette.wear[2] - palette.base[2]) * worn,
272
+ ];
273
+ pushColor(colors, rgb, 1);
274
+ }
275
+ }
276
+ rowIndex += 1;
277
+ if (previousRow !== null) {
278
+ const a = previousRow * 5;
279
+ const b = rowIndex * 5;
280
+ for (let k = 0; k < 4; k += 1) {
281
+ // Wound so face normals point up (+y): row b is ahead of row a
282
+ // along the route, slots run left→right across it.
283
+ indices.push(a + k, b + k + 1, b + k, a + k, a + k + 1, b + k + 1);
284
+ }
285
+ }
286
+ previousRow = rowIndex;
287
+ }
288
+ if (positions.length === 0) return null;
289
+
290
+ const geometry = new THREE.BufferGeometry();
291
+ geometry.setAttribute('position', new THREE.Float32BufferAttribute(positions, 3));
292
+ geometry.setAttribute('uv', new THREE.Float32BufferAttribute(uvs, 2));
293
+ geometry.setAttribute('color', new THREE.Float32BufferAttribute(colors, 3));
294
+ geometry.setIndex(indices);
295
+ geometry.computeVertexNormals();
296
+ return geometry;
297
+ }
298
+
299
+ /**
300
+ * Stepped stone segments over steep runs. Boxes sunk into the ramp — the
301
+ * read is stairs, the walk is the smooth profile underneath.
302
+ */
303
+ export function buildStairsGeometry(routeData, { settings, seed = 1, maxSteps = 400 }) {
304
+ const { stairRuns, samples } = routeData;
305
+ if (!stairRuns.length) return null;
306
+ const { stairs } = settings;
307
+ let stepsLeft = Math.max(Number(maxSteps) || 400, 0); // triangle-budget guard
308
+ const palette = PATH_STYLE_PALETTES.stone;
309
+ const geometries = [];
310
+ const box = new THREE.BoxGeometry(1, 1, 1);
311
+ const matrix = new THREE.Matrix4();
312
+ const quaternion = new THREE.Quaternion();
313
+ const up = new THREE.Vector3(0, 1, 0);
314
+ const random = (value) => valueNoise3(hashCombine(seed, 601), value * 3.7, 0.2, 0.8);
315
+
316
+ for (const run of stairRuns) {
317
+ if (stepsLeft <= 0) break;
318
+ const start = samples[run.startIndex];
319
+ const end = samples[run.endIndex];
320
+ const rise = end.profile - start.profile;
321
+ const stepCount = Math.min(
322
+ Math.max(Math.ceil(Math.abs(rise) / stairs.stepHeight), 2),
323
+ stepsLeft,
324
+ );
325
+ stepsLeft -= stepCount;
326
+ for (let stepIndex = 0; stepIndex < stepCount; stepIndex += 1) {
327
+ const t = (stepIndex + 0.5) / stepCount;
328
+ const s = start.s + (end.s - start.s) * t;
329
+ // find bracketing samples for position/heading
330
+ let at = run.startIndex;
331
+ while (at < run.endIndex && samples[at + 1].s < s) at += 1;
332
+ const a = samples[at];
333
+ const b = samples[Math.min(at + 1, run.endIndex)];
334
+ const spanT = (s - a.s) / Math.max(b.s - a.s, 1e-6);
335
+ const x = a.x + (b.x - a.x) * spanT;
336
+ const z = a.z + (b.z - a.z) * spanT;
337
+ const profile = a.profile + (b.profile - a.profile) * spanT;
338
+ const heading = Math.atan2(b.x - a.x, b.z - a.z);
339
+ const width = (a.half + b.half) * 0.92;
340
+ const depth = (end.s - start.s) / stepCount + 0.14;
341
+ const height = stairs.stepHeight + 0.1;
342
+ const jitter = random(stepIndex + run.startIndex);
343
+
344
+ const geometry = box.clone();
345
+ quaternion.setFromAxisAngle(up, heading + jitter * 0.06);
346
+ matrix.compose(
347
+ new THREE.Vector3(x, profile + stairs.stepHeight * 0.28, z),
348
+ quaternion,
349
+ new THREE.Vector3(width, height, depth),
350
+ );
351
+ geometry.applyMatrix4(matrix);
352
+ const count = geometry.attributes.position.count;
353
+ const stepColors = new Float32Array(count * 3);
354
+ const shade = 0.9 + jitter * 0.12;
355
+ for (let v = 0; v < count; v += 1) {
356
+ stepColors[v * 3] = Math.min(palette.base[0] * shade, 1);
357
+ stepColors[v * 3 + 1] = Math.min(palette.base[1] * shade, 1);
358
+ stepColors[v * 3 + 2] = Math.min(palette.base[2] * shade, 1);
359
+ }
360
+ geometry.setAttribute('color', new THREE.BufferAttribute(stepColors, 3));
361
+ geometries.push(geometry);
362
+ }
363
+ }
364
+ box.dispose();
365
+ if (!geometries.length) return null;
366
+ const merged = mergePathGeometries(geometries);
367
+ for (const geometry of geometries) geometry.dispose();
368
+ return merged;
369
+ }
370
+
371
+ // Minimal local merge (positions/normals/uvs/colors + index) — avoids the
372
+ // three/examples import here so the module stays lean for tests.
373
+ export function mergePathGeometries(geometries) {
374
+ const hasUv = geometries.every((geometry) => geometry.attributes.uv);
375
+ const positions = [];
376
+ const normals = [];
377
+ const uvs = [];
378
+ const colors = [];
379
+ const indices = [];
380
+ let offset = 0;
381
+ for (const geometry of geometries) {
382
+ const position = geometry.attributes.position;
383
+ const normal = geometry.attributes.normal;
384
+ const uv = geometry.attributes.uv;
385
+ const color = geometry.attributes.color;
386
+ for (let index = 0; index < position.count; index += 1) {
387
+ positions.push(position.getX(index), position.getY(index), position.getZ(index));
388
+ if (normal) normals.push(normal.getX(index), normal.getY(index), normal.getZ(index));
389
+ if (hasUv && uv) uvs.push(uv.getX(index), uv.getY(index));
390
+ if (color) colors.push(color.getX(index), color.getY(index), color.getZ(index));
391
+ else colors.push(1, 1, 1);
392
+ }
393
+ if (geometry.index) {
394
+ for (let index = 0; index < geometry.index.count; index += 1) {
395
+ indices.push(geometry.index.getX(index) + offset);
396
+ }
397
+ } else {
398
+ for (let index = 0; index < position.count; index += 1) indices.push(index + offset);
399
+ }
400
+ offset += position.count;
401
+ }
402
+ const merged = new THREE.BufferGeometry();
403
+ merged.setAttribute('position', new THREE.Float32BufferAttribute(positions, 3));
404
+ if (normals.length) merged.setAttribute('normal', new THREE.Float32BufferAttribute(normals, 3));
405
+ if (uvs.length) merged.setAttribute('uv', new THREE.Float32BufferAttribute(uvs, 2));
406
+ merged.setAttribute('color', new THREE.Float32BufferAttribute(colors, 3));
407
+ merged.setIndex(indices);
408
+ if (!normals.length) merged.computeVertexNormals();
409
+ return merged;
410
+ }