"""Cable Normalizer operators and UI panel. Contains the normalize and batch process operators, plus the sidebar panel for the Cable Normalizer addon. """ import bpy from . import utils from . import texture_gen class KABECK_OT_get_diameter(bpy.types.Operator): bl_idname = "kabeck.get_diameter" bl_label = "Set Target from Selected" bl_description = "Measures the selected cable and sets the Target Diameter" def execute(self, context): obj = context.active_object if not obj or obj.type != 'MESH': self.report({'ERROR'}, "Select a cable first") return {'CANCELLED'} current_diam = utils.measure_diameter(obj) internal_scale = utils.get_internal_scale() diam_mm = current_diam / internal_scale context.scene.normalizer_target_diameter = diam_mm self.report({'INFO'}, f"Target Diameter set to {diam_mm:.2f} mm") return {'FINISHED'} class KABECK_OT_normalize_cable(bpy.types.Operator): bl_idname = "kabeck.normalize_cable" bl_label = "Normalize Cable" bl_description = "Normalizes cable thickness, labels, AND length" bl_options = {'REGISTER', 'UNDO'} def execute(self, context): scene = context.scene obj = utils.find_cable_object(context) if obj is None: self.report({'ERROR'}, "No active cable object found.") return {'CANCELLED'} if bpy.context.object and bpy.context.object.mode != 'OBJECT': bpy.ops.object.mode_set(mode='OBJECT') try: # Read target values from Cable node if available, else from panel label_params = utils.get_cable_label_params(obj) if label_params: target_diam = label_params['target_diameter'] target_len = label_params['target_length'] else: target_diam = scene.normalizer_target_diameter target_len = scene.normalizer_target_length old_scale = obj.scale[0] new_scale = utils.normalize_diameter(obj, target_diam) utils.normalize_length(obj, target_len) utils.normalize_labels( obj, old_scale, new_scale, target_label_scale=scene.normalizer_target_label_scale, ) obj.update_tag() context.view_layer.depsgraph.update() self.report({'INFO'}, f"Cable normalized to {target_diam}mm") except Exception as e: self.report({'ERROR'}, f"Error normalizing cable: {str(e)}") import traceback traceback.print_exc() return {'CANCELLED'} return {'FINISHED'} class KABECK_PT_normalizer_panel(bpy.types.Panel): bl_label = "Cable Normalizer" bl_idname = "KABECK_PT_normalizer_panel" bl_space_type = 'VIEW_3D' bl_region_type = 'UI' bl_category = 'Item' def draw(self, context): layout = self.layout scene = context.scene obj = context.active_object # --- Convert to Cable (top, most important action) --- if obj and obj.type == 'MESH': if utils.is_cable(obj): row = layout.row() row.label(text=f"✓ '{obj.name}' is a Cable", icon='CHECKMARK') else: layout.operator( "kabeck.convert_to_cable", text="Convert to Cable", icon='OUTLINER_OB_MESH', ) layout.separator() # --- Fallback Targets (used when no Cable node) --- box = layout.box() box.label(text="Fallback Targets", icon='PREFERENCES') box.prop(scene, "normalizer_target_diameter", text="Diameter (mm)") box.operator("kabeck.get_diameter", text="Get from Selected", icon='EYEDROPPER') box.prop(scene, "normalizer_target_length", text="Length (mm)") # --- Label Settings (global) --- box_labels = layout.box() box_labels.label(text="Labels (Batch Sync)", icon='FONT_DATA') box_labels.prop(scene, "normalizer_label_prefix", text="Prefix") box_labels.prop(scene, "normalizer_prefix_gap", text="Prefix Gap (px)") box_labels.prop(scene, "normalizer_target_label_scale", text="Label Size") box_labels.prop(scene, "normalizer_target_label_rotation", text="Rotation") box_labels.prop(scene, "normalizer_target_label_translation", text="Translation") box_labels.prop(scene, "normalizer_font_path", text="Font") # --- Actions --- layout.separator() layout.operator( "kabeck.normalize_cable", text="Normalize Selected Cable", icon='MOD_LATTICE', ) layout.separator() layout.operator( "kabeck.batch_process", text="Batch Normalize & Generate All", icon='SCENE_DATA', ) # --- Migration / Update --- layout.separator() box_migrate = layout.box() box_migrate.label(text="Migration", icon='IMPORT') has_legacy = any( utils.is_legacy_cable(o) and not utils.is_cable(o) for o in bpy.data.objects ) if has_legacy: box_migrate.operator( "kabeck.migrate_cables", text="Migrate Legacy Cables", icon='FILE_REFRESH', ) has_cables = any(utils.is_cable(o) for o in bpy.data.objects) if has_cables: box_migrate.operator( "kabeck.migrate_cables", text="Update Cable Labels (Re-Migrate)", icon='FILE_REFRESH', ) def _assign_texture_to_decal_material(obj, bl_img): """Replace the texture in the decal material with a new Blender image. Walks the Attach Decals / Make Labels modifiers, finds the Material socket, then either swaps the image in an existing local material or creates a new local material (when the existing one is linked/readonly). """ for mod in obj.modifiers: if mod.type != 'NODES' or not mod.node_group: continue ng_name = mod.node_group.name if 'Attach Decals' not in ng_name and 'Make Labels' not in ng_name: continue # Find the Material socket for item in mod.node_group.interface.items_tree: if item.item_type != 'SOCKET' or item.in_out != 'INPUT': continue if item.socket_type != 'NodeSocketMaterial': continue mat = mod[item.identifier] safe = obj.name.replace(' ', '_').replace('(', '').replace(')', '') mat_name = f"CableLabel_Mat_{safe}" if mat is None or mat.library is not None: # No material or linked material → create a new local one mat = _create_label_material(mat_name, bl_img) mod[item.identifier] = mat else: # Local material → swap the image in place if not _swap_material_image(mat, bl_img): # No TEX_IMAGE node found → replace with new material mat = _create_label_material(mat_name, bl_img) mod[item.identifier] = mat def _swap_material_image(mat, bl_img): """Find the first Image Texture node in a material and replace its image. Returns True if an Image Texture node was found and updated, False otherwise. """ if not mat.use_nodes or not mat.node_tree: return False for node in mat.node_tree.nodes: if node.type == 'TEX_IMAGE': node.image = bl_img return True return False def _create_label_material(name, bl_img): """Create a simple unlit material with an image texture and alpha transparency.""" mat = bpy.data.materials.new(name) mat.use_nodes = True mat.blend_method = 'CLIP' # type: ignore[attr-defined] tree = mat.node_tree tree.nodes.clear() # Image Texture tex_node = tree.nodes.new('ShaderNodeTexImage') tex_node.image = bl_img tex_node.location = (-300, 300) # Principled BSDF bsdf = tree.nodes.new('ShaderNodeBsdfPrincipled') bsdf.location = (0, 300) # Material Output output = tree.nodes.new('ShaderNodeOutputMaterial') output.location = (300, 300) # Wire: Image Color → BSDF Base Color tree.links.new(tex_node.outputs['Color'], bsdf.inputs['Base Color']) # Wire: Image Alpha → BSDF Alpha tree.links.new(tex_node.outputs['Alpha'], bsdf.inputs['Alpha']) # Wire: BSDF → Output tree.links.new(bsdf.outputs['BSDF'], output.inputs['Surface']) return mat class KABECK_OT_batch_process(bpy.types.Operator): bl_idname = "kabeck.batch_process" bl_label = "Batch Normalize & Generate All" bl_description = ( "Generates label textures with uniform font sizing, " "normalizes all cables, and syncs label parameters" ) bl_options = {'REGISTER'} def execute(self, context): scene = context.scene font_path = scene.normalizer_font_path label_prefix = scene.normalizer_label_prefix prefix_gap = scene.normalizer_prefix_gap target_label_scale = scene.normalizer_target_label_scale target_rotation = tuple(scene.normalizer_target_label_rotation) target_translation = tuple(scene.normalizer_target_label_translation) try: cables = utils.find_all_cables() if not cables: self.report({'WARNING'}, "No cables found in the file") return {'CANCELLED'} # --- Phase 1: Collect label texts from all cables --- cable_texts = {} for obj in cables: label_params = utils.get_cable_label_params(obj) if label_params and label_params['label_text']: cable_texts[obj.name] = label_params else: # Fallback: use the scene name this object belongs to for scn in bpy.data.scenes: if obj.name in scn.objects: cable_texts[obj.name] = { 'label_text': scn.name, 'label_color': (1, 1, 1, 1), 'label_background': (0, 0, 0, 0), 'target_diameter': scene.normalizer_target_diameter, 'target_length': scene.normalizer_target_length, } break # --- Phase 2: Calculate uniform font size --- text_pairs = [] for p in cable_texts.values(): if not p['label_text']: continue text_pairs.append((label_prefix, p['label_text'])) uniform_font_size = texture_gen.calculate_uniform_font_size( text_pairs, font_path, utils.TEXTURE_WIDTH, prefix_gap_px=prefix_gap ) # --- Phase 3: Generate textures and assign to materials --- tex_count = 0 for obj_name, params in cable_texts.items(): text = params['label_text'] if not text: continue # Convert Blender color (0-1 float) to PIL (0-255 int) lc = params['label_color'] text_color = ( int(lc[0] * 255), int(lc[1] * 255), int(lc[2] * 255), 255, ) bg = params['label_background'] bg_color = ( int(bg[0] * 255), int(bg[1] * 255), int(bg[2] * 255), int(bg[3] * 255), ) pil_img = texture_gen.generate_label_texture( prefix=label_prefix, text=text, prefix_gap_px=prefix_gap, font_path=font_path, font_size=uniform_font_size, text_color=text_color, bg_color=bg_color, width=utils.TEXTURE_WIDTH, height=utils.TEXTURE_HEIGHT, align='right', ) # Create unique Blender image name per cable safe_name = obj_name.replace(' ', '_').replace('(', '').replace(')', '') img_name = f"CableLabel_{safe_name}" bl_img = texture_gen.pil_image_to_blender(img_name, pil_img) # Assign texture to the material on Attach Decals / Make Labels obj = bpy.data.objects.get(obj_name) if obj: _assign_texture_to_decal_material(obj, bl_img) tex_count += 1 # --- Phase 4: Normalize all cables --- norm_count = 0 for obj in cables: params = cable_texts.get(obj.name, {}) target_diam = params.get('target_diameter', scene.normalizer_target_diameter) target_len = params.get('target_length', scene.normalizer_target_length) # Apply rotation utils.apply_label_rotation(obj, target_rotation) # Normalize geometry old_scale = obj.scale[0] new_scale = utils.normalize_diameter(obj, target_diam) utils.normalize_length(obj, target_len) utils.normalize_labels( obj, old_scale, new_scale, target_label_scale=target_label_scale, ) utils.apply_label_translation(obj, target_translation) obj.update_tag() norm_count += 1 context.view_layer.depsgraph.update() self.report( {'INFO'}, f"Generated {tex_count} textures (font {uniform_font_size}px), " f"normalized {norm_count} cables.", ) except Exception as e: self.report({'ERROR'}, f"Batch process failed: {str(e)}") import traceback traceback.print_exc() return {'CANCELLED'} return {'FINISHED'}