[READ-ONLY] Mirror of https://github.com/flo-bit/shapecraft. flo-bit.dev/shapecraft/
0

Configure Feed

Select the types of activity you want to include in your feed.

coommit

+656 -42
+3 -3
demo/forest.ts
··· 4 4 import { plane, createRng } from '../src' 5 5 import { fbm } from '../src/noise' 6 6 import { heightGradient } from '../src/color' 7 - import { tree } from './generators/common-tree' 8 - import { pine } from './generators/pine-tree' 9 - import { palm } from './generators/palm-tree' 7 + import { tree } from '../src/generators/common-tree' 8 + import { pine } from '../src/generators/pine-tree' 9 + import { palm } from '../src/generators/palm-tree' 10 10 11 11 // --- Renderer --- 12 12 const renderer = new THREE.WebGLRenderer({ antialias: true })
+3 -3
demo/main.ts
··· 4 4 import { plane } from '../src' 5 5 import { fbm } from '../src/noise' 6 6 import { heightGradient } from '../src/color' 7 - import { tree, treeSchema, treePresets } from './generators/common-tree' 8 - import { pine, pineSchema, pinePresets } from './generators/pine-tree' 9 - import { palm, palmSchema, palmPresets } from './generators/palm-tree' 7 + import { tree, treeSchema, treePresets } from '../src/generators/common-tree' 8 + import { pine, pineSchema, pinePresets } from '../src/generators/pine-tree' 9 + import { palm, palmSchema, palmPresets } from '../src/generators/palm-tree' 10 10 import { createEditor } from './editor/editor' 11 11 12 12 // --- Renderer ---
+2 -1
package.json
··· 10 10 "./modifiers": "./src/modifiers/index.ts", 11 11 "./ops": "./src/ops/index.ts", 12 12 "./color": "./src/color/index.ts", 13 - "./uv": "./src/uv/index.ts" 13 + "./uv": "./src/uv/index.ts", 14 + "./generators": "./src/generators/index.ts" 14 15 }, 15 16 "scripts": { 16 17 "dev": "vite demo",
+1 -1
src/color/gradient.ts
··· 1 - import type { ColorInput, ColorFn, Vec3 } from '../types' 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]
+2 -2
src/color/palette.ts
··· 1 1 import { interpolate, oklch, formatRgb, parse, converter } from 'culori' 2 - import type { ColorInput, ColorFn, Vec3 } from '../types' 3 - import type { NoiseLike } from '../types' 2 + import type { ColorInput, ColorFn, Vec3 } from '../core/types' 3 + import type { NoiseLike } from '../core/types' 4 4 5 5 const toOklch = converter('oklch') 6 6
+1 -1
src/color/utils.ts
··· 1 1 import * as THREE from 'three' 2 - import type { ColorInput } from '../types' 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') {
+193
src/generators/common-tree.ts
··· 1 + import { icosphere, cylinder } from '../primitives' 2 + import { merge } from '../ops' 3 + import { createRng } from '../core/rng' 4 + import { scatterOnSphere } from '../core/scatter' 5 + import { resolveOptions } from '../core/schema' 6 + import { paletteGradient, pickRandom, type Palette } from '../color' 7 + import { UberNoise } from '../noise' 8 + import type { Mesh } from '../core/mesh' 9 + import type { OptionSchema } from '../core/schema' 10 + 11 + export 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 + 34 + export type TreeOptions = { 35 + [K in keyof typeof treeSchema]?: typeof treeSchema[K]['default'] 36 + } & { preset?: string } 37 + 38 + export 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 + 57 + export 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 + }
+3
src/generators/index.ts
··· 1 + export { tree, treeSchema, treePresets, type TreeOptions } from './common-tree' 2 + export { pine, pineSchema, pinePresets, type PineOptions } from './pine-tree' 3 + export { palm, palmSchema, palmPresets, type PalmOptions } from './palm-tree'
+218
src/generators/palm-tree.ts
··· 1 + import { sphere } from '../primitives' 2 + import { merge, tube, loft, thicken } from '../ops' 3 + import { createRng } from '../core/rng' 4 + import { resolveOptions } from '../core/schema' 5 + import { paletteGradient, pickRandom } from '../color' 6 + import { UberNoise } from '../noise' 7 + import type { Mesh } from '../core/mesh' 8 + import type { Vec3 } from '../core/types' 9 + import type { OptionSchema } from '../core/schema' 10 + 11 + export 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 + 36 + export type PalmOptions = { 37 + [K in keyof typeof palmSchema]?: typeof palmSchema[K]['default'] 38 + } & { preset?: string } 39 + 40 + export 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 + 62 + export 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 + }
+194
src/generators/pine-tree.ts
··· 1 + import { cylinder, cone } from '../primitives' 2 + import { merge } from '../ops' 3 + import { createRng } from '../core/rng' 4 + import { resolveOptions } from '../core/schema' 5 + import { paletteGradient, pickRandom } from '../color' 6 + import { UberNoise } from '../noise' 7 + import type { Mesh } from '../core/mesh' 8 + import type { OptionSchema } from '../core/schema' 9 + 10 + export const pineSchema = { 11 + seed: { type: 'integer', default: 1, min: 1, max: 100, label: 'Seed' }, 12 + height: { type: 'range', default: 3, min: 1, max: 8, step: 0.1, label: 'Height' }, 13 + trunkRadius: { type: 'range', default: 0.08, min: 0.02, max: 0.25, step: 0.01, label: 'Trunk Radius' }, 14 + trunkRatio: { type: 'range', default: 0.3, min: 0.1, max: 0.5, step: 0.05, label: 'Trunk Ratio' }, 15 + trunkTaper: { type: 'range', default: 1.5, min: 0.5, max: 4, step: 0.1, label: 'Root Flare' }, 16 + trunkTopScale: { type: 'range', default: 0.4, min: 0.1, max: 0.8, step: 0.05, label: 'Trunk Top Scale' }, 17 + lean: { type: 'range', default: 0.15, min: 0, max: 0.8, step: 0.05, label: 'Lean' }, 18 + layers: { type: 'integer', default: 5, min: 2, max: 10, label: 'Layers' }, 19 + layerOverlap: { type: 'range', default: 0.4, min: 0, max: 0.8, step: 0.05, label: 'Layer Overlap' }, 20 + layerShrink: { type: 'range', default: 0.75, min: 0.4, max: 0.95, step: 0.05, label: 'Layer Shrink' }, 21 + coneRadius: { type: 'range', default: 0.7, min: 0.2, max: 1.5, step: 0.05, label: 'Cone Radius' }, 22 + coneHeight: { type: 'range', default: 1.2, min: 0.4, max: 3, step: 0.1, label: 'Cone Height' }, 23 + coneSides: { type: 'integer', default: 8, min: 3, max: 12, label: 'Cone Sides' }, 24 + coneCurve: { type: 'range', default: 1.5, min: 1, max: 4, step: 0.1, label: 'Cone Curve' }, 25 + coneTilt: { type: 'range', default: [0.1, 0.4], min: 0, max: 0.6, step: 0.02, label: 'Cone Tilt' }, 26 + jitter: { type: 'range', default: 0.03, min: 0, max: 0.15, step: 0.005, label: 'Jitter' }, 27 + swayScale: { type: 'range', default: 0.8, min: 0.2, max: 5, step: 0.1, label: 'Sway Scale' }, 28 + swayAmount: { type: 'range', default: [0.15, 0.6], min: 0, max: 1, step: 0.05, label: 'Sway Amount' }, 29 + trunkNoiseScale:{ type: 'range', default: 8, min: 1, max: 20, step: 0.5, label: 'Trunk Noise Scale' }, 30 + trunkNoiseAmt: { type: 'range', default: 0.25, min: 0, max: 0.5, step: 0.05, label: 'Trunk Noise Amount' }, 31 + colorNoiseScale:{ type: 'range', default: 1.5, min: 0.3, max: 5, step: 0.1, label: 'Color Noise Scale' }, 32 + snowColors: { type: 'color-array', default: [], min: 0, max: 6, label: 'Snow Colors' }, 33 + snowAngle: { type: 'range', default: 20, min: 0, max: 80, step: 5, label: 'Snow Min Angle (°)' }, 34 + trunkColors: { type: 'color-array', default: ['#1a0f06', '#3d2210', '#4a2a15'], min: 2, max: 6, label: 'Trunk Colors' }, 35 + canopyColors: { type: 'color-array', default: ['#0a2e12', '#0e3a18', '#144a22', '#1a5a2c'], min: 1, max: 8, label: 'Canopy Colors' }, 36 + } satisfies OptionSchema 37 + 38 + export type PineOptions = { 39 + [K in keyof typeof pineSchema]?: typeof pineSchema[K]['default'] 40 + } & { preset?: string } 41 + 42 + export const pinePresets: Record<string, Partial<PineOptions>> = { 43 + default: {}, 44 + tall: { 45 + height: 5, 46 + layers: 7, 47 + trunkRatio: 0.25, 48 + coneRadius: 0.5, 49 + }, 50 + winter: { 51 + canopyColors: ['#0d3d06', '#124a0a', '#0a3505'], 52 + snowColors: ['#e8e8f0', '#dddde8', '#f0f0f5'], 53 + snowAngle: 10, 54 + trunkColors: ['#1a1510', '#2a2018', '#3a2a1a'], 55 + }, 56 + autumn: { 57 + canopyColors: ['#8a6a20', '#6a5a18', '#9a7a28', '#5a4a10'], 58 + }, 59 + } 60 + 61 + export function pine(options: PineOptions = {}): Mesh { 62 + const seed = options.seed ?? pineSchema.seed.default 63 + const rand = createRng(seed) 64 + const o = resolveOptions(pineSchema, options, pinePresets, rand) 65 + 66 + function subSeed() { return Math.floor(rand() * 2147483647) } 67 + 68 + // Trunk 69 + const baseRadius = o.trunkRadius * (1.4 + rand() * 0.4) 70 + const topRadius = o.trunkRadius * o.trunkTopScale 71 + const trunkHeight = o.height * o.trunkRatio 72 + const leanX = (rand() - 0.5) * o.lean 73 + const leanZ = (rand() - 0.5) * o.lean 74 + const trunkGrad = paletteGradient(o.trunkColors) 75 + 76 + const trunkNoise = new UberNoise({ seed: subSeed(), scale: o.trunkNoiseScale }) 77 + const trunk = cylinder({ radius: 1, radiusTop: 1, height: trunkHeight, segments: 5, heightSegments: 3 }) 78 + .translate(0, trunkHeight / 2, 0) 79 + .warp((pos) => { 80 + const t = Math.max(0, Math.min(1, pos[1] / trunkHeight)) 81 + const radius = topRadius + (baseRadius - topRadius) * Math.pow(1 - t, o.trunkTaper) 82 + const jitterAmt = radius * o.trunkNoiseAmt 83 + const nx = trunkNoise.get(pos[0] * 100, pos[1], pos[2] * 100) * jitterAmt 84 + const nz = trunkNoise.get(pos[0] * 100 + 500, pos[1] + 500, pos[2] * 100) * jitterAmt 85 + return [ 86 + pos[0] * radius + leanX * t * t + nx, 87 + pos[1], 88 + pos[2] * radius + leanZ * t * t + nz, 89 + ] 90 + }) 91 + .vertexColor((pos) => { 92 + const t = Math.max(0, Math.min(1, pos[1] / trunkHeight)) 93 + return trunkGrad(t) 94 + }) 95 + 96 + // Canopy — stacked pyramid cones 97 + const canopyParts: Mesh[] = [] 98 + const canopyStart = trunkHeight * 0.6 99 + const canopyHeight = o.height - canopyStart 100 + const layerCount = o.layers 101 + 102 + // Pre-allocate all seeds unconditionally 103 + const layerJitterSeeds = Array.from({ length: layerCount }, () => subSeed()) 104 + const colorNoiseSeed = subSeed() 105 + const snowNoiseSeed = subSeed() 106 + 107 + const canopyGrad = paletteGradient(o.canopyColors) 108 + const colorNoise = new UberNoise({ seed: colorNoiseSeed, scale: o.colorNoiseScale }) 109 + const hasSnow = o.snowColors.length > 0 110 + const snowNoise = hasSnow ? new UberNoise({ seed: snowNoiseSeed, scale: 2 }) : null 111 + const snowThreshold = Math.sin(o.snowAngle * Math.PI / 180) 112 + 113 + // Pre-compute layer sizes so we can stack proportionally 114 + const layerRadii: number[] = [] 115 + const layerHeights: number[] = [] 116 + for (let i = 0; i < layerCount; i++) { 117 + const r = o.coneRadius * Math.pow(o.layerShrink, i) * (0.9 + rand() * 0.2) 118 + layerRadii.push(r) 119 + layerHeights.push(r * o.coneHeight) 120 + } 121 + 122 + // Stack layers: each layer's base sits at overlap fraction of the previous layer's height 123 + const layerYPositions: number[] = [] 124 + let curY = canopyStart 125 + for (let i = 0; i < layerCount; i++) { 126 + layerYPositions.push(curY) 127 + curY += layerHeights[i] * (1 - o.layerOverlap) 128 + } 129 + 130 + for (let i = 0; i < layerCount; i++) { 131 + const t = i / Math.max(1, layerCount - 1) 132 + const layerRadius = layerRadii[i] 133 + const layerH = layerHeights[i] 134 + const layerY = layerYPositions[i] 135 + 136 + // Lean at this height 137 + const lt = layerY / o.height 138 + const lx = leanX * lt * lt 139 + const lz = leanZ * lt * lt 140 + 141 + // Pyramid cone with height segments, quadratic curve, random tilt 142 + const rotY = rand() * Math.PI * 2 143 + const tiltX = (rand() - 0.5) * o.coneTilt 144 + const tiltZ = (rand() - 0.5) * o.coneTilt 145 + let layer = cone({ radius: 1, height: layerH, segments: o.coneSides, heightSegments: 3 }) 146 + .warp((pos) => { 147 + // Quadratic curve: wider at the base than a straight cone 148 + const nt = (pos[1] + layerH / 2) / layerH // 0 at bottom, 1 at top 149 + const curvedRadius = layerRadius * Math.pow(Math.max(0.01, 1 - nt), 1 / o.coneCurve) 150 + return [pos[0] * curvedRadius, pos[1], pos[2] * curvedRadius] 151 + }) 152 + .rotateY(rotY) 153 + .rotateX(tiltX) 154 + .rotateZ(tiltZ) 155 + .jitter(layerRadius * o.jitter, { seed: layerJitterSeeds[i] }) 156 + .translate(lx, layerY + layerH * 0.3, lz) 157 + 158 + // Face color 159 + const base = canopyGrad(t) 160 + rand() // consume to keep sequence stable (was pickRandom) 161 + const snow = pickRandom(hasSnow ? o.snowColors : o.canopyColors, rand) 162 + 163 + layer = layer.faceColor((centroid, normal) => { 164 + if (hasSnow && snowNoise) { 165 + const n = snowNoise.get(centroid[0], centroid[1], centroid[2]) * 0.15 166 + if (normal[1] + n > snowThreshold) { 167 + return snow 168 + } 169 + } 170 + 171 + const top = normal[1] * 0.5 + 0.5 172 + const n = colorNoise.get(centroid[0], centroid[1], centroid[2]) * 0.15 173 + const darken = 0.6 + top * 0.4 + n 174 + return [base[0] * darken, base[1] * darken, base[2] * darken] 175 + }) 176 + 177 + canopyParts.push(layer) 178 + } 179 + 180 + // Noise-based sway: shift X/Z based on Y height for organic lean 181 + const swayNoiseX = new UberNoise({ seed: subSeed(), scale: o.swayScale }) 182 + const swayNoiseZ = new UberNoise({ seed: subSeed(), scale: o.swayScale }) 183 + 184 + return merge(trunk, ...canopyParts) 185 + .warp((pos) => { 186 + const t = pos[1] / o.height 187 + const sway = t * t * o.swayAmount 188 + return [ 189 + pos[0] + swayNoiseX.get(0, pos[1]) * sway, 190 + pos[1], 191 + pos[2] + swayNoiseZ.get(0, pos[1]) * sway, 192 + ] 193 + }) 194 + }
+11 -6
src/index.ts
··· 1 1 // Core 2 - export { Mesh } from './mesh' 3 - export type { Vec2, Vec3, Vec4, ColorInput, ColorFn, DisplaceFn, WarpFn, NoiseLike } from './types' 2 + export { Mesh } from './core/mesh' 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 - export { createRng } from './rng' 24 - export { scatterOnSphere } from './scatter' 23 + export { createRng } from './core/rng' 24 + export { scatterOnSphere } from './core/scatter' 25 25 26 26 // Schema & options 27 - export { resolveOptions } from './schema' 28 - export type { OptionSchema, OptionDef, OptionValues, Randomizable } from './schema' 27 + export { resolveOptions } from './core/schema' 28 + export type { OptionSchema, OptionDef, OptionValues, Randomizable } from './core/schema' 29 + 30 + // Generators 31 + export { tree, treeSchema, treePresets } from './generators' 32 + export { pine, pineSchema, pinePresets } from './generators' 33 + export { palm, palmSchema, palmPresets } from './generators'
src/math.ts src/core/math.ts
src/mesh.ts src/core/mesh.ts
+1 -1
src/modifiers/bend.ts
··· 1 - import type { WarpFn, Vec3 } from '../types' 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
src/modifiers/lattice.ts
··· 1 - import { Mesh } from '../mesh' 1 + import { Mesh } from '../core/mesh' 2 2 3 3 // STUB for v0 — lattice deformation placeholder 4 4 export interface LatticeOptions {
+1 -1
src/modifiers/smooth.ts
··· 1 1 import * as THREE from 'three' 2 - import { Mesh } from '../mesh' 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
src/modifiers/taper.ts
··· 1 - import type { WarpFn, Vec3 } from '../types' 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
src/modifiers/twist.ts
··· 1 - import type { WarpFn, Vec3 } from '../types' 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
src/ops/center.ts
··· 1 - import { Mesh } from '../mesh' 1 + import { Mesh } from '../core/mesh' 2 2 3 3 export function center(mesh: Mesh): Mesh { 4 4 return mesh.center()
+1 -1
src/ops/clone.ts
··· 1 - import { Mesh } from '../mesh' 1 + import { Mesh } from '../core/mesh' 2 2 3 3 export function clone(mesh: Mesh): Mesh { 4 4 return mesh.clone()
+2 -2
src/ops/loft.ts
··· 1 1 import * as THREE from 'three' 2 - import { Mesh } from '../mesh' 3 - import type { Vec3 } from '../types' 2 + import { Mesh } from '../core/mesh' 3 + import type { Vec3 } from '../core/types' 4 4 5 5 export interface LoftOptions { 6 6 /** Points along the path */
+1 -1
src/ops/merge.ts
··· 1 1 import * as THREE from 'three' 2 2 import { mergeGeometries } from 'three/examples/jsm/utils/BufferGeometryUtils.js' 3 - import { Mesh } from '../mesh' 3 + import { Mesh } from '../core/mesh' 4 4 5 5 export function merge(...meshes: Mesh[]): Mesh { 6 6 if (meshes.length === 0) {
+1 -1
src/ops/thicken.ts
··· 1 1 import * as THREE from 'three' 2 - import { Mesh } from '../mesh' 2 + import { Mesh } from '../core/mesh' 3 3 4 4 /** 5 5 * Give a mesh thickness by duplicating it, offsetting along normals,
+2 -2
src/primitives/box.ts
··· 1 1 import * as THREE from 'three' 2 - import { Mesh } from '../mesh' 3 - import type { Vec3 } from '../types' 2 + import { Mesh } from '../core/mesh' 3 + import type { Vec3 } from '../core/types' 4 4 5 5 export interface BoxOptions { 6 6 width?: number
+1 -1
src/primitives/cone.ts
··· 1 1 import * as THREE from 'three' 2 - import { Mesh } from '../mesh' 2 + import { Mesh } from '../core/mesh' 3 3 4 4 export interface ConeOptions { 5 5 radius?: number
+1 -1
src/primitives/cylinder.ts
··· 1 1 import * as THREE from 'three' 2 - import { Mesh } from '../mesh' 2 + import { Mesh } from '../core/mesh' 3 3 4 4 export interface CylinderOptions { 5 5 radius?: number
+1 -1
src/primitives/icosphere.ts
··· 1 1 import * as THREE from 'three' 2 - import { Mesh } from '../mesh' 2 + import { Mesh } from '../core/mesh' 3 3 4 4 export interface IcosphereOptions { 5 5 radius?: number
+2 -2
src/primitives/plane.ts
··· 1 1 import * as THREE from 'three' 2 - import { Mesh } from '../mesh' 3 - import type { Vec2 } from '../types' 2 + import { Mesh } from '../core/mesh' 3 + import type { Vec2 } from '../core/types' 4 4 5 5 export interface PlaneOptions { 6 6 width?: number
+1 -1
src/primitives/sphere.ts
··· 1 1 import * as THREE from 'three' 2 - import { Mesh } from '../mesh' 2 + import { Mesh } from '../core/mesh' 3 3 4 4 export interface SphereOptions { 5 5 radius?: number
+1 -1
src/primitives/torus.ts
··· 1 1 import * as THREE from 'three' 2 - import { Mesh } from '../mesh' 2 + import { Mesh } from '../core/mesh' 3 3 4 4 export interface TorusOptions { 5 5 radius?: number
+1 -1
src/rng.ts src/core/rng.ts
··· 1 - import { aleaFactory } from './noise/alea/alea' 1 + import { aleaFactory } from '../noise/alea/alea' 2 2 3 3 /** 4 4 * Create a seeded random number generator.
src/scatter.ts src/core/scatter.ts
src/schema.ts src/core/schema.ts
+1 -1
src/three/from-three.ts
··· 1 1 import * as THREE from 'three' 2 - import { Mesh } from '../mesh' 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
src/three/to-three.ts
··· 1 1 import * as THREE from 'three' 2 - import { Mesh } from '../mesh' 2 + import { Mesh } from '../core/mesh' 3 3 4 4 export function toThreeMesh(mesh: Mesh, options?: { 5 5 material?: THREE.Material
src/types.ts src/core/types.ts
+1 -1
src/worker/pool.ts
··· 1 - import { Mesh } from '../mesh' 1 + import { Mesh } from '../core/mesh' 2 2 3 3 export interface WorkerPoolOptions { 4 4 maxWorkers?: number
+1 -1
tests/mesh.test.ts
··· 1 1 import { describe, it, expect } from 'vitest' 2 2 import * as THREE from 'three' 3 - import { Mesh } from '../src/mesh' 3 + import { Mesh } from '../src/core/mesh' 4 4 5 5 function makeTriangleGeometry(): THREE.BufferGeometry { 6 6 const geo = new THREE.BufferGeometry()