Merge pull request #1120 from jwagenet/intersections-2d

Intersect Everything: 2D, 3D, Composite Shapes
This commit is contained in:
Roger Maitland 2025-11-15 13:28:39 -05:00 committed by GitHub
commit df17ae8698
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
14 changed files with 708 additions and 149 deletions

View file

@ -68,8 +68,7 @@ with BuildPart() as ex26:
with BuildSketch() as ex26_sk: with BuildSketch() as ex26_sk:
with Locations((0, rev)): with Locations((0, rev)):
Circle(rad) Circle(rad)
revolve(axis=Axis.X, revolution_arc=90) revolve(axis=Axis.X, revolution_arc=180)
mirror(about=Plane.XZ)
with BuildSketch() as ex26_sk2: with BuildSketch() as ex26_sk2:
Rectangle(rad, rev) Rectangle(rad, rev)
ex26_target = ex26.part ex26_target = ex26.part

View file

@ -26,8 +26,8 @@ rad, rev = 3, 25
# Extrude last # Extrude last
circle = Pos(0, rev) * Circle(rad) circle = Pos(0, rev) * Circle(rad)
ex26_target = revolve(circle, Axis.X, revolution_arc=90) ex26_target = revolve(circle, Axis.X, revolution_arc=180)
ex26_target = ex26_target + mirror(ex26_target, Plane.XZ) ex26_target = ex26_target
rect = Rectangle(rad, rev) rect = Rectangle(rad, rev)

View file

@ -466,7 +466,7 @@ class Builder(ABC, Generic[ShapeT]):
elif mode == Mode.INTERSECT: elif mode == Mode.INTERSECT:
if self._obj is None: if self._obj is None:
raise RuntimeError("Nothing to intersect with") raise RuntimeError("Nothing to intersect with")
combined = self._obj.intersect(*typed[self._shape]) combined = self._obj.intersect(Compound(typed[self._shape]))
elif mode == Mode.REPLACE: elif mode == Mode.REPLACE:
combined = self._sub_class(list(typed[self._shape])) combined = self._sub_class(list(typed[self._shape]))

View file

@ -453,7 +453,7 @@ class DimensionLine(BaseSketchObject):
if self_intersection is None: if self_intersection is None:
self_intersection_area = 0.0 self_intersection_area = 0.0
else: else:
self_intersection_area = self_intersection.area self_intersection_area = sum(f.area for f in self_intersection.faces())
d_line += placed_label d_line += placed_label
bbox_size = d_line.bounding_box().diagonal bbox_size = d_line.bounding_box().diagonal
@ -467,7 +467,7 @@ class DimensionLine(BaseSketchObject):
if line_intersection is None: if line_intersection is None:
common_area = 0.0 common_area = 0.0
else: else:
common_area = line_intersection.area common_area = sum(f.area for f in line_intersection.faces())
common_area += self_intersection_area common_area += self_intersection_area
score = (d_line.area - 10 * common_area) / bbox_size score = (d_line.area - 10 * common_area) / bbox_size
d_lines[d_line] = score d_lines[d_line] = score

View file

