0

I want to create a function in Python using matplotlib which can recreate the following image:

subspace

That is, a function which takes as an argument 1, 2 or 3 vectors (so if 1 vector is provided, plot the line on which that vector lies, if 2 vectors are provided, plot the plane on which these two vectors lie*, and for 3 vectors* plot the 6 planes constituting the boundary box of the axis, ending at the 8 vertices) and plots the corresponding subspace as a line, plane or volume. Most importantly, I want the line/plane/volume to be clipped, such that if I specify bounds on the axis it does not render beyond these bounds.

  • Assuming they are independent, so I might need to check for that.

I thought this would be easiest with the help of ax.add_collection3d(Poly3DCollection(verts,color=c)) similar to this post. For this, I wanted to calculate the intersection vertices of the line/plane, calculated from the basis vectors given, and then plot the corresponding polygon. However, I don't know how to actually compute this intersection, and more importantly, since verts needs to have the right order which I don't know how to compute systematically (is the next vertex always the closet vertex?). Does anybody have tips on how to approach this? Thanks in advance!

5
  • Your question is ambiguous. Firstly, since you associate "1, 2 or 3 vectors" to "line, plane, volume", it seems to indicate that you consider the vector to be the generators of the subspace. But your example image 1) does show 1 vector for 1 surface, which is apparently the normal (which is another way to specify a line or surface: giving 2 or 1 normals, whose coordinates are the coefficient of the 2 or 1 equations). 2) doesn't show a subspace. I don't know which cube corner is the origin is in your drawing, but none of them belong to your surface. Which therefore is not a vectorspace Commented Apr 23, 2024 at 11:59
  • If it is an affine plane, fine. But then you need other arguments. Either a point of origin (any point in the plane). Or, if you specify a normal, a shifting along that normal (or a point also). Commented Apr 23, 2024 at 12:01
  • Also, I have hard time imagining what you have in mind about volumes. It would fill the whole cube. Or another cube of the bounds. Commented Apr 23, 2024 at 12:02
  • In other words, we need way more specification than that. And preferably some attempt code. Commented Apr 23, 2024 at 12:03
  • Hi @chrslg, thanks for your comments: I hopefully cleared up some of the ambiguity of the question. Note that since I'm referring to subspaces the point o origin is the origin. And indeed, for 3 vectors I'm looking for a cube that would fill the axis, with added transparency. Commented Apr 23, 2024 at 15:28

1 Answer 1

1

For posterity, here is the solution I came up with:

box

plane

line

origin

WE:

"""
MWE of the subspace plotting method
"""

# Import packages
import numpy as np
import vg  # For signed angles on the plane
import matplotlib.pyplot as plt
from mpl_toolkits.mplot3d.art3d import Poly3DCollection  # For plotting polygons in 3d

# ------------ METHODS ------------


