From 26fa9de930942063dabd72443b30d5ac362a8031 Mon Sep 17 00:00:00 2001 From: Florian <45694132+flo-bit@users.noreply.github.com> Date: Sat, 28 Mar 2026 02:45:11 +0100 Subject: [PATCH] tree --- demo/editor/editor.ts | 281 ++++++++++++++++++++++++++++++++++++++++ demo/editor/types.ts | 1 + demo/generators/tree.ts | 203 +++++++++++++++++++++-------- demo/main.ts | 105 +++++++++------ src/index.ts | 4 + src/schema.ts | 92 +++++++++++++ src/three/to-three.ts | 8 ++ 7 files changed, 597 insertions(+), 97 deletions(-) create mode 100644 demo/editor/editor.ts create mode 100644 demo/editor/types.ts create mode 100644 src/schema.ts diff --git a/demo/editor/editor.ts b/demo/editor/editor.ts new file mode 100644 index 0000000..7d66e89 --- /dev/null +++ b/demo/editor/editor.ts @@ -0,0 +1,281 @@ +import type { OptionSchema, OptionDef } from './types' + +export interface EditorCallbacks { + onChange: (values: Record) => void +} + +export interface EditorOptions { + presets?: Record> +} + +export function createEditor( + schema: OptionSchema, + callbacks: EditorCallbacks, + editorOptions?: EditorOptions, +): HTMLElement { + const container = document.createElement('div') + container.style.cssText = ` + position: fixed; top: 0; right: 0; width: 280px; height: 100vh; + background: rgba(15, 15, 25, 0.92); color: #ddd; font-family: system-ui, sans-serif; + font-size: 12px; overflow-y: auto; padding: 12px; box-sizing: border-box; + backdrop-filter: blur(8px); z-index: 1000; + scrollbar-width: thin; scrollbar-color: #444 transparent; + ` + + const title = document.createElement('div') + title.textContent = 'Options' + title.style.cssText = 'font-size: 14px; font-weight: 600; margin-bottom: 12px; color: #fff;' + container.appendChild(title) + + const values: Record = {} + for (const [key, def] of Object.entries(schema)) { + values[key] = structuredClone(def.default) + } + + const controls: Map void }> = new Map() + + let rafId: number | null = null + function emitChange() { + if (rafId) return + rafId = requestAnimationFrame(() => { + rafId = null + callbacks.onChange({ ...values }) + }) + } + + // Preset selector + if (editorOptions?.presets) { + const presetNames = Object.keys(editorOptions.presets) + const row = document.createElement('div') + row.style.cssText = 'margin-bottom: 14px; padding-bottom: 10px; border-bottom: 1px solid #333;' + + const label = document.createElement('label') + label.textContent = 'Preset' + label.style.cssText = 'display: block; margin-bottom: 4px; color: #aaa; font-size: 11px;' + row.appendChild(label) + + const select = document.createElement('select') + select.style.cssText = 'width: 100%; padding: 4px; background: #222; color: #ddd; border: 1px solid #444; font-size: 12px;' + for (const name of presetNames) { + const opt = document.createElement('option') + opt.value = name + opt.textContent = name.charAt(0).toUpperCase() + name.slice(1) + select.appendChild(opt) + } + select.addEventListener('change', () => { + applyPreset(select.value) + }) + row.appendChild(select) + container.appendChild(row) + + function applyPreset(name: string) { + // Reset to schema defaults first + for (const [key, def] of Object.entries(schema)) { + values[key] = structuredClone(def.default) + } + // Apply preset overrides + const preset = editorOptions!.presets![name] + if (preset) { + for (const [key, val] of Object.entries(preset)) { + if (key in values) { + values[key] = structuredClone(val) + } + } + } + // Update all controls + for (const [key, ctrl] of controls) { + ctrl.set(values[key]) + } + emitChange() + } + } + + for (const [key, def] of Object.entries(schema)) { + const { element, set } = createControl(key, def, values, emitChange) + controls.set(key, { set }) + container.appendChild(element) + } + + // Initial emit + setTimeout(() => callbacks.onChange({ ...values }), 0) + + return container +} + +function createControl( + key: string, + def: OptionDef, + values: Record, + onChange: () => void +): { element: HTMLElement; set: (v: any) => void } { + const row = document.createElement('div') + row.style.cssText = 'margin-bottom: 10px;' + + const label = document.createElement('label') + label.textContent = def.label ?? formatLabel(key) + label.style.cssText = 'display: block; margin-bottom: 3px; color: #aaa; font-size: 11px;' + row.appendChild(label) + + let setter: (v: any) => void = () => {} + + if (def.type === 'range') { + const step = def.step ?? (def.max - def.min) / 200 + const wrap = document.createElement('div') + wrap.style.cssText = 'display: flex; align-items: center; gap: 6px;' + + const input = document.createElement('input') + input.type = 'range' + input.min = String(def.min) + input.max = String(def.max) + input.step = String(step) + input.value = String(def.default) + input.style.cssText = 'flex: 1; accent-color: #6a6; height: 4px;' + + const num = document.createElement('span') + num.textContent = formatNum(def.default) + num.style.cssText = 'width: 40px; text-align: right; font-size: 11px; color: #888; font-variant-numeric: tabular-nums;' + + input.addEventListener('input', () => { + values[key] = parseFloat(input.value) + num.textContent = formatNum(values[key]) + onChange() + }) + + setter = (v) => { + input.value = String(v) + num.textContent = formatNum(v) + } + + wrap.appendChild(input) + wrap.appendChild(num) + row.appendChild(wrap) + } else if (def.type === 'integer') { + const wrap = document.createElement('div') + wrap.style.cssText = 'display: flex; align-items: center; gap: 6px;' + + const input = document.createElement('input') + input.type = 'range' + input.min = String(def.min) + input.max = String(def.max) + input.step = '1' + input.value = String(def.default) + input.style.cssText = 'flex: 1; accent-color: #6a6; height: 4px;' + + const num = document.createElement('span') + num.textContent = String(def.default) + num.style.cssText = 'width: 30px; text-align: right; font-size: 11px; color: #888;' + + input.addEventListener('input', () => { + values[key] = parseInt(input.value) + num.textContent = String(values[key]) + onChange() + }) + + setter = (v) => { + input.value = String(v) + num.textContent = String(v) + } + + wrap.appendChild(input) + wrap.appendChild(num) + row.appendChild(wrap) + } else if (def.type === 'boolean') { + const input = document.createElement('input') + input.type = 'checkbox' + input.checked = def.default + input.style.cssText = 'accent-color: #6a6;' + input.addEventListener('change', () => { + values[key] = input.checked + onChange() + }) + setter = (v) => { input.checked = v } + label.style.cssText = 'display: flex; align-items: center; gap: 6px; color: #aaa; font-size: 11px; cursor: pointer;' + label.prepend(input) + } else if (def.type === 'color') { + const input = document.createElement('input') + input.type = 'color' + input.value = def.default + input.style.cssText = 'width: 100%; height: 24px; border: none; background: none; cursor: pointer;' + input.addEventListener('input', () => { + values[key] = input.value + onChange() + }) + setter = (v) => { input.value = v } + row.appendChild(input) + } else if (def.type === 'color-array') { + const wrap = document.createElement('div') + wrap.style.cssText = 'display: flex; flex-wrap: wrap; gap: 4px;' + + function rebuild() { + wrap.innerHTML = '' + const arr = values[key] as string[] + + for (let i = 0; i < arr.length; i++) { + const swatch = document.createElement('input') + swatch.type = 'color' + swatch.value = arr[i] + swatch.style.cssText = 'width: 32px; height: 24px; border: 1px solid #333; background: none; cursor: pointer; padding: 0;' + const idx = i + swatch.addEventListener('input', () => { + arr[idx] = swatch.value + onChange() + }) + swatch.addEventListener('contextmenu', (e) => { + e.preventDefault() + if (arr.length > (def.min ?? 1)) { + arr.splice(idx, 1) + rebuild() + onChange() + } + }) + wrap.appendChild(swatch) + } + + if (!def.max || arr.length < def.max) { + const add = document.createElement('button') + add.textContent = '+' + add.style.cssText = 'width: 24px; height: 24px; border: 1px dashed #555; background: none; color: #888; cursor: pointer; font-size: 14px;' + add.addEventListener('click', () => { + arr.push(arr[arr.length - 1] ?? '#ffffff') + rebuild() + onChange() + }) + wrap.appendChild(add) + } + } + + setter = (v) => { + values[key] = structuredClone(v) + rebuild() + } + + rebuild() + row.appendChild(wrap) + } else if (def.type === 'select') { + const select = document.createElement('select') + select.style.cssText = 'width: 100%; padding: 4px; background: #222; color: #ddd; border: 1px solid #444; font-size: 12px;' + for (const opt of def.options) { + const el = document.createElement('option') + el.value = opt + el.textContent = opt.charAt(0).toUpperCase() + opt.slice(1) + select.appendChild(el) + } + select.value = def.default + select.addEventListener('change', () => { + values[key] = select.value + onChange() + }) + setter = (v) => { select.value = v } + row.appendChild(select) + } + + return { element: row, set: setter } +} + +function formatLabel(key: string): string { + return key.replace(/([A-Z])/g, ' $1').replace(/^./, s => s.toUpperCase()) +} + +function formatNum(n: number): string { + return n >= 100 ? String(Math.round(n)) : n.toFixed(2) +} diff --git a/demo/editor/types.ts b/demo/editor/types.ts new file mode 100644 index 0000000..168170c --- /dev/null +++ b/demo/editor/types.ts @@ -0,0 +1 @@ +export type { OptionDef, OptionSchema, OptionValues, RangeOption, IntegerOption, ColorOption, ColorArrayOption, BooleanOption, SelectOption } from '../../src/schema' diff --git a/demo/generators/tree.ts b/demo/generators/tree.ts index 9e24d64..7e7a044 100644 --- a/demo/generators/tree.ts +++ b/demo/generators/tree.ts @@ -1,96 +1,187 @@ -import { icosphere, cylinder, merge, createRng, scatterOnSphere, normalGradient } from '../../src' +import { icosphere, cylinder, merge, createRng, scatterOnSphere, resolveOptions } from '../../src' +import { paletteGradient, pickRandom, type Palette } from '../../src/color' import { UberNoise } from '../../src/noise' -import type { Mesh } from '../../src' +import type { Mesh, OptionSchema } from '../../src' -export interface TreeOptions { - height?: number - trunkRadius?: number - canopyRadius?: number - seed?: number +export const treeSchema = { + seed: { type: 'integer', default: 1, min: 1, max: 100, label: 'Seed' }, + height: { type: 'range', default: 2.5, min: 0.5, max: 6, step: 0.1, label: 'Height' }, + trunkRadius: { type: 'range', default: 0.12, min: 0.03, max: 0.4, step: 0.01, label: 'Trunk Radius' }, + trunkRatio: { type: 'range', default: 0.45, min: 0.2, max: 0.7, step: 0.01, label: 'Trunk Ratio' }, + trunkTaper: { type: 'range', default: 2, min: 0.5, max: 5, step: 0.1, label: 'Root Flare' }, + trunkTopScale: { type: 'range', default: 0.5, min: 0.02, max: 1, step: 0.02, label: 'Trunk Top Scale' }, + lean: { type: 'range', default: 0.4, min: 0, max: 1.5, step: 0.05, label: 'Lean' }, + showCanopy: { type: 'boolean', default: true, label: 'Show Canopy' }, + canopyRadius: { type: 'range', default: 0.8, min: 0.2, max: 2, step: 0.05, label: 'Canopy Size' }, + canopySquash: { type: 'range', default: 0.8, min: 0.3, max: 1, step: 0.05, label: 'Canopy Squash' }, + canopyNoise: { type: 'range', default: 0.5, min: 0, max: 1.5, step: 0.05, label: 'Canopy Noise' }, + canopyDetail: { type: 'range', default: 0.45, min: 0.15, max: 1, step: 0.05, label: 'Canopy Detail' }, + canopyBumps: { type: 'integer', default: 3, min: 0, max: 8, label: 'Canopy Bumps' }, + bumpSize: { type: 'range', default: 0.4, min: 0.1, max: 0.8, step: 0.05, label: 'Bump Size' }, + canopyOffset: { type: 'range', default: 0.6, min: 0, max: 1.2, step: 0.05, label: 'Canopy Offset' }, + jitter: { type: 'range', default: 0.04, min: 0, max: 0.15, step: 0.005, label: 'Jitter' }, + snowColors: { type: 'color-array', default: [], min: 0, max: 6, label: 'Snow Colors' }, + snowAngle: { type: 'range', default: 30, min: 0, max: 80, step: 5, label: 'Snow Min Angle (°)' }, + trunkColors: { type: 'color-array', default: ['#1a0f06', '#4a2815', '#5a3520'], min: 2, max: 6, label: 'Trunk Colors' }, + canopyColors: { type: 'color-array', default: ['#1e6b10', '#2a7518', '#238020', '#2d8a1e'], min: 1, max: 8, label: 'Canopy Colors' }, +} satisfies OptionSchema + +export type TreeOptions = { + [K in keyof typeof treeSchema]?: typeof treeSchema[K]['default'] +} & { preset?: string } + +export const treePresets: Record> = { + default: {}, + autumn: { + canopyColors: ['#c44422', '#d48825', '#bf6b1a', '#a83a15', '#dba030'], + }, + winter: { + canopyColors: ['#1a5a10', '#1e4a15', '#224d18'], + snowColors: ['#e8e8f0', '#dddde8', '#f0f0f5'], + snowAngle: 15, + trunkColors: ['#1a1510', '#2a2018', '#3a2a1a'], + }, + cherry: { + canopyColors: ['#d45a8a', '#e87aa0', '#c44a75', '#f09ab5'], + }, + dead: { + showCanopy: false, + }, } export function tree(options: TreeOptions = {}): Mesh { - const height = options?.height ?? 2.5 - const trunkRadius = options?.trunkRadius ?? 0.12 - const canopyRadius = options?.canopyRadius ?? 0.8 - const seed = options?.seed ?? 1 - + // Create rand from seed before resolving (needed for [min,max] ranges) + const seed = options.seed ?? treeSchema.seed.default const rand = createRng(seed) + const o = resolveOptions(treeSchema, options, treePresets, rand) - // Randomize trunk radii - const baseRadius = trunkRadius * (1.6 + rand() * 0.8) - const topRadius = trunkRadius * (0.3 + rand() * 0.4) + // Derive all sub-seeds from the main rand so everything chains deterministically + function subSeed() { return Math.floor(rand() * 2147483647) } + + // Trunk radii — randomized within range + const baseRadius = o.trunkRadius * (1.6 + rand() * 0.8) + const topRadius = o.trunkRadius * o.trunkTopScale // Bigger trunk → bigger canopy - const canopyScale = baseRadius / (trunkRadius * 2) - const actualCanopyRadius = canopyRadius * canopyScale + const canopyScale = baseRadius / (o.trunkRadius * 2) + const actualCanopyRadius = o.canopyRadius * canopyScale + + // Trunk + const trunkHeight = o.height * o.trunkRatio + const leanX = (rand() - 0.5) * o.lean + const leanZ = (rand() - 0.5) * o.lean + const trunkGrad = paletteGradient(o.trunkColors) + const taperExp = o.trunkTaper - // Trunk — quadratic taper (root flare) + slight lean - const trunkHeight = height * 0.45 - const leanX = (rand() - 0.5) * 0.4 - const leanZ = (rand() - 0.5) * 0.4 + const trunkNoise = new UberNoise({ seed: subSeed(), scale: 8 }) const trunk = cylinder({ radius: 1, radiusTop: 1, height: trunkHeight, segments: 5, heightSegments: 4 }) .translate(0, trunkHeight / 2, 0) .warp((pos) => { const t = Math.max(0, Math.min(1, pos[1] / trunkHeight)) - const radius = topRadius + (baseRadius - topRadius) * (1 - t) * (1 - t) + const radius = topRadius + (baseRadius - topRadius) * Math.pow(1 - t, taperExp) + // Noise-based displacement scaled by local radius (thin top = less displacement) + const jitterAmount = radius * 0.3 + const nx = trunkNoise.get(pos[0] * 100, pos[1], pos[2] * 100) * jitterAmount + const nz = trunkNoise.get(pos[0] * 100 + 500, pos[1] + 500, pos[2] * 100) * jitterAmount return [ - pos[0] * radius + leanX * t * t, + pos[0] * radius + leanX * t * t + nx, pos[1], - pos[2] * radius + leanZ * t * t, + pos[2] * radius + leanZ * t * t + nz, ] }) - .jitter(baseRadius * 0.12, { seed }) .vertexColor((pos) => { - const t = pos[1] / trunkHeight - return [0.3 + t * 0.05, 0.2 + t * 0.02, 0.1] + const t = Math.max(0, Math.min(1, pos[1] / trunkHeight)) + return trunkGrad(t) }) // Trunk top position after lean const topOffsetX = leanX const topOffsetZ = leanZ + if (!o.showCanopy) return trunk + // Canopy const canopyParts: Mesh[] = [] - const canopyY = trunkHeight + actualCanopyRadius * 0.6 + const canopyY = trunkHeight + actualCanopyRadius * o.canopyOffset const mainR = actualCanopyRadius - const edgeLen = canopyRadius * 0.45 + const edgeLen = o.canopyRadius * o.canopyDetail - const canopyColor = normalGradient([0.15, 0.48, 0.08], [0.08, 0.28, 0.04]) + const colorNoiseSeed = subSeed() - function canopyBlob(r: number, blobSeed: number): Mesh { - const noise = new UberNoise({ seed: blobSeed, scale: 0.5, octaves: 3 }) - return icosphere({ radius: r, subdivisions: 0 }) + function canopyBlob(r: number): Mesh { + const noiseSeed = subSeed() + const jitterSeed = subSeed() + const noise = new UberNoise({ seed: noiseSeed, scale: 0.5, octaves: 3 }) + let blob = icosphere({ radius: r, subdivisions: 0 }) .subdivideAdaptive(edgeLen) - .spherize(r) - .displaceNoise(noise, r * 0.5, { direction: 'radial' }) - .jitter(r * 0.04, { seed: blobSeed }) + .warp((pos) => { + const len = Math.sqrt(pos[0] * pos[0] + pos[1] * pos[1] + pos[2] * pos[2]) || 1 + const nx = pos[0] / len, ny = pos[1] / len, nz = pos[2] / len + const d = r + noise.get(pos[0], pos[1], pos[2]) * r * o.canopyNoise + return [nx * d, ny * d, nz * d] + }) + .jitter(r * o.jitter, { seed: jitterSeed }) + + return blob + } + + // Face coloring + const colorNoise = new UberNoise({ seed: colorNoiseSeed, scale: 1.5 }) + + const hasSnow = o.snowColors.length > 0 + const snowNoiseSeed = subSeed() // always consume to keep sequence stable + const snowNoise = hasSnow ? new UberNoise({ seed: snowNoiseSeed, scale: 2 }) : null + const snowThreshold = Math.sin(o.snowAngle * Math.PI / 180) + + function blobFaceColor(): (centroid: [number, number, number], normal: [number, number, number], faceIndex: number) => [number, number, number] { + // Pick colors upfront so we don't consume rand() calls inside the per-face loop + const base = pickRandom(o.canopyColors, rand) + // Always consume the rand() call to keep sequence stable regardless of snow setting + const snowPick = pickRandom(hasSnow ? o.snowColors : o.canopyColors, rand) + const snow = hasSnow ? snowPick : null + return (centroid, normal) => { + const top = normal[1] * 0.5 + 0.5 + + // Snow on upward-facing faces + if (snow && snowNoise) { + const n = snowNoise.get(centroid[0], centroid[1], centroid[2]) * 0.15 + if (normal[1] + n > snowThreshold) { + return snow + } + } + + const n = colorNoise.get(centroid[0], centroid[1], centroid[2]) * 0.15 + const darken = 0.65 + top * 0.35 + n + return [base[0] * darken, base[1] * darken, base[2] * darken] + } } // Main sphere - const main = canopyBlob(mainR, seed) - .scale(1, 0.8, 1) + const main = canopyBlob(mainR) + .scale(1, o.canopySquash, 1) .translate(topOffsetX, canopyY, topOffsetZ) - .vertexColor(canopyColor) + .faceColor(blobFaceColor()) canopyParts.push(main) - // Smaller blobs scattered on main sphere surface (equatorial band) - const blobCount = 2 + Math.floor(rand() * 2) - const blobPositions = scatterOnSphere(blobCount, seed + 100, { - radius: mainR * 0.9, - polarMin: Math.PI * 0.3, - polarMax: Math.PI * 0.7, - }) - - for (let i = 0; i < blobCount; i++) { - const [bx, by, bz] = blobPositions[i] - const r = mainR * (i < 1 ? (0.45 + rand() * 0.15) : (0.3 + rand() * 0.15)) - - const blob = canopyBlob(r, seed + i + 1) - .scale(1, 0.8, 1) - .translate(bx + topOffsetX, by * 0.8 + canopyY, bz + topOffsetZ) - .vertexColor(canopyColor) - canopyParts.push(blob) + // Sub-blobs + const blobCount = o.canopyBumps + if (blobCount > 0) { + const blobPositions = scatterOnSphere(blobCount, subSeed(), { + radius: mainR * 0.9, + polarMin: Math.PI * 0.3, + polarMax: Math.PI * 0.7, + }) + + for (let i = 0; i < blobCount; i++) { + const [bx, by, bz] = blobPositions[i] + const r = mainR * (o.bumpSize + rand() * 0.15) + + const blob = canopyBlob(r) + .scale(1, o.canopySquash, 1) + .translate(bx + topOffsetX, by * o.canopySquash + canopyY, bz + topOffsetZ) + .faceColor(blobFaceColor()) + canopyParts.push(blob) + } } return merge(trunk, ...canopyParts) diff --git a/demo/main.ts b/demo/main.ts index 414ec45..7466d4b 100644 --- a/demo/main.ts +++ b/demo/main.ts @@ -1,10 +1,11 @@ import * as THREE from 'three' import { OrbitControls } from 'three/examples/jsm/controls/OrbitControls.js' import { toThreeMesh } from '../src/three' -import { plane, icosphere } from '../src' +import { plane } from '../src' import { fbm } from '../src/noise' import { heightGradient } from '../src/color' -import { tree } from './generators/tree' +import { tree, treeSchema, treePresets } from './generators/tree' +import { createEditor } from './editor/editor' // --- Renderer --- const renderer = new THREE.WebGLRenderer({ antialias: true }) @@ -19,20 +20,18 @@ document.body.appendChild(renderer.domElement) // --- Scene --- const scene = new THREE.Scene() -// --- Sky dome — vertex-colored hemisphere --- -const skyMesh = icosphere({ radius: 50, subdivisions: 3 }) - .vertexColor((pos) => { - const t = Math.max(0, pos[1] / 50) // 0 at horizon, 1 at zenith - // Horizon: warm haze → zenith: deeper blue - return [ - 0.55 + (0.2 - 0.55) * t, - 0.7 + (0.35 - 0.7) * t, - 0.85 + (0.65 - 0.85) * t, - ] - }) -const sky = toThreeMesh(skyMesh, { - material: new THREE.MeshBasicMaterial({ vertexColors: true, side: THREE.BackSide }), -}) +// --- Sky dome --- +const skyGeo = new THREE.SphereGeometry(50, 16, 12) +const skyColors = new Float32Array(skyGeo.getAttribute('position').count * 3) +const skyPos = skyGeo.getAttribute('position') +for (let i = 0; i < skyPos.count; i++) { + const t = Math.max(0, skyPos.getY(i) / 50) + skyColors[i * 3] = 0.55 + (0.2 - 0.55) * t + skyColors[i * 3 + 1] = 0.7 + (0.35 - 0.7) * t + skyColors[i * 3 + 2] = 0.85 + (0.65 - 0.85) * t +} +skyGeo.setAttribute('color', new THREE.BufferAttribute(skyColors, 3)) +const sky = new THREE.Mesh(skyGeo, new THREE.MeshBasicMaterial({ vertexColors: true, side: THREE.BackSide })) scene.add(sky) // --- Fog --- @@ -46,15 +45,13 @@ const controls = new OrbitControls(camera, renderer.domElement) controls.target.set(0, 1, 0) controls.enableDamping = true controls.dampingFactor = 0.08 -controls.maxPolarAngle = Math.PI / 2 - 0.05 // don't go below ground +controls.maxPolarAngle = Math.PI / 2 - 0.05 controls.update() // --- Lighting --- -// Hemisphere light: sky blue from above, ground green from below const hemi = new THREE.HemisphereLight(0x87ceeb, 0x3a5a2a, 0.6) scene.add(hemi) -// Sun const sun = new THREE.DirectionalLight(0xfff4e0, 1.4) sun.position.set(8, 12, 5) sun.castShadow = true @@ -68,12 +65,11 @@ sun.shadow.camera.far = 40 sun.shadow.bias = -0.001 scene.add(sun) -// Soft fill from opposite side const fill = new THREE.DirectionalLight(0xb0c4de, 0.3) fill.position.set(-5, 4, -3) scene.add(fill) -// --- Ground — noise-displaced plane with height coloring --- +// --- Ground --- const groundNoise = fbm({ seed: 7, octaves: 3, scale: 0.15, min: 0, max: 0.3 }) const groundMesh = plane({ size: 30, segments: 60 }) .displace((pos) => groundNoise.get(pos[0], pos[2])) @@ -86,28 +82,55 @@ const groundObj = toThreeMesh(groundMesh) groundObj.receiveShadow = true scene.add(groundObj) -// --- Trees — scattered naturally --- -let rngSeed = 42 -function rng() { rngSeed = (rngSeed * 16807) % 2147483647; return (rngSeed & 0x7fffffff) / 2147483647 } - -for (let i = 0; i < 18; i++) { - const x = (rng() - 0.5) * 20 - const z = (rng() - 0.5) * 20 - const dist = Math.sqrt(x * x + z * z) - if (dist < 1.5) continue // keep center clear - - const h = 1.8 + rng() * 1.5 - const t = tree({ seed: i + 1, height: h, canopyRadius: 0.5 + rng() * 0.5 }) - const obj = toThreeMesh(t) - - // Sample ground height at this position - const groundY = groundNoise.get(x, z) - obj.position.set(x, groundY, z) - obj.castShadow = true - obj.receiveShadow = true - scene.add(obj) +// --- Tree management --- +let treeObjects: THREE.Mesh[] = [] + +function rebuildTrees(opts: Record) { + // Remove old trees + for (const obj of treeObjects) { + scene.remove(obj) + obj.geometry.dispose() + if (Array.isArray(obj.material)) { + obj.material.forEach(m => m.dispose()) + } else { + obj.material.dispose() + } + } + treeObjects = [] + + // Scatter trees using a fixed layout RNG + let rngSeed = 42 + function rng() { rngSeed = (rngSeed * 16807) % 2147483647; return (rngSeed & 0x7fffffff) / 2147483647 } + + for (let i = 0; i < 15; i++) { + const x = (rng() - 0.5) * 20 + const z = (rng() - 0.5) * 20 + const dist = Math.sqrt(x * x + z * z) + if (dist < 1.5) continue + + const t = tree({ + ...opts, + seed: (opts.seed ?? 1) + i, + height: (opts.height ?? 2.5) * (0.8 + rng() * 0.4), + }) + const obj = toThreeMesh(t) + const groundY = groundNoise.get(x, z) + obj.position.set(x, groundY, z) + obj.castShadow = true + obj.receiveShadow = true + scene.add(obj) + treeObjects.push(obj) + } } +// --- Editor --- +const editorEl = createEditor(treeSchema, { + onChange: rebuildTrees, +}, { + presets: treePresets, +}) +document.body.appendChild(editorEl) + // Expose for screenshot scripts ;(window as any).__camera = camera ;(window as any).__controls = controls diff --git a/src/index.ts b/src/index.ts index de33563..bc01804 100644 --- a/src/index.ts +++ b/src/index.ts @@ -22,3 +22,7 @@ export { projectUVs } from './uv' // Utilities export { createRng } from './rng' export { scatterOnSphere } from './scatter' + +// Schema & options +export { resolveOptions } from './schema' +export type { OptionSchema, OptionDef, OptionValues, Randomizable } from './schema' diff --git a/src/schema.ts b/src/schema.ts new file mode 100644 index 0000000..8b01ae4 --- /dev/null +++ b/src/schema.ts @@ -0,0 +1,92 @@ +export interface RangeOption { + type: 'range' + default: number + min: number + max: number + step?: number + label?: string +} + +export interface IntegerOption { + type: 'integer' + default: number + min: number + max: number + label?: string +} + +export interface ColorOption { + type: 'color' + default: string + label?: string +} + +export interface ColorArrayOption { + type: 'color-array' + default: string[] + min?: number + max?: number + label?: string +} + +export interface BooleanOption { + type: 'boolean' + default: boolean + label?: string +} + +export interface SelectOption { + type: 'select' + default: string + options: string[] + label?: string +} + +export type OptionDef = RangeOption | IntegerOption | ColorOption | ColorArrayOption | BooleanOption | SelectOption + +export type OptionSchema = Record + +export type OptionValues = { + [K in keyof S]: S[K] extends RangeOption ? number + : S[K] extends IntegerOption ? number + : S[K] extends ColorOption ? string + : S[K] extends ColorArrayOption ? string[] + : S[K] extends BooleanOption ? boolean + : S[K] extends SelectOption ? string + : never +} + +/** A value that can be fixed or a [min, max] range resolved at generation time */ +export type Randomizable = T | [T, T] + +/** + * Resolve options for a generator: apply preset, then overrides, then fill schema defaults. + * Numeric values can be [min, max] tuples — resolved using rand() if provided. + */ +export function resolveOptions( + schema: S, + options: Record, + presets?: Record>, + rand?: () => number, +): OptionValues { + const presetName = options.preset ?? 'default' + const preset = presets?.[presetName] ?? {} + const { preset: _, ...overrides } = options + + const resolved: Record = {} + for (const [key, def] of Object.entries(schema)) { + let val = overrides[key] ?? preset[key] ?? structuredClone(def.default) + + // Resolve [min, max] ranges for numeric types + if (Array.isArray(val) && val.length === 2 && typeof val[0] === 'number' && typeof val[1] === 'number' + && (def.type === 'range' || def.type === 'integer')) { + const r = rand ? rand() : Math.random() + val = val[0] + (val[1] - val[0]) * r + if (def.type === 'integer') val = Math.round(val) + } + + resolved[key] = val + } + + return resolved as OptionValues +} diff --git a/src/three/to-three.ts b/src/three/to-three.ts index 508ecd5..4aec8c0 100644 --- a/src/three/to-three.ts +++ b/src/three/to-three.ts @@ -5,10 +5,14 @@ export function toThreeMesh(mesh: Mesh, options?: { material?: THREE.Material flatShading?: boolean wireframe?: boolean + roughness?: number + metalness?: number }): THREE.Mesh { const geometry = toThreeGeometry(mesh) const flatShading = options?.flatShading ?? true const wireframe = options?.wireframe ?? false + const roughness = options?.roughness ?? 1 + const metalness = options?.metalness ?? 0 let material: THREE.Material if (options?.material) { @@ -18,12 +22,16 @@ export function toThreeMesh(mesh: Mesh, options?: { vertexColors: true, flatShading, wireframe, + roughness, + metalness, }) } else { material = new THREE.MeshStandardMaterial({ color: 0xcccccc, flatShading, wireframe, + roughness, + metalness, }) } -- 2.51.2