cesiumjs-anywidget 0.6.0__py3-none-any.whl → 0.8.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,10 @@ 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"
131
172
  ).tag(sync=True)
132
173
 
133
174
  # Debug mode for JavaScript logging
@@ -141,6 +182,28 @@ class CesiumWidget(anywidget.AnyWidget):
141
182
  help="Enable or disable camera position synchronization callbacks"
142
183
  ).tag(sync=True)
143
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)"
205
+ ).tag(sync=True)
206
+
144
207
  def __init__(self, **kwargs):
145
208
  """Initialize the CesiumWidget.
146
209
 
@@ -152,14 +215,12 @@ class CesiumWidget(anywidget.AnyWidget):
152
215
  if env_token:
153
216
  kwargs["ion_access_token"] = env_token
154
217
  else:
155
- print("⚠️ No Cesium Ion access token provided.")
156
- print(
157
- " Your access token can be found at: https://ion.cesium.com/tokens"
158
- )
159
- print(" You can set it via:")
160
- print(" - CesiumWidget(ion_access_token='your_token')")
161
- print(" - export CESIUM_ION_TOKEN='your_token' # in your shell")
162
- 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.")
163
224
 
164
225
  super().__init__(**kwargs)
165
226
 
@@ -213,7 +274,8 @@ class CesiumWidget(anywidget.AnyWidget):
213
274
 
214
275
  def set_view(
215
276
  self, latitude: float, longitude: float, altitude: float = 400,
216
- 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
217
279
  ):
218
280
  """Set the camera view instantly without animation.
219
281
 
@@ -231,11 +293,24 @@ class CesiumWidget(anywidget.AnyWidget):
231
293
  Camera pitch in degrees (default: -15.0)
232
294
  roll : float, optional
233
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).
234
308
 
235
309
  Examples
236
310
  --------
237
311
  >>> widget.set_view(48.8566, 2.3522, altitude=1000)
238
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)
239
314
  """
240
315
  import time
241
316
  # Update traitlets for state sync
@@ -246,7 +321,7 @@ class CesiumWidget(anywidget.AnyWidget):
246
321
  self.pitch = pitch
247
322
  self.roll = roll
248
323
  # Send command for instant view change
249
- self.camera_command = {
324
+ camera_cmd = {
250
325
  'command': 'setView',
251
326
  'latitude': latitude,
252
327
  'longitude': longitude,
@@ -256,6 +331,16 @@ class CesiumWidget(anywidget.AnyWidget):
256
331
  'roll': roll,
257
332
  'timestamp': time.time()
258
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
259
344
 
260
345
  def look_at(self, target_latitude: float, target_longitude: float, target_altitude: float = 0,
261
346
  offset_heading: float = 0.0, offset_pitch: float = -45.0, offset_range: float = 1000.0):
@@ -572,6 +657,104 @@ class CesiumWidget(anywidget.AnyWidget):
572
657
  if roll is not None:
573
658
  self.roll = roll
574
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
+
575
758
  def load_geojson(self, geojson, append=False):
576
759
  """Load GeoJSON data for visualization.
577
760
 
@@ -681,6 +864,47 @@ class CesiumWidget(anywidget.AnyWidget):
681
864
  """
682
865
  self.czml_data = []
683
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
+
684
908
  def enable_measurement(self, mode: str = "distance"):
685
909
  """Enable a measurement tool.
686
910
 
@@ -1075,9 +1299,12 @@ class CesiumWidget(anywidget.AnyWidget):
1075
1299
  clicks, timeline scrubbing, etc.) with a dictionary containing:
1076
1300
 
1077
1301
  - type: Interaction type ('camera_move', 'left_click', 'right_click', 'timeline_scrub')
1078
- - timestamp: ISO 8601 timestamp when interaction occurred
1302
+ - timestamp: ISO 8601 timestamp when interaction occurred (system time)
1079
1303
  - camera: Camera state (latitude, longitude, altitude, heading, pitch, roll)
