Source code for mytk.view3d

"""Embedded 3D mesh viewers for myTk: one widget, two rendering backends.

`View3D` is an abstract widget that loads a GLB/GLTF/OBJ/PLY file with trimesh
and shows it lit and coloured inside an ordinary myTk layout — drag to orbit,
scroll to zoom. Rather than embed a fragile OpenGL widget, it renders off-screen
and blits each frame into a `tk.Canvas` via Pillow, the same strategy
`VideoView` uses for camera frames. It re-renders only on interaction.

Two concrete implementations share that machinery:

* :class:`View3DModernGL` — a hand-written moderngl renderer (its own shaders,
  matrices and buffers). Near-zero install footprint, and it uses its own GL
  bindings rather than PyOpenGL, so it works where pyrender's context fails::

      pip install moderngl trimesh numpy Pillow

* :class:`View3DPyrender` — delegates the GL work (shaders, lighting, materials,
  framebuffer) to pyrender, so the widget keeps almost no GL of its own. Heavier
  dependencies::

      pip install pyrender trimesh numpy Pillow

Both backends map UV image textures onto the mesh (else per-vertex/material
colour) and honour the object's own alpha.

Everything that is *not* backend-specific — the Tk widget and mouse handling,
the orbit camera, render scheduling, file loading and the blit — lives in the
abstract base. A backend only has to know how to build its renderer, upload the
geometry, and draw one frame into a Pillow image.

The base shows static geometry only (no baked animations): textured or
per-vertex-coloured triangles with a two-sided Lambert-ish shade — enough to
inspect exported scenes.
"""

import importlib
import tkinter as tk
from abc import ABC, abstractmethod

from .base import Base
from .modulesmanager import ModulesManager


