GeometryHelper.SolidGeometry 4.0.0

Suggested Alternatives

GeometryHelper

Additional Details

merge project

The owner has unlisted this package. This could mean that the package is deprecated, has security vulnerabilities or shouldn't be used anymore.
dotnet add package GeometryHelper.SolidGeometry --version 4.0.0
                    
NuGet\Install-Package GeometryHelper.SolidGeometry -Version 4.0.0
                    
This command is intended to be used within the Package Manager Console in Visual Studio, as it uses the NuGet module's version of Install-Package.
<PackageReference Include="GeometryHelper.SolidGeometry" Version="4.0.0" />
                    
For projects that support PackageReference, copy this XML node into the project file to reference the package.
<PackageVersion Include="GeometryHelper.SolidGeometry" Version="4.0.0" />
                    
Directory.Packages.props
<PackageReference Include="GeometryHelper.SolidGeometry" />
                    
Project file
For projects that support Central Package Management (CPM), copy this XML node into the solution Directory.Packages.props file to version the package.
paket add GeometryHelper.SolidGeometry --version 4.0.0
                    
#r "nuget: GeometryHelper.SolidGeometry, 4.0.0"
                    
#r directive can be used in F# Interactive and Polyglot Notebooks. Copy this into the interactive tool or source code of the script to reference the package.
#:package GeometryHelper.SolidGeometry@4.0.0
                    
#:package directive can be used in C# file-based apps starting in .NET 10 preview 4. Copy this into a .cs file before any lines of code to reference the package.
#addin nuget:?package=GeometryHelper.SolidGeometry&version=4.0.0
                    
Install as a Cake Addin
#tool nuget:?package=GeometryHelper.SolidGeometry&version=4.0.0
                    
Install as a Cake Tool

GeometryHelper.SolidGeometry

NuGet Version License: MIT

A 3D geometry library for engineering models, written in C# and targeting netstandard2.0.

It is the 3D counterpart of GeometryHelper.PlaneGeometry and follows the same shape: immutable geometry types, operations living in static classes under Core, and every operation mirrored as an instance method on the type it applies to. The two are independent — neither references the other, and what they share arrives through GeometryHelper.CommonGeometry.

Installation

dotnet add package GeometryHelper.SolidGeometry

Structure

Namespace Contents
GeometryHelper.SolidGeometry.Geometry GeoPoint3, GeoVector3, GeoLine3, GeoRay3, GeoPlane3, GeoTriangle3, GeoPolyline3, GeoPolygon3, GeoCircle3, GeoFace3, GeoAabb3, GeoObb3, GeoSolid3, GeoCoordinateSystem3, GeoTransform3
GeometryHelper.SolidGeometry.Core Boolean3, Collision3, Containment3, Distance3, Intersection3, Merge3, Parallel3, Parametrization3, Projection3, Splition3
GeometryHelper.SolidGeometry.Spatial GeoBvh3
GeometryHelper.SolidGeometry.Extension EnumerableExtension
GeometryHelper.CommonGeometry Tolerance
GeometryHelper.CommonGeometry.Datatype Angle
GeometryHelper.CommonGeometry.Enums PointLocation, PlaneSide

The last three come from the GeometryHelper.CommonGeometry package, which GeometryHelper.PlaneGeometry shares, so a program working in both dimensions sees one Tolerance and one Angle rather than two of each. It comes with this package as a dependency.

Quick Start

using GeometryHelper.CommonGeometry;
using GeometryHelper.SolidGeometry.Geometry;

var a = new GeoPoint3(0, 0, 0);
var b = new GeoPoint3(3, 4, 0);

double distance = a.DistanceTo(b);              // 5
GeoVector3 direction = a.GetVectorTo(b);        // [3, 4, 0]

var plane = new GeoPlane3(GeoPoint3.Origin, GeoVector3.ZAxis);
GeoPoint3 flat = plane.Project(new GeoPoint3(2, 3, 7));      // (2, 3, 0)
double signed = plane.SignedDistanceTo(new GeoPoint3(2, 3, 7)); // 7

var box = new GeoObb3(GeoPoint3.Origin, 10, 20, 30);
double volume = box.Volume;                     // 6000

Geometric Types

The shapes split into three families, and which family a shape belongs to decides what you can ask of it:

Family Types Encloses an area Encloses a volume
Curve GeoLine3, GeoRay3, GeoPolyline3 no no
Planar region GeoTriangle3, GeoPolygon3, GeoCircle3, GeoFace3 yes no
Volume GeoObb3, GeoAabb3, GeoSolid3 yes yes

Only regions and volumes offer Contains. Every shape offers Locate, and a curve can only ever answer OnSide or OutSide, because a curve has no interior for a point to be inside of.

