feat: initial commit for cable normalizer blender addon
This commit is contained in:
3
.gitignore
vendored
Normal file
3
.gitignore
vendored
Normal file
@@ -0,0 +1,3 @@
|
|||||||
|
__pycache__/
|
||||||
|
*.pyc
|
||||||
|
.DS_Store
|
||||||
9
README.md
Normal file
9
README.md
Normal 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
101
__init__.py
Normal 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
257
migration.py
Normal 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
371
operators.py
Normal 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
26
operators_get.py
Normal 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
28
panel.py
Normal 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
24
properties.py
Normal 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
198
texture_gen.py
Normal 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
300
utils.py
Normal 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
|
||||||
Reference in New Issue
Block a user