feat: add cesium traffic signal countdowns

This commit is contained in:
2026-08-06 16:43:08 +08:00
parent 9fbc218e10
commit 043766b84e
22 changed files with 1022 additions and 170 deletions

View File

@@ -127,12 +127,12 @@ EXPORT_EMISSION_OVERRIDES = {
def cli_args():
values = {"blend": None, "glb": None, "metadata": None}
values = {"blend": None, "glb": None, "metadata": None, "dynamic_glb": None, "countdown_0_glb": None, "countdown_1_glb": None}
argv = sys.argv[sys.argv.index("--") + 1:] if "--" in sys.argv else []
i = 0
while i < len(argv):
if argv[i].startswith("--") and i + 1 < len(argv):
values[argv[i][2:]] = argv[i + 1]
values[argv[i][2:].replace("-", "_")] = argv[i + 1]
i += 2
else:
i += 1
@@ -609,6 +609,8 @@ def export(args):
material_map = {}
meshes = []
dynamic_meshes = []
countdown_meshes = {0: [], 1: []}
unwrapped = set()
for obj in bpy.context.scene.objects:
if obj.type != "MESH":
@@ -617,7 +619,14 @@ def export(args):
continue
if obj.hide_viewport or obj.hide_render:
continue
meshes.append(obj)
if any(c.name == "06_TrafficSignalsDynamic" for c in obj.users_collection):
groups = {slot.material.name for slot in obj.material_slots if slot.material}
group = (0 if any("Countdown Group 0" in name for name in groups)
else 1 if any("Countdown Group 1" in name for name in groups)
else None)
(countdown_meshes[group] if group is not None else dynamic_meshes).append(obj)
else:
meshes.append(obj)
apply_mesh_modifiers(obj)
# Hundreds of grass tufts share four mesh datablocks; unwrapping and
# triangulating are properties of the mesh, so once per datablock.
@@ -642,6 +651,14 @@ def export(args):
slot.material = material_map[source.name]
export_glb(args["glb"], meshes)
if args.get("dynamic_glb"):
if not dynamic_meshes:
raise RuntimeError("Dynamic traffic signal collection is empty")
export_glb(args["dynamic_glb"], dynamic_meshes)
for group, key in ((0, "countdown_0_glb"), (1, "countdown_1_glb")):
if not countdown_meshes[group]:
raise RuntimeError("Traffic countdown collection %d is empty" % group)
export_glb(args[key], countdown_meshes[group])
semantic_assets = semantic_asset_specs(args["glb"], meshes)
for asset in semantic_assets:
export_glb(asset["path"], asset["meshes"])
@@ -664,6 +681,19 @@ def export(args):
"type": "model",
"url": os.path.basename(args["glb"]),
"enabled": True,
}, {
"id": "traffic-dynamic",
"label": "Traffic signals dynamic",
"type": "model",
"url": os.path.basename(args["dynamic_glb"]) if args.get("dynamic_glb") and dynamic_meshes else "",
"enabled": True,
"category": "dynamic",
}, {
"id": "traffic-countdown-0", "label": "Traffic countdown group 0", "type": "model",
"url": os.path.basename(args["countdown_0_glb"]), "enabled": True, "category": "countdown", "phaseGroup": 0,
}, {
"id": "traffic-countdown-1", "label": "Traffic countdown group 1", "type": "model",
"url": os.path.basename(args["countdown_1_glb"]), "enabled": True, "category": "countdown", "phaseGroup": 1,
}] + [{
"id": asset["id"],
"label": asset["label"],

View File

@@ -59,6 +59,7 @@ from osmassets import grass as _grass # noqa: E402
from osmassets import roads as _roads # noqa: E402
from osmassets import scrub as _scrub # noqa: E402
from osmassets import tree as _tree # noqa: E402
from osmassets import traffic_signals as _traffic_signals # noqa: E402
CUSTOM_MODEL_ROOT = os.path.abspath(os.path.join(
@@ -638,6 +639,7 @@ def build(args):
roads_c = new_collection("03_Roads")
buildings_c = new_collection("04_Buildings")
props_c = new_collection("05_Props")
traffic_dynamic_c = new_collection("06_TrafficSignalsDynamic")
ground_mat = material_from_spec(catalog.MATERIALS["ground"])
water_mat = material_from_spec(catalog.MATERIALS["water"])
@@ -661,6 +663,25 @@ def build(args):
layer["id"]: material_from_spec(spec)
for layer, spec in zip(catalog.ROAD_LAYERS, catalog.road_material_specs())
}
traffic_signal_mats = {
"metal": material_from_spec(catalog.MATERIALS["traffic_signal_metal"]),
"housing": material_from_spec(catalog.MATERIALS["traffic_signal_housing"]),
"lenses": {
"red": material_from_spec(catalog.MATERIALS["traffic_signal_red"]),
"yellow": material_from_spec(catalog.MATERIALS["traffic_signal_yellow"]),
"green": material_from_spec(catalog.MATERIALS["traffic_signal_green"]),
},
"active": material_from_spec(catalog.MATERIALS["traffic_signal_active_green"]),
"dynamic": {
state: material_from_spec(catalog.MATERIALS["traffic_signal_active_" + state])
for state in ("red", "yellow", "green")
},
}
traffic_signal_mats["dynamic"]["countdown"] = {}
for phase_group in (0, 1):
material = traffic_signal_mats["dynamic"]["green"].copy()
material.name = "Traffic Signal Countdown Group %d" % phase_group
traffic_signal_mats["dynamic"]["countdown"][phase_group] = material
b = bounds
scene_xmin, scene_ymin = projector.xy((b["min_lon"], b["min_lat"]))
@@ -690,6 +711,7 @@ def build(args):
"scrub_bush_count": 0,
"scrub_count": 0,
"scrub_tree_count": 0,
"traffic_signal_count": 0,
}
def add_scrub_patch_with_bushes(name, ring, ground_material, collection):
@@ -781,6 +803,22 @@ def build(args):
_roads.assemble_osm_fallback(
ways, projector, roads_c, road_mats["road_surface"])
traffic_signal_path = os.path.join(geojson_dir or "", "traffic_signals.json")
dynamic_signal_objects = 0
if os.path.exists(traffic_signal_path):
try:
with open(traffic_signal_path, "r", encoding="utf-8") as handle:
signal_data = json.load(handle)
counts["traffic_signal_count"] = _traffic_signals.assemble(
signal_data, projector, props_c, traffic_signal_mats)
dynamic_signal_objects = len(_traffic_signals.assemble_dynamic(
signal_data, projector, traffic_dynamic_c,
traffic_signal_mats["dynamic"]))
except (OSError, ValueError, TypeError) as error:
print("Traffic signal warning:", error)
if dynamic_signal_objects == 0:
raise RuntimeError("Traffic signal dynamic geometry failed") from error
trees = []
individual_tree_count = 0
for feature in point_features:
@@ -883,6 +921,7 @@ def build(args):
scene["scrub_bush_count"] = counts["scrub_bush_count"]
scene["scrub_tree_count"] = counts["scrub_tree_count"]
scene["fountain_count"] = counts["fountain_count"]
scene["traffic_signal_count"] = counts["traffic_signal_count"]
scene["tree_node_count"] = individual_tree_count
scene["tree_row_count"] = row_tree_count
scene["tree_count"] = len(trees)
@@ -914,6 +953,8 @@ def build(args):
"scrub_bushes": counts["scrub_bush_count"],
"scrub_trees": counts["scrub_tree_count"],
"fountains": counts["fountain_count"],
"traffic_signals": counts["traffic_signal_count"],
"traffic_signal_dynamic_objects": dynamic_signal_objects,
"tree_nodes": individual_tree_count,
"tree_row_instances": row_tree_count,
"trees": len(trees),

View File

@@ -152,6 +152,41 @@ MATERIALS = {
"cesium": {"tint": None,
"base_color": (0.11, 0.34, 0.075),
"emission": ((0.04, 0.11, 0.035), 0.02)}},
"traffic_signal_metal": {"kind": "solid", "name": "Traffic Signal Metal",
"color": (0.045, 0.065, 0.075), "roughness": 0.42,
"metallic": 0.62,
"cesium": {"base_color": (0.12, 0.16, 0.18),
"metallic": 0.42,
"emission": ((0.035, 0.05, 0.06), 0.03)}},
"traffic_signal_housing": {"kind": "solid", "name": "Traffic Signal Housing",
"color": (0.02, 0.03, 0.035), "roughness": 0.54,
"metallic": 0.12,
"cesium": {"base_color": (0.055, 0.075, 0.085),
"emission": ((0.018, 0.025, 0.03), 0.025)}},
# Static lenses are intentionally neutral and dark. The separate dynamic
# GLB is the sole source of phase colour, so inactive red/yellow/green
# glass cannot visually mask an otherwise working phase transition.
"traffic_signal_red": {"kind": "solid", "name": "Traffic Signal Red Lens",
"color": (0.025, 0.028, 0.030), "roughness": 0.30,
"cesium": {"base_color": (0.025, 0.028, 0.030)}},
"traffic_signal_yellow": {"kind": "solid", "name": "Traffic Signal Yellow Lens",
"color": (0.025, 0.028, 0.030), "roughness": 0.30,
"cesium": {"base_color": (0.025, 0.028, 0.030)}},
"traffic_signal_green": {"kind": "solid", "name": "Traffic Signal Green Lens",
"color": (0.025, 0.028, 0.030), "roughness": 0.30,
"cesium": {"base_color": (0.025, 0.028, 0.030)}},
"traffic_signal_active_red": {"kind": "solid", "name": "Traffic Signal Active Red",
"color": (0.93, 0.05, 0.035), "roughness": 0.25,
"cesium": {"base_color": (0.93, 0.05, 0.035),
"emission": ((0.93, 0.05, 0.035), 1.0)}},
"traffic_signal_active_yellow": {"kind": "solid", "name": "Traffic Signal Active Yellow",
"color": (0.98, 0.63, 0.03), "roughness": 0.25,
"cesium": {"base_color": (0.98, 0.63, 0.03),
"emission": ((0.98, 0.63, 0.03), 1.0)}},
"traffic_signal_active_green": {"kind": "solid", "name": "Traffic Signal Active Green",
"color": (0.04, 0.82, 0.22), "roughness": 0.25,
"cesium": {"base_color": (0.04, 0.82, 0.22),
"emission": ((0.04, 0.82, 0.22), 1.0)}},
}

View File

@@ -0,0 +1,369 @@
"""Static traffic-signal geometry for the main Blender scene.
The anchor file is generated by the intermediates stage. Cesium consumes the
same anchors for its dynamic lenses and countdown digits, so this module only
creates the durable structure around them.
"""
import math
import os
from osmassets.mesh import MeshBatch
DEFAULT_LAYOUT = {
"poleHeightMeters": 6.7,
"poleRadiusMeters": 0.13,
"armWidthMeters": 0.21,
"mastHeightMeters": 6.25,
"headCenterHeightMeters": 6.25,
"headWidthMeters": 0.68,
"headDepthMeters": 0.30,
"headBodyHeightMeters": 1.62,
"lensRadiusMeters": 0.22,
"lensDepthMeters": 0.07,
"lensFaceOffsetMeters": 0.18,
"lensVerticalOffsetsMeters": [0.49, -0.01, -0.51],
"countdownLateralMeters": 1.15,
"countdownFaceOffsetMeters": 0.05,
"countdownWidthMeters": 0.82,
"countdownDepthMeters": 0.14,
"countdownHeightMeters": 0.56,
"countdownVerticalOffsetMeters": 0.0,
}
COUNTDOWN_VALUES = tuple("%02d" % value for value in range(20))
COUNTDOWN_FONT_PATH = os.path.normpath(os.path.join(
os.path.dirname(__file__), "..", "..", "assets", "fonts", "7LED-1.ttf"))
def assemble(signal_data, projector, collection, materials):
"""Add batched static signal structures and return the accepted count."""
metal = MeshBatch("Traffic Signal Metal", collection, materials["metal"])
housing = MeshBatch("Traffic Signal Housing", collection, materials["housing"])
lenses = {
state: MeshBatch("Traffic Signal %s Lens" % state.title(), collection, material)
for state, material in materials["lenses"].items()
}
layout = _layout(signal_data.get("layout"))
count = 0
for signal in signal_data.get("signals", []):
if not _valid_signal(signal):
continue
pose = signal.get("pose") if _valid_pose(signal.get("pose")) else None
if pose:
x, y = projector.xy((pose["pole"]["longitude"], pose["pole"]["latitude"]))
head_x, head_y = projector.xy((pose["head"]["longitude"], pose["head"]["latitude"]))
face_heading = math.radians(pose["head"]["faceHeadingDegrees"])
face = (math.sin(face_heading), math.cos(face_heading))
lateral = (-math.cos(face_heading), math.sin(face_heading))
else:
x, y = projector.xy((signal["longitude"], signal["latitude"]))
heading = math.radians(signal["headingDegrees"])
longitudinal = (math.sin(heading), math.cos(heading))
lateral = (math.cos(heading), -math.sin(heading))
face = (-longitudinal[0], -longitudinal[1])
mast_reach = float(signal.get("mastReachMeters") or 4.5)
head_x, head_y = _offset(x, y, lateral, -mast_reach)
_add_cylinder(metal, x, y, layout["poleHeightMeters"] / 2,
layout["poleRadiusMeters"], layout["poleHeightMeters"])
_add_box(metal, (x, y), (head_x, head_y), layout["armWidthMeters"] / 2,
layout["mastHeightMeters"] - layout["armWidthMeters"] / 2,
layout["armWidthMeters"])
_add_oriented_box(
housing, head_x, head_y, lateral, face,
layout["headWidthMeters"], layout["headDepthMeters"],
layout["headCenterHeightMeters"],
layout["headBodyHeightMeters"],
)
for index, state in enumerate(("red", "yellow", "green")):
if pose:
lens_x, lens_y = projector.xy((pose["lenses"][index]["longitude"], pose["lenses"][index]["latitude"]))
lens_z = pose["lenses"][index]["height"]
else:
lens_x, lens_y = _offset(head_x, head_y, face, layout["lensFaceOffsetMeters"])
lens_z = layout["headCenterHeightMeters"] + layout["lensVerticalOffsetsMeters"][index]
_add_lens(
lenses[state], lens_x, lens_y, lens_z,
lateral, face, layout["lensRadiusMeters"], layout["lensDepthMeters"], 10,
)
if pose:
board_x, board_y = projector.xy((pose["countdown"]["longitude"], pose["countdown"]["latitude"]))
board_z = pose["countdown"]["height"]
else:
board_x, board_y = _offset(head_x, head_y, lateral, layout["countdownLateralMeters"])
board_x, board_y = _offset(board_x, board_y, face, layout["countdownFaceOffsetMeters"])
board_z = layout["mastHeightMeters"] + layout["countdownVerticalOffsetMeters"]
_add_oriented_box(housing, board_x, board_y, lateral, face,
layout["countdownWidthMeters"], layout["countdownDepthMeters"],
board_z,
layout["countdownHeightMeters"])
count += 1
metal.finish()
housing.finish()
for batch in lenses.values():
batch.finish()
return count
def assemble_dynamic(signal_data, projector, collection, materials):
"""Build phase meshes plus instanced font countdowns for Cesium."""
layout = _layout(signal_data.get("layout"))
objects = []
countdown_materials = materials.get("countdown") or {}
if not countdown_materials:
raise RuntimeError("Traffic signal countdown material is not configured")
countdown_meshes = _countdown_meshes(countdown_materials)
for signal in signal_data.get("signals", []):
if not _valid_signal(signal) or not _valid_pose(signal.get("pose")):
continue
pose = signal["pose"]
face_heading = math.radians(pose["head"]["faceHeadingDegrees"])
face = (math.sin(face_heading), math.cos(face_heading))
lateral = (-math.cos(face_heading), math.sin(face_heading))
# The static lenses already occupy the head face. Dynamic emissive
# covers must sit just in front of them or the static material wins the
# depth test and masks every phase change.
active_lens_depth = min(0.025, layout["lensDepthMeters"])
active_lens_radius = layout["lensRadiusMeters"] * 0.88
active_lens_offset = (layout["lensDepthMeters"] + active_lens_depth) / 2 + 0.003
for state in ("red", "yellow", "green"):
batch = MeshBatch("TrafficSignalDynamic_%s_%s" % (signal["id"], state), collection, materials[state])
for index in (0, 1, 2):
point = pose["lenses"][index]
if point["state"] == state:
x, y = projector.xy((point["longitude"], point["latitude"]))
x, y = _offset(x, y, face, active_lens_offset)
_add_lens(batch, x, y, point["height"], lateral, face,
active_lens_radius, active_lens_depth, 10)
obj = batch.finish()
if obj:
objects.append(obj)
board = pose["countdown"]
board_x, board_y = projector.xy((board["longitude"], board["latitude"]))
board_z = board["height"]
text_x, text_y = _offset(
board_x, board_y, face, layout["countdownDepthMeters"] / 2 + 0.008)
phase_group = int(signal.get("phaseGroup") or 0) % 2
for value, mesh in countdown_meshes[phase_group].items():
objects.append(_countdown_instance(
"TrafficSignalDynamic_%s_countdown_%s" % (signal["id"], value),
mesh, collection, text_x, text_y, board_z, lateral, face))
return objects
def _countdown_meshes(materials):
"""Create 20 inverted font meshes per phase group, shared by all signals."""
try:
import bpy
except ImportError:
# Geometry unit tests run in CPython without Blender. Their lens checks
# remain useful while the actual font conversion is Blender-only.
return {group: {} for group in materials}
if not os.path.exists(COUNTDOWN_FONT_PATH):
raise RuntimeError("Traffic signal countdown font not found: %s" % COUNTDOWN_FONT_PATH)
font = bpy.data.fonts.load(COUNTDOWN_FONT_PATH, check_existing=True)
meshes = {group: {} for group in materials}
for group, material in materials.items():
for value in COUNTDOWN_VALUES:
meshes[group][value] = _inverted_countdown_mesh(value, group, font, material)
return meshes
def _inverted_countdown_mesh(value, group, font, material):
"""Turn 7LED's dark glyph cut-out into the emissive number geometry."""
import bpy
curve = bpy.data.curves.new("TrafficSignalCountdown_%s_%s" % (group, value), "FONT")
curve.body = value
curve.font = font
curve.align_x = "CENTER"
curve.align_y = "CENTER"
curve.size = 0.44
curve.extrude = 0.004
curve.resolution_u = 1
text = bpy.data.objects.new("TrafficSignalCountdownTemplate_%s_%s" % (group, value), curve)
bpy.context.scene.collection.objects.link(text)
bpy.context.view_layer.objects.active = text
text.select_set(True)
bpy.ops.object.convert(target="CURVE")
glyph = bpy.context.view_layer.objects.active
vertices = []
faces = []
depth = 0.008
for spline in glyph.data.splines:
if _spline_area(spline) >= 0:
continue
loop = _sample_bezier_loop(spline)
if len(loop) < 3:
continue
start = len(vertices)
vertices.extend((x, y, -depth / 2) for x, y in loop)
vertices.extend((x, y, depth / 2) for x, y in loop)
count = len(loop)
faces.append(tuple(reversed(range(start, start + count))))
faces.append(tuple(range(start + count, start + count * 2)))
for index in range(count):
next_index = (index + 1) % count
faces.append((start + index, start + next_index,
start + count + next_index, start + count + index))
if not vertices:
raise RuntimeError("7LED font contains no digit cut-outs for %s" % value)
mesh = bpy.data.meshes.new("TrafficSignalCountdownMesh_%s_%s" % (group, value))
mesh.from_pydata(vertices, [], faces)
mesh.materials.append(material)
mesh.update()
mesh.name = "TrafficSignalCountdownMesh_%s_%s" % (group, value)
bpy.data.objects.remove(glyph, do_unlink=True)
return mesh
def _spline_area(spline):
if spline.type != "BEZIER" or len(spline.bezier_points) < 3:
return 0
points = spline.bezier_points
return sum(
point.co.x * points[(index + 1) % len(points)].co.y -
points[(index + 1) % len(points)].co.x * point.co.y
for index, point in enumerate(points)
) / 2
def _sample_bezier_loop(spline, samples_per_edge=8):
points = spline.bezier_points
result = []
for index, start in enumerate(points):
end = points[(index + 1) % len(points)]
p0 = start.co
p1 = start.handle_right
p2 = end.handle_left
p3 = end.co
for step in range(samples_per_edge):
t = step / samples_per_edge
inverse = 1 - t
result.append((
inverse ** 3 * p0.x + 3 * inverse ** 2 * t * p1.x +
3 * inverse * t ** 2 * p2.x + t ** 3 * p3.x,
inverse ** 3 * p0.y + 3 * inverse ** 2 * t * p1.y +
3 * inverse * t ** 2 * p2.y + t ** 3 * p3.y,
))
return result
def _countdown_instance(name, mesh, collection, x, y, z, across, face):
import bpy
from mathutils import Matrix
obj = bpy.data.objects.new(name, mesh)
collection.objects.link(obj)
# Text geometry starts in the local XY plane. Map X across the board, Y
# upward, and its front normal toward the same approach-facing axis as the
# static housing and dynamic lenses.
obj.matrix_world = Matrix(((
(across[0], 0.0, face[0], x),
(across[1], 0.0, face[1], y),
(0.0, 1.0, 0.0, z),
(0.0, 0.0, 0.0, 1.0),
)))
return obj
def _valid_signal(signal):
if not isinstance(signal, dict):
return False
try:
return all(math.isfinite(float(signal.get(key)))
for key in ("longitude", "latitude", "headingDegrees"))
except (TypeError, ValueError):
return False
def _valid_pose(pose):
try:
return (isinstance(pose, dict) and len(pose.get("lenses", [])) == 3
and all(math.isfinite(float(pose[key]["longitude"]))
and math.isfinite(float(pose[key]["latitude"]))
for key in ("pole", "head", "countdown")))
except (KeyError, TypeError, ValueError):
return False
def _layout(value):
layout = dict(DEFAULT_LAYOUT)
if not isinstance(value, dict):
return layout
for key, default in DEFAULT_LAYOUT.items():
candidate = value.get(key)
if isinstance(default, list):
if (isinstance(candidate, list) and len(candidate) == len(default)
and all(isinstance(item, (int, float)) and math.isfinite(item)
for item in candidate)):
layout[key] = candidate
elif (isinstance(candidate, (int, float)) and math.isfinite(candidate)
and (key == "countdownVerticalOffsetMeters" or candidate > 0)):
layout[key] = candidate
return layout
def _offset(x, y, direction, distance):
return x + direction[0] * distance, y + direction[1] * distance
def _add_box(batch, start, end, width, base, height):
dx, dy = end[0] - start[0], end[1] - start[1]
length = math.hypot(dx, dy)
if length <= 0:
return
across = (-dy / length, dx / length)
half = width / 2
ring = [
(start[0] + across[0] * half, start[1] + across[1] * half),
(end[0] + across[0] * half, end[1] + across[1] * half),
(end[0] - across[0] * half, end[1] - across[1] * half),
(start[0] - across[0] * half, start[1] - across[1] * half),
]
batch.add_prism(ring, base, height)
def _add_oriented_box(batch, x, y, across, depth, width, thickness, center_z, height):
half_width = width / 2
half_depth = thickness / 2
ring = [
(x + across[0] * sx * half_width + depth[0] * sy * half_depth,
y + across[1] * sx * half_width + depth[1] * sy * half_depth)
for sx, sy in ((-1, -1), (1, -1), (1, 1), (-1, 1))
]
batch.add_prism(ring, center_z - height / 2, height)
def _add_cylinder(batch, x, y, center_z, radius, height, sides=8):
ring = [
(x + math.cos(math.tau * index / sides) * radius,
y + math.sin(math.tau * index / sides) * radius)
for index in range(sides)
]
batch.add_prism(ring, center_z - height / 2, height)
def _add_lens(batch, x, y, z, across, face, radius, depth, sides):
"""Add a shallow round lens flush with the head's approach-facing surface."""
start = len(batch.vertices)
for face_offset in (-depth / 2, depth / 2):
for index in range(sides):
theta = math.tau * index / sides
batch.vertices.append((
x + face[0] * face_offset + across[0] * math.cos(theta) * radius,
y + face[1] * face_offset + across[1] * math.cos(theta) * radius,
z + math.sin(theta) * radius,
))
batch.faces.append(tuple(range(start, start + sides)))
batch.faces.append(tuple(range(start + sides, start + sides * 2)))
for index in range(sides):
next_index = (index + 1) % sides
a = start + index
b = start + next_index
c = start + sides + next_index
d = start + sides + index
batch.faces.append((a, b, c, d))

View File

@@ -0,0 +1,124 @@
"""Static traffic-signal geometry can be exercised without Blender itself."""
import importlib
import os
import sys
import types
import unittest
sys.path.insert(0, os.path.join(os.path.dirname(os.path.abspath(__file__)), ".."))
class FakeBatch:
created = []
def __init__(self, name, collection, material):
self.name = name
self.vertices = []
self.faces = []
FakeBatch.created.append(self)
def add_prism(self, ring, base, height):
if len(ring) < 3:
return
start = len(self.vertices)
self.vertices.extend((x, y, base) for x, y in ring)
self.vertices.extend((x, y, base + height) for x, y in ring)
size = len(ring)
self.faces.extend((tuple(range(start, start + size)),
tuple(range(start + size, start + size * 2))))
def finish(self):
return self.vertices or None
class Projector:
def xy(self, point):
return point
class TrafficSignalGeometryTest(unittest.TestCase):
@classmethod
def setUpClass(cls):
mesh = types.ModuleType("osmassets.mesh")
mesh.MeshBatch = FakeBatch
cls.previous_mesh = sys.modules.get("osmassets.mesh")
sys.modules["osmassets.mesh"] = mesh
sys.modules.pop("osmassets.traffic_signals", None)
cls.signals = importlib.import_module("osmassets.traffic_signals")
@classmethod
def tearDownClass(cls):
sys.modules.pop("osmassets.traffic_signals", None)
if cls.previous_mesh is None:
sys.modules.pop("osmassets.mesh", None)
else:
sys.modules["osmassets.mesh"] = cls.previous_mesh
def test_valid_anchor_builds_static_geometry_on_the_driver_right(self):
FakeBatch.created = []
count = self.signals.assemble({
"layout": {"countdownLateralMeters": 1.15},
"signals": [{
"longitude": 10.0,
"latitude": 20.0,
"headingDegrees": 0.0,
"mastReachMeters": 4.5,
}],
}, Projector(), object(), {
"metal": object(),
"housing": object(),
"lenses": {"red": object(), "yellow": object(), "green": object()},
})
self.assertEqual(count, 1)
housing = next(batch for batch in FakeBatch.created
if batch.name == "Traffic Signal Housing")
# A northbound driver's right is east, so the board's vertices must
# extend east of the mast-reached head at longitude 5.5.
self.assertGreater(max(vertex[0] for vertex in housing.vertices), 6.5)
red_lens = next(batch for batch in FakeBatch.created
if batch.name == "Traffic Signal Red Lens")
# The mast arm and the head share z=6.25. The red lens sits inside
# the top half of the 1.62m head rather than above its centre line.
self.assertLessEqual(max(vertex[2] for vertex in red_lens.vertices), 6.98)
def test_missing_anchor_coordinate_is_skipped(self):
FakeBatch.created = []
count = self.signals.assemble({"signals": [{"longitude": 10.0}]}, Projector(),
object(), {"metal": object(), "housing": object(),
"lenses": {"red": object(), "yellow": object(), "green": object()}})
self.assertEqual(count, 0)
def test_dynamic_lens_geometry_is_in_front_of_static_lens_face(self):
FakeBatch.created = []
signal = {
"id": "signal-1", "longitude": 10.0, "latitude": 20.0,
"headingDegrees": 0.0,
"pose": {
"pole": {"longitude": 10.0, "latitude": 20.0},
"head": {"longitude": 10.0, "latitude": 20.0,
"faceHeadingDegrees": 0.0},
"lenses": [{"state": state, "longitude": 10.0,
"latitude": 20.0, "height": 6.25}
for state in ("red", "yellow", "green")],
"countdown": {"longitude": 10.0, "latitude": 20.0,
"height": 6.25},
},
}
self.signals.assemble_dynamic({"signals": [signal]}, Projector(), object(), {
"red": object(), "yellow": object(), "green": object(), "active": object(),
"countdown": {0: object(), 1: object()},
})
red = next(batch for batch in FakeBatch.created
if batch.name == "TrafficSignalDynamic_signal-1_red")
# Facing north, every active overlay vertex must sit north of the
# static lens centre rather than intersecting its body.
self.assertGreater(min(vertex[1] for vertex in red.vertices), 20.035)
def test_countdown_uses_the_versioned_font_and_twenty_shared_values(self):
self.assertTrue(os.path.exists(self.signals.COUNTDOWN_FONT_PATH))
self.assertEqual(self.signals.COUNTDOWN_VALUES, tuple("%02d" % value for value in range(20)))
if __name__ == "__main__":
unittest.main()