/ generator.py
generator.py
1 """ 2 Generator-based relationship system for MoGraph compatibility. 3 4 This module enables DreamTalk CustomObjects to work with MoGraph Cloners 5 by automatically translating XPresso relationships into Python Generator code. 6 7 Key insight: Instead of XPresso nodes that store object references (which break 8 when cloned), we use Python Generators that modify their children based on 9 UserData parameters. The relationship logic lives in plain Python code. 10 11 Usage: 12 # Simple - just pass generator_mode=True to any CustomObject 13 virus = MindVirus(color=BLUE, generator_mode=True) 14 15 # The library automatically: 16 # 1. Converts the object to a Python Generator 17 # 2. Translates specify_relations() XPresso patterns to Python code 18 # 3. Recursively converts child CustomObjects that also have GeneratorMixin 19 """ 20 21 import c4d 22 import math 23 24 # Standard imports that generator code will need 25 GENERATOR_IMPORTS = '''import c4d 26 import math 27 PI = math.pi 28 29 def get_userdata_by_name(obj, param_name): 30 """Find UserData value by parameter name.""" 31 ud = obj.GetUserDataContainer() 32 for desc_id, bc in ud: 33 if bc[c4d.DESC_NAME] == param_name: 34 try: 35 return obj[desc_id] 36 except: 37 return None 38 return None 39 ''' 40 41 42 class GeneratorMixin: 43 """ 44 Mixin class that adds generator-based relationship support to CustomObjects. 45 46 When mixed into a CustomObject, the library can automatically: 47 - Convert XIdentity/XRelation patterns to generator code 48 - Create Python Generator objects instead of Null + XPresso 49 - Recursively convert child CustomObjects 50 51 The generator pattern: 52 1. Generator reads UserData parameters 53 2. Generator modifies children's properties (rotation, position, visibility, etc.) 54 3. Generator returns None (children ARE the output) 55 4. Works inside MoGraph Cloners - each clone gets unique values from op.GetMg() 56 """ 57 58 def _auto_generate_code_from_relations(self): 59 """ 60 Automatically generate Python code from specify_relations() patterns. 61 62 Introspects the XPresso relations defined on this object and generates 63 equivalent Python generator code. 64 65 Returns: 66 str: Python code for main() function, or empty string if no relations 67 """ 68 if not hasattr(self, 'relations') or not self.relations: 69 # No relations defined, return minimal pass-through code 70 return ''' 71 def main(): 72 return None 73 ''' 74 75 code_lines = [] 76 code_lines.append('def main():') 77 78 # Track which parameters we need to read 79 params_to_read = set() 80 child_updates = [] 81 82 for relation in self.relations: 83 rel_class = relation.__class__.__name__ 84 85 if rel_class == 'XIdentity': 86 # XIdentity: pass parameter value directly to child 87 # relation.parameter = source param on whole (self) 88 # relation.part = target child object 89 # relation.desc_ids = target parameter(s) on child 90 91 param = relation.parameter 92 part = relation.part 93 target_desc_ids = relation.desc_ids 94 95 # Build parameter read 96 param_name = param.name.lower().replace(' ', '_') 97 params_to_read.add((param_name, param)) 98 99 # Build child update 100 child_name = part.obj.GetName() if hasattr(part, 'obj') else str(part) 101 for desc_id in target_desc_ids: 102 child_updates.append((child_name, desc_id, param_name, None)) 103 104 elif rel_class == 'XRelation': 105 # XRelation: apply formula to parameter before passing to child 106 param = relation.parameters[0] if relation.parameters else None 107 if param: 108 param_name = param.name.lower().replace(' ', '_') 109 params_to_read.add((param_name, param)) 110 111 part = relation.part 112 child_name = part.obj.GetName() if hasattr(part, 'obj') else str(part) 113 formula = relation.formula if hasattr(relation, 'formula') else None 114 115 for desc_id in relation.desc_ids: 116 child_updates.append((child_name, desc_id, param_name, formula)) 117 118 # Generate parameter reading code 119 for param_name, param in params_to_read: 120 # Use name-based lookup for robustness 121 code_lines.append(f' # Read {param.name} parameter') 122 code_lines.append(f' {param_name} = get_userdata_by_name(op, "{param.name}")') 123 code_lines.append(f' if {param_name} is None:') 124 code_lines.append(f' {param_name} = 0.0 # fallback') 125 code_lines.append('') 126 127 # Generate child traversal and update code 128 if child_updates: 129 code_lines.append(' # Update children') 130 code_lines.append(' child = op.GetDown()') 131 code_lines.append(' while child:') 132 133 # Group updates by child name 134 updates_by_child = {} 135 for child_name, desc_id, param_name, formula in child_updates: 136 if child_name not in updates_by_child: 137 updates_by_child[child_name] = [] 138 updates_by_child[child_name].append((desc_id, param_name, formula)) 139 140 for child_name, updates in updates_by_child.items(): 141 code_lines.append(f' if child.GetName() == "{child_name}":') 142 for desc_id, param_name, formula in updates: 143 # Determine what value to set 144 if formula: 145 # Apply formula - replace parameter name with variable 146 value_expr = formula.replace(param_name.replace('_', ' ').title(), param_name) 147 value_expr = value_expr.replace('PI', 'PI') 148 else: 149 value_expr = param_name 150 151 # Determine target - is it a UserData param or a built-in? 152 if hasattr(desc_id, '__iter__') and len(desc_id) == 2: 153 # Likely a rotation or position component 154 # Check if it's ROT_P, ROT_B, etc. 155 code_lines.append(f' # Set parameter via UserData') 156 code_lines.append(f' child[c4d.DescID(c4d.DescLevel(c4d.ID_USERDATA, c4d.DTYPE_SUBCONTAINER, 0),') 157 code_lines.append(f' c4d.DescLevel(1, c4d.DTYPE_REAL, 0))] = {value_expr}') 158 else: 159 code_lines.append(f' child[c4d.DescID(c4d.DescLevel(c4d.ID_USERDATA, c4d.DTYPE_SUBCONTAINER, 0),') 160 code_lines.append(f' c4d.DescLevel(1, c4d.DTYPE_REAL, 0))] = {value_expr}') 161 162 code_lines.append(' child = child.GetNext()') 163 164 code_lines.append('') 165 code_lines.append(' return None') 166 167 return '\n'.join(code_lines) 168 169 def _build_generator_code(self): 170 """Build complete generator code including imports.""" 171 # PREFER manual specify_generator_code if defined (more reliable) 172 if hasattr(self, 'specify_generator_code'): 173 user_code = self.specify_generator_code() 174 if user_code and user_code.strip(): 175 return GENERATOR_IMPORTS + '\n' + user_code 176 177 # Fall back to auto-generation from relations 178 auto_code = self._auto_generate_code_from_relations() 179 if auto_code and 'child = op.GetDown()' in auto_code: 180 # Only use auto-generated code if it actually does something 181 return GENERATOR_IMPORTS + '\n' + auto_code 182 183 # Default: minimal pass-through 184 return GENERATOR_IMPORTS + ''' 185 def main(): 186 return None 187 ''' 188 189 def create_as_generator(self, recursive=True): 190 """ 191 Create this object as a Python Generator instead of a Null with XPresso. 192 193 Args: 194 recursive: If True, also convert child CustomObjects with GeneratorMixin 195 196 Returns: 197 c4d.BaseObject: A Python Generator object 198 """ 199 # Create the generator object 200 gen = c4d.BaseObject(1023866) # Python Generator 201 gen.SetName(self.obj.GetName() if hasattr(self, 'obj') else self.__class__.__name__) 202 203 # Set the code 204 gen[c4d.OPYTHON_CODE] = self._build_generator_code() 205 gen[c4d.OPYTHON_OPTIMIZE] = False # Critical for MoGraph! 206 207 # Copy UserData from self.obj to generator 208 self._copy_userdata_to_generator(gen) 209 210 # Move/convert children from self.obj to generator 211 self._move_children_to_generator(gen, recursive=recursive) 212 213 return gen 214 215 def _copy_userdata_to_generator(self, gen): 216 """Copy UserData definitions from self.obj to the generator.""" 217 if not hasattr(self, 'obj'): 218 return 219 220 # Get UserData container from source 221 ud = self.obj.GetUserDataContainer() 222 for desc_id, bc in ud: 223 # Skip group headers 224 if bc[c4d.DESC_CUSTOMGUI] == c4d.CUSTOMGUI_SEPARATOR: 225 continue 226 # Add to generator 227 new_id = gen.AddUserData(bc) 228 # Copy the value 229 try: 230 gen[new_id] = self.obj[desc_id] 231 except: 232 pass 233 234 def _move_children_to_generator(self, gen, recursive=True): 235 """Move children from self.obj to the generator. 236 237 Args: 238 gen: The generator object to move children under 239 recursive: If True, convert child CustomObjects with GeneratorMixin to generators 240 """ 241 if not hasattr(self, 'obj'): 242 return 243 244 # Collect children and their DreamTalk wrapper objects 245 children_to_move = [] 246 child = self.obj.GetDown() 247 while child: 248 children_to_move.append(child) 249 child = child.GetNext() 250 251 # Check if we have parts that map to these children 252 parts_by_name = {} 253 if hasattr(self, 'parts'): 254 for part in self.parts: 255 if hasattr(part, 'obj'): 256 parts_by_name[part.obj.GetName()] = part 257 258 # Move/convert each child 259 for child_obj in children_to_move: 260 child_obj.Remove() 261 262 # Check if this child has a DreamTalk wrapper with GeneratorMixin 263 child_name = child_obj.GetName() 264 part = parts_by_name.get(child_name) 265 266 if recursive and part and hasattr(part, 'create_as_generator'): 267 # This child is a CustomObject with GeneratorMixin - convert it 268 child_gen = part.create_as_generator(recursive=True) 269 child_gen.InsertUnder(gen) 270 else: 271 # Regular child - just move it 272 child_obj.InsertUnder(gen) 273 274 275 def build_generator_from_class(cls, **kwargs): 276 """ 277 Factory function to create a generator version of a CustomObject class. 278 279 This creates an instance to get the children/structure, then 280 converts it to a generator. 281 282 Args: 283 cls: A CustomObject class that uses GeneratorMixin 284 **kwargs: Constructor arguments for the class 285 286 Returns: 287 c4d.BaseObject: A Python Generator with the object's structure 288 """ 289 # Create the normal instance first 290 instance = cls(**kwargs) 291 292 # Convert to generator 293 if hasattr(instance, 'create_as_generator'): 294 return instance.create_as_generator() 295 else: 296 raise TypeError(f"{cls.__name__} must use GeneratorMixin") 297 298 299 # === Relationship Helper Functions === 300 # These can be called from generator code to implement common patterns 301 302 def relationship_code_fold_axes(fold_param_id, axes_config): 303 """ 304 Generate code for folding axes based on a fold parameter. 305 306 Args: 307 fold_param_id: DescID tuple for the Fold parameter 308 axes_config: List of (axis_name, rotation_axis, direction) tuples 309 e.g., [("LeftAxis", "z", 1), ("RightAxis", "z", -1)] 310 311 Returns: 312 str: Python code for the main() function 313 """ 314 axis_code = [] 315 for name, rot_axis, direction in axes_config: 316 sign = '+' if direction > 0 else '-' 317 if rot_axis == 'x': 318 axis_code.append(f''' 319 if child.GetName() == "{name}": 320 child.SetRelRot(c4d.Vector({sign}angle, 0, 0))''') 321 elif rot_axis == 'y': 322 axis_code.append(f''' 323 if child.GetName() == "{name}": 324 child.SetRelRot(c4d.Vector(0, {sign}angle, 0))''') 325 elif rot_axis == 'z': 326 axis_code.append(f''' 327 if child.GetName() == "{name}": 328 child.SetRelRot(c4d.Vector(0, 0, {sign}angle))''') 329 330 axes_str = '\n'.join(axis_code) 331 332 return f''' 333 def main(): 334 # Read Fold parameter 335 fold = op[c4d.DescID({fold_param_id})] 336 angle = fold * PI / 2 # 0 to 90 degrees 337 338 # Modify axis children 339 child = op.GetDown() 340 while child:{axes_str} 341 child = child.GetNext() 342 343 return None 344 ''' 345 346 347 def relationship_code_visibility(visibility_param_id): 348 """ 349 Generate code for controlling visibility of children. 350 351 Args: 352 visibility_param_id: DescID tuple for the Visibility parameter 353 354 Returns: 355 str: Python code for the main() function 356 """ 357 return f''' 358 def main(): 359 visible = op[c4d.DescID({visibility_param_id})] 360 361 # Set visibility on all children 362 child = op.GetDown() 363 while child: 364 # 0 = visible, 1 = hidden in C4D 365 child[c4d.ID_BASEOBJECT_VISIBILITY_EDITOR] = 0 if visible else 1 366 child[c4d.ID_BASEOBJECT_VISIBILITY_RENDER] = 0 if visible else 1 367 child = child.GetNext() 368 369 return None 370 ''' 371 372 373 def relationship_code_parameter_passthrough(source_param_id, child_name, target_param_id): 374 """ 375 Generate code for passing a parameter value to a child's parameter. 376 377 This is the generator equivalent of XIdentity. 378 379 Args: 380 source_param_id: DescID tuple for the source parameter on parent 381 child_name: Name of the child to receive the value 382 target_param_id: DescID tuple for the target parameter on child 383 384 Returns: 385 str: Python code for the main() function 386 """ 387 return f''' 388 def main(): 389 value = op[c4d.DescID({source_param_id})] 390 391 # Pass to child 392 child = op.GetDown() 393 while child: 394 if child.GetName() == "{child_name}": 395 child[c4d.DescID({target_param_id})] = value 396 child = child.GetNext() 397 398 return None 399 ''' 400 401 402 def relationship_code_position_from_clone(): 403 """ 404 Generate code that reads position from clone context (for MoGraph). 405 406 This enables position-based parameter variation when inside a Cloner. 407 408 Returns: 409 str: Python code snippet (not full main(), to be composed) 410 """ 411 return ''' 412 # Get position (unique per clone in MoGraph Cloner) 413 mg = op.GetMg() 414 pos = mg.off 415 ''' 416 417 418 def relationship_code_field_sample(field_name, radius=200): 419 """ 420 Generate code for sampling a field's influence based on distance. 421 422 Args: 423 field_name: Name of the field object to sample 424 radius: Influence radius 425 426 Returns: 427 str: Python code snippet for field sampling 428 """ 429 return f''' 430 # Sample field influence 431 field = doc.SearchObject("{field_name}") 432 if field: 433 field_pos = field.GetMg().off 434 dist = (pos - field_pos).GetLength() 435 influence = max(0.0, 1.0 - dist / {radius}) 436 else: 437 influence = 0.0 438 '''