"""Cable Normalizer utilities. Cable detection uses an explicit 'Cable' Geometry Node modifier as the single source of truth. This node acts as both a marker (identifying an object as a cable) and a metadata interface (storing per-cable parameters like label text, color, diameter). Legacy detection via 'Draw Circle' / 'Solidify' / 'Extrude Curves' is retained as fallback for backward compatibility and migration. """ import bpy # --- Constants --- # Node group name that marks an object as a cable CABLE_NODE_NAME = 'Cable' # The empirical extrusion-scale product that yields 1 meter cable length. EXTRUSION_SCALE_PRODUCT_PER_METER = 77.0 # Texture dimensions (8:1 aspect ratio) TEXTURE_WIDTH = 4096 TEXTURE_HEIGHT = 512 TEXTURE_ASPECT = TEXTURE_HEIGHT / TEXTURE_WIDTH # 0.125 # Decal plane dimensions DECAL_SIZE_X = 50.0 DECAL_SIZE_Y = DECAL_SIZE_X * TEXTURE_ASPECT # 6.25 # --- Cable Node Group Management --- def ensure_cable_node_group(): """Create or return the 'Cable' geometry node group. The node is a pass-through: Geometry In → Geometry Out. It exposes input sockets for per-cable metadata that our addon reads during batch processing. """ if CABLE_NODE_NAME in bpy.data.node_groups: return bpy.data.node_groups[CABLE_NODE_NAME] ng = bpy.data.node_groups.new(CABLE_NODE_NAME, 'GeometryNodeTree') # Create interface sockets ng.interface.new_socket('Geometry', in_out='INPUT', socket_type='NodeSocketGeometry') ng.interface.new_socket('Geometry', in_out='OUTPUT', socket_type='NodeSocketGeometry') ng.interface.new_socket('Label Text', in_out='INPUT', socket_type='NodeSocketString') color_sock = ng.interface.new_socket('Label Color', in_out='INPUT', socket_type='NodeSocketColor') color_sock.default_value = (1.0, 1.0, 1.0, 1.0) bg_sock = ng.interface.new_socket('Label Background', in_out='INPUT', socket_type='NodeSocketColor') bg_sock.default_value = (0.0, 0.0, 0.0, 0.0) diam_sock = ng.interface.new_socket('Target Diameter', in_out='INPUT', socket_type='NodeSocketFloat') diam_sock.default_value = 13.8 diam_sock.min_value = 0.1 length_sock = ng.interface.new_socket('Target Length', in_out='INPUT', socket_type='NodeSocketFloat') length_sock.default_value = 1000.0 length_sock.min_value = 1.0 # Wire Geometry In → Geometry Out (pass-through) group_in = ng.nodes.new('NodeGroupInput') group_out = ng.nodes.new('NodeGroupOutput') group_in.location = (-200, 0) group_out.location = (200, 0) ng.links.new(group_in.outputs['Geometry'], group_out.inputs['Geometry']) return ng # --- Cable Detection --- def is_cable(obj): """Check if an object has the 'Cable' node group modifier.""" if obj.type != 'MESH' or obj.library is not None: return False for mod in obj.modifiers: if mod.type == 'NODES' and mod.node_group and mod.node_group.name == CABLE_NODE_NAME: return True return False def is_legacy_cable(obj): """Check if an object uses the old cable structure (Draw Circle + Extrude).""" if obj.type != 'MESH' or obj.library is not None: return False has_circle = False has_extrude = False for mod in obj.modifiers: if mod.type != 'NODES' or not mod.node_group: continue name = mod.node_group.name if 'Draw Circle' in name: has_circle = True if 'Extrude Curves' in name: has_extrude = True return has_circle and has_extrude def find_all_cables(): """Return all cable objects (new-style Cable node preferred, legacy fallback).""" cables = [] for obj in bpy.data.objects: if is_cable(obj) or is_legacy_cable(obj): cables.append(obj) return cables # --- Cable Node Parameter Access --- def _get_cable_modifier(obj): """Return the Cable node modifier, or None.""" for mod in obj.modifiers: if mod.type == 'NODES' and mod.node_group and mod.node_group.name == CABLE_NODE_NAME: return mod return None def _read_socket_value(mod, socket_name): """Read a socket value from a modifier by socket name.""" ng = mod.node_group for item in ng.interface.items_tree: if item.item_type == 'SOCKET' and item.in_out == 'INPUT' and item.name == socket_name: return mod.get(item.identifier) return None def get_cable_label_params(obj): """Read label parameters from the Cable node. Returns dict with keys: label_text, label_color, label_background, target_diameter, target_length. Returns None if no Cable node found. """ mod = _get_cable_modifier(obj) if mod is None: return None return { 'label_text': _read_socket_value(mod, 'Label Text') or '', 'label_color': _read_socket_value(mod, 'Label Color') or (1, 1, 1, 1), 'label_background': _read_socket_value(mod, 'Label Background') or (0, 0, 0, 0), 'target_diameter': _read_socket_value(mod, 'Target Diameter') or 13.8, 'target_length': _read_socket_value(mod, 'Target Length') or 1000.0, } # --- Shared GeoNode Helpers --- def _iter_geonode_inputs(obj): """Yield (modifier, node_group_name, item) for every GeoNode input socket.""" for mod in obj.modifiers: if mod.type == 'NODES' and mod.node_group: ng = mod.node_group for item in ng.interface.items_tree: if item.item_type == 'SOCKET' and item.in_out == 'INPUT': yield mod, ng.name, item # --- Legacy Cable Params (backward compat) --- def get_cable_params(obj): """Read the cable's raw geometric params from GeoNode modifiers. Works with both new-style (Cable node) and legacy cables. """ params = { 'radius': None, 'thickness': 0.0, 'solid_offset': 1.0, 'extrusion': None, 'extrusion_mod': None, 'extrusion_id': None, } for mod, ng_name, item in _iter_geonode_inputs(obj): if 'Draw Circle' in ng_name and item.name == 'Radius': params['radius'] = mod.get(item.identifier) elif 'Solidify' in ng_name: if item.name == 'Thickness': params['thickness'] = mod.get(item.identifier, 0.0) elif item.name == 'Offset': params['solid_offset'] = mod.get(item.identifier, 1.0) elif 'Extrude Curves' in ng_name and item.name == 'Extrusion': params['extrusion'] = mod.get(item.identifier) params['extrusion_mod'] = mod params['extrusion_id'] = item.identifier return params def compute_outer_diameter(params): """Outer mantle diameter in BU (before obj.scale).""" radius = params['radius'] if radius is None: raise ValueError("No Draw Circle Radius found on this object") thickness = params['thickness'] offset = params['solid_offset'] if offset > 0: outer_radius = radius + thickness else: outer_radius = radius return outer_radius * 2.0 def get_internal_scale(): """Scene unit scale (e.g. 0.001 for mm scenes).""" return bpy.context.scene.unit_settings.scale_length def find_cable_object(context): """Return the active mesh object, or None.""" obj = context.active_object if obj and obj.type == 'MESH': return obj return None def measure_diameter(obj): """Return the visual outer diameter of the cable in BU (after scale).""" params = get_cable_params(obj) outer_diam = compute_outer_diameter(params) return outer_diam * obj.scale[0] # --- Normalization --- def normalize_diameter(obj, target_diam_mm): """Set obj.scale so the visual outer diameter equals target_diam_mm.""" params = get_cable_params(obj) outer_diam = compute_outer_diameter(params) new_scale = target_diam_mm / outer_diam obj.scale = (new_scale, new_scale, new_scale) return new_scale def normalize_length(obj, target_length_mm): """Set Extrusion so the cable length matches the given mm target.""" params = get_cable_params(obj) if params['extrusion_mod'] is None: raise ValueError("No Extrude Curves modifier found") scale_z = max(obj.scale[2], 0.0001) target_len_m = target_length_mm / 1000.0 new_extrusion = (EXTRUSION_SCALE_PRODUCT_PER_METER * target_len_m) / scale_z params['extrusion_mod'][params['extrusion_id']] = new_extrusion return new_extrusion def normalize_labels(obj, old_scale_factor, new_scale_factor, target_label_scale=1.0): """Force all label GeoNode parameters to produce visually identical labels. Scale is set so that: GeoNode_Scale * obj.scale = target_label_scale Size X/Y are forced to constants matching the texture aspect ratio. """ if new_scale_factor == 0: return computed_scale = target_label_scale / new_scale_factor for mod, ng_name, item in _iter_geonode_inputs(obj): if 'Attach Decals' not in ng_name and 'Make Labels' not in ng_name: continue if item.name == 'Scale': mod[item.identifier] = computed_scale elif item.name == 'Size X': mod[item.identifier] = DECAL_SIZE_X elif item.name == 'Size Y': mod[item.identifier] = DECAL_SIZE_Y elif item.name in ('Offset', 'Distance'): val = mod.get(item.identifier) if val is not None and old_scale_factor != 0: ratio = old_scale_factor / new_scale_factor try: mod[item.identifier] = val * ratio except TypeError: mod[item.identifier] = tuple(v * ratio for v in val) def apply_label_rotation(obj, rotation_euler): """Set Label Rotation and Rotation sockets on label nodes.""" rot_tuple = tuple(rotation_euler) for mod, ng_name, item in _iter_geonode_inputs(obj): if 'Attach Decals' not in ng_name and 'Make Labels' not in ng_name: continue if item.name in ('Label Rotation', 'Rotation'): try: mod[item.identifier] = rot_tuple except TypeError: mod[item.identifier] = rot_tuple def apply_label_translation(obj, translation_vec): """Set Translation socket on label nodes.""" trans_tuple = tuple(translation_vec) for mod, ng_name, item in _iter_geonode_inputs(obj): if 'Attach Decals' not in ng_name and 'Make Labels' not in ng_name: continue if item.name == 'Translation': mod[item.identifier] = trans_tuple