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

3
.gitignore vendored Normal file
View File

@@ -0,0 +1,3 @@
__pycache__/
*.pyc
.DS_Store

9
README.md Normal file
View File

@@ -0,0 +1,9 @@
# Cable Normalizer (addon_normalizer)
Blender Addon zum Normalisieren von Kabel-Objekten.
## Features
- **Batch Normalization**: Durchmesser, Länge, Label-Rotation, Translation und Label-Textur-Skalierung.
- **Dynamic Text Generation**: Automatische Erzeugung von Label-Texturen über PIL.
- **Migration**: Automatische Migration von alten TTG-Textur-Einstellungen.
- **Schnittstelle**: Nutzung eines dedizierten "Cable" Geometry-Node-Modifiers als Single Source of Truth.

101
__init__.py Normal file
View File

@@ -0,0 +1,101 @@
bl_info = {
"name": "Kabeck Cable Normalizer",
"author": "Antigravity",
"version": (3, 0),
"blender": (3, 3, 0),
"location": "View3D > Sidebar > Kabeck",
"description": "Normalizes cable thickness, labels, length and generates uniform label textures",
"warning": "",
"doc_url": "",
"category": "3D View",
}
if "bpy" in locals():
import importlib
importlib.reload(utils)
importlib.reload(texture_gen)
importlib.reload(migration)
importlib.reload(operators)
else:
import bpy
from . import utils
from . import texture_gen
from . import migration
from . import operators
classes = (
operators.KABECK_OT_get_diameter,
operators.KABECK_OT_normalize_cable,
operators.KABECK_OT_batch_process,
operators.KABECK_PT_normalizer_panel,
migration.KABECK_OT_convert_to_cable,
migration.KABECK_OT_migrate_cables,
)
def register():
bpy.types.Scene.normalizer_target_diameter = bpy.props.FloatProperty(
name="Target Diameter",
description="Fallback target outer diameter in mm (used when Cable node has no value)",
default=13.8,
min=0.1,
)
bpy.types.Scene.normalizer_target_length = bpy.props.FloatProperty(
name="Target Length",
description="Fallback target length in mm",
default=1000.0,
min=1.0,
)
bpy.types.Scene.normalizer_target_label_rotation = bpy.props.FloatVectorProperty(
name="Target Label Rotation",
description="Uniform rotation applied to all labels during batch",
subtype='EULER',
default=(0.0, 0.0, 0.0),
)
bpy.types.Scene.normalizer_target_label_scale = bpy.props.FloatProperty(
name="Target Label Size",
description="Visual size of the label (Scale * obj.scale product)",
default=1.0,
min=0.01,
)
bpy.types.Scene.normalizer_font_path = bpy.props.StringProperty(
name="Font Path",
description="Path to a .ttf or .otf font file for label textures",
default="/Volumes/Alpha SSD/Fonts/Windows Dots.ttf",
subtype='FILE_PATH',
)
bpy.types.Scene.normalizer_label_prefix = bpy.props.StringProperty(
name="Label Prefix",
description="Text prepended to all cable labels (e.g. 'Kabeck')",
default="Kabeck",
)
bpy.types.Scene.normalizer_prefix_gap = bpy.props.IntProperty(
name="Prefix Gap (px)",
description="Gap in pixels between prefix and label on the texture",
default=50,
min=0, max=2000,
)
bpy.types.Scene.normalizer_target_label_translation = bpy.props.FloatVectorProperty(
name="Label Translation",
description="XYZ Transform translation applied to all labels",
subtype='TRANSLATION',
default=(0.0, 0.0, 0.0),
)
for cls in classes:
bpy.utils.register_class(cls)
def unregister():
del bpy.types.Scene.normalizer_target_diameter
del bpy.types.Scene.normalizer_target_length
del bpy.types.Scene.normalizer_target_label_rotation
del bpy.types.Scene.normalizer_target_label_scale
del bpy.types.Scene.normalizer_font_path
del bpy.types.Scene.normalizer_label_prefix
del bpy.types.Scene.normalizer_prefix_gap
del bpy.types.Scene.normalizer_target_label_translation
for cls in reversed(classes):
bpy.utils.unregister_class(cls)
if __name__ == "__main__":
register()

257
migration.py Normal file
View File

