Перейти к содержимому
Для публикации в этом разделе необходимо провести 50 боёв.
Antitank_MK

Общий тест 25.9 — изменения нового интерфейса

В этой теме 202 комментария

Рекомендуемые комментарии

24
[FISH]
Участник
1 публикация
1 045 боёв

Честно говоря, мне новый дизайн как-то не зашел уж слишком он мультяшный сейчас как бы не времена виндоус 8 где всё плоское

  • Плюс 24

Рассказать о публикации


Ссылка на публикацию
11
[TPC]
Участник
9 публикаций

Выглядит все очень здорово, конечно, за исключением одного - это детских рисунков в виде снаряжения корабля (ББ снаряды, ОФ и тд). Переработайте иконки, пожалуйста. В остальном все очень круто!

  • Плюс 6
  • Ха-Ха 1

Рассказать о публикации


Ссылка на публикацию
4 055
[URLM]
Участник
3 515 публикаций
30 597 боёв
В 18.08.2025 в 17:07:27 пользователь _EzyRider_ сказал:

Значки/иконки не надо "исправлять", их просто не нужно трогать.

Сейчас они выглядят вполне нормально и привычно

Увы...поезд уже ушел.....

  • Плюс 3

Рассказать о публикации


Ссылка на публикацию
1 366
Участник
646 публикаций
18 546 боёв

Как в мультик попал из военной, брутальной игры....

  • Плюс 6
  • Круто 2

Рассказать о публикации


Ссылка на публикацию
161
[NPO]
Участник
304 публикации

Вот интересно-а куда спрятали снежинки?По результатам боя они есть а где их собрать?

  • Плюс 1
  • Ха-Ха 1

Рассказать о публикации


Ссылка на публикацию
177
[-SEA-]
Участник
24 публикации
3 296 боёв
Сегодня в 10:12:28 пользователь Cemeet1 сказал:

Вот интересно-а куда спрятали снежинки?По результатам боя они есть а где их собрать?

под вызовом дня есть плашка с наградами

  • Плюс 1

Рассказать о публикации


Ссылка на публикацию
536
[FEMOR]
Участник
136 публикаций
Сегодня в 13:37:14 пользователь Grim_Rat сказал:

Повальная борьба со скевоморфизмом

Считаю эту часть нововведений худшей стилистически. А ведь напоминание: игра все ещё про корабли эпохи первой, второй мировой и холодной войн. И там уместен стимпанк, дизельпанк и все такое. 

 

Тем не менее многие элементы интерфейса мне концептуально понравились. Особенно возможность продолжать полностью взаимодействовать со всем интерфейсом во время поиска боя. Но они требуют совершенствования. Особенно эти безвкусные примитивные значки. 

  • Плюс 12
  • Круто 2
  • Ха-Ха 1

Рассказать о публикации


Ссылка на публикацию
86
[SNOK]
Участник
53 публикации
3 808 боёв
В 21.08.2025 в 23:33:26 пользователь _DasWindows43_ сказал:

Нууууууууууу. Это их (разработчиков) игра как продукт, мы -всего лишь пользователи.

Список использованной литературы:
1. Лицензионное соглашение с конечным пользователем.

В том и проблема, что пропала ориентированность на пользователя. Раньше разработчики софта ориентировались на пользователя, теперь - то ли на Ктулху, то ли на собственный зуд в левой пятке…

Сегодня в 13:45:43 пользователь Troxis911 сказал:

Считаю эту часть нововведений худшей стилистически. А ведь напоминание: игра все ещё про корабли эпохи первой, второй мировой и холодной войн. И там уместен стимпанк, дизельпанк и все такое. 

 

Тем не менее многие элементы интерфейса мне концептуально понравились. Особенно возможность продолжать полностью взаимодействовать со всем интерфейсом во время поиска боя. Но они требуют совершенствования. Особенно эти безвкусные примитивные значки. 

Стимпанк, дизельпанк и всё такое подразумевают тот самый скевоморфизм - то есть, отображение элементов интерфейса максимально приближённо к "элементам мира реального", а не абстрактным абстракциям, понятным только терминатору из киношного будущего.

 

Возможность взаимодействовать с интерфейсом в процессе поиска - да, возможно. Но в данной игре всё взаимодействие строится вокруг корабля, а корабль - уже, как бы, "готовится к бою", и взаимодействовать с ним нельзя. Чятик, и так, доступен в процессе подготовки… тут - не знаю, степень полезности оценить не могу.

 

Прошу объединить это сообщение с №188.

  • Плюс 19
  • Круто 1
  • Плохо 1

Рассказать о публикации


Ссылка на публикацию
536
[FEMOR]
Участник
136 публикаций
Сегодня в 13:46:23 пользователь Grim_Rat сказал:

пропала ориентированность на пользователя

Она не то чтобы была. Разработчики всегда пытались с разной степенью успешности гадать, что пользователю вообще надо. И успешность этого гадания всегда определялась эффективностью методов тестирования. 

 

Тем не менее ориентированность на пользователя всегда была условной... И зависела от общего качества работы. Здесь то же самое, разработчики конечно же пытаются (именно пытаются) угодить всем, но из-за того что нововведения в интерфейсе выполнены не слишком качественно (а местами похожи на дешевый аутсорс), мы и имеем столько недовольных игроков. В иконках снаряжения полностью отсутствует преемственность со старыми иконками. В новом интерфейсе порой не наводя мышь на расходник невозможно догадаться что это такое вообще, если  не знаешь. Ограничение (абсолютно бессмысленное) в повороте камеры на вкладке активности, которое нужно исключительно чтобы вместить в интерфейс непомерно огромные баннеры событий, которые нельзя сгруппировать или убрать. 

 

Лично мне нравится идея (концепция) обновления интерфейса, но в деталях оно выполнено плохо, местами очень плохо. Надеюсь разработчики прислушаются к игрокам и смогут сделать его лучше. 

  • Плюс 14
  • Круто 1

Рассказать о публикации


Ссылка на публикацию
139
[GBUST]
Участник
60 публикаций
3 942 боя

Чисто из любопытства решил посмотреть, насколько просто автоматизировать создание кукол. За 15 минут с нейронкой получил полностью рабочую программу.

Интерфейс:

Скрытый текст

python_24-08-2025_21-28-47.thumb.png.23afd3df2be059ebd5013fff7dd8d14d.png

 

Результат:

Скрытый текст

outline.thumb.png.7b94a1b9b14c1d2013012627f8da307c.png 

 

Исходный код:

Скрытый текст
#!/usr/bin/env python3
"""
OBJ -> PNG silhouette extractor GUI (filled outline, not wireframe)
 
Requirements:
  - Python 3.8+
  - PySide6
  - numpy
  - shapely
 
Install:
  pip install PySide6 numpy shapely
 
What it does:
  - Load a Wavefront .obj (vertices and faces)
  - Apply rotation (Euler), scale, translation
  - Project to 2D (orthographic or simple perspective)
  - Union all projected face-polygons into one 2D filled shape
  - Export resulting outline as PNG raster (user chooses width OR height, the other auto-adjusts)
  - Preview matches export colors (transparent background supported)
 
"""
 
import sys
import math
import os
 
import numpy as np
from shapely.geometry import Polygon, MultiPolygon
from shapely.ops import unary_union
from PySide6 import QtWidgets, QtGui, QtCore
 
# ---------- Simple OBJ loader ----------
 
def load_obj_simple(path):
    verts = []
    faces = []
    with open(path, 'r', encoding='utf-8', errors='ignore') as f:
        for line in f:
            if not line.strip():
                continue
            parts = line.strip().split()
            if parts[0] == 'v' and len(parts) >= 4:
                x, y, z = map(float, parts[1:4])
                verts.append((x, y, z))
            elif parts[0] == 'f' and len(parts) >= 4:
                idxs = []
                for p in parts[1:]:
                    vi = p.split('/')[0]
                    if vi:
                        idxs.append(int(vi) - 1)
                if len(idxs) == 3:
                    faces.append(tuple(idxs))
                else:
                    for i in range(1, len(idxs) - 1):
                        faces.append((idxs[0], idxs[i], idxs[i + 1]))
    return np.array(verts, dtype=float), np.array(faces, dtype=int)
 
# ---------- Geometry utilities ----------
 
def euler_to_matrix(rx, ry, rz):
    rx = math.radians(rx)
    ry = math.radians(ry)
    rz = math.radians(rz)
    Rx = np.array([[1, 0, 0], [0, math.cos(rx), -math.sin(rx)], [0, math.sin(rx), math.cos(rx)]])
    Ry = np.array([[math.cos(ry), 0, math.sin(ry)], [0, 1, 0], [-math.sin(ry), 0, math.cos(ry)]])
    Rz = np.array([[math.cos(rz), -math.sin(rz), 0], [math.sin(rz), math.cos(rz), 0], [0, 0, 1]])
    return Rz @ Ry @ Rx
 
def project_vertices_orthographic(verts, scale=1.0, tx=0.0, ty=0.0):
    pts2 = verts[:, :2] * scale
    pts2 = pts2 + np.array([tx, ty])
    return pts2
 
def project_vertices_perspective(verts, fov_deg=60.0, camera_z=3.0, scale=1.0, tx=0.0, ty=0.0):
    z = verts[:, 2] + camera_z
    z = np.where(np.abs(z) < 1e-6, 1e-6, z)
    f = 1.0 / math.tan(math.radians(fov_deg) / 2.0)
    x = verts[:, 0] * (f / z) * scale + tx
    y = verts[:, 1] * (f / z) * scale + ty
    return np.column_stack((x, y))
 
# ---------- Silhouette via polygon union ----------
 
