···
9
9
pine, pineSchema, pinePresets,
10
10
palm, palmSchema, palmPresets,
11
11
bush, bushSchema, bushPresets,
12
12
+
grass, grassSchema, grassPresets,
13
13
+
fern, fernSchema, fernPresets,
12
14
} from '../src/generators'
13
15
import { createEditor } from './editor/editor'
16
16
+
import type { OptionSchema } from '../src/core/schema'
17
17
+
import type { Mesh } from '../src/core/mesh'
18
18
+
19
19
+
// --- Generator registry: add a model here and it appears in the switcher + editor. ---
20
20
+
interface GenEntry {
21
21
+
label: string
22
22
+
gen: (opts: Record<string, any>) => Mesh
23
23
+
schema: OptionSchema
24
24
+
presets: Record<string, Record<string, any>>
25
25
+
sizeKey: string // which option the forest-scatter varies per instance
26
26
+
defaultSize: number
27
27
+
}
28
28
+
const GENERATORS: Record<string, GenEntry> = {
29
29
+
common: { label: 'Common', gen: tree, schema: treeSchema, presets: treePresets, sizeKey: 'height', defaultSize: 2.5 },
30
30
+
pine: { label: 'Pine', gen: pine, schema: pineSchema, presets: pinePresets, sizeKey: 'height', defaultSize: 3 },
31
31
+
palm: { label: 'Palm', gen: palm, schema: palmSchema, presets: palmPresets, sizeKey: 'height', defaultSize: 3.5 },
32
32
+
bush: { label: 'Bush', gen: bush, schema: bushSchema, presets: bushPresets, sizeKey: 'size', defaultSize: 0.6 },
33
33
+
grass: { label: 'Grass', gen: grass, schema: grassSchema, presets: grassPresets, sizeKey: 'height', defaultSize: 0.6 },
34
34
+
fern: { label: 'Fern', gen: fern, schema: fernSchema, presets: fernPresets, sizeKey: 'length', defaultSize: 1.0 },
35
35
+
}
14
36
15
37
// --- Renderer ---
16
38
const renderer = new THREE.WebGLRenderer({ antialias: true })
···
89
111
90
112
// --- Tree management ---
91
113
let treeObjects: THREE.Mesh[] = []
92
92
-
let activeGenerator: 'common' | 'pine' | 'palm' | 'bush' = 'common'
114
114
+
let activeGenerator: keyof typeof GENERATORS | string = 'common'
93
115
94
116
function clearTrees() {
95
117
for (const obj of treeObjects) {
···
107
129
let rngSeed = 42
108
130
function rng() { rngSeed = (rngSeed * 16807) % 2147483647; return (rngSeed & 0x7fffffff) / 2147483647 }
109
131
110
110
-
const gen = activeGenerator === 'pine' ? pine
111
111
-
: activeGenerator === 'palm' ? palm
112
112
-
: activeGenerator === 'bush' ? bush
113
113
-
: tree
114
114
-
// Bushes are sized by `size`; the trees by `height`.
115
115
-
const sizeKey = activeGenerator === 'bush' ? 'size' : 'height'
116
116
-
const defaultSize = activeGenerator === 'bush' ? 0.6
117
117
-
: activeGenerator === 'pine' ? 3
118
118
-
: activeGenerator === 'palm' ? 3.5
119
119
-
: 2.5
132
132
+
const entry = GENERATORS[activeGenerator]
133
133
+
const { gen, sizeKey, defaultSize } = entry
120
134
121
135
for (let i = 0; i < 15; i++) {
122
136
const x = (rng() - 0.5) * 20
···
140
154
// --- Editor with generator switcher ---
141
155
let currentEditorEl: HTMLElement | null = null
142
156
143
143
-
function mountEditor(type: 'common' | 'pine' | 'palm' | 'bush') {
157
157
+
function mountEditor(type: string) {
144
158
activeGenerator = type
145
159
if (currentEditorEl) currentEditorEl.remove()
146
160
147
147
-
const schema = type === 'pine' ? pineSchema : type === 'palm' ? palmSchema : type === 'bush' ? bushSchema : treeSchema
148
148
-
const presets = type === 'pine' ? pinePresets : type === 'palm' ? palmPresets : type === 'bush' ? bushPresets : treePresets
149
149
-
150
150
-
currentEditorEl = createEditor(schema, {
161
161
+
const entry = GENERATORS[type]
162
162
+
currentEditorEl = createEditor(entry.schema, {
151
163
onChange: (opts) => { rebuildTrees(opts) },
152
152
-
}, { presets })
164
164
+
}, { presets: entry.presets })
153
165
154
166
// Add generator switcher at the top
155
167
const switcher = document.createElement('div')
156
156
-
switcher.style.cssText = 'margin-bottom: 12px; padding-bottom: 10px; border-bottom: 1px solid #333; display: flex; gap: 4px;'
157
157
-
for (const t of ['common', 'pine', 'palm', 'bush'] as const) {
168
168
+
switcher.style.cssText = 'margin-bottom: 12px; padding-bottom: 10px; border-bottom: 1px solid #333; display: flex; flex-wrap: wrap; gap: 4px;'
169
169
+
for (const key of Object.keys(GENERATORS)) {
158
170
const btn = document.createElement('button')
159
159
-
btn.textContent = t === 'common' ? 'Common' : t === 'pine' ? 'Pine' : t === 'palm' ? 'Palm' : 'Bush'
160
160
-
btn.style.cssText = `flex: 1; padding: 6px; border: 1px solid #444; background: ${t === type ? '#3a5a3a' : '#222'}; color: #ddd; cursor: pointer; font-size: 12px;`
161
161
-
btn.addEventListener('click', () => { if (t !== activeGenerator) mountEditor(t) })
171
171
+
btn.textContent = GENERATORS[key].label
172
172
+
btn.style.cssText = `flex: 1 1 30%; padding: 6px; border: 1px solid #444; background: ${key === type ? '#3a5a3a' : '#222'}; color: #ddd; cursor: pointer; font-size: 12px;`
173
173
+
btn.addEventListener('click', () => { if (key !== activeGenerator) mountEditor(key) })
162
174
switcher.appendChild(btn)
163
175
}
164
176
currentEditorEl.prepend(switcher)
···
1
1
+
import * as THREE from 'three'
2
2
+
import { Mesh } from '../core/mesh'
3
3
+
import type { Vec3 } from '../core/types'
4
4
+
5
5
+
export interface BladeOptions {
6
6
+
/** Full width across the blade at parameter t∈[0,1]. A number means `W·(1−t)` (tapers to a point). */
7
7
+
width: number | ((t: number) => number)
8
8
+
/** Reference up vector used to orient the ribbon's width direction. Default [0,1,0]. */
9
9
+
up?: Vec3
10
10
+
/** Emit back faces too, so the blade is visible from both sides. Default true. */
11
11
+
doubleSided?: boolean
12
12
+
}
13
13
+
14
14
+
/**
15
15
+
* Build a thin, tapered ribbon swept along a path — a leaf blade, grass blade, frond
16
16
+
* leaflet, or petal. The width runs perpendicular to the path (around `up`) and tapers
17
17
+
* along it; by default the blade is double-sided so it reads from any angle.
18
18
+
*/
19
19
+
export function blade(path: Vec3[], options: BladeOptions): Mesh {
20
20
+
const widthFn = typeof options.width === 'function'
21
21
+
? options.width
22
22
+
: (t: number) => (options.width as number) * (1 - t)
23
23
+
const up = options.up ?? [0, 1, 0]
24
24
+
const doubleSided = options.doubleSided ?? true
25
25
+
const n = path.length
26
26
+
27
27
+
const left: Vec3[] = []
28
28
+
const right: Vec3[] = []
29
29
+
for (let i = 0; i < n; i++) {
30
30
+
const t = n > 1 ? i / (n - 1) : 0
31
31
+
// Central-difference tangent.
32
32
+
const a = path[Math.max(0, i - 1)]
33
33
+
const b = path[Math.min(n - 1, i + 1)]
34
34
+
let tx = b[0] - a[0], ty = b[1] - a[1], tz = b[2] - a[2]
35
35
+
const tl = Math.hypot(tx, ty, tz) || 1
36
36
+
tx /= tl; ty /= tl; tz /= tl
37
37
+
// Width direction = tangent × up (perpendicular to the blade's length and facing).
38
38
+
let sx = ty * up[2] - tz * up[1]
39
39
+
let sy = tz * up[0] - tx * up[2]
40
40
+
let sz = tx * up[1] - ty * up[0]
41
41
+
let sl = Math.hypot(sx, sy, sz)
42
42
+
if (sl < 1e-5) { sx = 1; sy = 0; sz = 0; sl = 1 } // tangent ∥ up → fall back
43
43
+
sx /= sl; sy /= sl; sz /= sl
44
44
+
const hw = Math.max(0, widthFn(t)) / 2
45
45
+
const p = path[i]
46
46
+
left.push([p[0] - sx * hw, p[1] - sy * hw, p[2] - sz * hw])
47
47
+
right.push([p[0] + sx * hw, p[1] + sy * hw, p[2] + sz * hw])
48
48
+
}
49
49
+
50
50
+
const pos: number[] = []
51
51
+
const tri = (a: Vec3, b: Vec3, c: Vec3) =>
52
52
+
pos.push(a[0], a[1], a[2], b[0], b[1], b[2], c[0], c[1], c[2])
53
53
+
54
54
+
for (let i = 0; i < n - 1; i++) {
55
55
+
const l0 = left[i], r0 = right[i], l1 = left[i + 1], r1 = right[i + 1]
56
56
+
tri(l0, r0, r1)
57
57
+
tri(l0, r1, l1)
58
58
+
if (doubleSided) {
59
59
+
tri(l0, r1, r0)
60
60
+
tri(l0, l1, r1)
61
61
+
}
62
62
+
}
63
63
+
64
64
+
const geo = new THREE.BufferGeometry()
65
65
+
geo.setAttribute('position', new THREE.BufferAttribute(new Float32Array(pos), 3))
66
66
+
geo.computeVertexNormals()
67
67
+
return new Mesh(geo)
68
68
+
}
···
3
3
export { foliageBlob, type FoliageBlobOptions } from './foliage'
4
4
export { facetShade, heightShade, type FacetShadeOptions } from './shade'
5
5
export { scatterOnSurface, type SurfacePoint, type ScatterOnSurfaceOptions } from './surface'
6
6
+
export { blade, type BladeOptions } from './blade'
···
1
1
export * from './trees'
2
2
export * from './shrubs'
3
3
+
export * from './plants'
···
1
1
+
import { merge } from '../../../ops'
2
2
+
import { setup, blade, heightShade } from '../../../build'
3
3
+
import type { Mesh } from '../../../core/mesh'
4
4
+
import type { Vec3 } from '../../../core/types'
5
5
+
import type { OptionSchema, OptionInput } from '../../../core/schema'
6
6
+
7
7
+
export const fernSchema = {
8
8
+
seed: { type: 'integer', default: 1, min: 1, max: 100, label: 'Seed' },
9
9
+
fronds: { type: 'integer', default: 6, min: 2, max: 12, label: 'Fronds' },
10
10
+
length: { type: 'range', default: 1.0, min: 0.4, max: 2.2, step: 0.05, label: 'Frond Length' },
11
11
+
arch: { type: 'range', default: 0.6, min: 0.1, max: 1.4, step: 0.05, label: 'Arch' },
12
12
+
leaflets: { type: 'integer', default: 14, min: 5, max: 28, label: 'Leaflets / side' },
13
13
+
leafletLength: { type: 'range', default: 0.32, min: 0.1, max: 0.6, step: 0.02, label: 'Leaflet Length' },
14
14
+
leafletAngle: { type: 'range', default: 0.55, min: 0, max: 1, step: 0.05, label: 'Leaflet Forward' },
15
15
+
leafletDroop: { type: 'range', default: 0.3, min: 0, max: 1, step: 0.05, label: 'Leaflet Droop' },
16
16
+
rachisWidth: { type: 'range', default: 0.02, min: 0.005,max: 0.05, step: 0.005, label: 'Rachis Width' },
17
17
+
segments: { type: 'integer', default: 12, min: 6, max: 20, label: 'Rachis Smoothness' },
18
18
+
colors: { type: 'color-array', default: ['#1f5216', '#2f6e1f', '#4e9a2e'], min: 1, max: 6, label: 'Colors' },
19
19
+
} satisfies OptionSchema
20
20
+
21
21
+
export type FernOptions = Partial<OptionInput<typeof fernSchema>> & { preset?: string }
22
22
+
23
23
+
export const fernPresets: Record<string, Partial<FernOptions>> = {
24
24
+
default: {},
25
25
+
upright: { arch: 0.25, fronds: 7, length: 1.3 },
26
26
+
spreading: { arch: 1.1, fronds: 9, leafletLength: 0.4 },
27
27
+
autumn: { colors: ['#6a5a1e', '#8a7322', '#a8902e'] },
28
28
+
}
29
29
+
30
30
+
function norm(x: number, y: number, z: number): Vec3 {
31
31
+
const l = Math.hypot(x, y, z) || 1
32
32
+
return [x / l, y / l, z / l]
33
33
+
}
34
34
+
35
35
+
export function fern(options: FernOptions = {}): Mesh {
36
36
+
const { o, rng } = setup(fernSchema, options, fernPresets)
37
37
+
const shapeRng = rng.stream('shape')
38
38
+
39
39
+
const fronds: Mesh[] = []
40
40
+
41
41
+
for (let f = 0; f < o.fronds; f++) {
42
42
+
const azimuth = (f / o.fronds) * Math.PI * 2 + (shapeRng() - 0.5) * 0.5
43
43
+
const dx = Math.cos(azimuth)
44
44
+
const dz = Math.sin(azimuth)
45
45
+
const L = o.length * (0.8 + shapeRng() * 0.4)
46
46
+
const phiMax = Math.max(0.05, o.arch * 1.4 * (0.8 + shapeRng() * 0.4))
47
47
+
const R = L / phiMax
48
48
+
49
49
+
// Analytic arc rachis in the vertical plane along (dx,dz).
50
50
+
const rachisPoint = (u: number): Vec3 => {
51
51
+
const phi = phiMax * u
52
52
+
const hd = R * (1 - Math.cos(phi))
53
53
+
return [dx * hd, R * Math.sin(phi), dz * hd]
54
54
+
}
55
55
+
const rachisTangent = (u: number): Vec3 => {
56
56
+
const phi = phiMax * u
57
57
+
return norm(dx * Math.sin(phi), Math.cos(phi), dz * Math.sin(phi))
58
58
+
}
59
59
+
60
60
+
const parts: Mesh[] = []
61
61
+
let maxY = 0
62
62
+
63
63
+
// Rachis (thin tapering stalk).
64
64
+
const rachisPts: Vec3[] = []
65
65
+
for (let i = 0; i <= o.segments; i++) {
66
66
+
const p = rachisPoint(i / o.segments)
67
67
+
rachisPts.push(p)
68
68
+
if (p[1] > maxY) maxY = p[1]
69
69
+
}
70
70
+
parts.push(blade(rachisPts, { width: o.rachisWidth }))
71
71
+
72
72
+
// Leaflets down both sides, longest in the lower third, shrinking to the tip.
73
73
+
for (let k = 0; k < o.leaflets; k++) {
74
74
+
const u = (k + 0.5) / o.leaflets
75
75
+
const profile = Math.pow(Math.sin(Math.min(1, u * 1.05) * Math.PI), 0.7)
76
76
+
const ll = o.leafletLength * L * profile
77
77
+
if (ll < 1e-3) continue
78
78
+
79
79
+
const base = rachisPoint(u)
80
80
+
const T = rachisTangent(u)
81
81
+
const side = norm(T[1] * 0 - T[2] * 1, T[2] * 0 - T[0] * 0, T[0] * 1 - T[1] * 0) // T × up
82
82
+
83
83
+
for (const s of [-1, 1]) {
84
84
+
const dir = norm(
85
85
+
side[0] * s + T[0] * o.leafletAngle * 0.9,
86
86
+
side[1] * s + T[1] * o.leafletAngle * 0.9 + 0.18,
87
87
+
side[2] * s + T[2] * o.leafletAngle * 0.9,
88
88
+
)
89
89
+
const segs = 3
90
90
+
const leafPts: Vec3[] = []
91
91
+
for (let j = 0; j <= segs; j++) {
92
92
+
const t = j / segs
93
93
+
const droop = o.leafletDroop * ll * t * t
94
94
+
leafPts.push([
95
95
+
base[0] + dir[0] * ll * t,
96
96
+
base[1] + dir[1] * ll * t - droop,
97
97
+
base[2] + dir[2] * ll * t,
98
98
+
])
99
99
+
}
100
100
+
parts.push(blade(leafPts, { width: ll * 0.3 }))
101
101
+
}
102
102
+
}
103
103
+
104
104
+
fronds.push(merge(...parts).vertexColor(heightShade(o.colors, maxY * 0.9 || L)))
105
105
+
}
106
106
+
107
107
+
return merge(...fronds)
108
108
+
}
···
1
1
+
import { merge } from '../../../ops'
2
2
+
import { setup, blade, heightShade } from '../../../build'
3
3
+
import type { Mesh } from '../../../core/mesh'
4
4
+
import type { Vec3 } from '../../../core/types'
5
5
+
import type { OptionSchema, OptionInput } from '../../../core/schema'
6
6
+
7
7
+
export const grassSchema = {
8
8
+
seed: { type: 'integer', default: 1, min: 1, max: 100, label: 'Seed' },
9
9
+
blades: { type: 'integer', default: 16, min: 3, max: 60, label: 'Blades' },
10
10
+
height: { type: 'range', default: 0.6, min: 0.15, max: 1.6, step: 0.05, label: 'Height' },
11
11
+
bladeWidth: { type: 'range', default: 0.035,min: 0.01, max: 0.1, step: 0.005, label: 'Blade Width' },
12
12
+
spread: { type: 'range', default: 0.1, min: 0, max: 0.4, step: 0.01, label: 'Base Spread' },
13
13
+
fan: { type: 'range', default: 0.25, min: 0, max: 0.6, step: 0.02, label: 'Tip Fan' },
14
14
+
curve: { type: 'range', default: 0.8, min: 0, max: 1.5, step: 0.05, label: 'Curve' },
15
15
+
segments: { type: 'integer', default: 4, min: 2, max: 8, label: 'Segments' },
16
16
+
colors: { type: 'color-array', default: ['#3a6a1e', '#4e8a28', '#7ab33e'], min: 1, max: 6, label: 'Colors' },
17
17
+
} satisfies OptionSchema
18
18
+
19
19
+
export type GrassOptions = Partial<OptionInput<typeof grassSchema>> & { preset?: string }
20
20
+
21
21
+
export const grassPresets: Record<string, Partial<GrassOptions>> = {
22
22
+
default: {},
23
23
+
tall: { height: 1.2, blades: 22, curve: 0.5 },
24
24
+
dry: { colors: ['#7a6a28', '#9a8a3a', '#b7a64a'], curve: 1.0 },
25
25
+
lush: { blades: 30, colors: ['#2f6a1e', '#3f8a26', '#62ad36'] },
26
26
+
}
27
27
+
28
28
+
export function grass(options: GrassOptions = {}): Mesh {
29
29
+
const { o, rng } = setup(grassSchema, options, grassPresets)
30
30
+
const shapeRng = rng.stream('shape')
31
31
+
32
32
+
const blades: Mesh[] = []
33
33
+
for (let i = 0; i < o.blades; i++) {
34
34
+
// Base position within a small disc.
35
35
+
const baseAngle = shapeRng() * Math.PI * 2
36
36
+
const baseDist = Math.sqrt(shapeRng()) * o.spread
37
37
+
const bx = Math.cos(baseAngle) * baseDist
38
38
+
const bz = Math.sin(baseAngle) * baseDist
39
39
+
40
40
+
// Each blade arcs over in a random direction, biased outward from the clump.
41
41
+
const outAngle = baseDist > 1e-4 ? baseAngle : shapeRng() * Math.PI * 2
42
42
+
const leanAngle = outAngle + (shapeRng() - 0.5) * Math.PI * o.fan
43
43
+
const ldx = Math.cos(leanAngle)
44
44
+
const ldz = Math.sin(leanAngle)
45
45
+
46
46
+
const h = o.height * (0.7 + shapeRng() * 0.5)
47
47
+
const phiMax = Math.max(0.001, o.curve * (0.6 + shapeRng() * 0.8))
48
48
+
const R = h / phiMax
49
49
+
const w = o.bladeWidth * (0.7 + shapeRng() * 0.5)
50
50
+
51
51
+
const segs = o.segments
52
52
+
const path: Vec3[] = []
53
53
+
for (let j = 0; j <= segs; j++) {
54
54
+
const t = j / segs
55
55
+
const phi = phiMax * t
56
56
+
const y = R * Math.sin(phi)
57
57
+
const hd = R * (1 - Math.cos(phi))
58
58
+
path.push([bx + ldx * hd, y, bz + ldz * hd])
59
59
+
}
60
60
+
61
61
+
blades.push(blade(path, { width: w }).vertexColor(heightShade(o.colors, h)))
62
62
+
}
63
63
+
64
64
+
return merge(...blades)
65
65
+
}
···
1
1
+
export { grass, grassSchema, grassPresets, type GrassOptions } from './grass'
2
2
+
export { fern, fernSchema, fernPresets, type FernOptions } from './fern'
···
26
26
export { scatterOnSphere } from './core/scatter'
27
27
28
28
// Build — composable model primitives
29
29
-
export { setup, trunk, foliageBlob, facetShade, heightShade, scatterOnSurface } from './build'
30
30
-
export type { TrunkOptions, FoliageBlobOptions, FacetShadeOptions, SurfacePoint, ScatterOnSurfaceOptions } from './build'
29
29
+
export { setup, trunk, foliageBlob, facetShade, heightShade, scatterOnSurface, blade } from './build'
30
30
+
export type { TrunkOptions, FoliageBlobOptions, FacetShadeOptions, SurfacePoint, ScatterOnSurfaceOptions, BladeOptions } from './build'
31
31
32
32
// Schema & options
33
33
export { resolveOptions } from './core/schema'
···
38
38
export { pine, pineSchema, pinePresets } from './generators'
39
39
export { palm, palmSchema, palmPresets } from './generators'
40
40
export { bush, bushSchema, bushPresets } from './generators'
41
41
+
export { grass, grassSchema, grassPresets } from './generators'
42
42
+
export { fern, fernSchema, fernPresets } from './generators'
···
1
1
import { describe, it, expect } from 'vitest'
2
2
-
import { setup, trunk, foliageBlob, facetShade, heightShade, scatterOnSurface } from '../src/build'
2
2
+
import { setup, trunk, foliageBlob, facetShade, heightShade, scatterOnSurface, blade } from '../src/build'
3
3
import { sphere, box, plane } from '../src/primitives'
4
4
import type { OptionSchema } from '../src/core/schema'
5
5
import type { Vec3 } from '../src/core/types'
···
102
102
const low = shade([0, 0, 0])
103
103
const high = shade([0, 2, 0])
104
104
expect(high[0]).toBeGreaterThan(low[0])
105
105
+
})
106
106
+
})
107
107
+
108
108
+
describe('blade', () => {
109
109
+
const path: Vec3[] = [
110
110
+
[0, 0, 0],
111
111
+
[0, 0.5, 0.1],
112
112
+
[0, 0.9, 0.3],
113
113
+
[0, 1.1, 0.6],
114
114
+
]
115
115
+
116
116
+
it('builds a ribbon along the path', () => {
117
117
+
const m = blade(path, { width: 0.1 })
118
118
+
expect(m.vertexCount).toBeGreaterThan(0)
119
119
+
const p = m.positions
120
120
+
for (let i = 0; i < p.length; i++) expect(Number.isFinite(p[i])).toBe(true)
121
121
+
})
122
122
+
123
123
+
it('double-sided (default) has twice the triangles of single-sided', () => {
124
124
+
const two = blade(path, { width: 0.1 }).vertexCount
125
125
+
const one = blade(path, { width: 0.1, doubleSided: false }).vertexCount
126
126
+
expect(two).toBe(one * 2)
127
127
+
})
128
128
+
129
129
+
it('reaches the path extent', () => {
130
130
+
const m = blade(path, { width: 0.1 })
131
131
+
expect(m.boundingBox.max.y).toBeGreaterThan(1.0)
105
132
})
106
133
})
107
134
···
1
1
import { describe, it, expect } from 'vitest'
2
2
-
import { tree, pine, palm, bush } from '../src/generators'
2
2
+
import { tree, pine, palm, bush, grass, fern } from '../src/generators'
3
3
import type { Mesh } from '../src/core/mesh'
4
4
5
5
const generators = [
···
7
7
{ name: 'pine', gen: pine },
8
8
{ name: 'palm', gen: palm },
9
9
{ name: 'bush', gen: bush },
10
10
+
{ name: 'grass', gen: grass },
11
11
+
{ name: 'fern', gen: fern },
10
12
] as const
11
13
14
14
+
// Generators that support snow (trees + shrubs). Grass/ferns don't take snow options.
15
15
+
const snowGenerators = generators.filter((g) => ['tree', 'pine', 'palm', 'bush'].includes(g.name))
16
16
+
12
17
// Golden snapshots captured after the named-stream RNG migration. A change here means
13
18
// generator output shifted — intentional changes should update these numbers deliberately.
14
19
const golden: Record<string, { verts: number }> = {
···
16
21
pine: { verts: 840 },
17
22
palm: { verts: 3630 },
18
23
bush: { verts: 3600 },
24
24
+
grass: { verts: 768 },
25
25
+
fern: { verts: 6480 },
19
26
}
20
27
21
28
function allFinite(m: Mesh): boolean {
···
52
59
describe('stream independence at the model level', () => {
53
60
// The headline benefit of named streams: a feature that only affects color (snow)
54
61
// must not perturb geometry, because positions come from independent streams.
55
55
-
it.each(generators)('$name: painted snow (depth 0) leaves geometry byte-identical', ({ gen }) => {
62
62
+
it.each(snowGenerators)('$name: painted snow (depth 0) leaves geometry byte-identical', ({ gen }) => {
56
63
const bare = gen({ seed: 3, snowColors: [] })
57
64
const snowy = gen({ seed: 3, snowColors: ['#ffffff', '#eeeeee'] })
58
65
···
64
71
})
65
72
66
73
describe('geometric snow (snowDepth > 0)', () => {
67
67
-
it.each(generators)('$name: adds a snow layer of real geometry', ({ gen }) => {
74
74
+
it.each(snowGenerators)('$name: adds a snow layer of real geometry', ({ gen }) => {
68
75
const bare = gen({ seed: 4, snowColors: [] })
69
76
const snowy = gen({ seed: 4, snowColors: ['#eef0f5'], snowDepth: 0.08 })
70
77
// The snow shell adds vertices on top of the model.
···
73
80
expect(snowy.boundingBox.max.y).toBeGreaterThan(bare.boundingBox.max.y)
74
81
})
75
82
76
76
-
it.each(generators)('$name: geometric snow is deterministic', ({ gen }) => {
83
83
+
it.each(snowGenerators)('$name: geometric snow is deterministic', ({ gen }) => {
77
84
const a = gen({ seed: 4, snowColors: ['#eef0f5'], snowDepth: 0.08 }).positions
78
85
const b = gen({ seed: 4, snowColors: ['#eef0f5'], snowDepth: 0.08 }).positions
79
86
expect(Array.from(a)).toEqual(Array.from(b))