add sphere shape to my own liblary code:

import numpy as np
import pygame

class BasicMeshMaterial:
    def __init__(self, color, obj):
        self.color = color  # Color in RGB format, e.g., (255, 0, 0) for red
        self.object = obj

    def draw_material(self, screen, screen_width, screen_height, fov=256, viewer_distance=5):
        # Rotate and project vertices
        vertices = self.object.rotate(self.object.angle_x, self.object.angle_y, self.object.angle_z)
        projected_vertices = [self.object.project(v, screen_width, screen_height, fov, viewer_distance) for v in vertices]

        # Exclude vertices that can't be projected
        if all(pv is not None for pv in projected_vertices):
            self.draw_faces(screen, projected_vertices)

    def draw_faces(self, screen, projected_vertices):
        faces = self.object.get_faces()  # Get faces from object
        for face in faces:
            pygame.draw.polygon(screen, self.color, [projected_vertices[i] for i in face])

class OutlineMaterial:
    def __init__(self, color, obj, line_width=1):
        self.color = color
        self.object = obj
        self.line_width = line_width

    def draw_material(self, screen, screen_width, screen_height, fov=256, viewer_distance=5):
        # Rotate and project vertices
        vertices = self.object.rotate(self.object.angle_x, self.object.angle_y, self.object.angle_z)
        projected_vertices = [self.object.project(v, screen_width, screen_height, fov, viewer_distance) for v in vertices]

        # Exclude vertices that can't be projected
        if all(pv is not None for pv in projected_vertices):
            self.draw_edges(screen, projected_vertices)

    def draw_edges(self, screen, projected_vertices):
        edges = self.object.get_edges()  # Get edges from object
        for edge in edges:
            pygame.draw.line(screen, self.color, projected_vertices[edge[0]], projected_vertices[edge[1]], self.line_width)

class Cube:
    def __init__(self, x, y, z, width, height, depth, material=None):
        self.position = np.array([x, y, z])
        self.width = width
        self.height = height
        self.depth = depth
        self.angle_x = 0
        self.angle_y = 0
        self.angle_z = 0
        self.material = material if material else BasicMeshMaterial((255, 255, 255), self)  # default white material

    def setPosition(self, new_position):
        self.position = np.array(new_position)

    def getVertices(self):
        x, y, z = self.position
        return [
            [x, y, z],
            [x + self.width, y, z],
            [x + self.width, y + self.height, z],
            [x, y + self.height, z],
            [x, y, z + self.depth],
            [x + self.width, y, z + self.depth],
            [x + self.width, y + self.height, z + self.depth],
            [x, y + self.height, z + self.depth]
        ]

    def get_faces(self):
        return [
            (0, 1, 2, 3),  # Front
            (4, 5, 6, 7),  # Back
            (0, 3, 7, 4),  # Left
            (1, 2, 6, 5),  # Right
            (3, 2, 6, 7),  # Top
            (0, 1, 5, 4)   # Bottom
        ]

    def get_edges(self):
        return [
            (0, 1), (1, 2), (2, 3), (3, 0),  # Front edges
            (4, 5), (5, 6), (6, 7), (7, 4),  # Back edges
            (0, 4), (1, 5), (2, 6), (3, 7)   # Connecting edges
        ]

    def rotate(self, angle_x, angle_y, angle_z):
        cos_x, sin_x = np.cos(angle_x), np.sin(angle_x)
        cos_y, sin_y = np.cos(angle_y), np.sin(angle_y)
        cos_z, sin_z = np.cos(angle_z), np.sin(angle_z)

        rotation_x = np.array([
            [1, 0, 0],
            [0, cos_x, -sin_x],
            [0, sin_x, cos_x]
        ])

        rotation_y = np.array([
            [cos_y, 0, sin_y],
            [0, 1, 0],
            [-sin_y, 0, cos_y]
        ])

        rotation_z = np.array([
            [cos_z, -sin_z, 0],
            [sin_z, cos_z, 0],
            [0, 0, 1]
        ])

        rotation_matrix = rotation_x @ rotation_y @ rotation_z
        rotated_vertices = []
        for vertex in self.getVertices():
            rotated_vertex = np.dot(rotation_matrix, np.array(vertex) - self.position) + self.position
            rotated_vertices.append(rotated_vertex)
    
        return rotated_vertices

    def project(self, vertex, screen_width, screen_height, fov, viewer_distance, epsilon=0.1):
        z = vertex[2] + epsilon
        if z <= 0:
            return None  # Ignore vertices "behind the camera"
        
        factor = fov / z
        x = vertex[0] * factor + screen_width / 2
        y = -vertex[1] * factor + screen_height / 2
        return (int(x), int(y))

    def draw(self, screen, screen_width, screen_height, fov=256, viewer_distance=5):
        self.material.draw_material(screen, screen_width, screen_height, fov, viewer_distance)

