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