def compute_silhouette(verts, faces, rx, ry, rz, proj_mode, scale, tx, ty, buffer_percent=0.01):
    R = euler_to_matrix(rx, ry, rz)
    verts_r = (R @ verts.T).T
    if proj_mode == 'Orthographic':
        pts2 = project_vertices_orthographic(verts_r, scale=scale, tx=tx, ty=ty)
    else:
        pts2 = project_vertices_perspective(verts_r, fov_deg=60.0, camera_z=3.0, scale=scale, tx=tx, ty=ty)
 
    polys = []
    for f in faces:
        poly = Polygon([(pts2[i][0], pts2[i][1]) for i in f])
        if poly.is_valid and not poly.is_empty:
            polys.append(poly)
 
    if not polys:
        return None
 
    # Union all polygons
    silhouette = unary_union(polys)
   
    # Apply small buffer operation to merge nearby edges, then negative buffer to restore size
    # This helps eliminate thin internal boundaries while preserving actual holes
    if not silhouette.is_empty and buffer_percent > 0:
        # Calculate appropriate buffer distance based on bounding box
        bounds = silhouette.bounds
        size = max(bounds[2] - bounds[0], bounds[3] - bounds[1])
        buffer_dist = size * (buffer_percent / 100.0)  # Convert percentage to fraction
       
        # Positive buffer to merge close boundaries, then negative to restore
        buffered = silhouette.buffer(buffer_dist, join_style=2)  # join_style=2 for mitered joins
        clean_silhouette = buffered.buffer(-buffer_dist, join_style=2)
    else:
        clean_silhouette = silhouette
   
    return clean_silhouette
 
# ---------- PNG export ----------
 
def silhouette_to_png(silhouette, filename, *, target_w=None, target_h=None, stroke_width=2.0, stroke_qcolor=QtGui.QColor('black'), fill_qcolor=QtGui.QColor('white'), transparent_bg=True, margin=0):
    """Save silhouette to PNG. Exactly one of target_w/target_h should be provided.
    If both are provided, target_w takes precedence.
    """
    if silhouette is None or silhouette.is_empty:
        return None
 
    if isinstance(silhouette, Polygon):
        shapes = [silhouette]
    elif isinstance(silhouette, MultiPolygon):
        shapes = list(silhouette.geoms)
    else:
        return None
 
    minx, miny, maxx, maxy = silhouette.bounds
    width = maxx - minx if maxx > minx else 1.0
    height = maxy - miny if maxy > miny else 1.0
 
    # Calculate margin in pixels
    effective_margin = margin + stroke_width
 
    # decide scale & output size - shape fits within (target_size - 2*margin)
    if target_w and target_w > 0:
        out_w = int(target_w)
        available_w = target_w - 2 * effective_margin
        scale = float(available_w) / float(width)
        out_h = int(round(height * scale + 2 * effective_margin))
    elif target_h and target_h > 0:
        out_h = int(target_h)
        available_h = target_h - 2 * effective_margin
        scale = float(available_h) / float(height)
        out_w = int(round(width * scale + 2 * effective_margin))
    else:
        # default: fit largest dimension to 800 px minus margin
        available_size = 800.0 - 2 * effective_margin
        scale = available_size / float(max(width, height))
        out_w = int(round(width * scale + 2 * effective_margin))
        out_h = int(round(height * scale + 2 * effective_margin))
 
    # prepare image with transparency
    img = QtGui.QImage(out_w, out_h, QtGui.QImage.Format_ARGB32_Premultiplied)
    if transparent_bg:
        img.fill(QtGui.qRgba(0, 0, 0, 0))
    else:
        img.fill(QtGui.QColor('white'))
 
    painter = QtGui.QPainter(img)
    painter.setRenderHint(QtGui.QPainter.Antialiasing)
 
    pen = QtGui.QPen(stroke_qcolor)
    pen.setWidthF(stroke_width)
    painter.setPen(pen)
    painter.setBrush(QtGui.QBrush(fill_qcolor))
 
    def to_canvas(x, y):
        cx = (x - minx) * scale + effective_margin
        cy = out_h - ((y - miny) * scale + effective_margin)
        return QtCore.QPointF(cx, cy)
 
    def draw_poly(poly):
        coords = list(poly.exterior.coords)
        if not coords:
            return
        path = QtGui.QPainterPath()
        path.moveTo(to_canvas(*coords[0]))
        for x, y in coords[1:]:
            path.lineTo(to_canvas(x, y))
        path.closeSubpath()
        painter.drawPath(path)
 
    for poly in shapes:
        draw_poly(poly)
 
    painter.end()
    img.save(filename)
    return filename
 
# ---------- Qt GUI ----------
 
class MainWindow(QtWidgets.QMainWindow):
    def __init__(self):
        super().__init__()
        self.setWindowTitle('OBJ -> PNG silhouette (filled outline)')
        self.resize(1100, 740)
 
        self.verts = None
        self.faces = None
        self.fill_color = QtGui.QColor(204, 204, 204, 255)  # #cccccc with full alpha
        self.stroke_color = QtGui.QColor(0, 0, 0, 255)      # black with full alpha
 
        w = QtWidgets.QWidget()
        self.setCentralWidget(w)
        mainlay = QtWidgets.QHBoxLayout(w)
 
        ctrl = QtWidgets.QWidget()
        ctrl_layout = QtWidgets.QFormLayout(ctrl)
        mainlay.addWidget(ctrl, 0)
 
        self.load_btn = QtWidgets.QPushButton('Load .obj')
        self.load_btn.clicked.connect(self.on_load)
        ctrl_layout.addRow(self.load_btn)
 
        # Rotation controls (slider + spinbox for precise entry)
        self.rx = self._make_angle_control(ctrl_layout, 'Rotate X')
        self.ry = self._make_angle_control(ctrl_layout, 'Rotate Y')
        self.rz = self._make_angle_control(ctrl_layout, 'Rotate Z')
 
        self.scale_spin = QtWidgets.QDoubleSpinBox(); self.scale_spin.setRange(0.001, 1000.0); self.scale_spin.setValue(1.0)
        self.scale_spin.valueChanged.connect(self.on_params_changed)
        ctrl_layout.addRow('Scale', self.scale_spin)
 
        self.tx_spin = QtWidgets.QDoubleSpinBox(); self.tx_spin.setRange(-1000, 1000); self.tx_spin.setValue(0.0)
        self.tx_spin.valueChanged.connect(self.on_params_changed)
        ctrl_layout.addRow('Trans X', self.tx_spin)
 
        self.ty_spin = QtWidgets.QDoubleSpinBox(); self.ty_spin.setRange(-1000, 1000); self.ty_spin.setValue(0.0)
        self.ty_spin.valueChanged.connect(self.on_params_changed)
        ctrl_layout.addRow('Trans Y', self.ty_spin)
 
        self.proj_combo = QtWidgets.QComboBox(); self.proj_combo.addItems(['Orthographic', 'Perspective'])
        self.proj_combo.currentIndexChanged.connect(self.on_params_changed)
        ctrl_layout.addRow('Projection', self.proj_combo)
 
        # Colors
        self.fill_btn = self._make_color_button('Fill Color', self.fill_color, lambda c: setattr(self, 'fill_color', c))
        self.stroke_btn = self._make_color_button('Outline Color', self.stroke_color, lambda c: setattr(self, 'stroke_color', c))
        ctrl_layout.addRow('Fill', self.fill_btn)
        ctrl_layout.addRow('Outline', self.stroke_btn)
 
        # PNG sizing
        self.size_info = QtWidgets.QLabel('Set either width or height (the other will be 0). Width takes precedence if both > 0.')
        ctrl_layout.addRow(self.size_info)
        self.png_width_spin = QtWidgets.QSpinBox(); self.png_width_spin.setRange(1, 10000); self.png_width_spin.setValue(800)
        self.png_height_spin = QtWidgets.QSpinBox(); self.png_height_spin.setRange(0, 10000); self.png_height_spin.setValue(0)
        self.png_width_spin.valueChanged.connect(self._on_png_width_changed)
        self.png_height_spin.valueChanged.connect(self._on_png_height_changed)
        ctrl_layout.addRow('PNG Width (px)', self.png_width_spin)
        ctrl_layout.addRow('PNG Height (px)', self.png_height_spin)
 
        self.bg_transparent = QtWidgets.QCheckBox('Transparent background')
        self.bg_transparent.setChecked(True)
        ctrl_layout.addRow(self.bg_transparent)
 
        self.stroke_spin = QtWidgets.QDoubleSpinBox(); self.stroke_spin.setRange(0.1, 50.0); self.stroke_spin.setValue(2.0)
        ctrl_layout.addRow('Outline width (px)', self.stroke_spin)
 
        self.buffer_spin = QtWidgets.QDoubleSpinBox(); self.buffer_spin.setRange(0.0, 10.0); self.buffer_spin.setValue(0.01); self.buffer_spin.setSingleStep(0.001); self.buffer_spin.setDecimals(5); self.buffer_spin.setSuffix('%')
        self.buffer_spin.valueChanged.connect(self.on_params_changed)
        ctrl_layout.addRow('Clean outline buffer (%)', self.buffer_spin)
 
        self.margin_spin = QtWidgets.QSpinBox(); self.margin_spin.setRange(0, 200); self.margin_spin.setValue(0); self.margin_spin.setSuffix(' px')
        ctrl_layout.addRow('Image margin (px)', self.margin_spin)
 
        # Buttons
        self.preview_btn = QtWidgets.QPushButton('Render Preview')
        self.preview_btn.clicked.connect(self.render_preview)
        self.export_btn = QtWidgets.QPushButton('Export PNG...')
        self.export_btn.clicked.connect(self.export_png)
        ctrl_layout.addRow(self.preview_btn, self.export_btn)
 
        ctrl_layout.addRow(QtWidgets.QLabel('Tips: rotate Y ~90 for side view'))
 
        # Preview area
        canvas = QtWidgets.QWidget()
        canvas_layout = QtWidgets.QVBoxLayout(canvas)
        mainlay.addWidget(canvas, 1)
 
        self.preview_label = QtWidgets.QLabel(alignment=QtCore.Qt.AlignCenter)
        self.preview_label.setMinimumSize(600, 400)
        self.preview_label.setStyleSheet('background: #ffffff; border: 1px solid #888;')
        canvas_layout.addWidget(self.preview_label)
 
        self.status = QtWidgets.QLabel('No mesh loaded')
        canvas_layout.addWidget(self.status)
 
    # ---- UI helpers ----
    def _make_angle_control(self, layout, label):
        slider = QtWidgets.QSlider(QtCore.Qt.Horizontal); slider.setRange(0, 360)
        spin = QtWidgets.QSpinBox(); spin.setRange(0, 360)
        slider.valueChanged.connect(spin.setValue)
        spin.valueChanged.connect(slider.setValue)
        slider.valueChanged.connect(self.on_params_changed)
        spin.valueChanged.connect(self.on_params_changed)
        hl = QtWidgets.QHBoxLayout()
        hl.addWidget(slider)
        hl.addWidget(spin)
        layout.addRow(label, hl)
        return spin
 
    def _make_color_button(self, text, initial_color, setter):
        btn = QtWidgets.QPushButton(text)
        btn.setCursor(QtCore.Qt.PointingHandCursor)
        btn.setStyleSheet(f'background:rgba({initial_color.red()},{initial_color.green()},{initial_color.blue()},{initial_color.alpha()/255.0}); color: black')
        def pick():
            c = QtWidgets.QColorDialog.getColor(initial_color, self, f'Pick {text}', QtWidgets.QColorDialog.ShowAlphaChannel)
            if c.isValid():
                setter(c)
                btn.setStyleSheet(f'background:rgba({c.red()},{c.green()},{c.blue()},{c.alpha()/255.0}); color: black')
                self.render_preview()
        btn.clicked.connect(pick)
        return btn
 
    def _on_png_width_changed(self, v):
        # enforce width OR height (width takes precedence)
        if v > 0 and self.png_height_spin.value() != 0:
            self.png_height_spin.blockSignals(True)
            self.png_height_spin.setValue(0)
            self.png_height_spin.blockSignals(False)
 
    def _on_png_height_changed(self, v):
        if v > 0 and self.png_width_spin.value() != 0:
            self.png_width_spin.blockSignals(True)
            self.png_width_spin.setValue(0)
            self.png_width_spin.blockSignals(False)
 
    # ---- Event handlers ----
    def on_load(self):
        path, _ = QtWidgets.QFileDialog.getOpenFileName(self, 'Open OBJ', '.', 'OBJ files (*.obj)')
        if not path:
            return
        try:
            verts, faces = load_obj_simple(path)
        except Exception as e:
            QtWidgets.QMessageBox.critical(self, 'Error', f'Failed to load OBJ: {e}')
            return
        self.verts = verts; self.faces = faces
        self.status.setText(f'Loaded: {os.path.basename(path)} - {len(verts)} verts, {len(faces)} faces')
        self.render_preview()
 
    def on_params_changed(self):
        if self.verts is None:
            return
        if len(self.faces) < 5000:
            self.render_preview()
 
    def render_preview(self):
        if self.verts is None:
            return
        rx = self.rx.value(); ry = self.ry.value(); rz = self.rz.value()
        scale = float(self.scale_spin.value()); tx = float(self.tx_spin.value()); ty = float(self.ty_spin.value())
        proj = self.proj_combo.currentText()
 
        silhouette = compute_silhouette(self.verts, self.faces, rx, ry, rz, proj, scale, tx, ty, self.buffer_spin.value())
 
        w = max(600, int(self.preview_label.width()))
        h = max(400, int(self.preview_label.height()))
        pix = QtGui.QPixmap(w, h)
        pix.fill(QtGui.QColor('white'))
        painter = QtGui.QPainter(pix)
        painter.setRenderHint(QtGui.QPainter.Antialiasing)
 
        if silhouette is not None and not silhouette.is_empty:
            minx, miny, maxx, maxy = silhouette.bounds
            margin = 20
            sx = (w - 2 * margin) / (maxx - minx if maxx > minx else 1)
            sy = (h - 2 * margin) / (maxy - miny if maxy > miny else 1)
            s = min(sx, sy)
 
            def to_canvas(x, y):
                cx = (x - minx) * s + margin
                cy = h - ((y - miny) * s + margin)
                return QtCore.QPointF(cx, cy)
 
            pen = QtGui.QPen(self.stroke_color)
            pen.setWidthF(self.stroke_spin.value())
            painter.setPen(pen)
            painter.setBrush(QtGui.QBrush(self.fill_color))
 
            def draw_poly(poly):
                coords = list(poly.exterior.coords)
                path = QtGui.QPainterPath()
                path.moveTo(to_canvas(*coords[0]))
                for x, y in coords[1:]:
                    path.lineTo(to_canvas(x, y))
                path.closeSubpath()
                painter.drawPath(path)
 
            if isinstance(silhouette, Polygon):
                draw_poly(silhouette)
            elif isinstance(silhouette, MultiPolygon):
                for poly in silhouette.geoms:
                    draw_poly(poly)
 
            self.status.setText('Preview rendered (filled outline)')
        else:
            self.status.setText('No silhouette computed')
 
        painter.end()
        self.preview_label.setPixmap(pix)
 
    def export_png(self):
        if self.verts is None:
            return
        fn, _ = QtWidgets.QFileDialog.getSaveFileName(self, 'Save PNG', 'outline.png', 'PNG files (*.png)')
        if not fn:
            return
        rx = self.rx.value(); ry = self.ry.value(); rz = self.rz.value()
        scale = float(self.scale_spin.value()); tx = float(self.tx_spin.value()); ty = float(self.ty_spin.value())
        proj = self.proj_combo.currentText()
 
        silhouette = compute_silhouette(self.verts, self.faces, rx, ry, rz, proj, scale, tx, ty, self.buffer_spin.value())
 
        target_w = self.png_width_spin.value()
        target_h = self.png_height_spin.value()
        # treat zeros as None
        target_w = target_w if target_w > 0 else None
        target_h = target_h if target_h > 0 else None
 
        filename = silhouette_to_png(
            silhouette,
            fn,
            target_w=target_w,
            target_h=target_h,
            stroke_width=float(self.stroke_spin.value()),
            stroke_qcolor=self.stroke_color,
            fill_qcolor=self.fill_color,
            transparent_bg=self.bg_transparent.isChecked(),
            margin=self.margin_spin.value(),
        )
        if filename:
            QtWidgets.QMessageBox.information(self, 'Saved', f'Saved PNG to: {filename}')
 
