@call-me-sensei/toonlab 0.1.1 → 0.2.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.
@@ -0,0 +1,500 @@
1
+ import * as THREE from 'three';
2
+
3
+ import {
4
+ applyEnvironmentShader,
5
+ advanceEnvironmentShaderTime,
6
+ setEnvironmentCloudShadow,
7
+ } from './environment/environmentMaterialAdapter.js';
8
+ import { resolveEnvironmentPreset } from './environment/environmentPresets.js';
9
+ import { createEnvironmentSunRig } from './environment/environmentRigs.js';
10
+ import { createEnvironmentSunShadowPass } from './environment/environmentSunShadowPass.js';
11
+ import { WaterSurface } from './water/waterSurface.js';
12
+ import { StylizedSky } from './sky/stylizedSky.js';
13
+ import { StylizedGrassField } from './vegetation/stylizedGrass.js';
14
+ import { StylizedFlowerField } from './vegetation/stylizedFlowers.js';
15
+ import { StylizedForest } from './vegetation/stylizedForest.js';
16
+ import {
17
+ combineMasks,
18
+ createSlopeMask,
19
+ createWaterMask,
20
+ scatterForest,
21
+ scatterGrassAround,
22
+ } from './vegetation/scatter.js';
23
+ import { resolveWorldPreset } from './worldPresets.js';
24
+ import { createWorldCollision } from './worldCollision.js';
25
+
26
+ // The composed golden path: one call assembles a complete stylized outdoor
27
+ // world — environment-shaded terrain, sun rig, sky dome, interactive water,
28
+ // scattered trees/grass/flowers, shared cloud shadows — from a world preset,
29
+ // and returns a single update(delta) for the render loop. Import from
30
+ // '@call-me-sensei/toonlab'.
31
+ //
32
+ // The look of this library does not live in any single system; it emerges
33
+ // from the assembly (the sky feeds the water's reflections, the sun rig and
34
+ // fog set the palette, cloud shadows tie ground/canopy/water together). This
35
+ // function IS that assembly, so a host — human or coding agent — cannot
36
+ // accidentally ship the geometry without the look.
37
+ //
38
+ // const world = await createStylizedWorld({
39
+ // renderer, scene, camera,
40
+ // terrain: { root: terrainMesh, heightAt, size: { width: 2000, depth: 2000 } },
41
+ // water: { level: 8 },
42
+ // followTarget: characterRoot, // ripples, wakes, grass push-away
43
+ // });
44
+ //
45
+ // // render loop:
46
+ // world.update(delta);
47
+ // renderer.render(scene, camera); // or post.render(delta)
48
+ //
49
+ // Character/toon shading stays host-owned (applyToonShader +
50
+ // createCharacterRenderPasses), as does post-processing — both compose with
51
+ // this world untouched.
52
+
53
+ function cleanObject(value) {
54
+ return value && typeof value === 'object' && !Array.isArray(value) ? value : {};
55
+ }
56
+
57
+ /**
58
+ * Assembles a stylized outdoor world from a world preset.
59
+ *
60
+ * @param {Object} options
61
+ * @param {THREE.Renderer} options.renderer Host renderer (water passes need it).
62
+ * @param {THREE.Scene} options.scene Host scene; systems are added to it.
63
+ * @param {THREE.PerspectiveCamera} [options.camera] When given (and
64
+ * `applyCamera` isn't false), the preset's near/far/fov are applied.
65
+ * @param {Object} options.terrain `{ root, heightAt, size }` — `root` is the
66
+ * Object3D converted by the environment shader (required); `heightAt(x, z)`
67
+ * drives water bed, scatter placement, and slope/water masks (strongly
68
+ * recommended); `size` is `{ width, depth }` in meters for water extent and
69
+ * scatter radius defaults.
70
+ * @param {string} [options.preset] World preset name (default 'outdoorGameplay').
71
+ * @param {Object|false} [options.water] `{ level, width, depth, settings }`,
72
+ * or `false` to skip water.
73
+ * @param {Object|false} [options.trees] `{ scatter, settings, center }`
74
+ * overrides over the world preset, or `false` to skip.
75
+ * @param {Object|false} [options.grass] Same shape as trees, or `false`.
76
+ * @param {Object|false} [options.flowers] `{ scatter, settings }`; off unless given.
77
+ * @param {Object3D} [options.followTarget] Character root: water ripple
78
+ * window + interactor splashes/wakes, grass push-away.
79
+ * @param {Object|false} [options.cloudShadows] `{ strength, coverage, scale,
80
+ * velocity }` shared across terrain, water, grass, and trees.
81
+ * @param {boolean} [options.sun] Build the environment sun rig (default true).
82
+ * @param {boolean} [options.applyCamera] Apply preset camera planes (default true).
83
+ * @param {Object} [options.environment] Extra `applyEnvironmentShader`
84
+ * options merged last (features/parameters/roleOverrides/...).
85
+ * @returns {Promise<Object>} `{ update, dispose, sky, water, sunRig, grass,
86
+ * flowerField, trees, treeGroup, masks, worldPreset, classification }`
87
+ */
88
+ export async function createStylizedWorld({
89
+ renderer,
90
+ scene,
91
+ camera = null,
92
+ terrain,
93
+ preset = 'outdoorGameplay',
94
+ water = {},
95
+ sky: skyOptions = {},
96
+ fog = {},
97
+ shadows = {},
98
+ trees = {},
99
+ grass = {},
100
+ flowers = false,
101
+ followTarget = null,
102
+ cloudShadows = { strength: 0.35 },
103
+ sun = true,
104
+ applyCamera = true,
105
+ environment = {},
106
+ } = {}) {
107
+ if (!renderer || !scene) throw new Error('createStylizedWorld needs { renderer, scene }.');
108
+ const terrainRoot = terrain?.root ?? null;
109
+ if (!terrainRoot) throw new Error('createStylizedWorld needs terrain.root (the Object3D to shade).');
110
+ const heightAt = typeof terrain?.heightAt === 'function' ? terrain.heightAt : null;
111
+
112
+ const worldPreset = resolveWorldPreset(preset);
113
+ if (!worldPreset) throw new Error(`Unknown world preset "${preset}".`);
114
+
115
+ // terrain.size accepts a number (square), { width, depth }, or { x, z }
116
+ // (the shape createStylizedTerrain returns as meshExtent) — silently
117
+ // defaulting on an unrecognized shape mis-sizes the water plane.
118
+ const sizeOption = terrain?.size;
119
+ const width = Number(sizeOption?.width ?? sizeOption?.x ?? sizeOption) || 2000;
120
+ const depth = Number(sizeOption?.depth ?? sizeOption?.z ?? sizeOption) || 2000;
121
+ const halfExtent = Math.min(width, depth) / 2;
122
+
123
+ // Camera planes and fov are part of the scale contract.
124
+ if (camera && applyCamera !== false && worldPreset.camera) {
125
+ if (Number.isFinite(worldPreset.camera.fov)) camera.fov = worldPreset.camera.fov;
126
+ if (Number.isFinite(worldPreset.camera.near)) camera.near = worldPreset.camera.near;
127
+ if (Number.isFinite(worldPreset.camera.far)) camera.far = worldPreset.camera.far;
128
+ camera.updateProjectionMatrix();
129
+ }
130
+
131
+ // Environment shading over the terrain (and anything parented under it).
132
+ const environmentBox = new THREE.Box3().setFromObject(terrainRoot);
133
+ const environmentPreset = resolveEnvironmentPreset(worldPreset.environment?.preset ?? 'default') ?? {};
134
+ const environmentOverrides = cleanObject(worldPreset.environment?.overrides);
135
+ const classification = await applyEnvironmentShader(terrainRoot, {
136
+ ...environmentPreset,
137
+ ...environmentOverrides,
138
+ ...cleanObject(environment),
139
+ environmentBox,
140
+ hasSun: sun !== false,
141
+ parameters: {
142
+ ...cleanObject(environmentPreset.parameters),
143
+ ...cleanObject(environmentOverrides.parameters),
144
+ ...cleanObject(cleanObject(environment).parameters),
145
+ },
146
+ });
147
+
148
+ // Keep the light rig aligned with the sky's visible sun — a sky sun in one
149
+ // place and light from another reads flat and wrong, and near-vertical
150
+ // light hides every cast shadow under its caster.
151
+ const skySunDirection = cleanObject(cleanObject(skyOptions).settings).sunDirection
152
+ ?? cleanObject(worldPreset.sky?.settings).sunDirection;
153
+ // Unified atmosphere: scene.fog reaches every material class — converted
154
+ // environment surfaces, unlit forest impostors, rock clones — so distant
155
+ // geometry fades into haze silhouettes together. (The environment
156
+ // shader's height fog layers altitude nuance on top; without scene.fog,
157
+ // impostor trees float sharp and saturated at any distance.)
158
+ let sceneFog = null;
159
+ if (fog !== false) {
160
+ const fogOptions = cleanObject(fog);
161
+ const fogColor = fogOptions.color
162
+ ?? cleanObject(environmentOverrides.parameters).heightFogColor
163
+ ?? cleanObject(cleanObject(environment).parameters).heightFogColor
164
+ ?? [0.72, 0.83, 0.94];
165
+ const presetFar = Number(worldPreset.camera?.far) || 600;
166
+ sceneFog = new THREE.Fog(
167
+ new THREE.Color(...(Array.isArray(fogColor) ? fogColor : [fogColor])),
168
+ Number(fogOptions.near) || presetFar * 0.3,
169
+ Number(fogOptions.far) || presetFar * 1.4,
170
+ );
171
+ scene.fog = sceneFog;
172
+ }
173
+
174
+ // Resolved height-fog parameters (preset overrides, then host overrides).
175
+ // Water and forest impostors mirror this layer — any surface that skips it
176
+ // stays sharp against hazed terrain and reads as pasted-on.
177
+ const heightFogParams = {
178
+ ...cleanObject(environmentOverrides.parameters),
179
+ ...cleanObject(cleanObject(environment).parameters),
180
+ };
181
+
182
+ const sunRig = sun !== false
183
+ ? createEnvironmentSunRig({
184
+ environmentBox,
185
+ scene,
186
+ ...(Array.isArray(skySunDirection) ? {
187
+ sourceRatios: {
188
+ x: skySunDirection[0],
189
+ y: Math.max(skySunDirection[1], 0.2),
190
+ z: skySunDirection[2],
191
+ },
192
+ } : {}),
193
+ })
194
+ : null;
195
+
196
+ // Shadows are ON by default — a character that casts nothing on the grass
197
+ // is the fastest way for a stylized world to read fake. The renderer's
198
+ // shadow maps are enabled, everything under the terrain root casts and
199
+ // receives, the follow target casts, and — critically — the sun's shadow
200
+ // camera is shrunk to a window (default 140 m) that FOLLOWS the target:
201
+ // one map stretched across a whole open world has no texel density left
202
+ // for a character-sized shadow. Pass `shadows: false` to opt out, or
203
+ // `shadows: { area }` to resize the window.
204
+ const shadowOptions = cleanObject(shadows);
205
+ const shadowArea = shadows === false ? 0 : Number(shadowOptions.area) || 140;
206
+ let sunShadowOffset = null;
207
+ if (shadowArea > 0) {
208
+ if (renderer.shadowMap) renderer.shadowMap.enabled = true;
209
+ terrainRoot.traverse((object) => {
210
+ if (object.isMesh) {
211
+ object.castShadow = true;
212
+ object.receiveShadow = true;
213
+ }
214
+ });
215
+ followTarget?.traverse?.((object) => {
216
+ if (object.isMesh) object.castShadow = true;
217
+ });
218
+ if (sunRig?.light) {
219
+ const light = sunRig.light;
220
+ const shadowCamera = light.shadow.camera;
221
+ shadowCamera.left = -shadowArea;
222
+ shadowCamera.right = shadowArea;
223
+ shadowCamera.top = shadowArea;
224
+ shadowCamera.bottom = -shadowArea;
225
+ shadowCamera.near = 0.5;
226
+ shadowCamera.far = shadowArea * 6 + 400;
227
+ shadowCamera.updateProjectionMatrix();
228
+ // Tight window + high sun → self-shadow acne on flat ground without a
229
+ // healthy normal bias.
230
+ light.shadow.normalBias = Math.max(light.shadow.normalBias ?? 0, 0.5);
231
+ // Derive the shadow sun's direction from the sky's actual sun vector.
232
+ // (Deriving it from the rig light's box-relative position warps the
233
+ // elevation by the terrain box's aspect ratio — a 2 km × 200 m box
234
+ // squashes a 2 PM sun nearly horizontal, and shadows land tens of
235
+ // meters away from their casters.)
236
+ const direction = Array.isArray(skySunDirection)
237
+ ? new THREE.Vector3(
238
+ skySunDirection[0],
239
+ Math.max(skySunDirection[1], 0.15),
240
+ skySunDirection[2],
241
+ )
242
+ : light.position.clone().sub(environmentBox.getCenter(new THREE.Vector3()));
243
+ sunShadowOffset = direction.normalize().multiplyScalar(shadowArea * 3 + 120);
244
+ scene.add(light.target);
245
+ }
246
+ }
247
+ // On the TSL/WebGPU backends three's shadow system is bypassed by design;
248
+ // this pass renders the sun's shadow map each frame and publishes it to
249
+ // the environment and character shaders. Without it there are NO shadows,
250
+ // regardless of castShadow flags — which is why the composed world owns it.
251
+ const sunShadowPass = shadowArea > 0 && sunRig?.light
252
+ ? createEnvironmentSunShadowPass({ renderer, scene })
253
+ : null;
254
+ const shadowInterval = Math.max(Math.trunc(shadowOptions.interval ?? 2), 1);
255
+ let shadowFrame = 0;
256
+
257
+ const followSunShadow = () => {
258
+ if (!sunShadowOffset || !sunRig?.light) return;
259
+ const focus = followTarget?.position ?? camera?.position;
260
+ if (!focus) return;
261
+ sunRig.light.position.copy(focus).add(sunShadowOffset);
262
+ sunRig.light.target.position.copy(focus);
263
+ sunRig.light.target.updateMatrixWorld();
264
+ };
265
+ followSunShadow();
266
+
267
+ // Sky dome — the water's reflection fallback, so it comes before water.
268
+ const sky = new StylizedSky({
269
+ preset: worldPreset.sky?.preset,
270
+ ...cleanObject(worldPreset.sky?.settings),
271
+ ...cleanObject(cleanObject(skyOptions).settings),
272
+ });
273
+ scene.add(sky);
274
+
275
+ // Water with the terrain as its bed: waves shoal and break on real shores.
276
+ const waterLevel = Number(cleanObject(water).level) || 0;
277
+ let waterSurface = null;
278
+ if (water !== false) {
279
+ const waterOptions = cleanObject(water);
280
+ waterSurface = new WaterSurface({
281
+ preset: worldPreset.water?.preset,
282
+ width: Number(waterOptions.width) || width,
283
+ depth: Number(waterOptions.depth) || depth,
284
+ ...(heightAt ? { bedHeight: heightAt } : {}),
285
+ ...cleanObject(waterOptions.settings),
286
+ });
287
+ waterSurface.position.y = waterLevel;
288
+ // Match the environment's height fog so far-shore water hazes exactly
289
+ // like the terrain behind it (mismatch reads as water slicing into the
290
+ // mountains).
291
+ waterSurface.setDistanceFog?.({
292
+ color: heightFogParams.heightFogColor ?? [0.66, 0.8, 0.94],
293
+ density: Number(heightFogParams.heightFogDensity) || 0.0016,
294
+ });
295
+ scene.add(waterSurface);
296
+ if (followTarget) {
297
+ waterSurface.addInteractor(followTarget, { radius: 0.35 });
298
+ waterSurface.setFollowTarget(followTarget);
299
+ }
300
+ }
301
+
302
+ // Placement masks: no vegetation on cliffs or under water.
303
+ const masks = heightAt
304
+ ? {
305
+ slope: createSlopeMask({ heightAt, maxSlope: 0.55 }),
306
+ water: createWaterMask({ heightAt, margin: 0.4, waterLevel }),
307
+ }
308
+ : null;
309
+ const vegetationMask = masks ? combineMasks(masks.slope, masks.water) : null;
310
+ const defaultCenter = followTarget?.position ?? { x: 0, z: 0 };
311
+ // Per-system custom masks (e.g. a createNoisePatchMask forest-cluster
312
+ // mask) AND with the built-in slope/water masks.
313
+ const withWorldMask = (custom) => {
314
+ if (typeof custom !== 'function') return vegetationMask;
315
+ return vegetationMask ? combineMasks(vegetationMask, custom) : custom;
316
+ };
317
+
318
+ // Trees: LOD forest. Far placements render as baked instanced impostors
319
+ // (a couple of draw calls per variant); a budgeted pool of live detailed
320
+ // trees swaps in around the camera. See StylizedForest.
321
+ let forest = null;
322
+ if (trees !== false) {
323
+ const treeOptions = cleanObject(trees);
324
+ const placements = scatterForest({
325
+ ...cleanObject(worldPreset.trees?.scatter),
326
+ center: treeOptions.center ?? defaultCenter,
327
+ radius: Math.min(cleanObject(treeOptions.scatter).radius
328
+ ?? cleanObject(worldPreset.trees?.scatter).radius ?? halfExtent, halfExtent),
329
+ ...cleanObject(treeOptions.scatter),
330
+ heightAt,
331
+ mask: withWorldMask(treeOptions.mask),
332
+ });
333
+ forest = new StylizedForest({
334
+ canopyColors: treeOptions.canopyColors ?? worldPreset.trees?.canopyColors ?? null,
335
+ placements,
336
+ preset: worldPreset.trees?.preset,
337
+ // Billboard far-LOD: without the renderer the forest falls back to
338
+ // merged-geometry impostors (~12k verts/tree) that get redrawn by the
339
+ // main, water, and shadow passes — the difference between 20 and 60 fps.
340
+ renderer,
341
+ settings: {
342
+ ...cleanObject(worldPreset.trees?.settings),
343
+ ...cleanObject(treeOptions.settings),
344
+ },
345
+ ...cleanObject(treeOptions.lod),
346
+ });
347
+ // Same height-fog layer the terrain and water get: without it, far
348
+ // impostor canopies only receive the linear scene.fog and float on the
349
+ // fogged mountains as saturated green dots.
350
+ forest.setDistanceFog({
351
+ color: heightFogParams.heightFogColor ?? [0.66, 0.8, 0.94],
352
+ density: Number(heightFogParams.heightFogDensity) || 0.0016,
353
+ falloff: Number(heightFogParams.heightFogFalloff) || 400,
354
+ floorY: environmentBox.min.y,
355
+ });
356
+ scene.add(forest);
357
+ }
358
+
359
+ // Grass: a density-based window that follows the target. Placements are
360
+ // construction-only, so when the target strays past half the radius the
361
+ // field is rebuilt at the new center and swapped; a distance fade hides
362
+ // the window edge. Far grass simply doesn't exist — same as the anime
363
+ // games this emulates.
364
+ let grassField = null;
365
+ const grassState = { center: null, options: cleanObject(grass), radius: 0 };
366
+ const buildGrassAt = (center) => {
367
+ const grassOptions = grassState.options;
368
+ const scatterSpec = {
369
+ ...cleanObject(worldPreset.grass?.scatter),
370
+ ...cleanObject(grassOptions.scatter),
371
+ };
372
+ grassState.radius = Number(scatterSpec.radius) || 45;
373
+ const placements = scatterGrassAround({
374
+ ...scatterSpec,
375
+ center,
376
+ heightAt,
377
+ mask: withWorldMask(grassOptions.mask),
378
+ });
379
+ if (placements.length === 0) return null;
380
+ const field = new StylizedGrassField({
381
+ preset: worldPreset.grass?.preset,
382
+ ...cleanObject(worldPreset.grass?.settings),
383
+ ...cleanObject(grassOptions.settings),
384
+ placements,
385
+ });
386
+ field.setDistanceFade({ end: grassState.radius * 0.98, start: grassState.radius * 0.62 });
387
+ // 300k blades re-rendered into the water reflection, refraction grab,
388
+ // and depth passes is pure cost — grass sits above the waterline, so
389
+ // its contribution to all three is invisible at gameplay angles.
390
+ // (waterExclude hides from every water scene pass.)
391
+ field.userData.waterExclude = true;
392
+ scene.add(field);
393
+ if (followTarget) field.setPushTarget(followTarget);
394
+ grassState.center = { x: Number(center.x) || 0, z: Number(center.z) || 0 };
395
+ return field;
396
+ };
397
+ if (grass !== false) {
398
+ grassField = buildGrassAt(cleanObject(grass).center ?? defaultCenter);
399
+ }
400
+ const refreshGrassWindow = () => {
401
+ if (grass === false || !followTarget || !grassState.center) return;
402
+ const dx = followTarget.position.x - grassState.center.x;
403
+ const dz = followTarget.position.z - grassState.center.z;
404
+ if (dx * dx + dz * dz < (grassState.radius * 0.5) ** 2) return;
405
+ const previous = grassField;
406
+ grassField = buildGrassAt(followTarget.position) ?? previous;
407
+ if (previous && grassField !== previous) {
408
+ scene.remove(previous);
409
+ previous.dispose();
410
+ }
411
+ };
412
+
413
+ let flowerField = null;
414
+ if (flowers) {
415
+ const flowerOptions = cleanObject(flowers);
416
+ const placements = scatterGrassAround({
417
+ center: flowerOptions.center ?? defaultCenter,
418
+ density: 0.4,
419
+ radius: 30,
420
+ seed: 11,
421
+ ...cleanObject(flowerOptions.scatter),
422
+ heightAt,
423
+ mask: withWorldMask(flowerOptions.mask),
424
+ });
425
+ if (placements.length > 0) {
426
+ flowerField = new StylizedFlowerField({
427
+ ...cleanObject(flowerOptions.settings),
428
+ placements,
429
+ });
430
+ scene.add(flowerField);
431
+ }
432
+ }
433
+
434
+ // Walkability: circle blockers on a spatial hash, tree trunks
435
+ // pre-registered, so characters can't walk through the world ToonLab just
436
+ // built. Hosts add their own blockers (rocks, props) via
437
+ // `world.collision.addCircles(...)` and call
438
+ // `world.collision.resolve(character.position, radius)` per frame.
439
+ const collision = createWorldCollision({ heightAt });
440
+ if (forest) {
441
+ const trunkScale = [
442
+ cleanObject(cleanObject(trees).settings).size,
443
+ cleanObject(worldPreset.trees?.settings).size,
444
+ 3.2,
445
+ ].map(Number).find(Number.isFinite);
446
+ const trunkRadius = Math.max(0.13 * trunkScale, 0.2);
447
+ collision.addCircles(forest.placements.map((p) => ({ radius: trunkRadius, x: p.x, z: p.z })));
448
+ }
449
+
450
+ // One cloud-shadow field across terrain shader, water, grass, and canopies.
451
+ const applyCloudShadow = (field) => {
452
+ setEnvironmentCloudShadow(field);
453
+ waterSurface?.setCloudShadow(field);
454
+ grassField?.setCloudShadow(field);
455
+ forest?.setCloudShadow(field);
456
+ };
457
+ if (cloudShadows !== false) applyCloudShadow(cleanObject(cloudShadows));
458
+
459
+ let disposed = false;
460
+ return {
461
+ classification,
462
+ collision,
463
+ dispose() {
464
+ if (disposed) return;
465
+ disposed = true;
466
+ sunShadowPass?.dispose();
467
+ forest?.dispose();
468
+ for (const object of [sky, waterSurface, forest, grassField, flowerField, sunRig?.group ?? sunRig]) {
469
+ if (object?.parent) object.parent.remove(object);
470
+ }
471
+ },
472
+ flowerField,
473
+ fog: sceneFog,
474
+ forest,
475
+ get grass() { return grassField; },
476
+ masks,
477
+ setCloudShadow: applyCloudShadow,
478
+ sky,
479
+ sunRig,
480
+ /** Call once per frame before rendering. */
481
+ update(delta = 0.016) {
482
+ if (disposed) return;
483
+ advanceEnvironmentShaderTime(delta);
484
+ followSunShadow();
485
+ // Rendering the shadow map is a full scene pass; alternate frames are
486
+ // imperceptible for a sun that barely moves (shadows.interval: 1 to
487
+ // force every frame).
488
+ shadowFrame += 1;
489
+ if (sunShadowPass && shadowFrame % shadowInterval === 0) sunShadowPass.update();
490
+ refreshGrassWindow();
491
+ waterSurface?.update(renderer, scene, camera, delta);
492
+ sky.update(delta, camera);
493
+ grassField?.update(delta);
494
+ flowerField?.update(delta);
495
+ forest?.update(delta, camera);
496
+ },
497
+ water: waterSurface,
498
+ worldPreset,
499
+ };
500
+ }
@@ -8,3 +8,5 @@ export * from './stylizedBush.js';
8
8
  export * from './stylizedFlower.js';
9
9
  export * from './treeRecipe.js';
10
10
  export * from './treeExport.js';
11
+ export * from './scatter.js';
12
+ export * from './stylizedForest.js';