Happy Birthday


Birthday Cake

  • # %------------------------------------------- Packages --------------------------------------------% #
    import numpy as np
    import matplotlib.pyplot as plt
    
    from mpl_toolkits.mplot3d.art3d import Poly3DCollection
    from matplotlib.cm              import ScalarMappable
    from matplotlib.colors          import Normalize
    
    # %--------------------------------------- Helper Functions ----------------------------------------% #
    def cylinder(R, Ntheta=20):
        # Define angles around the circumference
        theta = np.linspace(0, 2 * np.pi, Ntheta + 1)
        
        # Define the height levels (2 levels: bottom and top)
        z = np.array([0, 1])
        
        # Compute X, Y, Z coordinates
        X = np.outer([R, R], np.cos(theta))
        Y = np.outer([R, R], np.sin(theta))
        Z = np.outer(z, np.ones_like(theta))
    
        return X, Y, Z
    
    def make_alphabet(resolution=1000):
        # Grid for the alphabet
        x1 = np.linspace(0, 1, resolution)
        x2 = x1[::-1]
        
        # Helper function
        hyp = lambda x, deg: (1 - x**deg)**(1/deg)
    
        # Make the alphabet
        alphabet = {
            'A': np.array([[0, 0.5, 1, 0.75, 0.25], 
                           [0, 1, 0, 0.5, 0.5]]),
            'B': np.array([[0, 0, *x1, *x2, *x1, *x2], 
                           [0, 1, *(hyp(x1, 3)/4 + 0.75), *(-hyp(x2, 3)/4 + 0.75), *(hyp(x1, 4)/4 + 0.25), *(-hyp(x2, 4)/4 + 0.25)]]),
            'C': np.array([[*(x2/2 + 0.5), *(-x1/2 + 0.5), *(-x2/2 + 0.5), *(x1/2 + 0.5)],
                           [*(hyp(x2, 3)/3 + 2/3), *(hyp(x1, 2)/2 + 0.5), *(-hyp(x2, 2)/2 + 0.5), *(-hyp(x1, 3)/3 + 1/3)]]),
            'D': np.array([[0, 0, *x1, *x2], 
                           [0, 1, *(hyp(x1, 3)/2 + 0.5), *(-hyp(x2, 3)/2 + 0.5)]]),
            'E': np.array([[1, 0, 0, 1, 0, 0, 1], 
                           [1, 1, 0.5, 0.5, 0.5, 0, 0]]),
            'F': np.array([[1, 0, 0, 0, 1], 
                           [1, 1, 0, 0.5, 0.5]]),
            'G': np.array([[*(x2/2 + 0.5), *(-x1/2 + 0.5), *(-x2/2 + 0.5), *(x1/2 + 0.5), 0.6, 1, 1],
                           [*(hyp(x2, 3)/4 + 3/4), *(hyp(x1, 2)/2 + 0.5), *(-hyp(x2, 2)/2 + 0.5), *(-hyp(x1, 3)/5 * 2 + 2/5), 2/5, 2/5, 0]]),
            'H': np.array([[0, 0, 0, 1, 1, 1], 
                           [1, 0, 0.5, 0.5, 1, 0]]),
            'I': np.array([[0.25, 0.75, 0.5, 0.5, 0.75, 0.25], 
                           [1, 1, 1, 0, 0, 0]]),
            'J': np.array([[0.25, 0.75, 0.5, 0.5, *(x2/4 + 0.25), *(-x1/4 + 0.25)], 
                           [1, 1, 1, 0.25, *(-hyp(x2, 3)/4 + 0.25), *(-hyp(x1, 2)/4 + 0.25)]]),
            'K': np.array([[0, 0, 0, 1, 0, 1], 
                           [1, 0, 0.5, 1, 0.5, 0]]),
            'L': np.array([[0, 0, 1], 
                           [1, 0, 0]]),
            'M': np.array([[0, 0, 0.5, 1, 1], 
                           [0, 1, 0, 1, 0]]),
            'N': np.array([[0, 0, 1, 1], 
                           [0, 1, 0, 1]]),
            'O': np.array([[*(-x2/2 + 0.5), *(x1/2 + 0.5), *(x2/2 + 0.5), *(-x1/2 + 0.5)],
                           [*(hyp(x2, 3)/2 + 0.5), *(hyp(x1, 3)/2 + 0.5), *(-hyp(x2, 3)/2 + 0.5), *(-hyp(x1, 3)/2 + 0.5)]]),
            'P': np.array([[0, 0, *x1, *x2], 
                           [0, 1, *(hyp(x1, 3)/4 + 0.75), *(-hyp(x2, 4)/4 + 0.75)]]),
            'Q': np.array([[*(-x2/2 + 0.5), *(x1/2 + 0.5), *(x2/2 + 0.5), *(-x1/2 + 0.5), 0.5, 0.9], 
                           [*(hyp(x2, 3)/2 + 0.5), *(hyp(x1, 3)/2 + 0.5), *(-hyp(x2, 3)/2 + 0.5), *(-hyp(x1, 3)/2 + 0.5), 1/3, 0.0]]),
            'R': np.array([[0, 0, *x1, *x2, 1], 
                           [0, 1, *(hyp(x1, 3)/4 + 0.75), *(-hyp(x2, 4)/4 + 0.75), 0]]),
            'S': np.array([[*(x2/2 + 0.5), *(-x1/2 + 0.5), *(-x2/2 + 0.5), *(x1/2 + 0.5), *(x2/2 + 0.5), *(-x1/2 + 0.5)], 
                           [*(hyp(x2, 3)/3 + 2/3), *(hyp(x1, 4)/4 + 3/4), *(-hyp(x2, 4)/4 + 3/4), *(hyp(x1, 4)/4 + 0.25), *(-hyp(x2, 3)/3 + 1/3), *(-hyp(x1, 4)/4 + 1/4)]]),
            'T': np.array([[0, 1, 0.5, 0.5], 
                           [1, 1, 1, 0]]),
            'U': np.array([[0, *(-x2/2 + 0.5), *(x1/2 + 0.5), 1], 
                           [1, *(-hyp(x2, 3)/3 + 1/3), *(-hyp(x1, 3)/3 + 1/3), 1]]),
            'V': np.array([[0, 0.5, 1], 
                           [1, 0, 1]]),
            'W': np.array([[0, 0.25, 0.5, 0.75, 1], 
                           [1, 0, 1, 0, 1]]),
            'X': np.array([[1, 0, 0.5, 0, 1], 
                           [1, 0, 0.5, 1, 0]]),
            'Y': np.array([[0, 0.5, 1, 0.5, 0.5], 
                           [1, 2/3, 1, 2/3, 0]]),
            'Z': np.array([[0, 1, 0, 1], 
                           [1, 1, 0, 0]]),
        }
        return alphabet
    
    # %------------------------------------------ Cake Class -------------------------------------------% #
    class Cake():
        def __init__(self, R=5, H=2, dr=4.5, r=0.15, h=0.5, cake_res=1000, Ntheta=100) -> None:
            # Cake parameters
            self.R  = R      # Cake radius
            self.H  = H      # Cake height
            self.dr = dr     # Decor radius
            self.r  = r      # Candle radius
            self.h  = h      # Candle height
            self.cake_res = cake_res
            
            # Plotting parameters
            self.tg_flg = False
            self.Ntheta = Ntheta
            self.theta  = np.linspace(0, 2 * np.pi, self.Ntheta)
            self.cake_color = {
                                'cake':     [0.4, 0.25, 0.1], 
                                'frosting': [1.0, 0.9, 0.95],
                                'decor1':   [0.85, 0.35, 0.15],
                                'decor2':   [0.85, 0.25, 0.50],
                                'candle':   [1.0, 0.08, 0.01],
                                'text':     [0.35, 0.2, 0.1],
                               }
    
            # Create figure
            self.cake_fig, self.cake_ax = self.make_fig()
            
            # Make the cake
            self.make_cake()
            
            # Make the candles
            self.make_candles()
    
            # Add top message drawn on the frosting surface
            self.add_top_message_curves("Happy Birthday", rotation_deg=-self.cake_ax.azim)
            
        def make_fig(self):
            # Create figure
            cake_fig = plt.figure(figsize=(10, 8))
            cake_ax  = cake_fig.add_subplot(111, projection='3d', computed_zorder=False)
            
            # Remove axes
            cake_ax.set_axis_off()
    
            # Remove background
            cake_ax.xaxis.set_pane_color((1.0, 1.0, 1.0, 0.0))  # X-axis background to transparent
            cake_ax.yaxis.set_pane_color((1.0, 1.0, 1.0, 0.0))  # Y-axis background to transparent
            cake_ax.zaxis.set_pane_color((1.0, 1.0, 1.0, 0.0))  # Z-axis background to transparent
    
            # Remove grid lines
            cake_ax.grid(False)
    
            # remove ticks
            cake_ax.set_xticks([])
            cake_ax.set_yticks([])
            cake_ax.set_zticks([])
            cake_ax.set_box_aspect([1, 1, 1])
            
            # return figure and axis
            return cake_fig, cake_ax
            
        def make_cake(self):
            # Base layer (bottom of the cake)
            x_c = self.R * np.cos(self.theta)
            y_c = self.R * np.sin(self.theta)
            z_c = np.zeros_like(self.theta)
            
            # Cake Cylinder
            x_cake, y_cake, z_cake = cylinder(self.R, self.cake_res)
            
            # Vertical sides of the bottom part
            self.cake_ax.plot_surface(x_cake, y_cake, z_cake * 1 * self.H/3 - self.H, 
                                      color=self.cake_color['cake'], linewidth=0, zorder=1.1)
    
            # Vertical sides of the top part
            self.cake_ax.plot_surface(x_cake, y_cake, z_cake * 2 * self.H/3 - 2 * self.H/3, 
                                      color=self.cake_color['frosting'], linewidth=0, zorder=2)
            # Top layer
            self.cake_ax.add_collection3d(Poly3DCollection([list(zip(x_c, y_c, z_c+0.01))],
                                          color=self.cake_color['frosting'], linewidths=0, zorder=2))
            
            # Bottom layer
            self.cake_ax.add_collection3d(Poly3DCollection([list(zip(x_c, y_c, z_c-self.H-0.01))],
                                          color=self.cake_color['cake'], linewidths=0, zorder=1))
        
            self.cake_ax.plot(x_c, y_c, z_c, 
                              color=self.cake_color['decor1'], linestyle='-', linewidth=1,
                              marker='o', markerfacecolor=self.cake_color['decor2'], markersize=8, zorder=3)
    
            # Inner circle decoration
            self.cake_ax.plot(self.dr * np.cos(self.theta), self.dr * np.sin(self.theta), z_c, 
                              color=self.cake_color['decor1'], linestyle='-', linewidth=3, zorder=3.1)
            
        def make_candles(self):
            # Candle positions
            theta_candles_pos = np.linspace(0, 2 * np.pi, 10, endpoint=False)
            x_candles = self.dr * np.cos(theta_candles_pos)
            y_candles = self.dr * np.sin(theta_candles_pos)
            z_candles = np.linspace(0, self.h, 50)
            theta_candles, z_candles = np.meshgrid(self.theta, z_candles)
            
            # Flame parameters
            flame_radius = 0.1  # Base radius of the flame
            flame_height = 0.3  # Height of the flame
            flame_base_height = self.h  
            flame_middle_height = self.h + flame_height / 2
            flame_tip_height    = self.h + flame_height
            flame_theta1 = np.linspace(0, 2 * np.pi, 50)
            flame_theta2 = np.linspace(0, 2 * np.pi, 50) 
            flame_z1 = np.linspace(flame_base_height, flame_middle_height, 50)
            flame_z2 = np.linspace(flame_middle_height, flame_tip_height, 50)
            flame_theta2, flame_z2 = np.meshgrid(flame_theta2, flame_z2)
            flame_theta1, flame_z1 = np.meshgrid(flame_theta1, flame_z1)
    
            # Define colormap for flame color gradient
            cmap = plt.cm.hot  # Red-to-yellow colormap
            norm = Normalize(vmin=flame_base_height, vmax=flame_tip_height)
    
            # Plot candles
            for x, y in zip(x_candles, y_candles):
                # Candle surface
                X = x + self.r * np.cos(theta_candles)  # x-coordinates of candle surface
                Y = y + self.r * np.sin(theta_candles)  # y-coordinates of candle surface
                Z = z_candles                           # z-coordinates of candle surface
                self.cake_ax.plot_surface(X, Y, Z, color=self.cake_color['candle'], edgecolor='none', alpha=1, zorder=5)
    
                # Parametric equation for the first cone
                flame_x1 = x + (flame_radius * (flame_z1 - flame_base_height) / 0.25) * np.cos(flame_theta1)
                flame_y1 = y + (flame_radius * (flame_z1 - flame_base_height) / 0.25) * np.sin(flame_theta1)
                colors1 = cmap(norm(flame_z1))  # Color gradient for the first cone
    
                # Parametric equation for the second cone
                flame_x2 = x + (flame_radius * (1 - (flame_z2 - flame_middle_height) / 0.25)) * np.cos(flame_theta2)
                flame_y2 = y + (flame_radius * (1 - (flame_z2 - flame_middle_height) / 0.25)) * np.sin(flame_theta2)
                colors2 = cmap(norm(flame_z2))  # Color gradient for the second cone
    
                # Plot the flames with gradient
                self.cake_ax.plot_surface(flame_x1, flame_y1, flame_z1, facecolors=colors1, edgecolor='none', alpha=1, zorder=6)
                self.cake_ax.plot_surface(flame_x2, flame_y2, flame_z2, facecolors=colors2, edgecolor='none', alpha=1, zorder=6)
    
        def add_top_message_curves(self, message, scale=0.28, spacing=0.3, z_offset=0.02, rotation_deg=0.0):
            # Draw each letter as a set of 3D curves on the top frosting plane
            alphabet = make_alphabet()
            advance = scale + spacing
            start_x = -(len(message) * advance) / 2  # center the word
            cursor = start_x
            theta = np.deg2rad(rotation_deg)
            rot = np.array([[np.cos(theta), -np.sin(theta)],
                            [np.sin(theta),  np.cos(theta)]])
    
            for char in message.upper():
                if char == ' ':
                    cursor += advance
                    continue
    
                if char not in alphabet:
                    cursor += advance
                    continue
    
                letter = alphabet[char]
                x = letter[0] * scale + cursor
                y = letter[1] * scale
                z = np.zeros_like(x) + z_offset  # sit just above the frosting
                xy = np.vstack((x, y))
                x_rot, y_rot = rot @ xy
    
                # Light outline for visibility against frosting
                self.cake_ax.plot(x_rot, y_rot, z, color='white', linewidth=1, alpha=0.8, zorder=4)
                self.cake_ax.plot(x_rot, y_rot, z, color='black', linewidth=1.5, zorder=4.1)
    
                cursor += advance
            
    # %--------------------------------------------- Main ----------------------------------------------% #
    def main():
        # Plot a cake
        cake = Cake(cake_res=300, Ntheta=50)
    
    # %--------------------------------------------- Run -----------------------------------------------% #
    if __name__ == '__main__':
        print(f'{"Start":-^{50}}')
        main()
        plt.show()
        print(f'{"End":-^{50}}')