diff --git a/demo/main.ts b/demo/main.ts index 0009f1b..cad53e0 100644 --- a/demo/main.ts +++ b/demo/main.ts @@ -11,6 +11,9 @@ import { bush, bushSchema, bushPresets, grass, grassSchema, grassPresets, fern, fernSchema, fernPresets, + flower, flowerSchema, flowerPresets, + deadTree, deadTreeSchema, deadTreePresets, + rock, rockSchema, rockPresets, } from '../src/generators' import { createEditor } from './editor/editor' import type { OptionSchema } from '../src/core/schema' @@ -32,6 +35,9 @@ const GENERATORS: Record = { bush: { label: 'Bush', gen: bush, schema: bushSchema, presets: bushPresets, sizeKey: 'size', defaultSize: 0.6 }, grass: { label: 'Grass', gen: grass, schema: grassSchema, presets: grassPresets, sizeKey: 'height', defaultSize: 0.6 }, fern: { label: 'Fern', gen: fern, schema: fernSchema, presets: fernPresets, sizeKey: 'length', defaultSize: 1.0 }, + flower: { label: 'Flower', gen: flower, schema: flowerSchema, presets: flowerPresets, sizeKey: 'height', defaultSize: 0.5 }, + dead: { label: 'Dead', gen: deadTree, schema: deadTreeSchema, presets: deadTreePresets, sizeKey: 'height', defaultSize: 1.5 }, + rock: { label: 'Rock', gen: rock, schema: rockSchema, presets: rockPresets, sizeKey: 'size', defaultSize: 0.6 }, } // --- Renderer --- diff --git a/src/build/branches.ts b/src/build/branches.ts new file mode 100644 index 0000000..a63ba76 --- /dev/null +++ b/src/build/branches.ts @@ -0,0 +1,153 @@ +import { tube, merge } from '../ops' +import { createRng } from '../core/rng' +import { Mesh } from '../core/mesh' +import type { Vec3 } from '../core/types' + +export interface BranchTip { + /** End point of a terminal limb — where foliage/leaves/fruit can attach. */ + position: Vec3 + /** Growth direction at the tip. */ + direction: Vec3 + /** Radius at the tip. */ + radius: number + /** Recursion depth remaining when this tip was emitted (0 = outermost). */ + depth: number +} + +export interface BranchResult { + /** Merged tapered limbs (uncolored — apply your own vertexColor/faceColor). */ + mesh: Mesh + /** Attachment points at the end of every terminal limb. */ + tips: BranchTip[] +} + +export interface BranchesOptions { + /** RNG to draw from (pass a generator stream for determinism). Falls back to `seed`. */ + rng?: () => number + seed?: number + /** Base of the trunk. Default origin. */ + start?: Vec3 + /** Initial growth direction. Default up. */ + direction?: Vec3 + /** Length of the root limb. */ + length: number + /** Base radius of the root limb. */ + radius: number + /** Recursion levels (number of limb generations). */ + depth: number + /** Children spawned at each split. Default 2. */ + children?: number | [number, number] + /** Child length = parent length × this. Default 0.72. */ + lengthFalloff?: number + /** Child radius = parent radius at the attach point × this. Default 0.82. */ + radiusFalloff?: number + /** Angle (radians) children diverge from the parent. Default 0.7. */ + spread?: number + /** Pull of each limb toward world-up, 0..1. Default 0.25. */ + upBias?: number + /** Random meander of a limb (radians, total). Default 0.4. */ + wander?: number + /** Path points per limb. Default 5. */ + segments?: number + /** Taper-curve exponent for a strand's radius (radius·(1−t)^taper). Lower keeps the body thick (sharpening only near the tip); higher thins sooner. Default 0.5. */ + taper?: number + /** Tube radial sides. Default 5. */ + sides?: number + /** Fraction up a limb where side branches begin sprouting. Default 0.3. */ + splitStart?: number +} + +function normalize(v: Vec3): Vec3 { + const l = Math.hypot(v[0], v[1], v[2]) || 1 + return [v[0] / l, v[1] / l, v[2] / l] +} +function cross(a: Vec3, b: Vec3): Vec3 { + return [a[1] * b[2] - a[2] * b[1], a[2] * b[0] - a[0] * b[2], a[0] * b[1] - a[1] * b[0]] +} +function perpendicular(d: Vec3): Vec3 { + const a: Vec3 = Math.abs(d[0]) < 0.9 ? [1, 0, 0] : [0, 1, 0] + return normalize(cross(d, a)) +} +/** Rotate direction `d` by `angle` away from itself, in azimuth `az` around `d`. */ +function diverge(d: Vec3, angle: number, az: number): Vec3 { + const p = perpendicular(d) + const q = cross(d, p) // second perpendicular + const pr: Vec3 = [ + p[0] * Math.cos(az) + q[0] * Math.sin(az), + p[1] * Math.cos(az) + q[1] * Math.sin(az), + p[2] * Math.cos(az) + q[2] * Math.sin(az), + ] + const c = Math.cos(angle), s = Math.sin(angle) + return normalize([d[0] * c + pr[0] * s, d[1] * c + pr[1] * s, d[2] * c + pr[2] * s]) +} +function towardUp(d: Vec3, t: number): Vec3 { + return normalize([d[0] * (1 - t), d[1] * (1 - t) + t, d[2] * (1 - t)]) +} + +/** + * Grow a recursive tapered branch skeleton — the engine behind dead trees, bare + * limbs, coral, and (with foliage attached at the returned tips) leafy trees. + */ +export function branches(options: BranchesOptions): BranchResult { + const rng = options.rng ?? createRng(options.seed ?? 0) + const childrenOpt = options.children ?? 3 + const lengthFalloff = options.lengthFalloff ?? 0.7 + const radiusFalloff = options.radiusFalloff ?? 0.82 + const spread = options.spread ?? 0.8 + const upBias = options.upBias ?? 0.22 + const wander = options.wander ?? 0.5 + const segments = options.segments ?? 6 + const taperExp = options.taper ?? 0.5 + const sides = options.sides ?? 5 + const splitStart = options.splitStart ?? 0.2 + + const limbs: Mesh[] = [] + const tips: BranchTip[] = [] + + // Each strand is ONE continuous axis (trunk or branch), built as a single tube that + // tapers smoothly from its base to a twig point. Laterals branch off along it; each + // lateral is itself a strand. There are no per-segment trunk joints to gap. + function strand(start: Vec3, dir: Vec3, length: number, radius: number, depth: number) { + let d = normalize(dir) + let pos = start + const path: Vec3[] = [pos] + const stepLen = length / segments + for (let s = 0; s < segments; s++) { + d = diverge(d, (wander / segments) * (0.5 + rng()), rng() * Math.PI * 2) + d = towardUp(d, upBias * 0.15) + pos = [pos[0] + d[0] * stepLen, pos[1] + d[1] * stepLen, pos[2] + d[2] * stepLen] + path.push(pos) + } + // Smooth taper from base to a point at the tip; the top is naturally a thin twig. + const radiusAt = (t: number) => radius * Math.pow(Math.max(0, 1 - t), taperExp) + limbs.push(tube(path, radiusAt, sides, false)) + tips.push({ position: pos, direction: d, radius: radius * 0.25, depth }) + + if (depth <= 1) return + + const n = Array.isArray(childrenOpt) + ? Math.round(childrenOpt[0] + rng() * (childrenOpt[1] - childrenOpt[0])) + : childrenOpt + + // Laterals sprout from points along the strand, each starting on the parent axis so + // its base is buried inside the parent tube → connected joints, no caps, no gaps. + for (let c = 0; c < n; c++) { + const f = splitStart + (0.9 - splitStart) * ((c + 0.5 + (rng() - 0.5) * 0.6) / n) + const fi = Math.max(0, Math.min(segments - 1e-6, f * segments)) + const i0 = Math.floor(fi) + const ft = fi - i0 + const a = path[i0], b = path[i0 + 1] + const apos: Vec3 = [a[0] + (b[0] - a[0]) * ft, a[1] + (b[1] - a[1]) * ft, a[2] + (b[2] - a[2]) * ft] + const adir = normalize([b[0] - a[0], b[1] - a[1], b[2] - a[2]]) + const arad = radiusAt(f) + + let cd = diverge(adir, spread * (0.6 + rng() * 0.7), rng() * Math.PI * 2) + cd = towardUp(cd, upBias) + strand(apos, cd, length * lengthFalloff * (0.7 + rng() * 0.4), arad * radiusFalloff, depth - 1) + } + } + + strand(options.start ?? [0, 0, 0], options.direction ?? [0, 1, 0], options.length, options.radius, options.depth) + + return { mesh: merge(...limbs), tips } +} diff --git a/src/build/index.ts b/src/build/index.ts index ec2b10f..25e3d44 100644 --- a/src/build/index.ts +++ b/src/build/index.ts @@ -4,3 +4,4 @@ export { foliageBlob, type FoliageBlobOptions } from './foliage' export { facetShade, heightShade, type FacetShadeOptions } from './shade' export { scatterOnSurface, type SurfacePoint, type ScatterOnSurfaceOptions } from './surface' export { blade, type BladeOptions } from './blade' +export { branches, type BranchesOptions, type BranchResult, type BranchTip } from './branches' diff --git a/src/generators/index.ts b/src/generators/index.ts index e6dd6ab..7e4c4af 100644 --- a/src/generators/index.ts +++ b/src/generators/index.ts @@ -1 +1,2 @@ export * from './vegetation' +export * from './rocks' diff --git a/src/generators/rocks/index.ts b/src/generators/rocks/index.ts new file mode 100644 index 0000000..f71666e --- /dev/null +++ b/src/generators/rocks/index.ts @@ -0,0 +1 @@ +export { rock, rockSchema, rockPresets, type RockOptions } from './rock' diff --git a/src/generators/rocks/rock.ts b/src/generators/rocks/rock.ts new file mode 100644 index 0000000..a2d0518 --- /dev/null +++ b/src/generators/rocks/rock.ts @@ -0,0 +1,71 @@ +import { setup, foliageBlob, facetShade } from '../../build' +import { pickRandom } from '../../color' +import { UberNoise } from '../../noise' +import type { Mesh } from '../../core/mesh' +import type { OptionSchema, OptionInput } from '../../core/schema' + +export const rockSchema = { + seed: { type: 'integer', default: 1, min: 1, max: 100, label: 'Seed' }, + size: { type: 'range', default: 0.6, min: 0.2, max: 2.5, step: 0.05, label: 'Size' }, + detail: { type: 'range', default: 0.45, min: 0.2, max: 0.9, step: 0.05, label: 'Detail' }, + noise: { type: 'range', default: 0.45, min: 0.1, max: 0.8, step: 0.05, label: 'Lumpiness' }, + squash: { type: 'range', default: 0.7, min: 0.3, max: 1.2, step: 0.05, label: 'Squash' }, + flatten: { type: 'range', default: 0.25, min: 0, max: 0.5, step: 0.05, label: 'Flat Base' }, + jitter: { type: 'range', default: 0.02, min: 0, max: 0.1, step: 0.005, label: 'Jitter' }, + mossColors: { type: 'color-array', default: [], min: 0, max: 4, label: 'Moss Colors' }, + mossAngle: { type: 'range', default: 40, min: 0, max: 80, step: 5, label: 'Moss Min Angle (°)' }, + colors: { type: 'color-array', default: ['#56514b', '#6a645c', '#7e776d'], min: 1, max: 6, label: 'Rock Colors' }, +} satisfies OptionSchema + +export type RockOptions = Partial> & { preset?: string } + +export const rockPresets: Record> = { + default: {}, + boulder: { size: 1.4, squash: 0.85, noise: 0.3, flatten: 0.3 }, + sharp: { noise: 0.7, detail: 0.3, squash: 1.0 }, + mossy: { mossColors: ['#3f6a2a', '#4e7d33', '#5c8c3a'], noise: 0.4 }, + slate: { squash: 0.45, flatten: 0.4, colors: ['#48504f', '#586160', '#6a7270'] }, +} + +export function rock(options: RockOptions = {}): Mesh { + const { o, rng } = setup(rockSchema, options, rockPresets) + const shapeRng = rng.stream('shape') + const colorRng = rng.stream('color') + const mossRng = rng.stream('moss') + + const colorNoise = new UberNoise({ seed: colorRng.seed(), scale: 2 }) + const hasMoss = o.mossColors.length > 0 + const mossNoise = hasMoss ? new UberNoise({ seed: mossRng.seed(), scale: 2.5 }) : null + const mossThreshold = Math.sin((o.mossAngle * Math.PI) / 180) + + // Lumpy faceted blob, flattened vertically. + const blob = foliageBlob({ + radius: o.size, + detail: o.size * o.detail, + noiseSeed: shapeRng.seed(), + noiseScale: 0.8, + noiseOctaves: 2, + noiseAmount: o.noise, + jitter: o.size * o.jitter, + jitterSeed: shapeRng.seed(), + }).scale(1, o.squash, 1) + + // Cut a flat base and rest it on the ground (y = 0). + const baseY = -o.size * o.squash * (1 - o.flatten) + const base = pickRandom(o.colors, colorRng) + const moss = hasMoss ? pickRandom(o.mossColors, mossRng) : null + + return blob + .warp((p) => (p[1] < baseY ? [p[0], baseY, p[2]] : p)) + .translate(0, -baseY, 0) + .faceColor(facetShade({ + base, + noise: colorNoise, + ambient: 0.5, + range: 0.5, + noiseAmount: 0.12, + snow: moss && mossNoise + ? { color: moss, noise: mossNoise, threshold: mossThreshold, noiseAmount: 0.25 } + : undefined, + })) +} diff --git a/src/generators/vegetation/plants/flower.ts b/src/generators/vegetation/plants/flower.ts new file mode 100644 index 0000000..6d8149b --- /dev/null +++ b/src/generators/vegetation/plants/flower.ts @@ -0,0 +1,122 @@ +import { sphere } from '../../../primitives' +import { merge } from '../../../ops' +import { setup, trunk, blade } from '../../../build' +import type { Mesh } from '../../../core/mesh' +import type { Vec3 } from '../../../core/types' +import type { OptionSchema, OptionInput } from '../../../core/schema' + +export const flowerSchema = { + seed: { type: 'integer', default: 1, min: 1, max: 100, label: 'Seed' }, + height: { type: 'range', default: 0.5, min: 0.15, max: 1.2, step: 0.05, label: 'Stem Height' }, + stemRadius: { type: 'range', default: 0.012,min: 0.004,max: 0.03, step: 0.002, label: 'Stem Radius' }, + lean: { type: 'range', default: 0.12, min: 0, max: 0.4, step: 0.02, label: 'Lean' }, + petals: { type: 'integer', default: 9, min: 3, max: 18, label: 'Petals' }, + petalLength: { type: 'range', default: 0.13, min: 0.05, max: 0.3, step: 0.01, label: 'Petal Length' }, + petalWidth: { type: 'range', default: 0.06, min: 0.02, max: 0.14, step: 0.005, label: 'Petal Width' }, + petalLift: { type: 'range', default: 0.45, min: 0, max: 1.2, step: 0.05, label: 'Petal Lift' }, + centerSize: { type: 'range', default: 0.045,min: 0.02, max: 0.1, step: 0.005, label: 'Center Size' }, + leaves: { type: 'integer', default: 2, min: 0, max: 4, label: 'Leaves' }, + leafLength: { type: 'range', default: 0.16, min: 0.05, max: 0.35, step: 0.01, label: 'Leaf Length' }, + petalColor: { type: 'color', default: '#e0556b', label: 'Petal Color' }, + centerColor: { type: 'color', default: '#f0c040', label: 'Center Color' }, + stemColors: { type: 'color-array', default: ['#2f6a1e', '#3f8526'], min: 1, max: 4, label: 'Stem Colors' }, +} satisfies OptionSchema + +export type FlowerOptions = Partial> & { preset?: string } + +export const flowerPresets: Record> = { + default: {}, + daisy: { petals: 14, petalColor: '#f3f0ee', centerColor: '#f0b830', petalWidth: 0.04, petalLength: 0.16 }, + tulip: { petals: 6, petalLift: 1.0, petalColor: '#d23a52', petalWidth: 0.1, petalLength: 0.18, centerSize: 0.03 }, + poppy: { petals: 5, petalColor: '#d8392b', petalWidth: 0.12, petalLift: 0.6 }, + dandelion: { petals: 18, petalColor: '#f2cf33', petalWidth: 0.025, petalLength: 0.1, petalLift: 0.7 }, +} + +function norm(x: number, y: number, z: number): Vec3 { + const l = Math.hypot(x, y, z) || 1 + return [x / l, y / l, z / l] +} + +export function flower(options: FlowerOptions = {}): Mesh { + const { o, rng } = setup(flowerSchema, options, flowerPresets) + const shapeRng = rng.stream('shape') + + const leanAngle = shapeRng() * Math.PI * 2 + const leanX = Math.cos(leanAngle) * o.lean + const leanZ = Math.sin(leanAngle) * o.lean + + const parts: Mesh[] = [] + + // Stem. + parts.push(trunk({ + height: o.height, + baseRadius: o.stemRadius, + topRadius: o.stemRadius * 0.7, + taper: 1, + lean: [leanX, leanZ], + noiseSeed: shapeRng.seed(), + noiseScale: 6, + noiseAmount: 0.06, + segments: 5, + heightSegments: 4, + colors: o.stemColors, + })) + + const head: Vec3 = [leanX, o.height, leanZ] + + // Flower center. + parts.push( + sphere({ radius: o.centerSize, widthSegments: 6, heightSegments: 4 }) + .scale(1, 0.6, 1) + .translate(head[0], head[1], head[2]) + .vertexColor(o.centerColor), + ) + + // Petals arranged in a ring, lifting up into a cup. + for (let i = 0; i < o.petals; i++) { + const angle = (i / o.petals) * Math.PI * 2 + (shapeRng() - 0.5) * 0.15 + const ca = Math.cos(angle), sa = Math.sin(angle) + const dir = norm(ca, o.petalLift * 1.4, sa) + const start: Vec3 = [head[0] + ca * o.centerSize, head[1], head[2] + sa * o.centerSize] + const segs = 3 + const path: Vec3[] = [] + for (let j = 0; j <= segs; j++) { + const t = j / segs + path.push([ + start[0] + dir[0] * o.petalLength * t, + start[1] + dir[1] * o.petalLength * t, + start[2] + dir[2] * o.petalLength * t, + ]) + } + parts.push( + blade(path, { width: (t) => o.petalWidth * Math.sin(Math.min(1, t) * Math.PI) }) + .vertexColor(o.petalColor), + ) + } + + // Leaves partway up the stem. + for (let i = 0; i < o.leaves; i++) { + const t0 = 0.3 + (i / Math.max(1, o.leaves)) * 0.4 + const angle = shapeRng() * Math.PI * 2 + const ca = Math.cos(angle), sa = Math.sin(angle) + const dir = norm(ca, 0.35, sa) + const base: Vec3 = [leanX * t0 * t0, o.height * t0, leanZ * t0 * t0] + const segs = 3 + const path: Vec3[] = [] + for (let j = 0; j <= segs; j++) { + const t = j / segs + const droop = 0.25 * o.leafLength * t * t + path.push([ + base[0] + dir[0] * o.leafLength * t, + base[1] + dir[1] * o.leafLength * t - droop, + base[2] + dir[2] * o.leafLength * t, + ]) + } + parts.push( + blade(path, { width: (t) => o.leafLength * 0.3 * Math.sin(Math.min(1, t) * Math.PI) }) + .vertexColor(o.stemColors[o.stemColors.length - 1]), + ) + } + + return merge(...parts) +} diff --git a/src/generators/vegetation/plants/index.ts b/src/generators/vegetation/plants/index.ts index 8946be6..26ca3fc 100644 --- a/src/generators/vegetation/plants/index.ts +++ b/src/generators/vegetation/plants/index.ts @@ -1,2 +1,3 @@ export { grass, grassSchema, grassPresets, type GrassOptions } from './grass' export { fern, fernSchema, fernPresets, type FernOptions } from './fern' +export { flower, flowerSchema, flowerPresets, type FlowerOptions } from './flower' diff --git a/src/generators/vegetation/trees/dead-tree.ts b/src/generators/vegetation/trees/dead-tree.ts new file mode 100644 index 0000000..fe5c5e9 --- /dev/null +++ b/src/generators/vegetation/trees/dead-tree.ts @@ -0,0 +1,52 @@ +import { setup, branches, heightShade } from '../../../build' +import type { Mesh } from '../../../core/mesh' +import type { OptionSchema, OptionInput } from '../../../core/schema' + +export const deadTreeSchema = { + seed: { type: 'integer', default: 1, min: 1, max: 100, label: 'Seed' }, + height: { type: 'range', default: 3, min: 1.5, max: 6, step: 0.1, label: 'Trunk Length' }, + trunkRadius: { type: 'range', default: 0.16, min: 0.05, max: 0.4, step: 0.01, label: 'Trunk Radius' }, + levels: { type: 'integer', default: 4, min: 2, max: 5, label: 'Branch Levels' }, + branches: { type: 'integer', default: 3, min: 2, max: 4, label: 'Splits' }, + spread: { type: 'range', default: 0.8, min: 0.3, max: 1.3, step: 0.05, label: 'Spread' }, + upBias: { type: 'range', default: 0.22, min: 0, max: 0.6, step: 0.02, label: 'Upward Bias' }, + wander: { type: 'range', default: 0.5, min: 0, max: 1, step: 0.05, label: 'Gnarl' }, + lengthFalloff: { type: 'range', default: 0.7, min: 0.5, max: 0.85, step: 0.02, label: 'Length Falloff' }, + radiusFalloff: { type: 'range', default: 0.82, min: 0.6, max: 0.95, step: 0.02, label: 'Radius Falloff' }, + taper: { type: 'range', default: 0.5, min: 0.3, max: 1.4, step: 0.05, label: 'Taper' }, + segments: { type: 'integer', default: 6, min: 4, max: 12, label: 'Smoothness' }, + sides: { type: 'integer', default: 5, min: 4, max: 8, label: 'Trunk Sides' }, + colors: { type: 'color-array', default: ['#2e2419', '#4a3d2e', '#6b5e50'], min: 2, max: 6, label: 'Bark Colors' }, +} satisfies OptionSchema + +export type DeadTreeOptions = Partial> & { preset?: string } + +export const deadTreePresets: Record> = { + default: {}, + gnarled: { spread: 1.1, wander: 0.9, upBias: 0.08 }, + tall: { height: 4.5, upBias: 0.4, spread: 0.6, levels: 5 }, + stump: { height: 1.0, levels: 3, trunkRadius: 0.3, branches: 4, taper: 1.3 }, +} + +export function deadTree(options: DeadTreeOptions = {}): Mesh { + const { o, rng } = setup(deadTreeSchema, options, deadTreePresets) + + const { mesh } = branches({ + rng: rng.stream('shape'), + length: o.height, + radius: o.trunkRadius, + depth: o.levels, + children: o.branches, + spread: o.spread, + upBias: o.upBias, + wander: o.wander, + lengthFalloff: o.lengthFalloff, + radiusFalloff: o.radiusFalloff, + taper: o.taper, + segments: o.segments, + sides: o.sides, + }) + + const top = mesh.boundingBox.max.y || o.height + return mesh.vertexColor(heightShade(o.colors, top)) +} diff --git a/src/generators/vegetation/trees/index.ts b/src/generators/vegetation/trees/index.ts index 12c0983..f1cf869 100644 --- a/src/generators/vegetation/trees/index.ts +++ b/src/generators/vegetation/trees/index.ts @@ -1,3 +1,4 @@ export { tree, treeSchema, treePresets, type TreeOptions } from './common-tree' export { pine, pineSchema, pinePresets, type PineOptions } from './pine-tree' export { palm, palmSchema, palmPresets, type PalmOptions } from './palm-tree' +export { deadTree, deadTreeSchema, deadTreePresets, type DeadTreeOptions } from './dead-tree' diff --git a/src/index.ts b/src/index.ts index e2de53f..203c1a5 100644 --- a/src/index.ts +++ b/src/index.ts @@ -26,8 +26,8 @@ export type { Rng } from './core/rng' export { scatterOnSphere } from './core/scatter' // Build — composable model primitives -export { setup, trunk, foliageBlob, facetShade, heightShade, scatterOnSurface, blade } from './build' -export type { TrunkOptions, FoliageBlobOptions, FacetShadeOptions, SurfacePoint, ScatterOnSurfaceOptions, BladeOptions } from './build' +export { setup, trunk, foliageBlob, facetShade, heightShade, scatterOnSurface, blade, branches } from './build' +export type { TrunkOptions, FoliageBlobOptions, FacetShadeOptions, SurfacePoint, ScatterOnSurfaceOptions, BladeOptions, BranchesOptions, BranchResult, BranchTip } from './build' // Schema & options export { resolveOptions } from './core/schema' @@ -40,3 +40,6 @@ export { palm, palmSchema, palmPresets } from './generators' export { bush, bushSchema, bushPresets } from './generators' export { grass, grassSchema, grassPresets } from './generators' export { fern, fernSchema, fernPresets } from './generators' +export { flower, flowerSchema, flowerPresets } from './generators' +export { deadTree, deadTreeSchema, deadTreePresets } from './generators' +export { rock, rockSchema, rockPresets } from './generators' diff --git a/src/ops/loft.ts b/src/ops/loft.ts index 09dcfe5..7d2f1b8 100644 --- a/src/ops/loft.ts +++ b/src/ops/loft.ts @@ -99,7 +99,7 @@ export function loft(options: LoftOptions): Mesh { * Simple loft variant: sweep a radius along a path to make a tube/branch shape. * radiusFn maps t (0-1 along path) to radius at that point. */ -export function tube(path: Vec3[], radiusFn: number | ((t: number) => number), segments: number = 6): Mesh { +export function tube(path: Vec3[], radiusFn: number | ((t: number) => number), segments: number = 6, caps: boolean = true): Mesh { const rFn = typeof radiusFn === 'number' ? () => radiusFn : radiusFn // Generate circle cross-sections at each path point @@ -138,18 +138,20 @@ export function tube(path: Vec3[], radiusFn: number | ((t: number) => number), s } } - // Caps - const startCenter = positions.length / 3 - positions.push(path[0][0], path[0][1], path[0][2]) - for (let j = 0; j < segments; j++) { - indices.push(startCenter, (j + 1) % segments, j) - } + // Caps (optional — omit for branch junctions where the cap discs would z-fight) + if (caps) { + const startCenter = positions.length / 3 + positions.push(path[0][0], path[0][1], path[0][2]) + for (let j = 0; j < segments; j++) { + indices.push(startCenter, (j + 1) % segments, j) + } - const endCenter = positions.length / 3 - positions.push(path[pathLen - 1][0], path[pathLen - 1][1], path[pathLen - 1][2]) - const endOff = (pathLen - 1) * segments - for (let j = 0; j < segments; j++) { - indices.push(endCenter, endOff + j, endOff + (j + 1) % segments) + const endCenter = positions.length / 3 + positions.push(path[pathLen - 1][0], path[pathLen - 1][1], path[pathLen - 1][2]) + const endOff = (pathLen - 1) * segments + for (let j = 0; j < segments; j++) { + indices.push(endCenter, endOff + j, endOff + (j + 1) % segments) + } } const geo = new THREE.BufferGeometry() diff --git a/tests/build.test.ts b/tests/build.test.ts index d112358..7c826d3 100644 --- a/tests/build.test.ts +++ b/tests/build.test.ts @@ -1,5 +1,5 @@ import { describe, it, expect } from 'vitest' -import { setup, trunk, foliageBlob, facetShade, heightShade, scatterOnSurface, blade } from '../src/build' +import { setup, trunk, foliageBlob, facetShade, heightShade, scatterOnSurface, blade, branches } from '../src/build' import { sphere, box, plane } from '../src/primitives' import type { OptionSchema } from '../src/core/schema' import type { Vec3 } from '../src/core/types' @@ -132,6 +132,42 @@ describe('blade', () => { }) }) +describe('branches', () => { + const opts = { seed: 1, length: 1, radius: 0.15, depth: 4, children: 2 as const } + + it('produces a limb mesh and a tip per strand', () => { + const r = branches(opts) + expect(r.mesh.vertexCount).toBeGreaterThan(0) + // One tip per strand (every axis ends in a twig); 2 children over 4 levels → 1+2+4+8 = 15. + expect(r.tips.length).toBe(15) + }) + + it('tips carry a position, a unit direction, and a radius', () => { + const r = branches(opts) + for (const tip of r.tips) { + expect(Math.hypot(...tip.direction)).toBeCloseTo(1, 5) + expect(tip.radius).toBeGreaterThan(0) + } + }) + + it('grows upward from the base', () => { + const r = branches(opts) + expect(r.mesh.boundingBox.max.y).toBeGreaterThan(0.8) + }) + + it('is deterministic for the same seed', () => { + const a = branches(opts).mesh.positions + const b = branches(opts).mesh.positions + expect(Array.from(a)).toEqual(Array.from(b)) + }) + + it('more depth → more limbs (more vertices)', () => { + const shallow = branches({ ...opts, depth: 2 }).mesh.vertexCount + const deep = branches({ ...opts, depth: 4 }).mesh.vertexCount + expect(deep).toBeGreaterThan(shallow) + }) +}) + describe('scatterOnSurface', () => { it('returns the requested number of points with unit normals', () => { const pts = scatterOnSurface(sphere({ radius: 1 }), 20, { seed: 1 }) diff --git a/tests/generators.test.ts b/tests/generators.test.ts index 4fa2715..2334418 100644 --- a/tests/generators.test.ts +++ b/tests/generators.test.ts @@ -1,5 +1,5 @@ import { describe, it, expect } from 'vitest' -import { tree, pine, palm, bush, grass, fern } from '../src/generators' +import { tree, pine, palm, bush, grass, fern, flower, deadTree, rock } from '../src/generators' import type { Mesh } from '../src/core/mesh' const generators = [ @@ -9,6 +9,9 @@ const generators = [ { name: 'bush', gen: bush }, { name: 'grass', gen: grass }, { name: 'fern', gen: fern }, + { name: 'flower', gen: flower }, + { name: 'dead', gen: deadTree }, + { name: 'rock', gen: rock }, ] as const // Generators that support snow (trees + shrubs). Grass/ferns don't take snow options. @@ -23,6 +26,9 @@ const golden: Record = { bush: { verts: 3600 }, grass: { verts: 768 }, fern: { verts: 6480 }, + flower: { verts: 654 }, + dead: { verts: 1400 }, + rock: { verts: 1440 }, } function allFinite(m: Mesh): boolean {