diff --git a/docs/PROCEDURAL_GENERATION_SPEC.md b/docs/PROCEDURAL_GENERATION_SPEC.md new file mode 100644 index 0000000..91ca01a --- /dev/null +++ b/docs/PROCEDURAL_GENERATION_SPEC.md @@ -0,0 +1,1026 @@ +# McRogueFace Procedural Generation System Specification + +**Version:** 1.0 Draft +**Date:** 2026-01-11 +**Status:** Design Complete, Pending Implementation + +## Overview + +This specification defines the procedural generation system for McRogueFace, exposing libtcod's noise, BSP, and heightmap capabilities through a Pythonic interface optimized for batch operations on Grid and Layer objects. + +### Design Philosophy + +1. **HeightMap as Universal Canvas**: All procedural data flows through HeightMap objects. Noise and BSP structures generate data *onto* HeightMaps, and Grids/Layers consume data *from* HeightMaps. + +2. **Data Stays in C++**: Python defines rules and configuration; C++ executes batch operations. Map data (potentially 1M+ cells) never crosses the Python/C++ boundary during generation. + +3. **Composability**: HeightMaps can be combined (add, multiply, lerp), allowing complex terrain from simple building blocks. + +4. **Vector-Based Coordinates**: All positions use `(x, y)` tuples. Regions use `((x, y), (w, h))` for bounds or `((x1, y1), (x2, y2))` for world regions. + +5. **Mutation with Chaining**: Operations mutate in place and return `self` for method chaining. Threshold operations return new HeightMaps to preserve originals. + +--- + +## Architecture + +``` +┌─────────────────────────────────────────────────────────────────────┐ +│ SOURCES │ +│ │ +│ NoiseSource BSP │ +│ (infinite function, (structural partition, │ +│ stateless) tree of nodes) │ +│ │ │ │ +│ │ .sample() │ .to_heightmap() │ +│ ▼ ▼ │ +│ ┌─────────────┐ ┌─────────────┐ │ +│ │ NoiseSample │ │ BSPMap │ │ +│ │ (HeightMap) │ │ (HeightMap) │ │ +│ └──────┬──────┘ └──────┬──────┘ │ +│ │ ▲ │ ▲ │ +│ │ │ .add_noise() │ │ .add_bsp() │ +│ │ │ .multiply_noise() │ │ .multiply_bsp() │ +│ ▼ │ ▼ │ │ +│ └────┴───────────┬───────────────────┴────┘ │ +│ ▼ │ +│ ┌─────────────────┐ │ +│ │ HeightMap │ │ +│ │ (2D float[]) │ │ +│ │ │ │ +│ │ Operations: │ │ +│ │ • add/multiply │ │ +│ │ • hills/erosion │ │ +│ │ • threshold │ │ +│ │ • normalize │ │ +│ └────────┬────────┘ │ +│ │ │ +│ ┌──────────────┼──────────────┐ │ +│ ▼ ▼ ▼ │ +│ ┌───────────┐ ┌───────────┐ ┌───────────┐ │ +│ │ Grid │ │ TileLayer │ │ColorLayer │ │ +│ │ │ │ │ │ │ │ +│ │ walkable │ │ tile_index│ │ color │ │ +│ │transparent│ │ │ │ gradient │ │ +│ └───────────┘ └───────────┘ └───────────┘ │ +│ TARGETS │ +└─────────────────────────────────────────────────────────────────────┘ +``` + +--- + +## Class Reference + +### mcrfpy.HeightMap + +The universal canvas for procedural generation. A 2D grid of float values that can be generated, manipulated, combined, and applied to game objects. + +#### Constructor + +```python +HeightMap(size: tuple[int, int], fill: float = 0.0) +``` + +| Parameter | Type | Description | +|-----------|------|-------------| +| `size` | `(int, int)` | Width and height in cells. Immutable after creation. | +| `fill` | `float` | Initial value for all cells. Default 0.0. | + +#### Properties + +| Property | Type | Description | +|----------|------|-------------| +| `size` | `(int, int)` | Read-only. Width and height of the heightmap. | + +#### Scalar Operations + +All scalar operations mutate in place and return `self` for chaining. + +```python +def fill(self, value: float) -> HeightMap +``` +Set all cells to `value`. + +```python +def clear(self) -> HeightMap +``` +Set all cells to 0.0. Equivalent to `fill(0.0)`. + +```python +def add_constant(self, value: float) -> HeightMap +``` +Add `value` to every cell. + +```python +def scale(self, factor: float) -> HeightMap +``` +Multiply every cell by `factor`. + +```python +def clamp(self, min: float = 0.0, max: float = 1.0) -> HeightMap +``` +Clamp all values to the range [min, max]. + +```python +def normalize(self, min: float = 0.0, max: float = 1.0) -> HeightMap +``` +Linearly rescale values so the current minimum becomes `min` and current maximum becomes `max`. + +#### HeightMap Combination + +All combination operations mutate in place and return `self`. The `other` HeightMap must have the same size. + +```python +def add(self, other: HeightMap) -> HeightMap +``` +Add `other` to this heightmap cell-by-cell. + +```python +def subtract(self, other: HeightMap) -> HeightMap +``` +Subtract `other` from this heightmap cell-by-cell. + +```python +def multiply(self, other: HeightMap) -> HeightMap +``` +Multiply this heightmap by `other` cell-by-cell. Useful for masking. + +```python +def lerp(self, other: HeightMap, t: float) -> HeightMap +``` +Linear interpolation: `self = self + (other - self) * t`. + +```python +def copy_from(self, other: HeightMap) -> HeightMap +``` +Copy all values from `other` into this heightmap. + +```python +def max(self, other: HeightMap) -> HeightMap +``` +Per-cell maximum of this and `other`. + +```python +def min(self, other: HeightMap) -> HeightMap +``` +Per-cell minimum of this and `other`. + +#### Direct Source Sampling + +These methods sample from sources directly onto the heightmap without intermediate allocation. + +```python +def add_noise(self, + source: NoiseSource, + world_region: tuple = None, + mode: str = "fbm", + octaves: int = 4, + scale: float = 1.0) -> HeightMap +``` +Sample noise and add to current values. + +| Parameter | Type | Description | +|-----------|------|-------------| +| `source` | `NoiseSource` | The noise generator to sample from. | +| `world_region` | `((x1,y1), (x2,y2))` | World coordinates to sample. Default: `((0,0), size)`. | +| `mode` | `str` | `"flat"`, `"fbm"`, or `"turbulence"`. Default: `"fbm"`. | +| `octaves` | `int` | Octaves for fbm/turbulence. Default: 4. | +| `scale` | `float` | Multiplier for sampled values. Default: 1.0. | + +```python +def multiply_noise(self, source: NoiseSource, **kwargs) -> HeightMap +``` +Sample noise and multiply with current values. Same parameters as `add_noise`. + +```python +def add_bsp(self, + bsp: BSP, + select: str = "leaves", + nodes: list[BSPNode] = None, + shrink: int = 0, + value: float = 1.0) -> HeightMap +``` +Add BSP node regions to heightmap. + +| Parameter | Type | Description | +|-----------|------|-------------| +| `bsp` | `BSP` | The BSP tree to sample from. | +| `select` | `str` | `"leaves"`, `"all"`, or `"internal"`. Default: `"leaves"`. | +| `nodes` | `list[BSPNode]` | Override: specific nodes only. Default: None (use select). | +| `shrink` | `int` | Pixels to shrink from node bounds. Default: 0. | +| `value` | `float` | Value to add inside selected regions. Default: 1.0. | + +```python +def multiply_bsp(self, bsp: BSP, **kwargs) -> HeightMap +``` +Multiply by BSP regions. Same parameters as `add_bsp`. + +#### Terrain Generation + +These methods implement libtcod heightmap generation algorithms. + +```python +def add_hill(self, center: tuple[int, int], radius: float, height: float) -> HeightMap +``` +Add a hill (half-spheroid) at the specified position. + +```python +def dig_hill(self, center: tuple[int, int], radius: float, depth: float) -> HeightMap +``` +Dig a depression. Takes minimum of current value and hill shape (for carving rivers, pits). + +```python +def add_voronoi(self, + num_points: int, + coefficients: tuple[float, ...] = (1.0, -0.5), + seed: int = None) -> HeightMap +``` +Add Voronoi diagram values. Coefficients weight distance to Nth-closest point. + +```python +def mid_point_displacement(self, + roughness: float = 0.5, + seed: int = None) -> HeightMap +``` +Generate fractal terrain using diamond-square algorithm. Roughness should be 0.4-0.6. + +```python +def rain_erosion(self, + drops: int, + erosion: float = 0.1, + sedimentation: float = 0.05, + seed: int = None) -> HeightMap +``` +Simulate rain erosion. `drops` should be at least `width * height`. + +```python +def dig_bezier(self, + points: tuple[tuple[int, int], ...], + start_radius: float, + end_radius: float, + start_depth: float, + end_depth: float) -> HeightMap +``` +Carve a path along a cubic Bezier curve. Requires exactly 4 control points. + +```python +def smooth(self, iterations: int = 1) -> HeightMap +``` +Apply smoothing kernel. Each iteration averages cells with neighbors. + +#### Threshold Operations + +These methods return **new** HeightMap objects, preserving the original. + +```python +def threshold(self, range: tuple[float, float]) -> HeightMap +``` +Return new HeightMap with original values where in range, 0.0 elsewhere. + +```python +def threshold_binary(self, + range: tuple[float, float], + value: float = 1.0) -> HeightMap +``` +Return new HeightMap with `value` where in range, 0.0 elsewhere. + +```python +def inverse(self) -> HeightMap +``` +Return new HeightMap with `(1.0 - value)` for each cell. + +#### Queries + +These methods return values to Python for inspection. + +```python +def get(self, pos: tuple[int, int]) -> float +``` +Get value at integer coordinates. + +```python +def get_interpolated(self, pos: tuple[float, float]) -> float +``` +Get bilinearly interpolated value at float coordinates. + +```python +def get_slope(self, pos: tuple[int, int]) -> float +``` +Get slope (0 to π/2) at position. + +```python +def get_normal(self, pos: tuple[int, int]) -> tuple[float, float, float] +``` +Get normalized surface normal vector at position. + +```python +def min_max(self) -> tuple[float, float] +``` +Return (minimum_value, maximum_value) across all cells. + +```python +def count_in_range(self, range: tuple[float, float]) -> int +``` +Count cells with values in the specified range. + +--- + +### mcrfpy.NoiseSource + +A configured noise generator function. Stateless and infinite - the same coordinates always produce the same value. + +#### Constructor + +```python +NoiseSource(dimensions: int = 2, + algorithm: str = "simplex", + hurst: float = 0.5, + lacunarity: float = 2.0, + seed: int = None) +``` + +| Parameter | Type | Description | +|-----------|------|-------------| +| `dimensions` | `int` | 1-4. Number of input dimensions. Default: 2. | +| `algorithm` | `str` | `"simplex"`, `"perlin"`, or `"wavelet"`. Default: `"simplex"`. | +| `hurst` | `float` | Fractal Hurst exponent for fbm/turbulence. Default: 0.5. | +| `lacunarity` | `float` | Frequency multiplier between octaves. Default: 2.0. | +| `seed` | `int` | Random seed. None for random. | + +#### Properties + +| Property | Type | Description | +|----------|------|-------------| +| `dimensions` | `int` | Read-only. | +| `algorithm` | `str` | Read-only. | +| `hurst` | `float` | Read-only. | +| `lacunarity` | `float` | Read-only. | +| `seed` | `int` | Read-only. | + +#### Point Queries + +```python +def get(self, pos: tuple[float, ...]) -> float +``` +Get flat noise value at coordinates. Tuple length must match dimensions. Returns -1.0 to 1.0. + +```python +def fbm(self, pos: tuple[float, ...], octaves: int = 4) -> float +``` +Get fractal brownian motion value. Returns -1.0 to 1.0. + +```python +def turbulence(self, pos: tuple[float, ...], octaves: int = 4) -> float +``` +Get turbulence (absolute fbm) value. Returns -1.0 to 1.0. + +#### Batch Sampling + +```python +def sample(self, + size: tuple[int, int], + world_region: tuple = None, + mode: str = "fbm", + octaves: int = 4) -> NoiseSample +``` +Create a NoiseSample (HeightMap subclass) by sampling a region. + +| Parameter | Type | Description | +|-----------|------|-------------| +| `size` | `(int, int)` | Output dimensions in cells. | +| `world_region` | `((x1,y1), (x2,y2))` | World coordinates to sample. Default: `((0,0), size)`. | +| `mode` | `str` | `"flat"`, `"fbm"`, or `"turbulence"`. Default: `"fbm"`. | +| `octaves` | `int` | Octaves for fbm/turbulence. Default: 4. | + +--- + +### mcrfpy.NoiseSample + +A HeightMap created by sampling a NoiseSource. Tracks its origin for convenient adjacent sampling and rescaling. + +#### Inheritance + +`NoiseSample` extends `HeightMap` - all HeightMap methods are available. + +#### Properties + +| Property | Type | Description | +|----------|------|-------------| +| `source` | `NoiseSource` | Read-only. The generator that created this sample. | +| `world_region` | `((x1,y1), (x2,y2))` | Read-only. World coordinates that were sampled. | +| `mode` | `str` | Read-only. `"flat"`, `"fbm"`, or `"turbulence"`. | +| `octaves` | `int` | Read-only. Octaves used for sampling. | + +#### Adjacent Sampling + +```python +def next_left(self) -> NoiseSample +def next_right(self) -> NoiseSample +def next_up(self) -> NoiseSample +def next_down(self) -> NoiseSample +``` +Sample the adjacent region in the specified direction. Returns a new NoiseSample with the same size, mode, and octaves, but shifted world_region. + +#### Rescaling + +```python +def resample(self, size: tuple[int, int]) -> NoiseSample +``` +Resample the same world_region at a different output resolution. Useful for minimaps (smaller size) or detail views (larger size). + +```python +def zoom(self, factor: float) -> NoiseSample +``` +Sample a different-sized world region at the same output size. +- `factor > 1.0`: Zoom in (smaller world region, more detail) +- `factor < 1.0`: Zoom out (larger world region, overview) + +--- + +### mcrfpy.BSP + +Binary space partition tree for rectangular regions. Useful for dungeon rooms, zones, or any hierarchical spatial organization. + +#### Constructor + +```python +BSP(bounds: tuple[tuple[int, int], tuple[int, int]]) +``` + +| Parameter | Type | Description | +|-----------|------|-------------| +| `bounds` | `((x, y), (w, h))` | Position and size of the root region. | + +#### Properties + +| Property | Type | Description | +|----------|------|-------------| +| `bounds` | `((x, y), (w, h))` | Read-only. Root node bounds. | +| `root` | `BSPNode` | Read-only. Reference to the root node. | + +#### Splitting + +```python +def split_once(self, horizontal: bool, position: int) -> BSP +``` +Split the root node once. Returns self for chaining. + +```python +def split_recursive(self, + depth: int, + min_size: tuple[int, int], + max_ratio: float = 1.5, + seed: int = None) -> BSP +``` +Recursively split to the specified depth. + +| Parameter | Type | Description | +|-----------|------|-------------| +| `depth` | `int` | Maximum recursion depth. Creates up to 2^depth leaves. | +| `min_size` | `(int, int)` | Minimum (width, height) for a node to be split. | +| `max_ratio` | `float` | Maximum aspect ratio before forcing split direction. Default: 1.5. | +| `seed` | `int` | Random seed. None for random. | + +```python +def clear(self) -> BSP +``` +Remove all children, keeping only the root node with original bounds. + +#### Iteration + +```python +def leaves(self) -> Iterator[BSPNode] +``` +Iterate all leaf nodes (the actual rooms). + +```python +def traverse(self, order: Traversal = Traversal.LEVEL_ORDER) -> Iterator[BSPNode] +``` +Iterate all nodes in the specified order. + +#### Queries + +```python +def find(self, pos: tuple[int, int]) -> BSPNode +``` +Find the smallest (deepest) node containing the position. + +```python +def find_path(self, start: BSPNode, end: BSPNode) -> tuple[BSPNode, ...] +``` +Find a sequence of sibling-connected nodes between start and end leaves. Useful for corridor generation. + +#### HeightMap Generation + +```python +def to_heightmap(self, + size: tuple[int, int] = None, + select: str = "leaves", + nodes: list[BSPNode] = None, + shrink: int = 0, + value: float = 1.0) -> BSPMap +``` +Create a BSPMap (HeightMap subclass) from selected nodes. + +| Parameter | Type | Description | +|-----------|------|-------------| +| `size` | `(int, int)` | Output size. Default: bounds size. | +| `select` | `str` | `"leaves"`, `"all"`, or `"internal"`. Default: `"leaves"`. | +| `nodes` | `list[BSPNode]` | Override: specific nodes only. | +| `shrink` | `int` | Pixels to shrink from each node's bounds. Default: 0. | +| `value` | `float` | Value inside selected regions. Default: 1.0. | + +--- + +### mcrfpy.BSPNode + +A lightweight reference to a node in a BSP tree. Read-only. + +#### Properties + +| Property | Type | Description | +|----------|------|-------------| +| `bounds` | `((x, y), (w, h))` | Position and size of this node. | +| `level` | `int` | Depth in tree (0 for root). | +| `index` | `int` | Unique index within the tree. | +| `is_leaf` | `bool` | True if this node has no children. | +| `split_horizontal` | `bool \| None` | Split orientation. None if leaf. | +| `split_position` | `int \| None` | Split coordinate. None if leaf. | + +#### Navigation + +| Property | Type | Description | +|----------|------|-------------| +| `left` | `BSPNode \| None` | Left child, or None if leaf. | +| `right` | `BSPNode \| None` | Right child, or None if leaf. | +| `parent` | `BSPNode \| None` | Parent node, or None if root. | +| `sibling` | `BSPNode \| None` | Other child of parent, or None. | + +#### Methods + +```python +def contains(self, pos: tuple[int, int]) -> bool +``` +Check if position is inside this node's bounds. + +```python +def center(self) -> tuple[int, int] +``` +Return the center point of this node's bounds. + +--- + +### mcrfpy.BSPMap + +A HeightMap created from BSP node selection. Tracks its origin for convenient re-querying. + +#### Inheritance + +`BSPMap` extends `HeightMap` - all HeightMap methods are available. + +#### Properties + +| Property | Type | Description | +|----------|------|-------------| +| `bsp` | `BSP` | Read-only. The tree that created this map. | +| `nodes` | `tuple[BSPNode, ...]` | Read-only. Nodes represented in this map. | +| `shrink` | `int` | Read-only. Margin applied from bounds. | + +#### BSP-Specific Operations + +```python +def inverse(self) -> BSPMap +``` +Return a new BSPMap with walls instead of rooms (inverts the selection within BSP bounds). + +```python +def expand(self, amount: int = 1) -> BSPMap +``` +Return a new BSPMap with node bounds grown by amount. + +```python +def contract(self, amount: int = 1) -> BSPMap +``` +Return a new BSPMap with node bounds shrunk by additional amount. + +#### Re-querying + +```python +def with_nodes(self, nodes: list[BSPNode]) -> BSPMap +``` +Return a new BSPMap with different node selection. + +```python +def with_shrink(self, shrink: int) -> BSPMap +``` +Return a new BSPMap with different shrink value. + +--- + +### mcrfpy.Traversal + +Enumeration for BSP tree traversal orders. + +```python +class Traversal(Enum): + PRE_ORDER = "pre" # Node, then left, then right + IN_ORDER = "in" # Left, then node, then right + POST_ORDER = "post" # Left, then right, then node + LEVEL_ORDER = "level" # Top to bottom, left to right + INVERTED_LEVEL_ORDER = "level_inverted" # Bottom to top, right to left +``` + +--- + +## Application to Grid and Layers + +### Grid.apply_threshold + +```python +def apply_threshold(self, + source: HeightMap, + range: tuple[float, float], + walkable: bool = None, + transparent: bool = None) -> Grid +``` +Set walkable/transparent properties where source value is in range. + +| Parameter | Type | Description | +|-----------|------|-------------| +| `source` | `HeightMap` | Must match grid size. | +| `range` | `(float, float)` | (min, max) inclusive range. | +| `walkable` | `bool \| None` | Set walkable to this value. None = don't change. | +| `transparent` | `bool \| None` | Set transparent to this value. None = don't change. | + +### Grid.apply_ranges + +```python +def apply_ranges(self, + source: HeightMap, + ranges: list[tuple]) -> Grid +``` +Apply multiple thresholds in a single pass. + +```python +# Example +grid.apply_ranges(terrain, [ + ((0.0, 0.3), {"walkable": False, "transparent": True}), # Water + ((0.3, 0.8), {"walkable": True, "transparent": True}), # Land + ((0.8, 1.0), {"walkable": False, "transparent": False}), # Mountains +]) +``` + +### TileLayer.apply_threshold + +```python +def apply_threshold(self, + source: HeightMap, + range: tuple[float, float], + tile: int) -> TileLayer +``` +Set tile index where source value is in range. + +### TileLayer.apply_ranges + +```python +def apply_ranges(self, + source: HeightMap, + ranges: list[tuple]) -> TileLayer +``` +Apply multiple tile assignments in a single pass. + +```python +# Example +tiles.apply_ranges(terrain, [ + ((0.0, 0.2), DEEP_WATER), + ((0.2, 0.3), SHALLOW_WATER), + ((0.3, 0.5), SAND), + ((0.5, 0.7), GRASS), + ((0.7, 0.85), ROCK), + ((0.85, 1.0), SNOW), +]) +``` + +### ColorLayer.apply_threshold + +```python +def apply_threshold(self, + source: HeightMap, + range: tuple[float, float], + color: tuple[int, ...]) -> ColorLayer +``` +Set fixed color where source value is in range. Color is (R, G, B) or (R, G, B, A). + +### ColorLayer.apply_gradient + +```python +def apply_gradient(self, + source: HeightMap, + range: tuple[float, float], + color_low: tuple[int, ...], + color_high: tuple[int, ...]) -> ColorLayer +``` +Interpolate between colors based on source value within range. +- At range minimum → color_low +- At range maximum → color_high + +### ColorLayer.apply_ranges + +```python +def apply_ranges(self, + source: HeightMap, + ranges: list[tuple]) -> ColorLayer +``` +Apply multiple color assignments. Each range maps to either a fixed color or a gradient. + +```python +# Example +colors.apply_ranges(terrain, [ + ((0.0, 0.3), (0, 0, 180)), # Fixed blue + ((0.3, 0.7), ((50, 120, 50), (100, 200, 100))), # Gradient green (tuple of 2) + ((0.7, 1.0), ((100, 100, 100), (255, 255, 255))), # Gradient gray to white +]) +``` + +--- + +## Usage Examples + +### Example 1: Simple Dungeon + +```python +import mcrfpy + +# Create BSP dungeon layout +bsp = mcrfpy.BSP(bounds=((0, 0), (80, 50))) +bsp.split_recursive(depth=4, min_size=(6, 6), max_ratio=1.5) + +# Generate room mask (1.0 inside rooms, 0.0 in walls) +rooms = bsp.to_heightmap(select="leaves", shrink=1) + +# Apply to grid +grid.apply_threshold(rooms, range=(0.5, 1.0), walkable=True, transparent=True) + +# Apply floor tiles +tiles.apply_threshold(rooms, range=(0.5, 1.0), tile=FLOOR_TILE) + +# Walls are left as default (not in threshold range) +``` + +### Example 2: Natural Caves + +```python +import mcrfpy + +# Create noise generator +noise = mcrfpy.NoiseSource(algorithm="simplex", seed=42) + +# Sample onto heightmap +cave = noise.sample( + size=(100, 100), + world_region=((0, 0), (10, 10)), # 10x zoom for smooth features + mode="fbm", + octaves=4 +) +cave.normalize() + +# Open areas where noise > 0.45 +grid.apply_threshold(cave, range=(0.45, 1.0), walkable=True, transparent=True) +tiles.apply_threshold(cave, range=(0.45, 1.0), tile=CAVE_FLOOR) +tiles.apply_threshold(cave, range=(0.0, 0.45), tile=CAVE_WALL) +``` + +### Example 3: Overworld with Rivers + +```python +import mcrfpy + +# Base terrain +terrain = mcrfpy.HeightMap(size=(200, 200)) +terrain.mid_point_displacement(roughness=0.5) + +# Add hills +terrain.add_hill(center=(50, 80), radius=20, height=0.4) +terrain.add_hill(center=(150, 120), radius=25, height=0.5) + +# Carve river +terrain.dig_bezier( + points=((10, 100), (60, 80), (140, 120), (190, 100)), + start_radius=2, end_radius=5, + start_depth=0.5, end_depth=0.5 +) + +# Erosion for realism +terrain.rain_erosion(drops=8000) +terrain.normalize() + +# Apply terrain bands +grid.apply_ranges(terrain, [ + ((0.0, 0.25), {"walkable": False, "transparent": True}), # Water + ((0.25, 0.75), {"walkable": True, "transparent": True}), # Land + ((0.75, 1.0), {"walkable": False, "transparent": False}), # Mountains +]) + +tiles.apply_ranges(terrain, [ + ((0.0, 0.25), WATER_TILE), + ((0.25, 0.4), SAND_TILE), + ((0.4, 0.65), GRASS_TILE), + ((0.65, 0.8), ROCK_TILE), + ((0.8, 1.0), SNOW_TILE), +]) + +colors.apply_ranges(terrain, [ + ((0.0, 0.25), ((0, 50, 150), (0, 100, 200))), + ((0.25, 0.65), ((80, 160, 80), (120, 200, 120))), + ((0.65, 1.0), ((120, 120, 120), (255, 255, 255))), +]) +``` + +### Example 4: Dungeon with Varied Room Terrain + +```python +import mcrfpy + +# Create dungeon structure +bsp = mcrfpy.BSP(bounds=((0, 0), (100, 100))) +bsp.split_recursive(depth=5, min_size=(8, 8)) + +# Room mask +rooms = bsp.to_heightmap(select="leaves", shrink=1) + +# Create terrain variation +noise = mcrfpy.NoiseSource(seed=123) +terrain = mcrfpy.HeightMap(size=(100, 100)) +terrain.add_noise(noise, mode="fbm", octaves=4) +terrain.normalize() + +# Mask terrain to rooms only +terrain.multiply(rooms) + +# Apply varied floor tiles within rooms +tiles.apply_threshold(rooms, range=(0.5, 1.0), tile=STONE_FLOOR) # Base floor +tiles.apply_threshold(terrain, range=(0.3, 0.5), tile=MOSSY_FLOOR) +tiles.apply_threshold(terrain, range=(0.5, 0.7), tile=CRACKED_FLOOR) + +# Walkability from rooms mask +grid.apply_threshold(rooms, range=(0.5, 1.0), walkable=True, transparent=True) +``` + +### Example 5: Infinite World with Chunks + +```python +import mcrfpy + +# Persistent world generator +WORLD_SEED = 12345 +world_noise = mcrfpy.NoiseSource(seed=WORLD_SEED) + +CHUNK_SIZE = 64 + +def generate_chunk(chunk_x: int, chunk_y: int) -> mcrfpy.NoiseSample: + """Generate terrain for a world chunk.""" + return world_noise.sample( + size=(CHUNK_SIZE, CHUNK_SIZE), + world_region=( + (chunk_x * CHUNK_SIZE, chunk_y * CHUNK_SIZE), + ((chunk_x + 1) * CHUNK_SIZE, (chunk_y + 1) * CHUNK_SIZE) + ), + mode="fbm", + octaves=6 + ) + +# Generate current chunk +current_chunk = generate_chunk(0, 0) + +# When player moves right, get adjacent chunk +next_chunk = current_chunk.next_right() + +# Generate minimap of large area +minimap = world_noise.sample( + size=(100, 100), + world_region=((0, 0), (1000, 1000)), + mode="fbm", + octaves=3 +) +``` + +### Example 6: Biome Blending + +```python +import mcrfpy + +# Multiple noise layers +elevation_noise = mcrfpy.NoiseSource(seed=1) +moisture_noise = mcrfpy.NoiseSource(seed=2) + +# Sample both +elevation = mcrfpy.HeightMap(size=(200, 200)) +elevation.add_noise(elevation_noise, mode="fbm", octaves=6) +elevation.normalize() + +moisture = mcrfpy.HeightMap(size=(200, 200)) +moisture.add_noise(moisture_noise, mode="fbm", octaves=4) +moisture.normalize() + +# Desert: low moisture AND medium elevation +desert_mask = moisture.threshold((0.0, 0.3)) +desert_mask.multiply(elevation.threshold((0.2, 0.6))) + +# Forest: high moisture AND low elevation +forest_mask = moisture.threshold((0.6, 1.0)) +forest_mask.multiply(elevation.threshold((0.1, 0.5))) + +# Swamp: high moisture AND very low elevation +swamp_mask = moisture.threshold((0.7, 1.0)) +swamp_mask.multiply(elevation.threshold((0.0, 0.2))) + +# Apply biome tiles (later biomes override earlier) +tiles.apply_threshold(elevation, range=(0.0, 1.0), tile=GRASS_TILE) # Default +tiles.apply_threshold(desert_mask, range=(0.5, 1.0), tile=SAND_TILE) +tiles.apply_threshold(forest_mask, range=(0.5, 1.0), tile=TREE_TILE) +tiles.apply_threshold(swamp_mask, range=(0.5, 1.0), tile=SWAMP_TILE) +``` + +--- + +## Implementation Notes + +### Size Matching + +All HeightMap operations between two heightmaps require matching sizes. The `apply_*` methods on Grid/Layer also require the source HeightMap to match the Grid's `grid_size`. + +```python +# This will raise ValueError +small = mcrfpy.HeightMap(size=(50, 50)) +large = mcrfpy.HeightMap(size=(100, 100)) +large.add(small) # Error: size mismatch +``` + +### Value Ranges + +- NoiseSource outputs: -1.0 to 1.0 +- HeightMap after `normalize()`: 0.0 to 1.0 (by default) +- Threshold operations: work on any float range + +Recommendation: Always `normalize()` before applying to Grid/Layer for predictable threshold behavior. + +### Performance Considerations + +For grids up to 1000×1000 (1M cells): + +| Operation | Approximate Cost | +|-----------|------------------| +| HeightMap creation | O(n) | +| add/multiply/scale | O(n), parallelizable | +| add_noise | O(n × octaves), parallelizable | +| mid_point_displacement | O(n log n) | +| rain_erosion | O(drops) | +| apply_ranges | O(n × num_ranges), single pass | + +All operations execute entirely in C++ with no Python callbacks. + +--- + +## Future Considerations + +The following features are explicitly **out of scope** for the initial implementation but should not be precluded by the design: + +1. **Serialization**: Saving/loading HeightMaps to disk for pre-generated worlds. + +2. **Region Operations**: Applying operations to sub-regions when HeightMap sizes don't match. + +3. **Corridor Generation**: Built-in BSP corridor creation (currently user code using `find_path` and `dig_bezier` or manual fills). + +4. **Custom Kernels**: User-defined kernel transforms beyond simple smoothing. + +--- + +## Appendix: libtcod Function Mapping + +| libtcod Function | McRogueFace Equivalent | +|------------------|------------------------| +| `TCOD_noise_new` | `NoiseSource()` | +| `TCOD_noise_get` | `NoiseSource.get()` | +| `TCOD_noise_get_fbm` | `NoiseSource.fbm()` | +| `TCOD_noise_get_turbulence` | `NoiseSource.turbulence()` | +| `TCOD_heightmap_new` | `HeightMap()` | +| `TCOD_heightmap_add_hill` | `HeightMap.add_hill()` | +| `TCOD_heightmap_dig_hill` | `HeightMap.dig_hill()` | +| `TCOD_heightmap_rain_erosion` | `HeightMap.rain_erosion()` | +| `TCOD_heightmap_add_voronoi` | `HeightMap.add_voronoi()` | +| `TCOD_heightmap_mid_point_displacement` | `HeightMap.mid_point_displacement()` | +| `TCOD_heightmap_dig_bezier` | `HeightMap.dig_bezier()` | +| `TCOD_heightmap_add_fbm` | `HeightMap.add_noise(..., mode="fbm")` | +| `TCOD_heightmap_scale_fbm` | `HeightMap.multiply_noise(..., mode="fbm")` | +| `TCOD_heightmap_normalize` | `HeightMap.normalize()` | +| `TCOD_heightmap_clamp` | `HeightMap.clamp()` | +| `TCOD_heightmap_add` | `HeightMap.add()` | +| `TCOD_heightmap_multiply` | `HeightMap.multiply()` | +| `TCOD_heightmap_lerp` | `HeightMap.lerp()` | +| `TCOD_bsp_new_with_size` | `BSP()` | +| `TCOD_bsp_split_once` | `BSP.split_once()` | +| `TCOD_bsp_split_recursive` | `BSP.split_recursive()` | +| `TCOD_bsp_traverse_*` | `BSP.traverse(order=...)` | +| `TCOD_bsp_is_leaf` | `BSPNode.is_leaf` | +| `TCOD_bsp_contains` | `BSPNode.contains()` | +| `TCOD_bsp_find_node` | `BSP.find()` | diff --git a/src/McRFPy_API.cpp b/src/McRFPy_API.cpp index 4eee80a..8fd857b 100644 --- a/src/McRFPy_API.cpp +++ b/src/McRFPy_API.cpp @@ -21,6 +21,7 @@ #include "PyKeyboard.h" #include "PyMouse.h" #include "UIGridPathfinding.h" // AStarPath and DijkstraMap types +#include "PyHeightMap.h" // Procedural generation heightmap (#193) #include "McRogueFaceVersion.h" #include "GameEngine.h" #include "ImGuiConsole.h" @@ -415,6 +416,9 @@ PyObject* PyInit_mcrfpy() &mcrfpydef::PyAStarPathType, &mcrfpydef::PyDijkstraMapType, + /*procedural generation (#192)*/ + &mcrfpydef::PyHeightMapType, + nullptr}; // Types that are used internally but NOT exported to module namespace (#189) @@ -439,7 +443,11 @@ PyObject* PyInit_mcrfpy() // Set up PySceneType methods and getsetters PySceneType.tp_methods = PySceneClass::methods; PySceneType.tp_getset = PySceneClass::getsetters; - + + // Set up PyHeightMapType methods and getsetters (#193) + mcrfpydef::PyHeightMapType.tp_methods = PyHeightMap::methods; + mcrfpydef::PyHeightMapType.tp_getset = PyHeightMap::getsetters; + // Set up weakref support for all types that need it PyTimerType.tp_weaklistoffset = offsetof(PyTimerObject, weakreflist); PyUIFrameType.tp_weaklistoffset = offsetof(PyUIFrameObject, weakreflist); diff --git a/src/PyHeightMap.cpp b/src/PyHeightMap.cpp new file mode 100644 index 0000000..d38e299 --- /dev/null +++ b/src/PyHeightMap.cpp @@ -0,0 +1,285 @@ +#include "PyHeightMap.h" +#include "McRFPy_API.h" +#include "McRFPy_Doc.h" +#include + +// Property definitions +PyGetSetDef PyHeightMap::getsetters[] = { + {"size", (getter)PyHeightMap::get_size, NULL, + MCRF_PROPERTY(size, "Dimensions (width, height) of the heightmap. Read-only."), NULL}, + {NULL} +}; + +// Method definitions +PyMethodDef PyHeightMap::methods[] = { + {"fill", (PyCFunction)PyHeightMap::fill, METH_VARARGS, + MCRF_METHOD(HeightMap, fill, + MCRF_SIG("(value: float)", "HeightMap"), + MCRF_DESC("Set all cells to the specified value."), + MCRF_ARGS_START + MCRF_ARG("value", "The value to set for all cells") + MCRF_RETURNS("HeightMap: self, for method chaining") + )}, + {"clear", (PyCFunction)PyHeightMap::clear, METH_NOARGS, + MCRF_METHOD(HeightMap, clear, + MCRF_SIG("()", "HeightMap"), + MCRF_DESC("Set all cells to 0.0. Equivalent to fill(0.0)."), + MCRF_RETURNS("HeightMap: self, for method chaining") + )}, + {"add_constant", (PyCFunction)PyHeightMap::add_constant, METH_VARARGS, + MCRF_METHOD(HeightMap, add_constant, + MCRF_SIG("(value: float)", "HeightMap"), + MCRF_DESC("Add a constant value to every cell."), + MCRF_ARGS_START + MCRF_ARG("value", "The value to add to each cell") + MCRF_RETURNS("HeightMap: self, for method chaining") + )}, + {"scale", (PyCFunction)PyHeightMap::scale, METH_VARARGS, + MCRF_METHOD(HeightMap, scale, + MCRF_SIG("(factor: float)", "HeightMap"), + MCRF_DESC("Multiply every cell by a factor."), + MCRF_ARGS_START + MCRF_ARG("factor", "The multiplier for each cell") + MCRF_RETURNS("HeightMap: self, for method chaining") + )}, + {"clamp", (PyCFunction)PyHeightMap::clamp, METH_VARARGS | METH_KEYWORDS, + MCRF_METHOD(HeightMap, clamp, + MCRF_SIG("(min: float = 0.0, max: float = 1.0)", "HeightMap"), + MCRF_DESC("Clamp all values to the specified range."), + MCRF_ARGS_START + MCRF_ARG("min", "Minimum value (default 0.0)") + MCRF_ARG("max", "Maximum value (default 1.0)") + MCRF_RETURNS("HeightMap: self, for method chaining") + )}, + {"normalize", (PyCFunction)PyHeightMap::normalize, METH_VARARGS | METH_KEYWORDS, + MCRF_METHOD(HeightMap, normalize, + MCRF_SIG("(min: float = 0.0, max: float = 1.0)", "HeightMap"), + MCRF_DESC("Linearly rescale values so the current minimum becomes min and current maximum becomes max."), + MCRF_ARGS_START + MCRF_ARG("min", "Target minimum value (default 0.0)") + MCRF_ARG("max", "Target maximum value (default 1.0)") + MCRF_RETURNS("HeightMap: self, for method chaining") + )}, + {NULL} +}; + +// Constructor +PyObject* PyHeightMap::pynew(PyTypeObject* type, PyObject* args, PyObject* kwds) +{ + PyHeightMapObject* self = (PyHeightMapObject*)type->tp_alloc(type, 0); + if (self) { + self->heightmap = nullptr; + } + return (PyObject*)self; +} + +int PyHeightMap::init(PyHeightMapObject* self, PyObject* args, PyObject* kwds) +{ + static const char* keywords[] = {"size", "fill", nullptr}; + PyObject* size_obj = nullptr; + float fill_value = 0.0f; + + if (!PyArg_ParseTupleAndKeywords(args, kwds, "O|f", const_cast(keywords), + &size_obj, &fill_value)) { + return -1; + } + + // Parse size tuple + if (!PyTuple_Check(size_obj) || PyTuple_Size(size_obj) != 2) { + PyErr_SetString(PyExc_TypeError, "size must be a tuple of (width, height)"); + return -1; + } + + int width = (int)PyLong_AsLong(PyTuple_GetItem(size_obj, 0)); + int height = (int)PyLong_AsLong(PyTuple_GetItem(size_obj, 1)); + + if (PyErr_Occurred()) { + return -1; + } + + if (width <= 0 || height <= 0) { + PyErr_SetString(PyExc_ValueError, "width and height must be positive integers"); + return -1; + } + + // Clean up any existing heightmap + if (self->heightmap) { + TCOD_heightmap_delete(self->heightmap); + } + + // Create new libtcod heightmap + self->heightmap = TCOD_heightmap_new(width, height); + if (!self->heightmap) { + PyErr_SetString(PyExc_MemoryError, "Failed to allocate heightmap"); + return -1; + } + + // Fill with initial value if not zero + if (fill_value != 0.0f) { + // libtcod's TCOD_heightmap_add adds to all cells, so we use it after clear + TCOD_heightmap_clear(self->heightmap); + TCOD_heightmap_add(self->heightmap, fill_value); + } + + return 0; +} + +void PyHeightMap::dealloc(PyHeightMapObject* self) +{ + if (self->heightmap) { + TCOD_heightmap_delete(self->heightmap); + self->heightmap = nullptr; + } + Py_TYPE(self)->tp_free((PyObject*)self); +} + +PyObject* PyHeightMap::repr(PyObject* obj) +{ + PyHeightMapObject* self = (PyHeightMapObject*)obj; + std::ostringstream ss; + + if (self->heightmap) { + ss << "heightmap->w << " x " << self->heightmap->h << ")>"; + } else { + ss << ""; + } + + return PyUnicode_FromString(ss.str().c_str()); +} + +// Property: size +PyObject* PyHeightMap::get_size(PyHeightMapObject* self, void* closure) +{ + if (!self->heightmap) { + PyErr_SetString(PyExc_RuntimeError, "HeightMap not initialized"); + return nullptr; + } + return Py_BuildValue("(ii)", self->heightmap->w, self->heightmap->h); +} + +// Method: fill(value) -> HeightMap +PyObject* PyHeightMap::fill(PyHeightMapObject* self, PyObject* args) +{ + float value; + if (!PyArg_ParseTuple(args, "f", &value)) { + return nullptr; + } + + if (!self->heightmap) { + PyErr_SetString(PyExc_RuntimeError, "HeightMap not initialized"); + return nullptr; + } + + // Clear and then add the value (libtcod doesn't have a direct "set all" function) + TCOD_heightmap_clear(self->heightmap); + if (value != 0.0f) { + TCOD_heightmap_add(self->heightmap, value); + } + + // Return self for chaining + Py_INCREF(self); + return (PyObject*)self; +} + +// Method: clear() -> HeightMap +PyObject* PyHeightMap::clear(PyHeightMapObject* self, PyObject* Py_UNUSED(args)) +{ + if (!self->heightmap) { + PyErr_SetString(PyExc_RuntimeError, "HeightMap not initialized"); + return nullptr; + } + + TCOD_heightmap_clear(self->heightmap); + + // Return self for chaining + Py_INCREF(self); + return (PyObject*)self; +} + +// Method: add_constant(value) -> HeightMap +PyObject* PyHeightMap::add_constant(PyHeightMapObject* self, PyObject* args) +{ + float value; + if (!PyArg_ParseTuple(args, "f", &value)) { + return nullptr; + } + + if (!self->heightmap) { + PyErr_SetString(PyExc_RuntimeError, "HeightMap not initialized"); + return nullptr; + } + + TCOD_heightmap_add(self->heightmap, value); + + // Return self for chaining + Py_INCREF(self); + return (PyObject*)self; +} + +// Method: scale(factor) -> HeightMap +PyObject* PyHeightMap::scale(PyHeightMapObject* self, PyObject* args) +{ + float factor; + if (!PyArg_ParseTuple(args, "f", &factor)) { + return nullptr; + } + + if (!self->heightmap) { + PyErr_SetString(PyExc_RuntimeError, "HeightMap not initialized"); + return nullptr; + } + + TCOD_heightmap_scale(self->heightmap, factor); + + // Return self for chaining + Py_INCREF(self); + return (PyObject*)self; +} + +// Method: clamp(min=0.0, max=1.0) -> HeightMap +PyObject* PyHeightMap::clamp(PyHeightMapObject* self, PyObject* args, PyObject* kwds) +{ + static const char* keywords[] = {"min", "max", nullptr}; + float min_val = 0.0f; + float max_val = 1.0f; + + if (!PyArg_ParseTupleAndKeywords(args, kwds, "|ff", const_cast(keywords), + &min_val, &max_val)) { + return nullptr; + } + + if (!self->heightmap) { + PyErr_SetString(PyExc_RuntimeError, "HeightMap not initialized"); + return nullptr; + } + + TCOD_heightmap_clamp(self->heightmap, min_val, max_val); + + // Return self for chaining + Py_INCREF(self); + return (PyObject*)self; +} + +// Method: normalize(min=0.0, max=1.0) -> HeightMap +PyObject* PyHeightMap::normalize(PyHeightMapObject* self, PyObject* args, PyObject* kwds) +{ + static const char* keywords[] = {"min", "max", nullptr}; + float min_val = 0.0f; + float max_val = 1.0f; + + if (!PyArg_ParseTupleAndKeywords(args, kwds, "|ff", const_cast(keywords), + &min_val, &max_val)) { + return nullptr; + } + + if (!self->heightmap) { + PyErr_SetString(PyExc_RuntimeError, "HeightMap not initialized"); + return nullptr; + } + + TCOD_heightmap_normalize(self->heightmap, min_val, max_val); + + // Return self for chaining + Py_INCREF(self); + return (PyObject*)self; +} diff --git a/src/PyHeightMap.h b/src/PyHeightMap.h new file mode 100644 index 0000000..df02af6 --- /dev/null +++ b/src/PyHeightMap.h @@ -0,0 +1,67 @@ +#pragma once +#include "Common.h" +#include "Python.h" +#include + +// Forward declaration +class PyHeightMap; + +// Python object structure +typedef struct { + PyObject_HEAD + TCOD_heightmap_t* heightmap; // libtcod heightmap pointer +} PyHeightMapObject; + +class PyHeightMap +{ +public: + // Python type interface + static PyObject* pynew(PyTypeObject* type, PyObject* args, PyObject* kwds); + static int init(PyHeightMapObject* self, PyObject* args, PyObject* kwds); + static void dealloc(PyHeightMapObject* self); + static PyObject* repr(PyObject* obj); + + // Properties + static PyObject* get_size(PyHeightMapObject* self, void* closure); + + // Scalar operations (all return self for chaining) + static PyObject* fill(PyHeightMapObject* self, PyObject* args); + static PyObject* clear(PyHeightMapObject* self, PyObject* Py_UNUSED(args)); + static PyObject* add_constant(PyHeightMapObject* self, PyObject* args); + static PyObject* scale(PyHeightMapObject* self, PyObject* args); + static PyObject* clamp(PyHeightMapObject* self, PyObject* args, PyObject* kwds); + static PyObject* normalize(PyHeightMapObject* self, PyObject* args, PyObject* kwds); + + // Method and property definitions + static PyMethodDef methods[]; + static PyGetSetDef getsetters[]; +}; + +namespace mcrfpydef { + static PyTypeObject PyHeightMapType = { + .ob_base = {.ob_base = {.ob_refcnt = 1, .ob_type = NULL}, .ob_size = 0}, + .tp_name = "mcrfpy.HeightMap", + .tp_basicsize = sizeof(PyHeightMapObject), + .tp_itemsize = 0, + .tp_dealloc = (destructor)PyHeightMap::dealloc, + .tp_repr = PyHeightMap::repr, + .tp_flags = Py_TPFLAGS_DEFAULT, + .tp_doc = PyDoc_STR( + "HeightMap(size: tuple[int, int], fill: float = 0.0)\n\n" + "A 2D grid of float values for procedural generation.\n\n" + "HeightMap is the universal canvas for procedural generation. It stores " + "float values that can be manipulated, combined, and applied to Grid and " + "Layer objects.\n\n" + "Args:\n" + " size: (width, height) dimensions of the heightmap. Immutable after creation.\n" + " fill: Initial value for all cells. Default 0.0.\n\n" + "Example:\n" + " hmap = mcrfpy.HeightMap((100, 100))\n" + " hmap.fill(0.5).scale(2.0).clamp(0.0, 1.0)\n" + ), + .tp_methods = nullptr, // Set in McRFPy_API.cpp before PyType_Ready + .tp_getset = nullptr, // Set in McRFPy_API.cpp before PyType_Ready + .tp_init = (initproc)PyHeightMap::init, + .tp_new = PyHeightMap::pynew, + }; +} diff --git a/tests/unit/test_heightmap_basic.py b/tests/unit/test_heightmap_basic.py new file mode 100644 index 0000000..9dd1279 --- /dev/null +++ b/tests/unit/test_heightmap_basic.py @@ -0,0 +1,199 @@ +#!/usr/bin/env python3 +"""Unit tests for mcrfpy.HeightMap core functionality (#193) + +Tests the HeightMap class constructor, size property, and scalar operations. +""" + +import sys +import mcrfpy + + +def test_constructor_basic(): + """HeightMap can be created with a size tuple""" + hmap = mcrfpy.HeightMap((100, 50)) + assert hmap is not None + print("PASS: test_constructor_basic") + + +def test_constructor_with_fill(): + """HeightMap can be created with a fill value""" + hmap = mcrfpy.HeightMap((10, 10), fill=0.5) + assert hmap is not None + print("PASS: test_constructor_with_fill") + + +def test_size_property(): + """size property returns correct dimensions""" + hmap = mcrfpy.HeightMap((100, 50)) + size = hmap.size + assert size == (100, 50), f"Expected (100, 50), got {size}" + print("PASS: test_size_property") + + +def test_size_immutable(): + """size property is read-only""" + hmap = mcrfpy.HeightMap((100, 50)) + try: + hmap.size = (200, 100) + print("FAIL: test_size_immutable - should have raised AttributeError") + sys.exit(1) + except AttributeError: + pass + print("PASS: test_size_immutable") + + +def test_fill_method(): + """fill() sets all cells and returns self""" + hmap = mcrfpy.HeightMap((10, 10)) + result = hmap.fill(0.5) + assert result is hmap, "fill() should return self" + print("PASS: test_fill_method") + + +def test_clear_method(): + """clear() sets all cells to 0.0 and returns self""" + hmap = mcrfpy.HeightMap((10, 10), fill=0.5) + result = hmap.clear() + assert result is hmap, "clear() should return self" + print("PASS: test_clear_method") + + +def test_add_constant_method(): + """add_constant() adds to all cells and returns self""" + hmap = mcrfpy.HeightMap((10, 10)) + result = hmap.add_constant(0.25) + assert result is hmap, "add_constant() should return self" + print("PASS: test_add_constant_method") + + +def test_scale_method(): + """scale() multiplies all cells and returns self""" + hmap = mcrfpy.HeightMap((10, 10), fill=0.5) + result = hmap.scale(2.0) + assert result is hmap, "scale() should return self" + print("PASS: test_scale_method") + + +def test_clamp_method(): + """clamp() clamps values and returns self""" + hmap = mcrfpy.HeightMap((10, 10), fill=0.5) + result = hmap.clamp(0.0, 1.0) + assert result is hmap, "clamp() should return self" + print("PASS: test_clamp_method") + + +def test_clamp_with_defaults(): + """clamp() works with default parameters""" + hmap = mcrfpy.HeightMap((10, 10), fill=0.5) + result = hmap.clamp() # Uses defaults 0.0, 1.0 + assert result is hmap + print("PASS: test_clamp_with_defaults") + + +def test_normalize_method(): + """normalize() rescales values and returns self""" + hmap = mcrfpy.HeightMap((10, 10)) + hmap.fill(0.25).add_constant(0.1) # Some values + result = hmap.normalize(0.0, 1.0) + assert result is hmap, "normalize() should return self" + print("PASS: test_normalize_method") + + +def test_normalize_with_defaults(): + """normalize() works with default parameters""" + hmap = mcrfpy.HeightMap((10, 10), fill=0.5) + result = hmap.normalize() # Uses defaults 0.0, 1.0 + assert result is hmap + print("PASS: test_normalize_with_defaults") + + +def test_method_chaining(): + """Methods can be chained""" + hmap = mcrfpy.HeightMap((10, 10)) + result = hmap.fill(0.5).scale(2.0).clamp(0.0, 1.0) + assert result is hmap, "Chained methods should return self" + print("PASS: test_method_chaining") + + +def test_complex_chaining(): + """Complex chains work correctly""" + hmap = mcrfpy.HeightMap((100, 100)) + result = (hmap + .fill(0.0) + .add_constant(0.5) + .scale(1.5) + .clamp(0.0, 1.0) + .normalize(0.2, 0.8)) + assert result is hmap + print("PASS: test_complex_chaining") + + +def test_repr(): + """repr() returns a readable string""" + hmap = mcrfpy.HeightMap((100, 50)) + r = repr(hmap) + assert "HeightMap" in r + assert "100" in r and "50" in r + print(f"PASS: test_repr - {r}") + + +def test_invalid_size(): + """Negative or zero size raises ValueError""" + try: + mcrfpy.HeightMap((0, 10)) + print("FAIL: test_invalid_size - should have raised ValueError for width=0") + sys.exit(1) + except ValueError: + pass + + try: + mcrfpy.HeightMap((10, -5)) + print("FAIL: test_invalid_size - should have raised ValueError for height=-5") + sys.exit(1) + except ValueError: + pass + + print("PASS: test_invalid_size") + + +def test_invalid_size_type(): + """Non-tuple size raises TypeError""" + try: + mcrfpy.HeightMap([100, 50]) # list instead of tuple + print("FAIL: test_invalid_size_type - should have raised TypeError") + sys.exit(1) + except TypeError: + pass + print("PASS: test_invalid_size_type") + + +def run_all_tests(): + """Run all tests""" + print("Running HeightMap basic tests...") + print() + + test_constructor_basic() + test_constructor_with_fill() + test_size_property() + test_size_immutable() + test_fill_method() + test_clear_method() + test_add_constant_method() + test_scale_method() + test_clamp_method() + test_clamp_with_defaults() + test_normalize_method() + test_normalize_with_defaults() + test_method_chaining() + test_complex_chaining() + test_repr() + test_invalid_size() + test_invalid_size_type() + + print() + print("All HeightMap basic tests PASSED!") + + +# Run tests directly +run_all_tests() +sys.exit(0)