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

commit

+472 -16
+6
demo/main.ts
··· 11 11 bush, bushSchema, bushPresets, 12 12 grass, grassSchema, grassPresets, 13 13 fern, fernSchema, fernPresets, 14 + flower, flowerSchema, flowerPresets, 15 + deadTree, deadTreeSchema, deadTreePresets, 16 + rock, rockSchema, rockPresets, 14 17 } from '../src/generators' 15 18 import { createEditor } from './editor/editor' 16 19 import type { OptionSchema } from '../src/core/schema' ··· 32 35 bush: { label: 'Bush', gen: bush, schema: bushSchema, presets: bushPresets, sizeKey: 'size', defaultSize: 0.6 }, 33 36 grass: { label: 'Grass', gen: grass, schema: grassSchema, presets: grassPresets, sizeKey: 'height', defaultSize: 0.6 }, 34 37 fern: { label: 'Fern', gen: fern, schema: fernSchema, presets: fernPresets, sizeKey: 'length', defaultSize: 1.0 }, 38 + flower: { label: 'Flower', gen: flower, schema: flowerSchema, presets: flowerPresets, sizeKey: 'height', defaultSize: 0.5 }, 39 + dead: { label: 'Dead', gen: deadTree, schema: deadTreeSchema, presets: deadTreePresets, sizeKey: 'height', defaultSize: 1.5 }, 40 + rock: { label: 'Rock', gen: rock, schema: rockSchema, presets: rockPresets, sizeKey: 'size', defaultSize: 0.6 }, 35 41 } 36 42 37 43 // --- Renderer ---
+153
src/build/branches.ts
··· 1 + import { tube, merge } from '../ops' 2 + import { createRng } from '../core/rng' 3 + import { Mesh } from '../core/mesh' 4 + import type { Vec3 } from '../core/types' 5 + 6 + export interface BranchTip { 7 + /** End point of a terminal limb — where foliage/leaves/fruit can attach. */ 8 + position: Vec3 9 + /** Growth direction at the tip. */ 10 + direction: Vec3 11 + /** Radius at the tip. */ 12 + radius: number 13 + /** Recursion depth remaining when this tip was emitted (0 = outermost). */ 14 + depth: number 15 + } 16 + 17 + export interface BranchResult { 18 + /** Merged tapered limbs (uncolored — apply your own vertexColor/faceColor). */ 19 + mesh: Mesh 20 + /** Attachment points at the end of every terminal limb. */ 21 + tips: BranchTip[] 22 + } 23 + 24 + export interface BranchesOptions { 25 + /** RNG to draw from (pass a generator stream for determinism). Falls back to `seed`. */ 26 + rng?: () => number 27 + seed?: number 28 + /** Base of the trunk. Default origin. */ 29 + start?: Vec3 30 + /** Initial growth direction. Default up. */ 31 + direction?: Vec3 32 + /** Length of the root limb. */ 33 + length: number 34 + /** Base radius of the root limb. */ 35 + radius: number 36 + /** Recursion levels (number of limb generations). */ 37 + depth: number 38 + /** Children spawned at each split. Default 2. */ 39 + children?: number | [number, number] 40 + /** Child length = parent length × this. Default 0.72. */ 41 + lengthFalloff?: number 42 + /** Child radius = parent radius at the attach point × this. Default 0.82. */ 43 + radiusFalloff?: number 44 + /** Angle (radians) children diverge from the parent. Default 0.7. */ 45 + spread?: number 46 + /** Pull of each limb toward world-up, 0..1. Default 0.25. */ 47 + upBias?: number 48 + /** Random meander of a limb (radians, total). Default 0.4. */ 49 + wander?: number 50 + /** Path points per limb. Default 5. */ 51 + segments?: number 52 + /** 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. */ 53 + taper?: number 54 + /** Tube radial sides. Default 5. */ 55 + sides?: number 56 + /** Fraction up a limb where side branches begin sprouting. Default 0.3. */ 57 + splitStart?: number 58 + } 59 + 60 + function normalize(v: Vec3): Vec3 { 61 + const l = Math.hypot(v[0], v[1], v[2]) || 1 62 + return [v[0] / l, v[1] / l, v[2] / l] 63 + } 64 + function cross(a: Vec3, b: Vec3): Vec3 { 65 + 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]] 66 + } 67 + function perpendicular(d: Vec3): Vec3 { 68 + const a: Vec3 = Math.abs(d[0]) < 0.9 ? [1, 0, 0] : [0, 1, 0] 69 + return normalize(cross(d, a)) 70 + } 71 + /** Rotate direction `d` by `angle` away from itself, in azimuth `az` around `d`. */ 72 + function diverge(d: Vec3, angle: number, az: number): Vec3 { 73 + const p = perpendicular(d) 74 + const q = cross(d, p) // second perpendicular 75 + const pr: Vec3 = [ 76 + p[0] * Math.cos(az) + q[0] * Math.sin(az), 77 + p[1] * Math.cos(az) + q[1] * Math.sin(az), 78 + p[2] * Math.cos(az) + q[2] * Math.sin(az), 79 + ] 80 + const c = Math.cos(angle), s = Math.sin(angle) 81 + return normalize([d[0] * c + pr[0] * s, d[1] * c + pr[1] * s, d[2] * c + pr[2] * s]) 82 + } 83 + function towardUp(d: Vec3, t: number): Vec3 { 84 + return normalize([d[0] * (1 - t), d[1] * (1 - t) + t, d[2] * (1 - t)]) 85 + } 86 + 87 + /** 88 + * Grow a recursive tapered branch skeleton — the engine behind dead trees, bare 89 + * limbs, coral, and (with foliage attached at the returned tips) leafy trees. 90 + */ 91 + export function branches(options: BranchesOptions): BranchResult { 92 + const rng = options.rng ?? createRng(options.seed ?? 0) 93 + const childrenOpt = options.children ?? 3 94 + const lengthFalloff = options.lengthFalloff ?? 0.7 95 + const radiusFalloff = options.radiusFalloff ?? 0.82 96 + const spread = options.spread ?? 0.8 97 + const upBias = options.upBias ?? 0.22 98 + const wander = options.wander ?? 0.5 99 + const segments = options.segments ?? 6 100 + const taperExp = options.taper ?? 0.5 101 + const sides = options.sides ?? 5 102 + const splitStart = options.splitStart ?? 0.2 103 + 104 + const limbs: Mesh[] = [] 105 + const tips: BranchTip[] = [] 106 + 107 + // Each strand is ONE continuous axis (trunk or branch), built as a single tube that 108 + // tapers smoothly from its base to a twig point. Laterals branch off along it; each 109 + // lateral is itself a strand. There are no per-segment trunk joints to gap. 110 + function strand(start: Vec3, dir: Vec3, length: number, radius: number, depth: number) { 111 + let d = normalize(dir) 112 + let pos = start 113 + const path: Vec3[] = [pos] 114 + const stepLen = length / segments 115 + for (let s = 0; s < segments; s++) { 116 + d = diverge(d, (wander / segments) * (0.5 + rng()), rng() * Math.PI * 2) 117 + d = towardUp(d, upBias * 0.15) 118 + pos = [pos[0] + d[0] * stepLen, pos[1] + d[1] * stepLen, pos[2] + d[2] * stepLen] 119 + path.push(pos) 120 + } 121 + // Smooth taper from base to a point at the tip; the top is naturally a thin twig. 122 + const radiusAt = (t: number) => radius * Math.pow(Math.max(0, 1 - t), taperExp) 123 + limbs.push(tube(path, radiusAt, sides, false)) 124 + tips.push({ position: pos, direction: d, radius: radius * 0.25, depth }) 125 + 126 + if (depth <= 1) return 127 + 128 + const n = Array.isArray(childrenOpt) 129 + ? Math.round(childrenOpt[0] + rng() * (childrenOpt[1] - childrenOpt[0])) 130 + : childrenOpt 131 + 132 + // Laterals sprout from points along the strand, each starting on the parent axis so 133 + // its base is buried inside the parent tube → connected joints, no caps, no gaps. 134 + for (let c = 0; c < n; c++) { 135 + const f = splitStart + (0.9 - splitStart) * ((c + 0.5 + (rng() - 0.5) * 0.6) / n) 136 + const fi = Math.max(0, Math.min(segments - 1e-6, f * segments)) 137 + const i0 = Math.floor(fi) 138 + const ft = fi - i0 139 + const a = path[i0], b = path[i0 + 1] 140 + const apos: Vec3 = [a[0] + (b[0] - a[0]) * ft, a[1] + (b[1] - a[1]) * ft, a[2] + (b[2] - a[2]) * ft] 141 + const adir = normalize([b[0] - a[0], b[1] - a[1], b[2] - a[2]]) 142 + const arad = radiusAt(f) 143 + 144 + let cd = diverge(adir, spread * (0.6 + rng() * 0.7), rng() * Math.PI * 2) 145 + cd = towardUp(cd, upBias) 146 + strand(apos, cd, length * lengthFalloff * (0.7 + rng() * 0.4), arad * radiusFalloff, depth - 1) 147 + } 148 + } 149 + 150 + strand(options.start ?? [0, 0, 0], options.direction ?? [0, 1, 0], options.length, options.radius, options.depth) 151 + 152 + return { mesh: merge(...limbs), tips } 153 + }
+1
src/build/index.ts
··· 4 4 export { facetShade, heightShade, type FacetShadeOptions } from './shade' 5 5 export { scatterOnSurface, type SurfacePoint, type ScatterOnSurfaceOptions } from './surface' 6 6 export { blade, type BladeOptions } from './blade' 7 + export { branches, type BranchesOptions, type BranchResult, type BranchTip } from './branches'
+1
src/generators/index.ts
··· 1 1 export * from './vegetation' 2 + export * from './rocks'
+1
src/generators/rocks/index.ts
··· 1 + export { rock, rockSchema, rockPresets, type RockOptions } from './rock'
+71
src/generators/rocks/rock.ts
··· 1 + import { setup, foliageBlob, facetShade } from '../../build' 2 + import { pickRandom } from '../../color' 3 + import { UberNoise } from '../../noise' 4 + import type { Mesh } from '../../core/mesh' 5 + import type { OptionSchema, OptionInput } from '../../core/schema' 6 + 7 + export const rockSchema = { 8 + seed: { type: 'integer', default: 1, min: 1, max: 100, label: 'Seed' }, 9 + size: { type: 'range', default: 0.6, min: 0.2, max: 2.5, step: 0.05, label: 'Size' }, 10 + detail: { type: 'range', default: 0.45, min: 0.2, max: 0.9, step: 0.05, label: 'Detail' }, 11 + noise: { type: 'range', default: 0.45, min: 0.1, max: 0.8, step: 0.05, label: 'Lumpiness' }, 12 + squash: { type: 'range', default: 0.7, min: 0.3, max: 1.2, step: 0.05, label: 'Squash' }, 13 + flatten: { type: 'range', default: 0.25, min: 0, max: 0.5, step: 0.05, label: 'Flat Base' }, 14 + jitter: { type: 'range', default: 0.02, min: 0, max: 0.1, step: 0.005, label: 'Jitter' }, 15 + mossColors: { type: 'color-array', default: [], min: 0, max: 4, label: 'Moss Colors' }, 16 + mossAngle: { type: 'range', default: 40, min: 0, max: 80, step: 5, label: 'Moss Min Angle (°)' }, 17 + colors: { type: 'color-array', default: ['#56514b', '#6a645c', '#7e776d'], min: 1, max: 6, label: 'Rock Colors' }, 18 + } satisfies OptionSchema 19 + 20 + export type RockOptions = Partial<OptionInput<typeof rockSchema>> & { preset?: string } 21 + 22 + export const rockPresets: Record<string, Partial<RockOptions>> = { 23 + default: {}, 24 + boulder: { size: 1.4, squash: 0.85, noise: 0.3, flatten: 0.3 }, 25 + sharp: { noise: 0.7, detail: 0.3, squash: 1.0 }, 26 + mossy: { mossColors: ['#3f6a2a', '#4e7d33', '#5c8c3a'], noise: 0.4 }, 27 + slate: { squash: 0.45, flatten: 0.4, colors: ['#48504f', '#586160', '#6a7270'] }, 28 + } 29 + 30 + export function rock(options: RockOptions = {}): Mesh { 31 + const { o, rng } = setup(rockSchema, options, rockPresets) 32 + const shapeRng = rng.stream('shape') 33 + const colorRng = rng.stream('color') 34 + const mossRng = rng.stream('moss') 35 + 36 + const colorNoise = new UberNoise({ seed: colorRng.seed(), scale: 2 }) 37 + const hasMoss = o.mossColors.length > 0 38 + const mossNoise = hasMoss ? new UberNoise({ seed: mossRng.seed(), scale: 2.5 }) : null 39 + const mossThreshold = Math.sin((o.mossAngle * Math.PI) / 180) 40 + 41 + // Lumpy faceted blob, flattened vertically. 42 + const blob = foliageBlob({ 43 + radius: o.size, 44 + detail: o.size * o.detail, 45 + noiseSeed: shapeRng.seed(), 46 + noiseScale: 0.8, 47 + noiseOctaves: 2, 48 + noiseAmount: o.noise, 49 + jitter: o.size * o.jitter, 50 + jitterSeed: shapeRng.seed(), 51 + }).scale(1, o.squash, 1) 52 + 53 + // Cut a flat base and rest it on the ground (y = 0). 54 + const baseY = -o.size * o.squash * (1 - o.flatten) 55 + const base = pickRandom(o.colors, colorRng) 56 + const moss = hasMoss ? pickRandom(o.mossColors, mossRng) : null 57 + 58 + return blob 59 + .warp((p) => (p[1] < baseY ? [p[0], baseY, p[2]] : p)) 60 + .translate(0, -baseY, 0) 61 + .faceColor(facetShade({ 62 + base, 63 + noise: colorNoise, 64 + ambient: 0.5, 65 + range: 0.5, 66 + noiseAmount: 0.12, 67 + snow: moss && mossNoise 68 + ? { color: moss, noise: mossNoise, threshold: mossThreshold, noiseAmount: 0.25 } 69 + : undefined, 70 + })) 71 + }
+122
src/generators/vegetation/plants/flower.ts
··· 1 + import { sphere } from '../../../primitives' 2 + import { merge } from '../../../ops' 3 + import { setup, trunk, blade } from '../../../build' 4 + import type { Mesh } from '../../../core/mesh' 5 + import type { Vec3 } from '../../../core/types' 6 + import type { OptionSchema, OptionInput } from '../../../core/schema' 7 + 8 + export const flowerSchema = { 9 + seed: { type: 'integer', default: 1, min: 1, max: 100, label: 'Seed' }, 10 + height: { type: 'range', default: 0.5, min: 0.15, max: 1.2, step: 0.05, label: 'Stem Height' }, 11 + stemRadius: { type: 'range', default: 0.012,min: 0.004,max: 0.03, step: 0.002, label: 'Stem Radius' }, 12 + lean: { type: 'range', default: 0.12, min: 0, max: 0.4, step: 0.02, label: 'Lean' }, 13 + petals: { type: 'integer', default: 9, min: 3, max: 18, label: 'Petals' }, 14 + petalLength: { type: 'range', default: 0.13, min: 0.05, max: 0.3, step: 0.01, label: 'Petal Length' }, 15 + petalWidth: { type: 'range', default: 0.06, min: 0.02, max: 0.14, step: 0.005, label: 'Petal Width' }, 16 + petalLift: { type: 'range', default: 0.45, min: 0, max: 1.2, step: 0.05, label: 'Petal Lift' }, 17 + centerSize: { type: 'range', default: 0.045,min: 0.02, max: 0.1, step: 0.005, label: 'Center Size' }, 18 + leaves: { type: 'integer', default: 2, min: 0, max: 4, label: 'Leaves' }, 19 + leafLength: { type: 'range', default: 0.16, min: 0.05, max: 0.35, step: 0.01, label: 'Leaf Length' }, 20 + petalColor: { type: 'color', default: '#e0556b', label: 'Petal Color' }, 21 + centerColor: { type: 'color', default: '#f0c040', label: 'Center Color' }, 22 + stemColors: { type: 'color-array', default: ['#2f6a1e', '#3f8526'], min: 1, max: 4, label: 'Stem Colors' }, 23 + } satisfies OptionSchema 24 + 25 + export type FlowerOptions = Partial<OptionInput<typeof flowerSchema>> & { preset?: string } 26 + 27 + export const flowerPresets: Record<string, Partial<FlowerOptions>> = { 28 + default: {}, 29 + daisy: { petals: 14, petalColor: '#f3f0ee', centerColor: '#f0b830', petalWidth: 0.04, petalLength: 0.16 }, 30 + tulip: { petals: 6, petalLift: 1.0, petalColor: '#d23a52', petalWidth: 0.1, petalLength: 0.18, centerSize: 0.03 }, 31 + poppy: { petals: 5, petalColor: '#d8392b', petalWidth: 0.12, petalLift: 0.6 }, 32 + dandelion: { petals: 18, petalColor: '#f2cf33', petalWidth: 0.025, petalLength: 0.1, petalLift: 0.7 }, 33 + } 34 + 35 + function norm(x: number, y: number, z: number): Vec3 { 36 + const l = Math.hypot(x, y, z) || 1 37 + return [x / l, y / l, z / l] 38 + } 39 + 40 + export function flower(options: FlowerOptions = {}): Mesh { 41 + const { o, rng } = setup(flowerSchema, options, flowerPresets) 42 + const shapeRng = rng.stream('shape') 43 + 44 + const leanAngle = shapeRng() * Math.PI * 2 45 + const leanX = Math.cos(leanAngle) * o.lean 46 + const leanZ = Math.sin(leanAngle) * o.lean 47 + 48 + const parts: Mesh[] = [] 49 + 50 + // Stem. 51 + parts.push(trunk({ 52 + height: o.height, 53 + baseRadius: o.stemRadius, 54 + topRadius: o.stemRadius * 0.7, 55 + taper: 1, 56 + lean: [leanX, leanZ], 57 + noiseSeed: shapeRng.seed(), 58 + noiseScale: 6, 59 + noiseAmount: 0.06, 60 + segments: 5, 61 + heightSegments: 4, 62 + colors: o.stemColors, 63 + })) 64 + 65 + const head: Vec3 = [leanX, o.height, leanZ] 66 + 67 + // Flower center. 68 + parts.push( 69 + sphere({ radius: o.centerSize, widthSegments: 6, heightSegments: 4 }) 70 + .scale(1, 0.6, 1) 71 + .translate(head[0], head[1], head[2]) 72 + .vertexColor(o.centerColor), 73 + ) 74 + 75 + // Petals arranged in a ring, lifting up into a cup. 76 + for (let i = 0; i < o.petals; i++) { 77 + const angle = (i / o.petals) * Math.PI * 2 + (shapeRng() - 0.5) * 0.15 78 + const ca = Math.cos(angle), sa = Math.sin(angle) 79 + const dir = norm(ca, o.petalLift * 1.4, sa) 80 + const start: Vec3 = [head[0] + ca * o.centerSize, head[1], head[2] + sa * o.centerSize] 81 + const segs = 3 82 + const path: Vec3[] = [] 83 + for (let j = 0; j <= segs; j++) { 84 + const t = j / segs 85 + path.push([ 86 + start[0] + dir[0] * o.petalLength * t, 87 + start[1] + dir[1] * o.petalLength * t, 88 + start[2] + dir[2] * o.petalLength * t, 89 + ]) 90 + } 91 + parts.push( 92 + blade(path, { width: (t) => o.petalWidth * Math.sin(Math.min(1, t) * Math.PI) }) 93 + .vertexColor(o.petalColor), 94 + ) 95 + } 96 + 97 + // Leaves partway up the stem. 98 + for (let i = 0; i < o.leaves; i++) { 99 + const t0 = 0.3 + (i / Math.max(1, o.leaves)) * 0.4 100 + const angle = shapeRng() * Math.PI * 2 101 + const ca = Math.cos(angle), sa = Math.sin(angle) 102 + const dir = norm(ca, 0.35, sa) 103 + const base: Vec3 = [leanX * t0 * t0, o.height * t0, leanZ * t0 * t0] 104 + const segs = 3 105 + const path: Vec3[] = [] 106 + for (let j = 0; j <= segs; j++) { 107 + const t = j / segs 108 + const droop = 0.25 * o.leafLength * t * t 109 + path.push([ 110 + base[0] + dir[0] * o.leafLength * t, 111 + base[1] + dir[1] * o.leafLength * t - droop, 112 + base[2] + dir[2] * o.leafLength * t, 113 + ]) 114 + } 115 + parts.push( 116 + blade(path, { width: (t) => o.leafLength * 0.3 * Math.sin(Math.min(1, t) * Math.PI) }) 117 + .vertexColor(o.stemColors[o.stemColors.length - 1]), 118 + ) 119 + } 120 + 121 + return merge(...parts) 122 + }
+1
src/generators/vegetation/plants/index.ts
··· 1 1 export { grass, grassSchema, grassPresets, type GrassOptions } from './grass' 2 2 export { fern, fernSchema, fernPresets, type FernOptions } from './fern' 3 + export { flower, flowerSchema, flowerPresets, type FlowerOptions } from './flower'
+52
src/generators/vegetation/trees/dead-tree.ts
··· 1 + import { setup, branches, heightShade } from '../../../build' 2 + import type { Mesh } from '../../../core/mesh' 3 + import type { OptionSchema, OptionInput } from '../../../core/schema' 4 + 5 + export const deadTreeSchema = { 6 + seed: { type: 'integer', default: 1, min: 1, max: 100, label: 'Seed' }, 7 + height: { type: 'range', default: 3, min: 1.5, max: 6, step: 0.1, label: 'Trunk Length' }, 8 + trunkRadius: { type: 'range', default: 0.16, min: 0.05, max: 0.4, step: 0.01, label: 'Trunk Radius' }, 9 + levels: { type: 'integer', default: 4, min: 2, max: 5, label: 'Branch Levels' }, 10 + branches: { type: 'integer', default: 3, min: 2, max: 4, label: 'Splits' }, 11 + spread: { type: 'range', default: 0.8, min: 0.3, max: 1.3, step: 0.05, label: 'Spread' }, 12 + upBias: { type: 'range', default: 0.22, min: 0, max: 0.6, step: 0.02, label: 'Upward Bias' }, 13 + wander: { type: 'range', default: 0.5, min: 0, max: 1, step: 0.05, label: 'Gnarl' }, 14 + lengthFalloff: { type: 'range', default: 0.7, min: 0.5, max: 0.85, step: 0.02, label: 'Length Falloff' }, 15 + radiusFalloff: { type: 'range', default: 0.82, min: 0.6, max: 0.95, step: 0.02, label: 'Radius Falloff' }, 16 + taper: { type: 'range', default: 0.5, min: 0.3, max: 1.4, step: 0.05, label: 'Taper' }, 17 + segments: { type: 'integer', default: 6, min: 4, max: 12, label: 'Smoothness' }, 18 + sides: { type: 'integer', default: 5, min: 4, max: 8, label: 'Trunk Sides' }, 19 + colors: { type: 'color-array', default: ['#2e2419', '#4a3d2e', '#6b5e50'], min: 2, max: 6, label: 'Bark Colors' }, 20 + } satisfies OptionSchema 21 + 22 + export type DeadTreeOptions = Partial<OptionInput<typeof deadTreeSchema>> & { preset?: string } 23 + 24 + export const deadTreePresets: Record<string, Partial<DeadTreeOptions>> = { 25 + default: {}, 26 + gnarled: { spread: 1.1, wander: 0.9, upBias: 0.08 }, 27 + tall: { height: 4.5, upBias: 0.4, spread: 0.6, levels: 5 }, 28 + stump: { height: 1.0, levels: 3, trunkRadius: 0.3, branches: 4, taper: 1.3 }, 29 + } 30 + 31 + export function deadTree(options: DeadTreeOptions = {}): Mesh { 32 + const { o, rng } = setup(deadTreeSchema, options, deadTreePresets) 33 + 34 + const { mesh } = branches({ 35 + rng: rng.stream('shape'), 36 + length: o.height, 37 + radius: o.trunkRadius, 38 + depth: o.levels, 39 + children: o.branches, 40 + spread: o.spread, 41 + upBias: o.upBias, 42 + wander: o.wander, 43 + lengthFalloff: o.lengthFalloff, 44 + radiusFalloff: o.radiusFalloff, 45 + taper: o.taper, 46 + segments: o.segments, 47 + sides: o.sides, 48 + }) 49 + 50 + const top = mesh.boundingBox.max.y || o.height 51 + return mesh.vertexColor(heightShade(o.colors, top)) 52 + }
+1
src/generators/vegetation/trees/index.ts
··· 1 1 export { tree, treeSchema, treePresets, type TreeOptions } from './common-tree' 2 2 export { pine, pineSchema, pinePresets, type PineOptions } from './pine-tree' 3 3 export { palm, palmSchema, palmPresets, type PalmOptions } from './palm-tree' 4 + export { deadTree, deadTreeSchema, deadTreePresets, type DeadTreeOptions } from './dead-tree'
+5 -2
src/index.ts
··· 26 26 export { scatterOnSphere } from './core/scatter' 27 27 28 28 // Build — composable model primitives 29 - export { setup, trunk, foliageBlob, facetShade, heightShade, scatterOnSurface, blade } from './build' 30 - export type { TrunkOptions, FoliageBlobOptions, FacetShadeOptions, SurfacePoint, ScatterOnSurfaceOptions, BladeOptions } from './build' 29 + export { setup, trunk, foliageBlob, facetShade, heightShade, scatterOnSurface, blade, branches } from './build' 30 + export type { TrunkOptions, FoliageBlobOptions, FacetShadeOptions, SurfacePoint, ScatterOnSurfaceOptions, BladeOptions, BranchesOptions, BranchResult, BranchTip } from './build' 31 31 32 32 // Schema & options 33 33 export { resolveOptions } from './core/schema' ··· 40 40 export { bush, bushSchema, bushPresets } from './generators' 41 41 export { grass, grassSchema, grassPresets } from './generators' 42 42 export { fern, fernSchema, fernPresets } from './generators' 43 + export { flower, flowerSchema, flowerPresets } from './generators' 44 + export { deadTree, deadTreeSchema, deadTreePresets } from './generators' 45 + export { rock, rockSchema, rockPresets } from './generators'
+14 -12
src/ops/loft.ts
··· 99 99 * Simple loft variant: sweep a radius along a path to make a tube/branch shape. 100 100 * radiusFn maps t (0-1 along path) to radius at that point. 101 101 */ 102 - export function tube(path: Vec3[], radiusFn: number | ((t: number) => number), segments: number = 6): Mesh { 102 + export function tube(path: Vec3[], radiusFn: number | ((t: number) => number), segments: number = 6, caps: boolean = true): Mesh { 103 103 const rFn = typeof radiusFn === 'number' ? () => radiusFn : radiusFn 104 104 105 105 // Generate circle cross-sections at each path point ··· 138 138 } 139 139 } 140 140 141 - // Caps 142 - const startCenter = positions.length / 3 143 - positions.push(path[0][0], path[0][1], path[0][2]) 144 - for (let j = 0; j < segments; j++) { 145 - indices.push(startCenter, (j + 1) % segments, j) 146 - } 141 + // Caps (optional — omit for branch junctions where the cap discs would z-fight) 142 + if (caps) { 143 + const startCenter = positions.length / 3 144 + positions.push(path[0][0], path[0][1], path[0][2]) 145 + for (let j = 0; j < segments; j++) { 146 + indices.push(startCenter, (j + 1) % segments, j) 147 + } 147 148 148 - const endCenter = positions.length / 3 149 - positions.push(path[pathLen - 1][0], path[pathLen - 1][1], path[pathLen - 1][2]) 150 - const endOff = (pathLen - 1) * segments 151 - for (let j = 0; j < segments; j++) { 152 - indices.push(endCenter, endOff + j, endOff + (j + 1) % segments) 149 + const endCenter = positions.length / 3 150 + positions.push(path[pathLen - 1][0], path[pathLen - 1][1], path[pathLen - 1][2]) 151 + const endOff = (pathLen - 1) * segments 152 + for (let j = 0; j < segments; j++) { 153 + indices.push(endCenter, endOff + j, endOff + (j + 1) % segments) 154 + } 153 155 } 154 156 155 157 const geo = new THREE.BufferGeometry()
+37 -1
tests/build.test.ts
··· 1 1 import { describe, it, expect } from 'vitest' 2 - import { setup, trunk, foliageBlob, facetShade, heightShade, scatterOnSurface, blade } from '../src/build' 2 + import { setup, trunk, foliageBlob, facetShade, heightShade, scatterOnSurface, blade, branches } from '../src/build' 3 3 import { sphere, box, plane } from '../src/primitives' 4 4 import type { OptionSchema } from '../src/core/schema' 5 5 import type { Vec3 } from '../src/core/types' ··· 129 129 it('reaches the path extent', () => { 130 130 const m = blade(path, { width: 0.1 }) 131 131 expect(m.boundingBox.max.y).toBeGreaterThan(1.0) 132 + }) 133 + }) 134 + 135 + describe('branches', () => { 136 + const opts = { seed: 1, length: 1, radius: 0.15, depth: 4, children: 2 as const } 137 + 138 + it('produces a limb mesh and a tip per strand', () => { 139 + const r = branches(opts) 140 + expect(r.mesh.vertexCount).toBeGreaterThan(0) 141 + // One tip per strand (every axis ends in a twig); 2 children over 4 levels → 1+2+4+8 = 15. 142 + expect(r.tips.length).toBe(15) 143 + }) 144 + 145 + it('tips carry a position, a unit direction, and a radius', () => { 146 + const r = branches(opts) 147 + for (const tip of r.tips) { 148 + expect(Math.hypot(...tip.direction)).toBeCloseTo(1, 5) 149 + expect(tip.radius).toBeGreaterThan(0) 150 + } 151 + }) 152 + 153 + it('grows upward from the base', () => { 154 + const r = branches(opts) 155 + expect(r.mesh.boundingBox.max.y).toBeGreaterThan(0.8) 156 + }) 157 + 158 + it('is deterministic for the same seed', () => { 159 + const a = branches(opts).mesh.positions 160 + const b = branches(opts).mesh.positions 161 + expect(Array.from(a)).toEqual(Array.from(b)) 162 + }) 163 + 164 + it('more depth → more limbs (more vertices)', () => { 165 + const shallow = branches({ ...opts, depth: 2 }).mesh.vertexCount 166 + const deep = branches({ ...opts, depth: 4 }).mesh.vertexCount 167 + expect(deep).toBeGreaterThan(shallow) 132 168 }) 133 169 }) 134 170
+7 -1
tests/generators.test.ts
··· 1 1 import { describe, it, expect } from 'vitest' 2 - import { tree, pine, palm, bush, grass, fern } from '../src/generators' 2 + import { tree, pine, palm, bush, grass, fern, flower, deadTree, rock } from '../src/generators' 3 3 import type { Mesh } from '../src/core/mesh' 4 4 5 5 const generators = [ ··· 9 9 { name: 'bush', gen: bush }, 10 10 { name: 'grass', gen: grass }, 11 11 { name: 'fern', gen: fern }, 12 + { name: 'flower', gen: flower }, 13 + { name: 'dead', gen: deadTree }, 14 + { name: 'rock', gen: rock }, 12 15 ] as const 13 16 14 17 // Generators that support snow (trees + shrubs). Grass/ferns don't take snow options. ··· 23 26 bush: { verts: 3600 }, 24 27 grass: { verts: 768 }, 25 28 fern: { verts: 6480 }, 29 + flower: { verts: 654 }, 30 + dead: { verts: 1400 }, 31 + rock: { verts: 1440 }, 26 32 } 27 33 28 34 function allFinite(m: Mesh): boolean {