CinematicEarth 0.2.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,45 @@
1
+ """CinematicEarth: cinematic multi-stop flyovers for Google Earth Studio.
2
+
3
+ import cinematicearth as ce
4
+ plan = ce.build("stops.md", out="flyover.esp") # also writes .kml + .plan.json
5
+ print(plan.summary())
6
+ """
7
+ from __future__ import annotations
8
+
9
+ import os
10
+ from typing import Optional
11
+
12
+ from .model import Settings, Stop, parse_moves, SPEED_PRESETS
13
+ from .io import load, parse_markdown, parse_stop_line
14
+ from .planner import plan_flyover, Plan
15
+ from .esp import build_esp, write_esp, decode_tracks
16
+ from .kml import build_kml, write_kml
17
+
18
+ __version__ = "0.2.0"
19
+ __all__ = ["Settings", "Stop", "Plan", "load", "plan_flyover", "build", "write_esp", "write_kml",
20
+ "build_esp", "decode_tracks", "parse_moves", "SPEED_PRESETS", "__version__"]
21
+
22
+
23
+ def build(source, out: Optional[str] = "flyover.esp", kml: bool = True, plan_json: bool = True,
24
+ template: Optional[str] = None, geocode: bool = False, elevation: bool = False,
25
+ verbose: bool = True, **overrides) -> Plan:
26
+ """Load stops (path / markdown text / list), plan the camera, write outputs.
27
+
28
+ overrides: any Settings field, e.g. duration=150, speed="slow", stop_duration=10, orbit=45.
29
+ """
30
+ settings, stops = load(source, **overrides)
31
+ if geocode or elevation:
32
+ from .geocode import resolve
33
+ resolve(stops, need_coords=geocode, need_elev=elevation, verbose=verbose)
34
+ plan = plan_flyover(stops, settings)
35
+ plan.esp_files = []
36
+ if out:
37
+ plan.esp_files = write_esp(plan, out, template)
38
+ base = os.path.splitext(out)[0]
39
+ if kml:
40
+ write_kml(plan, base + ".kml")
41
+ if plan_json:
42
+ plan.save_json(base + ".plan.json")
43
+ if verbose:
44
+ print(plan.summary())
45
+ return plan
cinematicearth/cli.py ADDED
@@ -0,0 +1,176 @@
1
+ """Command line interface.
2
+
3
+ cinematicearth build stops.md -o flyover.esp --speed slow --duration 180
4
+ cinematicearth build "Huntsville, AL; Santa Fe, NM" --geocode
5
+ cinematicearth preview stops.md -o preview.png
6
+ cinematicearth annotate flyover.plan.json render.zip -o flyover_final.mp4
7
+ cinematicearth annotate flyover.plan.json part01.zip part02.zip ... (split projects)
8
+ cinematicearth moves # print the camera-move reference
9
+ cinematicearth init stops.md # write an example stop file
10
+ """
11
+ from __future__ import annotations
12
+
13
+ import argparse
14
+ import os
15
+ import sys
16
+
17
+ from . import build as _build, load, plan_flyover, __version__
18
+ from .model import Settings
19
+
20
+ EXAMPLE = """---
21
+ title: My flyover
22
+ speed: slow # glacial | slow | medium | fast | number
23
+ stop_duration: 8 # seconds circling each stop
24
+ orbit: 45 # degrees of orbit per stop
25
+ tilt: 60 # 0 = straight down, 90 = horizon
26
+ # duration: 150 # optional total length (s); transits are re-paced to fit
27
+ ---
28
+
29
+ # Stops: name | lat, lon[, elev] | camera moves | key=value ...
30
+ - Huntsville, AL | 34.7304, -86.5861, 190 | orbit 60 + zoom 8km>3km | note=NASA EarthRISE
31
+ - Santa Fe, NM | 35.6870, -105.9378, 2130 | orbit 30 + tilt 55>68 @5s; truck 2km + zoom 2.5km
32
+ - Fort Collins, CO | 40.5853, -105.0844, 1525 | slow 1.5
33
+ """
34
+
35
+
36
+ def _overrides(a):
37
+ keys = ["duration", "speed", "stop_duration", "orbit", "tilt", "fps", "width", "height",
38
+ "range_start", "range_end", "title", "ease", "max_part_seconds", "keyframe_tol_px", "cut_km",
39
+ "direction"]
40
+ ov = {k: getattr(a, k) for k in keys if getattr(a, k, None) is not None}
41
+ if getattr(a, "no_intro", False):
42
+ ov["intro"] = False
43
+ if getattr(a, "no_outro", False):
44
+ ov["outro"] = False
45
+ if getattr(a, "every_frame", False):
46
+ ov["keyframe_every_frame"] = True
47
+ return ov
48
+
49
+
50
+ def _source(src):
51
+ if os.path.exists(src):
52
+ return src
53
+ if src == "-":
54
+ return sys.stdin.read()
55
+ # inline list: "A; B; C" or newline separated
56
+ return "\n".join("- " + s.strip() for s in src.replace("\n", ";").split(";") if s.strip())
57
+
58
+
59
+ def _add_common(p):
60
+ p.add_argument("source", help=".md/.csv/.json/.yaml/.txt file, '-' for stdin, or 'A; B; C' inline list")
61
+ p.add_argument("--duration", type=float, help="total seconds (default: auto from pacing)")
62
+ p.add_argument("--speed", help="glacial | slow | medium | fast | number")
63
+ p.add_argument("--stop-duration", dest="stop_duration", type=float)
64
+ p.add_argument("--orbit", type=float, help="default orbit degrees per stop")
65
+ p.add_argument("--tilt", type=float)
66
+ p.add_argument("--range-start", dest="range_start", type=float)
67
+ p.add_argument("--range-end", dest="range_end", type=float)
68
+ p.add_argument("--fps", type=int)
69
+ p.add_argument("--width", type=int)
70
+ p.add_argument("--height", type=int)
71
+ p.add_argument("--ease", choices=["smooth", "sine", "linear"])
72
+ p.add_argument("--split", dest="max_part_seconds", type=float,
73
+ help="split into several .esp projects of at most N seconds (lighter for Earth Studio)")
74
+ p.add_argument("--keyframe-tol", dest="keyframe_tol_px", type=float, help="px drift allowed at stops (default 3)")
75
+ p.add_argument("--cut-km", dest="cut_km", type=float, help="hops longer than this become hard cuts")
76
+ p.add_argument("--direction", help="orbit direction: alternate | 1 | -1")
77
+ p.add_argument("--every-frame", action="store_true", help="one keyframe per frame (big .esp)")
78
+ p.add_argument("--title")
79
+ p.add_argument("--no-intro", action="store_true")
80
+ p.add_argument("--no-outro", action="store_true")
81
+ p.add_argument("--geocode", action="store_true", help="look up missing lat/lon (Nominatim)")
82
+ p.add_argument("--elevation", action="store_true", help="look up missing ground elevation (Open-Meteo)")
83
+
84
+
85
+ def main(argv=None):
86
+ ap = argparse.ArgumentParser(prog="cinematicearth", description="Cinematic Google Earth Studio flyovers")
87
+ ap.add_argument("--version", action="version", version=__version__)
88
+ sub = ap.add_subparsers(dest="cmd", required=True)
89
+
90
+ b = sub.add_parser("build", help="write .esp (+ .kml, .plan.json)")
91
+ _add_common(b)
92
+ b.add_argument("-o", "--out", default="flyover.esp")
93
+ b.add_argument("--template", help="an .esp exported from Earth Studio to inject keyframes into")
94
+ b.add_argument("--no-kml", action="store_true")
95
+
96
+ pv = sub.add_parser("preview", help="plot ground track / altitude / pacing")
97
+ _add_common(pv)
98
+ pv.add_argument("-o", "--out", default="flyover_preview.png")
99
+
100
+ an = sub.add_parser("annotate", help="burn annotations into an Earth Studio render")
101
+ an.add_argument("plan", help="the .plan.json written by build")
102
+ an.add_argument("render", nargs="+", help="Earth Studio render(s): .zip of frames, frame folder, or video; "
103
+ "one per project part, in order")
104
+ an.add_argument("-o", "--out", default="flyover_final.mp4")
105
+ an.add_argument("--accent", default="#FFB000")
106
+ an.add_argument("--no-coords", action="store_true")
107
+ an.add_argument("--no-counter", action="store_true")
108
+ an.add_argument("--attribution", default="Google Earth")
109
+
110
+ sm = sub.add_parser("simulate", help="wireframe preview MP4 of an .esp (check motion before Earth Studio)")
111
+ sm.add_argument("esp")
112
+ sm.add_argument("plan", help="the .plan.json written by build")
113
+ sm.add_argument("-o", "--out", default="flyover_wireframe.mp4")
114
+ sm.add_argument("--width", type=int, default=640)
115
+ sm.add_argument("--height", type=int, default=360)
116
+ sm.add_argument("--fps", type=int, default=15)
117
+ sm.add_argument("--start", type=float, default=0.0)
118
+ sm.add_argument("--seconds", type=float)
119
+
120
+ sk = sub.add_parser("smoketest", help="write a tiny 10 s one-city .esp to check Earth Studio import")
121
+ sk.add_argument("-o", "--out", default="test_tiny.esp")
122
+
123
+ sub.add_parser("moves", help="print the camera-move reference")
124
+ ini = sub.add_parser("init", help="write an example stops file")
125
+ ini.add_argument("path", nargs="?", default="stops.md")
126
+
127
+ a = ap.parse_args(argv)
128
+ if a.cmd == "build":
129
+ plan = _build(_source(a.source), out=a.out, kml=not a.no_kml, template=a.template,
130
+ geocode=a.geocode, elevation=a.elevation, **_overrides(a))
131
+ for f in plan.esp_files:
132
+ print(f"wrote {f}")
133
+ elif a.cmd == "preview":
134
+ from .preview import plot_plan
135
+ settings, stops = load(_source(a.source), **_overrides(a))
136
+ if a.geocode or a.elevation:
137
+ from .geocode import resolve
138
+ resolve(stops, need_coords=a.geocode, need_elev=a.elevation)
139
+ plan = plan_flyover(stops, settings)
140
+ print(plan.summary())
141
+ plot_plan(plan, a.out, show=False)
142
+ print(f"wrote {a.out}")
143
+ elif a.cmd == "annotate":
144
+ from .overlay import annotate_video, OverlayStyle
145
+ st = OverlayStyle(accent=a.accent, show_coords=not a.no_coords, show_counter=not a.no_counter,
146
+ attribution=a.attribution)
147
+ annotate_video(a.render if len(a.render) > 1 else a.render[0], a.plan, a.out, st)
148
+ print(f"wrote {a.out}")
149
+ elif a.cmd == "simulate":
150
+ import json
151
+ from .simulate import render_preview, PlanWindows
152
+ with open(a.esp) as f:
153
+ esp = json.load(f)
154
+ render_preview(esp, PlanWindows(a.plan), a.out, a.width, a.height, a.fps, a.seconds, a.start)
155
+ print(f"wrote {a.out}")
156
+ elif a.cmd == "smoketest":
157
+ plan = _build("- Huntsville, AL | 34.7304, -86.5861, 190 | orbit 45 + zoom 7km>3.5km @10s",
158
+ out=a.out, kml=False, plan_json=False, intro=False, outro=False, verbose=False,
159
+ title="CinematicEarth smoke test")
160
+ print(f"wrote {a.out}: {plan.frames} frames, {len(plan.keyframes()[0])} keyframes")
161
+ elif a.cmd == "moves":
162
+ from . import model
163
+ src = model.__file__
164
+ with open(src) as f:
165
+ txt = f.read()
166
+ start = txt.index("# ---------------- move language")
167
+ end = txt.index("_DUR =")
168
+ print("\n".join(l[2:] if l.startswith("# ") else l.lstrip("#") for l in txt[start:end].splitlines()[1:]))
169
+ elif a.cmd == "init":
170
+ with open(a.path, "w") as f:
171
+ f.write(EXAMPLE)
172
+ print(f"wrote {a.path}")
173
+
174
+
175
+ if __name__ == "__main__":
176
+ main()
cinematicearth/esp.py ADDED
@@ -0,0 +1,206 @@
1
+ """Write Google Earth Studio project files (.esp).
2
+
3
+ The layout and value encodings follow a real Earth Studio export
4
+ (modelVersion 17) as documented by mkatzef/google-studio-utils:
5
+
6
+ * keyframe ``time`` is a fraction 0..1 of the project duration
7
+ * longitude: value = (lon - min) / (180 - min), ``minValueRange`` = min
8
+ * latitude: value = (lat - min) / (90 - min), ``minValueRange`` = min
9
+ * altitude: value = alt_m * 1.5356706349899208e-08 (linear, not logarithmic)
10
+ * rotationX (pan / heading, compass deg): value = (pan - min) / (max - min), with
11
+ ``minValueRange`` / ``maxValueRange``; pans may be unwrapped beyond 0..360
12
+ * rotationY (tilt, 0 = down, 90 = horizon): value = tilt / 180
13
+
14
+ Camera Target is written disabled: pan/tilt are computed exactly per keyframe,
15
+ so the camera always aims at the city without relying on target influence.
16
+ """
17
+ from __future__ import annotations
18
+
19
+ import copy
20
+ import json
21
+ from typing import Optional
22
+
23
+ import numpy as np
24
+
25
+ ALT_SCALE = 1.5356706349899208e-08
26
+
27
+
28
+ def _block(label, vals, times, min_val=None, max_val=None):
29
+ blk = {
30
+ "type": label,
31
+ "value": {"relative": 0},
32
+ "keyframes": [{"time": float(t), "value": float(v)} for t, v in zip(times, vals)],
33
+ "intimeline": True,
34
+ }
35
+ if min_val is not None:
36
+ blk["value"]["minValueRange"] = float(min_val)
37
+ if max_val is not None:
38
+ blk["value"]["maxValueRange"] = float(max_val)
39
+ if label == "altitude":
40
+ blk["value"]["logarithmic"] = False
41
+ return blk
42
+
43
+
44
+ def encode_tracks(times, cam, rot, duration):
45
+ """Return (position_blocks, rotation_blocks) in Earth Studio encoding."""
46
+ tn = np.clip(np.asarray(times) / duration, 0.0, 1.0)
47
+ lat, lon, alt = cam[:, 0], cam[:, 1], cam[:, 2]
48
+ pan, tilt = rot[:, 0], rot[:, 1]
49
+ lon_min = float(lon.min()) - 1e-6
50
+ lat_min = float(lat.min()) - 1e-6
51
+ pmin, pmax = float(pan.min()), float(pan.max())
52
+ if pmax - pmin < 1e-3:
53
+ pmin, pmax = pmin - 1.0, pmax + 1.0
54
+ pos = [
55
+ _block("longitude", (lon - lon_min) / (180.0 - lon_min), tn, lon_min),
56
+ _block("latitude", (lat - lat_min) / (90.0 - lat_min), tn, lat_min),
57
+ _block("altitude", alt * ALT_SCALE, tn),
58
+ ]
59
+ rotb = [
60
+ _block("rotationX", (pan - pmin) / (pmax - pmin), tn, pmin, pmax),
61
+ _block("rotationY", tilt / 180.0, tn),
62
+ {"type": "rotationZ", "value": {}},
63
+ ]
64
+ return pos, rotb
65
+
66
+
67
+ def decode_tracks(esp: dict):
68
+ """Inverse of encode_tracks (for verification / round-trip tests)."""
69
+ found = {}
70
+
71
+ def walk(o):
72
+ if isinstance(o, dict):
73
+ if o.get("type") in ("longitude", "latitude", "altitude", "rotationX", "rotationY") and "keyframes" in o:
74
+ found[o["type"]] = o
75
+ for v in o.values():
76
+ walk(v)
77
+ elif isinstance(o, list):
78
+ for v in o:
79
+ walk(v)
80
+
81
+ walk(esp)
82
+ dur = esp["settings"]["duration"] / esp["settings"]["frameRate"]
83
+ t = np.array([k["time"] for k in found["longitude"]["keyframes"]]) * dur
84
+ g = lambda k: np.array([x["value"] for x in found[k]["keyframes"]])
85
+ lon_min = found["longitude"]["value"]["minValueRange"]
86
+ lat_min = found["latitude"]["value"]["minValueRange"]
87
+ lon = lon_min + g("longitude") * (180 - lon_min)
88
+ lat = lat_min + g("latitude") * (90 - lat_min)
89
+ alt = g("altitude") / ALT_SCALE
90
+ rx = found["rotationX"]["value"]
91
+ pan = rx["minValueRange"] + g("rotationX") * (rx["maxValueRange"] - rx["minValueRange"])
92
+ tilt = g("rotationY") * 180
93
+ return t, np.c_[lat, lon, alt], np.c_[pan, tilt]
94
+
95
+
96
+ def _template(name, fps, width, height, frames, pos, rotb):
97
+ return {
98
+ "modelVersion": 17,
99
+ "settings": {"name": name, "frameRate": fps,
100
+ "dimensions": {"width": width, "height": height},
101
+ "duration": frames, "timeFormat": "frames"},
102
+ "scenes": [{
103
+ "animationModel": {"roving": False, "logarithmic": False, "groupedPosition": True},
104
+ "duration": frames,
105
+ "attributes": [
106
+ {"type": "cameraGroup", "inTimeline": True, "attributes": [
107
+ {"type": "cameraPositionGroup", "inTimeline": True, "attributes": pos},
108
+ {"type": "cameraTargetEffect", "attributes": [
109
+ {"type": "enabled", "value": {}},
110
+ {"type": "poi", "attributes": [
111
+ {"type": "longitudePOI", "value": {}},
112
+ {"type": "latitudePOI", "value": {}},
113
+ {"type": "altitudePOI", "value": {"logarithmic": False}}]},
114
+ {"type": "influence", "value": {}}]},
115
+ {"type": "cameraRotationGroup", "inTimeline": True, "attributes": rotb},
116
+ {"type": "cameraLensGroup", "attributes": [
117
+ {"type": "fov", "value": {}}, {"type": "exposure", "value": {}},
118
+ {"type": "aperture", "value": {}}, {"type": "minFocusLength", "value": {}}]},
119
+ ]},
120
+ {"type": "environmentGroup", "attributes": [
121
+ {"type": "sunGroup", "attributes": [
122
+ {"type": "sunVisibility", "value": {}},
123
+ {"type": "worldTime", "value": {"relative": 0.5}}]},
124
+ {"type": "cloudGroup", "attributes": [
125
+ {"type": "cloudVisibility", "value": {}}, {"type": "cloudopacity", "value": {}},
126
+ {"type": "cloudheight", "value": {}}, {"type": "clouddate", "value": {"relative": 0.95}}]},
127
+ {"type": "starsPlanetsGroup", "attributes": [{"type": "starsEnabled", "value": {}}]},
128
+ {"type": "seawaterGroup", "attributes": [
129
+ {"type": "seawater", "value": {}}, {"type": "influence", "value": {"relative": 1}}]},
130
+ {"type": "buildingsEnabled", "value": {}},
131
+ ]},
132
+ ],
133
+ "cameraExport": {"logarithmic": False, "modelVersion": 2},
134
+ }],
135
+ "playbackManager": {"range": {"start": 0, "end": frames}},
136
+ }
137
+
138
+
139
+ def _inject(template: dict, pos, rotb, frames, fps, width, height, name):
140
+ esp = copy.deepcopy(template)
141
+ hit = {"pos": False, "rot": False}
142
+
143
+ def walk(o):
144
+ if isinstance(o, dict):
145
+ if o.get("type") == "cameraPositionGroup":
146
+ o["attributes"] = pos
147
+ hit["pos"] = True
148
+ elif o.get("type") == "cameraRotationGroup":
149
+ o["attributes"] = rotb
150
+ hit["rot"] = True
151
+ elif o.get("type") == "cameraTargetEffect":
152
+ for a in o.get("attributes", []): # make sure target is OFF
153
+ if a.get("type") == "enabled":
154
+ a["value"] = {}
155
+ a.pop("keyframes", None)
156
+ for v in o.values():
157
+ walk(v)
158
+ elif isinstance(o, list):
159
+ for v in o:
160
+ walk(v)
161
+
162
+ walk(esp)
163
+ if not (hit["pos"] and hit["rot"]):
164
+ raise ValueError("template has no cameraPositionGroup/cameraRotationGroup")
165
+ esp.setdefault("settings", {}).update(name=name, frameRate=fps, duration=frames, timeFormat="frames",
166
+ dimensions={"width": width, "height": height})
167
+ for sc in esp.get("scenes", []):
168
+ sc["duration"] = frames
169
+ esp["playbackManager"] = {"range": {"start": 0, "end": frames}}
170
+ return esp
171
+
172
+
173
+ def build_esp(plan, template: Optional[dict] = None, part: Optional[int] = None) -> dict:
174
+ """Project dict for the whole plan, or for one part (see Settings.max_part_seconds)."""
175
+ st = plan.settings
176
+ parts = plan.parts()
177
+ a, b = parts[part or 0] if part is not None or len(parts) == 1 else (0.0, plan.frames / st.fps)
178
+ t, cam, rot, _ = plan.keyframes(a, b)
179
+ frames = int(round((b - a) * st.fps))
180
+ pos, rotb = encode_tracks(t, cam, rot, frames / st.fps)
181
+ name = st.title if len(parts) == 1 or part is None else f"{st.title} part {part + 1}"
182
+ if template is not None:
183
+ return _inject(template, pos, rotb, frames, st.fps, st.width, st.height, name)
184
+ return _template(name, st.fps, st.width, st.height, frames, pos, rotb)
185
+
186
+
187
+ def write_esp(plan, path, template_path: Optional[str] = None):
188
+ """Write path (or path_part01.esp, ... when the plan is split). Returns list of files."""
189
+ import os
190
+ tpl = None
191
+ if template_path:
192
+ with open(template_path) as f:
193
+ tpl = json.load(f)
194
+ parts = plan.parts()
195
+ if len(parts) == 1:
196
+ with open(path, "w") as f:
197
+ json.dump(build_esp(plan, tpl, 0), f, separators=(",", ":"))
198
+ return [path]
199
+ base, ext = os.path.splitext(path)
200
+ out = []
201
+ for k in range(len(parts)):
202
+ p = f"{base}_part{k + 1:02d}{ext or '.esp'}"
203
+ with open(p, "w") as f:
204
+ json.dump(build_esp(plan, tpl, k), f, separators=(",", ":"))
205
+ out.append(p)
206
+ return out
cinematicearth/geo.py ADDED
@@ -0,0 +1,130 @@
1
+ """Geodesy helpers: spherical-earth great circles, ENU frames, LookAt <-> camera."""
2
+ from __future__ import annotations
3
+
4
+ import math
5
+ from typing import Tuple
6
+
7
+ R_E = 6_371_000.0
8
+ rad, deg = math.radians, math.degrees
9
+
10
+
11
+ def wrap180(a: float) -> float:
12
+ return (a + 180.0) % 360.0 - 180.0
13
+
14
+
15
+ def unwrap_to(a: float, ref: float) -> float:
16
+ """Return angle equal to `a` (mod 360) closest to `ref` (continuous headings)."""
17
+ return ref + wrap180(a - ref)
18
+
19
+
20
+ def haversine(lat1, lon1, lat2, lon2) -> float:
21
+ p1, p2 = rad(lat1), rad(lat2)
22
+ dp, dl = p2 - p1, rad(lon2 - lon1)
23
+ h = math.sin(dp / 2) ** 2 + math.cos(p1) * math.cos(p2) * math.sin(dl / 2) ** 2
24
+ return 2 * R_E * math.asin(min(1.0, math.sqrt(h)))
25
+
26
+
27
+ def bearing(lat1, lon1, lat2, lon2) -> float:
28
+ p1, p2, dl = rad(lat1), rad(lat2), rad(lon2 - lon1)
29
+ y = math.sin(dl) * math.cos(p2)
30
+ x = math.cos(p1) * math.sin(p2) - math.sin(p1) * math.cos(p2) * math.cos(dl)
31
+ return deg(math.atan2(y, x)) % 360.0
32
+
33
+
34
+ def destination(lat, lon, brg, dist) -> Tuple[float, float]:
35
+ p1, l1, b, d = rad(lat), rad(lon), rad(brg), dist / R_E
36
+ p2 = math.asin(math.sin(p1) * math.cos(d) + math.cos(p1) * math.sin(d) * math.cos(b))
37
+ l2 = l1 + math.atan2(math.sin(b) * math.sin(d) * math.cos(p1),
38
+ math.cos(d) - math.sin(p1) * math.sin(p2))
39
+ return deg(p2), wrap180(deg(l2))
40
+
41
+
42
+ def gc_interp(lat1, lon1, lat2, lon2, f) -> Tuple[float, float]:
43
+ """Point at fraction f along the great circle."""
44
+ p1, l1, p2, l2 = rad(lat1), rad(lon1), rad(lat2), rad(lon2)
45
+ d = haversine(lat1, lon1, lat2, lon2) / R_E
46
+ if d < 1e-12:
47
+ return lat1, lon1
48
+ a = math.sin((1 - f) * d) / math.sin(d)
49
+ b = math.sin(f * d) / math.sin(d)
50
+ x = a * math.cos(p1) * math.cos(l1) + b * math.cos(p2) * math.cos(l2)
51
+ y = a * math.cos(p1) * math.sin(l1) + b * math.cos(p2) * math.sin(l2)
52
+ z = a * math.sin(p1) + b * math.sin(p2)
53
+ return deg(math.atan2(z, math.hypot(x, y))), deg(math.atan2(y, x))
54
+
55
+
56
+ def gc_crosses_antimeridian(lat1, lon1, lat2, lon2, n=64) -> bool:
57
+ prev = lon1
58
+ for k in range(1, n + 1):
59
+ _, lo = gc_interp(lat1, lon1, lat2, lon2, k / n)
60
+ if abs(lo - prev) > 180:
61
+ return True
62
+ prev = lo
63
+ return False
64
+
65
+
66
+ # ---------- ECEF / ENU (spherical) ----------
67
+ def ecef(lat, lon, alt):
68
+ r = R_E + alt
69
+ p, l = rad(lat), rad(lon)
70
+ return (r * math.cos(p) * math.cos(l), r * math.cos(p) * math.sin(l), r * math.sin(p))
71
+
72
+
73
+ def enu_basis(lat, lon):
74
+ p, l = rad(lat), rad(lon)
75
+ e = (-math.sin(l), math.cos(l), 0.0)
76
+ n = (-math.sin(p) * math.cos(l), -math.sin(p) * math.sin(l), math.cos(p))
77
+ u = (math.cos(p) * math.cos(l), math.cos(p) * math.sin(l), math.sin(p))
78
+ return e, n, u
79
+
80
+
81
+ def _dot(a, b):
82
+ return a[0] * b[0] + a[1] * b[1] + a[2] * b[2]
83
+
84
+
85
+ def look_rotation(cam_lat, cam_lon, cam_alt, tgt_lat, tgt_lon, tgt_alt) -> Tuple[float, float]:
86
+ """Exact (heading, tilt) for a camera at cam_* to centre the target point.
87
+
88
+ heading: compass degrees (0 = north, clockwise). tilt: 0 = straight down, 90 = horizon.
89
+ """
90
+ c = ecef(cam_lat, cam_lon, cam_alt)
91
+ t = ecef(tgt_lat, tgt_lon, tgt_alt)
92
+ v = (t[0] - c[0], t[1] - c[1], t[2] - c[2])
93
+ e, n, u = enu_basis(cam_lat, cam_lon)
94
+ ve, vn, vu = _dot(v, e), _dot(v, n), _dot(v, u)
95
+ horiz = math.hypot(ve, vn)
96
+ if horiz < 1e-4 * math.sqrt(ve * ve + vn * vn + vu * vu): # looking straight down
97
+ return None, deg(math.atan2(horiz, -vu))
98
+ heading = deg(math.atan2(ve, vn)) % 360.0
99
+ tilt = deg(math.atan2(horiz, -vu))
100
+ return heading, tilt
101
+
102
+
103
+ def lookat_to_camera(lat, lon, elev, rng, tilt, heading):
104
+ """LookAt (target + range/tilt/heading) -> camera lat, lon, alt (spherical earth).
105
+
106
+ Solves the triangle earth-centre / target / camera so it stays valid from
107
+ street level to orbit.
108
+ """
109
+ rt = R_E + elev
110
+ tr = rad(tilt)
111
+ # camera = target + rng * (sin(tilt) * back-horizontal + cos(tilt) * up), in target ENU
112
+ back = rad(heading + 180.0)
113
+ e, n, u = enu_basis(lat, lon)
114
+ t = ecef(lat, lon, elev)
115
+ h = rng * math.sin(tr)
116
+ vu = rng * math.cos(tr)
117
+ ve, vn = h * math.sin(back), h * math.cos(back)
118
+ c = tuple(t[i] + ve * e[i] + vn * n[i] + vu * u[i] for i in range(3))
119
+ r = math.sqrt(_dot(c, c))
120
+ clat = deg(math.asin(c[2] / r))
121
+ clon = deg(math.atan2(c[1], c[0]))
122
+ return clat, clon, r - R_E
123
+
124
+
125
+ def enu_offset(lat, lon, de, dn):
126
+ """Small local offset (metres east/north) -> lat, lon."""
127
+ dist = math.hypot(de, dn)
128
+ if dist == 0:
129
+ return lat, lon
130
+ return destination(lat, lon, deg(math.atan2(de, dn)), dist)
@@ -0,0 +1,82 @@
1
+ """Optional network helpers: place-name geocoding (Nominatim) and ground elevation
2
+ (Open-Meteo). Standard library only; results cached in ~/.cache/cinematicearth."""
3
+ from __future__ import annotations
4
+
5
+ import json
6
+ import os
7
+ import time
8
+ import urllib.parse
9
+ import urllib.request
10
+ from typing import Optional, Tuple
11
+
12
+ _CACHE = os.path.join(os.path.expanduser("~"), ".cache", "cinematicearth", "geo.json")
13
+ _UA = "CinematicEarth (https://github.com/MayerT1/CinematicEarth)"
14
+
15
+
16
+ def _load():
17
+ try:
18
+ with open(_CACHE) as f:
19
+ return json.load(f)
20
+ except Exception:
21
+ return {}
22
+
23
+
24
+ def _save(c):
25
+ try:
26
+ os.makedirs(os.path.dirname(_CACHE), exist_ok=True)
27
+ with open(_CACHE, "w") as f:
28
+ json.dump(c, f)
29
+ except Exception:
30
+ pass
31
+
32
+
33
+ def _get(url):
34
+ req = urllib.request.Request(url, headers={"User-Agent": _UA})
35
+ with urllib.request.urlopen(req, timeout=20) as r:
36
+ return json.loads(r.read().decode())
37
+
38
+
39
+ def geocode(name: str) -> Optional[Tuple[float, float]]:
40
+ c = _load()
41
+ key = "g:" + name.lower().strip()
42
+ if key in c:
43
+ return tuple(c[key]) if c[key] else None
44
+ q = urllib.parse.urlencode({"q": name, "format": "json", "limit": 1})
45
+ res = _get(f"https://nominatim.openstreetmap.org/search?{q}")
46
+ time.sleep(1.0) # Nominatim usage policy: max 1 request / second
47
+ val = (float(res[0]["lat"]), float(res[0]["lon"])) if res else None
48
+ c[key] = val
49
+ _save(c)
50
+ return val
51
+
52
+
53
+ def elevation(lat: float, lon: float) -> Optional[float]:
54
+ c = _load()
55
+ key = f"e:{lat:.4f},{lon:.4f}"
56
+ if key in c:
57
+ return c[key]
58
+ try:
59
+ res = _get(f"https://api.open-meteo.com/v1/elevation?latitude={lat}&longitude={lon}")
60
+ val = float(res["elevation"][0])
61
+ except Exception:
62
+ return None
63
+ c[key] = val
64
+ _save(c)
65
+ return val
66
+
67
+
68
+ def resolve(stops, need_coords=True, need_elev=True, verbose=True):
69
+ """Fill missing lat/lon (geocode) and elev (elevation API) in place."""
70
+ for s in stops:
71
+ if need_coords and (s.lat is None or s.lon is None):
72
+ r = geocode(s.name)
73
+ if r is None:
74
+ raise ValueError(f"could not geocode {s.name!r}; add lat/lon")
75
+ s.lat, s.lon = r
76
+ if verbose:
77
+ print(f"geocoded {s.name}: {s.lat:.4f}, {s.lon:.4f}")
78
+ if need_elev and s.elev is None:
79
+ s.elev = elevation(s.lat, s.lon)
80
+ if verbose and s.elev is not None:
81
+ print(f"elevation {s.name}: {s.elev:.0f} m")
82
+ return stops