feat: initial commit for cable normalizer blender addon

This commit is contained in:
2026-06-02 22:48:54 +02:00
commit 7f677335c4
10 changed files with 1317 additions and 0 deletions

371
operators.py Normal file
View File

@@ -0,0 +1,371 @@
"""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'}