/ introspection / hierarchy.py
hierarchy.py
   1  """
   2  Hierarchy Introspection
   3  
   4  Walks Cinema 4D document and extracts semantic information about DreamTalk objects.
   5  """
   6  
   7  import c4d
   8  
   9  
  10  # DreamTalk object type detection
  11  DREAMTALK_TYPES = {
  12      # LineObjects (spline-based)
  13      "Circle", "Rectangle", "Arc", "Spline", "SVG", "SplineText", "Line",
  14      "Arrow", "Brace", "NSide", "Helix", "Formula",
  15      # SolidObjects (3D)
  16      "Sphere", "Cylinder", "Cube", "Plane", "Cone", "Torus", "Capsule",
  17      "Pyramid", "Platonic", "Disc", "Tube", "Landscape", "Figure",
  18      # CustomObjects (composites) - common ones
  19      "Connection", "Group", "Membrane", "Morpher",
  20      # Known sovereign symbols
  21      "Fire", "Human", "Campfire", "Camp",
  22  }
  23  
  24  
  25  def get_userdata_value(obj, group_name, param_name):
  26      """
  27      Get a userdata parameter value by group and parameter name.
  28  
  29      Returns None if not found.
  30      """
  31      ud = obj.GetUserDataContainer()
  32      if not ud:
  33          return None
  34  
  35      # Find the parameter by name within the group
  36      for desc_id, bc in ud:
  37          name = bc.GetString(c4d.DESC_NAME)
  38          if name == param_name:
  39              try:
  40                  return obj[desc_id]
  41              except:
  42                  return None
  43      return None
  44  
  45  
  46  def get_userdata_groups(obj):
  47      """
  48      Get all userdata group names on an object.
  49  
  50      Returns list of group names.
  51      """
  52      groups = []
  53      ud = obj.GetUserDataContainer()
  54      if not ud:
  55          return groups
  56  
  57      for desc_id, bc in ud:
  58          # Groups have dtype=1 (DTYPE_GROUP) and customgui=0
  59          dtype = desc_id[1].dtype if len(desc_id) > 1 else 0
  60          if dtype == 1:  # c4d.DTYPE_GROUP
  61              name = bc.GetString(c4d.DESC_NAME)
  62              if name:
  63                  groups.append(name)
  64      return groups
  65  
  66  
  67  def _get_c4d_type(name, default=None):
  68      """Safely get a c4d constant, returning default if not found."""
  69      return getattr(c4d, name, default)
  70  
  71  
  72  def detect_dreamtalk_class(obj):
  73      """
  74      Detect what type of DreamTalk object this is based on heuristics.
  75  
  76      Returns one of: "CustomObject", "LineObject", "SolidObject", "Camera", "Light", "Unknown"
  77      """
  78      obj_type = obj.GetType()
  79      obj_name = obj.GetName()
  80  
  81      # Camera
  82      if obj_type == _get_c4d_type('Ocamera'):
  83          return "Camera"
  84  
  85      # Light
  86      if obj_type == _get_c4d_type('Olight'):
  87          return "Light"
  88  
  89      # Python Generator (holon container)
  90      if obj_type == 1023866:
  91          return "Generator"
  92  
  93      # Check userdata for DreamTalk signatures
  94      groups = get_userdata_groups(obj)
  95  
  96      # CustomObject signature: has Actions group with Creation parameter
  97      if any("Actions" in g for g in groups):
  98          # Check if it's a Null (CustomObjects are Nulls with children)
  99          if obj_type == _get_c4d_type('Onull'):
 100              return "CustomObject"
 101  
 102      # LineObject signature: Sketch group with Draw parameter
 103      if "Sketch" in groups:
 104          # Splines are LineObjects
 105          spline_types = [_get_c4d_type(t) for t in
 106                         ('Ospline', 'Osplinecircle', 'Osplinerectangle',
 107                          'Osplinearc', 'Osplinetext', 'Osplinehelix',
 108                          'Osplinenside', 'Osplineformula', 'Ospline4side')
 109                         if _get_c4d_type(t) is not None]
 110          if obj_type in spline_types:
 111              return "LineObject"
 112  
 113      # SolidObject signature: has both Fill and Sketch groups
 114      if "Solid" in groups and "Sketch" in groups:
 115          return "SolidObject"
 116  
 117      # Fall back to C4D type-based detection
 118      if obj_type == _get_c4d_type('Onull'):
 119          # Null with children might be a CustomObject without full setup
 120          if obj.GetDown():
 121              return "CustomObject"
 122          return "Null"
 123  
 124      # Check if spline type
 125      spline_types = [_get_c4d_type(t) for t in
 126                     ('Ospline', 'Osplinecircle', 'Osplinerectangle',
 127                      'Osplinearc', 'Osplinetext', 'Osplinehelix',
 128                      'Osplinenside', 'Osplineformula', 'Ospline4side')
 129                     if _get_c4d_type(t) is not None]
 130      if obj_type in spline_types:
 131          return "LineObject"
 132  
 133      # Check if solid/primitive type
 134      solid_types = [_get_c4d_type(t) for t in
 135                    ('Osphere', 'Ocylinder', 'Ocube', 'Oplane',
 136                     'Ocone', 'Otorus', 'Ocapsule', 'Opyramid',
 137                     'Oplatonic', 'Odisc', 'Otube')
 138                    if _get_c4d_type(t) is not None]
 139      if obj_type in solid_types:
 140          return "SolidObject"
 141  
 142      return "Unknown"
 143  
 144  
 145  def get_color_from_object(obj):
 146      """
 147      Try to extract the color from a DreamTalk object.
 148  
 149      Returns tuple (r, g, b) normalized 0-1, or None if not found.
 150      """
 151      # Try userdata Color parameter
 152      color = get_userdata_value(obj, "Sketch", "Color")
 153      if color and isinstance(color, c4d.Vector):
 154          return (color.x, color.y, color.z)
 155  
 156      # Try to get from material
 157      tags = obj.GetTags()
 158      for tag in tags:
 159          if tag.GetType() == c4d.Ttexture:
 160              mat = tag.GetMaterial()
 161              if mat:
 162                  try:
 163                      color = mat[c4d.MATERIAL_COLOR_COLOR]
 164                      if color:
 165                          return (color.x, color.y, color.z)
 166                  except:
 167                      pass
 168      return None
 169  
 170  
 171  def color_to_name(rgb):
 172      """Convert RGB tuple to approximate color name."""
 173      if rgb is None:
 174          return None
 175  
 176      r, g, b = rgb
 177  
 178      # Check for common DreamTalk colors
 179      if r > 0.8 and g < 0.3 and b < 0.3:
 180          return "RED"
 181      if r < 0.3 and g < 0.3 and b > 0.8:
 182          return "BLUE"
 183      if r < 0.3 and g > 0.8 and b < 0.3:
 184          return "GREEN"
 185      if r > 0.8 and g > 0.8 and b < 0.3:
 186          return "YELLOW"
 187      if r > 0.8 and g < 0.5 and b > 0.8:
 188          return "PURPLE"
 189      if r > 0.8 and g > 0.4 and b < 0.3:
 190          return "ORANGE"
 191      if r > 0.8 and g > 0.8 and b > 0.8:
 192          return "WHITE"
 193      if r < 0.2 and g < 0.2 and b < 0.2:
 194          return "BLACK"
 195  
 196      return f"rgb({r:.2f},{g:.2f},{b:.2f})"
 197  
 198  
 199  def describe_object(obj, depth=0, include_children=True):
 200      """
 201      Create a semantic description of a single object.
 202  
 203      Args:
 204          obj: Cinema 4D BaseObject
 205          depth: Current depth in hierarchy (for indentation)
 206          include_children: Whether to recursively describe children
 207  
 208      Returns:
 209          dict with object description
 210      """
 211      if obj is None:
 212          return None
 213  
 214      pos = obj.GetAbsPos()
 215      rot = obj.GetAbsRot()
 216      scale = obj.GetAbsScale()
 217  
 218      # Get DreamTalk class
 219      dt_class = detect_dreamtalk_class(obj)
 220  
 221      # Build description
 222      desc = {
 223          "name": obj.GetName(),
 224          "type": dt_class,
 225          "depth": depth,
 226          "position": {
 227              "x": round(pos.x, 1),
 228              "y": round(pos.y, 1),
 229              "z": round(pos.z, 1)
 230          },
 231      }
 232  
 233      # Only include rotation/scale if non-default
 234      if abs(rot.x) > 0.01 or abs(rot.y) > 0.01 or abs(rot.z) > 0.01:
 235          import math
 236          desc["rotation"] = {
 237              "h": round(math.degrees(rot.x), 1),
 238              "p": round(math.degrees(rot.y), 1),
 239              "b": round(math.degrees(rot.z), 1)
 240          }
 241  
 242      if abs(scale.x - 1) > 0.01 or abs(scale.y - 1) > 0.01 or abs(scale.z - 1) > 0.01:
 243          desc["scale"] = round(scale.x, 2)  # Assume uniform scale
 244  
 245      # DreamTalk-specific info
 246      creation = get_userdata_value(obj, "Actions", "Creation")
 247      if creation is not None:
 248          desc["creation"] = round(creation * 100, 1)  # As percentage
 249  
 250      draw = get_userdata_value(obj, "Sketch", "Draw")
 251      if draw is not None:
 252          desc["draw"] = round(draw * 100, 1)
 253  
 254      opacity = get_userdata_value(obj, "Sketch", "Opacity")
 255      if opacity is not None and opacity < 1.0:
 256          desc["opacity"] = round(opacity * 100, 1)
 257  
 258      # Color
 259      color = get_color_from_object(obj)
 260      color_name = color_to_name(color)
 261      if color_name:
 262          desc["color"] = color_name
 263  
 264      # Children
 265      if include_children:
 266          children = []
 267          child = obj.GetDown()
 268          while child:
 269              child_desc = describe_object(child, depth + 1, include_children=True)
 270              if child_desc:
 271                  children.append(child_desc)
 272              child = child.GetNext()
 273  
 274          if children:
 275              desc["children"] = children
 276              desc["child_count"] = len(children)
 277  
 278      return desc
 279  
 280  
 281  def describe_hierarchy(doc=None):
 282      """
 283      Generate a semantic description of the entire scene hierarchy.
 284  
 285      Args:
 286          doc: Cinema 4D document (defaults to active document)
 287  
 288      Returns:
 289          dict with:
 290          - objects: list of root-level object descriptions
 291          - stats: scene statistics
 292          - summary: human-readable summary
 293      """
 294      if doc is None:
 295          doc = c4d.documents.GetActiveDocument()
 296  
 297      # Collect root objects
 298      root_objects = []
 299      obj = doc.GetFirstObject()
 300      while obj:
 301          desc = describe_object(obj, depth=0)
 302          if desc:
 303              root_objects.append(desc)
 304          obj = obj.GetNext()
 305  
 306      # Calculate stats
 307      stats = {
 308          "total_objects": 0,
 309          "custom_objects": 0,
 310          "line_objects": 0,
 311          "solid_objects": 0,
 312          "max_depth": 0,
 313      }
 314  
 315      def count_recursive(objects, depth=0):
 316          for o in objects:
 317              stats["total_objects"] += 1
 318              stats["max_depth"] = max(stats["max_depth"], depth)
 319  
 320              obj_type = o.get("type", "Unknown")
 321              if obj_type == "CustomObject":
 322                  stats["custom_objects"] += 1
 323              elif obj_type == "LineObject":
 324                  stats["line_objects"] += 1
 325              elif obj_type == "SolidObject":
 326                  stats["solid_objects"] += 1
 327  
 328              if "children" in o:
 329                  count_recursive(o["children"], depth + 1)
 330  
 331      count_recursive(root_objects)
 332  
 333      # Generate summary
 334      parts = []
 335      if stats["custom_objects"]:
 336          parts.append(f"{stats['custom_objects']} CustomObject(s)")
 337      if stats["line_objects"]:
 338          parts.append(f"{stats['line_objects']} LineObject(s)")
 339      if stats["solid_objects"]:
 340          parts.append(f"{stats['solid_objects']} SolidObject(s)")
 341  
 342      summary = f"Scene contains {stats['total_objects']} objects"
 343      if parts:
 344          summary += f": {', '.join(parts)}"
 345      if stats["max_depth"] > 0:
 346          summary += f" (max depth: {stats['max_depth']})"
 347  
 348      return {
 349          "objects": root_objects,
 350          "stats": stats,
 351          "summary": summary,
 352          "document_name": doc.GetDocumentName() or "Untitled"
 353      }
 354  
 355  
 356  def find_object_by_name(name, doc=None):
 357      """
 358      Find an object by name in the document.
 359  
 360      Args:
 361          name: Object name to find
 362          doc: Cinema 4D document (defaults to active document)
 363  
 364      Returns:
 365          c4d.BaseObject or None
 366      """
 367      if doc is None:
 368          doc = c4d.documents.GetActiveDocument()
 369  
 370      def search_recursive(obj):
 371          while obj:
 372              if obj.GetName() == name:
 373                  return obj
 374              found = search_recursive(obj.GetDown())
 375              if found:
 376                  return found
 377              obj = obj.GetNext()
 378          return None
 379  
 380      return search_recursive(doc.GetFirstObject())
 381  
 382  
 383  def get_all_userdata(obj):
 384      """
 385      Get all userdata parameters on an object.
 386  
 387      Returns:
 388          dict mapping group_name -> {param_name: value}
 389      """
 390      ud = obj.GetUserDataContainer()
 391      if not ud:
 392          return {}
 393  
 394      result = {}
 395      current_group = "Ungrouped"
 396  
 397      for desc_id, bc in ud:
 398          name = bc.GetString(c4d.DESC_NAME)
 399          dtype = desc_id[1].dtype if len(desc_id) > 1 else 0
 400  
 401          if dtype == 1:  # Group
 402              current_group = name or "Unnamed Group"
 403              if current_group not in result:
 404                  result[current_group] = {}
 405          else:
 406              # Parameter
 407              try:
 408                  value = obj[desc_id]
 409                  # Convert c4d types to Python types
 410                  if isinstance(value, c4d.Vector):
 411                      value = {"x": value.x, "y": value.y, "z": value.z}
 412                  elif hasattr(value, '__float__'):
 413                      value = float(value)
 414  
 415                  if current_group not in result:
 416                      result[current_group] = {}
 417                  result[current_group][name] = value
 418              except:
 419                  pass
 420  
 421      return result
 422  
 423  
 424  def get_object_tags(obj):
 425      """
 426      Get all tags on an object with their types.
 427  
 428      Returns:
 429          list of {name, type, details}
 430      """
 431      tags = []
 432      for tag in obj.GetTags():
 433          tag_info = {
 434              "name": tag.GetName(),
 435              "type": tag.GetTypeName(),
 436          }
 437  
 438          # Extract specific tag info
 439          tag_type = tag.GetType()
 440          if tag_type == c4d.Ttexture:
 441              mat = tag.GetMaterial()
 442              if mat:
 443                  tag_info["material"] = mat.GetName()
 444  
 445          tags.append(tag_info)
 446  
 447      return tags
 448  
 449  
 450  def inspect_object(name, doc=None):
 451      """
 452      Deep inspection of a single object by name.
 453  
 454      Args:
 455          name: Object name to inspect
 456          doc: Cinema 4D document (defaults to active document)
 457  
 458      Returns:
 459          dict with detailed object information
 460      """
 461      if doc is None:
 462          doc = c4d.documents.GetActiveDocument()
 463  
 464      obj = find_object_by_name(name, doc)
 465      if obj is None:
 466          return {"error": f"Object '{name}' not found"}
 467  
 468      pos = obj.GetAbsPos()
 469      rot = obj.GetAbsRot()
 470      scale = obj.GetAbsScale()
 471  
 472      import math
 473  
 474      result = {
 475          "name": obj.GetName(),
 476          "type": detect_dreamtalk_class(obj),
 477          "c4d_type": obj.GetTypeName(),
 478          "transform": {
 479              "position": {"x": round(pos.x, 2), "y": round(pos.y, 2), "z": round(pos.z, 2)},
 480              "rotation": {"h": round(math.degrees(rot.x), 2), "p": round(math.degrees(rot.y), 2), "b": round(math.degrees(rot.z), 2)},
 481              "scale": {"x": round(scale.x, 3), "y": round(scale.y, 3), "z": round(scale.z, 3)},
 482          },
 483          "userdata": get_all_userdata(obj),
 484          "tags": get_object_tags(obj),
 485      }
 486  
 487      # Parent info
 488      parent = obj.GetUp()
 489      if parent:
 490          result["parent"] = parent.GetName()
 491  
 492      # Children
 493      children = []
 494      child = obj.GetDown()
 495      while child:
 496          children.append(child.GetName())
 497          child = child.GetNext()
 498      if children:
 499          result["children"] = children
 500  
 501      # Color
 502      color = get_color_from_object(obj)
 503      if color:
 504          result["color"] = {
 505              "rgb": {"r": round(color[0], 3), "g": round(color[1], 3), "b": round(color[2], 3)},
 506              "name": color_to_name(color)
 507          }
 508  
 509      # Bounding box
 510      bbox = obj.GetRad()
 511      if bbox:
 512          result["bounding_box"] = {
 513              "width": round(bbox.x * 2, 2),
 514              "height": round(bbox.y * 2, 2),
 515              "depth": round(bbox.z * 2, 2)
 516          }
 517  
 518      return result
 519  
 520  
 521  def inspect_materials(doc=None):
 522      """
 523      Describe all materials in the scene.
 524  
 525      Args:
 526          doc: Cinema 4D document (defaults to active document)
 527  
 528      Returns:
 529          dict with material descriptions and usage
 530      """
 531      if doc is None:
 532          doc = c4d.documents.GetActiveDocument()
 533  
 534      materials = []
 535      mat = doc.GetFirstMaterial()
 536  
 537      while mat:
 538          mat_info = {
 539              "name": mat.GetName(),
 540              "type": mat.GetTypeName(),
 541          }
 542  
 543          # Try to get color
 544          try:
 545              if mat[c4d.MATERIAL_USE_COLOR]:
 546                  color = mat[c4d.MATERIAL_COLOR_COLOR]
 547                  mat_info["color"] = {
 548                      "rgb": {"r": round(color.x, 3), "g": round(color.y, 3), "b": round(color.z, 3)},
 549                      "name": color_to_name((color.x, color.y, color.z))
 550                  }
 551          except:
 552              pass
 553  
 554          # Try to get transparency
 555          try:
 556              if mat[c4d.MATERIAL_USE_TRANSPARENCY]:
 557                  mat_info["has_transparency"] = True
 558          except:
 559              pass
 560  
 561          # Try to get luminance/glow
 562          try:
 563              if mat[c4d.MATERIAL_USE_LUMINANCE]:
 564                  mat_info["has_luminance"] = True
 565          except:
 566              pass
 567  
 568          # Find objects using this material
 569          used_by = []
 570  
 571          def find_usage(obj):
 572              while obj:
 573                  for tag in obj.GetTags():
 574                      if tag.GetType() == c4d.Ttexture:
 575                          tag_mat = tag.GetMaterial()
 576                          if tag_mat and tag_mat.GetName() == mat.GetName():
 577                              used_by.append(obj.GetName())
 578                  find_usage(obj.GetDown())
 579                  obj = obj.GetNext()
 580  
 581          find_usage(doc.GetFirstObject())
 582          if used_by:
 583              mat_info["used_by"] = used_by
 584  
 585          materials.append(mat_info)
 586          mat = mat.GetNext()
 587  
 588      return {
 589          "materials": materials,
 590          "count": len(materials),
 591          "summary": f"Scene has {len(materials)} material(s)"
 592      }
 593  
 594  
 595  def inspect_animation(start_frame=None, end_frame=None, doc=None):
 596      """
 597      Describe what happens in the animation between frames.
 598  
 599      Args:
 600          start_frame: Start frame (defaults to document start)
 601          end_frame: End frame (defaults to document end)
 602          doc: Cinema 4D document (defaults to active document)
 603  
 604      Returns:
 605          dict with animation description
 606      """
 607      if doc is None:
 608          doc = c4d.documents.GetActiveDocument()
 609  
 610      fps = doc.GetFps()
 611      doc_start = doc.GetMinTime().GetFrame(fps)
 612      doc_end = doc.GetMaxTime().GetFrame(fps)
 613  
 614      if start_frame is None:
 615          start_frame = doc_start
 616      if end_frame is None:
 617          end_frame = doc_end
 618  
 619      # Collect all animated objects and their keyframes
 620      animated_objects = []
 621  
 622      def find_animated(obj):
 623          while obj:
 624              tracks = obj.GetCTracks()
 625              if tracks:
 626                  obj_info = {
 627                      "name": obj.GetName(),
 628                      "type": detect_dreamtalk_class(obj),
 629                      "tracks": []
 630                  }
 631  
 632                  for track in tracks:
 633                      desc_id = track.GetDescriptionID()
 634                      curve = track.GetCurve()
 635                      if curve:
 636                          keyframes = []
 637                          for i in range(curve.GetKeyCount()):
 638                              key = curve.GetKey(i)
 639                              frame = key.GetTime().GetFrame(fps)
 640                              if start_frame <= frame <= end_frame:
 641                                  keyframes.append({
 642                                      "frame": frame,
 643                                      "value": round(key.GetValue(), 3)
 644                                  })
 645  
 646                          if keyframes:
 647                              # Try to get parameter name
 648                              param_name = "Unknown"
 649                              try:
 650                                  # Check common DreamTalk parameters
 651                                  ud = obj.GetUserDataContainer()
 652                                  if ud:
 653                                      for ud_id, bc in ud:
 654                                          if ud_id == desc_id:
 655                                              param_name = bc.GetString(c4d.DESC_NAME)
 656                                              break
 657                              except:
 658                                  pass
 659  
 660                              obj_info["tracks"].append({
 661                                  "parameter": param_name,
 662                                  "keyframes": keyframes
 663                              })
 664  
 665                  if obj_info["tracks"]:
 666                      animated_objects.append(obj_info)
 667  
 668              find_animated(obj.GetDown())
 669              obj = obj.GetNext()
 670  
 671      find_animated(doc.GetFirstObject())
 672  
 673      # Generate summary
 674      total_keyframes = sum(
 675          len(track["keyframes"])
 676          for obj in animated_objects
 677          for track in obj["tracks"]
 678      )
 679  
 680      return {
 681          "frame_range": {"start": start_frame, "end": end_frame},
 682          "fps": fps,
 683          "duration_seconds": round((end_frame - start_frame) / fps, 2),
 684          "animated_objects": animated_objects,
 685          "stats": {
 686              "animated_object_count": len(animated_objects),
 687              "total_keyframes": total_keyframes
 688          },
 689          "summary": f"Animation from frame {start_frame} to {end_frame} ({round((end_frame - start_frame) / fps, 2)}s): {len(animated_objects)} animated object(s), {total_keyframes} keyframe(s)"
 690      }
 691  
 692  
 693  def validate_scene(doc=None):
 694      """
 695      Run sanity checks on the scene before rendering.
 696  
 697      Args:
 698          doc: Cinema 4D document (defaults to active document)
 699  
 700      Returns:
 701          dict with validation results and any issues found
 702      """
 703      if doc is None:
 704          doc = c4d.documents.GetActiveDocument()
 705  
 706      issues = []
 707      warnings = []
 708      info = []
 709  
 710      # Check for objects at origin that shouldn't be
 711      origin_objects = []
 712  
 713      def check_objects(obj, depth=0):
 714          while obj:
 715              pos = obj.GetAbsPos()
 716              name = obj.GetName()
 717              obj_type = detect_dreamtalk_class(obj)
 718  
 719              # Check for objects stuck at origin (except root CustomObjects)
 720              if depth > 0 and obj_type != "Camera":
 721                  if pos.x == 0 and pos.y == 0 and pos.z == 0:
 722                      # Only flag if it has siblings at different positions
 723                      sibling = obj.GetNext() or obj.GetPred()
 724                      if sibling:
 725                          sib_pos = sibling.GetAbsPos()
 726                          if sib_pos.x != 0 or sib_pos.y != 0 or sib_pos.z != 0:
 727                              origin_objects.append(name)
 728  
 729              # Check for missing materials on visible objects
 730              if obj_type in ("LineObject", "SolidObject"):
 731                  has_material = False
 732                  for tag in obj.GetTags():
 733                      if tag.GetType() == c4d.Ttexture:
 734                          has_material = True
 735                          break
 736                  if not has_material:
 737                      warnings.append(f"'{name}' ({obj_type}) has no material assigned")
 738  
 739              # Check for Python Generator errors (red icon = no cache)
 740              if obj.GetType() == 1023866:  # Python Generator
 741                  cache = obj.GetCache()
 742                  if cache is None:
 743                      # Don't flag if generator is inside a MoGraph Cloner (known limitation)
 744                      # Cache returns None for template objects inside Cloners
 745                      is_inside_cloner = False
 746                      parent = obj.GetUp()
 747                      while parent:
 748                          if parent.GetType() == 1018544:  # MoGraph Cloner
 749                              is_inside_cloner = True
 750                              break
 751                          parent = parent.GetUp()
 752  
 753                      if not is_inside_cloner:
 754                          issues.append(f"'{name}' (Python Generator) has error - no cache produced")
 755  
 756              # Check for 0 creation on DreamTalk objects
 757              creation = get_userdata_value(obj, "Actions", "Creation")
 758              if creation is not None and creation == 0:
 759                  info.append(f"'{name}' has creation at 0% (not animated yet)")
 760  
 761              check_objects(obj.GetDown(), depth + 1)
 762              obj = obj.GetNext()
 763  
 764      check_objects(doc.GetFirstObject())
 765  
 766      # Check materials
 767      mat = doc.GetFirstMaterial()
 768      material_count = 0
 769      unused_materials = []
 770  
 771      while mat:
 772          material_count += 1
 773          mat_name = mat.GetName()
 774  
 775          # Check if material is used
 776          is_used = False
 777  
 778          def check_usage(obj):
 779              nonlocal is_used
 780              while obj and not is_used:
 781                  for tag in obj.GetTags():
 782                      if tag.GetType() == c4d.Ttexture:
 783                          tag_mat = tag.GetMaterial()
 784                          if tag_mat and tag_mat.GetName() == mat_name:
 785                              is_used = True
 786                              return
 787                  check_usage(obj.GetDown())
 788                  obj = obj.GetNext()
 789  
 790          check_usage(doc.GetFirstObject())
 791          if not is_used:
 792              unused_materials.append(mat_name)
 793  
 794          mat = mat.GetNext()
 795  
 796      if unused_materials:
 797          warnings.append(f"Unused materials: {', '.join(unused_materials)}")
 798  
 799      # Check render settings
 800      rd = doc.GetActiveRenderData()
 801      if rd:
 802          fps = rd[c4d.RDATA_FRAMERATE]
 803          width = rd[c4d.RDATA_XRES]
 804          height = rd[c4d.RDATA_YRES]
 805          info.append(f"Render settings: {int(width)}x{int(height)} @ {int(fps)}fps")
 806  
 807      # Check for camera
 808      has_camera = False
 809  
 810      def find_camera(obj):
 811          nonlocal has_camera
 812          while obj:
 813              if obj.GetType() == _get_c4d_type('Ocamera'):
 814                  has_camera = True
 815                  return
 816              find_camera(obj.GetDown())
 817              obj = obj.GetNext()
 818  
 819      find_camera(doc.GetFirstObject())
 820      if not has_camera:
 821          warnings.append("No camera in scene")
 822  
 823      # Build result
 824      is_valid = len(issues) == 0
 825  
 826      return {
 827          "valid": is_valid,
 828          "issues": issues,
 829          "warnings": warnings,
 830          "info": info,
 831          "stats": {
 832              "material_count": material_count,
 833              "unused_materials": len(unused_materials),
 834          },
 835          "summary": f"Scene validation: {'PASSED' if is_valid else 'FAILED'} - {len(issues)} issue(s), {len(warnings)} warning(s)"
 836      }
 837  
 838  
 839  def inspect_xpresso(obj):
 840      """
 841      Deep inspection of XPresso tags on an object.
 842  
 843      Reveals how userdata parameters connect to object properties through XPresso.
 844      This is critical for understanding DreamTalk's abstraction layer where
 845      Creation -> XPresso -> Fill/Draw -> Material visibility.
 846  
 847      Args:
 848          obj: Cinema 4D BaseObject (or object name string)
 849  
 850      Returns:
 851          dict with XPresso structure including groups, nodes, and connections
 852      """
 853      if isinstance(obj, str):
 854          doc = c4d.documents.GetActiveDocument()
 855          obj = find_object_by_name(obj, doc)
 856          if obj is None:
 857              return {"error": f"Object not found"}
 858  
 859      result = {
 860          "object": obj.GetName(),
 861          "xpresso_tags": []
 862      }
 863  
 864      for tag in obj.GetTags():
 865          if tag.GetType() != c4d.Texpresso:
 866              continue
 867  
 868          tag_info = {
 869              "name": tag.GetName(),
 870              "groups": []
 871          }
 872  
 873          node_master = tag.GetNodeMaster()
 874          if not node_master:
 875              continue
 876  
 877          root = node_master.GetRoot()
 878          if not root:
 879              continue
 880  
 881          def traverse_nodes(node, group_info=None):
 882              """Traverse XPresso nodes and extract their structure."""
 883              nodes_list = []
 884  
 885              while node:
 886                  node_name = node.GetName()
 887                  op_id = node.GetOperatorID()
 888  
 889                  node_info = {
 890                      "name": node_name,
 891                      "operator_id": op_id,
 892                      "inputs": [],
 893                      "outputs": []
 894                  }
 895  
 896                  # Get input ports
 897                  for port in node.GetInPorts():
 898                      port_name = port.GetName(node)
 899                      node_info["inputs"].append(port_name)
 900  
 901                  # Get output ports
 902                  for port in node.GetOutPorts():
 903                      port_name = port.GetName(node)
 904                      node_info["outputs"].append(port_name)
 905  
 906                  # Check if this is a group (contains children)
 907                  child = node.GetDown()
 908                  if child:
 909                      # This is a group node - extract its children
 910                      if op_id == 1001144:  # XGroup
 911                          group_data = {
 912                              "name": node_name,
 913                              "nodes": traverse_nodes(child)
 914                          }
 915                          if group_info is not None:
 916                              group_info.append(group_data)
 917                          else:
 918                              tag_info["groups"].append(group_data)
 919                      else:
 920                          node_info["children"] = traverse_nodes(child)
 921                          nodes_list.append(node_info)
 922                  else:
 923                      nodes_list.append(node_info)
 924  
 925                  node = node.GetNext()
 926  
 927              return nodes_list
 928  
 929          # Start traversal from root's children
 930          first_node = root.GetDown()
 931          if first_node:
 932              traverse_nodes(first_node, tag_info["groups"])
 933  
 934          result["xpresso_tags"].append(tag_info)
 935  
 936      return result
 937  
 938  
 939  def _capture_native_params(obj):
 940      """
 941      Capture all native C4D parameters for an object.
 942  
 943      Only captures serializable types (int, float, bool, str, Vector).
 944      Used for silent delta tracking - not displayed unless changed.
 945  
 946      Returns:
 947          dict with:
 948          - 'values': dict mapping DescID string to current value
 949          - 'meta': dict mapping DescID string to metadata (name, cycle options)
 950      """
 951      values = {}
 952      meta = {}
 953  
 954      try:
 955          desc = obj.GetDescription(c4d.DESCFLAGS_DESC_NONE)
 956  
 957          for bc, paramid, groupid in desc:
 958              # Skip container/group types that aren't actual values
 959              dtype = bc.GetInt32(c4d.DESC_CUSTOMGUI)
 960              if dtype in [0]:  # Skip pure groups
 961                  continue
 962  
 963              param_key = str(paramid)
 964  
 965              try:
 966                  value = obj[paramid]
 967  
 968                  # Only capture serializable types
 969                  if isinstance(value, (int, float, bool)):
 970                      values[param_key] = value
 971                  elif isinstance(value, str):
 972                      # Skip very long strings (likely code blocks)
 973                      if len(value) < 200:
 974                          values[param_key] = value
 975                  elif isinstance(value, c4d.Vector):
 976                      values[param_key] = (round(value.x, 4), round(value.y, 4), round(value.z, 4))
 977                  elif value is None:
 978                      values[param_key] = None
 979                  else:
 980                      continue  # Skip other complex types
 981  
 982                  # Capture metadata for this parameter
 983                  param_meta = {}
 984  
 985                  # Parameter name from UI
 986                  name = bc.GetString(c4d.DESC_NAME)
 987                  if name:
 988                      param_meta["name"] = name
 989  
 990                  # Internal ID name (e.g., "ID_MG_MOTIONGENERATOR_MODE")
 991                  ident = bc.GetString(999)  # 999 holds the identifier string
 992                  if ident:
 993                      param_meta["ident"] = ident
 994  
 995                  # Cycle/enum values (dropdown options)
 996                  cycle = bc.GetContainer(c4d.DESC_CYCLE)
 997                  if cycle:
 998                      options = {}
 999                      for i, label in cycle:
1000                          options[i] = label
1001                      if options:
1002                          param_meta["options"] = options
1003  
1004                  if param_meta:
1005                      meta[param_key] = param_meta
1006  
1007              except:
1008                  pass  # Skip unreadable parameters
1009      except:
1010          pass  # Object doesn't support description iteration
1011  
1012      return {"values": values, "meta": meta}
1013  
1014  
1015  def get_scene_snapshot(doc=None):
1016      """
1017      Create a snapshot of scene state for change detection.
1018  
1019      Captures two levels of data:
1020      1. DreamTalk data (always shown): transforms, userdata, sketch tags
1021      2. Native C4D params (silent): ALL object parameters, only surfaced on delta
1022  
1023      Objects are tracked by GUID for stable identity across renames.
1024  
1025      Args:
1026          doc: Cinema 4D document (defaults to active document)
1027  
1028      Returns:
1029          dict with 'objects' and 'materials' snapshots, keyed by GUID
1030      """
1031      if doc is None:
1032          doc = c4d.documents.GetActiveDocument()
1033  
1034      snapshot = {
1035          "objects": {},      # Keyed by GUID
1036          "materials": {},    # Keyed by name (materials don't have stable GUIDs)
1037          "guid_to_name": {}  # For display purposes
1038      }
1039  
1040      def capture_object(obj):
1041          while obj:
1042              obj_guid = str(obj.GetGUID())
1043              obj_name = obj.GetName()
1044  
1045              # Store GUID -> name mapping for display
1046              snapshot["guid_to_name"][obj_guid] = obj_name
1047  
1048              # DreamTalk data (always shown)
1049              dreamtalk_data = {}
1050  
1051              # Capture transform
1052              pos = obj.GetAbsPos()
1053              rot = obj.GetAbsRot()
1054              scale = obj.GetAbsScale()
1055  
1056              dreamtalk_data["transform.x"] = round(pos.x, 2)
1057              dreamtalk_data["transform.y"] = round(pos.y, 2)
1058              dreamtalk_data["transform.z"] = round(pos.z, 2)
1059  
1060              # Only include rotation if non-zero
1061              import math
1062              if abs(rot.x) > 0.001 or abs(rot.y) > 0.001 or abs(rot.z) > 0.001:
1063                  dreamtalk_data["transform.h"] = round(math.degrees(rot.x), 2)
1064                  dreamtalk_data["transform.p"] = round(math.degrees(rot.y), 2)
1065                  dreamtalk_data["transform.b"] = round(math.degrees(rot.z), 2)
1066  
1067              # Only include scale if non-uniform
1068              if abs(scale.x - 1) > 0.001 or abs(scale.y - 1) > 0.001 or abs(scale.z - 1) > 0.001:
1069                  dreamtalk_data["transform.scale_x"] = round(scale.x, 3)
1070                  dreamtalk_data["transform.scale_y"] = round(scale.y, 3)
1071                  dreamtalk_data["transform.scale_z"] = round(scale.z, 3)
1072  
1073              # Capture userdata
1074              userdata = get_all_userdata(obj)
1075              for group_name, params in userdata.items():
1076                  for param_name, value in params.items():
1077                      key = f"userdata.{group_name}.{param_name}"
1078                      # Convert complex types to comparable values
1079                      if isinstance(value, dict):
1080                          if 'x' in value and 'y' in value and 'z' in value:
1081                              value = (value['x'], value['y'], value['z'])
1082                      if isinstance(value, float):
1083                          value = round(value, 4)
1084                      dreamtalk_data[key] = value
1085  
1086              # Capture Sketch Style Tag properties (Tsketchstyle = 1011012)
1087              for tag in obj.GetTags():
1088                  if tag.GetType() == 1011012:  # Tsketchstyle
1089                      tag_name = tag.GetName()
1090                      prefix = f"tag.{tag_name}"
1091  
1092                      # Material links
1093                      try:
1094                          visible_mat = tag[10071]  # Default Visible
1095                          dreamtalk_data[f"{prefix}.visible_material"] = visible_mat.GetName() if visible_mat else None
1096                      except: pass
1097                      try:
1098                          hidden_mat = tag[10072]  # Default Hidden
1099                          dreamtalk_data[f"{prefix}.hidden_material"] = hidden_mat.GetName() if hidden_mat else None
1100                      except: pass
1101  
1102                      # Line types enabled
1103                      try: dreamtalk_data[f"{prefix}.outline"] = bool(tag[10001])
1104                      except: pass
1105                      try: dreamtalk_data[f"{prefix}.folds"] = bool(tag[10002])
1106                      except: pass
1107                      try: dreamtalk_data[f"{prefix}.creases"] = bool(tag[10005])
1108                      except: pass
1109                      try: dreamtalk_data[f"{prefix}.contour"] = bool(tag[10010])
1110                      except: pass
1111                      try: dreamtalk_data[f"{prefix}.splines"] = bool(tag[10013])
1112                      except: pass
1113  
1114                      # Contour settings (only if contour enabled)
1115                      if tag[10010]:  # Contour enabled
1116                          try: dreamtalk_data[f"{prefix}.contour_mode"] = tag[11000]  # 0=Angle, 1=Position, 2=UVW
1117                          except: pass
1118                          try: dreamtalk_data[f"{prefix}.contour_position"] = tag[11002]  # 0=Object X, 1=Y, 2=Z
1119                          except: pass
1120                          try: dreamtalk_data[f"{prefix}.contour_spacing_type"] = tag[11014]  # 0=Relative, 1=Absolute
1121                          except: pass
1122                          try: dreamtalk_data[f"{prefix}.contour_step"] = round(tag[11016], 2)  # Step value
1123                          except: pass
1124  
1125              # Native C4D params (silent - only surfaced on delta)
1126              native_params = _capture_native_params(obj)
1127  
1128              # Capture ALL tag parameters universally
1129              tags_data = {}
1130              for tag in obj.GetTags():
1131                  tag_type = tag.GetType()
1132                  tag_name = tag.GetName() or f"Tag_{tag_type}"
1133                  # Create a unique key if multiple tags share a name
1134                  tag_key = tag_name
1135                  suffix = 2
1136                  while tag_key in tags_data:
1137                      tag_key = f"{tag_name}_{suffix}"
1138                      suffix += 1
1139                  try:
1140                      tags_data[tag_key] = {
1141                          "type": tag_type,
1142                          "native": _capture_native_params(tag)
1143                      }
1144                  except:
1145                      pass  # Skip tags that can't be introspected
1146  
1147              snapshot["objects"][obj_guid] = {
1148                  "name": obj_name,
1149                  "dreamtalk": dreamtalk_data,
1150                  "native": native_params,
1151                  "tags": tags_data
1152              }
1153  
1154              # Recurse
1155              capture_object(obj.GetDown())
1156              obj = obj.GetNext()
1157  
1158      capture_object(doc.GetFirstObject())
1159  
1160      # Capture materials (especially Sketch & Toon)
1161      mat = doc.GetFirstMaterial()
1162      while mat:
1163          mat_name = mat.GetName()
1164          mat_type = mat.GetType()
1165          mat_data = {"type": mat.GetTypeName()}
1166  
1167          # Sketch & Toon material (Msketch = 1011014)
1168          if mat_type == _get_c4d_type('Msketch'):
1169              try:
1170                  mat_data["thickness"] = round(mat[c4d.OUTLINEMAT_THICKNESS], 2)
1171              except: pass
1172              try:
1173                  mat_data["line_contour"] = bool(mat[c4d.OUTLINEMAT_LINE_CONTOUR])
1174              except: pass
1175              try:
1176                  mat_data["contour_mode"] = mat[c4d.OUTLINEMAT_LINE_CONTOUR_MODE]
1177              except: pass
1178              try:
1179                  mat_data["contour_spacing_type"] = mat[c4d.OUTLINEMAT_LINE_CONTOUR_POSITION_SPACING]
1180              except: pass
1181              try:
1182                  mat_data["contour_spacing"] = round(mat[c4d.OUTLINEMAT_LINE_CONTOUR_POSITION_SPACE], 2)
1183              except: pass
1184              try:
1185                  mat_data["contour_steps"] = mat[c4d.OUTLINEMAT_LINE_CONTOUR_POSITION_STEPS]
1186              except: pass
1187              try:
1188                  # Contour position mode (Object X/Y/Z, World X/Y/Z, View X/Y/Z)
1189                  mat_data["contour_object_x"] = bool(mat[c4d.OUTLINEMAT_LINE_CONTOUR_OBJECT_X])
1190                  mat_data["contour_object_y"] = bool(mat[c4d.OUTLINEMAT_LINE_CONTOUR_OBJECT_Y])
1191                  mat_data["contour_object_z"] = bool(mat[c4d.OUTLINEMAT_LINE_CONTOUR_OBJECT_Z])
1192              except: pass
1193  
1194          snapshot["materials"][mat_name] = mat_data
1195          mat = mat.GetNext()
1196  
1197      return snapshot
1198  
1199  
1200  # Module-level storage for scene snapshots
1201  _last_snapshot = None
1202  
1203  # Console log tracking with deduplication
1204  # Stores: {"messages": [{"text": str, "count": int, "first_seen": int}], "total_captured": int}
1205  _console_log = {"messages": [], "total_captured": 0}
1206  _last_console_position = 0  # Index into _console_log["messages"] for delta tracking
1207  
1208  # Safety limits for console output
1209  CONSOLE_MAX_MESSAGE_LENGTH = 500  # Truncate individual messages longer than this
1210  CONSOLE_MAX_TOTAL_CHARS = 5000   # Max total characters in delta output
1211  CONSOLE_MAX_UNIQUE_MESSAGES = 100  # Max unique messages to track before rotation
1212  
1213  
1214  def add_console_message(text):
1215      """
1216      Add a console message with deduplication.
1217  
1218      Called by the plugin when capturing stdout during operations.
1219      Consecutive identical messages are counted rather than repeated.
1220  
1221      Args:
1222          text: The console message text
1223      """
1224      global _console_log
1225  
1226      # Truncate long messages
1227      if len(text) > CONSOLE_MAX_MESSAGE_LENGTH:
1228          text = text[:CONSOLE_MAX_MESSAGE_LENGTH] + f"... [truncated, {len(text)} chars total]"
1229  
1230      messages = _console_log["messages"]
1231  
1232      # Check if this matches the last message (deduplication)
1233      if messages and messages[-1]["text"] == text:
1234          messages[-1]["count"] += 1
1235      else:
1236          # New unique message
1237          messages.append({
1238              "text": text,
1239              "count": 1,
1240              "first_seen": _console_log["total_captured"]
1241          })
1242  
1243          # Rotate old messages if we have too many
1244          if len(messages) > CONSOLE_MAX_UNIQUE_MESSAGES:
1245              # Keep the most recent messages
1246              messages = messages[-CONSOLE_MAX_UNIQUE_MESSAGES:]
1247              _console_log["messages"] = messages
1248  
1249      _console_log["total_captured"] += 1
1250  
1251  
1252  def get_console_delta():
1253      """
1254      Get console messages since last describe_scene call.
1255  
1256      Returns dict with:
1257      - messages: List of {"text": str, "count": int} for new messages
1258      - has_new: bool indicating if there are new messages
1259      - truncated: bool if output was truncated for safety
1260  
1261      Updates the position marker so next call shows only newer messages.
1262      """
1263      global _last_console_position, _console_log
1264  
1265      messages = _console_log["messages"]
1266  
1267      # Find new messages since last position
1268      new_messages = []
1269      total_chars = 0
1270      truncated = False
1271  
1272      for i in range(len(messages)):
1273          msg = messages[i]
1274          # Include if this message appeared after our last position
1275          # (based on first_seen index or if count increased)
1276          if msg["first_seen"] >= _last_console_position or (
1277              i == len(messages) - 1 and msg["count"] > 1
1278          ):
1279              msg_text = msg["text"]
1280              msg_chars = len(msg_text) + 20  # Account for count display
1281  
1282              if total_chars + msg_chars > CONSOLE_MAX_TOTAL_CHARS:
1283                  truncated = True
1284                  break
1285  
1286              new_messages.append({
1287                  "text": msg_text,
1288                  "count": msg["count"]
1289              })
1290              total_chars += msg_chars
1291  
1292      # Update position for next delta
1293      _last_console_position = _console_log["total_captured"]
1294  
1295      return {
1296          "messages": new_messages,
1297          "has_new": len(new_messages) > 0,
1298          "truncated": truncated,
1299          "total_captured": _console_log["total_captured"]
1300      }
1301  
1302  
1303  def reset_console_log():
1304      """Reset console log tracking."""
1305      global _console_log, _last_console_position
1306      _console_log = {"messages": [], "total_captured": 0}
1307      _last_console_position = 0
1308  
1309  
1310  def diff_scene(doc=None):
1311      """
1312      Compare current scene state to last snapshot and show changes.
1313  
1314      Call this after making manual changes in C4D to see what was modified.
1315      Automatically stores current state for next diff.
1316  
1317      Detects changes to:
1318      - Object transforms (position, rotation, scale)
1319      - Userdata parameters
1320      - Native C4D parameters (only on delta)
1321      - Material properties (especially Sketch & Toon)
1322  
1323      Args:
1324          doc: Cinema 4D document (defaults to active document)
1325  
1326      Returns:
1327          dict with changes grouped by category (objects, materials)
1328      """
1329      global _last_snapshot
1330  
1331      if doc is None:
1332          doc = c4d.documents.GetActiveDocument()
1333  
1334      current = get_scene_snapshot(doc)
1335  
1336      if _last_snapshot is None:
1337          _last_snapshot = current
1338          return {
1339              "status": "first_snapshot",
1340              "message": "Initial snapshot captured. Make changes and call diff_scene() again.",
1341              "objects_tracked": len(current["objects"]),
1342              "materials_tracked": len(current["materials"])
1343          }
1344  
1345      # Use the shared diff function
1346      diff_result = _compute_diff(_last_snapshot, current)
1347  
1348      # Update snapshot for next diff
1349      _last_snapshot = current
1350  
1351      # Build summary
1352      summary_parts = []
1353      changes = diff_result["changes"]
1354  
1355      # Object changes
1356      obj_changes = changes["objects"]
1357      if obj_changes.get("dreamtalk_modified"):
1358          for obj, params in obj_changes["dreamtalk_modified"].items():
1359              for param, vals in params.items():
1360                  summary_parts.append(f"{obj}.{param}: {vals['old']} → {vals['new']}")
1361  
1362      if obj_changes.get("native_modified"):
1363          for obj, params in obj_changes["native_modified"].items():
1364              for desc_id, vals in params.items():
1365                  summary_parts.append(f"{obj} [DescID {desc_id}]: {vals['old']} → {vals['new']}")
1366  
1367      if obj_changes.get("tags_modified"):
1368          for obj, params in obj_changes["tags_modified"].items():
1369              for param_key, vals in params.items():
1370                  tag_name = vals.get('tag', '')
1371                  name = vals.get('name', '')
1372                  summary_parts.append(f"{obj} tag '{tag_name}' {name}: {vals['old']} → {vals['new']}")
1373  
1374      if obj_changes.get("added"):
1375          summary_parts.append(f"Added objects: {', '.join(obj_changes['added'])}")
1376      if obj_changes.get("removed"):
1377          summary_parts.append(f"Removed objects: {', '.join(obj_changes['removed'])}")
1378  
1379      # Material changes
1380      mat_changes = changes["materials"]
1381      if mat_changes.get("modified"):
1382          for mat, params in mat_changes["modified"].items():
1383              for param, vals in params.items():
1384                  summary_parts.append(f"Material {mat}.{param}: {vals['old']} → {vals['new']}")
1385      if mat_changes.get("added"):
1386          summary_parts.append(f"Added materials: {', '.join(mat_changes['added'])}")
1387      if mat_changes.get("removed"):
1388          summary_parts.append(f"Removed materials: {', '.join(mat_changes['removed'])}")
1389  
1390      return {
1391          "changes": changes,
1392          "total_changes": diff_result["total_changes"],
1393          "summary": "; ".join(summary_parts) if summary_parts else "No changes detected"
1394      }
1395  
1396  
1397  def reset_snapshot():
1398      """Reset the scene snapshot to force a fresh capture on next diff."""
1399      global _last_snapshot
1400      _last_snapshot = None
1401      return {"status": "reset", "message": "Snapshot cleared"}
1402  
1403  
1404  def describe_scene(doc=None):
1405      """
1406      Universal scene introspection with automatic change detection.
1407  
1408      Combines all introspection capabilities into one call:
1409      - Scene metadata (fps, frame range, current frame)
1410      - Full object hierarchy with DreamTalk semantics
1411      - All UserData parameters with current values
1412      - Materials and their assignments
1413      - Animation keyframes summary
1414      - Validation warnings
1415      - Changes since last call (auto-diffing)
1416      - Console output delta (new messages with deduplication)
1417  
1418      Auto-snapshots after each call for change detection on next call.
1419      Console messages are deduplicated and truncated for safety.
1420  
1421      Args:
1422          doc: Cinema 4D document (defaults to active document)
1423  
1424      Returns:
1425          dict with complete scene state, changes detected, and console delta
1426      """
1427      global _last_snapshot
1428  
1429      if doc is None:
1430          doc = c4d.documents.GetActiveDocument()
1431  
1432      # Get current snapshot for diffing
1433      current_snapshot = get_scene_snapshot(doc)
1434  
1435      # Compute diff if we have a previous snapshot
1436      changes = None
1437      if _last_snapshot is not None:
1438          diff_result = _compute_diff(_last_snapshot, current_snapshot)
1439          if diff_result["total_changes"] > 0:
1440              changes = diff_result
1441  
1442      # Update snapshot for next call
1443      _last_snapshot = current_snapshot
1444  
1445      # Gather all scene info
1446      fps = doc.GetFps()
1447      current_frame = doc.GetTime().GetFrame(fps)
1448      doc_start = doc.GetMinTime().GetFrame(fps)
1449      doc_end = doc.GetMaxTime().GetFrame(fps)
1450  
1451      # Hierarchy
1452      hierarchy = describe_hierarchy(doc)
1453  
1454      # Materials
1455      materials = inspect_materials(doc)
1456  
1457      # Animation summary
1458      animation = inspect_animation(doc_start, doc_end, doc)
1459  
1460      # Validation
1461      validation = validate_scene(doc)
1462  
1463      # Get console delta (new messages since last describe_scene call)
1464      console_delta = get_console_delta()
1465  
1466      # Build result
1467      result = {
1468          "document_name": doc.GetDocumentName() or "Untitled",
1469          "frame": {
1470              "current": current_frame,
1471              "start": doc_start,
1472              "end": doc_end,
1473              "fps": fps
1474          },
1475          "hierarchy": hierarchy,
1476          "materials": materials,
1477          "animation": animation,
1478          "validation": validation,
1479          "changes": changes,
1480          "console": console_delta
1481      }
1482  
1483      return result
1484  
1485  
1486  def _compute_diff(old_snapshot, current_snapshot):
1487      """
1488      Compute diff between two snapshots.
1489  
1490      Internal helper for describe_scene auto-diffing.
1491  
1492      Handles GUID-based object tracking with separate dreamtalk/native params.
1493      DreamTalk changes are always reported.
1494      Native C4D param changes are only reported when they differ (silent tracking).
1495      """
1496      changes = {
1497          "objects": {
1498              "dreamtalk_modified": {},  # DreamTalk param changes (always important)
1499              "native_modified": {},      # Native C4D param changes (only on delta)
1500              "tags_modified": {},        # Tag param changes (only on delta)
1501              "added": [],
1502              "removed": []
1503          },
1504          "materials": {"modified": {}, "added": [], "removed": []}
1505      }
1506  
1507      # Get GUID->name mappings for display
1508      current_names = current_snapshot.get("guid_to_name", {})
1509      old_names = old_snapshot.get("guid_to_name", {})
1510  
1511      current_objects = current_snapshot.get("objects", {})
1512      old_objects = old_snapshot.get("objects", {})
1513  
1514      # Find object changes by GUID
1515      for guid, obj_data in current_objects.items():
1516          obj_name = obj_data.get("name", current_names.get(guid, guid))
1517  
1518          if guid not in old_objects:
1519              # New object
1520              changes["objects"]["added"].append(obj_name)
1521          else:
1522              old_obj_data = old_objects[guid]
1523  
1524              # Compare DreamTalk params
1525              dreamtalk_changes = {}
1526              current_dt = obj_data.get("dreamtalk", {})
1527              old_dt = old_obj_data.get("dreamtalk", {})
1528  
1529              for param, value in current_dt.items():
1530                  if param not in old_dt:
1531                      dreamtalk_changes[param] = {"old": None, "new": value}
1532                  elif old_dt[param] != value:
1533                      dreamtalk_changes[param] = {"old": old_dt[param], "new": value}
1534  
1535              if dreamtalk_changes:
1536                  changes["objects"]["dreamtalk_modified"][obj_name] = dreamtalk_changes
1537  
1538              # Compare Native C4D params (silent - only report deltas)
1539              # New structure: native = {"values": {...}, "meta": {...}}
1540              native_changes = {}
1541              current_native = obj_data.get("native", {})
1542              old_native = old_obj_data.get("native", {})
1543  
1544              current_values = current_native.get("values", current_native) if isinstance(current_native, dict) else {}
1545              old_values = old_native.get("values", old_native) if isinstance(old_native, dict) else {}
1546              current_meta = current_native.get("meta", {}) if isinstance(current_native, dict) else {}
1547  
1548              for param, value in current_values.items():
1549                  if param in old_values and old_values[param] != value:
1550                      # Only report if param existed before AND changed
1551                      change_info = {"old": old_values[param], "new": value}
1552  
1553                      # Add metadata if available
1554                      if param in current_meta:
1555                          meta = current_meta[param]
1556                          if "name" in meta:
1557                              change_info["name"] = meta["name"]
1558                          if "ident" in meta:
1559                              change_info["ident"] = meta["ident"]
1560                          if "options" in meta:
1561                              # Resolve old/new values to human-readable labels
1562                              options = meta["options"]
1563                              if old_values[param] in options:
1564                                  change_info["old_label"] = options[old_values[param]]
1565                              if value in options:
1566                                  change_info["new_label"] = options[value]
1567  
1568                      native_changes[param] = change_info
1569  
1570              if native_changes:
1571                  changes["objects"]["native_modified"][obj_name] = native_changes
1572  
1573              # Compare tag parameters
1574              current_tags = obj_data.get("tags", {})
1575              old_tags = old_obj_data.get("tags", {})
1576  
1577              obj_tag_changes = {}
1578              for tag_key, tag_data in current_tags.items():
1579                  if tag_key not in old_tags:
1580                      continue  # New tag, skip
1581                  old_tag_data = old_tags[tag_key]
1582                  current_tag_values = tag_data.get("native", {}).get("values", {})
1583                  old_tag_values = old_tag_data.get("native", {}).get("values", {})
1584                  current_tag_meta = tag_data.get("native", {}).get("meta", {})
1585  
1586                  for param, value in current_tag_values.items():
1587                      if param in old_tag_values and old_tag_values[param] != value:
1588                          change_info = {"old": old_tag_values[param], "new": value, "tag": tag_key}
1589                          if param in current_tag_meta:
1590                              meta = current_tag_meta[param]
1591                              if "name" in meta:
1592                                  change_info["name"] = meta["name"]
1593                              if "ident" in meta:
1594                                  change_info["ident"] = meta["ident"]
1595                              if "options" in meta:
1596                                  options = meta["options"]
1597                                  if old_tag_values[param] in options:
1598                                      change_info["old_label"] = options[old_tag_values[param]]
1599                                  if value in options:
1600                                      change_info["new_label"] = options[value]
1601                          obj_tag_changes[f"{tag_key}.{param}"] = change_info
1602  
1603              if obj_tag_changes:
1604                  changes["objects"]["tags_modified"][obj_name] = obj_tag_changes
1605  
1606      # Find removed objects
1607      for guid in old_objects:
1608          if guid not in current_objects:
1609              old_name = old_objects[guid].get("name", old_names.get(guid, guid))
1610              changes["objects"]["removed"].append(old_name)
1611  
1612      # Diff materials (simpler - no native/dreamtalk split)
1613      current_mats = current_snapshot.get("materials", {})
1614      old_mats = old_snapshot.get("materials", {})
1615  
1616      for mat_name, mat_data in current_mats.items():
1617          if mat_name not in old_mats:
1618              changes["materials"]["added"].append(mat_name)
1619          else:
1620              old_mat = old_mats[mat_name]
1621              mat_changes = {}
1622              for param, value in mat_data.items():
1623                  if param not in old_mat:
1624                      mat_changes[param] = {"old": None, "new": value}
1625                  elif old_mat[param] != value:
1626                      mat_changes[param] = {"old": old_mat[param], "new": value}
1627              if mat_changes:
1628                  changes["materials"]["modified"][mat_name] = mat_changes
1629  
1630      for mat_name in old_mats:
1631          if mat_name not in current_mats:
1632              changes["materials"]["removed"].append(mat_name)
1633  
1634      # Count total changes
1635      total_changes = (
1636          len(changes["objects"]["dreamtalk_modified"]) +
1637          len(changes["objects"]["native_modified"]) +
1638          len(changes["objects"]["tags_modified"]) +
1639          len(changes["objects"]["added"]) +
1640          len(changes["objects"]["removed"]) +
1641          len(changes["materials"]["modified"]) +
1642          len(changes["materials"]["added"]) +
1643          len(changes["materials"]["removed"])
1644      )
1645  
1646      return {
1647          "changes": changes,
1648          "total_changes": total_changes
1649      }