Skip to content

Instantly share code, notes, and snippets.

@devleaks
Last active November 10, 2024 17:32
Show Gist options
  • Select an option

  • Save devleaks/80379f81249f06e0f6abaa937ab6c79c to your computer and use it in GitHub Desktop.

Select an option

Save devleaks/80379f81249f06e0f6abaa937ab6c79c to your computer and use it in GitHub Desktop.
LST File Generator from X-Plane Scenery Files
import os
import argparse
from datetime import datetime
from math import sin, cos, sqrt, atan2, radians
import xml.etree.ElementTree as ET
NAME = "LST File Python Generator"
VERSION = "1.0.2"
# CHANGELOG
#
# 2024-11-10 1.0.2 Corrected logic if no branch detected
# 2024-11-10 1.0.1 More warnings upon detected inconsistencies
# 2024-11-10 1.0.0 Initial version
#
#
# Local constants
#
DEBUG_EXTENSION = "-py" # set to "" to generate Init.lst and Objects.lst
SOURCE_FILE = "doc.osm"
DEFAULT_SPEED = 10
DEFAULT_CHANCE = 0.5
COMMAND_SEPARATOR = ";"
EARTH_RADIUS = 6373000.0 # Approximate radius of earth in meters
MAX_DISTANCE = 1.0 # in meters, for proximity between two points
# Command-line arguments
#
parser = argparse.ArgumentParser(description="Generate LST files from prepared scenery")
parser.add_argument("--antimeridian", action="store_true", help="force bounding box around abtimeridian")
parser.add_argument("scenery_folder", metavar="scenery_folder", type=str, nargs="?", help="scenery folder")
args = parser.parse_args()
indir = os.path.join(os.path.dirname(__file__), "LST v1.11.6 Dev", "LST Demo")
if args.scenery_folder is not None:
indir = args.scenery_folder
#
# Preferred proximity function
def distance(lat1, lon1, lat2, lon2) -> float:
# distance between points in meters
lat1 = radians(lat1)
lon1 = radians(lon1)
lat2 = radians(lat2)
lon2 = radians(lon2)
dlon = lon2 - lon1
dlat = lat2 - lat1
a = sin(dlat / 2)**2 + cos(lat1) * cos(lat2) * sin(dlon / 2)**2
c = 2 * atan2(sqrt(a), sqrt(1 - a))
return EARTH_RADIUS * c
def close2(p1, p2) -> bool:
# not used, but should ;-)
return distance(p1["lat"], p1["lon"], p2["lat"], p2["lon"]) < MAX_DISTANCE
# Original proximity function
MAX_ARC_DEGREE_DIFF = 0.000002 # in lat/lon fraction of degree of arc (0°-360°)
def close(p1, p2) -> bool:
# not really a distance, varies with latitude
test = abs(p1["lat"] - p2["lat"]) < MAX_ARC_DEGREE_DIFF and abs(p1["lon"] - p2["lon"]) < MAX_ARC_DEGREE_DIFF
# if test:
# close_print(p1, p2)
return test
def close_print(p1, p2):
print(f"# close {p1['id']} {p2['id']}")
def dual_print(s, file):
# prints both on screen and generate files
print(s) # comment out this line to just get the files
print(s, file=file)
#
# Collects all nodes
#
tree = ET.parse(os.path.join(indir, SOURCE_FILE))
root = tree.getroot()
all_nodes = {node.attrib["id"]: {
"id": node.attrib["id"],
"lat": float(node.attrib["lat"]),
"lon": float(node.attrib["lon"]),
"tags": {tag.attrib["k"]: tag.attrib["v"] for tag in node.findall("tag")}
} for node in root.findall("node")}
print(f"# {len(all_nodes)} nodes")
#
# Collects all ways (path, polygons)
# Assign a route number to each route found
# (numbered from begining of file to end of file)
#
all_ways = {}
route = 0
for way in root.findall("way"):
all_ways[way.attrib["id"]] = {
"id": way.attrib["id"],
"nodes": [nd.attrib["ref"] for nd in way.findall("nd")],
"tags": {tag.attrib["k"]: tag.attrib["v"] for tag in way.findall("tag")},
"route": route
}
route = route + 1
# sanity check: are we referencing nodes we don't have?
missing = [nd.attrib["ref"] for nd in way.findall("nd") if nd.attrib["ref"] not in all_nodes]
if len(missing) > 0:
print(f"referenced nodes {missing} missing?")
print(f"# {len(all_ways)} ways (route #0 to #{len(all_ways)-1})")
#
# Init.lst
#
BUFFER = 0.2
ROUND = 4
north = max([-90] + [float(n.get("lat")) for n in all_nodes.values()]) + BUFFER
south = min([90] + [float(n.get("lat")) for n in all_nodes.values()]) - BUFFER
east = max([-180] + [float(n.get("lon")) for n in all_nodes.values()]) + BUFFER # not correct over antimeridian
west = min([180] + [float(n.get("lon")) for n in all_nodes.values()]) - BUFFER # not correct over antimeridian
if args.antimeridian:
noteast = east
east = west
west = noteast
# with open("Init.lst", "w") as fp
print("############ Init.lst")
with open(f"Init{DEBUG_EXTENSION}.lst", "w") as fp:
dual_print("0", file=fp)
dual_print(f"{round(north, ROUND)}", file=fp)
dual_print(f"{round(south, ROUND)}", file=fp)
dual_print(f"{round(east, ROUND)}", file=fp)
dual_print(f"{round(west, ROUND)}", file=fp)
if not args.antimeridian:
dual_print("# warning, east and west bounds may have to be inverted around anti-meridian", file=fp)
dual_print(f"# generated by {NAME} {VERSION} on {datetime.now().isoformat(timespec='seconds')}", file=fp)
dual_print(f"# file {os.path.abspath(indir)}", file=fp)
#
# Objects.lst
#
print("")
print("############ Objects.lst")
with open(f"Objects{DEBUG_EXTENSION}.lst", "w") as fp:
dual_print(f"# generated by {NAME} {VERSION} on {datetime.now().isoformat(timespec='seconds')}", file=fp)
dual_print(f"# file {os.path.abspath(indir)}", file=fp)
for way in all_ways.values(): # for each polygon we found in the scenery, we build a route
name = way.get("tags").get("name", "unamed")
dual_print("", file=fp)
dual_print(f"# Route {way.get('route')} (way id={way.get('id')}; name={name})", file=fp)
# the route must contain a LST start statement: HIGHWAY, LOOP or TRAIN
# in its description field.
if (desc := way["tags"].get("description")) is not None:
if not (desc.startswith("HIGHWAY") or desc.startswith("LOOP") or desc.startswith("TRAIN")):
dual_print("# warning route has invalid description, adding empty HIGHWAY command", file=fp)
dual_print("HIGHWAY,NULL,-1,-1")
else:
dual_print(f"{'\n'.join(desc.split(COMMAND_SEPARATOR))}", file=fp)
else:
dual_print("# warning route has no description", file=fp)
# should we ignore it? continue?
# Loop through the nodes/points of the route to add them with their properties to the Objects.lst file
point_count = 0 # we remember at which point we are, we need to know we are at the last one
for node_ref in way["nodes"]:
node = all_nodes[node_ref]
point_count = point_count + 1
# find last route that starts at that point
branch_to = None
for way2 in all_ways.values():
if way2 == way: # same route, we skip it
continue
# we get the starting point of that route
start_ref2 = way2.get("nodes")[0]
start_node2 = all_nodes[start_ref2]
# if the starting point of the route close to the point of this route?
if (node["id"] != start_node2["id"]) and close(node, start_node2):
branch_to = way2["route"]
# note: since we loop over all routes and do not stop as soon as one if found,
# only the *last* route that starts at the current node is kept.
# define a branch if we found another route that starts at the current point
branch_command = None
if branch_to is not None:
if point_count == len(way["nodes"]): # is it the last point in way?
# note: node_ref == way["nodes"][-1] may be wrong test
# if node_ref used more than once in polygon
branch_command = f"BRANCH,{branch_to},1"
else:
branch_command = f"BRANCH,{branch_to},0.5"
# if the user expressed a BRANCHIF/BRANCH on the node, we keep it
if (desc := node["tags"].get("description")) is not None:
if desc.startswith("BRANCHIF"):
cond = None
if "_" in desc:
pos = desc.index("_")
cond = desc[pos+1:]
elif "," in desc:
pos = desc.index(",")
cond = desc[pos+1:]
else:
dual_print(f"# warning: node {node['id']}: no _ or , in statement {desc}, no condition", file=fp)
cond = "1"
branch_command = f"BRANCHIF,{branch_to},1"
elif desc.startswith("BRANCH"):
chance = None
if "_" in desc:
pos = desc.index("_")
chance = desc[pos+1:]
elif "," in desc:
pos = desc.index(",")
chance = desc[pos+1:]
else:
dual_print(f"# warning: node {node['id']}: no _ or , in statement {desc}, no chance", file=fp)
chance = DEFAULT_CHANCE
try:
chance = float(chance)
except:
chance = DEFAULT_CHANCE
dual_print(f"# warning: node {node['id']}: chance {chance} not a number, forcing to {chance}", file=fp)
if chance > 2:
chance = chance / 100
branch_command = f"BRANCH,{branch_to},{round(chance, 2)}"
else:
# print description as it is
dual_print(f"{'\n'.join(desc.split(COMMAND_SEPARATOR))}", file=fp)
if branch_to is not None and branch_command is not None:
dual_print(branch_command, file=fp)
# finally, we write the current node/point with its speed, if any
speed = None
if (speed_str := node["tags"].get("z_value")) is not None:
speed = 10
try:
speed = float(speed_str)
except:
speed = DEFAULT_SPEED
dual_print(f"# warning: speed {speed_str} not a number, forcing to {speed}", file=fp)
if speed is not None:
dual_print(f"WP,{node.get('lat')},{node.get('lon')},{speed}", file=fp)
else:
dual_print(f"WP,{node.get('lat')},{node.get('lon')}", file=fp)
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment