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

Binary file not shown.

View File

@@ -40,6 +40,9 @@ from osmassets.geom import ( # noqa: E402 (needs the sys.path line above)
feature_in_bounds,
geometry_rings,
point_in_polygon,
polygon_area,
sample_polygon_interior,
sample_ring_boundary,
sample_tree_row,
)
from osmassets.materials import ( # noqa: E402
@@ -94,6 +97,9 @@ def _assemble_building(ring, way_id, tag, args, buildings_c, building_mats):
MODEL_ROOT = os.path.abspath(os.path.join(
os.path.dirname(__file__), "..", "assets", "models", "polyhaven"
))
CUSTOM_MODEL_ROOT = os.path.abspath(os.path.join(
os.path.dirname(__file__), "..", "assets", "models", "custom"
))
# Vendored under the name shrub_02, but the plant reads as a tufted grass, not
# as a bush: it belongs on the lawns. Scrub beds stay flat ground cover until a
@@ -116,6 +122,22 @@ TUFT_LIMIT_PER_LAWN = 100
TUFT_TINT = (0.15, 0.52, 0.09)
TUFT_TINT_FACTOR = 0.66
SCRUB_BUSH_MODEL = os.path.join(CUSTOM_MODEL_ROOT, "bush", "bush.glb")
SCRUB_BUSH_SPACING = 0.82
SCRUB_BUSH_INSET = 0.38
SCRUB_BUSH_LIMIT_PER_PATCH = 180
SCRUB_BUSH_HEIGHT = 1.575
SCRUB_BUSH_SCALE_JITTER = 0.16
SCRUB_BUSH_LEAF_TINT = (0.075, 0.31, 0.055)
SCRUB_BUSH_LEAF_TINT_FACTOR = 0.36
SCRUB_BUSH_TRUNK_TINT = (0.13, 0.095, 0.055)
SCRUB_BUSH_TRUNK_TINT_FACTOR = 0.18
SCRUB_TREE_MIN_AREA = 45.0
SCRUB_TREE_SPACING = 8.0
SCRUB_TREE_EDGE_CLEARANCE = 2.8
SCRUB_TREE_LIMIT_PER_PATCH = 5
SCRUB_TREE_HEIGHT_RANGE = (4.6, 6.2)
# Tree styles. The two built from mesh batches live in this file; the rest are
# vendored models handled by osmassets.tree, which owns that list. A model style
# whose asset is missing falls back to "natural" rather than planting nothing.
@@ -476,25 +498,142 @@ def add_grass_tufts(name, ring, variants, collection):
return len(spots)
def add_scrub_patch(name, ring, ground_material, collection):
if len(ring) < 3:
def _bake_imported_mesh(obj, name):
mesh = obj.data.copy()
mesh.transform(obj.matrix_world.to_3x3().to_4x4())
mesh.name = name
mesh.use_fake_user = True
return mesh
def _measure_meshes(meshes):
zs = [vertex.co.z for mesh in meshes for vertex in mesh.vertices]
if not zs:
return 1.0, 0.0
low, high = min(zs), max(zs)
return max(high - low, 1e-6), low
def _discard_imported_mesh_objects(objects):
for obj in objects:
mesh = obj.data if obj.type == "MESH" else None
bpy.data.objects.remove(obj, do_unlink=True)
if mesh is not None and mesh.users == 0:
bpy.data.meshes.remove(mesh)
def load_scrub_bush_variant():
"""Import the optional custom bush once for edge instancing."""
if not os.path.exists(SCRUB_BUSH_MODEL):
return None
before = set(bpy.data.objects)
try:
bpy.ops.import_scene.gltf(filepath=SCRUB_BUSH_MODEL)
except (RuntimeError, AttributeError) as error:
print("Scrub bush import failed, scrub stays flat:", error)
return None
imported = [obj for obj in set(bpy.data.objects) - before if obj.type == "MESH"]
if not imported:
print("Scrub bush import produced no mesh objects, scrub stays flat")
return None
meshes = []
for index, obj in enumerate(sorted(imported, key=lambda item: item.name)):
mesh = _bake_imported_mesh(obj, f"ScrubBush_{index:02d}_{obj.name}")
mesh.calc_loop_triangles()
for material in mesh.materials:
if material is None:
continue
material_name = material.name.lower()
if "leaf" in material_name:
tint_base_color(
material, SCRUB_BUSH_LEAF_TINT,
SCRUB_BUSH_LEAF_TINT_FACTOR)
elif "trunk" in material_name or "twig" in material_name:
tint_base_color(
material, SCRUB_BUSH_TRUNK_TINT,
SCRUB_BUSH_TRUNK_TINT_FACTOR)
if getattr(material, "blend_method", "OPAQUE") == "BLEND":
material.blend_method = "HASHED"
material.alpha_threshold = 0.45
material.show_transparent_back = False
meshes.append(mesh)
height, base_z = _measure_meshes(meshes)
_discard_imported_mesh_objects(imported)
tris = sum(len(mesh.loop_triangles) for mesh in meshes)
print(f"Scrub bush loaded: {len(meshes)} mesh(es), {tris} tris shared")
return {"meshes": meshes, "height": height, "base_z": base_z}
def add_scrub_edge_bushes(name, ring, variant, collection):
if not variant:
return 0
samples = sample_ring_boundary(
ring, SCRUB_BUSH_SPACING, inset=SCRUB_BUSH_INSET,
max_samples=SCRUB_BUSH_LIMIT_PER_PATCH)
if not samples:
return 0
base_scale = SCRUB_BUSH_HEIGHT / variant["height"]
for index, (x, y, angle, seed) in enumerate(samples):
jitter = 1.0 + SCRUB_BUSH_SCALE_JITTER * (
2.0 * ((seed * 0.61803398875) % 1.0) - 1.0)
scale = base_scale * jitter
z = 0.065 - variant["base_z"] * scale
rotation = angle + math.tau * 0.08 * ((seed * 0.41421356237) % 1.0 - 0.5)
for mesh_index, mesh in enumerate(variant["meshes"]):
obj = bpy.data.objects.new(
f"{name}_Bush_{index:03d}_{mesh_index:02d}", mesh)
obj.location = (x, y, z)
obj.rotation_euler = (0.0, 0.0, rotation)
obj.scale = (scale, scale, scale)
collection.objects.link(obj)
return len(samples)
def add_scrub_patch(name, ring, ground_material, collection, bush_variant=None):
if len(ring) < 3:
return None, 0
if ring[0] == ring[-1]:
ring = ring[:-1]
if len(ring) < 3:
return None
return None, 0
ground = MeshBatch(name, collection, ground_material)
ground.add_polygon(ring, 0.055)
obj = ground.finish()
bush_count = add_scrub_edge_bushes(name, ring, bush_variant, collection)
# The vendored plant asset reads as grass, so it went to the lawns. Until a
# bush-shaped model lands, scrub beds stay flat ground cover: the previous
# procedural hedge mass read as blocky colour patches, not as planting.
if obj:
obj["scrub_texture"] = "Poly Haven leafy_grass, scrub-tinted"
obj["scrub_style"] = "flat foliage ground cover"
return obj
obj["scrub_style"] = (
"edge bush instances" if bush_count else "flat foliage ground cover"
)
obj["scrub_edge_bushes"] = bush_count
return obj, bush_count
def sample_scrub_interior_trees(ring, way_id):
if polygon_area(ring) < SCRUB_TREE_MIN_AREA:
return []
try:
seed = int(way_id)
except (TypeError, ValueError):
seed = sum(ord(char) for char in str(way_id))
samples = sample_polygon_interior(
ring, SCRUB_TREE_SPACING,
edge_clearance=SCRUB_TREE_EDGE_CLEARANCE,
max_samples=SCRUB_TREE_LIMIT_PER_PATCH,
seed=seed)
min_height, max_height = SCRUB_TREE_HEIGHT_RANGE
span = max_height - min_height
trees = []
for x, y, sample_seed in samples:
height = min_height + span * ((sample_seed * 0.754877666) % 1.0)
trees.append((x, y, height))
return trees
def add_fountain(name, x, y, collection, materials):
@@ -605,6 +744,7 @@ def build(args):
# between the ground materials and the props. Material creation order fixes
# the material indices in the exported GLB.
tuft_variants = load_tuft_variants()
scrub_bush_variant = load_scrub_bush_variant()
fountain_mats = {
key: material_from_spec(catalog.MATERIALS[key])
for key in ("fountain_stone", "fountain_water", "fountain_spray")
@@ -631,6 +771,7 @@ def build(args):
ground_batch.finish()
grass_rings = []
scrub_trees = []
tree_rows = []
# Counters were previously individual ints scattered through the loop body.
# Collecting them into a dict lets the scene[...] and SCENE_DONE sections
@@ -643,8 +784,17 @@ def build(args):
"grass_tuft_count": 0,
"industrial_count": 0,
"lake_count": 0,
"scrub_bush_count": 0,
"scrub_count": 0,
"scrub_tree_count": 0,
}
def add_scrub_patch_with_bushes(name, ring, ground_material, collection):
obj, bush_count = add_scrub_patch(
name, ring, ground_material, collection, scrub_bush_variant)
counts["scrub_bush_count"] += bush_count
return obj
focus_points = []
for way in ways:
coords = way["coords"]
@@ -668,10 +818,13 @@ def build(args):
elif tag.get("natural") == "scrub" and len(ring) >= 3:
added, ring_pts = _scrub.assemble(ring, way["id"], scene_xmin, scene_xmax,
scene_ymin, scene_ymax, green_c, scrub_mat,
add_scrub_patch)
add_scrub_patch_with_bushes)
counts["scrub_count"] += added
if ring_pts:
focus_points.extend(ring_pts)
new_scrub_trees = sample_scrub_interior_trees(ring_pts, way["id"])
scrub_trees.extend(new_scrub_trees)
counts["scrub_tree_count"] += len(new_scrub_trees)
elif tag.get("natural") == "tree_row":
tree_rows.append((ring, tag))
focus_points.extend(ring)
@@ -712,6 +865,7 @@ def build(args):
x, y = projector.xy(feature["coord"])
trees.append((x, y, parse_height(feature["tags"], 5.5)))
individual_tree_count += 1
trees.extend(scrub_trees)
row_tree_count = 0
for row, row_tags in tree_rows:
row_samples = sample_tree_row(row, spacing=5.0,
@@ -800,6 +954,8 @@ def build(args):
scene["grass_count"] = counts["grass_count"]
scene["grass_tuft_count"] = counts["grass_tuft_count"]
scene["scrub_count"] = counts["scrub_count"]
scene["scrub_bush_count"] = counts["scrub_bush_count"]
scene["scrub_tree_count"] = counts["scrub_tree_count"]
scene["fountain_count"] = counts["fountain_count"]
scene["tree_node_count"] = individual_tree_count
scene["tree_row_count"] = row_tree_count
@@ -829,6 +985,8 @@ def build(args):
"grass": counts["grass_count"],
"grass_tufts": counts["grass_tuft_count"],
"scrub": counts["scrub_count"],
"scrub_bushes": counts["scrub_bush_count"],
"scrub_trees": counts["scrub_tree_count"],
"fountains": counts["fountain_count"],
"tree_nodes": individual_tree_count,
"tree_row_instances": row_tree_count,

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):

