Загрузка данных


"""
ASUS ROG Astral GeForce RTX 5090 OC Edition - детальная процедурная модель для Blender 5.0.

Что внутри (всё строится кодом, внешние файлы и картинки не нужны):
  * задняя планка (как на фото): 3 x DP + 2 x HDMI, вентиляция, винты, надписи;
  * кожух с тремя фронтальными вентиляторами (по 11 лопастей), кольца подсветки, акценты;
  * 4-й вентилятор со стороны бэкплейта (в нижней части карты, под радиатором);
  * бэкплейт с вырезами и подсветкой, радиатор (164 ребра), тепловые трубки, медная база;
  * плата с GPU, 16 чипами GDDR7, дросселями, конденсаторами и золотыми контактами PCIe;
  * разъём питания 12V-2x6 и индикаторы Power Detector+;
  * процедурные текстуры: шлифованный металл, пластик, текстолит, золото, медь, RGB-градиент.

Размеры: 357.6 x 149.3 x 76 мм (по спецификации ASUS), 1 единица Blender = 1 мм.
Запуск: Blender -> Scripting -> Создать -> вставить код -> Текст -> Запустить скрипт.
Генерация занимает 10-60 секунд (булевы операции и ~170 рёбер радиатора).
Если всё тёмное: переключи 3D-вид в Material Preview (3-й шарик справа вверху).
Некоторые детали (положение 4-го вентилятора, форма акцентов) выполнены по общей
конструкции карты и могут отличаться от настоящей.
"""
import bpy
import bmesh
import mathutils
from math import pi, sin, cos, sqrt, radians

# =====================================================================
#  НАСТРОЙКИ
# =====================================================================
SHOW_SHROUD = True        # кожух с вентиляторами
SHOW_BACKPLATE = True     # задняя пластина
SHOW_HEATSINK = True      # радиатор, тепловые трубки
SHOW_PCB = True           # плата и компоненты
BLACK_BRACKET = True      # чёрная планка (False: шлифованный металл)
RGB_STRENGTH = 4.0        # яркость RGB-подсветки

CARD_L, CARD_H, CARD_W = 357.6, 149.3, 76.0
X_BACK = -6.25            # внешняя плоскость бэкплейта
X_TOP = X_BACK + CARD_W   # верхняя плоскость кожуха (69.75)
Z0 = 0.5                  # нижний край платы/карты
Z1 = Z0 + CARD_H          # верхний край карты (149.8)

PLATE_T = 1.0             # толщина планки
WALL = 0.3                # толщина металлической рамки гнезда
CLEAR = 0.15              # зазор для штекера
DEPTH = 12.0              # глубина гнезда
COL0 = 6.25               # смещение левой колонки портов от левого края планки

PORTS = [("DP", 6.25, 97.5), ("DP", 6.25, 75.2), ("DP", 6.25, 52.9),
         ("HDMI", 6.25, 30.7), ("HDMI", 27.6, 30.7)]
SCREWS = [(15.1, 97.6), (14.9, 75.5), (14.9, 53.7), (15.1, 31.2), (28.6, 45.3), (35.4, 31.2)]
TOP_HOLES = [(15.1, 111.1), (29.7, 111.1)]
VENT = dict(x=28.1, z0=101.8, step=7.3, count=8, wx=16.0, hz=5.2, r=1.0)
LOWER_SLOT = dict(x=27.6, z=18.2, wx=16.5, hz=5.7, r=2.5)
OUTLINE = [
    (3.0, 0.0), (16.6, 0.0), (17.3, 1.6), (22.1, 1.6), (22.8, 0.0), (37.6, 0.0),
    (39.1, 1.5), (39.1, 16.8), (40.64, 18.3), (40.64, 104.0), (38.6, 106.0),
    (38.6, 114.0), (37.0, 115.6), (3.0, 115.6), (1.5, 114.1), (1.5, 105.0),
    (0.0, 103.5), (0.0, 17.5), (1.5, 16.0), (1.5, 1.5),
]

# Вентиляторы
FAN_Z = Z0 + CARD_H / 2           # ось вентиляторов по высоте
FRONT_FANS_Y = [64.0, 172.0, 280.0]
FAN_R_OUT = 47.0                  # радиус лопастей
FAN_HOLE_R = 51.0                 # радиус отверстия в кожухе
FAN_BLADES = 11
REAR_FAN_Y = 312.0
REAR_FAN_R = 36.0
FAN_FRONT_XF = 60.5               # плоскость лопастей передних вентиляторов

# Радиатор
FIN_X0, FIN_X1 = 14.0, 60.0
FIN_Y0, FIN_Y1, FIN_PITCH, FIN_T = 10.0, 338.0, 2.0, 0.4
PCB_Y1 = 270.0                    # длина платы по Y
PCB_Z1 = 137.0

PREFIX = "ROG_"
COLL = None


