@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,453 @@
1
+ // Seeded path networks. Import from '@call-me-sensei/toonlab' (root) or
2
+ // '@call-me-sensei/toonlab/pathgen'.
3
+ //
4
+ // The whole contract is the existing pure `heightAt(x, z)` + `waterLevel`:
5
+ // routes are cost-field routed around slopes, ribbons overlay the terrain
6
+ // (never modifying its mesh), bridges appear where water must be crossed,
7
+ // and the network exposes its own `heightAt` (flattened on-path, raw
8
+ // off-path) plus `maskAt` (0..1 on-path) so collision, the minimap, and the
9
+ // scatter systems all follow the same ground truth.
10
+ //
11
+ // const paths = createStylizedPaths({
12
+ // seed: 42,
13
+ // heightAt: terrain.heightAt,
14
+ // waterLevel: terrain.waterLevel,
15
+ // size: terrain.meshExtent,
16
+ // auto: { count: 4, styles: ['dirt', 'stone'] },
17
+ // });
18
+ // terrainRoot.add(paths.root); // before createStylizedWorld, or…
19
+ // await createStylizedWorld({ ..., paths }); // …one option wires it all
20
+ //
21
+ // Same seed → identical network, bridges, and mask, forever.
22
+
23
+ import * as THREE from 'three';
24
+
25
+ import {
26
+ addCenterlineWander,
27
+ connectPointsOfInterest,
28
+ createRoutingGrid,
29
+ markRouteUsed,
30
+ pickPointsOfInterest,
31
+ routeBetween,
32
+ smoothWaypoints,
33
+ } from './pathRouter.js';
34
+ import {
35
+ buildRibbonGeometry,
36
+ buildRouteProfile,
37
+ buildStairsGeometry,
38
+ mergePathGeometries,
39
+ } from './pathRibbon.js';
40
+ import { buildBridge } from './pathBridge.js';
41
+ import { getPathDetailTexture } from './pathTextures.js';
42
+ import {
43
+ PATH_RECIPE_SCHEMA,
44
+ PATH_RECIPE_VERSION,
45
+ createPathRecipeDocument,
46
+ createPathSettings,
47
+ normalizeRouteSpec,
48
+ validatePathRecipeDocument,
49
+ } from './pathSettings.js';
50
+
51
+ function mulberry32(seed) {
52
+ let state = (Math.trunc(seed) || 1) >>> 0;
53
+ return () => {
54
+ state = (state + 0x6d2b79f5) >>> 0;
55
+ let t = state;
56
+ t = Math.imul(t ^ (t >>> 15), t | 1);
57
+ t ^= t + Math.imul(t ^ (t >>> 7), t | 61);
58
+ return ((t ^ (t >>> 14)) >>> 0) / 4294967296;
59
+ };
60
+ }
61
+
62
+ const smoothstep = (edge0, edge1, value) => {
63
+ const t = Math.min(Math.max((value - edge0) / (edge1 - edge0), 0), 1);
64
+ return t * t * (3 - 2 * t);
65
+ };
66
+
67
+ /**
68
+ * Spatial index over route samples: heightAt/maskAt resolve in O(bucket).
69
+ * Segments are inserted into every cell their inflated AABB touches, so a
70
+ * query only ever reads the one cell containing the point.
71
+ */
72
+ function createRouteIndex(routes, { lift, blendMargin, maskMargin }) {
73
+ const reach = Math.max(blendMargin, maskMargin);
74
+ let cell = 8;
75
+ for (const route of routes) {
76
+ for (const point of route.samples) {
77
+ cell = Math.max(cell, (point.half + reach) * 1.05);
78
+ }
79
+ }
80
+ const buckets = new Map();
81
+ const keyOf = (ix, iz) => `${ix},${iz}`;
82
+ for (const route of routes) {
83
+ const { samples } = route;
84
+ for (let index = 0; index < samples.length - 1; index += 1) {
85
+ const a = samples[index];
86
+ const b = samples[index + 1];
87
+ const pad = Math.max(a.half, b.half) + reach;
88
+ const minX = Math.min(a.x, b.x) - pad;
89
+ const maxX = Math.max(a.x, b.x) + pad;
90
+ const minZ = Math.min(a.z, b.z) - pad;
91
+ const maxZ = Math.max(a.z, b.z) + pad;
92
+ const segment = { a, b };
93
+ for (let ix = Math.floor(minX / cell); ix <= Math.floor(maxX / cell); ix += 1) {
94
+ for (let iz = Math.floor(minZ / cell); iz <= Math.floor(maxZ / cell); iz += 1) {
95
+ const key = keyOf(ix, iz);
96
+ const bucket = buckets.get(key);
97
+ if (bucket) bucket.push(segment);
98
+ else buckets.set(key, [segment]);
99
+ }
100
+ }
101
+ }
102
+ }
103
+
104
+ return (x, z) => {
105
+ const bucket = buckets.get(keyOf(Math.floor(x / cell), Math.floor(z / cell)));
106
+ if (!bucket) return null;
107
+ let best = null;
108
+ for (const { a, b } of bucket) {
109
+ const abx = b.x - a.x;
110
+ const abz = b.z - a.z;
111
+ const lengthSq = abx * abx + abz * abz;
112
+ const t = lengthSq > 1e-9
113
+ ? Math.min(Math.max(((x - a.x) * abx + (z - a.z) * abz) / lengthSq, 0), 1)
114
+ : 0;
115
+ const cx = a.x + abx * t;
116
+ const cz = a.z + abz * t;
117
+ const across = Math.hypot(x - cx, z - cz);
118
+ const half = a.half + (b.half - a.half) * t;
119
+ const surface = a.profile + (b.profile - a.profile) * t + lift;
120
+ const rank = across - half; // signed distance to the ribbon edge
121
+ if (!best || rank < best.rank) best = { across, half, rank, surface };
122
+ }
123
+ return best;
124
+ };
125
+ }
126
+
127
+ /**
128
+ * Builds a seeded path network. See module header for the golden path.
129
+ *
130
+ * @param {Object} options
131
+ * @param {number} [options.seed]
132
+ * @param {Function} options.heightAt Pure `(x, z) => meters` terrain sampler.
133
+ * @param {number} [options.waterLevel]
134
+ * @param {number|{x,z}|{width,depth}} [options.size] Terrain extent in meters.
135
+ * @param {Array} [options.routes] Explicit `{ from: [x,z], to: [x,z], style }` specs.
136
+ * @param {Object} [options.auto] `{ count, styles }` — probe POIs and connect them.
137
+ * @param {Object} [options.settings] Partial PATH settings overrides.
138
+ * @returns {Object} `{ root, heightAt, maskAt, splines, routes, bridges,
139
+ * blockers, pointsOfInterest, recipe, stats, dispose }`
140
+ */
141
+ export function createStylizedPaths({
142
+ seed = 1,
143
+ heightAt,
144
+ waterLevel = 0,
145
+ size = 1000,
146
+ routes = null,
147
+ auto = null,
148
+ settings: settingsOverrides = {},
149
+ } = {}) {
150
+ if (typeof heightAt !== 'function') {
151
+ throw new Error('createStylizedPaths needs a heightAt(x, z) sampler.');
152
+ }
153
+ const settings = createPathSettings(settingsOverrides);
154
+ const routeSeed = Math.round(Number(seed) || 1);
155
+ const random = mulberry32(routeSeed * 48271 + 11);
156
+
157
+ const grid = createRoutingGrid({
158
+ gridStep: settings.routing.gridStep,
159
+ heightAt,
160
+ shoreMargin: settings.routing.shoreMargin,
161
+ size,
162
+ waterLevel,
163
+ });
164
+
165
+ // Resolve the route list: explicit specs, then auto network.
166
+ const specs = [];
167
+ if (Array.isArray(routes)) {
168
+ for (const route of routes) {
169
+ const spec = normalizeRouteSpec(route);
170
+ if (spec) specs.push(spec);
171
+ }
172
+ }
173
+ let pointsOfInterest = [];
174
+ if (auto && typeof auto === 'object') {
175
+ const count = Math.min(12, Math.max(2, Math.round(Number(auto.count) || settings.routing.pointCount)));
176
+ const styles = Array.isArray(auto.styles) && auto.styles.length > 0 ? auto.styles : ['dirt'];
177
+ pointsOfInterest = pickPointsOfInterest({
178
+ count,
179
+ heightAt,
180
+ seed: routeSeed,
181
+ shoreMargin: settings.routing.shoreMargin,
182
+ size,
183
+ waterLevel,
184
+ });
185
+ const edges = connectPointsOfInterest(pointsOfInterest, {
186
+ loopChance: settings.routing.loopChance,
187
+ seed: routeSeed,
188
+ });
189
+ for (const [fromIndex, toIndex] of edges) {
190
+ const from = pointsOfInterest[fromIndex];
191
+ const to = pointsOfInterest[toIndex];
192
+ specs.push({
193
+ from: [from.x, from.z],
194
+ style: styles[Math.floor(random() * styles.length) % styles.length],
195
+ to: [to.x, to.z],
196
+ });
197
+ }
198
+ }
199
+
200
+ // Route, smooth, profile. Explicit-points specs skip the router (and
201
+ // optionally the wander) — village streets are laid, not found.
202
+ const built = [];
203
+ for (const spec of specs) {
204
+ let waypoints;
205
+ if (spec.points) {
206
+ waypoints = spec.points.map(([x, z]) => ({ x, z }));
207
+ } else {
208
+ waypoints = routeBetween(grid, { x: spec.from[0], z: spec.from[1] }, { x: spec.to[0], z: spec.to[1] }, {
209
+ reuseBonus: settings.routing.reuseBonus,
210
+ slopeCost: settings.routing.slopeCost,
211
+ waterCost: settings.routing.waterCost,
212
+ });
213
+ }
214
+ if (!waypoints || waypoints.length < 2) continue;
215
+ markRouteUsed(grid, waypoints, 1);
216
+ const smoothed = spec.points && spec.wander === false
217
+ ? smoothWaypoints(waypoints, { iterations: 2 })
218
+ : addCenterlineWander(
219
+ smoothWaypoints(waypoints, { iterations: 2 }),
220
+ { amplitude: settings.ribbon.width * 0.5, seed: routeSeed + built.length * 17 },
221
+ );
222
+ const profile = buildRouteProfile({
223
+ heightAt,
224
+ points: smoothed,
225
+ seed: routeSeed + built.length * 131,
226
+ settings,
227
+ waterLevel,
228
+ });
229
+ if (!profile) continue;
230
+ built.push({ profile, spec });
231
+ }
232
+
233
+ // Meshes: one draw call per style, one per bridge, one for all stairs.
234
+ const root = new THREE.Group();
235
+ root.name = 'StylizedPaths';
236
+ const materials = [];
237
+ const byStyle = new Map();
238
+ const stairGeometries = [];
239
+ const bridgeMeshes = [];
240
+ const blockers = [];
241
+ const routeInfos = [];
242
+
243
+ for (let index = 0; index < built.length; index += 1) {
244
+ const { profile, spec } = built[index];
245
+ const ribbonGeometry = buildRibbonGeometry(profile, {
246
+ heightAt,
247
+ seed: routeSeed + index * 131,
248
+ settings,
249
+ style: spec.style,
250
+ });
251
+ if (ribbonGeometry) {
252
+ const bucket = byStyle.get(spec.style);
253
+ if (bucket) bucket.push(ribbonGeometry);
254
+ else byStyle.set(spec.style, [ribbonGeometry]);
255
+ }
256
+ const stairsGeometry = buildStairsGeometry(profile, {
257
+ seed: routeSeed + index * 131 + 7,
258
+ settings,
259
+ });
260
+ if (stairsGeometry) stairGeometries.push(stairsGeometry);
261
+
262
+ const routeBridges = [];
263
+ for (const crossing of profile.bridges) {
264
+ const result = buildBridge({
265
+ crossing,
266
+ heightAt,
267
+ samples: profile.samples,
268
+ seed: routeSeed + index * 131 + crossing.startIndex,
269
+ settings,
270
+ waterLevel,
271
+ });
272
+ routeBridges.push(result);
273
+ blockers.push(...result.blockers);
274
+ }
275
+
276
+ const spline = new THREE.CatmullRomCurve3(
277
+ profile.samples.map((point) => new THREE.Vector3(
278
+ point.x,
279
+ point.profile + settings.ribbon.lift,
280
+ point.z,
281
+ )),
282
+ false,
283
+ 'catmullrom',
284
+ 0.1,
285
+ );
286
+ routeInfos.push({
287
+ bridges: routeBridges,
288
+ samples: profile.samples,
289
+ spline,
290
+ stairRuns: profile.stairRuns,
291
+ style: spec.style,
292
+ });
293
+ }
294
+
295
+ for (const [style, geometries] of byStyle) {
296
+ const merged = geometries.length === 1 ? geometries[0] : mergePathGeometries(geometries);
297
+ if (geometries.length > 1) for (const geometry of geometries) geometry.dispose();
298
+ const material = new THREE.MeshStandardMaterial({
299
+ map: getPathDetailTexture(style, routeSeed),
300
+ metalness: 0,
301
+ roughness: 0.96,
302
+ vertexColors: true,
303
+ });
304
+ material.name = `Path ${style}`;
305
+ material.userData.envRole = 'standard';
306
+ materials.push(material);
307
+ const mesh = new THREE.Mesh(merged, material);
308
+ mesh.name = `PathRibbon-${style}`;
309
+ mesh.receiveShadow = true;
310
+ mesh.castShadow = false;
311
+ // Ground-hugging overlay: its contribution to the water grab/depth
312
+ // passes is invisible at gameplay angles, and those passes redraw
313
+ // everything (waterExclude is the AGENTS.md idiom for this).
314
+ mesh.userData.waterExclude = true;
315
+ root.add(mesh);
316
+ }
317
+
318
+ if (stairGeometries.length) {
319
+ const merged = stairGeometries.length === 1
320
+ ? stairGeometries[0]
321
+ : mergePathGeometries(stairGeometries);
322
+ if (stairGeometries.length > 1) for (const geometry of stairGeometries) geometry.dispose();
323
+ const material = new THREE.MeshStandardMaterial({
324
+ map: getPathDetailTexture('stone', routeSeed),
325
+ metalness: 0,
326
+ roughness: 0.95,
327
+ vertexColors: true,
328
+ });
329
+ material.name = 'Path stairs';
330
+ material.userData.envRole = 'standard';
331
+ materials.push(material);
332
+ const mesh = new THREE.Mesh(merged, material);
333
+ mesh.name = 'PathStairs';
334
+ mesh.castShadow = true;
335
+ mesh.receiveShadow = true;
336
+ mesh.userData.waterExclude = true;
337
+ root.add(mesh);
338
+ }
339
+
340
+ const bridgeGroups = [];
341
+ if (routeInfos.some((route) => route.bridges.length > 0)) {
342
+ const bridgeMaterial = new THREE.MeshStandardMaterial({
343
+ map: getPathDetailTexture('planks', routeSeed),
344
+ metalness: 0,
345
+ roughness: 0.9,
346
+ vertexColors: true,
347
+ });
348
+ bridgeMaterial.name = 'Path bridge wood';
349
+ bridgeMaterial.userData.envRole = 'standard';
350
+ materials.push(bridgeMaterial);
351
+ for (const route of routeInfos) {
352
+ for (const result of route.bridges) {
353
+ const mesh = new THREE.Mesh(result.geometry, bridgeMaterial);
354
+ mesh.name = 'PathBridge';
355
+ mesh.castShadow = true;
356
+ mesh.receiveShadow = true;
357
+ // Bridges keep their reflection (they stand IN the water) but skip
358
+ // the refraction grab — the visible half of the cost.
359
+ mesh.userData.waterGrabExclude = true;
360
+ const group = new THREE.Group();
361
+ group.name = 'PathBridgeGroup';
362
+ group.add(mesh);
363
+ root.add(group);
364
+ bridgeGroups.push(group);
365
+ bridgeMeshes.push(mesh);
366
+ }
367
+ }
368
+ }
369
+
370
+ // Shared ground truth: flattened height + on-path mask.
371
+ const queryIndex = createRouteIndex(routeInfos, {
372
+ blendMargin: settings.ribbon.edgeSkirt + 0.6,
373
+ lift: settings.ribbon.lift,
374
+ maskMargin: settings.ribbon.edgeFade,
375
+ });
376
+ const blend = settings.ribbon.edgeSkirt + 0.6;
377
+ const fade = settings.ribbon.edgeFade;
378
+
379
+ const pathHeightAt = (x, z) => {
380
+ const raw = Number(heightAt(x, z)) || 0;
381
+ const hit = queryIndex(x, z);
382
+ if (!hit) return raw;
383
+ const weight = 1 - smoothstep(hit.half * 0.55, hit.half + blend, hit.across);
384
+ if (weight <= 0) return raw;
385
+ return raw * (1 - weight) + hit.surface * weight;
386
+ };
387
+
388
+ const maskAt = (x, z) => {
389
+ const hit = queryIndex(x, z);
390
+ if (!hit) return 0;
391
+ return 1 - smoothstep(hit.half, hit.half + fade, hit.across);
392
+ };
393
+
394
+ const stats = {
395
+ bridgeCount: bridgeGroups.length,
396
+ routeCount: routeInfos.length,
397
+ triangles: [...byStyle.values()].flat().length === 0 ? 0 : undefined,
398
+ };
399
+ let triangles = 0;
400
+ root.traverse((object) => {
401
+ if (object.isMesh && object.geometry?.index) triangles += object.geometry.index.count / 3;
402
+ });
403
+ stats.triangles = Math.round(triangles);
404
+
405
+ let disposed = false;
406
+ return {
407
+ blockers,
408
+ bridges: bridgeGroups,
409
+ dispose() {
410
+ if (disposed) return;
411
+ disposed = true;
412
+ root.parent?.remove(root);
413
+ root.traverse((object) => {
414
+ if (object.isMesh) object.geometry?.dispose();
415
+ });
416
+ // Detail textures are cache-owned — dispose materials only.
417
+ for (const material of materials) material.dispose();
418
+ },
419
+ heightAt: pathHeightAt,
420
+ maskAt,
421
+ pointsOfInterest,
422
+ recipe: createPathRecipeDocument({
423
+ auto,
424
+ routes: Array.isArray(routes) ? routes : null,
425
+ seed: routeSeed,
426
+ settings,
427
+ }),
428
+ root,
429
+ routes: routeInfos,
430
+ get splines() { return routeInfos.map((route) => route.spline); },
431
+ stats,
432
+ };
433
+ }
434
+
435
+ /**
436
+ * Deterministic rebuild from a recipe document plus the host's terrain
437
+ * context (heightAt is the host's and is never serialized).
438
+ */
439
+ export function createStylizedPathsFromRecipe(recipe, { heightAt, waterLevel = 0, size = 1000 } = {}) {
440
+ const { ok, errors } = validatePathRecipeDocument(recipe);
441
+ if (!ok) throw new Error(`Invalid path recipe: ${errors.join(' ')}`);
442
+ return createStylizedPaths({
443
+ auto: recipe.auto ?? null,
444
+ heightAt,
445
+ routes: recipe.routes ?? null,
446
+ seed: recipe.seed,
447
+ settings: recipe.settings,
448
+ size,
449
+ waterLevel,
450
+ });
451
+ }
452
+
453
+ export { PATH_RECIPE_SCHEMA, PATH_RECIPE_VERSION };
package/src/post/index.js CHANGED
@@ -1,2 +1,3 @@
1
1
  // Post-processing barrel. Import from '@call-me-sensei/toonlab/post'.
2
2
  export * from './postProcessing.js';
3
+ export * from './postGenerator.js';
@@ -0,0 +1,177 @@
1
+ // Deterministic Post & Color style generation. Built-in families are useful
2
+ // starting domains, never a closed catalog: callers may replace/extend every
3
+ // domain leaf and register additional generator families.
4
+
5
+ import {
6
+ createGeneratorRecipeDocument,
7
+ deepMerge,
8
+ parseGeneratorRecipeDocument,
9
+ resolveGeneratorRecipe,
10
+ serializeGeneratorRecipeDocument,
11
+ validateGeneratorRecipeDocument,
12
+ } from '../core/generation.js';
13
+ import {
14
+ createPostProcessingPresetDocument,
15
+ createPostProcessingSettings,
16
+ sanitizePostProcessingPresetSettings,
17
+ } from './postProcessing.js';
18
+
19
+ export const POST_GENERATOR_DOMAIN = 'post-processing';
20
+
21
+ const range = (min, max, step = 0.01, extra = {}) => Object.freeze({
22
+ $type: 'range', max, min, step, ...extra,
23
+ });
24
+ const chance = (probability) => Object.freeze({ $type: 'boolean', probability });
25
+ const choice = (...options) => Object.freeze({ $type: 'choice', options });
26
+
27
+ export const DEFAULT_POST_GENERATOR_DOMAINS = Object.freeze({
28
+ features: Object.freeze({
29
+ bloom: chance(0.38),
30
+ colorGrade: chance(0.78),
31
+ depthCue: chance(0.45),
32
+ enabled: Object.freeze({ $type: 'constant', value: true }),
33
+ motionBlur: chance(0.18),
34
+ screenOutline: chance(0.34),
35
+ vignette: chance(0.72),
36
+ verticalGrade: chance(0.28),
37
+ }),
38
+ parameters: Object.freeze({
39
+ bloomBackgroundSuppress: range(0.35, 1, 0.01),
40
+ bloomCharacterBoost: range(0.9, 2.2, 0.01),
41
+ bloomLevels: range(3, 6, 1, { integer: true }),
42
+ bloomMode: choice({ value: 'single', weight: 3 }, { value: 'pyramid', weight: 2 }),
43
+ bloomRadius: range(0.04, 0.62, 0.01),
44
+ bloomStrength: range(0.03, 0.42, 0.01, { distribution: 'normal', mean: 0.16, deviation: 0.1 }),
45
+ bloomThreshold: range(0.72, 0.998, 0.001),
46
+ bottomDark: range(0, 0.24, 0.01),
47
+ contrast: range(0.88, 1.22, 0.01, { distribution: 'normal', mean: 1.04, deviation: 0.08 }),
48
+ depthCueColor: Object.freeze({ $type: 'color', from: [0.46, 0.58, 0.76], to: [0.76, 0.88, 1] }),
49
+ depthCueFar: range(18, 160, 1, { distribution: 'log' }),
50
+ depthCueNear: range(0.5, 8, 0.1),
51
+ depthCueStrength: range(0.02, 0.36, 0.01),
52
+ exposure: range(0.82, 1.24, 0.01, { distribution: 'normal', mean: 1.04, deviation: 0.09 }),
53
+ motionBlurStrength: range(0.08, 0.68, 0.01),
54
+ outlineColor: Object.freeze({ $type: 'color', from: [0.015, 0.02, 0.035], to: [0.14, 0.18, 0.28], linked: true }),
55
+ outlineDepthStrength: range(0.04, 0.72, 0.01),
56
+ outlineLumaStrength: range(0, 0.28, 0.01),
57
+ outlineStrength: range(0.08, 0.9, 0.01),
58
+ saturation: range(0.78, 1.38, 0.01, { distribution: 'normal', mean: 1.12, deviation: 0.14 }),
59
+ strength: range(0.55, 1, 0.01),
60
+ topLight: range(0, 0.18, 0.01),
61
+ vignetteRadius: range(0.55, 0.9, 0.01),
62
+ vignetteSoftness: range(0.2, 0.62, 0.01),
63
+ vignetteStrength: range(0.004, 0.16, 0.002, { distribution: 'normal', mean: 0.035, deviation: 0.03 }),
64
+ warmth: range(-0.18, 0.28, 0.01, { distribution: 'normal', mean: 0.04, deviation: 0.1 }),
65
+ }),
66
+ });
67
+
68
+ const FAMILY_DEFINITIONS = new Map([
69
+ ['balanced-anime', {
70
+ basePreset: 'softAnime',
71
+ description: 'Vivid but restrained game-ready grades with bounded screen-space effects.',
72
+ domains: DEFAULT_POST_GENERATOR_DOMAINS,
73
+ label: 'Balanced Anime',
74
+ }],
75
+ ['dreamy', {
76
+ basePreset: 'showcase',
77
+ configuration: {
78
+ features: { bloom: true, colorGrade: true, vignette: true },
79
+ parameters: { bloomCharacterBoost: 1.5, saturation: 1.12, warmth: 0.1 },
80
+ },
81
+ domains: deepMerge(DEFAULT_POST_GENERATOR_DOMAINS, {
82
+ parameters: {
83
+ bloomRadius: range(0.28, 0.7, 0.01),
84
+ bloomStrength: range(0.12, 0.48, 0.01),
85
+ contrast: range(0.86, 1.08, 0.01),
86
+ },
87
+ }),
88
+ label: 'Dreamy',
89
+ }],
90
+ ]);
91
+
92
+ export function registerPostGeneratorFamily(id, definition = {}, { overwrite = false } = {}) {
93
+ const key = String(id ?? '').trim();
94
+ if (!key) throw new Error('Post generator family id is required.');
95
+ if (!overwrite && FAMILY_DEFINITIONS.has(key)) throw new Error(`Post generator family "${key}" already exists.`);
96
+ FAMILY_DEFINITIONS.set(key, deepMerge(definition));
97
+ return key;
98
+ }
99
+
100
+ export function getPostGeneratorFamilyOptions() {
101
+ return [...FAMILY_DEFINITIONS.entries()].map(([id, definition]) => ({
102
+ description: definition.description ?? '', id, label: definition.label ?? id,
103
+ }));
104
+ }
105
+
106
+ export function createPostGeneratorRecipe(id = 'generated-post', definition = {}) {
107
+ const family = FAMILY_DEFINITIONS.get(definition.family ?? 'balanced-anime') ?? {};
108
+ return createGeneratorRecipeDocument(POST_GENERATOR_DOMAIN, id, {
109
+ basePreset: definition.basePreset ?? family.basePreset ?? 'softAnime',
110
+ configuration: deepMerge(family.configuration, definition.configuration),
111
+ description: definition.description ?? '',
112
+ domains: deepMerge(family.domains ?? DEFAULT_POST_GENERATOR_DOMAINS, definition.domains),
113
+ label: definition.label ?? id,
114
+ locks: definition.locks ?? [],
115
+ seed: definition.seed ?? 1,
116
+ });
117
+ }
118
+
119
+ export function validatePostGeneratorRecipe(input) {
120
+ return validateGeneratorRecipeDocument(input, { domain: POST_GENERATOR_DOMAIN });
121
+ }
122
+
123
+ export function parsePostGeneratorRecipe(input) {
124
+ return parseGeneratorRecipeDocument(input, { domain: POST_GENERATOR_DOMAIN });
125
+ }
126
+
127
+ export function serializePostGeneratorRecipe(idOrDocument, definition = {}, { pretty = true } = {}) {
128
+ return serializeGeneratorRecipeDocument(
129
+ POST_GENERATOR_DOMAIN,
130
+ idOrDocument,
131
+ definition,
132
+ { pretty },
133
+ );
134
+ }
135
+
136
+ function applyQualityBudget(settings, quality = 'balanced') {
137
+ const next = createPostProcessingSettings(settings);
138
+ if (quality === 'mobile') {
139
+ Object.assign(next.features, { depthCue: false, motionBlur: false, screenOutline: false });
140
+ next.parameters.bloomMode = 'single';
141
+ next.parameters.bloomLevels = Math.min(next.parameters.bloomLevels, 3);
142
+ } else if (quality === 'balanced') {
143
+ next.parameters.bloomLevels = Math.min(next.parameters.bloomLevels, 5);
144
+ // Keep at most two depth-consuming effects in the balanced tier.
145
+ const depthFeatures = ['motionBlur', 'screenOutline', 'depthCue'].filter((key) => next.features[key]);
146
+ for (const key of depthFeatures.slice(2)) next.features[key] = false;
147
+ }
148
+ return next;
149
+ }
150
+
151
+ /** Resolves a generator document into a flat runtime settings object. */
152
+ export function resolvePostGeneratorRecipe(recipeInput, { quality = 'balanced' } = {}) {
153
+ const recipe = recipeInput?.type
154
+ ? validatePostGeneratorRecipe(recipeInput).value
155
+ : createPostGeneratorRecipe('generated-post', recipeInput);
156
+ if (!recipe) throw new Error('Invalid post generator recipe.');
157
+ const base = createPostProcessingSettings({ preset: recipe.basePreset ?? 'softAnime' });
158
+ const settings = resolveGeneratorRecipe(recipe, {
159
+ baseSettings: base,
160
+ sanitizeSettings: createPostProcessingSettings,
161
+ });
162
+ return applyQualityBudget(settings, quality);
163
+ }
164
+
165
+ export function createGeneratedPostPresetDocument(recipeInput, {
166
+ id = recipeInput?.id ? `${recipeInput.id}-resolved` : 'generated-post-resolved',
167
+ label = recipeInput?.label ? `${recipeInput.label} · resolved` : 'Generated Post · resolved',
168
+ quality = 'balanced',
169
+ } = {}) {
170
+ const settings = resolvePostGeneratorRecipe(recipeInput, { quality });
171
+ return createPostProcessingPresetDocument(id, {
172
+ description: `Resolved from generator seed ${recipeInput?.seed ?? 1}.`,
173
+ label,
174
+ settings: sanitizePostProcessingPresetSettings(settings),
175
+ });
176
+ }
177
+