From 44db9f11ff061d373ae5e940077e8215d47ba728 Mon Sep 17 00:00:00 2001 From: Florian <45694132+flo-bit@users.noreply.github.com> Date: Tue, 9 Jun 2026 22:15:59 +0200 Subject: [PATCH] commit --- plan-v0.md | 701 ++++++++++++++++++++++++++++++ plan.md | 782 +++++----------------------------- src/color/palette.ts | 9 +- src/core/rng.ts | 69 ++- src/core/schema.ts | 15 + src/generators/common-tree.ts | 55 +-- src/generators/palm-tree.ts | 65 +-- src/generators/pine-tree.ts | 57 +-- src/index.ts | 3 +- tests/generators.test.ts | 64 +++ tests/rng.test.ts | 123 ++++++ 11 files changed, 1180 insertions(+), 763 deletions(-) create mode 100644 plan-v0.md create mode 100644 tests/generators.test.ts create mode 100644 tests/rng.test.ts diff --git a/plan-v0.md b/plan-v0.md new file mode 100644 index 0000000..475d16e --- /dev/null +++ b/plan-v0.md @@ -0,0 +1,701 @@ +# Shapecraft — Implementation Plan + +## Overview + +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. + +**Key design principles:** +- Generators are just functions that return meshes. No class hierarchies, no registration. +- Composition = calling functions inside functions. +- Immutable by default — every transform/modifier returns a new Mesh. +- Three.js is the internal engine but the API should feel library-agnostic where possible. +- The package name is "shapecraft" but avoid hardcoding it deeply — keep it easy to rename. + +--- + +## Tech Stack + +- **Language:** TypeScript (strict) +- **3D Engine:** Three.js (peer dependency — user provides it) +- **Noise:** Existing UberNoise library (bundled, see `uber-noise.ts` and its deps `simplex-noise/` and `alea/`) +- **Build:** Vite (library mode for the package, dev server for demos) +- **Test:** Vitest +- **Package:** Single flat package, subpath exports for tree-shaking (`shapecraft`, `shapecraft/noise`, `shapecraft/three`, etc.) + +--- + +## Project Structure + +``` +shapecraft/ +├── package.json +├── tsconfig.json +├── vite.config.ts +├── vitest.config.ts +├── index.ts # Main barrel export +├── src/ +│ ├── mesh.ts # Core Mesh class +│ ├── types.ts # Shared types (Vec3, Color, etc.) +│ ├── math.ts # Vec3/Mat4 helpers (thin wrappers over THREE) +│ ├── primitives/ +│ │ ├── index.ts +│ │ ├── box.ts +│ │ ├── sphere.ts +│ │ ├── cylinder.ts +│ │ ├── plane.ts +│ │ ├── torus.ts +│ │ └── cone.ts +│ ├── ops/ +│ │ ├── index.ts +│ │ ├── merge.ts # Combine multiple meshes into one +│ │ ├── clone.ts # Deep clone a mesh +│ │ └── center.ts # Recenter mesh to origin +│ ├── modifiers/ +│ │ ├── index.ts +│ │ ├── twist.ts +│ │ ├── bend.ts +│ │ ├── taper.ts +│ │ ├── lattice.ts # Simple lattice deform +│ │ └── smooth.ts # Laplacian smooth +│ ├── noise/ +│ │ ├── index.ts # Re-exports UberNoise + helpers +│ │ ├── uber-noise.ts # Existing UberNoise (verbatim, with imports fixed) +│ │ ├── simplex-noise/ # Existing simplex-noise dependency +│ │ │ └── simplex-noise.ts +│ │ ├── alea/ # Existing alea dependency +│ │ │ └── alea.ts +│ │ └── helpers.ts # Convenience: fbm(), ridged(), etc. that return UberNoise instances +│ ├── color/ +│ │ ├── index.ts +│ │ ├── vertex-color.ts # Apply vertex colors (flat or procedural fn) +│ │ ├── face-color.ts # Apply per-face colors +│ │ ├── gradient.ts # Height/angle gradient helpers +│ │ └── utils.ts # Color parsing, lerp, hex<->rgb +│ ├── uv/ +│ │ ├── index.ts +│ │ ├── projections.ts # Box, planar, cylindrical, spherical UV projection +│ │ └── textures.ts # Procedural texture generators (checkerboard, wood, etc.) — STUB for v0 +│ ├── three/ +│ │ ├── index.ts +│ │ ├── to-three.ts # Mesh → THREE.Mesh / THREE.BufferGeometry +│ │ └── from-three.ts # THREE.BufferGeometry → Mesh +│ └── worker/ +│ ├── index.ts +│ └── pool.ts # STUB for v0 — types + placeholder +├── demo/ +│ ├── index.html +│ ├── main.ts # Vite dev entry — renders demo scene +│ └── generators/ +│ ├── chair.ts # Example generator: a simple chair +│ ├── table.ts # Example generator: table with chairs +│ └── terrain.ts # Example generator: noise terrain +└── tests/ + ├── mesh.test.ts + ├── primitives.test.ts + ├── ops.test.ts + ├── modifiers.test.ts + └── noise.test.ts +``` + +--- + +## Phase 1: Foundation + +### 1.1 — Project setup + +- Init `package.json` with: + - `name: "shapecraft"` + - `type: "module"` + - `peerDependencies: { "three": ">=0.150.0" }` + - `devDependencies: { "three": "^0.170.0", "@types/three": "...", "typescript": "...", "vite": "...", "vitest": "..." }` + - `exports` field with subpath exports: + ```json + { + ".": "./src/index.ts", + "./noise": "./src/noise/index.ts", + "./three": "./src/three/index.ts", + "./modifiers": "./src/modifiers/index.ts", + "./ops": "./src/ops/index.ts", + "./color": "./src/color/index.ts", + "./uv": "./src/uv/index.ts" + } + ``` + - Scripts: `"dev": "vite demo"`, `"build": "vite build"`, `"test": "vitest"` +- `tsconfig.json`: strict, ESNext, paths alias `@/*` → `./src/*` +- `vite.config.ts`: library mode config (entry `src/index.ts`, external `three`) +- `vitest.config.ts`: basic setup + +### 1.2 — Types (`src/types.ts`) + +```ts +export type Vec2 = [number, number] +export type Vec3 = [number, number, number] +export type Vec4 = [number, number, number, number] +export type ColorInput = string | Vec3 | Vec4 | number // hex string, rgb tuple, rgba tuple, or 0xRRGGBB +export type ColorFn = (position: Vec3, normal: Vec3, index: number) => ColorInput +export type DisplaceFn = (position: Vec3, normal: Vec3, uv: Vec2 | null, index: number) => number +export type WarpFn = (position: Vec3, index: number) => Vec3 +export type NoiseLike = { get(x: number, y?: number, z?: number): number } +``` + +### 1.3 — Core Mesh class (`src/mesh.ts`) + +This is the most important file. The Mesh wraps a `THREE.BufferGeometry` internally. + +```ts +import * as THREE from 'three' + +class Mesh { + /** Internal geometry — users CAN access this but the API doesn't require it */ + readonly geometry: THREE.BufferGeometry + + constructor(geometry: THREE.BufferGeometry) + + // --- Accessors (read from geometry attributes) --- + get positions(): Float32Array + get indices(): Uint32Array | Uint16Array | null + get normals(): Float32Array | null + get uvs(): Float32Array | null + get colors(): Float32Array | null + get vertexCount(): number + get faceCount(): number + get boundingBox(): THREE.Box3 + + // --- Transforms (all return new Mesh, geometry is cloned) --- + translate(x: number, y: number, z: number): Mesh + rotate(axis: Vec3 | 'x' | 'y' | 'z', angle: number): Mesh + rotateX(angle: number): Mesh + rotateY(angle: number): Mesh + rotateZ(angle: number): Mesh + scale(x: number, y?: number, z?: number): Mesh + transform(matrix: THREE.Matrix4): Mesh + + // --- Modifiers (return new Mesh) --- + displace(fn: DisplaceFn): Mesh + displaceNoise(noise: NoiseLike, amplitude?: number): Mesh + warp(fn: WarpFn): Mesh + subdivide(iterations?: number): Mesh // uses THREE's subdivision if available, else loop subdivision + computeNormals(): Mesh + + // --- Coloring (return new Mesh) --- + vertexColor(color: ColorInput | ColorFn): Mesh + faceColor(fn: (centroid: Vec3, normal: Vec3, faceIndex: number) => ColorInput): Mesh + + // --- UV (return new Mesh) --- + computeUVs(projection?: 'box' | 'planar' | 'cylindrical' | 'spherical'): Mesh + + // --- Utility --- + center(): Mesh + clone(): Mesh + + // --- Serialization (for future worker support) --- + serialize(): ArrayBuffer + static deserialize(buffer: ArrayBuffer): Mesh +} +``` + +**Implementation notes:** +- Every method that returns a new Mesh should: clone the geometry, apply the operation to the clone, return `new Mesh(clone)`. +- Use a private helper `cloneGeometry()` that does `geometry.clone()` and ensures all attributes are properly copied. +- `displace(fn)`: iterate vertices, call fn with position + normal + uv, move vertex along its normal by the returned amount. Recompute normals after. +- `warp(fn)`: iterate vertices, replace position with fn result. Recompute normals after. +- `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). +- `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. +- `serialize()`/`deserialize()`: pack all typed arrays into a single ArrayBuffer with a simple header (attribute count, sizes). This is for future worker support. + +### 1.4 — Math utilities (`src/math.ts`) + +Thin wrappers — don't reimplement, use THREE internally: + +```ts +import * as THREE from 'three' + +export function vec3(x: number, y: number, z: number): THREE.Vector3 +export function mat4(): THREE.Matrix4 +export function makeTranslation(x: number, y: number, z: number): THREE.Matrix4 +export function makeRotation(axis: Vec3 | 'x' | 'y' | 'z', angle: number): THREE.Matrix4 +export function makeScale(x: number, y: number, z: number): THREE.Matrix4 +export function parseColor(input: ColorInput): THREE.Color +``` + +--- + +## Phase 2: Primitives + +All primitives are **functions** that return a `Mesh`. They use Three.js geometry constructors internally. + +### `src/primitives/box.ts` +```ts +export interface BoxOptions { + width?: number // default 1 + height?: number // default 1 + depth?: number // default 1 + // OR shorthand: + size?: Vec3 | number // overrides width/height/depth + widthSegments?: number + heightSegments?: number + depthSegments?: number +} +export function box(options?: BoxOptions): Mesh +``` +Internally: `new THREE.BoxGeometry(...)` → wrap in Mesh. + +### `src/primitives/sphere.ts` +```ts +export interface SphereOptions { + radius?: number // default 0.5 + widthSegments?: number // default 16 + heightSegments?: number // default 12 +} +export function sphere(options?: SphereOptions): Mesh +``` + +### `src/primitives/cylinder.ts` +```ts +export interface CylinderOptions { + radius?: number // default 0.5 (sets both top and bottom) + radiusTop?: number // overrides radius for top + radiusBottom?: number // overrides radius for bottom + height?: number // default 1 + segments?: number // default 16 +} +export function cylinder(options?: CylinderOptions): Mesh +``` + +### `src/primitives/plane.ts` +```ts +export interface PlaneOptions { + width?: number // default 1 + height?: number // default 1 + // OR shorthand: + size?: number | Vec2 // overrides width/height + widthSegments?: number // default 1 + heightSegments?: number // default 1 + // OR shorthand: + segments?: number | Vec2 // overrides both segment counts +} +export function plane(options?: PlaneOptions): Mesh +``` +**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. + +### `src/primitives/cone.ts` +```ts +export interface ConeOptions { + radius?: number + height?: number + segments?: number +} +export function cone(options?: ConeOptions): Mesh +``` + +### `src/primitives/torus.ts` +```ts +export interface TorusOptions { + radius?: number + tube?: number + radialSegments?: number + tubularSegments?: number +} +export function torus(options?: TorusOptions): Mesh +``` + +### `src/primitives/index.ts` +Re-export all primitives. + +--- + +## Phase 3: Operations + +### `src/ops/merge.ts` +```ts +export function merge(...meshes: Mesh[]): Mesh +``` +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. + +**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. + +### `src/ops/center.ts` +```ts +export function center(mesh: Mesh): Mesh +``` +Compute bounding box center, translate by negative center. + +### `src/ops/clone.ts` +```ts +export function clone(mesh: Mesh): Mesh // just calls mesh.clone() +``` + +--- + +## Phase 4: Modifiers + +Modifiers are standalone functions that return `WarpFn` or can be applied directly. Two patterns: + +**Pattern A — warp functions** (for use with `mesh.warp(fn)`): +```ts +// Returns a WarpFn: (position: Vec3) => Vec3 +export function twist(options: { axis?: 'x' | 'y' | 'z', amount: number }): WarpFn +export function bend(options: { axis?: 'x' | 'y' | 'z', amount: number }): WarpFn +export function taper(options: { axis?: 'x' | 'y' | 'z', curve?: (t: number) => number }): WarpFn +``` + +**Pattern B — mesh-in mesh-out** (for complex operations): +```ts +export function smooth(mesh: Mesh, iterations?: number): Mesh +export function subdivide(mesh: Mesh, iterations?: number): Mesh +``` + +### `src/modifiers/twist.ts` +Rotate vertices around an axis proportional to their position along that axis. +``` +angle = position[axis] * amount +rotate position around axis by angle +``` + +### `src/modifiers/bend.ts` +Curve vertices along an axis. Map position along axis to an arc. + +### `src/modifiers/taper.ts` +Scale vertices perpendicular to an axis based on a curve function of their position along the axis. +``` +t = normalize position along axis to [0, 1] +scale = curve(t) +position[perpendicular axes] *= scale +``` + +### `src/modifiers/smooth.ts` +Laplacian smoothing: for each vertex, move it toward the average of its neighbors. Requires building an adjacency map from the index buffer. + +### `src/modifiers/lattice.ts` +STUB for v0. Just export the type and a placeholder that returns the input unchanged. + +--- + +## Phase 5: Noise Integration + +### Copy existing code +Copy the user's existing noise implementation into `src/noise/`: +- `src/noise/uber-noise.ts` — the UberNoise class (adjust imports to use relative paths within the package) +- `src/noise/simplex-noise/simplex-noise.ts` — existing simplex noise +- `src/noise/alea/alea.ts` — existing alea PRNG + +**Important:** Remove the `globalThis` assignments at the bottom of `uber-noise.ts`. We don't want to pollute globals — everything is imported. + +### `src/noise/helpers.ts` +Convenience factory functions that create pre-configured UberNoise instances: + +```ts +import { UberNoise, type NoiseOptions } from './uber-noise' + +/** Basic simplex noise */ +export function simplex(options?: Partial): UberNoise + +/** FBM (fractional Brownian motion) with sensible defaults */ +export function fbm(options?: Partial & { octaves?: number }): UberNoise +// Default: octaves 4, lacunarity 2, gain 0.5 + +/** Ridged noise */ +export function ridged(options?: Partial): UberNoise +// Sets sharpness: -1 + +/** Billowed noise */ +export function billowed(options?: Partial): UberNoise +// Sets sharpness: 1 + +/** Stepped/terraced noise */ +export function stepped(steps: number, options?: Partial): UberNoise +// Sets steps + +/** Warped noise */ +export function warped(amount: number, options?: Partial): UberNoise +// Sets warp amount +``` + +### `src/noise/index.ts` +```ts +export { UberNoise, type NoiseOptions } from './uber-noise' +export { simplex, fbm, ridged, billowed, stepped, warped } from './helpers' +``` + +### Integration with Mesh + +`mesh.displaceNoise(noise, amplitude)` should accept an `UberNoise` instance (or anything with a `.get(x, y, z)` method): + +```ts +displaceNoise(noise: NoiseLike, amplitude: number = 1): Mesh { + return this.displace((pos, normal) => { + return noise.get(pos[0], pos[1], pos[2]) * amplitude + }) +} +``` + +This keeps the coupling loose — any object with `get(x, y?, z?)` works. + +--- + +## Phase 6: Color & UV + +### `src/color/utils.ts` +```ts +export function parseColor(input: ColorInput): [number, number, number] // rgb 0-1 +export function lerpColor(a: ColorInput, b: ColorInput, t: number): [number, number, number] +export function hexToRgb(hex: string): [number, number, number] +export function rgbToHex(r: number, g: number, b: number): string +``` +Use `THREE.Color` internally for parsing. + +### `src/color/gradient.ts` +```ts +export type GradientStop = [number, ColorInput] // [threshold, color] + +/** Create a function that maps a value to a color based on gradient stops */ +export function gradient(stops: GradientStop[]): (value: number) => [number, number, number] + +/** Shorthand for height-based gradient (maps y position to color) */ +export function heightGradient(stops: GradientStop[]): ColorFn +``` + +### `src/color/vertex-color.ts` +Implementation of `Mesh.vertexColor()`: +- If given a flat color, set all vertex colors to that color. +- If given a function, iterate all vertices, call the function with (position, normal, vertexIndex), set the color attribute. + +### `src/color/face-color.ts` +Implementation of `Mesh.faceColor()`: +- Call `geometry.toNonIndexed()` first (un-share vertices). +- Iterate face by face (every 3 vertices), compute centroid and normal, call fn, set all 3 vertex colors. + +### `src/uv/projections.ts` +```ts +export function projectUVs(geometry: THREE.BufferGeometry, mode: 'box' | 'planar' | 'cylindrical' | 'spherical'): void +``` +Modifies geometry in place (called on a clone inside `Mesh.computeUVs()`). + +- **planar**: project from Y axis down onto XZ plane. `u = x`, `v = z` (normalized to bounding box). +- **box**: tri-planar — pick projection axis per face based on face normal, project from that axis. +- **cylindrical**: `u = atan2(z, x) / (2π)`, `v = y` (normalized). +- **spherical**: `u = atan2(z, x) / (2π)`, `v = acos(y/r) / π`. + +### `src/uv/textures.ts` +STUB for v0. Export types and a couple basic functions: +```ts +export function checkerboard(size?: number): (u: number, v: number) => [number, number, number] +``` +Full procedural texture system is post-v0. + +--- + +## Phase 7: Three.js Bridge + +### `src/three/to-three.ts` +```ts +import * as THREE from 'three' +import type { Mesh } from '../mesh' + +/** Convert a shapecraft Mesh to a THREE.Mesh ready to add to a scene */ +export function toThreeMesh(mesh: Mesh, options?: { + material?: THREE.Material + flatShading?: boolean // default true for low-poly look + wireframe?: boolean +}): THREE.Mesh + +/** Get just the geometry (if user wants to provide their own material) */ +export function toThreeGeometry(mesh: Mesh): THREE.BufferGeometry +``` + +`toThreeMesh` implementation: +- If mesh has vertex colors, use `THREE.MeshStandardMaterial({ vertexColors: true, flatShading })`. +- If no vertex colors, use `THREE.MeshStandardMaterial({ color: 0xcccccc, flatShading })`. +- If wireframe requested, set `material.wireframe = true`. +- The geometry is already a `THREE.BufferGeometry` internally, so just reference it (or clone if immutability is desired). + +### `src/three/from-three.ts` +```ts +/** Import an existing Three.js geometry into shapecraft */ +export function fromThreeGeometry(geometry: THREE.BufferGeometry): Mesh +``` + +--- + +## Phase 8: Worker Support (STUB) + +### `src/worker/pool.ts` +For v0, just define the interface and a simple synchronous fallback: + +```ts +export interface WorkerPoolOptions { + maxWorkers?: number +} + +export class WorkerPool { + constructor(options?: WorkerPoolOptions) + + /** Run a generator function in a worker. For v0, runs synchronously. */ + async run(fn: (...args: any[]) => Mesh, ...args: any[]): Promise + + /** Batch run multiple generators. For v0, runs sequentially. */ + async batch(tasks: Array<[Function, ...any[]]>): Promise + + dispose(): void +} +``` + +The v0 implementation just calls the functions directly. Real worker support comes later and will use `Mesh.serialize()`/`deserialize()` with `Transferable`. + +--- + +## Phase 9: Demo + +### `demo/index.html` +Basic HTML shell that loads `demo/main.ts` via Vite. + +### `demo/main.ts` +Sets up a Three.js scene with: +- Renderer, camera, controls (OrbitControls) +- Ambient light + directional light +- Calls all three demo generators +- Adds them to the scene +- Animation loop + +### `demo/generators/chair.ts` +```ts +export function chair(options?: { seatHeight?: number, legRadius?: number }): Mesh +``` +A simple 4-legged chair: +- Box for seat +- 4 cylinders for legs +- Box for back +- Vertex colored brown tones +- Returns merged mesh + +### `demo/generators/table.ts` +```ts +export function diningSet(options?: { chairs?: number, tableRadius?: number }): Mesh +``` +- Cylinder for table top +- 4 cylinder legs +- N chairs arranged in a circle using the `chair()` generator +- Demonstrates composition + +### `demo/generators/terrain.ts` +```ts +export function terrain(options?: { size?: number, segments?: number, seed?: number }): Mesh +``` +- Large subdivided plane +- Displaced with UberNoise (fbm, maybe ridged mix) +- Height-based vertex coloring (green → brown → gray → white) +- Demonstrates noise integration + +--- + +## Phase 10: Tests + +### `tests/mesh.test.ts` +- Construction from geometry +- Transforms: translate, rotate, scale produce correct vertex positions +- Immutability: original mesh unchanged after transform +- `vertexCount` and `faceCount` correct +- `clone()` produces independent copy + +### `tests/primitives.test.ts` +- Each primitive returns a Mesh +- Vertex counts are correct for given parameters +- Default options produce reasonable geometry +- `size` shorthand works for box and plane + +### `tests/ops.test.ts` +- `merge()` combines vertex counts correctly +- `merge()` handles mixed color/no-color meshes (fills missing with white) +- `center()` puts bounding box center at origin + +### `tests/modifiers.test.ts` +- `twist()` modifies positions (not identity) +- `taper()` scales vertices correctly at extremes +- `displace()` moves vertices along normals +- `displaceNoise()` produces non-zero displacement + +### `tests/noise.test.ts` +- UberNoise produces values in expected range +- `fbm()` helper returns configured UberNoise +- `ridged()` produces values with expected characteristics +- Seeded noise is deterministic + +--- + +## Implementation Order for Claude Code + +Follow this order. Each step should be completable and testable before moving to the next. + +1. **Project scaffolding** — `package.json`, `tsconfig.json`, `vite.config.ts`, `vitest.config.ts`, directory structure, install deps. + +2. **Types + Math** — `src/types.ts`, `src/math.ts`. + +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. + +4. **Primitives** — All 6 primitives. Write `tests/primitives.test.ts`. At this point you can already do `box().translate(1,0,0)`. + +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))`. + +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`. + +7. **Displace + warp on Mesh** — Implement `mesh.displace()`, `mesh.displaceNoise()`, `mesh.warp()`. These depend on noise being available for testing. + +8. **Modifiers** — `twist`, `bend`, `taper`, `smooth` (lattice as stub). Write `tests/modifiers.test.ts`. + +9. **Color system** — `src/color/*`. Implement `mesh.vertexColor()` and `mesh.faceColor()`, plus gradient helpers. + +10. **UV projections** — `src/uv/projections.ts`, implement `mesh.computeUVs()`. Texture stubs. + +11. **Three.js bridge** — `src/three/to-three.ts`, `src/three/from-three.ts`. + +12. **Worker stubs** — `src/worker/pool.ts`. + +13. **Main barrel exports** — `src/index.ts` re-exporting everything. + +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. + +15. **Final pass** — Run all tests, fix any issues, make sure `npm run dev` launches the demo and it looks good. + +--- + +## API Surface Summary (what gets exported) + +```ts +// shapecraft (main) +export { Mesh } from './mesh' +export { box, sphere, cylinder, plane, cone, torus } from './primitives' +export { merge, center, clone } from './ops' +export { twist, bend, taper, smooth, subdivide } from './modifiers' +export { vertexColor, faceColor, gradient, heightGradient, lerpColor } from './color' +export { projectUVs } from './uv' + +// shapecraft/noise +export { UberNoise, simplex, fbm, ridged, billowed, stepped, warped } from './noise' + +// shapecraft/three +export { toThreeMesh, toThreeGeometry, fromThreeGeometry } from './three' +``` + +--- + +## Notes & Gotchas for the Implementer + +1. **Always clone geometry before modifying.** Every Mesh method that modifies geometry must clone first. Never mutate the internal geometry of an existing Mesh. + +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). + +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. + +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. + +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. + +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. + +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. + +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. + +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. + +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. \ No newline at end of file diff --git a/plan.md b/plan.md index 475d16e..ac91713 100644 --- a/plan.md +++ b/plan.md @@ -1,701 +1,149 @@ -# Shapecraft — Implementation Plan +# Shapecraft — 6-Month Roadmap (v1) -## Overview +> The original v0 build plan lives in [`plan-v0.md`](./plan-v0.md). This document is the +> forward-looking strategy: improving the models we have, making many more, and growing the +> library to support them. -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. - -**Key design principles:** -- Generators are just functions that return meshes. No class hierarchies, no registration. -- Composition = calling functions inside functions. -- Immutable by default — every transform/modifier returns a new Mesh. -- Three.js is the internal engine but the API should feel library-agnostic where possible. -- The package name is "shapecraft" but avoid hardcoding it deeply — keep it easy to rename. +**Team & horizon:** 4 people, full-time, 6 months (~24 person-months). --- -## Tech Stack - -- **Language:** TypeScript (strict) -- **3D Engine:** Three.js (peer dependency — user provides it) -- **Noise:** Existing UberNoise library (bundled, see `uber-noise.ts` and its deps `simplex-noise/` and `alea/`) -- **Build:** Vite (library mode for the package, dev server for demos) -- **Test:** Vitest -- **Package:** Single flat package, subpath exports for tree-shaking (`shapecraft`, `shapecraft/noise`, `shapecraft/three`, etc.) - ---- +## Where we are -## Project Structure - -``` -shapecraft/ -├── package.json -├── tsconfig.json -├── vite.config.ts -├── vitest.config.ts -├── index.ts # Main barrel export -├── src/ -│ ├── mesh.ts # Core Mesh class -│ ├── types.ts # Shared types (Vec3, Color, etc.) -│ ├── math.ts # Vec3/Mat4 helpers (thin wrappers over THREE) -│ ├── primitives/ -│ │ ├── index.ts -│ │ ├── box.ts -│ │ ├── sphere.ts -│ │ ├── cylinder.ts -│ │ ├── plane.ts -│ │ ├── torus.ts -│ │ └── cone.ts -│ ├── ops/ -│ │ ├── index.ts -│ │ ├── merge.ts # Combine multiple meshes into one -│ │ ├── clone.ts # Deep clone a mesh -│ │ └── center.ts # Recenter mesh to origin -│ ├── modifiers/ -│ │ ├── index.ts -│ │ ├── twist.ts -│ │ ├── bend.ts -│ │ ├── taper.ts -│ │ ├── lattice.ts # Simple lattice deform -│ │ └── smooth.ts # Laplacian smooth -│ ├── noise/ -│ │ ├── index.ts # Re-exports UberNoise + helpers -│ │ ├── uber-noise.ts # Existing UberNoise (verbatim, with imports fixed) -│ │ ├── simplex-noise/ # Existing simplex-noise dependency -│ │ │ └── simplex-noise.ts -│ │ ├── alea/ # Existing alea dependency -│ │ │ └── alea.ts -│ │ └── helpers.ts # Convenience: fbm(), ridged(), etc. that return UberNoise instances -│ ├── color/ -│ │ ├── index.ts -│ │ ├── vertex-color.ts # Apply vertex colors (flat or procedural fn) -│ │ ├── face-color.ts # Apply per-face colors -│ │ ├── gradient.ts # Height/angle gradient helpers -│ │ └── utils.ts # Color parsing, lerp, hex<->rgb -│ ├── uv/ -│ │ ├── index.ts -│ │ ├── projections.ts # Box, planar, cylindrical, spherical UV projection -│ │ └── textures.ts # Procedural texture generators (checkerboard, wood, etc.) — STUB for v0 -│ ├── three/ -│ │ ├── index.ts -│ │ ├── to-three.ts # Mesh → THREE.Mesh / THREE.BufferGeometry -│ │ └── from-three.ts # THREE.BufferGeometry → Mesh -│ └── worker/ -│ ├── index.ts -│ └── pool.ts # STUB for v0 — types + placeholder -├── demo/ -│ ├── index.html -│ ├── main.ts # Vite dev entry — renders demo scene -│ └── generators/ -│ ├── chair.ts # Example generator: a simple chair -│ ├── table.ts # Example generator: table with chairs -│ └── terrain.ts # Example generator: noise terrain -└── tests/ - ├── mesh.test.ts - ├── primitives.test.ts - ├── ops.test.ts - ├── modifiers.test.ts - └── noise.test.ts -``` +Shapecraft is a procedural 3D model generation library for the browser: an immutable, +functional `Mesh` API over Three.js, with a stylized low-poly toolbox (noise, palettes, +face/vertex color, loft/tube/thicken, adaptive subdivision, jitter) and a **schema-driven +editor** — each generator declares its options and gets a live UI, presets, and +range-randomization for free. ---- +We ship **three models** today: common tree, pine, palm. All vegetation, all flat-shaded +vertex-colored. -## Phase 1: Foundation - -### 1.1 — Project setup - -- Init `package.json` with: - - `name: "shapecraft"` - - `type: "module"` - - `peerDependencies: { "three": ">=0.150.0" }` - - `devDependencies: { "three": "^0.170.0", "@types/three": "...", "typescript": "...", "vite": "...", "vitest": "..." }` - - `exports` field with subpath exports: - ```json - { - ".": "./src/index.ts", - "./noise": "./src/noise/index.ts", - "./three": "./src/three/index.ts", - "./modifiers": "./src/modifiers/index.ts", - "./ops": "./src/ops/index.ts", - "./color": "./src/color/index.ts", - "./uv": "./src/uv/index.ts" - } - ``` - - Scripts: `"dev": "vite demo"`, `"build": "vite build"`, `"test": "vitest"` -- `tsconfig.json`: strict, ESNext, paths alias `@/*` → `./src/*` -- `vite.config.ts`: library mode config (entry `src/index.ts`, external `three`) -- `vitest.config.ts`: basic setup - -### 1.2 — Types (`src/types.ts`) - -```ts -export type Vec2 = [number, number] -export type Vec3 = [number, number, number] -export type Vec4 = [number, number, number, number] -export type ColorInput = string | Vec3 | Vec4 | number // hex string, rgb tuple, rgba tuple, or 0xRRGGBB -export type ColorFn = (position: Vec3, normal: Vec3, index: number) => ColorInput -export type DisplaceFn = (position: Vec3, normal: Vec3, uv: Vec2 | null, index: number) => number -export type WarpFn = (position: Vec3, index: number) => Vec3 -export type NoiseLike = { get(x: number, y?: number, z?: number): number } -``` - -### 1.3 — Core Mesh class (`src/mesh.ts`) - -This is the most important file. The Mesh wraps a `THREE.BufferGeometry` internally. - -```ts -import * as THREE from 'three' - -class Mesh { - /** Internal geometry — users CAN access this but the API doesn't require it */ - readonly geometry: THREE.BufferGeometry - - constructor(geometry: THREE.BufferGeometry) - - // --- Accessors (read from geometry attributes) --- - get positions(): Float32Array - get indices(): Uint32Array | Uint16Array | null - get normals(): Float32Array | null - get uvs(): Float32Array | null - get colors(): Float32Array | null - get vertexCount(): number - get faceCount(): number - get boundingBox(): THREE.Box3 - - // --- Transforms (all return new Mesh, geometry is cloned) --- - translate(x: number, y: number, z: number): Mesh - rotate(axis: Vec3 | 'x' | 'y' | 'z', angle: number): Mesh - rotateX(angle: number): Mesh - rotateY(angle: number): Mesh - rotateZ(angle: number): Mesh - scale(x: number, y?: number, z?: number): Mesh - transform(matrix: THREE.Matrix4): Mesh - - // --- Modifiers (return new Mesh) --- - displace(fn: DisplaceFn): Mesh - displaceNoise(noise: NoiseLike, amplitude?: number): Mesh - warp(fn: WarpFn): Mesh - subdivide(iterations?: number): Mesh // uses THREE's subdivision if available, else loop subdivision - computeNormals(): Mesh - - // --- Coloring (return new Mesh) --- - vertexColor(color: ColorInput | ColorFn): Mesh - faceColor(fn: (centroid: Vec3, normal: Vec3, faceIndex: number) => ColorInput): Mesh - - // --- UV (return new Mesh) --- - computeUVs(projection?: 'box' | 'planar' | 'cylindrical' | 'spherical'): Mesh - - // --- Utility --- - center(): Mesh - clone(): Mesh - - // --- Serialization (for future worker support) --- - serialize(): ArrayBuffer - static deserialize(buffer: ArrayBuffer): Mesh -} -``` - -**Implementation notes:** -- Every method that returns a new Mesh should: clone the geometry, apply the operation to the clone, return `new Mesh(clone)`. -- Use a private helper `cloneGeometry()` that does `geometry.clone()` and ensures all attributes are properly copied. -- `displace(fn)`: iterate vertices, call fn with position + normal + uv, move vertex along its normal by the returned amount. Recompute normals after. -- `warp(fn)`: iterate vertices, replace position with fn result. Recompute normals after. -- `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). -- `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. -- `serialize()`/`deserialize()`: pack all typed arrays into a single ArrayBuffer with a simple header (attribute count, sizes). This is for future worker support. - -### 1.4 — Math utilities (`src/math.ts`) - -Thin wrappers — don't reimplement, use THREE internally: - -```ts -import * as THREE from 'three' - -export function vec3(x: number, y: number, z: number): THREE.Vector3 -export function mat4(): THREE.Matrix4 -export function makeTranslation(x: number, y: number, z: number): THREE.Matrix4 -export function makeRotation(axis: Vec3 | 'x' | 'y' | 'z', angle: number): THREE.Matrix4 -export function makeScale(x: number, y: number, z: number): THREE.Matrix4 -export function parseColor(input: ColorInput): THREE.Color -``` +**The core tension:** every model is ~200 lines of bespoke, copy-pasted code. Trunk warp, +snow logic, face-shading, and the fragile "call `rand()` to keep the sequence stable" pattern +are duplicated across all three. This does not scale to 20+ models. The ambition goes into the +framework that makes each new model cheap. --- -## Phase 2: Primitives - -All primitives are **functions** that return a `Mesh`. They use Three.js geometry constructors internally. - -### `src/primitives/box.ts` -```ts -export interface BoxOptions { - width?: number // default 1 - height?: number // default 1 - depth?: number // default 1 - // OR shorthand: - size?: Vec3 | number // overrides width/height/depth - widthSegments?: number - heightSegments?: number - depthSegments?: number -} -export function box(options?: BoxOptions): Mesh -``` -Internally: `new THREE.BoxGeometry(...)` → wrap in Mesh. - -### `src/primitives/sphere.ts` -```ts -export interface SphereOptions { - radius?: number // default 0.5 - widthSegments?: number // default 16 - heightSegments?: number // default 12 -} -export function sphere(options?: SphereOptions): Mesh -``` - -### `src/primitives/cylinder.ts` -```ts -export interface CylinderOptions { - radius?: number // default 0.5 (sets both top and bottom) - radiusTop?: number // overrides radius for top - radiusBottom?: number // overrides radius for bottom - height?: number // default 1 - segments?: number // default 16 -} -export function cylinder(options?: CylinderOptions): Mesh -``` - -### `src/primitives/plane.ts` -```ts -export interface PlaneOptions { - width?: number // default 1 - height?: number // default 1 - // OR shorthand: - size?: number | Vec2 // overrides width/height - widthSegments?: number // default 1 - heightSegments?: number // default 1 - // OR shorthand: - segments?: number | Vec2 // overrides both segment counts -} -export function plane(options?: PlaneOptions): Mesh -``` -**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. - -### `src/primitives/cone.ts` -```ts -export interface ConeOptions { - radius?: number - height?: number - segments?: number -} -export function cone(options?: ConeOptions): Mesh -``` - -### `src/primitives/torus.ts` -```ts -export interface TorusOptions { - radius?: number - tube?: number - radialSegments?: number - tubularSegments?: number -} -export function torus(options?: TorusOptions): Mesh -``` - -### `src/primitives/index.ts` -Re-export all primitives. +## North star ---- +**All four scopes at once** — best-in-class vegetation, a full stylized environment kit, an +asset-pipeline product, and a great general-purpose library. **All four consumers** — web/ +Three.js scenes, game engines, no-code designers, and developers using the API. -## Phase 3: Operations +### The "all four" insight -### `src/ops/merge.ts` -```ts -export function merge(...meshes: Mesh[]): Mesh -``` -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. +These don't pull in four directions; they share one spine. The non-negotiable shared platform +is: generator framework + CSG/bevel + curves + weld/LOD + AO/wind + instancing + glTF export + +workers + playground + npm publish. Build that once and all four audiences are served. -**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. +So "all four" resolves to a strict order: **engine-first, then catalog, then platform polish — +with the quality bar (LOD, AO, wind, export) baked into the shared pipeline from the start so +it is free for every model.** -### `src/ops/center.ts` -```ts -export function center(mesh: Mesh): Mesh -``` -Compute bounding box center, translate by negative center. - -### `src/ops/clone.ts` -```ts -export function clone(mesh: Mesh): Mesh // just calls mesh.clone() -``` +The one risk is shipping four half-products. The mitigation is the sequencing below: the +foundation serves all four scopes simultaneously, and we don't branch into audience-specific +polish until the substrate is real. --- -## Phase 4: Modifiers - -Modifiers are standalone functions that return `WarpFn` or can be applied directly. Two patterns: - -**Pattern A — warp functions** (for use with `mesh.warp(fn)`): -```ts -// Returns a WarpFn: (position: Vec3) => Vec3 -export function twist(options: { axis?: 'x' | 'y' | 'z', amount: number }): WarpFn -export function bend(options: { axis?: 'x' | 'y' | 'z', amount: number }): WarpFn -export function taper(options: { axis?: 'x' | 'y' | 'z', curve?: (t: number) => number }): WarpFn -``` - -**Pattern B — mesh-in mesh-out** (for complex operations): -```ts -export function smooth(mesh: Mesh, iterations?: number): Mesh -export function subdivide(mesh: Mesh, iterations?: number): Mesh -``` - -### `src/modifiers/twist.ts` -Rotate vertices around an axis proportional to their position along that axis. -``` -angle = position[axis] * amount -rotate position around axis by angle -``` +## Thesis: build the multiplier before the models -### `src/modifiers/bend.ts` -Curve vertices along an axis. Map position along axis to an arc. - -### `src/modifiers/taper.ts` -Scale vertices perpendicular to an axis based on a curve function of their position along the axis. -``` -t = normalize position along axis to [0, 1] -scale = curve(t) -position[perpendicular axes] *= scale -``` - -### `src/modifiers/smooth.ts` -Laplacian smoothing: for each vertex, move it toward the average of its neighbors. Requires building an adjacency map from the index buffer. - -### `src/modifiers/lattice.ts` -STUB for v0. Just export the type and a placeholder that returns the input unchanged. +The highest-leverage work is not more models — it's the substrate that makes every subsequent +model cheap. Spend the first third of the project there, then flood the catalog. --- -## Phase 5: Noise Integration - -### Copy existing code -Copy the user's existing noise implementation into `src/noise/`: -- `src/noise/uber-noise.ts` — the UberNoise class (adjust imports to use relative paths within the package) -- `src/noise/simplex-noise/simplex-noise.ts` — existing simplex noise -- `src/noise/alea/alea.ts` — existing alea PRNG - -**Important:** Remove the `globalThis` assignments at the bottom of `uber-noise.ts`. We don't want to pollute globals — everything is imported. - -### `src/noise/helpers.ts` -Convenience factory functions that create pre-configured UberNoise instances: - -```ts -import { UberNoise, type NoiseOptions } from './uber-noise' - -/** Basic simplex noise */ -export function simplex(options?: Partial): UberNoise - -/** FBM (fractional Brownian motion) with sensible defaults */ -export function fbm(options?: Partial & { octaves?: number }): UberNoise -// Default: octaves 4, lacunarity 2, gain 0.5 - -/** Ridged noise */ -export function ridged(options?: Partial): UberNoise -// Sets sharpness: -1 +## Two things to fix in week one (regardless of everything else) -/** Billowed noise */ -export function billowed(options?: Partial): UberNoise -// Sets sharpness: 1 - -/** Stepped/terraced noise */ -export function stepped(steps: number, options?: Partial): UberNoise -// Sets steps - -/** Warped noise */ -export function warped(amount: number, options?: Partial): UberNoise -// Sets warp amount -``` - -### `src/noise/index.ts` -```ts -export { UberNoise, type NoiseOptions } from './uber-noise' -export { simplex, fbm, ridged, billowed, stepped, warped } from './helpers' -``` - -### Integration with Mesh - -`mesh.displaceNoise(noise, amplitude)` should accept an `UberNoise` instance (or anything with a `.get(x, y, z)` method): - -```ts -displaceNoise(noise: NoiseLike, amplitude: number = 1): Mesh { - return this.displace((pos, normal) => { - return noise.get(pos[0], pos[1], pos[2]) * amplitude - }) -} -``` - -This keeps the coupling loose — any object with `get(x, y?, z?)` works. +1. **Determinism model.** Replace the single RNG with **named independent streams** + (`rng.stream('canopy')`, `rng.stream('snow')`). The current "consume a `rand()` to keep the + sequence stable" hack is a latent bug farm — change one branch and every downstream model + shifts. Fix it before it spreads into 20 more files. +2. **Packaging gap.** Today `package.json` `main` points at raw `.ts` — shapecraft is not + actually consumable as a package. Decide now that it ships as a real built npm package with + **stable seeds as a compatibility guarantee**. This changes how we test (golden snapshots) + and how we version from day one. --- -## Phase 6: Color & UV - -### `src/color/utils.ts` -```ts -export function parseColor(input: ColorInput): [number, number, number] // rgb 0-1 -export function lerpColor(a: ColorInput, b: ColorInput, t: number): [number, number, number] -export function hexToRgb(hex: string): [number, number, number] -export function rgbToHex(r: number, g: number, b: number): string -``` -Use `THREE.Color` internally for parsing. - -### `src/color/gradient.ts` -```ts -export type GradientStop = [number, ColorInput] // [threshold, color] - -/** Create a function that maps a value to a color based on gradient stops */ -export function gradient(stops: GradientStop[]): (value: number) => [number, number, number] - -/** Shorthand for height-based gradient (maps y position to color) */ -export function heightGradient(stops: GradientStop[]): ColorFn -``` - -### `src/color/vertex-color.ts` -Implementation of `Mesh.vertexColor()`: -- If given a flat color, set all vertex colors to that color. -- If given a function, iterate all vertices, call the function with (position, normal, vertexIndex), set the color attribute. - -### `src/color/face-color.ts` -Implementation of `Mesh.faceColor()`: -- Call `geometry.toNonIndexed()` first (un-share vertices). -- Iterate face by face (every 3 vertices), compute centroid and normal, call fn, set all 3 vertex colors. - -### `src/uv/projections.ts` -```ts -export function projectUVs(geometry: THREE.BufferGeometry, mode: 'box' | 'planar' | 'cylindrical' | 'spherical'): void -``` -Modifies geometry in place (called on a clone inside `Mesh.computeUVs()`). - -- **planar**: project from Y axis down onto XZ plane. `u = x`, `v = z` (normalized to bounding box). -- **box**: tri-planar — pick projection axis per face based on face normal, project from that axis. -- **cylindrical**: `u = atan2(z, x) / (2π)`, `v = y` (normalized). -- **spherical**: `u = atan2(z, x) / (2π)`, `v = acos(y/r) / π`. - -### `src/uv/textures.ts` -STUB for v0. Export types and a couple basic functions: -```ts -export function checkerboard(size?: number): (u: number, v: number) => [number, number, number] -``` -Full procedural texture system is post-v0. +## Workstreams + +### A. Generator framework (the model multiplier) +- Extract repeated patterns into reusable building blocks: a `trunk()` / tapered-limb builder, + a `canopyShade()` color helper, a `snow()` modifier. +- **Anchor / socket points** on meshes (canopy top, ground contact, attachment rings) for + composition. +- **Skeleton / L-system branching engine**: recursive tapered branches with leaf/attachment + slots. One engine yields oak, birch, willow, dead tree, bush, fern, coral, and vines from + parameters instead of bespoke files. +- RNG named streams + **golden-snapshot test harness** (vertex counts, bounds, hashes) so + "seed 5 looks like X" is locked across versions. + +### B. Library capability gaps (unlock new model classes) +- **CSG booleans** (union / subtract / intersect) — windows in walls, holes, carved props, + hard-surface. +- **Bevel / inset / extrude-faces** — architecture, crates, furniture, crisp edges. +- First-class **Curve / Path type with parallel-transport frames** — fixes twist artifacts in + `tube`/`loft` and cleans up every stalk/branch/vine. +- **Vertex weld + decimation + automatic LOD generation** — today `faceColor` un-indexes + everything (×3 vertices) with no way to weld back or produce LODs. Required for any real use + at scale and for game-engine export. + +### C. Quality lift across all models (cheap, high-impact, on-brand) +- **Baked vertex ambient occlusion / cavity shading** — instantly makes everything read as 3D + instead of flat. +- **Per-vertex wind weights** — trees sway in a shader, models ship game-ready. +- **Real branches** on the trees (currently trunk-plus-blobs). +- Poly-budget / triangle-count targets per model. + +### D. Product surface (make output usable) +- **Instancing**: `scatter → THREE.InstancedMesh` (the forest demo builds 15 unique meshes + today — critical perf win). +- **glTF / GLB export** (plus OBJ) — the asset pipeline for game engines and designers. +- **Real Web Worker pool** — the pool is a synchronous stub; `serialize()`/Transferable are + already in place to make it real. +- **Biome / scatter scene generator** — Poisson-disk, noise density maps, slope/altitude rules + (the forest demo is the seed). +- **Hosted playground + gallery** — the schema editor is the unfair advantage; one-click glTF + export for the no-code crowd. +- **npm publish pipeline** — real build, `.d.ts`, dual ESM. Docs, perf benchmarks, visual + regression (the `screenshot.cjs` scripts are the seed). --- -## Phase 7: Three.js Bridge - -### `src/three/to-three.ts` -```ts -import * as THREE from 'three' -import type { Mesh } from '../mesh' - -/** Convert a shapecraft Mesh to a THREE.Mesh ready to add to a scene */ -export function toThreeMesh(mesh: Mesh, options?: { - material?: THREE.Material - flatShading?: boolean // default true for low-poly look - wireframe?: boolean -}): THREE.Mesh - -/** Get just the geometry (if user wants to provide their own material) */ -export function toThreeGeometry(mesh: Mesh): THREE.BufferGeometry -``` - -`toThreeMesh` implementation: -- If mesh has vertex colors, use `THREE.MeshStandardMaterial({ vertexColors: true, flatShading })`. -- If no vertex colors, use `THREE.MeshStandardMaterial({ color: 0xcccccc, flatShading })`. -- If wireframe requested, set `material.wireframe = true`. -- The geometry is already a `THREE.BufferGeometry` internally, so just reference it (or clone if immutability is desired). - -### `src/three/from-three.ts` -```ts -/** Import an existing Three.js geometry into shapecraft */ -export function fromThreeGeometry(geometry: THREE.BufferGeometry): Mesh -``` +## Timeline + +### Months 1–2 — Foundation (all 4 people; the whole ballgame, nothing skippable) +- RNG named streams + golden-snapshot test harness. +- Shared model primitives + skeleton/L-system branch engine. +- CSG booleans + bevel/inset/extrude. +- First-class Curve/Path with parallel-transport frames. +- Weld + decimate + automatic LOD generation. + +### Months 2–4 — Catalog explosion + quality baked in (split: 2 on models, 2 on platform) +- **Models team:** ride the framework through vegetation (bush, grass, fern, dead tree, cactus, + birch, willow, mushroom, bamboo, flower) + rocks/cliffs + the prop/architecture set CSG now + enables (crates, fences, walls, simple buildings, furniture — chair/table already in the demo). +- **Platform team:** bake AO/cavity shading + per-vertex wind weights into the shared pipeline + (every model above ships better-looking and game-ready automatically); add instancing and the + glTF/GLB exporter. + +### Months 4–6 — Platform & product (split: scene/biome + playground) +- Real Web Worker pool. +- Biome/scatter scene generator. +- Hosted playground + gallery with one-click glTF export. +- npm publish pipeline (build, `.d.ts`, dual ESM). +- Docs, perf benchmarks, visual regression harness. --- -## Phase 8: Worker Support (STUB) - -### `src/worker/pool.ts` -For v0, just define the interface and a simple synchronous fallback: - -```ts -export interface WorkerPoolOptions { - maxWorkers?: number -} - -export class WorkerPool { - constructor(options?: WorkerPoolOptions) - - /** Run a generator function in a worker. For v0, runs synchronously. */ - async run(fn: (...args: any[]) => Mesh, ...args: any[]): Promise - - /** Batch run multiple generators. For v0, runs sequentially. */ - async batch(tasks: Array<[Function, ...any[]]>): Promise - - dispose(): void -} -``` - -The v0 implementation just calls the functions directly. Real worker support comes later and will use `Mesh.serialize()`/`deserialize()` with `Transferable`. - ---- - -## Phase 9: Demo - -### `demo/index.html` -Basic HTML shell that loads `demo/main.ts` via Vite. - -### `demo/main.ts` -Sets up a Three.js scene with: -- Renderer, camera, controls (OrbitControls) -- Ambient light + directional light -- Calls all three demo generators -- Adds them to the scene -- Animation loop - -### `demo/generators/chair.ts` -```ts -export function chair(options?: { seatHeight?: number, legRadius?: number }): Mesh -``` -A simple 4-legged chair: -- Box for seat -- 4 cylinders for legs -- Box for back -- Vertex colored brown tones -- Returns merged mesh - -### `demo/generators/table.ts` -```ts -export function diningSet(options?: { chairs?: number, tableRadius?: number }): Mesh -``` -- Cylinder for table top -- 4 cylinder legs -- N chairs arranged in a circle using the `chair()` generator -- Demonstrates composition - -### `demo/generators/terrain.ts` -```ts -export function terrain(options?: { size?: number, segments?: number, seed?: number }): Mesh -``` -- Large subdivided plane -- Displaced with UberNoise (fbm, maybe ridged mix) -- Height-based vertex coloring (green → brown → gray → white) -- Demonstrates noise integration - ---- - -## Phase 10: Tests - -### `tests/mesh.test.ts` -- Construction from geometry -- Transforms: translate, rotate, scale produce correct vertex positions -- Immutability: original mesh unchanged after transform -- `vertexCount` and `faceCount` correct -- `clone()` produces independent copy - -### `tests/primitives.test.ts` -- Each primitive returns a Mesh -- Vertex counts are correct for given parameters -- Default options produce reasonable geometry -- `size` shorthand works for box and plane - -### `tests/ops.test.ts` -- `merge()` combines vertex counts correctly -- `merge()` handles mixed color/no-color meshes (fills missing with white) -- `center()` puts bounding box center at origin - -### `tests/modifiers.test.ts` -- `twist()` modifies positions (not identity) -- `taper()` scales vertices correctly at extremes -- `displace()` moves vertices along normals -- `displaceNoise()` produces non-zero displacement - -### `tests/noise.test.ts` -- UberNoise produces values in expected range -- `fbm()` helper returns configured UberNoise -- `ridged()` produces values with expected characteristics -- Seeded noise is deterministic - ---- - -## Implementation Order for Claude Code - -Follow this order. Each step should be completable and testable before moving to the next. - -1. **Project scaffolding** — `package.json`, `tsconfig.json`, `vite.config.ts`, `vitest.config.ts`, directory structure, install deps. - -2. **Types + Math** — `src/types.ts`, `src/math.ts`. - -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. - -4. **Primitives** — All 6 primitives. Write `tests/primitives.test.ts`. At this point you can already do `box().translate(1,0,0)`. - -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))`. - -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`. - -7. **Displace + warp on Mesh** — Implement `mesh.displace()`, `mesh.displaceNoise()`, `mesh.warp()`. These depend on noise being available for testing. - -8. **Modifiers** — `twist`, `bend`, `taper`, `smooth` (lattice as stub). Write `tests/modifiers.test.ts`. - -9. **Color system** — `src/color/*`. Implement `mesh.vertexColor()` and `mesh.faceColor()`, plus gradient helpers. - -10. **UV projections** — `src/uv/projections.ts`, implement `mesh.computeUVs()`. Texture stubs. - -11. **Three.js bridge** — `src/three/to-three.ts`, `src/three/from-three.ts`. - -12. **Worker stubs** — `src/worker/pool.ts`. - -13. **Main barrel exports** — `src/index.ts` re-exporting everything. - -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. - -15. **Final pass** — Run all tests, fix any issues, make sure `npm run dev` launches the demo and it looks good. - ---- - -## API Surface Summary (what gets exported) - -```ts -// shapecraft (main) -export { Mesh } from './mesh' -export { box, sphere, cylinder, plane, cone, torus } from './primitives' -export { merge, center, clone } from './ops' -export { twist, bend, taper, smooth, subdivide } from './modifiers' -export { vertexColor, faceColor, gradient, heightGradient, lerpColor } from './color' -export { projectUVs } from './uv' - -// shapecraft/noise -export { UberNoise, simplex, fbm, ridged, billowed, stepped, warped } from './noise' - -// shapecraft/three -export { toThreeMesh, toThreeGeometry, fromThreeGeometry } from './three' -``` - ---- - -## Notes & Gotchas for the Implementer - -1. **Always clone geometry before modifying.** Every Mesh method that modifies geometry must clone first. Never mutate the internal geometry of an existing Mesh. - -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). - -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. - -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. - -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. - -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. - -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. - -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. - -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. +## Definition of success -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. \ No newline at end of file +- 20+ models spanning vegetation, rocks, props, and basic architecture — each with presets, + LODs, baked AO, and wind data. +- Shapecraft installable from npm with stable, versioned seeds. +- One-click glTF/GLB export from a hosted playground. +- Forests/biomes rendered via instancing + workers without blocking the main thread. +- A general-purpose, documented, benchmarked procedural-mesh library others can build on. diff --git a/src/color/palette.ts b/src/color/palette.ts index 094656c..0b8bfbd 100644 --- a/src/color/palette.ts +++ b/src/color/palette.ts @@ -1,4 +1,4 @@ -import { interpolate, oklch, formatRgb, parse, converter } from 'culori' +import { interpolate, formatRgb, parse, converter } from 'culori' import type { ColorInput, ColorFn, Vec3 } from '../core/types' import type { NoiseLike } from '../core/types' @@ -106,12 +106,13 @@ export function varyColor(color: ColorInput, amount: number, rand: () => number) const base = toOklch({ mode: 'rgb', r: rgb[0], g: rgb[1], b: rgb[2] }) return function varied(): [number, number, number] { - const varied = oklch({ + const variedColor = { + mode: 'oklch' as const, l: Math.max(0, Math.min(1, (base.l ?? 0.5) + (rand() - 0.5) * amount)), c: Math.max(0, (base.c ?? 0.1) + (rand() - 0.5) * amount * 0.5), h: (base.h ?? 140) + (rand() - 0.5) * amount * 60, - }) - const out = converter('rgb')(varied) + } + const out = converter('rgb')(variedColor) return [ Math.max(0, Math.min(1, out.r)), Math.max(0, Math.min(1, out.g)), diff --git a/src/core/rng.ts b/src/core/rng.ts index 9ff495a..a9d98d1 100644 --- a/src/core/rng.ts +++ b/src/core/rng.ts @@ -1,9 +1,70 @@ import { aleaFactory } from '../noise/alea/alea' /** - * Create a seeded random number generator. - * Returns a function that produces values in [0, 1) on each call. + * A seeded random number generator. + * + * It is callable — `rng()` returns the next float in [0, 1) — so it is a drop-in + * replacement anywhere a `() => number` is expected (scatter, schema, palettes). + * + * The important addition is {@link Rng.stream}: a named, independent sub-generator. + * Pulling values from one stream never advances another, so a model can give each + * concern (`'trunk'`, `'canopy'`, `'snow'`, …) its own stream and toggling one feature + * can't shift the randomness of an unrelated one. This replaces the fragile + * "call `rand()` to keep the sequence stable" pattern. */ -export function createRng(seed: number | string): () => number { - return aleaFactory(seed).random +export interface Rng { + /** Next float in [0, 1). Drop-in compatible with `() => number`. */ + (): number + /** Next float in [min, max) (defaults to [0, 1)). */ + float(min?: number, max?: number): number + /** Integer in [min, max] inclusive. */ + int(min: number, max: number): number + /** True with probability `p` (default 0.5). */ + bool(p?: number): boolean + /** Randomly -1 or 1. */ + sign(): number + /** A random element of `arr`. */ + pick(arr: readonly T[]): T + /** Resolve a fixed value or a `[min, max]` range. Rounds when `integer` is true. */ + range(value: number | [number, number], integer?: boolean): number + /** Derive a fresh integer seed (e.g. for noise/jitter). Advances this stream. */ + seed(): number + /** An independent, deterministic sub-stream identified by `name`. */ + stream(name: string): Rng + /** An independent anonymous sub-stream (auto-numbered per parent). */ + fork(): Rng +} + +/** + * Create a seeded RNG from a number or string seed. + */ +export function createRng(seed: number | string): Rng { + return makeRng(String(seed)) +} + +function makeRng(base: string): Rng { + const next = aleaFactory(base).random + let forkCounter = 0 + + const rng = function (): number { + return next() + } as Rng + + rng.float = (min = 0, max = 1) => min + next() * (max - min) + rng.int = (min, max) => Math.floor(min + next() * (max - min + 1)) + rng.bool = (p = 0.5) => next() < p + rng.sign = () => (next() < 0.5 ? -1 : 1) + rng.pick = (arr: readonly T[]): T => arr[Math.floor(next() * arr.length)] + rng.range = (value, integer = false) => { + if (Array.isArray(value)) { + const r = value[0] + next() * (value[1] - value[0]) + return integer ? Math.round(r) : r + } + return value + } + rng.seed = () => Math.floor(next() * 2147483647) + rng.stream = (name: string) => makeRng(`${base}/${name}`) + rng.fork = () => makeRng(`${base}#${forkCounter++}`) + + return rng } diff --git a/src/core/schema.ts b/src/core/schema.ts index 592b068..d38ee98 100644 --- a/src/core/schema.ts +++ b/src/core/schema.ts @@ -59,6 +59,21 @@ export type OptionValues = { /** A value that can be fixed or a [min, max] range resolved at generation time */ export type Randomizable = T | [T, T] +/** + * The *input* shape for a schema: like {@link OptionValues}, but numeric options also + * accept a `[min, max]` tuple (resolved to a number at generation time). Use this for a + * generator's public options type; use {@link OptionValues} for the resolved result. + */ +export type OptionInput = { + [K in keyof S]: S[K] extends RangeOption ? Randomizable + : S[K] extends IntegerOption ? Randomizable + : S[K] extends ColorOption ? string + : S[K] extends ColorArrayOption ? string[] + : S[K] extends BooleanOption ? boolean + : S[K] extends SelectOption ? string + : never +} + /** * Resolve options for a generator: apply preset, then overrides, then fill schema defaults. * Numeric values can be [min, max] tuples — resolved using rand() if provided. diff --git a/src/generators/common-tree.ts b/src/generators/common-tree.ts index 8415286..5a4848a 100644 --- a/src/generators/common-tree.ts +++ b/src/generators/common-tree.ts @@ -6,7 +6,7 @@ import { resolveOptions } from '../core/schema' import { paletteGradient, pickRandom, type Palette } from '../color' import { UberNoise } from '../noise' import type { Mesh } from '../core/mesh' -import type { OptionSchema } from '../core/schema' +import type { OptionSchema, OptionInput } from '../core/schema' export const treeSchema = { seed: { type: 'integer', default: 1, min: 1, max: 100, label: 'Seed' }, @@ -31,9 +31,7 @@ export const treeSchema = { canopyColors: { type: 'color-array', default: ['#1e6b10', '#2a7518', '#238020', '#2d8a1e'], min: 1, max: 8, label: 'Canopy Colors' }, } satisfies OptionSchema -export type TreeOptions = { - [K in keyof typeof treeSchema]?: typeof treeSchema[K]['default'] -} & { preset?: string } +export type TreeOptions = Partial> & { preset?: string } export const treePresets: Record> = { default: {}, @@ -55,16 +53,21 @@ export const treePresets: Record> = { } export function tree(options: TreeOptions = {}): Mesh { - // Create rand from seed before resolving (needed for [min,max] ranges) - const seed = options.seed ?? treeSchema.seed.default - const rand = createRng(seed) - const o = resolveOptions(treeSchema, options, treePresets, rand) - - // Derive all sub-seeds from the main rand so everything chains deterministically - function subSeed() { return Math.floor(rand() * 2147483647) } + // Create rng from seed before resolving (needed for [min,max] ranges) + const seedOpt = options.seed ?? treeSchema.seed.default + const seed = Array.isArray(seedOpt) ? seedOpt[0] : seedOpt + const rng = createRng(seed) + const o = resolveOptions(treeSchema, options, treePresets, rng) + + // Independent streams per concern: drawing from one never perturbs another, + // so toggling (e.g.) snow can't shift the trunk or canopy randomness. + const trunkRng = rng.stream('trunk') + const canopyRng = rng.stream('canopy') + const colorRng = rng.stream('color') + const snowRng = rng.stream('snow') // Trunk radii — randomized within range - const baseRadius = o.trunkRadius * (1.6 + rand() * 0.8) + const baseRadius = o.trunkRadius * (1.6 + trunkRng() * 0.8) const topRadius = o.trunkRadius * o.trunkTopScale // Bigger trunk → bigger canopy @@ -73,12 +76,12 @@ export function tree(options: TreeOptions = {}): Mesh { // Trunk const trunkHeight = o.height * o.trunkRatio - const leanX = (rand() - 0.5) * o.lean - const leanZ = (rand() - 0.5) * o.lean + const leanX = (trunkRng() - 0.5) * o.lean + const leanZ = (trunkRng() - 0.5) * o.lean const trunkGrad = paletteGradient(o.trunkColors) const taperExp = o.trunkTaper - const trunkNoise = new UberNoise({ seed: subSeed(), scale: 8 }) + const trunkNoise = new UberNoise({ seed: trunkRng.seed(), scale: 8 }) const trunk = cylinder({ radius: 1, radiusTop: 1, height: trunkHeight, segments: 5, heightSegments: 4 }) .translate(0, trunkHeight / 2, 0) .warp((pos) => { @@ -111,11 +114,11 @@ export function tree(options: TreeOptions = {}): Mesh { const mainR = actualCanopyRadius const edgeLen = o.canopyRadius * o.canopyDetail - const colorNoiseSeed = subSeed() + const colorNoiseSeed = colorRng.seed() function canopyBlob(r: number): Mesh { - const noiseSeed = subSeed() - const jitterSeed = subSeed() + const noiseSeed = canopyRng.seed() + const jitterSeed = canopyRng.seed() const noise = new UberNoise({ seed: noiseSeed, scale: 0.5, octaves: 3 }) let blob = icosphere({ radius: r, subdivisions: 0 }) .subdivideAdaptive(edgeLen) @@ -134,16 +137,14 @@ export function tree(options: TreeOptions = {}): Mesh { const colorNoise = new UberNoise({ seed: colorNoiseSeed, scale: 1.5 }) const hasSnow = o.snowColors.length > 0 - const snowNoiseSeed = subSeed() // always consume to keep sequence stable - const snowNoise = hasSnow ? new UberNoise({ seed: snowNoiseSeed, scale: 2 }) : null + const snowNoise = hasSnow ? new UberNoise({ seed: snowRng.seed(), scale: 2 }) : null const snowThreshold = Math.sin(o.snowAngle * Math.PI / 180) function blobFaceColor(): (centroid: [number, number, number], normal: [number, number, number], faceIndex: number) => [number, number, number] { - // Pick colors upfront so we don't consume rand() calls inside the per-face loop - const base = pickRandom(o.canopyColors, rand) - // Always consume the rand() call to keep sequence stable regardless of snow setting - const snowPick = pickRandom(hasSnow ? o.snowColors : o.canopyColors, rand) - const snow = hasSnow ? snowPick : null + // Pick colors upfront so we don't draw inside the per-face loop. Each draw comes + // from its own stream, so snow being on/off never shifts the base canopy color. + const base = pickRandom(o.canopyColors, colorRng) + const snow = hasSnow ? pickRandom(o.snowColors, snowRng) : null return (centroid, normal) => { const top = normal[1] * 0.5 + 0.5 @@ -171,7 +172,7 @@ export function tree(options: TreeOptions = {}): Mesh { // Sub-blobs const blobCount = o.canopyBumps if (blobCount > 0) { - const blobPositions = scatterOnSphere(blobCount, subSeed(), { + const blobPositions = scatterOnSphere(blobCount, canopyRng.seed(), { radius: mainR * 0.9, polarMin: Math.PI * 0.3, polarMax: Math.PI * 0.7, @@ -179,7 +180,7 @@ export function tree(options: TreeOptions = {}): Mesh { for (let i = 0; i < blobCount; i++) { const [bx, by, bz] = blobPositions[i] - const r = mainR * (o.bumpSize + rand() * 0.15) + const r = mainR * (o.bumpSize + canopyRng() * 0.15) const blob = canopyBlob(r) .scale(1, o.canopySquash, 1) diff --git a/src/generators/palm-tree.ts b/src/generators/palm-tree.ts index 6de4fe3..1811215 100644 --- a/src/generators/palm-tree.ts +++ b/src/generators/palm-tree.ts @@ -6,7 +6,7 @@ import { paletteGradient, pickRandom } from '../color' import { UberNoise } from '../noise' import type { Mesh } from '../core/mesh' import type { Vec3 } from '../core/types' -import type { OptionSchema } from '../core/schema' +import type { OptionSchema, OptionInput } from '../core/schema' export const palmSchema = { seed: { type: 'integer', default: 1, min: 1, max: 100, label: 'Seed' }, @@ -33,9 +33,7 @@ export const palmSchema = { coconutColor: { type: 'color', default: '#3a2810', label: 'Coconut Color' }, } satisfies OptionSchema -export type PalmOptions = { - [K in keyof typeof palmSchema]?: typeof palmSchema[K]['default'] -} & { preset?: string } +export type PalmOptions = Partial> & { preset?: string } export const palmPresets: Record> = { default: {}, @@ -60,16 +58,22 @@ export const palmPresets: Record> = { } export function palm(options: PalmOptions = {}): Mesh { - const seed = options.seed ?? palmSchema.seed.default - const rand = createRng(seed) - const o = resolveOptions(palmSchema, options, palmPresets, rand) - - function subSeed() { return Math.floor(rand() * 2147483647) } + const seedOpt = options.seed ?? palmSchema.seed.default + const seed = Array.isArray(seedOpt) ? seedOpt[0] : seedOpt + const rng = createRng(seed) + const o = resolveOptions(palmSchema, options, palmPresets, rng) + + // Independent streams per concern (see common-tree for rationale). + const trunkRng = rng.stream('trunk') + const frondRng = rng.stream('fronds') + const colorRng = rng.stream('color') + const snowRng = rng.stream('snow') + const coconutRng = rng.stream('coconut') // --- Trunk path: curved from base to top --- const trunkHeight = o.height * 0.75 - const baseRadius = o.trunkRadius * (1.3 + rand() * 0.4) - const curveAngle = rand() * Math.PI * 2 + const baseRadius = o.trunkRadius * (1.3 + trunkRng() * 0.4) + const curveAngle = trunkRng() * Math.PI * 2 const curveAmount = o.trunkCurve const curveDirX = Math.cos(curveAngle) const curveDirZ = Math.sin(curveAngle) @@ -99,7 +103,7 @@ export function palm(options: PalmOptions = {}): Mesh { }, o.trunkSegments, ) - .jitter(baseRadius * 0.1, { seed: subSeed() }) + .jitter(baseRadius * 0.1, { seed: trunkRng.seed() }) .vertexColor((pos) => { const t = Math.max(0, Math.min(1, pos[1] / trunkHeight)) return paletteGradient(o.trunkColors)(t) @@ -110,24 +114,22 @@ export function palm(options: PalmOptions = {}): Mesh { const frondCount = o.fronds const frondGrad = paletteGradient(o.frondColors) - // Pre-allocate seeds + // Per-frond jitter seeds from the frond stream const maxFronds = 14 - const frondSeeds = Array.from({ length: maxFronds }, () => subSeed()) - const colorNoiseSeed = subSeed() - const snowNoiseSeed = subSeed() + const frondSeeds = Array.from({ length: maxFronds }, () => frondRng.seed()) - const colorNoise = new UberNoise({ seed: colorNoiseSeed, scale: 1.5 }) + const colorNoise = new UberNoise({ seed: colorRng.seed(), scale: 1.5 }) const hasSnow = o.snowColors.length > 0 - const snowNoise = hasSnow ? new UberNoise({ seed: snowNoiseSeed, scale: 2 }) : null + const snowNoise = hasSnow ? new UberNoise({ seed: snowRng.seed(), scale: 2 }) : null const snowThreshold = Math.sin(o.snowAngle * Math.PI / 180) for (let i = 0; i < frondCount; i++) { - const angle = (i / frondCount) * Math.PI * 2 + rand() * 0.5 - const frondLen = o.frondLength * (0.5 + rand() * 0.7) - const droop = o.frondDroop * (0.3 + rand() * 1) - const curveUp = o.frondCurveUp * (0.5 + rand() * 1.5) - const width = o.frondWidth * (0.6 + rand() * 0.8) - const startAngleUp = rand() * 0.6 // some fronds point more upward + const angle = (i / frondCount) * Math.PI * 2 + frondRng() * 0.5 + const frondLen = o.frondLength * (0.5 + frondRng() * 0.7) + const droop = o.frondDroop * (0.3 + frondRng() * 1) + const curveUp = o.frondCurveUp * (0.5 + frondRng() * 1.5) + const width = o.frondWidth * (0.6 + frondRng() * 0.8) + const startAngleUp = frondRng() * 0.6 // some fronds point more upward // Build frond path: starts at trunk top, arcs out and droops const frondPath: Vec3[] = [] @@ -177,11 +179,10 @@ export function palm(options: PalmOptions = {}): Mesh { // Color const base = frondGrad(i / frondCount) - const snow = pickRandom(hasSnow ? o.snowColors : o.frondColors, rand) - rand() // consume for stability + const snow = hasSnow ? pickRandom(o.snowColors, snowRng) : null const colored = frondMesh.faceColor((centroid, normal) => { - if (hasSnow && snowNoise) { + if (snow && snowNoise) { const n = snowNoise.get(centroid[0], centroid[1], centroid[2]) * 0.15 if (normal[1] + n > snowThreshold) return snow } @@ -198,13 +199,13 @@ export function palm(options: PalmOptions = {}): Mesh { const coconutParts: Mesh[] = [] const coconutCount = o.coconuts for (let i = 0; i < coconutCount; i++) { - const angle = rand() * Math.PI * 2 + const angle = coconutRng() * Math.PI * 2 const topRadius = baseRadius * (1 - o.trunkTaper) - const dist = topRadius * (1.5 + rand() * 1) - const size = o.coconutSize * (0.7 + rand() * 0.6) - const hangY = size * (0.3 + rand() * 0.8) + const dist = topRadius * (1.5 + coconutRng() * 1) + const size = o.coconutSize * (0.7 + coconutRng() * 0.6) + const hangY = size * (0.3 + coconutRng() * 0.8) const coconut = sphere({ radius: size, widthSegments: 4, heightSegments: 3 }) - .scale(0.9 + rand() * 0.2, 1 + rand() * 0.3, 0.9 + rand() * 0.2) + .scale(0.9 + coconutRng() * 0.2, 1 + coconutRng() * 0.3, 0.9 + coconutRng() * 0.2) .translate( topPt[0] + Math.cos(angle) * dist, topPt[1] - hangY, diff --git a/src/generators/pine-tree.ts b/src/generators/pine-tree.ts index 4735923..8614390 100644 --- a/src/generators/pine-tree.ts +++ b/src/generators/pine-tree.ts @@ -5,7 +5,7 @@ import { resolveOptions } from '../core/schema' import { paletteGradient, pickRandom } from '../color' import { UberNoise } from '../noise' import type { Mesh } from '../core/mesh' -import type { OptionSchema } from '../core/schema' +import type { OptionSchema, OptionInput } from '../core/schema' export const pineSchema = { seed: { type: 'integer', default: 1, min: 1, max: 100, label: 'Seed' }, @@ -35,9 +35,7 @@ export const pineSchema = { canopyColors: { type: 'color-array', default: ['#0a2e12', '#0e3a18', '#144a22', '#1a5a2c'], min: 1, max: 8, label: 'Canopy Colors' }, } satisfies OptionSchema -export type PineOptions = { - [K in keyof typeof pineSchema]?: typeof pineSchema[K]['default'] -} & { preset?: string } +export type PineOptions = Partial> & { preset?: string } export const pinePresets: Record> = { default: {}, @@ -59,21 +57,27 @@ export const pinePresets: Record> = { } export function pine(options: PineOptions = {}): Mesh { - const seed = options.seed ?? pineSchema.seed.default - const rand = createRng(seed) - const o = resolveOptions(pineSchema, options, pinePresets, rand) - - function subSeed() { return Math.floor(rand() * 2147483647) } + const seedOpt = options.seed ?? pineSchema.seed.default + const seed = Array.isArray(seedOpt) ? seedOpt[0] : seedOpt + const rng = createRng(seed) + const o = resolveOptions(pineSchema, options, pinePresets, rng) + + // Independent streams per concern (see common-tree for rationale). + const trunkRng = rng.stream('trunk') + const canopyRng = rng.stream('canopy') + const colorRng = rng.stream('color') + const snowRng = rng.stream('snow') + const swayRng = rng.stream('sway') // Trunk - const baseRadius = o.trunkRadius * (1.4 + rand() * 0.4) + const baseRadius = o.trunkRadius * (1.4 + trunkRng() * 0.4) const topRadius = o.trunkRadius * o.trunkTopScale const trunkHeight = o.height * o.trunkRatio - const leanX = (rand() - 0.5) * o.lean - const leanZ = (rand() - 0.5) * o.lean + const leanX = (trunkRng() - 0.5) * o.lean + const leanZ = (trunkRng() - 0.5) * o.lean const trunkGrad = paletteGradient(o.trunkColors) - const trunkNoise = new UberNoise({ seed: subSeed(), scale: o.trunkNoiseScale }) + const trunkNoise = new UberNoise({ seed: trunkRng.seed(), scale: o.trunkNoiseScale }) const trunk = cylinder({ radius: 1, radiusTop: 1, height: trunkHeight, segments: 5, heightSegments: 3 }) .translate(0, trunkHeight / 2, 0) .warp((pos) => { @@ -99,22 +103,20 @@ export function pine(options: PineOptions = {}): Mesh { const canopyHeight = o.height - canopyStart const layerCount = o.layers - // Pre-allocate all seeds unconditionally - const layerJitterSeeds = Array.from({ length: layerCount }, () => subSeed()) - const colorNoiseSeed = subSeed() - const snowNoiseSeed = subSeed() + // Per-layer jitter seeds from the canopy stream + const layerJitterSeeds = Array.from({ length: layerCount }, () => canopyRng.seed()) const canopyGrad = paletteGradient(o.canopyColors) - const colorNoise = new UberNoise({ seed: colorNoiseSeed, scale: o.colorNoiseScale }) + const colorNoise = new UberNoise({ seed: colorRng.seed(), scale: o.colorNoiseScale }) const hasSnow = o.snowColors.length > 0 - const snowNoise = hasSnow ? new UberNoise({ seed: snowNoiseSeed, scale: 2 }) : null + const snowNoise = hasSnow ? new UberNoise({ seed: snowRng.seed(), scale: 2 }) : null const snowThreshold = Math.sin(o.snowAngle * Math.PI / 180) // Pre-compute layer sizes so we can stack proportionally const layerRadii: number[] = [] const layerHeights: number[] = [] for (let i = 0; i < layerCount; i++) { - const r = o.coneRadius * Math.pow(o.layerShrink, i) * (0.9 + rand() * 0.2) + const r = o.coneRadius * Math.pow(o.layerShrink, i) * (0.9 + canopyRng() * 0.2) layerRadii.push(r) layerHeights.push(r * o.coneHeight) } @@ -139,9 +141,9 @@ export function pine(options: PineOptions = {}): Mesh { const lz = leanZ * lt * lt // Pyramid cone with height segments, quadratic curve, random tilt - const rotY = rand() * Math.PI * 2 - const tiltX = (rand() - 0.5) * o.coneTilt - const tiltZ = (rand() - 0.5) * o.coneTilt + const rotY = canopyRng() * Math.PI * 2 + const tiltX = (canopyRng() - 0.5) * o.coneTilt + const tiltZ = (canopyRng() - 0.5) * o.coneTilt let layer = cone({ radius: 1, height: layerH, segments: o.coneSides, heightSegments: 3 }) .warp((pos) => { // Quadratic curve: wider at the base than a straight cone @@ -157,11 +159,10 @@ export function pine(options: PineOptions = {}): Mesh { // Face color const base = canopyGrad(t) - rand() // consume to keep sequence stable (was pickRandom) - const snow = pickRandom(hasSnow ? o.snowColors : o.canopyColors, rand) + const snow = hasSnow ? pickRandom(o.snowColors, snowRng) : null layer = layer.faceColor((centroid, normal) => { - if (hasSnow && snowNoise) { + if (snow && snowNoise) { const n = snowNoise.get(centroid[0], centroid[1], centroid[2]) * 0.15 if (normal[1] + n > snowThreshold) { return snow @@ -178,8 +179,8 @@ export function pine(options: PineOptions = {}): Mesh { } // Noise-based sway: shift X/Z based on Y height for organic lean - const swayNoiseX = new UberNoise({ seed: subSeed(), scale: o.swayScale }) - const swayNoiseZ = new UberNoise({ seed: subSeed(), scale: o.swayScale }) + const swayNoiseX = new UberNoise({ seed: swayRng.seed(), scale: o.swayScale }) + const swayNoiseZ = new UberNoise({ seed: swayRng.seed(), scale: o.swayScale }) return merge(trunk, ...canopyParts) .warp((pos) => { diff --git a/src/index.ts b/src/index.ts index d0bf5e1..9ef61e7 100644 --- a/src/index.ts +++ b/src/index.ts @@ -21,11 +21,12 @@ export { projectUVs } from './uv' // Utilities export { createRng } from './core/rng' +export type { Rng } from './core/rng' export { scatterOnSphere } from './core/scatter' // Schema & options export { resolveOptions } from './core/schema' -export type { OptionSchema, OptionDef, OptionValues, Randomizable } from './core/schema' +export type { OptionSchema, OptionDef, OptionValues, OptionInput, Randomizable } from './core/schema' // Generators export { tree, treeSchema, treePresets } from './generators' diff --git a/tests/generators.test.ts b/tests/generators.test.ts new file mode 100644 index 0000000..c58950b --- /dev/null +++ b/tests/generators.test.ts @@ -0,0 +1,64 @@ +import { describe, it, expect } from 'vitest' +import { tree } from '../src/generators/common-tree' +import { pine } from '../src/generators/pine-tree' +import { palm } from '../src/generators/palm-tree' +import type { Mesh } from '../src/core/mesh' + +const generators = [ + { name: 'tree', gen: tree }, + { name: 'pine', gen: pine }, + { name: 'palm', gen: palm }, +] as const + +// Golden snapshots captured after the named-stream RNG migration. A change here means +// generator output shifted — intentional changes should update these numbers deliberately. +const golden: Record = { + tree: { verts: 1830 }, + pine: { verts: 840 }, + palm: { verts: 3630 }, +} + +function allFinite(m: Mesh): boolean { + const p = m.positions + for (let i = 0; i < p.length; i++) if (!Number.isFinite(p[i])) return false + return true +} + +describe.each(generators)('$name generator', ({ name, gen }) => { + it('produces a valid, colored, finite mesh', () => { + const m = gen({ seed: 1 }) + expect(m.vertexCount).toBeGreaterThan(0) + expect(m.colors).not.toBeNull() + expect(allFinite(m)).toBe(true) + }) + + it('matches the golden vertex count for seed 1', () => { + expect(gen({ seed: 1 }).vertexCount).toBe(golden[name].verts) + }) + + it('is deterministic: same seed → identical positions', () => { + const a = gen({ seed: 7 }).positions + const b = gen({ seed: 7 }).positions + expect(Array.from(a)).toEqual(Array.from(b)) + }) + + it('different seeds produce different geometry', () => { + const a = gen({ seed: 1 }).positions + const b = gen({ seed: 2 }).positions + expect(Array.from(a)).not.toEqual(Array.from(b)) + }) +}) + +describe('stream independence at the model level', () => { + // The headline benefit of named streams: a feature that only affects color (snow) + // must not perturb geometry, because positions come from independent streams. + it.each(generators)('$name: toggling snow leaves geometry byte-identical', ({ gen }) => { + const bare = gen({ seed: 3, snowColors: [] }) + const snowy = gen({ seed: 3, snowColors: ['#ffffff', '#eeeeee'] }) + + // Same geometry... + expect(Array.from(snowy.positions)).toEqual(Array.from(bare.positions)) + // ...but different coloring. + expect(Array.from(snowy.colors!)).not.toEqual(Array.from(bare.colors!)) + }) +}) diff --git a/tests/rng.test.ts b/tests/rng.test.ts new file mode 100644 index 0000000..edf839e --- /dev/null +++ b/tests/rng.test.ts @@ -0,0 +1,123 @@ +import { describe, it, expect } from 'vitest' +import { createRng } from '../src/core/rng' + +describe('createRng', () => { + it('is callable and returns floats in [0, 1)', () => { + const rng = createRng(1) + for (let i = 0; i < 100; i++) { + const v = rng() + expect(v).toBeGreaterThanOrEqual(0) + expect(v).toBeLessThan(1) + } + }) + + it('is deterministic for the same seed', () => { + const a = createRng(42) + const b = createRng(42) + const seqA = Array.from({ length: 10 }, () => a()) + const seqB = Array.from({ length: 10 }, () => b()) + expect(seqA).toEqual(seqB) + }) + + it('differs across seeds', () => { + const a = createRng(1) + const b = createRng(2) + expect(a()).not.toEqual(b()) + }) + + it('accepts string seeds', () => { + const a = createRng('hello') + const b = createRng('hello') + expect(a()).toEqual(b()) + }) +}) + +describe('Rng helpers', () => { + it('int() is inclusive and within bounds', () => { + const rng = createRng(7) + const seen = new Set() + for (let i = 0; i < 1000; i++) { + const v = rng.int(1, 6) + expect(Number.isInteger(v)).toBe(true) + expect(v).toBeGreaterThanOrEqual(1) + expect(v).toBeLessThanOrEqual(6) + seen.add(v) + } + // Should eventually hit both ends of the inclusive range + expect(seen.has(1)).toBe(true) + expect(seen.has(6)).toBe(true) + }) + + it('float(min, max) stays within bounds', () => { + const rng = createRng(3) + for (let i = 0; i < 100; i++) { + const v = rng.float(5, 10) + expect(v).toBeGreaterThanOrEqual(5) + expect(v).toBeLessThan(10) + } + }) + + it('range() passes through fixed values and resolves tuples', () => { + const rng = createRng(9) + expect(rng.range(2.5)).toBe(2.5) + const v = rng.range([0, 4]) + expect(v).toBeGreaterThanOrEqual(0) + expect(v).toBeLessThanOrEqual(4) + const n = rng.range([0, 4], true) + expect(Number.isInteger(n)).toBe(true) + }) + + it('pick() returns an element of the array', () => { + const rng = createRng(11) + const arr = ['a', 'b', 'c'] + for (let i = 0; i < 50; i++) { + expect(arr).toContain(rng.pick(arr)) + } + }) + + it('seed() produces deterministic integer seeds', () => { + const a = createRng(5) + const b = createRng(5) + expect(a.seed()).toBe(b.seed()) + expect(Number.isInteger(a.seed())).toBe(true) + }) +}) + +describe('named streams', () => { + it('a stream is deterministic for the same name', () => { + const rootA = createRng(1) + const rootB = createRng(1) + const seqA = Array.from({ length: 5 }, () => rootA.stream('canopy')()) + // Re-derive each time — same name + same root => same first value + expect(rootB.stream('canopy')()).toEqual(rootA.stream('canopy')()) + expect(seqA.every((v) => v >= 0 && v < 1)).toBe(true) + }) + + it('different stream names are independent', () => { + const root = createRng(1) + expect(root.stream('a')()).not.toEqual(root.stream('b')()) + }) + + it('consuming one stream does not perturb another (the key property)', () => { + // Baseline: read canopy without touching snow at all. + const root1 = createRng(99) + const canopy1 = root1.stream('canopy') + const baseline = [canopy1(), canopy1(), canopy1()] + + // Now heavily consume an unrelated stream first, then read canopy. + const root2 = createRng(99) + const snow2 = root2.stream('snow') + for (let i = 0; i < 50; i++) snow2() + const canopy2 = root2.stream('canopy') + const after = [canopy2(), canopy2(), canopy2()] + + expect(after).toEqual(baseline) + }) + + it('fork() yields independent anonymous streams', () => { + const root = createRng(1) + const f1 = root.fork() + const f2 = root.fork() + expect(f1()).not.toEqual(f2()) + }) +}) -- 2.51.2