luminarycloud 0.15.0__py3-none-any.whl → 0.15.2__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 (35) hide show
  1. luminarycloud/_client/client.py +4 -0
  2. luminarycloud/_proto/api/v0/luminarycloud/geometry/geometry_pb2.py +81 -81
  3. luminarycloud/_proto/api/v0/luminarycloud/geometry/geometry_pb2.pyi +4 -1
  4. luminarycloud/_proto/api/v0/luminarycloud/inference/inference_pb2.py +61 -0
  5. luminarycloud/_proto/api/v0/luminarycloud/inference/inference_pb2.pyi +76 -0
  6. luminarycloud/_proto/api/v0/luminarycloud/inference/inference_pb2_grpc.py +67 -0
  7. luminarycloud/_proto/api/v0/luminarycloud/inference/inference_pb2_grpc.pyi +26 -0
  8. luminarycloud/_proto/api/v0/luminarycloud/mesh/mesh_pb2.py +29 -27
  9. luminarycloud/_proto/api/v0/luminarycloud/mesh/mesh_pb2.pyi +5 -1
  10. luminarycloud/_proto/api/v0/luminarycloud/named_variable_set/named_variable_set_pb2.py +50 -28
  11. luminarycloud/_proto/api/v0/luminarycloud/named_variable_set/named_variable_set_pb2.pyi +38 -2
  12. luminarycloud/_proto/api/v0/luminarycloud/named_variable_set/named_variable_set_pb2_grpc.py +34 -0
  13. luminarycloud/_proto/api/v0/luminarycloud/named_variable_set/named_variable_set_pb2_grpc.pyi +12 -0
  14. luminarycloud/_proto/assistant/assistant_pb2.py +74 -74
  15. luminarycloud/_proto/assistant/assistant_pb2.pyi +22 -30
  16. luminarycloud/_proto/geometry/geometry_pb2.py +8 -1
  17. luminarycloud/_proto/geometry/geometry_pb2.pyi +19 -0
  18. luminarycloud/_proto/hexmesh/hexmesh_pb2.py +37 -37
  19. luminarycloud/_proto/hexmesh/hexmesh_pb2.pyi +4 -4
  20. luminarycloud/_proto/inferenceservice/inferenceservice_pb2.py +69 -0
  21. luminarycloud/_proto/inferenceservice/inferenceservice_pb2.pyi +80 -0
  22. luminarycloud/_proto/parametricworker/parametricworker_pb2.py +59 -26
  23. luminarycloud/_proto/parametricworker/parametricworker_pb2.pyi +58 -3
  24. luminarycloud/feature_modification.py +909 -0
  25. luminarycloud/meshing/mesh_generation_params.py +1 -1
  26. luminarycloud/meshing/sizing_strategy/sizing_strategies.py +2 -2
  27. luminarycloud/named_variable_set.py +10 -4
  28. luminarycloud/physics_ai/inference.py +55 -0
  29. luminarycloud/simulation_template.py +4 -4
  30. luminarycloud/types/adfloat.py +19 -1
  31. luminarycloud/vis/interactive_scene.py +30 -6
  32. luminarycloud/vis/visualization.py +57 -0
  33. {luminarycloud-0.15.0.dist-info → luminarycloud-0.15.2.dist-info}/METADATA +1 -1
  34. {luminarycloud-0.15.0.dist-info → luminarycloud-0.15.2.dist-info}/RECORD +35 -27
  35. {luminarycloud-0.15.0.dist-info → luminarycloud-0.15.2.dist-info}/WHEEL +0 -0