# =====================================================================
#  МАТЕРИАЛЫ
# =====================================================================
def make_material(name, c0, c1, metallic, rough, rough_var=0.1, noise_scale=6.0,
                  stretch=(1.0, 1.0, 1.0), bump=0.1, traces=None, detail=6.0):
    mat = bpy.data.materials.get(name) or bpy.data.materials.new(name)
    try:
        mat.use_nodes = True
    except Exception:
        pass
    nt = mat.node_tree
    nt.nodes.clear()
    out = nt.nodes.new("ShaderNodeOutputMaterial")
    bsdf = nt.nodes.new("ShaderNodeBsdfPrincipled")
    nt.links.new(bsdf.outputs["BSDF"], out.inputs["Surface"])
    bsdf.inputs["Base Color"].default_value = c1
    bsdf.inputs["Metallic"].default_value = metallic
    bsdf.inputs["Roughness"].default_value = rough
    try:
        tc = nt.nodes.new("ShaderNodeTexCoord")
        mp = nt.nodes.new("ShaderNodeMapping")
        nt.links.new(tc.outputs["Object"], mp.inputs["Vector"])
        mp.inputs["Scale"].default_value = tuple(noise_scale * s for s in stretch)

        nz = nt.nodes.new("ShaderNodeTexNoise")
        nz.inputs["Scale"].default_value = 1.0
        nz.inputs["Detail"].default_value = detail
        nz.inputs["Roughness"].default_value = 0.6
        nt.links.new(mp.outputs["Vector"], nz.inputs["Vector"])

        ramp = nt.nodes.new("ShaderNodeValToRGB")
        ramp.color_ramp.elements[0].color = c0
        ramp.color_ramp.elements[1].color = c1
        nt.links.new(nz.outputs["Fac"], ramp.inputs["Fac"])
        color_src = ramp.outputs["Color"]

        if traces is not None:
            mp2 = nt.nodes.new("ShaderNodeMapping")
            nt.links.new(tc.outputs["Object"], mp2.inputs["Vector"])
            mp2.inputs["Scale"].default_value = (0.5, 0.5, 0.5)
            vn = nt.nodes.new("ShaderNodeTexVoronoi")
            vn.feature = "DISTANCE_TO_EDGE"
            vn.inputs["Scale"].default_value = 1.0
            nt.links.new(mp2.outputs["Vector"], vn.inputs["Vector"])
            tr = nt.nodes.new("ShaderNodeValToRGB")
            tr.color_ramp.interpolation = "CONSTANT"
            tr.color_ramp.elements[0].position = 0.0
            tr.color_ramp.elements[0].color = traces[0]
            e = tr.color_ramp.elements.new(0.04)
            e.color = traces[1]
            nt.links.new(vn.outputs["Distance"], tr.inputs["Fac"])
            color_src = tr.outputs["Color"]

        nt.links.new(color_src, bsdf.inputs["Base Color"])

        mr = nt.nodes.new("ShaderNodeMapRange")
        mr.inputs["From Min"].default_value = 0.3
        mr.inputs["From Max"].default_value = 0.7
        mr.inputs["To Min"].default_value = max(rough - rough_var, 0.02)
        mr.inputs["To Max"].default_value = min(rough + rough_var, 1.0)
        nt.links.new(nz.outputs["Fac"], mr.inputs["Value"])
        nt.links.new(mr.outputs["Result"], bsdf.inputs["Roughness"])

        bp = nt.nodes.new("ShaderNodeBump")
        bp.inputs["Strength"].default_value = bump
        bp.inputs["Distance"].default_value = 0.05
        nt.links.new(nz.outputs["Fac"], bp.inputs["Height"])
        nt.links.new(bp.outputs["Normal"], bsdf.inputs["Normal"])
    except Exception as ex:
        print("Текстуры материала", name, "не применены:", ex)
    return mat


def make_emission(name, color=(1.0, 0.0, 0.0, 1.0), rgb=False, strength=4.0):
    """Светящийся материал; rgb=True даёт радужный градиент вдоль оси Y."""
    mat = bpy.data.materials.get(name) or bpy.data.materials.new(name)
    try:
        mat.use_nodes = True
    except Exception:
        pass
    nt = mat.node_tree
    nt.nodes.clear()
    out = nt.nodes.new("ShaderNodeOutputMaterial")
    bsdf = nt.nodes.new("ShaderNodeBsdfPrincipled")
    nt.links.new(bsdf.outputs["BSDF"], out.inputs["Surface"])
    bsdf.inputs["Base Color"].default_value = (0.01, 0.01, 0.01, 1.0)
    bsdf.inputs["Roughness"].default_value = 0.3
    try:
        bsdf.inputs["Emission Color"].default_value = color
        bsdf.inputs["Emission Strength"].default_value = strength
        if rgb:
            tc = nt.nodes.new("ShaderNodeTexCoord")
            mp = nt.nodes.new("ShaderNodeMapping")
            nt.links.new(tc.outputs["Object"], mp.inputs["Vector"])
            s = 1.0 / 360.0
            mp.inputs["Scale"].default_value = (s, s, s)
            mp.inputs["Rotation"].default_value = (0.0, 0.0, -pi / 2)
            gr = nt.nodes.new("ShaderNodeTexGradient")
            nt.links.new(mp.outputs["Vector"], gr.inputs["Vector"])
            ramp = nt.nodes.new("ShaderNodeValToRGB")
            stops = [(0.0, (1.0, 0.0, 0.0, 1.0)), (0.2, (1.0, 0.7, 0.0, 1.0)),
                     (0.4, (0.0, 1.0, 0.2, 1.0)), (0.6, (0.0, 0.8, 1.0, 1.0)),
                     (0.8, (0.2, 0.1, 1.0, 1.0)), (1.0, (1.0, 0.0, 0.8, 1.0))]
            ramp.color_ramp.elements[0].position = stops[0][0]
            ramp.color_ramp.elements[0].color = stops[0][1]
            ramp.color_ramp.elements[1].position = stops[-1][0]
            ramp.color_ramp.elements[1].color = stops[-1][1]
            for pos, col in stops[1:-1]:
                el = ramp.color_ramp.elements.new(pos)
                el.color = col
            nt.links.new(gr.outputs["Fac"], ramp.inputs["Fac"])
            nt.links.new(ramp.outputs["Color"], bsdf.inputs["Emission Color"])
    except Exception as ex:
        print("Свечение материала", name, "не настроено:", ex)
    return mat


def build_materials():
    m = {}
    if BLACK_BRACKET:
        m["bracket"] = make_material("ROG_Bracket", (0.010, 0.010, 0.012, 1), (0.045, 0.045, 0.050, 1),
                                     0.3, 0.45, 0.12, noise_scale=25.0, bump=0.2, detail=3.0)
    else:
        m["bracket"] = make_material("ROG_Bracket", (0.55, 0.57, 0.60, 1), (0.82, 0.84, 0.86, 1),
                                     1.0, 0.35, 0.12, noise_scale=7.0, stretch=(8.0, 8.0, 0.25), bump=0.25)
    m["silver"] = make_material("ROG_Silver", (0.70, 0.72, 0.75, 1), (0.92, 0.93, 0.95, 1),
                                1.0, 0.22, 0.08, noise_scale=5.0, stretch=(6.0, 6.0, 0.4), bump=0.12)
    m["plastic"] = make_material("ROG_Plastic", (0.012, 0.012, 0.014, 1), (0.04, 0.04, 0.045, 1),
                                 0.0, 0.5, 0.15, noise_scale=40.0, bump=0.35, detail=3.0)
    m["gold"] = make_material("ROG_Gold", (0.80, 0.55, 0.15, 1), (1.0, 0.80, 0.30, 1),
                              1.0, 0.25, 0.1, noise_scale=20.0, bump=0.05)
    m["pcb"] = make_material("ROG_PCB", (0.004, 0.004, 0.006, 1), (0.02, 0.02, 0.024, 1),
                             0.0, 0.4, 0.1, noise_scale=30.0, bump=0.15,
                             traces=((0.30, 0.18, 0.07, 1), (0.012, 0.012, 0.015, 1)))
    m["screw"] = make_material("ROG_Screw", (0.45, 0.46, 0.50, 1), (0.78, 0.79, 0.82, 1),
                               1.0, 0.35, 0.1, noise_scale=20.0, bump=0.1)
    m["text"] = make_material("ROG_Text", (0.70, 0.70, 0.70, 1), (0.92, 0.92, 0.92, 1),
                              0.0, 0.5, 0.1, noise_scale=30.0, bump=0.05, detail=2.0)
    # тёмный шлифованный металл кожуха и бэкплейта (полосы вдоль карты, по оси Y)
    m["gun"] = make_material("ROG_Gunmetal", (0.020, 0.020, 0.024, 1), (0.085, 0.088, 0.096, 1),
                             1.0, 0.38, 0.12, noise_scale=7.0, stretch=(8.0, 0.25, 8.0), bump=0.25)
    m["gloss"] = make_material("ROG_GlossBlack", (0.003, 0.003, 0.004, 1), (0.016, 0.016, 0.020, 1),
                               0.0, 0.12, 0.05, noise_scale=40.0, bump=0.02, detail=2.0)
    m["fan"] = make_material("ROG_Fan", (0.008, 0.008, 0.010, 1), (0.035, 0.035, 0.040, 1),
                             0.0, 0.35, 0.1, noise_scale=45.0, bump=0.25, detail=3.0)
    m["copper"] = make_material("ROG_Copper", (0.50, 0.22, 0.10, 1), (0.88, 0.50, 0.28, 1),
                                1.0, 0.28, 0.1, noise_scale=12.0, bump=0.1)
    m["alu"] = make_material("ROG_Aluminium", (0.55, 0.57, 0.60, 1), (0.80, 0.82, 0.85, 1),
                             1.0, 0.42, 0.1, noise_scale=10.0, stretch=(1.0, 6.0, 6.0), bump=0.1)
    m["rgb"] = make_emission("ROG_RGB", rgb=True, strength=RGB_STRENGTH)
    m["red"] = make_emission("ROG_LED_Red", color=(1.0, 0.05, 0.05, 1.0), strength=6.0)
    return m


