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,
|
feature_in_bounds,
|
||||||
geometry_rings,
|
geometry_rings,
|
||||||
point_in_polygon,
|
point_in_polygon,
|
||||||
|
polygon_area,
|
||||||
|
sample_polygon_interior,
|
||||||
|
sample_ring_boundary,
|
||||||
sample_tree_row,
|
sample_tree_row,
|
||||||
)
|
)
|
||||||
from osmassets.materials import ( # noqa: E402
|
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(
|
MODEL_ROOT = os.path.abspath(os.path.join(
|
||||||
os.path.dirname(__file__), "..", "assets", "models", "polyhaven"
|
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
|
# 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
|
# 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 = (0.15, 0.52, 0.09)
|
||||||
TUFT_TINT_FACTOR = 0.66
|
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
|
# 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
|
# 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.
|
# 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)
|
return len(spots)
|
||||||
|
|
||||||
|
|
||||||
def add_scrub_patch(name, ring, ground_material, collection):
|
def _bake_imported_mesh(obj, name):
|
||||||
if len(ring) < 3:
|
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
|
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]:
|
if ring[0] == ring[-1]:
|
||||||
ring = ring[:-1]
|
ring = ring[:-1]
|
||||||
if len(ring) < 3:
|
if len(ring) < 3:
|
||||||
return None
|
return None, 0
|
||||||
|
|
||||||
ground = MeshBatch(name, collection, ground_material)
|
ground = MeshBatch(name, collection, ground_material)
|
||||||
ground.add_polygon(ring, 0.055)
|
ground.add_polygon(ring, 0.055)
|
||||||
obj = ground.finish()
|
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:
|
if obj:
|
||||||
obj["scrub_texture"] = "Poly Haven leafy_grass, scrub-tinted"
|
obj["scrub_texture"] = "Poly Haven leafy_grass, scrub-tinted"
|
||||||
obj["scrub_style"] = "flat foliage ground cover"
|
obj["scrub_style"] = (
|
||||||
return obj
|
"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):
|
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
|
# between the ground materials and the props. Material creation order fixes
|
||||||
# the material indices in the exported GLB.
|
# the material indices in the exported GLB.
|
||||||
tuft_variants = load_tuft_variants()
|
tuft_variants = load_tuft_variants()
|
||||||
|
scrub_bush_variant = load_scrub_bush_variant()
|
||||||
fountain_mats = {
|
fountain_mats = {
|
||||||
key: material_from_spec(catalog.MATERIALS[key])
|
key: material_from_spec(catalog.MATERIALS[key])
|
||||||
for key in ("fountain_stone", "fountain_water", "fountain_spray")
|
for key in ("fountain_stone", "fountain_water", "fountain_spray")
|
||||||
@@ -631,6 +771,7 @@ def build(args):
|
|||||||
ground_batch.finish()
|
ground_batch.finish()
|
||||||
|
|
||||||
grass_rings = []
|
grass_rings = []
|
||||||
|
scrub_trees = []
|
||||||
tree_rows = []
|
tree_rows = []
|
||||||
# Counters were previously individual ints scattered through the loop body.
|
# Counters were previously individual ints scattered through the loop body.
|
||||||
# Collecting them into a dict lets the scene[...] and SCENE_DONE sections
|
# Collecting them into a dict lets the scene[...] and SCENE_DONE sections
|
||||||
@@ -643,8 +784,17 @@ def build(args):
|
|||||||
"grass_tuft_count": 0,
|
"grass_tuft_count": 0,
|
||||||
"industrial_count": 0,
|
"industrial_count": 0,
|
||||||
"lake_count": 0,
|
"lake_count": 0,
|
||||||
|
"scrub_bush_count": 0,
|
||||||
"scrub_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 = []
|
focus_points = []
|
||||||
for way in ways:
|
for way in ways:
|
||||||
coords = way["coords"]
|
coords = way["coords"]
|
||||||
@@ -668,10 +818,13 @@ def build(args):
|
|||||||
elif tag.get("natural") == "scrub" and len(ring) >= 3:
|
elif tag.get("natural") == "scrub" and len(ring) >= 3:
|
||||||
added, ring_pts = _scrub.assemble(ring, way["id"], scene_xmin, scene_xmax,
|
added, ring_pts = _scrub.assemble(ring, way["id"], scene_xmin, scene_xmax,
|
||||||
scene_ymin, scene_ymax, green_c, scrub_mat,
|
scene_ymin, scene_ymax, green_c, scrub_mat,
|
||||||
add_scrub_patch)
|
add_scrub_patch_with_bushes)
|
||||||
counts["scrub_count"] += added
|
counts["scrub_count"] += added
|
||||||
if ring_pts:
|
if ring_pts:
|
||||||
focus_points.extend(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":
|
elif tag.get("natural") == "tree_row":
|
||||||
tree_rows.append((ring, tag))
|
tree_rows.append((ring, tag))
|
||||||
focus_points.extend(ring)
|
focus_points.extend(ring)
|
||||||
@@ -712,6 +865,7 @@ def build(args):
|
|||||||
x, y = projector.xy(feature["coord"])
|
x, y = projector.xy(feature["coord"])
|
||||||
trees.append((x, y, parse_height(feature["tags"], 5.5)))
|
trees.append((x, y, parse_height(feature["tags"], 5.5)))
|
||||||
individual_tree_count += 1
|
individual_tree_count += 1
|
||||||
|
trees.extend(scrub_trees)
|
||||||
row_tree_count = 0
|
row_tree_count = 0
|
||||||
for row, row_tags in tree_rows:
|
for row, row_tags in tree_rows:
|
||||||
row_samples = sample_tree_row(row, spacing=5.0,
|
row_samples = sample_tree_row(row, spacing=5.0,
|
||||||
@@ -800,6 +954,8 @@ def build(args):
|
|||||||
scene["grass_count"] = counts["grass_count"]
|
scene["grass_count"] = counts["grass_count"]
|
||||||
scene["grass_tuft_count"] = counts["grass_tuft_count"]
|
scene["grass_tuft_count"] = counts["grass_tuft_count"]
|
||||||
scene["scrub_count"] = counts["scrub_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["fountain_count"] = counts["fountain_count"]
|
||||||
scene["tree_node_count"] = individual_tree_count
|
scene["tree_node_count"] = individual_tree_count
|
||||||
scene["tree_row_count"] = row_tree_count
|
scene["tree_row_count"] = row_tree_count
|
||||||
@@ -829,6 +985,8 @@ def build(args):
|
|||||||
"grass": counts["grass_count"],
|
"grass": counts["grass_count"],
|
||||||
"grass_tufts": counts["grass_tuft_count"],
|
"grass_tufts": counts["grass_tuft_count"],
|
||||||
"scrub": counts["scrub_count"],
|
"scrub": counts["scrub_count"],
|
||||||
|
"scrub_bushes": counts["scrub_bush_count"],
|
||||||
|
"scrub_trees": counts["scrub_tree_count"],
|
||||||
"fountains": counts["fountain_count"],
|
"fountains": counts["fountain_count"],
|
||||||
"tree_nodes": individual_tree_count,
|
"tree_nodes": individual_tree_count,
|
||||||
"tree_row_instances": row_tree_count,
|
"tree_row_instances": row_tree_count,
|
||||||
|
|||||||
@@ -103,12 +103,105 @@ def sample_tree_row(points, spacing, height):
|
|||||||
|
|
||||||
def polygon_area(ring):
|
def polygon_area(ring):
|
||||||
"""Unsigned shoelace area; 0.0 for degenerate rings."""
|
"""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:
|
if len(ring) < 3:
|
||||||
return 0.0
|
return 0.0
|
||||||
area = 0.0
|
area = 0.0
|
||||||
for (x1, y1), (x2, y2) in zip(ring, ring[1:] + ring[:1]):
|
for (x1, y1), (x2, y2) in zip(ring, ring[1:] + ring[:1]):
|
||||||
area += x1 * y2 - x2 * y1
|
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):
|
def point_in_polygon(point, ring):
|
||||||
|
|||||||
@@ -25,7 +25,10 @@ from osmassets.geom import (
|
|||||||
geometry_rings,
|
geometry_rings,
|
||||||
point_in_polygon,
|
point_in_polygon,
|
||||||
polygon_area,
|
polygon_area,
|
||||||
|
sample_polygon_interior,
|
||||||
|
sample_ring_boundary,
|
||||||
sample_tree_row,
|
sample_tree_row,
|
||||||
|
signed_polygon_area,
|
||||||
)
|
)
|
||||||
from osmassets.osm import Projector, parse_height, parse_osm, tags
|
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):
|
def test_winding_does_not_change_the_sign(self):
|
||||||
self.assertAlmostEqual(polygon_area(list(reversed(SQUARE))), 100.0)
|
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):
|
def test_degenerate(self):
|
||||||
self.assertEqual(polygon_area([(0.0, 0.0), (1.0, 1.0)]), 0.0)
|
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):
|
class PointInPolygonTest(unittest.TestCase):
|
||||||
def test_inside_and_outside(self):
|
def test_inside_and_outside(self):
|
||||||
self.assertTrue(point_in_polygon((5.0, 5.0), SQUARE))
|
self.assertTrue(point_in_polygon((5.0, 5.0), SQUARE))
|
||||||
|
|||||||
Reference in New Issue
Block a user