[READ-ONLY] Mirror of https://github.com/flo-bit/shapecraft.
flo-bit.dev/shapecraft/
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}