[READ-ONLY] Mirror of https://github.com/flo-bit/shapecraft. flo-bit.dev/shapecraft/
0

Configure Feed

Select the types of activity you want to include in your feed.

grass, ferns

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