Add scrub edge bushes

This commit is contained in:
2026-07-31 16:53:28 +08:00
parent eb4a0d9b2a
commit 41a07a78ef
4 changed files with 319 additions and 10 deletions

View File

@@ -103,12 +103,105 @@ def sample_tree_row(points, spacing, height):
def polygon_area(ring):
"""Unsigned shoelace area; 0.0 for degenerate rings."""
return abs(signed_polygon_area(ring))
def signed_polygon_area(ring):
"""Signed shoelace area; positive for counter-clockwise rings."""
if len(ring) < 3:
return 0.0
area = 0.0
for (x1, y1), (x2, y2) in zip(ring, ring[1:] + ring[:1]):
area += x1 * y2 - x2 * y1
return abs(area) * 0.5
return area * 0.5
def sample_ring_boundary(ring, spacing, inset=0.0, max_samples=None):
"""Evenly sample a closed ring's boundary.
Returns (x, y, angle, index) samples. ``angle`` follows the local edge
direction, and ``inset`` moves the sample toward the polygon interior.
"""
if len(ring) > 1 and ring[0] == ring[-1]:
ring = ring[:-1]
if len(ring) < 3 or spacing <= 0.0:
return []
edges = []
perimeter = 0.0
winding = signed_polygon_area(ring)
for index, (start, end) in enumerate(zip(ring, ring[1:] + ring[:1])):
dx = end[0] - start[0]
dy = end[1] - start[1]
length = math.hypot(dx, dy)
if length <= 1e-9:
continue
ux = dx / length
uy = dy / length
# Counter-clockwise rings have their interior on the left side of each
# edge; clockwise rings have it on the right.
inward = (-uy, ux) if winding >= 0.0 else (uy, -ux)
edges.append((perimeter, start, ux, uy, length, inward, index))
perimeter += length
if not edges:
return []
count = max(1, int(perimeter / spacing))
if max_samples:
count = min(count, max_samples)
step = perimeter / count
samples = []
edge_cursor = 0
for sample_index in range(count):
target = (sample_index + 0.5) * step
while edge_cursor + 1 < len(edges) and (
edges[edge_cursor][0] + edges[edge_cursor][4] < target
):
edge_cursor += 1
edge_start, start, ux, uy, length, inward, _ = edges[edge_cursor]
along = max(0.0, min(length, target - edge_start))
x = start[0] + ux * along
y = start[1] + uy * along
sx = x + inward[0] * inset
sy = y + inward[1] * inset
if inset > 0.0 and not point_in_polygon((sx, sy), ring):
sx, sy = x, y
samples.append((sx, sy, math.atan2(uy, ux), sample_index))
return samples
def sample_polygon_interior(ring, spacing, edge_clearance=0.0, max_samples=None,
seed=0):
"""Jittered interior samples for sparse planting inside a polygon."""
if len(ring) > 1 and ring[0] == ring[-1]:
ring = ring[:-1]
if len(ring) < 3 or spacing <= 0.0 or polygon_area(ring) <= 1e-9:
return []
xmin = min(x for x, _ in ring)
xmax = max(x for x, _ in ring)
ymin = min(y for _, y in ring)
ymax = max(y for _, y in ring)
cols = max(1, int(math.ceil((xmax - xmin) / spacing)))
rows = max(1, int(math.ceil((ymax - ymin) / spacing)))
samples = []
for col in range(cols):
for row in range(rows):
sample_seed = ((col + 1) * 73856093) ^ ((row + 1) * 19349663) ^ seed
jx = ((sample_seed * 0.61803398875) % 1.0 - 0.5) * spacing * 0.7
jy = ((sample_seed * 0.41421356237) % 1.0 - 0.5) * spacing * 0.7
x = xmin + (col + 0.5) * spacing + jx
y = ymin + (row + 0.5) * spacing + jy
if not point_in_polygon((x, y), ring):
continue
if edge_clearance > 0.0 and distance_to_ring((x, y), ring) < edge_clearance:
continue
samples.append((x, y, sample_seed))
if max_samples and len(samples) > max_samples:
samples.sort(key=lambda item: (item[2] * 0.754877666) % 1.0)
samples = samples[:max_samples]
return samples
def point_in_polygon(point, ring):