[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.

shapecraft / src / generators / common-tree.ts
8.4 kB 193 lines
1import { icosphere, cylinder } from '../primitives' 2import { merge } from '../ops' 3import { createRng } from '../core/rng' 4import { scatterOnSphere } from '../core/scatter' 5import { resolveOptions } from '../core/schema' 6import { paletteGradient, pickRandom, type Palette } from '../color' 7import { UberNoise } from '../noise' 8import type { Mesh } from '../core/mesh' 9import type { OptionSchema } from '../core/schema' 10 11export const treeSchema = { 12 seed: { type: 'integer', default: 1, min: 1, max: 100, label: 'Seed' }, 13 height: { type: 'range', default: 2.5, min: 0.5, max: 6, step: 0.1, label: 'Height' }, 14 trunkRadius: { type: 'range', default: 0.12, min: 0.03, max: 0.4, step: 0.01, label: 'Trunk Radius' }, 15 trunkRatio: { type: 'range', default: 0.45, min: 0.2, max: 0.7, step: 0.01, label: 'Trunk Ratio' }, 16 trunkTaper: { type: 'range', default: 2, min: 0.5, max: 5, step: 0.1, label: 'Root Flare' }, 17 trunkTopScale: { type: 'range', default: 0.5, min: 0.02, max: 1, step: 0.02, label: 'Trunk Top Scale' }, 18 lean: { type: 'range', default: 0.4, min: 0, max: 1.5, step: 0.05, label: 'Lean' }, 19 showCanopy: { type: 'boolean', default: true, label: 'Show Canopy' }, 20 canopyRadius: { type: 'range', default: 0.8, min: 0.2, max: 2, step: 0.05, label: 'Canopy Size' }, 21 canopySquash: { type: 'range', default: 0.8, min: 0.3, max: 1, step: 0.05, label: 'Canopy Squash' }, 22 canopyNoise: { type: 'range', default: 0.5, min: 0, max: 1.5, step: 0.05, label: 'Canopy Noise' }, 23 canopyDetail: { type: 'range', default: 0.45, min: 0.15, max: 1, step: 0.05, label: 'Canopy Detail' }, 24 canopyBumps: { type: 'integer', default: 3, min: 0, max: 8, label: 'Canopy Bumps' }, 25 bumpSize: { type: 'range', default: 0.4, min: 0.1, max: 0.8, step: 0.05, label: 'Bump Size' }, 26 canopyOffset: { type: 'range', default: 0.6, min: 0, max: 1.2, step: 0.05, label: 'Canopy Offset' }, 27 jitter: { type: 'range', default: 0.04, min: 0, max: 0.15, step: 0.005, label: 'Jitter' }, 28 snowColors: { type: 'color-array', default: [], min: 0, max: 6, label: 'Snow Colors' }, 29 snowAngle: { type: 'range', default: 30, min: 0, max: 80, step: 5, label: 'Snow Min Angle (°)' }, 30 trunkColors: { type: 'color-array', default: ['#1a0f06', '#4a2815', '#5a3520'], min: 2, max: 6, label: 'Trunk Colors' }, 31 canopyColors: { type: 'color-array', default: ['#1e6b10', '#2a7518', '#238020', '#2d8a1e'], min: 1, max: 8, label: 'Canopy Colors' }, 32} satisfies OptionSchema 33 34export type TreeOptions = { 35 [K in keyof typeof treeSchema]?: typeof treeSchema[K]['default'] 36} & { preset?: string } 37 38export const treePresets: Record<string, Partial<TreeOptions>> = { 39 default: {}, 40 autumn: { 41 canopyColors: ['#c44422', '#d48825', '#bf6b1a', '#a83a15', '#dba030'], 42 }, 43 winter: { 44 canopyColors: ['#1a5a10', '#1e4a15', '#224d18'], 45 snowColors: ['#e8e8f0', '#dddde8', '#f0f0f5'], 46 snowAngle: 15, 47 trunkColors: ['#1a1510', '#2a2018', '#3a2a1a'], 48 }, 49 cherry: { 50 canopyColors: ['#d45a8a', '#e87aa0', '#c44a75', '#f09ab5'], 51 }, 52 dead: { 53 showCanopy: false, 54 }, 55} 56 57export function tree(options: TreeOptions = {}): Mesh { 58 // Create rand from seed before resolving (needed for [min,max] ranges) 59 const seed = options.seed ?? treeSchema.seed.default 60 const rand = createRng(seed) 61 const o = resolveOptions(treeSchema, options, treePresets, rand) 62 63 // Derive all sub-seeds from the main rand so everything chains deterministically 64 function subSeed() { return Math.floor(rand() * 2147483647) } 65 66 // Trunk radii — randomized within range 67 const baseRadius = o.trunkRadius * (1.6 + rand() * 0.8) 68 const topRadius = o.trunkRadius * o.trunkTopScale 69 70 // Bigger trunk → bigger canopy 71 const canopyScale = baseRadius / (o.trunkRadius * 2) 72 const actualCanopyRadius = o.canopyRadius * canopyScale 73 74 // Trunk 75 const trunkHeight = o.height * o.trunkRatio 76 const leanX = (rand() - 0.5) * o.lean 77 const leanZ = (rand() - 0.5) * o.lean 78 const trunkGrad = paletteGradient(o.trunkColors) 79 const taperExp = o.trunkTaper 80 81 const trunkNoise = new UberNoise({ seed: subSeed(), scale: 8 }) 82 const trunk = cylinder({ radius: 1, radiusTop: 1, height: trunkHeight, segments: 5, heightSegments: 4 }) 83 .translate(0, trunkHeight / 2, 0) 84 .warp((pos) => { 85 const t = Math.max(0, Math.min(1, pos[1] / trunkHeight)) 86 const radius = topRadius + (baseRadius - topRadius) * Math.pow(1 - t, taperExp) 87 // Noise-based displacement scaled by local radius (thin top = less displacement) 88 const jitterAmount = radius * 0.3 89 const nx = trunkNoise.get(pos[0] * 100, pos[1], pos[2] * 100) * jitterAmount 90 const nz = trunkNoise.get(pos[0] * 100 + 500, pos[1] + 500, pos[2] * 100) * jitterAmount 91 return [ 92 pos[0] * radius + leanX * t * t + nx, 93 pos[1], 94 pos[2] * radius + leanZ * t * t + nz, 95 ] 96 }) 97 .vertexColor((pos) => { 98 const t = Math.max(0, Math.min(1, pos[1] / trunkHeight)) 99 return trunkGrad(t) 100 }) 101 102 // Trunk top position after lean 103 const topOffsetX = leanX 104 const topOffsetZ = leanZ 105 106 if (!o.showCanopy) return trunk 107 108 // Canopy 109 const canopyParts: Mesh[] = [] 110 const canopyY = trunkHeight + actualCanopyRadius * o.canopyOffset 111 const mainR = actualCanopyRadius 112 const edgeLen = o.canopyRadius * o.canopyDetail 113 114 const colorNoiseSeed = subSeed() 115 116 function canopyBlob(r: number): Mesh { 117 const noiseSeed = subSeed() 118 const jitterSeed = subSeed() 119 const noise = new UberNoise({ seed: noiseSeed, scale: 0.5, octaves: 3 }) 120 let blob = icosphere({ radius: r, subdivisions: 0 }) 121 .subdivideAdaptive(edgeLen) 122 .warp((pos) => { 123 const len = Math.sqrt(pos[0] * pos[0] + pos[1] * pos[1] + pos[2] * pos[2]) || 1 124 const nx = pos[0] / len, ny = pos[1] / len, nz = pos[2] / len 125 const d = r + noise.get(pos[0], pos[1], pos[2]) * r * o.canopyNoise 126 return [nx * d, ny * d, nz * d] 127 }) 128 .jitter(r * o.jitter, { seed: jitterSeed }) 129 130 return blob 131 } 132 133 // Face coloring 134 const colorNoise = new UberNoise({ seed: colorNoiseSeed, scale: 1.5 }) 135 136 const hasSnow = o.snowColors.length > 0 137 const snowNoiseSeed = subSeed() // always consume to keep sequence stable 138 const snowNoise = hasSnow ? new UberNoise({ seed: snowNoiseSeed, scale: 2 }) : null 139 const snowThreshold = Math.sin(o.snowAngle * Math.PI / 180) 140 141 function blobFaceColor(): (centroid: [number, number, number], normal: [number, number, number], faceIndex: number) => [number, number, number] { 142 // Pick colors upfront so we don't consume rand() calls inside the per-face loop 143 const base = pickRandom(o.canopyColors, rand) 144 // Always consume the rand() call to keep sequence stable regardless of snow setting 145 const snowPick = pickRandom(hasSnow ? o.snowColors : o.canopyColors, rand) 146 const snow = hasSnow ? snowPick : null 147 return (centroid, normal) => { 148 const top = normal[1] * 0.5 + 0.5 149 150 // Snow on upward-facing faces 151 if (snow && snowNoise) { 152 const n = snowNoise.get(centroid[0], centroid[1], centroid[2]) * 0.15 153 if (normal[1] + n > snowThreshold) { 154 return snow 155 } 156 } 157 158 const n = colorNoise.get(centroid[0], centroid[1], centroid[2]) * 0.15 159 const darken = 0.65 + top * 0.35 + n 160 return [base[0] * darken, base[1] * darken, base[2] * darken] 161 } 162 } 163 164 // Main sphere 165 const main = canopyBlob(mainR) 166 .scale(1, o.canopySquash, 1) 167 .translate(topOffsetX, canopyY, topOffsetZ) 168 .faceColor(blobFaceColor()) 169 canopyParts.push(main) 170 171 // Sub-blobs 172 const blobCount = o.canopyBumps 173 if (blobCount > 0) { 174 const blobPositions = scatterOnSphere(blobCount, subSeed(), { 175 radius: mainR * 0.9, 176 polarMin: Math.PI * 0.3, 177 polarMax: Math.PI * 0.7, 178 }) 179 180 for (let i = 0; i < blobCount; i++) { 181 const [bx, by, bz] = blobPositions[i] 182 const r = mainR * (o.bumpSize + rand() * 0.15) 183 184 const blob = canopyBlob(r) 185 .scale(1, o.canopySquash, 1) 186 .translate(bx + topOffsetX, by * o.canopySquash + canopyY, bz + topOffsetZ) 187 .faceColor(blobFaceColor()) 188 canopyParts.push(blob) 189 } 190 } 191 192 return merge(trunk, ...canopyParts) 193}