···
1
1
+
import type { OptionSchema, OptionDef } from './types'
2
2
+
3
3
+
export interface EditorCallbacks {
4
4
+
onChange: (values: Record<string, any>) => void
5
5
+
}
6
6
+
7
7
+
export interface EditorOptions {
8
8
+
presets?: Record<string, Record<string, any>>
9
9
+
}
10
10
+
11
11
+
export function createEditor(
12
12
+
schema: OptionSchema,
13
13
+
callbacks: EditorCallbacks,
14
14
+
editorOptions?: EditorOptions,
15
15
+
): HTMLElement {
16
16
+
const container = document.createElement('div')
17
17
+
container.style.cssText = `
18
18
+
position: fixed; top: 0; right: 0; width: 280px; height: 100vh;
19
19
+
background: rgba(15, 15, 25, 0.92); color: #ddd; font-family: system-ui, sans-serif;
20
20
+
font-size: 12px; overflow-y: auto; padding: 12px; box-sizing: border-box;
21
21
+
backdrop-filter: blur(8px); z-index: 1000;
22
22
+
scrollbar-width: thin; scrollbar-color: #444 transparent;
23
23
+
`
24
24
+
25
25
+
const title = document.createElement('div')
26
26
+
title.textContent = 'Options'
27
27
+
title.style.cssText = 'font-size: 14px; font-weight: 600; margin-bottom: 12px; color: #fff;'
28
28
+
container.appendChild(title)
29
29
+
30
30
+
const values: Record<string, any> = {}
31
31
+
for (const [key, def] of Object.entries(schema)) {
32
32
+
values[key] = structuredClone(def.default)
33
33
+
}
34
34
+
35
35
+
const controls: Map<string, { set: (v: any) => void }> = new Map()
36
36
+
37
37
+
let rafId: number | null = null
38
38
+
function emitChange() {
39
39
+
if (rafId) return
40
40
+
rafId = requestAnimationFrame(() => {
41
41
+
rafId = null
42
42
+
callbacks.onChange({ ...values })
43
43
+
})
44
44
+
}
45
45
+
46
46
+
// Preset selector
47
47
+
if (editorOptions?.presets) {
48
48
+
const presetNames = Object.keys(editorOptions.presets)
49
49
+
const row = document.createElement('div')
50
50
+
row.style.cssText = 'margin-bottom: 14px; padding-bottom: 10px; border-bottom: 1px solid #333;'
51
51
+
52
52
+
const label = document.createElement('label')
53
53
+
label.textContent = 'Preset'
54
54
+
label.style.cssText = 'display: block; margin-bottom: 4px; color: #aaa; font-size: 11px;'
55
55
+
row.appendChild(label)
56
56
+
57
57
+
const select = document.createElement('select')
58
58
+
select.style.cssText = 'width: 100%; padding: 4px; background: #222; color: #ddd; border: 1px solid #444; font-size: 12px;'
59
59
+
for (const name of presetNames) {
60
60
+
const opt = document.createElement('option')
61
61
+
opt.value = name
62
62
+
opt.textContent = name.charAt(0).toUpperCase() + name.slice(1)
63
63
+
select.appendChild(opt)
64
64
+
}
65
65
+
select.addEventListener('change', () => {
66
66
+
applyPreset(select.value)
67
67
+
})
68
68
+
row.appendChild(select)
69
69
+
container.appendChild(row)
70
70
+
71
71
+
function applyPreset(name: string) {
72
72
+
// Reset to schema defaults first
73
73
+
for (const [key, def] of Object.entries(schema)) {
74
74
+
values[key] = structuredClone(def.default)
75
75
+
}
76
76
+
// Apply preset overrides
77
77
+
const preset = editorOptions!.presets![name]
78
78
+
if (preset) {
79
79
+
for (const [key, val] of Object.entries(preset)) {
80
80
+
if (key in values) {
81
81
+
values[key] = structuredClone(val)
82
82
+
}
83
83
+
}
84
84
+
}
85
85
+
// Update all controls
86
86
+
for (const [key, ctrl] of controls) {
87
87
+
ctrl.set(values[key])
88
88
+
}
89
89
+
emitChange()
90
90
+
}
91
91
+
}
92
92
+
93
93
+
for (const [key, def] of Object.entries(schema)) {
94
94
+
const { element, set } = createControl(key, def, values, emitChange)
95
95
+
controls.set(key, { set })
96
96
+
container.appendChild(element)
97
97
+
}
98
98
+
99
99
+
// Initial emit
100
100
+
setTimeout(() => callbacks.onChange({ ...values }), 0)
101
101
+
102
102
+
return container
103
103
+
}
104
104
+
105
105
+
function createControl(
106
106
+
key: string,
107
107
+
def: OptionDef,
108
108
+
values: Record<string, any>,
109
109
+
onChange: () => void
110
110
+
): { element: HTMLElement; set: (v: any) => void } {
111
111
+
const row = document.createElement('div')
112
112
+
row.style.cssText = 'margin-bottom: 10px;'
113
113
+
114
114
+
const label = document.createElement('label')
115
115
+
label.textContent = def.label ?? formatLabel(key)
116
116
+
label.style.cssText = 'display: block; margin-bottom: 3px; color: #aaa; font-size: 11px;'
117
117
+
row.appendChild(label)
118
118
+
119
119
+
let setter: (v: any) => void = () => {}
120
120
+
121
121
+
if (def.type === 'range') {
122
122
+
const step = def.step ?? (def.max - def.min) / 200
123
123
+
const wrap = document.createElement('div')
124
124
+
wrap.style.cssText = 'display: flex; align-items: center; gap: 6px;'
125
125
+
126
126
+
const input = document.createElement('input')
127
127
+
input.type = 'range'
128
128
+
input.min = String(def.min)
129
129
+
input.max = String(def.max)
130
130
+
input.step = String(step)
131
131
+
input.value = String(def.default)
132
132
+
input.style.cssText = 'flex: 1; accent-color: #6a6; height: 4px;'
133
133
+
134
134
+
const num = document.createElement('span')
135
135
+
num.textContent = formatNum(def.default)
136
136
+
num.style.cssText = 'width: 40px; text-align: right; font-size: 11px; color: #888; font-variant-numeric: tabular-nums;'
137
137
+
138
138
+
input.addEventListener('input', () => {
139
139
+
values[key] = parseFloat(input.value)
140
140
+
num.textContent = formatNum(values[key])
141
141
+
onChange()
142
142
+
})
143
143
+
144
144
+
setter = (v) => {
145
145
+
input.value = String(v)
146
146
+
num.textContent = formatNum(v)
147
147
+
}
148
148
+
149
149
+
wrap.appendChild(input)
150
150
+
wrap.appendChild(num)
151
151
+
row.appendChild(wrap)
152
152
+
} else if (def.type === 'integer') {
153
153
+
const wrap = document.createElement('div')
154
154
+
wrap.style.cssText = 'display: flex; align-items: center; gap: 6px;'
155
155
+
156
156
+
const input = document.createElement('input')
157
157
+
input.type = 'range'
158
158
+
input.min = String(def.min)
159
159
+
input.max = String(def.max)
160
160
+
input.step = '1'
161
161
+
input.value = String(def.default)
162
162
+
input.style.cssText = 'flex: 1; accent-color: #6a6; height: 4px;'
163
163
+
164
164
+
const num = document.createElement('span')
165
165
+
num.textContent = String(def.default)
166
166
+
num.style.cssText = 'width: 30px; text-align: right; font-size: 11px; color: #888;'
167
167
+
168
168
+
input.addEventListener('input', () => {
169
169
+
values[key] = parseInt(input.value)
170
170
+
num.textContent = String(values[key])
171
171
+
onChange()
172
172
+
})
173
173
+
174
174
+
setter = (v) => {
175
175
+
input.value = String(v)
176
176
+
num.textContent = String(v)
177
177
+
}
178
178
+
179
179
+
wrap.appendChild(input)
180
180
+
wrap.appendChild(num)
181
181
+
row.appendChild(wrap)
182
182
+
} else if (def.type === 'boolean') {
183
183
+
const input = document.createElement('input')
184
184
+
input.type = 'checkbox'
185
185
+
input.checked = def.default
186
186
+
input.style.cssText = 'accent-color: #6a6;'
187
187
+
input.addEventListener('change', () => {
188
188
+
values[key] = input.checked
189
189
+
onChange()
190
190
+
})
191
191
+
setter = (v) => { input.checked = v }
192
192
+
label.style.cssText = 'display: flex; align-items: center; gap: 6px; color: #aaa; font-size: 11px; cursor: pointer;'
193
193
+
label.prepend(input)
194
194
+
} else if (def.type === 'color') {
195
195
+
const input = document.createElement('input')
196
196
+
input.type = 'color'
197
197
+
input.value = def.default
198
198
+
input.style.cssText = 'width: 100%; height: 24px; border: none; background: none; cursor: pointer;'
199
199
+
input.addEventListener('input', () => {
200
200
+
values[key] = input.value
201
201
+
onChange()
202
202
+
})
203
203
+
setter = (v) => { input.value = v }
204
204
+
row.appendChild(input)
205
205
+
} else if (def.type === 'color-array') {
206
206
+
const wrap = document.createElement('div')
207
207
+
wrap.style.cssText = 'display: flex; flex-wrap: wrap; gap: 4px;'
208
208
+
209
209
+
function rebuild() {
210
210
+
wrap.innerHTML = ''
211
211
+
const arr = values[key] as string[]
212
212
+
213
213
+
for (let i = 0; i < arr.length; i++) {
214
214
+
const swatch = document.createElement('input')
215
215
+
swatch.type = 'color'
216
216
+
swatch.value = arr[i]
217
217
+
swatch.style.cssText = 'width: 32px; height: 24px; border: 1px solid #333; background: none; cursor: pointer; padding: 0;'
218
218
+
const idx = i
219
219
+
swatch.addEventListener('input', () => {
220
220
+
arr[idx] = swatch.value
221
221
+
onChange()
222
222
+
})
223
223
+
swatch.addEventListener('contextmenu', (e) => {
224
224
+
e.preventDefault()
225
225
+
if (arr.length > (def.min ?? 1)) {
226
226
+
arr.splice(idx, 1)
227
227
+
rebuild()
228
228
+
onChange()
229
229
+
}
230
230
+
})
231
231
+
wrap.appendChild(swatch)
232
232
+
}
233
233
+
234
234
+
if (!def.max || arr.length < def.max) {
235
235
+
const add = document.createElement('button')
236
236
+
add.textContent = '+'
237
237
+
add.style.cssText = 'width: 24px; height: 24px; border: 1px dashed #555; background: none; color: #888; cursor: pointer; font-size: 14px;'
238
238
+
add.addEventListener('click', () => {
239
239
+
arr.push(arr[arr.length - 1] ?? '#ffffff')
240
240
+
rebuild()
241
241
+
onChange()
242
242
+
})
243
243
+
wrap.appendChild(add)
244
244
+
}
245
245
+
}
246
246
+
247
247
+
setter = (v) => {
248
248
+
values[key] = structuredClone(v)
249
249
+
rebuild()
250
250
+
}
251
251
+
252
252
+
rebuild()
253
253
+
row.appendChild(wrap)
254
254
+
} else if (def.type === 'select') {
255
255
+
const select = document.createElement('select')
256
256
+
select.style.cssText = 'width: 100%; padding: 4px; background: #222; color: #ddd; border: 1px solid #444; font-size: 12px;'
257
257
+
for (const opt of def.options) {
258
258
+
const el = document.createElement('option')
259
259
+
el.value = opt
260
260
+
el.textContent = opt.charAt(0).toUpperCase() + opt.slice(1)
261
261
+
select.appendChild(el)
262
262
+
}
263
263
+
select.value = def.default
264
264
+
select.addEventListener('change', () => {
265
265
+
values[key] = select.value
266
266
+
onChange()
267
267
+
})
268
268
+
setter = (v) => { select.value = v }
269
269
+
row.appendChild(select)
270
270
+
}
271
271
+
272
272
+
return { element: row, set: setter }
273
273
+
}
274
274
+
275
275
+
function formatLabel(key: string): string {
276
276
+
return key.replace(/([A-Z])/g, ' $1').replace(/^./, s => s.toUpperCase())
277
277
+
}
278
278
+
279
279
+
function formatNum(n: number): string {
280
280
+
return n >= 100 ? String(Math.round(n)) : n.toFixed(2)
281
281
+
}
···
1
1
+
export type { OptionDef, OptionSchema, OptionValues, RangeOption, IntegerOption, ColorOption, ColorArrayOption, BooleanOption, SelectOption } from '../../src/schema'
···
1
1
-
import { icosphere, cylinder, merge, createRng, scatterOnSphere, normalGradient } from '../../src'
1
1
+
import { icosphere, cylinder, merge, createRng, scatterOnSphere, resolveOptions } from '../../src'
2
2
+
import { paletteGradient, pickRandom, type Palette } from '../../src/color'
2
3
import { UberNoise } from '../../src/noise'
3
3
-
import type { Mesh } from '../../src'
4
4
+
import type { Mesh, OptionSchema } from '../../src'
4
5
5
5
-
export interface TreeOptions {
6
6
-
height?: number
7
7
-
trunkRadius?: number
8
8
-
canopyRadius?: number
9
9
-
seed?: number
6
6
+
export const treeSchema = {
7
7
+
seed: { type: 'integer', default: 1, min: 1, max: 100, label: 'Seed' },
8
8
+
height: { type: 'range', default: 2.5, min: 0.5, max: 6, step: 0.1, label: 'Height' },
9
9
+
trunkRadius: { type: 'range', default: 0.12, min: 0.03, max: 0.4, step: 0.01, label: 'Trunk Radius' },
10
10
+
trunkRatio: { type: 'range', default: 0.45, min: 0.2, max: 0.7, step: 0.01, label: 'Trunk Ratio' },
11
11
+
trunkTaper: { type: 'range', default: 2, min: 0.5, max: 5, step: 0.1, label: 'Root Flare' },
12
12
+
trunkTopScale: { type: 'range', default: 0.5, min: 0.02, max: 1, step: 0.02, label: 'Trunk Top Scale' },
13
13
+
lean: { type: 'range', default: 0.4, min: 0, max: 1.5, step: 0.05, label: 'Lean' },
14
14
+
showCanopy: { type: 'boolean', default: true, label: 'Show Canopy' },
15
15
+
canopyRadius: { type: 'range', default: 0.8, min: 0.2, max: 2, step: 0.05, label: 'Canopy Size' },
16
16
+
canopySquash: { type: 'range', default: 0.8, min: 0.3, max: 1, step: 0.05, label: 'Canopy Squash' },
17
17
+
canopyNoise: { type: 'range', default: 0.5, min: 0, max: 1.5, step: 0.05, label: 'Canopy Noise' },
18
18
+
canopyDetail: { type: 'range', default: 0.45, min: 0.15, max: 1, step: 0.05, label: 'Canopy Detail' },
19
19
+
canopyBumps: { type: 'integer', default: 3, min: 0, max: 8, label: 'Canopy Bumps' },
20
20
+
bumpSize: { type: 'range', default: 0.4, min: 0.1, max: 0.8, step: 0.05, label: 'Bump Size' },
21
21
+
canopyOffset: { type: 'range', default: 0.6, min: 0, max: 1.2, step: 0.05, label: 'Canopy Offset' },
22
22
+
jitter: { type: 'range', default: 0.04, min: 0, max: 0.15, step: 0.005, label: 'Jitter' },
23
23
+
snowColors: { type: 'color-array', default: [], min: 0, max: 6, label: 'Snow Colors' },
24
24
+
snowAngle: { type: 'range', default: 30, min: 0, max: 80, step: 5, label: 'Snow Min Angle (°)' },
25
25
+
trunkColors: { type: 'color-array', default: ['#1a0f06', '#4a2815', '#5a3520'], min: 2, max: 6, label: 'Trunk Colors' },
26
26
+
canopyColors: { type: 'color-array', default: ['#1e6b10', '#2a7518', '#238020', '#2d8a1e'], min: 1, max: 8, label: 'Canopy Colors' },
27
27
+
} satisfies OptionSchema
28
28
+
29
29
+
export type TreeOptions = {
30
30
+
[K in keyof typeof treeSchema]?: typeof treeSchema[K]['default']
31
31
+
} & { preset?: string }
32
32
+
33
33
+
export const treePresets: Record<string, Partial<TreeOptions>> = {
34
34
+
default: {},
35
35
+
autumn: {
36
36
+
canopyColors: ['#c44422', '#d48825', '#bf6b1a', '#a83a15', '#dba030'],
37
37
+
},
38
38
+
winter: {
39
39
+
canopyColors: ['#1a5a10', '#1e4a15', '#224d18'],
40
40
+
snowColors: ['#e8e8f0', '#dddde8', '#f0f0f5'],
41
41
+
snowAngle: 15,
42
42
+
trunkColors: ['#1a1510', '#2a2018', '#3a2a1a'],
43
43
+
},
44
44
+
cherry: {
45
45
+
canopyColors: ['#d45a8a', '#e87aa0', '#c44a75', '#f09ab5'],
46
46
+
},
47
47
+
dead: {
48
48
+
showCanopy: false,
49
49
+
},
10
50
}
11
51
12
52
export function tree(options: TreeOptions = {}): Mesh {
13
13
-
const height = options?.height ?? 2.5
14
14
-
const trunkRadius = options?.trunkRadius ?? 0.12
15
15
-
const canopyRadius = options?.canopyRadius ?? 0.8
16
16
-
const seed = options?.seed ?? 1
53
53
+
// Create rand from seed before resolving (needed for [min,max] ranges)
54
54
+
const seed = options.seed ?? treeSchema.seed.default
55
55
+
const rand = createRng(seed)
56
56
+
const o = resolveOptions(treeSchema, options, treePresets, rand)
17
57
18
18
-
const rand = createRng(seed)
58
58
+
// Derive all sub-seeds from the main rand so everything chains deterministically
59
59
+
function subSeed() { return Math.floor(rand() * 2147483647) }
19
60
20
20
-
// Randomize trunk radii
21
21
-
const baseRadius = trunkRadius * (1.6 + rand() * 0.8)
22
22
-
const topRadius = trunkRadius * (0.3 + rand() * 0.4)
61
61
+
// Trunk radii — randomized within range
62
62
+
const baseRadius = o.trunkRadius * (1.6 + rand() * 0.8)
63
63
+
const topRadius = o.trunkRadius * o.trunkTopScale
23
64
24
65
// Bigger trunk → bigger canopy
25
25
-
const canopyScale = baseRadius / (trunkRadius * 2)
26
26
-
const actualCanopyRadius = canopyRadius * canopyScale
66
66
+
const canopyScale = baseRadius / (o.trunkRadius * 2)
67
67
+
const actualCanopyRadius = o.canopyRadius * canopyScale
27
68
28
28
-
// Trunk — quadratic taper (root flare) + slight lean
29
29
-
const trunkHeight = height * 0.45
30
30
-
const leanX = (rand() - 0.5) * 0.4
31
31
-
const leanZ = (rand() - 0.5) * 0.4
69
69
+
// Trunk
70
70
+
const trunkHeight = o.height * o.trunkRatio
71
71
+
const leanX = (rand() - 0.5) * o.lean
72
72
+
const leanZ = (rand() - 0.5) * o.lean
73
73
+
const trunkGrad = paletteGradient(o.trunkColors)
74
74
+
const taperExp = o.trunkTaper
75
75
+
76
76
+
const trunkNoise = new UberNoise({ seed: subSeed(), scale: 8 })
32
77
const trunk = cylinder({ radius: 1, radiusTop: 1, height: trunkHeight, segments: 5, heightSegments: 4 })
33
78
.translate(0, trunkHeight / 2, 0)
34
79
.warp((pos) => {
35
80
const t = Math.max(0, Math.min(1, pos[1] / trunkHeight))
36
36
-
const radius = topRadius + (baseRadius - topRadius) * (1 - t) * (1 - t)
81
81
+
const radius = topRadius + (baseRadius - topRadius) * Math.pow(1 - t, taperExp)
82
82
+
// Noise-based displacement scaled by local radius (thin top = less displacement)
83
83
+
const jitterAmount = radius * 0.3
84
84
+
const nx = trunkNoise.get(pos[0] * 100, pos[1], pos[2] * 100) * jitterAmount
85
85
+
const nz = trunkNoise.get(pos[0] * 100 + 500, pos[1] + 500, pos[2] * 100) * jitterAmount
37
86
return [
38
38
-
pos[0] * radius + leanX * t * t,
87
87
+
pos[0] * radius + leanX * t * t + nx,
39
88
pos[1],
40
40
-
pos[2] * radius + leanZ * t * t,
89
89
+
pos[2] * radius + leanZ * t * t + nz,
41
90
]
42
91
})
43
43
-
.jitter(baseRadius * 0.12, { seed })
44
92
.vertexColor((pos) => {
45
45
-
const t = pos[1] / trunkHeight
46
46
-
return [0.3 + t * 0.05, 0.2 + t * 0.02, 0.1]
93
93
+
const t = Math.max(0, Math.min(1, pos[1] / trunkHeight))
94
94
+
return trunkGrad(t)
47
95
})
48
96
49
97
// Trunk top position after lean
50
98
const topOffsetX = leanX
51
99
const topOffsetZ = leanZ
100
100
+
101
101
+
if (!o.showCanopy) return trunk
52
102
53
103
// Canopy
54
104
const canopyParts: Mesh[] = []
55
55
-
const canopyY = trunkHeight + actualCanopyRadius * 0.6
105
105
+
const canopyY = trunkHeight + actualCanopyRadius * o.canopyOffset
56
106
const mainR = actualCanopyRadius
57
57
-
const edgeLen = canopyRadius * 0.45
107
107
+
const edgeLen = o.canopyRadius * o.canopyDetail
58
108
59
59
-
const canopyColor = normalGradient([0.15, 0.48, 0.08], [0.08, 0.28, 0.04])
109
109
+
const colorNoiseSeed = subSeed()
60
110
61
61
-
function canopyBlob(r: number, blobSeed: number): Mesh {
62
62
-
const noise = new UberNoise({ seed: blobSeed, scale: 0.5, octaves: 3 })
63
63
-
return icosphere({ radius: r, subdivisions: 0 })
111
111
+
function canopyBlob(r: number): Mesh {
112
112
+
const noiseSeed = subSeed()
113
113
+
const jitterSeed = subSeed()
114
114
+
const noise = new UberNoise({ seed: noiseSeed, scale: 0.5, octaves: 3 })
115
115
+
let blob = icosphere({ radius: r, subdivisions: 0 })
64
116
.subdivideAdaptive(edgeLen)
65
65
-
.spherize(r)
66
66
-
.displaceNoise(noise, r * 0.5, { direction: 'radial' })
67
67
-
.jitter(r * 0.04, { seed: blobSeed })
117
117
+
.warp((pos) => {
118
118
+
const len = Math.sqrt(pos[0] * pos[0] + pos[1] * pos[1] + pos[2] * pos[2]) || 1
119
119
+
const nx = pos[0] / len, ny = pos[1] / len, nz = pos[2] / len
120
120
+
const d = r + noise.get(pos[0], pos[1], pos[2]) * r * o.canopyNoise
121
121
+
return [nx * d, ny * d, nz * d]
122
122
+
})
123
123
+
.jitter(r * o.jitter, { seed: jitterSeed })
124
124
+
125
125
+
return blob
126
126
+
}
127
127
+
128
128
+
// Face coloring
129
129
+
const colorNoise = new UberNoise({ seed: colorNoiseSeed, scale: 1.5 })
130
130
+
131
131
+
const hasSnow = o.snowColors.length > 0
132
132
+
const snowNoiseSeed = subSeed() // always consume to keep sequence stable
133
133
+
const snowNoise = hasSnow ? new UberNoise({ seed: snowNoiseSeed, scale: 2 }) : null
134
134
+
const snowThreshold = Math.sin(o.snowAngle * Math.PI / 180)
135
135
+
136
136
+
function blobFaceColor(): (centroid: [number, number, number], normal: [number, number, number], faceIndex: number) => [number, number, number] {
137
137
+
// Pick colors upfront so we don't consume rand() calls inside the per-face loop
138
138
+
const base = pickRandom(o.canopyColors, rand)
139
139
+
// Always consume the rand() call to keep sequence stable regardless of snow setting
140
140
+
const snowPick = pickRandom(hasSnow ? o.snowColors : o.canopyColors, rand)
141
141
+
const snow = hasSnow ? snowPick : null
142
142
+
return (centroid, normal) => {
143
143
+
const top = normal[1] * 0.5 + 0.5
144
144
+
145
145
+
// Snow on upward-facing faces
146
146
+
if (snow && snowNoise) {
147
147
+
const n = snowNoise.get(centroid[0], centroid[1], centroid[2]) * 0.15
148
148
+
if (normal[1] + n > snowThreshold) {
149
149
+
return snow
150
150
+
}
151
151
+
}
152
152
+
153
153
+
const n = colorNoise.get(centroid[0], centroid[1], centroid[2]) * 0.15
154
154
+
const darken = 0.65 + top * 0.35 + n
155
155
+
return [base[0] * darken, base[1] * darken, base[2] * darken]
156
156
+
}
68
157
}
69
158
70
159
// Main sphere
71
71
-
const main = canopyBlob(mainR, seed)
72
72
-
.scale(1, 0.8, 1)
160
160
+
const main = canopyBlob(mainR)
161
161
+
.scale(1, o.canopySquash, 1)
73
162
.translate(topOffsetX, canopyY, topOffsetZ)
74
74
-
.vertexColor(canopyColor)
163
163
+
.faceColor(blobFaceColor())
75
164
canopyParts.push(main)
76
165
77
77
-
// Smaller blobs scattered on main sphere surface (equatorial band)
78
78
-
const blobCount = 2 + Math.floor(rand() * 2)
79
79
-
const blobPositions = scatterOnSphere(blobCount, seed + 100, {
80
80
-
radius: mainR * 0.9,
81
81
-
polarMin: Math.PI * 0.3,
82
82
-
polarMax: Math.PI * 0.7,
83
83
-
})
166
166
+
// Sub-blobs
167
167
+
const blobCount = o.canopyBumps
168
168
+
if (blobCount > 0) {
169
169
+
const blobPositions = scatterOnSphere(blobCount, subSeed(), {
170
170
+
radius: mainR * 0.9,
171
171
+
polarMin: Math.PI * 0.3,
172
172
+
polarMax: Math.PI * 0.7,
173
173
+
})
84
174
85
85
-
for (let i = 0; i < blobCount; i++) {
86
86
-
const [bx, by, bz] = blobPositions[i]
87
87
-
const r = mainR * (i < 1 ? (0.45 + rand() * 0.15) : (0.3 + rand() * 0.15))
175
175
+
for (let i = 0; i < blobCount; i++) {
176
176
+
const [bx, by, bz] = blobPositions[i]
177
177
+
const r = mainR * (o.bumpSize + rand() * 0.15)
88
178
89
89
-
const blob = canopyBlob(r, seed + i + 1)
90
90
-
.scale(1, 0.8, 1)
91
91
-
.translate(bx + topOffsetX, by * 0.8 + canopyY, bz + topOffsetZ)
92
92
-
.vertexColor(canopyColor)
93
93
-
canopyParts.push(blob)
179
179
+
const blob = canopyBlob(r)
180
180
+
.scale(1, o.canopySquash, 1)
181
181
+
.translate(bx + topOffsetX, by * o.canopySquash + canopyY, bz + topOffsetZ)
182
182
+
.faceColor(blobFaceColor())
183
183
+
canopyParts.push(blob)
184
184
+
}
94
185
}
95
186
96
187
return merge(trunk, ...canopyParts)
···
1
1
import * as THREE from 'three'
2
2
import { OrbitControls } from 'three/examples/jsm/controls/OrbitControls.js'
3
3
import { toThreeMesh } from '../src/three'
4
4
-
import { plane, icosphere } from '../src'
4
4
+
import { plane } from '../src'
5
5
import { fbm } from '../src/noise'
6
6
import { heightGradient } from '../src/color'
7
7
-
import { tree } from './generators/tree'
7
7
+
import { tree, treeSchema, treePresets } from './generators/tree'
8
8
+
import { createEditor } from './editor/editor'
8
9
9
10
// --- Renderer ---
10
11
const renderer = new THREE.WebGLRenderer({ antialias: true })
···
19
20
// --- Scene ---
20
21
const scene = new THREE.Scene()
21
22
22
22
-
// --- Sky dome — vertex-colored hemisphere ---
23
23
-
const skyMesh = icosphere({ radius: 50, subdivisions: 3 })
24
24
-
.vertexColor((pos) => {
25
25
-
const t = Math.max(0, pos[1] / 50) // 0 at horizon, 1 at zenith
26
26
-
// Horizon: warm haze → zenith: deeper blue
27
27
-
return [
28
28
-
0.55 + (0.2 - 0.55) * t,
29
29
-
0.7 + (0.35 - 0.7) * t,
30
30
-
0.85 + (0.65 - 0.85) * t,
31
31
-
]
32
32
-
})
33
33
-
const sky = toThreeMesh(skyMesh, {
34
34
-
material: new THREE.MeshBasicMaterial({ vertexColors: true, side: THREE.BackSide }),
35
35
-
})
23
23
+
// --- Sky dome ---
24
24
+
const skyGeo = new THREE.SphereGeometry(50, 16, 12)
25
25
+
const skyColors = new Float32Array(skyGeo.getAttribute('position').count * 3)
26
26
+
const skyPos = skyGeo.getAttribute('position')
27
27
+
for (let i = 0; i < skyPos.count; i++) {
28
28
+
const t = Math.max(0, skyPos.getY(i) / 50)
29
29
+
skyColors[i * 3] = 0.55 + (0.2 - 0.55) * t
30
30
+
skyColors[i * 3 + 1] = 0.7 + (0.35 - 0.7) * t
31
31
+
skyColors[i * 3 + 2] = 0.85 + (0.65 - 0.85) * t
32
32
+
}
33
33
+
skyGeo.setAttribute('color', new THREE.BufferAttribute(skyColors, 3))
34
34
+
const sky = new THREE.Mesh(skyGeo, new THREE.MeshBasicMaterial({ vertexColors: true, side: THREE.BackSide }))
36
35
scene.add(sky)
37
36
38
37
// --- Fog ---
···
46
45
controls.target.set(0, 1, 0)
47
46
controls.enableDamping = true
48
47
controls.dampingFactor = 0.08
49
49
-
controls.maxPolarAngle = Math.PI / 2 - 0.05 // don't go below ground
48
48
+
controls.maxPolarAngle = Math.PI / 2 - 0.05
50
49
controls.update()
51
50
52
51
// --- Lighting ---
53
53
-
// Hemisphere light: sky blue from above, ground green from below
54
52
const hemi = new THREE.HemisphereLight(0x87ceeb, 0x3a5a2a, 0.6)
55
53
scene.add(hemi)
56
54
57
57
-
// Sun
58
55
const sun = new THREE.DirectionalLight(0xfff4e0, 1.4)
59
56
sun.position.set(8, 12, 5)
60
57
sun.castShadow = true
···
68
65
sun.shadow.bias = -0.001
69
66
scene.add(sun)
70
67
71
71
-
// Soft fill from opposite side
72
68
const fill = new THREE.DirectionalLight(0xb0c4de, 0.3)
73
69
fill.position.set(-5, 4, -3)
74
70
scene.add(fill)
75
71
76
76
-
// --- Ground — noise-displaced plane with height coloring ---
72
72
+
// --- Ground ---
77
73
const groundNoise = fbm({ seed: 7, octaves: 3, scale: 0.15, min: 0, max: 0.3 })
78
74
const groundMesh = plane({ size: 30, segments: 60 })
79
75
.displace((pos) => groundNoise.get(pos[0], pos[2]))
···
86
82
groundObj.receiveShadow = true
87
83
scene.add(groundObj)
88
84
89
89
-
// --- Trees — scattered naturally ---
90
90
-
let rngSeed = 42
91
91
-
function rng() { rngSeed = (rngSeed * 16807) % 2147483647; return (rngSeed & 0x7fffffff) / 2147483647 }
85
85
+
// --- Tree management ---
86
86
+
let treeObjects: THREE.Mesh[] = []
87
87
+
88
88
+
function rebuildTrees(opts: Record<string, any>) {
89
89
+
// Remove old trees
90
90
+
for (const obj of treeObjects) {
91
91
+
scene.remove(obj)
92
92
+
obj.geometry.dispose()
93
93
+
if (Array.isArray(obj.material)) {
94
94
+
obj.material.forEach(m => m.dispose())
95
95
+
} else {
96
96
+
obj.material.dispose()
97
97
+
}
98
98
+
}
99
99
+
treeObjects = []
92
100
93
93
-
for (let i = 0; i < 18; i++) {
94
94
-
const x = (rng() - 0.5) * 20
95
95
-
const z = (rng() - 0.5) * 20
96
96
-
const dist = Math.sqrt(x * x + z * z)
97
97
-
if (dist < 1.5) continue // keep center clear
101
101
+
// Scatter trees using a fixed layout RNG
102
102
+
let rngSeed = 42
103
103
+
function rng() { rngSeed = (rngSeed * 16807) % 2147483647; return (rngSeed & 0x7fffffff) / 2147483647 }
98
104
99
99
-
const h = 1.8 + rng() * 1.5
100
100
-
const t = tree({ seed: i + 1, height: h, canopyRadius: 0.5 + rng() * 0.5 })
101
101
-
const obj = toThreeMesh(t)
105
105
+
for (let i = 0; i < 15; i++) {
106
106
+
const x = (rng() - 0.5) * 20
107
107
+
const z = (rng() - 0.5) * 20
108
108
+
const dist = Math.sqrt(x * x + z * z)
109
109
+
if (dist < 1.5) continue
102
110
103
103
-
// Sample ground height at this position
104
104
-
const groundY = groundNoise.get(x, z)
105
105
-
obj.position.set(x, groundY, z)
106
106
-
obj.castShadow = true
107
107
-
obj.receiveShadow = true
108
108
-
scene.add(obj)
111
111
+
const t = tree({
112
112
+
...opts,
113
113
+
seed: (opts.seed ?? 1) + i,
114
114
+
height: (opts.height ?? 2.5) * (0.8 + rng() * 0.4),
115
115
+
})
116
116
+
const obj = toThreeMesh(t)
117
117
+
const groundY = groundNoise.get(x, z)
118
118
+
obj.position.set(x, groundY, z)
119
119
+
obj.castShadow = true
120
120
+
obj.receiveShadow = true
121
121
+
scene.add(obj)
122
122
+
treeObjects.push(obj)
123
123
+
}
109
124
}
125
125
+
126
126
+
// --- Editor ---
127
127
+
const editorEl = createEditor(treeSchema, {
128
128
+
onChange: rebuildTrees,
129
129
+
}, {
130
130
+
presets: treePresets,
131
131
+
})
132
132
+
document.body.appendChild(editorEl)
110
133
111
134
// Expose for screenshot scripts
112
135
;(window as any).__camera = camera
···
22
22
// Utilities
23
23
export { createRng } from './rng'
24
24
export { scatterOnSphere } from './scatter'
25
25
+
26
26
+
// Schema & options
27
27
+
export { resolveOptions } from './schema'
28
28
+
export type { OptionSchema, OptionDef, OptionValues, Randomizable } from './schema'
···
1
1
+
export interface RangeOption {
2
2
+
type: 'range'
3
3
+
default: number
4
4
+
min: number
5
5
+
max: number
6
6
+
step?: number
7
7
+
label?: string
8
8
+
}
9
9
+
10
10
+
export interface IntegerOption {
11
11
+
type: 'integer'
12
12
+
default: number
13
13
+
min: number
14
14
+
max: number
15
15
+
label?: string
16
16
+
}
17
17
+
18
18
+
export interface ColorOption {
19
19
+
type: 'color'
20
20
+
default: string
21
21
+
label?: string
22
22
+
}
23
23
+
24
24
+
export interface ColorArrayOption {
25
25
+
type: 'color-array'
26
26
+
default: string[]
27
27
+
min?: number
28
28
+
max?: number
29
29
+
label?: string
30
30
+
}
31
31
+
32
32
+
export interface BooleanOption {
33
33
+
type: 'boolean'
34
34
+
default: boolean
35
35
+
label?: string
36
36
+
}
37
37
+
38
38
+
export interface SelectOption {
39
39
+
type: 'select'
40
40
+
default: string
41
41
+
options: string[]
42
42
+
label?: string
43
43
+
}
44
44
+
45
45
+
export type OptionDef = RangeOption | IntegerOption | ColorOption | ColorArrayOption | BooleanOption | SelectOption
46
46
+
47
47
+
export type OptionSchema = Record<string, OptionDef>
48
48
+
49
49
+
export type OptionValues<S extends OptionSchema> = {
50
50
+
[K in keyof S]: S[K] extends RangeOption ? number
51
51
+
: S[K] extends IntegerOption ? number
52
52
+
: S[K] extends ColorOption ? string
53
53
+
: S[K] extends ColorArrayOption ? string[]
54
54
+
: S[K] extends BooleanOption ? boolean
55
55
+
: S[K] extends SelectOption ? string
56
56
+
: never
57
57
+
}
58
58
+
59
59
+
/** A value that can be fixed or a [min, max] range resolved at generation time */
60
60
+
export type Randomizable<T> = T | [T, T]
61
61
+
62
62
+
/**
63
63
+
* Resolve options for a generator: apply preset, then overrides, then fill schema defaults.
64
64
+
* Numeric values can be [min, max] tuples — resolved using rand() if provided.
65
65
+
*/
66
66
+
export function resolveOptions<S extends OptionSchema>(
67
67
+
schema: S,
68
68
+
options: Record<string, any>,
69
69
+
presets?: Record<string, Record<string, any>>,
70
70
+
rand?: () => number,
71
71
+
): OptionValues<S> {
72
72
+
const presetName = options.preset ?? 'default'
73
73
+
const preset = presets?.[presetName] ?? {}
74
74
+
const { preset: _, ...overrides } = options
75
75
+
76
76
+
const resolved: Record<string, any> = {}
77
77
+
for (const [key, def] of Object.entries(schema)) {
78
78
+
let val = overrides[key] ?? preset[key] ?? structuredClone(def.default)
79
79
+
80
80
+
// Resolve [min, max] ranges for numeric types
81
81
+
if (Array.isArray(val) && val.length === 2 && typeof val[0] === 'number' && typeof val[1] === 'number'
82
82
+
&& (def.type === 'range' || def.type === 'integer')) {
83
83
+
const r = rand ? rand() : Math.random()
84
84
+
val = val[0] + (val[1] - val[0]) * r
85
85
+
if (def.type === 'integer') val = Math.round(val)
86
86
+
}
87
87
+
88
88
+
resolved[key] = val
89
89
+
}
90
90
+
91
91
+
return resolved as OptionValues<S>
92
92
+
}
···
5
5
material?: THREE.Material
6
6
flatShading?: boolean
7
7
wireframe?: boolean
8
8
+
roughness?: number
9
9
+
metalness?: number
8
10
}): THREE.Mesh {
9
11
const geometry = toThreeGeometry(mesh)
10
12
const flatShading = options?.flatShading ?? true
11
13
const wireframe = options?.wireframe ?? false
14
14
+
const roughness = options?.roughness ?? 1
15
15
+
const metalness = options?.metalness ?? 0
12
16
13
17
let material: THREE.Material
14
18
if (options?.material) {
···
18
22
vertexColors: true,
19
23
flatShading,
20
24
wireframe,
25
25
+
roughness,
26
26
+
metalness,
21
27
})
22
28
} else {
23
29
material = new THREE.MeshStandardMaterial({
24
30
color: 0xcccccc,
25
31
flatShading,
26
32
wireframe,
33
33
+
roughness,
34
34
+
metalness,
27
35
})
28
36
}
29
37