[READ-ONLY] Mirror of https://github.com/flo-bit/shapecraft.
flo-bit.dev/shapecraft/
8.9 kB
218 lines
1import { sphere } from '../primitives'
2import { merge, tube, loft, thicken } 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 { Vec3 } from '../core/types'
9import type { OptionSchema } from '../core/schema'
10
11export const palmSchema = {
12 seed: { type: 'integer', default: 1, min: 1, max: 100, label: 'Seed' },
13 height: { type: 'range', default: 3.5, min: 1.5, max: 7, step: 0.1, label: 'Height' },
14 trunkRadius: { type: 'range', default: 0.07, min: 0.03, max: 0.2, step: 0.01, label: 'Trunk Radius' },
15 trunkTaper: { type: 'range', default: 0.65, min: 0.1, max: 0.9, step: 0.05, label: 'Trunk Taper' },
16 trunkCurve: { type: 'range', default: [0.3, 0.8], min: 0, max: 1.5, step: 0.05, label: 'Trunk Curve' },
17 trunkSegments: { type: 'integer', default: 5, min: 4, max: 8, label: 'Trunk Sides' },
18 trunkPathPoints: { type: 'integer', default: 20, min: 4, max: 40, label: 'Trunk Smoothness' },
19 fronds: { type: 'integer', default: [6, 9], min: 3, max: 14, label: 'Fronds' },
20 frondLength: { type: 'range', default: 1.2, min: 0.4, max: 2.5, step: 0.1, label: 'Frond Length' },
21 frondDroop: { type: 'range', default: [0.9, 1.4], min: 0.1, max: 2, step: 0.05, label: 'Frond Droop' },
22 frondWidth: { type: 'range', default: [0.15, 0.35], min: 0.05, max: 0.6, step: 0.02, label: 'Frond Width' },
23 frondThickness: { type: 'range', default: 0.015, min: 0.005, max: 0.05, step: 0.005, label: 'Frond Thickness' },
24 frondSegments: { type: 'integer', default: 5, min: 3, max: 10, label: 'Frond Segments' },
25 frondCurveUp: { type: 'range', default: 0.3, min: 0, max: 0.8, step: 0.05, label: 'Frond Curve Up' },
26 coconuts: { type: 'integer', default: [0, 4], min: 0, max: 6, label: 'Coconuts' },
27 coconutSize: { type: 'range', default: [0.05, 0.09], min: 0.02, max: 0.12, step: 0.005, label: 'Coconut Size' },
28 jitter: { type: 'range', default: 0.02, min: 0, max: 0.08, step: 0.005, label: 'Jitter' },
29 snowColors: { type: 'color-array', default: [], min: 0, max: 6, label: 'Snow Colors' },
30 snowAngle: { type: 'range', default: 20, min: 0, max: 80, step: 5, label: 'Snow Min Angle (°)' },
31 trunkColors: { type: 'color-array', default: ['#3a2a15', '#5a4025', '#6a5030'], min: 2, max: 6, label: 'Trunk Colors' },
32 frondColors: { type: 'color-array', default: ['#1a4a12', '#224e18', '#2a5520', '#1e4015'], min: 1, max: 8, label: 'Frond Colors' },
33 coconutColor: { type: 'color', default: '#3a2810', label: 'Coconut Color' },
34} satisfies OptionSchema
35
36export type PalmOptions = {
37 [K in keyof typeof palmSchema]?: typeof palmSchema[K]['default']
38} & { preset?: string }
39
40export const palmPresets: Record<string, Partial<PalmOptions>> = {
41 default: {},
42 tall: {
43 height: 6,
44 trunkCurve: [0.1, 0.4],
45 frondLength: 1.8,
46 fronds: [8, 12],
47 },
48 short: {
49 height: 2,
50 trunkCurve: [0.5, 1],
51 frondLength: 0.8,
52 fronds: [5, 7],
53 },
54 winter: {
55 frondColors: ['#1a4a15', '#154012'],
56 snowColors: ['#e8e8f0', '#dddde8', '#f0f0f5'],
57 snowAngle: 10,
58 trunkColors: ['#2a2018', '#3a2a1a', '#4a3a2a'],
59 },
60}
61
62export function palm(options: PalmOptions = {}): Mesh {
63 const seed = options.seed ?? palmSchema.seed.default
64 const rand = createRng(seed)
65 const o = resolveOptions(palmSchema, options, palmPresets, rand)
66
67 function subSeed() { return Math.floor(rand() * 2147483647) }
68
69 // --- Trunk path: curved from base to top ---
70 const trunkHeight = o.height * 0.75
71 const baseRadius = o.trunkRadius * (1.3 + rand() * 0.4)
72 const curveAngle = rand() * Math.PI * 2
73 const curveAmount = o.trunkCurve
74 const curveDirX = Math.cos(curveAngle)
75 const curveDirZ = Math.sin(curveAngle)
76
77 const trunkPath: Vec3[] = []
78 for (let i = 0; i <= o.trunkPathPoints; i++) {
79 const t = i / o.trunkPathPoints
80 const y = t * trunkHeight
81 // S-curve lean
82 const offset = Math.sin(t * Math.PI * 0.8) * t * curveAmount
83 trunkPath.push([
84 curveDirX * offset,
85 y,
86 curveDirZ * offset,
87 ])
88 }
89
90 // Trunk top position
91 const topPt = trunkPath[trunkPath.length - 1]
92
93 const trunkMesh = tube(
94 trunkPath,
95 (t) => {
96 const taper = baseRadius * (1 - t * o.trunkTaper)
97 const wave = 1 + Math.sin(t * Math.PI * 14) * 0.15
98 return taper * wave
99 },
100 o.trunkSegments,
101 )
102 .jitter(baseRadius * 0.1, { seed: subSeed() })
103 .vertexColor((pos) => {
104 const t = Math.max(0, Math.min(1, pos[1] / trunkHeight))
105 return paletteGradient(o.trunkColors)(t)
106 })
107
108 // --- Fronds: flat planes thickened, arcing outward and drooping ---
109 const frondParts: Mesh[] = []
110 const frondCount = o.fronds
111 const frondGrad = paletteGradient(o.frondColors)
112
113 // Pre-allocate seeds
114 const maxFronds = 14
115 const frondSeeds = Array.from({ length: maxFronds }, () => subSeed())
116 const colorNoiseSeed = subSeed()
117 const snowNoiseSeed = subSeed()
118
119 const colorNoise = new UberNoise({ seed: colorNoiseSeed, scale: 1.5 })
120 const hasSnow = o.snowColors.length > 0
121 const snowNoise = hasSnow ? new UberNoise({ seed: snowNoiseSeed, scale: 2 }) : null
122 const snowThreshold = Math.sin(o.snowAngle * Math.PI / 180)
123
124 for (let i = 0; i < frondCount; i++) {
125 const angle = (i / frondCount) * Math.PI * 2 + rand() * 0.5
126 const frondLen = o.frondLength * (0.5 + rand() * 0.7)
127 const droop = o.frondDroop * (0.3 + rand() * 1)
128 const curveUp = o.frondCurveUp * (0.5 + rand() * 1.5)
129 const width = o.frondWidth * (0.6 + rand() * 0.8)
130 const startAngleUp = rand() * 0.6 // some fronds point more upward
131
132 // Build frond path: starts at trunk top, arcs out and droops
133 const frondPath: Vec3[] = []
134 const segs = o.frondSegments
135 const dirX = Math.cos(angle)
136 const dirZ = Math.sin(angle)
137
138 for (let j = 0; j <= segs; j++) {
139 const t = j / segs
140 const outward = t * frondLen
141 // Initial upward angle + arc curve, then gravity droop
142 const upStart = t * startAngleUp * frondLen
143 const upCurve = Math.sin(t * Math.PI * 0.3) * curveUp * frondLen
144 const droopCurve = t * t * droop * frondLen
145 frondPath.push([
146 topPt[0] + dirX * outward,
147 topPt[1] + upStart + upCurve - droopCurve,
148 topPt[2] + dirZ * outward,
149 ])
150 }
151
152 // Frond: loft an open U-shape that tapers toward the tip
153 const frondLoft = loft({
154 path: frondPath,
155 closedShape: false,
156 closed: false,
157 up: [0, 1, 0],
158 shape: (t) => {
159 // Narrow at root, widens, then tapers to tip
160 const envelope = Math.sin(t * Math.PI) * (1 - t * 0.3)
161 const w = width * Math.max(0.05, envelope)
162 const h = w * 0.3
163 // Reversed order so normals face up
164 return [
165 [w, -h * 0.3],
166 [w * 0.7, h * 0.3],
167 [w * 0.3, h * 0.7],
168 [0, h],
169 [-w * 0.3, h * 0.7],
170 [-w * 0.7, h * 0.3],
171 [-w, -h * 0.3],
172 ]
173 },
174 })
175 const frondMesh = thicken(frondLoft, o.frondThickness)
176 .jitter(width * o.jitter, { seed: frondSeeds[i] })
177
178 // Color
179 const base = frondGrad(i / frondCount)
180 const snow = pickRandom(hasSnow ? o.snowColors : o.frondColors, rand)
181 rand() // consume for stability
182
183 const colored = frondMesh.faceColor((centroid, normal) => {
184 if (hasSnow && snowNoise) {
185 const n = snowNoise.get(centroid[0], centroid[1], centroid[2]) * 0.15
186 if (normal[1] + n > snowThreshold) return snow
187 }
188 const top = normal[1] * 0.5 + 0.5
189 const n = colorNoise.get(centroid[0], centroid[1], centroid[2]) * 0.1
190 const darken = 0.6 + top * 0.4 + n
191 return [base[0] * darken, base[1] * darken, base[2] * darken]
192 })
193
194 frondParts.push(colored)
195 }
196
197 // --- Coconuts ---
198 const coconutParts: Mesh[] = []
199 const coconutCount = o.coconuts
200 for (let i = 0; i < coconutCount; i++) {
201 const angle = rand() * Math.PI * 2
202 const topRadius = baseRadius * (1 - o.trunkTaper)
203 const dist = topRadius * (1.5 + rand() * 1)
204 const size = o.coconutSize * (0.7 + rand() * 0.6)
205 const hangY = size * (0.3 + rand() * 0.8)
206 const coconut = sphere({ radius: size, widthSegments: 4, heightSegments: 3 })
207 .scale(0.9 + rand() * 0.2, 1 + rand() * 0.3, 0.9 + rand() * 0.2)
208 .translate(
209 topPt[0] + Math.cos(angle) * dist,
210 topPt[1] - hangY,
211 topPt[2] + Math.sin(angle) * dist,
212 )
213 .vertexColor(o.coconutColor)
214 coconutParts.push(coconut)
215 }
216
217 return merge(trunkMesh, ...frondParts, ...coconutParts)
218}