def assign(obj, mat):
    obj.data.materials.clear()
    obj.data.materials.append(mat)


# =====================================================================
#  ПРОФИЛИ
# =====================================================================
def hdmi_uv(w, h, c=1.0):
    return [(-w / 2, h / 2), (w / 2, h / 2), (w / 2, 0.0),
            (w / 2 - c, -h / 2), (-w / 2 + c, -h / 2), (-w / 2, 0.0)]


def dp_uv(w, h, c=1.5):
    return [(-w / 2, -h / 2), (w / 2, -h / 2), (w / 2, h / 2 - c),
            (w / 2 - c, h / 2), (-w / 2, h / 2)]


def circle_uv(r, n=28):
    return [(r * sin(2 * pi * k / n), r * cos(2 * pi * k / n)) for k in range(n)]


def circle_ab(r, n=48, a0=0.0, b0=0.0):
    return [(a0 + r * cos(2 * pi * k / n), b0 + r * sin(2 * pi * k / n)) for k in range(n)]


def rrect_uv(hz, wx, r, seg=5):
    pts = []
    for sv, su, a0 in [(+1, +1, 0), (-1, +1, 90), (-1, -1, 180), (+1, -1, 270)]:
        cv, cu = sv * (wx / 2 - r), su * (hz / 2 - r)
        for k in range(seg + 1):
            a = (a0 + 90.0 * k / seg) * pi / 180.0
            pts.append((cu + r * sin(a), cv + r * cos(a)))
    return pts


SPEC = {
    "HDMI": dict(w=13.9, h=4.45, c=1.0, prof=hdmi_uv, top=10, bot=9),
    "DP": dict(w=16.1, h=4.76, c=1.5, prof=dp_uv, top=10, bot=10),
}


def port_profiles(kind):
    s = SPEC[kind]
    wi, hi = s["w"] + CLEAR, s["h"] + CLEAR
    inner = s["prof"](wi, hi, s["c"])
    outer = s["prof"](wi + 2 * WALL, hi + 2 * WALL, s["c"])
    lip = s["prof"](wi + 2 * WALL + 1.0, hi + 2 * WALL + 1.0, s["c"])
    return s, wi, hi, inner, outer, lip


HMAX = max((SPEC[k]["h"] + CLEAR) / 2 + WALL for k in SPEC)   # половина высоты рамки порта


# =====================================================================
#  ПОСТРОИТЕЛЬ СЕТОК
# =====================================================================
def link(obj):
    (COLL if COLL is not None else bpy.context.collection).objects.link(obj)


def TF_X(p):
    """Локальные (a=Y, b=Z, c=X) -> мир (X, Y, Z)."""
    return (p[2], p[0], p[1])


