feat: add cesium traffic signal countdowns
This commit is contained in:
@@ -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"],
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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)}},
|
||||
}
|
||||
|
||||
|
||||
|
||||
369
blender/osmassets/traffic_signals.py
Normal file
369
blender/osmassets/traffic_signals.py
Normal 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))
|
||||
124
blender/tests/test_traffic_signals.py
Normal file
124
blender/tests/test_traffic_signals.py
Normal 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()
|
||||
Reference in New Issue
Block a user