Euclidean Surfaces
Euclidean surfaces such as planes, spheres, cylinders and cones have been studied for centuries. They represent the most basic and commonly used surfaces in geometry.
Plane
The Euclidean plane is the most fundamental type of surface. There are several ways to define a plane presented below.
Parametric
A plane in 3D may be defined by its origin point o = [ox, oy, oz]
and two basis vectors such as x = [xx, xy, xz]
and y = [yx, yy, yz]
. In general, there is no other constraint than the vectors x
and y
being non-parallel. However, it is common to use orthonormal vectors, where |x| = |y| = 1
and x · y = 0
, because such as choice simplifies computations.
Points of the plane can be evaluated as linear combinations of the basis vectors as seen below. The plane's normal is constant independent of the u
and v
coordinates. Note that the definition below does not make any assumptions about the basis vectors being orthonormal or otherwise.
class PlaneParametric:
def __init__( self, origin, x_axis, y_axis ):
self.Origin = origin
self.XAxis = x_axis
self.YAxis = y_axis
def Evaluate( self, u, v ):
return ( self.Origin +
self.XAxis * u +
self.YAxis * v )
def Normal( self, u, v ):
return Vector3d.CrossProduct(
self.XAxis, self.YAxis )
Planes are typically considered as unbounded or infinite, however by introducing a range of allowable value per axis, also known as interval or domain, it is possible to define a bounded representation as seen below. This is a concept the can be applied to any parametric surface. Note that the parameters u
and v
are more natural to express normalized value within each domain, in the sense that values < 0
and > 1
denote points outside the planes bounds.
class Domain:
def __init__( self, v_min = 0.0, v_max = 1.0 ):
self.Min = v_min
self.Max = v_max
def Interpolate( self, t ):
return self.Min * ( 1 - t ) + self.Max * t
class PlaneParametricBounded( PlaneParametric ):
def __init__( self, origin, x_axis, y_axis,
x_domain = None, y_domain = None ):
PlaneParametric.__init__( self, origin, x_axis, y_axis )
self.XDomain = Domain( ) if( x_domain is None ) else x_domain
self.YDomain = Domain( ) if( y_domain is None ) else y_domain
def Evaluate( self, u, v ):
return ( self.Origin +
self.XAxis * self.XDomain.Interpolate( u ) +
self.YAxis * self.XDomain.Interpolate( v ) )
Rhino's definition of a plane follows the origin and basis vectors convention. However, the vectors are automatically orthonormalized.
""" Plane Construction
"""
plane = Plane(
Point3d( ox, oy, oz ),
Vector3d( xx, xy, xz ),
Vector3d( yz, yy, yz ) )
origin = plane.Origin
x_axis = plane.XAxis
y_axis = plane.YAxis
""" World Planes
"""
plane = Plane.WorldXY
plane = Plane.WorldYZ
plane = Plane.WorldZY
Implicit
A plane in 3D can be also defined using an origin point o
and a normal vector n
, that is a vector perpendicular to the plane. The normal vector is often assumed as unit-length |n| = 1
. A point p
on the plane satisfies the equation n · ( p - o ) = 0
, also known as the implicit form.
The dot product between the normal n
and a vector from the plane's origin o
to any point p
conveys the notion of a signed projected distance d = n · ( p - o )
to the plane. Therefore, the equation constraints points to be exactly on the plane by requiring their projected distance to be exactly zero d = 0
.
This definition is more compact but cannot be used in the parametric sense. However, for some application such requirement is not important. Below is an alternative representation and some useful methods regarding the relationship between a point the plane.
class PlaneImplicit:
def __init__( self, origin, normal ):
self.Origin = origin
self.Normal = normal
def SignedDistance( self, point ):
return self.Normal * ( point - self.Origin )
def Distance( self, point ):
return abs( self.SignedDistance( point ) )
def Project( self, point ):
return ( point + self.Normal *
-self.SignedDistance( point ) )
def ContainsPoint( self, point, epsilon = 1e-5 ):
return self.Distance( point ) <= epsilon
A plane is also associated with the concept of a half-space, because an unbounded or infinite plane may be considered as splitting space into two parts. Moreover, the plane's normal may be used to classify objects as being in front or behind the plane using the signed distance metric. Points with positive signed distance are in the front side of the plane and points with negative in back side.
class PlaneImplicit:
def __init__( self, origin, normal ):
self.Origin = origin
self.Normal = normal
def SignedDistance( self, point ):
return self.Normal * ( point - self.Origin )
def IsPointInFront( self, point ):
return self.SignedDistance( point ) > 0.0
def IsPointBehind( self, point ):
return self.SignedDistance( point ) < 0.0
It is possible to convert from a PlaneImplicit
to a PlaneParametric
by finding a pair of basis vectors x
and y
that are both orthogonal to the normal n
. However, this is slightly ill-defined because there is an infinite number of such vectors that are perpendicular to the normal. However, we can use the concept of the unary cross product to complete the basis vectors.
class PlaneImplicit:
def __init__( self, origin, normal ):
self.Origin = origin
self.Normal = normal
def ToParametric( self ):
x = Vector3d_UnaryCrossProduct( self.Normal )
y = Vector3d.CrossProduct( self.Normal, x )
x.Unitize( )
y.Unitize( )
return PlaneParametric( o, x, y )
A Plane
in Rhino can be constructed using an origin and a normal vector. Internally, the plane is represented by its parametric form and thus resolves the basis vectors using a deterministic approach similar to the one presented.
""" Plane by Origin & Normal
"""
plane = Plane( o, n )
x_axis = plane.XAxis
y_axis = plane.YAxis
Explicit
Yet, another form for a plane's equation is Ax + By + Cz + D = 0
, where A
, B
, C
, and D
real numbers, and x
, y
and z
are the point's coordinates p = [x, y, z]
. The coefficients seem geometrically void of semantics. However, expanding the implicit equation n · ( x - o ) = 0
, into n · x - n · o = 0
and further into nx * x + ny * y + nz * z - n · o = 0
it becomes evident that A = nx
, B = ny
, C = nz
. Therefore, the first three terms encode the normal vector, which may not be normalized. Moreover, the term D = -n · o
can be interpreted as an offset from the world's origin along the normal direction.
class PlaneExplicit:
def __init__( self, A, B, C, D ):
self.A = A
self.B = B
self.C = C
self.D = D
def Normal( self, u, v ):
return Vector3d( self.A, self.B, self.C )
def ToImplicit( self ):
o = Point3d( n * -self.D )
n = Vector3d( self.A, self.B, self.C )
return PlaneImplicit( o, n )
def ToParametric( self ):
o = Point3d( n * -self.D )
z = Vector3d( self.A, self.B, self.C )
x = Vector3d_UnaryCrossProduct( z )
y = Vector3d.CrossProduct( z, x )
x.Unitize( )
y.Unitize( )
return self.ToImplicit( o, x, y )
The explicit plane representation is the most compact requiring only 4 values as opposed to 6 and 9 for the implicit and parametric forms, respectively. It is typically used for solving systems of equations but for most other geometric constructions conversion to another form is required.
Rhino's Plane
also supports creating planes using their explicit form as seen below. Note that internally the parametric form is used.
""" Plane from Equation
"""
A = 1.0
B = 0.0
C = 0.0
D = 1.0
P = Plane( A, B, C, D )
Triangle
A plane may be defined by three points or a triangle in space. It is typically constructed with the first point as the origin and the triangle's sides defining the basis vectors by orthonormalization as seen below.
""" Triangle
"""
a = Point3d( ax, ay, az )
b = Point3d( bx, by, bz )
c = Point3d( cx, cy, cz )
""" Orthonormal Basis
"""
o = a #-- origin or ( a + b + c ) / 3
x = b - a #-- primary or a - o
w = c - b #-- auxiliary or c - a
z = Vector3d.CrossProduct( x, w )
y = Vector3d.CrossProduct( z, x )
x.Unitize( )
y.Unitize( )
plane = PlaneExplicit( o, x, y )
Rhino's Plane
supports construction from three points as seen below.
""" Triangle
"""
a = Point3d( ax, ay, az )
b = Point3d( bx, by, bz )
c = Point3d( cx, cy, cz )
plane = Plane( a, b, c )
Fitting
Constructing a plane using more than three points is known as plane fitting, where the plane is a form of approximation in the sense that it passes as close as possible by the points. There are several very interesting algorithms to compute fitted planes.
Rhino provides the static method FitPlaneToPoints
which computes a fitted plane as long the number of points is greater or equal to three.
""" Plane Fitting
"""
points = [
Point3d( 0.0, 0.0,-1.0 ),
Point3d( 1.0, 0.0, 0.0 ),
Point3d( 0.0, 1.0, 1.0 ),
Point3d( 1.0, 1.0, 2.0 )]
_, plane = Plane.FitPlaneToPoints( points )
Sphere
A sphere may be considered as an extension of the circle from the plane to space in the sense that it expresses points equidistance from a fixed location. Spheres are as fundamental as planes and the oldest surfaces studied of non-zero curvature, also known as non-Euclidean. There are several representations that parallel those of the circle but generally the implicit and parametric forms are more common.
Parametric
The parametric equation of a sphere is defined using trigonometric functions as S( u, v ) = r[cos( u ) * cos( v ), sin( u ) * sin( v ), sin( v )]
, with the parameters typically within u: [0, 2π]
or u: [-π, π]
and v: [-π/2, π/2]
.
class SphereParametric:
def __init__( self, basis, radius ):
self.Basis = basis
self.Radius = radius
def Evaluate( self, u, v ):
su, cu = math.sin( u ), math.cos( u )
sv, cv = math.sin( v ), math.cos( v )
x = self.Radius * cu * cv
y = self.Radius * su * cv
z = self.Radius * sv
return ( self.Basis.Origin +
self.Basis.XAxis * x +
self.Basis.YAxis * y +
self.Basis.ZAxis * z )
def NormalAt( self, u, v ):
su, cu = math.sin( u ), math.cos( u )
sv, cv = math.sin( v ), math.cos( v )
return ( self.Basis.XAxis * cu * cv +
self.Basis.YAxis * su * cv +
self.Basis.ZAxis * sv )
Recovering the u
and v
parameters from a point in space, in the closest point sense, is shown below. Note that u
is in [-π, π]
because of the math.atan2( )
. Moreover, for computing the parameter v
the expression min( max( -1, value ), 1 )
is used to ensure that the result is clamped within [-1, 1]
otherwise the math.asin( )
function will raise an exception.
class SphereParametric:
def __init__( self, basis, radius ):
self.Basis = basis
self.Radius = radius
def ClosestPoint( self, point ):
r = point - self.Basis.Origin
u = math.atan2( r.Y, r.X )
v = math.asin( min( max( -1, r.Z / r.Length ), 1 ) )
return ( u, v )
In Rhino Sphere
objects are internally parametric and can be constructed using a plane and a radius as seen below.
""" Sphere Construction
"""
sphere = Sphere( Plane.WorldXY, 1.0 )
""" Sphere Properties
"""
center = sphere.Center
radius = sphere.Radius
north = sphere.NorthPole
south = sphere.SouthPole
origin = sphere.EquitorialPlane.Origin
x_axis = sphere.EquitorialPlane.XAxis
y_axis = sphere.EquitorialPlane.YAxis
z_axis = sphere.EquitorialPlane.ZAxis
Implicit
The implicit form of the sphere is defined by an origin o
and a radius r
, expressing points p
in space constrained by |p - o| = r
. With this definition we can determine relationships between points in space and the sphere including their distance, projection and whether they are on the sphere or otherwise.
class SphereImplicit:
def __init__( self, origin, radius ):
self.Origin = origin
self.Radius = radius
def Distance( self, point ):
return self.Origin.DistanceTo( point )
def IsPointOnSphere( self, point, epsilon = 1e-5 ):
return abs( self.Distance( point ) - self.Radius ) <= epsilon
def ProjectPoint( self, point ):
u = point - self.Origin; u.Unitize( )
return self.Origin + u * self.Radius
In the same way a plane splits space in front and back parts, a sphere splits also splits space but in the inside and outside sense. Exactly because of this feature spheres may considered as both surfaces and solids at the same time.
class SphereImplicit:
def __init__( self, origin, radius ):
self.Origin = origin
self.Radius = radius
def IsPointInside( self, point ):
return self.Distance( point ) < self.Radius
def IsPointOutside( self, point ):
return self.Distance( point ) > self.Radius
A sphere in Rhino can be constructed from a center point and a radius as seen below.
""" Sphere Construction
"""
sphere = Sphere( Point3d.Origin, 1.0 )
""" Sphere Properties
"""
center = sphere.Center
radius = sphere.Radius
Stereographic
The stereographic form is a representation of a sphere analogous to the rational form of a circle. Suppose a sphere is defined by a basis { o, x, y, z }
and radius r
as in the parametric case. Its north and south poles are n = o + z * r
and s = o - z * r
, respectively. Any point p
in space can be parameterized as q = [u, v]
by the intersection of a line from s
to p
and the tangent plane at n
, see relevant information.
The implementation below provides the Evaluate( )
and ClosestPoint( )
methods in a constructive geometry sense. Note that as in the case of the rational parameterization of the circle, a point is missing from this representation, namely the point at the south pole.
class SphereStereo:
def __init__( self, basis, radius ):
self.Basis = basis
self.Radius = radius
def _Expand( self ):
o = self.Basis.Origin
x = self.Basis.XAxis
y = self.Basis.YAxis
z = self.Basis.ZAxis
r = self.Radius
n = o + z * r #-- North Pole
s = o - z * r #-- South Pole
return ( o, x, y, z, r, n, s )
def Evaluate( self, u, v ):
o, x, y, z, r, n, s = self._Expand( )
p = n + x * u + y * v #-- Point in North Plane
w = p - s #-- Line from South
d = w.Length #-- Distance s -> p
l = w * z * r / d #-- Proj z*r on w/|w|
q = s + w * l * 2 / d #-- Point on Sphere
return q
def ClosestPoint( self, point ):
o, x, y, z, r, n, s = self._Expand( )
w = point - s #-- Line from South
d = w.Length #-- Distance s -> p
h = z * w #-- Vertical Height
f = 2 * r * d / h #-- Similar Triangles
q = s + w * f / d #-- Point in North Plane
m = q - n #-- Vector in North Plane
u = x * m #-- Parameter U
v = y * m #-- Parameter V
return ( u, v )
Tetrahedron
A sphere may be defined using four non-coplanar points in space or tetrahedron's vertices. There are at least two approaches in finding the origin o = [x, y, z]
and radius r
of the sphere, that is by geometric construction or solving a system of equations.
Fitting
A sphere that passes as close as possible to a number of points greater than four can be constructed via fitting. There are several algorithms available.
Rhino provides the FitSphereToPoints
for fitting spheres to point clouds.
""" Sphere Fitting
"""
points = [
Point3d( 0.0, 0.0,-1.0 ),
Point3d( 1.0, 0.0, 0.0 ),
Point3d( 0.0, 1.0, 1.0 ),
Point3d( 1.0, 1.0, 2.0 ),
Point3d( 0.5, 0.0, 0.5 )]
sphere = Sphere.FitSphereToPoints( points )
Coordinates
The parametric sphere's Evaluate( )
and ClosestPoint( )
methods provide the principles for converting between Cartesian [x, y, z]
and spherical coordinates [r, t, p]
, where r
, ρ
or rho is the radial coordinate, t
, θ
or theta is the azimuthal angle, and p
, φ
or phi is the polar angle, as seen below.
def Point3d_FromSphere( r, t, p ):
sp, cp = math.sin( p ), math.cos( p )
st, ct = math.sin( t ), math.cos( t )
return ( r * cp * ct +
r * sp * ct +
r * st )
def Point3d_ToSphere( x, y, z ):
r = math.sqrt( x * x + y * y + z * z )
t = math.asin( min( max( -1, z / r ), 1 ) )
p = math.atan2( y, x )
return ( r, t, p )
Spheres in Rhino may be represented using either a Sphere
, NurbSuface
or Brep
objects. However, note that surfaces and breps do not retain their center and radius properties.
""" Sphere Conversions
"""
surface = sphere.ToNurbsSurface( )
surface = NurbsSurface.CreateFromSphere( sphere )
solid = sphere.ToBrep( )
solid = Brep.CreateFromSphere( sphere )
Ellipsoid
The ellipsoid has an analogous relationship with the sphere in the same way an ellipse relates to a circle. It thus requires three different radii, one per direction, instead of just one. Those may be considered as scaling factors for stretching a sphere in a non-uniform fashion.
Parametric
The parametric ellipsoid is nearly identical with a sphere requiring a basis { o, x, y, z }
, where o
is the origin and x
, y
and z
are orthonormal basis vectors, apart from the radius member which is a 3D vector r = [rx, ry, rz]
instead of a float
. The parameters u
and v
are both angles in [0, 2π]
or [-π, π]
.
class EllipsoidParametric:
def __init__( self, basis, radius ):
self.Basis = basis
self.Radius = radius
def Evaluate( self, u, v ):
su, cu = math.sin( u ), math.cos( u )
sv, cv = math.sin( v ), math.cos( v )
x = self.Radius.X * cu * cv
y = self.Radius.Y * su * cv
z = self.Radius.Z * sv
return ( self.Basis.Origin +
self.Basis.XAxis * x +
self.Basis.YAxis * y +
self.Basis.ZAxis * z )
Cylinder
Cylinders combine a periodic and a linear motion. The canonical right cylinder follows a circular motion in a plane and a linear in the normal direction. Alternatively, a cylinder expresses points in space with a fixed distance from a line. Yet another form expresses the cylinder as the surface of revolution between two parallel lines. A generalized cylinder is a surface described by a profile and a direction of extrusion, in the sense that the periodic curve does not need to be a circle and the axis does not have to be orthogonal to the profile.
Parametric
The parametric form requires a basis plane { o, x, y }
, where o
is the origin and x
and y
the basis vectors, and the radius of the circular base profile r
. The linear motion is assumed in the perpendicular direction z = x × y
. The parameter u
is an angle and thus in [0, 2π]
, while the parameter v
can take any real value. Note that the parameters are heterogenous.
class CylinderParametric:
def __init__( self, basis, radius ):
self.Basis = basis
self.Radius = radius
def Evaluate( self, u, v ):
return ( self.Basis.Origin +
self.Basis.XAxis * self.Radius * math.cos( u ) +
self.Basis.YAxis * self.Radius * math.sin( u ) +
self.Basis.ZAxis * v )
Implicit
Implicitly a cylinder may be constructed from an axis and a radius { o, z, r }
, where o
is the origin, z
is the unit direction of the axis, and r
is the radius. With those, points on a cylinder are expressed as |z × w| = r
or |z · w · z - w| = r
, where w = p - o
. Both constraint points in space such that their distance from a line has the desired radius, using either the cross or dot products to measure the projected distance.
class CylinderImplicit:
def __init__( self, origin, axis, radius ):
self.Origin = origin
self.Axis = axis
self.Radius = radius
def Distance( self, point ):
return Vector3d.CrossProduct( self.Axis,
point - self.Origin ).Length
def Project( self, point ):
return ( self.Origin + self.Axis *
( point - self.Origin ) * self.Axis )
def ContainsPoint( self, point, epsilon = 1-5 ):
return self.Distance( point ) <= epsilon
def IsPointInside( self, point ):
return self.Distance( point ) < self.Radius
def IsPointOutside( self, point ):
return self.Distance( point ) > self.Radius
Cone
Cones combine a periodic and a linear motion but unlike cylinders the two motions are coupled. Alternatively, a cone can be defined as the surface of revolution between two lines that intersect.
Parametric
The parametric cone requires a basis plane { o, x, y }
, where o
is the origin and x
and y
the basis vectors, the radius of the circular profile r
and the included angle a
. The linear motion is assumed in the perpendicular direction z = x × y
. The parameter u
is an angle and thus in [0, 2π]
while the parameter v
is the distance from the cone's apex and thus it can take any real value. Note that the relationship between the distance along the axis and radius of the circle at that height is given by radius = distance * tan( angle / 2 )
.
class ConeParametric:
def __init__( self, basis, radius, angle ):
self.Basis = basis
self.Radius = radius
self.Alpha = math.tan( angle / 2 )
def Evaluate( self, u, v ):
r = v * self.Alpha
x = r * math.cos( u )
y = r * math.sin( u )
z = v
return ( self.Basis.Origin +
self.Basis.XAxis * x +
self.Basis.YAxis * y +
self.Basis.ZAxis * z )
Torus
A torus combines two coupled circular motions. Alternatively, it can be considered as the surface of revolution of a circle about a line in the same plane as the circle.
Parametric
The parametric torus requires a basis { o, x, y, z }
, where o
is the origin and x
, y
and z
are orthonormal basis vectors, as well as the major
and minor
radii. The parameters u
and v
are both angles in [0, 2π]
or [-π, π]
.
class TorusParametric:
def __init__( self, basis, major, minor ):
self.Basis = basis
self.Major = major
self.Minor = minor
def Evaluate( self, u, v ):
su, cu = math.sin( u ), math.cos( u )
sv, cv = math.sin( v ), math.cos( v )
x = ( self.Major + self.Minor * cv ) * cu
y = ( self.Major + self.Minor * cv ) * su
z = self.Minor * sv
return ( self.Basis.Origin +
self.Basis.XAxis * x +
self.Basis.YAxis * y +
self.Basis.ZAxis * z )