import { sphere } from '../../../primitives' import { merge, loft, thicken, snow as applySnow } from '../../../ops' import { setup, trunk, facetShade } from '../../../build' import { paletteGradient, pickRandom } from '../../../color' import { UberNoise } from '../../../noise' import { group, part, Asset } from '../../../core/asset' import { VERTEX_COLOR_MATERIAL } from '../../../core/material' import type { Mesh } from '../../../core/mesh' import type { Vec3 } from '../../../core/types' import type { OptionSchema, OptionInput } from '../../../core/schema' export const palmSchema = { seed: { type: 'integer', default: 1, min: 1, max: 100, label: 'Seed', group: 'General' }, height: { type: 'range', default: 9, min: 4, max: 18, step: 0.2, label: 'Height', group: 'Trunk' }, trunkRadius: { type: 'range', default: 0.18, min: 0.08, max: 0.5, step: 0.02, label: 'Trunk Radius', group: 'Trunk' }, trunkTaper: { type: 'range', default: 0.65, min: 0.1, max: 0.9, step: 0.05, label: 'Trunk Taper', group: 'Trunk' }, trunkCurve: { type: 'range', default: [0.75, 2], min: 0, max: 4, step: 0.1, label: 'Trunk Curve', group: 'Trunk' }, trunkSegments: { type: 'integer', default: 5, min: 4, max: 8, label: 'Trunk Sides', group: 'Trunk' }, trunkPathPoints: { type: 'integer', default: 20, min: 4, max: 40, label: 'Trunk Smoothness', group: 'Trunk' }, fronds: { type: 'integer', default: [6, 9], min: 3, max: 14, label: 'Fronds', group: 'Fronds' }, frondLength: { type: 'range', default: 3, min: 1, max: 6, step: 0.2, label: 'Frond Length', group: 'Fronds' }, frondDroop: { type: 'range', default: [0.9, 1.4], min: 0.1, max: 2, step: 0.05, label: 'Frond Droop', group: 'Fronds' }, frondWidth: { type: 'range', default: [0.4, 0.9], min: 0.12, max: 1.5, step: 0.05, label: 'Frond Width', group: 'Fronds' }, frondThickness: { type: 'range', default: 0.04, min: 0.012, max: 0.12, step: 0.01, label: 'Frond Thickness', group: 'Fronds' }, frondSegments: { type: 'integer', default: 5, min: 3, max: 10, label: 'Frond Segments', group: 'Fronds' }, frondCurveUp: { type: 'range', default: 0.3, min: 0, max: 0.8, step: 0.05, label: 'Frond Curve Up', group: 'Fronds' }, coconuts: { type: 'integer', default: [0, 4], min: 0, max: 6, label: 'Coconuts', group: 'Coconuts' }, coconutSize: { type: 'range', default: [0.12, 0.22], min: 0.05, max: 0.3, step: 0.01, label: 'Coconut Size', group: 'Coconuts' }, jitter: { type: 'range', default: 0.02, min: 0, max: 0.08, step: 0.005, label: 'Jitter', group: 'General' }, snowColors: { type: 'color-array', default: [], min: 0, max: 6, label: 'Snow Colors', group: 'Snow' }, snowAngle: { type: 'range', default: 20, min: 0, max: 80, step: 5, label: 'Snow Min Angle (°)', group: 'Snow' }, snowDepth: { type: 'range', default: 0, min: 0, max: 0.6, step: 0.02, label: 'Snow Depth', group: 'Snow' }, trunkColors: { type: 'color-array', default: ['#3a2a15', '#5a4025', '#6a5030'], min: 2, max: 6, label: 'Trunk Colors', group: 'Colors' }, frondColors: { type: 'color-array', default: ['#1a4a12', '#224e18', '#2a5520', '#1e4015'], min: 1, max: 8, label: 'Frond Colors', group: 'Colors' }, coconutColor: { type: 'color', default: '#3a2810', label: 'Coconut Color', group: 'Coconuts' }, } satisfies OptionSchema export type PalmOptions = Partial> & { preset?: string } export const palmPresets: Record> = { default: {}, tall: { height: 15, trunkCurve: [0.25, 1], frondLength: 4.5, fronds: [8, 12], }, short: { height: 5, trunkCurve: [1.25, 2.5], frondLength: 2, fronds: [5, 7], }, winter: { frondColors: ['#1a4a15', '#154012'], snowColors: ['#e8e8f0', '#dddde8', '#f0f0f5'], snowAngle: 10, trunkColors: ['#2a2018', '#3a2a1a', '#4a3a2a'], }, } export function palm(options: PalmOptions = {}): Asset { const { o, rng } = setup(palmSchema, options, palmPresets) // Independent streams per concern (see common-tree for rationale). const trunkRng = rng.stream('trunk') const frondRng = rng.stream('fronds') const colorRng = rng.stream('color') const snowRng = rng.stream('snow') const coconutRng = rng.stream('coconut') // --- Trunk path: curved from base to top --- const trunkHeight = o.height * 0.75 const baseRadius = o.trunkRadius * (1.3 + trunkRng() * 0.4) const curveAngle = trunkRng() * Math.PI * 2 const curveAmount = o.trunkCurve const curveDirX = Math.cos(curveAngle) const curveDirZ = Math.sin(curveAngle) const trunkPath: Vec3[] = [] for (let i = 0; i <= o.trunkPathPoints; i++) { const t = i / o.trunkPathPoints const y = t * trunkHeight // S-curve lean const offset = Math.sin(t * Math.PI * 0.8) * t * curveAmount trunkPath.push([ curveDirX * offset, y, curveDirZ * offset, ]) } // Trunk top position const topPt = trunkPath[trunkPath.length - 1] const trunkMesh = trunk({ path: trunkPath, radiusProfile: (t) => { const taper = baseRadius * (1 - t * o.trunkTaper) const wave = 1 + Math.sin(t * Math.PI * 14) * 0.15 return taper * wave }, segments: o.trunkSegments, jitter: baseRadius * 0.1, jitterSeed: trunkRng.seed(), colors: o.trunkColors, }) // --- Fronds: flat planes thickened, arcing outward and drooping --- const frondParts: Mesh[] = [] const frondCount = o.fronds const frondGrad = paletteGradient(o.frondColors) // Per-frond jitter seeds from the frond stream const maxFronds = 14 const frondSeeds = Array.from({ length: maxFronds }, () => frondRng.seed()) const colorNoise = new UberNoise({ seed: colorRng.seed(), scale: 0.6 }) const hasSnow = o.snowColors.length > 0 const useGeoSnow = hasSnow && o.snowDepth > 0 const snowNoise = hasSnow ? new UberNoise({ seed: snowRng.seed(), scale: 0.8 }) : null const snowThreshold = Math.sin(o.snowAngle * Math.PI / 180) for (let i = 0; i < frondCount; i++) { const angle = (i / frondCount) * Math.PI * 2 + frondRng() * 0.5 const frondLen = o.frondLength * (0.5 + frondRng() * 0.7) const droop = o.frondDroop * (0.3 + frondRng() * 1) const curveUp = o.frondCurveUp * (0.5 + frondRng() * 1.5) const width = o.frondWidth * (0.6 + frondRng() * 0.8) const startAngleUp = frondRng() * 0.6 // some fronds point more upward // Build frond path: starts at trunk top, arcs out and droops const frondPath: Vec3[] = [] const segs = o.frondSegments const dirX = Math.cos(angle) const dirZ = Math.sin(angle) for (let j = 0; j <= segs; j++) { const t = j / segs const outward = t * frondLen // Initial upward angle + arc curve, then gravity droop const upStart = t * startAngleUp * frondLen const upCurve = Math.sin(t * Math.PI * 0.3) * curveUp * frondLen const droopCurve = t * t * droop * frondLen frondPath.push([ topPt[0] + dirX * outward, topPt[1] + upStart + upCurve - droopCurve, topPt[2] + dirZ * outward, ]) } // Frond: loft an open U-shape that tapers toward the tip const frondLoft = loft({ path: frondPath, closedShape: false, closed: false, up: [0, 1, 0], shape: (t) => { // Narrow at root, widens, then tapers to tip const envelope = Math.sin(t * Math.PI) * (1 - t * 0.3) const w = width * Math.max(0.05, envelope) const h = w * 0.3 // Reversed order so normals face up return [ [w, -h * 0.3], [w * 0.7, h * 0.3], [w * 0.3, h * 0.7], [0, h], [-w * 0.3, h * 0.7], [-w * 0.7, h * 0.3], [-w, -h * 0.3], ] }, }) const frondMesh = thicken(frondLoft, o.frondThickness) .jitter(width * o.jitter, { seed: frondSeeds[i] }) // Color const base = frondGrad(i / frondCount) const snowCol = (hasSnow && !useGeoSnow) ? pickRandom(o.snowColors, snowRng) : null const colored = frondMesh.faceColor(facetShade({ base, noise: colorNoise, ambient: 0.6, range: 0.4, noiseAmount: 0.1, snow: snowCol && snowNoise ? { color: snowCol, noise: snowNoise, threshold: snowThreshold, noiseAmount: 0.15 } : undefined, })) frondParts.push(colored) } // --- Coconuts --- const coconutParts: Mesh[] = [] const coconutCount = o.coconuts for (let i = 0; i < coconutCount; i++) { const angle = coconutRng() * Math.PI * 2 const topRadius = baseRadius * (1 - o.trunkTaper) const dist = topRadius * (1.5 + coconutRng() * 1) const size = o.coconutSize * (0.7 + coconutRng() * 0.6) const hangY = size * (0.3 + coconutRng() * 0.8) const coconut = sphere({ radius: size, widthSegments: 4, heightSegments: 3 }) .scale(0.9 + coconutRng() * 0.2, 1 + coconutRng() * 0.3, 0.9 + coconutRng() * 0.2) .translate( topPt[0] + Math.cos(angle) * dist, topPt[1] - hangY, topPt[2] + Math.sin(angle) * dist, ) .vertexColor(o.coconutColor) coconutParts.push(coconut) } const fronds = merge(...frondParts) const children = [ part('trunk', trunkMesh, VERTEX_COLOR_MATERIAL), part('fronds', fronds, VERTEX_COLOR_MATERIAL), ] if (coconutParts.length > 0) { children.push(part('coconuts', merge(...coconutParts), VERTEX_COLOR_MATERIAL)) } const asset = group('palm', children) if (!useGeoSnow) return asset // Snow settles on the fronds. const snowShell = applySnow(fronds, { depth: o.snowDepth, minAngle: 90 - o.snowAngle, color: pickRandom(o.snowColors, snowRng), seed: snowRng.seed(), merge: false, }) return asset.add(part('snow', snowShell, VERTEX_COLOR_MATERIAL)) }