@@ -0,0 +1,909 @@
1
+ # Copyright 2025 Luminary Cloud, Inc. All Rights Reserved.
2
+ from enum import Enum, auto
3
+ from typing import Dict, Iterable, List, Optional, cast
4
+ from copy import deepcopy
5
+ from ._proto.geometry import geometry_pb2 as gpb
6
+ from .types import Vector3Like
7
+ from .types.vector3 import _to_vector3
8
+ from .params.geometry import Shape, Sphere, Cube, Cylinder, Torus, Cone, HalfSphere, Volume
9
+ from google.protobuf.internal.containers import RepeatedScalarFieldContainer
10
+
11
+
12
+ class FeatureOperationType(Enum):
13
+ """Enum representing the type of operation in a Feature."""
14
+
15
+ IMPORT = auto()
16
+ CREATE = auto()
17
+ DELETE = auto()
18
+ UNION = auto()
19
+ SUBTRACTION = auto()
20
+ INTERSECTION = auto()
21
+ CHOP = auto()
22
+ IMPRINT = auto()
23
+ TRANSLATE = auto()
24
+ ROTATE = auto()
25
+ SCALE = auto()
26
+ SHRINKWRAP = auto()
27
+ FARFIELD = auto()
28
+ PATTERN_LINEAR = auto()
29
+ PATTERN_CIRCULAR = auto()
30
+ CONFIGURATIONS = auto()
31
+
32
+
33
+ def _volumes_to_int_list(volumes: Iterable[Volume | int]) -> List[int]:
34
+ """
35
+ Convert a collection of Volume objects or integer IDs to a list of integer IDs.
36
+
37
+ Args:
38
+ volumes: Collection of volumes or volume IDs
39
+
40
+ Returns:
41
+ List of integer IDs
42
+
43
+ Raises:
44
+ TypeError: If any item in volumes is not a Volume object or integer
45
+ """
46
+ result = []
47
+ for v in volumes:
48
+ if isinstance(v, Volume):
49
+ result.append(int(v.id))
50
+ elif isinstance(v, int):
51
+ result.append(v)
52
+ else:
53
+ raise TypeError(f"Unsupported type for volume: {type(v)}")
54
+ return result
55
+
56
+
57
+ def get_operation_type(feature: gpb.Feature) -> FeatureOperationType:
58
+ """
59
+ Determine which type of operation is active in a gpb.Feature object.
60
+
61
+ Args:
62
+ feature: A gpb.Feature object
63
+
64
+ Returns:
65
+ The FeatureOperationType enum value representing the active operation
66
+
67
+ Raises:
68
+ ValueError: If the feature has no operation set or has an unknown operation type
69
+ """
70
+ operation = feature.WhichOneof("operation")
71
+ if operation is None:
72
+ raise ValueError("No operation set on feature")
73
+
74
+ # Base operation map for simple one-to-one mappings
75
+ base_operation_map = {
76
+ "import": FeatureOperationType.IMPORT,
77
+ "create": FeatureOperationType.CREATE,
78
+ "delete": FeatureOperationType.DELETE,
79
+ "imprint": FeatureOperationType.IMPRINT,
80
+ "shrinkwrap": FeatureOperationType.SHRINKWRAP,
81
+ "farfield": FeatureOperationType.FARFIELD,
82
+ "configurations": FeatureOperationType.CONFIGURATIONS,
83
+ }
84
+
85
+ # Handle boolean operations
86
+ if operation == "boolean":
87
+ bool_op = feature.boolean.WhichOneof("op")
88
+ if bool_op == "reg_union":
89
+ return FeatureOperationType.UNION
90
+ elif bool_op == "reg_subtraction":
91
+ return FeatureOperationType.SUBTRACTION
92
+ elif bool_op == "reg_intersection":
93
+ return FeatureOperationType.INTERSECTION
94
+ elif bool_op == "reg_chop":
95
+ return FeatureOperationType.CHOP
96
+ else:
97
+ raise ValueError(f"Unknown boolean operation type: {bool_op}")
98
+
99
+ # Handle transform operations
100
+ elif operation == "transform":
101
+ transform_op = feature.transform.WhichOneof("t")
102
+ if transform_op == "translation":
103
+ return FeatureOperationType.TRANSLATE
104
+ elif transform_op == "rotation":
105
+ return FeatureOperationType.ROTATE
106
+ elif transform_op == "scaling":
107
+ return FeatureOperationType.SCALE
108
+ else:
109
+ raise ValueError(f"Unknown transform operation type: {transform_op}")
110
+
111
+ # Handle pattern operations
112
+ elif operation == "pattern":
113
+ pattern_type = feature.pattern.direction.WhichOneof("type")
114
+ if pattern_type == "linear_spacing":
115
+ return FeatureOperationType.PATTERN_LINEAR
116
+ elif pattern_type == "circular_distribution":
117
+ return FeatureOperationType.PATTERN_CIRCULAR
118
+ else:
119
+ raise ValueError(f"Unknown pattern type: {pattern_type}")
120
+
121
+ # Handle simple operations
122
+ elif operation in base_operation_map:
123
+ return base_operation_map[operation]
124
+
125
+ # If we get here, it's an unknown operation type
126
+ raise ValueError(f"Unknown operation type: {operation}")
127
+
128
+
129
+ def _update_repeated_field(
130
+ target_field: RepeatedScalarFieldContainer[int], new_values: list[int]
131
+ ) -> None:
132
+ """Helper function to update a repeated field without using clear().
133
+
134
+ Args:
135
+ target_field: The RepeatedScalarFieldContainer[int] protobuf repeated field
136
+ new_values: The new values to set
137
+
138
+ This is needed because some versions of protobuf don't support clear() on RepeatedScalarContainer.
139
+ """
140
+
141
+ # Delete all existing elements
142
+ while len(target_field) > 0:
143
+ del target_field[0]
144
+
145
+ # Add new elements
146
+ target_field.extend(new_values)
147
+
148
+
149
+ def modify_import(
150
+ feature: gpb.Feature,
151
+ geometry_url: Optional[str] = None,
152
+ scaling: Optional[float] = None,
153
+ force_discrete: Optional[bool] = None,
154
+ ) -> gpb.Modification:
155
+ """
156
+ Modify an import feature with optional new values.
157
+ For any parameter, if None is passed, the existing value is kept.
158
+
159
+ Args:
160
+ feature: A gpb.Feature object
161
+ geometry_url: The new geometry URL
162
+ scaling: New scaling factor
163
+ force_discrete: Whether to force discrete geometry
164
+
165
+ Returns:
166
+ A gpb.Modification object
167
+ """
168
+ if get_operation_type(feature) != FeatureOperationType.IMPORT:
169
+ raise ValueError("Feature is not an import operation")
170
+
171
+ feature_copy = deepcopy(feature)
172
+ import_op = getattr(feature_copy, "import")
173
+
174
+ if geometry_url is not None:
175
+ # TODO(chiodi): Handle upload of file here.
176
+ import_op.geometry_url = geometry_url
177
+
178
+ if scaling is not None:
179
+ import_op.scaling = scaling
180
+
181
+ if force_discrete is not None:
182
+ import_op.force_discrete = force_discrete
183
+
184
+ return gpb.Modification(
185
+ mod_type=gpb.Modification.ModificationType.MODIFICATION_TYPE_UPDATE_FEATURE,
186
+ feature=feature_copy,
187
+ )
188
+
189
+
190
+ def modify_create(
191
+ feature: gpb.Feature,
192
+ shape: Optional[Shape] = None,
193
+ ) -> gpb.Modification:
194
+ """
195
+ Modify a create feature with a new shape.
196
+ If shape is None, the existing shape is kept.
197
+
198
+ Args:
199
+ feature: A gpb.Feature object
200
+ shape: New shape to create (Sphere, Cube, Cylinder, etc.)
201
+
202
+ Returns:
203
+ A gpb.Modification object
204
+ """
205
+ if get_operation_type(feature) != FeatureOperationType.CREATE:
206
+ raise ValueError("Feature is not a create operation")
207
+
208
+ feature_copy = deepcopy(feature)
209
+ create_op = feature_copy.create
210
+
211
+ if shape is not None:
212
+ create_op.ClearField("shape")
213
+
214
+ if isinstance(shape, Sphere):
215
+ create_op.sphere.CopyFrom(shape._to_proto()) # type: ignore
216
+ elif isinstance(shape, Cube):
217
+ create_op.box.CopyFrom(shape._to_proto()) # type: ignore
218
+ elif isinstance(shape, Cylinder):
219
+ create_op.cylinder.CopyFrom(shape._to_proto()) # type: ignore
220
+ elif isinstance(shape, Torus):
221
+ create_op.torus.CopyFrom(shape._to_proto()) # type: ignore
222
+ elif isinstance(shape, Cone):
223
+ create_op.cone.CopyFrom(shape._to_proto()) # type: ignore
224
+ elif isinstance(shape, HalfSphere):
225
+ create_op.half_sphere.CopyFrom(shape._to_proto()) # type: ignore
226
+ else:
227
+ raise TypeError(f"Unsupported shape type: {type(shape)}")
228
+
229
+ return gpb.Modification(
230
+ mod_type=gpb.Modification.ModificationType.MODIFICATION_TYPE_UPDATE_FEATURE,
231
+ feature=feature_copy,
232
+ )
233
+
234
+
235
+ def modify_delete(
236
+ feature: gpb.Feature,
237
+ volumes: Optional[List[Volume | int]] = None,
238
+ ) -> gpb.Modification:
239
+ """
240
+ Modify a delete feature with optional new values.
241
+ For any parameter, if None is passed, the existing value is kept.
242
+
243
+ Args:
244
+ feature: A gpb.Feature object
245
+ volumes: List of volumes or volume IDs to delete
246
+
247
+ Returns:
248
+ A gpb.Modification object
249
+ """
250
+ if get_operation_type(feature) != FeatureOperationType.DELETE:
251
+ raise ValueError("Feature is not a delete operation")
252
+
253
+ feature_copy = deepcopy(feature)
254
+ delete_op = feature_copy.delete
255
+
256
+ if delete_op.type != gpb.EntityType.BODY:
257
+ raise ValueError("Only body delete operations currently supported")
258
+
259
+ if volumes is not None:
260
+ vol_ids = _volumes_to_int_list(volumes)
261
+ _update_repeated_field(delete_op.ids, vol_ids)
262
+
263
+ return gpb.Modification(
264
+ mod_type=gpb.Modification.ModificationType.MODIFICATION_TYPE_UPDATE_FEATURE,
265
+ feature=feature_copy,
266
+ )
267
+
268
+
269
+ def modify_union(
270
+ feature: gpb.Feature,
271
+ volumes: Optional[List[Volume | int]] = None,
272
+ ) -> gpb.Modification:
273
+ """
274
+ Modify a boolean union feature with optional new body IDs.
275
+ If volumes is None, the existing body IDs are kept.
276
+
277
+ Args:
278
+ feature: A gpb.Feature object
279
+ volumes: List of volumes or volume IDs to union
280
+
281
+ Returns:
282
+ A gpb.Modification object
283
+ """
284
+ if get_operation_type(feature) != FeatureOperationType.UNION:
285
+ raise ValueError("Feature is not a boolean union operation")
286
+
287
+ feature_copy = deepcopy(feature)
288
+ boolean_op = feature_copy.boolean
289
+
290
+ if volumes is not None:
291
+ vol_ids = _volumes_to_int_list(volumes)
292
+ _update_repeated_field(boolean_op.reg_union.bodies, vol_ids)
293
+
294
+ return gpb.Modification(
295
+ mod_type=gpb.Modification.ModificationType.MODIFICATION_TYPE_UPDATE_FEATURE,
296
+ feature=feature_copy,
297
+ )
298
+
299
+
300
+ def modify_subtraction(
301
+ feature: gpb.Feature,
302
+ volumes: Optional[List[Volume | int]] = None,
303
+ tool_volumes: Optional[List[Volume | int]] = None,
304
+ propagate_tool_tags: Optional[bool] = None,
305
+ ) -> gpb.Modification:
306
+ """
307
+ Modify a boolean subtraction feature with optional new values.
308
+ For any parameter, if None is passed, the existing value is kept.
309
+
310
+ Args:
311
+ feature: A gpb.Feature object
312
+ volumes: List of volumes or volume IDs to subtract from
313
+ tool_volumes: List of volumes or volume IDs to use for subtraction
314
+ propagate_tool_tags: Whether to propagate tool tags
315
+
316
+ Returns:
317
+ A gpb.Modification object
318
+ """
319
+ if get_operation_type(feature) != FeatureOperationType.SUBTRACTION:
320
+ raise ValueError("Feature is not a boolean subtraction operation")
321
+
322
+ feature_copy = deepcopy(feature)
323
+ boolean_op = feature_copy.boolean
324
+ if volumes is not None:
325
+ vol_ids = _volumes_to_int_list(volumes)
326
+ _update_repeated_field(boolean_op.reg_subtraction.bodies, vol_ids)
327
+
328
+ if tool_volumes is not None:
329
+ tool_ids = _volumes_to_int_list(tool_volumes)
330
+ _update_repeated_field(boolean_op.reg_subtraction.tools, tool_ids)
331
+
332
+ if propagate_tool_tags is not None:
333
+ boolean_op.reg_subtraction.propagate_tool_tags = propagate_tool_tags
334
+
335
+ return gpb.Modification(
336
+ mod_type=gpb.Modification.ModificationType.MODIFICATION_TYPE_UPDATE_FEATURE,
337
+ feature=feature_copy,
338
+ )
339
+
340
+
341
+ def modify_intersection(
342
+ feature: gpb.Feature, volumes: Optional[List[Volume | int]] = None
343
+ ) -> gpb.Modification:
344
+ """
345
+ Modify a boolean intersection feature with optional new volumes.
346
+ If volumes is None, the existing volumes are kept.
347
+
348
+ Args:
349
+ feature: A gpb.Feature object
350
+ volumes: List of volumes or volume IDs to intersect
351
+
352
+ Returns:
353
+ A gpb.Modification object
354
+ """
355
+ if get_operation_type(feature) != FeatureOperationType.INTERSECTION:
356
+ raise ValueError("Feature is not a boolean intersection operation")
357
+
358
+ feature_copy = deepcopy(feature)
359
+ boolean_op = feature_copy.boolean
360
+
361
+ if volumes is not None:
362
+ vol_ids = _volumes_to_int_list(volumes)
363
+ _update_repeated_field(boolean_op.reg_intersection.bodies, vol_ids)
364
+
365
+ return gpb.Modification(
366
+ mod_type=gpb.Modification.ModificationType.MODIFICATION_TYPE_UPDATE_FEATURE,
367
+ feature=feature_copy,
368
+ )
369
+
370
+
371
+ def modify_chop(
372
+ feature: gpb.Feature,
373
+ volumes: Optional[List[Volume | int]] = None,
374
+ tool_volumes: Optional[List[Volume | int]] = None,
375
+ propagate_tool_tags: Optional[bool] = None,
376
+ ) -> gpb.Modification:
377
+ """
378
+ Modify a boolean chop feature with optional new values.
379
+ For any parameter, if None is passed, the existing value is kept.
380
+
381
+ Args:
382
+ feature: A gpb.Feature object
383
+ volumes: List of volumes or volume IDs to chop
384
+ tool_volumes: List of volumes or volume IDs to use for chopping
385
+ propagate_tool_tags: Whether to propagate tool tags
386
+
387
+ Returns:
388
+ A gpb.Modification object
389
+ """
390
+ if get_operation_type(feature) != FeatureOperationType.CHOP:
391
+ raise ValueError("Feature is not a boolean chop operation")
392
+
393
+ feature_copy = deepcopy(feature)
394
+ boolean_op = feature_copy.boolean
395
+
396
+ if volumes is not None:
397
+ vol_ids = _volumes_to_int_list(volumes)
398
+ _update_repeated_field(boolean_op.reg_chop.bodies, vol_ids)
399
+
400
+ if tool_volumes is not None:
401
+ tool_ids = _volumes_to_int_list(tool_volumes)
402
+ _update_repeated_field(boolean_op.reg_chop.tools, tool_ids)
403
+
404
+ if propagate_tool_tags is not None:
405
+ boolean_op.reg_chop.propagate_tool_tags = propagate_tool_tags
406
+
407
+ return gpb.Modification(
408
+ mod_type=gpb.Modification.ModificationType.MODIFICATION_TYPE_UPDATE_FEATURE,
409
+ feature=feature_copy,
410
+ )
411
+
412
+
413
+ def modify_imprint(
414
+ feature: gpb.Feature,
415
+ behavior: Optional[str] = None,
416
+ volumes: Optional[List[Volume | int]] = None,
417
+ ) -> gpb.Modification:
418
+ """
419
+ Modify an imprint feature with optional new values.
420
+ For any parameter, if None is passed, the existing value is kept.
421
+
422
+ Args:
423
+ feature: A gpb.Feature object
424
+ behavior: Imprint behavior ("IMPRINT_ALL" or "IMPRINT_SELECTED")
425
+ volumes: List of volumes or volume IDs to imprint. Not needed for an Imprint behavior of IMPRINT_ALL
426
+
427
+ Returns:
428
+ A gpb.Modification object
429
+ """
430
+ if get_operation_type(feature) != FeatureOperationType.IMPRINT:
431
+ raise ValueError("Feature is not an imprint operation")
432
+
433
+ feature_copy = deepcopy(feature)
434
+ imprint_op = feature_copy.imprint
435
+
436
+ if behavior is not None:
437
+ behavior_map = {
438
+ "IMPRINT_ALL": gpb.Imprint.ImprintBehavior.IMPRINT_ALL,
439
+ "IMPRINT_SELECTED": gpb.Imprint.ImprintBehavior.IMPRINT_SELECTED,
440
+ }
441
+ if behavior not in behavior_map:
442
+ raise ValueError(
443
+ f"Invalid imprint behavior: {behavior}. Expected one of: {', '.join(behavior_map.keys())}"
444
+ )
445
+ imprint_op.behavior = behavior_map[behavior]
446
+
447
+ if volumes is not None and imprint_op.behavior != gpb.Imprint.ImprintBehavior.IMPRINT_ALL:
448
+ vol_ids = _volumes_to_int_list(volumes)
449
+ _update_repeated_field(imprint_op.body, vol_ids)
450
+
451
+ if imprint_op.behavior != gpb.Imprint.ImprintBehavior.IMPRINT_ALL and len(imprint_op.body) == 0:
452
+ raise ValueError("No volumes provided for imprint operation")
453
+
454
+ return gpb.Modification(
455
+ mod_type=gpb.Modification.ModificationType.MODIFICATION_TYPE_UPDATE_FEATURE,
456
+ feature=feature_copy,
457
+ )
458
+
459
+
460
+ def modify_translate(
461
+ feature: gpb.Feature,
462
+ volumes: Optional[List[Volume | int]] = None,
463
+ displacement: Optional[Vector3Like] = None,
464
+ keep: Optional[bool] = None,
465
+ ) -> gpb.Modification:
466
+ """
467
+ Modify a transform translate feature with optional new values.
468
+ For any parameter, if None is passed, the existing value is kept.
469
+
470
+ Args:
471
+ feature: A gpb.Feature object
472
+ volumes: List of volumes or volume IDs to transform
473
+ displacement: The displacement vector [x, y, z]
474
+ keep: Whether to keep the original bodies
475
+
476
+ Returns:
477
+ A gpb.Modification object
478
+ """
479
+ if get_operation_type(feature) != FeatureOperationType.TRANSLATE:
480
+ raise ValueError("Feature is not a translate transform operation")
481
+
482
+ feature_copy = deepcopy(feature)
483
+ transform_op = feature_copy.transform
484
+
485
+ if volumes is not None:
486
+ vol_ids = _volumes_to_int_list(volumes)
487
+ _update_repeated_field(transform_op.body, vol_ids)
488
+
489
+ if displacement is not None:
490
+ vec = _to_vector3(displacement)
491
+ transform_op.translation.vector.x = vec.x
492
+ transform_op.translation.vector.y = vec.y
493
+ transform_op.translation.vector.z = vec.z
494
+
495
+ if keep is not None:
496
+ transform_op.keep = keep
497
+
498
+ return gpb.Modification(
499
+ mod_type=gpb.Modification.ModificationType.MODIFICATION_TYPE_UPDATE_FEATURE,
500
+ feature=feature_copy,
501
+ )
502
+
503
+
504
+ def modify_rotate(
505
+ feature: gpb.Feature,
506
+ volumes: Optional[List[Volume | int]] = None,
507
+ angle: Optional[float] = None,
508
+ axis: Optional[Vector3Like] = None,
509
+ origin: Optional[Vector3Like] = None,
510
+ keep: Optional[bool] = None,
511
+ ) -> gpb.Modification:
512
+ """
513
+ Modify a transform rotate feature with optional new values.
514
+ For any parameter, if None is passed, the existing value is kept.
515
+
516
+ Args:
517
+ feature: A gpb.Feature object
518
+ volumes: List of volumes or volume IDs to transform
519
+ angle: Rotation angle in degrees
520
+ axis: Rotation axis vector [x, y, z]
521
+ origin: Rotation origin point [x, y, z]
522
+ keep: Whether to keep the original bodies
523
+
524
+ Returns:
525
+ A gpb.Modification object
526
+ """
527
+ if get_operation_type(feature) != FeatureOperationType.ROTATE:
528
+ raise ValueError("Feature is not a rotate transform operation")
529
+
530
+ feature_copy = deepcopy(feature)
531
+ transform_op = feature_copy.transform
532
+
533
+ if volumes is not None:
534
+ vol_ids = _volumes_to_int_list(volumes)
535
+ _update_repeated_field(transform_op.body, vol_ids)
536
+
537
+ # Update existing rotation
538
+ if angle is not None:
539
+ transform_op.rotation.angle = angle
540
+
541
+ if axis is not None:
542
+ axis_vec = _to_vector3(axis)
543
+ transform_op.rotation.arbitrary.direction.x = axis_vec.x
544
+ transform_op.rotation.arbitrary.direction.y = axis_vec.y
545
+ transform_op.rotation.arbitrary.direction.z = axis_vec.z
546
+
547
+ if origin is not None:
548
+ origin_vec = _to_vector3(origin)
549
+ transform_op.rotation.arbitrary.origin.x = origin_vec.x
550
+ transform_op.rotation.arbitrary.origin.y = origin_vec.y
551
+ transform_op.rotation.arbitrary.origin.z = origin_vec.z
552
+
553
+ if keep is not None:
554
+ transform_op.keep = keep
555
+
556
+ return gpb.Modification(
557
+ mod_type=gpb.Modification.ModificationType.MODIFICATION_TYPE_UPDATE_FEATURE,
558
+ feature=feature_copy,
559
+ )
560
+
561
+
562
+ def modify_scale(
563
+ feature: gpb.Feature,
564
+ volumes: Optional[List[Volume | int]] = None,
565
+ scale_factor: Optional[float] = None,
566
+ origin: Optional[Vector3Like] = None,
567
+ keep: Optional[bool] = None,
568
+ ) -> gpb.Modification:
569
+ """
570
+ Modify a transform scale feature with optional new values.
571
+ For any parameter, if None is passed, the existing value is kept.
572
+
573
+ Args:
574
+ feature: A gpb.Feature object
575
+ volumes: List of volumes or volume IDs to transform
576
+ scale_factor: Uniform scaling factor
577
+ origin: Scaling origin point [x, y, z]
578
+ keep: Whether to keep the original bodies
579
+
580
+ Returns:
581
+ A gpb.Modification object
582
+ """
583
+ if get_operation_type(feature) != FeatureOperationType.SCALE:
584
+ raise ValueError("Feature is not a scale transform operation")
585
+
586
+ feature_copy = deepcopy(feature)
587
+ transform_op = feature_copy.transform
588
+
589
+ if volumes is not None:
590
+ vol_ids = _volumes_to_int_list(volumes)
591
+ _update_repeated_field(transform_op.body, vol_ids)
592
+
593
+ if scale_factor is not None:
594
+ transform_op.scaling.isotropic = scale_factor
595
+
596
+ if origin is not None:
597
+ origin_vec = _to_vector3(origin)
598
+ transform_op.scaling.arbitrary.x = origin_vec.x
599
+ transform_op.scaling.arbitrary.y = origin_vec.y
600
+ transform_op.scaling.arbitrary.z = origin_vec.z
601
+
602
+ if keep is not None:
603
+ transform_op.keep = keep
604
+
605
+ return gpb.Modification(
606
+ mod_type=gpb.Modification.ModificationType.MODIFICATION_TYPE_UPDATE_FEATURE,
607
+ feature=feature_copy,
608
+ )
609
+
610
+
611
+ def modify_shrinkwrap(
612
+ feature: gpb.Feature,
613
+ volumes: Optional[List[Volume | int]] = None,
614
+ tool_volumes: Optional[List[Volume | int]] = None,
615
+ mode: Optional[str] = None,
616
+ resolution_min: Optional[float] = None,
617
+ resolution_max: Optional[float] = None,
618
+ resolution_uniform: Optional[float] = None,
619
+ ) -> gpb.Modification:
620
+ """
621
+ Modify a shrinkwrap feature with optional new values.
622
+ For any parameter, if None is passed, the existing value is kept.
623
+
624
+ Args:
625
+ feature: A gpb.Feature object
626
+ volumes: List of volumes or volume IDs to shrinkwrap
627
+ tool_volumes: List of volumes or volume IDs to use as tools
628
+ mode: Shrinkwrap mode ("AUTOMATIC", "MINMAX", or "UNIFORM")
629
+ resolution_min: Minimum resolution
630
+ resolution_max: Maximum resolution
631
+ resolution_uniform: Uniform resolution
632
+
633
+ Returns:
634
+ A gpb.Modification object
635
+ """
636
+ if get_operation_type(feature) != FeatureOperationType.SHRINKWRAP:
637
+ raise ValueError("Feature is not a shrinkwrap operation")
638
+
639
+ feature_copy = deepcopy(feature)
640
+ shrinkwrap_op = feature_copy.shrinkwrap
641
+
642
+ if volumes is not None:
643
+ vol_ids = _volumes_to_int_list(volumes)
644
+ _update_repeated_field(shrinkwrap_op.body, vol_ids)
645
+
646
+ if tool_volumes is not None:
647
+ tool_ids = _volumes_to_int_list(tool_volumes)
648
+ _update_repeated_field(shrinkwrap_op.tool, tool_ids)
649
+
650
+ if mode is not None:
651
+ mode_map = {
652
+ "AUTOMATIC": gpb.ShrinkwrapMode.AUTOMATIC,
653
+ "MINMAX": gpb.ShrinkwrapMode.MINMAX,
654
+ "UNIFORM": gpb.ShrinkwrapMode.UNIFORM,
655
+ }
656
+ if mode not in mode_map:
657
+ raise ValueError(
658
+ f"Invalid shrinkwrap mode: {mode}. Expected one of: {', '.join(mode_map.keys())}"
659
+ )
660
+ shrinkwrap_op.mode = mode_map[mode]
661
+
662
+ if resolution_min is not None:
663
+ shrinkwrap_op.resolution_min = resolution_min
664
+
665
+ if resolution_max is not None:
666
+ shrinkwrap_op.resolution_max = resolution_max
667
+
668
+ if resolution_uniform is not None:
669
+ shrinkwrap_op.resolution_uniform = resolution_uniform
670
+
671
+ return gpb.Modification(
672
+ mod_type=gpb.Modification.ModificationType.MODIFICATION_TYPE_UPDATE_FEATURE,
673
+ feature=feature_copy,
674
+ )
675
+
676
+
677
+ def modify_farfield(
678
+ feature: gpb.Feature,
679
+ shape: Optional[Shape] = None,
680
+ volumes: Optional[List[Volume | int]] = None,
681
+ keep_source_bodies: Optional[bool] = None,
682
+ keep_tool_bodies: Optional[bool] = None,
683
+ propagate_tool_tags: Optional[bool] = None,
684
+ ) -> gpb.Modification:
685
+ """
686
+ Modify a farfield feature with optional new values.
687
+ For any parameter, if None is passed, the existing value is kept.
688
+
689
+ Args:
690
+ feature: A gpb.Feature object
691
+ shape: The shape to create for the farfield
692
+ volumes: List of volumes or volume IDs to subtract
693
+ keep_source_bodies: Whether to keep source bodies
694
+ keep_tool_bodies: Whether to keep tool bodies
695
+ propagate_tool_tags: Whether to propagate tool tags
696
+
697
+ Returns:
698
+ A gpb.Modification object
699
+ """
700
+ if get_operation_type(feature) != FeatureOperationType.FARFIELD:
701
+ raise ValueError("Feature is not a farfield operation")
702
+
703
+ feature_copy = deepcopy(feature)
704
+ farfield_op = feature_copy.farfield
705
+
706
+ if shape is not None:
707
+ create_op = gpb.Create()
708
+ if isinstance(shape, Sphere):
709
+ create_op.sphere.CopyFrom(shape._to_proto()) # type: ignore
710
+ elif isinstance(shape, Cube):
711
+ create_op.box.CopyFrom(shape._to_proto()) # type: ignore
712
+ elif isinstance(shape, Cylinder):
713
+ create_op.cylinder.CopyFrom(shape._to_proto()) # type: ignore
714
+ elif isinstance(shape, Torus):
715
+ create_op.torus.CopyFrom(shape._to_proto()) # type: ignore
716
+ elif isinstance(shape, Cone):
717
+ create_op.cone.CopyFrom(shape._to_proto()) # type: ignore
718
+ elif isinstance(shape, HalfSphere):
719
+ create_op.half_sphere.CopyFrom(shape._to_proto()) # type: ignore
720
+ else:
721
+ raise TypeError(f"Unsupported shape type: {type(shape)}")
722
+ farfield_op.create.CopyFrom(create_op)
723
+
724
+ if volumes is not None:
725
+ vol_ids = _volumes_to_int_list(volumes)
726
+ _update_repeated_field(farfield_op.bodies, vol_ids)
727
+
728
+ if keep_source_bodies is not None:
729
+ farfield_op.keep_source_bodies = keep_source_bodies
730
+
731
+ if keep_tool_bodies is not None:
732
+ farfield_op.keep_tool_bodies = keep_tool_bodies
733
+
734
+ if propagate_tool_tags is not None:
735
+ farfield_op.propagate_tool_tags = propagate_tool_tags
736
+
737
+ return gpb.Modification(
738
+ mod_type=gpb.Modification.ModificationType.MODIFICATION_TYPE_UPDATE_FEATURE,
739
+ feature=feature_copy,
740
+ )
741
+
742
+
743
+ def modify_linear_pattern(
744
+ feature: gpb.Feature,
745
+ volumes: Optional[List[Volume | int]] = None,
746
+ direction: Optional[Vector3Like] = None,
747
+ quantity: Optional[int] = None,
748
+ symmetric: Optional[bool] = None,
749
+ ) -> gpb.Modification:
750
+ """
751
+ Modify a linear pattern feature with optional new values.
752
+ For any parameter, if None is passed, the existing value is kept.
753
+
754
+ Args:
755
+ feature: A gpb.Feature object
756
+ volumes: List of volumes or volume IDs to pattern
757
+ direction: Direction vector [x, y, z]
758
+ quantity: Number of instances
759
+ symmetric: Whether the pattern is symmetric
760
+
761
+ Returns:
762
+ A gpb.Modification object
763
+ """
764
+ if get_operation_type(feature) != FeatureOperationType.PATTERN_LINEAR:
765
+ raise ValueError("Feature is not a linear pattern operation")
766
+
767
+ feature_copy = deepcopy(feature)
768
+ pattern_op = feature_copy.pattern
769
+
770
+ if volumes is not None:
771
+ vol_ids = _volumes_to_int_list(volumes)
772
+ _update_repeated_field(pattern_op.body, vol_ids)
773
+
774
+ if direction is not None:
775
+ # Update existing linear pattern direction
776
+ dir_vec = _to_vector3(direction)
777
+ pattern_op.direction.linear_spacing.vector.x = dir_vec.x
778
+ pattern_op.direction.linear_spacing.vector.y = dir_vec.y
779
+ pattern_op.direction.linear_spacing.vector.z = dir_vec.z
780
+
781
+ if quantity is not None:
782
+ pattern_op.direction.quantity = quantity
783
+
784
+ if symmetric is not None:
785
+ pattern_op.direction.symmetric = symmetric
786
+
787
+ return gpb.Modification(
788
+ mod_type=gpb.Modification.ModificationType.MODIFICATION_TYPE_UPDATE_FEATURE,
789
+ feature=feature_copy,
790
+ )
791
+
792
+
793
+ def modify_circular_pattern(
794
+ feature: gpb.Feature,
795
+ volumes: Optional[List[Volume | int]] = None,
796
+ angle: Optional[float] = None,
797
+ axis: Optional[Vector3Like] = None,
798
+ origin: Optional[Vector3Like] = None,
799
+ quantity: Optional[int] = None,
800
+ symmetric: Optional[bool] = None,
801
+ full_rotation: Optional[bool] = None,
802
+ ) -> gpb.Modification:
803
+ """
804
+ Modify a circular pattern feature with optional new values.
805
+ For any parameter, if None is passed, the existing value is kept.
806
+
807
+ Args:
808
+ feature: A gpb.Feature object
809
+ volumes: List of volumes or volume IDs to pattern
810
+ angle: Rotation angle in degrees
811
+ axis: Rotation axis vector [x, y, z]
812
+ origin: Rotation origin point [x, y, z]
813
+ quantity: Number of instances
814
+ symmetric: Whether the pattern is symmetric
815
+ full_rotation: Whether it's a full 360-degree rotation
816
+
817
+ Returns:
818
+ A gpb.Modification object
819
+ """
820
+ if get_operation_type(feature) != FeatureOperationType.PATTERN_CIRCULAR:
821
+ raise ValueError("Feature is not a circular pattern operation")
822
+
823
+ feature_copy = deepcopy(feature)
824
+ pattern_op = feature_copy.pattern
825
+
826
+ if volumes is not None:
827
+ vol_ids = _volumes_to_int_list(volumes)
828
+ _update_repeated_field(pattern_op.body, vol_ids)
829
+
830
+ if quantity is not None:
831
+ pattern_op.direction.quantity = quantity
832
+
833
+ if symmetric is not None:
834
+ pattern_op.direction.symmetric = symmetric
835
+
836
+ circular = pattern_op.direction.circular_distribution
837
+
838
+ if angle is not None:
839
+ circular.rotation.angle = angle
840
+
841
+ if axis is not None:
842
+ axis_vec = _to_vector3(axis)
843
+ circular.rotation.arbitrary.direction.x = axis_vec.x
844
+ circular.rotation.arbitrary.direction.y = axis_vec.y
845
+ circular.rotation.arbitrary.direction.z = axis_vec.z
846
+
847
+ if origin is not None:
848
+ origin_vec = _to_vector3(origin)
849
+ circular.rotation.arbitrary.origin.x = origin_vec.x
850
+ circular.rotation.arbitrary.origin.y = origin_vec.y
851
+ circular.rotation.arbitrary.origin.z = origin_vec.z
852
+
853
+ if full_rotation is not None:
854
+ circular.full = full_rotation
855
+
856
+ return gpb.Modification(
857
+ mod_type=gpb.Modification.ModificationType.MODIFICATION_TYPE_UPDATE_FEATURE,
858
+ feature=feature_copy,
859
+ )
860
+
861
+
862
+ def modify_configurations(
863
+ feature: gpb.Feature,
864
+ configurations: Optional[Dict[str, List[Volume | int]]] = None,
865
+ active: Optional[str] = None,
866
+ ) -> gpb.Modification:
867
+ """
868
+ Modify a configurations feature with optional new values.
869
+ For any parameter, if None is passed, the existing value is kept.
870
+
871
+ Args:
872
+ feature: A gpb.Feature object
873
+ configurations: Dictionary mapping configuration names to lists of volumes or volume IDs
874
+ active: Name of the active configuration
875
+
876
+ Returns:
877
+ A gpb.Modification object
878
+ """
879
+ if get_operation_type(feature) != FeatureOperationType.CONFIGURATIONS:
880
+ raise ValueError("Feature is not a configurations operation")
881
+
882
+ feature_copy = deepcopy(feature)
883
+ configurations_op = feature_copy.configurations
884
+ # Replace only if there were new configs
885
+ if configurations is not None and len(configurations) > 0:
886
+ existing_keys = list(configurations_op.configuration.keys())
887
+ # Delete all old configurations
888
+ for key in existing_keys:
889
+ configurations_op.configuration.pop(key)
890
+
891
+ # Add new configurations
892
+ for name, vols in configurations.items():
893
+ config = gpb.Configurations.Configuration()
894
+ vol_ids = _volumes_to_int_list(vols)
895
+ config.body.extend(vol_ids)
896
+ configurations_op.configuration[name].CopyFrom(config)
897
+
898
+ available_configs = set(configurations_op.configuration.keys())
899
+ if active is not None:
900
+ if active not in available_configs:
901
+ raise ValueError(
902
+ f"Active configuration '{active}' not found in provided configurations"
903
+ )
904
+ configurations_op.active = active
905
+
906
+ return gpb.Modification(
907
+ mod_type=gpb.Modification.ModificationType.MODIFICATION_TYPE_UPDATE_FEATURE,
908
+ feature=feature_copy,
909
+ )