# ---------- Main ----------
 
def main():
    app = QtWidgets.QApplication(sys.argv)
    w = MainWindow()
    w.show()
    sys.exit(app.exec())
 
if __name__ == '__main__':
    main()

 

Модель для проверки взял отсюда. Остаётся подчистить внутренние линии, при необходимости конвертировать в .svg и... всё. Неужели это ТАК затратно и нет никаких альтернатив полному удалению кукол из игры?

 

 

 

Изменено пользователем KOMMyHuCT_USSR
Добавил прозрачность (скорее всего всё-таки нужна), попытался избавиться от лишних линий и сделал отступ от краёв
  • Плюс 15
  • Круто 15
  • Ха-Ха 2
  • Плохо 1

Рассказать о публикации


Ссылка на публикацию
536
[FEMOR]
Участник
136 публикаций
Сегодня в 19:00:43 пользователь KOMMyHuCT_USSR сказал:

Чисто из любопытства решил посмотреть, насколько просто автоматизировать создание кукол

Предлагаю отстранить ответственных за куклы кораблей/полоску хп и назначить энтого автора и его нейросеть на довольствие, чтоб он нам вернул куклы. :cap_haloween:

  • Плюс 16
  • Круто 1
  • Ха-Ха 4

Рассказать о публикации


Ссылка на публикацию
139
[GBUST]
Участник
60 публикаций
3 942 боя
Сегодня в 20:00:43 пользователь KOMMyHuCT_USSR сказал:

Чисто из любопытства решил посмотреть, насколько просто автоматизировать создание кукол. За 15 минут с нейронкой получил полностью рабочую программу.

Интерфейс:

  Показать содержимое

python_24-08-2025_21-28-47.thumb.png.23afd3df2be059ebd5013fff7dd8d14d.png

 

Результат:

  Показать содержимое

outline.thumb.png.7b94a1b9b14c1d2013012627f8da307c.png 

 

Исходный код:

  Показать содержимое
#!/usr/bin/env python3
"""
OBJ -> PNG silhouette extractor GUI (filled outline, not wireframe)
 
Requirements:
  - Python 3.8+
  - PySide6
  - numpy
  - shapely
 
Install:
  pip install PySide6 numpy shapely
 
What it does:
  - Load a Wavefront .obj (vertices and faces)
  - Apply rotation (Euler), scale, translation
  - Project to 2D (orthographic or simple perspective)
  - Union all projected face-polygons into one 2D filled shape
  - Export resulting outline as PNG raster (user chooses width OR height, the other auto-adjusts)
  - Preview matches export colors (transparent background supported)
 
"""
 
import sys
import math
import os
 
import numpy as np
from shapely.geometry import Polygon, MultiPolygon
from shapely.ops import unary_union
from PySide6 import QtWidgets, QtGui, QtCore
 
# ---------- Simple OBJ loader ----------
 
def load_obj_simple(path):
    verts = []
    faces = []
    with open(path, 'r', encoding='utf-8', errors='ignore') as f:
        for line in f:
            if not line.strip():
                continue
            parts = line.strip().split()
            if parts[0] == 'v' and len(parts) >= 4:
                x, y, z = map(float, parts[1:4])
                verts.append((x, y, z))
            elif parts[0] == 'f' and len(parts) >= 4:
                idxs = []
                for p in parts[1:]:
                    vi = p.split('/')[0]
                    if vi:
                        idxs.append(int(vi) - 1)
                if len(idxs) == 3:
                    faces.append(tuple(idxs))
                else:
                    for i in range(1, len(idxs) - 1):
                        faces.append((idxs[0], idxs[i], idxs[i + 1]))
    return np.array(verts, dtype=float), np.array(faces, dtype=int)
 
# ---------- Geometry utilities ----------
 
def euler_to_matrix(rx, ry, rz):
    rx = math.radians(rx)
    ry = math.radians(ry)
    rz = math.radians(rz)
    Rx = np.array([[1, 0, 0], [0, math.cos(rx), -math.sin(rx)], [0, math.sin(rx), math.cos(rx)]])
    Ry = np.array([[math.cos(ry), 0, math.sin(ry)], [0, 1, 0], [-math.sin(ry), 0, math.cos(ry)]])
    Rz = np.array([[math.cos(rz), -math.sin(rz), 0], [math.sin(rz), math.cos(rz), 0], [0, 0, 1]])
    return Rz @ Ry @ Rx
 
def project_vertices_orthographic(verts, scale=1.0, tx=0.0, ty=0.0):
    pts2 = verts[:, :2] * scale
    pts2 = pts2 + np.array([tx, ty])
    return pts2
 
def project_vertices_perspective(verts, fov_deg=60.0, camera_z=3.0, scale=1.0, tx=0.0, ty=0.0):
    z = verts[:, 2] + camera_z
    z = np.where(np.abs(z) < 1e-6, 1e-6, z)
    f = 1.0 / math.tan(math.radians(fov_deg) / 2.0)
    x = verts[:, 0] * (f / z) * scale + tx
    y = verts[:, 1] * (f / z) * scale + ty
    return np.column_stack((x, y))
 
