@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,450 @@
|
|
|
1
|
+
// The atmosphere layer. Import from '@call-me-sensei/toonlab/ambientfx'.
|
|
2
|
+
//
|
|
3
|
+
// const fx = createAmbientFx({
|
|
4
|
+
// seed, heightAt, waterLevel, followTarget, timeOfDay,
|
|
5
|
+
// effects: { petals: true, fireflies: { density: 1.5 } },
|
|
6
|
+
// });
|
|
7
|
+
// scene.add(fx.root);
|
|
8
|
+
// fx.update(delta, camera); // each frame
|
|
9
|
+
// fx.setWind({ windDirection: [1, 0.3], windSpeed: 1, windStrength: 0.16 });
|
|
10
|
+
// fx.setDistanceFog({ color, density, falloff, floorY }); // height-fog layer
|
|
11
|
+
// fx.addBloomSources([{ x, y, z, radius, color, effect: 'petals' }]);
|
|
12
|
+
//
|
|
13
|
+
// All five effects are emission presets over ONE particle backbone (three
|
|
14
|
+
// draw calls total — see particleBackbone.js for the grouping rationale).
|
|
15
|
+
// Effects listed in `effects` override their settings group; `density` there
|
|
16
|
+
// is a MULTIPLIER over the per-m³ base density, so `{ fireflies: { density:
|
|
17
|
+
// 1.5 } }` reads as "half again as many fireflies".
|
|
18
|
+
|
|
19
|
+
import * as THREE from 'three';
|
|
20
|
+
|
|
21
|
+
import {
|
|
22
|
+
AMBIENTFX_EFFECT_IDS,
|
|
23
|
+
createAmbientFxSettings,
|
|
24
|
+
timeGateWeight,
|
|
25
|
+
} from './ambientFxSettings.js';
|
|
26
|
+
import { resolveAmbientFxPreset } from './ambientFxPresets.js';
|
|
27
|
+
import {
|
|
28
|
+
createParticleBackbone,
|
|
29
|
+
EFFECT_KIND,
|
|
30
|
+
GROUP_FOR_KIND,
|
|
31
|
+
RECENTER_FRACTION,
|
|
32
|
+
windDriftVector,
|
|
33
|
+
} from './particleBackbone.js';
|
|
34
|
+
import { emitCanopyBound, emitGlobalVolume, emitWaterMargin } from './emitters.js';
|
|
35
|
+
|
|
36
|
+
const focusScratch = new THREE.Vector3();
|
|
37
|
+
const colorA = new THREE.Color();
|
|
38
|
+
const colorB = new THREE.Color();
|
|
39
|
+
const colorMix = new THREE.Color();
|
|
40
|
+
|
|
41
|
+
function cleanObject(value) {
|
|
42
|
+
return value && typeof value === 'object' && !Array.isArray(value) ? value : {};
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
function mergeGroupOverrides(...layers) {
|
|
46
|
+
const merged = {};
|
|
47
|
+
for (const layer of layers) {
|
|
48
|
+
for (const [group, values] of Object.entries(cleanObject(layer))) {
|
|
49
|
+
merged[group] = { ...merged[group], ...cleanObject(values) };
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
return merged;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
function lerp(a, b, t) {
|
|
56
|
+
return a + (b - a) * t;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
export function createAmbientFx({
|
|
60
|
+
seed = 1,
|
|
61
|
+
heightAt = () => 0,
|
|
62
|
+
waterLevel = null,
|
|
63
|
+
followTarget = null,
|
|
64
|
+
timeOfDay = null,
|
|
65
|
+
effects = {},
|
|
66
|
+
bounds = null,
|
|
67
|
+
preset = null,
|
|
68
|
+
settings = {},
|
|
69
|
+
} = {}) {
|
|
70
|
+
const worldSeed = Math.max(1, Math.round(Number(seed) || 1)) >>> 0;
|
|
71
|
+
|
|
72
|
+
// Per-effect runtime config pulled out of `effects`: enabled/density are
|
|
73
|
+
// levers, `mask` binds pollen/fireflies to host masks, the rest is plain
|
|
74
|
+
// settings overrides layered over preset + settings.
|
|
75
|
+
const densityMult = {};
|
|
76
|
+
const effectMasks = {};
|
|
77
|
+
const effectOverrides = {};
|
|
78
|
+
for (const id of AMBIENTFX_EFFECT_IDS) {
|
|
79
|
+
const raw = effects?.[id];
|
|
80
|
+
densityMult[id] = 1;
|
|
81
|
+
effectMasks[id] = null;
|
|
82
|
+
if (raw === false) {
|
|
83
|
+
effectOverrides[id] = { enabled: false };
|
|
84
|
+
} else if (raw === true) {
|
|
85
|
+
effectOverrides[id] = { enabled: true };
|
|
86
|
+
} else if (raw && typeof raw === 'object') {
|
|
87
|
+
const { density, mask, ...rest } = raw;
|
|
88
|
+
if (Number.isFinite(Number(density))) densityMult[id] = Math.max(Number(density), 0);
|
|
89
|
+
if (typeof mask === 'function') effectMasks[id] = mask;
|
|
90
|
+
effectOverrides[id] = { enabled: true, ...rest };
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
const resolved = createAmbientFxSettings(
|
|
94
|
+
mergeGroupOverrides(resolveAmbientFxPreset(preset), settings, effectOverrides));
|
|
95
|
+
const shared = resolved.shared;
|
|
96
|
+
const windowRadius = shared.windowRadius;
|
|
97
|
+
const worldBounds = bounds && Number.isFinite(Number(bounds.x)) && Number.isFinite(Number(bounds.z))
|
|
98
|
+
? { x: Math.abs(Number(bounds.x)), z: Math.abs(Number(bounds.z)) }
|
|
99
|
+
: null;
|
|
100
|
+
|
|
101
|
+
const backbone = createParticleBackbone(resolved);
|
|
102
|
+
const u = backbone.uniforms;
|
|
103
|
+
const root = new THREE.Group();
|
|
104
|
+
root.name = 'StylizedAmbientFx';
|
|
105
|
+
for (const group of Object.values(backbone.groups)) root.add(group.mesh);
|
|
106
|
+
// Mist is the one effect that must not enter the water scene passes: a
|
|
107
|
+
// fog sheet in the refraction grab reads as murk under the surface.
|
|
108
|
+
// Fireflies (and petals/leaves) deliberately stay IN — glowing motes
|
|
109
|
+
// doubling on the lake at dusk is the whole point of the effect, and at
|
|
110
|
+
// these counts the extra pass cost is noise.
|
|
111
|
+
backbone.groups.soft.mesh.userData.waterExclude = true;
|
|
112
|
+
|
|
113
|
+
const bloomSources = [];
|
|
114
|
+
const counts = { fireflies: 0, leaves: 0, mist: 0, petals: 0, pollen: 0 };
|
|
115
|
+
const gateWeights = { fireflies: 1, leaves: 1, mist: 1, petals: 1, pollen: 1 };
|
|
116
|
+
const emissionCenter = { x: 0, z: 0 };
|
|
117
|
+
let emittedOnce = false;
|
|
118
|
+
let hour = 13;
|
|
119
|
+
|
|
120
|
+
const effectiveDensity = (id) =>
|
|
121
|
+
(resolved[id].enabled ? resolved[id].density * densityMult[id] : 0);
|
|
122
|
+
|
|
123
|
+
// Soft global budget: expected counts scale down proportionally when the
|
|
124
|
+
// requested densities would blow past maxParticles. Upper-bound estimate —
|
|
125
|
+
// masks and water margins only ever emit fewer.
|
|
126
|
+
const budgetScale = () => {
|
|
127
|
+
const area = Math.PI * windowRadius * windowRadius;
|
|
128
|
+
let expected = 0;
|
|
129
|
+
for (const id of AMBIENTFX_EFFECT_IDS) {
|
|
130
|
+
const density = effectiveDensity(id);
|
|
131
|
+
if (!(density > 0)) continue;
|
|
132
|
+
const e = resolved[id];
|
|
133
|
+
const band = e.emitHeight ?? e.hoverHeight ?? [0, 1.2];
|
|
134
|
+
expected += density * area * Math.max(Math.abs(band[1] - band[0]), 0.25);
|
|
135
|
+
if (id === 'petals' || id === 'leaves') {
|
|
136
|
+
const canopyDensity = (e.canopyDensity ?? 0) * densityMult[id];
|
|
137
|
+
for (const source of bloomSources) {
|
|
138
|
+
if ((source.effect ?? 'petals') !== id) continue;
|
|
139
|
+
expected += canopyDensity * (2 / 3) * Math.PI * source.radius ** 3;
|
|
140
|
+
}
|
|
141
|
+
}
|
|
142
|
+
}
|
|
143
|
+
return expected > shared.maxParticles ? shared.maxParticles / expected : 1;
|
|
144
|
+
};
|
|
145
|
+
|
|
146
|
+
const emitAll = (center) => {
|
|
147
|
+
emissionCenter.x = Number(center.x) || 0;
|
|
148
|
+
emissionCenter.z = Number(center.z) || 0;
|
|
149
|
+
const scale = budgetScale();
|
|
150
|
+
const records = { cutout: [], glow: [], soft: [] };
|
|
151
|
+
|
|
152
|
+
const makeRecord = (id, e, x, y, z, rng, extra, fields) => {
|
|
153
|
+
colorA.setRGB(...(e.colorA ?? e.color), THREE.SRGBColorSpace);
|
|
154
|
+
colorB.setRGB(...(e.colorB ?? e.color), THREE.SRGBColorSpace);
|
|
155
|
+
if (extra?.color) colorA.setRGB(...extra.color, THREE.SRGBColorSpace).lerp(colorB, 0.1);
|
|
156
|
+
colorMix.copy(colorA).lerp(colorB, rng());
|
|
157
|
+
records[GROUP_FOR_KIND[id]].push({
|
|
158
|
+
gateJitter: rng(),
|
|
159
|
+
kind: EFFECT_KIND[id],
|
|
160
|
+
phase: rng(),
|
|
161
|
+
seed: rng(),
|
|
162
|
+
size: lerp(e.sizeRange[0], e.sizeRange[1], rng()),
|
|
163
|
+
windResponse: e.windResponse * (0.8 + 0.4 * rng()),
|
|
164
|
+
x, y, z,
|
|
165
|
+
r: colorMix.r, g: colorMix.g, b: colorMix.b,
|
|
166
|
+
...fields(rng),
|
|
167
|
+
});
|
|
168
|
+
};
|
|
169
|
+
|
|
170
|
+
const fallEmit = (id) => (x, y, z, rng, extra) => {
|
|
171
|
+
const e = resolved[id];
|
|
172
|
+
makeRecord(id, e, x, y, z, rng, extra, (r) => ({
|
|
173
|
+
range: Math.min(Math.max(y - Number(heightAt(x, z) || 0) + 0.4, 1.5), 60),
|
|
174
|
+
rate: (id === 'petals' ? e.flutter : e.tumble) * (0.75 + 0.5 * r()),
|
|
175
|
+
}));
|
|
176
|
+
};
|
|
177
|
+
const hoverEmit = (id) => (x, y, z, rng, extra) => {
|
|
178
|
+
const e = resolved[id];
|
|
179
|
+
makeRecord(id, e, x, y, z, rng, extra, (r) => (id === 'fireflies'
|
|
180
|
+
? { range: e.hoverRadius * (0.6 + 0.8 * r()), rate: e.blinkSpeed * (0.7 + 0.6 * r()) }
|
|
181
|
+
: { range: e.driftRadius * (0.5 + r()), rate: 0.8 + 1.2 * r() }));
|
|
182
|
+
};
|
|
183
|
+
const mistEmit = (x, y, z, rng, extra) => {
|
|
184
|
+
const e = resolved.mist;
|
|
185
|
+
makeRecord('mist', e, x, y, z, rng, extra, (r) => ({
|
|
186
|
+
range: e.scrollSpan * (0.7 + 0.6 * r()),
|
|
187
|
+
rate: 0.25 + 0.3 * r(),
|
|
188
|
+
}));
|
|
189
|
+
};
|
|
190
|
+
|
|
191
|
+
const common = (id) => ({
|
|
192
|
+
bounds: worldBounds,
|
|
193
|
+
center: emissionCenter,
|
|
194
|
+
heightAt,
|
|
195
|
+
kindId: EFFECT_KIND[id],
|
|
196
|
+
radius: windowRadius,
|
|
197
|
+
seed: worldSeed,
|
|
198
|
+
});
|
|
199
|
+
|
|
200
|
+
// Petals/leaves: canopy-bound wherever bloom volumes registered for
|
|
201
|
+
// them exist; the open-air fall band is the global fallback.
|
|
202
|
+
for (const id of ['petals', 'leaves']) {
|
|
203
|
+
counts[id] = 0;
|
|
204
|
+
const density = effectiveDensity(id);
|
|
205
|
+
if (!(density > 0)) continue;
|
|
206
|
+
const sources = bloomSources.filter((s) => (s.effect ?? 'petals') === id);
|
|
207
|
+
if (sources.length > 0) {
|
|
208
|
+
counts[id] = emitCanopyBound({
|
|
209
|
+
...common(id),
|
|
210
|
+
density: resolved[id].canopyDensity * densityMult[id] * scale,
|
|
211
|
+
emit: fallEmit(id),
|
|
212
|
+
sources,
|
|
213
|
+
});
|
|
214
|
+
} else {
|
|
215
|
+
counts[id] = emitGlobalVolume({
|
|
216
|
+
...common(id),
|
|
217
|
+
band: resolved[id].emitHeight,
|
|
218
|
+
density: density * scale,
|
|
219
|
+
emit: fallEmit(id),
|
|
220
|
+
mask: effectMasks[id],
|
|
221
|
+
});
|
|
222
|
+
}
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
// Fireflies over grass/shore: a host mask wins; otherwise dry land near
|
|
226
|
+
// the ground (they never hover over open deep water).
|
|
227
|
+
counts.fireflies = effectiveDensity('fireflies') > 0
|
|
228
|
+
? emitGlobalVolume({
|
|
229
|
+
...common('fireflies'),
|
|
230
|
+
band: resolved.fireflies.hoverHeight,
|
|
231
|
+
density: effectiveDensity('fireflies') * scale,
|
|
232
|
+
emit: hoverEmit('fireflies'),
|
|
233
|
+
mask: effectMasks.fireflies ?? (Number.isFinite(waterLevel)
|
|
234
|
+
? (x, z) => Number(heightAt(x, z) || 0) > waterLevel + 0.05
|
|
235
|
+
: null),
|
|
236
|
+
})
|
|
237
|
+
: 0;
|
|
238
|
+
|
|
239
|
+
counts.pollen = effectiveDensity('pollen') > 0
|
|
240
|
+
? emitGlobalVolume({
|
|
241
|
+
...common('pollen'),
|
|
242
|
+
band: resolved.pollen.hoverHeight,
|
|
243
|
+
density: effectiveDensity('pollen') * scale,
|
|
244
|
+
emit: hoverEmit('pollen'),
|
|
245
|
+
mask: effectMasks.pollen,
|
|
246
|
+
})
|
|
247
|
+
: 0;
|
|
248
|
+
|
|
249
|
+
// Mist hugs water margins when the world has a waterline, low ground
|
|
250
|
+
// otherwise. Band is thin — wisps are a floor treatment, not a wall.
|
|
251
|
+
counts.mist = 0;
|
|
252
|
+
if (effectiveDensity('mist') > 0) {
|
|
253
|
+
const mistOptions = {
|
|
254
|
+
...common('mist'),
|
|
255
|
+
band: [0.1, 0.8],
|
|
256
|
+
density: effectiveDensity('mist') * scale,
|
|
257
|
+
emit: mistEmit,
|
|
258
|
+
mask: effectMasks.mist,
|
|
259
|
+
};
|
|
260
|
+
counts.mist = Number.isFinite(waterLevel)
|
|
261
|
+
? emitWaterMargin({ ...mistOptions, margin: resolved.mist.marginWidth, waterLevel })
|
|
262
|
+
: emitGlobalVolume(mistOptions);
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
for (const [groupId, group] of Object.entries(backbone.groups)) {
|
|
266
|
+
group.setInstances(records[groupId]);
|
|
267
|
+
}
|
|
268
|
+
emittedOnce = true;
|
|
269
|
+
};
|
|
270
|
+
|
|
271
|
+
const applyGates = () => {
|
|
272
|
+
for (const id of AMBIENTFX_EFFECT_IDS) {
|
|
273
|
+
gateWeights[id] = timeGateWeight(resolved[id].gate, hour);
|
|
274
|
+
}
|
|
275
|
+
u.uGatePetals.value = gateWeights.petals;
|
|
276
|
+
u.uGateLeaves.value = gateWeights.leaves;
|
|
277
|
+
u.uGateFireflies.value = gateWeights.fireflies;
|
|
278
|
+
u.uGatePollen.value = gateWeights.pollen;
|
|
279
|
+
u.uGateMist.value = gateWeights.mist;
|
|
280
|
+
};
|
|
281
|
+
|
|
282
|
+
const resolveHourOption = (value) => {
|
|
283
|
+
if (typeof value === 'function') return Number(value());
|
|
284
|
+
if (value && typeof value === 'object') return Number(value.hour);
|
|
285
|
+
return Number(value);
|
|
286
|
+
};
|
|
287
|
+
const initialHour = resolveHourOption(timeOfDay);
|
|
288
|
+
hour = Number.isFinite(initialHour) ? initialHour : 13;
|
|
289
|
+
applyGates();
|
|
290
|
+
|
|
291
|
+
const resolveFocus = (camera) => {
|
|
292
|
+
const target = followTarget;
|
|
293
|
+
if (typeof target === 'function') {
|
|
294
|
+
const result = target(focusScratch);
|
|
295
|
+
if (result && Number.isFinite(result.x)) return result;
|
|
296
|
+
} else if (target?.isObject3D) {
|
|
297
|
+
return target.getWorldPosition(focusScratch);
|
|
298
|
+
} else if (target && Number.isFinite(target.x)) {
|
|
299
|
+
return focusScratch.set(target.x, target.y ?? 0, target.z ?? 0);
|
|
300
|
+
}
|
|
301
|
+
if (camera?.isObject3D) return camera.getWorldPosition(focusScratch);
|
|
302
|
+
return focusScratch.set(emissionCenter.x, 0, emissionCenter.z);
|
|
303
|
+
};
|
|
304
|
+
|
|
305
|
+
const fx = {
|
|
306
|
+
root,
|
|
307
|
+
|
|
308
|
+
/** Live emission/window state (also what verify-ambientfx.mjs asserts). */
|
|
309
|
+
get stats() {
|
|
310
|
+
return {
|
|
311
|
+
byEffect: { ...counts },
|
|
312
|
+
capacity: Object.values(backbone.groups).reduce((sum, g) => sum + g.capacity, 0),
|
|
313
|
+
center: { ...emissionCenter },
|
|
314
|
+
drawCalls: Object.values(backbone.groups).filter((g) => g.count > 0).length,
|
|
315
|
+
gateWeights: { ...gateWeights },
|
|
316
|
+
hour,
|
|
317
|
+
liveParticles: Object.values(counts).reduce((sum, n) => sum + n, 0),
|
|
318
|
+
wind: {
|
|
319
|
+
direction: windDriftVector(shared.windDirection),
|
|
320
|
+
speed: shared.windSpeed,
|
|
321
|
+
strength: shared.windStrength,
|
|
322
|
+
},
|
|
323
|
+
windowRadius,
|
|
324
|
+
};
|
|
325
|
+
},
|
|
326
|
+
|
|
327
|
+
settings: resolved,
|
|
328
|
+
|
|
329
|
+
/**
|
|
330
|
+
* Per frame. Ticks time, follows the target (or the camera when no
|
|
331
|
+
* target is set), and re-emits the window when the focus has strayed
|
|
332
|
+
* past RECENTER_FRACTION of the radius — world-anchored cells keep the
|
|
333
|
+
* overlap bit-identical, so the swap is invisible.
|
|
334
|
+
*/
|
|
335
|
+
update(delta, camera) {
|
|
336
|
+
u.uTime.value += Math.min(Math.max(delta ?? 0.016, 0), 0.1);
|
|
337
|
+
if (typeof timeOfDay === 'function') {
|
|
338
|
+
const next = Number(timeOfDay());
|
|
339
|
+
if (Number.isFinite(next) && Math.abs(next - hour) > 1e-3) {
|
|
340
|
+
hour = next;
|
|
341
|
+
applyGates();
|
|
342
|
+
}
|
|
343
|
+
}
|
|
344
|
+
const focus = resolveFocus(camera);
|
|
345
|
+
u.uCenter.value.set(focus.x, focus.z);
|
|
346
|
+
if (camera?.isObject3D) {
|
|
347
|
+
// Billboard basis from the camera, CPU-side — backend-agnostic.
|
|
348
|
+
const e = camera.matrixWorld.elements;
|
|
349
|
+
u.uCamRight.value.set(e[0], e[1], e[2]);
|
|
350
|
+
u.uCamUp.value.set(e[4], e[5], e[6]);
|
|
351
|
+
}
|
|
352
|
+
const dx = focus.x - emissionCenter.x;
|
|
353
|
+
const dz = focus.z - emissionCenter.z;
|
|
354
|
+
const stray = windowRadius * RECENTER_FRACTION;
|
|
355
|
+
if (!emittedOnce || dx * dx + dz * dz > stray * stray) emitAll(focus);
|
|
356
|
+
return fx;
|
|
357
|
+
},
|
|
358
|
+
|
|
359
|
+
/**
|
|
360
|
+
* Shares the world wind. Accepts the grass-shaped settings object
|
|
361
|
+
* `{ windDirection: [x, z], windSpeed, windStrength }` or the shorthand
|
|
362
|
+
* `(headingRadians, strength)` — heading is CCW from +X, so
|
|
363
|
+
* `setWind(0, 1)` blows toward +X and `setWind(Math.PI / 2, 1)` toward +Z.
|
|
364
|
+
*/
|
|
365
|
+
setWind(directionOrSettings, strength) {
|
|
366
|
+
if (typeof directionOrSettings === 'number') {
|
|
367
|
+
shared.windDirection = [Math.cos(directionOrSettings), Math.sin(directionOrSettings)];
|
|
368
|
+
if (Number.isFinite(strength)) shared.windStrength = Math.max(strength, 0);
|
|
369
|
+
} else {
|
|
370
|
+
const options = cleanObject(directionOrSettings);
|
|
371
|
+
if (Array.isArray(options.windDirection) && options.windDirection.length >= 2) {
|
|
372
|
+
shared.windDirection = [Number(options.windDirection[0]) || 0, Number(options.windDirection[1]) || 0];
|
|
373
|
+
}
|
|
374
|
+
if (Number.isFinite(options.windSpeed)) shared.windSpeed = options.windSpeed;
|
|
375
|
+
if (Number.isFinite(options.windStrength)) shared.windStrength = Math.max(options.windStrength, 0);
|
|
376
|
+
}
|
|
377
|
+
u.uWindDir.value.set(shared.windDirection[0], shared.windDirection[1]);
|
|
378
|
+
u.uWindSpeed.value = shared.windSpeed;
|
|
379
|
+
u.uWindStrength.value = shared.windStrength;
|
|
380
|
+
return fx;
|
|
381
|
+
},
|
|
382
|
+
|
|
383
|
+
/** Hour 0–24; fireflies/pollen/mist gates follow environmentTimeOfDay. */
|
|
384
|
+
setTimeOfDay(hours) {
|
|
385
|
+
const next = resolveHourOption(hours);
|
|
386
|
+
if (Number.isFinite(next)) {
|
|
387
|
+
hour = next;
|
|
388
|
+
applyGates();
|
|
389
|
+
}
|
|
390
|
+
return fx;
|
|
391
|
+
},
|
|
392
|
+
|
|
393
|
+
/** Aims the pollen backlight / petal sheen — match the sun rig. */
|
|
394
|
+
setSun({ direction } = {}) {
|
|
395
|
+
if (Array.isArray(direction) && direction.length >= 3) {
|
|
396
|
+
u.uSunDirection.value.set(direction[0], direction[1], direction[2]).normalize();
|
|
397
|
+
} else if (direction?.isVector3) {
|
|
398
|
+
u.uSunDirection.value.copy(direction).normalize();
|
|
399
|
+
}
|
|
400
|
+
return fx;
|
|
401
|
+
},
|
|
402
|
+
|
|
403
|
+
/**
|
|
404
|
+
* Joins every particle to the environment shader's height-fog layer —
|
|
405
|
+
* same parameters and formula as forest.setDistanceFog, so petals haze
|
|
406
|
+
* out with the terrain instead of floating on it. Density 0 disables.
|
|
407
|
+
*/
|
|
408
|
+
setDistanceFog({ color, density, falloff, floorY } = {}) {
|
|
409
|
+
if (density !== undefined) u.uFogDensity.value = Math.max(Number(density) || 0, 0);
|
|
410
|
+
if (falloff !== undefined) u.uFogFalloff.value = Math.max(Number(falloff) || 0, 0.001);
|
|
411
|
+
if (floorY !== undefined) u.uFogFloorY.value = Number(floorY) || 0;
|
|
412
|
+
if (color !== undefined) {
|
|
413
|
+
const next = Array.isArray(color) ? new THREE.Color(...color) : new THREE.Color(color);
|
|
414
|
+
u.uFogColor.value.copy(next);
|
|
415
|
+
}
|
|
416
|
+
return fx;
|
|
417
|
+
},
|
|
418
|
+
|
|
419
|
+
/**
|
|
420
|
+
* Registers bloom volumes — `{ x, y, z, radius, color?, effect? }` per
|
|
421
|
+
* emitters.js — and rebinds petals/leaves to them. Tree canopies are the
|
|
422
|
+
* intended source; any sphere works.
|
|
423
|
+
*/
|
|
424
|
+
addBloomSources(list = []) {
|
|
425
|
+
for (const source of list) {
|
|
426
|
+
if (!source || !Number.isFinite(Number(source.x))) continue;
|
|
427
|
+
bloomSources.push({
|
|
428
|
+
color: Array.isArray(source.color) ? source.color.slice(0, 3) : null,
|
|
429
|
+
effect: source.effect === 'leaves' ? 'leaves' : 'petals',
|
|
430
|
+
radius: Math.max(Number(source.radius) || 1.5, 0.4),
|
|
431
|
+
x: Number(source.x), y: Number(source.y) || 0, z: Number(source.z) || 0,
|
|
432
|
+
});
|
|
433
|
+
}
|
|
434
|
+
if (emittedOnce) emitAll(emissionCenter);
|
|
435
|
+
return fx;
|
|
436
|
+
},
|
|
437
|
+
|
|
438
|
+
/** Re-emits in place — call after terrain/mask edits under the window. */
|
|
439
|
+
refresh() {
|
|
440
|
+
if (emittedOnce) emitAll(emissionCenter);
|
|
441
|
+
return fx;
|
|
442
|
+
},
|
|
443
|
+
|
|
444
|
+
dispose() {
|
|
445
|
+
for (const group of Object.values(backbone.groups)) group.dispose();
|
|
446
|
+
root.parent?.remove(root);
|
|
447
|
+
},
|
|
448
|
+
};
|
|
449
|
+
return fx;
|
|
450
|
+
}
|
|
@@ -0,0 +1,117 @@
|
|
|
1
|
+
// CC0 asset source: ambientCG (ambientcg.com) — PBR material sets, CC0.
|
|
2
|
+
//
|
|
3
|
+
// The ambientCG API sends no CORS headers, so browsers reach it through a
|
|
4
|
+
// backend route: the Vite dev server proxies AMBIENTCG_PROXY_API →
|
|
5
|
+
// /api/v2/full_json and AMBIENTCG_PROXY_GET → /get (see vite.config.js);
|
|
6
|
+
// any future hosted backend implements the same two paths. Node callers
|
|
7
|
+
// (MCP tools, scripts) hit ambientcg.com directly — pass the default urls.
|
|
8
|
+
//
|
|
9
|
+
// Downloads are ZIP archives (e.g. Bricks097_1K-JPG.zip) — there is no
|
|
10
|
+
// per-map file URL; extract with src/assetlib/zip.js (browser/Node) or
|
|
11
|
+
// node:zlib. /get 302-redirects to their CDN, so proxies must follow
|
|
12
|
+
// redirects server-side.
|
|
13
|
+
|
|
14
|
+
import { ASSETLIB_USER_AGENT } from './assetRef.js';
|
|
15
|
+
|
|
16
|
+
export const AMBIENTCG_API_URL = 'https://ambientcg.com/api/v2/full_json';
|
|
17
|
+
export const AMBIENTCG_GET_URL = 'https://ambientcg.com/get';
|
|
18
|
+
|
|
19
|
+
/** Backend/dev-proxy routes the browser uses instead of ambientcg.com. */
|
|
20
|
+
export const AMBIENTCG_PROXY_API = '/api/ambientcg';
|
|
21
|
+
export const AMBIENTCG_PROXY_GET = '/api/ambientcg-get';
|
|
22
|
+
|
|
23
|
+
/** Absolute ambientCG download url → same request through the proxy route.
|
|
24
|
+
* Recipes always store the ORIGINAL url (portable across Node/browser);
|
|
25
|
+
* rewrite only at fetch time. */
|
|
26
|
+
export function rewriteAmbientcgDownloadUrl(url, base = AMBIENTCG_PROXY_GET) {
|
|
27
|
+
return String(url ?? '').replace(AMBIENTCG_GET_URL, base);
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
export const AMBIENTCG_ATTRIBUTION = Object.freeze({
|
|
31
|
+
license: 'CC0',
|
|
32
|
+
sourceLabel: 'ambientCG',
|
|
33
|
+
sourceUrl: 'https://ambientcg.com',
|
|
34
|
+
});
|
|
35
|
+
|
|
36
|
+
const KIND_BY_DATA_TYPE = { HDRI: 'hdri', Material: 'texture', Model: 'model' };
|
|
37
|
+
|
|
38
|
+
/** One API page (`foundAssets`) → normalized refs with a `downloads` list. */
|
|
39
|
+
export function normalizeAmbientcgAssets(payload) {
|
|
40
|
+
const refs = [];
|
|
41
|
+
for (const asset of payload?.foundAssets ?? []) {
|
|
42
|
+
const kind = KIND_BY_DATA_TYPE[asset.dataType];
|
|
43
|
+
if (!kind) continue;
|
|
44
|
+
const downloads = [];
|
|
45
|
+
for (const folder of Object.values(asset.downloadFolders ?? {})) {
|
|
46
|
+
for (const group of Object.values(folder.downloadFiletypeCategories ?? {})) {
|
|
47
|
+
for (const download of group.downloads ?? []) {
|
|
48
|
+
downloads.push({
|
|
49
|
+
attribute: download.attribute,
|
|
50
|
+
fileName: download.fileName,
|
|
51
|
+
filetype: download.filetype,
|
|
52
|
+
sizeBytes: download.size ?? 0,
|
|
53
|
+
url: download.downloadLink,
|
|
54
|
+
});
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
refs.push({
|
|
59
|
+
attribution: AMBIENTCG_ATTRIBUTION,
|
|
60
|
+
authors: [],
|
|
61
|
+
categories: [asset.displayCategory, asset.category]
|
|
62
|
+
.filter(Boolean)
|
|
63
|
+
.map((value) => String(value).toLowerCase()),
|
|
64
|
+
downloads,
|
|
65
|
+
id: asset.assetId,
|
|
66
|
+
kind,
|
|
67
|
+
name: asset.displayName || asset.assetId,
|
|
68
|
+
pageUrl: asset.shortLink ?? `https://ambientcg.com/a/${encodeURIComponent(asset.assetId)}`,
|
|
69
|
+
source: 'ambientcg',
|
|
70
|
+
tags: (asset.tags ?? []).map((value) => String(value).toLowerCase()),
|
|
71
|
+
thumbnailUrl: asset.previewImage?.['256-PNG'] ?? Object.values(asset.previewImage ?? {})[0] ?? null,
|
|
72
|
+
});
|
|
73
|
+
}
|
|
74
|
+
return refs;
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
/** Remote search (the corpus is too big to index locally like Poly Haven's).
|
|
78
|
+
* Pass `id` for an exact asset lookup — the free-text `q` search does NOT
|
|
79
|
+
* match asset ids like "Bricks097". */
|
|
80
|
+
export async function searchAmbientcg({
|
|
81
|
+
query = '',
|
|
82
|
+
id = null,
|
|
83
|
+
type = 'Material',
|
|
84
|
+
limit = 50,
|
|
85
|
+
offset = 0,
|
|
86
|
+
apiUrl = AMBIENTCG_API_URL,
|
|
87
|
+
fetchImpl = fetch,
|
|
88
|
+
// identify per ToS from Node; browsers cannot set UA (the proxy's origin
|
|
89
|
+
// request identifies the app instead)
|
|
90
|
+
userAgent = typeof window === 'undefined' ? ASSETLIB_USER_AGENT : null,
|
|
91
|
+
} = {}) {
|
|
92
|
+
const params = new URLSearchParams({
|
|
93
|
+
include: 'imageData,downloadData,tagData,displayData',
|
|
94
|
+
limit: String(limit),
|
|
95
|
+
offset: String(offset),
|
|
96
|
+
});
|
|
97
|
+
if (!id) params.set('type', type);
|
|
98
|
+
if (id) params.set('id', id);
|
|
99
|
+
else if (query) params.set('q', query);
|
|
100
|
+
const response = await fetchImpl(`${apiUrl}?${params}`, {
|
|
101
|
+
headers: userAgent ? { 'user-agent': userAgent } : {},
|
|
102
|
+
});
|
|
103
|
+
if (!response.ok) throw new Error(`ambientCG API: ${response.status}`);
|
|
104
|
+
return normalizeAmbientcgAssets(await response.json());
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
/**
|
|
108
|
+
* Pick a ZIP download from a normalized ref: exact `${resolution}-${format}`
|
|
109
|
+
* attribute match (e.g. "1K-JPG"), else the smallest zip.
|
|
110
|
+
*/
|
|
111
|
+
export function resolveAmbientcgDownload(ref, { resolution = '1K', format = 'JPG' } = {}) {
|
|
112
|
+
const zips = (ref?.downloads ?? []).filter((download) => download.filetype === 'zip');
|
|
113
|
+
if (zips.length === 0) throw new Error(`resolveAmbientcgDownload: no zip downloads on "${ref?.id}".`);
|
|
114
|
+
const wanted = `${resolution}-${format}`.toUpperCase();
|
|
115
|
+
const exact = zips.find((download) => (download.attribute ?? '').toUpperCase() === wanted);
|
|
116
|
+
return exact ?? zips.sort((a, b) => a.sizeBytes - b.sizeBytes)[0];
|
|
117
|
+
}
|
|
@@ -0,0 +1,91 @@
|
|
|
1
|
+
// Normalized third-party asset references — the one shape every CC0 source
|
|
2
|
+
// client (Poly Haven, ambientCG, …) produces, so the lab UI, catalog entries,
|
|
3
|
+
// and the MCP tools filter and display them identically:
|
|
4
|
+
//
|
|
5
|
+
// {
|
|
6
|
+
// source: 'polyhaven', // client id
|
|
7
|
+
// id: 'wooden_crate_01', // source-native id
|
|
8
|
+
// kind: 'model', // model | texture | hdri
|
|
9
|
+
// name: 'Wooden Crate 01',
|
|
10
|
+
// categories: ['furniture'],
|
|
11
|
+
// tags: ['crate', 'wood'],
|
|
12
|
+
// authors: ['Kirill Sannikov'],
|
|
13
|
+
// thumbnailUrl: 'https://…', // the source's own preview render
|
|
14
|
+
// pageUrl: 'https://…', // human page, used for attribution links
|
|
15
|
+
// attribution: { license: 'CC0', sourceLabel: 'Poly Haven', sourceUrl: '…' },
|
|
16
|
+
// disabled: true, // optional moderation flag — CC0 clears
|
|
17
|
+
// // copyright, NOT trademarks/personality
|
|
18
|
+
// // rights; flagged refs never list
|
|
19
|
+
// …source-specific extras (polycount, maxResolution, downloads, …)
|
|
20
|
+
// }
|
|
21
|
+
//
|
|
22
|
+
// Everything here is pure data-in/data-out (no fetch, no three) so it runs
|
|
23
|
+
// in the browser, in Node MCP tools, and in verify scripts unchanged.
|
|
24
|
+
|
|
25
|
+
export const ASSET_REF_KINDS = Object.freeze(['model', 'texture', 'hdri']);
|
|
26
|
+
|
|
27
|
+
/** Identify server-side asset API requests. Browsers cannot override their
|
|
28
|
+
* User-Agent, so browser integrations use the identifying dev/host proxy. */
|
|
29
|
+
export const ASSETLIB_USER_AGENT = 'ToonLab/0.2 (+https://toonlab.io; contact=jack@hyperbond.studio)';
|
|
30
|
+
|
|
31
|
+
/** Source-native ids → catalog-safe id segments ("ArmChair_01" → "armchair-01"). */
|
|
32
|
+
export function slugifyAssetId(id) {
|
|
33
|
+
return String(id ?? '')
|
|
34
|
+
.trim()
|
|
35
|
+
.toLowerCase()
|
|
36
|
+
.replace(/[^a-z0-9]+/g, '-')
|
|
37
|
+
.replace(/^-+|-+$/g, '');
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/**
|
|
41
|
+
* Shared search over normalized refs — mirrors catalog.list() semantics
|
|
42
|
+
* (free text over id/name/tags/categories; exact kind/category match).
|
|
43
|
+
* Refs flagged `disabled` (moderation: trademark/personality-rights concerns
|
|
44
|
+
* CC0 does not clear) are dropped unless `includeDisabled`.
|
|
45
|
+
*/
|
|
46
|
+
export function filterAssetRefs(refs, { text = null, kind = null, category = null, includeDisabled = false } = {}) {
|
|
47
|
+
const query = text ? String(text).toLowerCase() : null;
|
|
48
|
+
const results = [];
|
|
49
|
+
for (const ref of refs ?? []) {
|
|
50
|
+
if (ref.disabled && !includeDisabled) continue;
|
|
51
|
+
if (kind && ref.kind !== kind) continue;
|
|
52
|
+
if (category && !ref.categories.includes(category)) continue;
|
|
53
|
+
if (query) {
|
|
54
|
+
const haystack = `${ref.id} ${ref.name} ${ref.tags.join(' ')} ${ref.categories.join(' ')}`.toLowerCase();
|
|
55
|
+
if (!haystack.includes(query)) continue;
|
|
56
|
+
}
|
|
57
|
+
results.push(ref);
|
|
58
|
+
}
|
|
59
|
+
return results;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
/** Distinct categories across refs, most-used first (the lab's filter chips). */
|
|
63
|
+
export function collectAssetCategories(refs) {
|
|
64
|
+
const counts = new Map();
|
|
65
|
+
for (const ref of refs ?? []) {
|
|
66
|
+
for (const category of ref.categories) {
|
|
67
|
+
counts.set(category, (counts.get(category) ?? 0) + 1);
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
return [...counts.entries()]
|
|
71
|
+
.sort((a, b) => b[1] - a[1] || a[0].localeCompare(b[0]))
|
|
72
|
+
.map(([name]) => name);
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
/**
|
|
76
|
+
* Nearest available resolution key ('1k' | '2k' | …) to the wanted one.
|
|
77
|
+
* Exact match wins; otherwise the closest by pixel count, preferring the
|
|
78
|
+
* smaller side on ties (imports should err cheap).
|
|
79
|
+
*/
|
|
80
|
+
export function pickResolution(available, wanted = '1k') {
|
|
81
|
+
const keys = Object.keys(available ?? {});
|
|
82
|
+
if (keys.length === 0) return null;
|
|
83
|
+
if (available[wanted]) return wanted;
|
|
84
|
+
const parse = (key) => Number.parseInt(key, 10) || 0;
|
|
85
|
+
const target = parse(wanted);
|
|
86
|
+
return keys.sort((a, b) => {
|
|
87
|
+
const da = Math.abs(parse(a) - target);
|
|
88
|
+
const db = Math.abs(parse(b) - target);
|
|
89
|
+
return da - db || parse(a) - parse(b);
|
|
90
|
+
})[0];
|
|
91
|
+
}
|