"""Export a Blender scene (from generate_scene.py) as a Cesium-ready GLB. The authoring scene intentionally uses a few Blender-only nodes (for example the grass tint and procedural tree crown variation). glTF has a smaller material vocabulary, so this exporter creates temporary, export-only PBR materials, unwraps the meshes, and embeds all referenced images in the GLB. The model remains in a local ENU frame: X east, Y north, Z up. Use the companion JSON file to place the GLB with Cesium.Transforms.eastNorthUpToFixedFrame. """ import json import os import sys import bpy import numpy as np # --factory-startup does not put the script's own directory on sys.path, so the # osmassets package next to this file is not importable without this. _HERE = os.path.dirname(os.path.abspath(__file__)) if _HERE not in sys.path: sys.path.insert(0, _HERE) from osmassets.materials import CESIUM_EXPORT_PROPERTY, link_alpha_clip # noqa: E402 # Marks a material this exporter produced, so a second pass over an instanced # mesh's shared slots can recognise its own output and leave it alone. EXPORT_PREFIX = "Cesium " # Fraction of its own albedo a cut-out foliage material emits, to keep the # shadowed side of a crown off Cesium's near-black ambient floor. Kept well # under the 0.18 the buildings use: a tree still has to read as lit from one # side, it just must not go to black. FOLIAGE_EMISSION = 0.25 # Multiplier on a cut-out foliage albedo before export. # # This legacy profile exists for dark alpha-cut foliage from older .blend files. # Rendered at true albedo those assets are correct, but nothing else in this # scene is at true albedo. Every other material goes through Cesium export # contracts (grass mixes 72% toward a bright green, the ribbed facade 86% toward # white, 0.18 emission on the buildings), all hand-tuned against Cesium's # washed-out default lighting. A new asset dropped in untuned is the one thing # rendering honestly, and next to the rest it reads as black. # # A gain rather than a tint, because a tint is what the other materials use and # it is wrong here: they are single-surface, this is an atlas holding leaves, # bark and fruit at once, and mixing it toward green would turn the trunk # green. Scaling preserves the hue relationships and just lifts the whole # thing into the same exposure as its neighbours. FOLIAGE_ALBEDO_GAIN = 2.1 # Saturation multiplier applied with the gain, around each texel's own # luminance. The gain alone lifts the crown to the right brightness but leaves # it reading grey-green at distance: alpha-cut atlases are often desaturated to # begin with, and mip-averaging a crown mixes leaves with bark and sky-gaps, # pulling it further toward neutral exactly when the tree gets small. # # Scaling the distance from luminance pushes the leaves green without touching # what is already neutral much, and without the hue shift a green tint would # force on the trunk — bark just becomes a warmer brown, which it should be. FOLIAGE_SATURATION = 1.75 # Shapespark foliage is already graded brighter than legacy cut-out foliage. # Reusing the heavy gain/saturation/emission makes it read yellow-green and # glowing in Cesium, so these card materials get a gentler export profile. SHAPESPARK_FOLIAGE_ALBEDO_GAIN = 1.12 SHAPESPARK_FOLIAGE_SATURATION = 1.0 SHAPESPARK_FOLIAGE_EMISSION = 0.06 SHAPESPARK_FOLIAGE_PREFIXES = ( "branch-", "shrubbery-", "high-grass-", "hedge-", "clover-", "flowers-", ) # Legacy fallback for .blend files created before materials carried their own # `cesium_export` custom property. New scenes should get these values from # catalog.MATERIALS[*]["cesium"], serialized by osmassets.materials.from_spec(). EXPORT_TINTS = { "Grass": ((0.12, 0.48, 0.08), 0.72), "Tree Crown Dark": ((0.06, 0.22, 0.05), 0.18), "Tree Crown Light": ((0.16, 0.42, 0.09), 0.16), "Scrub Ground Cover": ((0.08, 0.28, 0.07), 0.28), "Office White Plaster Facade": ((0.92, 0.94, 0.92), 0.38), "Office White Metal Facade": ((0.92, 0.94, 0.92), 0.68), "Office Light Flat Roof": ((0.82, 0.86, 0.88), 0.35), "Industrial White Ribbed Facade": ((0.90, 0.93, 0.91), 0.86), "Factory Blue Metal Roof": ((0.08, 0.50, 0.88), 0.58), } EXPORT_METALLIC_OVERRIDES = { "Office White Plaster Facade": 0.0, "Office White Metal Facade": 0.0, "Industrial White Ribbed Facade": 0.08, } EXPORT_BASE_COLOR_OVERRIDES = { "Tree Crown": (0.11, 0.34, 0.075), "Tree Crown Dark": (0.065, 0.24, 0.055), "Tree Crown Light": (0.14, 0.40, 0.085), "Office White Plaster Facade": (0.93, 0.94, 0.91), "Office White Metal Facade": (0.93, 0.94, 0.91), "Office Light Flat Roof": (0.88, 0.90, 0.88), } EXPORT_EMISSION_OVERRIDES = { "Tree Crown": ((0.04, 0.11, 0.035), 0.02), "Tree Crown Dark": ((0.025, 0.07, 0.02), 0.015), "Tree Crown Light": ((0.045, 0.12, 0.03), 0.015), "Office White Plaster Facade": ((0.93, 0.94, 0.91), 0.18), "Office White Metal Facade": ((0.93, 0.94, 0.91), 0.18), "Office Light Flat Roof": ((0.88, 0.90, 0.88), 0.14), "Industrial White Ribbed Facade": ((0.90, 0.93, 0.91), 0.18), "Factory Blue Metal Roof": ((0.08, 0.50, 0.88), 0.12), } def cli_args(): values = {"blend": None, "glb": None, "metadata": 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] i += 2 else: i += 1 if not values.get("blend"): raise RuntimeError("--blend is required") if not values.get("glb"): raise RuntimeError("--glb is required") if not values.get("metadata"): raise RuntimeError("--metadata is required") return values def image_for(material, want_normal=False): candidates = [] for node in material.node_tree.nodes: if node.type != "TEX_IMAGE" or not node.image: continue name = os.path.basename(node.image.name).lower() is_normal = "_nor_" in name or "_normal" in name or "_bump" in name if is_normal == want_normal: candidates.append(node.image) return candidates[0] if candidates else None def source_color(material): color = tuple(material.diffuse_color[:3]) if len(color) != 3: return (0.5, 0.5, 0.5) return color def cesium_contract(material): payload = material.get(CESIUM_EXPORT_PROPERTY) if not payload: return {} try: if isinstance(payload, str): payload = json.loads(payload) except (TypeError, ValueError): return {} return payload if isinstance(payload, dict) else {} def principled_bsdf(material): if not material.use_nodes: return None for node in material.node_tree.nodes: if node.type == "BSDF_PRINCIPLED": return node return None def source_principled_value(material, input_name, fallback): node = principled_bsdf(material) if not node or input_name not in node.inputs: return fallback return node.inputs[input_name].default_value def source_texture_scale(material): for node in material.node_tree.nodes: if node.type == "MAPPING": return tuple(node.inputs["Scale"].default_value[:3]) return (1.0, 1.0, 1.0) def source_alpha_clipped(material): """Whether the source material carries its silhouette in a texture alpha. Two conditions, because either alone gives a wrong answer. A link into the Principled Alpha input is not enough: retired glTF lawn assets arrived with a Math node wired there even though the JPEG diffuse was opaque, and taking that at face value re-encoded an opaque texture as a PNG and made Cesium alpha-test hundreds of tufts for nothing. An alpha channel alone is not enough either; it can be fully opaque. So ask both — the author wired alpha, and the texture actually cuts. """ node = principled_bsdf(material) if not node or "Alpha" not in node.inputs: return False if not node.inputs["Alpha"].links: return False diffuse = image_for(material, want_normal=False) return diffuse is not None and image_has_cutout(diffuse) def foliage_export_profile(material): name = material.name.lower() if name.startswith(SHAPESPARK_FOLIAGE_PREFIXES): return ( SHAPESPARK_FOLIAGE_ALBEDO_GAIN, SHAPESPARK_FOLIAGE_SATURATION, SHAPESPARK_FOLIAGE_EMISSION, ) return FOLIAGE_ALBEDO_GAIN, FOLIAGE_SATURATION, FOLIAGE_EMISSION _CUTOUT_CACHE = {} def image_has_cutout(image, threshold=0.5): """Whether any of the image's texels are transparent enough to be cut. A full pass over the pixel buffer, so memoise it — the exporter asks once per material and several materials can share one texture. """ if image.name in _CUTOUT_CACHE: return _CUTOUT_CACHE[image.name] width, height = image.size result = False if width and height: alpha = np.empty(width * height * 4, dtype=np.float32) image.pixels.foreach_get(alpha) result = bool((alpha[3::4] < threshold).any()) _CUTOUT_CACHE[image.name] = result return result def tinted_image(source, name, tint, factor): existing = bpy.data.images.get(name) if existing: return existing width, height = source.size pixels = np.empty(width * height * 4, dtype=np.float32) source.pixels.foreach_get(pixels) rgba = pixels.reshape((-1, 4)) rgba[:, :3] = rgba[:, :3] * (1.0 - factor) + np.asarray( tint, dtype=np.float32) * factor result = bpy.data.images.new(name, width=width, height=height, alpha=True) result.file_format = "PNG" result.colorspace_settings.name = "sRGB" result.pixels.foreach_set(pixels) result.pack() return result def cesium_tinted_image(material, source, tint): if not tint or not source: return source color, factor = tint safe_name = material.name.replace(" ", "_") return tinted_image( source, f"{EXPORT_PREFIX}{safe_name} Baked", color, factor) def alpha_dilated_image(source, name, threshold=0.5, passes=8, gain=1.0, saturation=1.0): """Flood the opaque colour outward underneath the cut-out, and lift it. Some legacy leaf-card atlases write pure black wherever a card is cut away. An alpha mask hides that at full resolution, but Cesium mip-maps the texture and every mip level averages those black texels into the leaf edges, so the crown grows a dark fringe that thickens with distance. Blender's preview renders at mip 0 and never shows it, which is why this only surfaces in the viewer. Replacing the colour under the cut-out with its nearest opaque neighbours leaves no black to bleed. Alpha is copied through untouched, so the silhouette is byte-for-byte what it was. `gain` and `saturation` grade the result into the same exposure and colour as the rest of the scene — see FOLIAGE_ALBEDO_GAIN and FOLIAGE_SATURATION. Both are applied after the flood so the filled border keeps matching the leaves it was copied from, and the result is clipped at 1.0. """ existing = bpy.data.images.get(name) if existing: return existing width, height = source.size pixels = np.empty(width * height * 4, dtype=np.float32) source.pixels.foreach_get(pixels) rgba = pixels.reshape((height, width, 4)) rgb = rgba[..., :3].copy() filled = rgba[..., 3] >= threshold # Each pass pushes the colour one texel further out, so `passes` is how # many mip levels' worth of filter footprint gets covered. for _ in range(passes): if filled.all(): break weight = filled[..., None].astype(np.float32) total = np.zeros_like(rgb) count = np.zeros((height, width, 1), dtype=np.float32) for shift, axis in ((1, 0), (-1, 0), (1, 1), (-1, 1)): total += np.roll(rgb * weight, shift, axis=axis) count += np.roll(weight, shift, axis=axis) edge = (~filled) & (count[..., 0] > 0) rgb[edge] = total[edge] / count[edge] filled = filled | edge if saturation != 1.0: # Rec.709 luminance, so the push is around perceived brightness rather # than the channel average. luma = rgb @ np.asarray([0.2126, 0.7152, 0.0722], dtype=np.float32) rgb = luma[..., None] + (rgb - luma[..., None]) * saturation if gain != 1.0 or saturation != 1.0: rgb = np.clip(rgb * gain, 0.0, 1.0) dilated = rgba.copy() dilated[..., :3] = rgb result = bpy.data.images.new(name, width=width, height=height, alpha=True) result.file_format = "PNG" result.colorspace_settings.name = "sRGB" result.pixels.foreach_set(dilated.ravel()) result.pack() return result def tree_crown_image(): name = "Cesium Tree Crown Baked" existing = bpy.data.images.get(name) if existing: return existing size = 256 x, y = np.meshgrid( np.linspace(0.0, 1.0, size, dtype=np.float32), np.linspace(0.0, 1.0, size, dtype=np.float32), ) noise = ( np.sin((x * 17.0 + y * 7.0) * np.pi) * 0.24 + np.sin((x * 43.0 - y * 31.0) * np.pi) * 0.13 + np.sin((x * 89.0 + y * 67.0) * np.pi) * 0.07 ) noise = np.clip(0.5 + noise, 0.0, 1.0)[..., None] dark = np.asarray((0.04, 0.17, 0.04), dtype=np.float32) light = np.asarray((0.17, 0.46, 0.115), dtype=np.float32) rgb = dark + (light - dark) * noise rgba = np.concatenate( (rgb, np.ones((size, size, 1), dtype=np.float32)), axis=2) result = bpy.data.images.new(name, width=size, height=size, alpha=True) result.file_format = "PNG" result.colorspace_settings.name = "sRGB" result.pixels.foreach_set(rgba.ravel()) result.pack() return result def make_export_material(material): contract = cesium_contract(material) result = material.copy() result.name = EXPORT_PREFIX + material.name if CESIUM_EXPORT_PROPERTY in result: del result[CESIUM_EXPORT_PROPERTY] result.use_nodes = True nodes = result.node_tree.nodes links = result.node_tree.links nodes.clear() output = nodes.new("ShaderNodeOutputMaterial") output.location = (520, 0) bsdf = nodes.new("ShaderNodeBsdfPrincipled") bsdf.location = (250, 0) has_base_color_override = ( "base_color" in contract or material.name in EXPORT_BASE_COLOR_OVERRIDES ) base_color = contract.get("base_color", EXPORT_BASE_COLOR_OVERRIDES.get( material.name, source_color(material))) bsdf.inputs["Base Color"].default_value = (*base_color, 1.0) bsdf.inputs["Roughness"].default_value = source_principled_value( material, "Roughness", 0.8) bsdf.inputs["Metallic"].default_value = contract.get( "metallic", EXPORT_METALLIC_OVERRIDES.get( material.name, source_principled_value(material, "Metallic", 0.0))) emission = contract.get( "emission", EXPORT_EMISSION_OVERRIDES.get(material.name)) if emission: emission_color, emission_strength = emission if "Emission Color" in bsdf.inputs: bsdf.inputs["Emission Color"].default_value = (*emission_color, 1.0) elif "Emission" in bsdf.inputs: bsdf.inputs["Emission"].default_value = (*emission_color, 1.0) if "Emission Strength" in bsdf.inputs: bsdf.inputs["Emission Strength"].default_value = emission_strength links.new(bsdf.outputs["BSDF"], output.inputs["Surface"]) diffuse = image_for(material, want_normal=False) normal = image_for(material, want_normal=True) # Foliage that carries its silhouette in the texture's alpha has to keep # that channel; every other material is flattened to opaque below. alpha_clipped = source_alpha_clipped(material) foliage_gain, foliage_saturation, foliage_emission = foliage_export_profile(material) if material.name == "Tree Crown": diffuse = tree_crown_image() else: tint = contract.get("tint", EXPORT_TINTS.get(material.name)) diffuse = cesium_tinted_image(material, diffuse, tint) if alpha_clipped and diffuse is not None: safe_name = material.name.replace(" ", "_") diffuse = alpha_dilated_image( diffuse, f"{EXPORT_PREFIX}{safe_name} Dilated", gain=foliage_gain, saturation=foliage_saturation) if has_base_color_override: diffuse = None normal = None mapping = None if diffuse or normal: texcoord = nodes.new("ShaderNodeTexCoord") texcoord.location = (-650, 0) mapping = nodes.new("ShaderNodeMapping") mapping.location = (-450, 0) mapping.inputs["Scale"].default_value = source_texture_scale(material) links.new(texcoord.outputs["UV"], mapping.inputs["Vector"]) diffuse_node = None if diffuse: image = nodes.new("ShaderNodeTexImage") image.location = (-200, 80) image.image = diffuse image.extension = "REPEAT" links.new(mapping.outputs["Vector"], image.inputs["Vector"]) links.new(image.outputs["Color"], bsdf.inputs["Base Color"]) diffuse_node = image if normal: normal_tex = nodes.new("ShaderNodeTexImage") normal_tex.location = (-200, -180) normal_tex.image = normal normal_tex.image.colorspace_settings.name = "Non-Color" normal_tex.extension = "REPEAT" normal_map = nodes.new("ShaderNodeNormalMap") normal_map.location = (20, -160) normal_map.inputs["Strength"].default_value = 0.52 links.new(mapping.outputs["Vector"], normal_tex.inputs["Vector"]) links.new(normal_tex.outputs["Color"], normal_map.inputs["Color"]) links.new(normal_map.outputs["Normal"], bsdf.inputs["Normal"]) if alpha_clipped and diffuse_node is not None: # A leaf crown is a handful of quads whose shape lives entirely in this # channel. Pinning Alpha to 1.0 — which is what the rest of the scene # wants — exports those quads whole, and the cut-away regions of a # SpeedTree atlas are black, so Cesium draws black slabs. link_alpha_clip(result, diffuse_node.outputs["Alpha"], bsdf, cutoff=material.alpha_threshold) # Lift the crown out of Cesium's ambient. The preview configures no # environment map, so anything the sun does not hit directly falls to a # weak default spherical-harmonic term — which is why every other # material here carries an emission override. A crown is mostly # self-shadowed leaf cards facing away from the sun, so at distance it # collapses into one dark mass while a sunlit close-up still reads fine. # # Feed the diffuse back in as the emissive texture rather than using a # flat colour: a constant would wash the bark with leaf green, whereas # this floors every texel at a fraction of its own albedo. It costs no # extra bytes — the exporter points emissiveTexture at the image the # base colour already uses. if "Emission Color" in bsdf.inputs: links.new(diffuse_node.outputs["Color"], bsdf.inputs["Emission Color"]) elif "Emission" in bsdf.inputs: links.new(diffuse_node.outputs["Color"], bsdf.inputs["Emission"]) if "Emission Strength" in bsdf.inputs: bsdf.inputs["Emission Strength"].default_value = foliage_emission elif "Alpha" in bsdf.inputs: bsdf.inputs["Alpha"].default_value = 1.0 return result def unwrap_mesh(obj): if obj.type != "MESH" or not obj.data.polygons: return # Imported assets ship authored UVs that map onto their own texture atlas; # smart_project would scramble the leaves. Only the procedurally built # meshes arrive without a UV layer, so that is the reliable discriminator. if obj.data.uv_layers: return bpy.ops.object.select_all(action="DESELECT") obj.select_set(True) bpy.context.view_layer.objects.active = obj bpy.ops.object.mode_set(mode="EDIT") bpy.ops.mesh.select_all(action="SELECT") try: bpy.ops.uv.smart_project(island_margin=0.025, area_weight=0.0) finally: bpy.ops.object.mode_set(mode="OBJECT") def apply_mesh_modifiers(obj): if obj.type != "MESH": return bpy.ops.object.select_all(action="DESELECT") obj.select_set(True) bpy.context.view_layer.objects.active = obj for modifier in list(obj.modifiers): try: bpy.ops.object.modifier_apply(modifier=modifier.name) except RuntimeError: pass def triangulate_mesh(obj): """Split n-gons into triangles ahead of the exporter. glTF has no n-gons, so the exporter triangulates on the way out regardless — doing it here does not change a single output triangle. What it changes is tangents: Blender can only build a tangent basis on tris and quads, and every footprint this pipeline extrudes from OSM is an n-gon, so with export_tangents on each one logged "切向空间只能只算三角/四边形" and shipped without a basis. Triangulating first turns ~55 failures into tangents. Skipped for meshes that are already triangles, which covers the instanced props — those share one datablock across hundreds of objects and modifier_apply refuses to touch multi-user data. """ if obj.type != "MESH" or not obj.data.polygons: return if all(len(polygon.vertices) <= 3 for polygon in obj.data.polygons): return bpy.ops.object.select_all(action="DESELECT") obj.select_set(True) bpy.context.view_layer.objects.active = obj modifier = obj.modifiers.new("ExportTriangulate", "TRIANGULATE") modifier.min_vertices = 4 try: bpy.ops.object.modifier_apply(modifier=modifier.name) except RuntimeError: # Multi-user data. The exporter still triangulates it, we just lose the # tangent basis for that mesh. obj.modifiers.remove(modifier) def export(args): if not os.path.exists(args["blend"]): raise FileNotFoundError(args["blend"]) bpy.ops.wm.open_mainfile(filepath=args["blend"]) material_map = {} meshes = [] unwrapped = set() for obj in bpy.context.scene.objects: if obj.type != "MESH": continue if obj.name == "Ground Plane": continue if obj.hide_viewport or obj.hide_render: continue 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. if obj.data.name not in unwrapped: unwrapped.add(obj.data.name) triangulate_mesh(obj) unwrap_mesh(obj) for slot in obj.material_slots: if not slot.material: continue source = slot.material # Instanced props share one mesh datablock, and material slots live # on the mesh, so the first tree already swapped in the export # material for all 181 of them. Without this the next instance # wraps that result again — "Cesium Cesium Cesium ..." — and since # the baked-image cache is keyed by material name, every round # embeds another multi-megabyte copy of the same texture. if source.name.startswith(EXPORT_PREFIX): continue if source.name not in material_map: material_map[source.name] = make_export_material(source) slot.material = material_map[source.name] bpy.ops.object.select_all(action="DESELECT") for obj in meshes: obj.select_set(True) bpy.context.view_layer.objects.active = meshes[0] if meshes else None os.makedirs(os.path.dirname(args["glb"]), exist_ok=True) bpy.ops.export_scene.gltf( filepath=args["glb"], export_format="GLB", use_selection=True, export_apply=False, export_texcoords=True, export_normals=True, export_materials="EXPORT", export_image_format="AUTO", # The foliage materials carry a normal map, and glTF leaves tangent # derivation to the renderer when TANGENT is absent. On thin # double-sided leaf cards that derivation is unreliable, so ship real # tangents — it costs four floats a vertex on meshes this small. export_tangents=True, export_extras=True, export_cameras=False, export_lights=False, ) scene = bpy.context.scene try: bounds = json.loads(scene.get("osm_bounds", "{}")) except (TypeError, ValueError): bounds = {} if bounds: center_lon = (bounds["min_lon"] + bounds["max_lon"]) / 2.0 center_lat = (bounds["min_lat"] + bounds["max_lat"]) / 2.0 else: center_lon = center_lat = 0.0 metadata = { "asset": os.path.basename(args["glb"]), "assets": [{ "id": "main", "label": "Scene", "type": "model", "url": os.path.basename(args["glb"]), "enabled": True, }], "coordinate_system": "local ENU meters (X east, Y north, Z up)", "heading_correction_degrees": -90.0, "anchor": {"longitude": center_lon, "latitude": center_lat, "height": 0.35}, "bounds": bounds, "source_osm": scene.get("source_osm", ""), "source_geojson": scene.get("source_geojson", ""), "scene_stats": { "buildings": scene.get("building_count", 0), "industrial_buildings": scene.get("industrial_building_count", 0), "lake_polygons": scene.get("lake_count", 0), "trees": scene.get("tree_count", 0), "grass_polygons": scene.get("grass_count", 0), "scrub_polygons": scene.get("scrub_count", 0), "fountains": scene.get("fountain_count", 0), }, "cesium_js": ( "const p = Cesium.Cartesian3.fromDegrees(" + f"{center_lon:.8f}, {center_lat:.8f}, 0.35);\n" + "const enu = Cesium.Transforms.eastNorthUpToFixedFrame(p);\n" + "const correction = Cesium.Matrix3.fromRotationZ(Cesium.Math.toRadians(-90.0));\n" + "const modelMatrix = Cesium.Matrix4.multiplyByMatrix3(enu, correction, new Cesium.Matrix4());\n" + "Cesium.Model.fromGltfAsync({ url: '" + os.path.basename(args["glb"]) + "', modelMatrix }).then(model => viewer.scene.primitives.add(model));" ), } os.makedirs(os.path.dirname(args["metadata"]), exist_ok=True) with open(args["metadata"], "w", encoding="utf-8") as handle: json.dump(metadata, handle, ensure_ascii=False, indent=2) handle.write("\n") print("CESIUM_EXPORT_DONE", json.dumps({ "glb": args["glb"], "metadata": args["metadata"], "meshes": len(meshes), "materials": len(material_map), "anchor": [center_lon, center_lat], }, ensure_ascii=True)) if __name__ == "__main__": export(cli_args())