"""Shared primitive helpers. Blender coordinates: +Z up, -Y forward. Every helper creates one named, flat-shaded part. No joining, textures, scene deletion or export here. Angles are radians; sizes are metres. """ from math import pi import re import bpy from mathutils import Vector def _finish(obj, name, mat=None): name = re.sub(r'[^A-Za-z0-9_]', '_', name) obj.name = name obj['part_name'] = name if obj.type == 'MESH': obj.data.name = name + '_Mesh' for face in obj.data.polygons: face.use_smooth = False if mat is not None: obj.data.materials.append(mat) return obj def box(name, loc, size, mat, rotation=None): bpy.ops.mesh.primitive_cube_add(size=1, location=loc) obj = bpy.context.object obj.scale = size if rotation is not None: obj.rotation_euler = rotation return _finish(obj, name, mat) def ellipsoid(name, loc, scale, mat, subdivisions=1): bpy.ops.mesh.primitive_ico_sphere_add(subdivisions=subdivisions, radius=1, location=loc) obj = bpy.context.object obj.scale = scale return _finish(obj, name, mat) def cylinder(name, loc, radius, depth, mat, vertices=10, rotation=None): return cone(name, loc, radius, radius, depth, mat, vertices, rotation) def cone(name, loc, radius1, radius2, depth, mat, vertices=10, rotation=None): bpy.ops.mesh.primitive_cone_add(vertices=vertices, radius1=radius1, radius2=radius2, depth=depth, location=loc) obj = bpy.context.object if rotation is not None: obj.rotation_euler = rotation return _finish(obj, name, mat) def beam(name, start, end, radius, mat, vertices=8): start, end = Vector(start), Vector(end) direction = end - start if direction.length < 1e-6: raise ValueError(name + ': beam endpoints must differ') obj = cylinder(name, (start + end) * 0.5, radius, direction.length, mat, vertices) obj.rotation_euler = direction.to_track_quat('Z', 'Y').to_euler() return obj def ring(name, loc, major_radius, minor_radius, mat, rotation=None, segments=12): bpy.ops.mesh.primitive_torus_add(major_segments=segments, minor_segments=4, location=loc, major_radius=major_radius, minor_radius=minor_radius) obj = bpy.context.object if rotation is not None: obj.rotation_euler = rotation return _finish(obj, name, mat) def mesh_object(name, vertices, faces, mat): mesh = bpy.data.meshes.new(name + '_Mesh') mesh.from_pydata(vertices, [], faces) mesh.update() obj = bpy.data.objects.new(name, mesh) bpy.context.scene.collection.objects.link(obj) return _finish(obj, name, mat) def empty(name, loc=(0, 0, 0)): name = re.sub(r'[^A-Za-z0-9_]', '_', name) obj = bpy.data.objects.new(name, None) obj['part_name'] = name obj.location = loc obj.empty_display_type = 'PLAIN_AXES' obj.empty_display_size = 0.12 bpy.context.scene.collection.objects.link(obj) return obj PALETTES = { 'glutpakt': ['292B35', 'EF6B3B', 'D9A441', 'D8CEC0'], 'nietenkonvent': ['247A80', 'C79A48', '657481', 'E6DCCA'], 'nebelchor': ['45456F', '9578CF', '82DBCE', 'C4D6E5'], 'wurzelbund': ['685441', '56845A', 'A7BA6C', 'D996AF'], 'neutral': ['536375', 'A8654D', '9578CF', 'D8D2BF'], 'worker': ['A8654D', '536375', 'D9A441', '82DBCE'], 'terrain': ['657481', '292B35', 'D9A441', '56845A'], 'team0': ['292B35', 'D9A441', '82DBCE', 'D8CEC0'], 'team1': ['292B35', 'D9A441', 'E9967C', 'D8CEC0'], } def srgb_to_linear(value): return value / 12.92 if value <= 0.04045 else ((value + 0.055) / 1.055) ** 2.4 def make_materials(palette_name, overrides=None): """Hex colours match Phase 3; shader inputs use linear RGB for glTF export. Override individual keys with RGB hex strings in an entry script, e.g. PALETTE_OVERRIDES = {'accent': 'FFAA55'}. All materials stay opaque and matte. """ colours = dict(zip(('primary', 'secondary', 'accent', 'light'), PALETTES[palette_name])) if palette_name == 'wurzelbund': colours['water'] = '82DBCE' # Dew/spring accent from the Phase 3 healer icons. colours['wood'] = '9B805C' colours['bark_dark'] = '483A2F' if palette_name in ('neutral', 'worker'): colours['shadow'] = '202832' if palette_name == 'neutral': colours['rift'] = '82DBCE' colours.update(overrides or {}) result = {} for key, hex_colour in colours.items(): value = hex_colour.lstrip('#') rgb = [srgb_to_linear(int(value[i:i + 2], 16) / 255) for i in (0, 2, 4)] name = 'SW_' + palette_name + '_' + key + '_' + value # A previous user-edited material must never be reset by another run. mat = bpy.data.materials.new(name) mat['material_name'] = name mat.diffuse_color = (*rgb, 1) mat.use_nodes = True shader = mat.node_tree.nodes.get('Principled BSDF') shader.inputs['Base Color'].default_value = (*rgb, 1) shader.inputs['Metallic'].default_value = 0.08 if palette_name in ('nietenkonvent', 'team0', 'team1') else 0.0 shader.inputs['Roughness'].default_value = 0.82 if palette_name == 'glutpakt' and key == 'secondary': shader.inputs['Emission Color'].default_value = (*rgb, 1) shader.inputs['Emission Strength'].default_value = 0.18 if palette_name == 'glutpakt' and key == 'accent': shader.inputs['Metallic'].default_value = 0.5 shader.inputs['Roughness'].default_value = 0.62 result[key] = mat return result