1080
- - 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
1081
1308
  - picked_position: Coordinates of clicked location (if applicable)
1082
1309
  - picked_entity: Information about clicked entity (if applicable)
1083
1310
 
@@ -1091,6 +1318,8 @@ class CesiumWidget(anywidget.AnyWidget):
1091
1318
  >>> def handle_interaction(event):
1092
1319
  ... print(f"Interaction: {event['type']}")
1093
1320
  ... print(f"Camera at: {event['camera']['latitude']}, {event['camera']['longitude']}")
1321
+ ... if event['clock']:
1322
+ ... print(f"Cesium time: {event['clock']['current_time']}")
1094
1323
  ... if 'picked_position' in event:
1095
1324
  ... print(f"Clicked: {event['picked_position']}")
1096
1325
  >>>
@@ -1109,43 +1338,324 @@ class CesiumWidget(anywidget.AnyWidget):
1109
1338
 
1110
1339
  This is useful for troubleshooting widget initialization issues.
1111
1340
  """
1112
- print("=== CesiumWidget Debug Info ===")
1113
- print(f"Widget class: {self.__class__.__name__}")
1114
- 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__)
1115
1349
 
1116
1350
  # Check file paths (note: after widget instantiation, _esm and _css contain file contents)
1117
1351
  esm_path = pathlib.Path(__file__).parent / "index.js"
1118
1352
  css_path = pathlib.Path(__file__).parent / "styles.css"
1119
1353
 
1120
- print("\nJavaScript file:")
1121
- print(f" Path: {esm_path}")
1122
- print(f" Exists: {esm_path.exists()}")
1354
+ emit("JavaScript file:")
1355
+ emit(" Path: %s", esm_path)
1356
+ emit(" Exists: %s", esm_path.exists())
1123
1357
  if esm_path.exists():
1124
- print(f" Size: {esm_path.stat().st_size} bytes")
1358
+ emit(" Size: %d bytes", esm_path.stat().st_size)
1125
1359
  elif isinstance(self._esm, str):
1126
- print(f" Content loaded: {len(self._esm)} chars")
1360
+ emit(" Content loaded: %d chars", len(self._esm))
1127
1361
 
1128
- print("\nCSS file:")
1129
- print(f" Path: {css_path}")
1130
- print(f" Exists: {css_path.exists()}")
1362
+ emit("CSS file:")
1363
+ emit(" Path: %s", css_path)
1364
+ emit(" Exists: %s", css_path.exists())
1131
1365
  if css_path.exists():
1132
- print(f" Size: {css_path.stat().st_size} bytes")
1366
+ emit(" Size: %d bytes", css_path.stat().st_size)
1133
1367
  elif isinstance(self._css, str):
1134
- print(f" Content loaded: {len(self._css)} chars")
1368
+ emit(" Content loaded: %d chars", len(self._css))
1135
1369
 
1136
1370
  # Show current state
1137
- print("\nCurrent state:")
1138
- print(f" Position: ({self.latitude:.4f}°, {self.longitude:.4f}°)")
1139
- print(f" Altitude: {self.altitude:.2f}m")
1140
- print(f" Height: {self.height}")
1141
- print(f" Terrain: {self.enable_terrain}")
1142
- print(f" Lighting: {self.enable_lighting}")
1143
-
1144
- print("\n💡 Debugging tips:")
1145
- print(" 1. Open browser DevTools (F12) and check the Console tab for errors")
1146
- print(" 2. Check Network tab to see if CesiumJS CDN loads successfully")
1147
- print(
1148
- " 3. Try: widget = CesiumWidget(enable_terrain=False) to avoid async terrain loading"
1149
- )
1150
- print(" 4. Ensure you're using JupyterLab 4.0+ or Jupyter Notebook 7.0+")
1151
- 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
+ ]