@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.
- package/AGENTS.md +127 -4
- package/ATTRIBUTION.md +41 -3
- package/README.md +234 -34
- package/docs/characters.md +144 -0
- package/docs/debug-panel.md +126 -0
- package/docs/docs.css +589 -0
- package/docs/environment.md +186 -0
- package/docs/getting-started.md +180 -0
- package/docs/index.html +14 -0
- package/docs/lab-architecture.md +100 -0
- package/docs/lighting.md +825 -0
- package/docs/main.jsx +739 -0
- package/docs/mcp.md +97 -0
- package/docs/post-processing.md +98 -0
- package/docs/settings-reference.md +1878 -0
- package/docs/shader-constants.md +77 -0
- package/docs/sky.md +182 -0
- package/docs/style-labs.md +309 -0
- package/docs/texture-lab.md +135 -0
- package/docs/toon-shading.md +183 -0
- package/docs/tsl-conventions.md +167 -0
- package/docs/vegetation-sky.md +275 -0
- package/docs/water.md +430 -0
- package/docs/weather.md +200 -0
- package/docs/world-scale.md +111 -0
- package/mcp/server.mjs +610 -0
- package/mcp/style-lab-tools.mjs +371 -0
- package/mcp/vite-plugin.mjs +174 -0
- package/mcp/workspace.mjs +397 -0
- package/package.json +64 -5
- package/src/ambientfx/INTEGRATION.md +164 -0
- package/src/ambientfx/ambientFxPresets.js +83 -0
- package/src/ambientfx/ambientFxSettings.js +368 -0
- package/src/ambientfx/emitters.js +169 -0
- package/src/ambientfx/index.js +5 -0
- package/src/ambientfx/particleBackbone.js +493 -0
- package/src/ambientfx/stylizedAmbientFx.js +450 -0
- package/src/assetlib/ambientcg.js +117 -0
- package/src/assetlib/assetRef.js +91 -0
- package/src/assetlib/importedEntry.js +40 -0
- package/src/assetlib/index.js +23 -0
- package/src/assetlib/kaykit.js +192 -0
- package/src/assetlib/kaykitStaticIndex.js +700 -0
- package/src/assetlib/loadImported.js +143 -0
- package/src/assetlib/opensource3d.js +113 -0
- package/src/assetlib/polyhaven.js +182 -0
- package/src/assetlib/polypizza.js +115 -0
- package/src/assetlib/smithsonian.js +214 -0
- package/src/assetlib/sources.js +279 -0
- package/src/assetlib/zip.js +58 -0
- package/src/biome/biomeGenerator.js +385 -0
- package/src/biome/biomeRuntime.js +299 -0
- package/src/biome/index.js +2 -0
- package/src/buildinggen/buildingAsset.js +44 -0
- package/src/buildinggen/buildingGrammar.js +311 -0
- package/src/buildinggen/buildingMesh.js +451 -0
- package/src/buildinggen/buildingPresets.js +46 -0
- package/src/buildinggen/buildingRecipe.js +100 -0
- package/src/buildinggen/buildingSettings.js +238 -0
- package/src/buildinggen/index.js +6 -0
- package/src/camera/cameraDirector.js +157 -0
- package/src/camera/cameraGenerator.js +367 -0
- package/src/camera/cameraRig.js +570 -0
- package/src/camera/cameraSettings.js +236 -0
- package/src/camera/index.js +7 -0
- package/src/catalog/builtinEntries.js +245 -0
- package/src/catalog/catalog.js +210 -0
- package/src/catalog/index.js +3 -0
- package/src/catalog/manifest.js +84 -0
- package/src/core/generation.js +529 -0
- package/src/environment/environmentRigs.js +21 -1
- package/src/environment/environmentSettings.js +4 -0
- package/src/environment/environmentSunShadowPass.js +8 -0
- package/src/fauna/INTEGRATION.md +174 -0
- package/src/fauna/boids.js +861 -0
- package/src/fauna/faunaBodies.js +492 -0
- package/src/fauna/faunaPresets.js +52 -0
- package/src/fauna/faunaSettings.js +525 -0
- package/src/fauna/index.js +5 -0
- package/src/fauna/stylizedFauna.js +395 -0
- package/src/game-feel/gameFeelGenerator.js +402 -0
- package/src/game-feel/gameFeelRuntime.js +549 -0
- package/src/game-feel/index.js +2 -0
- package/src/index.js +17 -6
- package/src/lighting/colorIntensity.js +177 -0
- package/src/lighting/index.js +161 -0
- package/src/lighting/lightDescriptors.js +249 -0
- package/src/lighting/lightingCapabilities.js +79 -0
- package/src/lighting/lightingDocuments.js +247 -0
- package/src/lighting/lightingFixtures.js +446 -0
- package/src/lighting/lightingGenerator.js +449 -0
- package/src/lighting/lightingPresets.js +319 -0
- package/src/lighting/lightingRuntime.js +723 -0
- package/src/lighting/lightingStyle.js +386 -0
- package/src/lighting/lightingSystem.js +774 -0
- package/src/lighting/unrealExport.js +186 -0
- package/src/lighting/utils.js +87 -0
- package/src/motion/index.js +5 -0
- package/src/motion/motionClip.js +441 -0
- package/src/motion/motionController.js +628 -0
- package/src/motion/motionDocuments.js +225 -0
- package/src/motion/motionGraph.js +307 -0
- package/src/motion/motionSettings.js +222 -0
- package/src/pathgen/index.js +7 -0
- package/src/pathgen/pathBridge.js +232 -0
- package/src/pathgen/pathPresets.js +35 -0
- package/src/pathgen/pathRibbon.js +410 -0
- package/src/pathgen/pathRouter.js +380 -0
- package/src/pathgen/pathSettings.js +335 -0
- package/src/pathgen/pathTextures.js +123 -0
- package/src/pathgen/stylizedPaths.js +453 -0
- package/src/post/index.js +1 -0
- package/src/post/postGenerator.js +177 -0
- package/src/post/postProcessing.js +41 -0
- package/src/propgen/generatorsWave1.js +379 -0
- package/src/propgen/generatorsWave2.js +462 -0
- package/src/propgen/index.js +5 -0
- package/src/propgen/propAsset.js +323 -0
- package/src/propgen/propParts.js +170 -0
- package/src/propgen/propPlacement.js +459 -0
- package/src/propgen/propPresets.js +82 -0
- package/src/propgen/propSettings.js +395 -0
- package/src/shaders-tsl/chunks/projected-water-caustics.js +242 -0
- package/src/shaders-tsl/chunks/vegetation-style.js +360 -0
- package/src/shaders-tsl/chunks/water-shore-state.js +31 -0
- package/src/shaders-tsl/chunks/water-waves.js +90 -11
- package/src/shaders-tsl/environment.js +15 -1
- package/src/shaders-tsl/flower.js +279 -30
- package/src/shaders-tsl/grass.js +60 -33
- package/src/shaders-tsl/sky.js +125 -31
- package/src/shaders-tsl/tree-leaf.js +61 -25
- package/src/shaders-tsl/water-breaker.js +7 -4
- package/src/shaders-tsl/water-shore-state-simulation.js +523 -0
- package/src/shaders-tsl/water.js +439 -49
- package/src/shaders-tsl/woody-surface.js +154 -0
- package/src/sky/sceneOverrideLayers.js +10 -0
- package/src/sky/skyQuality.js +26 -0
- package/src/sky/stylizedSky.js +753 -45
- package/src/soundscape/index.js +4 -0
- package/src/soundscape/soundscapeGenerator.js +179 -0
- package/src/soundscape/soundscapeRuntime.js +806 -0
- package/src/soundscape/soundscapeSettings.js +292 -0
- package/src/styles/index.js +13 -0
- package/src/styles/styleBundle.js +325 -0
- package/src/stylizedTerrain.js +32 -2
- package/src/stylizedWorld.js +423 -20
- package/src/texgen/evaluateTexture.js +675 -0
- package/src/texgen/index.js +60 -0
- package/src/texgen/noise2.js +210 -0
- package/src/texgen/textureAi.js +436 -0
- package/src/texgen/textureGenerators.js +516 -0
- package/src/texgen/texturePresets.js +490 -0
- package/src/texgen/textureSettings.js +342 -0
- package/src/texgen/textureThree.js +59 -0
- package/src/vegetation/flowerSpecies.js +15 -3
- package/src/vegetation/grassPalettes.js +153 -0
- package/src/vegetation/index.js +6 -0
- package/src/vegetation/stylizedBush.js +2 -0
- package/src/vegetation/stylizedFlower.js +82 -0
- package/src/vegetation/stylizedFlowers.js +48 -7
- package/src/vegetation/stylizedForest.js +29 -1
- package/src/vegetation/stylizedGrass.js +291 -56
- package/src/vegetation/stylizedTree.js +38 -2
- package/src/vegetation/stylizedTreeFoliage.js +2 -1
- package/src/vegetation/vegetationShaders.js +1110 -0
- package/src/vfxgen/INTEGRATION.md +145 -0
- package/src/vfxgen/core/burstBackbone.js +380 -0
- package/src/vfxgen/core/projectileCore.js +92 -0
- package/src/vfxgen/core/spriteShapes.js +98 -0
- package/src/vfxgen/core/trailRibbon.js +272 -0
- package/src/vfxgen/core/vfxRandom.js +29 -0
- package/src/vfxgen/effects/emitHelpers.js +37 -0
- package/src/vfxgen/effects/magicEffects.js +162 -0
- package/src/vfxgen/effects/movementEffects.js +87 -0
- package/src/vfxgen/effects/weaponEffects.js +118 -0
- package/src/vfxgen/index.js +18 -0
- package/src/vfxgen/moves/moveController.js +146 -0
- package/src/vfxgen/moves/moveLibrary.js +335 -0
- package/src/vfxgen/vfxPresets.js +98 -0
- package/src/vfxgen/vfxSettings.js +384 -0
- package/src/vfxgen/vfxSystem.js +449 -0
- package/src/vfxgen/weapons/stylizedWeapons.js +137 -0
- package/src/villagegen/index.js +4 -0
- package/src/villagegen/stylizedVillage.js +490 -0
- package/src/villagegen/villageArchetypes.js +160 -0
- package/src/villagegen/villageNames.js +40 -0
- package/src/villagegen/villageSites.js +105 -0
- package/src/water/sceneOverrideLayers.js +23 -0
- package/src/water/water.js +5 -0
- package/src/water/waterBreakerSystem.js +15 -1
- package/src/water/waterCurrentField.js +447 -0
- package/src/water/waterMaterial.js +12 -0
- package/src/water/waterNearshorePhase.js +320 -0
- package/src/water/waterScenePasses.js +83 -30
- package/src/water/waterSettings.js +325 -13
- package/src/water/waterShoreMaterial.js +322 -0
- package/src/water/waterShoreStateField.js +605 -0
- package/src/water/waterSurface.js +797 -28
- package/src/weather/index.js +6 -0
- package/src/weather/weatherPrecipitation.js +221 -0
- package/src/weather/weatherPresets.js +258 -0
- package/src/weather/weatherSettings.js +269 -0
- package/src/weather/weatherSystem.js +871 -0
- package/src/worldMinimap.js +62 -0
- package/src/worldPresets.js +4 -1
|
@@ -0,0 +1,143 @@
|
|
|
1
|
+
// Three.js side of asset import: turn a saved import recipe (or a freshly
|
|
2
|
+
// resolved download) into a live object/material. The style pass stays the
|
|
3
|
+
// caller's job — the lab and worlds run applyEnvironmentShader (or
|
|
4
|
+
// applyToonShader for characters) over the result, exactly like procedural
|
|
5
|
+
// assets, so imports always render in the active style set.
|
|
6
|
+
|
|
7
|
+
import * as THREE from 'three';
|
|
8
|
+
import { DRACOLoader } from 'three/examples/jsm/loaders/DRACOLoader.js';
|
|
9
|
+
import { GLTFLoader } from 'three/examples/jsm/loaders/GLTFLoader.js';
|
|
10
|
+
|
|
11
|
+
import { readZipEntries } from './zip.js';
|
|
12
|
+
|
|
13
|
+
/**
|
|
14
|
+
* Load a (possibly multi-file) glTF whose companion files live at arbitrary
|
|
15
|
+
* CDN URLs. `resources` maps the glTF's internal relative URIs to real URLs
|
|
16
|
+
* (Poly Haven serves textures/bin outside the glTF's own directory); a
|
|
17
|
+
* LoadingManager URL modifier rewrites each request by suffix match, which
|
|
18
|
+
* also covers URL-encoded variants.
|
|
19
|
+
*/
|
|
20
|
+
export async function loadImportedModel({ url, resources = {}, dracoDecoderPath = '/draco/gltf/' }) {
|
|
21
|
+
const manager = new THREE.LoadingManager();
|
|
22
|
+
const byPath = Object.entries(resources);
|
|
23
|
+
manager.setURLModifier((requested) => {
|
|
24
|
+
for (const [relativePath, remoteUrl] of byPath) {
|
|
25
|
+
if (requested.endsWith(relativePath) || requested.endsWith(encodeURI(relativePath))) {
|
|
26
|
+
return remoteUrl;
|
|
27
|
+
}
|
|
28
|
+
}
|
|
29
|
+
return requested;
|
|
30
|
+
});
|
|
31
|
+
const loader = new GLTFLoader(manager);
|
|
32
|
+
const dracoLoader = new DRACOLoader(manager);
|
|
33
|
+
dracoLoader.setDecoderPath(dracoDecoderPath);
|
|
34
|
+
loader.setDRACOLoader(dracoLoader);
|
|
35
|
+
let gltf;
|
|
36
|
+
try {
|
|
37
|
+
gltf = await loader.loadAsync(url);
|
|
38
|
+
} finally {
|
|
39
|
+
dracoLoader.dispose();
|
|
40
|
+
}
|
|
41
|
+
const object = gltf.scene ?? gltf.scenes?.[0];
|
|
42
|
+
if (!object) throw new Error('loadImportedModel: glTF contained no scene.');
|
|
43
|
+
object.traverse((child) => {
|
|
44
|
+
if (child.isMesh) {
|
|
45
|
+
child.castShadow = true;
|
|
46
|
+
child.receiveShadow = true;
|
|
47
|
+
}
|
|
48
|
+
});
|
|
49
|
+
return object;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/**
|
|
53
|
+
* PBR texture-set maps → a tiling MeshStandardMaterial. Prefers the packed
|
|
54
|
+
* `arm` map (occlusion-R / roughness-G / metalness-B — the glTF ORM layout
|
|
55
|
+
* three samples those slots from); falls back to individual AO/Rough maps.
|
|
56
|
+
*/
|
|
57
|
+
export async function loadImportedTextureMaterial({ maps }, { repeat = 1 } = {}) {
|
|
58
|
+
const loader = new THREE.TextureLoader();
|
|
59
|
+
loader.setCrossOrigin('anonymous');
|
|
60
|
+
const load = async (slot, { srgb = false } = {}) => {
|
|
61
|
+
if (!maps?.[slot]?.url) return null;
|
|
62
|
+
const texture = await loader.loadAsync(maps[slot].url);
|
|
63
|
+
texture.colorSpace = srgb ? THREE.SRGBColorSpace : THREE.NoColorSpace;
|
|
64
|
+
texture.wrapS = THREE.RepeatWrapping;
|
|
65
|
+
texture.wrapT = THREE.RepeatWrapping;
|
|
66
|
+
texture.repeat.set(repeat, repeat);
|
|
67
|
+
return texture;
|
|
68
|
+
};
|
|
69
|
+
const [diffuse, normal, arm, roughness, ao] = await Promise.all([
|
|
70
|
+
load('diffuse', { srgb: true }),
|
|
71
|
+
load('normal'),
|
|
72
|
+
load('arm'),
|
|
73
|
+
maps?.arm ? null : load('roughness'),
|
|
74
|
+
maps?.arm ? null : load('ao'),
|
|
75
|
+
]);
|
|
76
|
+
if (!diffuse) throw new Error('loadImportedTextureMaterial: texture set has no diffuse map.');
|
|
77
|
+
const material = new THREE.MeshStandardMaterial({
|
|
78
|
+
aoMap: arm ?? ao ?? null,
|
|
79
|
+
map: diffuse,
|
|
80
|
+
metalness: arm ? 1 : 0,
|
|
81
|
+
metalnessMap: arm ?? null,
|
|
82
|
+
normalMap: normal ?? null,
|
|
83
|
+
roughness: 1,
|
|
84
|
+
roughnessMap: arm ?? roughness ?? null,
|
|
85
|
+
});
|
|
86
|
+
return material;
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
// ambientCG archive entry names → material slots (Bricks097_1K-JPG_Color.jpg …)
|
|
90
|
+
const AMBIENTCG_MAP_PATTERNS = Object.freeze({
|
|
91
|
+
ao: /_AmbientOcclusion\.(jpg|png)$/i,
|
|
92
|
+
diffuse: /_Color\.(jpg|png)$/i,
|
|
93
|
+
displacement: /_Displacement\.(jpg|png)$/i,
|
|
94
|
+
normal: /_NormalGL\.(jpg|png)$/i,
|
|
95
|
+
roughness: /_Roughness\.(jpg|png)$/i,
|
|
96
|
+
});
|
|
97
|
+
|
|
98
|
+
/**
|
|
99
|
+
* An ambientCG ZIP download → tiling MeshStandardMaterial: fetch the archive
|
|
100
|
+
* (through the backend/dev proxy in browsers — pass rewriteUrl), extract the
|
|
101
|
+
* PBR maps in memory, and feed them to loadImportedTextureMaterial as
|
|
102
|
+
* object-URLs (revoked once the textures are on the GPU).
|
|
103
|
+
*/
|
|
104
|
+
export async function loadAmbientcgTextureMaterial({ url }, { repeat = 1, rewriteUrl = (value) => value } = {}) {
|
|
105
|
+
const response = await fetch(rewriteUrl(url));
|
|
106
|
+
if (!response.ok) throw new Error(`loadAmbientcgTextureMaterial: ${response.status} for ${url}`);
|
|
107
|
+
const entries = await readZipEntries(await response.arrayBuffer());
|
|
108
|
+
const maps = {};
|
|
109
|
+
const objectUrls = [];
|
|
110
|
+
for (const [slot, pattern] of Object.entries(AMBIENTCG_MAP_PATTERNS)) {
|
|
111
|
+
const entry = entries.find((candidate) => pattern.test(candidate.name));
|
|
112
|
+
if (!entry) continue;
|
|
113
|
+
const type = entry.name.toLowerCase().endsWith('.png') ? 'image/png' : 'image/jpeg';
|
|
114
|
+
const objectUrl = URL.createObjectURL(new Blob([entry.data], { type }));
|
|
115
|
+
objectUrls.push(objectUrl);
|
|
116
|
+
maps[slot] = { url: objectUrl };
|
|
117
|
+
}
|
|
118
|
+
try {
|
|
119
|
+
return await loadImportedTextureMaterial({ maps }, { repeat });
|
|
120
|
+
} finally {
|
|
121
|
+
for (const objectUrl of objectUrls) URL.revokeObjectURL(objectUrl);
|
|
122
|
+
}
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
/**
|
|
126
|
+
* The spawn snippet entry point: a saved import recipe → live content.
|
|
127
|
+
* model → { kind: 'model', object3D }
|
|
128
|
+
* texture → { kind: 'texture', material }
|
|
129
|
+
* Recipes store portable origin urls; browsers pass `rewriteUrl`
|
|
130
|
+
* (e.g. rewriteAmbientcgDownloadUrl) to route zip fetches through the proxy.
|
|
131
|
+
*/
|
|
132
|
+
export async function loadImportedAsset(recipe, { repeat = 1, rewriteUrl = (value) => value } = {}) {
|
|
133
|
+
if (recipe?.kind === 'model' && recipe.download) {
|
|
134
|
+
return { kind: 'model', object3D: await loadImportedModel(recipe.download) };
|
|
135
|
+
}
|
|
136
|
+
if (recipe?.kind === 'texture' && recipe.textureSet) {
|
|
137
|
+
return { kind: 'texture', material: await loadImportedTextureMaterial(recipe.textureSet, { repeat }) };
|
|
138
|
+
}
|
|
139
|
+
if (recipe?.kind === 'texture' && recipe.download?.url) {
|
|
140
|
+
return { kind: 'texture', material: await loadAmbientcgTextureMaterial(recipe.download, { repeat, rewriteUrl }) };
|
|
141
|
+
}
|
|
142
|
+
throw new Error(`loadImportedAsset: unsupported recipe kind "${recipe?.kind}".`);
|
|
143
|
+
}
|
|
@@ -0,0 +1,113 @@
|
|
|
1
|
+
// CC0 asset source: Open Source 3D Assets (opensource3dassets.com) — a
|
|
2
|
+
// keyless curated registry (github.com/ToxSam/open-source-3D-assets) built
|
|
3
|
+
// FOR asset browsers: static JSON with direct GLB urls, thumbnails, tags,
|
|
4
|
+
// and per-collection license metadata. ~991 models today, all from the
|
|
5
|
+
// Polygonal Mind CC0 collections (park/museum/sci-fi/vaporwave/medieval
|
|
6
|
+
// environment sets + creatures). The registry metadata itself is CC0 too.
|
|
7
|
+
//
|
|
8
|
+
// Everything is served from raw.githubusercontent.com (CORS `*`), so this
|
|
9
|
+
// client runs in the browser with no proxy and in Node unchanged.
|
|
10
|
+
//
|
|
11
|
+
// LICENSING: collections declare their license in projects.json; today all
|
|
12
|
+
// are CC0 but the registry accepts CC-BY submissions, so we filter to
|
|
13
|
+
// license === 'CC0' exactly — never assume.
|
|
14
|
+
|
|
15
|
+
export const OS3D_DATA_URL = 'https://raw.githubusercontent.com/ToxSam/open-source-3d-assets/main/data';
|
|
16
|
+
|
|
17
|
+
export const OS3D_SOURCE = Object.freeze({
|
|
18
|
+
sourceLabel: 'Open Source 3D Assets',
|
|
19
|
+
sourceUrl: 'https://opensource3dassets.com',
|
|
20
|
+
});
|
|
21
|
+
|
|
22
|
+
/**
|
|
23
|
+
* Raw projects.json → CC0-only collection descriptors:
|
|
24
|
+
* { id, name, creator, description, license, assetDataFile, pageUrl }
|
|
25
|
+
*/
|
|
26
|
+
export function normalizeOs3dProjects(raw) {
|
|
27
|
+
const projects = [];
|
|
28
|
+
for (const project of raw ?? []) {
|
|
29
|
+
if (!project?.id || !project.asset_data_file) continue;
|
|
30
|
+
if (project.is_public === false) continue;
|
|
31
|
+
if (project.license !== 'CC0') continue; // exact — future collections may be CC-BY
|
|
32
|
+
projects.push({
|
|
33
|
+
assetDataFile: project.asset_data_file,
|
|
34
|
+
creator: project.creator_id ?? null,
|
|
35
|
+
description: project.description ?? '',
|
|
36
|
+
id: project.id,
|
|
37
|
+
license: project.license,
|
|
38
|
+
name: project.name ?? project.id,
|
|
39
|
+
pageUrl: project.github_url ?? OS3D_SOURCE.sourceUrl,
|
|
40
|
+
});
|
|
41
|
+
}
|
|
42
|
+
return projects;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/** metadata.attributes trait list → lowercase tag values. */
|
|
46
|
+
function attributeValues(asset) {
|
|
47
|
+
return (asset?.metadata?.attributes ?? [])
|
|
48
|
+
.map((attribute) => String(attribute?.value ?? '').toLowerCase())
|
|
49
|
+
.filter(Boolean);
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/**
|
|
53
|
+
* One collection's assets/*.json + its project descriptor → normalized refs
|
|
54
|
+
* (kind is always 'model'; download embedded like Poly Pizza's). Drafts,
|
|
55
|
+
* non-public rows, and non-glTF formats are dropped.
|
|
56
|
+
*/
|
|
57
|
+
export function normalizeOs3dAssets(raw, project) {
|
|
58
|
+
const refs = [];
|
|
59
|
+
for (const asset of raw ?? []) {
|
|
60
|
+
if (!asset?.id || !asset.model_file_url) continue;
|
|
61
|
+
if (asset.is_public === false || asset.is_draft === true) continue;
|
|
62
|
+
const format = String(asset.format ?? 'GLB').toLowerCase();
|
|
63
|
+
if (format !== 'glb' && format !== 'gltf') continue;
|
|
64
|
+
refs.push({
|
|
65
|
+
attribution: { license: project.license, ...OS3D_SOURCE },
|
|
66
|
+
authors: project.creator ? [project.creator] : [],
|
|
67
|
+
categories: [String(project.name ?? project.id).toLowerCase()],
|
|
68
|
+
download: {
|
|
69
|
+
format,
|
|
70
|
+
resources: {},
|
|
71
|
+
sizeBytes: asset.metadata?.file_size ?? 0,
|
|
72
|
+
url: asset.model_file_url,
|
|
73
|
+
},
|
|
74
|
+
id: asset.id,
|
|
75
|
+
kind: 'model',
|
|
76
|
+
name: asset.name ?? asset.id,
|
|
77
|
+
pageUrl: project.pageUrl,
|
|
78
|
+
source: 'opensource3d',
|
|
79
|
+
tags: attributeValues(asset),
|
|
80
|
+
thumbnailUrl: asset.thumbnail_url ?? null,
|
|
81
|
+
});
|
|
82
|
+
}
|
|
83
|
+
return refs;
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
let indexCache = null;
|
|
87
|
+
|
|
88
|
+
/**
|
|
89
|
+
* Full CC0 index (projects.json + every CC0 collection's asset file, fetched
|
|
90
|
+
* in parallel), normalized and cached per session. A collection that fails
|
|
91
|
+
* to fetch is skipped rather than blanking the browser; the projects.json
|
|
92
|
+
* fetch itself failing throws.
|
|
93
|
+
*/
|
|
94
|
+
export function fetchOs3dIndex({ dataUrl = OS3D_DATA_URL, fetchImpl = fetch } = {}) {
|
|
95
|
+
if (!indexCache) {
|
|
96
|
+
indexCache = (async () => {
|
|
97
|
+
const response = await fetchImpl(`${dataUrl}/projects.json`);
|
|
98
|
+
if (!response.ok) throw new Error(`Open Source 3D Assets projects.json: ${response.status}`);
|
|
99
|
+
const projects = normalizeOs3dProjects(await response.json());
|
|
100
|
+
const settled = await Promise.allSettled(projects.map(async (project) => {
|
|
101
|
+
const assetsResponse = await fetchImpl(`${dataUrl}/${project.assetDataFile}`);
|
|
102
|
+
if (!assetsResponse.ok) throw new Error(`Open Source 3D Assets ${project.assetDataFile}: ${assetsResponse.status}`);
|
|
103
|
+
return normalizeOs3dAssets(await assetsResponse.json(), project);
|
|
104
|
+
}));
|
|
105
|
+
return settled
|
|
106
|
+
.flatMap((result) => (result.status === 'fulfilled' ? result.value : []))
|
|
107
|
+
.sort((a, b) => a.name.localeCompare(b.name));
|
|
108
|
+
})();
|
|
109
|
+
// a failed fetch must not poison the session cache
|
|
110
|
+
indexCache.catch(() => { indexCache = null; });
|
|
111
|
+
}
|
|
112
|
+
return indexCache;
|
|
113
|
+
}
|
|
@@ -0,0 +1,182 @@
|
|
|
1
|
+
// CC0 asset source: Poly Haven (polyhaven.com) — photoscanned models, PBR
|
|
2
|
+
// texture sets, and HDRIs, all CC0. The current API page says the live API is
|
|
3
|
+
// free for any use and requires visible credit plus a unique application UA:
|
|
4
|
+
// https://polyhaven.com/our-api
|
|
5
|
+
// Browsers cannot override User-Agent, so browser calls use the identifying
|
|
6
|
+
// /api/polyhaven dev/host proxy. Node calls add ASSETLIB_USER_AGENT here.
|
|
7
|
+
//
|
|
8
|
+
// Donation-vault ("early access") assets never appear in the public /assets
|
|
9
|
+
// dump today; the date_published guard below keeps them out if that changes.
|
|
10
|
+
|
|
11
|
+
import { ASSETLIB_USER_AGENT, ASSET_REF_KINDS, pickResolution } from './assetRef.js';
|
|
12
|
+
|
|
13
|
+
export const POLYHAVEN_API_URL = 'https://api.polyhaven.com';
|
|
14
|
+
export const POLYHAVEN_BROWSER_API_URL = '/api/polyhaven';
|
|
15
|
+
export const POLYHAVEN_THUMB_URL = 'https://cdn.polyhaven.com/asset_img/thumbs';
|
|
16
|
+
|
|
17
|
+
export const POLYHAVEN_ATTRIBUTION = Object.freeze({
|
|
18
|
+
license: 'CC0',
|
|
19
|
+
sourceLabel: 'Poly Haven',
|
|
20
|
+
sourceUrl: 'https://polyhaven.com',
|
|
21
|
+
});
|
|
22
|
+
|
|
23
|
+
/** API `type` param values, in the order the lab offers them. */
|
|
24
|
+
export const POLYHAVEN_ASSET_TYPES = Object.freeze(['models', 'textures', 'hdris']);
|
|
25
|
+
|
|
26
|
+
const KIND_BY_TYPE = { hdris: 'hdri', models: 'model', textures: 'texture' };
|
|
27
|
+
|
|
28
|
+
// files-doc keys for texture-set maps → the material slots we care about.
|
|
29
|
+
// `arm` is a packed occlusion(R)/roughness(G)/metalness(B) map — exactly the
|
|
30
|
+
// glTF ORM layout three.js samples aoMap/roughnessMap/metalnessMap from.
|
|
31
|
+
const TEXTURE_MAP_KEYS = Object.freeze({
|
|
32
|
+
ao: 'AO',
|
|
33
|
+
arm: 'arm',
|
|
34
|
+
diffuse: 'Diffuse',
|
|
35
|
+
displacement: 'Displacement',
|
|
36
|
+
normal: 'nor_gl',
|
|
37
|
+
roughness: 'Rough',
|
|
38
|
+
});
|
|
39
|
+
|
|
40
|
+
export function polyhavenThumbnailUrl(id, { width = 256, height = 256 } = {}) {
|
|
41
|
+
return `${POLYHAVEN_THUMB_URL}/${encodeURIComponent(id)}.png?width=${width}&height=${height}`;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
export function polyhavenAssetPageUrl(id) {
|
|
45
|
+
return `https://polyhaven.com/a/${encodeURIComponent(id)}`;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/**
|
|
49
|
+
* Raw `GET /assets?type=…` payload (`{ id: info }`) → sorted array of
|
|
50
|
+
* normalized asset refs. `now` is epoch seconds; assets published in the
|
|
51
|
+
* future (donation vault) are dropped.
|
|
52
|
+
*/
|
|
53
|
+
export function normalizePolyhavenIndex(raw, { type = 'models', now = Date.now() / 1000 } = {}) {
|
|
54
|
+
const kind = KIND_BY_TYPE[type];
|
|
55
|
+
if (!kind || !ASSET_REF_KINDS.includes(kind)) {
|
|
56
|
+
throw new Error(`normalizePolyhavenIndex: unknown type "${type}".`);
|
|
57
|
+
}
|
|
58
|
+
const refs = [];
|
|
59
|
+
for (const [id, info] of Object.entries(raw ?? {})) {
|
|
60
|
+
if (!info || typeof info !== 'object') continue;
|
|
61
|
+
if ((info.date_published ?? 0) > now) continue;
|
|
62
|
+
refs.push({
|
|
63
|
+
attribution: POLYHAVEN_ATTRIBUTION,
|
|
64
|
+
authors: Object.keys(info.authors ?? {}),
|
|
65
|
+
categories: (info.categories ?? []).map((value) => String(value).toLowerCase()),
|
|
66
|
+
datePublished: info.date_published ?? 0,
|
|
67
|
+
dimensions: info.dimensions ?? null,
|
|
68
|
+
id,
|
|
69
|
+
kind,
|
|
70
|
+
maxResolution: info.max_resolution ?? null,
|
|
71
|
+
name: info.name ?? id,
|
|
72
|
+
pageUrl: polyhavenAssetPageUrl(id),
|
|
73
|
+
polycount: info.polycount ?? null,
|
|
74
|
+
source: 'polyhaven',
|
|
75
|
+
tags: (info.tags ?? []).map((value) => String(value).toLowerCase()),
|
|
76
|
+
thumbnailUrl: info.thumbnail_url ?? polyhavenThumbnailUrl(id),
|
|
77
|
+
});
|
|
78
|
+
}
|
|
79
|
+
return refs.sort((a, b) => a.name.localeCompare(b.name));
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
const indexCache = new Map();
|
|
83
|
+
|
|
84
|
+
function defaultPolyhavenApiUrl() {
|
|
85
|
+
return typeof window === 'undefined' ? POLYHAVEN_API_URL : POLYHAVEN_BROWSER_API_URL;
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
function polyhavenRequestOptions(headers) {
|
|
89
|
+
const merged = new Headers(headers);
|
|
90
|
+
if (typeof window === 'undefined' && !merged.has('user-agent')) {
|
|
91
|
+
merged.set('user-agent', ASSETLIB_USER_AGENT);
|
|
92
|
+
}
|
|
93
|
+
return { headers: merged };
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
/** Full asset index for one type, normalized; cached per type per session.
|
|
97
|
+
* Browser hosts must implement `/api/polyhaven` as an identifying proxy;
|
|
98
|
+
* Node defaults to the upstream API and supplies ASSETLIB_USER_AGENT. */
|
|
99
|
+
export function fetchPolyhavenIndex({
|
|
100
|
+
type = 'models',
|
|
101
|
+
fetchImpl = fetch,
|
|
102
|
+
headers = {},
|
|
103
|
+
apiUrl = defaultPolyhavenApiUrl(),
|
|
104
|
+
now = Date.now() / 1000,
|
|
105
|
+
} = {}) {
|
|
106
|
+
const cacheKey = `${apiUrl}\n${type}`;
|
|
107
|
+
if (!indexCache.has(cacheKey)) {
|
|
108
|
+
const promise = (async () => {
|
|
109
|
+
const response = await fetchImpl(
|
|
110
|
+
`${apiUrl.replace(/\/$/, '')}/assets?type=${type}`,
|
|
111
|
+
polyhavenRequestOptions(headers),
|
|
112
|
+
);
|
|
113
|
+
if (!response.ok) throw new Error(`Poly Haven /assets?type=${type}: ${response.status}`);
|
|
114
|
+
return normalizePolyhavenIndex(await response.json(), { now, type });
|
|
115
|
+
})();
|
|
116
|
+
// a failed fetch must not poison the session cache
|
|
117
|
+
promise.catch(() => indexCache.delete(cacheKey));
|
|
118
|
+
indexCache.set(cacheKey, promise);
|
|
119
|
+
}
|
|
120
|
+
return indexCache.get(cacheKey);
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
/** Raw `GET /files/{id}` document, using the same browser/Node routing. */
|
|
124
|
+
export async function fetchPolyhavenFiles(id, {
|
|
125
|
+
fetchImpl = fetch,
|
|
126
|
+
headers = {},
|
|
127
|
+
apiUrl = defaultPolyhavenApiUrl(),
|
|
128
|
+
} = {}) {
|
|
129
|
+
const response = await fetchImpl(
|
|
130
|
+
`${apiUrl.replace(/\/$/, '')}/files/${encodeURIComponent(id)}`,
|
|
131
|
+
polyhavenRequestOptions(headers),
|
|
132
|
+
);
|
|
133
|
+
if (!response.ok) throw new Error(`Poly Haven /files/${id}: ${response.status}`);
|
|
134
|
+
return response.json();
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
/**
|
|
138
|
+
* files doc → one loadable glTF download:
|
|
139
|
+
* { format: 'gltf', resolution, url, sizeBytes,
|
|
140
|
+
* resources: { 'textures/…_diff_1k.jpg': 'https://…', '….bin': '…' } }
|
|
141
|
+
* `resources` maps the glTF's internal relative URIs to their real CDN URLs
|
|
142
|
+
* (Poly Haven hosts them outside the glTF's directory) — feed it to
|
|
143
|
+
* loadImportedModel, which resolves them via a LoadingManager URL modifier.
|
|
144
|
+
*/
|
|
145
|
+
export function resolvePolyhavenModelDownload(filesDoc, { resolution = '1k' } = {}) {
|
|
146
|
+
const byResolution = filesDoc?.gltf;
|
|
147
|
+
const picked = pickResolution(byResolution, resolution);
|
|
148
|
+
if (!picked) throw new Error('resolvePolyhavenModelDownload: no glTF files in document.');
|
|
149
|
+
const entry = byResolution[picked]?.gltf;
|
|
150
|
+
if (!entry?.url) throw new Error(`resolvePolyhavenModelDownload: no glTF url at ${picked}.`);
|
|
151
|
+
const resources = {};
|
|
152
|
+
let sizeBytes = entry.size ?? 0;
|
|
153
|
+
for (const [relativePath, file] of Object.entries(entry.include ?? {})) {
|
|
154
|
+
resources[relativePath] = file.url;
|
|
155
|
+
sizeBytes += file.size ?? 0;
|
|
156
|
+
}
|
|
157
|
+
return { format: 'gltf', resolution: picked, resources, sizeBytes, url: entry.url };
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
/**
|
|
161
|
+
* files doc → PBR map urls for a texture set:
|
|
162
|
+
* { resolution, format, maps: { diffuse: {url,sizeBytes}, normal, arm, … } }
|
|
163
|
+
* Prefers `arm` (packed ORM) plus diffuse/normal; individual AO/Rough are
|
|
164
|
+
* still included when present so callers can choose.
|
|
165
|
+
*/
|
|
166
|
+
export function resolvePolyhavenTextureDownload(filesDoc, { resolution = '1k', format = 'jpg' } = {}) {
|
|
167
|
+
const maps = {};
|
|
168
|
+
let picked = null;
|
|
169
|
+
for (const [slot, key] of Object.entries(TEXTURE_MAP_KEYS)) {
|
|
170
|
+
const byResolution = filesDoc?.[key];
|
|
171
|
+
if (!byResolution) continue;
|
|
172
|
+
const mapResolution = pickResolution(byResolution, resolution);
|
|
173
|
+
if (!mapResolution) continue;
|
|
174
|
+
const byFormat = byResolution[mapResolution];
|
|
175
|
+
const file = byFormat?.[format] ?? byFormat?.jpg ?? byFormat?.png;
|
|
176
|
+
if (!file?.url) continue;
|
|
177
|
+
picked = picked ?? mapResolution;
|
|
178
|
+
maps[slot] = { sizeBytes: file.size ?? 0, url: file.url };
|
|
179
|
+
}
|
|
180
|
+
if (!maps.diffuse) throw new Error('resolvePolyhavenTextureDownload: no diffuse map found.');
|
|
181
|
+
return { format, maps, resolution: picked };
|
|
182
|
+
}
|
|
@@ -0,0 +1,115 @@
|
|
|
1
|
+
// Asset source: Poly Pizza (poly.pizza) — low-poly stylized models (the
|
|
2
|
+
// Quaternius catalog, Google Poly CC0/CC-BY survivors, and more). The best
|
|
3
|
+
// STYLE fit for a toon renderer of all our sources: authored low-poly takes
|
|
4
|
+
// cel shading and outlines far better than photoscans.
|
|
5
|
+
//
|
|
6
|
+
// BYO API KEY (free: https://poly.pizza/settings/api; their terms: hobby use
|
|
7
|
+
// free, commercial pay-as-you-go — a commercial toonlab-pro deployment needs
|
|
8
|
+
// its own arrangement). The API sends no CORS headers AND wants the key in a
|
|
9
|
+
// header, so browsers go through a backend route that injects it server-side
|
|
10
|
+
// (Vite dev proxy: /api/polypizza with TOONLAB_POLYPIZZA_KEY from env; the
|
|
11
|
+
// key never reaches the client). Node callers pass `apiKey` directly.
|
|
12
|
+
//
|
|
13
|
+
// LICENSING: unlike Poly Haven/ambientCG this corpus is NOT all-CC0 — models
|
|
14
|
+
// carry Licence 'CC0' or 'CC-BY' (ready-made credit string in Attribution).
|
|
15
|
+
// Callers default to CC0-only; CC-BY may be surfaced with its license badge
|
|
16
|
+
// and attribution shown, but must never be bundled/redistributed by us.
|
|
17
|
+
|
|
18
|
+
export const POLYPIZZA_API_URL = 'https://api.poly.pizza/v1.1';
|
|
19
|
+
export const POLYPIZZA_STATIC_URL = 'https://static.poly.pizza';
|
|
20
|
+
|
|
21
|
+
/** Backend/dev-proxy routes the browser uses instead of poly.pizza hosts. */
|
|
22
|
+
export const POLYPIZZA_PROXY_API = '/api/polypizza';
|
|
23
|
+
export const POLYPIZZA_PROXY_STATIC = '/api/polypizza-static';
|
|
24
|
+
|
|
25
|
+
export const POLYPIZZA_SOURCE = Object.freeze({
|
|
26
|
+
sourceLabel: 'Poly Pizza',
|
|
27
|
+
sourceUrl: 'https://poly.pizza',
|
|
28
|
+
});
|
|
29
|
+
|
|
30
|
+
/** Absolute static.poly.pizza url → same request through the proxy route. */
|
|
31
|
+
export function rewritePolyPizzaDownloadUrl(url, base = POLYPIZZA_PROXY_STATIC) {
|
|
32
|
+
return String(url ?? '').replace(POLYPIZZA_STATIC_URL, base);
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
function normalizeLicence(licence) {
|
|
36
|
+
const value = String(licence ?? '').toUpperCase().replace(/\s+/g, '-');
|
|
37
|
+
return value.includes('CC0') ? 'CC0' : value || 'unknown';
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/** One API model object → normalized ref (kind is always 'model'). */
|
|
41
|
+
export function normalizePolyPizzaModel(model) {
|
|
42
|
+
if (!model?.ID) return null;
|
|
43
|
+
return {
|
|
44
|
+
animated: Boolean(model.Animated),
|
|
45
|
+
attribution: {
|
|
46
|
+
license: normalizeLicence(model.Licence),
|
|
47
|
+
// Poly Pizza ships the exact credit line CC-BY requires — keep it.
|
|
48
|
+
text: model.Attribution ?? null,
|
|
49
|
+
...POLYPIZZA_SOURCE,
|
|
50
|
+
},
|
|
51
|
+
authors: model.Creator?.Username ? [model.Creator.Username] : [],
|
|
52
|
+
categories: model.Category ? [String(model.Category).toLowerCase()] : [],
|
|
53
|
+
download: { format: 'glb', url: model.Download },
|
|
54
|
+
id: model.ID,
|
|
55
|
+
kind: 'model',
|
|
56
|
+
name: model.Title ?? model.ID,
|
|
57
|
+
pageUrl: `https://poly.pizza/m/${encodeURIComponent(model.ID)}`,
|
|
58
|
+
polycount: model['Tri Count'] ?? null,
|
|
59
|
+
source: 'polypizza',
|
|
60
|
+
tags: (model.Tags ?? []).map((value) => String(value).toLowerCase()),
|
|
61
|
+
thumbnailUrl: model.Thumbnail ?? null,
|
|
62
|
+
};
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
function keyHeaders(apiKey) {
|
|
66
|
+
return apiKey ? { 'x-auth-token': apiKey } : {};
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
async function requireOk(response, what) {
|
|
70
|
+
if (response.status === 401) {
|
|
71
|
+
throw new Error(`Poly Pizza ${what}: 401 — API key missing or invalid. Get a free key at poly.pizza/settings/api, then paste it in the sidebar (or set TOONLAB_POLYPIZZA_KEY for the dev server / MCP).`);
|
|
72
|
+
}
|
|
73
|
+
if (!response.ok) throw new Error(`Poly Pizza ${what}: ${response.status}`);
|
|
74
|
+
return response;
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
/**
|
|
78
|
+
* Keyword search. The endpoint requires a search term; callers with an empty
|
|
79
|
+
* query should supply a starter word. `cc0Only` (default true) drops CC-BY
|
|
80
|
+
* results — flip it off only where the license badge + attribution show.
|
|
81
|
+
*/
|
|
82
|
+
export async function searchPolyPizza({
|
|
83
|
+
query,
|
|
84
|
+
limit = 32,
|
|
85
|
+
page = 0,
|
|
86
|
+
cc0Only = true,
|
|
87
|
+
apiUrl = POLYPIZZA_API_URL,
|
|
88
|
+
apiKey = null,
|
|
89
|
+
fetchImpl = fetch,
|
|
90
|
+
} = {}) {
|
|
91
|
+
const keyword = String(query ?? '').trim() || 'low poly';
|
|
92
|
+
const params = new URLSearchParams({ Limit: String(limit), Page: String(page) });
|
|
93
|
+
const response = await fetchImpl(
|
|
94
|
+
`${apiUrl}/search/${encodeURIComponent(keyword)}?${params}`,
|
|
95
|
+
{ headers: keyHeaders(apiKey) },
|
|
96
|
+
);
|
|
97
|
+
await requireOk(response, 'search');
|
|
98
|
+
const payload = await response.json();
|
|
99
|
+
const refs = (payload?.results ?? []).map(normalizePolyPizzaModel).filter(Boolean);
|
|
100
|
+
return cc0Only ? refs.filter((ref) => ref.attribution.license === 'CC0') : refs;
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
/** Exact lookup by model id (headless boot, MCP get/import). */
|
|
104
|
+
export async function fetchPolyPizzaModel(id, {
|
|
105
|
+
apiUrl = POLYPIZZA_API_URL,
|
|
106
|
+
apiKey = null,
|
|
107
|
+
fetchImpl = fetch,
|
|
108
|
+
} = {}) {
|
|
109
|
+
const response = await fetchImpl(
|
|
110
|
+
`${apiUrl}/model/${encodeURIComponent(id)}`,
|
|
111
|
+
{ headers: keyHeaders(apiKey) },
|
|
112
|
+
);
|
|
113
|
+
await requireOk(response, `model/${id}`);
|
|
114
|
+
return normalizePolyPizzaModel(await response.json());
|
|
115
|
+
}
|