class MB:
    def __init__(self, tf=None):
        self.v = []
        self.f = []
        self.tf = tf

    def _p(self, p):
        return self.tf(p) if self.tf else p

    def _loop_prism(self, a, b):
        n = len(a)
        base = len(self.v)
        self.v += a + b
        self.f.append(tuple(base + i for i in reversed(range(n))))
        self.f.append(tuple(base + n + i for i in range(n)))
        for i in range(n):
            j = (i + 1) % n
            self.f.append((base + i, base + j, base + n + j, base + n + i))

    # --- мировые координаты (старый стиль, ось призмы Y) ---
    def box(self, x0, x1, y0, y1, z0, z1):
        b = len(self.v)
        self.v += [(x0, y0, z0), (x1, y0, z0), (x1, y1, z0), (x0, y1, z0),
                   (x0, y0, z1), (x1, y0, z1), (x1, y1, z1), (x0, y1, z1)]
        for q in ((0, 3, 2, 1), (4, 5, 6, 7), (0, 1, 5, 4),
                  (2, 3, 7, 6), (1, 2, 6, 5), (3, 0, 4, 7)):
            self.f.append(tuple(b + i for i in q))

    def prism_y(self, uv, xc, zc, y0, y1):
        self._loop_prism([(xc + v, y0, zc + u) for (u, v) in uv],
                         [(xc + v, y1, zc + u) for (u, v) in uv])

    def tube_y(self, outer_uv, inner_uv, xc, zc, y0, y1):
        n = len(outer_uv)
        b = len(self.v)
        self.v += [(xc + v, y0, zc + u) for (u, v) in outer_uv]
        self.v += [(xc + v, y1, zc + u) for (u, v) in outer_uv]
        self.v += [(xc + v, y0, zc + u) for (u, v) in inner_uv]
        self.v += [(xc + v, y1, zc + u) for (u, v) in inner_uv]
        for i in range(n):
            j = (i + 1) % n
            self.f.append((b + i, b + j, b + n + j, b + n + i))
            self.f.append((b + 2 * n + i, b + 3 * n + i, b + 3 * n + j, b + 2 * n + j))
            self.f.append((b + i, b + 2 * n + i, b + 2 * n + j, b + j))
            self.f.append((b + n + i, b + n + j, b + 3 * n + j, b + 3 * n + i))

    # --- локальные координаты с преобразованием tf (ось призмы c) ---
    def poly(self, ab, c0, c1):
        self._loop_prism([self._p((a, b, c0)) for a, b in ab],
                         [self._p((a, b, c1)) for a, b in ab])

    def tube(self, outer_ab, inner_ab, c0, c1):
        n = len(outer_ab)
        b = len(self.v)
        self.v += [self._p((a, bb, c0)) for a, bb in outer_ab]
        self.v += [self._p((a, bb, c1)) for a, bb in outer_ab]
        self.v += [self._p((a, bb, c0)) for a, bb in inner_ab]
        self.v += [self._p((a, bb, c1)) for a, bb in inner_ab]
        for i in range(n):
            j = (i + 1) % n
            self.f.append((b + i, b + j, b + n + j, b + n + i))
            self.f.append((b + 2 * n + i, b + 3 * n + i, b + 3 * n + j, b + 2 * n + j))
            self.f.append((b + i, b + 2 * n + i, b + 2 * n + j, b + j))
            self.f.append((b + n + i, b + n + j, b + 3 * n + j, b + 3 * n + i))

    def cbox(self, a0, a1, b0, b1, c0, c1):
        pts = [(a0, b0, c0), (a1, b0, c0), (a1, b1, c0), (a0, b1, c0),
               (a0, b0, c1), (a1, b0, c1), (a1, b1, c1), (a0, b1, c1)]
        b = len(self.v)
        self.v += [self._p(p) for p in pts]
        for q in ((0, 3, 2, 1), (4, 5, 6, 7), (0, 1, 5, 4),
                  (2, 3, 7, 6), (1, 2, 6, 5), (3, 0, 4, 7)):
            self.f.append(tuple(b + i for i in q))

    def sweep(self, stations):
        """Тело по сечениям: stations - список сечений (одинаковое число точек)."""
        k = len(stations[0])
        n = len(stations)
        base = len(self.v)
        for st in stations:
            self.v += [self._p(p) for p in st]
        for i in range(n - 1):
            for j in range(k):
                jj = (j + 1) % k
                self.f.append((base + i * k + j, base + i * k + jj,
                               base + (i + 1) * k + jj, base + (i + 1) * k + j))
        self.f.append(tuple(base + j for j in reversed(range(k))))
        self.f.append(tuple(base + (n - 1) * k + j for j in range(k)))

    def build(self, name, mat=None):
        mesh = bpy.data.meshes.new(name)
        mesh.from_pydata(self.v, [], self.f)
        mesh.update()
        obj = bpy.data.objects.new(name, mesh)
        link(obj)
        bm = bmesh.new()
        bm.from_mesh(mesh)
        bmesh.ops.recalc_face_normals(bm, faces=bm.faces)
        bm.to_mesh(mesh)
        bm.free()
        if mat is not None:
            assign(obj, mat)
        return obj


def X(x_mm):
    return x_mm - COL0


def boolean_cut(obj, cutter_mb, name):
    """Вычитает cutter из obj (с применением). При ошибке объект остаётся без вырезов."""
    cutter = cutter_mb.build(PREFIX + name)
    try:
        bpy.context.view_layer.update()
        mod = obj.modifiers.new("Cut", "BOOLEAN")
        mod.operation = "DIFFERENCE"
        mod.solver = "EXACT"
        mod.object = cutter
        dg = bpy.context.evaluated_depsgraph_get()
        new_mesh = bpy.data.meshes.new_from_object(obj.evaluated_get(dg))
        obj.modifiers.remove(mod)
        old = obj.data
        obj.data = new_mesh
        bpy.data.meshes.remove(old)
    except Exception as ex:
        print("Булева операция не удалась (", name, "):", ex)
    cmesh = cutter.data
    bpy.data.objects.remove(cutter, do_unlink=True)
    bpy.data.meshes.remove(cmesh)


def add_text(body, loc, size, mat, name, rot_rows=None, rot_euler=None, extrude=0.05):
    cu = bpy.data.curves.new(PREFIX + name, "FONT")
    cu.body = body
    cu.size = size
    cu.align_x = "CENTER"
    cu.align_y = "CENTER"
    cu.extrude = extrude
    ob = bpy.data.objects.new(PREFIX + name, cu)
    link(ob)
    if rot_rows is not None:
        ob.rotation_euler = mathutils.Matrix(rot_rows).to_euler()
    elif rot_euler is not None:
        ob.rotation_euler = rot_euler
    ob.location = loc
    ob.data.materials.append(mat)
    return ob


# Ориентации текста: (строки матрицы локальные -> мировые)
ROT_FRONT_MINUS_Y = ((1, 0, 0), (0, 0, -1), (0, 1, 0))      # лицом к -Y (планка)
ROT_FACE_PLUS_X = ((0, 0, 1), (1, 0, 0), (0, 1, 0))         # лицом к +X, строка вдоль +Y
ROT_FACE_MINUS_X = ((0, 0, -1), (-1, 0, 0), (0, 1, 0))      # лицом к -X, строка вдоль -Y
ROT_FACE_PLUS_Z = ((0, -1, 0), (1, 0, 0), (0, 0, 1))        # лицом к +Z, строка вдоль +Y


# =====================================================================
#  ПЛАНКА С ПОРТАМИ
# =====================================================================
def build_bracket(mats):
    mb = MB()
    mb.prism_y([(z, X(x)) for (x, z) in OUTLINE], 0.0, 0.0, 0.0, PLATE_T)
    plate = mb.build(PREFIX + "Bracket")

    cut = MB()
    for kind, x, z in PORTS:
        cut.prism_y(port_profiles(kind)[4], X(x), z, -1.0, PLATE_T + 1.0)
    for x, z in SCREWS:
        cut.prism_y(circle_uv(2.7), X(x), z, -1.0, PLATE_T + 1.0)
    for x, z in TOP_HOLES:
        cut.prism_y(circle_uv(2.6), X(x), z, -1.0, PLATE_T + 1.0)
    for i in range(VENT["count"]):
        cut.prism_y(rrect_uv(VENT["hz"], VENT["wx"], VENT["r"]), X(VENT["x"]),
                    VENT["z0"] - i * VENT["step"], -1.0, PLATE_T + 1.0)
    cut.prism_y(rrect_uv(LOWER_SLOT["hz"], LOWER_SLOT["wx"], LOWER_SLOT["r"]),
                X(LOWER_SLOT["x"]), LOWER_SLOT["z"], -1.0, PLATE_T + 1.0)
    boolean_cut(plate, cut, "BracketCutter")

    bev = plate.modifiers.new("Bevel", "BEVEL")
    bev.width = 0.1
    bev.segments = 2
    assign(plate, mats["bracket"])


