cesiumjs-anywidget 0.5.0__py3-none-any.whl → 0.7.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.
@@ -4,6 +4,9 @@ import os
4
4
  import pathlib
5
5
  import anywidget
6
6
  import traitlets
7
+ from .logger import get_logger
8
+
9
+ logger = get_logger(__name__)
7
10
 
8
11
 
9
12
  class CesiumWidget(anywidget.AnyWidget):
@@ -12,6 +15,7 @@ class CesiumWidget(anywidget.AnyWidget):
12
15
  This widget provides an interactive 3D globe with support for:
13
16
  - Camera position control (latitude, longitude, altitude)
14
17
  - Terrain and imagery visualization
18
+ - Google Photorealistic 3D Tiles (global photorealistic base layer)
15
19
  - Entity management (markers, shapes, models)
16
20
  - Bidirectional state synchronization between Python and JavaScript
17
21
 
@@ -59,6 +63,16 @@ class CesiumWidget(anywidget.AnyWidget):
59
63
  enable_lighting = traitlets.Bool(False, help="Enable scene lighting").tag(sync=True)
60
64
  show_timeline = traitlets.Bool(True, help="Show timeline widget").tag(sync=True)
61
65
  show_animation = traitlets.Bool(True, help="Show animation widget").tag(sync=True)
66
+
67
+ # Photorealistic 3D Tiles options
68
+ enable_photorealistic_tiles = traitlets.Bool(
69
+ False,
70
+ help="Enable Google Photorealistic 3D Tiles (global photorealistic base layer)"
71
+ ).tag(sync=True)
72
+ show_globe = traitlets.Bool(
73
+ True,
74
+ help="Show the base globe (set to False when using photorealistic tiles)"
75
+ ).tag(sync=True)
62
76
 
63
77
  # Cesium Ion access token (optional, uses default if not set)
64
78
  ion_access_token = traitlets.Unicode("", help="Cesium Ion access token").tag(
@@ -79,6 +93,12 @@ class CesiumWidget(anywidget.AnyWidget):
79
93
  help="List of CZML documents to display",
80
94
  ).tag(sync=True)
81
95
 
96
+ # CZML entity updates for dynamic property changes
97
+ czml_entity_update = traitlets.Dict(
98
+ default_value={},
99
+ help="Update properties of existing CZML entities (entity_id, property_name, value)"
100
+ ).tag(sync=True)
101
+
82
102
  # Interaction event data - sent when user interaction ends
83
103
  interaction_event = traitlets.Dict(
84
104
  default_value={},
@@ -103,6 +123,27 @@ class CesiumWidget(anywidget.AnyWidget):
103
123
  help="SkyBox rendering settings (show, sources for cube map faces)"
104
124
  ).tag(sync=True)
105
125
 
126
+ # Clock/Timeline configuration
127
+ current_time = traitlets.Unicode(
128
+ "",
129
+ help="Current time in ISO 8601 format (e.g., '2023-08-01T14:30:00Z')"
130
+ ).tag(sync=True)
131
+
132
+ clock_multiplier = traitlets.Float(
133
+ 1.0,
134
+ help="Animation speed multiplier (1.0 = real-time, 60.0 = 60x speed)"
135
+ ).tag(sync=True)
136
+
137
+ should_animate = traitlets.Bool(
138
+ False,
139
+ help="Whether the clock should animate (play/pause)"
140
+ ).tag(sync=True)
141
+
142
+ clock_command = traitlets.Dict(
143
+ default_value={},
144
+ help="Clock control commands (setTime, play, pause, setMultiplier)"
145
+ ).tag(sync=True)
146
+
106
147
  # Camera commands (for advanced camera operations)
107
148
  camera_command = traitlets.Dict(
108
149
  default_value={},
@@ -124,10 +165,43 @@ class CesiumWidget(anywidget.AnyWidget):
124
165
  default_value={}, help="Trigger to focus on a specific measurement"
125
166
  ).tag(sync=True)
126
167
  show_measurement_tools = traitlets.Bool(
127
- default_value=True, help="Show or hide measurement toolbar"
168
+ default_value=False, help="Show or hide measurement toolbar"
128
169
  ).tag(sync=True)
