Files
cable-normalizer/utils.py

301 lines
10 KiB
Python

"""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