From 7f677335c4d4c03a272415ee663014ca521d6505 Mon Sep 17 00:00:00 2001 From: Marc Mintel Date: Tue, 2 Jun 2026 22:48:54 +0200 Subject: [PATCH] feat: initial commit for cable normalizer blender addon --- .gitignore | 3 + README.md | 9 ++ __init__.py | 101 +++++++++++++ migration.py | 257 ++++++++++++++++++++++++++++++++ operators.py | 371 +++++++++++++++++++++++++++++++++++++++++++++++ operators_get.py | 26 ++++ panel.py | 28 ++++ properties.py | 24 +++ texture_gen.py | 198 +++++++++++++++++++++++++ utils.py | 300 ++++++++++++++++++++++++++++++++++++++ 10 files changed, 1317 insertions(+) create mode 100644 .gitignore create mode 100644 README.md create mode 100644 __init__.py create mode 100644 migration.py create mode 100644 operators.py create mode 100644 operators_get.py create mode 100644 panel.py create mode 100644 properties.py create mode 100644 texture_gen.py create mode 100644 utils.py diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..b908d4c --- /dev/null +++ b/.gitignore @@ -0,0 +1,3 @@ +__pycache__/ +*.pyc +.DS_Store diff --git a/README.md b/README.md new file mode 100644 index 0000000..821c479 --- /dev/null +++ b/README.md @@ -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. diff --git a/__init__.py b/__init__.py new file mode 100644 index 0000000..c1c0368 --- /dev/null +++ b/__init__.py @@ -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() diff --git a/migration.py b/migration.py new file mode 100644 index 0000000..792e1b4 --- /dev/null +++ b/migration.py @@ -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 diff --git a/operators.py b/operators.py new file mode 100644 index 0000000..9677aa3 --- /dev/null +++ b/operators.py @@ -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'} diff --git a/operators_get.py b/operators_get.py new file mode 100644 index 0000000..2ceb538 --- /dev/null +++ b/operators_get.py @@ -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'} diff --git a/panel.py b/panel.py new file mode 100644 index 0000000..ec4fc58 --- /dev/null +++ b/panel.py @@ -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') \ No newline at end of file diff --git a/properties.py b/properties.py new file mode 100644 index 0000000..2b3e063 --- /dev/null +++ b/properties.py @@ -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, + ) \ No newline at end of file diff --git a/texture_gen.py b/texture_gen.py new file mode 100644 index 0000000..30e47bf --- /dev/null +++ b/texture_gen.py @@ -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 diff --git a/utils.py b/utils.py new file mode 100644 index 0000000..3701639 --- /dev/null +++ b/utils.py @@ -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