129
170
  show_measurements_list = traitlets.Bool(
130
- default_value=True, help="Show or hide measurements list panel"
171
+ default_value=False, help="Show or hide measurements list panel"
172
+ ).tag(sync=True)
173
+
174
+ # Debug mode for JavaScript logging
175
+ debug_mode = traitlets.Bool(
176
+ default_value=False, help="Enable or disable JavaScript console logging"
177
+ ).tag(sync=True)
178
+
179
+ # Camera synchronization callbacks
180
+ camera_sync_enabled = traitlets.Bool(
181
+ default_value=False,
182
+ help="Enable or disable camera position synchronization callbacks"
183
+ ).tag(sync=True)
184
+
185
+ # Point picking mode for camera calibration
186
+ point_picking_mode = traitlets.Bool(
187
+ default_value=False,
188
+ help="Enable point picking mode for camera calibration"
189
+ ).tag(sync=True)
190
+
191
+ point_picking_config = traitlets.Dict(
192
+ default_value={},
193
+ help="Configuration for point picking (color, label, etc.)"
194
+ ).tag(sync=True)
195
+
196
+ picked_points = traitlets.List(
197
+ trait=traitlets.Dict(),
198
+ default_value=[],
199
+ help="List of picked 3D points with coordinates and metadata"
200
+ ).tag(sync=True)
201
+
202
+ point_picking_event = traitlets.Dict(
203
+ default_value={},
204
+ help="Last picked point event (triggered on each pick)"
131
205
  ).tag(sync=True)
132
206
 
133
207
  def __init__(self, **kwargs):
@@ -141,14 +215,12 @@ class CesiumWidget(anywidget.AnyWidget):
141
215
  if env_token:
142
216
  kwargs["ion_access_token"] = env_token
143
217
  else:
144
- print("⚠️ No Cesium Ion access token provided.")
145
- print(
146
- " Your access token can be found at: https://ion.cesium.com/tokens"
147
- )
148
- print(" You can set it via:")
149
- print(" - CesiumWidget(ion_access_token='your_token')")
150
- print(" - export CESIUM_ION_TOKEN='your_token' # in your shell")
151
- print(" Note: Some features may not work without a token.")
218
+ logger.warning("No Cesium Ion access token provided.")
219
+ logger.warning("Your access token can be found at: https://ion.cesium.com/tokens")
220
+ logger.warning("You can set it via:")
221
+ logger.warning(" - CesiumWidget(ion_access_token='your_token')")
222
+ logger.warning(" - export CESIUM_ION_TOKEN='your_token' # in your shell")
223
+ logger.warning("Note: Some features may not work without a token.")
152
224
 
153
225
  super().__init__(**kwargs)
154
226
 
@@ -202,7 +274,8 @@ class CesiumWidget(anywidget.AnyWidget):
202
274
 
203
275
  def set_view(
204
276
  self, latitude: float, longitude: float, altitude: float = 400,
205
- heading: float = 0.0, pitch: float = -15.0, roll: float = 0.0
277
+ heading: float = 0.0, pitch: float = -15.0, roll: float = 0.0,
278
+ fov: float = None, aspect_ratio: float = None, near: float = None, far: float = None
206
279
  ):
207
280
  """Set the camera view instantly without animation.
208
281
 
@@ -220,11 +293,24 @@ class CesiumWidget(anywidget.AnyWidget):
220
293
  Camera pitch in degrees (default: -15.0)
221
294
  roll : float, optional
222
295
  Camera roll in degrees (default: 0.0)
296
+ fov : float, optional
297
+ Field of view in degrees. If provided, sets the camera frustum's vertical
298
+ field of view (for PerspectiveFrustum). Default is None (uses camera default).
299
+ aspect_ratio : float, optional
300
+ Aspect ratio (width/height) of the frustum. If provided, sets the camera
301
+ frustum's aspect ratio. Default is None (uses camera default).
302
+ near : float, optional
303
+ Near clipping plane distance in meters. If provided, sets the camera
304
+ frustum's near plane. Default is None (uses camera default).
305
+ far : float, optional
306
+ Far clipping plane distance in meters. If provided, sets the camera
307
+ frustum's far plane. Default is None (uses camera default).
223
308
 
224
309
  Examples
225
310
  --------
226
311
  >>> widget.set_view(48.8566, 2.3522, altitude=1000)
227
312
  >>> widget.set_view(40.7128, -74.0060, heading=90, pitch=-45)
313
+ >>> widget.set_view(46.3712, 4.6355, altitude=310, fov=60, aspect_ratio=1.5)
228
314
  """
229
315
  import time
230
316
  # Update traitlets for state sync