def build_ports(mats):
    shell, lips, wall, tongue, pins = MB(), MB(), MB(), MB(), MB()
    tt, ct, tip = 1.0, 0.12, 2.5
    for kind, xmm, zc in PORTS:
        xc = X(xmm)
        s, wi, hi, inner, outer, lip = port_profiles(kind)
        shell.tube_y(outer, inner, xc, zc, 0.0, DEPTH + 1.0)
        lips.tube_y(lip, inner, xc, zc, -0.3, 0.0)
        for du in (-(wi / 2 - 3.0), (wi / 2 - 3.0)):
            shell.box(xc + hi / 2 - 0.35, xc + hi / 2, 2.0, 5.0, zc + du - 1.0, zc + du + 1.0)
            shell.box(xc - hi / 2, xc - hi / 2 + 0.35, 2.0, 5.0, zc + du - 1.0, zc + du + 1.0)
        wall.prism_y(inner, xc, zc, DEPTH, DEPTH + 1.0)
        tw = wi - 2.0
        tongue.box(xc - tt / 2, xc + tt / 2, tip, DEPTH, zc - tw / 2, zc + tw / 2)
        tongue.box(xc - tt / 2 - 0.1, xc + tt / 2 + 0.1, tip + 0.4, tip + 1.0,
                   zc - tw / 2 + 0.6, zc + tw / 2 - 0.6)
        for count, sign in ((s["top"], 1), (s["bot"], -1)):
            x = xc + sign * (tt / 2 + ct / 2)
            for i in range(count):
                u = zc + (i - (count - 1) / 2) * 1.0
                pins.box(x - ct / 2, x + ct / 2, tip + 0.5, DEPTH - 0.5, u - 0.25, u + 0.25)
    shell.build(PREFIX + "PortShells", mats["silver"])
    lips.build(PREFIX + "PortBezels", mats["silver"])
    wall.build(PREFIX + "PortBackWalls", mats["plastic"])
    tongue.build(PREFIX + "PortTongues", mats["plastic"])
    pins.build(PREFIX + "PortPins", mats["gold"])


def build_screws(mats):
    heads, slots = MB(), MB()
    for x, z in SCREWS:
        xc = X(x)
        heads.prism_y(circle_uv(2.0), xc, z, 0.3, 1.6)
        slots.box(xc - 1.3, xc + 1.3, 0.22, 0.3, z - 0.22, z + 0.22)
        slots.box(xc - 0.22, xc + 0.22, 0.22, 0.3, z - 1.3, z + 1.3)
    heads.build(PREFIX + "BracketScrews", mats["screw"])
    slots.build(PREFIX + "BracketScrewSlots", mats["plastic"])


def build_bracket_labels(mats):
    for kind, x, z in PORTS:
        if kind == "DP":
            add_text("DP", (X(x), -0.04, z - 11.0), 2.2, mats["text"], "Label_DP",
                     rot_rows=ROT_FRONT_MINUS_Y)
    add_text("HDMI", (X(6.25), -0.04, 19.8), 2.2, mats["text"], "Label_HDMI_L", rot_rows=ROT_FRONT_MINUS_Y)
    add_text("HDMI", (X(34.9), -0.04, 24.2), 2.2, mats["text"], "Label_HDMI_R", rot_rows=ROT_FRONT_MINUS_Y)
    add_text("Stainless Steel 304", (X(19.3), -0.04, 5.5), 2.2, mats["text"], "Label_Steel",
             rot_rows=ROT_FRONT_MINUS_Y)


# =====================================================================
#  ПЛАТА И КОМПОНЕНТЫ
# =====================================================================
def build_pcb(mats):
    xt = -HMAX                 # верхняя поверхность платы
    xb = xt - 1.6              # нижняя поверхность
    pcb, gold, chips, parts, caps = MB(), MB(), MB(), MB(), MB()

    pcb.box(xb, xt, PLATE_T + 0.3, PCB_Y1, Z0, PCB_Z1)
    # маленькая плата под правый HDMI
    xr = X(27.6)
    pcb.box(xr - HMAX - 1.6, xr - HMAX, PLATE_T + 0.3, 45.0, 19.5, 42.5)

    # золотые контакты PCIe x16 по нижнему краю: 11 + 71 контакт с ключом
    def finger(y):
        gold.box(xb - 0.05, xb, y, y + 0.6, Z0, Z0 + 6.5)
        gold.box(xt, xt + 0.05, y, y + 0.6, Z0, Z0 + 6.5)
    y = 12.0
    for i in range(11):
        finger(y + i * 1.0)
    y2 = y + 11 * 1.0 + 3.0
    for i in range(71):
        finger(y2 + i * 1.0)

    # GPU: подложка и кристалл
    gy, gz = 95.0, 70.0
    chips.box(xt, xt + 1.3, gy - 22.5, gy + 22.5, gz - 22.5, gz + 22.5)
    chips.box(xt + 1.3, xt + 2.2, gy - 14.0, gy + 14.0, gz - 12.0, gz + 12.0)
    # 16 чипов GDDR7 вокруг GPU
    cw, cl = 11.0, 13.0
    for k in range(4):
        yy = gy - 21.0 + k * 14.0
        for zz in (gz + 33.0, gz - 33.0):
            chips.box(xt, xt + 1.1, yy - cw / 2, yy + cw / 2, zz - cl / 2, zz + cl / 2)
    for k in range(4):
        zz = gz - 21.0 + k * 14.0
        for yy in (gy - 36.0, gy + 36.0):
            chips.box(xt, xt + 1.1, yy - cw / 2, yy + cw / 2, zz - cl / 2, zz + cl / 2)

    # VRM: 2 ряда дросселей и 24 силовых каскада
    for i in range(12):
        for zz in (45.0, 95.0):
            yy = 150.0 + i * 10.5
            parts.box(xt, xt + 6.0, yy - 4.5, yy + 4.5, zz - 6.0, zz + 6.0)
    for i in range(24):
        yy = 142.0 + i * 5.2
        for zz in (28.0, 112.0):
            chips.box(xt, xt + 1.5, yy - 2.2, yy + 2.2, zz - 3.0, zz + 3.0)

    # конденсаторы: цилиндры вдоль X
    caps.tf = lambda p: (xt + p[2], p[0], p[1])
    for i in range(8):
        for zz in (22.0, 118.0):
            yy = 215.0 + i * 6.5
            caps.poly(circle_ab(2.6, 16, yy, zz), 0.0, 6.5)

    pcb.build(PREFIX + "PCB", mats["pcb"])
    gold.build(PREFIX + "PCIe_Fingers", mats["gold"])
    chips.build(PREFIX + "GPU_and_GDDR7", mats["plastic"])
    parts.build(PREFIX + "VRM_Chokes", mats["gloss"])
    caps.build(PREFIX + "Capacitors", mats["silver"])


