[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

+750 -352
+170
src/core/asset.ts
··· 1 + import * as THREE from 'three' 2 + import { Mesh } from './mesh' 3 + import { mergeMeshes } from './geometry-merge' 4 + import { makeRotation } from './math' 5 + import type { Material } from './material' 6 + import type { Vec3 } from './types' 7 + 8 + /** A named local anchor on an asset — an attach point for foliage, props, fruit, etc. */ 9 + export interface Socket { 10 + position: Vec3 11 + direction?: Vec3 12 + radius?: number 13 + } 14 + 15 + interface AssetInit { 16 + name: string 17 + geometry?: Mesh | null 18 + material?: Material | null 19 + transform?: THREE.Matrix4 20 + children?: readonly Asset[] 21 + sockets?: Readonly<Record<string, Socket>> 22 + } 23 + 24 + /** 25 + * A procedural asset: a tree of named parts. A leaf part carries geometry + a material; 26 + * a group part carries children. Transforms live on the node (lazy — no geometry clone), 27 + * and named sockets mark attach points. This replaces "merge everything into one anonymous 28 + * colored mesh", keeping structure for per-part materials, editing, sockets, and export. 29 + * 30 + * All edit methods are immutable and return a new Asset. 31 + */ 32 + export class Asset { 33 + readonly name: string 34 + readonly geometry: Mesh | null 35 + readonly material: Material | null 36 + readonly transform: THREE.Matrix4 37 + readonly children: readonly Asset[] 38 + readonly sockets: Readonly<Record<string, Socket>> 39 + 40 + constructor(init: AssetInit) { 41 + this.name = init.name 42 + this.geometry = init.geometry ?? null 43 + this.material = init.material ?? null 44 + this.transform = init.transform ?? new THREE.Matrix4() 45 + this.children = init.children ?? [] 46 + this.sockets = init.sockets ?? {} 47 + } 48 + 49 + static part(name: string, geometry: Mesh, material?: Material): Asset { 50 + return new Asset({ name, geometry, material: material ?? null }) 51 + } 52 + static group(name: string, children: Asset[]): Asset { 53 + return new Asset({ name, children }) 54 + } 55 + 56 + private patch(p: Partial<AssetInit>): Asset { 57 + return new Asset({ 58 + name: p.name ?? this.name, 59 + geometry: p.geometry !== undefined ? p.geometry : this.geometry, 60 + material: p.material !== undefined ? p.material : this.material, 61 + transform: p.transform ?? this.transform, 62 + children: p.children ?? this.children, 63 + sockets: p.sockets ?? this.sockets, 64 + }) 65 + } 66 + 67 + // --- Transforms (applied to the node's matrix; geometry is not touched) --- 68 + transformBy(m: THREE.Matrix4): Asset { 69 + return this.patch({ transform: this.transform.clone().premultiply(m) }) 70 + } 71 + translate(x: number, y: number, z: number): Asset { 72 + return this.transformBy(new THREE.Matrix4().makeTranslation(x, y, z)) 73 + } 74 + rotate(axis: Vec3 | 'x' | 'y' | 'z', angle: number): Asset { 75 + return this.transformBy(makeRotation(axis, angle)) 76 + } 77 + rotateX(a: number): Asset { return this.rotate('x', a) } 78 + rotateY(a: number): Asset { return this.rotate('y', a) } 79 + rotateZ(a: number): Asset { return this.rotate('z', a) } 80 + scale(x: number, y: number = x, z: number = x): Asset { 81 + return this.transformBy(new THREE.Matrix4().makeScale(x, y, z)) 82 + } 83 + 84 + // --- Edits --- 85 + withMaterial(material: Material): Asset { return this.patch({ material }) } 86 + withName(name: string): Asset { return this.patch({ name }) } 87 + add(...children: Asset[]): Asset { return this.patch({ children: [...this.children, ...children] }) } 88 + socket(name: string, s: Socket): Asset { return this.patch({ sockets: { ...this.sockets, [name]: s } }) } 89 + 90 + /** Replace the material on every part named `name` (recursively). */ 91 + recolor(name: string, material: Material): Asset { 92 + const here = this.name === name ? this.withMaterial(material) : this 93 + if (here.children.length === 0) return here 94 + return here.patch({ children: here.children.map((c) => c.recolor(name, material)) }) 95 + } 96 + 97 + // --- Queries --- 98 + find(name: string): Asset | null { 99 + if (this.name === name) return this 100 + for (const c of this.children) { 101 + const f = c.find(name) 102 + if (f) return f 103 + } 104 + return null 105 + } 106 + 107 + /** Visit every node with its accumulated world matrix. */ 108 + walk(fn: (node: Asset, world: THREE.Matrix4) => void, parentWorld = new THREE.Matrix4()): void { 109 + const world = parentWorld.clone().multiply(this.transform) 110 + fn(this, world) 111 + for (const c of this.children) c.walk(fn, world) 112 + } 113 + 114 + bounds(): THREE.Box3 { 115 + const box = new THREE.Box3() 116 + this.walk((node, world) => { 117 + if (!node.geometry) return 118 + const g = node.geometry.geometry.clone() 119 + g.applyMatrix4(world) 120 + g.computeBoundingBox() 121 + box.union(g.boundingBox!) 122 + }) 123 + return box 124 + } 125 + 126 + /** World-space socket by name (searches the tree). */ 127 + getSocket(name: string): Socket | null { 128 + let found: Socket | null = null 129 + this.walk((node, world) => { 130 + if (found) return 131 + const s = node.sockets[name] 132 + if (!s) return 133 + const p = new THREE.Vector3(s.position[0], s.position[1], s.position[2]).applyMatrix4(world) 134 + found = { position: [p.x, p.y, p.z], direction: s.direction, radius: s.radius } 135 + }) 136 + return found 137 + } 138 + 139 + /** Bake the whole tree (transforms applied) into a single Mesh — for export or when one mesh is wanted. */ 140 + flatten(): Mesh { 141 + if (this._flat) return this._flat 142 + const parts: Mesh[] = [] 143 + this.walk((node, world) => { 144 + if (node.geometry) parts.push(node.geometry.transform(world)) 145 + }) 146 + this._flat = mergeMeshes(parts) 147 + return this._flat 148 + } 149 + private _flat: Mesh | null = null 150 + 151 + // --- Mesh-compatible read accessors (delegate to the flattened mesh, cached) so an 152 + // Asset can stand in for a Mesh anywhere read-only geometry is consumed. --- 153 + get vertexCount(): number { return this.flatten().vertexCount } 154 + get faceCount(): number { return this.flatten().faceCount } 155 + get positions(): Float32Array { return this.flatten().positions } 156 + get colors(): Float32Array | null { return this.flatten().colors } 157 + get boundingBox(): THREE.Box3 { return this.flatten().boundingBox } 158 + 159 + clone(): Asset { return this.patch({}) } 160 + } 161 + 162 + /** Make a leaf part. */ 163 + export function part(name: string, geometry: Mesh, material?: Material): Asset { 164 + return Asset.part(name, geometry, material) 165 + } 166 + 167 + /** Make a group of parts. */ 168 + export function group(name: string, children: Asset[]): Asset { 169 + return Asset.group(name, children) 170 + }
+45
src/core/geometry-merge.ts
··· 1 + import * as THREE from 'three' 2 + import { mergeGeometries } from 'three/examples/jsm/utils/BufferGeometryUtils.js' 3 + import { Mesh } from './mesh' 4 + 5 + /** 6 + * Merge several meshes into one, normalizing attribute sets (fills missing colors with 7 + * white, missing UVs with zero, recomputes missing normals) and indexed/non-indexed 8 + * mismatches. Lives in core so both `ops/merge` and `Asset.flatten` can use it without a 9 + * core ↔ ops cycle. 10 + */ 11 + export function mergeMeshes(meshes: Mesh[]): Mesh { 12 + if (meshes.length === 0) return new Mesh(new THREE.BufferGeometry()) 13 + if (meshes.length === 1) return meshes[0].clone() 14 + 15 + let geometries = meshes.map((m) => m.geometry.clone()) 16 + 17 + const hasIndexed = geometries.some((g) => g.getIndex() !== null) 18 + const hasNonIndexed = geometries.some((g) => g.getIndex() === null) 19 + if (hasIndexed && hasNonIndexed) { 20 + geometries = geometries.map((g) => (g.getIndex() ? g.toNonIndexed() : g)) 21 + } 22 + 23 + const hasColors = geometries.some((g) => g.getAttribute('color')) 24 + const hasUVs = geometries.some((g) => g.getAttribute('uv')) 25 + const hasNormals = geometries.some((g) => g.getAttribute('normal')) 26 + 27 + for (const geo of geometries) { 28 + const vertCount = geo.getAttribute('position').count 29 + if (hasColors && !geo.getAttribute('color')) { 30 + const colors = new Float32Array(vertCount * 3) 31 + colors.fill(1) 32 + geo.setAttribute('color', new THREE.BufferAttribute(colors, 3)) 33 + } 34 + if (hasUVs && !geo.getAttribute('uv')) { 35 + geo.setAttribute('uv', new THREE.BufferAttribute(new Float32Array(vertCount * 2), 2)) 36 + } 37 + if (hasNormals && !geo.getAttribute('normal')) { 38 + geo.computeVertexNormals() 39 + } 40 + } 41 + 42 + const merged = mergeGeometries(geometries, false) 43 + if (!merged) throw new Error('Failed to merge geometries') 44 + return new Mesh(merged) 45 + }
+33
src/core/material.ts
··· 1 + import type { ColorInput } from './types' 2 + 3 + /** 4 + * A surface material, decoupled from geometry. A part references a Material; the same 5 + * geometry can be re-skinned, and distinct parts can carry distinct materials (the basis 6 + * for multi-material export). Baked vertex colors become just one option (`vertexColors`). 7 + */ 8 + export interface Material { 9 + /** Optional name (used as the glTF material name when exporting). */ 10 + name?: string 11 + /** Solid base color. Ignored when `vertexColors` is true. */ 12 + color?: ColorInput 13 + /** Use the geometry's baked `color` attribute instead of `color`. */ 14 + vertexColors?: boolean 15 + /** PBR roughness 0–1. Default 1. */ 16 + roughness?: number 17 + /** PBR metalness 0–1. Default 0. */ 18 + metalness?: number 19 + /** Emissive color. */ 20 + emissive?: ColorInput 21 + /** Faceted (low-poly) shading. Default true. */ 22 + flatShading?: boolean 23 + /** Render both sides (for thin geometry like leaves/blades). Default false. */ 24 + doubleSided?: boolean 25 + } 26 + 27 + /** Convenience identity helper for authoring materials inline with defaults documented. */ 28 + export function material(m: Material): Material { 29 + return m 30 + } 31 + 32 + /** A vertex-colored, faceted material — the default for the stylized generators. */ 33 + export const VERTEX_COLOR_MATERIAL: Material = { vertexColors: true, flatShading: true }
+12
src/core/schema.ts
··· 5 5 max: number 6 6 step?: number 7 7 label?: string 8 + /** Optional UI grouping (e.g. 'Trunk', 'Canopy'); ungrouped params land in 'General' */ 9 + group?: string 8 10 } 9 11 10 12 export interface IntegerOption { ··· 13 15 min: number 14 16 max: number 15 17 label?: string 18 + /** Optional UI grouping (e.g. 'Trunk', 'Canopy'); ungrouped params land in 'General' */ 19 + group?: string 16 20 } 17 21 18 22 export interface ColorOption { 19 23 type: 'color' 20 24 default: string 21 25 label?: string 26 + /** Optional UI grouping (e.g. 'Trunk', 'Canopy'); ungrouped params land in 'General' */ 27 + group?: string 22 28 } 23 29 24 30 export interface ColorArrayOption { ··· 27 33 min?: number 28 34 max?: number 29 35 label?: string 36 + /** Optional UI grouping (e.g. 'Trunk', 'Canopy'); ungrouped params land in 'General' */ 37 + group?: string 30 38 } 31 39 32 40 export interface BooleanOption { 33 41 type: 'boolean' 34 42 default: boolean 35 43 label?: string 44 + /** Optional UI grouping (e.g. 'Trunk', 'Canopy'); ungrouped params land in 'General' */ 45 + group?: string 36 46 } 37 47 38 48 export interface SelectOption { ··· 40 50 default: string 41 51 options: string[] 42 52 label?: string 53 + /** Optional UI grouping (e.g. 'Trunk', 'Canopy'); ungrouped params land in 'General' */ 54 + group?: string 43 55 } 44 56 45 57 export type OptionDef = RangeOption | IntegerOption | ColorOption | ColorArrayOption | BooleanOption | SelectOption
+16 -16
src/generators/rocks/block-rock.ts
··· 7 7 import type { OptionSchema, OptionInput } from '../../core/schema' 8 8 9 9 export const blockRockSchema = { 10 - seed: { type: 'integer', default: 1, min: 1, max: 100, label: 'Seed' }, 11 - size: { type: 'range', default: 0.7, min: 0.2, max: 2.5, step: 0.05, label: 'Size' }, 12 - blocks: { type: 'integer', default: 3, min: 1, max: 10, label: 'Blocks' }, 13 - aspect: { type: 'range', default: 1.0, min: 0.4, max: 2.5, step: 0.05, label: 'Aspect (Height)' }, 14 - irregularity: { type: 'range', default: 0.45, min: 0, max: 0.8, step: 0.05, label: 'Irregularity' }, 15 - spread: { type: 'range', default: 0.7, min: 0.3, max: 1.2, step: 0.05, label: 'Spread' }, 16 - tilt: { type: 'range', default: 15, min: 0, max: 35, step: 1, label: 'Tilt (°)' }, 17 - noise: { type: 'range', default: 0.18, min: 0, max: 0.5, step: 0.02, label: 'Lumpiness' }, 18 - jitter: { type: 'range', default: 0.1, min: 0, max: 0.2, step: 0.005, label: 'Jitter' }, 19 - sink: { type: 'range', default: 0.15, min: 0, max: 0.5, step: 0.05, label: 'Sink' }, 20 - mossColors: { type: 'color-array', default: [], min: 0, max: 4, label: 'Moss Colors' }, 21 - mossAngle: { type: 'range', default: 40, min: 0, max: 80, step: 5, label: 'Moss Min Angle (°)' }, 22 - snowColors: { type: 'color-array', default: [], min: 0, max: 6, label: 'Snow Colors' }, 23 - snowAngle: { type: 'range', default: 35, min: 0, max: 80, step: 5, label: 'Snow Min Angle (°)' }, 24 - snowDepth: { type: 'range', default: 0, min: 0, max: 0.3, step: 0.01, label: 'Snow Depth' }, 25 - colors: { type: 'color-array', default: ['#56514b', '#6a645c', '#7e776d'], min: 1, max: 6, label: 'Rock Colors' }, 10 + seed: { type: 'integer', default: 1, min: 1, max: 100, label: 'Seed', group: 'General' }, 11 + size: { type: 'range', default: 0.7, min: 0.2, max: 2.5, step: 0.05, label: 'Size', group: 'Shape' }, 12 + blocks: { type: 'integer', default: 3, min: 1, max: 10, label: 'Blocks', group: 'Shape' }, 13 + aspect: { type: 'range', default: 1.0, min: 0.4, max: 2.5, step: 0.05, label: 'Aspect (Height)', group: 'Shape' }, 14 + irregularity: { type: 'range', default: 0.45, min: 0, max: 0.8, step: 0.05, label: 'Irregularity', group: 'Shape' }, 15 + spread: { type: 'range', default: 0.7, min: 0.3, max: 1.2, step: 0.05, label: 'Spread', group: 'Shape' }, 16 + tilt: { type: 'range', default: 15, min: 0, max: 35, step: 1, label: 'Tilt (°)', group: 'Shape' }, 17 + noise: { type: 'range', default: 0.18, min: 0, max: 0.5, step: 0.02, label: 'Lumpiness', group: 'Shape' }, 18 + jitter: { type: 'range', default: 0.1, min: 0, max: 0.2, step: 0.005, label: 'Jitter', group: 'General' }, 19 + sink: { type: 'range', default: 0.15, min: 0, max: 0.5, step: 0.05, label: 'Sink', group: 'Shape' }, 20 + mossColors: { type: 'color-array', default: [], min: 0, max: 4, label: 'Moss Colors', group: 'Moss' }, 21 + mossAngle: { type: 'range', default: 40, min: 0, max: 80, step: 5, label: 'Moss Min Angle (°)', group: 'Moss' }, 22 + snowColors: { type: 'color-array', default: [], min: 0, max: 6, label: 'Snow Colors', group: 'Snow' }, 23 + snowAngle: { type: 'range', default: 35, min: 0, max: 80, step: 5, label: 'Snow Min Angle (°)', group: 'Snow' }, 24 + snowDepth: { type: 'range', default: 0, min: 0, max: 0.3, step: 0.01, label: 'Snow Depth', group: 'Snow' }, 25 + colors: { type: 'color-array', default: ['#56514b', '#6a645c', '#7e776d'], min: 1, max: 6, label: 'Rock Colors', group: 'Colors' }, 26 26 } satisfies OptionSchema 27 27 28 28 export type BlockRockOptions = Partial<OptionInput<typeof blockRockSchema>> & { preset?: string }
+13 -13
src/generators/rocks/rock.ts
··· 6 6 import type { OptionSchema, OptionInput } from '../../core/schema' 7 7 8 8 export const rockSchema = { 9 - seed: { type: 'integer', default: 1, min: 1, max: 100, label: 'Seed' }, 10 - size: { type: 'range', default: 0.6, min: 0.2, max: 2.5, step: 0.05, label: 'Size' }, 11 - detail: { type: 'range', default: 0.45, min: 0.2, max: 0.9, step: 0.05, label: 'Detail' }, 12 - noise: { type: 'range', default: 0.45, min: 0.1, max: 0.8, step: 0.05, label: 'Lumpiness' }, 13 - squash: { type: 'range', default: 0.7, min: 0.3, max: 1.2, step: 0.05, label: 'Squash' }, 14 - flatten: { type: 'range', default: 0.25, min: 0, max: 0.5, step: 0.05, label: 'Flat Base' }, 15 - jitter: { type: 'range', default: 0.02, min: 0, max: 0.1, step: 0.005, label: 'Jitter' }, 16 - mossColors: { type: 'color-array', default: [], min: 0, max: 4, label: 'Moss Colors' }, 17 - mossAngle: { type: 'range', default: 40, min: 0, max: 80, step: 5, label: 'Moss Min Angle (°)' }, 18 - snowColors: { type: 'color-array', default: [], min: 0, max: 6, label: 'Snow Colors' }, 19 - snowAngle: { type: 'range', default: 35, min: 0, max: 80, step: 5, label: 'Snow Min Angle (°)' }, 20 - snowDepth: { type: 'range', default: 0, min: 0, max: 0.3, step: 0.01, label: 'Snow Depth' }, 21 - colors: { type: 'color-array', default: ['#56514b', '#6a645c', '#7e776d'], min: 1, max: 6, label: 'Rock Colors' }, 9 + seed: { type: 'integer', default: 1, min: 1, max: 100, label: 'Seed', group: 'General' }, 10 + size: { type: 'range', default: 0.6, min: 0.2, max: 2.5, step: 0.05, label: 'Size', group: 'Shape' }, 11 + detail: { type: 'range', default: 0.45, min: 0.2, max: 0.9, step: 0.05, label: 'Detail', group: 'Shape' }, 12 + noise: { type: 'range', default: 0.45, min: 0.1, max: 0.8, step: 0.05, label: 'Lumpiness', group: 'Shape' }, 13 + squash: { type: 'range', default: 0.7, min: 0.3, max: 1.2, step: 0.05, label: 'Squash', group: 'Shape' }, 14 + flatten: { type: 'range', default: 0.25, min: 0, max: 0.5, step: 0.05, label: 'Flat Base', group: 'Shape' }, 15 + jitter: { type: 'range', default: 0.02, min: 0, max: 0.1, step: 0.005, label: 'Jitter', group: 'General' }, 16 + mossColors: { type: 'color-array', default: [], min: 0, max: 4, label: 'Moss Colors', group: 'Moss' }, 17 + mossAngle: { type: 'range', default: 40, min: 0, max: 80, step: 5, label: 'Moss Min Angle (°)', group: 'Moss' }, 18 + snowColors: { type: 'color-array', default: [], min: 0, max: 6, label: 'Snow Colors', group: 'Snow' }, 19 + snowAngle: { type: 'range', default: 35, min: 0, max: 80, step: 5, label: 'Snow Min Angle (°)', group: 'Snow' }, 20 + snowDepth: { type: 'range', default: 0, min: 0, max: 0.3, step: 0.01, label: 'Snow Depth', group: 'Snow' }, 21 + colors: { type: 'color-array', default: ['#56514b', '#6a645c', '#7e776d'], min: 1, max: 6, label: 'Rock Colors', group: 'Colors' }, 22 22 } satisfies OptionSchema 23 23 24 24 export type RockOptions = Partial<OptionInput<typeof rockSchema>> & { preset?: string }
+13 -13
src/generators/rocks/sharp-rock.ts
··· 7 7 import type { OptionSchema, OptionInput } from '../../core/schema' 8 8 9 9 export const sharpRockSchema = { 10 - seed: { type: 'integer', default: 1, min: 1, max: 100, label: 'Seed' }, 11 - size: { type: 'range', default: 1.0, min: 0.3, max: 3, step: 0.05, label: 'Size' }, 12 - shards: { type: 'integer', default: 5, min: 1, max: 12, label: 'Shards' }, 13 - width: { type: 'range', default: 0.3, min: 0.1, max: 0.6, step: 0.05, label: 'Shard Width' }, 14 - spread: { type: 'range', default: 0.45, min: 0, max: 1, step: 0.05, label: 'Spread' }, 15 - tilt: { type: 'range', default: 22, min: 0, max: 50, step: 1, label: 'Tilt (°)' }, 16 - jitter: { type: 'range', default: 0.05, min: 0, max: 0.15, step: 0.005, label: 'Jitter' }, 17 - mossColors: { type: 'color-array', default: [], min: 0, max: 4, label: 'Moss Colors' }, 18 - mossAngle: { type: 'range', default: 35, min: 0, max: 80, step: 5, label: 'Moss Min Angle (°)' }, 19 - snowColors: { type: 'color-array', default: [], min: 0, max: 6, label: 'Snow Colors' }, 20 - snowAngle: { type: 'range', default: 10, min: 0, max: 80, step: 5, label: 'Snow Min Angle (°)' }, 21 - snowDepth: { type: 'range', default: 0, min: 0, max: 0.3, step: 0.01, label: 'Snow Depth' }, 22 - colors: { type: 'color-array', default: ['#5b5650', '#6e6862', '#7d766c'], min: 1, max: 6, label: 'Rock Colors' }, 10 + seed: { type: 'integer', default: 1, min: 1, max: 100, label: 'Seed', group: 'General' }, 11 + size: { type: 'range', default: 1.0, min: 0.3, max: 3, step: 0.05, label: 'Size', group: 'Shape' }, 12 + shards: { type: 'integer', default: 5, min: 1, max: 12, label: 'Shards', group: 'Shape' }, 13 + width: { type: 'range', default: 0.3, min: 0.1, max: 0.6, step: 0.05, label: 'Shard Width', group: 'Shape' }, 14 + spread: { type: 'range', default: 0.45, min: 0, max: 1, step: 0.05, label: 'Spread', group: 'Shape' }, 15 + tilt: { type: 'range', default: 22, min: 0, max: 50, step: 1, label: 'Tilt (°)', group: 'Shape' }, 16 + jitter: { type: 'range', default: 0.05, min: 0, max: 0.15, step: 0.005, label: 'Jitter', group: 'General' }, 17 + mossColors: { type: 'color-array', default: [], min: 0, max: 4, label: 'Moss Colors', group: 'Moss' }, 18 + mossAngle: { type: 'range', default: 35, min: 0, max: 80, step: 5, label: 'Moss Min Angle (°)', group: 'Moss' }, 19 + snowColors: { type: 'color-array', default: [], min: 0, max: 6, label: 'Snow Colors', group: 'Snow' }, 20 + snowAngle: { type: 'range', default: 10, min: 0, max: 80, step: 5, label: 'Snow Min Angle (°)', group: 'Snow' }, 21 + snowDepth: { type: 'range', default: 0, min: 0, max: 0.3, step: 0.01, label: 'Snow Depth', group: 'Snow' }, 22 + colors: { type: 'color-array', default: ['#5b5650', '#6e6862', '#7d766c'], min: 1, max: 6, label: 'Rock Colors', group: 'Colors' }, 23 23 } satisfies OptionSchema 24 24 25 25 export type SharpRockOptions = Partial<OptionInput<typeof sharpRockSchema>> & { preset?: string }
+15 -15
src/generators/vegetation/fungi/mushroom.ts
··· 7 7 import type { OptionSchema, OptionInput } from '../../../core/schema' 8 8 9 9 export const mushroomSchema = { 10 - seed: { type: 'integer', default: 1, min: 1, max: 100, label: 'Seed' }, 11 - height: { type: 'range', default: 0.35, min: 0.1, max: 1.2, step: 0.05, label: 'Stem Height' }, 12 - stemRadius: { type: 'range', default: 0.045, min: 0.015, max: 0.15, step: 0.005, label: 'Stem Radius' }, 13 - lean: { type: 'range', default: 0.1, min: 0, max: 0.4, step: 0.02, label: 'Lean' }, 14 - capRadius: { type: 'range', default: 0.16, min: 0.05, max: 0.5, step: 0.01, label: 'Cap Radius' }, 15 - capSquash: { type: 'range', default: 0.6, min: 0.25, max: 1.2, step: 0.05, label: 'Cap Squash' }, 16 - capCurl: { type: 'range', default: 0.35, min: 0, max: 0.8, step: 0.05, label: 'Cap Curl' }, 17 - capNoise: { type: 'range', default: 0.12, min: 0, max: 0.5, step: 0.02, label: 'Cap Lumpiness' }, 18 - count: { type: 'integer', default: 1, min: 1, max: 9, label: 'Cluster' }, 19 - spread: { type: 'range', default: 1.0, min: 0.4, max: 2, step: 0.05, label: 'Spread' }, 20 - spots: { type: 'integer', default: 0, min: 0, max: 20, label: 'Spots' }, 21 - spotColor: { type: 'color', default: '#f2efe6', label: 'Spot Color' }, 22 - capColors: { type: 'color-array', default: ['#9c6b3f', '#a87844', '#8a5a32'], min: 1, max: 6, label: 'Cap Colors' }, 23 - gillColor: { type: 'color', default: '#e8dcc2', label: 'Gill Color' }, 24 - stemColors: { type: 'color-array', default: ['#ddd2bc', '#cfc4ae'], min: 1, max: 4, label: 'Stem Colors' }, 10 + seed: { type: 'integer', default: 1, min: 1, max: 100, label: 'Seed', group: 'General' }, 11 + height: { type: 'range', default: 0.35, min: 0.1, max: 1.2, step: 0.05, label: 'Stem Height', group: 'Stem' }, 12 + stemRadius: { type: 'range', default: 0.045, min: 0.015, max: 0.15, step: 0.005, label: 'Stem Radius', group: 'Stem' }, 13 + lean: { type: 'range', default: 0.1, min: 0, max: 0.4, step: 0.02, label: 'Lean', group: 'Stem' }, 14 + capRadius: { type: 'range', default: 0.16, min: 0.05, max: 0.5, step: 0.01, label: 'Cap Radius', group: 'Cap' }, 15 + capSquash: { type: 'range', default: 0.6, min: 0.25, max: 1.2, step: 0.05, label: 'Cap Squash', group: 'Cap' }, 16 + capCurl: { type: 'range', default: 0.35, min: 0, max: 0.8, step: 0.05, label: 'Cap Curl', group: 'Cap' }, 17 + capNoise: { type: 'range', default: 0.12, min: 0, max: 0.5, step: 0.02, label: 'Cap Lumpiness', group: 'Cap' }, 18 + count: { type: 'integer', default: 1, min: 1, max: 9, label: 'Cluster', group: 'Cluster' }, 19 + spread: { type: 'range', default: 1.0, min: 0.4, max: 2, step: 0.05, label: 'Spread', group: 'Cluster' }, 20 + spots: { type: 'integer', default: 0, min: 0, max: 20, label: 'Spots', group: 'Cap' }, 21 + spotColor: { type: 'color', default: '#f2efe6', label: 'Spot Color', group: 'Colors' }, 22 + capColors: { type: 'color-array', default: ['#9c6b3f', '#a87844', '#8a5a32'], min: 1, max: 6, label: 'Cap Colors', group: 'Colors' }, 23 + gillColor: { type: 'color', default: '#e8dcc2', label: 'Gill Color', group: 'Colors' }, 24 + stemColors: { type: 'color-array', default: ['#ddd2bc', '#cfc4ae'], min: 1, max: 4, label: 'Stem Colors', group: 'Colors' }, 25 25 } satisfies OptionSchema 26 26 27 27 export type MushroomOptions = Partial<OptionInput<typeof mushroomSchema>> & { preset?: string }
+11 -11
src/generators/vegetation/plants/fern.ts
··· 5 5 import type { OptionSchema, OptionInput } from '../../../core/schema' 6 6 7 7 export const fernSchema = { 8 - seed: { type: 'integer', default: 1, min: 1, max: 100, label: 'Seed' }, 9 - fronds: { type: 'integer', default: 6, min: 2, max: 12, label: 'Fronds' }, 10 - length: { type: 'range', default: 1.0, min: 0.4, max: 2.2, step: 0.05, label: 'Frond Length' }, 11 - arch: { type: 'range', default: 0.6, min: 0.1, max: 1.4, step: 0.05, label: 'Arch' }, 12 - leaflets: { type: 'integer', default: 14, min: 5, max: 28, label: 'Leaflets / side' }, 13 - leafletLength: { type: 'range', default: 0.32, min: 0.1, max: 0.6, step: 0.02, label: 'Leaflet Length' }, 14 - leafletAngle: { type: 'range', default: 0.55, min: 0, max: 1, step: 0.05, label: 'Leaflet Forward' }, 15 - leafletDroop: { type: 'range', default: 0.3, min: 0, max: 1, step: 0.05, label: 'Leaflet Droop' }, 16 - rachisWidth: { type: 'range', default: 0.02, min: 0.005,max: 0.05, step: 0.005, label: 'Rachis Width' }, 17 - segments: { type: 'integer', default: 12, min: 6, max: 20, label: 'Rachis Smoothness' }, 18 - colors: { type: 'color-array', default: ['#1f5216', '#2f6e1f', '#4e9a2e'], min: 1, max: 6, label: 'Colors' }, 8 + seed: { type: 'integer', default: 1, min: 1, max: 100, label: 'Seed', group: 'General' }, 9 + fronds: { type: 'integer', default: 6, min: 2, max: 12, label: 'Fronds', group: 'Fronds' }, 10 + length: { type: 'range', default: 1.0, min: 0.4, max: 2.2, step: 0.05, label: 'Frond Length', group: 'Fronds' }, 11 + arch: { type: 'range', default: 0.6, min: 0.1, max: 1.4, step: 0.05, label: 'Arch', group: 'Fronds' }, 12 + leaflets: { type: 'integer', default: 14, min: 5, max: 28, label: 'Leaflets / side', group: 'Leaflets' }, 13 + leafletLength: { type: 'range', default: 0.32, min: 0.1, max: 0.6, step: 0.02, label: 'Leaflet Length', group: 'Leaflets' }, 14 + leafletAngle: { type: 'range', default: 0.55, min: 0, max: 1, step: 0.05, label: 'Leaflet Forward', group: 'Leaflets' }, 15 + leafletDroop: { type: 'range', default: 0.3, min: 0, max: 1, step: 0.05, label: 'Leaflet Droop', group: 'Leaflets' }, 16 + rachisWidth: { type: 'range', default: 0.02, min: 0.005,max: 0.05, step: 0.005, label: 'Rachis Width', group: 'Fronds' }, 17 + segments: { type: 'integer', default: 12, min: 6, max: 20, label: 'Rachis Smoothness', group: 'Fronds' }, 18 + colors: { type: 'color-array', default: ['#1f5216', '#2f6e1f', '#4e9a2e'], min: 1, max: 6, label: 'Colors', group: 'Colors' }, 19 19 } satisfies OptionSchema 20 20 21 21 export type FernOptions = Partial<OptionInput<typeof fernSchema>> & { preset?: string }
+14 -14
src/generators/vegetation/plants/flower.ts
··· 6 6 import type { OptionSchema, OptionInput } from '../../../core/schema' 7 7 8 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' }, 9 + seed: { type: 'integer', default: 1, min: 1, max: 100, label: 'Seed', group: 'General' }, 10 + height: { type: 'range', default: 0.5, min: 0.15, max: 1.2, step: 0.05, label: 'Stem Height', group: 'Stem' }, 11 + stemRadius: { type: 'range', default: 0.012,min: 0.004,max: 0.03, step: 0.002, label: 'Stem Radius', group: 'Stem' }, 12 + lean: { type: 'range', default: 0.12, min: 0, max: 0.4, step: 0.02, label: 'Lean', group: 'Stem' }, 13 + petals: { type: 'integer', default: 9, min: 3, max: 18, label: 'Petals', group: 'Petals' }, 14 + petalLength: { type: 'range', default: 0.13, min: 0.05, max: 0.3, step: 0.01, label: 'Petal Length', group: 'Petals' }, 15 + petalWidth: { type: 'range', default: 0.06, min: 0.02, max: 0.14, step: 0.005, label: 'Petal Width', group: 'Petals' }, 16 + petalLift: { type: 'range', default: 0.45, min: 0, max: 1.2, step: 0.05, label: 'Petal Lift', group: 'Petals' }, 17 + centerSize: { type: 'range', default: 0.045,min: 0.02, max: 0.1, step: 0.005, label: 'Center Size', group: 'Petals' }, 18 + leaves: { type: 'integer', default: 2, min: 0, max: 4, label: 'Leaves', group: 'Leaves' }, 19 + leafLength: { type: 'range', default: 0.16, min: 0.05, max: 0.35, step: 0.01, label: 'Leaf Length', group: 'Leaves' }, 20 + petalColor: { type: 'color', default: '#e0556b', label: 'Petal Color', group: 'Colors' }, 21 + centerColor: { type: 'color', default: '#f0c040', label: 'Center Color', group: 'Colors' }, 22 + stemColors: { type: 'color-array', default: ['#2f6a1e', '#3f8526'], min: 1, max: 4, label: 'Stem Colors', group: 'Colors' }, 23 23 } satisfies OptionSchema 24 24 25 25 export type FlowerOptions = Partial<OptionInput<typeof flowerSchema>> & { preset?: string }
+9 -9
src/generators/vegetation/plants/grass.ts
··· 5 5 import type { OptionSchema, OptionInput } from '../../../core/schema' 6 6 7 7 export const grassSchema = { 8 - seed: { type: 'integer', default: 1, min: 1, max: 100, label: 'Seed' }, 9 - blades: { type: 'integer', default: 16, min: 3, max: 60, label: 'Blades' }, 10 - height: { type: 'range', default: 0.6, min: 0.15, max: 1.6, step: 0.05, label: 'Height' }, 11 - bladeWidth: { type: 'range', default: 0.035,min: 0.01, max: 0.1, step: 0.005, label: 'Blade Width' }, 12 - spread: { type: 'range', default: 0.1, min: 0, max: 0.4, step: 0.01, label: 'Base Spread' }, 13 - fan: { type: 'range', default: 0.25, min: 0, max: 0.6, step: 0.02, label: 'Tip Fan' }, 14 - curve: { type: 'range', default: 0.8, min: 0, max: 1.5, step: 0.05, label: 'Curve' }, 15 - segments: { type: 'integer', default: 4, min: 2, max: 8, label: 'Segments' }, 16 - colors: { type: 'color-array', default: ['#3a6a1e', '#4e8a28', '#7ab33e'], min: 1, max: 6, label: 'Colors' }, 8 + seed: { type: 'integer', default: 1, min: 1, max: 100, label: 'Seed', group: 'General' }, 9 + blades: { type: 'integer', default: 16, min: 3, max: 60, label: 'Blades', group: 'Shape' }, 10 + height: { type: 'range', default: 0.6, min: 0.15, max: 1.6, step: 0.05, label: 'Height', group: 'Shape' }, 11 + bladeWidth: { type: 'range', default: 0.035,min: 0.01, max: 0.1, step: 0.005, label: 'Blade Width', group: 'Shape' }, 12 + spread: { type: 'range', default: 0.1, min: 0, max: 0.4, step: 0.01, label: 'Base Spread', group: 'Shape' }, 13 + fan: { type: 'range', default: 0.25, min: 0, max: 0.6, step: 0.02, label: 'Tip Fan', group: 'Shape' }, 14 + curve: { type: 'range', default: 0.8, min: 0, max: 1.5, step: 0.05, label: 'Curve', group: 'Shape' }, 15 + segments: { type: 'integer', default: 4, min: 2, max: 8, label: 'Segments', group: 'Shape' }, 16 + colors: { type: 'color-array', default: ['#3a6a1e', '#4e8a28', '#7ab33e'], min: 1, max: 6, label: 'Colors', group: 'Colors' }, 17 17 } satisfies OptionSchema 18 18 19 19 export type GrassOptions = Partial<OptionInput<typeof grassSchema>> & { preset?: string }
+17 -17
src/generators/vegetation/shrubs/bush.ts
··· 7 7 import type { OptionSchema, OptionInput } from '../../../core/schema' 8 8 9 9 export const bushSchema = { 10 - seed: { type: 'integer', default: 1, min: 1, max: 100, label: 'Seed' }, 11 - size: { type: 'range', default: 0.6, min: 0.2, max: 1.5, step: 0.05, label: 'Size' }, 12 - squash: { type: 'range', default: 0.75, min: 0.3, max: 1.1, step: 0.05, label: 'Squash' }, 13 - clumps: { type: 'integer', default: 6, min: 1, max: 14, label: 'Clumps' }, 14 - spread: { type: 'range', default: 0.6, min: 0.1, max: 1.2, step: 0.05, label: 'Spread' }, 15 - clumpSize: { type: 'range', default: 0.55, min: 0.3, max: 0.9, step: 0.05, label: 'Clump Size' }, 16 - detail: { type: 'range', default: 0.35, min: 0.15, max: 0.8, step: 0.05, label: 'Detail' }, 17 - noise: { type: 'range', default: 0.5, min: 0, max: 1.2, step: 0.05, label: 'Surface Noise' }, 18 - jitter: { type: 'range', default: 0.04, min: 0, max: 0.15, step: 0.005, label: 'Jitter' }, 19 - colorNoiseScale: { type: 'range', default: 1.5, min: 0.3, max: 5, step: 0.1, label: 'Color Noise Scale' }, 20 - berries: { type: 'integer', default: 0, min: 0, max: 30, label: 'Berries' }, 21 - berrySize: { type: 'range', default: 0.04, min: 0.02, max: 0.1, step: 0.005, label: 'Berry Size' }, 22 - berryColor: { type: 'color', default: '#c0303a', label: 'Berry Color' }, 23 - snowColors: { type: 'color-array', default: [], min: 0, max: 6, label: 'Snow Colors' }, 24 - snowAngle: { type: 'range', default: 35, min: 0, max: 80, step: 5, label: 'Snow Min Angle (°)' }, 25 - snowDepth: { type: 'range', default: 0, min: 0, max: 0.3, step: 0.01, label: 'Snow Depth' }, 26 - foliageColors: { type: 'color-array', default: ['#2a6018', '#327020', '#3a7d24', '#286a1c'], min: 1, max: 8, label: 'Foliage Colors' }, 10 + seed: { type: 'integer', default: 1, min: 1, max: 100, label: 'Seed', group: 'General' }, 11 + size: { type: 'range', default: 0.6, min: 0.2, max: 1.5, step: 0.05, label: 'Size', group: 'Shape' }, 12 + squash: { type: 'range', default: 0.75, min: 0.3, max: 1.1, step: 0.05, label: 'Squash', group: 'Shape' }, 13 + clumps: { type: 'integer', default: 6, min: 1, max: 14, label: 'Clumps', group: 'Shape' }, 14 + spread: { type: 'range', default: 0.6, min: 0.1, max: 1.2, step: 0.05, label: 'Spread', group: 'Shape' }, 15 + clumpSize: { type: 'range', default: 0.55, min: 0.3, max: 0.9, step: 0.05, label: 'Clump Size', group: 'Shape' }, 16 + detail: { type: 'range', default: 0.35, min: 0.15, max: 0.8, step: 0.05, label: 'Detail', group: 'Shape' }, 17 + noise: { type: 'range', default: 0.5, min: 0, max: 1.2, step: 0.05, label: 'Surface Noise', group: 'Shape' }, 18 + jitter: { type: 'range', default: 0.04, min: 0, max: 0.15, step: 0.005, label: 'Jitter', group: 'General' }, 19 + colorNoiseScale: { type: 'range', default: 1.5, min: 0.3, max: 5, step: 0.1, label: 'Color Noise Scale', group: 'Colors' }, 20 + berries: { type: 'integer', default: 0, min: 0, max: 30, label: 'Berries', group: 'Berries' }, 21 + berrySize: { type: 'range', default: 0.04, min: 0.02, max: 0.1, step: 0.005, label: 'Berry Size', group: 'Berries' }, 22 + berryColor: { type: 'color', default: '#c0303a', label: 'Berry Color', group: 'Berries' }, 23 + snowColors: { type: 'color-array', default: [], min: 0, max: 6, label: 'Snow Colors', group: 'Snow' }, 24 + snowAngle: { type: 'range', default: 35, min: 0, max: 80, step: 5, label: 'Snow Min Angle (°)', group: 'Snow' }, 25 + snowDepth: { type: 'range', default: 0, min: 0, max: 0.3, step: 0.01, label: 'Snow Depth', group: 'Snow' }, 26 + foliageColors: { type: 'color-array', default: ['#2a6018', '#327020', '#3a7d24', '#286a1c'], min: 1, max: 8, label: 'Foliage Colors', group: 'Colors' }, 27 27 } satisfies OptionSchema 28 28 29 29 export type BushOptions = Partial<OptionInput<typeof bushSchema>> & { preset?: string }
+37 -26
src/generators/vegetation/trees/common-tree.ts
··· 3 3 import { scatterOnSphere } from '../../../core/scatter' 4 4 import { pickRandom } from '../../../color' 5 5 import { UberNoise } from '../../../noise' 6 + import { group, part, Asset } from '../../../core/asset' 7 + import { VERTEX_COLOR_MATERIAL } from '../../../core/material' 6 8 import type { Mesh } from '../../../core/mesh' 7 9 import type { OptionSchema, OptionInput } from '../../../core/schema' 8 10 9 11 export const treeSchema = { 10 - seed: { type: 'integer', default: 1, min: 1, max: 100, label: 'Seed' }, 11 - height: { type: 'range', default: 2.5, min: 0.5, max: 6, step: 0.1, label: 'Height' }, 12 - trunkRadius: { type: 'range', default: 0.12, min: 0.03, max: 0.4, step: 0.01, label: 'Trunk Radius' }, 13 - trunkRatio: { type: 'range', default: 0.45, min: 0.2, max: 0.7, step: 0.01, label: 'Trunk Ratio' }, 14 - trunkTaper: { type: 'range', default: 2, min: 0.5, max: 5, step: 0.1, label: 'Root Flare' }, 15 - trunkTopScale: { type: 'range', default: 0.5, min: 0.02, max: 1, step: 0.02, label: 'Trunk Top Scale' }, 16 - lean: { type: 'range', default: 0.4, min: 0, max: 1.5, step: 0.05, label: 'Lean' }, 17 - showCanopy: { type: 'boolean', default: true, label: 'Show Canopy' }, 18 - canopyRadius: { type: 'range', default: 0.8, min: 0.2, max: 2, step: 0.05, label: 'Canopy Size' }, 19 - canopySquash: { type: 'range', default: 0.8, min: 0.3, max: 1, step: 0.05, label: 'Canopy Squash' }, 20 - canopyNoise: { type: 'range', default: 0.5, min: 0, max: 1.5, step: 0.05, label: 'Canopy Noise' }, 21 - canopyDetail: { type: 'range', default: 0.45, min: 0.15, max: 1, step: 0.05, label: 'Canopy Detail' }, 22 - canopyBumps: { type: 'integer', default: 3, min: 0, max: 8, label: 'Canopy Bumps' }, 23 - bumpSize: { type: 'range', default: 0.4, min: 0.1, max: 0.8, step: 0.05, label: 'Bump Size' }, 24 - canopyOffset: { type: 'range', default: 0.6, min: 0, max: 1.2, step: 0.05, label: 'Canopy Offset' }, 25 - jitter: { type: 'range', default: 0.04, min: 0, max: 0.15, step: 0.005, label: 'Jitter' }, 26 - snowColors: { type: 'color-array', default: [], min: 0, max: 6, label: 'Snow Colors' }, 27 - snowAngle: { type: 'range', default: 30, min: 0, max: 80, step: 5, label: 'Snow Min Angle (°)' }, 28 - snowDepth: { type: 'range', default: 0, min: 0, max: 0.3, step: 0.01, label: 'Snow Depth' }, 29 - trunkColors: { type: 'color-array', default: ['#1a0f06', '#4a2815', '#5a3520'], min: 2, max: 6, label: 'Trunk Colors' }, 30 - canopyColors: { type: 'color-array', default: ['#1e6b10', '#2a7518', '#238020', '#2d8a1e'], min: 1, max: 8, label: 'Canopy Colors' }, 12 + seed: { type: 'integer', default: 1, min: 1, max: 100, label: 'Seed', group: 'General' }, 13 + height: { type: 'range', default: 2.5, min: 0.5, max: 6, step: 0.1, label: 'Height', group: 'Trunk' }, 14 + trunkRadius: { type: 'range', default: 0.12, min: 0.03, max: 0.4, step: 0.01, label: 'Trunk Radius', group: 'Trunk' }, 15 + trunkRatio: { type: 'range', default: 0.45, min: 0.2, max: 0.7, step: 0.01, label: 'Trunk Ratio', group: 'Trunk' }, 16 + trunkTaper: { type: 'range', default: 2, min: 0.5, max: 5, step: 0.1, label: 'Root Flare', group: 'Trunk' }, 17 + trunkTopScale: { type: 'range', default: 0.5, min: 0.02, max: 1, step: 0.02, label: 'Trunk Top Scale', group: 'Trunk' }, 18 + lean: { type: 'range', default: 0.4, min: 0, max: 1.5, step: 0.05, label: 'Lean', group: 'Trunk' }, 19 + showCanopy: { type: 'boolean', default: true, label: 'Show Canopy', group: 'Canopy' }, 20 + canopyRadius: { type: 'range', default: 0.8, min: 0.2, max: 2, step: 0.05, label: 'Canopy Size', group: 'Canopy' }, 21 + canopySquash: { type: 'range', default: 0.8, min: 0.3, max: 1, step: 0.05, label: 'Canopy Squash', group: 'Canopy' }, 22 + canopyNoise: { type: 'range', default: 0.5, min: 0, max: 1.5, step: 0.05, label: 'Canopy Noise', group: 'Canopy' }, 23 + canopyDetail: { type: 'range', default: 0.45, min: 0.15, max: 1, step: 0.05, label: 'Canopy Detail', group: 'Canopy' }, 24 + canopyBumps: { type: 'integer', default: 3, min: 0, max: 8, label: 'Canopy Bumps', group: 'Canopy' }, 25 + bumpSize: { type: 'range', default: 0.4, min: 0.1, max: 0.8, step: 0.05, label: 'Bump Size', group: 'Canopy' }, 26 + canopyOffset: { type: 'range', default: 0.6, min: 0, max: 1.2, step: 0.05, label: 'Canopy Offset', group: 'Canopy' }, 27 + jitter: { type: 'range', default: 0.04, min: 0, max: 0.15, step: 0.005, label: 'Jitter', group: 'General' }, 28 + snowColors: { type: 'color-array', default: [], min: 0, max: 6, label: 'Snow Colors', group: 'Snow' }, 29 + snowAngle: { type: 'range', default: 30, min: 0, max: 80, step: 5, label: 'Snow Min Angle (°)', group: 'Snow' }, 30 + snowDepth: { type: 'range', default: 0, min: 0, max: 0.3, step: 0.01, label: 'Snow Depth', group: 'Snow' }, 31 + trunkColors: { type: 'color-array', default: ['#1a0f06', '#4a2815', '#5a3520'], min: 2, max: 6, label: 'Trunk Colors', group: 'Colors' }, 32 + canopyColors: { type: 'color-array', default: ['#1e6b10', '#2a7518', '#238020', '#2d8a1e'], min: 1, max: 8, label: 'Canopy Colors', group: 'Colors' }, 31 33 } satisfies OptionSchema 32 34 33 35 export type TreeOptions = Partial<OptionInput<typeof treeSchema>> & { preset?: string } ··· 51 53 }, 52 54 } 53 55 54 - export function tree(options: TreeOptions = {}): Mesh { 56 + export function tree(options: TreeOptions = {}): Asset { 55 57 const { o, rng } = setup(treeSchema, options, treePresets) 56 58 57 59 // Independent streams per concern: drawing from one never perturbs another, ··· 92 94 const topOffsetX = leanX 93 95 const topOffsetZ = leanZ 94 96 95 - if (!o.showCanopy) return trunkMesh 97 + if (!o.showCanopy) { 98 + return group('tree', [part('trunk', trunkMesh, VERTEX_COLOR_MATERIAL)]) 99 + } 96 100 97 101 // Canopy 98 102 const canopyParts: Mesh[] = [] ··· 170 174 } 171 175 } 172 176 173 - const result = merge(trunkMesh, ...canopyParts) 174 - if (!useGeoSnow) return result 177 + const canopy = merge(...canopyParts) 178 + const treeAsset = group('tree', [ 179 + part('trunk', trunkMesh, VERTEX_COLOR_MATERIAL), 180 + part('canopy', canopy, VERTEX_COLOR_MATERIAL), 181 + ]) 182 + if (!useGeoSnow) return treeAsset 175 183 176 - return applySnow(result, { 184 + // Snow as its own part, computed from the canopy only (it settles on leaves, not bark). 185 + const snowShell = applySnow(canopy, { 177 186 depth: o.snowDepth, 178 187 minAngle: 90 - o.snowAngle, 179 188 color: pickRandom(o.snowColors, snowRng), 180 189 seed: snowRng.seed(), 190 + merge: false, 181 191 }) 192 + return treeAsset.add(part('snow', snowShell, VERTEX_COLOR_MATERIAL)) 182 193 }
+14 -14
src/generators/vegetation/trees/dead-tree.ts
··· 3 3 import type { OptionSchema, OptionInput } from '../../../core/schema' 4 4 5 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' }, 6 + seed: { type: 'integer', default: 1, min: 1, max: 100, label: 'Seed', group: 'General' }, 7 + height: { type: 'range', default: 3, min: 1.5, max: 6, step: 0.1, label: 'Trunk Length', group: 'Trunk' }, 8 + trunkRadius: { type: 'range', default: 0.16, min: 0.05, max: 0.4, step: 0.01, label: 'Trunk Radius', group: 'Trunk' }, 9 + levels: { type: 'integer', default: 4, min: 2, max: 5, label: 'Branch Levels', group: 'Branches' }, 10 + branches: { type: 'integer', default: 3, min: 2, max: 4, label: 'Splits', group: 'Branches' }, 11 + spread: { type: 'range', default: 0.8, min: 0.3, max: 1.3, step: 0.05, label: 'Spread', group: 'Branches' }, 12 + upBias: { type: 'range', default: 0.22, min: 0, max: 0.6, step: 0.02, label: 'Upward Bias', group: 'Branches' }, 13 + wander: { type: 'range', default: 0.5, min: 0, max: 1, step: 0.05, label: 'Gnarl', group: 'Branches' }, 14 + lengthFalloff: { type: 'range', default: 0.7, min: 0.5, max: 0.85, step: 0.02, label: 'Length Falloff', group: 'Branches' }, 15 + radiusFalloff: { type: 'range', default: 0.82, min: 0.6, max: 0.95, step: 0.02, label: 'Radius Falloff', group: 'Branches' }, 16 + taper: { type: 'range', default: 0.5, min: 0.3, max: 1.4, step: 0.05, label: 'Taper', group: 'Branches' }, 17 + segments: { type: 'integer', default: 6, min: 4, max: 12, label: 'Smoothness', group: 'Branches' }, 18 + sides: { type: 'integer', default: 5, min: 4, max: 8, label: 'Trunk Sides', group: 'Trunk' }, 19 + colors: { type: 'color-array', default: ['#2e2419', '#4a3d2e', '#6b5e50'], min: 2, max: 6, label: 'Bark Colors', group: 'Colors' }, 20 20 } satisfies OptionSchema 21 21 22 22 export type DeadTreeOptions = Partial<OptionInput<typeof deadTreeSchema>> & { preset?: string }
+19 -19
src/generators/vegetation/trees/leafy-tree.ts
··· 6 6 import type { OptionSchema, OptionInput } from '../../../core/schema' 7 7 8 8 export const leafyTreeSchema = { 9 - seed: { type: 'integer', default: 1, min: 1, max: 100, label: 'Seed' }, 10 - height: { type: 'range', default: 2.5, min: 1.2, max: 5, step: 0.1, label: 'Trunk Length' }, 11 - trunkRadius: { type: 'range', default: 0.13, min: 0.05, max: 0.35, step: 0.01, label: 'Trunk Radius' }, 12 - levels: { type: 'integer', default: 3, min: 2, max: 5, label: 'Branch Levels' }, 13 - branches: { type: 'integer', default: 3, min: 2, max: 4, label: 'Splits' }, 14 - spread: { type: 'range', default: 0.7, min: 0.3, max: 1.1, step: 0.05, label: 'Spread' }, 15 - upBias: { type: 'range', default: 0.35, min: 0, max: 0.6, step: 0.02, label: 'Upward Bias' }, 16 - wander: { type: 'range', default: 0.4, min: 0, max: 1, step: 0.05, label: 'Gnarl' }, 17 - taper: { type: 'range', default: 0.5, min: 0.3, max: 1.2, step: 0.05, label: 'Taper' }, 18 - leafSize: { type: 'range', default: 0.55, min: 0.2, max: 1.2, step: 0.05, label: 'Leaf Cluster Size' }, 19 - blobCanopy: { type: 'boolean', default: false, label: 'Merge Canopy' }, 20 - blobDetail: { type: 'range', default: 0.4, min: 0.1, max: 1, step: 0.05, label: 'Canopy Detail' }, 21 - canopyNoise: { type: 'range', default: 0.5, min: 0, max: 1.2, step: 0.05, label: 'Canopy Noise' }, 22 - jitter: { type: 'range', default: 0.05, 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 - snowDepth: { type: 'range', default: 0, min: 0, max: 0.3, step: 0.01, label: 'Snow Depth' }, 26 - trunkColors: { type: 'color-array', default: ['#241a0f', '#3f2c1a', '#574028'], min: 2, max: 6, label: 'Trunk Colors' }, 27 - leafColors: { type: 'color-array', default: ['#2f6a1e', '#3a7d24', '#46902c', '#2a6a20'], min: 1, max: 8, label: 'Leaf Colors' }, 9 + seed: { type: 'integer', default: 1, min: 1, max: 100, label: 'Seed', group: 'General' }, 10 + height: { type: 'range', default: 2.5, min: 1.2, max: 5, step: 0.1, label: 'Trunk Length', group: 'Trunk' }, 11 + trunkRadius: { type: 'range', default: 0.13, min: 0.05, max: 0.35, step: 0.01, label: 'Trunk Radius', group: 'Trunk' }, 12 + levels: { type: 'integer', default: 3, min: 2, max: 5, label: 'Branch Levels', group: 'Branches' }, 13 + branches: { type: 'integer', default: 3, min: 2, max: 4, label: 'Splits', group: 'Branches' }, 14 + spread: { type: 'range', default: 0.7, min: 0.3, max: 1.1, step: 0.05, label: 'Spread', group: 'Branches' }, 15 + upBias: { type: 'range', default: 0.35, min: 0, max: 0.6, step: 0.02, label: 'Upward Bias', group: 'Branches' }, 16 + wander: { type: 'range', default: 0.4, min: 0, max: 1, step: 0.05, label: 'Gnarl', group: 'Branches' }, 17 + taper: { type: 'range', default: 0.5, min: 0.3, max: 1.2, step: 0.05, label: 'Taper', group: 'Trunk' }, 18 + leafSize: { type: 'range', default: 0.55, min: 0.2, max: 1.2, step: 0.05, label: 'Leaf Cluster Size', group: 'Canopy' }, 19 + blobCanopy: { type: 'boolean', default: false, label: 'Merge Canopy', group: 'Canopy' }, 20 + blobDetail: { type: 'range', default: 0.4, min: 0.1, max: 1, step: 0.05, label: 'Canopy Detail', group: 'Canopy' }, 21 + canopyNoise: { type: 'range', default: 0.5, min: 0, max: 1.2, step: 0.05, label: 'Canopy Noise', group: 'Canopy' }, 22 + jitter: { type: 'range', default: 0.05, min: 0, max: 0.15, step: 0.005, label: 'Jitter', group: 'General' }, 23 + snowColors: { type: 'color-array', default: [], min: 0, max: 6, label: 'Snow Colors', group: 'Snow' }, 24 + snowAngle: { type: 'range', default: 30, min: 0, max: 80, step: 5, label: 'Snow Min Angle (°)', group: 'Snow' }, 25 + snowDepth: { type: 'range', default: 0, min: 0, max: 0.3, step: 0.01, label: 'Snow Depth', group: 'Snow' }, 26 + trunkColors: { type: 'color-array', default: ['#241a0f', '#3f2c1a', '#574028'], min: 2, max: 6, label: 'Trunk Colors', group: 'Colors' }, 27 + leafColors: { type: 'color-array', default: ['#2f6a1e', '#3a7d24', '#46902c', '#2a6a20'], min: 1, max: 8, label: 'Leaf Colors', group: 'Colors' }, 28 28 } satisfies OptionSchema 29 29 30 30 export type LeafyTreeOptions = Partial<OptionInput<typeof leafyTreeSchema>> & { preset?: string }
+23 -23
src/generators/vegetation/trees/palm-tree.ts
··· 8 8 import type { OptionSchema, OptionInput } from '../../../core/schema' 9 9 10 10 export const palmSchema = { 11 - seed: { type: 'integer', default: 1, min: 1, max: 100, label: 'Seed' }, 12 - height: { type: 'range', default: 3.5, min: 1.5, max: 7, step: 0.1, label: 'Height' }, 13 - trunkRadius: { type: 'range', default: 0.07, min: 0.03, max: 0.2, step: 0.01, label: 'Trunk Radius' }, 14 - trunkTaper: { type: 'range', default: 0.65, min: 0.1, max: 0.9, step: 0.05, label: 'Trunk Taper' }, 15 - trunkCurve: { type: 'range', default: [0.3, 0.8], min: 0, max: 1.5, step: 0.05, label: 'Trunk Curve' }, 16 - trunkSegments: { type: 'integer', default: 5, min: 4, max: 8, label: 'Trunk Sides' }, 17 - trunkPathPoints: { type: 'integer', default: 20, min: 4, max: 40, label: 'Trunk Smoothness' }, 18 - fronds: { type: 'integer', default: [6, 9], min: 3, max: 14, label: 'Fronds' }, 19 - frondLength: { type: 'range', default: 1.2, min: 0.4, max: 2.5, step: 0.1, label: 'Frond Length' }, 20 - frondDroop: { type: 'range', default: [0.9, 1.4], min: 0.1, max: 2, step: 0.05, label: 'Frond Droop' }, 21 - frondWidth: { type: 'range', default: [0.15, 0.35], min: 0.05, max: 0.6, step: 0.02, label: 'Frond Width' }, 22 - frondThickness: { type: 'range', default: 0.015, min: 0.005, max: 0.05, step: 0.005, label: 'Frond Thickness' }, 23 - frondSegments: { type: 'integer', default: 5, min: 3, max: 10, label: 'Frond Segments' }, 24 - frondCurveUp: { type: 'range', default: 0.3, min: 0, max: 0.8, step: 0.05, label: 'Frond Curve Up' }, 25 - coconuts: { type: 'integer', default: [0, 4], min: 0, max: 6, label: 'Coconuts' }, 26 - coconutSize: { type: 'range', default: [0.05, 0.09], min: 0.02, max: 0.12, step: 0.005, label: 'Coconut Size' }, 27 - jitter: { type: 'range', default: 0.02, min: 0, max: 0.08, step: 0.005, label: 'Jitter' }, 28 - snowColors: { type: 'color-array', default: [], min: 0, max: 6, label: 'Snow Colors' }, 29 - snowAngle: { type: 'range', default: 20, min: 0, max: 80, step: 5, label: 'Snow Min Angle (°)' }, 30 - snowDepth: { type: 'range', default: 0, min: 0, max: 0.3, step: 0.01, label: 'Snow Depth' }, 31 - trunkColors: { type: 'color-array', default: ['#3a2a15', '#5a4025', '#6a5030'], min: 2, max: 6, label: 'Trunk Colors' }, 32 - frondColors: { type: 'color-array', default: ['#1a4a12', '#224e18', '#2a5520', '#1e4015'], min: 1, max: 8, label: 'Frond Colors' }, 33 - coconutColor: { type: 'color', default: '#3a2810', label: 'Coconut Color' }, 11 + seed: { type: 'integer', default: 1, min: 1, max: 100, label: 'Seed', group: 'General' }, 12 + height: { type: 'range', default: 3.5, min: 1.5, max: 7, step: 0.1, label: 'Height', group: 'Trunk' }, 13 + trunkRadius: { type: 'range', default: 0.07, min: 0.03, max: 0.2, step: 0.01, label: 'Trunk Radius', group: 'Trunk' }, 14 + trunkTaper: { type: 'range', default: 0.65, min: 0.1, max: 0.9, step: 0.05, label: 'Trunk Taper', group: 'Trunk' }, 15 + trunkCurve: { type: 'range', default: [0.3, 0.8], min: 0, max: 1.5, step: 0.05, label: 'Trunk Curve', group: 'Trunk' }, 16 + trunkSegments: { type: 'integer', default: 5, min: 4, max: 8, label: 'Trunk Sides', group: 'Trunk' }, 17 + trunkPathPoints: { type: 'integer', default: 20, min: 4, max: 40, label: 'Trunk Smoothness', group: 'Trunk' }, 18 + fronds: { type: 'integer', default: [6, 9], min: 3, max: 14, label: 'Fronds', group: 'Fronds' }, 19 + frondLength: { type: 'range', default: 1.2, min: 0.4, max: 2.5, step: 0.1, label: 'Frond Length', group: 'Fronds' }, 20 + frondDroop: { type: 'range', default: [0.9, 1.4], min: 0.1, max: 2, step: 0.05, label: 'Frond Droop', group: 'Fronds' }, 21 + frondWidth: { type: 'range', default: [0.15, 0.35], min: 0.05, max: 0.6, step: 0.02, label: 'Frond Width', group: 'Fronds' }, 22 + frondThickness: { type: 'range', default: 0.015, min: 0.005, max: 0.05, step: 0.005, label: 'Frond Thickness', group: 'Fronds' }, 23 + frondSegments: { type: 'integer', default: 5, min: 3, max: 10, label: 'Frond Segments', group: 'Fronds' }, 24 + frondCurveUp: { type: 'range', default: 0.3, min: 0, max: 0.8, step: 0.05, label: 'Frond Curve Up', group: 'Fronds' }, 25 + coconuts: { type: 'integer', default: [0, 4], min: 0, max: 6, label: 'Coconuts', group: 'Coconuts' }, 26 + coconutSize: { type: 'range', default: [0.05, 0.09], min: 0.02, max: 0.12, step: 0.005, label: 'Coconut Size', group: 'Coconuts' }, 27 + jitter: { type: 'range', default: 0.02, min: 0, max: 0.08, step: 0.005, label: 'Jitter', group: 'General' }, 28 + snowColors: { type: 'color-array', default: [], min: 0, max: 6, label: 'Snow Colors', group: 'Snow' }, 29 + snowAngle: { type: 'range', default: 20, min: 0, max: 80, step: 5, label: 'Snow Min Angle (°)', group: 'Snow' }, 30 + snowDepth: { type: 'range', default: 0, min: 0, max: 0.3, step: 0.01, label: 'Snow Depth', group: 'Snow' }, 31 + trunkColors: { type: 'color-array', default: ['#3a2a15', '#5a4025', '#6a5030'], min: 2, max: 6, label: 'Trunk Colors', group: 'Colors' }, 32 + frondColors: { type: 'color-array', default: ['#1a4a12', '#224e18', '#2a5520', '#1e4015'], min: 1, max: 8, label: 'Frond Colors', group: 'Colors' }, 33 + coconutColor: { type: 'color', default: '#3a2810', label: 'Coconut Color', group: 'Coconuts' }, 34 34 } satisfies OptionSchema 35 35 36 36 export type PalmOptions = Partial<OptionInput<typeof palmSchema>> & { preset?: string }
+26 -26
src/generators/vegetation/trees/pine-tree.ts
··· 7 7 import type { OptionSchema, OptionInput } from '../../../core/schema' 8 8 9 9 export const pineSchema = { 10 - seed: { type: 'integer', default: 1, min: 1, max: 100, label: 'Seed' }, 11 - height: { type: 'range', default: 3, min: 1, max: 8, step: 0.1, label: 'Height' }, 12 - trunkRadius: { type: 'range', default: 0.08, min: 0.02, max: 0.25, step: 0.01, label: 'Trunk Radius' }, 13 - trunkRatio: { type: 'range', default: 0.3, min: 0.1, max: 0.5, step: 0.05, label: 'Trunk Ratio' }, 14 - trunkTaper: { type: 'range', default: 1.5, min: 0.5, max: 4, step: 0.1, label: 'Root Flare' }, 15 - trunkTopScale: { type: 'range', default: 0.4, min: 0.1, max: 0.8, step: 0.05, label: 'Trunk Top Scale' }, 16 - lean: { type: 'range', default: 0.15, min: 0, max: 0.8, step: 0.05, label: 'Lean' }, 17 - layers: { type: 'integer', default: 5, min: 2, max: 10, label: 'Layers' }, 18 - layerOverlap: { type: 'range', default: 0.4, min: 0, max: 0.8, step: 0.05, label: 'Layer Overlap' }, 19 - layerShrink: { type: 'range', default: 0.75, min: 0.4, max: 0.95, step: 0.05, label: 'Layer Shrink' }, 20 - coneRadius: { type: 'range', default: 0.7, min: 0.2, max: 1.5, step: 0.05, label: 'Cone Radius' }, 21 - coneHeight: { type: 'range', default: 1.2, min: 0.4, max: 3, step: 0.1, label: 'Cone Height' }, 22 - coneSides: { type: 'integer', default: 8, min: 3, max: 12, label: 'Cone Sides' }, 23 - coneCurve: { type: 'range', default: 1.5, min: 1, max: 4, step: 0.1, label: 'Cone Curve' }, 24 - coneTilt: { type: 'range', default: [0.1, 0.4], min: 0, max: 0.6, step: 0.02, label: 'Cone Tilt' }, 25 - jitter: { type: 'range', default: 0.03, min: 0, max: 0.15, step: 0.005, label: 'Jitter' }, 26 - swayScale: { type: 'range', default: 0.8, min: 0.2, max: 5, step: 0.1, label: 'Sway Scale' }, 27 - swayAmount: { type: 'range', default: [0.15, 0.6], min: 0, max: 1, step: 0.05, label: 'Sway Amount' }, 28 - trunkNoiseScale:{ type: 'range', default: 8, min: 1, max: 20, step: 0.5, label: 'Trunk Noise Scale' }, 29 - trunkNoiseAmt: { type: 'range', default: 0.25, min: 0, max: 0.5, step: 0.05, label: 'Trunk Noise Amount' }, 30 - colorNoiseScale:{ type: 'range', default: 1.5, min: 0.3, max: 5, step: 0.1, label: 'Color Noise Scale' }, 31 - snowColors: { type: 'color-array', default: [], min: 0, max: 6, label: 'Snow Colors' }, 32 - snowAngle: { type: 'range', default: 20, min: 0, max: 80, step: 5, label: 'Snow Min Angle (°)' }, 33 - snowDepth: { type: 'range', default: 0, min: 0, max: 0.3, step: 0.01, label: 'Snow Depth' }, 34 - trunkColors: { type: 'color-array', default: ['#1a0f06', '#3d2210', '#4a2a15'], min: 2, max: 6, label: 'Trunk Colors' }, 35 - canopyColors: { type: 'color-array', default: ['#0a2e12', '#0e3a18', '#144a22', '#1a5a2c'], min: 1, max: 8, label: 'Canopy Colors' }, 10 + seed: { type: 'integer', default: 1, min: 1, max: 100, label: 'Seed', group: 'General' }, 11 + height: { type: 'range', default: 3, min: 1, max: 8, step: 0.1, label: 'Height', group: 'Trunk' }, 12 + trunkRadius: { type: 'range', default: 0.08, min: 0.02, max: 0.25, step: 0.01, label: 'Trunk Radius', group: 'Trunk' }, 13 + trunkRatio: { type: 'range', default: 0.3, min: 0.1, max: 0.5, step: 0.05, label: 'Trunk Ratio', group: 'Trunk' }, 14 + trunkTaper: { type: 'range', default: 1.5, min: 0.5, max: 4, step: 0.1, label: 'Root Flare', group: 'Trunk' }, 15 + trunkTopScale: { type: 'range', default: 0.4, min: 0.1, max: 0.8, step: 0.05, label: 'Trunk Top Scale', group: 'Trunk' }, 16 + lean: { type: 'range', default: 0.15, min: 0, max: 0.8, step: 0.05, label: 'Lean', group: 'Trunk' }, 17 + layers: { type: 'integer', default: 5, min: 2, max: 10, label: 'Layers', group: 'Canopy' }, 18 + layerOverlap: { type: 'range', default: 0.4, min: 0, max: 0.8, step: 0.05, label: 'Layer Overlap', group: 'Canopy' }, 19 + layerShrink: { type: 'range', default: 0.75, min: 0.4, max: 0.95, step: 0.05, label: 'Layer Shrink', group: 'Canopy' }, 20 + coneRadius: { type: 'range', default: 0.7, min: 0.2, max: 1.5, step: 0.05, label: 'Cone Radius', group: 'Canopy' }, 21 + coneHeight: { type: 'range', default: 1.2, min: 0.4, max: 3, step: 0.1, label: 'Cone Height', group: 'Canopy' }, 22 + coneSides: { type: 'integer', default: 8, min: 3, max: 12, label: 'Cone Sides', group: 'Canopy' }, 23 + coneCurve: { type: 'range', default: 1.5, min: 1, max: 4, step: 0.1, label: 'Cone Curve', group: 'Canopy' }, 24 + coneTilt: { type: 'range', default: [0.1, 0.4], min: 0, max: 0.6, step: 0.02, label: 'Cone Tilt', group: 'Canopy' }, 25 + jitter: { type: 'range', default: 0.03, min: 0, max: 0.15, step: 0.005, label: 'Jitter', group: 'General' }, 26 + swayScale: { type: 'range', default: 0.8, min: 0.2, max: 5, step: 0.1, label: 'Sway Scale', group: 'Sway' }, 27 + swayAmount: { type: 'range', default: [0.15, 0.6], min: 0, max: 1, step: 0.05, label: 'Sway Amount', group: 'Sway' }, 28 + trunkNoiseScale:{ type: 'range', default: 8, min: 1, max: 20, step: 0.5, label: 'Trunk Noise Scale', group: 'Trunk' }, 29 + trunkNoiseAmt: { type: 'range', default: 0.25, min: 0, max: 0.5, step: 0.05, label: 'Trunk Noise Amount', group: 'Trunk' }, 30 + colorNoiseScale:{ type: 'range', default: 1.5, min: 0.3, max: 5, step: 0.1, label: 'Color Noise Scale', group: 'Colors' }, 31 + snowColors: { type: 'color-array', default: [], min: 0, max: 6, label: 'Snow Colors', group: 'Snow' }, 32 + snowAngle: { type: 'range', default: 20, min: 0, max: 80, step: 5, label: 'Snow Min Angle (°)', group: 'Snow' }, 33 + snowDepth: { type: 'range', default: 0, min: 0, max: 0.3, step: 0.01, label: 'Snow Depth', group: 'Snow' }, 34 + trunkColors: { type: 'color-array', default: ['#1a0f06', '#3d2210', '#4a2a15'], min: 2, max: 6, label: 'Trunk Colors', group: 'Colors' }, 35 + canopyColors: { type: 'color-array', default: ['#0a2e12', '#0e3a18', '#144a22', '#1a5a2c'], min: 1, max: 8, label: 'Canopy Colors', group: 'Colors' }, 36 36 } satisfies OptionSchema 37 37 38 38 export type PineOptions = Partial<OptionInput<typeof pineSchema>> & { preset?: string }
+2
src/index.ts
··· 1 1 // Core 2 2 export { Mesh } from './core/mesh' 3 + export { Asset, part, group, type Socket } from './core/asset' 4 + export { material, VERTEX_COLOR_MATERIAL, type Material } from './core/material' 3 5 export type { Vec2, Vec3, Vec4, ColorInput, ColorFn, DisplaceFn, WarpFn, NoiseLike } from './core/types' 4 6 5 7 // Primitives
+4 -48
src/ops/merge.ts
··· 1 - import * as THREE from 'three' 2 - import { mergeGeometries } from 'three/examples/jsm/utils/BufferGeometryUtils.js' 3 - import { Mesh } from '../core/mesh' 1 + import { mergeMeshes } from '../core/geometry-merge' 2 + import type { Mesh } from '../core/mesh' 4 3 4 + /** Combine multiple meshes into one (attributes normalized; see `mergeMeshes`). */ 5 5 export function merge(...meshes: Mesh[]): Mesh { 6 - if (meshes.length === 0) { 7 - return new Mesh(new THREE.BufferGeometry()) 8 - } 9 - if (meshes.length === 1) { 10 - return meshes[0].clone() 11 - } 12 - 13 - let geometries = meshes.map(m => m.geometry.clone()) 14 - 15 - // Normalize indexed/non-indexed: if mixed, convert all to non-indexed 16 - const hasIndexed = geometries.some(g => g.getIndex() !== null) 17 - const hasNonIndexed = geometries.some(g => g.getIndex() === null) 18 - if (hasIndexed && hasNonIndexed) { 19 - geometries = geometries.map(g => g.getIndex() ? g.toNonIndexed() : g) 20 - } 21 - 22 - // Normalize attributes: if any geometry has an attribute, all must have it 23 - const hasColors = geometries.some(g => g.getAttribute('color')) 24 - const hasUVs = geometries.some(g => g.getAttribute('uv')) 25 - const hasNormals = geometries.some(g => g.getAttribute('normal')) 26 - 27 - for (const geo of geometries) { 28 - const vertCount = geo.getAttribute('position').count 29 - 30 - if (hasColors && !geo.getAttribute('color')) { 31 - const colors = new Float32Array(vertCount * 3) 32 - colors.fill(1) // white default 33 - geo.setAttribute('color', new THREE.BufferAttribute(colors, 3)) 34 - } 35 - 36 - if (hasUVs && !geo.getAttribute('uv')) { 37 - const uvs = new Float32Array(vertCount * 2) 38 - geo.setAttribute('uv', new THREE.BufferAttribute(uvs, 2)) 39 - } 40 - 41 - if (hasNormals && !geo.getAttribute('normal')) { 42 - geo.computeVertexNormals() 43 - } 44 - } 45 - 46 - const merged = mergeGeometries(geometries, false) 47 - if (!merged) { 48 - throw new Error('Failed to merge geometries') 49 - } 50 - return new Mesh(merged) 6 + return mergeMeshes(meshes) 51 7 }
+1 -1
src/three/index.ts
··· 1 - export { toThreeMesh, toThreeGeometry } from './to-three' 1 + export { toThreeMesh, toThreeGeometry, toThree, toThreeMaterial, type ToThreeOptions } from './to-three' 2 2 export { fromThreeGeometry } from './from-three'
+53 -1
src/three/to-three.ts
··· 1 1 import * as THREE from 'three' 2 2 import { Mesh } from '../core/mesh' 3 + import { Asset } from '../core/asset' 4 + import { parseColor } from '../core/math' 5 + import type { Material } from '../core/material' 3 6 4 - export function toThreeMesh(mesh: Mesh, options?: { 7 + /** Build a THREE material from a shapecraft Material (or sensible defaults). */ 8 + export function toThreeMaterial(m: Material | null, hasVertexColors: boolean): THREE.Material { 9 + const vertexColors = m ? !!m.vertexColors : hasVertexColors 10 + return new THREE.MeshStandardMaterial({ 11 + color: vertexColors ? new THREE.Color(0xffffff) : (m?.color !== undefined ? parseColor(m.color) : new THREE.Color(0xcccccc)), 12 + vertexColors, 13 + roughness: m?.roughness ?? 1, 14 + metalness: m?.metalness ?? 0, 15 + emissive: m?.emissive !== undefined ? parseColor(m.emissive) : new THREE.Color(0x000000), 16 + flatShading: m?.flatShading ?? true, 17 + side: m?.doubleSided ? THREE.DoubleSide : THREE.FrontSide, 18 + }) 19 + } 20 + 21 + export interface ToThreeOptions { 22 + castShadow?: boolean 23 + receiveShadow?: boolean 24 + } 25 + 26 + /** Convert an Asset (or plain Mesh) to a THREE.Object3D tree, one part per node. */ 27 + export function toThree(input: Asset | Mesh, options?: ToThreeOptions): THREE.Object3D { 28 + const obj = input instanceof Asset ? assetToObject3D(input) : toThreeMesh(input) 29 + if (options?.castShadow || options?.receiveShadow) { 30 + obj.traverse((o) => { 31 + if ((o as THREE.Mesh).isMesh) { 32 + o.castShadow = !!options.castShadow 33 + o.receiveShadow = !!options.receiveShadow 34 + } 35 + }) 36 + } 37 + return obj 38 + } 39 + 40 + function assetToObject3D(asset: Asset): THREE.Object3D { 41 + let obj: THREE.Object3D 42 + if (asset.geometry) { 43 + obj = new THREE.Mesh(asset.geometry.geometry, toThreeMaterial(asset.material, !!asset.geometry.colors)) 44 + } else { 45 + obj = new THREE.Group() 46 + } 47 + obj.name = asset.name 48 + obj.applyMatrix4(asset.transform) 49 + for (const c of asset.children) obj.add(assetToObject3D(c)) 50 + return obj 51 + } 52 + 53 + export function toThreeMesh(input: Mesh | Asset, options?: { 5 54 material?: THREE.Material 6 55 flatShading?: boolean 7 56 wireframe?: boolean ··· 9 58 metalness?: number 10 59 doubleSided?: boolean 11 60 }): THREE.Mesh { 61 + // Accept an Asset by flattening it to a single mesh (per-part materials are lost — 62 + // use toThree() for those). Lets migrated Asset generators flow through existing code. 63 + const mesh = input instanceof Asset ? input.flatten() : input 12 64 const geometry = toThreeGeometry(mesh) 13 65 const flatShading = options?.flatShading ?? true 14 66 const wireframe = options?.wireframe ?? false
+55
tests/asset.test.ts
··· 1 + import { describe, it, expect } from 'vitest' 2 + import * as THREE from 'three' 3 + import { part, group } from '../src/core/asset' 4 + import { box, sphere } from '../src/primitives' 5 + 6 + describe('Asset', () => { 7 + it('builds a tree of named parts', () => { 8 + const a = group('thing', [ 9 + part('a', box({ size: 1 })), 10 + part('b', sphere({ radius: 0.5 })), 11 + ]) 12 + expect(a.name).toBe('thing') 13 + expect(a.children).toHaveLength(2) 14 + expect(a.find('b')).not.toBeNull() 15 + expect(a.find('nope')).toBeNull() 16 + }) 17 + 18 + it('flatten merges all part geometry', () => { 19 + const b = box({ size: 1 }) 20 + const s = sphere({ radius: 0.5 }) 21 + const a = group('g', [part('a', b), part('b', s)]) 22 + expect(a.flatten().vertexCount).toBe(b.vertexCount + s.vertexCount) 23 + }) 24 + 25 + it('node transforms move world bounds without touching geometry', () => { 26 + const b = box({ size: 1 }) 27 + const a = part('b', b).translate(10, 0, 0) 28 + // The part geometry is untouched... 29 + expect(a.geometry).toBe(b) 30 + // ...but bounds reflect the transform. 31 + const c = a.bounds().getCenter(new THREE.Vector3()) 32 + expect(c.x).toBeCloseTo(10, 5) 33 + }) 34 + 35 + it('transforms are immutable', () => { 36 + const a = part('b', box({ size: 1 })) 37 + const moved = a.translate(5, 0, 0) 38 + expect(moved).not.toBe(a) 39 + expect(a.bounds().getCenter(new THREE.Vector3()).x).toBeCloseTo(0, 5) 40 + }) 41 + 42 + it('recolor replaces a named part material', () => { 43 + const a = group('g', [part('trunk', box()), part('leaf', sphere())]) 44 + const recolored = a.recolor('leaf', { color: '#00ff00' }) 45 + expect(recolored.find('leaf')!.material).toEqual({ color: '#00ff00' }) 46 + expect(recolored.find('trunk')!.material).toBeNull() 47 + }) 48 + 49 + it('sockets report world-space position', () => { 50 + const a = part('b', box()).socket('top', { position: [0, 1, 0] }).translate(0, 5, 0) 51 + const s = a.getSocket('top') 52 + expect(s).not.toBeNull() 53 + expect(s!.position[1]).toBeCloseTo(6, 5) 54 + }) 55 + })
+125 -61
webapp/src/lib/components/ParamPanel.svelte
··· 1 1 <script lang="ts"> 2 - import { Download, RotateCcw } from '@lucide/svelte'; 2 + import { ChevronDown, ChevronRight, RotateCcw, Search } from '@lucide/svelte'; 3 + import type { OptionDef } from 'shapecraft/core/schema'; 3 4 import type { GeneratorEntry } from '$lib/registry'; 4 5 import { displayValue, getPreset, resetParams, setParam, setPreset } from '$lib/editor.svelte'; 5 6 import SliderControl from './controls/SliderControl.svelte'; ··· 8 9 import ColorControl from './controls/ColorControl.svelte'; 9 10 import ColorArrayControl from './controls/ColorArrayControl.svelte'; 10 11 11 - let { entry, onexport }: { entry: GeneratorEntry; onexport: () => void } = $props(); 12 + let { entry }: { entry: GeneratorEntry } = $props(); 12 13 13 14 const presetNames = $derived(Object.keys(entry.presets)); 15 + 16 + let query = $state(''); 17 + let collapsed = $state<Record<string, boolean>>({}); 18 + 19 + interface Group { 20 + name: string; 21 + params: [string, OptionDef][]; 22 + } 23 + 24 + // Groups in order of first appearance in the schema; search filters params 25 + // by label/key and overrides collapsing so matches are always visible. 26 + const groups = $derived.by(() => { 27 + const q = query.trim().toLowerCase(); 28 + const out: Group[] = []; 29 + for (const [key, def] of Object.entries(entry.schema)) { 30 + if (q && !(def.label ?? key).toLowerCase().includes(q) && !key.toLowerCase().includes(q)) 31 + continue; 32 + const name = def.group ?? 'General'; 33 + let group = out.find((g) => g.name === name); 34 + if (!group) { 35 + group = { name, params: [] }; 36 + out.push(group); 37 + } 38 + group.params.push([key, def]); 39 + } 40 + return out; 41 + }); 42 + 43 + const searching = $derived(query.trim() !== ''); 44 + 45 + function isOpen(name: string) { 46 + return searching || !collapsed[`${entry.id}:${name}`]; 47 + } 48 + function toggle(name: string) { 49 + if (searching) return; 50 + collapsed[`${entry.id}:${name}`] = !collapsed[`${entry.id}:${name}`]; 51 + } 14 52 </script> 15 53 16 54 <aside class="flex w-84 shrink-0 flex-col border-l border-line bg-panel"> ··· 25 63 <RotateCcw size={15} /> 26 64 </button> 27 65 </div> 66 + <div 67 + class="mx-3.5 mb-3 flex h-9 items-center gap-2 rounded-[9px] border border-line2 bg-bg px-2.5" 68 + > 69 + <Search size={15} class="shrink-0 text-faint" /> 70 + <input 71 + type="text" 72 + class="w-full border-none bg-transparent p-0 text-[13px] text-ink placeholder:text-faint focus:ring-0" 73 + placeholder="Search parameters…" 74 + bind:value={query} 75 + /> 76 + </div> 28 77 {#if presetNames.length > 1} 29 - <div class="mx-3.5 mb-3.5"> 78 + <div class="mx-3.5 mb-3"> 30 79 <select 31 80 class="h-9.5 w-full rounded-[9px] border-line2 bg-panel2 text-[13px] font-semibold text-ink focus:border-accent focus:ring-0" 32 81 value={getPreset(entry.id)} ··· 38 87 </select> 39 88 </div> 40 89 {/if} 41 - <div class="flex min-h-0 flex-1 flex-col gap-3.5 overflow-y-auto px-4 pb-4"> 42 - {#each Object.entries(entry.schema) as [key, def] (entry.id + ':' + key)} 43 - {#if def.type === 'range'} 44 - <SliderControl 45 - label={def.label ?? key} 46 - value={displayValue(entry, key) as number | [number, number]} 47 - min={def.min} 48 - max={def.max} 49 - step={def.step ?? 0.01} 50 - onchange={(v) => setParam(entry.id, key, v)} 51 - /> 52 - {:else if def.type === 'integer'} 53 - <SliderControl 54 - label={def.label ?? key} 55 - value={displayValue(entry, key) as number | [number, number]} 56 - min={def.min} 57 - max={def.max} 58 - step={1} 59 - onchange={(v) => setParam(entry.id, key, v)} 60 - /> 61 - {:else if def.type === 'boolean'} 62 - <ToggleControl 63 - label={def.label ?? key} 64 - value={displayValue(entry, key) as boolean} 65 - onchange={(v) => setParam(entry.id, key, v)} 66 - /> 67 - {:else if def.type === 'select'} 68 - <SelectControl 69 - label={def.label ?? key} 70 - value={displayValue(entry, key) as string} 71 - options={def.options} 72 - onchange={(v) => setParam(entry.id, key, v)} 73 - /> 74 - {:else if def.type === 'color'} 75 - <ColorControl 76 - label={def.label ?? key} 77 - value={displayValue(entry, key) as string} 78 - onchange={(v) => setParam(entry.id, key, v)} 79 - /> 80 - {:else if def.type === 'color-array'} 81 - <ColorArrayControl 82 - label={def.label ?? key} 83 - colors={displayValue(entry, key) as string[]} 84 - min={def.min} 85 - max={def.max} 86 - onchange={(v) => setParam(entry.id, key, v)} 87 - /> 88 - {/if} 90 + <div class="min-h-0 flex-1 overflow-y-auto px-1.5 pb-3"> 91 + {#each groups as group, gi (entry.id + ':' + group.name)} 92 + <div class={gi > 0 ? 'border-t border-line' : ''}> 93 + <button 94 + type="button" 95 + class="flex w-full items-center gap-2.5 px-2.5 py-3 text-left" 96 + onclick={() => toggle(group.name)} 97 + > 98 + <span class="text-faint"> 99 + {#if isOpen(group.name)} 100 + <ChevronDown size={15} /> 101 + {:else} 102 + <ChevronRight size={15} /> 103 + {/if} 104 + </span> 105 + <span class="flex-1 text-[13px] font-bold">{group.name}</span> 106 + <span class="mono text-[11px] text-faint">{group.params.length}</span> 107 + </button> 108 + {#if isOpen(group.name)} 109 + <div class="flex flex-col gap-[13px] px-2.5 pt-0.5 pb-3.5"> 110 + {#each group.params as [key, def] (entry.id + ':' + key)} 111 + {#if def.type === 'range'} 112 + <SliderControl 113 + label={def.label ?? key} 114 + value={displayValue(entry, key) as number | [number, number]} 115 + min={def.min} 116 + max={def.max} 117 + step={def.step ?? 0.01} 118 + onchange={(v) => setParam(entry.id, key, v)} 119 + /> 120 + {:else if def.type === 'integer'} 121 + <SliderControl 122 + label={def.label ?? key} 123 + value={displayValue(entry, key) as number | [number, number]} 124 + min={def.min} 125 + max={def.max} 126 + step={1} 127 + onchange={(v) => setParam(entry.id, key, v)} 128 + /> 129 + {:else if def.type === 'boolean'} 130 + <ToggleControl 131 + label={def.label ?? key} 132 + value={displayValue(entry, key) as boolean} 133 + onchange={(v) => setParam(entry.id, key, v)} 134 + /> 135 + {:else if def.type === 'select'} 136 + <SelectControl 137 + label={def.label ?? key} 138 + value={displayValue(entry, key) as string} 139 + options={def.options} 140 + onchange={(v) => setParam(entry.id, key, v)} 141 + /> 142 + {:else if def.type === 'color'} 143 + <ColorControl 144 + label={def.label ?? key} 145 + value={displayValue(entry, key) as string} 146 + onchange={(v) => setParam(entry.id, key, v)} 147 + /> 148 + {:else if def.type === 'color-array'} 149 + <ColorArrayControl 150 + label={def.label ?? key} 151 + colors={displayValue(entry, key) as string[]} 152 + min={def.min} 153 + max={def.max} 154 + onchange={(v) => setParam(entry.id, key, v)} 155 + /> 156 + {/if} 157 + {/each} 158 + </div> 159 + {/if} 160 + </div> 161 + {:else} 162 + <div class="px-3 py-6 text-center text-[13px] text-faint">No matching parameters</div> 89 163 {/each} 90 - </div> 91 - <div class="flex flex-col gap-[7px] border-t border-line px-3.5 py-3"> 92 - <button 93 - type="button" 94 - class="flex h-10 w-full items-center justify-center gap-1.5 rounded-[9px] bg-accent text-[13px] font-bold text-accent-ink hover:bg-[#b6f37e]" 95 - onclick={onexport} 96 - > 97 - <Download size={17} />Export GLB 98 - </button> 99 - <div class="mono text-center text-[11px] text-faint">binary glTF · vertex colors</div> 100 164 </div> 101 165 </aside>
+6 -22
webapp/src/lib/components/Viewport.svelte
··· 1 1 <script lang="ts"> 2 2 import type * as THREE from 'three'; 3 3 import { Canvas } from '@threlte/core'; 4 - import { Box, Dices, Grid3x3, TreeDeciduous, Sprout, Mountain } from '@lucide/svelte'; 4 + import { Box, Grid3x3, TreeDeciduous, Sprout, Mountain } from '@lucide/svelte'; 5 5 import type { GeneratorEntry } from '$lib/registry'; 6 6 import Scene from './Scene.svelte'; 7 7 ··· 10 10 entry, 11 11 seed, 12 12 tris, 13 - error, 14 - ondice 13 + error 15 14 }: { 16 15 model: THREE.Mesh | null; 17 16 entry: GeneratorEntry; 18 17 seed: number | undefined; 19 18 tris: number; 20 19 error: string | null; 21 - ondice: () => void; 22 20 } = $props(); 23 21 24 22 let showGrid = $state(false); ··· 73 71 </button> 74 72 </div> 75 73 76 - <!-- status --> 77 - <div class="{float} bottom-4 left-4 flex items-center gap-2.5 px-3 py-[7px]"> 78 - {#if error} 74 + {#if error} 75 + <div class="{float} bottom-4 left-4 flex items-center gap-2.5 px-3 py-[7px]"> 79 76 <span class="size-1.5 rounded-full bg-red-400"></span> 80 77 <span class="mono text-[11px] text-red-300">{error}</span> 81 - {:else} 82 - <span class="size-1.5 rounded-full bg-accent"></span> 83 - <span class="mono text-[11px] text-white/80">{tris.toLocaleString()} tris · GLB ready</span> 84 - {/if} 85 - </div> 86 - 87 - <!-- randomize --> 88 - <button 89 - type="button" 90 - class="absolute bottom-4 left-1/2 grid size-10.5 -translate-x-1/2 place-items-center rounded-[10px] bg-accent text-accent-ink shadow-lg hover:bg-[#b6f37e]" 91 - title="Randomize seed" 92 - onclick={ondice} 93 - > 94 - <Dices size={20} /> 95 - </button> 78 + </div> 79 + {/if} 96 80 </div>
+15 -1
webapp/src/lib/registry.ts
··· 1 1 import type { OptionSchema } from 'shapecraft/core/schema'; 2 2 import type { Mesh } from 'shapecraft/core/mesh'; 3 + import type { Asset } from 'shapecraft/core/asset'; 3 4 import { 4 5 tree, 5 6 treeSchema, ··· 28 29 leafyTree, 29 30 leafyTreeSchema, 30 31 leafyTreePresets, 32 + mushroom, 33 + mushroomSchema, 34 + mushroomPresets, 31 35 rock, 32 36 rockSchema, 33 37 rockPresets, ··· 56 60 id: string; 57 61 label: string; 58 62 category: CategoryId; 59 - gen: (opts: Record<string, unknown>) => Mesh; 63 + // Generators are migrating from single Mesh to multi-part Asset; toThreeMesh handles both. 64 + gen: (opts: Record<string, unknown>) => Mesh | Asset; 60 65 schema: OptionSchema; 61 66 presets: Record<string, Record<string, unknown>>; 62 67 /** Rough camera distance that frames the default model nicely */ ··· 144 149 schema: flowerSchema, 145 150 presets: flowerPresets, 146 151 viewDistance: 2.5 152 + }, 153 + { 154 + id: 'mushroom', 155 + label: 'Mushroom', 156 + category: 'plants', 157 + gen: mushroom, 158 + schema: mushroomSchema, 159 + presets: mushroomPresets, 160 + viewDistance: 1.6 147 161 }, 148 162 { 149 163 id: 'rock',
+2 -2
webapp/src/routes/edit/[id]/+page.svelte
··· 42 42 <div class="flex min-h-0 flex-1"> 43 43 <Rail active={entry.category} /> 44 44 <TypeList {entry} /> 45 - <Viewport {model} {entry} {seed} {tris} error={result.error} ondice={dice} /> 46 - <ParamPanel {entry} onexport={doExport} /> 45 + <Viewport {model} {entry} {seed} {tris} error={result.error} /> 46 + <ParamPanel {entry} /> 47 47 </div> 48 48 </div>