[docs] class View3D(Base, ABC): """Off-screen 3D mesh viewer blitted into a Tk label. Load a mesh with :meth:`load_file`, place it like any other myTk widget, then drag to orbit, scroll to zoom, and Shift+drag (or Shift+scroll) to pan. Renders only on interaction, so it is cheap when idle. Calling ``View3D(...)`` does **not** build a base instance — it is a factory that picks a rendering backend and returns one of its concrete subclasses, preferring :class:`View3DModernGL` (lighter deps, its own GL bindings, so it works where pyrender's PyOpenGL context fails) and falling back to :class:`View3DPyrender` only when moderngl is not importable. Both backends map UV image textures onto the mesh. Ask for a specific backend — e.g. pyrender for its nicer lighting — by instantiating that subclass directly. The choice is made from which backend module imports. moderngl is preferred because pyrender can import yet still fail to create a GL context at render time (notably PyOpenGL on newer Pythons), which the import-probe cannot detect; construct :class:`View3DPyrender` explicitly when you want it. Args: width (int): Initial render width in pixels (the label adopts the size of the first rendered frame, then follows the layout). height (int): Initial render height in pixels. background (str): Tk colour shown behind the mesh while empty. A mesh's own per-vertex/material alpha is honoured, so translucent geometry is blended over the background. """ # Backend module to import-probe; concrete subclasses set their own. _BACKEND_IMPORT = None # ------------------------------------------------------------------ # # Construction and public API # ------------------------------------------------------------------ # def __new__(cls, *args, **kwargs): # A concrete subclass builds normally; only bare View3D(...) dispatches. if cls is not View3D: return super().__new__(cls) for backend in (View3DModernGL, View3DPyrender): if backend._backend_importable(): return super().__new__(backend) # Neither is installed yet: default to moderngl (lighter deps, portable # GL), which the normal ModulesManager path offers to install on first use. return super().__new__(View3DModernGL) @classmethod def _backend_importable(cls): """Whether this backend's module imports (catches absent/broken installs).""" try: importlib.import_module(cls._BACKEND_IMPORT) return True except Exception: return False def __init__(self, width=820, height=620, background="#1a1a1f"): super().__init__() self._initial_size = (int(width), int(height)) self.background = background # Bounding-sphere centre/radius used to frame the orbit camera. self.center = None self.radius = 1.0 # Orbit camera state (angles in radians). self.azimuth, self.elevation = 0.6, 0.4 self.distance = 2.6 # World-space pan offset of the look-at point (shift-drag / shift-scroll). self.pan = None self._size = (0, 0) self._last = None # last mouse position while dragging self._displayed_tkimage = None # keep a ref so Tk does not GC the image # Render-scheduling flags; the rendering section explains why renders # are deferred onto the Tk event loop rather than run immediately. self._mapped = False self._render_pending = False self._geometry_dirty = False
[docs] def is_environment_valid(self): """Check that trimesh, numpy, Pillow and the backend module are present.""" modules = {"trimesh": "trimesh", "numpy": "numpy", "Pillow": "PIL"} modules.update(self._backend_modules()) ModulesManager.install_and_import_modules_if_absent(modules) imported = ModulesManager.imported self.trimesh = imported.get("trimesh", None) self.np = imported.get("numpy", None) self.PIL = imported.get("Pillow", None) if self.PIL is not None: self.PILImage = importlib.import_module("PIL.Image") self.PILImageTk = importlib.import_module("PIL.ImageTk") self._capture_backend(imported) shared = all(v is not None for v in [self.trimesh, self.np, self.PIL]) return shared and self._backend_ready()
[docs] def create_widget(self, master): """Create the canvas that displays rendered frames and wire up the mouse. A ``tk.Canvas`` (not a label) is used on purpose: a label sizes itself to its image, so each rendered frame would grow the widget by its chrome and re-trigger ``<Configure>`` — a runaway resize loop when the widget is content-sized. A canvas keeps the size the layout gives it regardless of what is drawn into it, so no feedback occurs. """ self.parent = master w, h = self._initial_size self.widget = tk.Canvas( master, background=self.background, width=w, height=h, highlightthickness=0, # no focus border (would add to the size) ) self.widget.bind("<Map>", self._on_map) self.widget.bind("<Configure>", self._on_resize) self.widget.bind("<Button-1>", self._on_press) self.widget.bind("<B1-Motion>", self._on_drag) # drag = orbit self.widget.bind("<MouseWheel>", self._on_wheel) # macOS / Windows self.widget.bind("<Button-4>", lambda e: self._zoom(0.9)) # Linux up self.widget.bind("<Button-5>", lambda e: self._zoom(1.1)) # Linux down # Shift = pan instead of orbit/zoom (Tk prefers the more specific binding). self.widget.bind("<Shift-Button-1>", self._on_press) self.widget.bind("<Shift-B1-Motion>", self._on_pan_drag) # shift-drag = pan self.widget.bind("<Shift-MouseWheel>", self._on_shift_wheel) self.widget.bind("<Shift-Button-4>", lambda e: self._pan(0, 20)) # Linux self.widget.bind("<Shift-Button-5>", lambda e: self._pan(0, -20)) # Linux # No rendering yet: the first frame is driven by <Map> (see _on_map). self._bind_destroy_cancel()
[docs] def load_file(self, path): """Load a GLB/GLTF/OBJ/PLY file and display it. All meshes in the file are handed to the backend, each keeping its own colour (vertex colours, else the material colour, else grey). Raises whatever trimesh raises for an unreadable/unknown file; use :meth:`load_file_or_warn` for the interactive (drop) case. """ loaded = self.trimesh.load(path, force="scene") meshes = list(loaded.geometry.values()) center, radius = self._compute_bounds(meshes) self._ingest(meshes, center, radius)
[docs] def load_file_or_warn(self, path): """Load a file, popping up a warning dialog if it is not a usable mesh. Unlike :meth:`load_file`, this never raises — it is meant for dropped or user-picked files, where an unrecognised format should be reported in the UI rather than crash the app. Returns True if the file loaded. """ import os from .dialog import Dialog try: self.load_file(path) return True except Exception: Dialog.showwarning( title="Unrecognized file", message=( f"“{os.path.basename(path)}” could not be opened as a 3D " f"mesh.\n\nSupported formats: GLB, GLTF, OBJ, PLY, STL." ), ) return False
# ------------------------------------------------------------------ # # Camera and mouse interaction # ------------------------------------------------------------------ # def _compute_bounds(self, meshes): """Bounding-box centre and half-diagonal radius over all meshes.""" np = self.np verts = np.vstack( [np.asarray(m.vertices, np.float32) for m in meshes] ) lo, hi = verts.min(0), verts.max(0) center = (lo + hi) / 2.0 radius = float(np.linalg.norm(hi - lo)) / 2.0 or 1.0 return center, radius def _vertex_colors(self, mesh): """Per-vertex RGBA in 0..1 for a trimesh mesh, however it stores colour. The alpha channel is carried through (defaulting to opaque) so the object's own transparency is honoured. Colour is taken, in order, from ColorVisuals' vertex colours, the glTF ``COLOR_0`` vertex attribute (which a textured mesh can still carry — e.g. per-vertex alpha encoding translucency), then the material colour, then grey. Both backends use this so they agree on colour and transparency. """ np = self.np visual = mesh.visual # 1. ColorVisuals: per-vertex RGBA directly. try: return (np.asarray(visual.vertex_colors)[:, :4] / 255.0).astype( np.float32 ) except Exception: pass # 2. glTF COLOR_0 carried as a vertex attribute on a textured mesh. raw = (getattr(visual, "vertex_attributes", None) or {}).get("color") if raw is not None: raw = np.asarray(raw) if raw.ndim == 2 and raw.shape[1] >= 3: colors = raw[:, :4].astype(np.float32) if np.issubdtype(raw.dtype, np.integer): colors /= np.iinfo(raw.dtype).max # 0..255 / 0..65535 → 0..1 if colors.shape[1] == 3: # RGB without alpha → opaque colors = np.hstack( [colors, np.ones((len(colors), 1), np.float32)] ) return colors # 3. Single material / main colour, else grey. material = getattr(visual, "material", None) rgba = getattr(visual, "main_color", None) if rgba is None and material is not None: rgba = getattr(material, "main_color", None) if rgba is not None: rgba = np.asarray(rgba, np.float32)[:4] / 255.0 if len(rgba) == 3: # RGB without alpha → opaque rgba = np.append(rgba, 1.0) else: rgba = np.array((0.7, 0.7, 0.7, 1.0), np.float32) return np.tile(rgba, (len(mesh.vertices), 1)).astype(np.float32) def _frame(self, center, radius): """Adopt new geometry bounds, reset the camera, request a render.""" self.center = center self.radius = radius or 1.0 self.azimuth, self.elevation = 0.6, 0.4 self.distance = 2.6 * self.radius self.pan = self.np.zeros(3) self._schedule_render(upload=True) def _center_or_origin(self): """Geometry centre, or the origin before any geometry is loaded. The bounds (and centre) are unset until a mesh loads; falling back to the origin lets an empty viewer render its background instead of crashing (the pyrender backend always builds a camera pose from the centre). """ return self.center if self.center is not None else self.np.zeros(3) def _look_target(self): """Point the camera orbits around and looks at: centre plus any pan.""" target = self._center_or_origin() return target if self.pan is None else target + self.pan def _eye(self): """Camera position on the orbit sphere for the current angles.""" np = self.np ce = np.cos(self.elevation) return self._look_target() + self.distance * np.array( [ ce * np.cos(self.azimuth), np.sin(self.elevation), ce * np.sin(self.azimuth), ] ) def _camera_basis(self): """Orthonormal (right, up) of the current view, for screen-plane panning.""" np = self.np forward = self._look_target() - self._eye() forward = forward / np.linalg.norm(forward) right = np.cross(forward, (0.0, 1.0, 0.0)) right = right / np.linalg.norm(right) up = np.cross(right, forward) return right, up def _on_press(self, event): """Remember where a drag started.""" self._last = (event.x, event.y) def _on_drag(self, event): """Orbit the camera as the mouse drags.""" if self._last is None: return np = self.np dx, dy = event.x - self._last[0], event.y - self._last[1] self.azimuth += dx * 0.01 self.elevation = float(np.clip(self.elevation + dy * 0.01, -1.5, 1.5)) self._last = (event.x, event.y) self.render() def _on_pan_drag(self, event): """Pan in the screen plane as Shift+drag moves the mouse.""" if self._last is None: return dx, dy = event.x - self._last[0], event.y - self._last[1] self._last = (event.x, event.y) self._pan(dx, dy) def _on_shift_wheel(self, event): """Shift + two-finger scroll pans instead of zooming. macOS delivers horizontal trackpad scroll here too, so this is the natural home for panning; the gesture shifts the model in its own plane. """ self._pan(0, -event.delta) def _pan(self, dx, dy): """Shift the look-at point by a screen-pixel delta, so the model slides. The delta is mapped through the camera's right/up axes and scaled by how much world space one pixel covers at the look-at plane, so panning feels the same at any zoom. Dragging moves the model with the cursor. """ np = self.np if self.pan is None: self.pan = np.zeros(3) right, up = self._camera_basis() h = self._size[1] or self._initial_size[1] # World units per pixel at the focal plane for a 45° vertical fov. per_pixel = 2.0 * self.distance * np.tan(np.radians(45.0) / 2.0) / h self.pan = self.pan + (-dx * right + dy * up) * per_pixel self.render() def _on_wheel(self, event): """Zoom on a macOS/Windows scroll wheel event.""" self._zoom(0.9 if event.delta > 0 else 1.1) def _zoom(self, factor): """Move the camera nearer/farther, clamped to the geometry's scale.""" self.distance = float( self.np.clip( self.distance * factor, 0.05 * self.radius, 50.0 * self.radius ) ) self.render() def _on_resize(self, event): """Resize the renderer to the label and re-render.""" if not self._mapped: return # Rendering is off-limits until <Map>; _on_map renders first. self._ensure_renderer(event.width, event.height) self.render() # ------------------------------------------------------------------ # # Render scheduling and the blit (backend-agnostic) # # The off-screen GL context (moderngl's standalone context, or pyrender's # pyglet context) may only be created once the window is actually on screen: # building it earlier wedges Tk's macOS Cocoa run loop and the window never # appears. So no rendering happens until the widget's <Map> event, after # which every render is coalesced onto the event loop. # ------------------------------------------------------------------ # def _on_map(self, event): """On first appearance, kick off the initial render (now GL is safe).""" if self._mapped: return self._mapped = True # The extra after(0) lets the macOS run loop finish realizing the window. self.widget.after(0, self.render) def _schedule_render(self, upload=False): """Coalesce a render onto the event loop, once the window is on screen. Before the first ``<Map>`` we only flag the work (``_on_map`` performs the initial render); afterwards we defer with ``after()``, collapsing repeated requests into a single pending render. """ if upload: self._geometry_dirty = True if not self._mapped or self.widget is None or self._render_pending: return self._render_pending = True self.widget.after(0, self.render)
[docs] def render(self): """Drive one frame: size the renderer, upload if dirty, draw, blit.""" self._render_pending = False if self.widget is None: return w, h = self._size if self._size != (0, 0) else self._initial_size self._ensure_renderer(w, h) w, h = self._size if w < 2 or h < 2: return if self._geometry_dirty: self._upload_geometry() self._geometry_dirty = False image = self._draw(w, h) if image is None: return self._displayed_tkimage = self.PILImageTk.PhotoImage(image) self.widget.delete("all") self.widget.create_image(0, 0, anchor="nw", image=self._displayed_tkimage)
# ------------------------------------------------------------------ # # Backend contract — implemented by each concrete renderer below. # ------------------------------------------------------------------ # @abstractmethod def _backend_modules(self): """Return the {name: import_name} of modules this backend needs.""" @abstractmethod def _capture_backend(self, imported): """Store the backend module(s) from ModulesManager.imported onto self.""" @abstractmethod def _backend_ready(self): """Whether the backend module imported successfully.""" @abstractmethod def _ingest(self, meshes, center, radius): """Store the backend's representation of these trimesh meshes.""" @abstractmethod def _ensure_renderer(self, w, h): """(Re)create the off-screen renderer for size (w, h); set ``_size``.""" @abstractmethod def _upload_geometry(self): """Push the stored geometry to the (now context-ready) backend.""" @abstractmethod def _draw(self, w, h): """Render the current camera/geometry into a Pillow RGB image."""
# ---------------------------------------------------------------------------- # # moderngl backend # ---------------------------------------------------------------------------- # VERTEX_SHADER = """ #version 330 uniform mat4 mvp; in vec3 in_pos; in vec3 in_norm; in vec4 in_color; in vec2 in_uv; out vec3 v_norm; out vec4 v_color; out vec2 v_uv; void main() { gl_Position = mvp * vec4(in_pos, 1.0); v_norm = in_norm; v_color = in_color; v_uv = in_uv; } """ FRAGMENT_SHADER = """ #version 330 uniform sampler2D tex; uniform int use_tex; // 1: sample the bound texture, 0: use the vertex colour in vec3 v_norm; in vec4 v_color; in vec2 v_uv; out vec4 f_color; void main() { // Per-vertex colour, or the UV-mapped texel when this mesh is textured. // Flip V: the image is uploaded with its top row at v=0 (GL samples that as // the bottom), but glTF UVs put v=0 at the top — so sample with 1 - v, or // asymmetric textures map upside-down onto the atlas background. vec4 base = (use_tex == 1) ? texture(tex, vec2(v_uv.x, 1.0 - v_uv.y)) : v_color; if (base.a < 0.004) discard; // cutout textures: drop holes vec3 n = normalize(v_norm); vec3 light = normalize(vec3(0.4, 0.8, 0.6)); float diff = abs(dot(n, light)); // two-sided, so open shells stay lit f_color = vec4(base.rgb * (0.35 + 0.75 * diff), base.a); // object's own alpha } """
[docs] class View3DModernGL(View3D): """`View3D` backed by a hand-written moderngl renderer. Owns its own shader program, perspective/look-at matrices and vertex/index buffers, drawing into a standalone off-screen framebuffer. """ _BACKEND_IMPORT = "moderngl" def __init__(self, width=820, height=620, background="#1a1a1f"): super().__init__(width, height, background) # One draw item per mesh: each holds its own interleaved # [pos, normal, rgba, uv] vertices, Mx3 int faces, an optional RGBA # texture image, and whether it is translucent. Per-mesh (rather than # one merged buffer) so each mesh can bind its own texture. self._items = [] self._translucent = False # any item translucent → blend through depth # GL objects, created lazily once the window is mapped. One entry in # _gl per item in _items (vao/vbo/ibo and an optional texture). self.ctx = None self.prog = None self._gl = [] self.fbo = None # -- backend contract -------------------------------------------------- # def _backend_modules(self): return {"moderngl": "moderngl"} def _capture_backend(self, imported): self.moderngl = imported.get("moderngl", None) def _backend_ready(self): return self.moderngl is not None def _ingest(self, meshes, center, radius): np = self.np items = [] for mesh in meshes: v = np.asarray(mesh.vertices, np.float32) norm = np.asarray(mesh.vertex_normals, np.float32) color = self._vertex_colors(mesh) image, uv = self._texture(mesh) if image is not None: # Textured: alpha comes from the image, not the vertex colour. image = image.convert("RGBA") translucent = bool(np.asarray(image)[:, :, 3].min() < 255) else: translucent = bool((color[:, 3] < 1.0).any()) data = np.hstack([v, norm, color, uv]).astype("f4") items.append( { "data": data, "faces": np.asarray(mesh.faces, np.int32), "image": image, "translucent": translucent, } ) self._set_items(items, center, radius) def _texture(self, mesh): """Return ``(PIL image or None, Nx2 UV float32)`` for a trimesh mesh. A texture is used only when the mesh has ``TextureVisuals`` with both an image (PBRMaterial ``baseColorTexture`` or SimpleMaterial ``image``) and one UV per vertex. Otherwise there is nothing to map, so UVs are zeros and the caller falls back to ``_vertex_colors``. The V coordinate is flipped in the shader (glTF's origin is top-left, GL's is bottom-left). """ np = self.np n = len(mesh.vertices) visual = mesh.visual if isinstance(visual, self.trimesh.visual.TextureVisuals): uv = getattr(visual, "uv", None) material = getattr(visual, "material", None) image = getattr(material, "baseColorTexture", None) or getattr( material, "image", None ) if image is not None and uv is not None and len(uv) == n: return image, np.asarray(uv, np.float32) return None, np.zeros((n, 2), np.float32) def _ensure_renderer(self, w, h): self._ensure_context() self._ensure_fbo(w, h) def _draw(self, w, h): self.fbo.use() self.ctx.clear(0.10, 0.10, 0.12, 1.0) if self._gl: proj = self._perspective( 45.0, w / h, 0.01 * self.radius, 100.0 * self.radius ) view = self._look_at(self._eye(), self._look_target(), (0.0, 1.0, 0.0)) # column-major for GL self.prog["mvp"].write((proj @ view).T.astype("f4").tobytes()) # Draw opaque meshes first (writing depth so they occlude correctly), # then translucent ones with depth writes off so they blend over the # solid geometry. Disabling depth per-mesh — not for the whole scene # when any one mesh is translucent — keeps an opaque body from # z-fighting just because, say, the hair has alpha. order = sorted( range(len(self._gl)), key=lambda i: self._items[i]["translucent"], ) for i in order: gl = self._gl[i] self.ctx.depth_mask = not self._items[i]["translucent"] texture = gl["tex"] if texture is not None: texture.use(location=0) self.prog["tex"].value = 0 self.prog["use_tex"].value = 1 else: self.prog["use_tex"].value = 0 gl["vao"].render() self.ctx.depth_mask = True img = self.PILImage.frombytes( "RGB", (w, h), self.fbo.read(components=3) ) return img.transpose(self.PILImage.FLIP_TOP_BOTTOM) # GL origin bottom-left # -- moderngl internals ------------------------------------------------ #
[docs] def set_geometry(self, interleaved, faces, center, radius): """Display a single untextured mesh from raw buffers. A convenience for vertex-coloured geometry; textured meshes arrive through :meth:`_ingest` instead. The buffer is wrapped as one draw item with zero UVs and no texture. Args: interleaved: Nx10 float32 array of [position, normal, rgba] per vertex (the alpha channel carries the object's transparency). faces: Mx3 int32 array of triangle vertex indices. center: 3-vector at the centre of the geometry's bounding box. radius: Half the bounding-box diagonal; frames the orbit camera. """ np = self.np interleaved = np.asarray(interleaved, np.float32) uv = np.zeros((len(interleaved), 2), np.float32) item = { "data": np.hstack([interleaved, uv]).astype("f4"), "faces": np.asarray(faces, np.int32), "image": None, "translucent": bool((interleaved[:, 9] < 1.0).any()), } self._set_items([item], center, radius)
def _set_items(self, items, center, radius): """Adopt per-mesh draw items, recompute translucency, frame the camera.""" self._items = items self._translucent = any(it["translucent"] for it in items) self._frame(center, radius) def _ensure_context(self): """Create the standalone GL context and shader program once.""" if self.ctx is not None or self.moderngl is None: return self.ctx = self.moderngl.create_standalone_context() self.ctx.enable(self.moderngl.DEPTH_TEST | self.moderngl.BLEND) self.ctx.blend_func = ( self.moderngl.SRC_ALPHA, self.moderngl.ONE_MINUS_SRC_ALPHA, ) self.prog = self.ctx.program( vertex_shader=VERTEX_SHADER, fragment_shader=FRAGMENT_SHADER ) def _upload_geometry(self): """(Re)upload per-mesh vertex/index buffers and textures.""" self._ensure_context() if self.ctx is None or not self._items: return self._release_gl() for item in self._items: vbo = self.ctx.buffer(item["data"].tobytes()) ibo = self.ctx.buffer(item["faces"].tobytes()) vao = self.ctx.vertex_array( self.prog, [(vbo, "3f 3f 4f 2f", "in_pos", "in_norm", "in_color", "in_uv")], ibo, ) texture = None image = item["image"] if image is not None: # image is already RGBA (see _ingest). Its top row uploads as # texel row 0; the shader flips V when sampling so glTF UVs land # the right way up. texture = self.ctx.texture(image.size, 4, image.tobytes()) # Sample the base image with a plain LINEAR filter. moderngl's # DEFAULT filter is LINEAR_MIPMAP_LINEAR, which needs a complete # mip chain; if a driver can't build one, the texture reads as # black/white on minified (angled/distant) surfaces while face-on # ones look fine. Not relying on mipmaps avoids that entirely. texture.filter = (self.moderngl.LINEAR, self.moderngl.LINEAR) self._gl.append( {"vao": vao, "vbo": vbo, "ibo": ibo, "tex": texture} ) def _release_gl(self): """Release any existing per-mesh GL objects before a re-upload.""" for gl in self._gl: for obj in (gl["vao"], gl["vbo"], gl["ibo"], gl["tex"]): if obj is not None: obj.release() self._gl = [] def _ensure_fbo(self, w, h): """(Re)create the off-screen framebuffer when the size changes.""" self._ensure_context() if self.ctx is None: return if (w, h) != self._size and w > 1 and h > 1: # Release the previous framebuffer AND its attachments first: a # framebuffer's release() does not free the textures bound to it, so # resizing would otherwise leak GPU memory until allocations start # failing (black/white frames under pressure). if self.fbo is not None: for attachment in self.fbo.color_attachments: attachment.release() if self.fbo.depth_attachment is not None: self.fbo.depth_attachment.release() self.fbo.release() self.fbo = self.ctx.framebuffer( color_attachments=[self.ctx.texture((w, h), 3)], depth_attachment=self.ctx.depth_texture((w, h)), ) self._size = (w, h) def _perspective(self, fovy_deg, aspect, near, far): """Build a perspective projection matrix.""" np = self.np f = 1.0 / np.tan(np.radians(fovy_deg) / 2.0) m = np.zeros((4, 4), np.float32) m[0, 0] = f / aspect m[1, 1] = f m[2, 2] = (far + near) / (near - far) m[2, 3] = (2.0 * far * near) / (near - far) m[3, 2] = -1.0 return m def _look_at(self, eye, target, up): """Build a view matrix looking from `eye` toward `target`.""" np = self.np eye, target, up = (np.asarray(a, np.float32) for a in (eye, target, up)) f = target - eye f /= np.linalg.norm(f) s = np.cross(f, up) s /= np.linalg.norm(s) u = np.cross(s, f) m = np.eye(4, dtype=np.float32) m[0, :3], m[1, :3], m[2, :3] = s, u, -f m[0, 3], m[1, 3], m[2, 3] = -np.dot(s, eye), -np.dot(u, eye), np.dot(f, eye) return m
# ---------------------------------------------------------------------------- # # pyrender backend # ---------------------------------------------------------------------------- #
[docs] class View3DPyrender(View3D): """`View3D` backed by pyrender. pyrender owns the GL context, shaders, lighting, materials and framebuffer, so this backend keeps only a scene graph, an off-screen renderer, and the orbit-camera pose. A mesh's own alpha is honoured via ``alphaMode='BLEND'``. """ _BACKEND_IMPORT = "pyrender" def __init__(self, width=820, height=620, background="#1a1a1f"): super().__init__(width, height, background) self._meshes = [] # trimesh meshes awaiting upload self._mesh_nodes = [] # their pyrender scene nodes self._scene = None self._cam_node = None self._light_node = None self._renderer = None # -- backend contract -------------------------------------------------- # def _backend_modules(self): return {"pyrender": "pyrender"} def _capture_backend(self, imported): self.pyrender = imported.get("pyrender", None) def _backend_ready(self): return self.pyrender is not None def _ingest(self, meshes, center, radius): self._meshes = list(meshes) self._frame(center, radius) def _ensure_renderer(self, w, h): self._ensure_scene() if (w, h) == self._size or w < 2 or h < 2: return if self._renderer is not None: self._renderer.delete() self._renderer = self.pyrender.OffscreenRenderer(w, h) self._size = (w, h) def _upload_geometry(self): """Rebuild the pyrender mesh nodes from the stored trimesh meshes.""" self._ensure_scene() for node in self._mesh_nodes: self._scene.remove_node(node) self._mesh_nodes = [] for mesh in self._meshes: if self._has_texture(mesh): # Textured mesh: hand it to pyrender untouched so it builds the # UV-mapped sampler and material itself. The per-vertex COLOR_0 # alpha trick (used below for untextured meshes) is given up # here — the texture's own alpha drives transparency instead. pr_mesh = self.pyrender.Mesh.from_trimesh(mesh, smooth=False) else: # No texture: drive colour/transparency from _vertex_colors # (same as the moderngl backend), so per-vertex glTF COLOR_0 # alpha is honoured — pyrender's own from_trimesh would # otherwise read only the opaque material colour. Restamp it as # per-vertex ColorVisuals. rgba = (self._vertex_colors(mesh) * 255.0).astype("uint8") mesh = mesh.copy() mesh.visual = self.trimesh.visual.ColorVisuals( mesh, vertex_colors=rgba ) pr_mesh = self.pyrender.Mesh.from_trimesh(mesh, smooth=False) for prim in pr_mesh.primitives: # Matte (non-metallic) so colours read true under one light, and # blended so per-vertex / texture alpha shows. prim.material.alphaMode = "BLEND" prim.material.metallicFactor = 0.0 prim.material.roughnessFactor = 1.0 self._mesh_nodes.append(self._scene.add(pr_mesh)) def _has_texture(self, mesh): """Whether ``mesh`` carries a UV-mapped image texture pyrender can use. True only when the mesh has ``TextureVisuals`` with both UV coordinates and an image — i.e. enough to sample. A ``TextureVisuals`` that holds only a flat material colour (no UVs or no image) is treated as untextured so it still goes through the per-vertex colour path. """ visual = mesh.visual if not isinstance(visual, self.trimesh.visual.TextureVisuals): return False if getattr(visual, "uv", None) is None or len(visual.uv) == 0: return False material = getattr(visual, "material", None) # PBRMaterial exposes baseColorTexture; SimpleMaterial exposes image. image = getattr(material, "baseColorTexture", None) or getattr( material, "image", None ) return image is not None def _draw(self, w, h): # The object's own vertex/material alpha drives transparency (the meshes # are uploaded with alphaMode='BLEND'); nothing extra to apply here. pose = self._camera_pose() self._scene.set_pose(self._cam_node, pose) self._scene.set_pose(self._light_node, pose) # headlight color, _ = self._renderer.render(self._scene) return self.PILImage.fromarray(color, "RGB") # -- pyrender internals ------------------------------------------------ # def _ensure_scene(self): """Create the scene, camera node and headlight once.""" if self._scene is not None: return np, pr = self.np, self.pyrender self._scene = pr.Scene( bg_color=[0.10, 0.10, 0.12, 1.0], ambient_light=[0.35, 0.35, 0.35] ) self._cam_node = self._scene.add( pr.PerspectiveCamera(yfov=np.radians(45.0)), pose=np.eye(4) ) self._light_node = self._scene.add( pr.DirectionalLight(color=[1.0, 1.0, 1.0], intensity=4.0), pose=np.eye(4), ) def _camera_pose(self): """Camera-to-world pose looking from the orbit eye at the (panned) centre.""" np = self.np eye = self._eye() f = self._look_target() - eye f = f / np.linalg.norm(f) # forward (camera looks down -z) s = np.cross(f, (0.0, 1.0, 0.0)) s = s / np.linalg.norm(s) # right u = np.cross(s, f) m = np.eye(4) m[:3, 0], m[:3, 1], m[:3, 2], m[:3, 3] = s, u, -f, eye return m
if __name__ == "__main__": # A very simple example: show a checkerboard-textured box so the texture # mapping is visible. Drag to orbit, scroll to zoom, Shift+drag (or # Shift+scroll) to pan, or drop a mesh file onto the viewer to load it. import os import tempfile import numpy as np import trimesh from PIL import Image from mytk import App # A checkerboard-textured box. Each face gets its own full [0,1]^2 UV square # so the texture maps cleanly on every side (a naive shared-vertex UV would # collapse the side faces to a thin strip and read as solid black/white). box = trimesh.creation.box(extents=(1.0, 1.0, 1.0)) tris = box.vertices[box.faces] # (M, 3, 3): unmerged verts = tris.reshape(-1, 3) faces = np.arange(len(verts)).reshape(-1, 3) # Per triangle, drop the axis along its normal and map the other two # coordinates from [-0.5, 0.5] to [0, 1] — an axis-aligned per-face unwrap. normals = np.cross(tris[:, 1] - tris[:, 0], tris[:, 2] - tris[:, 0]) drop = np.abs(normals).argmax(1) keep = np.array([[a for a in range(3) if a != d] for d in drop]) uv = np.take_along_axis( tris, keep[:, None, :], axis=2 ).reshape(-1, 2) + 0.5 # Asymmetric on purpose: a top-to-bottom brightness ramp makes an # upside-down (V-flipped) texture obvious — a symmetric checker would hide it. checker = (np.indices((16, 16)).sum(0) % 2 * 255).astype("uint8") ramp = np.linspace(0.25, 1.0, 16)[:, None] # darker at the top, brighter low image = Image.fromarray( np.dstack( [checker * ramp, (checker // 2) * ramp, (255 - checker) * ramp] ).astype("uint8"), "RGB", ) box = trimesh.Trimesh(vertices=verts, faces=faces, process=False) box.visual = trimesh.visual.TextureVisuals( uv=uv, material=trimesh.visual.material.PBRMaterial(baseColorTexture=image) ) path = os.path.join(tempfile.gettempdir(), "view3d_box.glb") box.export(path) app = App(geometry="400x400") app.window.widget.title("View3D") # The View3D factory picks a backend (moderngl if available, else pyrender). viewer = View3D(width=400, height=400) viewer.grid_into(app.window, row=0, column=0, sticky="nsew") app.window.row_resize_weight(0, 1) app.window.column_resize_weight(0, 1) viewer.load_file(path) viewer.accept_dropped_files(lambda paths: viewer.load_file_or_warn(paths[0])) app.mainloop()