Files
cable-normalizer/operators.py

372 lines
14 KiB
Python

"""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'}