diff --git a/assets/models/custom/bush/bush.glb b/assets/models/custom/bush/bush.glb new file mode 100644 index 0000000..b38ac75 Binary files /dev/null and b/assets/models/custom/bush/bush.glb differ diff --git a/blender/generate_scene.py b/blender/generate_scene.py index a7b95c9..94ae44a 100644 --- a/blender/generate_scene.py +++ b/blender/generate_scene.py @@ -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, diff --git a/blender/osmassets/geom.py b/blender/osmassets/geom.py index 4dae0e2..428997d 100644 --- a/blender/osmassets/geom.py +++ b/blender/osmassets/geom.py @@ -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): diff --git a/blender/tests/test_pure.py b/blender/tests/test_pure.py index 86d829f..404eac3 100644 --- a/blender/tests/test_pure.py +++ b/blender/tests/test_pure.py @@ -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))