@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,210 @@
|
|
|
1
|
+
// The asset catalog runtime — the Megascans moment, minus the download.
|
|
2
|
+
// One searchable registry of every ToonLab recipe/preset, and ONE function
|
|
3
|
+
// that turns any asset entry into a placeable PropAsset:
|
|
4
|
+
//
|
|
5
|
+
// import { catalog } from '@call-me-sensei/toonlab/catalog';
|
|
6
|
+
// catalog.list({ tags: ['prop'] });
|
|
7
|
+
// const asset = catalog.spawn('prop/lantern/stone-toro', { seed: 7 });
|
|
8
|
+
// placeAlongSpline({ asset, spline: world.paths.splines[0], ... });
|
|
9
|
+
//
|
|
10
|
+
// OSS ships the full built-in registry + local user entries;
|
|
11
|
+
// `catalog.addSource(url, { headers })` mounts remote registries (the pro
|
|
12
|
+
// cloud library and its generated imports use exactly this seam — the catalog never
|
|
13
|
+
// knows they exist).
|
|
14
|
+
|
|
15
|
+
import * as THREE from 'three';
|
|
16
|
+
|
|
17
|
+
import { validateCatalogEntry } from './manifest.js';
|
|
18
|
+
import { builtinCatalogEntries } from './builtinEntries.js';
|
|
19
|
+
import { createPropAssetFromRecipe, propAssetFromObject } from '../propgen/index.js';
|
|
20
|
+
import { buildingAsset } from '../buildinggen/buildingAsset.js';
|
|
21
|
+
import { createPlantFromRecipe } from '../vegetation/treeRecipe.js';
|
|
22
|
+
import { createRockDocument } from '../rockgen/rockDocument.js';
|
|
23
|
+
import { meshDocument } from '../rockgen/index.js';
|
|
24
|
+
import { createDebrisAsset } from '../debrisgen/debrisGenerator.js';
|
|
25
|
+
import { createStylizedPathsFromRecipe } from '../pathgen/stylizedPaths.js';
|
|
26
|
+
|
|
27
|
+
// Per-cluster spawners: entry (+ seed override) → PropAsset. Clusters whose
|
|
28
|
+
// presets are settings documents rather than placeable things (water, sky,
|
|
29
|
+
// post, toon) intentionally have no spawner — spawn() explains instead of
|
|
30
|
+
// guessing.
|
|
31
|
+
const SPAWNERS = {
|
|
32
|
+
buildinggen(entry, { seed }) {
|
|
33
|
+
const recipe = entry.recipe;
|
|
34
|
+
return buildingAsset(seed === undefined
|
|
35
|
+
? recipe
|
|
36
|
+
: { ...recipe, options: { ...recipe.options, seed } });
|
|
37
|
+
},
|
|
38
|
+
debrisgen(entry, { seed }) {
|
|
39
|
+
const settings = entry.recipe.settings;
|
|
40
|
+
const spawnSettings = seed === undefined
|
|
41
|
+
? settings
|
|
42
|
+
: { ...settings, asset: { ...settings.asset, seed } };
|
|
43
|
+
return {
|
|
44
|
+
build(buildSeed = spawnSettings.asset.seed) {
|
|
45
|
+
const root = createDebrisAsset({
|
|
46
|
+
...spawnSettings,
|
|
47
|
+
asset: { ...spawnSettings.asset, seed: buildSeed },
|
|
48
|
+
});
|
|
49
|
+
const asset = propAssetFromObject(root, {
|
|
50
|
+
footprint: {
|
|
51
|
+
radius: Math.max((spawnSettings.asset.spread || 0.6) * spawnSettings.asset.scale, 0.4),
|
|
52
|
+
},
|
|
53
|
+
});
|
|
54
|
+
return asset.build();
|
|
55
|
+
},
|
|
56
|
+
linear: false,
|
|
57
|
+
type: 'debris',
|
|
58
|
+
variant: spawnSettings.asset.variant,
|
|
59
|
+
};
|
|
60
|
+
},
|
|
61
|
+
propgen(entry, { seed }) {
|
|
62
|
+
const recipe = entry.recipe;
|
|
63
|
+
const spawnRecipe = seed === undefined
|
|
64
|
+
? recipe
|
|
65
|
+
: {
|
|
66
|
+
...recipe,
|
|
67
|
+
settings: {
|
|
68
|
+
...recipe.settings,
|
|
69
|
+
asset: { ...recipe.settings.asset, seed },
|
|
70
|
+
},
|
|
71
|
+
};
|
|
72
|
+
return createPropAssetFromRecipe(spawnRecipe);
|
|
73
|
+
},
|
|
74
|
+
rockgen(entry, { seed = 4, resolution = 44 }) {
|
|
75
|
+
const preset = entry.recipe.preset;
|
|
76
|
+
return {
|
|
77
|
+
build(buildSeed = seed) {
|
|
78
|
+
const geometry = meshDocument(
|
|
79
|
+
createRockDocument({ preset, seed: buildSeed }),
|
|
80
|
+
{ resolution },
|
|
81
|
+
);
|
|
82
|
+
const material = new THREE.MeshStandardMaterial({ vertexColors: true });
|
|
83
|
+
material.userData.envRole = 'standard';
|
|
84
|
+
const mesh = new THREE.Mesh(geometry, material);
|
|
85
|
+
mesh.castShadow = true;
|
|
86
|
+
mesh.receiveShadow = true;
|
|
87
|
+
const asset = propAssetFromObject(mesh);
|
|
88
|
+
return asset.build();
|
|
89
|
+
},
|
|
90
|
+
linear: false,
|
|
91
|
+
type: 'rock',
|
|
92
|
+
variant: preset,
|
|
93
|
+
};
|
|
94
|
+
},
|
|
95
|
+
vegetation(entry, { seed }) {
|
|
96
|
+
const recipe = entry.recipe;
|
|
97
|
+
return {
|
|
98
|
+
build(buildSeed = seed ?? recipe.options?.seed ?? 1) {
|
|
99
|
+
const plant = createPlantFromRecipe({
|
|
100
|
+
...recipe,
|
|
101
|
+
options: { ...recipe.options, seed: buildSeed },
|
|
102
|
+
});
|
|
103
|
+
const size = Number(recipe.options?.size) || 1.7;
|
|
104
|
+
return propAssetFromObject(plant, {
|
|
105
|
+
// trunk-scale blocker, not canopy-scale — characters walk under crowns
|
|
106
|
+
anchor: 0,
|
|
107
|
+
footprint: { radius: Math.max(0.13 * size, 0.2) },
|
|
108
|
+
}).build();
|
|
109
|
+
},
|
|
110
|
+
linear: false,
|
|
111
|
+
type: recipe.type ?? 'tree',
|
|
112
|
+
variant: recipe.type ?? 'tree',
|
|
113
|
+
};
|
|
114
|
+
},
|
|
115
|
+
};
|
|
116
|
+
|
|
117
|
+
function createRegistry() {
|
|
118
|
+
const entries = new Map();
|
|
119
|
+
const sources = [];
|
|
120
|
+
|
|
121
|
+
const register = (entry, { source = 'user' } = {}) => {
|
|
122
|
+
const { ok, errors } = validateCatalogEntry(entry);
|
|
123
|
+
if (!ok) throw new Error(`catalog.register: ${errors.join(' ')}`);
|
|
124
|
+
entries.set(entry.id, { ...entry, source });
|
|
125
|
+
return entry.id;
|
|
126
|
+
};
|
|
127
|
+
|
|
128
|
+
for (const entry of builtinCatalogEntries()) register(entry, { source: 'builtin' });
|
|
129
|
+
|
|
130
|
+
return {
|
|
131
|
+
/**
|
|
132
|
+
* Mounts a remote registry: fetches `{ entries: [...] }` JSON and
|
|
133
|
+
* registers every valid entry (thumbnails resolved against the source
|
|
134
|
+
* URL). This is the whole pro seam — auth is just headers.
|
|
135
|
+
*/
|
|
136
|
+
async addSource(url, { headers = {}, name = url } = {}) {
|
|
137
|
+
const response = await fetch(url, { headers });
|
|
138
|
+
if (!response.ok) throw new Error(`catalog.addSource: ${response.status} for ${url}`);
|
|
139
|
+
const manifest = await response.json();
|
|
140
|
+
const base = new URL(url, globalThis.location?.href ?? 'http://localhost/');
|
|
141
|
+
let added = 0;
|
|
142
|
+
for (const entry of manifest.entries ?? []) {
|
|
143
|
+
try {
|
|
144
|
+
const resolved = entry.thumbnail
|
|
145
|
+
? { ...entry, thumbnail: new URL(entry.thumbnail, base).href }
|
|
146
|
+
: entry;
|
|
147
|
+
register(resolved, { source: name });
|
|
148
|
+
added += 1;
|
|
149
|
+
} catch {
|
|
150
|
+
// skip invalid remote entries; a bad source must not break the lab
|
|
151
|
+
}
|
|
152
|
+
}
|
|
153
|
+
sources.push({ count: added, name, url });
|
|
154
|
+
return added;
|
|
155
|
+
},
|
|
156
|
+
entries,
|
|
157
|
+
get(id) {
|
|
158
|
+
return entries.get(id) ?? null;
|
|
159
|
+
},
|
|
160
|
+
/** Filter by tags (every tag must match), cluster, kind, or free text. */
|
|
161
|
+
list({ tags = null, cluster = null, kind = null, text = null } = {}) {
|
|
162
|
+
const query = text ? String(text).toLowerCase() : null;
|
|
163
|
+
const wanted = Array.isArray(tags) ? tags.map((tag) => tag.toLowerCase()) : null;
|
|
164
|
+
const results = [];
|
|
165
|
+
for (const entry of entries.values()) {
|
|
166
|
+
if (cluster && entry.cluster !== cluster) continue;
|
|
167
|
+
if (kind && entry.kind !== kind) continue;
|
|
168
|
+
if (wanted && !wanted.every((tag) => entry.tags.includes(tag))) continue;
|
|
169
|
+
if (query) {
|
|
170
|
+
const haystack = `${entry.id} ${entry.label} ${entry.description ?? ''} ${entry.tags.join(' ')}`.toLowerCase();
|
|
171
|
+
if (!haystack.includes(query)) continue;
|
|
172
|
+
}
|
|
173
|
+
results.push(entry);
|
|
174
|
+
}
|
|
175
|
+
return results.sort((a, b) => a.id.localeCompare(b.id));
|
|
176
|
+
},
|
|
177
|
+
register,
|
|
178
|
+
sources,
|
|
179
|
+
/**
|
|
180
|
+
* The download button: any asset entry id → a PropAsset ready for
|
|
181
|
+
* placeProps / scatterProps / placeAlongSpline. Settings-preset entries
|
|
182
|
+
* (water, sky, post, toon, path networks) aren't placeable objects —
|
|
183
|
+
* spawn throws with their copy-paste snippet instead.
|
|
184
|
+
*/
|
|
185
|
+
spawn(id, options = {}) {
|
|
186
|
+
const entry = entries.get(id);
|
|
187
|
+
if (!entry) throw new Error(`catalog.spawn: unknown entry "${id}".`);
|
|
188
|
+
const spawner = SPAWNERS[entry.cluster];
|
|
189
|
+
if (!spawner) {
|
|
190
|
+
throw new Error(
|
|
191
|
+
`catalog.spawn: "${id}" is a ${entry.cluster} settings preset, not a placeable asset. Use its snippet instead:\n ${entry.spawn}`,
|
|
192
|
+
);
|
|
193
|
+
}
|
|
194
|
+
return spawner(entry, options);
|
|
195
|
+
},
|
|
196
|
+
unregister(id) {
|
|
197
|
+
return entries.delete(id);
|
|
198
|
+
},
|
|
199
|
+
};
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
/** The shared default registry (labs and docs use this instance). */
|
|
203
|
+
export const catalog = createRegistry();
|
|
204
|
+
|
|
205
|
+
/** Fresh isolated registry (tests, embedded hosts). */
|
|
206
|
+
export function createCatalog() {
|
|
207
|
+
return createRegistry();
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
export { createStylizedPathsFromRecipe };
|
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
// The catalog entry format — FROZEN during workstream 02 so every cluster
|
|
2
|
+
// (props, buildings, villages, trees, rocks, debris, pro imports) accretes
|
|
3
|
+
// entries from day one without retrofits. The browsing runtime and UI land
|
|
4
|
+
// with workstream 05; this module is only the contract.
|
|
5
|
+
//
|
|
6
|
+
// {
|
|
7
|
+
// id: 'prop/lantern/stone-toro', // stable, path-like
|
|
8
|
+
// cluster: 'propgen', // owning generator cluster
|
|
9
|
+
// kind: 'recipe', // recipe | preset | imported-glb
|
|
10
|
+
// label: 'Stone tōrō',
|
|
11
|
+
// recipe: { … }, // cluster-specific document
|
|
12
|
+
// thumbnail: 'thumbs/prop-lantern-stone-toro.webp',
|
|
13
|
+
// spawn: 'createPropAssetFromRecipe(recipe)', // copy-paste snippet
|
|
14
|
+
// tags: ['prop', 'lighting', 'shrine'],
|
|
15
|
+
// budget: { triHi: 1800, triLo: 160 }, // optional, advisory
|
|
16
|
+
// }
|
|
17
|
+
|
|
18
|
+
export const CATALOG_ENTRY_VERSION = 1;
|
|
19
|
+
|
|
20
|
+
export const CATALOG_ENTRY_KINDS = Object.freeze(['recipe', 'preset', 'imported-glb']);
|
|
21
|
+
|
|
22
|
+
const ID_PATTERN = /^[a-z0-9]+(?:[-/][a-z0-9]+)*$/;
|
|
23
|
+
|
|
24
|
+
/** Normalizes + validates in one step; throws on unusable input. */
|
|
25
|
+
export function createCatalogEntry({
|
|
26
|
+
id,
|
|
27
|
+
cluster,
|
|
28
|
+
kind = 'recipe',
|
|
29
|
+
label = null,
|
|
30
|
+
description = null,
|
|
31
|
+
recipe = null,
|
|
32
|
+
thumbnail = null,
|
|
33
|
+
spawn = null,
|
|
34
|
+
tags = [],
|
|
35
|
+
budget = null,
|
|
36
|
+
} = {}) {
|
|
37
|
+
const { ok, errors } = validateCatalogEntry({
|
|
38
|
+
budget, cluster, description, id, kind, label, recipe, spawn, tags, thumbnail,
|
|
39
|
+
});
|
|
40
|
+
if (!ok) throw new Error(`Invalid catalog entry: ${errors.join(' ')}`);
|
|
41
|
+
const entry = {
|
|
42
|
+
cluster,
|
|
43
|
+
id,
|
|
44
|
+
kind,
|
|
45
|
+
label: label ?? id.split('/').pop(),
|
|
46
|
+
spawn,
|
|
47
|
+
tags: [...new Set(tags.map((tag) => String(tag).toLowerCase()))],
|
|
48
|
+
version: CATALOG_ENTRY_VERSION,
|
|
49
|
+
};
|
|
50
|
+
if (description) entry.description = description;
|
|
51
|
+
if (recipe) entry.recipe = recipe;
|
|
52
|
+
if (thumbnail) entry.thumbnail = thumbnail;
|
|
53
|
+
if (budget) entry.budget = { ...budget };
|
|
54
|
+
return entry;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
export function validateCatalogEntry(input) {
|
|
58
|
+
const errors = [];
|
|
59
|
+
if (!input || typeof input !== 'object') {
|
|
60
|
+
return { errors: ['Entry must be an object.'], ok: false };
|
|
61
|
+
}
|
|
62
|
+
if (typeof input.id !== 'string' || !ID_PATTERN.test(input.id)) {
|
|
63
|
+
errors.push('id must be a lowercase path-like string (e.g. "prop/lantern/stone-toro").');
|
|
64
|
+
}
|
|
65
|
+
if (typeof input.cluster !== 'string' || !input.cluster.trim()) {
|
|
66
|
+
errors.push('cluster is required (owning generator, e.g. "propgen").');
|
|
67
|
+
}
|
|
68
|
+
if (!CATALOG_ENTRY_KINDS.includes(input.kind)) {
|
|
69
|
+
errors.push(`kind must be one of ${CATALOG_ENTRY_KINDS.join(', ')}.`);
|
|
70
|
+
}
|
|
71
|
+
if (input.kind !== 'imported-glb' && (!input.recipe || typeof input.recipe !== 'object')) {
|
|
72
|
+
errors.push('recipe object is required for recipe/preset entries.');
|
|
73
|
+
}
|
|
74
|
+
if (typeof input.spawn !== 'string' || !input.spawn.trim()) {
|
|
75
|
+
errors.push('spawn snippet is required — the catalog is a contract, not a wiki.');
|
|
76
|
+
}
|
|
77
|
+
if (input.tags !== undefined && !Array.isArray(input.tags)) {
|
|
78
|
+
errors.push('tags must be an array when present.');
|
|
79
|
+
}
|
|
80
|
+
if (input.budget && (typeof input.budget !== 'object' || Array.isArray(input.budget))) {
|
|
81
|
+
errors.push('budget must be an object when present.');
|
|
82
|
+
}
|
|
83
|
+
return { errors, ok: errors.length === 0 };
|
|
84
|
+
}
|