Improved algebra +,-,& operators Issue #752

This commit is contained in:
gumyr 2024-11-06 13:50:20 -05:00
parent 9fb163cb64
commit 0b4b2b2b54
2 changed files with 205 additions and 50 deletions

View file

@ -1699,64 +1699,94 @@ class Shape(NodeMixin):
def __add__(self, other: Union[list[Shape], Shape]) -> Self:
"""fuse shape to self operator +"""
others = other if isinstance(other, (list, tuple)) else [other]
# Convert `other` to list of base objects and filter out None values
summands = [
shape
for o in (other if isinstance(other, (list, tuple)) else [other])
if o is not None
for shape in (o.first_level_shapes() if isinstance(o, Compound) else [o])
]
# If there is nothing to add return the original object
if not summands:
return self
if not all([type(other)._dim == type(self)._dim for other in others]):
# Check that all dimensions are the same
addend_dim = self._dim
if addend_dim is None:
raise ValueError("Dimensions of objects to add to are inconsistent")
if not all(summand._dim == addend_dim for summand in summands):
raise ValueError("Only shapes with the same dimension can be added")
if self.wrapped is None:
if len(others) == 1:
new_shape = others[0]
if self.wrapped is None: # an empty object
if len(summands) == 1:
sum_shape = summands[0]
else:
new_shape = others[0].fuse(*others[1:])
elif isinstance(other, Shape) and other.wrapped is None:
new_shape = self
sum_shape = summands[0].fuse(*summands[1:])
else:
new_shape = self.fuse(*others)
sum_shape = self.fuse(*summands)
# Simplify Compounds if possible
sum_shape = (
sum_shape.unwrap(fully=True)
if isinstance(sum_shape, Compound)
else sum_shape
)
if SkipClean.clean:
new_shape = new_shape.clean()
sum_shape = sum_shape.clean()
if self._dim == 3:
new_shape = Part(new_shape.wrapped)
elif self._dim == 2:
new_shape = Sketch(new_shape.wrapped)
elif self._dim == 1:
new_shape = Curve(Compound(new_shape.edges()).wrapped)
# To allow the @, % and ^ operators to work 1D objects must be type Curve
if addend_dim == 1:
sum_shape = Curve(Compound(sum_shape.edges()).wrapped)
return new_shape
return sum_shape
def __sub__(self, other: Shape) -> Self:
def __sub__(self, other: Union[Shape, Iterable[Shape]]) -> Self:
"""cut shape from self operator -"""
others = other if isinstance(other, (list, tuple)) else [other]
for _other in others:
if type(_other)._dim < type(self)._dim:
raise ValueError(
f"Only shapes with equal or greater dimension can be subtracted: "
f"not {type(self).__name__} ({type(self)._dim}D) and "
f"{type(_other).__name__} ({type(_other)._dim}D)"
)
new_shape = None
if self.wrapped is None:
raise ValueError("Cannot subtract shape from empty compound")
if isinstance(other, Shape) and other.wrapped is None:
new_shape = self
else:
new_shape = self.cut(*others)
if new_shape is not None and SkipClean.clean:
new_shape = new_shape.clean()
# Convert `other` to list of base objects and filter out None values
subtrahends = [
shape
for o in (other if isinstance(other, (list, tuple)) else [other])
if o is not None
for shape in (o.first_level_shapes() if isinstance(o, Compound) else [o])
]
# If there is nothing to subtract return the original object
if not subtrahends:
return self
if self._dim == 3:
new_shape = Part(new_shape.wrapped)
elif self._dim == 2:
new_shape = Sketch(new_shape.wrapped)
elif self._dim == 1:
new_shape = Curve(Compound(new_shape.edges()).wrapped)
# Check that all dimensions are the same
minuend_dim = self._dim
if minuend_dim is None:
raise ValueError("Dimensions of objects to subtract from are inconsistent")
return new_shape
# Check that the operation is valid
subtrahend_dims = [s._dim for s in subtrahends]
if any(d < minuend_dim for d in subtrahend_dims):
raise ValueError(
f"Only shapes with equal or greater dimension can be subtracted: "
f"not {type(self).__name__} ({minuend_dim}D) and "
f"{type(other).__name__} ({min(subtrahend_dims)}D)"
)
# Do the actual cut operation
difference = self.cut(*subtrahends)
# Simplify Compounds if possible
difference = (
difference.unwrap(fully=True)
if isinstance(difference, Compound)
else difference
)
# To allow the @, % and ^ operators to work 1D objects must be type Curve
if minuend_dim == 1:
difference = Curve(Compound(difference.edges()).wrapped)
return difference
def __and__(self, other: Shape) -> Self:
"""intersect shape with self operator &"""
@ -1769,11 +1799,15 @@ class Shape(NodeMixin):
if new_shape.wrapped is not None and SkipClean.clean:
new_shape = new_shape.clean()
if self._dim == 3:
new_shape = Part(new_shape.wrapped)
elif self._dim == 2:
new_shape = Sketch(new_shape.wrapped)
elif self._dim == 1:
# Simplify Compounds if possible
new_shape = (
new_shape.unwrap(fully=True)
if isinstance(new_shape, Compound)
else new_shape
)
# To allow the @, % and ^ operators to work 1D objects must be type Curve
if self._dim == 1:
new_shape = Curve(Compound(new_shape.edges()).wrapped)
return new_shape
@ -3940,6 +3974,12 @@ class Compound(Mixin3D, Shape):
_dim = None
@property
def _dim(self) -> Union[int, None]:
"""The dimension of the shapes within the Compound - None if inconsistent"""
sub_dims = {s._dim for s in self.first_level_shapes()}
return sub_dims.pop() if len(sub_dims) == 1 else None
@overload
def __init__(
self,
@ -4524,6 +4564,8 @@ class Compound(Mixin3D, Shape):
Returns:
ShapeList[Shape]: Shapes contained within the Compound
"""
if self.wrapped is None:
return ShapeList()
if _shapes is None:
_shapes = []
iterator = TopoDS_Iterator()
@ -4575,18 +4617,30 @@ class Part(Compound):
_dim = 3
@property
def _dim(self) -> int:
return 3
class Sketch(Compound):
"""A Compound containing 2D objects - aka Faces"""
_dim = 2
@property
def _dim(self) -> int:
return 2
class Curve(Compound):
"""A Compound containing 1D objects - aka Edges"""
_dim = 1
@property
def _dim(self) -> int:
return 1
def __matmul__(self, position: float) -> Vector:
"""Position on curve operator @ - only works if continuous"""
return Wire(self.edges()).position_at(position)
@ -4617,6 +4671,10 @@ class Edge(Mixin1D, Shape):
_dim = 1
@property
def _dim(self) -> int:
return 1
@overload
def __init__(
self,
@ -5544,6 +5602,10 @@ class Face(Shape):
_dim = 2
@property
def _dim(self) -> int:
return 2
@overload
def __init__(
self,
@ -6743,6 +6805,10 @@ class Shell(Shape):
_dim = 2
@property
def _dim(self) -> int:
return 2
@overload
def __init__(
self,
@ -6922,6 +6988,10 @@ class Solid(Mixin3D, Shape):
_dim = 3
@property
def _dim(self) -> int:
return 3
@overload
def __init__(
self,
@ -7713,6 +7783,10 @@ class Vertex(Shape):
_dim = 0
@property
def _dim(self) -> int:
return 0
@overload
def __init__(self): # pragma: no cover
"""Default Vertext at the origin"""
@ -7894,6 +7968,10 @@ class Wire(Mixin1D, Shape):
_dim = 1
@property
def _dim(self) -> int:
return 1
@overload
def __init__(
self,

View file

@ -553,12 +553,14 @@ class AlgebraTests(unittest.TestCase):
def test_empty_plus_part(self):
b = Box(1, 2, 3)
r = Part() + b
self.assertEqual(b.wrapped, r.wrapped)
self.assertAlmostEqual(b.volume, r.volume, 5)
self.assertEqual(r._dim, 3)
def test_part_plus_empty(self):
b = Box(1, 2, 3)
r = b + Part()
self.assertEqual(b.wrapped, r.wrapped)
self.assertAlmostEqual(b.volume, r.volume, 5)
self.assertEqual(r._dim, 3)
def test_empty_minus_part(self):
b = Box(1, 2, 3)
@ -585,12 +587,14 @@ class AlgebraTests(unittest.TestCase):
def test_empty_plus_sketch(self):
b = Rectangle(1, 2)
r = Sketch() + b
self.assertEqual(b.wrapped, r.wrapped)
self.assertAlmostEqual(b.area, r.area, 5)
self.assertEqual(r._dim, 2)
def test_sketch_plus_empty(self):
b = Rectangle(1, 2)
r = b + Sketch()
self.assertEqual(b.wrapped, r.wrapped)
self.assertAlmostEqual(b.area, r.area, 5)
self.assertEqual(r._dim, 2)
def test_empty_minus_sketch(self):
b = Rectangle(1, 2)
@ -659,6 +663,79 @@ class AlgebraTests(unittest.TestCase):
with self.assertRaises(ValueError):
_ = rectangle - line
def test_compound_plus(self):
addend0 = Box(4, 4, 4)
addend1 = Box(1, 1, 8)
base_shapes = PolarLocations(15, 20) * addend0
addons = PolarLocations(16, 20) * addend1
p1 = Compound(base_shapes) + Compound(addons)
self.assertEqual(p1._dim, 3)
self.assertAlmostEqual(p1.volume, 20 * (4**3 + 1 * 1 * 4), 5)
with self.assertRaises(ValueError):
Compound(children=[addend0] + addend1.faces()) + Box(1, 1, 1)
with self.assertRaises(ValueError):
Box(1, 1, 1) + Compound(children=addend1.faces())
def test_compound_minus(self):
minuend = Box(4, 4, 4)
subtrahend = Box(1, 1, 4)
base_shapes = PolarLocations(15, 20) * minuend
holes = PolarLocations(17, 20) * subtrahend
s1 = Compound(base_shapes) - Compound(holes)
self.assertAlmostEqual(s1.volume, 20 * (16 - 0.5) * 4, 5)
s2 = minuend - subtrahend
self.assertEqual(s2._dim, 3)
self.assertAlmostEqual(s2.volume, (16 - 1) * 4, 5)
s3 = minuend.solid() - subtrahend.solid()
self.assertEqual(s3._dim, 3)
self.assertAlmostEqual(s3.volume, (16 - 1) * 4, 5)
s4 = Compound(minuend.faces()) - subtrahend
self.assertEqual(s4._dim, 2)
self.assertAlmostEqual(s4.area, 4 * 16 + 2 * (16 - 1), 5)
s5 = minuend.shell() - subtrahend
self.assertEqual(s5._dim, 2)
self.assertAlmostEqual(s5.area, 4 * 16 + 2 * (16 - 1), 5)
s6 = minuend - None
self.assertEqual(s6._dim, 3)
self.assertAlmostEqual(s6.volume, 4**3, 5)
s7 = minuend - Pos((8, 0, 0)) * subtrahend
self.assertEqual(s7._dim, 3)
self.assertAlmostEqual(s7.volume, 4**3, 5)
s8 = minuend - [subtrahend, Box(4, 1, 1)]
self.assertEqual(s8._dim, 3)
self.assertAlmostEqual(s8.volume, (16 - 1) * 4 - 1 * 3, 5)
s9 = Compound(children=[minuend]) - Compound(children=[subtrahend])
self.assertEqual(s9._dim, 3)
self.assertAlmostEqual(s9.volume, (16 - 1) * 4, 5)
s10 = Compound(minuend) - Compound(subtrahend)
self.assertEqual(s10._dim, 3)
self.assertAlmostEqual(s10.volume, (16 - 1) * 4, 5)
s11 = minuend.shell() - Compound(children=[subtrahend, Box(4, 1, 1)])
self.assertEqual(s11._dim, 2)
self.assertAlmostEqual(s11.area, 2 * 16 + 4 * (16 - 1), 5)
b1 = minuend.shell()
s12 = b1 - Compound(children=[subtrahend, b1.faces()[0]])
self.assertEqual(s12._dim, 2)
self.assertAlmostEqual(s12.area, 3 * 16 + 2 * (16 - 1), 5)
self.assertEqual(len(s12.faces()), 5)
with self.assertRaises(ValueError):
Compound(children=[minuend] + subtrahend.faces()) - Box(1, 1, 1)
class LocationTests(unittest.TestCase):
def test_wheel(self):