cortex-solver 3.0.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.
- cortex/__init__.py +5 -0
- cortex/__main__.py +5 -0
- cortex/core/__init__.py +1 -0
- cortex/core/decomposer.py +792 -0
- cortex/core/scene_solver.py +586 -0
- cortex/core/solver.py +304 -0
- cortex/core/validator.py +360 -0
- cortex/core/verifier.py +307 -0
- cortex/server.py +226 -0
- cortex/tools/__init__.py +1 -0
- cortex/tools/decompose.py +24 -0
- cortex/tools/research.py +17 -0
- cortex/tools/solve.py +16 -0
- cortex/tools/solve_scene.py +57 -0
- cortex/tools/validate.py +75 -0
- cortex/tools/verify.py +22 -0
- cortex/types.py +298 -0
- cortex_solver-3.0.0.dist-info/METADATA +151 -0
- cortex_solver-3.0.0.dist-info/RECORD +21 -0
- cortex_solver-3.0.0.dist-info/WHEEL +4 -0
- cortex_solver-3.0.0.dist-info/entry_points.txt +2 -0
|
@@ -0,0 +1,586 @@
|
|
|
1
|
+
"""Scene solver for scene-level constraints.
|
|
2
|
+
|
|
3
|
+
The solver initializes all objects at the origin and applies constraints
|
|
4
|
+
in the order they appear in the recipe. It produces a SolveResult with
|
|
5
|
+
placements for each object (and any mirrored objects).
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
from dataclasses import dataclass
|
|
11
|
+
import math
|
|
12
|
+
import random
|
|
13
|
+
from typing import Any, Iterable
|
|
14
|
+
|
|
15
|
+
from cortex.types import (
|
|
16
|
+
Dimensions,
|
|
17
|
+
PartSpec,
|
|
18
|
+
SceneConstraint,
|
|
19
|
+
SceneConstraintType,
|
|
20
|
+
SceneRecipe,
|
|
21
|
+
SolveResult,
|
|
22
|
+
)
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
Vector3 = list[float]
|
|
26
|
+
Placement = dict[str, list[float]]
|
|
27
|
+
Placements = dict[str, Placement]
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
@dataclass(frozen=True)
|
|
31
|
+
class ZoneBounds:
|
|
32
|
+
min: Vector3
|
|
33
|
+
max: Vector3
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
def solve_scene(recipe: SceneRecipe) -> SolveResult:
|
|
37
|
+
"""Solve all scene constraints for a recipe.
|
|
38
|
+
|
|
39
|
+
Constraints are processed in order. Object placements are always
|
|
40
|
+
stored as "location", "rotation", and "scale" vectors.
|
|
41
|
+
"""
|
|
42
|
+
|
|
43
|
+
placements = _init_placements(recipe.objects)
|
|
44
|
+
specs = dict(recipe.objects)
|
|
45
|
+
warnings: list[str] = []
|
|
46
|
+
|
|
47
|
+
for constraint in recipe.constraints:
|
|
48
|
+
if constraint.type == SceneConstraintType.GRID:
|
|
49
|
+
_apply_grid(constraint, placements, warnings)
|
|
50
|
+
elif constraint.type == SceneConstraintType.ALONG_PATH:
|
|
51
|
+
_apply_along_path(constraint, placements, warnings)
|
|
52
|
+
elif constraint.type == SceneConstraintType.RADIAL:
|
|
53
|
+
_apply_radial(constraint, placements, warnings)
|
|
54
|
+
elif constraint.type == SceneConstraintType.FACING:
|
|
55
|
+
_apply_facing(constraint, placements, warnings)
|
|
56
|
+
elif constraint.type == SceneConstraintType.DISTANCE:
|
|
57
|
+
_apply_distance(constraint, placements, warnings)
|
|
58
|
+
elif constraint.type == SceneConstraintType.AGAINST_EDGE:
|
|
59
|
+
_apply_against_edge(constraint, placements, recipe, warnings)
|
|
60
|
+
elif constraint.type == SceneConstraintType.RANDOM_SCATTER:
|
|
61
|
+
_apply_random_scatter(constraint, placements, recipe, warnings)
|
|
62
|
+
elif constraint.type == SceneConstraintType.STACK_VERTICAL:
|
|
63
|
+
_apply_stack_vertical(constraint, placements, specs, warnings)
|
|
64
|
+
elif constraint.type == SceneConstraintType.MIRROR_SCENE:
|
|
65
|
+
_apply_mirror_scene(constraint, placements, specs, warnings)
|
|
66
|
+
else:
|
|
67
|
+
warnings.append(f"Unknown scene constraint type: {constraint.type}")
|
|
68
|
+
|
|
69
|
+
return SolveResult(success=True, positions=placements, warnings=warnings)
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
# ---------------------------------------------------------------------------
|
|
73
|
+
# Initialization helpers
|
|
74
|
+
# ---------------------------------------------------------------------------
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
def _init_placements(objects: dict[str, PartSpec]) -> Placements:
|
|
78
|
+
placements: Placements = {}
|
|
79
|
+
for name in objects:
|
|
80
|
+
placements[name] = _default_pose()
|
|
81
|
+
return placements
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
def _default_pose() -> Placement:
|
|
85
|
+
return {
|
|
86
|
+
"location": [0.0, 0.0, 0.0],
|
|
87
|
+
"rotation": [0.0, 0.0, 0.0],
|
|
88
|
+
"scale": [1.0, 1.0, 1.0],
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
def _ensure_object_list(value: str | list[str]) -> list[str]:
|
|
93
|
+
if isinstance(value, list):
|
|
94
|
+
return value
|
|
95
|
+
return [value]
|
|
96
|
+
|
|
97
|
+
|
|
98
|
+
# ---------------------------------------------------------------------------
|
|
99
|
+
# Vector helpers
|
|
100
|
+
# ---------------------------------------------------------------------------
|
|
101
|
+
|
|
102
|
+
|
|
103
|
+
def _vec_add(a: Vector3, b: Vector3) -> Vector3:
|
|
104
|
+
return [a[0] + b[0], a[1] + b[1], a[2] + b[2]]
|
|
105
|
+
|
|
106
|
+
|
|
107
|
+
def _vec_sub(a: Vector3, b: Vector3) -> Vector3:
|
|
108
|
+
return [a[0] - b[0], a[1] - b[1], a[2] - b[2]]
|
|
109
|
+
|
|
110
|
+
|
|
111
|
+
def _vec_mul(a: Vector3, scalar: float) -> Vector3:
|
|
112
|
+
return [a[0] * scalar, a[1] * scalar, a[2] * scalar]
|
|
113
|
+
|
|
114
|
+
|
|
115
|
+
def _vec_length(a: Vector3) -> float:
|
|
116
|
+
return math.sqrt(a[0] ** 2 + a[1] ** 2 + a[2] ** 2)
|
|
117
|
+
|
|
118
|
+
|
|
119
|
+
def _normalize(a: Vector3) -> Vector3:
|
|
120
|
+
length = _vec_length(a)
|
|
121
|
+
if length == 0:
|
|
122
|
+
return [1.0, 0.0, 0.0]
|
|
123
|
+
return [a[0] / length, a[1] / length, a[2] / length]
|
|
124
|
+
|
|
125
|
+
|
|
126
|
+
def _perpendicular_xy(a: Vector3) -> Vector3:
|
|
127
|
+
return [-a[1], a[0], 0.0]
|
|
128
|
+
|
|
129
|
+
|
|
130
|
+
def _lerp(a: float, b: float, t: float) -> float:
|
|
131
|
+
return a + (b - a) * t
|
|
132
|
+
|
|
133
|
+
|
|
134
|
+
def _distance(a: Vector3, b: Vector3) -> float:
|
|
135
|
+
return _vec_length(_vec_sub(a, b))
|
|
136
|
+
|
|
137
|
+
|
|
138
|
+
def _degrees_atan2(dy: float, dx: float) -> float:
|
|
139
|
+
return math.degrees(math.atan2(dy, dx))
|
|
140
|
+
|
|
141
|
+
|
|
142
|
+
# ---------------------------------------------------------------------------
|
|
143
|
+
# Constraint implementations
|
|
144
|
+
# ---------------------------------------------------------------------------
|
|
145
|
+
|
|
146
|
+
|
|
147
|
+
def _apply_grid(
|
|
148
|
+
constraint: SceneConstraint, placements: Placements, warnings: list[str]
|
|
149
|
+
) -> None:
|
|
150
|
+
objects = _ensure_object_list(constraint.object)
|
|
151
|
+
origin = _as_vec3(constraint.params.get("origin"), default=[0.0, 0.0, 0.0])
|
|
152
|
+
rows = int(constraint.params.get("rows", 0))
|
|
153
|
+
cols = int(constraint.params.get("cols", 0))
|
|
154
|
+
spacing_x = float(constraint.params.get("spacing_x", 0.0))
|
|
155
|
+
spacing_y = float(constraint.params.get("spacing_y", 0.0))
|
|
156
|
+
|
|
157
|
+
capacity = rows * cols
|
|
158
|
+
if capacity and len(objects) > capacity:
|
|
159
|
+
warnings.append(
|
|
160
|
+
f"GRID capacity {capacity} smaller than object count {len(objects)}"
|
|
161
|
+
)
|
|
162
|
+
|
|
163
|
+
for index, name in enumerate(objects):
|
|
164
|
+
row = 0 if cols == 0 else index // cols
|
|
165
|
+
col = index if cols == 0 else index % cols
|
|
166
|
+
position = [
|
|
167
|
+
origin[0] + col * spacing_x,
|
|
168
|
+
origin[1] + row * spacing_y,
|
|
169
|
+
origin[2],
|
|
170
|
+
]
|
|
171
|
+
_set_location(placements, name, position, warnings)
|
|
172
|
+
|
|
173
|
+
|
|
174
|
+
def _apply_along_path(
|
|
175
|
+
constraint: SceneConstraint,
|
|
176
|
+
placements: Placements,
|
|
177
|
+
warnings: list[str],
|
|
178
|
+
) -> None:
|
|
179
|
+
objects = _ensure_object_list(constraint.object)
|
|
180
|
+
start = _as_vec3(constraint.params.get("start"), default=[0.0, 0.0, 0.0])
|
|
181
|
+
end = _as_vec3(constraint.params.get("end"), default=[0.0, 0.0, 0.0])
|
|
182
|
+
spacing = float(constraint.params.get("spacing", 0.0))
|
|
183
|
+
offset_lateral = float(constraint.params.get("offset_lateral", 0.0))
|
|
184
|
+
|
|
185
|
+
direction = _vec_sub(end, start)
|
|
186
|
+
if _vec_length(direction) == 0:
|
|
187
|
+
warnings.append(
|
|
188
|
+
"ALONG_PATH start and end are identical; using default direction"
|
|
189
|
+
)
|
|
190
|
+
direction = _normalize(direction)
|
|
191
|
+
perpendicular = _perpendicular_xy(direction)
|
|
192
|
+
|
|
193
|
+
for index, name in enumerate(objects):
|
|
194
|
+
along = _vec_mul(direction, spacing * index)
|
|
195
|
+
lateral = _vec_mul(perpendicular, offset_lateral)
|
|
196
|
+
position = _vec_add(start, _vec_add(along, lateral))
|
|
197
|
+
_set_location(placements, name, position, warnings)
|
|
198
|
+
|
|
199
|
+
|
|
200
|
+
def _apply_radial(
|
|
201
|
+
constraint: SceneConstraint,
|
|
202
|
+
placements: Placements,
|
|
203
|
+
warnings: list[str],
|
|
204
|
+
) -> None:
|
|
205
|
+
objects = _ensure_object_list(constraint.object)
|
|
206
|
+
center = _as_vec3(constraint.params.get("center"), default=[0.0, 0.0, 0.0])
|
|
207
|
+
radius = float(constraint.params.get("radius", 0.0))
|
|
208
|
+
count = int(constraint.params.get("count", len(objects)))
|
|
209
|
+
start_angle = float(constraint.params.get("start_angle", 0.0))
|
|
210
|
+
face_center = bool(constraint.params.get("face_center", False))
|
|
211
|
+
|
|
212
|
+
if count <= 0:
|
|
213
|
+
warnings.append("RADIAL count must be positive; using 1")
|
|
214
|
+
count = 1
|
|
215
|
+
|
|
216
|
+
step = 360.0 / count
|
|
217
|
+
|
|
218
|
+
for index, name in enumerate(objects):
|
|
219
|
+
angle_deg = start_angle + index * step
|
|
220
|
+
angle_rad = math.radians(angle_deg)
|
|
221
|
+
position = [
|
|
222
|
+
center[0] + radius * math.cos(angle_rad),
|
|
223
|
+
center[1] + radius * math.sin(angle_rad),
|
|
224
|
+
center[2],
|
|
225
|
+
]
|
|
226
|
+
_set_location(placements, name, position, warnings)
|
|
227
|
+
if face_center:
|
|
228
|
+
rotation = _get_rotation(placements, name, warnings)
|
|
229
|
+
rotation[2] = _degrees_atan2(
|
|
230
|
+
center[1] - position[1], center[0] - position[0]
|
|
231
|
+
)
|
|
232
|
+
_set_rotation(placements, name, rotation, warnings)
|
|
233
|
+
|
|
234
|
+
|
|
235
|
+
def _apply_facing(
|
|
236
|
+
constraint: SceneConstraint,
|
|
237
|
+
placements: Placements,
|
|
238
|
+
warnings: list[str],
|
|
239
|
+
) -> None:
|
|
240
|
+
objects = _ensure_object_list(constraint.object)
|
|
241
|
+
target = constraint.params.get("target")
|
|
242
|
+
if not isinstance(target, str):
|
|
243
|
+
warnings.append("FACING constraint requires 'target' object name")
|
|
244
|
+
return
|
|
245
|
+
|
|
246
|
+
target_location = _get_location(placements, target, warnings)
|
|
247
|
+
if target_location is None:
|
|
248
|
+
return
|
|
249
|
+
|
|
250
|
+
for name in objects:
|
|
251
|
+
location = _get_location(placements, name, warnings)
|
|
252
|
+
if location is None:
|
|
253
|
+
continue
|
|
254
|
+
rotation = _get_rotation(placements, name, warnings)
|
|
255
|
+
rotation[2] = _degrees_atan2(
|
|
256
|
+
target_location[1] - location[1],
|
|
257
|
+
target_location[0] - location[0],
|
|
258
|
+
)
|
|
259
|
+
_set_rotation(placements, name, rotation, warnings)
|
|
260
|
+
|
|
261
|
+
|
|
262
|
+
def _apply_distance(
|
|
263
|
+
constraint: SceneConstraint,
|
|
264
|
+
placements: Placements,
|
|
265
|
+
warnings: list[str],
|
|
266
|
+
) -> None:
|
|
267
|
+
objects = _ensure_object_list(constraint.object)
|
|
268
|
+
min_distance = float(constraint.params.get("min_distance", 0.0))
|
|
269
|
+
|
|
270
|
+
for idx, name_a in enumerate(objects):
|
|
271
|
+
loc_a = _get_location(placements, name_a, warnings)
|
|
272
|
+
if loc_a is None:
|
|
273
|
+
continue
|
|
274
|
+
for name_b in objects[idx + 1 :]:
|
|
275
|
+
loc_b = _get_location(placements, name_b, warnings)
|
|
276
|
+
if loc_b is None:
|
|
277
|
+
continue
|
|
278
|
+
distance = _distance(loc_a, loc_b)
|
|
279
|
+
if distance < min_distance:
|
|
280
|
+
warnings.append(
|
|
281
|
+
f"DISTANCE constraint violated by {name_a} and {name_b}"
|
|
282
|
+
)
|
|
283
|
+
|
|
284
|
+
|
|
285
|
+
def _apply_against_edge(
|
|
286
|
+
constraint: SceneConstraint,
|
|
287
|
+
placements: Placements,
|
|
288
|
+
recipe: SceneRecipe,
|
|
289
|
+
warnings: list[str],
|
|
290
|
+
) -> None:
|
|
291
|
+
objects = _ensure_object_list(constraint.object)
|
|
292
|
+
if len(objects) != 1:
|
|
293
|
+
warnings.append("AGAINST_EDGE constraint expects a single object")
|
|
294
|
+
return
|
|
295
|
+
|
|
296
|
+
edge = str(constraint.params.get("edge", "")).lower()
|
|
297
|
+
zone_name = constraint.params.get("zone")
|
|
298
|
+
position_along = float(constraint.params.get("position_along", 0.0))
|
|
299
|
+
|
|
300
|
+
bounds = _get_zone_bounds(recipe, zone_name, warnings)
|
|
301
|
+
if bounds is None:
|
|
302
|
+
return
|
|
303
|
+
|
|
304
|
+
x = bounds.min[0]
|
|
305
|
+
y = bounds.min[1]
|
|
306
|
+
if edge == "north":
|
|
307
|
+
x = _lerp(bounds.min[0], bounds.max[0], position_along)
|
|
308
|
+
y = bounds.max[1]
|
|
309
|
+
elif edge == "south":
|
|
310
|
+
x = _lerp(bounds.min[0], bounds.max[0], position_along)
|
|
311
|
+
y = bounds.min[1]
|
|
312
|
+
elif edge == "east":
|
|
313
|
+
x = bounds.max[0]
|
|
314
|
+
y = _lerp(bounds.min[1], bounds.max[1], position_along)
|
|
315
|
+
elif edge == "west":
|
|
316
|
+
x = bounds.min[0]
|
|
317
|
+
y = _lerp(bounds.min[1], bounds.max[1], position_along)
|
|
318
|
+
else:
|
|
319
|
+
warnings.append(f"AGAINST_EDGE unknown edge: {edge}")
|
|
320
|
+
return
|
|
321
|
+
|
|
322
|
+
location = _get_location(placements, objects[0], warnings)
|
|
323
|
+
if location is None:
|
|
324
|
+
return
|
|
325
|
+
location[0] = x
|
|
326
|
+
location[1] = y
|
|
327
|
+
_set_location(placements, objects[0], location, warnings)
|
|
328
|
+
|
|
329
|
+
|
|
330
|
+
def _apply_random_scatter(
|
|
331
|
+
constraint: SceneConstraint,
|
|
332
|
+
placements: Placements,
|
|
333
|
+
recipe: SceneRecipe,
|
|
334
|
+
warnings: list[str],
|
|
335
|
+
) -> None:
|
|
336
|
+
objects = _ensure_object_list(constraint.object)
|
|
337
|
+
zone_name = constraint.params.get("zone")
|
|
338
|
+
min_spacing = float(constraint.params.get("min_spacing", 0.0))
|
|
339
|
+
seed = constraint.params.get("seed")
|
|
340
|
+
rng = random.Random(seed)
|
|
341
|
+
|
|
342
|
+
bounds = _get_zone_bounds(recipe, zone_name, warnings)
|
|
343
|
+
if bounds is None:
|
|
344
|
+
return
|
|
345
|
+
|
|
346
|
+
for name in objects:
|
|
347
|
+
placed = False
|
|
348
|
+
for _ in range(1000):
|
|
349
|
+
position = [
|
|
350
|
+
rng.uniform(bounds.min[0], bounds.max[0]),
|
|
351
|
+
rng.uniform(bounds.min[1], bounds.max[1]),
|
|
352
|
+
rng.uniform(bounds.min[2], bounds.max[2]),
|
|
353
|
+
]
|
|
354
|
+
if _is_far_enough(position, placements, min_spacing):
|
|
355
|
+
_set_location(placements, name, position, warnings)
|
|
356
|
+
placed = True
|
|
357
|
+
break
|
|
358
|
+
if not placed:
|
|
359
|
+
warnings.append(
|
|
360
|
+
f"RANDOM_SCATTER failed to place {name} in zone {zone_name}"
|
|
361
|
+
)
|
|
362
|
+
|
|
363
|
+
|
|
364
|
+
def _apply_stack_vertical(
|
|
365
|
+
constraint: SceneConstraint,
|
|
366
|
+
placements: Placements,
|
|
367
|
+
specs: dict[str, PartSpec],
|
|
368
|
+
warnings: list[str],
|
|
369
|
+
) -> None:
|
|
370
|
+
objects = _ensure_object_list(constraint.object)
|
|
371
|
+
base_position = _as_vec3(
|
|
372
|
+
constraint.params.get("base_position"), default=[0.0, 0.0, 0.0]
|
|
373
|
+
)
|
|
374
|
+
gap = float(constraint.params.get("gap", 0.0))
|
|
375
|
+
|
|
376
|
+
prev_pos = None
|
|
377
|
+
prev_height = 0.0
|
|
378
|
+
for index, name in enumerate(objects):
|
|
379
|
+
dimensions = _get_dimensions(specs, name, warnings)
|
|
380
|
+
curr_height = dimensions.height if dimensions else 0.0
|
|
381
|
+
if index == 0:
|
|
382
|
+
position = base_position
|
|
383
|
+
else:
|
|
384
|
+
prev_top = 0.0 if prev_pos is None else prev_pos[2] + prev_height / 2.0
|
|
385
|
+
position = [
|
|
386
|
+
base_position[0],
|
|
387
|
+
base_position[1],
|
|
388
|
+
prev_top + gap + curr_height / 2.0,
|
|
389
|
+
]
|
|
390
|
+
_set_location(placements, name, position, warnings)
|
|
391
|
+
prev_pos = position
|
|
392
|
+
prev_height = curr_height
|
|
393
|
+
|
|
394
|
+
|
|
395
|
+
def _apply_mirror_scene(
|
|
396
|
+
constraint: SceneConstraint,
|
|
397
|
+
placements: Placements,
|
|
398
|
+
specs: dict[str, PartSpec],
|
|
399
|
+
warnings: list[str],
|
|
400
|
+
) -> None:
|
|
401
|
+
targets = _ensure_object_list(constraint.object)
|
|
402
|
+
axis = str(constraint.params.get("axis", "")).lower()
|
|
403
|
+
center = float(constraint.params.get("center", 0.0))
|
|
404
|
+
|
|
405
|
+
axis_idx = _axis_to_index(axis)
|
|
406
|
+
if axis_idx is None:
|
|
407
|
+
warnings.append(f"MIRROR_SCENE unknown axis: {axis}")
|
|
408
|
+
return
|
|
409
|
+
|
|
410
|
+
for name in targets:
|
|
411
|
+
location = _get_location(placements, name, warnings)
|
|
412
|
+
if location is None:
|
|
413
|
+
continue
|
|
414
|
+
mirrored_name = f"{name}_mirrored"
|
|
415
|
+
mirrored_location = list(location)
|
|
416
|
+
mirrored_location[axis_idx] = 2 * center - location[axis_idx]
|
|
417
|
+
|
|
418
|
+
placements[mirrored_name] = _default_pose()
|
|
419
|
+
_set_location(placements, mirrored_name, mirrored_location, warnings)
|
|
420
|
+
_set_rotation(
|
|
421
|
+
placements,
|
|
422
|
+
mirrored_name,
|
|
423
|
+
_get_rotation(placements, name, warnings),
|
|
424
|
+
warnings,
|
|
425
|
+
)
|
|
426
|
+
_set_scale(
|
|
427
|
+
placements, mirrored_name, _get_scale(placements, name, warnings), warnings
|
|
428
|
+
)
|
|
429
|
+
|
|
430
|
+
if name in specs:
|
|
431
|
+
specs[mirrored_name] = specs[name]
|
|
432
|
+
else:
|
|
433
|
+
warnings.append(f"MIRROR_SCENE missing spec for {name}")
|
|
434
|
+
|
|
435
|
+
|
|
436
|
+
# ---------------------------------------------------------------------------
|
|
437
|
+
# Constraint helpers
|
|
438
|
+
# ---------------------------------------------------------------------------
|
|
439
|
+
|
|
440
|
+
|
|
441
|
+
def _as_vec3(value: Any, default: Vector3) -> Vector3:
|
|
442
|
+
if isinstance(value, (list, tuple)) and len(value) >= 3:
|
|
443
|
+
return [float(value[0]), float(value[1]), float(value[2])]
|
|
444
|
+
return list(default)
|
|
445
|
+
|
|
446
|
+
|
|
447
|
+
def _axis_to_index(axis: str) -> int | None:
|
|
448
|
+
if axis == "x":
|
|
449
|
+
return 0
|
|
450
|
+
if axis == "y":
|
|
451
|
+
return 1
|
|
452
|
+
if axis == "z":
|
|
453
|
+
return 2
|
|
454
|
+
return None
|
|
455
|
+
|
|
456
|
+
|
|
457
|
+
def _get_zone_bounds(
|
|
458
|
+
recipe: SceneRecipe,
|
|
459
|
+
zone_name: Any,
|
|
460
|
+
warnings: list[str],
|
|
461
|
+
) -> ZoneBounds | None:
|
|
462
|
+
if not isinstance(zone_name, str):
|
|
463
|
+
warnings.append("Zone name must be a string")
|
|
464
|
+
return None
|
|
465
|
+
|
|
466
|
+
zone = recipe.zones.get(zone_name)
|
|
467
|
+
if zone is None:
|
|
468
|
+
warnings.append(f"Unknown zone: {zone_name}")
|
|
469
|
+
return None
|
|
470
|
+
|
|
471
|
+
bounds = zone.get("bounds") if isinstance(zone, dict) else None
|
|
472
|
+
if not isinstance(bounds, dict):
|
|
473
|
+
warnings.append(f"Zone {zone_name} missing bounds")
|
|
474
|
+
return None
|
|
475
|
+
|
|
476
|
+
min_vec = _as_vec3(bounds.get("min"), default=[0.0, 0.0, 0.0])
|
|
477
|
+
max_vec = _as_vec3(bounds.get("max"), default=[0.0, 0.0, 0.0])
|
|
478
|
+
return ZoneBounds(min=min_vec, max=max_vec)
|
|
479
|
+
|
|
480
|
+
|
|
481
|
+
def _get_dimensions(
|
|
482
|
+
specs: dict[str, PartSpec],
|
|
483
|
+
name: str,
|
|
484
|
+
warnings: list[str],
|
|
485
|
+
) -> Dimensions | None:
|
|
486
|
+
spec = specs.get(name)
|
|
487
|
+
if spec is None:
|
|
488
|
+
warnings.append(f"Missing dimensions for {name}")
|
|
489
|
+
return None
|
|
490
|
+
return spec.dimensions
|
|
491
|
+
|
|
492
|
+
|
|
493
|
+
def _set_location(
|
|
494
|
+
placements: Placements,
|
|
495
|
+
name: str,
|
|
496
|
+
location: Vector3,
|
|
497
|
+
warnings: list[str],
|
|
498
|
+
) -> None:
|
|
499
|
+
pose = placements.get(name)
|
|
500
|
+
if pose is None:
|
|
501
|
+
warnings.append(f"Unknown object: {name}")
|
|
502
|
+
return
|
|
503
|
+
pose["location"] = [float(location[0]), float(location[1]), float(location[2])]
|
|
504
|
+
|
|
505
|
+
|
|
506
|
+
def _set_rotation(
|
|
507
|
+
placements: Placements,
|
|
508
|
+
name: str,
|
|
509
|
+
rotation: Vector3,
|
|
510
|
+
warnings: list[str],
|
|
511
|
+
) -> None:
|
|
512
|
+
pose = placements.get(name)
|
|
513
|
+
if pose is None:
|
|
514
|
+
warnings.append(f"Unknown object: {name}")
|
|
515
|
+
return
|
|
516
|
+
pose["rotation"] = [float(rotation[0]), float(rotation[1]), float(rotation[2])]
|
|
517
|
+
|
|
518
|
+
|
|
519
|
+
def _set_scale(
|
|
520
|
+
placements: Placements,
|
|
521
|
+
name: str,
|
|
522
|
+
scale: Vector3,
|
|
523
|
+
warnings: list[str],
|
|
524
|
+
) -> None:
|
|
525
|
+
pose = placements.get(name)
|
|
526
|
+
if pose is None:
|
|
527
|
+
warnings.append(f"Unknown object: {name}")
|
|
528
|
+
return
|
|
529
|
+
pose["scale"] = [float(scale[0]), float(scale[1]), float(scale[2])]
|
|
530
|
+
|
|
531
|
+
|
|
532
|
+
def _get_location(
|
|
533
|
+
placements: Placements,
|
|
534
|
+
name: str,
|
|
535
|
+
warnings: list[str],
|
|
536
|
+
) -> Vector3 | None:
|
|
537
|
+
pose = placements.get(name)
|
|
538
|
+
if pose is None:
|
|
539
|
+
warnings.append(f"Unknown object: {name}")
|
|
540
|
+
return None
|
|
541
|
+
return list(pose["location"])
|
|
542
|
+
|
|
543
|
+
|
|
544
|
+
def _get_rotation(
|
|
545
|
+
placements: Placements,
|
|
546
|
+
name: str,
|
|
547
|
+
warnings: list[str],
|
|
548
|
+
) -> Vector3:
|
|
549
|
+
pose = placements.get(name)
|
|
550
|
+
if pose is None:
|
|
551
|
+
warnings.append(f"Unknown object: {name}")
|
|
552
|
+
return [0.0, 0.0, 0.0]
|
|
553
|
+
return list(pose["rotation"])
|
|
554
|
+
|
|
555
|
+
|
|
556
|
+
def _get_scale(
|
|
557
|
+
placements: Placements,
|
|
558
|
+
name: str,
|
|
559
|
+
warnings: list[str],
|
|
560
|
+
) -> Vector3:
|
|
561
|
+
pose = placements.get(name)
|
|
562
|
+
if pose is None:
|
|
563
|
+
warnings.append(f"Unknown object: {name}")
|
|
564
|
+
return [1.0, 1.0, 1.0]
|
|
565
|
+
return list(pose["scale"])
|
|
566
|
+
|
|
567
|
+
|
|
568
|
+
def _is_far_enough(
|
|
569
|
+
position: Vector3,
|
|
570
|
+
placements: Placements,
|
|
571
|
+
min_spacing: float,
|
|
572
|
+
) -> bool:
|
|
573
|
+
if min_spacing <= 0:
|
|
574
|
+
return True
|
|
575
|
+
for pose in placements.values():
|
|
576
|
+
distance = _distance(position, pose["location"])
|
|
577
|
+
if distance < min_spacing:
|
|
578
|
+
return False
|
|
579
|
+
return True
|
|
580
|
+
|
|
581
|
+
|
|
582
|
+
def _iter_locations(names: Iterable[str], placements: Placements) -> Iterable[Vector3]:
|
|
583
|
+
for name in names:
|
|
584
|
+
pose = placements.get(name)
|
|
585
|
+
if pose is not None:
|
|
586
|
+
yield list(pose["location"])
|