bindings.py
1 """ 2 DreamTalk Declarative Bindings (v2.0) 3 4 A minimal, declarative syntax for expressing parameter relationships. 5 Replaces XPresso with Python-native binding expressions. 6 7 Usage (specify_relationships style): 8 class MyObject(CustomObject): 9 radius = ULength(default=100) 10 11 def specify_parts(self): 12 self.circle = Circle() 13 self.parts = [self.circle] 14 15 def specify_relationships(self): 16 self.circle.radius << self.radius # Identity 17 self.circle.x << self.radius * 0.5 # Formula 18 19 Usage (inline binding style - Phase 3): 20 class MyObject(Holon): 21 radius: Length = 100 22 23 def specify_parts(self): 24 # Bind circle radius to parent's radius parameter 25 self.circle = Circle(radius=self.radius_parameter >> 100) 26 self.parts = [self.circle] 27 28 The << operator creates a Binding for relationships. 29 The >> operator creates a BoundValue for inline bindings in constructors. 30 """ 31 32 import math 33 from typing import Any, List, Union, Optional 34 35 36 class BindingExpression: 37 """ 38 Represents a mathematical expression involving parameters. 39 Supports operator overloading to build expression trees. 40 """ 41 42 def __init__(self, expr_str: str, dependencies: List[str] = None): 43 self.expr_str = expr_str 44 self.dependencies = dependencies or [] 45 46 def __repr__(self): 47 return f"Expr({self.expr_str})" 48 49 # Arithmetic operators - build expression strings 50 def __add__(self, other): 51 other_expr, other_deps = self._unwrap(other) 52 return BindingExpression( 53 f"({self.expr_str} + {other_expr})", 54 self.dependencies + other_deps 55 ) 56 57 def __radd__(self, other): 58 other_expr, other_deps = self._unwrap(other) 59 return BindingExpression( 60 f"({other_expr} + {self.expr_str})", 61 other_deps + self.dependencies 62 ) 63 64 def __sub__(self, other): 65 other_expr, other_deps = self._unwrap(other) 66 return BindingExpression( 67 f"({self.expr_str} - {other_expr})", 68 self.dependencies + other_deps 69 ) 70 71 def __rsub__(self, other): 72 other_expr, other_deps = self._unwrap(other) 73 return BindingExpression( 74 f"({other_expr} - {self.expr_str})", 75 other_deps + self.dependencies 76 ) 77 78 def __mul__(self, other): 79 other_expr, other_deps = self._unwrap(other) 80 return BindingExpression( 81 f"({self.expr_str} * {other_expr})", 82 self.dependencies + other_deps 83 ) 84 85 def __rmul__(self, other): 86 other_expr, other_deps = self._unwrap(other) 87 return BindingExpression( 88 f"({other_expr} * {self.expr_str})", 89 other_deps + self.dependencies 90 ) 91 92 def __truediv__(self, other): 93 other_expr, other_deps = self._unwrap(other) 94 return BindingExpression( 95 f"({self.expr_str} / {other_expr})", 96 self.dependencies + other_deps 97 ) 98 99 def __rtruediv__(self, other): 100 other_expr, other_deps = self._unwrap(other) 101 return BindingExpression( 102 f"({other_expr} / {self.expr_str})", 103 other_deps + self.dependencies 104 ) 105 106 def __neg__(self): 107 return BindingExpression(f"(-{self.expr_str})", self.dependencies) 108 109 def __pow__(self, other): 110 other_expr, other_deps = self._unwrap(other) 111 return BindingExpression( 112 f"({self.expr_str} ** {other_expr})", 113 self.dependencies + other_deps 114 ) 115 116 def __rshift__(self, default_value): 117 """ 118 Create a BoundValue for inline binding syntax. 119 120 Usage: 121 Circle(radius=(self.size_parameter * 0.5) >> 50) 122 123 This allows binding expressions (not just parameters) to be 124 used with inline binding syntax. 125 """ 126 return BoundValue(expression=self, default=default_value) 127 128 def _unwrap(self, other): 129 """Extract expression string and dependencies from any value.""" 130 if isinstance(other, BindingExpression): 131 return other.expr_str, other.dependencies 132 elif isinstance(other, ParameterRef): 133 return other.to_expr().expr_str, other.to_expr().dependencies 134 else: 135 return str(other), [] 136 137 138 class ParameterRef: 139 """ 140 Reference to a UserData parameter on the parent object. 141 Created when accessing a parameter in a relationship context. 142 """ 143 144 def __init__(self, name: str): 145 self.name = name 146 147 def to_expr(self) -> BindingExpression: 148 """Convert to expression that reads this parameter.""" 149 return BindingExpression( 150 f'get_userdata_by_name(op, "{self.name}")', 151 [self.name] 152 ) 153 154 def __repr__(self): 155 return f"Param({self.name})" 156 157 # >> operator for inline bindings: self.param >> default_value 158 def __rshift__(self, default_value): 159 """ 160 Create a BoundValue for inline binding syntax. 161 162 Usage in part constructors: 163 Circle(radius=self.radius_parameter >> 100) 164 165 This means: bind the circle's radius to the parent's radius parameter, 166 using 100 as the default/initial value. 167 """ 168 return BoundValue(expression=self.to_expr(), default=default_value, param_name=self.name) 169 170 # Delegate arithmetic to BindingExpression 171 def __add__(self, other): 172 return self.to_expr() + other 173 174 def __radd__(self, other): 175 return other + self.to_expr() 176 177 def __sub__(self, other): 178 return self.to_expr() - other 179 180 def __rsub__(self, other): 181 return other - self.to_expr() 182 183 def __mul__(self, other): 184 return self.to_expr() * other 185 186 def __rmul__(self, other): 187 return other * self.to_expr() 188 189 def __truediv__(self, other): 190 return self.to_expr() / other 191 192 def __rtruediv__(self, other): 193 return other / self.to_expr() 194 195 def __neg__(self): 196 return -self.to_expr() 197 198 def __pow__(self, other): 199 return self.to_expr() ** other 200 201 202 class BoundValue: 203 """ 204 A value bound to a parameter expression, for inline binding in constructors. 205 206 Created via the >> operator: 207 Circle(radius=self.radius_parameter >> 100) 208 209 This stores both the binding expression and the default value. 210 When parts are instantiated, ProtoObject detects BoundValues and: 211 1. Uses the default value for initialization 212 2. Stores the binding for later collection by the parent Holon 213 214 Attributes: 215 expression: The BindingExpression that computes the value 216 default: The default/initial value to use 217 param_name: Name of the source parameter (for debugging) 218 target_property: Set by the part constructor to record what property this binds to 219 """ 220 221 def __init__(self, expression: BindingExpression, default, param_name: str = None): 222 self.expression = expression 223 self.default = default 224 self.param_name = param_name 225 self.target_property = None # Set when binding is collected 226 227 def __repr__(self): 228 return f"BoundValue({self.param_name} >> {self.default})" 229 230 # Support arithmetic operations to allow expressions like: 231 # Circle(radius=(self.size_parameter * 0.5) >> 50) 232 def __add__(self, other): 233 other_expr, other_deps = self._unwrap(other) 234 new_expr = BindingExpression( 235 f"({self.expression.expr_str} + {other_expr})", 236 self.expression.dependencies + other_deps 237 ) 238 return BoundValue(new_expr, self.default + other if isinstance(other, (int, float)) else self.default, self.param_name) 239 240 def __mul__(self, other): 241 other_expr, other_deps = self._unwrap(other) 242 new_expr = BindingExpression( 243 f"({self.expression.expr_str} * {other_expr})", 244 self.expression.dependencies + other_deps 245 ) 246 return BoundValue(new_expr, self.default * other if isinstance(other, (int, float)) else self.default, self.param_name) 247 248 def __rmul__(self, other): 249 return self.__mul__(other) 250 251 def _unwrap(self, other): 252 """Extract expression string and dependencies from any value.""" 253 if isinstance(other, BindingExpression): 254 return other.expr_str, other.dependencies 255 elif isinstance(other, ParameterRef): 256 return other.to_expr().expr_str, other.to_expr().dependencies 257 elif isinstance(other, BoundValue): 258 return other.expression.expr_str, other.expression.dependencies 259 else: 260 return str(other), [] 261 262 263 class PropertyTarget: 264 """ 265 Target for a binding - a property on a child object. 266 Receives values via the << operator. 267 """ 268 269 # Class-level collector for auto-registration 270 _active_collector = None 271 272 def __init__(self, part_name: str, property_name: str, c4d_attr: str = None, 273 vector_component: str = None, setter_template: str = None): 274 self.part_name = part_name 275 self.property_name = property_name 276 self.c4d_attr = c4d_attr # e.g., "c4d.PRIM_CIRCLE_RADIUS" 277 self.vector_component = vector_component # e.g., "x", "y", "z" for position 278 self.setter_template = setter_template # Code template with {value} placeholder 279 self._binding = None 280 281 def __lshift__(self, expr): 282 """The << operator - creates a binding from expression to this target.""" 283 if isinstance(expr, ParameterRef): 284 expr = expr.to_expr() 285 elif isinstance(expr, (int, float)): 286 expr = BindingExpression(str(expr), []) 287 288 binding = Binding(target=self, expression=expr) 289 self._binding = binding 290 291 # Auto-register with active collector if one exists 292 if PropertyTarget._active_collector is not None: 293 PropertyTarget._active_collector.add_binding(binding) 294 295 return binding 296 297 def __repr__(self): 298 return f"Target({self.part_name}.{self.property_name})" 299 300 @classmethod 301 def set_collector(cls, collector): 302 """Set the active collector for auto-registration.""" 303 cls._active_collector = collector 304 305 @classmethod 306 def clear_collector(cls): 307 """Clear the active collector.""" 308 cls._active_collector = None 309 310 311 class Binding: 312 """ 313 A complete binding: target property <- expression. 314 Collected by CustomObject to generate code. 315 """ 316 317 def __init__(self, target: PropertyTarget, expression: BindingExpression): 318 self.target = target 319 self.expression = expression 320 321 def __repr__(self): 322 return f"Binding({self.target} << {self.expression})" 323 324 def to_code(self, indent: int = 4) -> str: 325 """Generate the Python code for this binding.""" 326 ind = " " * indent 327 comment = f"{ind}# {self.target.part_name}.{self.target.property_name} << ..." 328 329 # Use setter template if available (for position/rotation vector components) 330 if self.target.setter_template: 331 setter_code = self.target.setter_template.format(value=self.expression.expr_str) 332 return f'''{comment} 333 {ind}child = find_child_by_name(op, "{self.target.part_name}") 334 {ind}if child: 335 {ind} {setter_code}''' 336 337 # Handle UserData parameters 338 if self.target.c4d_attr and self.target.c4d_attr.startswith('userdata:'): 339 param_name = self.target.c4d_attr.split(':')[1] 340 return f'''{comment} 341 {ind}child = find_child_by_name(op, "{self.target.part_name}") 342 {ind}if child: 343 {ind} set_userdata_by_name(child, "{param_name}", {self.expression.expr_str})''' 344 345 # Default: direct property access 346 return f'''{comment} 347 {ind}child = find_child_by_name(op, "{self.target.part_name}") 348 {ind}if child: 349 {ind} child[{self.target.c4d_attr}] = {self.expression.expr_str}''' 350 351 352 class PartProxy: 353 """ 354 Proxy object for accessing part properties in relationship definitions. 355 Created when accessing self.<part_name> in specify_relationships(). 356 """ 357 358 # Common property mappings to C4D attributes 359 # Format: property_name -> (c4d_constant, is_vector_component, setter_code_template) 360 # Note: For primitive properties on LineObjects/SolidObjects, we need to find the nested 361 # spline/mesh inside the StrokeGen wrapper. The setter template handles this. 362 PROPERTY_MAP = { 363 # Position (vector components need special handling) 364 'x': ('c4d.ID_BASEOBJECT_POSITION', 'x', 'pos = child.GetRelPos(); pos.x = {value}; child.SetRelPos(pos)'), 365 'y': ('c4d.ID_BASEOBJECT_POSITION', 'y', 'pos = child.GetRelPos(); pos.y = {value}; child.SetRelPos(pos)'), 366 'z': ('c4d.ID_BASEOBJECT_POSITION', 'z', 'pos = child.GetRelPos(); pos.z = {value}; child.SetRelPos(pos)'), 367 # Rotation 368 'h': ('c4d.ID_BASEOBJECT_ROTATION', 'x', 'rot = child.GetRelRot(); rot.x = {value}; child.SetRelRot(rot)'), 369 'p': ('c4d.ID_BASEOBJECT_ROTATION', 'y', 'rot = child.GetRelRot(); rot.y = {value}; child.SetRelRot(rot)'), 370 'b': ('c4d.ID_BASEOBJECT_ROTATION', 'z', 'rot = child.GetRelRot(); rot.z = {value}; child.SetRelRot(rot)'), 371 # Scale 372 'scale_x': ('c4d.ID_BASEOBJECT_SCALE', 'x', 'scale = child.GetRelScale(); scale.x = {value}; child.SetRelScale(scale)'), 373 'scale_y': ('c4d.ID_BASEOBJECT_SCALE', 'y', 'scale = child.GetRelScale(); scale.y = {value}; child.SetRelScale(scale)'), 374 'scale_z': ('c4d.ID_BASEOBJECT_SCALE', 'z', 'scale = child.GetRelScale(); scale.z = {value}; child.SetRelScale(scale)'), 375 # Circle primitive - look for nested spline if child is a generator 376 'radius': ('c4d.PRIM_CIRCLE_RADIUS', None, 'spline = child.GetDown() if child.GetType() == 1023866 else child; spline[c4d.PRIM_CIRCLE_RADIUS] = {value}'), 377 # Rectangle primitive 378 'width': ('c4d.PRIM_RECTANGLE_WIDTH', None, 'spline = child.GetDown() if child.GetType() == 1023866 else child; spline[c4d.PRIM_RECTANGLE_WIDTH] = {value}'), 379 'height': ('c4d.PRIM_RECTANGLE_HEIGHT', None, 'spline = child.GetDown() if child.GetType() == 1023866 else child; spline[c4d.PRIM_RECTANGLE_HEIGHT] = {value}'), 380 # Sphere primitive 381 'sphere_radius': ('c4d.PRIM_SPHERE_RAD', None, 'mesh = child.GetDown() if child.GetType() == 1023866 else child; mesh[c4d.PRIM_SPHERE_RAD] = {value}'), 382 # Camera 383 'zoom': ('c4d.CAMERA_ZOOM', None, 'child[c4d.CAMERA_ZOOM] = {value}'), 384 } 385 386 def __init__(self, part_name: str, part_obj=None): 387 self.part_name = part_name 388 self.part_obj = part_obj 389 self._bindings = [] 390 391 def __getattr__(self, name: str) -> 'PropertyTarget': 392 """Access a property on this part, returning a bindable target.""" 393 if name.startswith('_'): 394 raise AttributeError(name) 395 396 prop_info = self.PROPERTY_MAP.get(name) 397 398 if prop_info: 399 c4d_attr, vector_comp, setter_template = prop_info 400 else: 401 # Check if part has desc_ids 402 if self.part_obj and hasattr(self.part_obj, 'desc_ids') and name in self.part_obj.desc_ids: 403 c4d_attr = f"self.{self.part_name}.desc_ids['{name}']" 404 else: 405 # Fallback - assume it's a UserData parameter on the child 406 c4d_attr = f'userdata:{name}' 407 vector_comp = None 408 setter_template = None 409 410 target = PropertyTarget(self.part_name, name, c4d_attr, vector_comp, setter_template) 411 return target 412 413 414 # Math functions that work with BindingExpressions 415 def sin(x) -> BindingExpression: 416 if isinstance(x, BindingExpression): 417 return BindingExpression(f"math.sin({x.expr_str})", x.dependencies) 418 elif isinstance(x, ParameterRef): 419 expr = x.to_expr() 420 return BindingExpression(f"math.sin({expr.expr_str})", expr.dependencies) 421 else: 422 return BindingExpression(f"math.sin({x})", []) 423 424 def cos(x) -> BindingExpression: 425 if isinstance(x, BindingExpression): 426 return BindingExpression(f"math.cos({x.expr_str})", x.dependencies) 427 elif isinstance(x, ParameterRef): 428 expr = x.to_expr() 429 return BindingExpression(f"math.cos({expr.expr_str})", expr.dependencies) 430 else: 431 return BindingExpression(f"math.cos({x})", []) 432 433 def sqrt(x) -> BindingExpression: 434 if isinstance(x, BindingExpression): 435 return BindingExpression(f"math.sqrt({x.expr_str})", x.dependencies) 436 elif isinstance(x, ParameterRef): 437 expr = x.to_expr() 438 return BindingExpression(f"math.sqrt({expr.expr_str})", expr.dependencies) 439 else: 440 return BindingExpression(f"math.sqrt({x})", []) 441 442 443 class BindingCollector: 444 """ 445 Collects bindings created during specify_relationships(). 446 Used by CustomObject to gather bindings and compile them to generator code. 447 """ 448 449 def __init__(self): 450 self.bindings: List[Binding] = [] 451 self._part_proxies = {} 452 self._param_refs = {} 453 454 def get_part_proxy(self, name: str, part_obj=None) -> PartProxy: 455 """Get or create a proxy for a part.""" 456 if name not in self._part_proxies: 457 self._part_proxies[name] = PartProxy(name, part_obj) 458 return self._part_proxies[name] 459 460 def get_param_ref(self, name: str) -> ParameterRef: 461 """Get or create a reference to a parameter.""" 462 if name not in self._param_refs: 463 self._param_refs[name] = ParameterRef(name) 464 return self._param_refs[name] 465 466 def add_binding(self, binding: Binding): 467 """Add a binding to the collection.""" 468 self.bindings.append(binding) 469 470 def compile_to_generator_code(self) -> str: 471 """Compile collected bindings into generator Python code.""" 472 if not self.bindings: 473 return None 474 475 # Collect all parameter dependencies 476 all_params = set() 477 for binding in self.bindings: 478 all_params.update(binding.expression.dependencies) 479 480 # Generate parameter reading code 481 param_code = [] 482 for param in sorted(all_params): 483 # Use lowercase variable name for the local 484 var_name = param.lower().replace(' ', '_') 485 param_code.append(f' {var_name} = get_userdata_by_name(op, "{param}") or 0.0') 486 487 # Generate binding code 488 binding_code = [] 489 for binding in self.bindings: 490 binding_code.append(binding.to_code(indent=4)) 491 492 code = '''def main(): 493 # Read parameters 494 ''' 495 if param_code: 496 code += '\n'.join(param_code) + '\n' 497 498 code += ''' 499 # Apply bindings 500 ''' 501 code += '\n'.join(binding_code) 502 code += ''' 503 504 return None 505 ''' 506 return code 507 508 509 class RelationshipContext: 510 """ 511 Context for defining relationships between parameters and parts. 512 513 Provides a clean namespace where: 514 - Accessing self.<param> returns a ParameterRef 515 - Accessing self.<part> returns a PartProxy 516 - Using << creates bindings that get collected 517 518 Usage in CustomObject: 519 def specify_relationships(self): 520 self.circle.radius << self.radius 521 self.circle.x << self.distance * cos(PI/6) 522 """ 523 524 def __init__(self, custom_object, collector: BindingCollector): 525 object.__setattr__(self, '_custom_object', custom_object) 526 object.__setattr__(self, '_collector', collector) 527 object.__setattr__(self, '_part_names', set()) 528 object.__setattr__(self, '_param_names', set()) 529 530 # Discover parts (objects with .obj attribute that are children) 531 for attr_name in dir(custom_object): 532 if attr_name.startswith('_'): 533 continue 534 try: 535 attr = getattr(custom_object, attr_name) 536 # Check if it's a part (has .obj attribute - is a DreamTalk object) 537 if hasattr(attr, 'obj'): 538 self._part_names.add(attr_name) 539 # Check if it's a parameter (has .desc_id or is a parameter class) 540 elif hasattr(attr, 'desc_id') or (hasattr(attr, 'name') and hasattr(attr, 'default_value')): 541 self._param_names.add(attr_name) 542 except: 543 pass 544 545 # Also check the parameters list 546 if hasattr(custom_object, 'parameters'): 547 for param in custom_object.parameters: 548 if hasattr(param, 'name'): 549 # Store by name for lookup 550 self._param_names.add(param.name) 551 552 def __getattr__(self, name: str): 553 """Return a PartProxy or ParameterRef based on what's being accessed.""" 554 custom_obj = object.__getattribute__(self, '_custom_object') 555 collector = object.__getattribute__(self, '_collector') 556 part_names = object.__getattribute__(self, '_part_names') 557 param_names = object.__getattribute__(self, '_param_names') 558 559 # Check if it's a known part 560 if name in part_names: 561 part_obj = getattr(custom_obj, name, None) 562 return collector.get_part_proxy(name, part_obj) 563 564 # Check if it's a known parameter (by attribute name) 565 if name in param_names: 566 # Get the actual parameter name from the parameter object 567 param_obj = getattr(custom_obj, name, None) 568 if hasattr(param_obj, 'name'): 569 return collector.get_param_ref(param_obj.name) 570 return collector.get_param_ref(name) 571 572 # Check if accessing a parameter by its name directly 573 # e.g., self.Radius where the parameter's name is "Radius" 574 if hasattr(custom_obj, 'parameters'): 575 for param in custom_obj.parameters: 576 if hasattr(param, 'name') and param.name == name: 577 return collector.get_param_ref(name) 578 579 # Fall back to actual attribute on custom object 580 return getattr(custom_obj, name) 581 582 583 # Convenience: PI constant that works in expressions 584 PI = math.pi 585 586 587 def collect_relationships(custom_object, specify_relationships_func) -> str: 588 """ 589 Execute specify_relationships() and collect bindings into generator code. 590 591 The key insight is that we can't easily intercept `self.part.prop << self.param` 592 inside the method. Instead, we temporarily replace the object's attributes 593 with proxies before calling the method. 594 595 Args: 596 custom_object: The CustomObject instance 597 specify_relationships_func: The specify_relationships method (bound) 598 599 Returns: 600 Generated Python code string for the generator, or None if no bindings 601 """ 602 collector = BindingCollector() 603 604 # Set up the collector for auto-registration 605 PropertyTarget.set_collector(collector) 606 607 # Store original attributes so we can restore them 608 original_attrs = {} 609 610 try: 611 # Discover and replace parts with proxies 612 for attr_name in list(vars(custom_object).keys()): 613 if attr_name.startswith('_'): 614 continue 615 attr = getattr(custom_object, attr_name) 616 # Check if it's a part (has .obj attribute - is a DreamTalk object) 617 if hasattr(attr, 'obj'): 618 original_attrs[attr_name] = attr 619 # Get the actual name the part uses in the hierarchy 620 part_name = attr.obj.GetName() if hasattr(attr.obj, 'GetName') else attr_name 621 setattr(custom_object, attr_name, collector.get_part_proxy(part_name, attr)) 622 623 # Replace parameters with refs 624 for attr_name in list(vars(custom_object).keys()): 625 if attr_name.startswith('_'): 626 continue 627 attr = original_attrs.get(attr_name) or getattr(custom_object, attr_name, None) 628 # Check if it's a parameter 629 if hasattr(attr, 'name') and hasattr(attr, 'default_value'): 630 if attr_name not in original_attrs: 631 original_attrs[attr_name] = attr 632 setattr(custom_object, attr_name, collector.get_param_ref(attr.name)) 633 634 # Now call the method - it will use our proxies 635 specify_relationships_func() 636 637 finally: 638 # Restore original attributes 639 for attr_name, attr in original_attrs.items(): 640 setattr(custom_object, attr_name, attr) 641 642 # Always clean up 643 PropertyTarget.clear_collector() 644 645 return collector.compile_to_generator_code() 646 647 648 def extract_bound_values(kwargs: dict) -> tuple: 649 """ 650 Extract BoundValues from constructor kwargs. 651 652 Returns: 653 (clean_kwargs, bindings_list) 654 - clean_kwargs: dict with BoundValues replaced by their defaults 655 - bindings_list: list of (property_name, BoundValue) tuples 656 657 Usage in part constructors: 658 def __init__(self, radius=100, **kwargs): 659 kwargs, bindings = extract_bound_values({'radius': radius, **kwargs}) 660 radius = kwargs.pop('radius', 100) 661 # ... use radius as the value 662 # Store bindings on self._pending_bindings for collection 663 """ 664 clean_kwargs = {} 665 bindings = [] 666 667 for key, value in kwargs.items(): 668 if isinstance(value, BoundValue): 669 # Replace with default value 670 clean_kwargs[key] = value.default 671 # Record the binding 672 value.target_property = key 673 bindings.append((key, value)) 674 else: 675 clean_kwargs[key] = value 676 677 return clean_kwargs, bindings 678 679 680 def collect_inline_bindings(holon, parts: list) -> str: 681 """ 682 Collect inline bindings from parts and compile them to generator code. 683 684 Called by CustomObject after specify_parts() to gather any bindings 685 that were created via the >> inline syntax. 686 687 Args: 688 holon: The parent Holon/CustomObject 689 parts: List of parts to scan for bindings 690 691 Returns: 692 Generated Python code string, or None if no bindings 693 """ 694 collector = BindingCollector() 695 696 for part in parts: 697 # Check if part has pending bindings 698 pending = getattr(part, '_pending_bindings', []) 699 for prop_name, bound_value in pending: 700 # Get the part's name in the hierarchy 701 part_name = part.obj.GetName() if hasattr(part, 'obj') and hasattr(part.obj, 'GetName') else part.__class__.__name__ 702 703 # Look up property mapping 704 prop_info = PartProxy.PROPERTY_MAP.get(prop_name) 705 if prop_info: 706 c4d_attr, vector_comp, setter_template = prop_info 707 else: 708 # Assume UserData 709 c4d_attr = f'userdata:{prop_name}' 710 vector_comp = None 711 setter_template = None 712 713 # Create PropertyTarget and Binding 714 target = PropertyTarget(part_name, prop_name, c4d_attr, vector_comp, setter_template) 715 binding = Binding(target=target, expression=bound_value.expression) 716 collector.add_binding(binding) 717 718 return collector.compile_to_generator_code()