luminarycloud 0.19.0__py3-none-any.whl → 0.22.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.
Files changed (110) hide show
  1. luminarycloud/__init__.py +5 -1
  2. luminarycloud/_client/client.py +7 -0
  3. luminarycloud/_client/http_client.py +10 -8
  4. luminarycloud/_feature_flag.py +22 -0
  5. luminarycloud/_helpers/_create_simulation.py +7 -2
  6. luminarycloud/_helpers/_upload_mesh.py +1 -0
  7. luminarycloud/_helpers/_wait_for_mesh.py +6 -5
  8. luminarycloud/_helpers/_wait_for_simulation.py +3 -3
  9. luminarycloud/_helpers/download.py +3 -1
  10. luminarycloud/_helpers/pagination.py +62 -0
  11. luminarycloud/_helpers/proto_decorator.py +13 -5
  12. luminarycloud/_helpers/upload.py +18 -12
  13. luminarycloud/_proto/api/v0/luminarycloud/feature_flag/feature_flag_pb2.py +55 -0
  14. luminarycloud/_proto/api/v0/luminarycloud/feature_flag/feature_flag_pb2.pyi +52 -0
  15. luminarycloud/_proto/api/v0/luminarycloud/feature_flag/feature_flag_pb2_grpc.py +72 -0
  16. luminarycloud/_proto/api/v0/luminarycloud/feature_flag/feature_flag_pb2_grpc.pyi +35 -0
  17. luminarycloud/_proto/api/v0/luminarycloud/geometry/geometry_pb2.py +168 -124
  18. luminarycloud/_proto/api/v0/luminarycloud/geometry/geometry_pb2.pyi +133 -4
  19. luminarycloud/_proto/api/v0/luminarycloud/geometry/geometry_pb2_grpc.py +66 -0
  20. luminarycloud/_proto/api/v0/luminarycloud/geometry/geometry_pb2_grpc.pyi +20 -0
  21. luminarycloud/_proto/api/v0/luminarycloud/inference/inference_pb2.py +8 -8
  22. luminarycloud/_proto/api/v0/luminarycloud/inference/inference_pb2.pyi +5 -5
  23. luminarycloud/_proto/api/v0/luminarycloud/mesh/mesh_pb2.py +74 -73
  24. luminarycloud/_proto/api/v0/luminarycloud/mesh/mesh_pb2.pyi +17 -3
  25. luminarycloud/_proto/api/v0/luminarycloud/physics_ai/physics_ai_pb2.py +96 -25
  26. luminarycloud/_proto/api/v0/luminarycloud/physics_ai/physics_ai_pb2.pyi +235 -1
  27. luminarycloud/_proto/api/v0/luminarycloud/physics_ai/physics_ai_pb2_grpc.py +34 -0
  28. luminarycloud/_proto/api/v0/luminarycloud/physics_ai/physics_ai_pb2_grpc.pyi +12 -0
  29. luminarycloud/_proto/api/v0/luminarycloud/project/project_pb2.py +16 -16
  30. luminarycloud/_proto/api/v0/luminarycloud/project/project_pb2.pyi +7 -3
  31. luminarycloud/_proto/api/v0/luminarycloud/simulation/simulation_pb2.py +97 -61
  32. luminarycloud/_proto/api/v0/luminarycloud/simulation/simulation_pb2.pyi +77 -4
  33. luminarycloud/_proto/api/v0/luminarycloud/simulation/simulation_pb2_grpc.py +34 -0
  34. luminarycloud/_proto/api/v0/luminarycloud/simulation/simulation_pb2_grpc.pyi +12 -0
  35. luminarycloud/_proto/api/v0/luminarycloud/simulation_template/simulation_template_pb2.py +33 -31
  36. luminarycloud/_proto/api/v0/luminarycloud/simulation_template/simulation_template_pb2.pyi +23 -2
  37. luminarycloud/_proto/api/v0/luminarycloud/vis/vis_pb2.py +126 -27
  38. luminarycloud/_proto/api/v0/luminarycloud/vis/vis_pb2.pyi +183 -0
  39. luminarycloud/_proto/api/v0/luminarycloud/vis/vis_pb2_grpc.py +99 -0
  40. luminarycloud/_proto/api/v0/luminarycloud/vis/vis_pb2_grpc.pyi +30 -0
  41. luminarycloud/_proto/assistant/assistant_pb2.py +74 -41
  42. luminarycloud/_proto/assistant/assistant_pb2.pyi +64 -2
  43. luminarycloud/_proto/assistant/assistant_pb2_grpc.py +33 -0
  44. luminarycloud/_proto/assistant/assistant_pb2_grpc.pyi +10 -0
  45. luminarycloud/_proto/base/base_pb2.py +20 -7
  46. luminarycloud/_proto/base/base_pb2.pyi +38 -0
  47. luminarycloud/_proto/cad/shape_pb2.py +39 -19
  48. luminarycloud/_proto/cad/shape_pb2.pyi +86 -34
  49. luminarycloud/_proto/cad/transformation_pb2.py +60 -16
  50. luminarycloud/_proto/cad/transformation_pb2.pyi +138 -32
  51. luminarycloud/_proto/client/simulation_pb2.py +501 -348
  52. luminarycloud/_proto/client/simulation_pb2.pyi +607 -11
  53. luminarycloud/_proto/geometry/geometry_pb2.py +77 -63
  54. luminarycloud/_proto/geometry/geometry_pb2.pyi +42 -3
  55. luminarycloud/_proto/hexmesh/hexmesh_pb2.py +24 -18
  56. luminarycloud/_proto/hexmesh/hexmesh_pb2.pyi +23 -2
  57. luminarycloud/_proto/inferenceservice/inferenceservice_pb2.py +10 -10
  58. luminarycloud/_proto/inferenceservice/inferenceservice_pb2.pyi +5 -5
  59. luminarycloud/_proto/physicsaitrainingservice/physicsaitrainingservice_pb2.py +29 -0
  60. luminarycloud/_proto/physicsaitrainingservice/physicsaitrainingservice_pb2.pyi +7 -0
  61. luminarycloud/_proto/physicsaitrainingservice/physicsaitrainingservice_pb2_grpc.py +70 -0
  62. luminarycloud/_proto/physicsaitrainingservice/physicsaitrainingservice_pb2_grpc.pyi +30 -0
  63. luminarycloud/_proto/quantity/quantity_options_pb2.py +6 -6
  64. luminarycloud/_proto/quantity/quantity_options_pb2.pyi +10 -1
  65. luminarycloud/_proto/quantity/quantity_pb2.py +176 -167
  66. luminarycloud/_proto/quantity/quantity_pb2.pyi +11 -5
  67. luminarycloud/enum/__init__.py +1 -0
  68. luminarycloud/enum/gpu_type.py +2 -0
  69. luminarycloud/enum/quantity_type.py +9 -0
  70. luminarycloud/enum/vis_enums.py +23 -3
  71. luminarycloud/exceptions.py +7 -1
  72. luminarycloud/feature_modification.py +45 -35
  73. luminarycloud/geometry.py +107 -9
  74. luminarycloud/geometry_version.py +57 -3
  75. luminarycloud/mesh.py +1 -2
  76. luminarycloud/meshing/mesh_generation_params.py +8 -8
  77. luminarycloud/params/enum/_enum_wrappers.py +562 -30
  78. luminarycloud/params/simulation/adaptive_mesh_refinement_.py +4 -0
  79. luminarycloud/params/simulation/material/material_solid_.py +15 -1
  80. luminarycloud/params/simulation/physics/__init__.py +0 -1
  81. luminarycloud/params/simulation/physics/periodic_pair_.py +12 -31
  82. luminarycloud/physics_ai/architectures.py +58 -0
  83. luminarycloud/physics_ai/inference.py +13 -13
  84. luminarycloud/physics_ai/solution.py +3 -1
  85. luminarycloud/physics_ai/training_jobs.py +37 -0
  86. luminarycloud/pipelines/__init__.py +11 -3
  87. luminarycloud/pipelines/api.py +248 -16
  88. luminarycloud/pipelines/arguments.py +15 -0
  89. luminarycloud/pipelines/core.py +113 -96
  90. luminarycloud/pipelines/{operators.py → stages.py} +96 -39
  91. luminarycloud/project.py +15 -47
  92. luminarycloud/simulation.py +69 -5
  93. luminarycloud/simulation_param.py +0 -9
  94. luminarycloud/simulation_template.py +2 -1
  95. luminarycloud/types/matrix3.py +12 -0
  96. luminarycloud/vis/__init__.py +17 -0
  97. luminarycloud/vis/data_extraction.py +20 -4
  98. luminarycloud/vis/interactive_report.py +110 -0
  99. luminarycloud/vis/interactive_scene.py +29 -2
  100. luminarycloud/vis/report.py +252 -0
  101. luminarycloud/vis/visualization.py +127 -5
  102. luminarycloud/volume_selection.py +132 -69
  103. {luminarycloud-0.19.0.dist-info → luminarycloud-0.22.0.dist-info}/METADATA +1 -1
  104. {luminarycloud-0.19.0.dist-info → luminarycloud-0.22.0.dist-info}/RECORD +105 -97
  105. luminarycloud/params/simulation/physics/periodic_pair/__init__.py +0 -2
  106. luminarycloud/params/simulation/physics/periodic_pair/periodicity_type/__init__.py +0 -2
  107. luminarycloud/params/simulation/physics/periodic_pair/periodicity_type/rotational_periodicity_.py +0 -31
  108. luminarycloud/params/simulation/physics/periodic_pair/periodicity_type/translational_periodicity_.py +0 -29
  109. luminarycloud/params/simulation/physics/periodic_pair/periodicity_type_.py +0 -25
  110. {luminarycloud-0.19.0.dist-info → luminarycloud-0.22.0.dist-info}/WHEEL +0 -0