# ---------- Silhouette via polygon union ----------
 
def compute_silhouette(verts, faces, rx, ry, rz, proj_mode, scale, tx, ty, buffer_percent=0.01):
    R = euler_to_matrix(rx, ry, rz)
    verts_r = (R @ verts.T).T
    if proj_mode == 'Orthographic':
        pts2 = project_vertices_orthographic(verts_r, scale=scale, tx=tx, ty=ty)
    else:
        pts2 = project_vertices_perspective(verts_r, fov_deg=60.0, camera_z=3.0, scale=scale, tx=tx, ty=ty)
 
    polys = []
    for f in faces:
        poly = Polygon([(pts2[i][0], pts2[i][1]) for i in f])
        if poly.is_valid and not poly.is_empty:
            polys.append(poly)
 
    if not polys:
        return None
 
    # Union all polygons
    silhouette = unary_union(polys)
   
    # Apply small buffer operation to merge nearby edges, then negative buffer to restore size
    # This helps eliminate thin internal boundaries while preserving actual holes
    if not silhouette.is_empty and buffer_percent > 0:
        # Calculate appropriate buffer distance based on bounding box
        bounds = silhouette.bounds
        size = max(bounds[2] - bounds[0], bounds[3] - bounds[1])
        buffer_dist = size * (buffer_percent / 100.0)  # Convert percentage to fraction
       
        # Positive buffer to merge close boundaries, then negative to restore
        buffered = silhouette.buffer(buffer_dist, join_style=2)  # join_style=2 for mitered joins
        clean_silhouette = buffered.buffer(-buffer_dist, join_style=2)
    else:
        clean_silhouette = silhouette
   
    return clean_silhouette
 
# ---------- PNG export ----------
 
def silhouette_to_png(silhouette, filename, *, target_w=None, target_h=None, stroke_width=2.0, stroke_qcolor=QtGui.QColor('black'), fill_qcolor=QtGui.QColor('white'), transparent_bg=True, margin=0):
    """Save silhouette to PNG. Exactly one of target_w/target_h should be provided.
    If both are provided, target_w takes precedence.
    """
    if silhouette is None or silhouette.is_empty:
        return None
 
    if isinstance(silhouette, Polygon):
        shapes = [silhouette]
    elif isinstance(silhouette, MultiPolygon):
        shapes = list(silhouette.geoms)
    else:
        return None
 
    minx, miny, maxx, maxy = silhouette.bounds
    width = maxx - minx if maxx > minx else 1.0
    height = maxy - miny if maxy > miny else 1.0
 
    # Calculate margin in pixels
    effective_margin = margin + stroke_width
 
    # decide scale & output size - shape fits within (target_size - 2*margin)
    if target_w and target_w > 0:
        out_w = int(target_w)
        available_w = target_w - 2 * effective_margin
        scale = float(available_w) / float(width)
        out_h = int(round(height * scale + 2 * effective_margin))
    elif target_h and target_h > 0:
        out_h = int(target_h)
        available_h = target_h - 2 * effective_margin
        scale = float(available_h) / float(height)
        out_w = int(round(width * scale + 2 * effective_margin))
    else:
        # default: fit largest dimension to 800 px minus margin
        available_size = 800.0 - 2 * effective_margin
        scale = available_size / float(max(width, height))
        out_w = int(round(width * scale + 2 * effective_margin))
        out_h = int(round(height * scale + 2 * effective_margin))
 
    # prepare image with transparency
    img = QtGui.QImage(out_w, out_h, QtGui.QImage.Format_ARGB32_Premultiplied)
    if transparent_bg:
        img.fill(QtGui.qRgba(0, 0, 0, 0))
    else:
        img.fill(QtGui.QColor('white'))
 
    painter = QtGui.QPainter(img)
    painter.setRenderHint(QtGui.QPainter.Antialiasing)
 
    pen = QtGui.QPen(stroke_qcolor)
    pen.setWidthF(stroke_width)
    painter.setPen(pen)
    painter.setBrush(QtGui.QBrush(fill_qcolor))
 
    def to_canvas(x, y):
        cx = (x - minx) * scale + effective_margin
        cy = out_h - ((y - miny) * scale + effective_margin)
        return QtCore.QPointF(cx, cy)
 
    def draw_poly(poly):
        coords = list(poly.exterior.coords)
        if not coords:
            return
        path = QtGui.QPainterPath()
        path.moveTo(to_canvas(*coords[0]))
        for x, y in coords[1:]:
            path.lineTo(to_canvas(x, y))
        path.closeSubpath()
        painter.drawPath(path)
 
    for poly in shapes:
        draw_poly(poly)
 
    painter.end()
    img.save(filename)
    return filename
 
# ---------- Qt GUI ----------
 
