[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.

tree

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