diff --git a/assets/models/polyhaven/shrub_02/shrub_02.bin b/assets/models/polyhaven/shrub_02/shrub_02.bin new file mode 100644 index 0000000..428a3b5 Binary files /dev/null and b/assets/models/polyhaven/shrub_02/shrub_02.bin differ diff --git a/assets/models/polyhaven/shrub_02/shrub_02_1k.gltf b/assets/models/polyhaven/shrub_02/shrub_02_1k.gltf new file mode 100644 index 0000000..8e58e2e --- /dev/null +++ b/assets/models/polyhaven/shrub_02/shrub_02_1k.gltf @@ -0,0 +1,437 @@ +{ + "asset": { + "generator": "Khronos glTF Blender I/O v3.5.30", + "version": "2.0" + }, + "scene": 0, + "scenes": [ + { + "name": "Scene", + "nodes": [ + 0, + 1, + 2, + 3 + ] + } + ], + "nodes": [ + { + "mesh": 0, + "name": "shrub_02_a", + "rotation": [ + 0.12589912116527557, + -0.6947680711746216, + 0.006097283214330673, + 0.708102822303772 + ], + "translation": [ + -1.5478131771087646, + -0.005240630358457565, + -0.02073405683040619 + ] + }, + { + "mesh": 1, + "name": "shrub_02_b", + "rotation": [ + 0.08358937501907349, + -0.746630847454071, + 0.26227879524230957, + 0.6056113243103027 + ], + "translation": [ + 0.0007123738760128617, + -0.006340932101011276, + -0.01680799387395382 + ] + }, + { + "mesh": 2, + "name": "shrub_02_c", + "rotation": [ + 0.12648986279964447, + -0.7129753232002258, + 0.3943961560726166, + 0.5657898783683777 + ], + "translation": [ + 1.5559492111206055, + -0.005484403111040592, + -0.006101916544139385 + ] + }, + { + "mesh": 3, + "name": "shrub_02_d", + "rotation": [ + 0.047377295792102814, + -0.7383221983909607, + -0.300414502620697, + 0.60198575258255 + ], + "translation": [ + 3.258664131164551, + -0.004767145495861769, + -0.005447957664728165 + ] + } + ], + "materials": [ + { + "alphaCutoff": 0.5, + "alphaMode": "MASK", + "doubleSided": true, + "name": "shrub_02", + "normalTexture": { + "index": 0 + }, + "pbrMetallicRoughness": { + "baseColorTexture": { + "index": 1 + }, + "metallicFactor": 0, + "metallicRoughnessTexture": { + "index": 2 + } + } + } + ], + "meshes": [ + { + "name": "Plane.024", + "primitives": [ + { + "attributes": { + "POSITION": 0, + "TEXCOORD_0": 1, + "NORMAL": 2 + }, + "indices": 3, + "material": 0 + } + ] + }, + { + "name": "Plane.029", + "primitives": [ + { + "attributes": { + "POSITION": 4, + "TEXCOORD_0": 5, + "NORMAL": 6 + }, + "indices": 7, + "material": 0 + } + ] + }, + { + "name": "Plane.041", + "primitives": [ + { + "attributes": { + "POSITION": 8, + "TEXCOORD_0": 9, + "NORMAL": 10 + }, + "indices": 11, + "material": 0 + } + ] + }, + { + "name": "Plane.036", + "primitives": [ + { + "attributes": { + "POSITION": 12, + "TEXCOORD_0": 13, + "NORMAL": 14 + }, + "indices": 15, + "material": 0 + } + ] + } + ], + "textures": [ + { + "sampler": 0, + "source": 0 + }, + { + "sampler": 0, + "source": 1 + }, + { + "sampler": 0, + "source": 2 + } + ], + "images": [ + { + "mimeType": "image/jpeg", + "name": "shrub_02_nor_gl", + "uri": "textures/shrub_02_nor_gl_1k.jpg" + }, + { + "mimeType": "image/jpeg", + "name": "shrub_02_diff", + "uri": "textures/shrub_02_diff_1k.jpg" + }, + { + "mimeType": "image/jpeg", + "name": "shrub_02_rough", + "uri": "textures/shrub_02_arm_1k.jpg" + } + ], + "accessors": [ + { + "bufferView": 0, + "componentType": 5126, + "count": 5125, + "max": [ + 0.6156559586524963, + 1.6821810007095337, + 0.42684367299079895 + ], + "min": [ + -0.9439988136291504, + -0.03012576885521412, + -0.863244354724884 + ], + "type": "VEC3" + }, + { + "bufferView": 1, + "componentType": 5126, + "count": 5125, + "type": "VEC2" + }, + { + "bufferView": 2, + "componentType": 5126, + "count": 5125, + "type": "VEC3" + }, + { + "bufferView": 3, + "componentType": 5123, + "count": 22770, + "type": "SCALAR" + }, + { + "bufferView": 4, + "componentType": 5126, + "count": 3600, + "max": [ + 0.9647624492645264, + 1.0844767093658447, + 0.30848613381385803 + ], + "min": [ + -0.4038015305995941, + -0.10226154327392578, + -1.0773800611495972 + ], + "type": "VEC3" + }, + { + "bufferView": 5, + "componentType": 5126, + "count": 3600, + "type": "VEC2" + }, + { + "bufferView": 6, + "componentType": 5126, + "count": 3600, + "type": "VEC3" + }, + { + "bufferView": 7, + "componentType": 5123, + "count": 15726, + "type": "SCALAR" + }, + { + "bufferView": 8, + "componentType": 5126, + "count": 6346, + "max": [ + 1.287977695465088, + 0.8719967007637024, + 0.24143657088279724 + ], + "min": [ + -0.8910231590270996, + -0.2947101294994354, + -1.22649085521698 + ], + "type": "VEC3" + }, + { + "bufferView": 9, + "componentType": 5126, + "count": 6346, + "type": "VEC2" + }, + { + "bufferView": 10, + "componentType": 5126, + "count": 6346, + "type": "VEC3" + }, + { + "bufferView": 11, + "componentType": 5123, + "count": 27702, + "type": "SCALAR" + }, + { + "bufferView": 12, + "componentType": 5126, + "count": 3528, + "max": [ + 0.22877177596092224, + 1.153072476387024, + 0.5660104751586914 + ], + "min": [ + -1.051486849784851, + -0.09016040712594986, + -0.41910019516944885 + ], + "type": "VEC3" + }, + { + "bufferView": 13, + "componentType": 5126, + "count": 3528, + "type": "VEC2" + }, + { + "bufferView": 14, + "componentType": 5126, + "count": 3528, + "type": "VEC3" + }, + { + "bufferView": 15, + "componentType": 5123, + "count": 15564, + "type": "SCALAR" + } + ], + "bufferViews": [ + { + "buffer": 0, + "byteLength": 61500, + "byteOffset": 0, + "target": 34962 + }, + { + "buffer": 0, + "byteLength": 41000, + "byteOffset": 61500, + "target": 34962 + }, + { + "buffer": 0, + "byteLength": 61500, + "byteOffset": 102500, + "target": 34962 + }, + { + "buffer": 0, + "byteLength": 45540, + "byteOffset": 164000, + "target": 34963 + }, + { + "buffer": 0, + "byteLength": 43200, + "byteOffset": 209540, + "target": 34962 + }, + { + "buffer": 0, + "byteLength": 28800, + "byteOffset": 252740, + "target": 34962 + }, + { + "buffer": 0, + "byteLength": 43200, + "byteOffset": 281540, + "target": 34962 + }, + { + "buffer": 0, + "byteLength": 31452, + "byteOffset": 324740, + "target": 34963 + }, + { + "buffer": 0, + "byteLength": 76152, + "byteOffset": 356192, + "target": 34962 + }, + { + "buffer": 0, + "byteLength": 50768, + "byteOffset": 432344, + "target": 34962 + }, + { + "buffer": 0, + "byteLength": 76152, + "byteOffset": 483112, + "target": 34962 + }, + { + "buffer": 0, + "byteLength": 55404, + "byteOffset": 559264, + "target": 34963 + }, + { + "buffer": 0, + "byteLength": 42336, + "byteOffset": 614668, + "target": 34962 + }, + { + "buffer": 0, + "byteLength": 28224, + "byteOffset": 657004, + "target": 34962 + }, + { + "buffer": 0, + "byteLength": 42336, + "byteOffset": 685228, + "target": 34962 + }, + { + "buffer": 0, + "byteLength": 31128, + "byteOffset": 727564, + "target": 34963 + } + ], + "samplers": [ + { + "magFilter": 9729, + "minFilter": 9987 + } + ], + "buffers": [ + { + "byteLength": 758692, + "uri": "shrub_02.bin" + } + ] +} \ No newline at end of file diff --git a/assets/models/polyhaven/shrub_02/textures/shrub_02_arm_1k.jpg b/assets/models/polyhaven/shrub_02/textures/shrub_02_arm_1k.jpg new file mode 100644 index 0000000..589e56b Binary files /dev/null and b/assets/models/polyhaven/shrub_02/textures/shrub_02_arm_1k.jpg differ diff --git a/assets/models/polyhaven/shrub_02/textures/shrub_02_diff_1k.jpg b/assets/models/polyhaven/shrub_02/textures/shrub_02_diff_1k.jpg new file mode 100644 index 0000000..e720907 Binary files /dev/null and b/assets/models/polyhaven/shrub_02/textures/shrub_02_diff_1k.jpg differ diff --git a/assets/models/polyhaven/shrub_02/textures/shrub_02_nor_gl_1k.jpg b/assets/models/polyhaven/shrub_02/textures/shrub_02_nor_gl_1k.jpg new file mode 100644 index 0000000..05f76ce Binary files /dev/null and b/assets/models/polyhaven/shrub_02/textures/shrub_02_nor_gl_1k.jpg differ diff --git a/blender/export_cesium.py b/blender/export_cesium.py index 267caa7..820fc35 100644 --- a/blender/export_cesium.py +++ b/blender/export_cesium.py @@ -250,6 +250,11 @@ def make_export_material(material): def unwrap_mesh(obj): if obj.type != "MESH" or not obj.data.polygons: return + # Imported assets ship authored UVs that map onto their own texture atlas; + # smart_project would scramble the leaves. Only the procedurally built + # meshes arrive without a UV layer, so that is the reliable discriminator. + if obj.data.uv_layers: + return bpy.ops.object.select_all(action="DESELECT") obj.select_set(True) bpy.context.view_layer.objects.active = obj @@ -281,6 +286,7 @@ def export(args): material_map = {} meshes = [] + unwrapped = set() for obj in bpy.context.scene.objects: if obj.type != "MESH": continue @@ -290,7 +296,11 @@ def export(args): continue meshes.append(obj) apply_mesh_modifiers(obj) - unwrap_mesh(obj) + # Hundreds of grass tufts share four mesh datablocks; unwrapping is a + # property of the mesh, so doing it once per datablock is enough. + if obj.data.name not in unwrapped: + unwrapped.add(obj.data.name) + unwrap_mesh(obj) for slot in obj.material_slots: if not slot.material: continue diff --git a/blender/generate_scene.py b/blender/generate_scene.py index aac61bc..31bdfdf 100644 --- a/blender/generate_scene.py +++ b/blender/generate_scene.py @@ -34,6 +34,31 @@ TEXTURE_ROOT = os.path.abspath(os.path.join( os.path.dirname(__file__), "..", "assets", "textures", "polyhaven" )) +MODEL_ROOT = os.path.abspath(os.path.join( + os.path.dirname(__file__), "..", "assets", "models", "polyhaven" +)) + +# 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 +# genuinely bush-shaped asset is sourced. +TUFT_MODEL = os.path.join(MODEL_ROOT, "shrub_02", "shrub_02_1k.gltf") +# Poly Haven ships it at ~27k triangles across four variants. The leaves are +# modelled as real geometry, so decimation eats them: at ~2.2k per variant the +# tufts render as bare twigs. Instancing makes the full mesh affordable anyway — +# every tuft shares one of four datablocks, so the scene and the exported GLB +# carry that geometry once no matter how many are scattered. 0 disables it. +TUFT_TARGET_TRIS = 0 +# The variants stand 1.17-1.68m tall natively, which is shrub height. Roughly a +# third of that lands in the 0.3-0.8m range real lawn tufts occupy. +TUFT_SCALE_RANGE = (0.26, 0.46) +TUFT_SPACING = 1.7 +TUFT_LIMIT_PER_LAWN = 100 +# The vendored diffuse is a grey-green leaf over brown stems. Dropped onto the +# saturated lawn as-is it reads as dead weeds, so the base colour is mixed +# toward the lawn tint, a shade brighter so the tufts still separate from it. +TUFT_TINT = (0.15, 0.52, 0.09) +TUFT_TINT_FACTOR = 0.66 + def cli_args(): values = {"osm": None, "geojson": None, "output": None, "render": None, @@ -162,7 +187,8 @@ def make_material(name, color, roughness=0.8, metallic=0.0): return material -def add_procedural_surface(material, colors, scale=2.0, detail=2.0, bump_strength=0.08): +def add_procedural_surface(material, colors, scale=2.0, detail=2.0, bump_strength=0.08, + object_space=False): nodes = material.node_tree.nodes links = material.node_tree.links bsdf = principled_bsdf(material) @@ -179,13 +205,47 @@ def add_procedural_surface(material, colors, scale=2.0, detail=2.0, bump_strengt bump = nodes.new("ShaderNodeBump") bump.inputs["Strength"].default_value = bump_strength bump.inputs["Distance"].default_value = 0.12 - links.new(texcoord.outputs["Generated"], noise.inputs["Vector"]) + # "Generated" normalises across the object bounding box, so on a mesh that + # spans the whole scene the noise stretches to tens of metres and vanishes. + # Object space keeps the scale in metres, which is what foliage needs. + source = "Object" if object_space else "Generated" + links.new(texcoord.outputs[source], noise.inputs["Vector"]) links.new(noise.outputs["Fac"], ramp.inputs["Fac"]) links.new(ramp.outputs["Color"], bsdf.inputs["Base Color"]) links.new(noise.outputs["Fac"], bump.inputs["Height"]) links.new(bump.outputs["Normal"], bsdf.inputs["Normal"]) +def tint_base_color(material, tint, factor): + """Mix an existing material's base colour toward `tint`. + + Imported assets arrive with their own diffuse texture wired up. Rather than + replacing it — which throws away the leaf detail — this splices a mix node + in front of the Base Color input so the texture survives at (1 - factor). + """ + if factor <= 0.0 or not material.use_nodes: + return + bsdf = principled_bsdf(material) + if not bsdf: + return + nodes = material.node_tree.nodes + links = material.node_tree.links + base = bsdf.inputs["Base Color"] + tint_node = nodes.new("ShaderNodeRGB") + tint_node.outputs["Color"].default_value = (*tint, 1.0) + mix = nodes.new("ShaderNodeMixRGB") + mix.blend_type = "MIX" + mix.inputs["Fac"].default_value = factor + if base.is_linked: + # Capture the upstream socket before relinking; Blender drops the old + # link as soon as the input takes a new one. + links.new(base.links[0].from_socket, mix.inputs[1]) + else: + mix.inputs[1].default_value = base.default_value + links.new(tint_node.outputs["Color"], mix.inputs[2]) + links.new(mix.outputs["Color"], base) + + def make_textured_material(name, diffuse_file, normal_file, roughness, scale, normal_is_bump=False, metallic=0.0, tint=None, tint_factor=0.0): @@ -649,7 +709,146 @@ def point_in_polygon(point, ring): return inside -def add_scrub_patch(name, ring, material, collection): +def distance_to_ring(point, ring): + px, py = point + best = float("inf") + count = len(ring) + for index in range(count): + ax, ay = ring[index] + bx, by = ring[(index + 1) % count] + dx = bx - ax + dy = by - ay + length_sq = dx * dx + dy * dy + if length_sq <= 1e-9: + distance = math.hypot(px - ax, py - ay) + else: + t = ((px - ax) * dx + (py - ay) * dy) / length_sq + t = max(0.0, min(1.0, t)) + distance = math.hypot(px - (ax + t * dx), py - (ay + t * dy)) + if distance < best: + best = distance + return best + + +def load_tuft_variants(): + """Import the vendored Poly Haven plant once and return decimated meshes. + + Returns [] when the asset is missing so a clean checkout still builds; the + lawns then fall back to plain textured ground. + """ + if not os.path.exists(TUFT_MODEL): + return [] + before = set(bpy.data.objects) + try: + bpy.ops.import_scene.gltf(filepath=TUFT_MODEL) + except (RuntimeError, AttributeError) as error: + print("Grass tuft import failed, lawns stay flat:", error) + return [] + + imported = [obj for obj in set(bpy.data.objects) - before if obj.type == "MESH"] + variants = [] + tinted = set() + for obj in sorted(imported, key=lambda item: item.name): + obj.data.calc_loop_triangles() + source_tris = len(obj.data.loop_triangles) + if TUFT_TARGET_TRIS and source_tris > TUFT_TARGET_TRIS: + modifier = obj.modifiers.new("TuftDecimate", "DECIMATE") + modifier.ratio = max(0.02, TUFT_TARGET_TRIS / source_tris) + bpy.context.view_layer.update() + evaluated = obj.evaluated_get(bpy.context.evaluated_depsgraph_get()) + mesh = bpy.data.meshes.new_from_object(evaluated) + else: + mesh = obj.data.copy() + mesh.name = "GrassTuft_" + obj.name + mesh.calc_loop_triangles() + # Nothing is saved yet at this point, so keep the datablock alive even + # if a scene ends up with no lawn polygons to instance it into. + mesh.use_fake_user = True + for material in mesh.materials: + # The vendored textures are JPEG with no alpha channel, so hashed + # transparency only costs sorting work in Blender and Cesium. + if hasattr(material, "blend_method"): + material.blend_method = "OPAQUE" + # All four variants share one material, so guard against stacking + # the mix node — and the tint with it — four times over. + if material.name not in tinted: + tint_base_color(material, TUFT_TINT, TUFT_TINT_FACTOR) + tinted.add(material.name) + variants.append(mesh) + + for obj in imported: + bpy.data.objects.remove(obj, do_unlink=True) + + tris = sum(len(mesh.loop_triangles) for mesh in variants) + detail = f"decimated to ~{TUFT_TARGET_TRIS} tris each" if TUFT_TARGET_TRIS else "full detail" + print(f"Grass tuft variants loaded: {len(variants)} ({detail}, {tris} tris shared)") + return variants + + +def tuft_density_wave(x, y): + # A lawn wants gentle clumping, not the hard banding a shrub bed needs: + # low amplitude keeps most of the polygon planted so the bare stretches + # read as mown patches rather than as dead ground. + return (math.sin(x * 0.21 + y * 0.17) + + 0.55 * math.sin(x * 0.44 - y * 0.29 + 1.3)) + + +def add_grass_tufts(name, ring, variants, collection): + """Scatter the plant variants across a lawn polygon as grass tufts.""" + 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) + + def sample(spacing): + spots = [] + cols = max(1, int(math.ceil((xmax - xmin) / spacing))) + rows = max(1, int(math.ceil((ymax - ymin) / spacing))) + for i in range(cols): + for j in range(rows): + # Jitter breaks up the lattice; without it the tufts read as a + # planted grid rather than as grass. + seed = (i * 73856093) ^ (j * 19349663) + jx = ((seed * 0.61803398875) % 1.0 - 0.5) * spacing * 0.8 + jy = ((seed * 0.41421356237) % 1.0 - 0.5) * spacing * 0.8 + x = xmin + (i + 0.5) * spacing + jx + y = ymin + (j + 0.5) * spacing + jy + if not point_in_polygon((x, y), ring): + continue + # Keep tufts off the kerb line so they do not overhang paving. + if distance_to_ring((x, y), ring) < 0.45: + continue + if tuft_density_wave(x, y) < -0.85: + continue + spots.append((x, y, seed)) + return spots + + # Adapt spacing to the lawn so a large polygon does not explode the export. + spacing = TUFT_SPACING + spots = sample(spacing) + while len(spots) > TUFT_LIMIT_PER_LAWN and spacing < 12.0: + spacing *= 1.22 + spots = sample(spacing) + + min_scale, max_scale = TUFT_SCALE_RANGE + span = max_scale - min_scale + for index, (x, y, seed) in enumerate(spots): + mesh = variants[index % len(variants)] + obj = bpy.data.objects.new(f"{name}_Tuft_{index:03d}", mesh) + scale = min_scale + span * ((seed * 0.754877666) % 1.0) + # Sunk slightly below the lawn surface so the stems never float. + obj.location = (x, y, 0.008) + obj.rotation_euler = ( + math.radians(-4.0 + 8.0 * ((seed * 0.5698403) % 1.0)), + math.radians(-4.0 + 8.0 * ((seed * 0.3317554) % 1.0)), + math.tau * ((seed * 0.61803398875) % 1.0), + ) + obj.scale = (scale, scale * (0.90 + 0.20 * ((seed * 0.2236068) % 1.0)), scale) + collection.objects.link(obj) + return len(spots) + + +def add_scrub_patch(name, ring, ground_material, collection): if len(ring) < 3: return None if ring[0] == ring[-1]: @@ -657,63 +856,16 @@ def add_scrub_patch(name, ring, material, collection): if len(ring) < 3: return None - batch = MeshBatch(name, collection, material) - batch.add_polygon(ring, 0.055) + ground = MeshBatch(name, collection, ground_material) + ground.add_polygon(ring, 0.055) + obj = ground.finish() - 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) - width = max(0.1, xmax - xmin) - depth = max(0.1, ymax - ymin) - area = polygon_area(ring) - clump_count = max(10, min(90, int(area / 18.0) + 8)) - sides = 10 - rings = 4 - - def add_dome(cx, cy, rx, ry, height, phase): - start = len(batch.vertices) - for ring_index in range(rings): - t = ring_index / (rings - 1) - z = 0.055 + height * math.sin(t * math.pi / 2) - radius_scale = math.cos(t * math.pi / 2) - for side in range(sides): - angle = math.tau * side / sides - wobble = 1.0 + 0.12 * math.sin(phase + side * 1.37 + ring_index * 0.73) - batch.vertices.append(( - cx + rx * radius_scale * math.cos(angle) * wobble, - cy + ry * radius_scale * math.sin(angle) * wobble, - z, - )) - for ring_index in range(rings - 1): - for side in range(sides): - next_side = (side + 1) % sides - batch.faces.append(( - start + ring_index * sides + side, - start + ring_index * sides + next_side, - start + (ring_index + 1) * sides + next_side, - start + (ring_index + 1) * sides + side, - )) - - added = 0 - attempts = 0 - while added < clump_count and attempts < clump_count * 8: - attempts += 1 - u = (attempts * 0.61803398875) % 1.0 - v = (attempts * 0.41421356237) % 1.0 - x = xmin + u * width - y = ymin + v * depth - if not point_in_polygon((x, y), ring): - continue - scale = 0.65 + 0.55 * ((attempts * 0.754877666) % 1.0) - add_dome(x, y, 0.95 * scale, 0.72 * scale, - 0.34 + 0.28 * scale, attempts * 0.91) - added += 1 - - obj = batch.finish() + # 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"] = "tileable foliage ground cover with low shrub domes" + obj["scrub_style"] = "flat foliage ground cover" return obj @@ -830,8 +982,9 @@ def build(args): roughness=0.92, scale=7.0, tint=(0.12, 0.48, 0.08), tint_factor=0.72) scrub_mat = make_textured_material( "Scrub Ground Cover", "leafy_grass_diff_1k.jpg", - "leafy_grass_nor_gl_1k.jpg", roughness=0.96, scale=13.0, - tint=(0.09, 0.32, 0.07), tint_factor=0.42) + "leafy_grass_nor_gl_1k.jpg", roughness=0.96, scale=15.0, + tint=(0.085, 0.30, 0.065), tint_factor=0.46) + tuft_variants = load_tuft_variants() fountain_mats = { "fountain_stone": make_material("Fountain Stone", (0.42, 0.45, 0.43), 0.72), "fountain_water": make_material("Fountain Water", (0.03, 0.32, 0.42), 0.16, 0.05), @@ -888,6 +1041,7 @@ def build(args): tree_rows = [] lake_count = 0 grass_count = 0 + grass_tuft_count = 0 scrub_count = 0 fountain_count = 0 building_count = 0 @@ -915,8 +1069,14 @@ def build(args): batch = MeshBatch("Grass_" + str(way["id"]), green_c, grass_mat) if len(ring) >= 3: batch.add_polygon(ring, 0.015) - batch.finish() + obj = batch.finish() grass_count += 1 + if tuft_variants: + tufts = add_grass_tufts("Grass_" + str(way["id"]), ring, + tuft_variants, green_c) + grass_tuft_count += tufts + if obj: + obj["grass_tufts"] = tufts elif tag.get("natural") == "scrub" and len(ring) >= 3: ring = clip_polygon(ring, scene_xmin, scene_xmax, scene_ymin, scene_ymax) @@ -1079,6 +1239,7 @@ def build(args): scene["office_override_way_ids"] = json.dumps(sorted(args["office_overrides"])) scene["lake_count"] = lake_count scene["grass_count"] = grass_count + scene["grass_tuft_count"] = grass_tuft_count scene["scrub_count"] = scrub_count scene["fountain_count"] = fountain_count scene["tree_node_count"] = individual_tree_count @@ -1097,6 +1258,7 @@ def build(args): "industrial_buildings": industrial_count, "lake": lake_count, "grass": grass_count, + "grass_tufts": grass_tuft_count, "scrub": scrub_count, "fountains": fountain_count, "tree_nodes": individual_tree_count,