class MainWindow(QtWidgets.QMainWindow):
    def __init__(self):
        super().__init__()
        self.setWindowTitle('OBJ -> PNG silhouette (filled outline)')
        self.resize(1100, 740)
 
        self.verts = None
        self.faces = None
        self.fill_color = QtGui.QColor(204, 204, 204, 255)  # #cccccc with full alpha
        self.stroke_color = QtGui.QColor(0, 0, 0, 255)      # black with full alpha
 
        w = QtWidgets.QWidget()
        self.setCentralWidget(w)
        mainlay = QtWidgets.QHBoxLayout(w)
 
        ctrl = QtWidgets.QWidget()
        ctrl_layout = QtWidgets.QFormLayout(ctrl)
        mainlay.addWidget(ctrl, 0)
 
        self.load_btn = QtWidgets.QPushButton('Load .obj')
        self.load_btn.clicked.connect(self.on_load)
        ctrl_layout.addRow(self.load_btn)
 
        # Rotation controls (slider + spinbox for precise entry)
        self.rx = self._make_angle_control(ctrl_layout, 'Rotate X')
        self.ry = self._make_angle_control(ctrl_layout, 'Rotate Y')
        self.rz = self._make_angle_control(ctrl_layout, 'Rotate Z')
 
        self.scale_spin = QtWidgets.QDoubleSpinBox(); self.scale_spin.setRange(0.001, 1000.0); self.scale_spin.setValue(1.0)
        self.scale_spin.valueChanged.connect(self.on_params_changed)
        ctrl_layout.addRow('Scale', self.scale_spin)
 
        self.tx_spin = QtWidgets.QDoubleSpinBox(); self.tx_spin.setRange(-1000, 1000); self.tx_spin.setValue(0.0)
        self.tx_spin.valueChanged.connect(self.on_params_changed)
        ctrl_layout.addRow('Trans X', self.tx_spin)
 
        self.ty_spin = QtWidgets.QDoubleSpinBox(); self.ty_spin.setRange(-1000, 1000); self.ty_spin.setValue(0.0)
        self.ty_spin.valueChanged.connect(self.on_params_changed)
        ctrl_layout.addRow('Trans Y', self.ty_spin)
 
        self.proj_combo = QtWidgets.QComboBox(); self.proj_combo.addItems(['Orthographic', 'Perspective'])
        self.proj_combo.currentIndexChanged.connect(self.on_params_changed)
        ctrl_layout.addRow('Projection', self.proj_combo)
 
        # Colors
        self.fill_btn = self._make_color_button('Fill Color', self.fill_color, lambda c: setattr(self, 'fill_color', c))
        self.stroke_btn = self._make_color_button('Outline Color', self.stroke_color, lambda c: setattr(self, 'stroke_color', c))
        ctrl_layout.addRow('Fill', self.fill_btn)
        ctrl_layout.addRow('Outline', self.stroke_btn)
 
        # PNG sizing
        self.size_info = QtWidgets.QLabel('Set either width or height (the other will be 0). Width takes precedence if both > 0.')
        ctrl_layout.addRow(self.size_info)
        self.png_width_spin = QtWidgets.QSpinBox(); self.png_width_spin.setRange(1, 10000); self.png_width_spin.setValue(800)
        self.png_height_spin = QtWidgets.QSpinBox(); self.png_height_spin.setRange(0, 10000); self.png_height_spin.setValue(0)
        self.png_width_spin.valueChanged.connect(self._on_png_width_changed)
        self.png_height_spin.valueChanged.connect(self._on_png_height_changed)
        ctrl_layout.addRow('PNG Width (px)', self.png_width_spin)
        ctrl_layout.addRow('PNG Height (px)', self.png_height_spin)
 
        self.bg_transparent = QtWidgets.QCheckBox('Transparent background')
        self.bg_transparent.setChecked(True)
        ctrl_layout.addRow(self.bg_transparent)
 
        self.stroke_spin = QtWidgets.QDoubleSpinBox(); self.stroke_spin.setRange(0.1, 50.0); self.stroke_spin.setValue(2.0)
        ctrl_layout.addRow('Outline width (px)', self.stroke_spin)
 
        self.buffer_spin = QtWidgets.QDoubleSpinBox(); self.buffer_spin.setRange(0.0, 10.0); self.buffer_spin.setValue(0.01); self.buffer_spin.setSingleStep(0.001); self.buffer_spin.setDecimals(5); self.buffer_spin.setSuffix('%')
        self.buffer_spin.valueChanged.connect(self.on_params_changed)
        ctrl_layout.addRow('Clean outline buffer (%)', self.buffer_spin)
 
        self.margin_spin = QtWidgets.QSpinBox(); self.margin_spin.setRange(0, 200); self.margin_spin.setValue(0); self.margin_spin.setSuffix(' px')
        ctrl_layout.addRow('Image margin (px)', self.margin_spin)
 
        # Buttons
        self.preview_btn = QtWidgets.QPushButton('Render Preview')
        self.preview_btn.clicked.connect(self.render_preview)
        self.export_btn = QtWidgets.QPushButton('Export PNG...')
        self.export_btn.clicked.connect(self.export_png)
        ctrl_layout.addRow(self.preview_btn, self.export_btn)
 
        ctrl_layout.addRow(QtWidgets.QLabel('Tips: rotate Y ~90 for side view'))
 
        # Preview area
        canvas = QtWidgets.QWidget()
        canvas_layout = QtWidgets.QVBoxLayout(canvas)
        mainlay.addWidget(canvas, 1)
 
        self.preview_label = QtWidgets.QLabel(alignment=QtCore.Qt.AlignCenter)
        self.preview_label.setMinimumSize(600, 400)
        self.preview_label.setStyleSheet('background: #ffffff; border: 1px solid #888;')
        canvas_layout.addWidget(self.preview_label)
 
        self.status = QtWidgets.QLabel('No mesh loaded')
        canvas_layout.addWidget(self.status)
 
    # ---- UI helpers ----
    def _make_angle_control(self, layout, label):
        slider = QtWidgets.QSlider(QtCore.Qt.Horizontal); slider.setRange(0, 360)
        spin = QtWidgets.QSpinBox(); spin.setRange(0, 360)
        slider.valueChanged.connect(spin.setValue)
        spin.valueChanged.connect(slider.setValue)
        slider.valueChanged.connect(self.on_params_changed)
        spin.valueChanged.connect(self.on_params_changed)
        hl = QtWidgets.QHBoxLayout()
        hl.addWidget(slider)
        hl.addWidget(spin)
        layout.addRow(label, hl)
        return spin
 
    def _make_color_button(self, text, initial_color, setter):
        btn = QtWidgets.QPushButton(text)
        btn.setCursor(QtCore.Qt.PointingHandCursor)
        btn.setStyleSheet(f'background:rgba({initial_color.red()},{initial_color.green()},{initial_color.blue()},{initial_color.alpha()/255.0}); color: black')
        def pick():
            c = QtWidgets.QColorDialog.getColor(initial_color, self, f'Pick {text}', QtWidgets.QColorDialog.ShowAlphaChannel)
            if c.isValid():
                setter(c)
                btn.setStyleSheet(f'background:rgba({c.red()},{c.green()},{c.blue()},{c.alpha()/255.0}); color: black')
                self.render_preview()
        btn.clicked.connect(pick)
        return btn
 
    def _on_png_width_changed(self, v):
        # enforce width OR height (width takes precedence)
        if v > 0 and self.png_height_spin.value() != 0:
            self.png_height_spin.blockSignals(True)
            self.png_height_spin.setValue(0)
            self.png_height_spin.blockSignals(False)
 
    def _on_png_height_changed(self, v):
        if v > 0 and self.png_width_spin.value() != 0:
            self.png_width_spin.blockSignals(True)
            self.png_width_spin.setValue(0)
            self.png_width_spin.blockSignals(False)
 
    # ---- Event handlers ----
    def on_load(self):
        path, _ = QtWidgets.QFileDialog.getOpenFileName(self, 'Open OBJ', '.', 'OBJ files (*.obj)')
        if not path:
            return
        try:
            verts, faces = load_obj_simple(path)
        except Exception as e:
            QtWidgets.QMessageBox.critical(self, 'Error', f'Failed to load OBJ: {e}')
            return
        self.verts = verts; self.faces = faces
        self.status.setText(f'Loaded: {os.path.basename(path)} - {len(verts)} verts, {len(faces)} faces')
        self.render_preview()
 
    def on_params_changed(self):
        if self.verts is None:
            return
        if len(self.faces) < 5000:
            self.render_preview()
 
    def render_preview(self):
        if self.verts is None:
            return
        rx = self.rx.value(); ry = self.ry.value(); rz = self.rz.value()
        scale = float(self.scale_spin.value()); tx = float(self.tx_spin.value()); ty = float(self.ty_spin.value())
        proj = self.proj_combo.currentText()
 
        silhouette = compute_silhouette(self.verts, self.faces, rx, ry, rz, proj, scale, tx, ty, self.buffer_spin.value())
 
        w = max(600, int(self.preview_label.width()))
        h = max(400, int(self.preview_label.height()))
        pix = QtGui.QPixmap(w, h)
        pix.fill(QtGui.QColor('white'))
        painter = QtGui.QPainter(pix)
        painter.setRenderHint(QtGui.QPainter.Antialiasing)
 
        if silhouette is not None and not silhouette.is_empty:
            minx, miny, maxx, maxy = silhouette.bounds
            margin = 20
            sx = (w - 2 * margin) / (maxx - minx if maxx > minx else 1)
            sy = (h - 2 * margin) / (maxy - miny if maxy > miny else 1)
            s = min(sx, sy)
 
            def to_canvas(x, y):
                cx = (x - minx) * s + margin
                cy = h - ((y - miny) * s + margin)
                return QtCore.QPointF(cx, cy)
 
            pen = QtGui.QPen(self.stroke_color)
            pen.setWidthF(self.stroke_spin.value())
            painter.setPen(pen)
            painter.setBrush(QtGui.QBrush(self.fill_color))
 
            def draw_poly(poly):
                coords = list(poly.exterior.coords)
                path = QtGui.QPainterPath()
                path.moveTo(to_canvas(*coords[0]))
                for x, y in coords[1:]:
                    path.lineTo(to_canvas(x, y))
                path.closeSubpath()
                painter.drawPath(path)
 
            if isinstance(silhouette, Polygon):
                draw_poly(silhouette)
            elif isinstance(silhouette, MultiPolygon):
                for poly in silhouette.geoms:
                    draw_poly(poly)
 
            self.status.setText('Preview rendered (filled outline)')
        else:
            self.status.setText('No silhouette computed')
 
        painter.end()
        self.preview_label.setPixmap(pix)
 
    def export_png(self):
        if self.verts is None:
            return
        fn, _ = QtWidgets.QFileDialog.getSaveFileName(self, 'Save PNG', 'outline.png', 'PNG files (*.png)')
        if not fn:
            return
        rx = self.rx.value(); ry = self.ry.value(); rz = self.rz.value()
        scale = float(self.scale_spin.value()); tx = float(self.tx_spin.value()); ty = float(self.ty_spin.value())
        proj = self.proj_combo.currentText()
 
        silhouette = compute_silhouette(self.verts, self.faces, rx, ry, rz, proj, scale, tx, ty, self.buffer_spin.value())
 
        target_w = self.png_width_spin.value()
        target_h = self.png_height_spin.value()
        # treat zeros as None
        target_w = target_w if target_w > 0 else None
        target_h = target_h if target_h > 0 else None
 
        filename = silhouette_to_png(
            silhouette,
            fn,
            target_w=target_w,
            target_h=target_h,
            stroke_width=float(self.stroke_spin.value()),
            stroke_qcolor=self.stroke_color,
            fill_qcolor=self.fill_color,
            transparent_bg=self.bg_transparent.isChecked(),
            margin=self.margin_spin.value(),
        )
        if filename:
            QtWidgets.QMessageBox.information(self, 'Saved', f'Saved PNG to: {filename}')
 
# ---------- Main ----------
 
def main():
    app = QtWidgets.QApplication(sys.argv)
    w = MainWindow()
    w.show()
    sys.exit(app.exec())
 
if __name__ == '__main__':
    main()

 

Модель для проверки взял отсюда. Остаётся подчистить внутренние линии, при необходимости конвертировать в .svg и... всё. Неужели это ТАК затратно и нет никаких альтернатив полному удалению кукол из игры?

Альтернативный подход: вместо работы с полигонами и вертексами напрямую можно отрендерить модель одним цветом взять разницу между ней и фоном. Полностью решается проблема внутренних линий, но появляется небольшой алиасинг на плавных линиях (думаю, не критично).

 

Интерфейс:

Скрытый текст

image.thumb.png.6e36eb31c465cdafc9cb4fe68569aefd.png

 

Результат:

Скрытый текст

1720821687_outline2.thumb.png.55dcebbb6425e56a63a776ebb395004e.png

 

Исходный код:

Скрытый текст
#!/usr/bin/env python3
"""
OBJ -> PNG silhouette extractor GUI (3D render + edge detection approach)
 
Requirements:
  - Python 3.8+
  - PySide6
  - numpy
  - opencv-python
 
Install:
  pip install PySide6 numpy opencv-python
 
What it does:
  - Load a Wavefront .obj (vertices and faces)
  - Apply rotation (Euler), scale, translation
  - Render 3D model as solid color using Qt's 3D capabilities
  - Extract outline via edge detection on rendered image
  - Export resulting outline as PNG raster (user chooses width OR height, the other auto-adjusts)
  - Preview matches export colors (transparent background supported)
 
"""
 
import sys
import math
import os
 
import numpy as np
import cv2
from PySide6 import QtWidgets, QtGui, QtCore, QtOpenGL, QtOpenGLWidgets
from PySide6.QtOpenGL import QOpenGLShader, QOpenGLShaderProgram
try:
    from OpenGL.GL import *
    from OpenGL.arrays import vbo
    OPENGL_AVAILABLE = True
except ImportError:
    OPENGL_AVAILABLE = False
 
# ---------- Simple OBJ loader ----------
 
def load_obj_simple(path):
    verts = []
    faces = []
    with open(path, 'r', encoding='utf-8', errors='ignore') as f:
        for line in f:
            if not line.strip():
                continue
            parts = line.strip().split()
            if parts[0] == 'v' and len(parts) >= 4:
                x, y, z = map(float, parts[1:4])
                verts.append((x, y, z))
            elif parts[0] == 'f' and len(parts) >= 4:
                idxs = []
                for p in parts[1:]:
                    vi = p.split('/')[0]
                    if vi:
                        idxs.append(int(vi) - 1)
                if len(idxs) == 3:
                    faces.append(tuple(idxs))
                else:
                    for i in range(1, len(idxs) - 1):
                        faces.append((idxs[0], idxs[i], idxs[i + 1]))
    return np.array(verts, dtype=float), np.array(faces, dtype=int)
 
# ---------- 3D Renderer for Silhouette ----------
 
