The CAD systems that I design use functional representations to represent solid models. These f-reps need to be converted into meshes before they can be used in other programs (e.g. to be manufactured on a 3D printer).
There are many meshing algorithms in the literature, but we want one that is:
- Watertight (without any cracks)
- Hierarchical (avoids many tiny triangles)
- Feature-preserving (corners and edges should remain sharp)
Also, we'll be using Haskell. Hold on to your hats!
We'll start by defining a type for 2D points and a type for our shape functions.
type Point = (Double, Double) type Shape = Point -> Double
A shape function takes an
(x,y) tuple and and returns a value. If this
value is less than or equal to zero, the point
(x,y) is inside the shape.
Our first shape will be a circle:
circle :: Point -> Double -> Shape circle (x0, y0) r (x, y) = sqrt ((x0 - x)**2 + (y0 - y)**2) - r
This function parameterizes a circle with a center and radius, then returns the
distance from any point in the plane to the circle's edge. Thanks to
partial application, you can
call it with just
(x,y) r arguments to get a
Shape object out.
λ> let c = circle (0,0) 1 λ> c (0,0) -- points inside the circle are negative -1.0 λ> c (1,0) -- points on the contour are zero 0.0 λ> c (2,0) -- points outside the circle are positive 1.0
We'll also define a set of half-planes, e.g.
left 1 means that all points
x <= 1 are part of the shape:
left :: Double -> Shape left x0 (x, _) = x - x0 right :: Double -> Shape right x0 (x, _) = x0 - x lower :: Double -> Shape lower y0 (_, y) = y - y0 upper :: Double -> Shape upper y0 (_, y) = y0 - y
Next, let's create operators for union, intersection, and negation:
(∪) :: Shape -> Shape -> Shape a ∪ b = \p -> min (a p) (b p) (∩) :: Shape -> Shape -> Shape a ∩ b = \p -> max (a p) (b p) inv :: Shape -> Shape inv a = \p -> -(a p)
The negation operator lets us make cutouts: subtracting
a is given
a ∩ (inv b).
We can use these CSG operators and the half-planes defined above to create a rectangle, which is the intersection of four half-planes:
type Min = Point type Max = Point rectangle :: Min -> Max -> Shape rectangle (xmin, ymin) (xmax, ymax) = right xmin ∩ left xmax ∩ upper ymin ∩ lower ymax
Here's a simple test shape that we'll be using for the rest of this writeup:
hi :: Shape hi = h ∪ i where h = (rectangle (0.1, 0.1) (0.25, 0.9) ∪ rectangle (0.1, 0.1) (0.6, 0.35) ∪ circle (0.35, 0.35) 0.25) ∩ inv (circle (0.35, 0.35) 0.1 ∪ rectangle (0.25, 0.1) (0.45, 0.35)) i = rectangle (0.75, 0.1) (0.9, 0.55) ∪ circle (0.825, 0.75) 0.1
Rendering this to a bitmap is a matter of evaluating the function on many
(x,y) coordinates, then coloring them depending on the function's sign.
This is a very naïve rendering strategy, but it's good enough to put
pixels on the screen.
Here's our testing shape, evaluated as a bitmap:
Quadtrees are a basic data structure in 2D graphics. Quadtreees are hierarchical: a 2D region is recursively split into four quadrants; each quadrant is either a leaf cell or subdivided further.
They are interesting because they represent a shape with fewer samples than a bitmap: areas that are completely filled or empty can be collapsed into a single cell.
We'll use one type of hierarchical cell and three terminal cells in the tree:
Rootcells contain four subtrees
Leafcells are minimum-size and contain the contour
Fullcells are collapsed cells that are empty or full respectively
As motivation, here's a quadtree decomposition of our test shape. Green, grey,
and black squares represent
Full cells respectively.
Now that we know what we're looking for, let's start by creating our data type. Subtrees and corners will always be numbered as follows:
For the purposes of this exercise, we'll make a generic
Tree_ type that
stores some kind of data at each leaf, then specialize it to storing the
leaf's corner positions.
data Tree_ a = Root (Tree_ a) (Tree_ a) (Tree_ a) (Tree_ a) | Empty | Full | Leaf a deriving (Show, Eq) type Cell = (Min, Max) type Tree = Tree_ Cell
Now, let's build a complete tree structure:
buildTree :: Min -> Max -> Int -> Tree buildTree min max 0 = Leaf (min, max) buildTree (xmin, ymin) (xmax, ymax) i = Root (buildTree (xmin, ymin) (xmid, ymid) (i - 1)) (buildTree (xmid, ymin) (xmax, ymid) (i - 1)) (buildTree (xmin, ymid) (xmid, ymax) (i - 1)) (buildTree (xmid, ymid) (xmax, ymax) (i - 1)) where xmid = (xmin + xmax) / 2 ymid = (ymin + ymax) / 2
This tree isn't specialized to any particular shape; it contains a uniform set of leaf cells filling a space. For example:
λ> buildTree (0,0) (1,1) 2
It may look like we're building a full tree here, but that's not quite the case. Because Haskell is lazy, this tree will actually be constructed on an as-needed basis, so we need not feel guilty about building it naïvely.
Now, let's think about collapsing cells. Given a
Shape function, we can mark
cells that are entirely filled or empty, then recursively collapse them:
collapse :: Shape -> Tree -> Tree collapse shape leaf@(Leaf ((xmin, ymin), (xmax, ymax))) = if all (< 0) values then Full else if all (>= 0) values then Empty else leaf where values = [shape (x,y) | x <- [xmin, xmax], y <- [ymin, ymax]] collapse shape (Root a b c d) = collapse' $ map (collapse shape) [a, b, c, d] where collapse' [Empty, Empty, Empty, Empty] = Empty collapse' [Full, Full, Full, Full] = Full collapse' [q, r, s, t] = Root q r s t collapse _ t = t
Here's what the first five quadtree levels look like on our example shape:
Note that the contour-containing cells are all
Leaf cells (and vice versa):
- Cells whose corners are different signs are not collapsed
- Cells that intersect the isosurface have corners of different signs
Marching squares, which will be introduced shortly, operates on a single cell
at a time. To map it across all of the
Leaf cells in the shape, let's make
Tree_ type an instance of the
instance Foldable Tree_ where foldMap f (Leaf a) = f a foldMap f (Root a b c d) = mconcat $ map (foldMap f) [a, b, c, d] foldMap _ _ = mempty
This means we can easily map functions across every
Leaf in the tree (or,
more strictly, across every
Cell stored in a leaf). For example, consider
finding the centers of each leaf:
λ> let center ((x,y), (x',y')) = ((x+x')/2, (y+y')/2) λ> foldMap (\cell -> [center cell]) $ buildTree (0,0) (1,1) 2 [(0.125,0.125),(0.375,0.125),(0.125,0.375),(0.375,0.375) (0.625,0.125),(0.875,0.125),(0.625,0.375),(0.875,0.375), (0.125,0.625),(0.375,0.625),(0.125,0.875),(0.375,0.875), (0.625,0.625),(0.875,0.625),(0.625,0.875),(0.875,0.875)]
Marching Squares is a fundamental algorithm for extracting isocontours from 2D samples.
Here's the idea: for every cell, we examine the corners, which match one of sixteen cases:
Each case creates between zero and two edges. Run this procedure on every contour-containing cell and you'll get your shape's contour.
To get started, we'll encode the 16 cases shown above in a lookup table named
data Side = Upper | Lower | Left | Right deriving Show -- This lookup table takes a bitmask abcd and -- returns a list of edges between which we -- should draw contours (to outline the shape) lut :: [[(Side, Side)]] lut = [, -- 0000 [(Upper,Right)], -- 000d [(Left,Upper)], -- 00c0 [(Left,Right)], -- 00cd [(Right,Lower)], -- 0b00 [(Upper,Lower)], -- 0b0d [(Right,Lower),(Left,Upper)],-- 0bc0 [(Left,Lower)], -- 0bcd [(Lower,Left)], -- a000 [(Lower,Left),(Upper,Right)],-- a00d [(Lower,Upper)], -- a0c0 [(Lower,Right)], -- a0cd [(Right,Left)], -- ab00 [(Upper,Left)], -- ab0d [(Right,Upper)], -- abc0 ] -- abcd
Next, we'll write helper functions that go from a
Cell to an index in
the lookup table and then to a list of
index :: Shape -> Cell -> Int index shape ((xmin, ymin), (xmax, ymax)) = sum [if shape pt < 0 then 2^(3 - i) else 0 | (pt, i) <- zip pts [0..]] where pts = [(x,y) | y <- [ymin, ymax], x <- [xmin, xmax]] edges :: Shape -> Cell -> [(Side, Side)] edges shape c = lut !! index shape c
gives us pairs of cell sides between which we should draw edges; however,
we need to pick the starting and ending point for each edge:
In the image above, how do we find the green points? Well, the green points should be points where the shape function is zero. We'll assume that the shape function is monotonic along the edge and use binary search:
pt :: Shape -> Cell -> Side -> Point pt shape ((xmin, ymin), (xmax, ymax)) side = case side of Left -> zero shape (xmin, ymin) (xmin, ymax) Right -> zero shape (xmax, ymin) (xmax, ymax) Lower -> zero shape (xmin, ymin) (xmax, ymin) Upper -> zero shape (xmin, ymax) (xmax, ymax) zero :: Shape -> Point -> Point -> Point zero s a@(ax, ay) b@(bx, by) | s a >= 0 = zero s b a | otherwise = zero' 0.5 0.25 10 where pos f = (ax * (1-f) + bx * f, ay * (1-f) + by * f) zero' f step i = if i == 0 then pos f else if s (pos f) < 0 then zero' (f + step) (step / 2) (i - 1) else zero' (f - step) (step / 2) (i - 1)
Finally, we can combine these functions into one that converts a cell into
a set of contours. Notice that this function can be applied to a tree with
type Edge = (Point, Point) contours :: Shape -> Cell -> [Edge] contours shape cell = [(pt' a, pt' b) | (a, b) <- edges shape cell] where pt' = pt shape cell
Applying this to a tree extracts the shape's contours:
λ> foldMap (contours hi) $ collapse hi $ buildTree (0,0) (1,1) 5
hi with a monad is left as an exercise for the reader)
Sampled distance fields
There's an interesting nuance to our quadtrees: the
collapse function treats
sampled data as a binary value (based on whether it's less than zero). Can we
do better by using the scalar value?
Consider the following quadtree (with one level of subdivision):
The internal samples have different boolean values (some are inside the shape, some are outside), but all of the internal sampled values can be reconstructed from the corner values using bilinear interpolation.
This means that we can merge the cells without losing any information!
(This idea appears in the literature as Adaptively Sampled Distance Fields).
This may seem like an uncommon occurance, but it turns out that this kind of merging is possible whenever a cell's distance field is entirely determined by a single line (or face in 3D). When working with flat surfaces, this happens a lot!
Let's write some code to do this kind of reduction. First, we'll create functions that measure the error that would be introduced by this kind of merging:
-- interpolate samples a Shape at the four corners of a Cell, -- then uses those values to estimate the function's result at -- an arbitrary (x,y) position interpolate :: Shape -> Cell -> Point -> Double interpolate shape ((xmin, ymin), (xmax, ymax)) (x,y) = let dx = (x - xmin) / (xmax - xmin) dy = (y - ymin) / (ymax - ymin) ab = (shape (xmin, ymin)) * (1 - dx) + (shape (xmax, ymin)) * dx cd = (shape (xmin, ymax)) * (1 - dx) + (shape (xmax, ymax)) * dx in ab * (1 - dy) + cd * dy -- score returns the difference between an interpolated -- estimate and the function value at a given point score :: Shape -> Cell -> Point -> Double score shape cell pt = abs $ (interpolate shape cell pt) - shape pt
Now, we can use the score function and a user-defined cutoff to decide whether to merge a set of leaf cells. Note the names for nodes and cells:
merge :: Shape -> Tree -> Tree merge shape (Root a b c d) = merge' a' b' c' d' where [a', b', c', d'] = map (merge shape) [a, b, c, d] merge' (Leaf (min, i)) (Leaf (q, r)) (Leaf (s, t)) (Leaf (_, max)) = let scores = map (score shape (min, max)) [i, q, r, s, t] in if all (< 0.001) scores then Leaf (min, max) else Root a' b' c' d' merge' _ _ _ _ = Root a' b' c' d' merge _ t = t
We apply this function before calling
collapse to produce a sparser quadtree:
λ> collapse hi $ merge hi $ buildTree (0,0) (1,1) res
As expected, we now see larger leaf cells containing a single flat edge.
We can use the same contouring strategy on this sparser quadtree, producing a contour with fewer samples along flat edges:
This seems too easy — what's the catch?
Our meshing strategy assumed that all leaf cells were the same size, so a pair of leaves that shared an edge performed the same zero-crossing search. Now, with leaves of different sizes, the zero-crossing search will not be numerically identical, so the edges will not quite touch:
This becomes a more significant issue in 3D: naïvely meshing a hierarchical structure will leave cracks in between cells of different sizes.
Dual Contouring addresses two issues:
- Cracks between cells of different sizes
- Corners being bevelled
It does so by considering the dual graph of the quadtree. Instead of each leaf containing zero or more edges, each leaf stores a vertex; we connect these vertices to create the contour.
In this image, the original quadtree is drawn in light grey and the dual grid is shown in black.
There are three important questions to answer:
- How do we choose which edges of the dual graph belong to the contour?
- How do we position the vertices of the dual graph within cells?
- How do we construct the dual graph?
To answer the first question, we want to draw an edge between any two cells that share a side with a sign change.
The answer to the second question is more subtle: we want to position vertices of the dual graph on features of the original shape. For example, in a cell containing a corner, the vertex should be on that corner. This strategy allows the algorithm to preserve sharp features.
We'll find features by looking at the normals of the shape function, which are given by its partial derivatives. In the past, I've written my own automatic differentiator; here, we'll do it numerically:
deriv :: Shape -> Point -> Point deriv shape (x,y) = let epsilon = 0.001 dx = shape (x + epsilon, y) - shape (x - epsilon, y) dy = shape (x, y + epsilon) - shape (x, y - epsilon) len = sqrt $ dx**2 + dy**2 in (dx / len, dy / len)
A circle's partial derivatives are always pointing outwards from the center:
λ> deriv (circle (0,0) 1) (0,1) (0.0,1.0) λ> deriv (circle (0,0) 1) (1,1) (0.7071067811865476,0.7071067811865476) λ> deriv (circle (0,0) 1) (1,0) (1.0,0.0)
To find a feature in a cell, we find the position and normal of every point along the cell's sides that intersects the isosurface, then perform a least-squares fit to estimate the feature position.
This is the same technique I use in Antimony, though we'll skip the sanity-checking for simplicity. The details are described in Feature Sensitive Surface Extraction from Volume Data.
The code below uses the HMatrix library for matrix math.
feature :: Shape -> Cell -> Maybe Point feature shape cell = if length pts_ >= 2 then let pts = map fromTuple pts_ nms = map (fromTuple . deriv shape) pts_ center = sum pts / (fromIntegral $ length pts) a = fromRows pts b = col $ zipWith (\pt nm -> (pt - center) <·> nm) pts nms p = center + (toColumns $ linearSolveSVD a b) !! 0 in Just $ (\[x,y] -> (x,y)) $ (toList p) else Nothing where pts_ = concatMap (\(a,b) -> [a,b]) $ contours shape cell fromTuple = \(x,y) -> fromList [x,y]
We can run this function across every leaf cell to see where vertices are placed:
λ> let tree = collapse hi $ merge hi $ buildTree (0,0) (1,1) 5 λ> catMaybes $ foldMap (\cell -> [feature hi cell]) tree
We use a set of three recursive functions to isolate edges of the dual graph. They're tricky, so I'll present them three times: in a diagram, in plain English, and in code.
First, the diagram:
faceProc is called on one cell. If that cell is a leaf, then it returns
nothing. If that cell is a root, it recurses by calling
faceProc on each
edgeProcH on each horizontal pair of cells, and
edgeProcV on each
vertical pair of cells.
edgeProcH is called on a pair of cells. If both are leafs, it creates a
contour edge between the two cells. Otherwise, it recursively calls itself
on two horizontal pairs of cells.
edgeProcV is identical to
edgeProcH, but applies to vertical pairs of
Now, in code:
dc :: Shape -> Tree -> Edge dc = faceProc faceProc :: Shape -> Tree -> [Edge] faceProc shape (Root a b c d) = concatMap (faceProc shape) [a,b,c,d] ++ edgeProcH shape a b ++ edgeProcH shape c d ++ edgeProcV shape a c ++ edgeProcV shape b d faceProc _ _ =  edgeProcH :: Shape -> Tree -> Tree -> [Edge] edgeProcH shape (Leaf a) (Leaf b) = [(fromJust $ feature shape a, fromJust $ feature shape b)] edgeProcH shape leaf@(Leaf _) (Root a _ c _) = edgeProcH shape leaf a ++ edgeProcH shape leaf c edgeProcH shape (Root _ b _ d) leaf@(Leaf _) = edgeProcH shape b leaf ++ edgeProcH shape d leaf edgeProcH shape (Root _ b _ d) (Root a _ c _) = edgeProcH shape b a ++ edgeProcH shape d c edgeProcH _ _ _ =  edgeProcV :: Shape -> Tree -> Tree -> [Edge] edgeProcV shape (Leaf a) (Leaf b) = [(fromJust $ feature shape a, fromJust $ feature shape b)] edgeProcV shape (Root _ _ c d) leaf@(Leaf _) = edgeProcV shape c leaf ++ edgeProcV shape d leaf edgeProcV shape leaf@(Leaf _) (Root a b _ _) = edgeProcV shape leaf a ++ edgeProcV shape leaf b edgeProcV shape (Root _ _ c d) (Root a b _ _) = edgeProcV shape c a ++ edgeProcV shape d b edgeProcV _ _ _ = 
This completes our implementation of Dual Contouring:
λ> dc hi $ collapse hi $ merge hi $ buildTree (0,0) (1,1) res
At this point, we'll declare victory: the contour is a faithful representation of the original shape and point density along the contour scales with local complexity / curvature.
There's plenty more to dive into in the literature, with references below.
Thanks to Richard Bowen and Michael Gilik for their feedback on an early draft.
Download the source here (including code used to generate diagrams).
- Marching cubes
- Feature Sensitive Surface Extraction from Volume Data
- Adaptively Sampled Distance Fields
- Dual Contouring
- Dual Marching Cubes
- Cubical Marching Squares