forest_navigating_uav/worldgen/forest_worldgen/patterns/regular.py
  1
  2
  3
  4
  5
  6
  7
  8
  9
 10
 11
 12
 13
 14
 15
 16
 17
 18
 19
 20
 21
 22
 23
 24
 25
 26
 27
 28
 29
 30
 31
 32
 33
 34
 35
 36
 37
 38
 39
 40
 41
 42
 43
 44
 45
 46
 47
 48
 49
 50
 51
 52
 53
 54
 55
 56
 57
 58
 59
 60
 61
 62
 63
 64
 65
 66
 67
 68
 69
 70
 71
 72
 73
 74
 75
 76
 77
 78
 79
 80
 81
 82
 83
 84
 85
 86
 87
 88
 89
 90
 91
 92
 93
 94
 95
 96
 97
 98
 99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
"""
Model a regular inhibitory point process.

Generates spatially repulsive tree placements that approximate
plantation-style or competition-driven spacing.

Algorithm
---------
Bridson-style Poisson-disc with a background grid for O(1) neighbour
lookup, falling back to rejection sampling when the active list is
exhausted before ``count`` points are placed.

Validation targets (logged, not enforced):
    Clark-Evans R   > 1
    g(r) < 1        at small r
    L(r) - r < 0    at small r
"""

import math
import random


# ---------------------------------------------------------------------------
# Bridson Poisson-disc core
# ---------------------------------------------------------------------------


def _bridson_poisson_disc(
    count, x_min, x_max, y_min, y_max, d_min, existing_positions, k_candidates=30
):
    """
    Run Bridson's algorithm for Poisson-disc sampling.

    Apply it in a rectangular
    domain [x_min, x_max] x [y_min, y_max].

    Parameters
    ----------
    count : int
        Desired number of points.
    x_min, x_max, y_min, y_max : float
        Domain bounds.
    d_min : float
        Hard minimum separation distance.
    existing_positions : list[(float, float)]
        Points already placed (respected for distance checks).
    k_candidates : int
        Candidates tested around each active point before rejection.

    Returns
    -------
    list[(float, float)]
        Generated positions (up to *count*).
    """
    width = x_max - x_min
    height = y_max - y_min

    if d_min <= 0:
        # degenerate: just uniform
        pts = []
        for _ in range(count):
            pts.append((random.uniform(x_min, x_max), random.uniform(y_min, y_max)))
        return pts

    cell = d_min / math.sqrt(2)
    cols = max(1, int(math.ceil(width / cell)))
    rows = max(1, int(math.ceil(height / cell)))

    # spatial grid  (-1 = empty, otherwise index into *all_points*)
    grid = [[-1] * cols for _ in range(rows)]

    all_points = list(existing_positions)  # indices 0 .. len(existing)-1

    def _grid_col(x):
        return max(0, min(cols - 1, int((x - x_min) / cell)))

    def _grid_row(y):
        return max(0, min(rows - 1, int((y - y_min) / cell)))

    # seed existing points into the grid
    for idx, (px, py) in enumerate(existing_positions):
        if x_min <= px <= x_max and y_min <= py <= y_max:
            grid[_grid_row(py)][_grid_col(px)] = idx

    def _neighbours_ok(x, y):
        """Check d_min against grid neighbourhood (5x5 cells)."""
        gc = _grid_col(x)
        gr = _grid_row(y)
        for dr in range(-2, 3):
            for dc in range(-2, 3):
                rr = gr + dr
                cc = gc + dc
                if 0 <= rr < rows and 0 <= cc < cols:
                    pi = grid[rr][cc]
                    if pi != -1:
                        px, py2 = all_points[pi]
                        if math.hypot(x - px, y - py2) < d_min:
                            return False
        return True

    # --- phase 1: Bridson active-list ---
    new_points = []
    active = []

    def _insert(x, y):
        idx = len(all_points)
        all_points.append((x, y))
        new_points.append((x, y))
        grid[_grid_row(y)][_grid_col(x)] = idx
        active.append(idx)

    # first seed
    for _attempt in range(200):
        sx = random.uniform(x_min, x_max)
        sy = random.uniform(y_min, y_max)
        if _neighbours_ok(sx, sy):
            _insert(sx, sy)
            break
    else:
        # couldn't even place the seed; just uniform-fill
        sx = random.uniform(x_min, x_max)
        sy = random.uniform(y_min, y_max)
        _insert(sx, sy)

    while active and len(new_points) < count:
        # pick random active point
        ai = random.randrange(len(active))
        pi = active[ai]
        px, py = all_points[pi]
        found = False
        for _ in range(k_candidates):
            angle = random.uniform(0, 2 * math.pi)
            r = random.uniform(d_min, 2 * d_min)
            cx = px + r * math.cos(angle)
            cy = py + r * math.sin(angle)
            if cx < x_min or cx > x_max or cy < y_min or cy > y_max:
                continue
            if _neighbours_ok(cx, cy):
                _insert(cx, cy)
                found = True
                if len(new_points) >= count:
                    break
        if not found:
            active.pop(ai)

    # --- phase 2: rejection-sampling fallback ---
    max_fallback = count * 50
    attempts = 0
    while len(new_points) < count and attempts < max_fallback:
        cx = random.uniform(x_min, x_max)
        cy = random.uniform(y_min, y_max)
        attempts += 1
        if _neighbours_ok(cx, cy):
            _insert(cx, cy)

    # --- phase 3: if still short, relax and warn ---
    if len(new_points) < count:
        deficit = count - len(new_points)
        print(
            f"Warning [regular]: could only place {len(new_points)}/{count} "
            f"with d_min={d_min:.2f}; relaxing for {deficit} remaining points"
        )
        for _ in range(deficit):
            cx = random.uniform(x_min, x_max)
            cy = random.uniform(y_min, y_max)
            new_points.append((cx, cy))

    return new_points


# ---------------------------------------------------------------------------
# Public API  (matches the sampler signature used by the rest of the system)
# ---------------------------------------------------------------------------


def sample_regular(count, region, K, min_distance, existing_positions, params):
    """
    Poisson-disc / inhibitory point process placement.

    Parameters
    ----------
    count : int
        Number of points to generate.
    region : dict or None
        Rectangular sub-region ``{x_min, x_max, y_min, y_max}`` or None
        for the full K*K world.
    K : float
        World side length (domain is [-K/2, K/2]^2).
    min_distance : float
        Global minimum distance from world config.
    existing_positions : list[(float, float)]
        Already-placed points.
    params : dict
        Distribution parameters.  Recognised keys:
            ``min_distance``  - override d_min (default: world min_distance)
            ``k_candidates``  - Bridson candidates per active point (default 30)

    Returns
    -------
    list[(float, float)]
    """
    d_min = params.get("min_distance", min_distance)
    k_cand = int(params.get("k_candidates", 30))

    if region is not None:
        x_min = region["x_min"]
        x_max = region["x_max"]
        y_min = region["y_min"]
        y_max = region["y_max"]
    else:
        x_min = -K / 2
        x_max = K / 2
        y_min = -K / 2
        y_max = K / 2

    return _bridson_poisson_disc(
        count,
        x_min,
        x_max,
        y_min,
        y_max,
        d_min,
        existing_positions,
        k_candidates=k_cand,
    )