def build_power_connector(mats):
    """Разъём питания 12V-2x6 (16 pin) на верхнем крае платы + индикаторы Power Detector+."""
    xc, yc = -HMAX + 4.2, 245.0
    c0, c1 = PCB_Z1 - 9.0, PCB_Z1 + 3.0
    tf = lambda p: (xc + p[1], yc + p[0], p[2])      # (a=Y, b=X, c=Z)
    house = MB(tf)
    outer = [(-10.2, -4.2), (10.2, -4.2), (10.2, 4.2), (-8.0, 4.2), (-10.2, 2.0)]
    inner = [(-9.1, -3.1), (9.1, -3.1), (9.1, 3.1), (-7.5, 3.1), (-9.1, 1.5)]
    house.tube(outer, inner, c0, c1)
    house.poly(inner, c0, c0 + 3.0)
    pins = MB(tf)
    for i in range(6):
        for j in (-1, 1):
            a = (i - 2.5) * 3.0
            b = j * 1.5
            pins.cbox(a - 0.7, a + 0.7, b - 0.7, b + 0.7, c0 + 3.0, c1 - 4.5)
    house.build(PREFIX + "Power_12V-2x6_Housing", mats["plastic"])
    pins.build(PREFIX + "Power_12V-2x6_Pins", mats["gold"])

    leds = MB()
    for i in range(6):
        leds.box(xc + 5.5, xc + 6.7, yc - 9.0 + i * 3.6, yc - 7.0 + i * 3.6, PCB_Z1 - 1.2, PCB_Z1 - 0.2)
    leds.build(PREFIX + "PowerDetector_LEDs", mats["red"])


# =====================================================================
#  ВЕНТИЛЯТОРЫ
# =====================================================================
def blade_stations(r_in, r_out, phi0, n=7):
    stations = []
    for i in range(n):
        t = i / (n - 1)
        r = r_in + (r_out - r_in) * t
        chord = 13.0 + 13.0 * t
        th = 1.2
        pitch = radians(42.0 - 22.0 * t)
        phi = phi0 + radians(38.0) * t
        sec = [(-chord / 2, 0.0), (0.0, th), (chord / 2, 0.0), (0.0, -th * 0.6)]
        pts = []
        for s, h in sec:
            s2 = s * cos(pitch) - h * sin(pitch)
            h2 = s * sin(pitch) + h * cos(pitch)
            pts.append((r * cos(phi) - s2 * sin(phi), r * sin(phi) + s2 * cos(phi), h2))
        stations.append(pts)
    return stations


def build_fan(parts, cy, cz, xf, dirn, r_out, nblades=FAN_BLADES):
    """Осевой вентилятор: лопасти, барьерное кольцо, ступица, держатели."""
    tf = lambda p: (xf + dirn * p[2], cy + p[0], cz + p[1])
    for k in parts:
        parts[k].tf = tf
    for k in range(nblades):
        parts["blades"].sweep(blade_stations(12.0, r_out, 2 * pi * k / nblades))
    parts["ring"].tube(circle_ab(r_out + 2.6, 64), circle_ab(r_out + 0.2, 64), -3.0, 4.0)
    hub = parts["hub"]
    hub.poly(circle_ab(15.0, 40), -6.0, 6.0)
    hub.poly(circle_ab(12.5, 40), 6.0, 7.6)
    hub.tube(circle_ab(11.2, 40), circle_ab(10.4, 40), 7.6, 8.1)
    hub.poly(circle_ab(5.5, 24), 7.6, 8.1)
    frame = parts["frame"]
    frame.tube(circle_ab(r_out + 2.6, 64), circle_ab(r_out - 4.0, 64), -7.0, -4.0)
    frame.poly(circle_ab(18.0, 32), -8.0, -3.0)
    for ang in (0.0, 120.0, 240.0):
        a = radians(ang)
        d = (cos(a), sin(a))
        nrm = (-d[1], d[0])
        w = 3.0
        r0, r1 = 12.0, r_out - 3.0
        bar = [(d[0] * r0 + nrm[0] * w, d[1] * r0 + nrm[1] * w),
               (d[0] * r1 + nrm[0] * w, d[1] * r1 + nrm[1] * w),
               (d[0] * r1 - nrm[0] * w, d[1] * r1 - nrm[1] * w),
               (d[0] * r0 - nrm[0] * w, d[1] * r0 - nrm[1] * w)]
        frame.poly(bar, -6.5, -3.5)
    for k in parts:
        parts[k].tf = None


def build_fans(mats):
    parts = {"blades": MB(), "ring": MB(), "hub": MB(), "frame": MB()}
    if SHOW_SHROUD:
        for cy in FRONT_FANS_Y:
            build_fan(parts, cy, FAN_Z, FAN_FRONT_XF, +1, FAN_R_OUT)
    # 4-й вентилятор со стороны бэкплейта: дует через радиатор
    build_fan(parts, REAR_FAN_Y, FAN_Z, 2.5, -1, REAR_FAN_R, nblades=9)
    parts["blades"].build(PREFIX + "Fan_Blades", mats["fan"])
    parts["ring"].build(PREFIX + "Fan_Rings", mats["gun"])
    parts["hub"].build(PREFIX + "Fan_Hubs", mats["gloss"])
    parts["frame"].build(PREFIX + "Fan_Frames", mats["plastic"])

    # воздуховод вокруг заднего вентилятора
    duct = MB(TF_X)
    duct.tube(circle_ab(REAR_FAN_R + 6.0, 64, REAR_FAN_Y, FAN_Z),
              circle_ab(REAR_FAN_R + 3.5, 64, REAR_FAN_Y, FAN_Z), X_BACK + 1.5, FIN_X0)
    duct.build(PREFIX + "RearFan_Duct", mats["plastic"])