A GeoPolyline3 is always an open chain — it has no IsClosed flag and never joins its last vertex back to its first. Geometry meant to enclose something is a GeoPolygon3, and polyline.ToPolygon() converts between them. A chain of vertices tracing a square still holds only the points on its path:

var traced = new GeoPolyline3(
    new GeoPoint3(0, 0, 0), new GeoPoint3(10, 0, 0),
    new GeoPoint3(10, 10, 0), new GeoPoint3(0, 10, 0), new GeoPoint3(0, 0, 0));

var middle = new GeoPoint3(5, 5, 0);

traced.IsPointOn(middle);              // false — a curve has no interior
traced.DistanceTo(middle);             // 5    — measured to the path
traced.ToPolygon().Contains(middle);   // true — now it is a region
traced.ToPolygon().DistanceTo(middle); // 0

A planar region is flat, so a point counts as inside it only when it lies on the carrier plane as well as within the boundary. A point hovering above the middle of a polygon is outside it:

var square = new GeoPolygon3(
    new GeoPoint3(0, 0, 0), new GeoPoint3(10, 0, 0),
    new GeoPoint3(10, 10, 0), new GeoPoint3(0, 10, 0));

square.Locate(new GeoPoint3(5, 5, 0));  // Inside
square.Locate(new GeoPoint3(5, 0, 0));  // OnSide
square.Locate(new GeoPoint3(5, 5, 3));  // OutSide — off the plane

Flatness is enforced

A polygon that is not flat has no normal, no area and no interior, so GeoPolygon3 refuses one at construction rather than letting every property on it become quietly meaningless. Geometry that wanders out of a plane is a GeoPolyline3.

// Throws ArgumentException: these four vertices do not share a plane.
new GeoPolygon3(
    new GeoPoint3(0, 0, 0), new GeoPoint3(10, 0, 0),
    new GeoPoint3(10, 10, 0), new GeoPoint3(0, 10, 5));

How flat is flat enough is Tolerance.EqualPlanar, which is separate from EqualPoint because coplanarity is measured far from the reference point: a polygon several metres across turns a hundredth of a degree of tilt into a deviation of nearly a millimetre.

Two kinds of box

GeoAabb3 is an axis-aligned bound — the cheap test that comes before the expensive one. GeoObb3 is an oriented shape that carries its own axes and describes a beam running at an angle tightly, where an axis-aligned box would only bound it loosely.

var bounds = GeoAabb3.FromPoints(new[]
{
    new GeoPoint3(1, 5, -2),
    new GeoPoint3(-3, 0, 4),
});

bounds.Min;    // (-3, 0, -2)
bounds.Max;    // (1, 5, 4)
bounds.Volume; // 4 * 5 * 6 = 120

The axes of a GeoObb3 are made orthonormal on the way in, so a Y direction that is not quite square to X is corrected rather than producing a skewed box:

var box = new GeoObb3(
    GeoPoint3.Origin, 2, 2, 2,
    GeoVector3.XAxis,
    new GeoVector3(0.5, 1, 0));   // not square to X

box.AxisX.IsPerpendicularTo(box.AxisY);  // true
box.AxisX.CrossProduct(box.AxisY).IsEqualTo(box.AxisZ); // true

Solids and openings

A GeoSolid3 is the set of faces bounding a body. An opening is a whole solid subtracted from it — a duct through a slab, a recess in a footing — which is not the same thing as a hole in a GeoFace3, which is flat and belongs to a single face.

GeoSolid3 slab = new GeoAabb3(GeoPoint3.Origin, new GeoPoint3(10, 10, 10)).ToObb().ToSolid();
GeoSolid3 duct = new GeoAabb3(new GeoPoint3(4, 4, 4), new GeoPoint3(6, 6, 6)).ToObb().ToSolid();

GeoSolid3 pierced = slab.WithOpenings(new[] { duct });

pierced.Volume;     // 1000 — the gross body
pierced.NetVolume;  //  992 — with the duct removed
pierced.IsClosed(); // true

pierced.Locate(new GeoPoint3(1, 1, 1)); // Inside
pierced.Locate(new GeoPoint3(5, 5, 5)); // OutSide — inside the duct

Volume is measured by the divergence theorem, so it does not depend on where the solid sits and is reported unsigned: faces wound inwards give the same answer as faces wound outwards. It does depend on the boundary being closed, which is what IsClosed() is for.

Operations

Class Answers
Parallel3 is this parallel, perpendicular, coplanar with that
Containment3 where does a point sit relative to a shape
Distance3 how far apart are two shapes
Projection3 which point of a shape is closest to a point
Intersection3 where exactly do two shapes meet
Collision3 do two shapes overlap at all
Parametrization3 which point sits at a position along a curve
Merge3 put curves that meet end to end back together
Splition3 cut a curve, a region or a body into pieces
Boolean3 join two bodies, keep what they share, take one out of the other

