Add scrub edge bushes
This commit is contained in:
BIN
assets/models/custom/bush/bush.glb
Normal file
BIN
assets/models/custom/bush/bush.glb
Normal file
Binary file not shown.
@@ -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,
|
||||
|
||||
@@ -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):
|
||||
|
||||
@@ -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))
|
||||
|
||||
Reference in New Issue
Block a user