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.
@@ -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"])