@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,214 @@
1
+ // CC0 asset source: Smithsonian 3D Open Access (3d.si.edu) — museum-grade
2
+ // scans published through the keyless Smithsonian 3D file API. This is not
3
+ // the broader api.si.edu Collections API (which uses an api.data.gov key).
4
+ // The 3D API states that every file in its response is part of Smithsonian
5
+ // Open Access; this client still accepts only the low Web3D GLB rows used by
6
+ // browsers and joins their separately indexed Image2D thumbnail rows. The
7
+ // API responds with CORS `*`, so browser and Node callers use it directly.
8
+
9
+ import { ASSETLIB_USER_AGENT } from './assetRef.js';
10
+
11
+ export const SMITHSONIAN_3D_API_URL = 'https://3d-api.si.edu/api/v1.0';
12
+
13
+ export const SMITHSONIAN_3D_ATTRIBUTION = Object.freeze({
14
+ license: 'CC0',
15
+ sourceLabel: 'Smithsonian 3D Open Access',
16
+ sourceUrl: 'https://3d.si.edu',
17
+ });
18
+
19
+ const ALLOWED_FILE_HOSTS = new Set(['3d-api.si.edu', 'cdn.3d-api.si.edu']);
20
+ const PAGE_SIZE = 1000;
21
+ const UUID_TITLE = /^[0-9a-f]{8}(?:-[0-9a-f]{4}){3}-[0-9a-f]{12}$/i;
22
+
23
+ function requestOptions(headers) {
24
+ const merged = new Headers(headers);
25
+ if (typeof window === 'undefined' && !merged.has('user-agent')) {
26
+ merged.set('user-agent', ASSETLIB_USER_AGENT);
27
+ }
28
+ return { headers: merged };
29
+ }
30
+
31
+ function packageId(content) {
32
+ return String(content?.model_url ?? '').replace(/^3d_package:/, '').trim();
33
+ }
34
+
35
+ function trustedFileUrl(value) {
36
+ const raw = String(value ?? '');
37
+ // Some legacy rows wrap the CDN URL inside a 3d-api.si.edu path.
38
+ const nestedIndex = raw.indexOf('https://', 'https://'.length);
39
+ const candidate = nestedIndex > 0 ? raw.slice(nestedIndex) : raw;
40
+ try {
41
+ const url = new URL(candidate);
42
+ return url.protocol === 'https:' && ALLOWED_FILE_HOSTS.has(url.hostname) ? url.toString() : null;
43
+ } catch {
44
+ return null;
45
+ }
46
+ }
47
+
48
+ function words(value) {
49
+ return [...new Set(String(value ?? '')
50
+ .toLowerCase()
51
+ .split(/[^a-z0-9]+/)
52
+ .filter((word) => word.length > 2))]
53
+ .slice(0, 20);
54
+ }
55
+
56
+ /** Official Image2D/Thumb rows keyed by the 3D package they illustrate. */
57
+ export function normalizeSmithsonianThumbnails(rows) {
58
+ const thumbnails = new Map();
59
+ for (const row of rows ?? []) {
60
+ const content = row?.content ?? {};
61
+ const id = packageId(content);
62
+ if (!id || thumbnails.has(id)) continue;
63
+ if (String(content.file_type ?? '').toLowerCase() !== 'jpg') continue;
64
+ if (String(content.quality ?? '').toLowerCase() !== 'thumb') continue;
65
+ if (String(content.usage ?? '').toLowerCase() !== 'image2d') continue;
66
+ const thumbnailUrl = trustedFileUrl(content.uri);
67
+ if (thumbnailUrl) thumbnails.set(id, thumbnailUrl);
68
+ }
69
+ return thumbnails;
70
+ }
71
+
72
+ /** Official low-quality Web3D GLB rows -> normalized asset refs. */
73
+ export function normalizeSmithsonianModels(rows, thumbnailByPackageId = new Map()) {
74
+ const refs = [];
75
+ const seen = new Set();
76
+ for (const row of rows ?? []) {
77
+ const content = row?.content ?? {};
78
+ const id = packageId(content);
79
+ if (!id || seen.has(id)) continue;
80
+ if (String(content.file_type ?? '').toLowerCase() !== 'glb') continue;
81
+ if (String(content.model_type ?? '').toLowerCase() !== 'glb') continue;
82
+ if (String(content.quality ?? '').toLowerCase() !== 'low') continue;
83
+ if (String(content.usage ?? '').toLowerCase() !== 'web3d') continue;
84
+ const modelUrl = trustedFileUrl(content.uri);
85
+ if (!modelUrl) continue;
86
+ seen.add(id);
87
+
88
+ const name = (String(row.title ?? id).trim().replace(/^:\s*/, '') || id);
89
+ const slug = name.toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/^-+|-+$/g, '') || 'object';
90
+ const owningUnit = String(content.owning_unit ?? row.owning_unit ?? '').trim().toLowerCase();
91
+ refs.push({
92
+ attribution: SMITHSONIAN_3D_ATTRIBUTION,
93
+ authors: [],
94
+ categories: ['smithsonian open access', owningUnit].filter(Boolean),
95
+ download: {
96
+ format: 'glb',
97
+ resources: {},
98
+ sizeBytes: Number(content.file_size) || 0,
99
+ url: modelUrl,
100
+ },
101
+ id,
102
+ kind: 'model',
103
+ name,
104
+ openAccess: true,
105
+ pageUrl: `https://3d.si.edu/object/3d/${encodeURIComponent(`${slug}:${id}`)}`,
106
+ source: 'smithsonian',
107
+ tags: words(name),
108
+ thumbnailUrl: thumbnailByPackageId.get(id) ?? null,
109
+ });
110
+ }
111
+ return refs;
112
+ }
113
+
114
+ /** Gallery cards need both visual and human metadata. The raw file index
115
+ * also contains unpublished/legacy packages whose only title is a UUID and
116
+ * which have no Image2D thumbnail; those remain valid downloadable refs but
117
+ * must not become blank public-gallery cards. */
118
+ export function isSmithsonianGalleryReady(ref) {
119
+ const name = String(ref?.name ?? '').trim();
120
+ return Boolean(ref?.thumbnailUrl)
121
+ && Boolean(name)
122
+ && name !== ref?.id
123
+ && !UUID_TITLE.test(name);
124
+ }
125
+
126
+ /** Fetch every page for one file type/quality pair. */
127
+ export async function fetchSmithsonianFileRows({
128
+ apiUrl = SMITHSONIAN_3D_API_URL,
129
+ fetchImpl = fetch,
130
+ fileType,
131
+ headers = {},
132
+ modelId = null,
133
+ quality,
134
+ pageSize = PAGE_SIZE,
135
+ } = {}) {
136
+ if (!fileType || !quality) throw new Error('Smithsonian 3D fileType and quality are required.');
137
+ const allRows = [];
138
+ let start = 0;
139
+ let rowCount = Number.POSITIVE_INFINITY;
140
+ while (start < rowCount) {
141
+ const params = new URLSearchParams({
142
+ file_quality: quality,
143
+ file_type: fileType,
144
+ rows: String(pageSize),
145
+ start: String(start),
146
+ });
147
+ if (modelId) params.set('model_url', String(modelId).replace(/^3d_package:/, ''));
148
+ const response = await fetchImpl(
149
+ `${apiUrl.replace(/\/$/, '')}/content/file/search?${params}`,
150
+ requestOptions(headers),
151
+ );
152
+ if (!response.ok) throw new Error(`Smithsonian 3D file search: ${response.status}`);
153
+ const payload = await response.json();
154
+ const page = Array.isArray(payload?.rows) ? payload.rows : [];
155
+ allRows.push(...page);
156
+ rowCount = Number(payload?.rowCount);
157
+ if (!Number.isFinite(rowCount)) rowCount = start + page.length;
158
+ if (page.length === 0) break;
159
+ start += page.length;
160
+ }
161
+ return allRows;
162
+ }
163
+
164
+ const indexCache = new Map();
165
+ const assetCache = new Map();
166
+
167
+ /** One package lookup for ToonLab's detail page. */
168
+ export function fetchSmithsonianAsset(id, {
169
+ apiUrl = SMITHSONIAN_3D_API_URL,
170
+ fetchImpl = fetch,
171
+ headers = {},
172
+ } = {}) {
173
+ const modelId = String(id ?? '').replace(/^3d_package:/, '').trim();
174
+ if (!modelId) return Promise.resolve(null);
175
+ const cacheKey = `${apiUrl}\n${modelId}`;
176
+ if (!assetCache.has(cacheKey)) {
177
+ const promise = (async () => {
178
+ const [modelRows, thumbnailRows] = await Promise.all([
179
+ fetchSmithsonianFileRows({ apiUrl, fetchImpl, fileType: 'glb', headers, modelId, quality: 'Low' }),
180
+ fetchSmithsonianFileRows({ apiUrl, fetchImpl, fileType: 'jpg', headers, modelId, quality: 'Thumb' }),
181
+ ]);
182
+ return normalizeSmithsonianModels(
183
+ modelRows,
184
+ normalizeSmithsonianThumbnails(thumbnailRows),
185
+ ).find((ref) => ref.id === modelId) ?? null;
186
+ })();
187
+ promise.catch(() => assetCache.delete(cacheKey));
188
+ assetCache.set(cacheKey, promise);
189
+ }
190
+ return assetCache.get(cacheKey);
191
+ }
192
+
193
+ /** Full browser-ready Smithsonian CC0 3D index, cached per API endpoint. */
194
+ export function fetchSmithsonianIndex({
195
+ apiUrl = SMITHSONIAN_3D_API_URL,
196
+ fetchImpl = fetch,
197
+ headers = {},
198
+ } = {}) {
199
+ if (!indexCache.has(apiUrl)) {
200
+ const promise = (async () => {
201
+ const [modelRows, thumbnailRows] = await Promise.all([
202
+ fetchSmithsonianFileRows({ apiUrl, fetchImpl, fileType: 'glb', headers, quality: 'Low' }),
203
+ fetchSmithsonianFileRows({ apiUrl, fetchImpl, fileType: 'jpg', headers, quality: 'Thumb' }),
204
+ ]);
205
+ return normalizeSmithsonianModels(
206
+ modelRows,
207
+ normalizeSmithsonianThumbnails(thumbnailRows),
208
+ ).sort((a, b) => a.name.localeCompare(b.name));
209
+ })();
210
+ promise.catch(() => indexCache.delete(apiUrl));
211
+ indexCache.set(apiUrl, promise);
212
+ }
213
+ return indexCache.get(apiUrl);
214
+ }
@@ -0,0 +1,279 @@
1
+ // Source registry — the one list of every asset source the lab knows about,
2
+ // integrated or not. Honest capability levels instead of fake adapters:
3
+ //
4
+ // api a client module in this folder lists/downloads programmatically
5
+ // manual CC0 downloads exist but there is no public file API — the lab
6
+ // links out and the user imports the downloaded file/zip
7
+ // linkout browsable site we deliberately do NOT automate (OAuth-only
8
+ // APIs, or platform terms that forbid automated downloads)
9
+ // reference adjacent media (images/audio), listed for completeness only
10
+ //
11
+ // SAFEGUARD (product decision 2026-07-16): CC0 clears copyright but NOT
12
+ // trademarks, personality rights, or logos visible in scans — so moderation
13
+ // is a config flip, not a code edit, at both levels:
14
+ // source `enabled: false` hides the whole catalog (listAssetSources; same
15
+ // flag name as public/props/cc0/manifest.json uses
16
+ // for bundled packs)
17
+ // ref `disabled: true` hides one asset (filterAssetRefs)
18
+ // and every ref keeps `source` + attribution provenance even for all-CC0
19
+ // providers, so anything surfaced is always traceable.
20
+ //
21
+ // CURATION POLICY (product call 2026-07-16): the bar is Genshin-Impact-level
22
+ // polish — flooding the platform with low-quality assets is worse than fewer
23
+ // sources, and blocky/chibi low-poly does NOT qualify (KayKit was reviewed
24
+ // in real scenes and rejected). Hence:
25
+ // qualityTier 'reference' (Poly Haven-tier scans/PBR) | 'stylized'
26
+ // (owner-approved stylization compatible with a polished
27
+ // anime open-world look — currently NONE hold it) |
28
+ // 'unreviewed' (ships enabled: false until an owner pass)
29
+ // curated optional explicit include list of ref ids — when a source's
30
+ // catalog is mixed-quality, list the keepers here instead of
31
+ // enabling the whole firehose (curateAssetRefs applies it)
32
+
33
+ export const ASSET_SOURCE_INTEGRATIONS = Object.freeze(['api', 'manual', 'linkout', 'reference']);
34
+ export const ASSET_SOURCE_QUALITY_TIERS = Object.freeze(['reference', 'stylized', 'unreviewed']);
35
+
36
+ export const ASSET_SOURCES = Object.freeze([
37
+ {
38
+ enabled: true,
39
+ goodFor: 'photoscanned props, PBR texture sets, HDRIs',
40
+ id: 'polyhaven',
41
+ integration: 'api',
42
+ keyed: false,
43
+ kinds: ['model', 'texture', 'hdri'],
44
+ label: 'Poly Haven',
45
+ license: 'CC0',
46
+ notes: 'The current API page permits any use; live calls require visible Poly Haven credit and an identifying User-Agent — see polyhaven.js.',
47
+ qualityTier: 'reference',
48
+ url: 'https://polyhaven.com',
49
+ },
50
+ {
51
+ enabled: true,
52
+ goodFor: 'PBR material sets (bricks, wood, ground …)',
53
+ id: 'ambientcg',
54
+ integration: 'api',
55
+ keyed: false,
56
+ kinds: ['texture', 'hdri', 'model'],
57
+ label: 'ambientCG',
58
+ license: 'CC0',
59
+ notes: 'Keyless API, zip downloads via the backend/dev-proxy route — see ambientcg.js.',
60
+ qualityTier: 'reference',
61
+ url: 'https://ambientcg.com',
62
+ },
63
+ {
64
+ // Community corpus is mixed-quality → unreviewed under the 2026-07-16
65
+ // curation policy despite shipping earlier; re-enable (or add a
66
+ // `curated` list) after an owner pass in the asset-lab.
67
+ enabled: false,
68
+ goodFor: 'authored low-poly models — the best toon-shader fit',
69
+ id: 'polypizza',
70
+ integration: 'api',
71
+ key: { env: 'TOONLAB_POLYPIZZA_KEY', url: 'https://poly.pizza/settings/api' },
72
+ keyed: true,
73
+ kinds: ['model'],
74
+ label: 'Poly Pizza',
75
+ license: 'CC0 + CC-BY',
76
+ notes: 'BYO free key; corpus is CC0 or CC-BY — searchPolyPizza filters to CC0 by default. Hobby free, commercial pay-as-you-go.',
77
+ qualityTier: 'unreviewed',
78
+ url: 'https://poly.pizza',
79
+ },
80
+ {
81
+ // Owner-reviewed in real scenes 2026-07-16 and rejected for the platform
82
+ // bar (blocky low-poly ≠ Genshin-level polish). Adapter stays — this
83
+ // flag is exactly how that call ships cheaply.
84
+ enabled: false,
85
+ goodFor: 'stylized low-poly packs: city, furniture, dungeon, characters',
86
+ id: 'kaykit',
87
+ integration: 'api',
88
+ keyed: false,
89
+ kinds: ['model'],
90
+ label: 'KayKit',
91
+ license: 'CC0',
92
+ notes: 'Kay Lousberg’s GitHub packs, raw-downloadable; static pack index avoids the 60 req/h unauthenticated API limit — see kaykit.js.',
93
+ qualityTier: 'unreviewed',
94
+ url: 'https://kaylousberg.com',
95
+ },
96
+ {
97
+ // Disabled pending quality review (product call 2026-07-16: collection
98
+ // may be too low quality for us). The adapter works — flip this, or use
99
+ // the lab's local "enable for evaluation" override, to browse it.
100
+ enabled: false,
101
+ goodFor: 'environment sets (park, museum, sci-fi, vaporwave) + creatures',
102
+ id: 'opensource3d',
103
+ integration: 'api',
104
+ keyed: false,
105
+ kinds: ['model'],
106
+ label: 'Open Source 3D Assets',
107
+ license: 'CC0',
108
+ notes: 'Keyless JSON registry with direct GLB urls; per-collection licenses filtered to exactly CC0 — see opensource3d.js.',
109
+ qualityTier: 'unreviewed',
110
+ url: 'https://opensource3dassets.com',
111
+ },
112
+ {
113
+ enabled: false,
114
+ goodFor: 'museum-grade scans (fossils, sculpture, artifacts)',
115
+ id: 'smithsonian',
116
+ integration: 'api',
117
+ keyed: false,
118
+ kinds: ['model'],
119
+ label: 'Smithsonian 3D Open Access',
120
+ license: 'CC0',
121
+ notes: 'Keyless 3d-api.si.edu file API with direct browser-ready GLBs; every returned file is published through Smithsonian Open Access — see smithsonian.js.',
122
+ qualityTier: 'unreviewed',
123
+ url: 'https://3d.si.edu',
124
+ },
125
+ {
126
+ enabled: false,
127
+ goodFor: 'huge CC0 subset of community models',
128
+ id: 'sketchfab',
129
+ integration: 'linkout',
130
+ keyed: true,
131
+ kinds: ['model'],
132
+ label: 'Sketchfab',
133
+ license: 'CC0 (filtered search)',
134
+ notes: 'Download API needs per-user OAuth (not a server key), and developer terms require Sketchfab branding + creator-attribution UI in the app — deliberately NOT automated this pass. Browse the CC0 search, download, then import the file below.',
135
+ qualityTier: 'unreviewed',
136
+ restrictions: 'Do not wire a server-key download proxy: their API is OAuth-per-user and the developer terms impose branding/attribution UI requirements.',
137
+ url: 'https://sketchfab.com/search?features=downloadable&licenses=cc0&type=models',
138
+ },
139
+ {
140
+ enabled: false,
141
+ goodFor: 'game-jam-ready 2D/3D/audio packs, consistent style',
142
+ id: 'kenney',
143
+ integration: 'manual',
144
+ keyed: false,
145
+ kinds: ['model', 'texture'],
146
+ label: 'Kenney',
147
+ license: 'CC0',
148
+ notes: 'No public file API (downloads sit behind the site button). Download a pack, then import the zip/glb below.',
149
+ qualityTier: 'unreviewed',
150
+ url: 'https://kenney.nl/assets',
151
+ },
152
+ {
153
+ enabled: false,
154
+ goodFor: 'low-poly animals, characters, environments (also on Poly Pizza)',
155
+ id: 'quaternius',
156
+ integration: 'manual',
157
+ keyed: false,
158
+ kinds: ['model'],
159
+ label: 'Quaternius',
160
+ license: 'CC0',
161
+ notes: 'No file API (packs are drive links). Most of the catalog is searchable through the Poly Pizza source; otherwise download and import below.',
162
+ qualityTier: 'unreviewed',
163
+ url: 'https://quaternius.com',
164
+ },
165
+ {
166
+ enabled: false,
167
+ goodFor: '1250+ clean base meshes (props, anatomy, primitives)',
168
+ id: 'basemesh',
169
+ integration: 'manual',
170
+ keyed: false,
171
+ kinds: ['model'],
172
+ label: 'The Base Mesh',
173
+ license: 'CC0',
174
+ notes: 'No documented API. Download a mesh, then import the file below.',
175
+ qualityTier: 'unreviewed',
176
+ url: 'https://thebasemesh.com',
177
+ },
178
+ {
179
+ enabled: false,
180
+ goodFor: 'stylized/painterly PBR materials',
181
+ id: 'threedtextures',
182
+ integration: 'manual',
183
+ keyed: false,
184
+ kinds: ['texture'],
185
+ label: '3DTextures.me',
186
+ license: 'CC0',
187
+ notes: 'All CC0 and the license explicitly permits redistribution, but downloads are per-page drive links (no API). A curated mirrored set is a future candidate; for now download and import below.',
188
+ qualityTier: 'unreviewed',
189
+ url: 'https://3dtextures.me',
190
+ },
191
+ {
192
+ enabled: false,
193
+ goodFor: 'PBR textures with per-map downloads',
194
+ id: 'texturecan',
195
+ integration: 'manual',
196
+ keyed: false,
197
+ kinds: ['texture'],
198
+ label: 'TextureCan',
199
+ license: 'CC0',
200
+ notes: 'CC0 per their terms; no API. Download, then import below.',
201
+ qualityTier: 'unreviewed',
202
+ url: 'https://www.texturecan.com',
203
+ },
204
+ {
205
+ enabled: false,
206
+ goodFor: 'PBR materials (fabric, wood, wall …)',
207
+ id: 'sharetextures',
208
+ integration: 'linkout',
209
+ keyed: false,
210
+ kinds: ['texture'],
211
+ label: 'ShareTextures',
212
+ license: 'CC0 (files only)',
213
+ notes: 'Files are CC0, but the PLATFORM terms prohibit automated downloads, API access, hotlinking, and embedding downloads in third-party tools. Browse manually only.',
214
+ qualityTier: 'unreviewed',
215
+ restrictions: 'Never automate: their terms prohibit automated downloads, API access, hotlinking, and embedding downloads in third-party tools — do not build an adapter or proxy for this source.',
216
+ url: 'https://www.sharetextures.com',
217
+ },
218
+ {
219
+ enabled: false,
220
+ goodFor: 'openly licensed IMAGES (reference, decals, ui) — not 3D',
221
+ id: 'openverse',
222
+ integration: 'reference',
223
+ keyed: false,
224
+ kinds: [],
225
+ label: 'Openverse',
226
+ license: 'CC0/CC-BY (per-record)',
227
+ notes: 'WordPress-run aggregator with a real keyless API (api.openverse.org) over 800M+ images/audio; out of scope for the 3D browser, listed for future image needs.',
228
+ qualityTier: 'unreviewed',
229
+ url: 'https://openverse.org',
230
+ },
231
+ {
232
+ enabled: false,
233
+ goodFor: 'sound effects — pairs with the soundscape cluster someday',
234
+ id: 'freesound',
235
+ integration: 'reference',
236
+ keyed: true,
237
+ kinds: [],
238
+ label: 'Freesound',
239
+ license: 'CC0/CC-BY (per-record)',
240
+ notes: 'Audio, not 3D. Has a real API, but the free API tier is NON-COMMERCIAL only — a commercial deployment needs their commercial terms.',
241
+ qualityTier: 'unreviewed',
242
+ url: 'https://freesound.org',
243
+ },
244
+ ].map(Object.freeze));
245
+
246
+ /** Registry lookup by source id ('polyhaven', 'kaykit', …). */
247
+ export function getAssetSource(id) {
248
+ return ASSET_SOURCES.find((source) => source.id === id) ?? null;
249
+ }
250
+
251
+ /**
252
+ * Apply a source's optional curated include list: with `curated` set, only
253
+ * listed ref ids survive (curation-first over volume — see header). Sources
254
+ * without a list pass through untouched.
255
+ */
256
+ export function curateAssetRefs(refs, source) {
257
+ const curated = source?.curated;
258
+ if (!Array.isArray(curated) || curated.length === 0) return refs ?? [];
259
+ const keep = new Set(curated);
260
+ return (refs ?? []).filter((ref) => keep.has(ref.id));
261
+ }
262
+
263
+ /**
264
+ * Registry query: by integration level ('api' | 'manual' | 'linkout' |
265
+ * 'reference' | array of those) and/or asset kind. Sources flipped to
266
+ * `enabled: false` are hidden unless `includeDisabled` (moderation surfaces
267
+ * pass true).
268
+ */
269
+ export function listAssetSources({ integration = null, kind = null, includeDisabled = false } = {}) {
270
+ const integrations = integration == null
271
+ ? null
272
+ : (Array.isArray(integration) ? integration : [integration]);
273
+ return ASSET_SOURCES.filter((source) => {
274
+ if (source.enabled === false && !includeDisabled) return false;
275
+ if (integrations && !integrations.includes(source.integration)) return false;
276
+ if (kind && !source.kinds.includes(kind)) return false;
277
+ return true;
278
+ });
279
+ }
@@ -0,0 +1,58 @@
1
+ // Minimal ZIP reader (STORE + DEFLATE) — enough for ambientCG's archives.
2
+ // Runs in the browser AND Node ≥18 (DecompressionStream/Blob/Response are
3
+ // global in both). Hand-rolled on purpose: the repo ships zero runtime
4
+ // dependencies (texture-lab's zip WRITER set the precedent).
5
+
6
+ async function inflateRaw(bytes) {
7
+ const stream = new Blob([bytes]).stream().pipeThrough(new DecompressionStream('deflate-raw'));
8
+ return new Uint8Array(await new Response(stream).arrayBuffer());
9
+ }
10
+
11
+ /**
12
+ * ArrayBuffer/Uint8Array of a .zip → [{ name, data: Uint8Array }] for every
13
+ * file entry (directories skipped), via the central directory.
14
+ */
15
+ export async function readZipEntries(input) {
16
+ const bytes = input instanceof Uint8Array ? input : new Uint8Array(input);
17
+ const view = new DataView(bytes.buffer, bytes.byteOffset, bytes.byteLength);
18
+
19
+ let eocd = -1;
20
+ for (let index = bytes.byteLength - 22; index >= 0; index -= 1) {
21
+ if (view.getUint32(index, true) === 0x06054b50) {
22
+ eocd = index;
23
+ break;
24
+ }
25
+ }
26
+ if (eocd === -1) throw new Error('readZipEntries: no end-of-central-directory (not a ZIP?).');
27
+
28
+ const count = view.getUint16(eocd + 10, true);
29
+ let offset = view.getUint32(eocd + 16, true);
30
+ const entries = [];
31
+ const decoder = new TextDecoder();
32
+
33
+ for (let index = 0; index < count; index += 1) {
34
+ if (view.getUint32(offset, true) !== 0x02014b50) break;
35
+ const method = view.getUint16(offset + 10, true);
36
+ const compressedSize = view.getUint32(offset + 20, true);
37
+ const nameLength = view.getUint16(offset + 28, true);
38
+ const extraLength = view.getUint16(offset + 30, true);
39
+ const commentLength = view.getUint16(offset + 32, true);
40
+ const localOffset = view.getUint32(offset + 42, true);
41
+ const name = decoder.decode(bytes.subarray(offset + 46, offset + 46 + nameLength));
42
+
43
+ // local header repeats name/extra with potentially different lengths
44
+ const localNameLength = view.getUint16(localOffset + 26, true);
45
+ const localExtraLength = view.getUint16(localOffset + 28, true);
46
+ const dataStart = localOffset + 30 + localNameLength + localExtraLength;
47
+ const raw = bytes.subarray(dataStart, dataStart + compressedSize);
48
+
49
+ if (!name.endsWith('/')) {
50
+ if (method !== 0 && method !== 8) {
51
+ throw new Error(`readZipEntries: unsupported compression method ${method} for "${name}".`);
52
+ }
53
+ entries.push({ data: method === 8 ? await inflateRaw(raw) : raw.slice(), name });
54
+ }
55
+ offset += 46 + nameLength + extraLength + commentLength;
56
+ }
57
+ return entries;
58
+ }