class SilhouetteRenderer(QtOpenGLWidgets.QOpenGLWidget):
    def __init__(self, parent=None):
        super().__init__(parent)
        self.verts = None
        self.faces = None
        self.rotation = [0, 0, 0]
        self.scale = 1.0
        self.translation = [0, 0]
        self.projection_mode = 'Orthographic'
        self.render_width = 800
        self.render_height = 600
        self.model_color = QtGui.QColor(255, 255, 255, 255)  # White model
       
    def set_model(self, verts, faces):
        self.verts = verts
        self.faces = faces
        if self.isValid():
            self.update()
   
    def set_transform(self, rx, ry, rz, scale, tx, ty):
        self.rotation = [rx, ry, rz]
        self.scale = scale
        self.translation = [tx, ty]
        if self.isValid():
            self.update()
   
    def set_projection(self, mode):
        self.projection_mode = mode
        if self.isValid():
            self.update()
   
    def render_silhouette(self, width, height):
        """Render model and return silhouette outline"""
        if not OPENGL_AVAILABLE or self.verts is None:
            return None
           
        # Set render size
        self.render_width = width
        self.render_height = height
        self.resize(width, height)
       
        # Force render
        self.makeCurrent()
        self.paintGL()
       
        # Read pixels
        glPixelStorei(GL_PACK_ALIGNMENT, 1)
        data = glReadPixels(0, 0, width, height, GL_RGB, GL_UNSIGNED_BYTE)
        img = np.frombuffer(data, dtype=np.uint8).reshape((height, width, 3))
        img = np.flip(img, 0)  # OpenGL has origin at bottom-left
       
        # Convert to binary mask (white model on black background)
        gray = cv2.cvtColor(img, cv2.COLOR_RGB2GRAY)
        mask = gray > 128  # Threshold to separate model from background
       
        # Find contours - use RETR_CCOMP to get both outer boundaries and holes
        mask_uint8 = mask.astype(np.uint8) * 255
        contours, hierarchy = cv2.findContours(mask_uint8, cv2.RETR_CCOMP, cv2.CHAIN_APPROX_SIMPLE)
       
        return contours, hierarchy
   
    def initializeGL(self):
        if not OPENGL_AVAILABLE:
            return
        glEnable(GL_DEPTH_TEST)
        glClearColor(0.0, 0.0, 0.0, 1.0)  # Black background
   
    def resizeGL(self, width, height):
        if not OPENGL_AVAILABLE:
            return
        glViewport(0, 0, width, height)
        self.setup_projection()
   
    def setup_projection(self):
        if not OPENGL_AVAILABLE:
            return
        glMatrixMode(GL_PROJECTION)
        glLoadIdentity()
       
        aspect = self.render_width / self.render_height if self.render_height > 0 else 1.0
       
        if self.projection_mode == 'Orthographic':
            size = 2.0
            glOrtho(-size * aspect, size * aspect, -size, size, -10, 10)
        else:  # Perspective
            from math import tan, radians
            fov = 60.0
            near = 0.1
            far = 100.0
            top = near * tan(radians(fov) / 2.0)
            right = top * aspect
            glFrustum(-right, right, -top, top, near, far)
            glTranslatef(0, 0, -3)
   
    def paintGL(self):
        if not OPENGL_AVAILABLE or self.verts is None:
            return
           
        glClear(GL_COLOR_BUFFER_BIT | GL_DEPTH_BUFFER_BIT)
       
        glMatrixMode(GL_MODELVIEW)
        glLoadIdentity()
       
        # Apply transformations
        glTranslatef(self.translation[0], self.translation[1], 0)
        glScalef(self.scale, self.scale, self.scale)
        glRotatef(self.rotation[0], 1, 0, 0)
        glRotatef(self.rotation[1], 0, 1, 0)
        glRotatef(self.rotation[2], 0, 0, 1)
       
        # Set model color
        glColor3f(1.0, 1.0, 1.0)  # White
       
        # Draw faces
        glBegin(GL_TRIANGLES)
        for face in self.faces:
            for vi in face:
                if vi < len(self.verts):
                    v = self.verts[vi]
                    glVertex3f(v[0], v[1], v[2])
        glEnd()
 
# ---------- Fallback CPU renderer ----------
 
def render_silhouette_cpu(verts, faces, rx, ry, rz, scale, tx, ty, proj_mode, width, height):
    """CPU fallback when OpenGL is not available"""
    from math import cos, sin, radians
   
    # Apply transformations
    rx_rad, ry_rad, rz_rad = map(radians, [rx, ry, rz])
   
    # Rotation matrices
    Rx = np.array([[1, 0, 0], [0, cos(rx_rad), -sin(rx_rad)], [0, sin(rx_rad), cos(rx_rad)]])
    Ry = np.array([[cos(ry_rad), 0, sin(ry_rad)], [0, 1, 0], [-sin(ry_rad), 0, cos(ry_rad)]])
    Rz = np.array([[cos(rz_rad), -sin(rz_rad), 0], [sin(rz_rad), cos(rz_rad), 0], [0, 0, 1]])
    R = Rz @ Ry @ Rx
   
    # Transform vertices
    verts_transformed = (R @ verts.T).T * scale
   
    # Project to 2D
    if proj_mode == 'Orthographic':
        pts2d = verts_transformed[:, :2]
    else:  # Perspective
        z = verts_transformed[:, 2] + 3.0
        z = np.where(np.abs(z) < 1e-6, 1e-6, z)
        f = 1.0 / math.tan(math.radians(30))  # 60 degree FOV
        x = verts_transformed[:, 0] * f / z
        y = verts_transformed[:, 1] * f / z
        pts2d = np.column_stack((x, y))
   
    # Apply translation
    pts2d += np.array([tx, ty])
   
    # Find bounding box and scale to fit image
    if len(pts2d) == 0:
        return []
   
    min_pt = np.min(pts2d, axis=0)
    max_pt = np.max(pts2d, axis=0)
    size = max_pt - min_pt
   
    margin = min(width, height) * 0.1
    scale_x = (width - 2 * margin) / size[0] if size[0] > 0 else 1
    scale_y = (height - 2 * margin) / size[1] if size[1] > 0 else 1
    render_scale = min(scale_x, scale_y)
   
    # Convert to image coordinates
    pts_img = (pts2d - min_pt) * render_scale + margin
    pts_img[:, 1] = height - pts_img[:, 1]  # Flip Y
   
    # Render to image
    img = np.zeros((height, width), dtype=np.uint8)
   
    for face in faces:
        if all(vi < len(pts_img) for vi in face):
            triangle = np.array([pts_img[vi] for vi in face], dtype=np.int32)
            cv2.fillPoly(img, [triangle], 255)
   
    # Find contours - use RETR_CCOMP to get both outer boundaries and holes
    contours, hierarchy = cv2.findContours(img, cv2.RETR_CCOMP, cv2.CHAIN_APPROX_SIMPLE)
    return contours, hierarchy
 
# ---------- PNG export ----------
 
def contours_to_png(contours, hierarchy, filename, width, height, *, stroke_width=2.0, stroke_qcolor=QtGui.QColor('black'), fill_qcolor=QtGui.QColor('white'), transparent_bg=True, margin=0):
    """Save contours to PNG"""
    if not contours:
        return None
   
    # Calculate effective margin
    effective_margin = margin + stroke_width
   
    # Create image
    img = QtGui.QImage(width, height, QtGui.QImage.Format_ARGB32_Premultiplied)
    if transparent_bg:
        img.fill(QtGui.qRgba(0, 0, 0, 0))
    else:
        img.fill(QtGui.QColor('white'))
   
    painter = QtGui.QPainter(img)
    painter.setRenderHint(QtGui.QPainter.Antialiasing)
   
    # Set up pen and brush
    pen = QtGui.QPen(stroke_qcolor)
    pen.setWidthF(stroke_width)
    painter.setPen(pen)
    painter.setBrush(QtGui.QBrush(fill_qcolor))
   
    # Calculate scaling to fit within margins
    if len(contours) > 0:
        all_points = np.vstack([contour.reshape(-1, 2) for contour in contours])
        min_pt = np.min(all_points, axis=0)
        max_pt = np.max(all_points, axis=0)
        size = max_pt - min_pt
       
        available_w = width - 2 * effective_margin
        available_h = height - 2 * effective_margin
       
        if size[0] > 0 and size[1] > 0:
            scale_x = available_w / size[0]
            scale_y = available_h / size[1]
            scale = min(scale_x, scale_y)
           
            # Center the shape
            scaled_size = size * scale
            offset_x = effective_margin + (available_w - scaled_size[0]) / 2
            offset_y = effective_margin + (available_h - scaled_size[1]) / 2
           
            # Draw contours with hierarchy (outer contours and holes)
            if hierarchy is not None:
                for i, contour in enumerate(contours):
                    # hierarchy[0][i] = [next, previous, first_child, parent]
                    # If parent == -1, it's an outer contour
                    # If parent != -1, it's a hole
                    parent = hierarchy[0][i][3]
                   
                    points = []
                    for pt in contour.reshape(-1, 2):
                        x = (pt[0] - min_pt[0]) * scale + offset_x
                        y = (pt[1] - min_pt[1]) * scale + offset_y
                        points.append(QtCore.QPointF(x, y))
                   
                    if len(points) >= 3:
                        path = QtGui.QPainterPath()
                        path.moveTo(points[0])
                        for pt in points[1:]:
                            path.lineTo(pt)
                        path.closeSubpath()
                       
                        if parent == -1:
                            # Outer contour - fill and stroke
                            painter.drawPath(path)
                        else:
                            # Hole - subtract from fill (create cutout)
                            painter.setCompositionMode(QtGui.QPainter.CompositionMode_Clear)
                            painter.fillPath(path, QtGui.QBrush(QtCore.Qt.transparent))
                            painter.setCompositionMode(QtGui.QPainter.CompositionMode_SourceOver)
                            # Still draw the stroke for hole outline
                            painter.setBrush(QtCore.Qt.NoBrush)
                            painter.drawPath(path)
                            painter.setBrush(QtGui.QBrush(fill_qcolor))
            else:
                # Fallback - treat all as outer contours
                for contour in contours:
                    points = []
                    for pt in contour.reshape(-1, 2):
                        x = (pt[0] - min_pt[0]) * scale + offset_x
                        y = (pt[1] - min_pt[1]) * scale + offset_y
                        points.append(QtCore.QPointF(x, y))
                   
                    if len(points) >= 3:
                        path = QtGui.QPainterPath()
                        path.moveTo(points[0])
                        for pt in points[1:]:
                            path.lineTo(pt)
                        path.closeSubpath()
                        painter.drawPath(path)
   
    painter.end()
    img.save(filename)
    return filename
 
# ---------- Qt GUI ----------
 