@ -58,13 +58,12 @@ import copy
import os import os
import sys import sys
import warnings import warnings
from itertools import combinations
from typing import Type, Union
from collections.abc import Iterable, Iterator, Sequence from collections.abc import Iterable, Iterator, Sequence
from itertools import combinations
from typing_extensions import Self
import OCP.TopAbs as ta import OCP.TopAbs as ta
from OCP.BRepAlgoAPI import BRepAlgoAPI_Fuse from OCP.BRepAlgoAPI import BRepAlgoAPI_Common, BRepAlgoAPI_Fuse, BRepAlgoAPI_Section
from OCP.Font import ( from OCP.Font import (
Font_FA_Bold, Font_FA_Bold,
Font_FA_BoldItalic, Font_FA_BoldItalic,
@ -107,7 +106,6 @@ from build123d.geometry import (
VectorLike, VectorLike,
logger, logger,
) )
from typing_extensions import Self
from .one_d import Edge, Wire, Mixin1D from .one_d import Edge, Wire, Mixin1D
from .shape_core import ( from .shape_core import (
@ -651,11 +649,7 @@ class Compound(Mixin3D[TopoDS_Compound]):
children[child_index_pair[1]] children[child_index_pair[1]]
) )
if obj_intersection is not None: if obj_intersection is not None:
common_volume = ( common_volume = sum(s.volume for s in obj_intersection.solids())
0.0
if isinstance(obj_intersection, list)
else obj_intersection.volume
)
if common_volume > tolerance: if common_volume > tolerance:
return ( return (
True, True,
@ -711,6 +705,148 @@ class Compound(Mixin3D[TopoDS_Compound]):
return results return results
def intersect(
self, *to_intersect: Shape | Vector | Location | Axis | Plane
) -> None | ShapeList[Vertex | Edge | Face | Solid]:
"""Intersect Compound with Shape or geometry object
Args:
to_intersect (Shape | Vector | Location | Axis | Plane): objects to intersect
Returns:
ShapeList[Vertex | Edge | Face | Solid] | None: ShapeList of vertices, edges,
faces, and/or solids.
"""
def to_vector(objs: Iterable) -> ShapeList:
return ShapeList([Vector(v) if isinstance(v, Vertex) else v for v in objs])
def to_vertex(objs: Iterable) -> ShapeList:
return ShapeList([Vertex(v) if isinstance(v, Vector) else v for v in objs])
def bool_op(
args: Sequence,
tools: Sequence,
operation: BRepAlgoAPI_Common | BRepAlgoAPI_Section,
) -> ShapeList:
# Wrap Shape._bool_op for corrected output
intersections: Shape | ShapeList = Shape()._bool_op(args, tools, operation)
if isinstance(intersections, ShapeList):
return intersections
if isinstance(intersections, Shape) and not intersections.is_null:
return ShapeList([intersections])
return ShapeList()
def expand_compound(compound: Compound) -> ShapeList:
shapes = ShapeList(compound.children)
for shape_type in [Vertex, Edge, Wire, Face, Shell, Solid]:
shapes.extend(compound.get_type(shape_type))
return shapes
def filter_shapes_by_order(shapes: ShapeList, orders: list) -> ShapeList:
# Remove lower order shapes from list which *appear* to be part of
# a higher order shape using a lazy distance check
# (sufficient for vertices, may be an issue for higher orders)
order_groups = []
for order in orders:
order_groups.append(
ShapeList([s for s in shapes if isinstance(s, order)])
)
filtered_shapes = order_groups[-1]
for i in range(len(order_groups) - 1):
los = order_groups[i]
his: list = sum(order_groups[i + 1 :], [])
filtered_shapes.extend(
ShapeList(
lo
for lo in los
if all(lo.distance_to(hi) > TOLERANCE for hi in his)
)
)
return filtered_shapes
common_set: ShapeList[Shape] = expand_compound(self)
target: ShapeList | Shape
for other in to_intersect:
# Conform target type
match other:
case Axis():
# BRepAlgoAPI_Section seems happier if Edge isnt infinite
bbox = self.bounding_box()
dist = self.distance_to(other.position)
dist = dist if dist >= 1 else 1
target = Edge.make_line(
other.position - other.direction * bbox.diagonal * dist,
other.position + other.direction * bbox.diagonal * dist,
)
case Plane():
target = Face(other)
case Vector():
target = Vertex(other)
case Location():
target = Vertex(other.position)
case Compound():
target = expand_compound(other)
case _ if issubclass(type(other), Shape):
target = other
case _:
raise ValueError(f"Unsupported type to_intersect: {type(other)}")
# Find common matches
common: list[Vertex | Edge | Wire | Face | Shell | Solid] = []
result: ShapeList
for obj in common_set:
if isinstance(target, Shape):
target = ShapeList([target])
result = ShapeList()
for t in target:
operation = BRepAlgoAPI_Section()
result.extend(bool_op((obj,), (t,), operation))
if (
not isinstance(obj, Edge | Wire)
and not isinstance(t, Edge | Wire)
) or (
isinstance(obj, Solid | Compound)
or isinstance(t, Solid | Compound)
):
# Face + Edge combinations may produce an intersection
# with Common but always with Section.
# No easy way to deduplicate
# Many Solid + Edge combinations need Common
operation = BRepAlgoAPI_Common()
result.extend(bool_op((obj,), (t,), operation))
if result:
common.extend(result)
expanded: ShapeList = ShapeList()
if common:
for shape in common:
if isinstance(shape, Compound):
expanded.extend(expand_compound(shape))
else:
expanded.append(shape)
if expanded:
common_set = ShapeList()
for shape in expanded:
if isinstance(shape, Wire):
common_set.extend(shape.edges())
elif isinstance(shape, Shell):
common_set.extend(shape.faces())
else:
common_set.append(shape)
common_set = to_vertex(set(to_vector(common_set)))
common_set = filter_shapes_by_order(
common_set, [Vertex, Edge, Face, Solid]
)
else:
return None
return ShapeList(common_set)
def unwrap(self, fully: bool = True) -> Self | Shape: def unwrap(self, fully: bool = True) -> Self | Shape:
"""Strip unnecessary Compound wrappers """Strip unnecessary Compound wrappers

View file

@ -52,12 +52,11 @@ license:
from __future__ import annotations from __future__ import annotations
import copy import copy
import numpy as np
import warnings import warnings
from collections.abc import Iterable from collections.abc import Iterable, Sequence
from itertools import combinations from itertools import combinations
from math import atan2, ceil, copysign, cos, floor, inf, isclose, pi, radians from math import atan2, ceil, copysign, cos, floor, inf, isclose, pi, radians
from typing import TYPE_CHECKING, Literal, TypeAlias, overload from typing import TYPE_CHECKING, Literal, overload
from typing import cast as tcast from typing import cast as tcast
import numpy as np import numpy as np
@ -729,122 +728,103 @@ class Mixin1D(Shape[TOPODS]):
def to_vertex(objs: Iterable) -> ShapeList: def to_vertex(objs: Iterable) -> ShapeList:
return ShapeList([Vertex(v) if isinstance(v, Vector) else v for v in objs]) return ShapeList([Vertex(v) if isinstance(v, Vector) else v for v in objs])
common_set: ShapeList[Vertex | Edge] = ShapeList(self.edges()) def bool_op(
target: ShapeList | Shape | Plane args: Sequence,
tools: Sequence,
operation: BRepAlgoAPI_Section | BRepAlgoAPI_Common,
) -> ShapeList:
# Wrap Shape._bool_op for corrected output
intersections: Shape | ShapeList = Shape()._bool_op(args, tools, operation)
if isinstance(intersections, ShapeList):
return intersections or ShapeList()
if isinstance(intersections, Shape) and not intersections.is_null:
return ShapeList([intersections])
return ShapeList()
def filter_shapes_by_order(shapes: ShapeList, orders: list) -> ShapeList:
# Remove lower order shapes from list which *appear* to be part of
# a higher order shape using a lazy distance check
# (sufficient for vertices, may be an issue for higher orders)
order_groups = []
for order in orders:
order_groups.append(
ShapeList([s for s in shapes if isinstance(s, order)])
)
filtered_shapes = order_groups[-1]
for i in range(len(order_groups) - 1):
los = order_groups[i]
his: list = sum(order_groups[i + 1 :], [])
filtered_shapes.extend(
ShapeList(
lo
for lo in los
if all(lo.distance_to(hi) > TOLERANCE for hi in his)
)
)
return filtered_shapes
common_set: ShapeList[Vertex | Edge | Wire] = ShapeList([self])
target: Shape | Plane
for other in to_intersect: for other in to_intersect:
# Conform target type # Conform target type
# Vertices need to be Vector for set()
match other: match other:
case Axis(): case Axis():
target = ShapeList([Edge(other)]) # BRepAlgoAPI_Section seems happier if Edge isnt infinite
bbox = self.bounding_box()
dist = self.distance_to(other.position)
dist = dist if dist >= 1 else 1
target = Edge.make_line(
other.position - other.direction * bbox.diagonal * dist,
other.position + other.direction * bbox.diagonal * dist,
)
case Plane(): case Plane():
target = other target = other
case Vector(): case Vector():
target = Vertex(other) target = Vertex(other)
case Location(): case Location():
target = Vertex(other.position) target = Vertex(other.position)
case Edge():
target = ShapeList([other])
case Wire():
target = ShapeList(other.edges())
case _ if issubclass(type(other), Shape): case _ if issubclass(type(other), Shape):
target = other target = other
case _: case _:
raise ValueError(f"Unsupported type to_intersect: {type(other)}") raise ValueError(f"Unsupported type to_intersect: {type(other)}")
# Find common matches # Find common matches
common: list[Vector | Edge] = [] common: list[Vertex | Edge | Wire] = []
result: ShapeList | Shape | None result: ShapeList | None
for obj in common_set: for obj in common_set:
match (obj, target): match (obj, target):
case obj, Shape() as target: case (_, Plane()):
# Find Shape with Edge/Wire target = Shape(BRepBuilderAPI_MakeFace(other.wrapped).Face())
if isinstance(target, Vertex): operation = BRepAlgoAPI_Section()
result = Shape.intersect(obj, target) result = bool_op((obj,), (target,), operation)
else: operation = BRepAlgoAPI_Common()
result.extend(bool_op((obj,), (target,), operation))
case (_, Vertex() | Edge() | Wire()):
operation = BRepAlgoAPI_Section()
section = bool_op((obj,), (target,), operation)
result = section
if not section:
operation = BRepAlgoAPI_Common()
result.extend(bool_op((obj,), (target,), operation))
case _ if issubclass(type(target), Shape):
result = target.intersect(obj) result = target.intersect(obj)
if result: if result:
if not isinstance(result, list): common.extend(result)
result = ShapeList([result])
common.extend(to_vector(result))
case Vertex() as obj, target:
if not isinstance(target, ShapeList):
target = ShapeList([target])
for tar in target:
if isinstance(tar, Edge):
result = Shape.intersect(obj, tar)
else:
result = obj.intersect(tar)
if result:
if not isinstance(result, list):
result = ShapeList([result])
common.extend(to_vector(result))
case Edge() as obj, ShapeList() as targets:
# Find any edge / edge intersection points
for tar in targets:
# Find crossing points
try:
intersection_points = obj.find_intersection_points(tar)
common.extend(intersection_points)
except ValueError:
pass
# Find common end points
obj_end_points = set(Vector(v) for v in obj.vertices())
tar_end_points = set(Vector(v) for v in tar.vertices())
points = set.intersection(obj_end_points, tar_end_points)
common.extend(points)
# Find Edge/Edge overlaps
result = obj._bool_op(
(obj,), targets, BRepAlgoAPI_Common()
).edges()
common.extend(result if isinstance(result, list) else [result])
case Edge() as obj, Plane() as plane:
# Find any edge / plane intersection points & edges
# Find point intersections
if not obj:
continue
geom_line = BRep_Tool.Curve_s(
obj.wrapped, obj.param_at(0), obj.param_at(1)
)
geom_plane = Geom_Plane(plane.local_coord_system)
intersection_calculator = GeomAPI_IntCS(geom_line, geom_plane)
plane_intersection_points: list[Vector] = []
if intersection_calculator.IsDone():
plane_intersection_points = [
Vector(intersection_calculator.Point(i + 1))
for i in range(intersection_calculator.NbPoints())
]
common.extend(plane_intersection_points)
# Find edge intersections
if all(
plane.contains(v)
for v in obj.positions(i / 7 for i in range(8))
): # is a 2D edge
common.append(obj)
if common: if common:
common_set = to_vertex(set(common)) common_set = ShapeList()
# Remove Vertex intersections coincident to Edge intersections for shape in common:
vts = common_set.vertices() if isinstance(shape, Wire):
eds = common_set.edges() common_set.extend(shape.edges())
if vts and eds: else:
filtered_vts = ShapeList( common_set.append(shape)
[ common_set = to_vertex(set(to_vector(common_set)))
v common_set = filter_shapes_by_order(common_set, [Vertex, Edge])
for v in vts
if all(v.distance_to(e) > TOLERANCE for e in eds)
]
)
common_set = filtered_vts + eds
else: else:
return None return None

View file

@ -1339,7 +1339,7 @@ class Shape(NodeMixin, Generic[TOPODS]):
def intersect( def intersect(
self, *to_intersect: Shape | Vector | Location | Axis | Plane self, *to_intersect: Shape | Vector | Location | Axis | Plane
) -> None | Self | ShapeList[Self]: ) -> None | ShapeList[Self]:
"""Intersection of the arguments and this shape """Intersection of the arguments and this shape
Args: Args:
@ -1347,8 +1347,8 @@ class Shape(NodeMixin, Generic[TOPODS]):
intersect with intersect with
Returns: Returns:
Self | ShapeList[Self]: Resulting object may be of a different class than self None | ShapeList[Self]: Resulting ShapeList may contain different class
or a ShapeList if multiple non-Compound object created than self
""" """
def _to_vertex(vec: Vector) -> Vertex: def _to_vertex(vec: Vector) -> Vertex:
@ -1392,15 +1392,12 @@ class Shape(NodeMixin, Generic[TOPODS]):
# Find the shape intersections # Find the shape intersections
intersect_op = BRepAlgoAPI_Common() intersect_op = BRepAlgoAPI_Common()
shape_intersections = self._bool_op((self,), objs, intersect_op) intersections = self._bool_op((self,), objs, intersect_op)
if isinstance(shape_intersections, ShapeList) and not shape_intersections: if isinstance(intersections, ShapeList):
return intersections or None
if isinstance(intersections, Shape) and not intersections.is_null:
return ShapeList([intersections])
return None return None
if (
not isinstance(shape_intersections, ShapeList)
and shape_intersections.is_null
):
return None
return shape_intersections
def is_equal(self, other: Shape) -> bool: def is_equal(self, other: Shape) -> bool:
"""Returns True if two shapes are equal, i.e. if they share the same """Returns True if two shapes are equal, i.e. if they share the same
@ -2138,7 +2135,11 @@ class Shape(NodeMixin, Generic[TOPODS]):
args = list(args) args = list(args)
tools = list(tools) tools = list(tools)
# Find the highest order class from all the inputs Solid > Vertex # Find the highest order class from all the inputs Solid > Vertex
order_dict = {type(s): type(s).order for s in [self] + args + tools} order_dict = {
type(s): type(s).order
for s in [self] + args + tools
if hasattr(type(s), "order")
}
highest_order = sorted(order_dict.items(), key=lambda item: item[1])[-1] highest_order = sorted(order_dict.items(), key=lambda item: item[1])[-1]
# The base of the operation # The base of the operation

View file

@ -56,13 +56,13 @@ from __future__ import annotations
import platform import platform
import warnings import warnings
from collections.abc import Iterable, Sequence
from math import radians, cos, tan from math import radians, cos, tan
from typing import Union, TYPE_CHECKING from typing import TYPE_CHECKING
from typing_extensions import Self
from collections.abc import Iterable
import OCP.TopAbs as ta import OCP.TopAbs as ta
from OCP.BRepAlgoAPI import BRepAlgoAPI_Cut from OCP.BRepAlgoAPI import BRepAlgoAPI_Common, BRepAlgoAPI_Cut, BRepAlgoAPI_Section
from OCP.BRepBuilderAPI import BRepBuilderAPI_MakeSolid from OCP.BRepBuilderAPI import BRepBuilderAPI_MakeSolid
from OCP.BRepClass3d import BRepClass3d_SolidClassifier from OCP.BRepClass3d import BRepClass3d_SolidClassifier
from OCP.BRepFeat import BRepFeat_MakeDPrism from OCP.BRepFeat import BRepFeat_MakeDPrism
@ -95,6 +95,7 @@ from OCP.gp import gp_Ax2, gp_Pnt
from build123d.build_enums import CenterOf, GeomType, Kind, Transition, Until from build123d.build_enums import CenterOf, GeomType, Kind, Transition, Until
from build123d.geometry import ( from build123d.geometry import (
DEG2RAD, DEG2RAD,
TOLERANCE,
Axis, Axis,
BoundBox, BoundBox,
Color, Color,
@ -104,7 +105,6 @@ from build123d.geometry import (
Vector, Vector,
VectorLike, VectorLike,
) )
from typing_extensions import Self
from .one_d import Edge, Wire, Mixin1D from .one_d import Edge, Wire, Mixin1D
from .shape_core import TOPODS, Shape, ShapeList, Joint, downcast, shapetype from .shape_core import TOPODS, Shape, ShapeList, Joint, downcast, shapetype
@ -420,6 +420,130 @@ class Mixin3D(Shape[TOPODS]):
return return_value return return_value
def intersect(
self, *to_intersect: Shape | Vector | Location | Axis | Plane
) -> None | ShapeList[Vertex | Edge | Face | Solid]:
"""Intersect Solid with Shape or geometry object
Args:
to_intersect (Shape | Vector | Location | Axis | Plane): objects to intersect
Returns:
ShapeList[Vertex | Edge | Face | Solid] | None: ShapeList of vertices, edges,
faces, and/or solids.
"""
def to_vector(objs: Iterable) -> ShapeList:
return ShapeList([Vector(v) if isinstance(v, Vertex) else v for v in objs])
def to_vertex(objs: Iterable) -> ShapeList:
return ShapeList([Vertex(v) if isinstance(v, Vector) else v for v in objs])
def bool_op(
args: Sequence,
tools: Sequence,
operation: BRepAlgoAPI_Common | BRepAlgoAPI_Section,
) -> ShapeList:
# Wrap Shape._bool_op for corrected output
intersections: Shape | ShapeList = Shape()._bool_op(args, tools, operation)
if isinstance(intersections, ShapeList):
return intersections or ShapeList()
if isinstance(intersections, Shape) and not intersections.is_null:
return ShapeList([intersections])
return ShapeList()
def filter_shapes_by_order(shapes: ShapeList, orders: list) -> ShapeList:
# Remove lower order shapes from list which *appear* to be part of
# a higher order shape using a lazy distance check
# (sufficient for vertices, may be an issue for higher orders)
order_groups = []
for order in orders:
order_groups.append(
ShapeList([s for s in shapes if isinstance(s, order)])
)
filtered_shapes = order_groups[-1]
for i in range(len(order_groups) - 1):
los = order_groups[i]
his: list = sum(order_groups[i + 1 :], [])
filtered_shapes.extend(
ShapeList(
lo
for lo in los
if all(lo.distance_to(hi) > TOLERANCE for hi in his)
)
)
return filtered_shapes
common_set: ShapeList[Vertex | Edge | Face | Solid] = ShapeList([self])
target: Shape
for other in to_intersect:
# Conform target type
match other:
case Axis():
# BRepAlgoAPI_Section seems happier if Edge isnt infinite
bbox = self.bounding_box()
dist = self.distance_to(other.position)
dist = dist if dist >= 1 else 1
target = Edge.make_line(
other.position - other.direction * bbox.diagonal * dist,
other.position + other.direction * bbox.diagonal * dist,
)
case Plane():
target = Face(other)
case Vector():
target = Vertex(other)
case Location():
target = Vertex(other.position)
case _ if issubclass(type(other), Shape):
target = other
case _:
raise ValueError(f"Unsupported type to_intersect: {type(other)}")
# Find common matches
common: list[Vertex | Edge | Wire | Face | Shell | Solid] = []
result: ShapeList | None
for obj in common_set:
match (obj, target):
case (_, Vertex() | Edge() | Wire() | Face() | Shell() | Solid()):
operation = BRepAlgoAPI_Section()
result = bool_op((obj,), (target,), operation)
if (
not isinstance(obj, Edge | Wire)
and not isinstance(target, (Edge | Wire))
) or (isinstance(obj, Solid) or isinstance(target, Solid)):
# Face + Edge combinations may produce an intersection
# with Common but always with Section.
# No easy way to deduplicate
# Many Solid + Edge combinations need Common
operation = BRepAlgoAPI_Common()
result.extend(bool_op((obj,), (target,), operation))
case _ if issubclass(type(target), Shape):
result = target.intersect(obj)
if result:
common.extend(result)
if common:
common_set = ShapeList()
for shape in common:
if isinstance(shape, Wire):
common_set.extend(shape.edges())
elif isinstance(shape, Shell):
common_set.extend(shape.faces())
else:
common_set.append(shape)
common_set = to_vertex(set(to_vector(common_set)))
common_set = filter_shapes_by_order(
common_set, [Vertex, Edge, Face, Solid]
)
else:
return None
return ShapeList(common_set)
def is_inside(self, point: VectorLike, tolerance: float = 1.0e-6) -> bool: def is_inside(self, point: VectorLike, tolerance: float = 1.0e-6) -> bool:
"""Returns whether or not the point is inside a solid or compound """Returns whether or not the point is inside a solid or compound
object within the specified tolerance. object within the specified tolerance.

View file

@ -66,7 +66,7 @@ import OCP.TopAbs as ta
from OCP.BRep import BRep_Builder, BRep_Tool from OCP.BRep import BRep_Builder, BRep_Tool
from OCP.BRepAdaptor import BRepAdaptor_Curve, BRepAdaptor_Surface from OCP.BRepAdaptor import BRepAdaptor_Curve, BRepAdaptor_Surface
from OCP.BRepAlgo import BRepAlgo from OCP.BRepAlgo import BRepAlgo
from OCP.BRepAlgoAPI import BRepAlgoAPI_Common from OCP.BRepAlgoAPI import BRepAlgoAPI_Common, BRepAlgoAPI_Section
from OCP.BRepBuilderAPI import ( from OCP.BRepBuilderAPI import (
BRepBuilderAPI_MakeEdge, BRepBuilderAPI_MakeEdge,
BRepBuilderAPI_MakeFace, BRepBuilderAPI_MakeFace,
@ -279,6 +279,126 @@ class Mixin2D(ABC, Shape[TOPODS]):
return result return result
def intersect(
self, *to_intersect: Shape | Vector | Location | Axis | Plane
) -> None | ShapeList[Vertex | Edge | Face]:
"""Intersect Face with Shape or geometry object
Args:
to_intersect (Shape | Vector | Location | Axis | Plane): objects to intersect
Returns:
ShapeList[Vertex | Edge | Face] | None: ShapeList of vertices, edges, and/or
faces.
"""
def to_vector(objs: Iterable) -> ShapeList:
return ShapeList([Vector(v) if isinstance(v, Vertex) else v for v in objs])
def to_vertex(objs: Iterable) -> ShapeList:
return ShapeList([Vertex(v) if isinstance(v, Vector) else v for v in objs])
def bool_op(
args: Sequence,
tools: Sequence,
operation: BRepAlgoAPI_Section | BRepAlgoAPI_Common,
) -> ShapeList:
# Wrap Shape._bool_op for corrected output
intersections: Shape | ShapeList = Shape()._bool_op(args, tools, operation)
if isinstance(intersections, ShapeList):
return intersections or ShapeList()
if isinstance(intersections, Shape) and not intersections.is_null:
return ShapeList([intersections])
return ShapeList()
def filter_shapes_by_order(shapes: ShapeList, orders: list) -> ShapeList:
# Remove lower order shapes from list which *appear* to be part of
# a higher order shape using a lazy distance check
# (sufficient for vertices, may be an issue for higher orders)
order_groups = []
for order in orders:
order_groups.append(
ShapeList([s for s in shapes if isinstance(s, order)])
)
filtered_shapes = order_groups[-1]
for i in range(len(order_groups) - 1):
los = order_groups[i]
his: list = sum(order_groups[i + 1 :], [])
filtered_shapes.extend(
ShapeList(
lo
for lo in los
if all(lo.distance_to(hi) > TOLERANCE for hi in his)
)
)
return filtered_shapes
common_set: ShapeList[Vertex | Edge | Face | Shell] = ShapeList([self])
target: Shape
for other in to_intersect:
# Conform target type
match other:
case Axis():
# BRepAlgoAPI_Section seems happier if Edge isnt infinite
bbox = self.bounding_box()
dist = self.distance_to(other.position)
dist = dist if dist >= 1 else 1
target = Edge.make_line(
other.position - other.direction * bbox.diagonal * dist,
other.position + other.direction * bbox.diagonal * dist,
)
case Plane():
target = Face(other)
case Vector():
target = Vertex(other)
case Location():
target = Vertex(other.position)
case _ if issubclass(type(other), Shape):
target = other
case _:
raise ValueError(f"Unsupported type to_intersect: {type(other)}")
# Find common matches
common: list[Vertex | Edge | Wire | Face | Shell] = []
result: ShapeList | None
for obj in common_set:
match (obj, target):
case (_, Vertex() | Edge() | Wire() | Face() | Shell()):
operation = BRepAlgoAPI_Section()
result = bool_op((obj,), (target,), operation)
if not isinstance(obj, Edge | Wire) and not isinstance(
target, (Edge | Wire)
):
# Face + Edge combinations may produce an intersection
# with Common but always with Section.
# No easy way to deduplicate
operation = BRepAlgoAPI_Common()
result.extend(bool_op((obj,), (target,), operation))
case _ if issubclass(type(target), Shape):
result = target.intersect(obj)
if result:
common.extend(result)
if common:
common_set = ShapeList()
for shape in common:
if isinstance(shape, Wire):
common_set.extend(shape.edges())
elif isinstance(shape, Shell):
common_set.extend(shape.faces())
else:
common_set.append(shape)
common_set = to_vertex(set(to_vector(common_set)))
common_set = filter_shapes_by_order(common_set, [Vertex, Edge, Face])
else:
return None
return ShapeList(common_set)
@abstractmethod @abstractmethod
def location_at(self, *args: Any, **kwargs: Any) -> Location: def location_at(self, *args: Any, **kwargs: Any) -> Location:
"""A location from a face or shell""" """A location from a face or shell"""
@ -677,15 +797,13 @@ class Face(Mixin2D[TopoDS_Face]):
).sort_by(Axis(cog, cross_dir)) ).sort_by(Axis(cog, cross_dir))
bottom_area = sum(f.area for f in bottom_list) bottom_area = sum(f.area for f in bottom_list)
intersect_area = 0.0
for flipped_face, bottom_face in zip(top_flipped_list, bottom_list): for flipped_face, bottom_face in zip(top_flipped_list, bottom_list):
intersection = flipped_face.intersect(bottom_face) intersection = flipped_face.intersect(bottom_face)
if intersection is None or isinstance(intersection, list): if intersection is None:
intersect_area = -1.0 intersect_area = -1.0
break break
else: else:
assert isinstance(intersection, Face) intersect_area = sum(f.area for f in intersection.faces())
intersect_area += intersection.area
if intersect_area == -1.0: if intersect_area == -1.0:
continue continue

View file

@ -152,6 +152,7 @@ def test_shape_0d(obj, target, expected):
run_test(obj, target, expected) run_test(obj, target, expected)
# 1d Shapes
ed1 = Line((0, 0), (5, 0)).edge() ed1 = Line((0, 0), (5, 0)).edge()
ed2 = Line((0, -1), (5, 1)).edge() ed2 = Line((0, -1), (5, 1)).edge()
ed3 = Line((0, 0, 5), (5, 0, 5)).edge() ed3 = Line((0, 0, 5), (5, 0, 5)).edge()
@ -220,6 +221,195 @@ def test_shape_1d(obj, target, expected):
run_test(obj, target, expected) run_test(obj, target, expected)
# 2d Shapes
fc1 = Rectangle(5, 5).face()
fc2 = Pos(Z=5) * Rectangle(5, 5).face()
fc3 = Rot(Y=90) * Rectangle(5, 5).face()
fc4 = Rot(Z=45) * Rectangle(5, 5).face()
fc5 = Pos(2.5, 2.5, 2.5) * Rot(0, 90) * Rectangle(5, 5).face()
fc6 = Pos(2.5, 2.5) * Rot(0, 90, 45, Extrinsic.XYZ) * Rectangle(5, 5).face()
fc7 = (Rot(90) * Cylinder(2, 4)).faces().filter_by(GeomType.CYLINDER)[0]
fc11 = Rectangle(4, 4).face()
fc22 = sweep(Rot(90) * CenterArc((0, 0), 2, 0, 180), Line((0, 2), (0, -2)))
sh1 = Shell([Pos(-4) * fc11, fc22])
sh2 = Pos(Z=1) * sh1
sh3 = Shell([Pos(-4) * fc11, fc22, Pos(2, 0, -2) * Rot(0, 90) * fc11])
sh4 = Shell([Pos(-4) * fc11, fc22, Pos(4) * fc11])
sh5 = Pos(Z=1) * Shell([Pos(-2, 0, -2) * Rot(0, -90) * fc11, fc22, Pos(2, 0, -2) * Rot(0, 90) * fc11])
shape_2d_matrix = [
Case(fc1, vl2, None, "non-coincident", None),
Case(fc1, vl1, [Vertex], "coincident", None),
Case(fc1, lc2, None, "non-coincident", None),
Case(fc1, lc1, [Vertex], "coincident", None),
Case(fc2, ax1, None, "parallel/skew", None),
Case(fc3, ax1, [Vertex], "intersecting", None),
Case(fc1, ax1, [Edge], "collinear", None),
Case(fc1, pl3, None, "parallel/skew", None),
Case(fc1, pl1, [Edge], "intersecting", None),
Case(fc1, pl2, [Face], "collinear", None),
Case(fc7, pl1, [Edge, Edge], "multi intersect", None),
Case(fc1, vt2, None, "non-coincident", None),
Case(fc1, vt1, [Vertex], "coincident", None),
Case(fc1, ed3, None, "parallel/skew", None),
Case(Pos(1) * fc3, ed1, [Vertex], "intersecting", None),
Case(fc1, ed1, [Edge], "collinear", None),
Case(Pos(1.1) * fc3, ed4, [Vertex, Vertex], "multi intersect", None),
Case(fc1, wi6, None, "parallel/skew", None),
Case(Pos(1) * fc3, wi4, [Vertex], "intersecting", None),
Case(fc1, wi1, [Edge, Edge], "2 collinear", None),
Case(Rot(90) * fc4, wi5, [Vertex, Vertex], "multi intersect", None),
Case(Rot(90) * fc4, wi2, [Vertex, Edge], "intersect + collinear", None),
Case(fc1, fc2, None, "parallel/skew", None),
Case(fc1, fc3, [Edge], "intersecting", None),
Case(fc1, fc4, [Face], "coplanar", None),
Case(fc1, fc5, [Edge], "intersecting edge", None),
Case(fc1, fc6, [Vertex], "intersecting vertex", None),
Case(fc1, fc7, [Edge, Edge], "multi-intersecting", None),
Case(fc7, Pos(Y=2) * fc7, [Face], "cyl intersecting", None),
Case(sh2, fc1, None, "parallel/skew", None),
Case(Pos(Z=1) * sh3, fc1, [Edge], "intersecting", None),
Case(sh1, fc1, [Face, Edge], "coplanar + intersecting", None),
Case(sh4, fc1, [Face, Face], "2 coplanar", None),
Case(sh5, fc1, [Edge, Edge], "2 intersecting", None),
Case(fc1, [fc4, Pos(2, 2) * fc1], [Face], "multi to_intersect, intersecting", None),
Case(fc1, [ed1, Pos(2.5, 2.5) * fc1], [Edge], "multi to_intersect, intersecting", None),
Case(fc7, [wi5, fc1], [Vertex], "multi to_intersect, intersecting", None),
]
@pytest.mark.parametrize("obj, target, expected", make_params(shape_2d_matrix))
def test_shape_2d(obj, target, expected):
run_test(obj, target, expected)
# 3d Shapes
sl1 = Box(2, 2, 2).solid()
sl2 = Pos(Z=5) * Box(2, 2, 2).solid()
sl3 = Cylinder(2, 1).solid() - Cylinder(1.5, 1).solid()
wi7 = Wire([l1 := sl3.faces().sort_by(Axis.Z)[-1].edges()[0].trim(.3, .4),
l2 := l1.trim(2, 3),
RadiusArc(l1 @ 1, l2 @ 0, 1, short_sagitta=False)
])
shape_3d_matrix = [
Case(sl2, vl1, None, "non-coincident", None),
Case(Pos(2) * sl1, vl1, [Vertex], "contained", None),
Case(Pos(1, 1, -1) * sl1, vl1, [Vertex], "coincident", None),
Case(sl2, lc1, None, "non-coincident", None),
Case(Pos(2) * sl1, lc1, [Vertex], "contained", None),
Case(Pos(1, 1, -1) * sl1, lc1, [Vertex], "coincident", None),
Case(sl2, ax1, None, "non-coincident", None),
Case(sl1, ax1, [Edge], "intersecting", None),
Case(Pos(1, 1, 1) * sl1, ax2, [Edge], "coincident", None),
Case(sl1, pl3, None, "non-coincident", None),
Case(sl1, pl2, [Face], "intersecting", None),
Case(sl2, vt1, None, "non-coincident", None),
Case(Pos(2) * sl1, vt1, [Vertex], "contained", None),
Case(Pos(1, 1, -1) * sl1, vt1, [Vertex], "coincident", None),
Case(sl1, ed3, None, "non-coincident", None),
Case(sl1, ed1, [Edge], "intersecting", None),
Case(sl1, Pos(0, 1, 1) * ed1, [Edge], "edge collinear", "duplicate edges, BRepAlgoAPI_Common and _Section both return edge"),
Case(sl1, Pos(1, 1, 1) * ed1, [Vertex], "corner coincident", None),
Case(Pos(2.1, 1) * sl1, ed4, [Edge, Edge], "multi-intersect", None),
Case(Pos(2, .5, -1) * sl1, wi6, None, "non-coincident", None),
Case(Pos(2, .5, 1) * sl1, wi6, [Edge, Edge], "multi-intersecting", None),
Case(sl3, wi7, [Edge, Edge], "multi-coincident, is_equal check", None),
Case(sl2, fc1, None, "non-coincident", None),
Case(sl1, fc1, [Face], "intersecting", None),
Case(Pos(3.5, 0, 1) * sl1, fc1, [Edge], "edge collinear", None),
Case(Pos(3.5, 3.5) * sl1, fc1, [Vertex], "corner coincident", None),
Case(Pos(.9) * sl1, fc7, [Face, Face], "multi-intersecting", None),
Case(sl2, sh1, None, "non-coincident", None),
Case(Pos(-2) * sl1, sh1, [Face, Face], "multi-intersecting", None),
Case(sl1, sl2, None, "non-coincident", None),
Case(sl1, Pos(1, 1, 1) * sl1, [Solid], "intersecting", None),
Case(sl1, Pos(2, 2, 1) * sl1, [Edge], "edge collinear", None),
Case(sl1, Pos(2, 2, 2) * sl1, [Vertex], "corner coincident", None),
Case(sl1, Pos(.45) * sl3, [Solid, Solid], "multi-intersect", None),
Case(Pos(1.5, 1.5) * sl1, [sl3, Pos(.5, .5) * sl1], [Solid], "multi to_intersect, intersecting", None),
Case(Pos(1.5, 1.5) * sl1, [sl3, Pos(Z=.5) * fc1], [Face], "multi to_intersect, intersecting", None),
]
@pytest.mark.parametrize("obj, target, expected", make_params(shape_3d_matrix))
def test_shape_3d(obj, target, expected):
run_test(obj, target, expected)
# Compound Shapes
cp1 = Compound(GridLocations(5, 0, 2, 1) * Vertex())
cp2 = Compound(GridLocations(5, 0, 2, 1) * Line((0, -1), (0, 1)))
cp3 = Compound(GridLocations(5, 0, 2, 1) * Rectangle(2, 2))
cp4 = Compound(GridLocations(5, 0, 2, 1) * Box(2, 2, 2))
cv1 = Curve() + [ed1, ed2, ed3]
sk1 = Sketch() + [fc1, fc2, fc3]
pt1 = Part() + [sl1, sl2, sl3]
shape_compound_matrix = [
Case(cp1, vl1, None, "non-coincident", None),
Case(Pos(-.5) * cp1, vl1, [Vertex], "intersecting", None),
Case(cp2, lc1, None, "non-coincident", None),
Case(Pos(-.5) * cp2, lc1, [Vertex], "intersecting", None),
Case(Pos(Z=1) * cp3, ax1, None, "non-coincident", None),
Case(cp3, ax1, [Edge, Edge], "intersecting", None),
Case(Pos(Z=3) * cp4, pl2, None, "non-coincident", None),
Case(cp4, pl2, [Face, Face], "intersecting", None),
Case(cp1, vt1, None, "non-coincident", None),
Case(Pos(-.5) * cp1, vt1, [Vertex], "intersecting", None),
Case(Pos(Z=1) * cp2, ed1, None, "non-coincident", None),
Case(cp2, ed1, [Vertex], "intersecting", None),
Case(Pos(Z=1) * cp3, fc1, None, "non-coincident", None),
Case(cp3, fc1, [Face, Face], "intersecting", None),
Case(Pos(Z=5) * cp4, sl1, None, "non-coincident", None),
Case(Pos(2) * cp4, sl1, [Solid], "intersecting", None),
Case(cp1, Pos(Z=1) * cp1, None, "non-coincident", None),
Case(cp1, cp2, [Vertex, Vertex], "intersecting", None),
Case(cp2, cp3, [Edge, Edge], "intersecting", None),
Case(cp3, cp4, [Face, Face], "intersecting", None),
Case(cp1, Compound(children=cp1.get_type(Vertex)), [Vertex, Vertex], "mixed child type", None),
Case(cp4, Compound(children=cp3.get_type(Face)), [Face, Face], "mixed child type", None),
Case(cp2, [cp3, cp4], [Edge, Edge], "multi to_intersect, intersecting", None),
Case(cv1, cp3, [Edge, Edge], "intersecting", "duplicate edges, BRepAlgoAPI_Common and _Section both return edge"),
Case(sk1, cp3, [Face, Face], "intersecting", None),
Case(pt1, cp3, [Face, Face], "intersecting", None),
]
@pytest.mark.parametrize("obj, target, expected", make_params(shape_compound_matrix))
def test_shape_compound(obj, target, expected):
run_test(obj, target, expected)
# FreeCAD issue example # FreeCAD issue example
c1 = CenterArc((0, 0), 10, 0, 360).edge() c1 = CenterArc((0, 0), 10, 0, 360).edge()
c2 = CenterArc((19, 0), 10, 0, 360).edge() c2 = CenterArc((19, 0), 10, 0, 360).edge()
@ -240,7 +430,7 @@ freecad_matrix = [
Case(vert, e1, [Vertex], "vertical, ellipse, tangent", None), Case(vert, e1, [Vertex], "vertical, ellipse, tangent", None),
Case(horz, e1, [Vertex], "horizontal, ellipse, tangent", None), Case(horz, e1, [Vertex], "horizontal, ellipse, tangent", None),
Case(c1, c2, [Vertex, Vertex], "circle, skew, intersect", "Should return 2 Vertices"), Case(c1, c2, [Vertex, Vertex], "circle, skew, intersect", None),
Case(c1, horz, [Vertex], "circle, horiz, tangent", None), Case(c1, horz, [Vertex], "circle, horiz, tangent", None),
Case(c2, horz, [Vertex], "circle, horiz, tangent", None), Case(c2, horz, [Vertex], "circle, horiz, tangent", None),
Case(c1, vert, [Vertex], "circle, vert, tangent", None), Case(c1, vert, [Vertex], "circle, vert, tangent", None),
@ -263,11 +453,11 @@ w1 = Wire.make_circle(0.5)
f1 = Face(Wire.make_circle(0.5)) f1 = Face(Wire.make_circle(0.5))
issues_matrix = [ issues_matrix = [
Case(t, t, [Face, Face], "issue #1015", "Returns Compound"), Case(t, t, [Face, Face], "issue #1015", None),
Case(l, s, [Edge], "issue #945", "Edge.intersect only takes 1D"), Case(l, s, [Edge], "issue #945", None),
Case(a, b, [Edge], "issue #918", "Returns empty Compound"), Case(a, b, [Edge], "issue #918", None),
Case(e1, w1, [Vertex, Vertex], "issue #697"), Case(e1, w1, [Vertex, Vertex], "issue #697", None),
Case(e1, f1, [Edge], "issue #697", "Edge.intersect only takes 1D"), Case(e1, f1, [Edge], "issue #697", None),
] ]
@pytest.mark.parametrize("obj, target, expected", make_params(issues_matrix)) @pytest.mark.parametrize("obj, target, expected", make_params(issues_matrix))
@ -279,6 +469,9 @@ def test_issues(obj, target, expected):
exception_matrix = [ exception_matrix = [
Case(vt1, Color(), None, "Unsupported type", None), Case(vt1, Color(), None, "Unsupported type", None),
Case(ed1, Color(), None, "Unsupported type", None), Case(ed1, Color(), None, "Unsupported type", None),
Case(fc1, Color(), None, "Unsupported type", None),
Case(sl1, Color(), None, "Unsupported type", None),
Case(cp1, Color(), None, "Unsupported type", None),
] ]
@pytest.mark.skip @pytest.mark.skip

View file

@ -229,13 +229,15 @@ class TestOrientedBoundBox(unittest.TestCase):
obb = OrientedBoundBox(rect) obb = OrientedBoundBox(rect)
corners = obb.corners corners = obb.corners
poly = Polygon(*corners, align=None) poly = Polygon(*corners, align=None)
self.assertAlmostEqual(rect.intersect(poly).area, rect.area, 5) area = sum(f.area for f in rect.intersect(poly).faces())
self.assertAlmostEqual(area, rect.area, 5)
for face in Box(1, 2, 3).faces(): for face in Box(1, 2, 3).faces():
obb = OrientedBoundBox(face) obb = OrientedBoundBox(face)
corners = obb.corners corners = obb.corners
poly = Polygon(*corners, align=None) poly = Polygon(*corners, align=None)
self.assertAlmostEqual(face.intersect(poly).area, face.area, 5) area = sum(f.area for f in face.intersect(poly).faces())
self.assertAlmostEqual(area, face.area, 5)
def test_line_corners(self): def test_line_corners(self):
""" """

View file

@ -299,7 +299,8 @@ class TestShape(unittest.TestCase):
predicted_location = Location(offset) * Rotation(*rotation) predicted_location = Location(offset) * Rotation(*rotation)
located_shape = Solid.make_box(1, 1, 1).locate(predicted_location) located_shape = Solid.make_box(1, 1, 1).locate(predicted_location)
intersect = shape.intersect(located_shape) intersect = shape.intersect(located_shape)
self.assertAlmostEqual(intersect.volume, 1, 5) volume = sum(s.volume for s in intersect.solids())
self.assertAlmostEqual(volume, 1, 5)
def test_position_and_orientation(self): def test_position_and_orientation(self):
box = Solid.make_box(1, 1, 1).locate(Location((1, 2, 3), (10, 20, 30))) box = Solid.make_box(1, 1, 1).locate(Location((1, 2, 3), (10, 20, 30)))
@ -588,7 +589,7 @@ class TestShape(unittest.TestCase):
empty.distance_to_with_closest_points(Vector(1, 1, 1)) empty.distance_to_with_closest_points(Vector(1, 1, 1))
with self.assertRaises(ValueError): with self.assertRaises(ValueError):
empty.distance_to(Vector(1, 1, 1)) empty.distance_to(Vector(1, 1, 1))
with self.assertRaises(ValueError): with self.assertRaises(AttributeError):
box.intersect(empty_loc) box.intersect(empty_loc)
self.assertEqual(empty._ocp_section(Vertex(1, 1, 1)), ([], [])) self.assertEqual(empty._ocp_section(Vertex(1, 1, 1)), ([], []))
self.assertEqual(empty.faces_intersected_by_axis(Axis.Z), ShapeList()) self.assertEqual(empty.faces_intersected_by_axis(Axis.Z), ShapeList())

View file

@ -153,7 +153,9 @@ class TestSolid(unittest.TestCase):
self.assertAlmostEqual(twist.volume, 1, 5) self.assertAlmostEqual(twist.volume, 1, 5)
top = twist.faces().sort_by(Axis.Z)[-1].rotate(Axis.Z, 45) top = twist.faces().sort_by(Axis.Z)[-1].rotate(Axis.Z, 45)
bottom = twist.faces().sort_by(Axis.Z)[0] bottom = twist.faces().sort_by(Axis.Z)[0]
self.assertAlmostEqual(top.translate((0, 0, -1)).intersect(bottom).area, 1, 5) intersect = top.translate((0, 0, -1)).intersect(bottom)
area = sum(f.area for f in intersect.faces())
self.assertAlmostEqual(area, 1, 5)
# Wire # Wire
base = Wire.make_rect(1, 1) base = Wire.make_rect(1, 1)
twist = Solid.extrude_linear_with_rotation( twist = Solid.extrude_linear_with_rotation(
@ -162,7 +164,9 @@ class TestSolid(unittest.TestCase):
self.assertAlmostEqual(twist.volume, 1, 5) self.assertAlmostEqual(twist.volume, 1, 5)
top = twist.faces().sort_by(Axis.Z)[-1].rotate(Axis.Z, 45) top = twist.faces().sort_by(Axis.Z)[-1].rotate(Axis.Z, 45)
bottom = twist.faces().sort_by(Axis.Z)[0] bottom = twist.faces().sort_by(Axis.Z)[0]
self.assertAlmostEqual(top.translate((0, 0, -1)).intersect(bottom).area, 1, 5) intersect = top.translate((0, 0, -1)).intersect(bottom)
area = sum(f.area for f in intersect.faces())
self.assertAlmostEqual(area, 1, 5)
def test_make_loft(self): def test_make_loft(self):
loft = Solid.make_loft( loft = Solid.make_loft(

View file

@ -231,7 +231,8 @@ class DimensionLineTestCase(unittest.TestCase):
], ],
draft=metric, draft=metric,
) )
self.assertGreater(hole.intersect(d_line).area, 0) area = sum(f.area for f in hole.intersect(d_line).faces())
self.assertGreater(area, 0)
def test_outside_arrows(self): def test_outside_arrows(self):
d_line = DimensionLine([(0, 0, 0), (15, 0, 0)], draft=metric) d_line = DimensionLine([(0, 0, 0), (15, 0, 0)], draft=metric)