445 lines
15 KiB
Python
445 lines
15 KiB
Python
# License
|
|
# Copyright (c) 2011 Evan Wallace (http://madebyevan.com/), under the MIT license.
|
|
# Python port Copyright (c) 2012 Tim Knip (http://www.floorplanner.com), under the MIT license.
|
|
# Additions by Alex Pletzer (Pennsylvania State University)
|
|
# Integration as ezdxf add-on, Copyright (c) 2020, Manfred Moitzi, MIT License.
|
|
from __future__ import annotations
|
|
from typing import Optional
|
|
from ezdxf.math import Vec3, best_fit_normal, normal_vector_3p
|
|
from ezdxf.render import MeshVertexMerger, MeshBuilder, MeshTransformer
|
|
|
|
# Implementation Details
|
|
# ----------------------
|
|
#
|
|
# All CSG operations are implemented in terms of two functions, clip_to() and
|
|
# invert(), which remove parts of a BSP tree inside another BSP tree and swap
|
|
# solid and empty space, respectively. To find the union of a and b, we
|
|
# want to remove everything in a inside b and everything in b inside a,
|
|
# then combine polygons from a and b into one solid:
|
|
#
|
|
# a.clip_to(b)
|
|
# b.clip_to(a)
|
|
# a.build(b.all_polygons())
|
|
#
|
|
# The only tricky part is handling overlapping coplanar polygons in both trees.
|
|
# The code above keeps both copies, but we need to keep them in one tree and
|
|
# remove them in the other tree. To remove them from b we can clip the
|
|
# inverse of b against a. The code for union now looks like this:
|
|
#
|
|
# a.clip_to(b)
|
|
# b.clip_to(a)
|
|
# b.invert()
|
|
# b.clip_to(a)
|
|
# b.invert()
|
|
# a.build(b.all_polygons())
|
|
#
|
|
# Subtraction and intersection naturally follow from set operations. If
|
|
# union is A | B, subtraction is A - B = ~(~A | B) and intersection is
|
|
# A & B = ~(~A | ~B) where '~' is the complement operator.
|
|
|
|
__all__ = ["CSG"]
|
|
|
|
COPLANAR = 0 # all the vertices are within EPSILON distance from plane
|
|
FRONT = 1 # all the vertices are in front of the plane
|
|
BACK = 2 # all the vertices are at the back of the plane
|
|
SPANNING = 3 # some vertices are in front, some in the back
|
|
PLANE_EPSILON = 1e-5 # Tolerance used by split_polygon() to decide if a point is on the plane.
|
|
|
|
|
|
class Plane:
|
|
"""Represents a plane in 3D space."""
|
|
|
|
__slots__ = ("normal", "w")
|
|
|
|
def __init__(self, normal: Vec3, w: float):
|
|
self.normal = normal
|
|
# w is the (perpendicular) distance of the plane from (0, 0, 0)
|
|
self.w = w
|
|
|
|
@classmethod
|
|
def from_points(cls, a: Vec3, b: Vec3, c: Vec3) -> Plane:
|
|
n = normal_vector_3p(a, b, c)
|
|
return Plane(n, n.dot(a))
|
|
|
|
def clone(self) -> Plane:
|
|
return Plane(self.normal, self.w)
|
|
|
|
def flip(self) -> None:
|
|
self.normal = -self.normal
|
|
self.w = -self.w
|
|
|
|
def __repr__(self) -> str:
|
|
return f"Plane({self.normal}, {self.w})"
|
|
|
|
def split_polygon(
|
|
self,
|
|
polygon: "Polygon",
|
|
coplanar_front: list[Polygon],
|
|
coplanar_back: list[Polygon],
|
|
front: list[Polygon],
|
|
back: list[Polygon],
|
|
) -> None:
|
|
"""
|
|
Split `polygon` by this plane if needed, then put the polygon or polygon
|
|
fragments in the appropriate lists. Coplanar polygons go into either
|
|
`coplanarFront` or `coplanarBack` depending on their orientation with
|
|
respect to this plane. Polygons in front or in back of this plane go into
|
|
either `front` or `back`
|
|
"""
|
|
polygon_type = 0
|
|
vertex_types = []
|
|
vertices = polygon.vertices
|
|
meshid = polygon.meshid # mesh ID of the associated mesh
|
|
|
|
# Classify each point as well as the entire polygon into one of four classes:
|
|
# COPLANAR, FRONT, BACK, SPANNING = FRONT + BACK
|
|
for vertex in vertices:
|
|
distance = self.normal.dot(vertex) - self.w
|
|
if distance < -PLANE_EPSILON:
|
|
vertex_type = BACK
|
|
elif distance > PLANE_EPSILON:
|
|
vertex_type = FRONT
|
|
else:
|
|
vertex_type = COPLANAR
|
|
polygon_type |= vertex_type
|
|
vertex_types.append(vertex_type)
|
|
|
|
# Put the polygon in the correct list, splitting it when necessary.
|
|
if polygon_type == COPLANAR:
|
|
if self.normal.dot(polygon.plane.normal) > 0:
|
|
coplanar_front.append(polygon)
|
|
else:
|
|
coplanar_back.append(polygon)
|
|
elif polygon_type == FRONT:
|
|
front.append(polygon)
|
|
elif polygon_type == BACK:
|
|
back.append(polygon)
|
|
elif polygon_type == SPANNING:
|
|
front_vertices = []
|
|
back_vertices = []
|
|
len_vertices = len(vertices)
|
|
for index in range(len_vertices):
|
|
next_index = (index + 1) % len_vertices
|
|
vertex_type = vertex_types[index]
|
|
next_vertex_type = vertex_types[next_index]
|
|
vertex = vertices[index]
|
|
next_vertex = vertices[next_index]
|
|
if vertex_type != BACK: # FRONT or COPLANAR
|
|
front_vertices.append(vertex)
|
|
if vertex_type != FRONT: # BACK or COPLANAR
|
|
back_vertices.append(vertex)
|
|
if (vertex_type | next_vertex_type) == SPANNING:
|
|
interpolation_weight = (
|
|
self.w - self.normal.dot(vertex)
|
|
) / self.normal.dot(next_vertex - vertex)
|
|
plane_intersection_point = vertex.lerp(
|
|
next_vertex, interpolation_weight
|
|
)
|
|
front_vertices.append(plane_intersection_point)
|
|
back_vertices.append(plane_intersection_point)
|
|
if len(front_vertices) >= 3:
|
|
front.append(Polygon(front_vertices, meshid=meshid))
|
|
if len(back_vertices) >= 3:
|
|
back.append(Polygon(back_vertices, meshid=meshid))
|
|
|
|
|
|
class Polygon:
|
|
"""
|
|
Represents a convex polygon. The vertices used to initialize a polygon must
|
|
be coplanar and form a convex loop, the `meshid` argument associates a polygon
|
|
to a mesh.
|
|
|
|
Args:
|
|
vertices: polygon vertices as :class:`Vec3` objects
|
|
meshid: id associated mesh
|
|
|
|
"""
|
|
|
|
__slots__ = ("vertices", "plane", "meshid")
|
|
|
|
def __init__(self, vertices: list[Vec3], meshid: int = 0):
|
|
self.vertices = vertices
|
|
try:
|
|
normal = normal_vector_3p(vertices[0], vertices[1], vertices[2])
|
|
except ZeroDivisionError: # got three colinear vertices
|
|
normal = best_fit_normal(vertices)
|
|
self.plane = Plane(normal, normal.dot(vertices[0]))
|
|
# number of mesh, this polygon is associated to
|
|
self.meshid = meshid
|
|
|
|
def clone(self) -> Polygon:
|
|
return Polygon(list(self.vertices), meshid=self.meshid)
|
|
|
|
def flip(self) -> None:
|
|
self.vertices.reverse()
|
|
self.plane.flip()
|
|
|
|
def __repr__(self) -> str:
|
|
v = ", ".join(repr(v) for v in self.vertices)
|
|
return f"Polygon([{v}], mesh={self.meshid})"
|
|
|
|
|
|
class BSPNode:
|
|
"""
|
|
Holds a node in a BSP tree. A BSP tree is built from a collection of polygons
|
|
by picking a polygon to split along. That polygon (and all other coplanar
|
|
polygons) are added directly to that node and the other polygons are added to
|
|
the front and/or back subtrees. This is not a leafy BSP tree since there is
|
|
no distinction between internal and leaf nodes.
|
|
"""
|
|
|
|
__slots__ = ("plane", "front", "back", "polygons")
|
|
|
|
def __init__(self, polygons: Optional[list[Polygon]] = None):
|
|
self.plane: Optional[Plane] = None
|
|
self.front: Optional[BSPNode] = None
|
|
self.back: Optional[BSPNode] = None
|
|
self.polygons: list[Polygon] = []
|
|
if polygons:
|
|
self.build(polygons)
|
|
|
|
def clone(self) -> BSPNode:
|
|
node = BSPNode()
|
|
if self.plane:
|
|
node.plane = self.plane.clone()
|
|
if self.front:
|
|
node.front = self.front.clone()
|
|
if self.back:
|
|
node.back = self.back.clone()
|
|
node.polygons = [p.clone() for p in self.polygons]
|
|
return node
|
|
|
|
def invert(self) -> None:
|
|
"""Convert solid space to empty space and empty space to solid space."""
|
|
for poly in self.polygons:
|
|
poly.flip()
|
|
assert self.plane is not None
|
|
self.plane.flip()
|
|
if self.front:
|
|
self.front.invert()
|
|
if self.back:
|
|
self.back.invert()
|
|
self.front, self.back = self.back, self.front
|
|
|
|
def clip_polygons(self, polygons: list[Polygon]) -> list[Polygon]:
|
|
"""Recursively remove all polygons in `polygons` that are inside this
|
|
BSP tree.
|
|
|
|
"""
|
|
if self.plane is None:
|
|
return polygons[:]
|
|
|
|
front: list[Polygon] = []
|
|
back: list[Polygon] = []
|
|
for polygon in polygons:
|
|
self.plane.split_polygon(polygon, front, back, front, back)
|
|
|
|
if self.front:
|
|
front = self.front.clip_polygons(front)
|
|
|
|
if self.back:
|
|
back = self.back.clip_polygons(back)
|
|
else:
|
|
back = []
|
|
|
|
front.extend(back)
|
|
return front
|
|
|
|
def clip_to(self, bsp: BSPNode) -> None:
|
|
"""Remove all polygons in this BSP tree that are inside the other BSP
|
|
tree `bsp`.
|
|
"""
|
|
self.polygons = bsp.clip_polygons(self.polygons)
|
|
if self.front:
|
|
self.front.clip_to(bsp)
|
|
if self.back:
|
|
self.back.clip_to(bsp)
|
|
|
|
def all_polygons(self) -> list[Polygon]:
|
|
"""Return a list of all polygons in this BSP tree."""
|
|
polygons = self.polygons[:]
|
|
if self.front:
|
|
polygons.extend(self.front.all_polygons())
|
|
if self.back:
|
|
polygons.extend(self.back.all_polygons())
|
|
return polygons
|
|
|
|
def build(self, polygons: list[Polygon]) -> None:
|
|
"""
|
|
Build a BSP tree out of `polygons`. When called on an existing tree, the
|
|
new polygons are filtered down to the bottom of the tree and become new
|
|
nodes there. Each set of polygons is partitioned using the first polygon
|
|
(no heuristic is used to pick a good split).
|
|
"""
|
|
if len(polygons) == 0:
|
|
return
|
|
if self.plane is None:
|
|
# do a wise choice and pick the first polygon as split-plane ;)
|
|
self.plane = polygons[0].plane.clone()
|
|
# add first polygon to this node
|
|
self.polygons.append(polygons[0])
|
|
front: list[Polygon] = []
|
|
back: list[Polygon] = []
|
|
# split all other polygons at the split plane
|
|
for poly in polygons[1:]:
|
|
# coplanar front and back polygons go into self.polygons
|
|
self.plane.split_polygon(
|
|
poly, self.polygons, self.polygons, front, back
|
|
)
|
|
# recursively build the BSP tree
|
|
if len(front) > 0:
|
|
if self.front is None:
|
|
self.front = BSPNode()
|
|
self.front.build(front)
|
|
if len(back) > 0:
|
|
if self.back is None:
|
|
self.back = BSPNode()
|
|
self.back.build(back)
|
|
|
|
|
|
class CSG:
|
|
"""
|
|
Constructive Solid Geometry (CSG) is a modeling technique that uses Boolean
|
|
operations like union and intersection to combine 3D solids. This class
|
|
implements CSG operations on meshes.
|
|
|
|
New 3D solids are created from :class:`~ezdxf.render.MeshBuilder` objects
|
|
and results can be exported as :class:`~ezdxf.render.MeshTransformer` objects
|
|
to `ezdxf` by method :meth:`mesh`.
|
|
|
|
Args:
|
|
mesh: :class:`ezdxf.render.MeshBuilder` or inherited object
|
|
meshid: individual mesh ID to separate result meshes, ``0`` is default
|
|
|
|
"""
|
|
|
|
def __init__(self, mesh: Optional[MeshBuilder] = None, meshid: int = 0):
|
|
if mesh is None:
|
|
self.polygons: list[Polygon] = []
|
|
else:
|
|
mesh_copy = mesh.copy()
|
|
mesh_copy.normalize_faces()
|
|
self.polygons = [
|
|
Polygon(face, meshid) for face in mesh_copy.faces_as_vertices()
|
|
]
|
|
|
|
@classmethod
|
|
def from_polygons(cls, polygons: list[Polygon]) -> CSG:
|
|
csg = CSG()
|
|
csg.polygons = polygons
|
|
return csg
|
|
|
|
def mesh(self, meshid: int = 0) -> MeshTransformer:
|
|
"""
|
|
Returns a :class:`ezdxf.render.MeshTransformer` object.
|
|
|
|
Args:
|
|
meshid: individual mesh ID, ``0`` is default
|
|
|
|
"""
|
|
mesh = MeshVertexMerger()
|
|
for face in self.polygons:
|
|
if meshid == face.meshid:
|
|
mesh.add_face(face.vertices)
|
|
return MeshTransformer.from_builder(mesh)
|
|
|
|
def clone(self) -> CSG:
|
|
return self.from_polygons([p.clone() for p in self.polygons])
|
|
|
|
def union(self, other: CSG) -> CSG:
|
|
"""
|
|
Return a new CSG solid representing space in either this solid or in the
|
|
solid `other`. Neither this solid nor the solid `other` are modified::
|
|
|
|
A.union(B)
|
|
|
|
+-------+ +-------+
|
|
| | | |
|
|
| A | | |
|
|
| +--+----+ = | +----+
|
|
+----+--+ | +----+ |
|
|
| B | | |
|
|
| | | |
|
|
+-------+ +-------+
|
|
"""
|
|
a = BSPNode(self.clone().polygons)
|
|
b = BSPNode(other.clone().polygons)
|
|
a.clip_to(b)
|
|
b.clip_to(a)
|
|
b.invert()
|
|
b.clip_to(a)
|
|
b.invert()
|
|
a.build(b.all_polygons())
|
|
return CSG.from_polygons(a.all_polygons())
|
|
|
|
__add__ = union
|
|
|
|
def subtract(self, other: CSG) -> CSG:
|
|
"""
|
|
Return a new CSG solid representing space in this solid but not in the
|
|
solid `other`. Neither this solid nor the solid `other` are modified::
|
|
|
|
A.subtract(B)
|
|
|
|
+-------+ +-------+
|
|
| | | |
|
|
| A | | |
|
|
| +--+----+ = | +--+
|
|
+----+--+ | +----+
|
|
| B |
|
|
| |
|
|
+-------+
|
|
"""
|
|
a = BSPNode(self.clone().polygons)
|
|
b = BSPNode(other.clone().polygons)
|
|
a.invert()
|
|
a.clip_to(b)
|
|
b.clip_to(a)
|
|
b.invert()
|
|
b.clip_to(a)
|
|
b.invert()
|
|
a.build(b.all_polygons())
|
|
a.invert()
|
|
return CSG.from_polygons(a.all_polygons())
|
|
|
|
__sub__ = subtract
|
|
|
|
def intersect(self, other: CSG) -> CSG:
|
|
"""
|
|
Return a new CSG solid representing space both this solid and in the
|
|
solid `other`. Neither this solid nor the solid `other` are modified::
|
|
|
|
A.intersect(B)
|
|
|
|
+-------+
|
|
| |
|
|
| A |
|
|
| +--+----+ = +--+
|
|
+----+--+ | +--+
|
|
| B |
|
|
| |
|
|
+-------+
|
|
"""
|
|
a = BSPNode(self.clone().polygons)
|
|
b = BSPNode(other.clone().polygons)
|
|
a.invert()
|
|
b.clip_to(a)
|
|
b.invert()
|
|
a.clip_to(b)
|
|
b.clip_to(a)
|
|
a.build(b.all_polygons())
|
|
a.invert()
|
|
return CSG.from_polygons(a.all_polygons())
|
|
|
|
__mul__ = intersect
|
|
|
|
def inverse(self) -> CSG:
|
|
"""
|
|
Return a new CSG solid with solid and empty space switched. This solid is
|
|
not modified.
|
|
"""
|
|
csg = self.clone()
|
|
for p in csg.polygons:
|
|
p.flip()
|
|
return csg
|