def get_plot_subspace(V, x_bounds, ax_exist = None, color = 'blue', alpha = 0.5):
    """
    V ∈ ℝ^{n_x × dim_v} is a basis for the subspace
    # FIXME: I think not specifying x_bounds leads to trouble.
    # TODO: Currently, we are assuming the vectors in V are linearly independent: we need to rigorously check this when the argument are first passed.
    """

    #: Retrieve the dimensionality
    if V.size != 0:
        n_x, dim_v = V.shape[0], V.shape[1]
    else:  # Empty basis
        #: Retrieve the projection
        if ax_exist is not None:
            #: Retrieve the projection
            if ax_exist.name == '3d':
                n_x, dim_v = 3, 0
            else:
                n_x, dim_v = 2, 0
        else:  # NOTE: This assumes as standard that the plotting is in 3d
            n_x, dim_v = 3, 0
    #: Add to existing axes (if they exist)
    if ax_exist is not None:
        #: Set equal
        fig, ax = None, ax_exist
    else:
        if n_x == 3:
            #: Set the projection to 3d
            fig = plt.figure()
            ax = fig.add_subplot(projection='3d')
        else:
            #: Create a figure
            fig, ax = plt.subplots()
    #: Get the bounds
    if x_bounds is None:
        if n_x == 2:
            x_bounds = ((ax.get_xlim()), (ax.get_ylim()))
        else:
            x_bounds = ((ax.get_xlim()), (ax.get_ylim()), (ax.get_zlim()))
    #: Check the dimensionality of the subspace V
    match dim_v:
        case 0:  # Origin
            if n_x == 2:  # Planar
                raise NotImplementedError(f"Planar plotting is not yet implemented")
            else:  # 3-dimensional
                ax.plot(0, 0, 0, '.', color=color)
        case 1:  # Line
            if n_x == 2:  # Planar
                raise NotImplementedError(f"Planar plotting is not yet implemented")
            else:  # 3-dimensional
                ax = get_plot_line_3d(ax, V[:, 0], np.array([0, 0, 0]), x_bounds, color, alpha)
        case 2:  # Plane
            if n_x == 2:  # Planar
                raise NotImplementedError(f"Planar plotting is not yet implemented")
            else:  # 3-dimensional
                ax = get_plot_plane_3d(ax, V[:, 0], V[:, 1], np.array([0, 0, 0]), x_bounds, color, alpha)
        case 3:  # Box
            if n_x == 2:  # Planar
                raise NotImplementedError(f"Planar plotting is not yet implemented")
            else:  # 3-dimensional
                ax = get_plot_box_3d(ax, x_bounds, color, alpha)
    #: Set the axis labels
    ax.set_xlabel(r'$x_{1}$')
    ax.set_ylabel(r'$x_{2}$', rotation='horizontal')
    if n_x == 3:
        ax.set_zlabel(r'$x_{3}$')
    # TODO: Make it such that the <label> parameter shows up with the correct icon
    #: Create the bounds
    if x_bounds is not None:
        ax.set_xlim(x_bounds[0][0], x_bounds[0][1])
        ax.set_ylim(x_bounds[1][0], x_bounds[1][1])
        if n_x == 3:
            ax.set_zlim(x_bounds[2][0], x_bounds[2][1])
    #: Return the axis
    if ax_exist is not None:
        return ax
    else:
        return fig, ax
    


def get_plot_plane_3d(ax, v_1, v_2, v_offset, x_bounds, color = 'blue', alpha = 0.5):
    # TODO: Merge this with the line plot
    #: Get the list of intersection points
    intersection_list = get_intersection_bounding_box_3d(v_1, v_2, v_offset, x_bounds)
    #: Compute the normal vector to the plane
    normal = np.cross(v_1, v_2)
    #: Sort the points
    intersection_list = get_sorted_list_of_vertices(intersection_list, normal)
    #: Compute the hexadecimal value to rgb
    if isinstance(color, str) and color[0] == '#':
        #: Convert to rgb
        color_rgba = [int(color[i:(i + 2)], 16) / 255. for i in (1, 3, 5)]
        color_rgba.append(alpha)
    else:
        color_rgba = [0, 0, 0.8, alpha]
    #: Add the polygon
    ax.add_collection3d(Poly3DCollection([intersection_list], color=[color_rgba for _ in range(len(intersection_list))]))
    #: Return the results
    return ax


def get_plot_line_3d(ax, v, v_offset, x_bounds, color = 'blue', alpha = 0.5):
    # TODO: Merge this with the line plot
    #: Get the list of intersection points
    intersection_list = get_intersection_bounding_box_3d(v, None, v_offset, x_bounds)
    #: Create the color
    if isinstance(color, str) and color[0] == '#':
        #: Convert to rgb
        color_rgba = [int(color[i:(i + 2)], 16) / 255. for i in (1, 3, 5)]
        color_rgba.append(alpha)
    else:
        color_rgba = [0, 0, 0.8, alpha]
    #: Add the polygon
    ax.add_collection3d(Poly3DCollection([intersection_list], color=[color_rgba for _ in range(len(intersection_list))]))
    #: Return the results
    return ax


def get_plot_box_3d(ax, bounds_box, color = 'blue', alpha = 0.5):
    #: Set the dimension of the state-space
    n_x = 3
    #: Create a list
    list_planes = ['xmin', 'xmax', 'ymin', 'ymax', 'zmin', 'zmax']
    #: Create a dictionary
    dict_planes = get_dict_planes(bounds_box)
    #: Loop over all the planes
    for name_plane in list_planes:
        #: Extract the planes data
        v_1_plane, v_2_plane, offset_plane = dict_planes[name_plane]
        #: Plot the plane
        ax = get_plot_plane_3d(ax, v_1_plane, v_2_plane, offset_plane, bounds_box, color, alpha)
    #: Return the results
    return ax