@@ -0,0 +1,252 @@
1
+ import json
2
+ import os
3
+ from typing import TYPE_CHECKING
4
+
5
+ from .visualization import Scene, RenderOutput, range_query
6
+ from .data_extraction import DataExtractor, ExtractOutput
7
+ from ..enum import RenderStatusType, ExtractStatusType, FieldAssociation
8
+ from ..solution import Solution
9
+ from .vis_util import _InternalToken, _get_status
10
+ from time import sleep
11
+ from .._proto.api.v0.luminarycloud.vis import vis_pb2
12
+ from .._client import get_default_client
13
+ from .._helpers._get_project_id import _get_project_id
14
+ import luminarycloud.enum.quantity_type as quantity_type
15
+
16
+ if TYPE_CHECKING:
17
+ from .interactive_report import InteractiveReport
18
+
19
+
20
+ # TODO Will/Matt: this could be something like what we store in the DB
21
+ # A report can contain a list of report entries that reference post proc.
22
+ # extracts + styling info for how they should be displayed
23
+ class ReportEntry:
24
+ """
25
+ A single entry in a report, containing references to extracts and metadata.
26
+ Each extract can have multiple pieces of data (e.g. multiple images for a
27
+ RenderOutput, or multiple curves for an ExtractOutput). Metadata is a
28
+ dictionary of key/value pairs that can be used to store additional
29
+ information about the report entry. Typically, the metadata would include
30
+ things like simulation ID, lift/drag values, and scalar ranges for each
31
+ solution. The metadata is used to filter and sort data in the ensemble
32
+ widget.
33
+ """
34
+
35
+ def __init__(
36
+ self, project_id: str, extract_ids: list[str] = [], metadata: dict[str, str | float] = {}
37
+ ) -> None:
38
+ self._project_id = project_id
39
+ self._extract_ids = extract_ids
40
+ self._extracts: list[ExtractOutput | RenderOutput] = []
41
+ self._metadata = metadata
42
+ self._statuses = statuses = [RenderStatusType.INVALID] * len(self._extract_ids)
43
+
44
+ def to_dict(self) -> dict:
45
+ return {
46
+ "project_id": self._project_id,
47
+ "extract_ids": self._extract_ids,
48
+ "metadata": self._metadata,
49
+ }
50
+
51
+ @classmethod
52
+ def from_dict(cls, data: dict) -> "ReportEntry":
53
+ return cls(
54
+ project_id=data["project_id"],
55
+ extract_ids=data.get("extract_ids", []),
56
+ metadata=data.get("metadata", {}),
57
+ )
58
+
59
+ def refresh_statuses(self) -> None:
60
+ for i, eid in enumerate(self._extract_ids):
61
+ self._statuses[i] = _get_status(self._project_id, eid)
62
+
63
+ def is_complete(self) -> bool:
64
+ self.refresh_statuses()
65
+ return all(
66
+ (status == RenderStatusType.COMPLETED or status == RenderStatusType.FAILED)
67
+ for status in self._statuses
68
+ )
69
+
70
+ # Download all extracts for this report entry
71
+ def download_extracts(self) -> None:
72
+ self._extracts = []
73
+ for eid in self._extract_ids:
74
+ status = _get_status(self._project_id, eid)
75
+ if status != ExtractStatusType.COMPLETED:
76
+ raise Exception(f"Extract {eid} is not complete")
77
+ req = vis_pb2.DownloadExtractRequest()
78
+ req.extract_id = eid
79
+ req.project_id = self._project_id
80
+ # TODO: This is a bit awkward in that we download the extract to figure out what type
81
+ # it is, but this is just a temporary thing, later we'll have a report DB table that
82
+ # stores the extracts for a report and their types, etc.
83
+ res: vis_pb2.DownloadExtractResponse = get_default_client().DownloadExtract(req)
84
+ extract = (
85
+ ExtractOutput(_InternalToken())
86
+ if res.HasField("line_data")
87
+ else RenderOutput(_InternalToken())
88
+ )
89
+ extract._set_data(eid, self._project_id, eid, eid, status)
90
+ self._extracts.append(extract)
91
+
92
+
93
+ class Report:
94
+ """
95
+ A report containing multiple report entries. There is support for
96
+ serialization and deserialization to/from JSON since generating the extracts
97
+ and metadata can be expensive.
98
+ """
99
+
100
+ def __init__(self, entries: list[ReportEntry]):
101
+ self._entries = entries
102
+
103
+ def to_dict(self) -> dict:
104
+ return {"entries": [entry.to_dict() for entry in self._entries]}
105
+
106
+ @classmethod
107
+ def from_dict(cls, data: dict) -> "Report":
108
+ entries = [ReportEntry.from_dict(e) for e in data.get("entries", [])]
109
+ return cls(entries)
110
+
111
+ def _check_status(self) -> bool:
112
+ """Check the status of all ReportEntries and their extracts, grouped by entry."""
113
+ still_pending = False
114
+ print("\n" + "=" * 60)
115
+ print("STATUS CHECK".center(60))
116
+ print("=" * 60)
117
+
118
+ if not self._entries:
119
+ raise RuntimeError("No report entries to check status.")
120
+
121
+ print(f"{'Entry':<8} | {'Extract ID':<20} | {'Status':<15}")
122
+ print("-" * 60)
123
+ for idx, entry in enumerate(self._entries):
124
+ entry.refresh_statuses()
125
+ for eid, status in zip(entry._extract_ids, entry._statuses):
126
+ if status != RenderStatusType.COMPLETED and status != RenderStatusType.FAILED:
127
+ still_pending = True
128
+ print(f"{idx:<8} | {eid:<20} | {status.name:<15}")
129
+ print("=" * 60)
130
+ return still_pending
131
+
132
+ def wait_for_completion(self):
133
+ """Wait for all report entries' extracts to complete."""
134
+ if not self._entries:
135
+ raise RuntimeError("No report entries to wait for.")
136
+ while self._check_status():
137
+ sleep(5)
138
+ print("All report entries' extracts have completed.")
139
+
140
+ def interact(self) -> "InteractiveReport":
141
+ from .interactive_report import InteractiveReport
142
+
143
+ if not self._check_status:
144
+ raise ValueError("Error: report entries are still pending")
145
+ return InteractiveReport(self._entries)
146
+
147
+
148
+ class ReportGenerator:
149
+ """
150
+ A helper for generating reports from multiple solutions, scenes, data extractors and
151
+ per solution metatdata.
152
+
153
+ Attributes:
154
+ -----------
155
+ calculate_ranges: bool
156
+ Whether to auto-calculate solution quantity ranges and add them to the
157
+ metadata. Default is False.
158
+ """
159
+
160
+ def __init__(self, solutions: list[Solution]):
161
+ self._scenes: list[Scene] = []
162
+ self._data_extractors: list[DataExtractor] = []
163
+ self._solution: list[Solution] = solutions
164
+ # When we fire off requests we use these objects to track the progress.
165
+ self._extract_outputs: list[ExtractOutput] = []
166
+ self._render_outputs: list[RenderOutput] = []
167
+ # Controls if we should calculate solution quanity ranges
168
+ self.calculate_ranges: bool = False
169
+ # Key is solution ID, value is the metadata dict
170
+ self._metadata: dict[str, dict[str, str | float]] = {}
171
+ for solution in solutions:
172
+ if not isinstance(solution, Solution):
173
+ raise TypeError("Expected a list of Solution objects.")
174
+
175
+ def add_scene(self, scene: Scene):
176
+ if not isinstance(scene, Scene):
177
+ raise TypeError("Expected a Scene object.")
178
+ self._scenes.append(scene)
179
+
180
+ # TODO(Matt): we could just make this a single data extract then control how they
181
+ # are added to each solution.
182
+ def add_data_extractor(self, data_extractor: DataExtractor):
183
+ if not isinstance(data_extractor, DataExtractor):
184
+ raise TypeError("Expected a DataExtractor object.")
185
+ self._data_extractors.append(data_extractor)
186
+
187
+ def add_metadata(self, solution_id: str, metadata: dict[str, str | float]):
188
+ if solution_id not in self._metadata:
189
+ self._metadata[solution_id] = {}
190
+ self._metadata[solution_id].update(metadata)
191
+
192
+ def create_report(self) -> Report:
193
+ entries = []
194
+ for solution in self._solution:
195
+ extract_ids = []
196
+ project_id = _get_project_id(solution)
197
+ if not project_id:
198
+ raise ValueError("Solution does not have a project_id.")
199
+ metadata = self._metadata.get(solution.id, {})
200
+ metadata["solution id"] = solution.id
201
+ if self.calculate_ranges:
202
+ print(f"Calculating solution quantity ranges {solution.id}")
203
+ ranges = range_query(solution, FieldAssociation.CELLS)
204
+ for range_res in ranges:
205
+ if not quantity_type._is_vector(range_res.quantity):
206
+ metadata[f"{range_res.field_name} min"] = range_res.ranges[0].min_value
207
+ metadata[f"{range_res.field_name} max"] = range_res.ranges[0].max_value
208
+ else:
209
+ for r in range(len(range_res.ranges)):
210
+ if r == 0:
211
+ comp = "x"
212
+ elif r == 1:
213
+ comp = "y"
214
+ elif r == 2:
215
+ comp = "z"
216
+ elif r == 3:
217
+ comp = "mag"
218
+ comp_range = range_res.ranges[r]
219
+ metadata[f"{range_res.field_name} min ({comp})"] = comp_range.min_value
220
+ metadata[f"{range_res.field_name} max ({comp})"] = comp_range.max_value
221
+ for extractor in self._data_extractors:
222
+ sol_extractor = extractor.clone(solution)
223
+ extract = sol_extractor.create_extracts(
224
+ name="Report Extract", description="Generated Report Extract"
225
+ )
226
+ extract_ids.append(extract._extract_id)
227
+
228
+ for scene in self._scenes:
229
+ sol_scene = scene.clone(solution)
230
+ render_extract = sol_scene.render_images(
231
+ name="Report Scene", description="Generated Report Scene"
232
+ )
233
+ extract_ids.append(render_extract._extract_id)
234
+
235
+ entries.append(ReportEntry(project_id, extract_ids, metadata))
236
+ return Report(entries)
237
+
238
+
239
+ def save_report_to_json(report: Report, name: str, directory: str = ".") -> str:
240
+ """Save a Report object to a JSON file named {name}_lcreport.json in the specified directory."""
241
+ filename = f"{name}_lcreport.json"
242
+ filepath = os.path.join(directory, filename)
243
+ with open(filepath, "w") as f:
244
+ json.dump(report.to_dict(), f, indent=2)
245
+ return filepath
246
+
247
+
248
+ def load_report_from_json(filepath: str) -> "Report":
249
+ """Load a Report object from a JSON file at the given file path."""
250
+ with open(filepath, "r") as f:
251
+ data = json.load(f)
252
+ return Report.from_dict(data)
@@ -24,6 +24,7 @@ from luminarycloud.enum import (
24
24
  SceneMode,
25
25
  VisQuantity,
26
26
  QuantityType,
27
+ FieldAssociation,
27
28
  )