@@ -0,0 +1,257 @@
"""Migration and conversion utilities for Cable Normalizer.
Provides operators to:
- Convert the selected object to a cable (adds 'Cable' GeoNode)
- Batch-migrate all legacy cables at once
- Migrate text/font settings from the Text Texture Generator addon
"""
import bpy
from . import utils
# Mapping from kabeck.blend scene names to their correct TTG label text.
# Derived from scanning all source .blend files' text_texture_props.
# Only entries where scene name differs from the actual label text need
# to be listed; for all others the scene name IS the label text.
_TTG_LABEL_TEXT = {
'N2XSF2Y': 'N2XS(F)2Y',
'NA2XSF2Y': 'NA2XS(F)2Y',
'NA2XSFL2Y': 'NA2XS(FL)2Y',
'NA2XY': '(N)A2XY',
}
def _get_ttg_data_for_scene(scene):
"""Extract text and font from Text Texture Generator properties.
Checks both the scene's own text_texture_props and, if the object
is linked, attempts to read from the source library's scene.
Returns dict with keys: text, font_path (or None if TTG not found).
"""
if not hasattr(scene, 'text_texture_props'):
return None
tp = scene.text_texture_props
text = tp.prepend_text + tp.text + tp.append_text
font_path = ''
if tp.use_custom_font and tp.custom_font_path:
font_path = tp.custom_font_path
elif tp.font_path and tp.font_path != 'default':
font_path = tp.font_path
return {'text': text, 'font_path': font_path}
def _find_ttg_font_from_libraries():
"""Scan linked library scenes for Text Texture Generator font settings.
Since the kabeck.blend links objects from source blends (NYY.blend etc.),
the TTG settings live in those source files. We can't open them during
migration, but the linked data may carry scene references.
As a pragmatic fallback, we check if any scene in the current file
has non-empty TTG settings. If not, we look for commonly used fonts
on disk based on what we know from the source blend analysis.
"""
# First: Check all scenes in current file for TTG data
for scn in bpy.data.scenes:
data = _get_ttg_data_for_scene(scn)
if data and data['font_path']:
return data['font_path']
# Fallback: Check known font path from the source blends
# (all source blends use "Windows Dots.ttf" per analysis)
import os
known_font = "/Volumes/Alpha SSD/Fonts/Windows Dots.ttf"
if os.path.exists(known_font):
return known_font
return ''
def _find_ttg_prefix():
"""Find the PREPEND composition text from TTG overlays.
The TTG composition system stores prepend text as overlay components
with placement_mode='PREPEND'. All source blends use 'Kabeck' as
the prefix, set as a TEXT overlay component.
"""
# Check all scenes for TTG overlay data
for scn in bpy.data.scenes:
if not hasattr(scn, 'text_texture_props'):
continue
tp = scn.text_texture_props
for ov in tp.image_overlays:
if ov.component_type == 'TEXT' and ov.placement_mode == 'PREPEND':
if ov.text_content:
return ov.text_content
# Fallback: known prefix from source blend analysis
return 'Kabeck'
def _add_cable_node(obj, label_text=''):
"""Add a Cable node modifier to an object and populate defaults.
The Cable node group is created once and shared across all cables.
This is effectively 'linked' behavior — all cables reference the
same node group definition.
"""
cable_ng = utils.ensure_cable_node_group()
# Avoid duplicates
if utils.is_cable(obj):
return None
mod = obj.modifiers.new(name=utils.CABLE_NODE_NAME, type='NODES')
mod.node_group = cable_ng
# Auto-detect label text with priority chain:
# 1. TTG mapping table (known mismatches)
# 2. TTG scene properties (prepend + text + append)
# 3. Scene name as fallback
if not label_text:
for scn in bpy.data.scenes:
if obj.name in scn.objects:
scene_name = scn.name
# Priority 1: Known TTG label text mapping
if scene_name in _TTG_LABEL_TEXT:
label_text = _TTG_LABEL_TEXT[scene_name]
else:
# Priority 2: TTG properties on the scene
ttg = _get_ttg_data_for_scene(scn)
if ttg and ttg['text']:
label_text = ttg['text']
else:
# Priority 3: Scene name
label_text = scene_name
break
# Read current diameter from legacy structure
try:
params = utils.get_cable_params(obj)
outer_diam = utils.compute_outer_diameter(params)
current_diam = outer_diam * obj.scale[0]
except ValueError:
current_diam = 13.8
# Populate sockets
for item in cable_ng.interface.items_tree:
if item.item_type != 'SOCKET' or item.in_out != 'INPUT':
continue
if item.name == 'Label Text':
mod[item.identifier] = label_text
elif item.name == 'Target Diameter':
mod[item.identifier] = current_diam
return mod
class KABECK_OT_convert_to_cable(bpy.types.Operator):
"""Convert the selected object to a cable by adding the Cable node."""
bl_idname = "kabeck.convert_to_cable"
bl_label = "Convert to Cable"
bl_description = (
"Adds the 'Cable' Geometry Node to the selected object, "
"marking it as a cable and enabling per-cable label settings"
)
bl_options = {'REGISTER', 'UNDO'}
@classmethod
def poll(cls, context):
obj = context.active_object
return obj is not None and obj.type == 'MESH' and not utils.is_cable(obj)
def execute(self, context):
obj = context.active_object
mod = _add_cable_node(obj)
if mod is None:
self.report({'WARNING'}, f"'{obj.name}' is already a cable")
return {'CANCELLED'}
self.report({'INFO'}, f"Converted '{obj.name}' to cable")
return {'FINISHED'}
class KABECK_OT_migrate_cables(bpy.types.Operator):
"""Batch-convert legacy cables and update existing Cable nodes.
Migrates Text Texture Generator settings:
- Label Text from TTG text/prepend/append fields (or mapping table)
- Font path set globally from TTG custom_font_path
Can be re-run to update Label Text on already-migrated cables.
"""
bl_idname = "kabeck.migrate_cables"
bl_label = "Migrate / Update Cable Labels"
bl_description = (
"Add or update 'Cable' GeoNode on all cables, "
"migrate text/font from Text Texture Generator"
)
bl_options = {'REGISTER', 'UNDO'}
def execute(self, context):
migrated = 0
updated = 0
# Migrate font setting globally from TTG
ttg_font = _find_ttg_font_from_libraries()
if ttg_font:
context.scene.normalizer_font_path = ttg_font
# Migrate prefix from TTG composition overlays
ttg_prefix = _find_ttg_prefix()
if ttg_prefix:
context.scene.normalizer_label_prefix = ttg_prefix
for obj in bpy.data.objects:
if utils.is_cable(obj):
# Already has Cable node → update Label Text
self._update_label_text(obj)
updated += 1
continue
if not utils.is_legacy_cable(obj):
continue
_add_cable_node(obj)
migrated += 1
font_msg = f", font: {ttg_font}" if ttg_font else ""
self.report(
{'INFO'},
f"Migrated {migrated}, updated {updated} cables{font_msg}",
)
return {'FINISHED'}
def _update_label_text(self, obj):
"""Update the Label Text on an existing Cable node from TTG data."""
mod = utils._get_cable_modifier(obj)
if mod is None:
return
# Resolve the correct label text
label_text = ''
for scn in bpy.data.scenes:
if obj.name in scn.objects:
scene_name = scn.name
if scene_name in _TTG_LABEL_TEXT:
label_text = _TTG_LABEL_TEXT[scene_name]
else:
ttg = _get_ttg_data_for_scene(scn)
if ttg and ttg['text']:
label_text = ttg['text']
else:
label_text = scene_name
break
if not label_text:
return
# Write to the Cable node socket
cable_ng = mod.node_group
for item in cable_ng.interface.items_tree:
if item.item_type == 'SOCKET' and item.in_out == 'INPUT' and item.name == 'Label Text':
mod[item.identifier] = label_text
break

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