def get_intersection_three_planes(v_1_plane_1, v_2_plane_1, offset_plane_1, v_1_plane_2, v_2_plane_2, offset_plane_2, v_1_plane_3, v_2_plane_3, offset_plane_3):
    # FROM: ChatGPT3.5
    # TODO: Make an actual plane class for an equivalent representation
    #: Calculating normal vectors of the planes
    normal_plane_1 = np.cross(v_1_plane_1, v_2_plane_1)
    normal_plane_2 = np.cross(v_1_plane_2, v_2_plane_2)
    normal_plane_3 = np.cross(v_1_plane_3, v_2_plane_3)
    #: Adjusting D constants for the planes based on offsets
    D_plane_1 = -np.dot(normal_plane_1, offset_plane_1)
    D_plane_2 = -np.dot(normal_plane_2, offset_plane_2)
    D_plane_3 = -np.dot(normal_plane_3, offset_plane_3)
    #: Constructing coefficient matrix
    coeff_matrix = np.array([normal_plane_1, normal_plane_2, normal_plane_3])
    #: Constructing constant vector
    const_vector = np.array([D_plane_1, D_plane_2, D_plane_3])
    #: Solving the system of equations
    try:
        intersection_point = np.linalg.solve(coeff_matrix, const_vector)
    except np.linalg.LinAlgError:  # There is no intersection point
        intersection_point = None 
    #: Return the result
    return intersection_point


def get_intersection_line_plane(v_1_plane, v_2_plane, offset_plane, line, offset_line):
    # FROM: ChatGPT3.5
    #: Define the plane by three points
    p_1_plane = offset_plane
    #: Calculate the normal vector of the plane
    normal_plane = np.cross(v_1_plane, v_2_plane)
    #: Define the line by a point and its direction
    point_line = offset_line
    direction_line = line
    #: Calculate the parameter t for the intersection point
    t = np.dot(normal_plane, p_1_plane - point_line) / np.dot(normal_plane, direction_line)
    #: Calculate the intersection point
    intersection_point = point_line + t * direction_line
    #: Return the results
    return intersection_point


def get_intersection_bounding_box_3d(v_1, v_2, v_offset, x_bounds):    
    #: Set the dimension of the state-space
    n_x = 3
    #: Create a list
    list_planes = ['xmin', 'xmax', 'ymin', 'ymax', 'zmin', 'zmax']
    #: Create the inequality plot
    F_ineq, b_ineq = np.vstack((np.eye(n_x), -np.eye(n_x))), np.array([x_bounds[0][1], x_bounds[1][1], x_bounds[2][1], -x_bounds[0][0], -x_bounds[1][0], -x_bounds[2][0]])
    #: Create a dictionary
    dict_planes = get_dict_planes(x_bounds)
    #: Calculate the intersection points
    if v_2 is not None:  # Intersection with a plane
        #: List all the pairs
        list_pairs = [(list_planes[p1], list_planes[p2]) for p1 in range(len(list_planes)) for p2 in range(p1+1, len(list_planes))]
        #: Create a list of intersection points
        intersection_list = []
        #: Loop over all elements in the list
        for pair in list_pairs:
            #: Extract the planes data
            v_1_plane_1, v_2_plane_1, offset_plane_1 = dict_planes[pair[0]]
            v_1_plane_2, v_2_plane_2, offset_plane_2 = dict_planes[pair[1]]
            #: Find the intersection
            points_intersection = get_intersection_three_planes(v_1_plane_1, v_2_plane_1, offset_plane_1, v_1_plane_2, v_2_plane_2, offset_plane_2, v_1, v_2, v_offset)
            #: FIXME: For some reason there is an imaginary part?
            points_intersection = points_intersection.real if points_intersection is not None else None
            #: Check if the element is within the plotting area
            if points_intersection is not None and np.all(F_ineq @ points_intersection <= 1.1 * b_ineq):
                #: Add the vertex to the collection
                intersection_list.append(points_intersection)
    else:  # Intersection with a line
        #: Create a list of intersection points
        intersection_list = []
        #: Loop over all elements in the list
        for plane in list_planes:
            #: Extract the plane data
            v_1_plane, v_2_plane, offset_plane = dict_planes[plane]
            #: Find the intersection
            points_intersection = get_intersection_line_plane(v_1_plane, v_2_plane, offset_plane, v_1, np.zeros(n_x))
            #: Check if the element is within the plotting area
            if points_intersection is not None and np.all(F_ineq @ points_intersection <= b_ineq):
                #: Add the vertex to the collection
                intersection_list.append(points_intersection)
    #: Return the result
    return intersection_list