View File

@@ -25,7 +25,10 @@ from osmassets.geom import (
geometry_rings,
point_in_polygon,
polygon_area,
sample_polygon_interior,
sample_ring_boundary,
sample_tree_row,
signed_polygon_area,
)
from osmassets.osm import Projector, parse_height, parse_osm, tags
@@ -84,11 +87,66 @@ class PolygonAreaTest(unittest.TestCase):
def test_winding_does_not_change_the_sign(self):
self.assertAlmostEqual(polygon_area(list(reversed(SQUARE))), 100.0)
self.assertGreater(signed_polygon_area(SQUARE), 0.0)
self.assertLess(signed_polygon_area(list(reversed(SQUARE))), 0.0)
def test_degenerate(self):
self.assertEqual(polygon_area([(0.0, 0.0), (1.0, 1.0)]), 0.0)
class SampleRingBoundaryTest(unittest.TestCase):
def test_samples_closed_boundary_evenly(self):
samples = sample_ring_boundary(SQUARE, spacing=10.0)
self.assertEqual(len(samples), 4)
self.assertEqual([(round(x, 6), round(y, 6)) for x, y, _, _ in samples],
[(5.0, 0.0), (10.0, 5.0), (5.0, 10.0), (0.0, 5.0)])
def test_repeated_closing_point_is_ignored(self):
open_samples = sample_ring_boundary(SQUARE, spacing=10.0)
closed_samples = sample_ring_boundary(SQUARE + [SQUARE[0]], spacing=10.0)
self.assertEqual(open_samples, closed_samples)
def test_inset_moves_samples_inside_for_either_winding(self):
ccw = sample_ring_boundary(SQUARE, spacing=10.0, inset=1.0)
cw = sample_ring_boundary(list(reversed(SQUARE)), spacing=10.0, inset=1.0)
self.assertTrue(all(point_in_polygon((x, y), SQUARE) for x, y, _, _ in ccw))
self.assertTrue(all(point_in_polygon((x, y), SQUARE) for x, y, _, _ in cw))
self.assertEqual([(round(x, 6), round(y, 6)) for x, y, _, _ in ccw],
[(5.0, 1.0), (9.0, 5.0), (5.0, 9.0), (1.0, 5.0)])
def test_max_samples_reduces_density(self):
samples = sample_ring_boundary(SQUARE, spacing=1.0, max_samples=5)
self.assertEqual(len(samples), 5)
def test_degenerate_input(self):
self.assertEqual(sample_ring_boundary([(0.0, 0.0)], spacing=1.0), [])
self.assertEqual(sample_ring_boundary(SQUARE, spacing=0.0), [])
class SamplePolygonInteriorTest(unittest.TestCase):
def test_samples_are_inside_and_clear_of_edges(self):
samples = sample_polygon_interior(SQUARE, spacing=3.0, edge_clearance=1.0,
seed=42)
self.assertTrue(samples)
for x, y, _ in samples:
self.assertTrue(point_in_polygon((x, y), SQUARE))
self.assertGreaterEqual(distance_to_ring((x, y), SQUARE), 1.0)
def test_seed_is_deterministic(self):
first = sample_polygon_interior(SQUARE, spacing=3.0, seed=7)
second = sample_polygon_interior(SQUARE, spacing=3.0, seed=7)
self.assertEqual(first, second)
def test_max_samples_reduces_density(self):
samples = sample_polygon_interior(SQUARE, spacing=1.0, max_samples=4,
seed=99)
self.assertEqual(len(samples), 4)
def test_degenerate_input(self):
self.assertEqual(sample_polygon_interior([(0.0, 0.0)], spacing=1.0), [])
self.assertEqual(sample_polygon_interior(SQUARE, spacing=0.0), [])
class PointInPolygonTest(unittest.TestCase):
def test_inside_and_outside(self):
self.assertTrue(point_in_polygon((5.0, 5.0), SQUARE))