@@ -235,7 +321,7 @@ class CesiumWidget(anywidget.AnyWidget):
235
321
  self.pitch = pitch
236
322
  self.roll = roll
237
323
  # Send command for instant view change
238
- self.camera_command = {
324
+ camera_cmd = {
239
325
  'command': 'setView',
240
326
  'latitude': latitude,
241
327
  'longitude': longitude,
@@ -245,6 +331,16 @@ class CesiumWidget(anywidget.AnyWidget):
245
331
  'roll': roll,
246
332
  'timestamp': time.time()
247
333
  }
334
+ # Add optional frustum parameters if provided
335
+ if fov is not None:
336
+ camera_cmd['fov'] = fov
337
+ if aspect_ratio is not None:
338
+ camera_cmd['aspectRatio'] = aspect_ratio
339
+ if near is not None:
340
+ camera_cmd['near'] = near
341
+ if far is not None:
342
+ camera_cmd['far'] = far
343
+ self.camera_command = camera_cmd
248
344
 
249
345
  def look_at(self, target_latitude: float, target_longitude: float, target_altitude: float = 0,
250
346
  offset_heading: float = 0.0, offset_pitch: float = -45.0, offset_range: float = 1000.0):
@@ -561,6 +657,104 @@ class CesiumWidget(anywidget.AnyWidget):
561
657
  if roll is not None:
562
658
  self.roll = roll
563
659
 
660
+ def set_camera_from_frustum(self, frustum):
661
+ """Set camera parameters from a CameraFrustum object.
662
+
663
+ This is a convenience method for setting the camera to match a frustum's
664
+ position and orientation.
665
+
666
+ Parameters
667
+ ----------
668
+ frustum : CameraFrustum
669
+ A CameraFrustum object from frustum_czml module containing position
670
+ and orientation information.
671
+
672
+ Examples
673
+ --------
674
+ >>> from examples.photo_projection.frustum_czml import CameraFrustum, Position, Orientation
675
+ >>> position = Position.from_cartographic(longitude=-74.0060, latitude=40.7128, altitude=500)
676
+ >>> orientation = Orientation(heading=45, pitch=-30, roll=0)
677
+ >>> frustum = CameraFrustum(
678
+ ... id="my_frustum",
679
+ ... name="My Camera",
680
+ ... position=position,
681
+ ... orientation=orientation,
682
+ ... fov_horizontal=60
683
+ ... )
684
+ >>> widget.set_camera_from_frustum(frustum)
685
+
686
+ See Also
687
+ --------
688
+ set_camera : Set individual camera parameters
689
+ """
690
+ # Extract position (convert to cartographic if needed)
691
+ lon, lat, alt = frustum.position.to_cartographic()
692
+
693
+ # Set camera with frustum's position and orientation
694
+ self.set_camera(
695
+ latitude=lat,
696
+ longitude=lon,
697
+ altitude=alt,
698
+ heading=frustum.orientation.heading,
699
+ pitch=frustum.orientation.pitch,
700
+ roll=frustum.orientation.roll
701
+ )
702
+
703
+ def enable_photorealistic_3d_tiles(self, enabled: bool = True):
704
+ """Enable or disable Google Photorealistic 3D Tiles.
705
+
706
+ This is a convenience method that automatically configures the widget
707
+ with recommended settings for photorealistic tiles.
708
+
709
+ When enabled (True):
710
+ - Activates Google Photorealistic 3D Tiles (global photorealistic base layer)
711
+ - Disables the base globe (recommended to avoid rendering conflicts)
712
+ - Disables terrain (tiles include 3D terrain data)
713
+
714
+ When disabled (False):
715
+ - Deactivates photorealistic tiles
716
+ - Re-enables the base globe
717
+ - Re-enables terrain
718
+
719
+ Parameters
720
+ ----------
721
+ enabled : bool, optional
722
+ Whether to enable (True) or disable (False) photorealistic tiles (default: True)
723
+
724
+ Examples
725
+ --------
726
+ >>> widget = CesiumWidget()
727
+ >>> widget.enable_photorealistic_3d_tiles(True)
728
+ >>> # Fly to a location to see the photorealistic tiles
729
+ >>> widget.fly_to(latitude=40.7128, longitude=-74.0060, altitude=2000)
730
+
731
+ >>> # Disable photorealistic tiles and return to standard globe
732
+ >>> widget.enable_photorealistic_3d_tiles(False)
733
+
734
+ Notes
735
+ -----
736
+ - Photorealistic 3D Tiles require a valid Cesium Ion access token
737
+ - Coverage is available for major cities and populated areas globally
738
+ - Performance may vary based on your hardware and network connection
739
+ - Use the geocoder (magnifying glass icon) to search for locations
740
+
741
+ See Also
742
+ --------
743
+ fly_to : Fly the camera to a specific location
744
+ set_view : Set the camera view instantly
745
+
746
+ References
747
+ ----------
748
+ - CesiumJS Photorealistic 3D Tiles Tutorial:
749
+ https://cesium.com/learn/cesiumjs-learn/cesiumjs-photorealistic-3d-tiles/
750
+ - Google Photorealistic 3D Tiles Documentation:
751
+ https://cesium.com/platform/cesium-ion/content/google-photorealistic-3d-tiles/
752
+ """
753
+ self.enable_photorealistic_tiles = enabled
754
+ self.show_globe = not enabled
755
+ if enabled:
756
+ self.enable_terrain = False
757
+
564
758
  def load_geojson(self, geojson, append=False):
565
759
  """Load GeoJSON data for visualization.
566
760
 
@@ -670,6 +864,47 @@ class CesiumWidget(anywidget.AnyWidget):
670
864
  """
671
865
  self.czml_data = []
672
866
 
867
+ def update_czml_entity(self, entity_id: str, properties: dict):
868
+ """Update properties of an existing CZML entity.
869
+
870
+ This method allows you to dynamically update properties of CZML entities
871
+ without reloading the entire data source. Useful for interactive sliders.
872
+
873
+ Parameters
874
+ ----------
875
+ entity_id : str
876
+ The ID of the entity to update
877
+ properties : dict
878
+ Dictionary of properties to update. Supports:
879
+ - orientation: dict with heading, pitch, roll (in degrees)
880
+ - position: dict with latitude, longitude, altitude
881
+ - Any other CZML property that can be updated
882
+
883
+ Examples
884
+ --------
885
+ Update orientation:
886
+ >>> widget.update_czml_entity('photo1', {
887
+ ... 'orientation': {'heading': 45, 'pitch': -30, 'roll': 0}
888
+ ... })
889
+
890
+ Update position:
891
+ >>> widget.update_czml_entity('photo1', {
892
+ ... 'position': {'latitude': 40.7128, 'longitude': -74.0060, 'altitude': 100}
893
+ ... })
894
+
895
+ Update multiple properties:
896
+ >>> widget.update_czml_entity('photo1', {
897
+ ... 'orientation': {'heading': 90, 'pitch': -15, 'roll': 5},
898
+ ... 'position': {'latitude': 40.7128, 'longitude': -74.0060, 'altitude': 150}
899
+ ... })
900
+ """
901
+ import time
902
+ self.czml_entity_update = {
903
+ 'entity_id': entity_id,
904
+ 'properties': properties,
905
+ 'timestamp': time.time()
906
+ }
907
+
673
908
  def enable_measurement(self, mode: str = "distance"):
674
909
  """Enable a measurement tool.
675
910
 
@@ -774,6 +1009,59 @@ class CesiumWidget(anywidget.AnyWidget):
774
1009
  """Hide the measurements list panel."""
775
1010
  self.show_measurements_list = False
776
1011
 
1012
+ def enable_debug(self):
1013
+ """Enable JavaScript console logging for debugging.
1014
+
1015
+ When enabled, detailed logs will be printed to the browser console
1016
+ showing widget initialization, data loading, camera events, etc.
1017
+
1018
+ Examples
1019
+ --------
1020
+ >>> widget.enable_debug() # Enable logging
1021
+ >>> # ... interact with widget, check browser console for logs
1022
+ >>> widget.disable_debug() # Disable logging when done
1023
+ """
1024
+ self.debug_mode = True
1025
+
1026
+ def disable_debug(self):
1027
+ """Disable JavaScript console logging.
1028
+
1029
+ Examples
1030
+ --------
1031
+ >>> widget.disable_debug()
1032
+ """
1033
+ self.debug_mode = False
1034
+
1035
+ def enable_camera_sync(self):
1036
+ """Enable camera synchronization callbacks.
1037
+
1038
+ When enabled, camera position changes in the Cesium viewer will be
1039
+ synchronized back to the Python model (latitude, longitude, altitude,
1040
+ heading, pitch, roll properties).
1041
+
1042
+ Note: This is disabled by default to avoid unnecessary updates when
1043
+ you don't need to track camera position in Python.
1044
+
1045
+ Examples
1046
+ --------
1047
+ >>> widget.enable_camera_sync()
1048
+ >>> # Move camera in the viewer...
1049
+ >>> print(widget.latitude, widget.longitude) # Updated values
1050
+ """
1051
+ self.camera_sync_enabled = True
1052
+
1053
+ def disable_camera_sync(self):
1054
+ """Disable camera synchronization callbacks.
1055
+
1056
+ When disabled, camera movements in the viewer will not update the
1057
+ Python model properties. This is the default state.
1058
+
1059
+ Examples
1060
+ --------
1061
+ >>> widget.disable_camera_sync()
1062
+ """
1063
+ self.camera_sync_enabled = False
1064
+
777
1065
  def set_atmosphere(self,
778
1066
  brightness_shift=None,
779
1067
  hue_shift=None,
@@ -1011,9 +1299,12 @@ class CesiumWidget(anywidget.AnyWidget):
1011
1299
  clicks, timeline scrubbing, etc.) with a dictionary containing:
1012
1300
 
1013
1301
  - type: Interaction type ('camera_move', 'left_click', 'right_click', 'timeline_scrub')
1014
- - timestamp: ISO 8601 timestamp when interaction occurred
1302
+ - timestamp: ISO 8601 timestamp when interaction occurred (system time)
1015
1303
  - camera: Camera state (latitude, longitude, altitude, heading, pitch, roll)
1016
- - clock: Clock state (current_time, multiplier, is_animating) if timeline enabled
1304
+ - clock: Cesium clock state with:
1305
+ - current_time: ISO 8601 timestamp from Cesium's clock (simulation time)
1306
+ - multiplier: Clock speed multiplier
1307
+ - is_animating: Whether clock is currently animating
1017
1308
  - picked_position: Coordinates of clicked location (if applicable)
1018
1309
  - picked_entity: Information about clicked entity (if applicable)
1019
1310
 
@@ -1027,6 +1318,8 @@ class CesiumWidget(anywidget.AnyWidget):
1027
1318
  >>> def handle_interaction(event):
1028
1319
  ... print(f"Interaction: {event['type']}")
1029
1320
  ... print(f"Camera at: {event['camera']['latitude']}, {event['camera']['longitude']}")
1321
+ ... if event['clock']:
1322
+ ... print(f"Cesium time: {event['clock']['current_time']}")
1030
1323
  ... if 'picked_position' in event:
1031
1324
  ... print(f"Clicked: {event['picked_position']}")
1032
1325
  >>>
@@ -1045,43 +1338,324 @@ class CesiumWidget(anywidget.AnyWidget):
1045
1338
 
1046
1339
  This is useful for troubleshooting widget initialization issues.
1047
1340
  """
1048
- print("=== CesiumWidget Debug Info ===")
1049
- print(f"Widget class: {self.__class__.__name__}")
1050
- print(f"Anywidget version: {anywidget.__version__}")
1341
+ def emit(message: str, *args):
1342
+ line = message % args if args else message
1343
+ print(line)
1344
+ logger.info(message, *args)
1345
+
1346
+ emit("=== CesiumWidget Debug Info ===")
1347
+ emit("Widget class: %s", self.__class__.__name__)
1348
+ emit("Anywidget version: %s", anywidget.__version__)
1051
1349
 
1052
1350
  # Check file paths (note: after widget instantiation, _esm and _css contain file contents)
1053
1351
  esm_path = pathlib.Path(__file__).parent / "index.js"
1054
1352
  css_path = pathlib.Path(__file__).parent / "styles.css"
1055
1353
 
1056
- print("\nJavaScript file:")
1057
- print(f" Path: {esm_path}")
1058
- print(f" Exists: {esm_path.exists()}")
1354
+ emit("JavaScript file:")
1355
+ emit(" Path: %s", esm_path)
1356
+ emit(" Exists: %s", esm_path.exists())
1059
1357
  if esm_path.exists():
1060
- print(f" Size: {esm_path.stat().st_size} bytes")
1358
+ emit(" Size: %d bytes", esm_path.stat().st_size)
1061
1359
  elif isinstance(self._esm, str):
1062
- print(f" Content loaded: {len(self._esm)} chars")
1360
+ emit(" Content loaded: %d chars", len(self._esm))
1063
1361
 
1064
- print("\nCSS file:")
1065
- print(f" Path: {css_path}")
1066
- print(f" Exists: {css_path.exists()}")
1362
+ emit("CSS file:")
1363
+ emit(" Path: %s", css_path)
1364
+ emit(" Exists: %s", css_path.exists())
1067
1365
  if css_path.exists():
1068
- print(f" Size: {css_path.stat().st_size} bytes")
1366
+ emit(" Size: %d bytes", css_path.stat().st_size)
1069
1367
  elif isinstance(self._css, str):
1070
- print(f" Content loaded: {len(self._css)} chars")
1368
+ emit(" Content loaded: %d chars", len(self._css))
1071
1369
 
1072
1370
  # Show current state
1073
- print("\nCurrent state:")
1074
- print(f" Position: ({self.latitude:.4f}°, {self.longitude:.4f}°)")
1075
- print(f" Altitude: {self.altitude:.2f}m")
1076
- print(f" Height: {self.height}")
1077
- print(f" Terrain: {self.enable_terrain}")
1078
- print(f" Lighting: {self.enable_lighting}")
1079
-
1080
- print("\n💡 Debugging tips:")
1081
- print(" 1. Open browser DevTools (F12) and check the Console tab for errors")
1082
- print(" 2. Check Network tab to see if CesiumJS CDN loads successfully")
1083
- print(
1084
- " 3. Try: widget = CesiumWidget(enable_terrain=False) to avoid async terrain loading"
1085
- )
1086
- print(" 4. Ensure you're using JupyterLab 4.0+ or Jupyter Notebook 7.0+")
1087
- print(" 5. Check if anywidget is properly installed: pip show anywidget")
1371
+ emit("Current state:")
1372
+ emit(" Position: (%.4f°, %.4f°)", self.latitude, self.longitude)
1373
+ emit(" Altitude: %.2fm", self.altitude)
1374
+ emit(" Height: %s", self.height)
1375
+ emit(" Terrain: %s", self.enable_terrain)
1376
+ emit(" Lighting: %s", self.enable_lighting)
1377
+
1378
+ emit("💡 Debugging tips:")
1379
+ emit(" 1. Open browser DevTools (F12) and check the Console tab for errors")
1380
+ emit(" 2. Check Network tab to see if CesiumJS CDN loads successfully")
1381
+ emit(" 3. Try: widget = CesiumWidget(enable_terrain=False) to avoid async terrain loading")
1382
+ emit(" 4. Ensure you're using JupyterLab 4.0+ or Jupyter Notebook 7.0+")
1383
+ emit(" 5. Check if anywidget is properly installed: pip show anywidget")
1384
+
1385
+ # ============= Point Picking Methods for Camera Calibration =============
1386
+
1387
+ def start_point_picking(
1388
+ self,
1389
+ color: tuple = (255, 0, 0, 255),
1390
+ label_prefix: str = "GCP",
1391
+ point_id_prefix: str = "",
1392
+ continuous: bool = True,
1393
+ ):
1394
+ """Start point picking mode for camera calibration.
1395
+
1396
+ When enabled, clicking on the 3D scene will record the 3D coordinates
1397
+ of the clicked point. Points are added to the `picked_points` list.
1398
+
1399
+ Parameters
1400
+ ----------
1401
+ color : tuple, optional
1402
+ RGBA color for the point marker (0-255 each). Default is red.
1403
+ label_prefix : str, optional
1404
+ Prefix for auto-generated labels (e.g., "GCP" -> "GCP_1", "GCP_2", ...)
1405
+ Default is "GCP".
1406
+ point_id_prefix : str, optional
1407
+ Prefix for auto-generated point IDs. If empty, uses label_prefix.
1408
+ continuous : bool, optional
1409
+ If True (default), allows picking multiple points with auto-incrementing IDs.
1410
+ If False, picks only one point and then stops.
1411
+
1412
+ Examples
1413
+ --------
1414
+ Pick multiple points (default):
1415
+ >>> widget.start_point_picking(label_prefix="GCP")
1416
+ >>> # Click multiple times on the scene to pick points
1417
+ >>> # Points will be labeled GCP_1, GCP_2, GCP_3, etc.
1418
+ >>> widget.stop_point_picking()
1419
+ >>> print(widget.picked_points)
1420
+
1421
+ Pick a single point:
1422
+ >>> widget.start_point_picking(label_prefix="P1", continuous=False)
1423
+ >>> # Click once - picking stops automatically
1424
+ """
1425
+ import time
1426
+
1427
+ if not point_id_prefix:
1428
+ point_id_prefix = label_prefix
1429
+
1430
+ self.point_picking_config = {
1431
+ 'color': list(color),
1432
+ 'label_prefix': label_prefix,
1433
+ 'point_id_prefix': point_id_prefix,
1434
+ 'continuous': continuous,
1435
+ 'timestamp': time.time(),
1436
+ }
1437
+ self.point_picking_mode = True
1438
+
1439
+ mode_desc = "continuous" if continuous else "single-point"
1440
+ logger.info("🎯 Point picking mode enabled (%s). Click on the 3D scene to pick points.", mode_desc)
1441
+ logger.info(" Points will be labeled: %s_1, %s_2, ...", label_prefix, label_prefix)
1442
+ logger.info(" Call widget.stop_point_picking() when done.")
1443
+
1444
+ def stop_point_picking(self):
1445
+ """Stop point picking mode.
1446
+
1447
+ Examples
1448
+ --------
1449
+ >>> widget.stop_point_picking()
1450
+ """
1451
+ self.point_picking_mode = False
1452
+ self.point_picking_config = {}
1453
+ logger.info("🛑 Point picking mode disabled.")
1454
+
1455
+ def clear_picked_points(self):
1456
+ """Clear all picked points.
1457
+
1458
+ This removes all picked points from the list and their visual markers
1459
+ from the 3D scene.
1460
+
1461
+ Examples
1462
+ --------
1463
+ >>> widget.clear_picked_points()
1464
+ """
1465
+ self.picked_points = []
1466
+ logger.info("🗑️ Cleared all picked points.")
1467
+
1468
+ def remove_picked_point(self, point_id: str):
1469
+ """Remove a specific picked point by its ID.
1470
+
1471
+ Parameters
1472
+ ----------
1473
+ point_id : str
1474
+ The ID of the point to remove
1475
+
1476
+ Examples
1477
+ --------
1478
+ >>> widget.remove_picked_point("P1")
1479
+ """
1480
+ current_points = list(self.picked_points)
1481
+ self.picked_points = [p for p in current_points if p.get('id') != point_id]
1482
+ logger.info("🗑️ Removed point %s.", point_id)
1483
+
1484
+ def on_point_picked(self, callback):
1485
+ """Register a callback for point picking events.
1486
+
1487
+ The callback will be called each time a point is picked in the 3D scene.
1488
+
1489
+ Parameters
1490
+ ----------
1491
+ callback : callable
1492
+ Function to call with picked point data:
1493
+ callback({'id': str, 'latitude': float, 'longitude': float,
1494
+ 'altitude_wgs84': float, 'altitude_msl': float,
1495
+ 'color': list, 'label': str})
1496
+
1497
+ Returns
1498
+ -------
1499
+ callable
1500
+ The wrapper function (can be used to unobserve)
1501
+
1502
+ Examples
1503
+ --------
1504
+ >>> def handle_pick(point):
1505
+ ... print(f"Picked {point['id']} at {point['latitude']}, {point['longitude']}")
1506
+ >>>
1507
+ >>> widget.on_point_picked(handle_pick)
1508
+ """
1509
+ def wrapper(change):
1510
+ event_data = change['new']
1511
+ if event_data and event_data.get('id'):
1512
+ callback(event_data)
1513
+
1514
+ self.observe(wrapper, names='point_picking_event')
1515
+ return wrapper
1516
+
1517
+ # ============= Clock/Timeline Control Methods =============
1518
+
1519
+ def set_time(self, time):
1520
+ """Set the current time on the Cesium timeline.
1521
+
1522
+ Parameters
1523
+ ----------
1524
+ time : str or datetime
1525
+ Time to set. Can be:
1526
+ - ISO 8601 string (e.g., '2023-08-01T14:30:00Z')
1527
+ - datetime object (will be converted to ISO 8601)
1528
+
1529
+ Examples
1530
+ --------
1531
+ >>> from datetime import datetime
1532
+ >>> widget.set_time('2023-08-01T14:30:00Z')
1533
+ >>> widget.set_time(datetime(2023, 8, 1, 14, 30))
1534
+ """
1535
+ from datetime import datetime as dt
1536
+
1537
+ if isinstance(time, dt):
1538
+ time_str = time.isoformat() + 'Z'
1539
+ else:
1540
+ time_str = str(time)
1541
+
1542
+ self.current_time = time_str
1543
+ self.clock_command = {
1544
+ 'command': 'setTime',
1545
+ 'time': time_str,
1546
+ 'timestamp': __import__('time').time()
1547
+ }
1548
+
1549
+ def play(self):
1550
+ """Start the timeline animation.
1551
+
1552
+ Examples
1553
+ --------
1554
+ >>> widget.play()
1555
+ """
1556
+ self.should_animate = True
1557
+ self.clock_command = {
1558
+ 'command': 'play',
1559
+ 'timestamp': __import__('time').time()
1560
+ }
1561
+
1562
+ def pause(self):
1563
+ """Pause the timeline animation.
1564
+
1565
+ Examples
1566
+ --------
1567
+ >>> widget.pause()
1568
+ """
1569
+ self.should_animate = False
1570
+ self.clock_command = {
1571
+ 'command': 'pause',
1572
+ 'timestamp': __import__('time').time()
1573
+ }
1574
+
1575
+ def set_speed(self, multiplier: float = 1.0):
1576
+ """Set the animation speed multiplier.
1577
+
1578
+ Parameters
1579
+ ----------
1580
+ multiplier : float, optional
1581
+ Speed multiplier (default: 1.0)
1582
+ - 1.0 = real-time
1583
+ - 60.0 = 60x speed (1 second = 1 minute)
1584
+ - 0.5 = half speed
1585
+
1586
+ Examples
1587
+ --------
1588
+ >>> widget.set_speed(60) # 60x speed
1589
+ >>> widget.set_speed(0.5) # Half speed
1590
+ """
1591
+ self.clock_multiplier = multiplier
1592
+ self.clock_command = {
1593
+ 'command': 'setMultiplier',
1594
+ 'multiplier': multiplier,
1595
+ 'timestamp': __import__('time').time()
1596
+ }
1597
+
1598
+ def set_clock_range(self, start_time, stop_time):
1599
+ """Set the time range for the clock/timeline.
1600
+
1601
+ Parameters
1602
+ ----------
1603
+ start_time : str or datetime
1604
+ Start time of the range
1605
+ stop_time : str or datetime
1606
+ Stop time of the range
1607
+
1608
+ Examples
1609
+ --------
1610
+ >>> from datetime import datetime
1611
+ >>> widget.set_clock_range(
1612
+ ... datetime(2023, 8, 1, 0, 0),
1613
+ ... datetime(2023, 8, 2, 0, 0)
1614
+ ... )
1615
+ """
1616
+ from datetime import datetime as dt
1617
+
1618
+ if isinstance(start_time, dt):
1619
+ start_str = start_time.isoformat() + 'Z'
1620
+ else:
1621
+ start_str = str(start_time)
1622
+
1623
+ if isinstance(stop_time, dt):
1624
+ stop_str = stop_time.isoformat() + 'Z'
1625
+ else:
1626
+ stop_str = str(stop_time)
1627
+
1628
+ self.clock_command = {
1629
+ 'command': 'setRange',
1630
+ 'startTime': start_str,
1631
+ 'stopTime': stop_str,
1632
+ 'timestamp': __import__('time').time()
1633
+ }
1634
+
1635
+ def get_picked_points_as_correspondences(self):
1636
+ """Convert picked points to CorrespondencePoint objects.
1637
+
1638
+ This is useful for camera calibration workflows where you need
1639
+ to combine 2D photo points with 3D scene points.
1640
+
1641
+ Returns
1642
+ -------
1643
+ list
1644
+ List of dictionaries with point data ready for camera calibration
1645
+
1646
+ Examples
1647
+ --------
1648
+ >>> points_3d = widget.get_picked_points_as_correspondences()
1649
+ >>> # Combine with 2D points from photo selector
1650
+ """
1651
+ return [
1652
+ {
1653
+ 'id': p.get('id', f"point_{i}"),
1654
+ 'latitude': p.get('latitude'),
1655
+ 'longitude': p.get('longitude'),
1656
+ 'altitude_msl': p.get('altitude_msl'),
1657
+ 'color': tuple(p.get('color', [255, 0, 0, 255])),
1658
+ 'description': p.get('label', ''),
1659
+ }
1660
+ for i, p in enumerate(self.picked_points)
1661
+ ]