class MainWindow(QtWidgets.QMainWindow):
    def __init__(self):
        super().__init__()
        self.setWindowTitle('OBJ -> PNG silhouette (3D render + edge detection)')
        self.resize(1100, 740)
 
        self.verts = None
        self.faces = None
        self.fill_color = QtGui.QColor(204, 204, 204, 255)  # #cccccc with full alpha
        self.stroke_color = QtGui.QColor(0, 0, 0, 255)      # black with full alpha
 
        w = QtWidgets.QWidget()
        self.setCentralWidget(w)
        mainlay = QtWidgets.QHBoxLayout(w)
 
        ctrl = QtWidgets.QWidget()
        ctrl_layout = QtWidgets.QFormLayout(ctrl)
        mainlay.addWidget(ctrl, 0)
 
        self.load_btn = QtWidgets.QPushButton('Load .obj')
        self.load_btn.clicked.connect(self.on_load)
        ctrl_layout.addRow(self.load_btn)
 
        # Rotation controls (slider + spinbox for precise entry)
        self.rx = self._make_angle_control(ctrl_layout, 'Rotate X')
        self.ry = self._make_angle_control(ctrl_layout, 'Rotate Y')
        self.rz = self._make_angle_control(ctrl_layout, 'Rotate Z')
 
        self.scale_spin = QtWidgets.QDoubleSpinBox(); self.scale_spin.setRange(0.001, 1000.0); self.scale_spin.setValue(1.0)
        self.scale_spin.valueChanged.connect(self.on_params_changed)
        ctrl_layout.addRow('Scale', self.scale_spin)
 
        self.tx_spin = QtWidgets.QDoubleSpinBox(); self.tx_spin.setRange(-1000, 1000); self.tx_spin.setValue(0.0)
        self.tx_spin.valueChanged.connect(self.on_params_changed)
        ctrl_layout.addRow('Trans X', self.tx_spin)
 
        self.ty_spin = QtWidgets.QDoubleSpinBox(); self.ty_spin.setRange(-1000, 1000); self.ty_spin.setValue(0.0)
        self.ty_spin.valueChanged.connect(self.on_params_changed)
        ctrl_layout.addRow('Trans Y', self.ty_spin)
 
        self.proj_combo = QtWidgets.QComboBox(); self.proj_combo.addItems(['Orthographic', 'Perspective'])
        self.proj_combo.currentIndexChanged.connect(self.on_params_changed)
        ctrl_layout.addRow('Projection', self.proj_combo)
 
        # Colors
        self.fill_btn = self._make_color_button('Fill Color', self.fill_color, lambda c: setattr(self, 'fill_color', c))
        self.stroke_btn = self._make_color_button('Outline Color', self.stroke_color, lambda c: setattr(self, 'stroke_color', c))
        ctrl_layout.addRow('Fill', self.fill_btn)
        ctrl_layout.addRow('Outline', self.stroke_btn)
 
        # PNG sizing
        self.size_info = QtWidgets.QLabel('Set either width or height (the other will be 0). Width takes precedence if both > 0.')
        ctrl_layout.addRow(self.size_info)
        self.png_width_spin = QtWidgets.QSpinBox(); self.png_width_spin.setRange(1, 10000); self.png_width_spin.setValue(800)
        self.png_height_spin = QtWidgets.QSpinBox(); self.png_height_spin.setRange(0, 10000); self.png_height_spin.setValue(0)
        self.png_width_spin.valueChanged.connect(self._on_png_width_changed)
        self.png_height_spin.valueChanged.connect(self._on_png_height_changed)
        ctrl_layout.addRow('PNG Width (px)', self.png_width_spin)
        ctrl_layout.addRow('PNG Height (px)', self.png_height_spin)
 
        self.bg_transparent = QtWidgets.QCheckBox('Transparent background')
        self.bg_transparent.setChecked(True)
        ctrl_layout.addRow(self.bg_transparent)
 
        self.stroke_spin = QtWidgets.QDoubleSpinBox(); self.stroke_spin.setRange(0.1, 50.0); self.stroke_spin.setValue(2.0)
        ctrl_layout.addRow('Outline width (px)', self.stroke_spin)
 
        self.margin_spin = QtWidgets.QSpinBox(); self.margin_spin.setRange(0, 200); self.margin_spin.setValue(0); self.margin_spin.setSuffix(' px')
        ctrl_layout.addRow('Image margin (px)', self.margin_spin)
 
        # Buttons
        self.preview_btn = QtWidgets.QPushButton('Render Preview')
        self.preview_btn.clicked.connect(self.render_preview)
        self.export_btn = QtWidgets.QPushButton('Export PNG...')
        self.export_btn.clicked.connect(self.export_png)
        ctrl_layout.addRow(self.preview_btn, self.export_btn)
 
        ctrl_layout.addRow(QtWidgets.QLabel('Tips: rotate Y ~90 for side view'))
 
        # Preview area
        canvas = QtWidgets.QWidget()
        canvas_layout = QtWidgets.QVBoxLayout(canvas)
        mainlay.addWidget(canvas, 1)
 
        self.preview_label = QtWidgets.QLabel(alignment=QtCore.Qt.AlignCenter)
        self.preview_label.setMinimumSize(600, 400)
        self.preview_label.setStyleSheet('background: #ffffff; border: 1px solid #888;')
        canvas_layout.addWidget(self.preview_label)
 
        self.status = QtWidgets.QLabel('No mesh loaded')
        canvas_layout.addWidget(self.status)
 
        # 3D renderer (hidden)
        if OPENGL_AVAILABLE:
            self.renderer = SilhouetteRenderer()
            self.renderer.hide()
        else:
            self.renderer = None
            self.status.setText('OpenGL not available - using CPU fallback')
 
    # ---- UI helpers ----
    def _make_angle_control(self, layout, label):
        slider = QtWidgets.QSlider(QtCore.Qt.Horizontal); slider.setRange(0, 360)
        spin = QtWidgets.QSpinBox(); spin.setRange(0, 360)
        slider.valueChanged.connect(spin.setValue)
        spin.valueChanged.connect(slider.setValue)
        slider.valueChanged.connect(self.on_params_changed)
        spin.valueChanged.connect(self.on_params_changed)
        hl = QtWidgets.QHBoxLayout()
        hl.addWidget(slider)
        hl.addWidget(spin)
        layout.addRow(label, hl)
        return spin
 
    def _make_color_button(self, text, initial_color, setter):
        btn = QtWidgets.QPushButton(text)
        btn.setCursor(QtCore.Qt.PointingHandCursor)
        btn.setStyleSheet(f'background:rgba({initial_color.red()},{initial_color.green()},{initial_color.blue()},{initial_color.alpha()/255.0}); color: black')
        def pick():
            c = QtWidgets.QColorDialog.getColor(initial_color, self, f'Pick {text}', QtWidgets.QColorDialog.ShowAlphaChannel)
            if c.isValid():
                setter(c)
                btn.setStyleSheet(f'background:rgba({c.red()},{c.green()},{c.blue()},{c.alpha()/255.0}); color: black')
                self.render_preview()
        btn.clicked.connect(pick)
        return btn
 
    def _on_png_width_changed(self, v):
        # enforce width OR height (width takes precedence)
        if v > 0 and self.png_height_spin.value() != 0:
            self.png_height_spin.blockSignals(True)
            self.png_height_spin.setValue(0)
            self.png_height_spin.blockSignals(False)
 
    def _on_png_height_changed(self, v):
        if v > 0 and self.png_width_spin.value() != 0:
            self.png_width_spin.blockSignals(True)
            self.png_width_spin.setValue(0)
            self.png_width_spin.blockSignals(False)
 
    # ---- Event handlers ----
    def on_load(self):
        path, _ = QtWidgets.QFileDialog.getOpenFileName(self, 'Open OBJ', '.', 'OBJ files (*.obj)')
        if not path:
            return
        try:
            verts, faces = load_obj_simple(path)
        except Exception as e:
            QtWidgets.QMessageBox.critical(self, 'Error', f'Failed to load OBJ: {e}')
            return
        self.verts = verts; self.faces = faces
        if self.renderer:
            self.renderer.set_model(verts, faces)
        self.status.setText(f'Loaded: {os.path.basename(path)} - {len(verts)} verts, {len(faces)} faces')
        self.render_preview()
 
    def on_params_changed(self):
        if self.verts is None:
            return
        if self.renderer:
            self.renderer.set_transform(
                self.rx.value(), self.ry.value(), self.rz.value(),
                float(self.scale_spin.value()),
                float(self.tx_spin.value()), float(self.ty_spin.value())
            )
            self.renderer.set_projection(self.proj_combo.currentText())
        if len(self.faces) < 5000:
            self.render_preview()
 
    def render_preview(self):
        if self.verts is None:
            return
 
        # Get current parameters
        rx, ry, rz = self.rx.value(), self.ry.value(), self.rz.value()
        scale = float(self.scale_spin.value())
        tx, ty = float(self.tx_spin.value()), float(self.ty_spin.value())
        proj = self.proj_combo.currentText()
 
        # Render silhouette
        preview_w = max(600, int(self.preview_label.width()))
        preview_h = max(400, int(self.preview_label.height()))
 
        if self.renderer:
            result = self.renderer.render_silhouette(preview_w, preview_h)
            contours = result[0] if result else []
            hierarchy = result[1] if result and len(result) > 1 else None
        else:
            result = render_silhouette_cpu(self.verts, self.faces, rx, ry, rz, scale, tx, ty, proj, preview_w, preview_h)
            contours = result[0] if result else []
            hierarchy = result[1] if result and len(result) > 1 else None
 
        # Create preview image
        pix = QtGui.QPixmap(preview_w, preview_h)
        pix.fill(QtGui.QColor('white'))
        painter = QtGui.QPainter(pix)
        painter.setRenderHint(QtGui.QPainter.Antialiasing)
 
        if contours:
            pen = QtGui.QPen(self.stroke_color)
            pen.setWidthF(self.stroke_spin.value())
            painter.setPen(pen)
            painter.setBrush(QtGui.QBrush(self.fill_color))
 
            # Draw contours with hierarchy (outer contours and holes)
            if hierarchy is not None:
                for i, contour in enumerate(contours):
                    parent = hierarchy[0][i][3]
                   
                    points = []
                    for pt in contour.reshape(-1, 2):
                        points.append(QtCore.QPointF(float(pt[0]), float(pt[1])))
 
                    if len(points) >= 3:
                        path = QtGui.QPainterPath()
                        path.moveTo(points[0])
                        for pt in points[1:]:
                            path.lineTo(pt)
                        path.closeSubpath()
                       
                        if parent == -1:
                            # Outer contour
                            painter.drawPath(path)
                        else:
                            # Hole - clear the area
                            painter.setCompositionMode(QtGui.QPainter.CompositionMode_Clear)
                            painter.fillPath(path, QtGui.QBrush(QtCore.Qt.transparent))
                            painter.setCompositionMode(QtGui.QPainter.CompositionMode_SourceOver)
                            # Draw stroke around hole
                            painter.setBrush(QtCore.Qt.NoBrush)
                            painter.drawPath(path)
                            painter.setBrush(QtGui.QBrush(self.fill_color))
            else:
                # Fallback
                for contour in contours:
                    points = []
                    for pt in contour.reshape(-1, 2):
                        points.append(QtCore.QPointF(float(pt[0]), float(pt[1])))
 
                    if len(points) >= 3:
                        path = QtGui.QPainterPath()
                        path.moveTo(points[0])
                        for pt in points[1:]:
                            path.lineTo(pt)
                        path.closeSubpath()
                        painter.drawPath(path)
 
            self.status.setText('Preview rendered (clean 3D outline)')
        else:
            self.status.setText('No outline detected')
 
        painter.end()
        self.preview_label.setPixmap(pix)
 
    def export_png(self):
        if self.verts is None:
            return
        fn, _ = QtWidgets.QFileDialog.getSaveFileName(self, 'Save PNG', 'outline.png', 'PNG files (*.png)')
        if not fn:
            return
 
        # Get current parameters
        rx, ry, rz = self.rx.value(), self.ry.value(), self.rz.value()
        scale = float(self.scale_spin.value())
        tx, ty = float(self.tx_spin.value()), float(self.ty_spin.value())
        proj = self.proj_combo.currentText()
 
        # Determine output size
        target_w = self.png_width_spin.value()
        target_h = self.png_height_spin.value()
        target_w = target_w if target_w > 0 else None
        target_h = target_h if target_h > 0 else None
 
        if target_w:
            width = target_w
            height = int(target_w * 3/4)  # Default aspect ratio
        elif target_h:
            height = target_h
            width = int(target_h * 4/3)  # Default aspect ratio
        else:
            width, height = 800, 600
 
        # Render at high resolution
        if self.renderer:
            result = self.renderer.render_silhouette(width*2, height*2)  # 2x for quality
            contours = result[0] if result else []
            hierarchy = result[1] if result and len(result) > 1 else None
        else:
            result = render_silhouette_cpu(self.verts, self.faces, rx, ry, rz, scale, tx, ty, proj, width*2, height*2)
            contours = result[0] if result else []
            hierarchy = result[1] if result and len(result) > 1 else None
 
        if contours:
            # Scale contours back down
            scaled_contours = []
            for contour in contours:
                scaled_contours.append(contour // 2)
            contours = scaled_contours
 
            filename = contours_to_png(
                contours,
                hierarchy,
                fn,
                width, height,
                stroke_width=float(self.stroke_spin.value()),
                stroke_qcolor=self.stroke_color,
                fill_qcolor=self.fill_color,
                transparent_bg=self.bg_transparent.isChecked(),
                margin=self.margin_spin.value(),
            )
            if filename:
                QtWidgets.QMessageBox.information(self, 'Saved', f'Saved PNG to: {filename}')
        else:
            QtWidgets.QMessageBox.warning(self, 'Error', 'No outline detected - cannot export')
 
# ---------- Main ----------
 
def main():
    app = QtWidgets.QApplication(sys.argv)
   
    if not OPENGL_AVAILABLE:
        QtWidgets.QMessageBox.warning(None, 'Warning', 'OpenGL/PyOpenGL not available. Using CPU fallback (slower but functional).')
   
    w = MainWindow()
    w.show()
    sys.exit(app.exec())
 
if __name__ == '__main__':
    main()

 

Изменено пользователем KOMMyHuCT_USSR
  • Плюс 17
  • Круто 7

Рассказать о публикации


Ссылка на публикацию
161
[NPO]
Участник
304 публикации
В 24.08.2025 в 10:28:26 пользователь Ed_nur сказал:

под вызовом дня есть плашка с наградами

Судя по всему у меня бракованная копия-не отыскал.Ну и латно.

  • Плюс 2
  • Ха-Ха 1

Рассказать о публикации


Ссылка на публикацию
13 225
[FLD]
Старший бета-тестер, Коллекционер, Мододел
14 885 публикаций
20 251 бой
Сегодня в 12:16:27 пользователь Cemeet1 сказал:

Судя по всему у меня бракованная копия-не отыскал.Ну и латно.

Ниже прокрути, скорей всего на экран не влазит 

Скрытый текст

image.png.f26f121732276a806d7230e61228afb4.png

 

  • Плюс 4

Рассказать о публикации


Ссылка на публикацию
1 537
[YTEC]
Участник
419 публикаций
17 231 бой
В 24.08.2025 в 21:57:41 пользователь KOMMyHuCT_USSR сказал:

Альтернативный подход: вместо работы с полигонами и вертексами напрямую

Спасибо за пример. Я уже выше писал, что, имея 3D модель, сделать генератор силуэтов, который будет идеально создавать куклы-кораблей с любыми требуемыми упрощениями, хоть в растре хоть в векторе, это отнюдь "не бином Ньютона".  В чем проблема, и зачем тут нужны живые 2D художники , я абсолютно не понимаю.   

  • Плюс 21

Рассказать о публикации


Ссылка на публикацию
7 173
[-ZOO-]
Старший бета-тестер, Коллекционер
11 871 публикация
30 703 боя
Сегодня в 04:57:41 пользователь KOMMyHuCT_USSR сказал:

льтернативный подход: вместо работы с полигонами и вертексами напрямую можно отрендерить модель одним цветом в

Нанять сего товарисча в разработчики - он могет более чем одну полоску! :cap_rambo:

  • Плюс 13
  • Ха-Ха 5

Рассказать о публикации


Ссылка на публикацию
4 055
[URLM]
Участник
3 515 публикаций
30 597 боёв
В 18.08.2025 в 17:07:27 пользователь _EzyRider_ сказал:

и привычно

ключевое слово - привычно...

  • Плюс 3
  • Плохо 2

Рассказать о публикации


Ссылка на публикацию
536
[FEMOR]
Участник
136 публикаций
Сегодня в 05:23:49 пользователь ORClNUS_ORCA сказал:

ключевое слово - привычно...

Возможно у многих игроков необходимость переучиваться под новое вызывает неудобства, но лично я критикую нововведения в интерфейсе не поэтому. Даже если новое работает не так как старое, нет никакой проблемы адаптироваться к новому, если конечно оно, новое, работает хорошо. В конце концов в какой-то момент игрокам пришлось вникать в то как старый интерфейс работает, когда они только пришли в проект. 

 

Но проблема заключается в том, что стилистически и функционально новый интерфейс очень спорно сделан. Иконки снаряжения-расходников например не просто другие, новые. Они плохо читаемые, выполненные монотонно. То что на них изображено не всегда ассоциируется с тем что эти расходники делают. Кроме того эстетически они выглядят  несоответствующими атмосфере игры.

 

Цветовая схема в интерфейсе стала более не контрастно яркой, из-за этого опять же падает читаемость интерфейса. Кроме того преобладание насыщенного синего цвета хотя и красиво, может сказаться на усталости зрения, поскольку этот цвет наименее комфортный с точки зрения офтальмологии. У людей с проблемным зрением это скажется определенно.

 

Вкладка активности получилась перегруженной информацией из-за обилия баннеров, выглядит как большой суетливый "базар". Кроме того, ради того чтобы вместить эти баннеры, разработчикам пришлось ограничить углы камеры на вкладке, лишив игроков возможности вертеть кораблик. Мелочь а неприятно! Лично я бы предпочел чтобы все эти баннеры были на отдельной странице (в отдельном окне), которое открывалось бы например по нажатию кнопки. Компактно и без излишеств. 

 

Ну... Про убранную куклу корабля в пользу примитивной полоски хп тут говорилось уже очень много.

 

  • Плюс 21
  • Круто 2

Рассказать о публикации


Ссылка на публикацию
4 055
[URLM]
Участник
3 515 публикаций
30 597 боёв
Сегодня в 14:50:30 пользователь Troxis911 сказал:

Возможно у многих игроков необходимость переучиваться под новое вызывает неудобства, но лично я критикую нововведения в интерфейсе не поэтому. Даже если новое работает не так как старое, нет никакой проблемы адаптироваться к новому, если конечно оно, новое, работает хорошо. В конце концов в какой-то момент игрокам пришлось вникать в то как старый интерфейс работает, когда они только пришли в проект. 

 

Но проблема заключается в том, что стилистически и функционально новый интерфейс очень спорно сделан. Иконки снаряжения-расходников например не просто другие, новые. Они плохо читаемые, выполненные монотонно. То что на них изображено не всегда ассоциируется с тем что эти расходники делают. Кроме того эстетически они выглядят  несоответствующими атмосфере игры.

 

Цветовая схема в интерфейсе стала более не контрастно яркой, из-за этого опять же падает читаемость интерфейса. Кроме того преобладание насыщенного синего цвета хотя и красиво, может сказаться на усталости зрения, поскольку этот цвет наименее комфортный с точки зрения офтальмологии. У людей с проблемным зрением это скажется определенно.

 

Вкладка активности получилась перегруженной информацией из-за обилия баннеров, выглядит как большой суетливый "базар". Кроме того, ради того чтобы вместить эти баннеры, разработчикам пришлось ограничить углы камеры на вкладке, лишив игроков возможности вертеть кораблик. Мелочь а неприятно! Лично я бы предпочел чтобы все эти баннеры были на отдельной странице (в отдельном окне), которое открывалось бы например по нажатию кнопки. Компактно и без излишеств. 

 

Ну... Про убранную куклу корабля в пользу примитивной полоски хп тут говорилось уже очень много.

 

Поверьте мне через месяц все станет привычным и удобным...все это было уже много раз....

  • Плохо 1

Рассказать о публикации


Ссылка на публикацию
Участник
592 публикации

не понравился новый интерфейс от слова вообще...все как будто из мобильной игры или мультика..иконки отцтой-линия хп корабля вообще фигня-кукла корабля уникальна и смотрится отлично...нынешний интерфейс считаю на данный момент лучшим

Изменено пользователем Warager
  • Плюс 17

Рассказать о публикации


Ссылка на публикацию

×