# =====================================================================
#  РАДИАТОР
# =====================================================================
def build_heatsink(mats):
    fins = MB()
    n = int((FIN_Y1 - FIN_Y0) / FIN_PITCH)
    for i in range(n):
        y = FIN_Y0 + i * FIN_PITCH
        fins.box(FIN_X0, FIN_X1, y, y + FIN_T, Z0 + 3.0, Z1 - 3.5)
    fins.build(PREFIX + "Heatsink_Fins", mats["alu"])

    base = MB()
    base.box(3.0, 8.0, 38.0, 150.0, 28.0, 112.0)          # медная база / испарительная камера
    base.build(PREFIX + "VaporChamber_Base", mats["copper"])

    pipes = MB()
    for i in range(8):
        zc = 28.0 + 8.0 + i * 10.0
        pipes.prism_y(circle_uv(3.0, 14), 11.0, zc, 38.0, 300.0)
    pipes.build(PREFIX + "Heatpipes", mats["copper"])

    # рёбра жёсткости рамки
    frame = MB()
    frame.box(8.0, 14.0, FIN_Y0, FIN_Y1, Z0 + 3.0, Z0 + 5.0)
    frame.box(8.0, 14.0, FIN_Y0, FIN_Y1, Z1 - 5.5, Z1 - 3.5)
    frame.build(PREFIX + "Heatsink_Rails", mats["gun"])


# =====================================================================
#  КОЖУХ
# =====================================================================
def shroud_outline():
    y0, y1 = 8.0, CARD_L
    return [(y0 + 6, Z0), (y1 - 10, Z0), (y1, Z0 + 10), (y1, Z1 - 10),
            (y1 - 10, Z1), (y0 + 6, Z1), (y0, Z1 - 6), (y0, Z0 + 6)]


def build_shroud(mats):
    xp0, xp1 = X_TOP - 5.5, X_TOP - 1.0           # передняя пластина 64.25 .. 68.75
    front = MB(TF_X)
    front.poly(shroud_outline(), xp0, xp1)
    plate = front.build(PREFIX + "Shroud_Front")

    cut = MB(TF_X)
    for cy in FRONT_FANS_Y:
        cut.poly(circle_ab(FAN_HOLE_R, 72, cy, FAN_Z), xp0 - 2.0, xp1 + 2.0)
    boolean_cut(plate, cut, "ShroudCutter")
    bev = plate.modifiers.new("Bevel", "BEVEL")
    bev.width = 0.25
    bev.segments = 2
    assign(plate, mats["gun"])

    # боковые закраины сверху и снизу
    lips = MB()
    lips.box(X_TOP - 12.0, xp1, 8.0 + 6, CARD_L - 10, Z1 - 1.5, Z1)
    lips.box(X_TOP - 12.0, xp1, 8.0 + 6, CARD_L - 10, Z0, Z0 + 1.5)
    lips.build(PREFIX + "Shroud_Lips", mats["gun"])

    # акценты «звёздолёта»: угловые накладки между вентиляторами и хвостовые шевроны
    acc = MB(TF_X)
    for yc in (118.0, 226.0):
        acc.poly([(yc - 5, Z0 + 8), (yc + 5, Z0 + 14), (yc + 5, Z1 - 14), (yc - 5, Z1 - 8)], xp1, X_TOP)
    acc.poly([(332.0, Z0 + 10), (352.0, Z0 + 22), (332.0, Z0 + 34)], xp1, X_TOP)
    acc.poly([(332.0, Z1 - 10), (352.0, Z1 - 22), (332.0, Z1 - 34)], xp1, X_TOP)
    acc.poly([(14.0, Z0 + 8), (36.0, Z0 + 8), (14.0, Z0 + 26)], xp1, X_TOP)
    acc.poly([(14.0, Z1 - 8), (36.0, Z1 - 8), (14.0, Z1 - 26)], xp1, X_TOP)
    acc.build(PREFIX + "Shroud_Accents", mats["gloss"])

    # серебристая окантовка вокруг вентиляторов
    trim = MB(TF_X)
    for cy in FRONT_FANS_Y:
        trim.tube(circle_ab(FAN_HOLE_R + 2.2, 72, cy, FAN_Z), circle_ab(FAN_HOLE_R + 0.2, 72, cy, FAN_Z),
                  xp1, xp1 + 0.5)
    trim.build(PREFIX + "Shroud_FanTrim", mats["silver"])

    # RGB-кольца внутри отверстий и полоса сверху
    glow = MB(TF_X)
    for cy in FRONT_FANS_Y:
        glow.tube(circle_ab(FAN_HOLE_R - 0.2, 72, cy, FAN_Z), circle_ab(FAN_HOLE_R - 1.6, 72, cy, FAN_Z),
                  xp1 - 1.2, xp1 - 0.2)
    glow.build(PREFIX + "Shroud_RGB_Rings", mats["rgb"])
    strip = MB()
    strip.box(X_TOP - 8.8, X_TOP - 6.0, 30.0, 332.0, Z1 - 0.15, Z1 + 0.15)
    strip.box(X_TOP - 8.8, X_TOP - 6.0, 30.0, 332.0, Z0 - 0.15, Z0 + 0.15)
    strip.build(PREFIX + "Shroud_RGB_Strips", mats["rgb"])

    # хвостовая решётка
    grille = MB()
    for k in range(9):
        y = 336.0 + k * 2.4
        grille.box(24.0, X_TOP - 1.0, y, y + 1.0, Z0 + 4.0, Z1 - 4.0)
    grille.box(24.0, X_TOP - 1.0, 334.0, 357.0, Z0 + 2.0, Z0 + 4.0)
    grille.box(24.0, X_TOP - 1.0, 334.0, 357.0, Z1 - 4.0, Z1 - 2.0)
    grille.build(PREFIX + "Shroud_RearGrille", mats["gun"])

    # надписи: ROG ASTRAL на верхнем торце и крупный ROG на лицевой стороне
    add_text("ROG ASTRAL", (X_TOP - 4.0, 300.0, Z1 + 0.02), 3.2, mats["rgb"], "Text_TopEdge",
             rot_rows=ROT_FACE_PLUS_Z)
    add_text("ROG", (X_TOP + 0.02, 345.0, FAN_Z), 13.0, mats["rgb"], "Text_Front", rot_rows=ROT_FACE_PLUS_X,
             extrude=0.1)
    add_text("ASTRAL", (X_TOP + 0.02, 20.0, FAN_Z), 5.0, mats["text"], "Text_FrontSmall",
             rot_rows=ROT_FACE_PLUS_X, extrude=0.06)