class Pyramid:
    def __init__(self, x, y, z, width, height, depth, material=None):
        self.position = np.array([x, y, z])
        self.width = width
        self.height = height
        self.depth = depth
        self.angle_x = 0
        self.angle_y = 0
        self.angle_z = 0
        self.material = material if material else BasicMeshMaterial((255, 255, 255), self)  # Default white material

    def setPosition(self, new_position):
        self.position = np.array(new_position)

    def getVertices(self):
        x, y, z = self.position
        return [
            [x, y, z],  # Vertex 0 (bottom left front)
            [x + self.width, y, z],  # Vertex 1 (bottom right front)
            [x + self.width, y, z + self.depth],  # Vertex 2 (bottom right back)
            [x, y, z + self.depth],  # Vertex 3 (bottom left back)
            [(x + x + self.width) / 2, y + self.height, (z + z + self.depth) / 2]  # Vertex 4 (pyramid peak)
        ]

    def get_faces(self):
        return [
            (0, 1, 4),  # Front
            (1, 2, 4),  # Right
            (2, 3, 4),  # Back
            (3, 0, 4),  # Left
            (0, 1, 2, 3)  # Bottom
        ]

    def get_edges(self):
        return [
            (0, 1), (1, 2), (2, 3), (3, 0),  # Base edges
            (0, 4), (1, 4), (2, 4), (3, 4)   # Side edges
        ]

    def rotate(self, angle_x, angle_y, angle_z):
        cos_x, sin_x = np.cos(angle_x), np.sin(angle_x)
        cos_y, sin_y = np.cos(angle_y), np.sin(angle_y)
        cos_z, sin_z = np.cos(angle_z), np.sin(angle_z)

        rotation_x = np.array([
            [1, 0, 0],
            [0, cos_x, -sin_x],
            [0, sin_x, cos_x]
        ])

        rotation_y = np.array([
            [cos_y, 0, sin_y],
            [0, 1, 0],
            [-sin_y, 0, cos_y]
        ])

        rotation_z = np.array([
            [cos_z, -sin_z, 0],
            [sin_z, cos_z, 0],
            [0, 0, 1]
        ])

        rotation_matrix = rotation_x @ rotation_y @ rotation_z
        rotated_vertices = []
        for vertex in self.getVertices():
            rotated_vertex = np.dot(rotation_matrix, np.array(vertex) - self.position) + self.position
            rotated_vertices.append(rotated_vertex)
        
        return rotated_vertices

    def project(self, vertex, screen_width, screen_height, fov, viewer_distance, epsilon=0.1):
        z = vertex[2] + epsilon
        if z <= 0:
            return None  # Ignore vertices "behind the camera"

        factor = fov / z
        x = vertex[0] * factor + screen_width / 2
        y = -vertex[1] * factor + screen_height / 2
        return (int(x), int(y))

    def draw(self, screen, screen_width, screen_height, fov=256, viewer_distance=5):
        self.material.draw_material(screen, screen_width, screen_height, fov, viewer_distance)