Every operation is reachable both ways. The static form names the larger shape first and the point or curve second; the instance form sits on whichever of the two reads better where you are calling from:

var line = new GeoLine3(new GeoPoint3(0, 0, 0), new GeoPoint3(10, 0, 0));
var point = new GeoPoint3(4, 3, 0);

Distance3.DistanceTo(line, point);   // 3
line.DistanceTo(point);              // 3
point.DistanceTo(line);              // 3

Intersection3 returns a single result and reports false when there is not exactly one. Two shapes that overlap along a whole line or a whole area — a segment lying in a plane, two coincident planes, two collinear segments — have no single crossing to name, so they come back false rather than picking an arbitrary point out of the overlap:

var crossing = new GeoLine3(new GeoPoint3(0, 0, -5), new GeoPoint3(0, 0, 5));
var lyingIn  = new GeoLine3(new GeoPoint3(0, 0, 0), new GeoPoint3(10, 0, 0));

crossing.TryIntersectWith(GeoPlane3.XY, out GeoPoint3 hit); // true, hit is (0, 0, 0)
lyingIn.TryIntersectWith(GeoPlane3.XY, out _);              // false

Collision3 answers yes or no and says nothing about where. Two oriented boxes are tested with the separating axis theorem, which needs the nine cross-product axes as well as the six face axes to catch two boxes passing each other at an angle:

var first  = new GeoObb3(GeoPoint3.Origin, 10, 10, 10);
var beside = new GeoObb3(new GeoPoint3(5, 0, 0), 10, 10, 10);
var apart  = new GeoObb3(new GeoPoint3(11, 0, 0), 10, 10, 10);

first.CollidesWith(beside); // true
first.CollidesWith(apart);  // false

Parametrization

A parameter is normalized: 0 is the start of a curve and 1 its end. A distance is a true arc length from the start. Values outside the natural range follow the shape of the curve — a line segment extrapolates along the infinite line carrying it, a polyline clamps because an open chain has no single direction to extend along, and a circle wraps.

var segment = new GeoLine3(new GeoPoint3(0, 0, 0), new GeoPoint3(10, 0, 0));
segment.GetPointAtParameter(2.0);   // (20, 0, 0) — extrapolates

var chain = new GeoPolyline3(
    new GeoPoint3(0, 0, 0), new GeoPoint3(3, 0, 0), new GeoPoint3(3, 4, 0));
chain.GetPointAtDistance(5.0);      // (3, 2, 0)
chain.GetPointAtDistance(100.0);    // (3, 4, 0) — clamps

var square = new GeoPolygon3(
    new GeoPoint3(0, 0, 0), new GeoPoint3(10, 0, 0),
    new GeoPoint3(10, 10, 0), new GeoPoint3(0, 10, 0));
square.GetPointAtParameter(0.25);   // (10, 0, 0) — a quarter of the way round
square.GetPointAtParameter(1.25);   // the same point again — wraps

A polygon is measured around its boundary starting at its first vertex, and a circle around its circumference. Both wrap, so there is no out-of-range parameter for a closed curve.

Splitting

Splition3 cuts a curve at a position along it or wherever a plane crosses it, and cuts a region or a body by a plane. Pieces of a curve come back in order along the subject, so the first piece always holds its start point and the last holds its end point. Every overload reports false when there was nothing to cut and still hands back the subject as a single piece, so the result is usable either way. Each split is also reachable as an instance method on the shape being cut — it sits on the subject, since plane.Split(line) would not say which of the two comes back in pieces.

var line = new GeoLine3(new GeoPoint3(0, 0, 0), new GeoPoint3(10, 0, 0));

line.TrySplitAtDistance(4, out GeoLine3[] pieces); // true: lengths 4 and 6
line.TrySplitAtDistance(0, out _);                 // false — a cut at an endpoint makes no piece
line.TrySplitBy(new GeoPoint3(5, 3, 0), out _);    // false — the point is not on the segment

A cutter does not have to be a plane. Cutting by a closed body sorts the pieces into those inside it and those outside, because a body has no side the way a plane does:

var chain = new GeoPolyline3(new GeoPoint3(-5, 5, 5), new GeoPoint3(20, 5, 5));

chain.TrySplitBy(solid, out GeoPolyline3[] inside, out GeoPolyline3[] outside);
// inside holds the stretch within the body, outside the two stretches beyond it

