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