/ 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  '''