The same works against a GeoObb3 or a GeoAabb3, which are cheaper because the crossings come from the slab test rather than from walking a surface. Neighbouring pieces that end up on the same side are joined back up, so what comes out is the longest run on each side rather than a chain chopped at positions that separate nothing — a chain running down the shaft of an opening meets the caps at each end without ever entering material, and that is not a cut.

Several bodies can be given at once, and they behave as their union: a stretch is inside when any one of them holds it. Cutting by each in turn would not do the same thing, because the pieces of the first cut would have to be sorted again against the second and the runs joined back up by hand.

var route = new GeoPolyline3(new GeoPoint3(-10, 5, 5), new GeoPoint3(60, 5, 5));

route.TrySplitBy(new[] { beam, slab }, out GeoPolyline3[] embedded, out GeoPolyline3[] clear);
// embedded holds the stretches buried in either body, clear the stretches in the open

Because it is the union being asked about, two bodies that overlap do not each claim their own piece of the answer, and two meeting face to face leave no cut between them — the run through both comes back whole. An empty array is the same rule at its limit: nothing holds anything, so the whole chain is clear.

An array of GeoObb3 or of GeoAabb3 works the same way and is the cheaper form where the cutters really are boxes. A GeoObb3 is a reference, so a null entry is skipped; a GeoAabb3 is a value with no null to pass, and the entry left out there is the empty box, which could hold nothing anyway.

Cutting by a bounded region cuts only where the subject really goes through it, which is what is wanted when the cutter stands for a physical plate rather than an endless surface:

chain.TrySplitBy(plate, out GeoPolyline3[] pieces);          // only where it pierces the plate
chain.TrySplitBy(plate.GetPlane(), out GeoPolyline3[] more); // anywhere it crosses the carrying plane

Cutting a region sorts the result by side, and a concave subject can fall into more than two pieces, which is why each side comes back as an array:

// A U shape opening upwards, cut horizontally through its two arms.
var uShape = new GeoPolygon3(
    new GeoPoint3(0, 0, 0), new GeoPoint3(9, 0, 0), new GeoPoint3(9, 10, 0),
    new GeoPoint3(6, 10, 0), new GeoPoint3(6, 4, 0), new GeoPoint3(3, 4, 0),
    new GeoPoint3(3, 10, 0), new GeoPoint3(0, 10, 0));

var cutter = new GeoPlane3(new GeoPoint3(0, 7, 0), GeoVector3.YAxis);

uShape.TrySplitBy(cutter, out GeoPolygon3[] above, out GeoPolygon3[] below);
// above.Length == 2 — one piece per arm
// below.Length == 1

A solid is cut the same way, and the two halves come back closed:

GeoSolid3 cube = new GeoAabb3(GeoPoint3.Origin, new GeoPoint3(10, 10, 10)).ToObb().ToSolid();

cube.TrySplitBy(GeoPlane3.XY.Offset(4), out GeoSolid3 upper, out GeoSolid3 lower);
// upper.Volume == 600, lower.Volume == 400, both IsClosed()

The body may be concave and its faces may carry holes. Each face is cut on its own, and the new surface closing each half is built from the edges the cut left behind: in a closed body every edge is traversed once by the face beside it, so the same edge traversed the other way belongs to the cap. That section may be several loops, and one loop may sit inside another, so the loops are nested into faces rather than assumed to be a single boundary:

// A square tube — a block with a shaft through it. The section across it is a ring, not a disc.
tube.TrySplitBy(GeoPlane3.XY.Offset(5), out GeoSolid3 upper, out GeoSolid3 lower);
// each half is closed, and its cap is one face carrying one hole

Two consequences worth knowing. A cut can leave one half as several disconnected shells — a plane across both arms of a U leaves two bodies above it — and they come back as one GeoSolid3, which measures and answers containment correctly because each shell is closed and wound outwards. And a plane passing through a hole in a face turns that rim into part of the boundary, so the piece on each side has no hole where the subject had one.

A plate can also be cut along a line marked on it, or against a body:

plate.TrySplitBy(cutLine, out GeoPolygon3[] halves);              // cut along a drawn chain
plate.TrySplitBy(solid, out GeoPolygon3[] embedded, out GeoPolygon3[] clear);

The second answers which part of a plate is embedded in a body. It works by cutting the plate with the plane of each face of the body in turn — the surface of the body never leaves those planes, so once that is done no piece can straddle the boundary — and then joining the pieces back up where they agree. What comes back covers each side exactly, though it may be in more pieces than strictly necessary.

Combining bodies

Boolean3 joins two solids, keeps the part they share, or takes one out of the other:

first.TryUnion(second, out GeoSolid3 joined);
first.TryIntersect(second, out GeoSolid3 shared);
first.TrySubtract(tool, out GeoSolid3 left);