26
operators_get.py Normal file
View File

@@ -0,0 +1,26 @@
import bpy
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 to its exact size"
def execute(self, context):
from . import utils
obj = context.active_object
if not obj or obj.type != 'MESH':
self.report({'ERROR'}, "Select a cable first")
return {'CANCELLED'}
depsgraph = context.evaluated_depsgraph_get()
eval_obj = obj.evaluated_get(depsgraph)
dims = [eval_obj.dimensions.x, eval_obj.dimensions.y, eval_obj.dimensions.z]
dims.sort()
internal_scale = utils.get_internal_scale()
# Convert internal units back to mm for the UI
diam_mm = dims[1] / internal_scale if dims[1] > 0.0001 else 13.8
context.scene.normalizer_target_diameter = diam_mm
self.report({'INFO'}, f"Target Diameter set to {diam_mm:.2f} mm")
return {'FINISHED'}

28
panel.py Normal file
View File

@@ -0,0 +1,28 @@
import bpy
class CABLENORMALIZER_PT_panel(bpy.types.Panel):
bl_label = "Cable Normalizer"
bl_idname = "CABLENORMALIZER_PT_panel"
bl_space_type = 'VIEW_3D'
bl_region_type = 'UI'
bl_category = "Cable Normalizer"
def draw(self, context):
layout = self.layout
settings = context.scene.cable_normalizer
box = layout.box()
box.label(text="Dimensions", icon='MESH_DATA')
box.prop(settings, "target_diameter")
box.prop(settings, "target_length")
box = layout.box()
box.label(text="Labels", icon='FONT_DATA')
box.prop(scene, "normalizer_target_label_scale", text="Label Visual Size")
box.prop(scene, "normalizer_target_label_rotation", text="Label Rotation")
layout.separator()
row = layout.row(align=True)
row.scale_y = 2.0
row.operator("cablenormalizer.normalize", text="Normalize Cable", icon='CHECKMARK')

