372 lines
14 KiB
Python
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'}
|