Last active
November 10, 2024 17:32
-
-
Save devleaks/80379f81249f06e0f6abaa937ab6c79c to your computer and use it in GitHub Desktop.
LST File Generator from X-Plane Scenery Files
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| 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