@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,40 @@
1
+ // Imported third-party assets → catalog entries. The catalog manifest froze
2
+ // kind 'imported-glb' for exactly this: the entry's recipe carries everything
3
+ // needed to re-download and re-shade the asset (source, id, urls, attribution),
4
+ // so a library-saved import browses next to procedural entries and survives
5
+ // reloads via the same IndexedDB user library.
6
+
7
+ import { createCatalogEntry } from '../catalog/manifest.js';
8
+ import { slugifyAssetId } from './assetRef.js';
9
+
10
+ export const IMPORTED_ENTRY_CLUSTER = 'assetlib';
11
+
12
+ /**
13
+ * Normalized ref (+ the resolved download) → validated catalog entry.
14
+ * For models pass `download` (resolvePolyhavenModelDownload result); for
15
+ * texture sets pass `textureSet` (resolvePolyhavenTextureDownload result).
16
+ */
17
+ export function importedAssetCatalogEntry(ref, { download = null, textureSet = null } = {}) {
18
+ if (!ref?.source || !ref?.id) throw new Error('importedAssetCatalogEntry: ref with source and id required.');
19
+ const recipe = {
20
+ assetId: ref.id,
21
+ attribution: { ...ref.attribution, authors: ref.authors ?? [] },
22
+ kind: ref.kind,
23
+ pageUrl: ref.pageUrl,
24
+ source: ref.source,
25
+ };
26
+ if (download) recipe.download = download;
27
+ if (textureSet) recipe.textureSet = textureSet;
28
+ const resolution = download?.resolution ?? download?.attribute ?? textureSet?.resolution ?? null;
29
+ return createCatalogEntry({
30
+ cluster: IMPORTED_ENTRY_CLUSTER,
31
+ description: `${ref.attribution.sourceLabel} · CC0${ref.authors?.length ? ` · by ${ref.authors.join(', ')}` : ''}${resolution ? ` · ${resolution}` : ''}`,
32
+ id: `imported/${ref.source}/${slugifyAssetId(ref.id)}`,
33
+ kind: 'imported-glb',
34
+ label: ref.name,
35
+ recipe,
36
+ spawn: "import { loadImportedAsset } from '@call-me-sensei/toonlab/assetlib'; const { object3D } = await loadImportedAsset(entry.recipe);",
37
+ tags: ['imported', ref.source, ref.kind, ...(ref.categories ?? [])],
38
+ thumbnail: ref.thumbnailUrl ?? null,
39
+ });
40
+ }
@@ -0,0 +1,23 @@
1
+ // Third-party CC0 asset integration barrel.
2
+ // Import from '@call-me-sensei/toonlab/assetlib'.
3
+ //
4
+ // Pure clients (browser + Node): assetRef, sources (the registry of every
5
+ // source incl. manual/link-out ones), polyhaven, ambientcg, polypizza,
6
+ // kaykit, opensource3d, smithsonian, importedEntry.
7
+ // Three-dependent loading: loadImported (browser labs, worlds).
8
+ // CORS notes per client header: KayKit / Open Source 3D / Smithsonian run in
9
+ // the browser directly; Poly Haven / ambientCG / Poly Pizza use the
10
+ // backend/dev proxy routes where required.
11
+
12
+ export * from './assetRef.js';
13
+ export * from './sources.js';
14
+ export * from './polyhaven.js';
15
+ export * from './ambientcg.js';
16
+ export * from './polypizza.js';
17
+ export * from './kaykit.js';
18
+ export * from './kaykitStaticIndex.js';
19
+ export * from './opensource3d.js';
20
+ export * from './smithsonian.js';
21
+ export * from './importedEntry.js';
22
+ export * from './loadImported.js';
23
+ export * from './zip.js';
@@ -0,0 +1,192 @@
1
+ // CC0 asset source: KayKit (kaylousberg.com) — Kay Lousberg's stylized
2
+ // low-poly game packs, published as version-frozen GitHub repos under
3
+ // KayKit-Game-Assets. Everything is CC0 (each repo bundles LICENSE.txt);
4
+ // attribution is optional and we keep it anyway (house style stores
5
+ // provenance). Great toon-shader fit: authored low-poly like Poly Pizza,
6
+ // but keyless.
7
+ //
8
+ // Files are raw-downloadable from raw.githubusercontent.com (CORS `*`, no
9
+ // rate limit), so models load in the browser with no proxy. LISTING is the
10
+ // only rate-limited part: the GitHub REST API allows 60 unauthenticated
11
+ // req/h — so the known packs ship as a static index (kaykitStaticIndex.js,
12
+ // zero network) and the git-trees API is a fallback for packs without one
13
+ // (in-memory cached, clear rate-limit error).
14
+ //
15
+ // Pack layouts (all under addons/<slug>/):
16
+ // "bits" packs Assets/gltf/<name>.gltf + <name>.bin + one shared
17
+ // <pack>_texture.png the glTFs reference by relative uri
18
+ // dungeon Assets/gltf/<name>.gltf.glb (single-file)
19
+ // character packs Characters/gltf/<Name>.glb (single-file)
20
+ // medieval-hexagon Assets/gltf/<theme>/<sub>/… (nested → trees API)
21
+
22
+ import { KAYKIT_STATIC_INDEX } from './kaykitStaticIndex.js';
23
+
24
+ export const KAYKIT_GITHUB_ORG = 'KayKit-Game-Assets';
25
+ export const KAYKIT_API_URL = 'https://api.github.com';
26
+ export const KAYKIT_RAW_URL = 'https://raw.githubusercontent.com';
27
+
28
+ export const KAYKIT_ATTRIBUTION = Object.freeze({
29
+ license: 'CC0',
30
+ sourceLabel: 'KayKit',
31
+ sourceUrl: 'https://kaylousberg.com',
32
+ // CC0 needs no credit; ship the ready-made line anyway (same idea as
33
+ // Poly Pizza's Attribution field).
34
+ text: 'Assets by Kay Lousberg — kaylousberg.com (attribution optional under CC0)',
35
+ });
36
+
37
+ /** Curated pack index — repo + layout facts per pack. Flip `enabled: false`
38
+ * to pull a whole pack out of listings with no code edit (same convention as
39
+ * public/props/cc0/manifest.json). `bundled` names that manifest's pack key
40
+ * for packs also shipped in this repo under public/props/cc0/<key>/;
41
+ * listing/downloads still use the canonical GitHub urls so recipes stay
42
+ * portable. */
43
+ export const KAYKIT_PACKS = Object.freeze([
44
+ { bundled: 'kaykit-city', category: 'city', enabled: true, gltfPath: 'Assets/gltf', id: 'city-builder-bits', name: 'City Builder Bits', repo: 'KayKit-City-Builder-Bits-1.0', slug: 'kaykit_city_builder_bits' },
45
+ { bundled: 'kaykit-furniture', category: 'furniture', enabled: true, gltfPath: 'Assets/gltf', id: 'furniture-bits', name: 'Furniture Bits', repo: 'KayKit-Furniture-Bits-1.0', slug: 'kaykit_furniture_bits' },
46
+ { bundled: null, category: 'restaurant', enabled: true, gltfPath: 'Assets/gltf', id: 'restaurant-bits', name: 'Restaurant Bits', repo: 'KayKit-Restaurant-Bits-1.0', slug: 'kaykit_restaurant_bits' },
47
+ { bundled: null, category: 'dungeon', enabled: true, gltfPath: 'Assets/gltf', id: 'dungeon-remastered', name: 'Dungeon Remastered', repo: 'KayKit-Dungeon-Remastered-1.0', slug: 'kaykit_dungeon_remastered' },
48
+ { bundled: null, category: 'medieval', enabled: true, gltfPath: 'Assets/gltf', id: 'medieval-hexagon', name: 'Medieval Hexagon Pack', repo: 'KayKit-Medieval-Hexagon-Pack-1.0', slug: 'kaykit_medieval_hexagon_pack' },
49
+ { bundled: null, category: 'halloween', enabled: true, gltfPath: 'Assets/gltf', id: 'halloween-bits', name: 'Halloween Bits', repo: 'KayKit-Halloween-Bits-1.0', slug: 'kaykit_halloween_bits' },
50
+ { bundled: null, category: 'space', enabled: true, gltfPath: 'Assets/gltf', id: 'space-base-bits', name: 'Space Base Bits', repo: 'KayKit-Space-Base-Bits-1.0', slug: 'kaykit_space_base_bits' },
51
+ { bundled: null, category: 'prototype', enabled: true, gltfPath: 'Assets/gltf', id: 'prototype-bits', name: 'Prototype Bits', repo: 'KayKit-Prototype-Bits-1.0', slug: 'kaykit_prototype_bits' },
52
+ { bundled: null, category: 'characters', enabled: true, gltfPath: 'Characters/gltf', id: 'character-adventurers', name: 'Adventurers Characters', repo: 'KayKit-Character-Pack-Adventures-1.0', slug: 'kaykit_character_pack_adventures' },
53
+ { bundled: null, category: 'characters', enabled: true, gltfPath: 'Characters/gltf', id: 'character-skeletons', name: 'Skeletons Characters', repo: 'KayKit-Character-Pack-Skeletons-1.0', slug: 'kaykit_character_pack_skeletons' },
54
+ ].map(Object.freeze));
55
+
56
+ export function getKayKitPack(packId) {
57
+ return KAYKIT_PACKS.find((pack) => pack.id === packId) ?? null;
58
+ }
59
+
60
+ export function kaykitRepoPageUrl(pack) {
61
+ return `https://github.com/${KAYKIT_GITHUB_ORG}/${pack.repo}`;
62
+ }
63
+
64
+ /** Raw url for one file inside a pack's gltf directory (relative paths from
65
+ * nested packs pass through unencoded-slash intact). */
66
+ export function kaykitRawFileUrl(pack, relativePath) {
67
+ const encoded = String(relativePath).split('/').map(encodeURIComponent).join('/');
68
+ return `${KAYKIT_RAW_URL}/${KAYKIT_GITHUB_ORG}/${pack.repo}/main/addons/${pack.slug}/${pack.gltfPath}/${encoded}`;
69
+ }
70
+
71
+ const MODEL_FILE = /\.(gltf|glb)$/i;
72
+
73
+ function modelBaseName(fileName) {
74
+ // "chair_A_wood.gltf" → "chair_A_wood"; dungeon's "banner_blue.gltf.glb"
75
+ // → "banner_blue"
76
+ return fileName.replace(/\.gltf\.glb$/i, '').replace(MODEL_FILE, '');
77
+ }
78
+
79
+ /**
80
+ * One model file inside a pack → loadable download:
81
+ * { format: 'gltf'|'glb', url, resources: { '<name>.bin': url, '<tex>.png': url } }
82
+ * Multi-file .gltf models reference their .bin + shared texture by relative
83
+ * uri in the SAME directory, so a plain fetch of the gltf url already works;
84
+ * the explicit `resources` map keeps recipes self-describing (and covers
85
+ * loaders that re-root relative uris), exactly like Poly Haven downloads.
86
+ */
87
+ export function resolveKayKitDownload(pack, relativePath, { texture = null } = {}) {
88
+ const url = kaykitRawFileUrl(pack, relativePath);
89
+ if (!relativePath.toLowerCase().endsWith('.gltf')) {
90
+ return { format: 'glb', resources: {}, url };
91
+ }
92
+ const bin = relativePath.replace(/\.gltf$/i, '.bin');
93
+ const directory = relativePath.includes('/') ? `${relativePath.slice(0, relativePath.lastIndexOf('/') + 1)}` : '';
94
+ const resources = { [bin]: kaykitRawFileUrl(pack, bin) };
95
+ if (texture) resources[`${directory}${texture}`] = kaykitRawFileUrl(pack, `${directory}${texture}`);
96
+ return { format: 'gltf', resources, url };
97
+ }
98
+
99
+ /**
100
+ * File list of one pack (relative paths under its gltf directory) → sorted
101
+ * normalized refs (kind is always 'model'; download embedded like
102
+ * Poly Pizza's). The shared texture png is detected, not listed.
103
+ */
104
+ export function normalizeKayKitFiles(files, pack) {
105
+ const names = (files ?? []).map(String);
106
+ // .gltf models reference ONE shared png per directory by relative uri
107
+ // (flat packs: <pack>_texture.png at the root; the nested hexagon pack
108
+ // keeps a hexagons_medieval.png copy per subdirectory). Map dir → png;
109
+ // .glb models embed their textures and ignore this.
110
+ const textureByDirectory = new Map();
111
+ for (const name of names) {
112
+ if (!/\.png$/i.test(name)) continue;
113
+ const directory = name.includes('/') ? name.slice(0, name.lastIndexOf('/') + 1) : '';
114
+ if (!textureByDirectory.has(directory)) textureByDirectory.set(directory, name.split('/').pop());
115
+ }
116
+ const refs = [];
117
+ for (const relativePath of names) {
118
+ if (!MODEL_FILE.test(relativePath)) continue;
119
+ const directory = relativePath.includes('/') ? relativePath.slice(0, relativePath.lastIndexOf('/') + 1) : '';
120
+ const texture = textureByDirectory.get(directory) ?? null;
121
+ const base = modelBaseName(relativePath.split('/').pop());
122
+ refs.push({
123
+ attribution: KAYKIT_ATTRIBUTION,
124
+ authors: ['Kay Lousberg'],
125
+ categories: [pack.category],
126
+ download: resolveKayKitDownload(pack, relativePath, { texture }),
127
+ id: `${pack.id}/${base}`,
128
+ kind: 'model',
129
+ name: base.replace(/_/g, ' '),
130
+ pack: pack.id,
131
+ pageUrl: kaykitRepoPageUrl(pack),
132
+ source: 'kaykit',
133
+ tags: [pack.category, ...base.toLowerCase().split(/[_\s]+/).filter(Boolean)],
134
+ // no per-model renders upstream — the pack cover is the honest preview
135
+ thumbnailUrl: `${KAYKIT_RAW_URL}/${KAYKIT_GITHUB_ORG}/${pack.repo}/main/icon.png`,
136
+ });
137
+ }
138
+ return refs.sort((a, b) => a.name.localeCompare(b.name));
139
+ }
140
+
141
+ const packCache = new Map();
142
+
143
+ /**
144
+ * Relative model/texture file paths of one pack. Static index first (zero
145
+ * network — the "-1.0" repos are version-frozen); otherwise ONE git-trees API
146
+ * call (cached per session) with a clear rate-limit message on 403/429.
147
+ */
148
+ export function fetchKayKitPackFiles(pack, { fetchImpl = fetch, headers = {} } = {}) {
149
+ const staticEntry = KAYKIT_STATIC_INDEX[pack.id];
150
+ if (staticEntry) {
151
+ return Promise.resolve(
152
+ staticEntry.texture ? [...staticEntry.models, staticEntry.texture] : [...staticEntry.models],
153
+ );
154
+ }
155
+ if (!packCache.has(pack.id)) {
156
+ const promise = (async () => {
157
+ const response = await fetchImpl(
158
+ `${KAYKIT_API_URL}/repos/${KAYKIT_GITHUB_ORG}/${pack.repo}/git/trees/main?recursive=1`,
159
+ { headers },
160
+ );
161
+ if (response.status === 403 || response.status === 429) {
162
+ throw new Error(`KayKit ${pack.id}: GitHub API rate limit hit (60 req/h unauthenticated) — the static packs still work; retry this one in an hour.`);
163
+ }
164
+ if (!response.ok) throw new Error(`KayKit ${pack.id}: GitHub trees API ${response.status}`);
165
+ const payload = await response.json();
166
+ const prefix = `addons/${pack.slug}/${pack.gltfPath}/`;
167
+ return (payload?.tree ?? [])
168
+ .filter((node) => node.type === 'blob' && node.path.startsWith(prefix))
169
+ .map((node) => node.path.slice(prefix.length));
170
+ })();
171
+ // a failed fetch must not poison the session cache
172
+ promise.catch(() => packCache.delete(pack.id));
173
+ packCache.set(pack.id, promise);
174
+ }
175
+ return packCache.get(pack.id);
176
+ }
177
+
178
+ /**
179
+ * Normalized refs across packs (packs flipped to `enabled: false` never
180
+ * list). Static packs always work; API-listed packs that fail (rate limit,
181
+ * offline) are skipped so one bad pack never blanks the browser — it throws
182
+ * only when EVERY pack failed.
183
+ */
184
+ export async function fetchKayKitIndex({ packs = KAYKIT_PACKS, fetchImpl = fetch, headers = {} } = {}) {
185
+ const enabledPacks = packs.filter((pack) => pack.enabled !== false);
186
+ const settled = await Promise.allSettled(
187
+ enabledPacks.map(async (pack) => normalizeKayKitFiles(await fetchKayKitPackFiles(pack, { fetchImpl, headers }), pack)),
188
+ );
189
+ const refs = settled.flatMap((result) => (result.status === 'fulfilled' ? result.value : []));
190
+ if (refs.length === 0 && settled[0]?.status === 'rejected') throw settled[0].reason;
191
+ return refs;
192
+ }