demo
src
color
core
generators
modifiers
ops
primitives
three
worker
tests
···
4
4
import { plane, createRng } from '../src'
5
5
import { fbm } from '../src/noise'
6
6
import { heightGradient } from '../src/color'
7
7
-
import { tree } from './generators/common-tree'
8
8
-
import { pine } from './generators/pine-tree'
9
9
-
import { palm } from './generators/palm-tree'
7
7
+
import { tree } from '../src/generators/common-tree'
8
8
+
import { pine } from '../src/generators/pine-tree'
9
9
+
import { palm } from '../src/generators/palm-tree'
10
10
11
11
// --- Renderer ---
12
12
const renderer = new THREE.WebGLRenderer({ antialias: true })
···
4
4
import { plane } from '../src'
5
5
import { fbm } from '../src/noise'
6
6
import { heightGradient } from '../src/color'
7
7
-
import { tree, treeSchema, treePresets } from './generators/common-tree'
8
8
-
import { pine, pineSchema, pinePresets } from './generators/pine-tree'
9
9
-
import { palm, palmSchema, palmPresets } from './generators/palm-tree'
7
7
+
import { tree, treeSchema, treePresets } from '../src/generators/common-tree'
8
8
+
import { pine, pineSchema, pinePresets } from '../src/generators/pine-tree'
9
9
+
import { palm, palmSchema, palmPresets } from '../src/generators/palm-tree'
10
10
import { createEditor } from './editor/editor'
11
11
12
12
// --- Renderer ---
···
10
10
"./modifiers": "./src/modifiers/index.ts",
11
11
"./ops": "./src/ops/index.ts",
12
12
"./color": "./src/color/index.ts",
13
13
-
"./uv": "./src/uv/index.ts"
13
13
+
"./uv": "./src/uv/index.ts",
14
14
+
"./generators": "./src/generators/index.ts"
14
15
},
15
16
"scripts": {
16
17
"dev": "vite demo",
···
1
1
-
import type { ColorInput, ColorFn, Vec3 } from '../types'
1
1
+
import type { ColorInput, ColorFn, Vec3 } from '../core/types'
2
2
import { parseColorToRgb, lerpColor } from './utils'
3
3
4
4
export type GradientStop = [number, ColorInput]
···
1
1
import { interpolate, oklch, formatRgb, parse, converter } from 'culori'
2
2
-
import type { ColorInput, ColorFn, Vec3 } from '../types'
3
3
-
import type { NoiseLike } from '../types'
2
2
+
import type { ColorInput, ColorFn, Vec3 } from '../core/types'
3
3
+
import type { NoiseLike } from '../core/types'
4
4
5
5
const toOklch = converter('oklch')
6
6
···
1
1
import * as THREE from 'three'
2
2
-
import type { ColorInput } from '../types'
2
2
+
import type { ColorInput } from '../core/types'
3
3
4
4
export function parseColorToRgb(input: ColorInput): [number, number, number] {
5
5
if (typeof input === 'number') {
···
1
1
+
import { icosphere, cylinder } from '../primitives'
2
2
+
import { merge } from '../ops'
3
3
+
import { createRng } from '../core/rng'
4
4
+
import { scatterOnSphere } from '../core/scatter'
5
5
+
import { resolveOptions } from '../core/schema'
6
6
+
import { paletteGradient, pickRandom, type Palette } from '../color'
7
7
+
import { UberNoise } from '../noise'
8
8
+
import type { Mesh } from '../core/mesh'
9
9
+
import type { OptionSchema } from '../core/schema'
10
10
+
11
11
+
export const treeSchema = {
12
12
+
seed: { type: 'integer', default: 1, min: 1, max: 100, label: 'Seed' },
13
13
+
height: { type: 'range', default: 2.5, min: 0.5, max: 6, step: 0.1, label: 'Height' },
14
14
+
trunkRadius: { type: 'range', default: 0.12, min: 0.03, max: 0.4, step: 0.01, label: 'Trunk Radius' },
15
15
+
trunkRatio: { type: 'range', default: 0.45, min: 0.2, max: 0.7, step: 0.01, label: 'Trunk Ratio' },
16
16
+
trunkTaper: { type: 'range', default: 2, min: 0.5, max: 5, step: 0.1, label: 'Root Flare' },
17
17
+
trunkTopScale: { type: 'range', default: 0.5, min: 0.02, max: 1, step: 0.02, label: 'Trunk Top Scale' },
18
18
+
lean: { type: 'range', default: 0.4, min: 0, max: 1.5, step: 0.05, label: 'Lean' },
19
19
+
showCanopy: { type: 'boolean', default: true, label: 'Show Canopy' },
20
20
+
canopyRadius: { type: 'range', default: 0.8, min: 0.2, max: 2, step: 0.05, label: 'Canopy Size' },
21
21
+
canopySquash: { type: 'range', default: 0.8, min: 0.3, max: 1, step: 0.05, label: 'Canopy Squash' },
22
22
+
canopyNoise: { type: 'range', default: 0.5, min: 0, max: 1.5, step: 0.05, label: 'Canopy Noise' },
23
23
+
canopyDetail: { type: 'range', default: 0.45, min: 0.15, max: 1, step: 0.05, label: 'Canopy Detail' },
24
24
+
canopyBumps: { type: 'integer', default: 3, min: 0, max: 8, label: 'Canopy Bumps' },
25
25
+
bumpSize: { type: 'range', default: 0.4, min: 0.1, max: 0.8, step: 0.05, label: 'Bump Size' },
26
26
+
canopyOffset: { type: 'range', default: 0.6, min: 0, max: 1.2, step: 0.05, label: 'Canopy Offset' },
27
27
+
jitter: { type: 'range', default: 0.04, min: 0, max: 0.15, step: 0.005, label: 'Jitter' },
28
28
+
snowColors: { type: 'color-array', default: [], min: 0, max: 6, label: 'Snow Colors' },
29
29
+
snowAngle: { type: 'range', default: 30, min: 0, max: 80, step: 5, label: 'Snow Min Angle (°)' },
30
30
+
trunkColors: { type: 'color-array', default: ['#1a0f06', '#4a2815', '#5a3520'], min: 2, max: 6, label: 'Trunk Colors' },
31
31
+
canopyColors: { type: 'color-array', default: ['#1e6b10', '#2a7518', '#238020', '#2d8a1e'], min: 1, max: 8, label: 'Canopy Colors' },
32
32
+
} satisfies OptionSchema
33
33
+
34
34
+
export type TreeOptions = {
35
35
+
[K in keyof typeof treeSchema]?: typeof treeSchema[K]['default']
36
36
+
} & { preset?: string }
37
37
+
38
38
+
export const treePresets: Record<string, Partial<TreeOptions>> = {
39
39
+
default: {},
40
40
+
autumn: {
41
41
+
canopyColors: ['#c44422', '#d48825', '#bf6b1a', '#a83a15', '#dba030'],
42
42
+
},
43
43
+
winter: {
44
44
+
canopyColors: ['#1a5a10', '#1e4a15', '#224d18'],
45
45
+
snowColors: ['#e8e8f0', '#dddde8', '#f0f0f5'],
46
46
+
snowAngle: 15,
47
47
+
trunkColors: ['#1a1510', '#2a2018', '#3a2a1a'],
48
48
+
},
49
49
+
cherry: {
50
50
+
canopyColors: ['#d45a8a', '#e87aa0', '#c44a75', '#f09ab5'],
51
51
+
},
52
52
+
dead: {
53
53
+
showCanopy: false,
54
54
+
},
55
55
+
}
56
56
+
57
57
+
export function tree(options: TreeOptions = {}): Mesh {
58
58
+
// Create rand from seed before resolving (needed for [min,max] ranges)
59
59
+
const seed = options.seed ?? treeSchema.seed.default
60
60
+
const rand = createRng(seed)
61
61
+
const o = resolveOptions(treeSchema, options, treePresets, rand)
62
62
+
63
63
+
// Derive all sub-seeds from the main rand so everything chains deterministically
64
64
+
function subSeed() { return Math.floor(rand() * 2147483647) }
65
65
+
66
66
+
// Trunk radii — randomized within range
67
67
+
const baseRadius = o.trunkRadius * (1.6 + rand() * 0.8)
68
68
+
const topRadius = o.trunkRadius * o.trunkTopScale
69
69
+
70
70
+
// Bigger trunk → bigger canopy
71
71
+
const canopyScale = baseRadius / (o.trunkRadius * 2)
72
72
+
const actualCanopyRadius = o.canopyRadius * canopyScale
73
73
+
74
74
+
// Trunk
75
75
+
const trunkHeight = o.height * o.trunkRatio
76
76
+
const leanX = (rand() - 0.5) * o.lean
77
77
+
const leanZ = (rand() - 0.5) * o.lean
78
78
+
const trunkGrad = paletteGradient(o.trunkColors)
79
79
+
const taperExp = o.trunkTaper
80
80
+
81
81
+
const trunkNoise = new UberNoise({ seed: subSeed(), scale: 8 })
82
82
+
const trunk = cylinder({ radius: 1, radiusTop: 1, height: trunkHeight, segments: 5, heightSegments: 4 })
83
83
+
.translate(0, trunkHeight / 2, 0)
84
84
+
.warp((pos) => {
85
85
+
const t = Math.max(0, Math.min(1, pos[1] / trunkHeight))
86
86
+
const radius = topRadius + (baseRadius - topRadius) * Math.pow(1 - t, taperExp)
87
87
+
// Noise-based displacement scaled by local radius (thin top = less displacement)
88
88
+
const jitterAmount = radius * 0.3
89
89
+
const nx = trunkNoise.get(pos[0] * 100, pos[1], pos[2] * 100) * jitterAmount
90
90
+
const nz = trunkNoise.get(pos[0] * 100 + 500, pos[1] + 500, pos[2] * 100) * jitterAmount
91
91
+
return [
92
92
+
pos[0] * radius + leanX * t * t + nx,
93
93
+
pos[1],
94
94
+
pos[2] * radius + leanZ * t * t + nz,
95
95
+
]
96
96
+
})
97
97
+
.vertexColor((pos) => {
98
98
+
const t = Math.max(0, Math.min(1, pos[1] / trunkHeight))
99
99
+
return trunkGrad(t)
100
100
+
})
101
101
+
102
102
+
// Trunk top position after lean
103
103
+
const topOffsetX = leanX
104
104
+
const topOffsetZ = leanZ
105
105
+
106
106
+
if (!o.showCanopy) return trunk
107
107
+
108
108
+
// Canopy
109
109
+
const canopyParts: Mesh[] = []
110
110
+
const canopyY = trunkHeight + actualCanopyRadius * o.canopyOffset
111
111
+
const mainR = actualCanopyRadius
112
112
+
const edgeLen = o.canopyRadius * o.canopyDetail
113
113
+
114
114
+
const colorNoiseSeed = subSeed()
115
115
+
116
116
+
function canopyBlob(r: number): Mesh {
117
117
+
const noiseSeed = subSeed()
118
118
+
const jitterSeed = subSeed()
119
119
+
const noise = new UberNoise({ seed: noiseSeed, scale: 0.5, octaves: 3 })
120
120
+
let blob = icosphere({ radius: r, subdivisions: 0 })
121
121
+
.subdivideAdaptive(edgeLen)
122
122
+
.warp((pos) => {
123
123
+
const len = Math.sqrt(pos[0] * pos[0] + pos[1] * pos[1] + pos[2] * pos[2]) || 1
124
124
+
const nx = pos[0] / len, ny = pos[1] / len, nz = pos[2] / len
125
125
+
const d = r + noise.get(pos[0], pos[1], pos[2]) * r * o.canopyNoise
126
126
+
return [nx * d, ny * d, nz * d]
127
127
+
})
128
128
+
.jitter(r * o.jitter, { seed: jitterSeed })
129
129
+
130
130
+
return blob
131
131
+
}
132
132
+
133
133
+
// Face coloring
134
134
+
const colorNoise = new UberNoise({ seed: colorNoiseSeed, scale: 1.5 })
135
135
+
136
136
+
const hasSnow = o.snowColors.length > 0
137
137
+
const snowNoiseSeed = subSeed() // always consume to keep sequence stable
138
138
+
const snowNoise = hasSnow ? new UberNoise({ seed: snowNoiseSeed, scale: 2 }) : null
139
139
+
const snowThreshold = Math.sin(o.snowAngle * Math.PI / 180)
140
140
+
141
141
+
function blobFaceColor(): (centroid: [number, number, number], normal: [number, number, number], faceIndex: number) => [number, number, number] {
142
142
+
// Pick colors upfront so we don't consume rand() calls inside the per-face loop
143
143
+
const base = pickRandom(o.canopyColors, rand)
144
144
+
// Always consume the rand() call to keep sequence stable regardless of snow setting
145
145
+
const snowPick = pickRandom(hasSnow ? o.snowColors : o.canopyColors, rand)
146
146
+
const snow = hasSnow ? snowPick : null
147
147
+
return (centroid, normal) => {
148
148
+
const top = normal[1] * 0.5 + 0.5
149
149
+
150
150
+
// Snow on upward-facing faces
151
151
+
if (snow && snowNoise) {
152
152
+
const n = snowNoise.get(centroid[0], centroid[1], centroid[2]) * 0.15
153
153
+
if (normal[1] + n > snowThreshold) {
154
154
+
return snow
155
155
+
}
156
156
+
}
157
157
+
158
158
+
const n = colorNoise.get(centroid[0], centroid[1], centroid[2]) * 0.15
159
159
+
const darken = 0.65 + top * 0.35 + n
160
160
+
return [base[0] * darken, base[1] * darken, base[2] * darken]
161
161
+
}
162
162
+
}
163
163
+
164
164
+
// Main sphere
165
165
+
const main = canopyBlob(mainR)
166
166
+
.scale(1, o.canopySquash, 1)
167
167
+
.translate(topOffsetX, canopyY, topOffsetZ)
168
168
+
.faceColor(blobFaceColor())
169
169
+
canopyParts.push(main)
170
170
+
171
171
+
// Sub-blobs
172
172
+
const blobCount = o.canopyBumps
173
173
+
if (blobCount > 0) {
174
174
+
const blobPositions = scatterOnSphere(blobCount, subSeed(), {
175
175
+
radius: mainR * 0.9,
176
176
+
polarMin: Math.PI * 0.3,
177
177
+
polarMax: Math.PI * 0.7,
178
178
+
})
179
179
+
180
180
+
for (let i = 0; i < blobCount; i++) {
181
181
+
const [bx, by, bz] = blobPositions[i]
182
182
+
const r = mainR * (o.bumpSize + rand() * 0.15)
183
183
+
184
184
+
const blob = canopyBlob(r)
185
185
+
.scale(1, o.canopySquash, 1)
186
186
+
.translate(bx + topOffsetX, by * o.canopySquash + canopyY, bz + topOffsetZ)
187
187
+
.faceColor(blobFaceColor())
188
188
+
canopyParts.push(blob)
189
189
+
}
190
190
+
}
191
191
+
192
192
+
return merge(trunk, ...canopyParts)
193
193
+
}
···
1
1
+
export { tree, treeSchema, treePresets, type TreeOptions } from './common-tree'
2
2
+
export { pine, pineSchema, pinePresets, type PineOptions } from './pine-tree'
3
3
+
export { palm, palmSchema, palmPresets, type PalmOptions } from './palm-tree'
···
1
1
+
import { sphere } from '../primitives'
2
2
+
import { merge, tube, loft, thicken } from '../ops'
3
3
+
import { createRng } from '../core/rng'
4
4
+
import { resolveOptions } from '../core/schema'
5
5
+
import { paletteGradient, pickRandom } from '../color'
6
6
+
import { UberNoise } from '../noise'
7
7
+
import type { Mesh } from '../core/mesh'
8
8
+
import type { Vec3 } from '../core/types'
9
9
+
import type { OptionSchema } from '../core/schema'
10
10
+
11
11
+
export const palmSchema = {
12
12
+
seed: { type: 'integer', default: 1, min: 1, max: 100, label: 'Seed' },
13
13
+
height: { type: 'range', default: 3.5, min: 1.5, max: 7, step: 0.1, label: 'Height' },
14
14
+
trunkRadius: { type: 'range', default: 0.07, min: 0.03, max: 0.2, step: 0.01, label: 'Trunk Radius' },
15
15
+
trunkTaper: { type: 'range', default: 0.65, min: 0.1, max: 0.9, step: 0.05, label: 'Trunk Taper' },
16
16
+
trunkCurve: { type: 'range', default: [0.3, 0.8], min: 0, max: 1.5, step: 0.05, label: 'Trunk Curve' },
17
17
+
trunkSegments: { type: 'integer', default: 5, min: 4, max: 8, label: 'Trunk Sides' },
18
18
+
trunkPathPoints: { type: 'integer', default: 20, min: 4, max: 40, label: 'Trunk Smoothness' },
19
19
+
fronds: { type: 'integer', default: [6, 9], min: 3, max: 14, label: 'Fronds' },
20
20
+
frondLength: { type: 'range', default: 1.2, min: 0.4, max: 2.5, step: 0.1, label: 'Frond Length' },
21
21
+
frondDroop: { type: 'range', default: [0.9, 1.4], min: 0.1, max: 2, step: 0.05, label: 'Frond Droop' },
22
22
+
frondWidth: { type: 'range', default: [0.15, 0.35], min: 0.05, max: 0.6, step: 0.02, label: 'Frond Width' },
23
23
+
frondThickness: { type: 'range', default: 0.015, min: 0.005, max: 0.05, step: 0.005, label: 'Frond Thickness' },
24
24
+
frondSegments: { type: 'integer', default: 5, min: 3, max: 10, label: 'Frond Segments' },
25
25
+
frondCurveUp: { type: 'range', default: 0.3, min: 0, max: 0.8, step: 0.05, label: 'Frond Curve Up' },
26
26
+
coconuts: { type: 'integer', default: [0, 4], min: 0, max: 6, label: 'Coconuts' },
27
27
+
coconutSize: { type: 'range', default: [0.05, 0.09], min: 0.02, max: 0.12, step: 0.005, label: 'Coconut Size' },
28
28
+
jitter: { type: 'range', default: 0.02, min: 0, max: 0.08, step: 0.005, label: 'Jitter' },
29
29
+
snowColors: { type: 'color-array', default: [], min: 0, max: 6, label: 'Snow Colors' },
30
30
+
snowAngle: { type: 'range', default: 20, min: 0, max: 80, step: 5, label: 'Snow Min Angle (°)' },
31
31
+
trunkColors: { type: 'color-array', default: ['#3a2a15', '#5a4025', '#6a5030'], min: 2, max: 6, label: 'Trunk Colors' },
32
32
+
frondColors: { type: 'color-array', default: ['#1a4a12', '#224e18', '#2a5520', '#1e4015'], min: 1, max: 8, label: 'Frond Colors' },
33
33
+
coconutColor: { type: 'color', default: '#3a2810', label: 'Coconut Color' },
34
34
+
} satisfies OptionSchema
35
35
+
36
36
+
export type PalmOptions = {
37
37
+
[K in keyof typeof palmSchema]?: typeof palmSchema[K]['default']
38
38
+
} & { preset?: string }
39
39
+
40
40
+
export const palmPresets: Record<string, Partial<PalmOptions>> = {
41
41
+
default: {},
42
42
+
tall: {
43
43
+
height: 6,
44
44
+
trunkCurve: [0.1, 0.4],
45
45
+
frondLength: 1.8,
46
46
+
fronds: [8, 12],
47
47
+
},
48
48
+
short: {
49
49
+
height: 2,
50
50
+
trunkCurve: [0.5, 1],
51
51
+
frondLength: 0.8,
52
52
+
fronds: [5, 7],
53
53
+
},
54
54
+
winter: {
55
55
+
frondColors: ['#1a4a15', '#154012'],
56
56
+
snowColors: ['#e8e8f0', '#dddde8', '#f0f0f5'],
57
57
+
snowAngle: 10,
58
58
+
trunkColors: ['#2a2018', '#3a2a1a', '#4a3a2a'],
59
59
+
},
60
60
+
}
61
61
+
62
62
+
export function palm(options: PalmOptions = {}): Mesh {
63
63
+
const seed = options.seed ?? palmSchema.seed.default
64
64
+
const rand = createRng(seed)
65
65
+
const o = resolveOptions(palmSchema, options, palmPresets, rand)
66
66
+
67
67
+
function subSeed() { return Math.floor(rand() * 2147483647) }
68
68
+
69
69
+
// --- Trunk path: curved from base to top ---
70
70
+
const trunkHeight = o.height * 0.75
71
71
+
const baseRadius = o.trunkRadius * (1.3 + rand() * 0.4)
72
72
+
const curveAngle = rand() * Math.PI * 2
73
73
+
const curveAmount = o.trunkCurve
74
74
+
const curveDirX = Math.cos(curveAngle)
75
75
+
const curveDirZ = Math.sin(curveAngle)
76
76
+
77
77
+
const trunkPath: Vec3[] = []
78
78
+
for (let i = 0; i <= o.trunkPathPoints; i++) {
79
79
+
const t = i / o.trunkPathPoints
80
80
+
const y = t * trunkHeight
81
81
+
// S-curve lean
82
82
+
const offset = Math.sin(t * Math.PI * 0.8) * t * curveAmount
83
83
+
trunkPath.push([
84
84
+
curveDirX * offset,
85
85
+
y,
86
86
+
curveDirZ * offset,
87
87
+
])
88
88
+
}
89
89
+
90
90
+
// Trunk top position
91
91
+
const topPt = trunkPath[trunkPath.length - 1]
92
92
+
93
93
+
const trunkMesh = tube(
94
94
+
trunkPath,
95
95
+
(t) => {
96
96
+
const taper = baseRadius * (1 - t * o.trunkTaper)
97
97
+
const wave = 1 + Math.sin(t * Math.PI * 14) * 0.15
98
98
+
return taper * wave
99
99
+
},
100
100
+
o.trunkSegments,
101
101
+
)
102
102
+
.jitter(baseRadius * 0.1, { seed: subSeed() })
103
103
+
.vertexColor((pos) => {
104
104
+
const t = Math.max(0, Math.min(1, pos[1] / trunkHeight))
105
105
+
return paletteGradient(o.trunkColors)(t)
106
106
+
})
107
107
+
108
108
+
// --- Fronds: flat planes thickened, arcing outward and drooping ---
109
109
+
const frondParts: Mesh[] = []
110
110
+
const frondCount = o.fronds
111
111
+
const frondGrad = paletteGradient(o.frondColors)
112
112
+
113
113
+
// Pre-allocate seeds
114
114
+
const maxFronds = 14
115
115
+
const frondSeeds = Array.from({ length: maxFronds }, () => subSeed())
116
116
+
const colorNoiseSeed = subSeed()
117
117
+
const snowNoiseSeed = subSeed()
118
118
+
119
119
+
const colorNoise = new UberNoise({ seed: colorNoiseSeed, scale: 1.5 })
120
120
+
const hasSnow = o.snowColors.length > 0
121
121
+
const snowNoise = hasSnow ? new UberNoise({ seed: snowNoiseSeed, scale: 2 }) : null
122
122
+
const snowThreshold = Math.sin(o.snowAngle * Math.PI / 180)
123
123
+
124
124
+
for (let i = 0; i < frondCount; i++) {
125
125
+
const angle = (i / frondCount) * Math.PI * 2 + rand() * 0.5
126
126
+
const frondLen = o.frondLength * (0.5 + rand() * 0.7)
127
127
+
const droop = o.frondDroop * (0.3 + rand() * 1)
128
128
+
const curveUp = o.frondCurveUp * (0.5 + rand() * 1.5)
129
129
+
const width = o.frondWidth * (0.6 + rand() * 0.8)
130
130
+
const startAngleUp = rand() * 0.6 // some fronds point more upward
131
131
+
132
132
+
// Build frond path: starts at trunk top, arcs out and droops
133
133
+
const frondPath: Vec3[] = []
134
134
+
const segs = o.frondSegments
135
135
+
const dirX = Math.cos(angle)
136
136
+
const dirZ = Math.sin(angle)
137
137
+
138
138
+
for (let j = 0; j <= segs; j++) {
139
139
+
const t = j / segs
140
140
+
const outward = t * frondLen
141
141
+
// Initial upward angle + arc curve, then gravity droop
142
142
+
const upStart = t * startAngleUp * frondLen
143
143
+
const upCurve = Math.sin(t * Math.PI * 0.3) * curveUp * frondLen
144
144
+
const droopCurve = t * t * droop * frondLen
145
145
+
frondPath.push([
146
146
+
topPt[0] + dirX * outward,
147
147
+
topPt[1] + upStart + upCurve - droopCurve,
148
148
+
topPt[2] + dirZ * outward,
149
149
+
])
150
150
+
}
151
151
+
152
152
+
// Frond: loft an open U-shape that tapers toward the tip
153
153
+
const frondLoft = loft({
154
154
+
path: frondPath,
155
155
+
closedShape: false,
156
156
+
closed: false,
157
157
+
up: [0, 1, 0],
158
158
+
shape: (t) => {
159
159
+
// Narrow at root, widens, then tapers to tip
160
160
+
const envelope = Math.sin(t * Math.PI) * (1 - t * 0.3)
161
161
+
const w = width * Math.max(0.05, envelope)
162
162
+
const h = w * 0.3
163
163
+
// Reversed order so normals face up
164
164
+
return [
165
165
+
[w, -h * 0.3],
166
166
+
[w * 0.7, h * 0.3],
167
167
+
[w * 0.3, h * 0.7],
168
168
+
[0, h],
169
169
+
[-w * 0.3, h * 0.7],
170
170
+
[-w * 0.7, h * 0.3],
171
171
+
[-w, -h * 0.3],
172
172
+
]
173
173
+
},
174
174
+
})
175
175
+
const frondMesh = thicken(frondLoft, o.frondThickness)
176
176
+
.jitter(width * o.jitter, { seed: frondSeeds[i] })
177
177
+
178
178
+
// Color
179
179
+
const base = frondGrad(i / frondCount)
180
180
+
const snow = pickRandom(hasSnow ? o.snowColors : o.frondColors, rand)
181
181
+
rand() // consume for stability
182
182
+
183
183
+
const colored = frondMesh.faceColor((centroid, normal) => {
184
184
+
if (hasSnow && snowNoise) {
185
185
+
const n = snowNoise.get(centroid[0], centroid[1], centroid[2]) * 0.15
186
186
+
if (normal[1] + n > snowThreshold) return snow
187
187
+
}
188
188
+
const top = normal[1] * 0.5 + 0.5
189
189
+
const n = colorNoise.get(centroid[0], centroid[1], centroid[2]) * 0.1
190
190
+
const darken = 0.6 + top * 0.4 + n
191
191
+
return [base[0] * darken, base[1] * darken, base[2] * darken]
192
192
+
})
193
193
+
194
194
+
frondParts.push(colored)
195
195
+
}
196
196
+
197
197
+
// --- Coconuts ---
198
198
+
const coconutParts: Mesh[] = []
199
199
+
const coconutCount = o.coconuts
200
200
+
for (let i = 0; i < coconutCount; i++) {
201
201
+
const angle = rand() * Math.PI * 2
202
202
+
const topRadius = baseRadius * (1 - o.trunkTaper)
203
203
+
const dist = topRadius * (1.5 + rand() * 1)
204
204
+
const size = o.coconutSize * (0.7 + rand() * 0.6)
205
205
+
const hangY = size * (0.3 + rand() * 0.8)
206
206
+
const coconut = sphere({ radius: size, widthSegments: 4, heightSegments: 3 })
207
207
+
.scale(0.9 + rand() * 0.2, 1 + rand() * 0.3, 0.9 + rand() * 0.2)
208
208
+
.translate(
209
209
+
topPt[0] + Math.cos(angle) * dist,
210
210
+
topPt[1] - hangY,
211
211
+
topPt[2] + Math.sin(angle) * dist,
212
212
+
)
213
213
+
.vertexColor(o.coconutColor)
214
214
+
coconutParts.push(coconut)
215
215
+
}
216
216
+
217
217
+
return merge(trunkMesh, ...frondParts, ...coconutParts)
218
218
+
}
···
1
1
+
import { cylinder, cone } from '../primitives'
2
2
+
import { merge } from '../ops'
3
3
+
import { createRng } from '../core/rng'
4
4
+
import { resolveOptions } from '../core/schema'
5
5
+
import { paletteGradient, pickRandom } from '../color'
6
6
+
import { UberNoise } from '../noise'
7
7
+
import type { Mesh } from '../core/mesh'
8
8
+
import type { OptionSchema } from '../core/schema'
9
9
+
10
10
+
export const pineSchema = {
11
11
+
seed: { type: 'integer', default: 1, min: 1, max: 100, label: 'Seed' },
12
12
+
height: { type: 'range', default: 3, min: 1, max: 8, step: 0.1, label: 'Height' },
13
13
+
trunkRadius: { type: 'range', default: 0.08, min: 0.02, max: 0.25, step: 0.01, label: 'Trunk Radius' },
14
14
+
trunkRatio: { type: 'range', default: 0.3, min: 0.1, max: 0.5, step: 0.05, label: 'Trunk Ratio' },
15
15
+
trunkTaper: { type: 'range', default: 1.5, min: 0.5, max: 4, step: 0.1, label: 'Root Flare' },
16
16
+
trunkTopScale: { type: 'range', default: 0.4, min: 0.1, max: 0.8, step: 0.05, label: 'Trunk Top Scale' },
17
17
+
lean: { type: 'range', default: 0.15, min: 0, max: 0.8, step: 0.05, label: 'Lean' },
18
18
+
layers: { type: 'integer', default: 5, min: 2, max: 10, label: 'Layers' },
19
19
+
layerOverlap: { type: 'range', default: 0.4, min: 0, max: 0.8, step: 0.05, label: 'Layer Overlap' },
20
20
+
layerShrink: { type: 'range', default: 0.75, min: 0.4, max: 0.95, step: 0.05, label: 'Layer Shrink' },
21
21
+
coneRadius: { type: 'range', default: 0.7, min: 0.2, max: 1.5, step: 0.05, label: 'Cone Radius' },
22
22
+
coneHeight: { type: 'range', default: 1.2, min: 0.4, max: 3, step: 0.1, label: 'Cone Height' },
23
23
+
coneSides: { type: 'integer', default: 8, min: 3, max: 12, label: 'Cone Sides' },
24
24
+
coneCurve: { type: 'range', default: 1.5, min: 1, max: 4, step: 0.1, label: 'Cone Curve' },
25
25
+
coneTilt: { type: 'range', default: [0.1, 0.4], min: 0, max: 0.6, step: 0.02, label: 'Cone Tilt' },
26
26
+
jitter: { type: 'range', default: 0.03, min: 0, max: 0.15, step: 0.005, label: 'Jitter' },
27
27
+
swayScale: { type: 'range', default: 0.8, min: 0.2, max: 5, step: 0.1, label: 'Sway Scale' },
28
28
+
swayAmount: { type: 'range', default: [0.15, 0.6], min: 0, max: 1, step: 0.05, label: 'Sway Amount' },
29
29
+
trunkNoiseScale:{ type: 'range', default: 8, min: 1, max: 20, step: 0.5, label: 'Trunk Noise Scale' },
30
30
+
trunkNoiseAmt: { type: 'range', default: 0.25, min: 0, max: 0.5, step: 0.05, label: 'Trunk Noise Amount' },
31
31
+
colorNoiseScale:{ type: 'range', default: 1.5, min: 0.3, max: 5, step: 0.1, label: 'Color Noise Scale' },
32
32
+
snowColors: { type: 'color-array', default: [], min: 0, max: 6, label: 'Snow Colors' },
33
33
+
snowAngle: { type: 'range', default: 20, min: 0, max: 80, step: 5, label: 'Snow Min Angle (°)' },
34
34
+
trunkColors: { type: 'color-array', default: ['#1a0f06', '#3d2210', '#4a2a15'], min: 2, max: 6, label: 'Trunk Colors' },
35
35
+
canopyColors: { type: 'color-array', default: ['#0a2e12', '#0e3a18', '#144a22', '#1a5a2c'], min: 1, max: 8, label: 'Canopy Colors' },
36
36
+
} satisfies OptionSchema
37
37
+
38
38
+
export type PineOptions = {
39
39
+
[K in keyof typeof pineSchema]?: typeof pineSchema[K]['default']
40
40
+
} & { preset?: string }
41
41
+
42
42
+
export const pinePresets: Record<string, Partial<PineOptions>> = {
43
43
+
default: {},
44
44
+
tall: {
45
45
+
height: 5,
46
46
+
layers: 7,
47
47
+
trunkRatio: 0.25,
48
48
+
coneRadius: 0.5,
49
49
+
},
50
50
+
winter: {
51
51
+
canopyColors: ['#0d3d06', '#124a0a', '#0a3505'],
52
52
+
snowColors: ['#e8e8f0', '#dddde8', '#f0f0f5'],
53
53
+
snowAngle: 10,
54
54
+
trunkColors: ['#1a1510', '#2a2018', '#3a2a1a'],
55
55
+
},
56
56
+
autumn: {
57
57
+
canopyColors: ['#8a6a20', '#6a5a18', '#9a7a28', '#5a4a10'],
58
58
+
},
59
59
+
}
60
60
+
61
61
+
export function pine(options: PineOptions = {}): Mesh {
62
62
+
const seed = options.seed ?? pineSchema.seed.default
63
63
+
const rand = createRng(seed)
64
64
+
const o = resolveOptions(pineSchema, options, pinePresets, rand)
65
65
+
66
66
+
function subSeed() { return Math.floor(rand() * 2147483647) }
67
67
+
68
68
+
// Trunk
69
69
+
const baseRadius = o.trunkRadius * (1.4 + rand() * 0.4)
70
70
+
const topRadius = o.trunkRadius * o.trunkTopScale
71
71
+
const trunkHeight = o.height * o.trunkRatio
72
72
+
const leanX = (rand() - 0.5) * o.lean
73
73
+
const leanZ = (rand() - 0.5) * o.lean
74
74
+
const trunkGrad = paletteGradient(o.trunkColors)
75
75
+
76
76
+
const trunkNoise = new UberNoise({ seed: subSeed(), scale: o.trunkNoiseScale })
77
77
+
const trunk = cylinder({ radius: 1, radiusTop: 1, height: trunkHeight, segments: 5, heightSegments: 3 })
78
78
+
.translate(0, trunkHeight / 2, 0)
79
79
+
.warp((pos) => {
80
80
+
const t = Math.max(0, Math.min(1, pos[1] / trunkHeight))
81
81
+
const radius = topRadius + (baseRadius - topRadius) * Math.pow(1 - t, o.trunkTaper)
82
82
+
const jitterAmt = radius * o.trunkNoiseAmt
83
83
+
const nx = trunkNoise.get(pos[0] * 100, pos[1], pos[2] * 100) * jitterAmt
84
84
+
const nz = trunkNoise.get(pos[0] * 100 + 500, pos[1] + 500, pos[2] * 100) * jitterAmt
85
85
+
return [
86
86
+
pos[0] * radius + leanX * t * t + nx,
87
87
+
pos[1],
88
88
+
pos[2] * radius + leanZ * t * t + nz,
89
89
+
]
90
90
+
})
91
91
+
.vertexColor((pos) => {
92
92
+
const t = Math.max(0, Math.min(1, pos[1] / trunkHeight))
93
93
+
return trunkGrad(t)
94
94
+
})
95
95
+
96
96
+
// Canopy — stacked pyramid cones
97
97
+
const canopyParts: Mesh[] = []
98
98
+
const canopyStart = trunkHeight * 0.6
99
99
+
const canopyHeight = o.height - canopyStart
100
100
+
const layerCount = o.layers
101
101
+
102
102
+
// Pre-allocate all seeds unconditionally
103
103
+
const layerJitterSeeds = Array.from({ length: layerCount }, () => subSeed())
104
104
+
const colorNoiseSeed = subSeed()
105
105
+
const snowNoiseSeed = subSeed()
106
106
+
107
107
+
const canopyGrad = paletteGradient(o.canopyColors)
108
108
+
const colorNoise = new UberNoise({ seed: colorNoiseSeed, scale: o.colorNoiseScale })
109
109
+
const hasSnow = o.snowColors.length > 0
110
110
+
const snowNoise = hasSnow ? new UberNoise({ seed: snowNoiseSeed, scale: 2 }) : null
111
111
+
const snowThreshold = Math.sin(o.snowAngle * Math.PI / 180)
112
112
+
113
113
+
// Pre-compute layer sizes so we can stack proportionally
114
114
+
const layerRadii: number[] = []
115
115
+
const layerHeights: number[] = []
116
116
+
for (let i = 0; i < layerCount; i++) {
117
117
+
const r = o.coneRadius * Math.pow(o.layerShrink, i) * (0.9 + rand() * 0.2)
118
118
+
layerRadii.push(r)
119
119
+
layerHeights.push(r * o.coneHeight)
120
120
+
}
121
121
+
122
122
+
// Stack layers: each layer's base sits at overlap fraction of the previous layer's height
123
123
+
const layerYPositions: number[] = []
124
124
+
let curY = canopyStart
125
125
+
for (let i = 0; i < layerCount; i++) {
126
126
+
layerYPositions.push(curY)
127
127
+
curY += layerHeights[i] * (1 - o.layerOverlap)
128
128
+
}
129
129
+
130
130
+
for (let i = 0; i < layerCount; i++) {
131
131
+
const t = i / Math.max(1, layerCount - 1)
132
132
+
const layerRadius = layerRadii[i]
133
133
+
const layerH = layerHeights[i]
134
134
+
const layerY = layerYPositions[i]
135
135
+
136
136
+
// Lean at this height
137
137
+
const lt = layerY / o.height
138
138
+
const lx = leanX * lt * lt
139
139
+
const lz = leanZ * lt * lt
140
140
+
141
141
+
// Pyramid cone with height segments, quadratic curve, random tilt
142
142
+
const rotY = rand() * Math.PI * 2
143
143
+
const tiltX = (rand() - 0.5) * o.coneTilt
144
144
+
const tiltZ = (rand() - 0.5) * o.coneTilt
145
145
+
let layer = cone({ radius: 1, height: layerH, segments: o.coneSides, heightSegments: 3 })
146
146
+
.warp((pos) => {
147
147
+
// Quadratic curve: wider at the base than a straight cone
148
148
+
const nt = (pos[1] + layerH / 2) / layerH // 0 at bottom, 1 at top
149
149
+
const curvedRadius = layerRadius * Math.pow(Math.max(0.01, 1 - nt), 1 / o.coneCurve)
150
150
+
return [pos[0] * curvedRadius, pos[1], pos[2] * curvedRadius]
151
151
+
})
152
152
+
.rotateY(rotY)
153
153
+
.rotateX(tiltX)
154
154
+
.rotateZ(tiltZ)
155
155
+
.jitter(layerRadius * o.jitter, { seed: layerJitterSeeds[i] })
156
156
+
.translate(lx, layerY + layerH * 0.3, lz)
157
157
+
158
158
+
// Face color
159
159
+
const base = canopyGrad(t)
160
160
+
rand() // consume to keep sequence stable (was pickRandom)
161
161
+
const snow = pickRandom(hasSnow ? o.snowColors : o.canopyColors, rand)
162
162
+
163
163
+
layer = layer.faceColor((centroid, normal) => {
164
164
+
if (hasSnow && snowNoise) {
165
165
+
const n = snowNoise.get(centroid[0], centroid[1], centroid[2]) * 0.15
166
166
+
if (normal[1] + n > snowThreshold) {
167
167
+
return snow
168
168
+
}
169
169
+
}
170
170
+
171
171
+
const top = normal[1] * 0.5 + 0.5
172
172
+
const n = colorNoise.get(centroid[0], centroid[1], centroid[2]) * 0.15
173
173
+
const darken = 0.6 + top * 0.4 + n
174
174
+
return [base[0] * darken, base[1] * darken, base[2] * darken]
175
175
+
})
176
176
+
177
177
+
canopyParts.push(layer)
178
178
+
}
179
179
+
180
180
+
// Noise-based sway: shift X/Z based on Y height for organic lean
181
181
+
const swayNoiseX = new UberNoise({ seed: subSeed(), scale: o.swayScale })
182
182
+
const swayNoiseZ = new UberNoise({ seed: subSeed(), scale: o.swayScale })
183
183
+
184
184
+
return merge(trunk, ...canopyParts)
185
185
+
.warp((pos) => {
186
186
+
const t = pos[1] / o.height
187
187
+
const sway = t * t * o.swayAmount
188
188
+
return [
189
189
+
pos[0] + swayNoiseX.get(0, pos[1]) * sway,
190
190
+
pos[1],
191
191
+
pos[2] + swayNoiseZ.get(0, pos[1]) * sway,
192
192
+
]
193
193
+
})
194
194
+
}
···
1
1
// Core
2
2
-
export { Mesh } from './mesh'
3
3
-
export type { Vec2, Vec3, Vec4, ColorInput, ColorFn, DisplaceFn, WarpFn, NoiseLike } from './types'
2
2
+
export { Mesh } from './core/mesh'
3
3
+
export type { Vec2, Vec3, Vec4, ColorInput, ColorFn, DisplaceFn, WarpFn, NoiseLike } from './core/types'
4
4
5
5
// Primitives
6
6
export { box, sphere, cylinder, plane, cone, torus, icosphere } from './primitives'
···
20
20
export { projectUVs } from './uv'
21
21
22
22
// Utilities
23
23
-
export { createRng } from './rng'
24
24
-
export { scatterOnSphere } from './scatter'
23
23
+
export { createRng } from './core/rng'
24
24
+
export { scatterOnSphere } from './core/scatter'
25
25
26
26
// Schema & options
27
27
-
export { resolveOptions } from './schema'
28
28
-
export type { OptionSchema, OptionDef, OptionValues, Randomizable } from './schema'
27
27
+
export { resolveOptions } from './core/schema'
28
28
+
export type { OptionSchema, OptionDef, OptionValues, Randomizable } from './core/schema'
29
29
+
30
30
+
// Generators
31
31
+
export { tree, treeSchema, treePresets } from './generators'
32
32
+
export { pine, pineSchema, pinePresets } from './generators'
33
33
+
export { palm, palmSchema, palmPresets } from './generators'
···
1
1
-
import type { WarpFn, Vec3 } from '../types'
1
1
+
import type { WarpFn, Vec3 } from '../core/types'
2
2
3
3
export function bend(options: { axis?: 'x' | 'y' | 'z'; amount: number }): WarpFn {
4
4
const { axis = 'y', amount } = options
···
1
1
-
import { Mesh } from '../mesh'
1
1
+
import { Mesh } from '../core/mesh'
2
2
3
3
// STUB for v0 — lattice deformation placeholder
4
4
export interface LatticeOptions {
···
1
1
import * as THREE from 'three'
2
2
-
import { Mesh } from '../mesh'
2
2
+
import { Mesh } from '../core/mesh'
3
3
4
4
export function smooth(mesh: Mesh, iterations: number = 1): Mesh {
5
5
let geo = mesh.geometry.clone()
···
1
1
-
import type { WarpFn, Vec3 } from '../types'
1
1
+
import type { WarpFn, Vec3 } from '../core/types'
2
2
3
3
export function taper(options: { axis?: 'x' | 'y' | 'z'; curve?: (t: number) => number }): WarpFn {
4
4
const { axis = 'y', curve = (t: number) => 1 - t } = options
···
1
1
-
import type { WarpFn, Vec3 } from '../types'
1
1
+
import type { WarpFn, Vec3 } from '../core/types'
2
2
3
3
export function twist(options: { axis?: 'x' | 'y' | 'z'; amount: number }): WarpFn {
4
4
const { axis = 'y', amount } = options
···
1
1
-
import { Mesh } from '../mesh'
1
1
+
import { Mesh } from '../core/mesh'
2
2
3
3
export function center(mesh: Mesh): Mesh {
4
4
return mesh.center()
···
1
1
-
import { Mesh } from '../mesh'
1
1
+
import { Mesh } from '../core/mesh'
2
2
3
3
export function clone(mesh: Mesh): Mesh {
4
4
return mesh.clone()
···
1
1
import * as THREE from 'three'
2
2
-
import { Mesh } from '../mesh'
3
3
-
import type { Vec3 } from '../types'
2
2
+
import { Mesh } from '../core/mesh'
3
3
+
import type { Vec3 } from '../core/types'
4
4
5
5
export interface LoftOptions {
6
6
/** Points along the path */
···
1
1
import * as THREE from 'three'
2
2
import { mergeGeometries } from 'three/examples/jsm/utils/BufferGeometryUtils.js'
3
3
-
import { Mesh } from '../mesh'
3
3
+
import { Mesh } from '../core/mesh'
4
4
5
5
export function merge(...meshes: Mesh[]): Mesh {
6
6
if (meshes.length === 0) {
···
1
1
import * as THREE from 'three'
2
2
-
import { Mesh } from '../mesh'
2
2
+
import { Mesh } from '../core/mesh'
3
3
4
4
/**
5
5
* Give a mesh thickness by duplicating it, offsetting along normals,
···
1
1
import * as THREE from 'three'
2
2
-
import { Mesh } from '../mesh'
3
3
-
import type { Vec3 } from '../types'
2
2
+
import { Mesh } from '../core/mesh'
3
3
+
import type { Vec3 } from '../core/types'
4
4
5
5
export interface BoxOptions {
6
6
width?: number
···
1
1
import * as THREE from 'three'
2
2
-
import { Mesh } from '../mesh'
2
2
+
import { Mesh } from '../core/mesh'
3
3
4
4
export interface ConeOptions {
5
5
radius?: number
···
1
1
import * as THREE from 'three'
2
2
-
import { Mesh } from '../mesh'
2
2
+
import { Mesh } from '../core/mesh'
3
3
4
4
export interface CylinderOptions {
5
5
radius?: number
···
1
1
import * as THREE from 'three'
2
2
-
import { Mesh } from '../mesh'
2
2
+
import { Mesh } from '../core/mesh'
3
3
4
4
export interface IcosphereOptions {
5
5
radius?: number
···
1
1
import * as THREE from 'three'
2
2
-
import { Mesh } from '../mesh'
3
3
-
import type { Vec2 } from '../types'
2
2
+
import { Mesh } from '../core/mesh'
3
3
+
import type { Vec2 } from '../core/types'
4
4
5
5
export interface PlaneOptions {
6
6
width?: number
···
1
1
import * as THREE from 'three'
2
2
-
import { Mesh } from '../mesh'
2
2
+
import { Mesh } from '../core/mesh'
3
3
4
4
export interface SphereOptions {
5
5
radius?: number
···
1
1
import * as THREE from 'three'
2
2
-
import { Mesh } from '../mesh'
2
2
+
import { Mesh } from '../core/mesh'
3
3
4
4
export interface TorusOptions {
5
5
radius?: number
···
1
1
-
import { aleaFactory } from './noise/alea/alea'
1
1
+
import { aleaFactory } from '../noise/alea/alea'
2
2
3
3
/**
4
4
* Create a seeded random number generator.
···
1
1
import * as THREE from 'three'
2
2
-
import { Mesh } from '../mesh'
2
2
+
import { Mesh } from '../core/mesh'
3
3
4
4
export function fromThreeGeometry(geometry: THREE.BufferGeometry): Mesh {
5
5
return new Mesh(geometry.clone())
···
1
1
import * as THREE from 'three'
2
2
-
import { Mesh } from '../mesh'
2
2
+
import { Mesh } from '../core/mesh'
3
3
4
4
export function toThreeMesh(mesh: Mesh, options?: {
5
5
material?: THREE.Material
···
1
1
-
import { Mesh } from '../mesh'
1
1
+
import { Mesh } from '../core/mesh'
2
2
3
3
export interface WorkerPoolOptions {
4
4
maxWorkers?: number
···
1
1
import { describe, it, expect } from 'vitest'
2
2
import * as THREE from 'three'
3
3
-
import { Mesh } from '../src/mesh'
3
3
+
import { Mesh } from '../src/core/mesh'
4
4
5
5
function makeTriangleGeometry(): THREE.BufferGeometry {
6
6
const geo = new THREE.BufferGeometry()