def get_sorted_list_of_vertices(intersection_list, normal):
    #: Loop over all indices
    for idx in range(len(intersection_list) - 1):
        #: Compute the angle between the current vertex
        angle_to_current = np.array([vg.signed_angle(intersection_list[idx], vert, look=normal, units='rad').real for vert in intersection_list])
        #: Create the mask
        mask = np.zeros(len(intersection_list))
        mask[idx] = 1
        mask[angle_to_current < 0] = 1
        #: Compute the smallest index
        smallest_index = np.argmin(np.ma.array(angle_to_current, mask=mask))
        #: Reorder the list
        intersection_list[idx + 1], intersection_list[smallest_index] = intersection_list[smallest_index], intersection_list[idx + 1]
    #: Return the results
    return intersection_list


def get_dict_planes(box_bounds):
    #: Create the direction vectors
    xmin_1, xmin_2 = np.array([0, 1, 0]), np.array([0, 0, 1]) 
    xmax_1, xmax_2 = np.array([0, 1, 0]), np.array([0, 0, 1]) 
    ymin_1, ymin_2 = np.array([1, 0, 0]), np.array([0, 0, 1]) 
    ymax_1, ymax_2 = np.array([1, 0, 0]), np.array([0, 0, 1]) 
    zmin_1, zmin_2 = np.array([1, 0, 0]), np.array([0, 1, 0]) 
    zmax_1, zmax_2 = np.array([1, 0, 0]), np.array([0, 1, 0]) 
    #: Create the offset vectors
    xmin_offset = np.array([box_bounds[0][0], 0, 0])
    xmax_offset = np.array([box_bounds[0][1], 0, 0])
    ymin_offset = np.array([0, box_bounds[1][0], 0])
    ymax_offset = np.array([0, box_bounds[1][1], 0])
    zmin_offset = np.array([0, 0, box_bounds[2][0]])
    zmax_offset = np.array([0, 0, box_bounds[2][1]])
    #: Create the dictionary
    dict_planes = {'xmin': (xmin_1, xmin_2, xmin_offset), 'xmax': (xmax_1, xmax_2, xmax_offset), 'ymin': (ymin_1, ymin_2, ymin_offset), 'ymax': (ymax_1, ymax_2, ymax_offset), 'zmin': (zmin_1, zmin_2, zmin_offset), 'zmax': (zmax_1, zmax_2, zmax_offset)}
    #: return the results
    return dict_planes


# ------------ SCRIPT ------------

# Set the axis bounds
bounds_unsafe = ((-5, 5), (-3, 3), (-2, 2))

# Create some sample vectors
v_1 = np.array([1, -1, 0])
v_2 = np.array([1, 1, 1])
v_3 = np.array([3, 3, 0])

# Create for subset
origin = np.array([])
line = (v_1 + v_2)[:, np.newaxis]
plane = np.hstack((v_1[:, np.newaxis], v_2[:, np.newaxis]))
box = np.hstack((v_1[:, np.newaxis], v_2[:, np.newaxis], v_3[:, np.newaxis]))

# Plot the subspaces
_, _ = get_plot_subspace(origin, x_bounds=bounds_unsafe)
_, _ = get_plot_subspace(line, x_bounds=bounds_unsafe)
_, _ = get_plot_subspace(plane, x_bounds=bounds_unsafe)
_, _ = get_plot_subspace(box, x_bounds=bounds_unsafe)

# Show the figures
plt.show()
Sign up to request clarification or add additional context in comments.

Comments

Your Answer

By clicking “Post Your Answer”, you agree to our terms of service and acknowledge you have read our privacy policy.

Start asking to get answers

Find the answer to your question by asking.

Ask question

Explore related questions

See similar questions with these tags.