#!/usr/bin/env python3 import argparse import re from math import cos, sin from typing import cast import dearpygui.dearpygui as dpg # pyright: ignore[reportMissingTypeStubs] RENDER_VERTICES = True RENDER_EDGES = True BEST_EFFORT_PARSE = True CANVAS_SIZE = 500 TIMESTEP = 0.01 CAMERA_DISTANCE = 2.5 VERTEX_SIZE = 5.0 VERTEX_COLOR = (27, 188, 104) EDGE_SIZE = 1.0 EDGE_COLOR = (20, 133, 38) Vertex2 = tuple[float, float] Vertex2Set = list[Vertex2] Vertex3 = tuple[float, float, float] Vertex3Set = list[Vertex3] IndexedFace = tuple[int, int, int, int] IndexedFaceSet = list[IndexedFace] # 3D Space [-1, 1]x[-1, 1]x[-1, 1] -> 2D ImagePlane [-1, 1]x[-1, 1] -> Viewport in [0, WIDTH]x[0, HEIGHT] def to_viewport(verts: Vertex2Set) -> Vertex2Set: """Maps vertices into the [0, WIDTH]x[0, HEIGHT] viewport""" def transform(vert: Vertex2) -> Vertex2: return ( ((vert[0] + 1.0) / 2.0) * CANVAS_SIZE, (1 - (vert[1] + 1.0) / 2.0) * CANVAS_SIZE, ) return list(map(transform, verts)) def to_imageplane(verts: Vertex3Set) -> Vertex2Set: """Projects vertices onto the [-1, 1]x[-1, 1] imageplane""" def transform(vert: Vertex3) -> Vertex2: return (vert[0] / vert[2], vert[1] / vert[2]) return list(map(transform, verts)) def translate(verts: Vertex3Set, distance: float) -> Vertex3Set: """Translates vertices along the forward-axis in 3D space""" def transform(vert: Vertex3) -> Vertex3: return (vert[0], vert[1], vert[2] + distance) return list(map(transform, verts)) def rotate(verts: Vertex3Set, angle: float) -> Vertex3Set: """Rotates vertices around the up-axis in 3D space""" def transform(vert: Vertex3) -> Vertex3: return ( vert[0] * cos(angle) - vert[2] * sin(angle), vert[1], vert[0] * sin(angle) + vert[2] * cos(angle), ) return list(map(transform, verts)) def draw(drawlist, vertices: Vertex3Set, faces: IndexedFaceSet, time: float): dpg.delete_item(drawlist, children_only=True) def draw_vertex(vert: Vertex2) -> None: _ = dpg.draw_circle( center=(vert[0], vert[1]), radius=VERTEX_SIZE, parent=drawlist, fill=VERTEX_COLOR, color=VERTEX_COLOR, ) def draw_line(vertA: Vertex2, vertB: Vertex2) -> None: _ = dpg.draw_line( p1=(vertA[0], vertA[1]), p2=(vertB[0], vertB[1]), parent=drawlist, color=EDGE_COLOR, thickness=EDGE_SIZE, ) rotated_vertices: Vertex3Set = rotate(vertices, time) translated_vertices: Vertex3Set = translate(rotated_vertices, CAMERA_DISTANCE) imageplane_vertices: Vertex2Set = to_imageplane(translated_vertices) viewport_vertices: Vertex2Set = to_viewport(imageplane_vertices) if RENDER_VERTICES: for v in viewport_vertices: draw_vertex(v) if RENDER_EDGES: for f in faces: v0: Vertex2 = viewport_vertices[f[0]] v1: Vertex2 = viewport_vertices[f[1]] v2: Vertex2 = viewport_vertices[f[2]] v3: Vertex2 = viewport_vertices[f[3]] draw_line(v0, v1) draw_line(v1, v2) draw_line(v2, v3) draw_line(v3, v0) def parse_obj_file(path: str) -> tuple[Vertex3Set, IndexedFaceSet]: with open(path, "r") as file: lines = file.readlines() vertices: Vertex3Set = [] faces: IndexedFaceSet = [] for line in lines: if line.startswith("v "): matches = re.findall(r"^v (.*?) (.*?) (.*?)$", line) if len(matches) != 1: if BEST_EFFORT_PARSE: continue raise RuntimeError("Failed to parse .obj file") x, y, z = matches[0] vertices += [(float(x), float(y), float(z))] elif line.startswith("f "): matches = re.findall(r"^f (.*?) (.*?) (.*?) (.*?)$", line) if len(matches) != 1: if BEST_EFFORT_PARSE: continue raise RuntimeError("Failed to parse .obj file") v0, v1, v2, v3 = matches[0] v0 = int(v0) - 1 v1 = int(v1) - 1 v2 = int(v2) - 1 v3 = int(v3) - 1 faces += [(v0, v1, v2, v3)] return vertices, faces def main(): parser = argparse.ArgumentParser() _ = parser.add_argument("objfile", type=str, help=".obj file to render") _ = parser.add_argument("--vertices", action="store_true", help="render vertices") _ = parser.add_argument("--edges", action="store_true", help="render edges") _ = parser.add_argument( "--strict", action="store_true", help="require complete parsing of .obj file" ) args = parser.parse_args() args.objfile = cast(str, args.objfile) global RENDER_VERTICES global RENDER_EDGES global BEST_EFFORT_PARSE RENDER_VERTICES = bool(args.vertices) RENDER_EDGES = bool(args.edges) BEST_EFFORT_PARSE = not bool(args.strict) dpg.create_context() with dpg.theme() as canvas_theme, dpg.theme_component(): _ = dpg.add_theme_style(dpg.mvStyleVar_WindowPadding, 0, 0) with dpg.window(label="Obj Render") as canvas: dpg.bind_item_theme(canvas, canvas_theme) dpg.set_primary_window(dpg.last_item(), True) with dpg.drawlist(width=CANVAS_SIZE, height=CANVAS_SIZE) as d: drawlist = d dpg.create_viewport(title="Obj Render", width=CANVAS_SIZE, height=CANVAS_SIZE) dpg.setup_dearpygui() dpg.show_viewport() vertices, faces = parse_obj_file(args.objfile) time: float = 0 while dpg.is_dearpygui_running(): draw(drawlist, vertices, faces, time) dpg.render_dearpygui_frame() time += TIMESTEP dpg.destroy_context() if __name__ == "__main__": main()