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