"""Carga de modelos: lendo uma malha indexada de um arquivo Wavefront OBJ. Aula 07 — Leitura de Objetos e Superfícies Ocultas. Exemplo em Python com PySide6 e PyOpenGL (pipeline fixed-function). Execute com: uv run aula-07-modelagem-geometrica/carga_modelos.py uv run aula-07-modelagem-geometrica/carga_modelos.py meu_modelo.obj Nos exemplos anteriores a malha foi digitada no próprio programa: cinco vértices e seis faces cabem em duas listas. O toro deste exemplo tem 512 vértices e 1024 faces — digitá-lo à mão é inviável, e é por isso que modelos vêm de arquivo. O formato Wavefront OBJ é texto puro e traz exatamente a estrutura da aula: v x y z um vértice (as coordenadas: a GEOMETRIA) vn x y z uma normal f i/it/in ... uma face, por ÍNDICES em v/vt/vn (a TOPOLOGIA) Os índices do OBJ começam em 1, e não em 0 — a conversão é o erro mais frequente de quem escreve o primeiro carregador. Índices negativos contam de trás para frente a partir do último elemento lido. Teclas: W alterna wireframe e preenchimento C liga/desliga o descarte de faces traseiras L iluminação opcional (ponte para a Aula 08) S alterna GL_FLAT e GL_SMOOTH N alterna as normais do ARQUIVO e as CALCULADAS (em smooth) setas giram o modelo 0 volta à orientação inicial Esc fecha """ from __future__ import annotations import math import sys from pathlib import Path from PySide6.QtCore import Qt from PySide6.QtGui import QSurfaceFormat from PySide6.QtOpenGLWidgets import QOpenGLWidget from PySide6.QtWidgets import QApplication from OpenGL.GL import * from OpenGL.GLU import gluLookAt, gluPerspective # --- leitura do arquivo ------------------------------------------------- def _normalizar(vetor): tamanho = math.sqrt(sum(c * c for c in vetor)) if tamanho < 1e-12: # face degenerada: sem direção definida return (0.0, 0.0, 0.0) return tuple(c / tamanho for c in vetor) def _indice(campo: str, total: int) -> int: """Converte um índice do OBJ (base 1, podendo ser negativo) para base 0.""" valor = int(campo) if valor == 0: raise ValueError("o OBJ não permite índice 0") indice = valor - 1 if valor > 0 else total + valor if not 0 <= indice < total: raise ValueError(f"índice {valor} fora do intervalo (há {total} elementos)") return indice def carregar_obj(caminho: Path): """Lê um OBJ e devolve (vertices, faces, normais_do_arquivo). Cada face é uma tupla de três índices em ``vertices``. Faces com mais de três vértices são trianguladas em leque: um polígono de n lados vira n-2 triângulos que compartilham o primeiro vértice. O leque só é correto para polígonos convexos — que é o caso usual em arquivos OBJ. ``normais_do_arquivo`` contém uma tripla de normais para cada triângulo. Ela fica vazia quando algum canto de face não referencia ``vn``. OBJ associa a normal ao *canto da face*, não necessariamente ao vértice. Preservar os dois índices permite representar quinas: a mesma posição pode usar uma normal diferente em cada face vizinha. """ vertices: list[tuple[float, float, float]] = [] normais_lidas: list[tuple[float, float, float]] = [] faces: list[tuple[int, int, int]] = [] normais_faces: list[tuple[ tuple[float, float, float], tuple[float, float, float], tuple[float, float, float], ]] = [] todas_as_faces_tem_normais = True for numero, linha in enumerate(caminho.read_text(encoding="utf-8").splitlines(), 1): partes = linha.split("#", 1)[0].split() if not partes or partes[0].startswith("#"): continue tipo, dados = partes[0], partes[1:] if tipo in ("v", "vn") and len(dados) < 3: raise ValueError(f"{caminho.name}:{numero}: {tipo} exige três coordenadas") if tipo == "v": vertices.append(tuple(float(c) for c in dados[:3])) elif tipo == "vn": normais_lidas.append(tuple(float(c) for c in dados[:3])) elif tipo == "f": if len(dados) < 3: raise ValueError(f"{caminho.name}:{numero}: face com menos de 3 vértices") cantos: list[tuple[int, int | None]] = [] for campo in dados: # Formas aceitas: "v", "v/vt", "v//vn" e "v/vt/vn". pedacos = campo.split("/") iv = _indice(pedacos[0], len(vertices)) inormal = None if len(pedacos) == 3 and pedacos[2]: inormal = _indice(pedacos[2], len(normais_lidas)) else: todas_as_faces_tem_normais = False cantos.append((iv, inormal)) for k in range(1, len(cantos) - 1): # triangulação em leque triangulo = (cantos[0], cantos[k], cantos[k + 1]) faces.append(tuple(canto[0] for canto in triangulo)) if all(canto[1] is not None for canto in triangulo): normais_faces.append(tuple( normais_lidas[canto[1]] for canto in triangulo )) # Demais registros (vt, o, g, s, usemtl, mtllib) são ignorados: este # carregador cobre só o que a aula usa — geometria e normais. if not vertices or not faces: raise ValueError(f"{caminho.name}: nenhum vértice ou nenhuma face encontrada") normais_arquivo = normais_faces if todas_as_faces_tem_normais else [] return vertices, faces, normais_arquivo def normais_da_malha(vertices, faces): """Deriva as normais da própria geometria, como no exemplo anterior. Devolve (por_face, por_vertice). A normal de vértice é a média normalizada das normais das faces que o referenciam — só possível porque a malha é INDEXADA: faces vizinhas apontam para o mesmo índice. """ por_face = [] for a_i, b_i, c_i in faces: a, b, c = vertices[a_i], vertices[b_i], vertices[c_i] u = tuple(b[i] - a[i] for i in range(3)) v = tuple(c[i] - a[i] for i in range(3)) por_face.append(_normalizar(( u[1] * v[2] - u[2] * v[1], u[2] * v[0] - u[0] * v[2], u[0] * v[1] - u[1] * v[0], ))) somas = [[0.0, 0.0, 0.0] for _ in vertices] for face, normal in zip(faces, por_face): for indice in face: for e in range(3): somas[indice][e] += normal[e] return por_face, [_normalizar(s) for s in somas] def centralizar_e_normalizar(vertices): """Move o modelo para a origem e o escala para caber na cena. Um arquivo qualquer pode vir em qualquer unidade e em qualquer posição. Sem esta etapa, unidades incompatíveis podem deixar o modelo grande ou pequeno demais; um modelo distante da origem pode ficar fora do volume de visualização — o programa parece não desenhar nada. """ minimos = [min(v[e] for v in vertices) for e in range(3)] maximos = [max(v[e] for v in vertices) for e in range(3)] centro = [(minimos[e] + maximos[e]) / 2 for e in range(3)] maior = max(maximos[e] - minimos[e] for e in range(3)) or 1.0 escala = 2.4 / maior return [tuple((v[e] - centro[e]) * escala for e in range(3)) for v in vertices] # --- exibição ----------------------------------------------------------- class VisualizadorOBJ(QOpenGLWidget): def __init__(self, caminho: Path, parent=None): super().__init__(parent) self.caminho = caminho vertices, self.faces, normais_arquivo = carregar_obj(caminho) self.vertices = centralizar_e_normalizar(vertices) self.normais_arquivo = normais_arquivo self.normais_faces, self.normais_vertices = normais_da_malha(self.vertices, self.faces) self.wireframe = False self.culling = False self.iluminacao = False self.smooth = False self.usar_arquivo = bool(normais_arquivo) self.rot_x, self.rot_y = -20.0, 30.0 self.setFocusPolicy(Qt.FocusPolicy.StrongFocus) print(f"{caminho.name}: {len(self.vertices)} vértices, {len(self.faces)} faces " f"({len(self.faces) * 3} índices, contra {len(self.faces) * 9} coordenadas " f"se cada face guardasse as suas)") print("normais no arquivo:", "sim" if normais_arquivo else "não — calculadas da malha") def initializeGL(self) -> None: glClearColor(0.08, 0.09, 0.12, 1.0) glEnable(GL_DEPTH_TEST) glEnable(GL_LIGHTING) glEnable(GL_LIGHT0) glEnable(GL_NORMALIZE) glLightfv(GL_LIGHT0, GL_AMBIENT, (0.14, 0.14, 0.18, 1.0)) glLightfv(GL_LIGHT0, GL_DIFFUSE, (0.95, 0.93, 0.88, 1.0)) glMaterialfv(GL_FRONT, GL_AMBIENT_AND_DIFFUSE, (0.30, 0.62, 0.88, 1.0)) glCullFace(GL_BACK) glFrontFace(GL_CCW) def resizeGL(self, w: int, h: int) -> None: dpr = self.devicePixelRatioF() w, h = max(1, round(w * dpr)), max(1, round(h * dpr)) glViewport(0, 0, w, h) glMatrixMode(GL_PROJECTION) glLoadIdentity() gluPerspective(45.0, w / float(h), 0.1, 100.0) glMatrixMode(GL_MODELVIEW) def paintGL(self) -> None: (glEnable if self.iluminacao else glDisable)(GL_LIGHTING) (glEnable if self.culling else glDisable)(GL_CULL_FACE) glPolygonMode(GL_FRONT_AND_BACK, GL_LINE if self.wireframe else GL_FILL) glShadeModel(GL_SMOOTH if self.smooth else GL_FLAT) glClear(GL_COLOR_BUFFER_BIT | GL_DEPTH_BUFFER_BIT) glLoadIdentity() gluLookAt(0.0, 1.6, 5.0, 0.0, 0.0, 0.0, 0.0, 1.0, 0.0) # Depois de gluLookAt e antes das rotações: a luz fica parada no # mundo e é o modelo que gira diante dela. glLightfv(GL_LIGHT0, GL_POSITION, (3.0, 4.0, 5.0, 1.0)) glRotatef(self.rot_x, 1.0, 0.0, 0.0) glRotatef(self.rot_y, 0.0, 1.0, 0.0) glBegin(GL_TRIANGLES) for numero, (face, normal_face) in enumerate(zip(self.faces, self.normais_faces)): # Cores por face revelam a triangulação sem depender da luz. cores = ((0.30, 0.62, 0.88), (0.22, 0.48, 0.70)) glColor3f(*cores[numero % len(cores)]) if not self.smooth: glNormal3f(*normal_face) # uma normal para o triângulo todo for canto, indice in enumerate(face): if self.smooth: normal = ( self.normais_arquivo[numero][canto] if self.usar_arquivo else self.normais_vertices[indice] ) glNormal3f(*normal) glVertex3f(*self.vertices[indice]) glEnd() origem = "arquivo" if self.usar_arquivo else "calculadas" self.setWindowTitle( f"{self.caminho.name} — {len(self.faces)} faces | " f"W {'wireframe' if self.wireframe else 'preenchido'} | " f"C culling={'on' if self.culling else 'off'} | " f"L luz={self.iluminacao} | S {'smooth' if self.smooth else 'flat'} | N normais (smooth): {origem}" ) def keyPressEvent(self, event) -> None: tecla = event.key() if tecla == Qt.Key.Key_Escape: self.close() return if tecla == Qt.Key.Key_W: self.wireframe = not self.wireframe elif tecla == Qt.Key.Key_C: self.culling = not self.culling elif tecla == Qt.Key.Key_L: self.iluminacao = not self.iluminacao elif tecla == Qt.Key.Key_S: self.smooth = not self.smooth elif tecla == Qt.Key.Key_N: if self.normais_arquivo: self.usar_arquivo = not self.usar_arquivo else: print("este modelo não traz vn: só há as normais calculadas") elif tecla == Qt.Key.Key_Left: self.rot_y -= 5.0 elif tecla == Qt.Key.Key_Right: self.rot_y += 5.0 elif tecla == Qt.Key.Key_Up: self.rot_x -= 5.0 elif tecla == Qt.Key.Key_Down: self.rot_x += 5.0 elif tecla == Qt.Key.Key_0: self.rot_x, self.rot_y = -20.0, 30.0 else: return self.update() def main() -> int: padrao = Path(__file__).resolve().parent / "toro.obj" caminho = Path(sys.argv[1]) if len(sys.argv) > 1 else padrao if not caminho.is_file(): print(f"modelo não encontrado: {caminho}", file=sys.stderr) return 1 fmt = QSurfaceFormat() fmt.setVersion(2, 1) fmt.setProfile(QSurfaceFormat.OpenGLContextProfile.NoProfile) fmt.setDepthBufferSize(24) QSurfaceFormat.setDefaultFormat(fmt) app = QApplication(sys.argv) try: janela = VisualizadorOBJ(caminho) except (OSError, UnicodeError, ValueError) as erro: print(f"não foi possível carregar {caminho}: {erro}", file=sys.stderr) return 1 janela.resize(800, 600) janela.show() return app.exec() if __name__ == "__main__": raise SystemExit(main())