@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
@@ -1003,6 +1003,25 @@ export function createPostProcessingPipeline({
1003
1003
  const currentViewProjection = new THREE.Matrix4();
1004
1004
  const previousViewProjection = new THREE.Matrix4();
1005
1005
  let hasPreviousViewProjection = false;
1006
+ let disposed = false;
1007
+
1008
+ function stats() {
1009
+ const fullWidth = Math.max(1, Math.round(currentWidth * currentPixelRatio));
1010
+ const fullHeight = Math.max(1, Math.round(currentHeight * currentPixelRatio));
1011
+ const bloomTargets = bloomDownChain.length + bloomUpChain.length;
1012
+ return {
1013
+ bloomTargets,
1014
+ disposed,
1015
+ enabled,
1016
+ fullResolutionTargets: 2,
1017
+ height: fullHeight,
1018
+ // Approximation for authoring diagnostics: two RGBA16F full-res targets
1019
+ // plus allocated bloom-chain targets. Driver-specific overhead excluded.
1020
+ renderTargetBytesApprox: fullWidth * fullHeight * 8 * (2 + bloomTargets),
1021
+ scenePasses: enabled ? 1 + (needsScenePostDepth(settings) ? 1 : 0) : 1,
1022
+ width: fullWidth,
1023
+ };
1024
+ }
1006
1025
 
1007
1026
  return {
1008
1027
  compositeMaterial,
@@ -1013,8 +1032,26 @@ export function createPostProcessingPipeline({
1013
1032
  get settings() {
1014
1033
  return settings;
1015
1034
  },
1035
+ dispose() {
1036
+ if (disposed) return;
1037
+ disposed = true;
1038
+ target.dispose();
1039
+ sceneDepthColorTarget.dispose();
1040
+ disposeBloomChain();
1041
+ for (const material of [
1042
+ compositeMaterial,
1043
+ sceneDepthColorMaterial,
1044
+ bloomPrefilterMaterial,
1045
+ bloomDownsampleMaterial,
1046
+ bloomUpsampleMaterial,
1047
+ ]) material?.dispose?.();
1048
+ },
1049
+ resetHistory() {
1050
+ hasPreviousViewProjection = false;
1051
+ },
1016
1052
  render(delta) {
1017
1053
  void delta;
1054
+ if (disposed) return;
1018
1055
  if (!enabled) {
1019
1056
  renderer.render(scene, camera);
1020
1057
  return;
@@ -1070,6 +1107,7 @@ export function createPostProcessingPipeline({
1070
1107
  renderer.setRenderTarget(previousRenderTarget);
1071
1108
  },
1072
1109
  setSize(nextWidth, nextHeight, nextPixelRatio = pixelRatio) {
1110
+ if (disposed) return;
1073
1111
  currentWidth = nextWidth;
1074
1112
  currentHeight = nextHeight;
1075
1113
  currentPixelRatio = nextPixelRatio;
@@ -1091,6 +1129,7 @@ export function createPostProcessingPipeline({
1091
1129
  );
1092
1130
  },
1093
1131
  setCharacterMask(texture) {
1132
+ if (disposed) return;
1094
1133
  // See applyCompositeSettings' tLut comment: the node backends need a
1095
1134
  // real bound texture at all times, never `null`; harmless on classic
1096
1135
  // since tCharacterMask is only read behind the useCharacterMask guard.
@@ -1098,6 +1137,7 @@ export function createPostProcessingPipeline({
1098
1137
  compositeMaterial.uniforms.useCharacterMask.value = texture ? 1.0 : 0.0;
1099
1138
  },
1100
1139
  setSettings(nextSettingsInput = {}) {
1140
+ if (disposed) return;
1101
1141
  settings = createPostProcessingSettings(nextSettingsInput);
1102
1142
  enabled = isPostProcessingEnabled(settings);
1103
1143
  applyCompositeSettings(
@@ -1110,6 +1150,7 @@ export function createPostProcessingPipeline({
1110
1150
  currentPixelRatio,
1111
1151
  );
1112
1152
  },
1153
+ stats,
1113
1154
  target,
1114
1155
  };
1115
1156
  }
@@ -0,0 +1,379 @@
1
+ // Wave-1 prop generators: path dressing. Each generator returns
2
+ // { main, glow, footprint } — merged painted geometry per role plus the
3
+ // collision footprint ({ radius } or { circles }) in the prop's local
4
+ // frame (origin = ground contact, +y up).
5
+ //
6
+ // `detail` is 'hi' or 'lo': lo drops segment counts and garnish parts but
7
+ // keeps the silhouette, so the distance swap is invisible in motion.
8
+
9
+ import { PartsBuilder } from './propParts.js';
10
+
11
+ const TAU = Math.PI * 2;
12
+
13
+ // --- lantern -----------------------------------------------------------------
14
+
15
+ export function generateLantern(settings, random, detail = 'hi') {
16
+ const { shape } = settings;
17
+ const hi = detail === 'hi';
18
+ const builder = new PartsBuilder(settings.surface, random);
19
+ // seeded proportion jitter: every lantern is a cousin, not a clone
20
+ const height = shape.height * (0.95 + random() * 0.1);
21
+ const lamp = shape.lampSize * (0.94 + random() * 0.12);
22
+
23
+ if (settings.asset.variant === 'chochin') {
24
+ // Post + arm + hanging paper barrel. The paper is the glow role — warm
25
+ // unlit lamp bodies read at dusk without any real light.
26
+ const postHeight = height;
27
+ builder.cylinder(0.05, 0.065, postHeight, {
28
+ position: [0, postHeight / 2, 0], segments: hi ? 8 : 5, slot: 0,
29
+ });
30
+ builder.box(0.05, 0.05, 0.42, {
31
+ position: [0, postHeight - 0.05, 0.18], slot: 0, shade: 0.92,
32
+ });
33
+ const bodyR = 0.16 * lamp;
34
+ const bodyH = 0.42 * lamp;
35
+ const bodyY = postHeight - 0.14 - bodyH / 2;
36
+ builder.lathe([
37
+ [0.02, 0], [bodyR * 0.8, bodyH * 0.12], [bodyR, bodyH * 0.5],
38
+ [bodyR * 0.8, bodyH * 0.88], [0.02, bodyH],
39
+ ], {
40
+ position: [0, bodyY, 0.34], role: 'glow', segments: hi ? 12 : 7,
41
+ shade: 0.75 + shape.glow * 0.5, slot: 2,
42
+ });
43
+ builder.cylinder(0.05, 0.05, 0.03, {
44
+ position: [0, bodyY + bodyH + 0.015, 0.34], segments: hi ? 8 : 5, shade: 0.5, slot: 0,
45
+ });
46
+ builder.cylinder(0.05, 0.05, 0.03, {
47
+ position: [0, bodyY - 0.015, 0.34], segments: hi ? 8 : 5, shade: 0.5, slot: 0,
48
+ });
49
+ return { footprint: { radius: 0.14 }, ...builder.build() };
50
+ }
51
+
52
+ if (settings.asset.variant === 'woodPost') {
53
+ const postHeight = height;
54
+ builder.box(0.09, postHeight, 0.09, { position: [0, postHeight / 2, 0], slot: 0 });
55
+ const boxSize = 0.24 * lamp;
56
+ const boxY = postHeight - boxSize / 2 - 0.02;
57
+ // housing frame: four corner sticks + glow core
58
+ for (const [dx, dz] of [[-1, -1], [-1, 1], [1, -1], [1, 1]]) {
59
+ builder.box(0.03, boxSize, 0.03, {
60
+ position: [dx * boxSize * 0.45, boxY, dz * boxSize * 0.45], shade: 0.85, slot: 0,
61
+ });
62
+ }
63
+ builder.box(boxSize * 0.8, boxSize * 0.8, boxSize * 0.8, {
64
+ position: [0, boxY, 0], role: 'glow', shade: 0.7 + shape.glow * 0.5, slot: 2,
65
+ });
66
+ builder.box(boxSize * 1.5, 0.04, boxSize * 1.5, {
67
+ position: [0, boxY + boxSize * 0.55, 0], rotation: [0, Math.PI / 4, 0], shade: 0.8, slot: 1,
68
+ });
69
+ return { footprint: { radius: 0.12 }, ...builder.build() };
70
+ }
71
+
72
+ // stoneToro: plinth → pillar → firebox (glow core behind stone posts) →
73
+ // pyramidal cap → jewel. The anime shrine-path staple.
74
+ const plinthH = height * 0.14;
75
+ const pillarH = height * 0.34;
76
+ const shelfH = height * 0.06;
77
+ const boxH = height * 0.26 * lamp;
78
+ const roofH = height * 0.16;
79
+ let y = 0;
80
+ builder.lathe([
81
+ [height * 0.22, 0], [height * 0.2, plinthH * 0.5], [height * 0.13, plinthH],
82
+ ], { position: [0, y, 0], segments: hi ? 10 : 6, slot: 0 });
83
+ y += plinthH;
84
+ builder.cylinder(height * 0.06, height * 0.08, pillarH, {
85
+ position: [0, y + pillarH / 2, 0], segments: hi ? 9 : 6, shade: 0.96, slot: 0,
86
+ });
87
+ y += pillarH;
88
+ builder.box(height * 0.3, shelfH, height * 0.3, {
89
+ position: [0, y + shelfH / 2, 0], shade: 0.9, slot: 0,
90
+ });
91
+ y += shelfH;
92
+ const boxW = height * 0.22;
93
+ builder.box(boxW * 0.8, boxH * 0.9, boxW * 0.8, {
94
+ position: [0, y + boxH / 2, 0], role: 'glow', shade: 0.7 + shape.glow * 0.5, slot: 2,
95
+ });
96
+ if (hi) {
97
+ for (const [dx, dz] of [[-1, -1], [-1, 1], [1, -1], [1, 1]]) {
98
+ builder.box(boxW * 0.16, boxH, boxW * 0.16, {
99
+ position: [dx * boxW * 0.42, y + boxH / 2, dz * boxW * 0.42], shade: 0.85, slot: 0,
100
+ });
101
+ }
102
+ }
103
+ y += boxH;
104
+ builder.cone(height * 0.24 * shape.roofOverhang, roofH, {
105
+ position: [0, y + roofH / 2, 0], rotation: [0, Math.PI / 4, 0], segments: 4, shade: 0.88, slot: 0,
106
+ });
107
+ y += roofH;
108
+ builder.sphere(height * 0.045, {
109
+ position: [0, y + height * 0.03, 0], segments: hi ? 8 : 6, shade: 1.05, slot: 0,
110
+ });
111
+ return { footprint: { radius: Math.max(height * 0.22, 0.2) }, ...builder.build() };
112
+ }
113
+
114
+ // --- signpost ----------------------------------------------------------------
115
+
116
+ export function generateSignpost(settings, random, detail = 'hi') {
117
+ const { shape } = settings;
118
+ const hi = detail === 'hi';
119
+ const builder = new PartsBuilder(settings.surface, random);
120
+ const tilt = (random() * 2 - 1) * shape.tilt * 0.09;
121
+ const height = shape.height;
122
+ builder.cylinder(0.045, 0.06, height, {
123
+ position: [Math.sin(tilt) * height * 0.4, height / 2, 0],
124
+ rotation: [0, 0, tilt],
125
+ segments: hi ? 8 : 5,
126
+ slot: 0,
127
+ });
128
+ const boards = settings.asset.variant === 'single' ? 1
129
+ : settings.asset.variant === 'double' ? 2 : 4;
130
+ for (let index = 0; index < boards; index += 1) {
131
+ const y = height - 0.22 - index * 0.24;
132
+ const yaw = settings.asset.variant === 'crossroads'
133
+ ? (index * Math.PI) / 2 + (random() - 0.5) * 0.2
134
+ : (random() - 0.5) * 0.6 + (index % 2 ? Math.PI : 0);
135
+ const length = shape.boardLength * (0.85 + random() * 0.3);
136
+ builder.box(length, 0.13, 0.03, {
137
+ position: [
138
+ Math.cos(yaw) * length * 0.42 + Math.sin(tilt) * y * 0.8,
139
+ y,
140
+ -Math.sin(yaw) * length * 0.42,
141
+ ],
142
+ rotation: [0, yaw, (random() - 0.5) * shape.tilt * 0.12],
143
+ shade: 0.95 + random() * 0.1,
144
+ slot: 1,
145
+ });
146
+ if (hi) {
147
+ // pointed tip: a small wedge at the far end sells "direction"
148
+ builder.cone(0.075, 0.1, {
149
+ position: [
150
+ Math.cos(yaw) * length * 0.95 + Math.sin(tilt) * y * 0.8,
151
+ y,
152
+ -Math.sin(yaw) * length * 0.95,
153
+ ],
154
+ // cone +y → horizontal board direction (cos yaw, 0, -sin yaw)
155
+ rotation: [Math.PI / 2, Math.atan2(Math.cos(yaw), -Math.sin(yaw)), 0],
156
+ segments: 4,
157
+ shade: 0.9,
158
+ slot: 1,
159
+ });
160
+ }
161
+ }
162
+ return { footprint: { radius: 0.14 }, ...builder.build() };
163
+ }
164
+
165
+ // --- stone stairs -------------------------------------------------------------
166
+
167
+ export function generateStoneStairs(settings, random, detail = 'hi') {
168
+ const { shape } = settings;
169
+ const builder = new PartsBuilder(settings.surface, random);
170
+ const depth = 0.34;
171
+ const worn = settings.asset.variant === 'worn' ? 1 : shape.wear;
172
+ for (let index = 0; index < shape.stepCount; index += 1) {
173
+ const wearJitter = (random() * 2 - 1) * worn;
174
+ const mossRoll = random(); // drawn unconditionally: hi and lo must stay in step
175
+ builder.box(
176
+ shape.width * (1 - Math.abs(wearJitter) * 0.06),
177
+ shape.stepHeight + 0.05,
178
+ depth + 0.1,
179
+ {
180
+ position: [
181
+ wearJitter * 0.05,
182
+ (index + 0.5) * shape.stepHeight - 0.04,
183
+ index * depth,
184
+ ],
185
+ rotation: [wearJitter * 0.03, wearJitter * 0.05, wearJitter * 0.025],
186
+ shade: 0.92 + random() * 0.14,
187
+ // lower steps go mossy: accent slot carries the green
188
+ slot: detail === 'hi' && index < shape.stepCount * shape.wear * 0.6 && mossRoll < 0.5 ? 2 : 0,
189
+ },
190
+ );
191
+ }
192
+ const runDepth = shape.stepCount * depth;
193
+ return {
194
+ footprint: {
195
+ circles: [
196
+ { radius: shape.width * 0.55, x: 0, z: runDepth * 0.25 },
197
+ { radius: shape.width * 0.55, x: 0, z: runDepth * 0.75 },
198
+ ],
199
+ },
200
+ ...builder.build(),
201
+ };
202
+ }
203
+
204
+ // --- milestone -----------------------------------------------------------------
205
+
206
+ export function generateMilestone(settings, random, detail = 'hi') {
207
+ const { shape } = settings;
208
+ const hi = detail === 'hi';
209
+ const builder = new PartsBuilder(settings.surface, random);
210
+ const height = shape.height;
211
+ const girth = shape.girth * height * 0.22;
212
+
213
+ if (settings.asset.variant === 'obelisk') {
214
+ builder.box(girth * 1.6, height * 0.12, girth * 1.6, {
215
+ position: [0, height * 0.06, 0], shade: 0.9, slot: 0,
216
+ });
217
+ builder.box(girth, height * 0.76, girth, {
218
+ position: [0, height * 0.5, 0], scale: [1, 1, 1], slot: 0,
219
+ });
220
+ builder.cone(girth * 0.72, height * 0.14, {
221
+ position: [0, height * 0.93, 0], rotation: [0, Math.PI / 4, 0], segments: 4, shade: 1.04, slot: 0,
222
+ });
223
+ } else if (settings.asset.variant === 'jizo') {
224
+ builder.lathe([
225
+ [girth * 1.3, 0], [girth * 1.1, height * 0.1], [girth * 0.9, height * 0.16],
226
+ ], { segments: hi ? 10 : 6, shade: 0.9, slot: 0 });
227
+ builder.lathe([
228
+ [girth * 0.85, 0], [girth, height * 0.3], [girth * 0.8, height * 0.62], [girth * 0.35, height * 0.72],
229
+ ], { position: [0, height * 0.14, 0], segments: hi ? 10 : 6, slot: 0 });
230
+ builder.sphere(girth * 0.55, {
231
+ position: [0, height * 0.9, 0], segments: hi ? 9 : 6, shade: 1.02, slot: 0,
232
+ });
233
+ // the red bib — tiny, and instantly reads "jizō"
234
+ builder.box(girth * 0.9, height * 0.2, 0.02, {
235
+ position: [0, height * 0.68, girth * 0.72], rotation: [0.25, 0, 0], shade: 1, slot: 2,
236
+ });
237
+ } else {
238
+ // roadStone: weathered rounded slab
239
+ builder.lathe([
240
+ [girth * 1.15, 0], [girth * 1.05, height * 0.32], [girth * 0.92, height * 0.7],
241
+ [girth * 0.6, height * 0.94], [0.01, height],
242
+ ], { rotation: [0, random() * TAU, 0], scale: [1, 1, 0.7], segments: hi ? 11 : 7, slot: 0 });
243
+ if (hi && shape.inscription > 0.05) {
244
+ builder.box(girth * 1.1, height * 0.26 * shape.inscription, girth * 0.16, {
245
+ position: [0, height * 0.46, girth * 0.62], shade: 0.78, slot: 0,
246
+ });
247
+ }
248
+ }
249
+ if (hi && shape.moss > 0.05) {
250
+ builder.lathe([
251
+ [girth * 1.22, 0], [girth * 1.08, height * 0.1 + shape.moss * height * 0.12],
252
+ ], { segments: 8, shade: 0.95, slot: 2 });
253
+ }
254
+ return { footprint: { radius: Math.max(girth * 1.3, 0.18) }, ...builder.build() };
255
+ }
256
+
257
+ // --- fence (linear) -------------------------------------------------------------
258
+
259
+ /**
260
+ * Builds a fence run along resolved 3D points (one per post). Returns
261
+ * { main, glow: null, footprints } where footprints are world-frame circles
262
+ * along the run (the run is baked in world space by the caller's points).
263
+ */
264
+ export function generateFenceAlong(settings, random, points, detail = 'hi') {
265
+ const { shape } = settings;
266
+ const hi = detail === 'hi';
267
+ const builder = new PartsBuilder(settings.surface, random);
268
+ const variant = settings.asset.variant;
269
+ const thickness = shape.thickness;
270
+
271
+ for (let index = 0; index < points.length; index += 1) {
272
+ const point = points[index];
273
+ const leanX = (random() * 2 - 1) * shape.lean * 0.1;
274
+ const leanZ = (random() * 2 - 1) * shape.lean * 0.1;
275
+ const postHeight = shape.postHeight * (1 + (random() - 0.5) * 0.08);
276
+ if (variant === 'picket' || variant === 'ranch') {
277
+ builder.box(thickness * 1.2, postHeight, thickness * 1.2, {
278
+ position: [point.x, point.y + postHeight / 2, point.z],
279
+ rotation: [leanX, 0, leanZ],
280
+ shade: 0.9 + random() * 0.16,
281
+ slot: 0,
282
+ });
283
+ } else {
284
+ builder.cylinder(thickness * 0.8, thickness, postHeight, {
285
+ position: [point.x, point.y + postHeight / 2, point.z],
286
+ rotation: [leanX, 0, leanZ],
287
+ segments: hi ? 7 : 5,
288
+ shade: 0.9 + random() * 0.16,
289
+ slot: 0,
290
+ });
291
+ }
292
+ }
293
+
294
+ for (let index = 0; index < points.length - 1; index += 1) {
295
+ const a = points[index];
296
+ const b = points[index + 1];
297
+ if (random() < shape.gapChance) continue;
298
+ const span = Math.hypot(b.x - a.x, b.z - a.z);
299
+ const heading = Math.atan2(b.x - a.x, b.z - a.z);
300
+ const rails = variant === 'rope' ? 1 : Math.round(shape.railCount);
301
+ for (let rail = 0; rail < rails; rail += 1) {
302
+ const t = (rail + 1) / (rails + 1);
303
+ const yA = a.y + shape.postHeight * (0.35 + t * 0.55);
304
+ const yB = b.y + shape.postHeight * (0.35 + t * 0.55);
305
+ if (variant === 'rope') {
306
+ // sagging rope: three chained segments approximating a catenary
307
+ const sagDrop = shape.sag * 0.22 * span * 0.25;
308
+ const midX = (a.x + b.x) / 2;
309
+ const midZ = (a.z + b.z) / 2;
310
+ const midY = (yA + yB) / 2 - sagDrop;
311
+ for (const [fromX, fromY, fromZ, toX, toY, toZ] of [
312
+ [a.x, yA, a.z, midX, midY, midZ],
313
+ [midX, midY, midZ, b.x, yB, b.z],
314
+ ]) {
315
+ const length = Math.hypot(toX - fromX, toY - fromY, toZ - fromZ);
316
+ // cylinder +y → segment direction: pitch = acos(dy), yaw = atan2(dx, dz)
317
+ const dy = (toY - fromY) / Math.max(length, 1e-6);
318
+ builder.cylinder(0.02, 0.02, length, {
319
+ position: [(fromX + toX) / 2, (fromY + toY) / 2, (fromZ + toZ) / 2],
320
+ rotation: [
321
+ Math.acos(Math.min(Math.max(dy, -1), 1)),
322
+ Math.atan2(toX - fromX, toZ - fromZ),
323
+ 0,
324
+ ],
325
+ segments: 5,
326
+ shade: 0.85,
327
+ slot: 1,
328
+ });
329
+ }
330
+ continue;
331
+ }
332
+ const pitch = Math.atan2(yB - yA, span);
333
+ const bow = variant === 'log' ? 0 : (random() - 0.5) * shape.sag * 0.06;
334
+ builder.box(thickness * 0.8, thickness * (variant === 'log' ? 1.4 : 0.8), span + thickness, {
335
+ position: [(a.x + b.x) / 2, (yA + yB) / 2 - bow, (a.z + b.z) / 2],
336
+ rotation: [-pitch, heading, 0],
337
+ shade: 0.92 + random() * 0.14,
338
+ slot: 1,
339
+ });
340
+ }
341
+ if (variant === 'picket') {
342
+ const slats = Math.max(Math.floor(span / 0.22), 2);
343
+ for (let slat = 0; slat < slats; slat += 1) {
344
+ const t = (slat + 0.5) / slats;
345
+ const roll = (random() - 0.5) * 0.05;
346
+ const shade = 0.94 + random() * 0.12;
347
+ if (!hi) continue; // values drawn either way — streams stay aligned
348
+ const x = a.x + (b.x - a.x) * t;
349
+ const z = a.z + (b.z - a.z) * t;
350
+ const y = a.y + (b.y - a.y) * t;
351
+ builder.box(0.1, shape.postHeight * 0.82, 0.02, {
352
+ position: [x, y + shape.postHeight * 0.5, z],
353
+ rotation: [0, heading, roll],
354
+ shade,
355
+ slot: 1,
356
+ });
357
+ }
358
+ }
359
+ }
360
+
361
+ // Collision: a chain of circles along the run so characters can't slip
362
+ // between posts.
363
+ const footprints = [];
364
+ for (let index = 0; index < points.length - 1; index += 1) {
365
+ const a = points[index];
366
+ const b = points[index + 1];
367
+ const span = Math.hypot(b.x - a.x, b.z - a.z);
368
+ const steps = Math.max(Math.round(span / 0.55), 1);
369
+ for (let step = 0; step <= steps; step += 1) {
370
+ const t = step / steps;
371
+ footprints.push({
372
+ radius: 0.2,
373
+ x: a.x + (b.x - a.x) * t,
374
+ z: a.z + (b.z - a.z) * t,
375
+ });
376
+ }
377
+ }
378
+ return { footprints, ...builder.build() };
379
+ }