301 lines
10 KiB
Python
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
|