# Copyright 2021 United States Government as represented by the Administrator of the National Aeronautics and Space
# Administration. No copyright is claimed in the United States under Title 17, U.S. Code. All Other Rights Reserved.
"""
This module provides some basic utilities for working with the ray tracer in GIANT.
Use
---
In general you won't use the utilities provided herein directly, as they are dispersed throughout the ray tracer in the
areas where they are needed already. That being said, everything in here should be useable with clear documentation if
you have some non-typical use.
"""
import copy
from typing import Tuple, Optional, Union
import numpy as np
from giant.ray_tracer.rays import Rays
from giant.ray_tracer.shapes.ellipsoid import Ellipsoid
from giant.ray_tracer.shapes.triangle import Triangle64, Triangle32
from giant.ray_tracer.shapes.shape import Shape
from giant._typing import Real, ARRAY_LIKE
[docs]def find_limbs(shape: Shape, scan_center_dir: ARRAY_LIKE, scan_dirs: ARRAY_LIKE,
observer_position: Optional[ARRAY_LIKE] = None, initial_step: Real = 1, max_iterations: int = 25,
rtol: Real = 1e-12, atol: Real = 1e-12) -> np.ndarray:
r"""
This helper function determines the limb points for any traceable shape (visible edge of the shape) that would be
visible for an observer located at ``observer_position`` looking toward ``scan_center_dir`` along the
directions given by ``scan_dirs``.
Typically it is assumed that the location of the observer is at the origin of the current frame and therefore
``observer_position`` can be left as ``None``.
The limb for the surface is found iteratively by tracing rays from the observer to the shape. First, the
rays are traced along the scan center direction, which should beg guaranteed to strike the shape. Then, we
adjust the direction of the rays so that they no longer intersect the surface using ``initial_step``.
We then proceed by tracing rays with directions half way between the left rays (guaranteed to strike the surface)
and the right rays (guaranteed to not strike the surface) updating the left and right rays based on the result of
the last trace. This continues for a maximum of ``max_iterations`` or until the tolerances specified by ``rtol``
and ``atol`` are met for the change in the estimate of the limb location. The returned limb location is the last
ray intersect location that hit the surface for each ``scan_dirs``.
The returned limbs are expressed as vectors from the observer to the limb point in the current frame as a 3xn
numpy array.
:param shape: The target shape that we are to find the limb points for as a :class:`.Shape`
:param scan_center_dir: the unit vector which the scan is to begin at in the current frame as a length 3 array
A ray cast along this unit vector from the ``observer_position`` should be guaranteed
to strike the surface and ideally should be towards the center of figure of the surface
:param scan_dirs: the unit vectors along with the scan is to proceed as a 3xn array in the current frame where
each column represents a new limb point we wish to find (should be nearly orthogonal to the
``scan_center_dir`` in most cases).
:param observer_position: The location of the observer in the current frame. If ``None`` then it is assumed
the observer is at the origin of the current frame
:param initial_step: The size of the initial step to take along the ``scan_dirs`` direction. This should be
guaranteed to result in rays that do not strike the body.
:param max_iterations: The maximum number of iteration steps to take when locating the limbs
:param rtol: The relative tolerance of the change in the limb location from one iteration to the next that
indicates convergence.
:param atol: the absolute tolerance of the change int he limb location from one iteration to the next that
indicates convergence.
:return: the vectors from the observer to the limbs in the current frame as a 3xn array
"""
if observer_position is not None:
single_start = np.array(observer_position).reshape(3, 1)
else:
single_start = np.zeros(3, dtype=np.float64)
start = np.broadcast_to(single_start, (3, scan_dirs.shape[1]))
left_rays = Rays(start, np.array([scan_center_dir] * scan_dirs.shape[1]).T)
right_rays = Rays(start, scan_center_dir.reshape(3, 1) + scan_dirs * initial_step)
res: np.ndarray = shape.trace(left_rays)
old_res = res
for ind in range(max_iterations):
trace_rays = copy.copy(left_rays)
trace_rays.direction = left_rays.direction + (right_rays.direction - left_rays.direction) / 2
res = shape.trace(trace_rays)
keep_right = res["check"]
left_rays.direction[:, keep_right] = trace_rays.direction[:, keep_right]
right_rays.direction[:, ~keep_right] = trace_rays.direction[:, ~keep_right]
converged = np.zeros(res.size, dtype=np.bool)
converged[keep_right] = (np.abs(old_res[keep_right] - res[keep_right]) <=
atol + rtol*np.abs(res[keep_right])).all(axis=0)
converged[~keep_right] = (np.abs(right_rays.direction[:, ~keep_right] - left_rays.direction[:, ~keep_right]) <=
atol + rtol*np.abs(left_rays.direction[:, ~keep_right])).all(axis=0)
if converged.all():
break
final_rays = left_rays[~res["check"]]
final_res = shape.trace(final_rays)
res["intersect"][~res["check"]] = final_res["intersect"]
return res["intersect"].T - observer_position.reshape(3, 1)
[docs]def compute_com(tris: Union[Triangle64, Triangle32]) -> np.ndarray:
"""
This function computes the center of mass assuming uniform density for a triangle tesselated surface.
The center of mass is found by finding the center of volume (since with uniform density this is equivalent to the
center of mass). This is done by computing the volume of each tetrahedron formed by a face connected to the center
of figure of the shape (note that therefore very irregular shapes cannot be analyzed by this function). These
volumes are then used in the usual center of mass equation (multiply the volumes times the volume centroids, take
the sum, and divide by the sum of the volumes).
:param tris: the triangular tessellated surface which we are to compute the uniform density center of mass for
:return: the center of mass of the surface as a length 3 numpy array expressed in the current frame
"""
# compute the volume of each pyramid
# D = (x0-x).T@n where x0 is a vertex from the triangle (in this case vertex 0),
# x is the point we want the surface for (in this case the origin),
# and n is the normal vector
sv = tris.stacked_vertices
heights = np.abs((sv[..., 0]*tris.normals).sum(axis=-1))
# determine the area of each triangle
areas = np.linalg.norm(np.cross(tris.sides[..., 0], tris.sides[..., 1]).reshape(-1, 3), axis=-1)/2
volumes = areas * heights/3
# determine the centroid of each triangle
tri_cents = sv.sum(axis=-1)/3
# determine the centroid of each pyramid
# this is 3/4 of the distance from the apex (origin) to the centeroid of the base
pyr_cents = 3/4 * tri_cents
# determine the centroid of the body
com = (pyr_cents * volumes.reshape(-1, 1)).sum(axis=0)/volumes.sum()
return com
[docs]def ref_ellipse(verts: np.ndarray) -> Ellipsoid:
r"""
This function finds the best fit ellipsoid to a set of vertices (minimizing the algebraic distance residuals).
This is done by solving the least squares equation
.. math::
\left[\begin{array}{ccccccccc} \mathbf{x}^2+\mathbf{y}^2-2\mathbf{z}^2 &
\mathbf{x}^2 + \mathbf{z}^2 - 2\mathbf{y}^2 &
2\mathbf{x}*\mathbf{y} &
2\mathbf{x}*\mathbf{z} &
2\mathbf{y}*\mathbf{z} &
2\mathbf{x} &
2\mathbf{y} &
2\mathbf{z} &
\mathbf{1} \end{array}\right]\left[\begin{array}{c} A\\ B \\ C \\ D \\ E \\ F \\ G \\ H \\ I\end{array}\right] =
\mathbf{x}^2+\mathbf{y}^2+\mathbf{z}^2
Given the solution, we then form the matrix
.. math::
\mathbf{A} = \left[\begin{array}{cccc} A+B+C-1 & C & D & F \\
C & A-2B-1 & E & G \\
D & E & B-2A-1 & H \\
F & G & H & I \end{array}\right]
We then have that the center of the ellipse is found by solving the 3x3 system
.. math::
-\mathbf{A}_{0:2,0:2}\mathbf{c} = \left[\begin{array}{ccc} F \\ G \\ H \end{array}\right]
and the ellipsoid matrix is found according to
.. math::
\mathbf{T} = \left[\begin{array}{cccc} 1 & 0 & 0 & 0 \\
0 & 1 & 0 & 0 \\
0 & 0 & 1 & 0 \\
c_0 & c_1 & c_2 & 1\end{array}\right] \\
\mathbf{R} = \mathbf{T}\mathbf{A}\mathbf{T}^T \\
\mathbf{E} = -\frac{\mathbf{R}_{0:3,0:3}}{r_{3,3}}
where :math:`\mathbf{E}` is the ellipsoid matrix.
:param verts: The vertices to fit the ellipsoid to as a (n x 3) array of points
:return: An :class:`.Ellipsoid` object that represents the best fit to the supplied vertices
"""
x, y, z = verts.T
x2 = x**2
y2 = y**2
z2 = z**2
xy = x*y
xz = x*z
yz = y*z
hmat = np.vstack([x2 + y2 - 2*z2,
x2 + z2 - 2*y2,
2*xy,
2*xz,
2*yz,
2*x,
2*y,
2*z,
np.ones(x.shape)]).T
rhs = x2 + y2 + z2
solu = np.linalg.lstsq(hmat, rhs, rcond=None)[0]
coefs = np.zeros(10)
coefs[0] = solu[:2].sum() - 1
coefs[1] = solu[0] - 2 * solu[1] - 1
coefs[2] = solu[1] - 2 * solu[0] - 1
coefs[3:] = solu[2:]
amat = np.array([[coefs[0], coefs[3], coefs[4], coefs[6]],
[coefs[3], coefs[1], coefs[5], coefs[7]],
[coefs[4], coefs[5], coefs[2], coefs[8]],
[coefs[6], coefs[7], coefs[8], coefs[9]]])
center = -np.linalg.solve(amat[:3, :3], coefs[6:9])
tmat = np.eye(4)
tmat[-1, :3] = center
rmat = tmat@amat@tmat.T
ellipsmat = -rmat[:3, :3] / rmat[-1, -1]
return Ellipsoid(center=center, ellipsoid_matrix=ellipsmat)
[docs]def to_block(vals: ARRAY_LIKE) -> np.ndarray:
"""
This helper function takes a list of lists/arrays and puts them all into a single contiguous array
This is used when ray tracing rays that have ignore lists of different lengths. It works by iterating through,
determining the maximum length of the "rows", creating a new array of -1 with this many columns, and then assigning
each row to this new array.
For instance, an input of the form
.. code::
[[1, 2, 3], [0, 1], [7, 8, 3, 4]]
will be converted to
.. code::
np.array([[1, 2, 3, -1], [0, 1, -1, -1], [7, 8, 3, 4]])
:param vals: A ragged list of lists or arrays that is to be converted into a single contiguous array
:return: the contiguous block
"""
# check to see if we already have a contiguous array
if isinstance(vals, np.ndarray):
if vals.ndim == 2:
return vals.astype(np.int64)
else:
return vals.reshape(-1, 1).astype(np.int64)
# determine how many rows there are
n_rows = len(vals)
# make each row a 1d numpy array
vals_one_d = list(map(np.atleast_1d, vals))
# determine the maximum number of columns in any row
n_cols = max(row.size for row in vals_one_d)
# create an output array that is n_rows x n_cols
out = -np.ones((n_rows, n_cols), dtype=np.int64)
# copy each row into the appropriate columns in the output array
for ind, row in enumerate(vals_one_d):
out[ind, :row.size] = row
return out
[docs]def compute_stats(tris: Union[Triangle64, Triangle32], mass: Real) -> Tuple[np.ndarray, float, float, np.ndarray,
np.ndarray, np.ndarray, np.ndarray]:
"""
Compute statistics on a shape tessellated using Triangles.
The statistics computed include the center of mass, total volume, surface area, Inertia matrix, center of mass
relative inertia matrix, moments of inertia, and rotation matrix to the inertia frame. These are only valid for
mostly-regular bodies, where the tetrahedrons formed by connecting each face to the center of figure of the shape
is contained entirely within the shape.
:param tris: The GIANT triangle objects to compute the statistics on
:param mass: The mass of the object in kg
:return: A tuple of the statistics in the order mentioned above
"""
# compute the volume of each pyramid
# D = (x0-x).T@n where x0 is a vertex from the triangle (in this case vertex 0),
# x is the point we want the surface for (in this case the origin),
# and n is the normal vector
sv = tris.stacked_vertices
heights = np.abs((sv[..., 0] * tris.normals).sum(axis=-1))
# determine the area of each triangle
areas = np.linalg.norm(np.cross(tris.sides[..., 0], tris.sides[..., 1]).reshape(-1, 3), axis=-1) / 2
volumes = areas * heights / 3
# determine the centroid of each triangle
tri_cents = sv.sum(axis=-1) / 3
# determine the centroid of each pyramid
# this is 3/4 of the distance from the apex (origin) to the centeroid of the base
pyr_cents = 3 / 4 * tri_cents
# determine the centroid of the body
volume = volumes.sum()
com = (pyr_cents * volumes.reshape(-1, 1)).sum(axis=0) / volume
# determine the density for the object
density = mass / volume
# determine the product of inertia for each facet
vert0 = sv[..., 0].T
vert1 = sv[..., 1].T
vert2 = sv[..., 2].T
# compute the outer products between the sides
op00 = np.einsum('ij,jk->jik', vert0, vert0.T)
op11 = np.einsum('ij,jk->jik', vert1, vert1.T)
op22 = np.einsum('ij,jk->jik', vert2, vert2.T)
op01 = np.einsum('ij,jk->jik', vert0, vert1.T)
op10 = op01.swapaxes(-1, -2)
op02 = np.einsum('ij,jk->jik', vert0, vert2.T)
op20 = op02.swapaxes(-1, -2)
op12 = np.einsum('ij,jk->jik', vert1, vert2.T)
op21 = op12.swapaxes(-1, -2)
i_product = (volumes.reshape(-1, 1, 1) * (2 * op00 + 2 * op11 + 2 * op22 + op01 +
op10 + op02 + op20 + op12 + op21)).sum(axis=0) * density / 20
# compute the moments of inertia
ixx = i_product[[1, 2], [1, 2]].sum()
iyy = i_product[[0, 2], [0, 2]].sum()
izz = i_product[[0, 1], [0, 1]].sum()
ixy = iyx = -i_product[0, 1]
ixz = izx = -i_product[0, 2]
iyz = izy = -i_product[1, 2]
# form the inertia matrix
i_matrix = np.array([[ixx, ixy, ixz], [iyx, iyy, iyz], [izx, izy, izz]])
# determine the inertia matrix with respect to the center of mass
i_matrix_com = i_matrix - mass * (np.diag([(com ** 2).sum()] * 3) - np.outer(com, com))
# determine the moments and rotation matrix
moments, rotation_matrix = np.linalg.eigh(i_matrix_com)
return com, volume, areas.sum(), i_matrix, i_matrix_com, moments, rotation_matrix