24
properties.py Normal file
View File

@@ -0,0 +1,24 @@
import bpy
class CableNormalizerSettings(bpy.types.PropertyGroup):
target_diameter: bpy.props.FloatProperty(
name="Target Diameter",
description="Target cable diameter in meters",
default=0.030, min=0.001, max=1.0, subtype='DISTANCE', unit='LENGTH',
)
target_length: bpy.props.FloatProperty(
name="Target Length",
description="Target cable length in meters",
default=1.0, min=0.01, max=100.0, subtype='DISTANCE', unit='LENGTH',
)
target_font_size: bpy.props.FloatProperty(
name="Font Size",
description="Label font size",
default=200.0, min=1.0, max=10000.0,
)
target_aspect_ratio: bpy.props.FloatProperty(
name="Aspect Ratio",
description="Label aspect ratio (width:height)",
default=4.0, min=0.1, max=20.0,
)

198
texture_gen.py Normal file
View File

@@ -0,0 +1,198 @@
"""Lightweight label texture generator using PIL.
Generates label textures for cables with uniform font sizing.
The 'fit-to-longest' algorithm ensures all labels use the same
font size, calibrated to the longest text across all cables.
"""
from PIL import Image, ImageDraw, ImageFont
# --- Constants ---
# Default texture canvas dimensions (8:1 aspect ratio)
DEFAULT_WIDTH = 4096
DEFAULT_HEIGHT = 512
# Reference font size used for measuring relative text widths
_REFERENCE_FONT_SIZE = 200
# Horizontal margin in pixels on each side of the text
DEFAULT_MARGIN_PX = 40
# Fallback font if no custom font is provided
_FALLBACK_FONT_PATH = "/System/Library/Fonts/Supplemental/Arial.ttf"
def _load_font(font_path, font_size):
"""Load a TrueType font, falling back gracefully on failure."""
paths_to_try = [font_path, _FALLBACK_FONT_PATH] if font_path else [_FALLBACK_FONT_PATH]
for path in paths_to_try:
if not path:
continue
try:
return ImageFont.truetype(path, font_size)
except (OSError, IOError):
continue
# Ultimate fallback: PIL default bitmap font (ugly but functional)
return ImageFont.load_default()
def measure_text_width(text, font_path, font_size):
"""Measure the rendered pixel width of a text string.
Returns the width in pixels at the given font size.
"""
font = _load_font(font_path, font_size)
# Use a temporary image just for measurement
img = Image.new('RGBA', (1, 1))
draw = ImageDraw.Draw(img)
bbox = draw.textbbox((0, 0), text, font=font)
return bbox[2] - bbox[0]
def calculate_uniform_font_size(text_pairs, font_path, canvas_width, prefix_gap_px=0, margin_px=DEFAULT_MARGIN_PX):
"""Calculate the largest font size that fits ALL texts within the canvas.
This is the core of the 'fit-to-longest' algorithm:
1. Measure all texts at a reference font size
2. Find the widest one
3. Scale the font size so the widest text fits in (canvas_width - 2*margin)
Returns the calculated font size as an integer.
"""
if not text_pairs:
return _REFERENCE_FONT_SIZE
# We subtract prefix_gap_px from the usable width because the gap is absolute
# (in final pixels) and does not scale with the font size.
usable_width = canvas_width - (2 * margin_px) - prefix_gap_px
if usable_width <= 0:
raise ValueError(f"Canvas too narrow: {canvas_width}px with {margin_px}px margins and {prefix_gap_px}px gap")
max_width = 0
for prefix, text in text_pairs:
w1 = measure_text_width(prefix, font_path, _REFERENCE_FONT_SIZE) if prefix else 0
w2 = measure_text_width(text, font_path, _REFERENCE_FONT_SIZE) if text else 0
max_width = max(max_width, w1 + w2)
if max_width == 0:
return _REFERENCE_FONT_SIZE
# Scale the reference font size proportionally
# At _REFERENCE_FONT_SIZE, the widest text is max_width pixels.
# We need it to be usable_width pixels.
font_size = int((_REFERENCE_FONT_SIZE * usable_width) / max_width)
# Clamp to reasonable bounds
return max(8, min(font_size, 500))
def generate_label_texture(
prefix,
text,
prefix_gap_px,
font_path,
font_size,
text_color=(255, 255, 255, 255),
bg_color=(0, 0, 0, 0),
width=DEFAULT_WIDTH,
height=DEFAULT_HEIGHT,
align='left',
margin_px=DEFAULT_MARGIN_PX,
):
"""Render a text label onto a PIL Image.
Args:
prefix: The prefix string (can be empty).
text: The label string to render.
prefix_gap_px: The gap between prefix and text in pixels.
font_path: Path to a .ttf/.otf font file.
font_size: Font size in pixels.
text_color: RGBA tuple for the text color.
bg_color: RGBA tuple for the background.
width: Canvas width in pixels.
height: Canvas height in pixels.
align: 'left' or 'right'.
margin_px: Horizontal margin in pixels.
Returns:
A PIL.Image.Image in RGBA mode.
"""
img = Image.new('RGBA', (width, height), bg_color)
draw = ImageDraw.Draw(img)
font = _load_font(font_path, font_size)
# Measure prefix
if prefix:
p_bbox = draw.textbbox((0, 0), prefix, font=font)
p_width = p_bbox[2] - p_bbox[0]
p_height = p_bbox[3] - p_bbox[1]
else:
p_width, p_height, p_bbox = 0, 0, [0, 0, 0, 0]
# Measure text
if text:
t_bbox = draw.textbbox((0, 0), text, font=font)
t_width = t_bbox[2] - t_bbox[0]
t_height = t_bbox[3] - t_bbox[1]
else:
t_width, t_height, t_bbox = 0, 0, [0, 0, 0, 0]
actual_gap = prefix_gap_px if (prefix and text) else 0
total_width = p_width + actual_gap + t_width
# Vertical centering
max_height = max(p_height, t_height)
max_y_offset = min(p_bbox[1] if prefix else 9999, t_bbox[1] if text else 9999)
if max_y_offset == 9999:
max_y_offset = 0
y = (height - max_height) // 2 - max_y_offset
# Horizontal positioning
if align == 'right':
x = width - margin_px - total_width
else:
x = margin_px
if prefix:
draw.text((x, y), prefix, font=font, fill=text_color)
x += p_width + actual_gap
if text:
draw.text((x, y), text, font=font, fill=text_color)
return img
def pil_image_to_blender(image_name, pil_img):
"""Convert a PIL Image to a Blender image datablock.
Creates or overwrites a Blender image with the given name,
packs it so it's stored inside the .blend file.
"""
import bpy
import numpy as np
width, height = pil_img.size
# Ensure RGBA
if pil_img.mode != 'RGBA':
pil_img = pil_img.convert('RGBA')
# Create or reuse the Blender image
if image_name in bpy.data.images:
bl_img = bpy.data.images[image_name]
if bl_img.size[0] != width or bl_img.size[1] != height:
bl_img.scale(width, height)
else:
bl_img = bpy.data.images.new(image_name, width, height, alpha=True)
# PIL stores top-to-bottom, Blender expects bottom-to-top
flipped = pil_img.transpose(Image.FLIP_TOP_BOTTOM)
# Convert to float32 array normalized 0-1 (Blender pixel format)
pixels = np.array(flipped, dtype=np.float32) / 255.0
bl_img.pixels.foreach_set(pixels.ravel())
bl_img.pack()
bl_img.update()
return bl_img

300
utils.py Normal file
View File

@@ -0,0 +1,300 @@
"""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