[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

+1170 -753
+701
plan-v0.md
··· 1 + # Shapecraft — Implementation Plan 2 + 3 + ## Overview 4 + 5 + A procedural 3D model generation library for the browser. Functional-first API, Three.js under the hood, composable generators and modifiers, first-class noise support via an existing UberNoise library. 6 + 7 + **Key design principles:** 8 + - Generators are just functions that return meshes. No class hierarchies, no registration. 9 + - Composition = calling functions inside functions. 10 + - Immutable by default — every transform/modifier returns a new Mesh. 11 + - Three.js is the internal engine but the API should feel library-agnostic where possible. 12 + - The package name is "shapecraft" but avoid hardcoding it deeply — keep it easy to rename. 13 + 14 + --- 15 + 16 + ## Tech Stack 17 + 18 + - **Language:** TypeScript (strict) 19 + - **3D Engine:** Three.js (peer dependency — user provides it) 20 + - **Noise:** Existing UberNoise library (bundled, see `uber-noise.ts` and its deps `simplex-noise/` and `alea/`) 21 + - **Build:** Vite (library mode for the package, dev server for demos) 22 + - **Test:** Vitest 23 + - **Package:** Single flat package, subpath exports for tree-shaking (`shapecraft`, `shapecraft/noise`, `shapecraft/three`, etc.) 24 + 25 + --- 26 + 27 + ## Project Structure 28 + 29 + ``` 30 + shapecraft/ 31 + ├── package.json 32 + ├── tsconfig.json 33 + ├── vite.config.ts 34 + ├── vitest.config.ts 35 + ├── index.ts # Main barrel export 36 + ├── src/ 37 + │ ├── mesh.ts # Core Mesh class 38 + │ ├── types.ts # Shared types (Vec3, Color, etc.) 39 + │ ├── math.ts # Vec3/Mat4 helpers (thin wrappers over THREE) 40 + │ ├── primitives/ 41 + │ │ ├── index.ts 42 + │ │ ├── box.ts 43 + │ │ ├── sphere.ts 44 + │ │ ├── cylinder.ts 45 + │ │ ├── plane.ts 46 + │ │ ├── torus.ts 47 + │ │ └── cone.ts 48 + │ ├── ops/ 49 + │ │ ├── index.ts 50 + │ │ ├── merge.ts # Combine multiple meshes into one 51 + │ │ ├── clone.ts # Deep clone a mesh 52 + │ │ └── center.ts # Recenter mesh to origin 53 + │ ├── modifiers/ 54 + │ │ ├── index.ts 55 + │ │ ├── twist.ts 56 + │ │ ├── bend.ts 57 + │ │ ├── taper.ts 58 + │ │ ├── lattice.ts # Simple lattice deform 59 + │ │ └── smooth.ts # Laplacian smooth 60 + │ ├── noise/ 61 + │ │ ├── index.ts # Re-exports UberNoise + helpers 62 + │ │ ├── uber-noise.ts # Existing UberNoise (verbatim, with imports fixed) 63 + │ │ ├── simplex-noise/ # Existing simplex-noise dependency 64 + │ │ │ └── simplex-noise.ts 65 + │ │ ├── alea/ # Existing alea dependency 66 + │ │ │ └── alea.ts 67 + │ │ └── helpers.ts # Convenience: fbm(), ridged(), etc. that return UberNoise instances 68 + │ ├── color/ 69 + │ │ ├── index.ts 70 + │ │ ├── vertex-color.ts # Apply vertex colors (flat or procedural fn) 71 + │ │ ├── face-color.ts # Apply per-face colors 72 + │ │ ├── gradient.ts # Height/angle gradient helpers 73 + │ │ └── utils.ts # Color parsing, lerp, hex<->rgb 74 + │ ├── uv/ 75 + │ │ ├── index.ts 76 + │ │ ├── projections.ts # Box, planar, cylindrical, spherical UV projection 77 + │ │ └── textures.ts # Procedural texture generators (checkerboard, wood, etc.) — STUB for v0 78 + │ ├── three/ 79 + │ │ ├── index.ts 80 + │ │ ├── to-three.ts # Mesh → THREE.Mesh / THREE.BufferGeometry 81 + │ │ └── from-three.ts # THREE.BufferGeometry → Mesh 82 + │ └── worker/ 83 + │ ├── index.ts 84 + │ └── pool.ts # STUB for v0 — types + placeholder 85 + ├── demo/ 86 + │ ├── index.html 87 + │ ├── main.ts # Vite dev entry — renders demo scene 88 + │ └── generators/ 89 + │ ├── chair.ts # Example generator: a simple chair 90 + │ ├── table.ts # Example generator: table with chairs 91 + │ └── terrain.ts # Example generator: noise terrain 92 + └── tests/ 93 + ├── mesh.test.ts 94 + ├── primitives.test.ts 95 + ├── ops.test.ts 96 + ├── modifiers.test.ts 97 + └── noise.test.ts 98 + ``` 99 + 100 + --- 101 + 102 + ## Phase 1: Foundation 103 + 104 + ### 1.1 — Project setup 105 + 106 + - Init `package.json` with: 107 + - `name: "shapecraft"` 108 + - `type: "module"` 109 + - `peerDependencies: { "three": ">=0.150.0" }` 110 + - `devDependencies: { "three": "^0.170.0", "@types/three": "...", "typescript": "...", "vite": "...", "vitest": "..." }` 111 + - `exports` field with subpath exports: 112 + ```json 113 + { 114 + ".": "./src/index.ts", 115 + "./noise": "./src/noise/index.ts", 116 + "./three": "./src/three/index.ts", 117 + "./modifiers": "./src/modifiers/index.ts", 118 + "./ops": "./src/ops/index.ts", 119 + "./color": "./src/color/index.ts", 120 + "./uv": "./src/uv/index.ts" 121 + } 122 + ``` 123 + - Scripts: `"dev": "vite demo"`, `"build": "vite build"`, `"test": "vitest"` 124 + - `tsconfig.json`: strict, ESNext, paths alias `@/*` → `./src/*` 125 + - `vite.config.ts`: library mode config (entry `src/index.ts`, external `three`) 126 + - `vitest.config.ts`: basic setup 127 + 128 + ### 1.2 — Types (`src/types.ts`) 129 + 130 + ```ts 131 + export type Vec2 = [number, number] 132 + export type Vec3 = [number, number, number] 133 + export type Vec4 = [number, number, number, number] 134 + export type ColorInput = string | Vec3 | Vec4 | number // hex string, rgb tuple, rgba tuple, or 0xRRGGBB 135 + export type ColorFn = (position: Vec3, normal: Vec3, index: number) => ColorInput 136 + export type DisplaceFn = (position: Vec3, normal: Vec3, uv: Vec2 | null, index: number) => number 137 + export type WarpFn = (position: Vec3, index: number) => Vec3 138 + export type NoiseLike = { get(x: number, y?: number, z?: number): number } 139 + ``` 140 + 141 + ### 1.3 — Core Mesh class (`src/mesh.ts`) 142 + 143 + This is the most important file. The Mesh wraps a `THREE.BufferGeometry` internally. 144 + 145 + ```ts 146 + import * as THREE from 'three' 147 + 148 + class Mesh { 149 + /** Internal geometry — users CAN access this but the API doesn't require it */ 150 + readonly geometry: THREE.BufferGeometry 151 + 152 + constructor(geometry: THREE.BufferGeometry) 153 + 154 + // --- Accessors (read from geometry attributes) --- 155 + get positions(): Float32Array 156 + get indices(): Uint32Array | Uint16Array | null 157 + get normals(): Float32Array | null 158 + get uvs(): Float32Array | null 159 + get colors(): Float32Array | null 160 + get vertexCount(): number 161 + get faceCount(): number 162 + get boundingBox(): THREE.Box3 163 + 164 + // --- Transforms (all return new Mesh, geometry is cloned) --- 165 + translate(x: number, y: number, z: number): Mesh 166 + rotate(axis: Vec3 | 'x' | 'y' | 'z', angle: number): Mesh 167 + rotateX(angle: number): Mesh 168 + rotateY(angle: number): Mesh 169 + rotateZ(angle: number): Mesh 170 + scale(x: number, y?: number, z?: number): Mesh 171 + transform(matrix: THREE.Matrix4): Mesh 172 + 173 + // --- Modifiers (return new Mesh) --- 174 + displace(fn: DisplaceFn): Mesh 175 + displaceNoise(noise: NoiseLike, amplitude?: number): Mesh 176 + warp(fn: WarpFn): Mesh 177 + subdivide(iterations?: number): Mesh // uses THREE's subdivision if available, else loop subdivision 178 + computeNormals(): Mesh 179 + 180 + // --- Coloring (return new Mesh) --- 181 + vertexColor(color: ColorInput | ColorFn): Mesh 182 + faceColor(fn: (centroid: Vec3, normal: Vec3, faceIndex: number) => ColorInput): Mesh 183 + 184 + // --- UV (return new Mesh) --- 185 + computeUVs(projection?: 'box' | 'planar' | 'cylindrical' | 'spherical'): Mesh 186 + 187 + // --- Utility --- 188 + center(): Mesh 189 + clone(): Mesh 190 + 191 + // --- Serialization (for future worker support) --- 192 + serialize(): ArrayBuffer 193 + static deserialize(buffer: ArrayBuffer): Mesh 194 + } 195 + ``` 196 + 197 + **Implementation notes:** 198 + - Every method that returns a new Mesh should: clone the geometry, apply the operation to the clone, return `new Mesh(clone)`. 199 + - Use a private helper `cloneGeometry()` that does `geometry.clone()` and ensures all attributes are properly copied. 200 + - `displace(fn)`: iterate vertices, call fn with position + normal + uv, move vertex along its normal by the returned amount. Recompute normals after. 201 + - `warp(fn)`: iterate vertices, replace position with fn result. Recompute normals after. 202 + - `vertexColor(color | fn)`: if color, set all vertex colors to that color. If fn, iterate vertices and call fn. Creates/updates a `color` attribute (Float32Array, itemSize 3). 203 + - `faceColor(fn)`: iterate faces (3 indices per face), compute centroid and face normal, call fn, set all 3 vertices of that face to the returned color. **Important:** this requires un-indexing the geometry first (so vertices aren't shared between faces). Use `geometry.toNonIndexed()` before applying. 204 + - `serialize()`/`deserialize()`: pack all typed arrays into a single ArrayBuffer with a simple header (attribute count, sizes). This is for future worker support. 205 + 206 + ### 1.4 — Math utilities (`src/math.ts`) 207 + 208 + Thin wrappers — don't reimplement, use THREE internally: 209 + 210 + ```ts 211 + import * as THREE from 'three' 212 + 213 + export function vec3(x: number, y: number, z: number): THREE.Vector3 214 + export function mat4(): THREE.Matrix4 215 + export function makeTranslation(x: number, y: number, z: number): THREE.Matrix4 216 + export function makeRotation(axis: Vec3 | 'x' | 'y' | 'z', angle: number): THREE.Matrix4 217 + export function makeScale(x: number, y: number, z: number): THREE.Matrix4 218 + export function parseColor(input: ColorInput): THREE.Color 219 + ``` 220 + 221 + --- 222 + 223 + ## Phase 2: Primitives 224 + 225 + All primitives are **functions** that return a `Mesh`. They use Three.js geometry constructors internally. 226 + 227 + ### `src/primitives/box.ts` 228 + ```ts 229 + export interface BoxOptions { 230 + width?: number // default 1 231 + height?: number // default 1 232 + depth?: number // default 1 233 + // OR shorthand: 234 + size?: Vec3 | number // overrides width/height/depth 235 + widthSegments?: number 236 + heightSegments?: number 237 + depthSegments?: number 238 + } 239 + export function box(options?: BoxOptions): Mesh 240 + ``` 241 + Internally: `new THREE.BoxGeometry(...)` → wrap in Mesh. 242 + 243 + ### `src/primitives/sphere.ts` 244 + ```ts 245 + export interface SphereOptions { 246 + radius?: number // default 0.5 247 + widthSegments?: number // default 16 248 + heightSegments?: number // default 12 249 + } 250 + export function sphere(options?: SphereOptions): Mesh 251 + ``` 252 + 253 + ### `src/primitives/cylinder.ts` 254 + ```ts 255 + export interface CylinderOptions { 256 + radius?: number // default 0.5 (sets both top and bottom) 257 + radiusTop?: number // overrides radius for top 258 + radiusBottom?: number // overrides radius for bottom 259 + height?: number // default 1 260 + segments?: number // default 16 261 + } 262 + export function cylinder(options?: CylinderOptions): Mesh 263 + ``` 264 + 265 + ### `src/primitives/plane.ts` 266 + ```ts 267 + export interface PlaneOptions { 268 + width?: number // default 1 269 + height?: number // default 1 270 + // OR shorthand: 271 + size?: number | Vec2 // overrides width/height 272 + widthSegments?: number // default 1 273 + heightSegments?: number // default 1 274 + // OR shorthand: 275 + segments?: number | Vec2 // overrides both segment counts 276 + } 277 + export function plane(options?: PlaneOptions): Mesh 278 + ``` 279 + **Note:** Three.js PlaneGeometry creates a vertical plane (XY). We should rotate it to be horizontal (XZ) by default since that's more natural for terrain/floors. Document this clearly. 280 + 281 + ### `src/primitives/cone.ts` 282 + ```ts 283 + export interface ConeOptions { 284 + radius?: number 285 + height?: number 286 + segments?: number 287 + } 288 + export function cone(options?: ConeOptions): Mesh 289 + ``` 290 + 291 + ### `src/primitives/torus.ts` 292 + ```ts 293 + export interface TorusOptions { 294 + radius?: number 295 + tube?: number 296 + radialSegments?: number 297 + tubularSegments?: number 298 + } 299 + export function torus(options?: TorusOptions): Mesh 300 + ``` 301 + 302 + ### `src/primitives/index.ts` 303 + Re-export all primitives. 304 + 305 + --- 306 + 307 + ## Phase 3: Operations 308 + 309 + ### `src/ops/merge.ts` 310 + ```ts 311 + export function merge(...meshes: Mesh[]): Mesh 312 + ``` 313 + Implementation: use `THREE.BufferGeometryUtils.mergeGeometries()` (import from `three/addons/utils/BufferGeometryUtils.js`). Handle the case where some meshes have vertex colors and some don't — fill missing colors with white. Same for UVs — fill missing with zeros. 314 + 315 + **Important:** `mergeGeometries` requires all geometries to have the same set of attributes. Before merging, normalize all geometries to have the same attribute set. If any mesh in the set has colors, ensure all do. If any has UVs, ensure all do. 316 + 317 + ### `src/ops/center.ts` 318 + ```ts 319 + export function center(mesh: Mesh): Mesh 320 + ``` 321 + Compute bounding box center, translate by negative center. 322 + 323 + ### `src/ops/clone.ts` 324 + ```ts 325 + export function clone(mesh: Mesh): Mesh // just calls mesh.clone() 326 + ``` 327 + 328 + --- 329 + 330 + ## Phase 4: Modifiers 331 + 332 + Modifiers are standalone functions that return `WarpFn` or can be applied directly. Two patterns: 333 + 334 + **Pattern A — warp functions** (for use with `mesh.warp(fn)`): 335 + ```ts 336 + // Returns a WarpFn: (position: Vec3) => Vec3 337 + export function twist(options: { axis?: 'x' | 'y' | 'z', amount: number }): WarpFn 338 + export function bend(options: { axis?: 'x' | 'y' | 'z', amount: number }): WarpFn 339 + export function taper(options: { axis?: 'x' | 'y' | 'z', curve?: (t: number) => number }): WarpFn 340 + ``` 341 + 342 + **Pattern B — mesh-in mesh-out** (for complex operations): 343 + ```ts 344 + export function smooth(mesh: Mesh, iterations?: number): Mesh 345 + export function subdivide(mesh: Mesh, iterations?: number): Mesh 346 + ``` 347 + 348 + ### `src/modifiers/twist.ts` 349 + Rotate vertices around an axis proportional to their position along that axis. 350 + ``` 351 + angle = position[axis] * amount 352 + rotate position around axis by angle 353 + ``` 354 + 355 + ### `src/modifiers/bend.ts` 356 + Curve vertices along an axis. Map position along axis to an arc. 357 + 358 + ### `src/modifiers/taper.ts` 359 + Scale vertices perpendicular to an axis based on a curve function of their position along the axis. 360 + ``` 361 + t = normalize position along axis to [0, 1] 362 + scale = curve(t) 363 + position[perpendicular axes] *= scale 364 + ``` 365 + 366 + ### `src/modifiers/smooth.ts` 367 + Laplacian smoothing: for each vertex, move it toward the average of its neighbors. Requires building an adjacency map from the index buffer. 368 + 369 + ### `src/modifiers/lattice.ts` 370 + STUB for v0. Just export the type and a placeholder that returns the input unchanged. 371 + 372 + --- 373 + 374 + ## Phase 5: Noise Integration 375 + 376 + ### Copy existing code 377 + Copy the user's existing noise implementation into `src/noise/`: 378 + - `src/noise/uber-noise.ts` — the UberNoise class (adjust imports to use relative paths within the package) 379 + - `src/noise/simplex-noise/simplex-noise.ts` — existing simplex noise 380 + - `src/noise/alea/alea.ts` — existing alea PRNG 381 + 382 + **Important:** Remove the `globalThis` assignments at the bottom of `uber-noise.ts`. We don't want to pollute globals — everything is imported. 383 + 384 + ### `src/noise/helpers.ts` 385 + Convenience factory functions that create pre-configured UberNoise instances: 386 + 387 + ```ts 388 + import { UberNoise, type NoiseOptions } from './uber-noise' 389 + 390 + /** Basic simplex noise */ 391 + export function simplex(options?: Partial<NoiseOptions>): UberNoise 392 + 393 + /** FBM (fractional Brownian motion) with sensible defaults */ 394 + export function fbm(options?: Partial<NoiseOptions> & { octaves?: number }): UberNoise 395 + // Default: octaves 4, lacunarity 2, gain 0.5 396 + 397 + /** Ridged noise */ 398 + export function ridged(options?: Partial<NoiseOptions>): UberNoise 399 + // Sets sharpness: -1 400 + 401 + /** Billowed noise */ 402 + export function billowed(options?: Partial<NoiseOptions>): UberNoise 403 + // Sets sharpness: 1 404 + 405 + /** Stepped/terraced noise */ 406 + export function stepped(steps: number, options?: Partial<NoiseOptions>): UberNoise 407 + // Sets steps 408 + 409 + /** Warped noise */ 410 + export function warped(amount: number, options?: Partial<NoiseOptions>): UberNoise 411 + // Sets warp amount 412 + ``` 413 + 414 + ### `src/noise/index.ts` 415 + ```ts 416 + export { UberNoise, type NoiseOptions } from './uber-noise' 417 + export { simplex, fbm, ridged, billowed, stepped, warped } from './helpers' 418 + ``` 419 + 420 + ### Integration with Mesh 421 + 422 + `mesh.displaceNoise(noise, amplitude)` should accept an `UberNoise` instance (or anything with a `.get(x, y, z)` method): 423 + 424 + ```ts 425 + displaceNoise(noise: NoiseLike, amplitude: number = 1): Mesh { 426 + return this.displace((pos, normal) => { 427 + return noise.get(pos[0], pos[1], pos[2]) * amplitude 428 + }) 429 + } 430 + ``` 431 + 432 + This keeps the coupling loose — any object with `get(x, y?, z?)` works. 433 + 434 + --- 435 + 436 + ## Phase 6: Color & UV 437 + 438 + ### `src/color/utils.ts` 439 + ```ts 440 + export function parseColor(input: ColorInput): [number, number, number] // rgb 0-1 441 + export function lerpColor(a: ColorInput, b: ColorInput, t: number): [number, number, number] 442 + export function hexToRgb(hex: string): [number, number, number] 443 + export function rgbToHex(r: number, g: number, b: number): string 444 + ``` 445 + Use `THREE.Color` internally for parsing. 446 + 447 + ### `src/color/gradient.ts` 448 + ```ts 449 + export type GradientStop = [number, ColorInput] // [threshold, color] 450 + 451 + /** Create a function that maps a value to a color based on gradient stops */ 452 + export function gradient(stops: GradientStop[]): (value: number) => [number, number, number] 453 + 454 + /** Shorthand for height-based gradient (maps y position to color) */ 455 + export function heightGradient(stops: GradientStop[]): ColorFn 456 + ``` 457 + 458 + ### `src/color/vertex-color.ts` 459 + Implementation of `Mesh.vertexColor()`: 460 + - If given a flat color, set all vertex colors to that color. 461 + - If given a function, iterate all vertices, call the function with (position, normal, vertexIndex), set the color attribute. 462 + 463 + ### `src/color/face-color.ts` 464 + Implementation of `Mesh.faceColor()`: 465 + - Call `geometry.toNonIndexed()` first (un-share vertices). 466 + - Iterate face by face (every 3 vertices), compute centroid and normal, call fn, set all 3 vertex colors. 467 + 468 + ### `src/uv/projections.ts` 469 + ```ts 470 + export function projectUVs(geometry: THREE.BufferGeometry, mode: 'box' | 'planar' | 'cylindrical' | 'spherical'): void 471 + ``` 472 + Modifies geometry in place (called on a clone inside `Mesh.computeUVs()`). 473 + 474 + - **planar**: project from Y axis down onto XZ plane. `u = x`, `v = z` (normalized to bounding box). 475 + - **box**: tri-planar — pick projection axis per face based on face normal, project from that axis. 476 + - **cylindrical**: `u = atan2(z, x) / (2π)`, `v = y` (normalized). 477 + - **spherical**: `u = atan2(z, x) / (2π)`, `v = acos(y/r) / π`. 478 + 479 + ### `src/uv/textures.ts` 480 + STUB for v0. Export types and a couple basic functions: 481 + ```ts 482 + export function checkerboard(size?: number): (u: number, v: number) => [number, number, number] 483 + ``` 484 + Full procedural texture system is post-v0. 485 + 486 + --- 487 + 488 + ## Phase 7: Three.js Bridge 489 + 490 + ### `src/three/to-three.ts` 491 + ```ts 492 + import * as THREE from 'three' 493 + import type { Mesh } from '../mesh' 494 + 495 + /** Convert a shapecraft Mesh to a THREE.Mesh ready to add to a scene */ 496 + export function toThreeMesh(mesh: Mesh, options?: { 497 + material?: THREE.Material 498 + flatShading?: boolean // default true for low-poly look 499 + wireframe?: boolean 500 + }): THREE.Mesh 501 + 502 + /** Get just the geometry (if user wants to provide their own material) */ 503 + export function toThreeGeometry(mesh: Mesh): THREE.BufferGeometry 504 + ``` 505 + 506 + `toThreeMesh` implementation: 507 + - If mesh has vertex colors, use `THREE.MeshStandardMaterial({ vertexColors: true, flatShading })`. 508 + - If no vertex colors, use `THREE.MeshStandardMaterial({ color: 0xcccccc, flatShading })`. 509 + - If wireframe requested, set `material.wireframe = true`. 510 + - The geometry is already a `THREE.BufferGeometry` internally, so just reference it (or clone if immutability is desired). 511 + 512 + ### `src/three/from-three.ts` 513 + ```ts 514 + /** Import an existing Three.js geometry into shapecraft */ 515 + export function fromThreeGeometry(geometry: THREE.BufferGeometry): Mesh 516 + ``` 517 + 518 + --- 519 + 520 + ## Phase 8: Worker Support (STUB) 521 + 522 + ### `src/worker/pool.ts` 523 + For v0, just define the interface and a simple synchronous fallback: 524 + 525 + ```ts 526 + export interface WorkerPoolOptions { 527 + maxWorkers?: number 528 + } 529 + 530 + export class WorkerPool { 531 + constructor(options?: WorkerPoolOptions) 532 + 533 + /** Run a generator function in a worker. For v0, runs synchronously. */ 534 + async run<T>(fn: (...args: any[]) => Mesh, ...args: any[]): Promise<Mesh> 535 + 536 + /** Batch run multiple generators. For v0, runs sequentially. */ 537 + async batch(tasks: Array<[Function, ...any[]]>): Promise<Mesh[]> 538 + 539 + dispose(): void 540 + } 541 + ``` 542 + 543 + The v0 implementation just calls the functions directly. Real worker support comes later and will use `Mesh.serialize()`/`deserialize()` with `Transferable`. 544 + 545 + --- 546 + 547 + ## Phase 9: Demo 548 + 549 + ### `demo/index.html` 550 + Basic HTML shell that loads `demo/main.ts` via Vite. 551 + 552 + ### `demo/main.ts` 553 + Sets up a Three.js scene with: 554 + - Renderer, camera, controls (OrbitControls) 555 + - Ambient light + directional light 556 + - Calls all three demo generators 557 + - Adds them to the scene 558 + - Animation loop 559 + 560 + ### `demo/generators/chair.ts` 561 + ```ts 562 + export function chair(options?: { seatHeight?: number, legRadius?: number }): Mesh 563 + ``` 564 + A simple 4-legged chair: 565 + - Box for seat 566 + - 4 cylinders for legs 567 + - Box for back 568 + - Vertex colored brown tones 569 + - Returns merged mesh 570 + 571 + ### `demo/generators/table.ts` 572 + ```ts 573 + export function diningSet(options?: { chairs?: number, tableRadius?: number }): Mesh 574 + ``` 575 + - Cylinder for table top 576 + - 4 cylinder legs 577 + - N chairs arranged in a circle using the `chair()` generator 578 + - Demonstrates composition 579 + 580 + ### `demo/generators/terrain.ts` 581 + ```ts 582 + export function terrain(options?: { size?: number, segments?: number, seed?: number }): Mesh 583 + ``` 584 + - Large subdivided plane 585 + - Displaced with UberNoise (fbm, maybe ridged mix) 586 + - Height-based vertex coloring (green → brown → gray → white) 587 + - Demonstrates noise integration 588 + 589 + --- 590 + 591 + ## Phase 10: Tests 592 + 593 + ### `tests/mesh.test.ts` 594 + - Construction from geometry 595 + - Transforms: translate, rotate, scale produce correct vertex positions 596 + - Immutability: original mesh unchanged after transform 597 + - `vertexCount` and `faceCount` correct 598 + - `clone()` produces independent copy 599 + 600 + ### `tests/primitives.test.ts` 601 + - Each primitive returns a Mesh 602 + - Vertex counts are correct for given parameters 603 + - Default options produce reasonable geometry 604 + - `size` shorthand works for box and plane 605 + 606 + ### `tests/ops.test.ts` 607 + - `merge()` combines vertex counts correctly 608 + - `merge()` handles mixed color/no-color meshes (fills missing with white) 609 + - `center()` puts bounding box center at origin 610 + 611 + ### `tests/modifiers.test.ts` 612 + - `twist()` modifies positions (not identity) 613 + - `taper()` scales vertices correctly at extremes 614 + - `displace()` moves vertices along normals 615 + - `displaceNoise()` produces non-zero displacement 616 + 617 + ### `tests/noise.test.ts` 618 + - UberNoise produces values in expected range 619 + - `fbm()` helper returns configured UberNoise 620 + - `ridged()` produces values with expected characteristics 621 + - Seeded noise is deterministic 622 + 623 + --- 624 + 625 + ## Implementation Order for Claude Code 626 + 627 + Follow this order. Each step should be completable and testable before moving to the next. 628 + 629 + 1. **Project scaffolding** — `package.json`, `tsconfig.json`, `vite.config.ts`, `vitest.config.ts`, directory structure, install deps. 630 + 631 + 2. **Types + Math** — `src/types.ts`, `src/math.ts`. 632 + 633 + 3. **Core Mesh class** — `src/mesh.ts`. Start with constructor, accessors, transforms (`translate`, `rotate`, `scale`, `transform`), `clone()`, `center()`, `computeNormals()`. Write `tests/mesh.test.ts` alongside. 634 + 635 + 4. **Primitives** — All 6 primitives. Write `tests/primitives.test.ts`. At this point you can already do `box().translate(1,0,0)`. 636 + 637 + 5. **Merge operation** — `src/ops/merge.ts` + `center.ts` + `clone.ts`. Write `tests/ops.test.ts`. Now you can compose: `merge(box().translate(-1,0,0), sphere().translate(1,0,0))`. 638 + 639 + 6. **Noise integration** — Copy UberNoise + deps into `src/noise/`, fix imports, strip globals, write `src/noise/helpers.ts`, write `src/noise/index.ts`. Write `tests/noise.test.ts`. 640 + 641 + 7. **Displace + warp on Mesh** — Implement `mesh.displace()`, `mesh.displaceNoise()`, `mesh.warp()`. These depend on noise being available for testing. 642 + 643 + 8. **Modifiers** — `twist`, `bend`, `taper`, `smooth` (lattice as stub). Write `tests/modifiers.test.ts`. 644 + 645 + 9. **Color system** — `src/color/*`. Implement `mesh.vertexColor()` and `mesh.faceColor()`, plus gradient helpers. 646 + 647 + 10. **UV projections** — `src/uv/projections.ts`, implement `mesh.computeUVs()`. Texture stubs. 648 + 649 + 11. **Three.js bridge** — `src/three/to-three.ts`, `src/three/from-three.ts`. 650 + 651 + 12. **Worker stubs** — `src/worker/pool.ts`. 652 + 653 + 13. **Main barrel exports** — `src/index.ts` re-exporting everything. 654 + 655 + 14. **Demo** — `demo/index.html`, `demo/main.ts`, all three generators (`chair.ts`, `table.ts`, `terrain.ts`). This is the integration test that proves everything works together. 656 + 657 + 15. **Final pass** — Run all tests, fix any issues, make sure `npm run dev` launches the demo and it looks good. 658 + 659 + --- 660 + 661 + ## API Surface Summary (what gets exported) 662 + 663 + ```ts 664 + // shapecraft (main) 665 + export { Mesh } from './mesh' 666 + export { box, sphere, cylinder, plane, cone, torus } from './primitives' 667 + export { merge, center, clone } from './ops' 668 + export { twist, bend, taper, smooth, subdivide } from './modifiers' 669 + export { vertexColor, faceColor, gradient, heightGradient, lerpColor } from './color' 670 + export { projectUVs } from './uv' 671 + 672 + // shapecraft/noise 673 + export { UberNoise, simplex, fbm, ridged, billowed, stepped, warped } from './noise' 674 + 675 + // shapecraft/three 676 + export { toThreeMesh, toThreeGeometry, fromThreeGeometry } from './three' 677 + ``` 678 + 679 + --- 680 + 681 + ## Notes & Gotchas for the Implementer 682 + 683 + 1. **Always clone geometry before modifying.** Every Mesh method that modifies geometry must clone first. Never mutate the internal geometry of an existing Mesh. 684 + 685 + 2. **mergeGeometries attribute alignment.** Before calling `THREE.BufferGeometryUtils.mergeGeometries()`, ensure all geometries have the same attribute set. If any geometry has `color` attribute, add a default white color attribute to those that don't. Same pattern for UVs (fill with zeros). 686 + 687 + 3. **Plane orientation.** `THREE.PlaneGeometry` is XY-aligned. Rotate it -π/2 around X to make it XZ (horizontal) before wrapping in Mesh. This is more intuitive for terrain/floors. 688 + 689 + 4. **faceColor requires toNonIndexed().** Three.js indexed geometry shares vertices between faces, so you can't set per-face colors without first un-indexing. Call `geometry.toNonIndexed()` before applying face colors. 690 + 691 + 5. **UberNoise integration.** The existing UberNoise `.get(x, y, z, w)` signature matches what we need. The `NoiseLike` interface should be `{ get(x: number, y?: number, z?: number, w?: number): number }` so UberNoise satisfies it directly. 692 + 693 + 6. **Color attribute format.** Three.js expects vertex colors as a `Float32Array` with itemSize 3 (RGB, values 0-1). Use `THREE.Color` for parsing hex strings etc. 694 + 695 + 7. **subdivide()** — For v0, use a simple midpoint subdivision (split each triangle into 4). If `three/examples/jsm` has a SubdivisionModifier or similar, prefer that. Otherwise implement a basic loop subdivision. 696 + 697 + 8. **Performance note.** Cloning geometry on every operation is fine for the typical use case (building a mesh from a few dozen operations at setup time). It would be a problem if someone tried to modify meshes every frame — but that's not the intended use pattern. Document this. 698 + 699 + 9. **Import BufferGeometryUtils correctly.** In modern Three.js: `import { mergeGeometries } from 'three/addons/utils/BufferGeometryUtils.js'` or `import { mergeGeometries } from 'three/examples/jsm/utils/BufferGeometryUtils.js'` depending on the version. Check which import works with the installed Three.js version and use that. 700 + 701 + 10. **Demo scene lighting.** Use a combination of `THREE.AmbientLight(0x404040)` and `THREE.DirectionalLight(0xffffff, 1)` positioned at `(5, 10, 7)` for good default look with flat shading.
+108 -660
plan.md
··· 1 - # Shapecraft — Implementation Plan 2 - 3 - ## Overview 4 - 5 - A procedural 3D model generation library for the browser. Functional-first API, Three.js under the hood, composable generators and modifiers, first-class noise support via an existing UberNoise library. 6 - 7 - **Key design principles:** 8 - - Generators are just functions that return meshes. No class hierarchies, no registration. 9 - - Composition = calling functions inside functions. 10 - - Immutable by default — every transform/modifier returns a new Mesh. 11 - - Three.js is the internal engine but the API should feel library-agnostic where possible. 12 - - The package name is "shapecraft" but avoid hardcoding it deeply — keep it easy to rename. 13 - 14 - --- 15 - 16 - ## Tech Stack 17 - 18 - - **Language:** TypeScript (strict) 19 - - **3D Engine:** Three.js (peer dependency — user provides it) 20 - - **Noise:** Existing UberNoise library (bundled, see `uber-noise.ts` and its deps `simplex-noise/` and `alea/`) 21 - - **Build:** Vite (library mode for the package, dev server for demos) 22 - - **Test:** Vitest 23 - - **Package:** Single flat package, subpath exports for tree-shaking (`shapecraft`, `shapecraft/noise`, `shapecraft/three`, etc.) 24 - 25 - --- 26 - 27 - ## Project Structure 28 - 29 - ``` 30 - shapecraft/ 31 - ├── package.json 32 - ├── tsconfig.json 33 - ├── vite.config.ts 34 - ├── vitest.config.ts 35 - ├── index.ts # Main barrel export 36 - ├── src/ 37 - │ ├── mesh.ts # Core Mesh class 38 - │ ├── types.ts # Shared types (Vec3, Color, etc.) 39 - │ ├── math.ts # Vec3/Mat4 helpers (thin wrappers over THREE) 40 - │ ├── primitives/ 41 - │ │ ├── index.ts 42 - │ │ ├── box.ts 43 - │ │ ├── sphere.ts 44 - │ │ ├── cylinder.ts 45 - │ │ ├── plane.ts 46 - │ │ ├── torus.ts 47 - │ │ └── cone.ts 48 - │ ├── ops/ 49 - │ │ ├── index.ts 50 - │ │ ├── merge.ts # Combine multiple meshes into one 51 - │ │ ├── clone.ts # Deep clone a mesh 52 - │ │ └── center.ts # Recenter mesh to origin 53 - │ ├── modifiers/ 54 - │ │ ├── index.ts 55 - │ │ ├── twist.ts 56 - │ │ ├── bend.ts 57 - │ │ ├── taper.ts 58 - │ │ ├── lattice.ts # Simple lattice deform 59 - │ │ └── smooth.ts # Laplacian smooth 60 - │ ├── noise/ 61 - │ │ ├── index.ts # Re-exports UberNoise + helpers 62 - │ │ ├── uber-noise.ts # Existing UberNoise (verbatim, with imports fixed) 63 - │ │ ├── simplex-noise/ # Existing simplex-noise dependency 64 - │ │ │ └── simplex-noise.ts 65 - │ │ ├── alea/ # Existing alea dependency 66 - │ │ │ └── alea.ts 67 - │ │ └── helpers.ts # Convenience: fbm(), ridged(), etc. that return UberNoise instances 68 - │ ├── color/ 69 - │ │ ├── index.ts 70 - │ │ ├── vertex-color.ts # Apply vertex colors (flat or procedural fn) 71 - │ │ ├── face-color.ts # Apply per-face colors 72 - │ │ ├── gradient.ts # Height/angle gradient helpers 73 - │ │ └── utils.ts # Color parsing, lerp, hex<->rgb 74 - │ ├── uv/ 75 - │ │ ├── index.ts 76 - │ │ ├── projections.ts # Box, planar, cylindrical, spherical UV projection 77 - │ │ └── textures.ts # Procedural texture generators (checkerboard, wood, etc.) — STUB for v0 78 - │ ├── three/ 79 - │ │ ├── index.ts 80 - │ │ ├── to-three.ts # Mesh → THREE.Mesh / THREE.BufferGeometry 81 - │ │ └── from-three.ts # THREE.BufferGeometry → Mesh 82 - │ └── worker/ 83 - │ ├── index.ts 84 - │ └── pool.ts # STUB for v0 — types + placeholder 85 - ├── demo/ 86 - │ ├── index.html 87 - │ ├── main.ts # Vite dev entry — renders demo scene 88 - │ └── generators/ 89 - │ ├── chair.ts # Example generator: a simple chair 90 - │ ├── table.ts # Example generator: table with chairs 91 - │ └── terrain.ts # Example generator: noise terrain 92 - └── tests/ 93 - ├── mesh.test.ts 94 - ├── primitives.test.ts 95 - ├── ops.test.ts 96 - ├── modifiers.test.ts 97 - └── noise.test.ts 98 - ``` 99 - 100 - --- 101 - 102 - ## Phase 1: Foundation 103 - 104 - ### 1.1 — Project setup 105 - 106 - - Init `package.json` with: 107 - - `name: "shapecraft"` 108 - - `type: "module"` 109 - - `peerDependencies: { "three": ">=0.150.0" }` 110 - - `devDependencies: { "three": "^0.170.0", "@types/three": "...", "typescript": "...", "vite": "...", "vitest": "..." }` 111 - - `exports` field with subpath exports: 112 - ```json 113 - { 114 - ".": "./src/index.ts", 115 - "./noise": "./src/noise/index.ts", 116 - "./three": "./src/three/index.ts", 117 - "./modifiers": "./src/modifiers/index.ts", 118 - "./ops": "./src/ops/index.ts", 119 - "./color": "./src/color/index.ts", 120 - "./uv": "./src/uv/index.ts" 121 - } 122 - ``` 123 - - Scripts: `"dev": "vite demo"`, `"build": "vite build"`, `"test": "vitest"` 124 - - `tsconfig.json`: strict, ESNext, paths alias `@/*` → `./src/*` 125 - - `vite.config.ts`: library mode config (entry `src/index.ts`, external `three`) 126 - - `vitest.config.ts`: basic setup 127 - 128 - ### 1.2 — Types (`src/types.ts`) 129 - 130 - ```ts 131 - export type Vec2 = [number, number] 132 - export type Vec3 = [number, number, number] 133 - export type Vec4 = [number, number, number, number] 134 - export type ColorInput = string | Vec3 | Vec4 | number // hex string, rgb tuple, rgba tuple, or 0xRRGGBB 135 - export type ColorFn = (position: Vec3, normal: Vec3, index: number) => ColorInput 136 - export type DisplaceFn = (position: Vec3, normal: Vec3, uv: Vec2 | null, index: number) => number 137 - export type WarpFn = (position: Vec3, index: number) => Vec3 138 - export type NoiseLike = { get(x: number, y?: number, z?: number): number } 139 - ``` 140 - 141 - ### 1.3 — Core Mesh class (`src/mesh.ts`) 142 - 143 - This is the most important file. The Mesh wraps a `THREE.BufferGeometry` internally. 144 - 145 - ```ts 146 - import * as THREE from 'three' 147 - 148 - class Mesh { 149 - /** Internal geometry — users CAN access this but the API doesn't require it */ 150 - readonly geometry: THREE.BufferGeometry 151 - 152 - constructor(geometry: THREE.BufferGeometry) 153 - 154 - // --- Accessors (read from geometry attributes) --- 155 - get positions(): Float32Array 156 - get indices(): Uint32Array | Uint16Array | null 157 - get normals(): Float32Array | null 158 - get uvs(): Float32Array | null 159 - get colors(): Float32Array | null 160 - get vertexCount(): number 161 - get faceCount(): number 162 - get boundingBox(): THREE.Box3 163 - 164 - // --- Transforms (all return new Mesh, geometry is cloned) --- 165 - translate(x: number, y: number, z: number): Mesh 166 - rotate(axis: Vec3 | 'x' | 'y' | 'z', angle: number): Mesh 167 - rotateX(angle: number): Mesh 168 - rotateY(angle: number): Mesh 169 - rotateZ(angle: number): Mesh 170 - scale(x: number, y?: number, z?: number): Mesh 171 - transform(matrix: THREE.Matrix4): Mesh 172 - 173 - // --- Modifiers (return new Mesh) --- 174 - displace(fn: DisplaceFn): Mesh 175 - displaceNoise(noise: NoiseLike, amplitude?: number): Mesh 176 - warp(fn: WarpFn): Mesh 177 - subdivide(iterations?: number): Mesh // uses THREE's subdivision if available, else loop subdivision 178 - computeNormals(): Mesh 179 - 180 - // --- Coloring (return new Mesh) --- 181 - vertexColor(color: ColorInput | ColorFn): Mesh 182 - faceColor(fn: (centroid: Vec3, normal: Vec3, faceIndex: number) => ColorInput): Mesh 183 - 184 - // --- UV (return new Mesh) --- 185 - computeUVs(projection?: 'box' | 'planar' | 'cylindrical' | 'spherical'): Mesh 186 - 187 - // --- Utility --- 188 - center(): Mesh 189 - clone(): Mesh 190 - 191 - // --- Serialization (for future worker support) --- 192 - serialize(): ArrayBuffer 193 - static deserialize(buffer: ArrayBuffer): Mesh 194 - } 195 - ``` 196 - 197 - **Implementation notes:** 198 - - Every method that returns a new Mesh should: clone the geometry, apply the operation to the clone, return `new Mesh(clone)`. 199 - - Use a private helper `cloneGeometry()` that does `geometry.clone()` and ensures all attributes are properly copied. 200 - - `displace(fn)`: iterate vertices, call fn with position + normal + uv, move vertex along its normal by the returned amount. Recompute normals after. 201 - - `warp(fn)`: iterate vertices, replace position with fn result. Recompute normals after. 202 - - `vertexColor(color | fn)`: if color, set all vertex colors to that color. If fn, iterate vertices and call fn. Creates/updates a `color` attribute (Float32Array, itemSize 3). 203 - - `faceColor(fn)`: iterate faces (3 indices per face), compute centroid and face normal, call fn, set all 3 vertices of that face to the returned color. **Important:** this requires un-indexing the geometry first (so vertices aren't shared between faces). Use `geometry.toNonIndexed()` before applying. 204 - - `serialize()`/`deserialize()`: pack all typed arrays into a single ArrayBuffer with a simple header (attribute count, sizes). This is for future worker support. 205 - 206 - ### 1.4 — Math utilities (`src/math.ts`) 207 - 208 - Thin wrappers — don't reimplement, use THREE internally: 209 - 210 - ```ts 211 - import * as THREE from 'three' 212 - 213 - export function vec3(x: number, y: number, z: number): THREE.Vector3 214 - export function mat4(): THREE.Matrix4 215 - export function makeTranslation(x: number, y: number, z: number): THREE.Matrix4 216 - export function makeRotation(axis: Vec3 | 'x' | 'y' | 'z', angle: number): THREE.Matrix4 217 - export function makeScale(x: number, y: number, z: number): THREE.Matrix4 218 - export function parseColor(input: ColorInput): THREE.Color 219 - ``` 220 - 221 - --- 222 - 223 - ## Phase 2: Primitives 224 - 225 - All primitives are **functions** that return a `Mesh`. They use Three.js geometry constructors internally. 226 - 227 - ### `src/primitives/box.ts` 228 - ```ts 229 - export interface BoxOptions { 230 - width?: number // default 1 231 - height?: number // default 1 232 - depth?: number // default 1 233 - // OR shorthand: 234 - size?: Vec3 | number // overrides width/height/depth 235 - widthSegments?: number 236 - heightSegments?: number 237 - depthSegments?: number 238 - } 239 - export function box(options?: BoxOptions): Mesh 240 - ``` 241 - Internally: `new THREE.BoxGeometry(...)` → wrap in Mesh. 242 - 243 - ### `src/primitives/sphere.ts` 244 - ```ts 245 - export interface SphereOptions { 246 - radius?: number // default 0.5 247 - widthSegments?: number // default 16 248 - heightSegments?: number // default 12 249 - } 250 - export function sphere(options?: SphereOptions): Mesh 251 - ``` 252 - 253 - ### `src/primitives/cylinder.ts` 254 - ```ts 255 - export interface CylinderOptions { 256 - radius?: number // default 0.5 (sets both top and bottom) 257 - radiusTop?: number // overrides radius for top 258 - radiusBottom?: number // overrides radius for bottom 259 - height?: number // default 1 260 - segments?: number // default 16 261 - } 262 - export function cylinder(options?: CylinderOptions): Mesh 263 - ``` 264 - 265 - ### `src/primitives/plane.ts` 266 - ```ts 267 - export interface PlaneOptions { 268 - width?: number // default 1 269 - height?: number // default 1 270 - // OR shorthand: 271 - size?: number | Vec2 // overrides width/height 272 - widthSegments?: number // default 1 273 - heightSegments?: number // default 1 274 - // OR shorthand: 275 - segments?: number | Vec2 // overrides both segment counts 276 - } 277 - export function plane(options?: PlaneOptions): Mesh 278 - ``` 279 - **Note:** Three.js PlaneGeometry creates a vertical plane (XY). We should rotate it to be horizontal (XZ) by default since that's more natural for terrain/floors. Document this clearly. 280 - 281 - ### `src/primitives/cone.ts` 282 - ```ts 283 - export interface ConeOptions { 284 - radius?: number 285 - height?: number 286 - segments?: number 287 - } 288 - export function cone(options?: ConeOptions): Mesh 289 - ``` 290 - 291 - ### `src/primitives/torus.ts` 292 - ```ts 293 - export interface TorusOptions { 294 - radius?: number 295 - tube?: number 296 - radialSegments?: number 297 - tubularSegments?: number 298 - } 299 - export function torus(options?: TorusOptions): Mesh 300 - ``` 301 - 302 - ### `src/primitives/index.ts` 303 - Re-export all primitives. 304 - 305 - --- 306 - 307 - ## Phase 3: Operations 308 - 309 - ### `src/ops/merge.ts` 310 - ```ts 311 - export function merge(...meshes: Mesh[]): Mesh 312 - ``` 313 - Implementation: use `THREE.BufferGeometryUtils.mergeGeometries()` (import from `three/addons/utils/BufferGeometryUtils.js`). Handle the case where some meshes have vertex colors and some don't — fill missing colors with white. Same for UVs — fill missing with zeros. 314 - 315 - **Important:** `mergeGeometries` requires all geometries to have the same set of attributes. Before merging, normalize all geometries to have the same attribute set. If any mesh in the set has colors, ensure all do. If any has UVs, ensure all do. 316 - 317 - ### `src/ops/center.ts` 318 - ```ts 319 - export function center(mesh: Mesh): Mesh 320 - ``` 321 - Compute bounding box center, translate by negative center. 322 - 323 - ### `src/ops/clone.ts` 324 - ```ts 325 - export function clone(mesh: Mesh): Mesh // just calls mesh.clone() 326 - ``` 327 - 328 - --- 329 - 330 - ## Phase 4: Modifiers 331 - 332 - Modifiers are standalone functions that return `WarpFn` or can be applied directly. Two patterns: 333 - 334 - **Pattern A — warp functions** (for use with `mesh.warp(fn)`): 335 - ```ts 336 - // Returns a WarpFn: (position: Vec3) => Vec3 337 - export function twist(options: { axis?: 'x' | 'y' | 'z', amount: number }): WarpFn 338 - export function bend(options: { axis?: 'x' | 'y' | 'z', amount: number }): WarpFn 339 - export function taper(options: { axis?: 'x' | 'y' | 'z', curve?: (t: number) => number }): WarpFn 340 - ``` 341 - 342 - **Pattern B — mesh-in mesh-out** (for complex operations): 343 - ```ts 344 - export function smooth(mesh: Mesh, iterations?: number): Mesh 345 - export function subdivide(mesh: Mesh, iterations?: number): Mesh 346 - ``` 347 - 348 - ### `src/modifiers/twist.ts` 349 - Rotate vertices around an axis proportional to their position along that axis. 350 - ``` 351 - angle = position[axis] * amount 352 - rotate position around axis by angle 353 - ``` 354 - 355 - ### `src/modifiers/bend.ts` 356 - Curve vertices along an axis. Map position along axis to an arc. 357 - 358 - ### `src/modifiers/taper.ts` 359 - Scale vertices perpendicular to an axis based on a curve function of their position along the axis. 360 - ``` 361 - t = normalize position along axis to [0, 1] 362 - scale = curve(t) 363 - position[perpendicular axes] *= scale 364 - ``` 365 - 366 - ### `src/modifiers/smooth.ts` 367 - Laplacian smoothing: for each vertex, move it toward the average of its neighbors. Requires building an adjacency map from the index buffer. 368 - 369 - ### `src/modifiers/lattice.ts` 370 - STUB for v0. Just export the type and a placeholder that returns the input unchanged. 371 - 372 - --- 373 - 374 - ## Phase 5: Noise Integration 375 - 376 - ### Copy existing code 377 - Copy the user's existing noise implementation into `src/noise/`: 378 - - `src/noise/uber-noise.ts` — the UberNoise class (adjust imports to use relative paths within the package) 379 - - `src/noise/simplex-noise/simplex-noise.ts` — existing simplex noise 380 - - `src/noise/alea/alea.ts` — existing alea PRNG 381 - 382 - **Important:** Remove the `globalThis` assignments at the bottom of `uber-noise.ts`. We don't want to pollute globals — everything is imported. 383 - 384 - ### `src/noise/helpers.ts` 385 - Convenience factory functions that create pre-configured UberNoise instances: 386 - 387 - ```ts 388 - import { UberNoise, type NoiseOptions } from './uber-noise' 389 - 390 - /** Basic simplex noise */ 391 - export function simplex(options?: Partial<NoiseOptions>): UberNoise 392 - 393 - /** FBM (fractional Brownian motion) with sensible defaults */ 394 - export function fbm(options?: Partial<NoiseOptions> & { octaves?: number }): UberNoise 395 - // Default: octaves 4, lacunarity 2, gain 0.5 396 - 397 - /** Ridged noise */ 398 - export function ridged(options?: Partial<NoiseOptions>): UberNoise 399 - // Sets sharpness: -1 400 - 401 - /** Billowed noise */ 402 - export function billowed(options?: Partial<NoiseOptions>): UberNoise 403 - // Sets sharpness: 1 404 - 405 - /** Stepped/terraced noise */ 406 - export function stepped(steps: number, options?: Partial<NoiseOptions>): UberNoise 407 - // Sets steps 408 - 409 - /** Warped noise */ 410 - export function warped(amount: number, options?: Partial<NoiseOptions>): UberNoise 411 - // Sets warp amount 412 - ``` 413 - 414 - ### `src/noise/index.ts` 415 - ```ts 416 - export { UberNoise, type NoiseOptions } from './uber-noise' 417 - export { simplex, fbm, ridged, billowed, stepped, warped } from './helpers' 418 - ``` 419 - 420 - ### Integration with Mesh 421 - 422 - `mesh.displaceNoise(noise, amplitude)` should accept an `UberNoise` instance (or anything with a `.get(x, y, z)` method): 423 - 424 - ```ts 425 - displaceNoise(noise: NoiseLike, amplitude: number = 1): Mesh { 426 - return this.displace((pos, normal) => { 427 - return noise.get(pos[0], pos[1], pos[2]) * amplitude 428 - }) 429 - } 430 - ``` 431 - 432 - This keeps the coupling loose — any object with `get(x, y?, z?)` works. 433 - 434 - --- 435 - 436 - ## Phase 6: Color & UV 437 - 438 - ### `src/color/utils.ts` 439 - ```ts 440 - export function parseColor(input: ColorInput): [number, number, number] // rgb 0-1 441 - export function lerpColor(a: ColorInput, b: ColorInput, t: number): [number, number, number] 442 - export function hexToRgb(hex: string): [number, number, number] 443 - export function rgbToHex(r: number, g: number, b: number): string 444 - ``` 445 - Use `THREE.Color` internally for parsing. 446 - 447 - ### `src/color/gradient.ts` 448 - ```ts 449 - export type GradientStop = [number, ColorInput] // [threshold, color] 450 - 451 - /** Create a function that maps a value to a color based on gradient stops */ 452 - export function gradient(stops: GradientStop[]): (value: number) => [number, number, number] 453 - 454 - /** Shorthand for height-based gradient (maps y position to color) */ 455 - export function heightGradient(stops: GradientStop[]): ColorFn 456 - ``` 457 - 458 - ### `src/color/vertex-color.ts` 459 - Implementation of `Mesh.vertexColor()`: 460 - - If given a flat color, set all vertex colors to that color. 461 - - If given a function, iterate all vertices, call the function with (position, normal, vertexIndex), set the color attribute. 462 - 463 - ### `src/color/face-color.ts` 464 - Implementation of `Mesh.faceColor()`: 465 - - Call `geometry.toNonIndexed()` first (un-share vertices). 466 - - Iterate face by face (every 3 vertices), compute centroid and normal, call fn, set all 3 vertex colors. 467 - 468 - ### `src/uv/projections.ts` 469 - ```ts 470 - export function projectUVs(geometry: THREE.BufferGeometry, mode: 'box' | 'planar' | 'cylindrical' | 'spherical'): void 471 - ``` 472 - Modifies geometry in place (called on a clone inside `Mesh.computeUVs()`). 1 + # Shapecraft — 6-Month Roadmap (v1) 473 2 474 - - **planar**: project from Y axis down onto XZ plane. `u = x`, `v = z` (normalized to bounding box). 475 - - **box**: tri-planar — pick projection axis per face based on face normal, project from that axis. 476 - - **cylindrical**: `u = atan2(z, x) / (2π)`, `v = y` (normalized). 477 - - **spherical**: `u = atan2(z, x) / (2π)`, `v = acos(y/r) / π`. 3 + > The original v0 build plan lives in [`plan-v0.md`](./plan-v0.md). This document is the 4 + > forward-looking strategy: improving the models we have, making many more, and growing the 5 + > library to support them. 478 6 479 - ### `src/uv/textures.ts` 480 - STUB for v0. Export types and a couple basic functions: 481 - ```ts 482 - export function checkerboard(size?: number): (u: number, v: number) => [number, number, number] 483 - ``` 484 - Full procedural texture system is post-v0. 7 + **Team & horizon:** 4 people, full-time, 6 months (~24 person-months). 485 8 486 9 --- 487 10 488 - ## Phase 7: Three.js Bridge 489 - 490 - ### `src/three/to-three.ts` 491 - ```ts 492 - import * as THREE from 'three' 493 - import type { Mesh } from '../mesh' 494 - 495 - /** Convert a shapecraft Mesh to a THREE.Mesh ready to add to a scene */ 496 - export function toThreeMesh(mesh: Mesh, options?: { 497 - material?: THREE.Material 498 - flatShading?: boolean // default true for low-poly look 499 - wireframe?: boolean 500 - }): THREE.Mesh 11 + ## Where we are 501 12 502 - /** Get just the geometry (if user wants to provide their own material) */ 503 - export function toThreeGeometry(mesh: Mesh): THREE.BufferGeometry 504 - ``` 13 + Shapecraft is a procedural 3D model generation library for the browser: an immutable, 14 + functional `Mesh` API over Three.js, with a stylized low-poly toolbox (noise, palettes, 15 + face/vertex color, loft/tube/thicken, adaptive subdivision, jitter) and a **schema-driven 16 + editor** — each generator declares its options and gets a live UI, presets, and 17 + range-randomization for free. 505 18 506 - `toThreeMesh` implementation: 507 - - If mesh has vertex colors, use `THREE.MeshStandardMaterial({ vertexColors: true, flatShading })`. 508 - - If no vertex colors, use `THREE.MeshStandardMaterial({ color: 0xcccccc, flatShading })`. 509 - - If wireframe requested, set `material.wireframe = true`. 510 - - The geometry is already a `THREE.BufferGeometry` internally, so just reference it (or clone if immutability is desired). 19 + We ship **three models** today: common tree, pine, palm. All vegetation, all flat-shaded 20 + vertex-colored. 511 21 512 - ### `src/three/from-three.ts` 513 - ```ts 514 - /** Import an existing Three.js geometry into shapecraft */ 515 - export function fromThreeGeometry(geometry: THREE.BufferGeometry): Mesh 516 - ``` 22 + **The core tension:** every model is ~200 lines of bespoke, copy-pasted code. Trunk warp, 23 + snow logic, face-shading, and the fragile "call `rand()` to keep the sequence stable" pattern 24 + are duplicated across all three. This does not scale to 20+ models. The ambition goes into the 25 + framework that makes each new model cheap. 517 26 518 27 --- 519 28 520 - ## Phase 8: Worker Support (STUB) 521 - 522 - ### `src/worker/pool.ts` 523 - For v0, just define the interface and a simple synchronous fallback: 524 - 525 - ```ts 526 - export interface WorkerPoolOptions { 527 - maxWorkers?: number 528 - } 29 + ## North star 529 30 530 - export class WorkerPool { 531 - constructor(options?: WorkerPoolOptions) 31 + **All four scopes at once** — best-in-class vegetation, a full stylized environment kit, an 32 + asset-pipeline product, and a great general-purpose library. **All four consumers** — web/ 33 + Three.js scenes, game engines, no-code designers, and developers using the API. 532 34 533 - /** Run a generator function in a worker. For v0, runs synchronously. */ 534 - async run<T>(fn: (...args: any[]) => Mesh, ...args: any[]): Promise<Mesh> 35 + ### The "all four" insight 535 36 536 - /** Batch run multiple generators. For v0, runs sequentially. */ 537 - async batch(tasks: Array<[Function, ...any[]]>): Promise<Mesh[]> 37 + These don't pull in four directions; they share one spine. The non-negotiable shared platform 38 + is: generator framework + CSG/bevel + curves + weld/LOD + AO/wind + instancing + glTF export + 39 + workers + playground + npm publish. Build that once and all four audiences are served. 538 40 539 - dispose(): void 540 - } 541 - ``` 41 + So "all four" resolves to a strict order: **engine-first, then catalog, then platform polish — 42 + with the quality bar (LOD, AO, wind, export) baked into the shared pipeline from the start so 43 + it is free for every model.** 542 44 543 - The v0 implementation just calls the functions directly. Real worker support comes later and will use `Mesh.serialize()`/`deserialize()` with `Transferable`. 45 + The one risk is shipping four half-products. The mitigation is the sequencing below: the 46 + foundation serves all four scopes simultaneously, and we don't branch into audience-specific 47 + polish until the substrate is real. 544 48 545 49 --- 546 50 547 - ## Phase 9: Demo 548 - 549 - ### `demo/index.html` 550 - Basic HTML shell that loads `demo/main.ts` via Vite. 551 - 552 - ### `demo/main.ts` 553 - Sets up a Three.js scene with: 554 - - Renderer, camera, controls (OrbitControls) 555 - - Ambient light + directional light 556 - - Calls all three demo generators 557 - - Adds them to the scene 558 - - Animation loop 559 - 560 - ### `demo/generators/chair.ts` 561 - ```ts 562 - export function chair(options?: { seatHeight?: number, legRadius?: number }): Mesh 563 - ``` 564 - A simple 4-legged chair: 565 - - Box for seat 566 - - 4 cylinders for legs 567 - - Box for back 568 - - Vertex colored brown tones 569 - - Returns merged mesh 570 - 571 - ### `demo/generators/table.ts` 572 - ```ts 573 - export function diningSet(options?: { chairs?: number, tableRadius?: number }): Mesh 574 - ``` 575 - - Cylinder for table top 576 - - 4 cylinder legs 577 - - N chairs arranged in a circle using the `chair()` generator 578 - - Demonstrates composition 51 + ## Thesis: build the multiplier before the models 579 52 580 - ### `demo/generators/terrain.ts` 581 - ```ts 582 - export function terrain(options?: { size?: number, segments?: number, seed?: number }): Mesh 583 - ``` 584 - - Large subdivided plane 585 - - Displaced with UberNoise (fbm, maybe ridged mix) 586 - - Height-based vertex coloring (green → brown → gray → white) 587 - - Demonstrates noise integration 53 + The highest-leverage work is not more models — it's the substrate that makes every subsequent 54 + model cheap. Spend the first third of the project there, then flood the catalog. 588 55 589 56 --- 590 57 591 - ## Phase 10: Tests 592 - 593 - ### `tests/mesh.test.ts` 594 - - Construction from geometry 595 - - Transforms: translate, rotate, scale produce correct vertex positions 596 - - Immutability: original mesh unchanged after transform 597 - - `vertexCount` and `faceCount` correct 598 - - `clone()` produces independent copy 599 - 600 - ### `tests/primitives.test.ts` 601 - - Each primitive returns a Mesh 602 - - Vertex counts are correct for given parameters 603 - - Default options produce reasonable geometry 604 - - `size` shorthand works for box and plane 605 - 606 - ### `tests/ops.test.ts` 607 - - `merge()` combines vertex counts correctly 608 - - `merge()` handles mixed color/no-color meshes (fills missing with white) 609 - - `center()` puts bounding box center at origin 610 - 611 - ### `tests/modifiers.test.ts` 612 - - `twist()` modifies positions (not identity) 613 - - `taper()` scales vertices correctly at extremes 614 - - `displace()` moves vertices along normals 615 - - `displaceNoise()` produces non-zero displacement 58 + ## Two things to fix in week one (regardless of everything else) 616 59 617 - ### `tests/noise.test.ts` 618 - - UberNoise produces values in expected range 619 - - `fbm()` helper returns configured UberNoise 620 - - `ridged()` produces values with expected characteristics 621 - - Seeded noise is deterministic 60 + 1. **Determinism model.** Replace the single RNG with **named independent streams** 61 + (`rng.stream('canopy')`, `rng.stream('snow')`). The current "consume a `rand()` to keep the 62 + sequence stable" hack is a latent bug farm — change one branch and every downstream model 63 + shifts. Fix it before it spreads into 20 more files. 64 + 2. **Packaging gap.** Today `package.json` `main` points at raw `.ts` — shapecraft is not 65 + actually consumable as a package. Decide now that it ships as a real built npm package with 66 + **stable seeds as a compatibility guarantee**. This changes how we test (golden snapshots) 67 + and how we version from day one. 622 68 623 69 --- 624 70 625 - ## Implementation Order for Claude Code 626 - 627 - Follow this order. Each step should be completable and testable before moving to the next. 628 - 629 - 1. **Project scaffolding** — `package.json`, `tsconfig.json`, `vite.config.ts`, `vitest.config.ts`, directory structure, install deps. 630 - 631 - 2. **Types + Math** — `src/types.ts`, `src/math.ts`. 632 - 633 - 3. **Core Mesh class** — `src/mesh.ts`. Start with constructor, accessors, transforms (`translate`, `rotate`, `scale`, `transform`), `clone()`, `center()`, `computeNormals()`. Write `tests/mesh.test.ts` alongside. 634 - 635 - 4. **Primitives** — All 6 primitives. Write `tests/primitives.test.ts`. At this point you can already do `box().translate(1,0,0)`. 636 - 637 - 5. **Merge operation** — `src/ops/merge.ts` + `center.ts` + `clone.ts`. Write `tests/ops.test.ts`. Now you can compose: `merge(box().translate(-1,0,0), sphere().translate(1,0,0))`. 638 - 639 - 6. **Noise integration** — Copy UberNoise + deps into `src/noise/`, fix imports, strip globals, write `src/noise/helpers.ts`, write `src/noise/index.ts`. Write `tests/noise.test.ts`. 640 - 641 - 7. **Displace + warp on Mesh** — Implement `mesh.displace()`, `mesh.displaceNoise()`, `mesh.warp()`. These depend on noise being available for testing. 642 - 643 - 8. **Modifiers** — `twist`, `bend`, `taper`, `smooth` (lattice as stub). Write `tests/modifiers.test.ts`. 644 - 645 - 9. **Color system** — `src/color/*`. Implement `mesh.vertexColor()` and `mesh.faceColor()`, plus gradient helpers. 646 - 647 - 10. **UV projections** — `src/uv/projections.ts`, implement `mesh.computeUVs()`. Texture stubs. 648 - 649 - 11. **Three.js bridge** — `src/three/to-three.ts`, `src/three/from-three.ts`. 71 + ## Workstreams 650 72 651 - 12. **Worker stubs** — `src/worker/pool.ts`. 73 + ### A. Generator framework (the model multiplier) 74 + - Extract repeated patterns into reusable building blocks: a `trunk()` / tapered-limb builder, 75 + a `canopyShade()` color helper, a `snow()` modifier. 76 + - **Anchor / socket points** on meshes (canopy top, ground contact, attachment rings) for 77 + composition. 78 + - **Skeleton / L-system branching engine**: recursive tapered branches with leaf/attachment 79 + slots. One engine yields oak, birch, willow, dead tree, bush, fern, coral, and vines from 80 + parameters instead of bespoke files. 81 + - RNG named streams + **golden-snapshot test harness** (vertex counts, bounds, hashes) so 82 + "seed 5 looks like X" is locked across versions. 652 83 653 - 13. **Main barrel exports** — `src/index.ts` re-exporting everything. 84 + ### B. Library capability gaps (unlock new model classes) 85 + - **CSG booleans** (union / subtract / intersect) — windows in walls, holes, carved props, 86 + hard-surface. 87 + - **Bevel / inset / extrude-faces** — architecture, crates, furniture, crisp edges. 88 + - First-class **Curve / Path type with parallel-transport frames** — fixes twist artifacts in 89 + `tube`/`loft` and cleans up every stalk/branch/vine. 90 + - **Vertex weld + decimation + automatic LOD generation** — today `faceColor` un-indexes 91 + everything (×3 vertices) with no way to weld back or produce LODs. Required for any real use 92 + at scale and for game-engine export. 654 93 655 - 14. **Demo** — `demo/index.html`, `demo/main.ts`, all three generators (`chair.ts`, `table.ts`, `terrain.ts`). This is the integration test that proves everything works together. 94 + ### C. Quality lift across all models (cheap, high-impact, on-brand) 95 + - **Baked vertex ambient occlusion / cavity shading** — instantly makes everything read as 3D 96 + instead of flat. 97 + - **Per-vertex wind weights** — trees sway in a shader, models ship game-ready. 98 + - **Real branches** on the trees (currently trunk-plus-blobs). 99 + - Poly-budget / triangle-count targets per model. 656 100 657 - 15. **Final pass** — Run all tests, fix any issues, make sure `npm run dev` launches the demo and it looks good. 101 + ### D. Product surface (make output usable) 102 + - **Instancing**: `scatter → THREE.InstancedMesh` (the forest demo builds 15 unique meshes 103 + today — critical perf win). 104 + - **glTF / GLB export** (plus OBJ) — the asset pipeline for game engines and designers. 105 + - **Real Web Worker pool** — the pool is a synchronous stub; `serialize()`/Transferable are 106 + already in place to make it real. 107 + - **Biome / scatter scene generator** — Poisson-disk, noise density maps, slope/altitude rules 108 + (the forest demo is the seed). 109 + - **Hosted playground + gallery** — the schema editor is the unfair advantage; one-click glTF 110 + export for the no-code crowd. 111 + - **npm publish pipeline** — real build, `.d.ts`, dual ESM. Docs, perf benchmarks, visual 112 + regression (the `screenshot.cjs` scripts are the seed). 658 113 659 114 --- 660 115 661 - ## API Surface Summary (what gets exported) 116 + ## Timeline 662 117 663 - ```ts 664 - // shapecraft (main) 665 - export { Mesh } from './mesh' 666 - export { box, sphere, cylinder, plane, cone, torus } from './primitives' 667 - export { merge, center, clone } from './ops' 668 - export { twist, bend, taper, smooth, subdivide } from './modifiers' 669 - export { vertexColor, faceColor, gradient, heightGradient, lerpColor } from './color' 670 - export { projectUVs } from './uv' 118 + ### Months 1–2 — Foundation (all 4 people; the whole ballgame, nothing skippable) 119 + - RNG named streams + golden-snapshot test harness. 120 + - Shared model primitives + skeleton/L-system branch engine. 121 + - CSG booleans + bevel/inset/extrude. 122 + - First-class Curve/Path with parallel-transport frames. 123 + - Weld + decimate + automatic LOD generation. 671 124 672 - // shapecraft/noise 673 - export { UberNoise, simplex, fbm, ridged, billowed, stepped, warped } from './noise' 125 + ### Months 2–4 — Catalog explosion + quality baked in (split: 2 on models, 2 on platform) 126 + - **Models team:** ride the framework through vegetation (bush, grass, fern, dead tree, cactus, 127 + birch, willow, mushroom, bamboo, flower) + rocks/cliffs + the prop/architecture set CSG now 128 + enables (crates, fences, walls, simple buildings, furniture — chair/table already in the demo). 129 + - **Platform team:** bake AO/cavity shading + per-vertex wind weights into the shared pipeline 130 + (every model above ships better-looking and game-ready automatically); add instancing and the 131 + glTF/GLB exporter. 674 132 675 - // shapecraft/three 676 - export { toThreeMesh, toThreeGeometry, fromThreeGeometry } from './three' 677 - ``` 133 + ### Months 4–6 — Platform & product (split: scene/biome + playground) 134 + - Real Web Worker pool. 135 + - Biome/scatter scene generator. 136 + - Hosted playground + gallery with one-click glTF export. 137 + - npm publish pipeline (build, `.d.ts`, dual ESM). 138 + - Docs, perf benchmarks, visual regression harness. 678 139 679 140 --- 680 141 681 - ## Notes & Gotchas for the Implementer 682 - 683 - 1. **Always clone geometry before modifying.** Every Mesh method that modifies geometry must clone first. Never mutate the internal geometry of an existing Mesh. 684 - 685 - 2. **mergeGeometries attribute alignment.** Before calling `THREE.BufferGeometryUtils.mergeGeometries()`, ensure all geometries have the same attribute set. If any geometry has `color` attribute, add a default white color attribute to those that don't. Same pattern for UVs (fill with zeros). 686 - 687 - 3. **Plane orientation.** `THREE.PlaneGeometry` is XY-aligned. Rotate it -π/2 around X to make it XZ (horizontal) before wrapping in Mesh. This is more intuitive for terrain/floors. 688 - 689 - 4. **faceColor requires toNonIndexed().** Three.js indexed geometry shares vertices between faces, so you can't set per-face colors without first un-indexing. Call `geometry.toNonIndexed()` before applying face colors. 690 - 691 - 5. **UberNoise integration.** The existing UberNoise `.get(x, y, z, w)` signature matches what we need. The `NoiseLike` interface should be `{ get(x: number, y?: number, z?: number, w?: number): number }` so UberNoise satisfies it directly. 692 - 693 - 6. **Color attribute format.** Three.js expects vertex colors as a `Float32Array` with itemSize 3 (RGB, values 0-1). Use `THREE.Color` for parsing hex strings etc. 694 - 695 - 7. **subdivide()** — For v0, use a simple midpoint subdivision (split each triangle into 4). If `three/examples/jsm` has a SubdivisionModifier or similar, prefer that. Otherwise implement a basic loop subdivision. 696 - 697 - 8. **Performance note.** Cloning geometry on every operation is fine for the typical use case (building a mesh from a few dozen operations at setup time). It would be a problem if someone tried to modify meshes every frame — but that's not the intended use pattern. Document this. 698 - 699 - 9. **Import BufferGeometryUtils correctly.** In modern Three.js: `import { mergeGeometries } from 'three/addons/utils/BufferGeometryUtils.js'` or `import { mergeGeometries } from 'three/examples/jsm/utils/BufferGeometryUtils.js'` depending on the version. Check which import works with the installed Three.js version and use that. 142 + ## Definition of success 700 143 701 - 10. **Demo scene lighting.** Use a combination of `THREE.AmbientLight(0x404040)` and `THREE.DirectionalLight(0xffffff, 1)` positioned at `(5, 10, 7)` for good default look with flat shading. 144 + - 20+ models spanning vegetation, rocks, props, and basic architecture — each with presets, 145 + LODs, baked AO, and wind data. 146 + - Shapecraft installable from npm with stable, versioned seeds. 147 + - One-click glTF/GLB export from a hosted playground. 148 + - Forests/biomes rendered via instancing + workers without blocking the main thread. 149 + - A general-purpose, documented, benchmarked procedural-mesh library others can build on.
+5 -4
src/color/palette.ts
··· 1 - import { interpolate, oklch, formatRgb, parse, converter } from 'culori' 1 + import { interpolate, formatRgb, parse, converter } from 'culori' 2 2 import type { ColorInput, ColorFn, Vec3 } from '../core/types' 3 3 import type { NoiseLike } from '../core/types' 4 4 ··· 106 106 const base = toOklch({ mode: 'rgb', r: rgb[0], g: rgb[1], b: rgb[2] }) 107 107 108 108 return function varied(): [number, number, number] { 109 - const varied = oklch({ 109 + const variedColor = { 110 + mode: 'oklch' as const, 110 111 l: Math.max(0, Math.min(1, (base.l ?? 0.5) + (rand() - 0.5) * amount)), 111 112 c: Math.max(0, (base.c ?? 0.1) + (rand() - 0.5) * amount * 0.5), 112 113 h: (base.h ?? 140) + (rand() - 0.5) * amount * 60, 113 - }) 114 - const out = converter('rgb')(varied) 114 + } 115 + const out = converter('rgb')(variedColor) 115 116 return [ 116 117 Math.max(0, Math.min(1, out.r)), 117 118 Math.max(0, Math.min(1, out.g)),
+65 -4
src/core/rng.ts
··· 1 1 import { aleaFactory } from '../noise/alea/alea' 2 2 3 3 /** 4 - * Create a seeded random number generator. 5 - * Returns a function that produces values in [0, 1) on each call. 4 + * A seeded random number generator. 5 + * 6 + * It is callable — `rng()` returns the next float in [0, 1) — so it is a drop-in 7 + * replacement anywhere a `() => number` is expected (scatter, schema, palettes). 8 + * 9 + * The important addition is {@link Rng.stream}: a named, independent sub-generator. 10 + * Pulling values from one stream never advances another, so a model can give each 11 + * concern (`'trunk'`, `'canopy'`, `'snow'`, …) its own stream and toggling one feature 12 + * can't shift the randomness of an unrelated one. This replaces the fragile 13 + * "call `rand()` to keep the sequence stable" pattern. 14 + */ 15 + export interface Rng { 16 + /** Next float in [0, 1). Drop-in compatible with `() => number`. */ 17 + (): number 18 + /** Next float in [min, max) (defaults to [0, 1)). */ 19 + float(min?: number, max?: number): number 20 + /** Integer in [min, max] inclusive. */ 21 + int(min: number, max: number): number 22 + /** True with probability `p` (default 0.5). */ 23 + bool(p?: number): boolean 24 + /** Randomly -1 or 1. */ 25 + sign(): number 26 + /** A random element of `arr`. */ 27 + pick<T>(arr: readonly T[]): T 28 + /** Resolve a fixed value or a `[min, max]` range. Rounds when `integer` is true. */ 29 + range(value: number | [number, number], integer?: boolean): number 30 + /** Derive a fresh integer seed (e.g. for noise/jitter). Advances this stream. */ 31 + seed(): number 32 + /** An independent, deterministic sub-stream identified by `name`. */ 33 + stream(name: string): Rng 34 + /** An independent anonymous sub-stream (auto-numbered per parent). */ 35 + fork(): Rng 36 + } 37 + 38 + /** 39 + * Create a seeded RNG from a number or string seed. 6 40 */ 7 - export function createRng(seed: number | string): () => number { 8 - return aleaFactory(seed).random 41 + export function createRng(seed: number | string): Rng { 42 + return makeRng(String(seed)) 43 + } 44 + 45 + function makeRng(base: string): Rng { 46 + const next = aleaFactory(base).random 47 + let forkCounter = 0 48 + 49 + const rng = function (): number { 50 + return next() 51 + } as Rng 52 + 53 + rng.float = (min = 0, max = 1) => min + next() * (max - min) 54 + rng.int = (min, max) => Math.floor(min + next() * (max - min + 1)) 55 + rng.bool = (p = 0.5) => next() < p 56 + rng.sign = () => (next() < 0.5 ? -1 : 1) 57 + rng.pick = <T>(arr: readonly T[]): T => arr[Math.floor(next() * arr.length)] 58 + rng.range = (value, integer = false) => { 59 + if (Array.isArray(value)) { 60 + const r = value[0] + next() * (value[1] - value[0]) 61 + return integer ? Math.round(r) : r 62 + } 63 + return value 64 + } 65 + rng.seed = () => Math.floor(next() * 2147483647) 66 + rng.stream = (name: string) => makeRng(`${base}/${name}`) 67 + rng.fork = () => makeRng(`${base}#${forkCounter++}`) 68 + 69 + return rng 9 70 }
+15
src/core/schema.ts
··· 60 60 export type Randomizable<T> = T | [T, T] 61 61 62 62 /** 63 + * The *input* shape for a schema: like {@link OptionValues}, but numeric options also 64 + * accept a `[min, max]` tuple (resolved to a number at generation time). Use this for a 65 + * generator's public options type; use {@link OptionValues} for the resolved result. 66 + */ 67 + export type OptionInput<S extends OptionSchema> = { 68 + [K in keyof S]: S[K] extends RangeOption ? Randomizable<number> 69 + : S[K] extends IntegerOption ? Randomizable<number> 70 + : S[K] extends ColorOption ? string 71 + : S[K] extends ColorArrayOption ? string[] 72 + : S[K] extends BooleanOption ? boolean 73 + : S[K] extends SelectOption ? string 74 + : never 75 + } 76 + 77 + /** 63 78 * Resolve options for a generator: apply preset, then overrides, then fill schema defaults. 64 79 * Numeric values can be [min, max] tuples — resolved using rand() if provided. 65 80 */
+27 -26
src/generators/common-tree.ts
··· 6 6 import { paletteGradient, pickRandom, type Palette } from '../color' 7 7 import { UberNoise } from '../noise' 8 8 import type { Mesh } from '../core/mesh' 9 - import type { OptionSchema } from '../core/schema' 9 + import type { OptionSchema, OptionInput } from '../core/schema' 10 10 11 11 export const treeSchema = { 12 12 seed: { type: 'integer', default: 1, min: 1, max: 100, label: 'Seed' }, ··· 31 31 canopyColors: { type: 'color-array', default: ['#1e6b10', '#2a7518', '#238020', '#2d8a1e'], min: 1, max: 8, label: 'Canopy Colors' }, 32 32 } satisfies OptionSchema 33 33 34 - export type TreeOptions = { 35 - [K in keyof typeof treeSchema]?: typeof treeSchema[K]['default'] 36 - } & { preset?: string } 34 + export type TreeOptions = Partial<OptionInput<typeof treeSchema>> & { preset?: string } 37 35 38 36 export const treePresets: Record<string, Partial<TreeOptions>> = { 39 37 default: {}, ··· 55 53 } 56 54 57 55 export function tree(options: TreeOptions = {}): Mesh { 58 - // Create rand from seed before resolving (needed for [min,max] ranges) 59 - const seed = options.seed ?? treeSchema.seed.default 60 - const rand = createRng(seed) 61 - const o = resolveOptions(treeSchema, options, treePresets, rand) 56 + // Create rng from seed before resolving (needed for [min,max] ranges) 57 + const seedOpt = options.seed ?? treeSchema.seed.default 58 + const seed = Array.isArray(seedOpt) ? seedOpt[0] : seedOpt 59 + const rng = createRng(seed) 60 + const o = resolveOptions(treeSchema, options, treePresets, rng) 62 61 63 - // Derive all sub-seeds from the main rand so everything chains deterministically 64 - function subSeed() { return Math.floor(rand() * 2147483647) } 62 + // Independent streams per concern: drawing from one never perturbs another, 63 + // so toggling (e.g.) snow can't shift the trunk or canopy randomness. 64 + const trunkRng = rng.stream('trunk') 65 + const canopyRng = rng.stream('canopy') 66 + const colorRng = rng.stream('color') 67 + const snowRng = rng.stream('snow') 65 68 66 69 // Trunk radii — randomized within range 67 - const baseRadius = o.trunkRadius * (1.6 + rand() * 0.8) 70 + const baseRadius = o.trunkRadius * (1.6 + trunkRng() * 0.8) 68 71 const topRadius = o.trunkRadius * o.trunkTopScale 69 72 70 73 // Bigger trunk → bigger canopy ··· 73 76 74 77 // Trunk 75 78 const trunkHeight = o.height * o.trunkRatio 76 - const leanX = (rand() - 0.5) * o.lean 77 - const leanZ = (rand() - 0.5) * o.lean 79 + const leanX = (trunkRng() - 0.5) * o.lean 80 + const leanZ = (trunkRng() - 0.5) * o.lean 78 81 const trunkGrad = paletteGradient(o.trunkColors) 79 82 const taperExp = o.trunkTaper 80 83 81 - const trunkNoise = new UberNoise({ seed: subSeed(), scale: 8 }) 84 + const trunkNoise = new UberNoise({ seed: trunkRng.seed(), scale: 8 }) 82 85 const trunk = cylinder({ radius: 1, radiusTop: 1, height: trunkHeight, segments: 5, heightSegments: 4 }) 83 86 .translate(0, trunkHeight / 2, 0) 84 87 .warp((pos) => { ··· 111 114 const mainR = actualCanopyRadius 112 115 const edgeLen = o.canopyRadius * o.canopyDetail 113 116 114 - const colorNoiseSeed = subSeed() 117 + const colorNoiseSeed = colorRng.seed() 115 118 116 119 function canopyBlob(r: number): Mesh { 117 - const noiseSeed = subSeed() 118 - const jitterSeed = subSeed() 120 + const noiseSeed = canopyRng.seed() 121 + const jitterSeed = canopyRng.seed() 119 122 const noise = new UberNoise({ seed: noiseSeed, scale: 0.5, octaves: 3 }) 120 123 let blob = icosphere({ radius: r, subdivisions: 0 }) 121 124 .subdivideAdaptive(edgeLen) ··· 134 137 const colorNoise = new UberNoise({ seed: colorNoiseSeed, scale: 1.5 }) 135 138 136 139 const hasSnow = o.snowColors.length > 0 137 - const snowNoiseSeed = subSeed() // always consume to keep sequence stable 138 - const snowNoise = hasSnow ? new UberNoise({ seed: snowNoiseSeed, scale: 2 }) : null 140 + const snowNoise = hasSnow ? new UberNoise({ seed: snowRng.seed(), scale: 2 }) : null 139 141 const snowThreshold = Math.sin(o.snowAngle * Math.PI / 180) 140 142 141 143 function blobFaceColor(): (centroid: [number, number, number], normal: [number, number, number], faceIndex: number) => [number, number, number] { 142 - // Pick colors upfront so we don't consume rand() calls inside the per-face loop 143 - const base = pickRandom(o.canopyColors, rand) 144 - // Always consume the rand() call to keep sequence stable regardless of snow setting 145 - const snowPick = pickRandom(hasSnow ? o.snowColors : o.canopyColors, rand) 146 - const snow = hasSnow ? snowPick : null 144 + // Pick colors upfront so we don't draw inside the per-face loop. Each draw comes 145 + // from its own stream, so snow being on/off never shifts the base canopy color. 146 + const base = pickRandom(o.canopyColors, colorRng) 147 + const snow = hasSnow ? pickRandom(o.snowColors, snowRng) : null 147 148 return (centroid, normal) => { 148 149 const top = normal[1] * 0.5 + 0.5 149 150 ··· 171 172 // Sub-blobs 172 173 const blobCount = o.canopyBumps 173 174 if (blobCount > 0) { 174 - const blobPositions = scatterOnSphere(blobCount, subSeed(), { 175 + const blobPositions = scatterOnSphere(blobCount, canopyRng.seed(), { 175 176 radius: mainR * 0.9, 176 177 polarMin: Math.PI * 0.3, 177 178 polarMax: Math.PI * 0.7, ··· 179 180 180 181 for (let i = 0; i < blobCount; i++) { 181 182 const [bx, by, bz] = blobPositions[i] 182 - const r = mainR * (o.bumpSize + rand() * 0.15) 183 + const r = mainR * (o.bumpSize + canopyRng() * 0.15) 183 184 184 185 const blob = canopyBlob(r) 185 186 .scale(1, o.canopySquash, 1)
+32 -31
src/generators/palm-tree.ts
··· 6 6 import { UberNoise } from '../noise' 7 7 import type { Mesh } from '../core/mesh' 8 8 import type { Vec3 } from '../core/types' 9 - import type { OptionSchema } from '../core/schema' 9 + import type { OptionSchema, OptionInput } from '../core/schema' 10 10 11 11 export const palmSchema = { 12 12 seed: { type: 'integer', default: 1, min: 1, max: 100, label: 'Seed' }, ··· 33 33 coconutColor: { type: 'color', default: '#3a2810', label: 'Coconut Color' }, 34 34 } satisfies OptionSchema 35 35 36 - export type PalmOptions = { 37 - [K in keyof typeof palmSchema]?: typeof palmSchema[K]['default'] 38 - } & { preset?: string } 36 + export type PalmOptions = Partial<OptionInput<typeof palmSchema>> & { preset?: string } 39 37 40 38 export const palmPresets: Record<string, Partial<PalmOptions>> = { 41 39 default: {}, ··· 60 58 } 61 59 62 60 export function palm(options: PalmOptions = {}): Mesh { 63 - const seed = options.seed ?? palmSchema.seed.default 64 - const rand = createRng(seed) 65 - const o = resolveOptions(palmSchema, options, palmPresets, rand) 61 + const seedOpt = options.seed ?? palmSchema.seed.default 62 + const seed = Array.isArray(seedOpt) ? seedOpt[0] : seedOpt 63 + const rng = createRng(seed) 64 + const o = resolveOptions(palmSchema, options, palmPresets, rng) 66 65 67 - function subSeed() { return Math.floor(rand() * 2147483647) } 66 + // Independent streams per concern (see common-tree for rationale). 67 + const trunkRng = rng.stream('trunk') 68 + const frondRng = rng.stream('fronds') 69 + const colorRng = rng.stream('color') 70 + const snowRng = rng.stream('snow') 71 + const coconutRng = rng.stream('coconut') 68 72 69 73 // --- Trunk path: curved from base to top --- 70 74 const trunkHeight = o.height * 0.75 71 - const baseRadius = o.trunkRadius * (1.3 + rand() * 0.4) 72 - const curveAngle = rand() * Math.PI * 2 75 + const baseRadius = o.trunkRadius * (1.3 + trunkRng() * 0.4) 76 + const curveAngle = trunkRng() * Math.PI * 2 73 77 const curveAmount = o.trunkCurve 74 78 const curveDirX = Math.cos(curveAngle) 75 79 const curveDirZ = Math.sin(curveAngle) ··· 99 103 }, 100 104 o.trunkSegments, 101 105 ) 102 - .jitter(baseRadius * 0.1, { seed: subSeed() }) 106 + .jitter(baseRadius * 0.1, { seed: trunkRng.seed() }) 103 107 .vertexColor((pos) => { 104 108 const t = Math.max(0, Math.min(1, pos[1] / trunkHeight)) 105 109 return paletteGradient(o.trunkColors)(t) ··· 110 114 const frondCount = o.fronds 111 115 const frondGrad = paletteGradient(o.frondColors) 112 116 113 - // Pre-allocate seeds 117 + // Per-frond jitter seeds from the frond stream 114 118 const maxFronds = 14 115 - const frondSeeds = Array.from({ length: maxFronds }, () => subSeed()) 116 - const colorNoiseSeed = subSeed() 117 - const snowNoiseSeed = subSeed() 119 + const frondSeeds = Array.from({ length: maxFronds }, () => frondRng.seed()) 118 120 119 - const colorNoise = new UberNoise({ seed: colorNoiseSeed, scale: 1.5 }) 121 + const colorNoise = new UberNoise({ seed: colorRng.seed(), scale: 1.5 }) 120 122 const hasSnow = o.snowColors.length > 0 121 - const snowNoise = hasSnow ? new UberNoise({ seed: snowNoiseSeed, scale: 2 }) : null 123 + const snowNoise = hasSnow ? new UberNoise({ seed: snowRng.seed(), scale: 2 }) : null 122 124 const snowThreshold = Math.sin(o.snowAngle * Math.PI / 180) 123 125 124 126 for (let i = 0; i < frondCount; i++) { 125 - const angle = (i / frondCount) * Math.PI * 2 + rand() * 0.5 126 - const frondLen = o.frondLength * (0.5 + rand() * 0.7) 127 - const droop = o.frondDroop * (0.3 + rand() * 1) 128 - const curveUp = o.frondCurveUp * (0.5 + rand() * 1.5) 129 - const width = o.frondWidth * (0.6 + rand() * 0.8) 130 - const startAngleUp = rand() * 0.6 // some fronds point more upward 127 + const angle = (i / frondCount) * Math.PI * 2 + frondRng() * 0.5 128 + const frondLen = o.frondLength * (0.5 + frondRng() * 0.7) 129 + const droop = o.frondDroop * (0.3 + frondRng() * 1) 130 + const curveUp = o.frondCurveUp * (0.5 + frondRng() * 1.5) 131 + const width = o.frondWidth * (0.6 + frondRng() * 0.8) 132 + const startAngleUp = frondRng() * 0.6 // some fronds point more upward 131 133 132 134 // Build frond path: starts at trunk top, arcs out and droops 133 135 const frondPath: Vec3[] = [] ··· 177 179 178 180 // Color 179 181 const base = frondGrad(i / frondCount) 180 - const snow = pickRandom(hasSnow ? o.snowColors : o.frondColors, rand) 181 - rand() // consume for stability 182 + const snow = hasSnow ? pickRandom(o.snowColors, snowRng) : null 182 183 183 184 const colored = frondMesh.faceColor((centroid, normal) => { 184 - if (hasSnow && snowNoise) { 185 + if (snow && snowNoise) { 185 186 const n = snowNoise.get(centroid[0], centroid[1], centroid[2]) * 0.15 186 187 if (normal[1] + n > snowThreshold) return snow 187 188 } ··· 198 199 const coconutParts: Mesh[] = [] 199 200 const coconutCount = o.coconuts 200 201 for (let i = 0; i < coconutCount; i++) { 201 - const angle = rand() * Math.PI * 2 202 + const angle = coconutRng() * Math.PI * 2 202 203 const topRadius = baseRadius * (1 - o.trunkTaper) 203 - const dist = topRadius * (1.5 + rand() * 1) 204 - const size = o.coconutSize * (0.7 + rand() * 0.6) 205 - const hangY = size * (0.3 + rand() * 0.8) 204 + const dist = topRadius * (1.5 + coconutRng() * 1) 205 + const size = o.coconutSize * (0.7 + coconutRng() * 0.6) 206 + const hangY = size * (0.3 + coconutRng() * 0.8) 206 207 const coconut = sphere({ radius: size, widthSegments: 4, heightSegments: 3 }) 207 - .scale(0.9 + rand() * 0.2, 1 + rand() * 0.3, 0.9 + rand() * 0.2) 208 + .scale(0.9 + coconutRng() * 0.2, 1 + coconutRng() * 0.3, 0.9 + coconutRng() * 0.2) 208 209 .translate( 209 210 topPt[0] + Math.cos(angle) * dist, 210 211 topPt[1] - hangY,
+28 -27
src/generators/pine-tree.ts
··· 5 5 import { paletteGradient, pickRandom } from '../color' 6 6 import { UberNoise } from '../noise' 7 7 import type { Mesh } from '../core/mesh' 8 - import type { OptionSchema } from '../core/schema' 8 + import type { OptionSchema, OptionInput } from '../core/schema' 9 9 10 10 export const pineSchema = { 11 11 seed: { type: 'integer', default: 1, min: 1, max: 100, label: 'Seed' }, ··· 35 35 canopyColors: { type: 'color-array', default: ['#0a2e12', '#0e3a18', '#144a22', '#1a5a2c'], min: 1, max: 8, label: 'Canopy Colors' }, 36 36 } satisfies OptionSchema 37 37 38 - export type PineOptions = { 39 - [K in keyof typeof pineSchema]?: typeof pineSchema[K]['default'] 40 - } & { preset?: string } 38 + export type PineOptions = Partial<OptionInput<typeof pineSchema>> & { preset?: string } 41 39 42 40 export const pinePresets: Record<string, Partial<PineOptions>> = { 43 41 default: {}, ··· 59 57 } 60 58 61 59 export function pine(options: PineOptions = {}): Mesh { 62 - const seed = options.seed ?? pineSchema.seed.default 63 - const rand = createRng(seed) 64 - const o = resolveOptions(pineSchema, options, pinePresets, rand) 60 + const seedOpt = options.seed ?? pineSchema.seed.default 61 + const seed = Array.isArray(seedOpt) ? seedOpt[0] : seedOpt 62 + const rng = createRng(seed) 63 + const o = resolveOptions(pineSchema, options, pinePresets, rng) 65 64 66 - function subSeed() { return Math.floor(rand() * 2147483647) } 65 + // Independent streams per concern (see common-tree for rationale). 66 + const trunkRng = rng.stream('trunk') 67 + const canopyRng = rng.stream('canopy') 68 + const colorRng = rng.stream('color') 69 + const snowRng = rng.stream('snow') 70 + const swayRng = rng.stream('sway') 67 71 68 72 // Trunk 69 - const baseRadius = o.trunkRadius * (1.4 + rand() * 0.4) 73 + const baseRadius = o.trunkRadius * (1.4 + trunkRng() * 0.4) 70 74 const topRadius = o.trunkRadius * o.trunkTopScale 71 75 const trunkHeight = o.height * o.trunkRatio 72 - const leanX = (rand() - 0.5) * o.lean 73 - const leanZ = (rand() - 0.5) * o.lean 76 + const leanX = (trunkRng() - 0.5) * o.lean 77 + const leanZ = (trunkRng() - 0.5) * o.lean 74 78 const trunkGrad = paletteGradient(o.trunkColors) 75 79 76 - const trunkNoise = new UberNoise({ seed: subSeed(), scale: o.trunkNoiseScale }) 80 + const trunkNoise = new UberNoise({ seed: trunkRng.seed(), scale: o.trunkNoiseScale }) 77 81 const trunk = cylinder({ radius: 1, radiusTop: 1, height: trunkHeight, segments: 5, heightSegments: 3 }) 78 82 .translate(0, trunkHeight / 2, 0) 79 83 .warp((pos) => { ··· 99 103 const canopyHeight = o.height - canopyStart 100 104 const layerCount = o.layers 101 105 102 - // Pre-allocate all seeds unconditionally 103 - const layerJitterSeeds = Array.from({ length: layerCount }, () => subSeed()) 104 - const colorNoiseSeed = subSeed() 105 - const snowNoiseSeed = subSeed() 106 + // Per-layer jitter seeds from the canopy stream 107 + const layerJitterSeeds = Array.from({ length: layerCount }, () => canopyRng.seed()) 106 108 107 109 const canopyGrad = paletteGradient(o.canopyColors) 108 - const colorNoise = new UberNoise({ seed: colorNoiseSeed, scale: o.colorNoiseScale }) 110 + const colorNoise = new UberNoise({ seed: colorRng.seed(), scale: o.colorNoiseScale }) 109 111 const hasSnow = o.snowColors.length > 0 110 - const snowNoise = hasSnow ? new UberNoise({ seed: snowNoiseSeed, scale: 2 }) : null 112 + const snowNoise = hasSnow ? new UberNoise({ seed: snowRng.seed(), scale: 2 }) : null 111 113 const snowThreshold = Math.sin(o.snowAngle * Math.PI / 180) 112 114 113 115 // Pre-compute layer sizes so we can stack proportionally 114 116 const layerRadii: number[] = [] 115 117 const layerHeights: number[] = [] 116 118 for (let i = 0; i < layerCount; i++) { 117 - const r = o.coneRadius * Math.pow(o.layerShrink, i) * (0.9 + rand() * 0.2) 119 + const r = o.coneRadius * Math.pow(o.layerShrink, i) * (0.9 + canopyRng() * 0.2) 118 120 layerRadii.push(r) 119 121 layerHeights.push(r * o.coneHeight) 120 122 } ··· 139 141 const lz = leanZ * lt * lt 140 142 141 143 // Pyramid cone with height segments, quadratic curve, random tilt 142 - const rotY = rand() * Math.PI * 2 143 - const tiltX = (rand() - 0.5) * o.coneTilt 144 - const tiltZ = (rand() - 0.5) * o.coneTilt 144 + const rotY = canopyRng() * Math.PI * 2 145 + const tiltX = (canopyRng() - 0.5) * o.coneTilt 146 + const tiltZ = (canopyRng() - 0.5) * o.coneTilt 145 147 let layer = cone({ radius: 1, height: layerH, segments: o.coneSides, heightSegments: 3 }) 146 148 .warp((pos) => { 147 149 // Quadratic curve: wider at the base than a straight cone ··· 157 159 158 160 // Face color 159 161 const base = canopyGrad(t) 160 - rand() // consume to keep sequence stable (was pickRandom) 161 - const snow = pickRandom(hasSnow ? o.snowColors : o.canopyColors, rand) 162 + const snow = hasSnow ? pickRandom(o.snowColors, snowRng) : null 162 163 163 164 layer = layer.faceColor((centroid, normal) => { 164 - if (hasSnow && snowNoise) { 165 + if (snow && snowNoise) { 165 166 const n = snowNoise.get(centroid[0], centroid[1], centroid[2]) * 0.15 166 167 if (normal[1] + n > snowThreshold) { 167 168 return snow ··· 178 179 } 179 180 180 181 // Noise-based sway: shift X/Z based on Y height for organic lean 181 - const swayNoiseX = new UberNoise({ seed: subSeed(), scale: o.swayScale }) 182 - const swayNoiseZ = new UberNoise({ seed: subSeed(), scale: o.swayScale }) 182 + const swayNoiseX = new UberNoise({ seed: swayRng.seed(), scale: o.swayScale }) 183 + const swayNoiseZ = new UberNoise({ seed: swayRng.seed(), scale: o.swayScale }) 183 184 184 185 return merge(trunk, ...canopyParts) 185 186 .warp((pos) => {
+2 -1
src/index.ts
··· 21 21 22 22 // Utilities 23 23 export { createRng } from './core/rng' 24 + export type { Rng } from './core/rng' 24 25 export { scatterOnSphere } from './core/scatter' 25 26 26 27 // Schema & options 27 28 export { resolveOptions } from './core/schema' 28 - export type { OptionSchema, OptionDef, OptionValues, Randomizable } from './core/schema' 29 + export type { OptionSchema, OptionDef, OptionValues, OptionInput, Randomizable } from './core/schema' 29 30 30 31 // Generators 31 32 export { tree, treeSchema, treePresets } from './generators'
+64
tests/generators.test.ts
··· 1 + import { describe, it, expect } from 'vitest' 2 + import { tree } from '../src/generators/common-tree' 3 + import { pine } from '../src/generators/pine-tree' 4 + import { palm } from '../src/generators/palm-tree' 5 + import type { Mesh } from '../src/core/mesh' 6 + 7 + const generators = [ 8 + { name: 'tree', gen: tree }, 9 + { name: 'pine', gen: pine }, 10 + { name: 'palm', gen: palm }, 11 + ] as const 12 + 13 + // Golden snapshots captured after the named-stream RNG migration. A change here means 14 + // generator output shifted — intentional changes should update these numbers deliberately. 15 + const golden: Record<string, { verts: number }> = { 16 + tree: { verts: 1830 }, 17 + pine: { verts: 840 }, 18 + palm: { verts: 3630 }, 19 + } 20 + 21 + function allFinite(m: Mesh): boolean { 22 + const p = m.positions 23 + for (let i = 0; i < p.length; i++) if (!Number.isFinite(p[i])) return false 24 + return true 25 + } 26 + 27 + describe.each(generators)('$name generator', ({ name, gen }) => { 28 + it('produces a valid, colored, finite mesh', () => { 29 + const m = gen({ seed: 1 }) 30 + expect(m.vertexCount).toBeGreaterThan(0) 31 + expect(m.colors).not.toBeNull() 32 + expect(allFinite(m)).toBe(true) 33 + }) 34 + 35 + it('matches the golden vertex count for seed 1', () => { 36 + expect(gen({ seed: 1 }).vertexCount).toBe(golden[name].verts) 37 + }) 38 + 39 + it('is deterministic: same seed → identical positions', () => { 40 + const a = gen({ seed: 7 }).positions 41 + const b = gen({ seed: 7 }).positions 42 + expect(Array.from(a)).toEqual(Array.from(b)) 43 + }) 44 + 45 + it('different seeds produce different geometry', () => { 46 + const a = gen({ seed: 1 }).positions 47 + const b = gen({ seed: 2 }).positions 48 + expect(Array.from(a)).not.toEqual(Array.from(b)) 49 + }) 50 + }) 51 + 52 + describe('stream independence at the model level', () => { 53 + // The headline benefit of named streams: a feature that only affects color (snow) 54 + // must not perturb geometry, because positions come from independent streams. 55 + it.each(generators)('$name: toggling snow leaves geometry byte-identical', ({ gen }) => { 56 + const bare = gen({ seed: 3, snowColors: [] }) 57 + const snowy = gen({ seed: 3, snowColors: ['#ffffff', '#eeeeee'] }) 58 + 59 + // Same geometry... 60 + expect(Array.from(snowy.positions)).toEqual(Array.from(bare.positions)) 61 + // ...but different coloring. 62 + expect(Array.from(snowy.colors!)).not.toEqual(Array.from(bare.colors!)) 63 + }) 64 + })
+123
tests/rng.test.ts
··· 1 + import { describe, it, expect } from 'vitest' 2 + import { createRng } from '../src/core/rng' 3 + 4 + describe('createRng', () => { 5 + it('is callable and returns floats in [0, 1)', () => { 6 + const rng = createRng(1) 7 + for (let i = 0; i < 100; i++) { 8 + const v = rng() 9 + expect(v).toBeGreaterThanOrEqual(0) 10 + expect(v).toBeLessThan(1) 11 + } 12 + }) 13 + 14 + it('is deterministic for the same seed', () => { 15 + const a = createRng(42) 16 + const b = createRng(42) 17 + const seqA = Array.from({ length: 10 }, () => a()) 18 + const seqB = Array.from({ length: 10 }, () => b()) 19 + expect(seqA).toEqual(seqB) 20 + }) 21 + 22 + it('differs across seeds', () => { 23 + const a = createRng(1) 24 + const b = createRng(2) 25 + expect(a()).not.toEqual(b()) 26 + }) 27 + 28 + it('accepts string seeds', () => { 29 + const a = createRng('hello') 30 + const b = createRng('hello') 31 + expect(a()).toEqual(b()) 32 + }) 33 + }) 34 + 35 + describe('Rng helpers', () => { 36 + it('int() is inclusive and within bounds', () => { 37 + const rng = createRng(7) 38 + const seen = new Set<number>() 39 + for (let i = 0; i < 1000; i++) { 40 + const v = rng.int(1, 6) 41 + expect(Number.isInteger(v)).toBe(true) 42 + expect(v).toBeGreaterThanOrEqual(1) 43 + expect(v).toBeLessThanOrEqual(6) 44 + seen.add(v) 45 + } 46 + // Should eventually hit both ends of the inclusive range 47 + expect(seen.has(1)).toBe(true) 48 + expect(seen.has(6)).toBe(true) 49 + }) 50 + 51 + it('float(min, max) stays within bounds', () => { 52 + const rng = createRng(3) 53 + for (let i = 0; i < 100; i++) { 54 + const v = rng.float(5, 10) 55 + expect(v).toBeGreaterThanOrEqual(5) 56 + expect(v).toBeLessThan(10) 57 + } 58 + }) 59 + 60 + it('range() passes through fixed values and resolves tuples', () => { 61 + const rng = createRng(9) 62 + expect(rng.range(2.5)).toBe(2.5) 63 + const v = rng.range([0, 4]) 64 + expect(v).toBeGreaterThanOrEqual(0) 65 + expect(v).toBeLessThanOrEqual(4) 66 + const n = rng.range([0, 4], true) 67 + expect(Number.isInteger(n)).toBe(true) 68 + }) 69 + 70 + it('pick() returns an element of the array', () => { 71 + const rng = createRng(11) 72 + const arr = ['a', 'b', 'c'] 73 + for (let i = 0; i < 50; i++) { 74 + expect(arr).toContain(rng.pick(arr)) 75 + } 76 + }) 77 + 78 + it('seed() produces deterministic integer seeds', () => { 79 + const a = createRng(5) 80 + const b = createRng(5) 81 + expect(a.seed()).toBe(b.seed()) 82 + expect(Number.isInteger(a.seed())).toBe(true) 83 + }) 84 + }) 85 + 86 + describe('named streams', () => { 87 + it('a stream is deterministic for the same name', () => { 88 + const rootA = createRng(1) 89 + const rootB = createRng(1) 90 + const seqA = Array.from({ length: 5 }, () => rootA.stream('canopy')()) 91 + // Re-derive each time — same name + same root => same first value 92 + expect(rootB.stream('canopy')()).toEqual(rootA.stream('canopy')()) 93 + expect(seqA.every((v) => v >= 0 && v < 1)).toBe(true) 94 + }) 95 + 96 + it('different stream names are independent', () => { 97 + const root = createRng(1) 98 + expect(root.stream('a')()).not.toEqual(root.stream('b')()) 99 + }) 100 + 101 + it('consuming one stream does not perturb another (the key property)', () => { 102 + // Baseline: read canopy without touching snow at all. 103 + const root1 = createRng(99) 104 + const canopy1 = root1.stream('canopy') 105 + const baseline = [canopy1(), canopy1(), canopy1()] 106 + 107 + // Now heavily consume an unrelated stream first, then read canopy. 108 + const root2 = createRng(99) 109 + const snow2 = root2.stream('snow') 110 + for (let i = 0; i < 50; i++) snow2() 111 + const canopy2 = root2.stream('canopy') 112 + const after = [canopy2(), canopy2(), canopy2()] 113 + 114 + expect(after).toEqual(baseline) 115 + }) 116 + 117 + it('fork() yields independent anonymous streams', () => { 118 + const root = createRng(1) 119 + const f1 = root.fork() 120 + const f2 = root.fork() 121 + expect(f1()).not.toEqual(f2()) 122 + }) 123 + })