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