Each reports false when the answer is nothing at all — two bodies that never touch share nothing, and a body swallowed whole leaves nothing behind. That is an outcome rather than a failure, which is why it comes back as false rather than as an exception or an empty body.

The method is the cutting above carried up a dimension. Both bodies are divided by one shared set of planes — the face planes of each of them together — which leaves cells that are each wholly inside or wholly outside the other, since the surface of a body never leaves the planes of its own faces. The cells the operation wants are then glued: a face shared by two kept cells appears twice, once each way round, and dropping both leaves exactly the outer skin.

Two details are worth knowing. Using one shared plane set for both bodies rather than each against the other is what makes the gluing work where they meet — cut that way both sides of the interface are the same plane carved by the same knives, so they come out as the same polygon and cancel. And dividing A by the planes of B already lays a face along every part of the surface of B that runs through A, which is why a difference is just the cells of A that fall outside B: the walls of the cavity are already there.

Merging

Merge3 is the other direction. ConsecutiveLines and ConsecutivePolylines take the pieces in the order given and only ever join a piece to the one after it. Join ignores order and direction and reassembles whatever chains the set actually forms, which is what a bag of edges out of a model needs:

GeoPolyline3[] chains = Merge3.Join(new[]
{
    new GeoPolyline3(new GeoPoint3(3, 4, 0), new GeoPoint3(3, 0, 0)), // reversed middle
    new GeoPolyline3(new GeoPoint3(3, 4, 0), new GeoPoint3(8, 4, 0)), // last
    new GeoPolyline3(new GeoPoint3(0, 0, 0), new GeoPoint3(3, 0, 0)), // reversed first
});

// One chain, 4 vertices, total length 12.

CoplanarFaces does the same job for a surface. Cutting a body leaves it more finely divided than it needs to be, and repeated cutting compounds that, so this puts the surface back into as few faces as describe it:

GeoSolid3 tidied = Merge3.CoplanarFaces(subdivided);
// same volume, same surface area, fewer faces

Faces are grouped by the oriented plane they lie on, so a face and one facing the other way are never merged — they are different surfaces that happen to be flat in the same place. Merging a ring of faces keeps the hole in the middle as a hole. Two faces count as touching only where they share a whole edge, so a T-junction stops that one join rather than the whole group; merging under-joins rather than guessing.

Working with large meshes

Every operation above looks at the whole of what it is given, because nothing rules any of it out in advance. GeoBvh3 is the index that does: a tree of nested boxes over a triangle mesh, in which a ray that misses a box misses everything inside it, and a box farther away than the best answer so far cannot hold anything nearer.

var tree = GeoBvh3.FromSolid(solid);

tree.DistanceTo(point);            // nearest point on the surface
tree.GetClosestPoint(point);
tree.GetIntersections(ray);        // every crossing of the surface
tree.CollidesWith(otherTree);      // surface contact between two meshes

Building the tree costs a sort of the triangles, so it pays for itself over repeated queries rather than on the first one. Build it once and keep it: every geometry type here is immutable, so a mesh never goes stale under its index.

The mesh it indexes comes from GeoSolid3.Triangulate, which follows the material of every face: a concave face is traced rather than spanned, and a hole in a face is left open. So a ray fired through the notch of an L-shaped body, or through a bolt hole in a plate, passes clean through, and the nearest point on the surface is a point that is really on it. That is what separates it from GeoFace3.Triangulate, which fans the boundary from one vertex and is meant only for the signed sums — area, centroid, volume — where the part of a fan reaching outside the face cancels against the part overlapping it. Use GeoFace3.TriangulateSurface wherever the triangles stand for material.

Collision3.CollidesWith(solid, solid) builds one internally once the meshes are large enough to be worth it, and falls back to comparing every pair below that — below the threshold, the plain scan wins.

One caveat, shared with any triangle mesh: a ray running exactly along an edge is reported by both triangles that share it. Anything counting crossings to tell inside from outside must keep clear of edges, which is why Containment3 throws its ray again in another direction when a hit lands near one.

Point chains

GeometryHelper.SolidGeometry.Extension covers the step before a polyline exists: a raw list of points, usually read out of a model and carrying more of them than the geometry needs.

using GeometryHelper.SolidGeometry.Extension;

var traced = new List<GeoPoint3>
{
    new GeoPoint3(0, 0, 0), new GeoPoint3(0.0001, 0, 0), new GeoPoint3(5, 0, 0), new GeoPoint3(5, 0, 5),
};

// The second point is a hair away from the first and goes.
List<GeoPoint3> thinned = traced.RemoveConsecutiveNearPoints(new Tolerance(0.001, 0.001)); // 3 points

