Add scrub edge bushes
This commit is contained in:
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user