28
29
  from ..exceptions import NotFoundError
29
30
  from ..geometry import Geometry, get_geometry
@@ -162,7 +163,7 @@ class LookAtCamera(CodeRepr):
162
163
  self.projection = CameraProjection(static_ani.camera.projection)
163
164
 
164
165
  def __repr__(self) -> str:
165
- return self._to_code_helper(obj_name="camera")
166
+ return self._to_code_helper(obj_name="camera", hide_defaults=False)
166
167
 
167
168
 
168
169
  class RenderOutput:
@@ -206,13 +207,18 @@ class RenderOutput:
206
207
  self._deleted = False
207
208
 
208
209
  def _set_data(
209
- self, extract_id: str, project_id: str, name: str, desciption: str, status: RenderStatusType
210
+ self,
211
+ extract_id: str,
212
+ project_id: str,
213
+ name: str,
214
+ description: str,
215
+ status: RenderStatusType,
210
216
  ) -> None:
211
217
  self._extract_id = extract_id
212
218
  self._project_id = project_id
213
219
  self.status = status
214
220
  self.name = name
215
- self.description = desciption
221
+ self.description = description
216
222
 
217
223
  def __repr__(self) -> str:
218
224
  return f"RenderOutput(Id: {self._extract_id} status: {self.status})"