// One segment per consecutive pair.
List<GeoLine3> segments = thinned.ToGeoLine3s();                                           // 2 segments

RemoveConsecutiveNearPoints compares each point against the last one kept, not against its original neighbour, which is what guarantees no two points of the result are coincident within the tolerance. A huddle collapses onto its first point, and collapsing stops as soon as one point escapes the tolerance around that anchor, so a long run thins rather than vanishes. Distance is measured in all three dimensions, so two points that share X and Y but differ in Z are not fused.

The first point always survives; the last one is not privileged. A final point lying within the tolerance of the one kept before it is dropped like any other, so re-append it yourself when the endpoint matters.

ToGeoLine3s leaves the chain open — nothing joins the last point back to the first, so a ring has to repeat its first point at the end. It does not filter coincident neighbours either, so run RemoveConsecutiveNearPoints first if zero length segments would be a problem.

Tolerance

Nothing in this library compares coordinates with ==. Every comparison that floating point error can affect takes a Tolerance, and every such method has an overload without one that reads Tolerance.Global.

var a = GeoPoint3.Origin;
var b = new GeoPoint3(1e-9, 0, 0);

a.IsEqualTo(b);  // true  — within the default tolerance
a.Equals(b);     // false — exact comparison

a.IsEqualTo(new GeoPoint3(0.05, 0, 0), new Tolerance(0.1, 0.1)); // true

Tolerance carries four thresholds: EqualPoint for coincidence, EqualVector for direction, EqualAngleRad for parallelism, and EqualPlanar for flatness.

Degenerate input is treated as "no answer" rather than being guessed at. A zero-length vector has no direction, so it is neither parallel nor perpendicular to anything, and Normalize() on it throws while TryGetNormal reports the failure:

GeoVector3.Zero.IsParallelTo(GeoVector3.XAxis);        // false
GeoVector3.Zero.TryGetNormal(out GeoVector3 unit);     // false
GeoVector3.Zero.Normalize();                           // throws InvalidOperationException

Working in a local frame

GeoCoordinateSystem3 moves geometry between world coordinates and a local frame, and its axes are always orthonormal whatever is passed in. ToLocal and ToGlobal are exact inverses of each other.

var frame = new GeoCoordinateSystem3(
    new GeoPoint3(10, -20, 30),
    new GeoVector3(1, 1, 0),
    new GeoVector3(-1, 1, 1));

var point = new GeoPoint3(3, -7, 11);
frame.ToGlobal(frame.ToLocal(point)).IsEqualTo(point);  // true

GeoTransform3 is a 4x4 matrix applied on the left, so a.Multiply(b) means "apply b, then a". A plane normal is carried by the inverse transpose rather than by the matrix itself, so it stays perpendicular to the surface even under a non-uniform scaling.

GeoTransform3 motion = GeoTransform3.Translation(new GeoVector3(10, 0, 0))
    .Multiply(GeoTransform3.RotationZ(Math.PI / 2));

motion.Transform(GeoPoint3.Origin);  // (10, 0, 0)
motion.Inverse().Transform(motion.Transform(GeoPoint3.Origin)); // back to the origin

Tekla Structures

GeometryHelper.TeklaConvert converts geometry between Tekla Structures and this library: points, vectors, segments, planes, coordinate systems, bounding boxes, transformation matrices, and the faces and loops of a Tekla solid. It is a separate project, so the core library carries no dependency on Tekla.

using GeometryHelper.TeklaConvert;

teklaSolid.TryToGeoSolid3(out GeoSolid3 body, tolerance);

body.Volume;
body.TrySubtract(otherBody, out GeoSolid3 left);

Three things are checked rather than trusted on the way in. A Tekla coordinate system whose Y axis is not quite square to its X axis is squared up. Each face is turned to agree with the normal Tekla gives it, and the finished body is turned inside out if its signed volume says the whole surface arrived reversed — without that, volume still measures the same but every containment query answers backwards. And a face that cannot be made sense of is skipped rather than thrown on, which leaves the body no longer closed, so ask IsClosed() before trusting a volume.

Tekla models in millimetres with coordinates that can run to hundreds of thousands, and a face of a twelve metre member is rarely flat to the last decimal. The default EqualPlanar is often too tight for that, so pass a Tolerance suited to the model rather than relying on the default.

Build and Test

dotnet build Libraries/GeometryHelper.SolidGeometry/GeometryHelper.SolidGeometry.csproj
dotnet test  Tests/GeometryHelper.SolidGeometry.UnitTest/GeometryHelper.SolidGeometry.UnitTest.csproj
dotnet test  Tests/GeometryHelper.TeklaConvert.UnitTest/GeometryHelper.TeklaConvert.UnitTest.csproj

Every snippet in this README is covered by a unit test in ReadmeExamplesTests.