# =====================================================================
#  БЭКПЛЕЙТ
# =====================================================================
def build_backplate(mats):
    x0, x1 = X_BACK, X_BACK + 1.5
    y0, y1 = 8.0, CARD_L - 6.0
    outline = [(y0 + 6, Z0), (y1 - 10, Z0), (y1, Z0 + 10), (y1, Z1 - 10),
               (y1 - 10, Z1), (y0 + 6, Z1), (y0, Z1 - 6), (y0, Z0 + 6)]
    mb = MB(TF_X)
    mb.poly(outline, x0, x1)
    plate = mb.build(PREFIX + "Backplate")

    cut = MB(TF_X)
    cut.poly(circle_ab(REAR_FAN_R + 4.0, 72, REAR_FAN_Y, FAN_Z), x0 - 1.0, x1 + 1.0)
    # косые вентиляционные прорези
    for k in range(6):
        yb = 70.0 + k * 22.0
        cut.poly([(yb, Z1 - 30.0), (yb + 12.0, Z1 - 30.0), (yb + 24.0, Z1 - 12.0), (yb + 12.0, Z1 - 12.0)],
                 x0 - 1.0, x1 + 1.0)
        cut.poly([(yb, Z0 + 12.0), (yb + 12.0, Z0 + 12.0), (yb + 24.0, Z0 + 30.0), (yb + 12.0, Z0 + 30.0)],
                 x0 - 1.0, x1 + 1.0)
    boolean_cut(plate, cut, "BackplateCutter")
    bev = plate.modifiers.new("Bevel", "BEVEL")
    bev.width = 0.2
    bev.segments = 2
    assign(plate, mats["gun"])

    # решётка 4-го вентилятора (концентрические кольца + спицы)
    gr = MB(TF_X)
    for r in (14.0, 22.0, 30.0, 38.0):
        gr.tube(circle_ab(r + 0.7, 64, REAR_FAN_Y, FAN_Z), circle_ab(r - 0.7, 64, REAR_FAN_Y, FAN_Z),
                x0, x1 - 0.2)
    gr.build(PREFIX + "Backplate_FanGuard", mats["gun"])
    spokes = MB(TF_X)
    for ang in range(0, 180, 30):
        a = radians(ang)
        d = (cos(a), sin(a))
        nrm = (-d[1], d[0])
        r1 = REAR_FAN_R + 3.5
        w = 0.7
        poly = [(REAR_FAN_Y + d[0] * r1 + nrm[0] * w, FAN_Z + d[1] * r1 + nrm[1] * w),
                (REAR_FAN_Y - d[0] * r1 + nrm[0] * w, FAN_Z - d[1] * r1 + nrm[1] * w),
                (REAR_FAN_Y - d[0] * r1 - nrm[0] * w, FAN_Z - d[1] * r1 - nrm[1] * w),
                (REAR_FAN_Y + d[0] * r1 - nrm[0] * w, FAN_Z + d[1] * r1 - nrm[1] * w)]
        spokes.poly(poly, x0, x1 - 0.2)
    spokes.build(PREFIX + "Backplate_FanSpokes", mats["gun"])

    # винты бэкплейта (круглые головки с крестообразным шлицем)
    screws = MB(TF_X)
    slots = MB(TF_X)
    pos = [(30.0, 14.0), (30.0, Z1 - 8.0), (120.0, 14.0), (120.0, Z1 - 8.0),
           (200.0, 14.0), (200.0, Z1 - 8.0), (270.0, 14.0), (270.0, Z1 - 8.0),
           (30.0, FAN_Z), (230.0, FAN_Z)]
    for (yy, zz) in pos:
        screws.poly(circle_ab(2.6, 20, yy, zz), x0 - 0.5, x0)
        slots.cbox(yy - 1.5, yy + 1.5, zz - 0.25, zz + 0.25, x0 - 0.62, x0 - 0.5)
        slots.cbox(yy - 0.25, yy + 0.25, zz - 1.5, zz + 1.5, x0 - 0.62, x0 - 0.5)
    screws.build(PREFIX + "Backplate_Screws", mats["screw"])
    slots.build(PREFIX + "Backplate_ScrewSlots", mats["plastic"])

    # надписи на бэкплейте
    add_text("ROG", (x0 - 0.04, 215.0, FAN_Z + 22.0), 20.0, mats["silver"], "Text_Back_ROG",
             rot_rows=ROT_FACE_MINUS_X, extrude=0.08)
    add_text("ASTRAL  GEFORCE RTX 5090", (x0 - 0.04, 215.0, FAN_Z - 8.0), 5.0, mats["silver"],
             "Text_Back_Name", rot_rows=ROT_FACE_MINUS_X, extrude=0.06)
    # световая полоса на бэкплейте
    glow = MB()
    glow.box(x0 - 0.2, x0, 40.0, 190.0, FAN_Z - 18.0, FAN_Z - 16.0)
    glow.build(PREFIX + "Backplate_RGB_Bar", mats["rgb"])


# =====================================================================
#  СЦЕНА
# =====================================================================
def build_scene_extras():
    cam_data = bpy.data.cameras.new(PREFIX + "Camera")
    cam_data.lens = 50
    cam_data.clip_end = 5000.0
    cam = bpy.data.objects.new(PREFIX + "Camera", cam_data)
    link(cam)
    cam.location = (330.0, -330.0, 260.0)
    target = mathutils.Vector((25.0, 170.0, 70.0))
    cam.rotation_euler = (target - cam.location).to_track_quat("-Z", "Y").to_euler()
    bpy.context.scene.camera = cam

    for name, loc, rot, energy, size in (
            ("Key", (250.0, 100.0, 380.0), (0.0, 0.0, 0.0), 600000, 300),
            ("Fill", (-250.0, -150.0, 200.0), (pi / 3, 0.0, -0.6), 250000, 250),
            ("Rim", (150.0, 420.0, 150.0), (pi / 2.5, 0.0, pi), 200000, 200)):
        ld = bpy.data.lights.new(PREFIX + name, "AREA")
        ld.energy = energy
        ld.size = size
        lo = bpy.data.objects.new(PREFIX + name, ld)
        link(lo)
        lo.location = loc
        lo.rotation_euler = rot

    try:
        for area in bpy.context.screen.areas:
            if area.type == "VIEW_3D":
                area.spaces[0].shading.type = "MATERIAL"
    except Exception as ex:
        print("Не удалось переключить вид:", ex)


def cleanup():
    for ob in list(bpy.data.objects):
        if ob.name.startswith(PREFIX):
            bpy.data.objects.remove(ob, do_unlink=True)
    old = bpy.data.collections.get("ROG_Astral_RTX5090")
    if old is not None:
        bpy.data.collections.remove(old)


def main():
    global COLL
    cleanup()
    COLL = bpy.data.collections.new("ROG_Astral_RTX5090")
    bpy.context.scene.collection.children.link(COLL)

    mats = build_materials()
    build_bracket(mats)
    build_ports(mats)
    build_screws(mats)
    build_bracket_labels(mats)
    if SHOW_PCB:
        build_pcb(mats)
        build_power_connector(mats)
    build_fans(mats)
    if SHOW_HEATSINK:
        build_heatsink(mats)
    if SHOW_SHROUD:
        build_shroud(mats)
    if SHOW_BACKPLATE:
        build_backplate(mats)
    build_scene_extras()
    print("Готово: ROG Astral RTX 5090")


main()