[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 / pine-tree.ts
8.9 kB 194 lines
1import { cylinder, cone } from '../primitives' 2import { merge } from '../ops' 3import { createRng } from '../core/rng' 4import { resolveOptions } from '../core/schema' 5import { paletteGradient, pickRandom } from '../color' 6import { UberNoise } from '../noise' 7import type { Mesh } from '../core/mesh' 8import type { OptionSchema } from '../core/schema' 9 10export const pineSchema = { 11 seed: { type: 'integer', default: 1, min: 1, max: 100, label: 'Seed' }, 12 height: { type: 'range', default: 3, min: 1, max: 8, step: 0.1, label: 'Height' }, 13 trunkRadius: { type: 'range', default: 0.08, min: 0.02, max: 0.25, step: 0.01, label: 'Trunk Radius' }, 14 trunkRatio: { type: 'range', default: 0.3, min: 0.1, max: 0.5, step: 0.05, label: 'Trunk Ratio' }, 15 trunkTaper: { type: 'range', default: 1.5, min: 0.5, max: 4, step: 0.1, label: 'Root Flare' }, 16 trunkTopScale: { type: 'range', default: 0.4, min: 0.1, max: 0.8, step: 0.05, label: 'Trunk Top Scale' }, 17 lean: { type: 'range', default: 0.15, min: 0, max: 0.8, step: 0.05, label: 'Lean' }, 18 layers: { type: 'integer', default: 5, min: 2, max: 10, label: 'Layers' }, 19 layerOverlap: { type: 'range', default: 0.4, min: 0, max: 0.8, step: 0.05, label: 'Layer Overlap' }, 20 layerShrink: { type: 'range', default: 0.75, min: 0.4, max: 0.95, step: 0.05, label: 'Layer Shrink' }, 21 coneRadius: { type: 'range', default: 0.7, min: 0.2, max: 1.5, step: 0.05, label: 'Cone Radius' }, 22 coneHeight: { type: 'range', default: 1.2, min: 0.4, max: 3, step: 0.1, label: 'Cone Height' }, 23 coneSides: { type: 'integer', default: 8, min: 3, max: 12, label: 'Cone Sides' }, 24 coneCurve: { type: 'range', default: 1.5, min: 1, max: 4, step: 0.1, label: 'Cone Curve' }, 25 coneTilt: { type: 'range', default: [0.1, 0.4], min: 0, max: 0.6, step: 0.02, label: 'Cone Tilt' }, 26 jitter: { type: 'range', default: 0.03, min: 0, max: 0.15, step: 0.005, label: 'Jitter' }, 27 swayScale: { type: 'range', default: 0.8, min: 0.2, max: 5, step: 0.1, label: 'Sway Scale' }, 28 swayAmount: { type: 'range', default: [0.15, 0.6], min: 0, max: 1, step: 0.05, label: 'Sway Amount' }, 29 trunkNoiseScale:{ type: 'range', default: 8, min: 1, max: 20, step: 0.5, label: 'Trunk Noise Scale' }, 30 trunkNoiseAmt: { type: 'range', default: 0.25, min: 0, max: 0.5, step: 0.05, label: 'Trunk Noise Amount' }, 31 colorNoiseScale:{ type: 'range', default: 1.5, min: 0.3, max: 5, step: 0.1, label: 'Color Noise Scale' }, 32 snowColors: { type: 'color-array', default: [], min: 0, max: 6, label: 'Snow Colors' }, 33 snowAngle: { type: 'range', default: 20, min: 0, max: 80, step: 5, label: 'Snow Min Angle (°)' }, 34 trunkColors: { type: 'color-array', default: ['#1a0f06', '#3d2210', '#4a2a15'], min: 2, max: 6, label: 'Trunk Colors' }, 35 canopyColors: { type: 'color-array', default: ['#0a2e12', '#0e3a18', '#144a22', '#1a5a2c'], min: 1, max: 8, label: 'Canopy Colors' }, 36} satisfies OptionSchema 37 38export type PineOptions = { 39 [K in keyof typeof pineSchema]?: typeof pineSchema[K]['default'] 40} & { preset?: string } 41 42export const pinePresets: Record<string, Partial<PineOptions>> = { 43 default: {}, 44 tall: { 45 height: 5, 46 layers: 7, 47 trunkRatio: 0.25, 48 coneRadius: 0.5, 49 }, 50 winter: { 51 canopyColors: ['#0d3d06', '#124a0a', '#0a3505'], 52 snowColors: ['#e8e8f0', '#dddde8', '#f0f0f5'], 53 snowAngle: 10, 54 trunkColors: ['#1a1510', '#2a2018', '#3a2a1a'], 55 }, 56 autumn: { 57 canopyColors: ['#8a6a20', '#6a5a18', '#9a7a28', '#5a4a10'], 58 }, 59} 60 61export function pine(options: PineOptions = {}): Mesh { 62 const seed = options.seed ?? pineSchema.seed.default 63 const rand = createRng(seed) 64 const o = resolveOptions(pineSchema, options, pinePresets, rand) 65 66 function subSeed() { return Math.floor(rand() * 2147483647) } 67 68 // Trunk 69 const baseRadius = o.trunkRadius * (1.4 + rand() * 0.4) 70 const topRadius = o.trunkRadius * o.trunkTopScale 71 const trunkHeight = o.height * o.trunkRatio 72 const leanX = (rand() - 0.5) * o.lean 73 const leanZ = (rand() - 0.5) * o.lean 74 const trunkGrad = paletteGradient(o.trunkColors) 75 76 const trunkNoise = new UberNoise({ seed: subSeed(), scale: o.trunkNoiseScale }) 77 const trunk = cylinder({ radius: 1, radiusTop: 1, height: trunkHeight, segments: 5, heightSegments: 3 }) 78 .translate(0, trunkHeight / 2, 0) 79 .warp((pos) => { 80 const t = Math.max(0, Math.min(1, pos[1] / trunkHeight)) 81 const radius = topRadius + (baseRadius - topRadius) * Math.pow(1 - t, o.trunkTaper) 82 const jitterAmt = radius * o.trunkNoiseAmt 83 const nx = trunkNoise.get(pos[0] * 100, pos[1], pos[2] * 100) * jitterAmt 84 const nz = trunkNoise.get(pos[0] * 100 + 500, pos[1] + 500, pos[2] * 100) * jitterAmt 85 return [ 86 pos[0] * radius + leanX * t * t + nx, 87 pos[1], 88 pos[2] * radius + leanZ * t * t + nz, 89 ] 90 }) 91 .vertexColor((pos) => { 92 const t = Math.max(0, Math.min(1, pos[1] / trunkHeight)) 93 return trunkGrad(t) 94 }) 95 96 // Canopy — stacked pyramid cones 97 const canopyParts: Mesh[] = [] 98 const canopyStart = trunkHeight * 0.6 99 const canopyHeight = o.height - canopyStart 100 const layerCount = o.layers 101 102 // Pre-allocate all seeds unconditionally 103 const layerJitterSeeds = Array.from({ length: layerCount }, () => subSeed()) 104 const colorNoiseSeed = subSeed() 105 const snowNoiseSeed = subSeed() 106 107 const canopyGrad = paletteGradient(o.canopyColors) 108 const colorNoise = new UberNoise({ seed: colorNoiseSeed, scale: o.colorNoiseScale }) 109 const hasSnow = o.snowColors.length > 0 110 const snowNoise = hasSnow ? new UberNoise({ seed: snowNoiseSeed, scale: 2 }) : null 111 const snowThreshold = Math.sin(o.snowAngle * Math.PI / 180) 112 113 // Pre-compute layer sizes so we can stack proportionally 114 const layerRadii: number[] = [] 115 const layerHeights: number[] = [] 116 for (let i = 0; i < layerCount; i++) { 117 const r = o.coneRadius * Math.pow(o.layerShrink, i) * (0.9 + rand() * 0.2) 118 layerRadii.push(r) 119 layerHeights.push(r * o.coneHeight) 120 } 121 122 // Stack layers: each layer's base sits at overlap fraction of the previous layer's height 123 const layerYPositions: number[] = [] 124 let curY = canopyStart 125 for (let i = 0; i < layerCount; i++) { 126 layerYPositions.push(curY) 127 curY += layerHeights[i] * (1 - o.layerOverlap) 128 } 129 130 for (let i = 0; i < layerCount; i++) { 131 const t = i / Math.max(1, layerCount - 1) 132 const layerRadius = layerRadii[i] 133 const layerH = layerHeights[i] 134 const layerY = layerYPositions[i] 135 136 // Lean at this height 137 const lt = layerY / o.height 138 const lx = leanX * lt * lt 139 const lz = leanZ * lt * lt 140 141 // Pyramid cone with height segments, quadratic curve, random tilt 142 const rotY = rand() * Math.PI * 2 143 const tiltX = (rand() - 0.5) * o.coneTilt 144 const tiltZ = (rand() - 0.5) * o.coneTilt 145 let layer = cone({ radius: 1, height: layerH, segments: o.coneSides, heightSegments: 3 }) 146 .warp((pos) => { 147 // Quadratic curve: wider at the base than a straight cone 148 const nt = (pos[1] + layerH / 2) / layerH // 0 at bottom, 1 at top 149 const curvedRadius = layerRadius * Math.pow(Math.max(0.01, 1 - nt), 1 / o.coneCurve) 150 return [pos[0] * curvedRadius, pos[1], pos[2] * curvedRadius] 151 }) 152 .rotateY(rotY) 153 .rotateX(tiltX) 154 .rotateZ(tiltZ) 155 .jitter(layerRadius * o.jitter, { seed: layerJitterSeeds[i] }) 156 .translate(lx, layerY + layerH * 0.3, lz) 157 158 // Face color 159 const base = canopyGrad(t) 160 rand() // consume to keep sequence stable (was pickRandom) 161 const snow = pickRandom(hasSnow ? o.snowColors : o.canopyColors, rand) 162 163 layer = layer.faceColor((centroid, normal) => { 164 if (hasSnow && snowNoise) { 165 const n = snowNoise.get(centroid[0], centroid[1], centroid[2]) * 0.15 166 if (normal[1] + n > snowThreshold) { 167 return snow 168 } 169 } 170 171 const top = normal[1] * 0.5 + 0.5 172 const n = colorNoise.get(centroid[0], centroid[1], centroid[2]) * 0.15 173 const darken = 0.6 + top * 0.4 + n 174 return [base[0] * darken, base[1] * darken, base[2] * darken] 175 }) 176 177 canopyParts.push(layer) 178 } 179 180 // Noise-based sway: shift X/Z based on Y height for organic lean 181 const swayNoiseX = new UberNoise({ seed: subSeed(), scale: o.swayScale }) 182 const swayNoiseZ = new UberNoise({ seed: subSeed(), scale: o.swayScale }) 183 184 return merge(trunk, ...canopyParts) 185 .warp((pos) => { 186 const t = pos[1] / o.height 187 const sway = t * t * o.swayAmount 188 return [ 189 pos[0] + swayNoiseX.get(0, pos[1]) * sway, 190 pos[1], 191 pos[2] + swayNoiseZ.get(0, pos[1]) * sway, 192 ] 193 }) 194}