License

MIT

Product Compatible and additional computed target framework versions.
.NET net5.0 was computed.  net5.0-windows was computed.  net6.0 was computed.  net6.0-android was computed.  net6.0-ios was computed.  net6.0-maccatalyst was computed.  net6.0-macos was computed.  net6.0-tvos was computed.  net6.0-windows was computed.  net7.0 was computed.  net7.0-android was computed.  net7.0-ios was computed.  net7.0-maccatalyst was computed.  net7.0-macos was computed.  net7.0-tvos was computed.  net7.0-windows was computed.  net8.0 was computed.  net8.0-android was computed.  net8.0-browser was computed.  net8.0-ios was computed.  net8.0-maccatalyst was computed.  net8.0-macos was computed.  net8.0-tvos was computed.  net8.0-windows was computed.  net9.0 was computed.  net9.0-android was computed.  net9.0-browser was computed.  net9.0-ios was computed.  net9.0-maccatalyst was computed.  net9.0-macos was computed.  net9.0-tvos was computed.  net9.0-windows was computed.  net10.0 was computed.  net10.0-android was computed.  net10.0-browser was computed.  net10.0-ios was computed.  net10.0-maccatalyst was computed.  net10.0-macos was computed.  net10.0-tvos was computed.  net10.0-windows was computed. 
.NET Core netcoreapp2.0 was computed.  netcoreapp2.1 was computed.  netcoreapp2.2 was computed.  netcoreapp3.0 was computed.  netcoreapp3.1 was computed. 
.NET Standard netstandard2.0 is compatible.  netstandard2.1 was computed. 
.NET Framework net461 was computed.  net462 was computed.  net463 was computed.  net47 was computed.  net471 was computed.  net472 was computed.  net48 was computed.  net481 was computed. 
MonoAndroid monoandroid was computed. 
MonoMac monomac was computed. 
MonoTouch monotouch was computed. 
Tizen tizen40 was computed.  tizen60 was computed. 
Xamarin.iOS xamarinios was computed. 
Xamarin.Mac xamarinmac was computed. 
Xamarin.TVOS xamarintvos was computed. 
Xamarin.WatchOS xamarinwatchos was computed. 
Compatible target framework(s)
Included target framework(s) (in package)
Learn more about Target Frameworks and .NET Standard.

NuGet packages

This package is not used by any NuGet packages.

GitHub repositories

This package is not used by any popular GitHub repositories.

Version Downloads Last Updated

