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

@@ -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,