@@ -806,7 +812,7 @@ class Scene(CodeRepr):
806
812
  extract_id=res.extract.extract_id,
807
813
  project_id=self._project_id,
808
814
  name=name,
809
- desciption=description,
815
+ description=description,
810
816
  status=RenderStatusType(res.extract.status),
811
817
  )
812
818
  return render_output
@@ -1089,6 +1095,65 @@ def list_quantities(solution: Solution) -> List[VisQuantity]:
1089
1095
  return result
1090
1096
 
1091
1097
 
1098
+ @dc.dataclass
1099
+ class RangeResult:
1100
+ ranges: List[DataRange]
1101
+ """
1102
+ A list of ranges per component. Scalars have a single range and vector ranges are in
1103
+ in x,y,z, magnitude order.
1104
+ """
1105
+ quantity: VisQuantity
1106
+ """ The quantity type for the field, if available. """
1107
+ field_name: str
1108
+ """ Name of the field. """
1109
+
1110
+
1111
+ def range_query(solution: Solution, field_association: FieldAssociation) -> List[RangeResult]:
1112
+ """
1113
+ The range query returns the min/max values for all fields in a solution. Two
1114
+ types of ranges can be chosen: cell-centered and point-centered data. The
1115
+ results will vary based on the choice. The solver natively outputs cell-centered
1116
+ data, so the cell based query will return the actual min/max values
1117
+ from the solver run. Visualization operates on point-centered data, which is
1118
+ recentered from the cell-centered data.
1119
+
1120
+ Parameters
1121
+ ----------
1122
+ solution: Solution
1123
+ The the solution object to query.
1124
+ field_association: FieldAssociation
1125
+ The type of data to query, either cell-centered or point-centered.
1126
+ """
1127
+ if not isinstance(solution, Solution):
1128
+ raise TypeError(f"Expected 'Solution', got {type(solution).__name__}")
1129
+
1130
+ if not isinstance(field_association, FieldAssociation):
1131
+ raise TypeError(f"Expected 'FieldAssociation', got {type(field_association).__name__}")
1132
+
1133
+ sim = get_simulation(solution.simulation_id)
1134
+ req = vis_pb2.RangeQueryRequest()
1135
+ req.entity.simulation.id = solution.simulation_id
1136
+ req.entity.simulation.solution_id = solution.id
1137
+ req.project_id = sim.project_id
1138
+ if field_association == FieldAssociation.POINTS:
1139
+ req.field_association = vis_pb2.FieldAssociation.FIELD_ASSOCIATION_POINTS
1140
+ else:
1141
+ req.field_association = vis_pb2.FieldAssociation.FIELD_ASSOCIATION_CELLS
1142
+ res: vis_pb2.RangeQueryReply = get_default_client().RangeQuery(req)
1143
+ result: List[RangeResult] = []
1144
+ for r in res.range:
1145
+ ranges = []
1146
+ for r_range in r.range:
1147
+ data_range = DataRange()
1148
+ data_range.min_value = r_range.min
1149
+ data_range.max_value = r_range.max
1150
+ ranges.append(data_range)
1151
+ result.append(
1152
+ RangeResult(ranges=ranges, quantity=VisQuantity(r.quantity), field_name=r.field_name)
1153
+ )
1154
+ return result
1155
+
1156
+
1092
1157
  def list_renders(entity: Geometry | Mesh | Solution) -> List[RenderOutput]:
1093
1158
  """
1094
1159
  Lists all previously created renders associated with a project and an entity.
@@ -1145,7 +1210,7 @@ def list_renders(entity: Geometry | Mesh | Solution) -> List[RenderOutput]:
1145
1210
  extract_id=extract.extract_id,
1146
1211
  project_id=extract.project_id,
1147
1212
  name=extract.name,
1148
- desciption=extract.description,
1213
+ description=extract.description,
1149
1214
  status=RenderStatusType(extract.status),
1150
1215
  )
1151
1216
  # This need to be fixed on the backend, but manually refreshing works for now.
@@ -1315,3 +1380,60 @@ def _reconstruct(extract_id: str, project_id: str) -> Scene:
1315
1380
  req.project_id = project_id
1316
1381
  res: vis_pb2.GetExtractSpecResponse = get_default_client().GetExtractSpec(req)
1317
1382
  return _spec_to_scene(res.spec)
1383
+
1384
+
1385
+ @dc.dataclass
1386
+ class CameraEntry:
1387
+ camera_id: int
1388
+ name: str
1389
+
1390
+
1391
+ def list_cameras(project_id: str = "") -> List[CameraEntry]:
1392
+ """
1393
+ List all cameras in the specified project. If no project id is provided,
1394
+ global cameras are returned.
1395
+ """
1396
+ req = vis_pb2.ListCamerasRequest()
1397
+ req.project_id = project_id
1398
+ res: vis_pb2.ListCamerasReply = get_default_client().ListCameras(req)
1399
+ return [CameraEntry(camera_id=c.camera_id, name=c.name) for c in res.camera]
1400
+
1401
+
1402
+ def get_camera(entry: CameraEntry, width: int, height: int) -> LookAtCamera:
1403
+ """
1404
+ Instantiate a LookAt camera by its entry returned from list_cameras. The
1405
+ width and the height impact orthographic cameras. Most screens have a 16:9
1406
+ aspect ratio, so using the width = 1920 and height = 1080 is a good default
1407
+ to match cameras created in the UI, otherwise parts of the scene may be
1408
+ clipped.
1409
+
1410
+ .. warning:: This feature is experimental and may change or be removed in the future.
1411
+
1412
+ Parameters
1413
+ ----------
1414
+ entry: CameraEntry, required
1415
+ A camera entry returned from list_cameras.
1416
+ width: int, required
1417
+ The target width of the camera.
1418
+ height: int, required
1419
+ The target height of the camera.
1420
+ """
1421
+ req = vis_pb2.GetCameraRequest()
1422
+ req.camera_id = entry.camera_id
1423
+ req.resolution.width = width
1424
+ req.resolution.height = height
1425
+ res: vis_pb2.GetCameraReply = get_default_client().GetCamera(req)
1426
+ cam = LookAtCamera()
1427
+ cam.width = width
1428
+ cam.height = height
1429
+ cam.label = entry.name
1430
+ cam.up = Vector3()
1431
+ cam.up._from_proto(res.camera.look_at.up)
1432
+ cam.look_at = Vector3()
1433
+ cam.look_at._from_proto(res.camera.look_at.look_at)
1434
+ cam.position = Vector3()
1435
+ cam.position._from_proto(res.camera.look_at.position)
1436
+ cam.projection = CameraProjection(res.camera.projection)
1437
+ cam.pan_x = res.camera.look_at.pan.x
1438
+ cam.pan_y = res.camera.look_at.pan.y
1439
+ return cam