3D geometry for engineering models, built to the same design as GeometryHelper.PlaneGeometry.
     The four packages in this repository are versioned and released as one set.

     NEW IN 3.1.0

     GeoSolid3.Triangulate now describes the surface rather than fanning it. The old fan was correct
     for the signed sums it was written for — area, centroid, volume, where the part reaching outside
     a concave face cancels against the part overlapping it — but the same triangles were read as an
     actual surface by Collision3, Distance3 and GeoBvh3, where nothing cancels. On a U-shaped body
     the fan spanned the empty notch, and a ray fired up it reported two hits, the nearest point on
     the surface came back in mid-air, and a box sitting in the notch collided with the body. Faces
     are now cut into triangles that each lie within the material, holes left open. This is a change
     in behaviour, not only a fix: anything reading the mesh gets different, correct answers.
     GeoFace3.TriangulateSurface is the new per-face entry point; GeoFace3.Triangulate keeps the fan
     for the signed sums.

     Openings survive cutting and boolean operations. Splition3.TrySplitBy(solid, plane) and all
     three Boolean3 operations never read GeoSolid3.Openings and rebuilt their results without them,
     so a slab with a duct came back solid — no exception and no false, the halves closed and
     measured correctly in every other respect. Splitting now cuts a straddling opening along with
     the body; Boolean3 drops the cells filling one, so the cavity survives as real geometry.

     GeoSolid3.GetNetVolume measures what was actually removed, cutting the openings out rather than
     deducting their volumes. NetVolume keeps the cheap arithmetic and now documents that it
     over-deducts an opening drawn overshooting the far face, which is the usual way to draw a
     through-hole, and double-counts two openings that overlap.

     GeoTransform3.TryGetInverse judges the determinant against the size of the transformation
     instead of against zero. Three unit-length axes tilted nearly into one plane give a determinant
     around 1e-12 and were reported invertible, yielding a matrix of numbers around 1e12. Inverse and
     TryGetInverse gain Tolerance overloads. GeoSolid3.Centroid guards its division with a length
     tolerance cubed rather than with double.Epsilon, which only ever caught an exactly zero volume.

     Documentation that contradicted the code is corrected: GeoSolid3.TrySplitBy no longer claims the
     solid must be convex, and Newell.GetAreaVector no longer claims to return twice the area.
     Tolerance.Global now states that it is unsynchronized process-wide state.

     SHARED TYPES
     - Tolerance, Angle, PointLocation and PlaneSide come from the GeometryHelper.CommonGeometry
       package, which GeometryHelper.PlaneGeometry shares. A program working in both dimensions sees
       one Tolerance and one Angle rather than two of each, and Tolerance.Global is one setting for
       both.

     GEOMETRY
     - Fifteen immutable types across three families. A curve (GeoLine3, GeoRay3, GeoPolyline3)
       encloses nothing; a planar region (GeoTriangle3, GeoPolygon3, GeoCircle3, GeoFace3) encloses
       an area; a volume (GeoObb3, GeoAabb3, GeoSolid3) encloses space. Only the last two
       families offer Contains, and a planar region counts a point as inside only when it lies on
       the carrier plane as well as within the boundary.
     - Coplanarity is enforced at construction for GeoPolygon3 and GeoFace3. A polygon that is not
       flat has no normal, no area and no interior, so it is refused rather than accepted with every
       property on it quietly meaningless.
     - GeoObb3 and GeoCoordinateSystem3 orthonormalize their axes in full, so a Y direction that is
       not square to X is corrected rather than producing a skewed frame in which ToLocal and
       ToGlobal stop being inverses.
     - GeoSolid3 carries openings — whole solids subtracted from it — separately from the holes a
       GeoFace3 carries, which are flat and belong to one face.

     OPERATIONS
     - Nine Core classes: Collision3, Containment3, Distance3, Intersection3, Merge3, Parallel3,
       Parametrization3, Projection3, Splition3. Every one is mirrored as instance methods on the
       types it applies to, and splitting sits on the subject rather than the cutter.
     - Collision3 tests two oriented boxes with the separating axis theorem over all fifteen axes,
       so two boxes passing each other at an angle are separated by the cross-product axes that a
       face-only test would miss.
     - Splition3 cuts by four kinds of cutter, and what the result means follows from which. A
       position along the subject or an infinite plane divides it in order or by side; a bounded
       region cuts only where the subject really passes through it; a closed body sorts the pieces
       into inside and outside, because a body has no side the way a plane does.
     - Cutting a body by a plane handles a concave body whose faces carry holes: each face is cut on
       its own and the surface closing each half is built from the edges the cut left behind. A cut
       can leave one half as several disconnected shells, and a plane through a hole turns that rim
       into part of the boundary.
     - A plate can be cut along a chain marked on it, or against a body to separate the embedded part
       from the part standing clear.
     - Merge3.CoplanarFaces puts a surface back into as few faces as describe it, which is what keeps
       repeated cutting from growing the face count without changing the shape.
     - Boolean3 joins two bodies, keeps the part they share, or takes one out of the other. Both bodies
       are divided by one shared set of planes so that the two sides of every interface come out as the
       same polygon and cancel when the kept cells are glued.
     - Parametrization3 covers closed curves as well as open ones, so a polygon and a circle wrap
       where a segment extrapolates and a chain clamps.
     - Intersection3 returns a single result and reports false when there is not exactly one, so a
       segment lying in a plane or two coincident planes are refused rather than answered with an
       arbitrary point out of the overlap.

     LARGE MESHES
     - GeoBvh3 indexes a triangle mesh as a tree of nested boxes, turning ray casting, nearest-point
       and mesh-to-mesh contact from a scan of every triangle into a traversal.
     - The mesh it indexes follows the material of every face, so a ray fired through the notch of a
       concave body or through a bolt hole in a plate passes clean through. Nothing about a tree
       depends on what is asked of it, so a body queried repeatedly is worth indexing once with
       GeoBvh3.FromSolid rather than through the convenience paths in Collision3 and Distance3,
       which build one per call.
     - Collision3.CollidesWith(solid, solid) builds one internally once the meshes are large enough
       to be worth it, and keeps the plain scan below that.
     - GeoSolid3.IsClosed matches vertices through a spatial grid rather than by scanning what it has
       already seen, which takes it from quadratic time to linear.

     TOLERANCE
     - Nothing compares coordinates with ==. Every affected comparison takes a Tolerance and has an
       overload reading Tolerance.Global.
     - Four thresholds: EqualPoint, EqualVector, EqualAngleRad, and EqualPlanar. The last is
       separate because coplanarity is measured far from the reference point, where a hundredth of a
       degree of tilt becomes a deviation the point threshold would not tolerate.
     - Degenerate input is treated as no answer rather than guessed at: a zero-length vector is
       neither parallel nor perpendicular to anything, Normalize throws, and TryGetNormal reports it.