Files
cadquery/tests/test_assembly.py
Marcus Boyd 6a440b63af Handle constraints from infinite faces (#797)
* Face.toPln method added

* solver: set xtol_abs

* Constraint: handle infinite faces

Infinite faces have their center at 1e99, which was causing overflows in
the solver and also was not what the user intended when creating the
Shape. They are now converted to the expected values.
2021-07-06 16:47:36 +09:30

536 lines
15 KiB
Python

import pytest
import os
from itertools import product
import nlopt
import cadquery as cq
from cadquery.occ_impl.exporters.assembly import (
exportAssembly,
exportCAF,
exportVTKJS,
exportVRML,
)
from cadquery.occ_impl.assembly import toJSON
from OCP.gp import gp_XYZ
@pytest.fixture
def simple_assy():
b1 = cq.Solid.makeBox(1, 1, 1)
b2 = cq.Workplane().box(1, 1, 2)
b3 = cq.Workplane().pushPoints([(0, 0), (-2, -5)]).box(1, 1, 3)
assy = cq.Assembly(b1, loc=cq.Location(cq.Vector(2, -5, 0)))
assy.add(b2, loc=cq.Location(cq.Vector(1, 1, 0)))
assy.add(b3, loc=cq.Location(cq.Vector(2, 3, 0)))
return assy
@pytest.fixture
def nested_assy():
b1 = cq.Workplane().box(1, 1, 1).faces("<Z").tag("top_face").end()
b2 = cq.Workplane().box(1, 1, 1).faces("<Z").tag("bottom_face").end()
b3 = (
cq.Workplane()
.pushPoints([(-2, 0), (2, 0)])
.tag("pts")
.box(1, 1, 0.5)
.tag("boxes")
)
assy = cq.Assembly(b1, loc=cq.Location(cq.Vector(0, 0, 0)), name="TOP")
assy2 = cq.Assembly(b2, loc=cq.Location(cq.Vector(0, 4, 0)), name="SECOND")
assy2.add(b3, loc=cq.Location(cq.Vector(0, 4, 0)), name="BOTTOM")
assy.add(assy2, color=cq.Color("green"))
return assy
@pytest.fixture
def empty_top_assy():
b1 = cq.Workplane().box(1, 1, 1)
assy = cq.Assembly()
assy.add(b1, color=cq.Color("green"))
return assy
@pytest.fixture
def box_and_vertex():
box_wp = cq.Workplane().box(1, 2, 3)
assy = cq.Assembly(box_wp, name="box")
vertex_wp = cq.Workplane().newObject([cq.Vertex.makeVertex(0, 0, 0)])
assy.add(vertex_wp, name="vertex")
return assy
def solve_result_check(solve_result: dict) -> bool:
checks = [
solve_result["status"] == nlopt.XTOL_REACHED,
solve_result["cost"] < 1e-9,
solve_result["iters"] > 0,
]
return all(checks)
def test_color():
c1 = cq.Color("red")
assert c1.wrapped.GetRGB().Red() == 1
assert c1.wrapped.Alpha() == 1
c2 = cq.Color(1, 0, 0)
assert c2.wrapped.GetRGB().Red() == 1
assert c2.wrapped.Alpha() == 1
c3 = cq.Color(1, 0, 0, 0.5)
assert c3.wrapped.GetRGB().Red() == 1
assert c3.wrapped.Alpha() == 0.5
with pytest.raises(ValueError):
cq.Color("?????")
with pytest.raises(ValueError):
cq.Color(1, 2, 3, 4, 5)
def test_assembly(simple_assy, nested_assy):
# basic checks
assert len(simple_assy.objects) == 3
assert len(simple_assy.children) == 2
assert len(simple_assy.shapes) == 1
assert len(nested_assy.objects) == 3
assert len(nested_assy.children) == 1
assert nested_assy.objects["SECOND"].parent is nested_assy
# bottom-up traversal
kvs = list(nested_assy.traverse())
assert kvs[0][0] == "BOTTOM"
assert len(kvs[0][1].shapes[0].Solids()) == 2
assert kvs[-1][0] == "TOP"
def test_step_export(nested_assy):
exportAssembly(nested_assy, "nested.step")
w = cq.importers.importStep("nested.step")
assert w.solids().size() == 4
# check that locations were applied correctly
c = cq.Compound.makeCompound(w.solids().vals()).Center()
c.toTuple()
assert pytest.approx(c.toTuple()) == (0, 4, 0)
def test_native_export(simple_assy):
exportCAF(simple_assy, "assy.xml")
# only sanity check for now
assert os.path.exists("assy.xml")
def test_vtkjs_export(nested_assy):
exportVTKJS(nested_assy, "assy")
# only sanity check for now
assert os.path.exists("assy.zip")
def test_vrml_export(simple_assy):
exportVRML(simple_assy, "assy.wrl")
# only sanity check for now
assert os.path.exists("assy.wrl")
def test_toJSON(simple_assy, nested_assy, empty_top_assy):
r1 = toJSON(simple_assy)
r2 = toJSON(simple_assy)
r3 = toJSON(empty_top_assy)
assert len(r1) == 3
assert len(r2) == 3
assert len(r3) == 1
def test_save(simple_assy, nested_assy):
simple_assy.save("simple.step")
assert os.path.exists("simple.step")
simple_assy.save("simple.xml")
assert os.path.exists("simple.xml")
simple_assy.save("simple.step")
assert os.path.exists("simple.step")
simple_assy.save("simple.stp", "STEP")
assert os.path.exists("simple.stp")
simple_assy.save("simple.caf", "XML")
assert os.path.exists("simple.caf")
with pytest.raises(ValueError):
simple_assy.save("simple.dxf")
with pytest.raises(ValueError):
simple_assy.save("simple.step", "DXF")
def test_constrain(simple_assy, nested_assy):
subassy1 = simple_assy.children[0]
subassy2 = simple_assy.children[1]
b1 = simple_assy.obj
b2 = subassy1.obj
b3 = subassy2.obj
simple_assy.constrain(
simple_assy.name, b1.Faces()[0], subassy1.name, b2.faces("<Z").val(), "Plane"
)
simple_assy.constrain(
simple_assy.name, b1.Faces()[0], subassy2.name, b3.faces("<Z").val(), "Axis"
)
simple_assy.constrain(
subassy1.name,
b2.faces(">Z").val(),
subassy2.name,
b3.faces("<Z").val(),
"Point",
)
assert len(simple_assy.constraints) == 3
nested_assy.constrain("TOP@faces@>Z", "SECOND/BOTTOM@faces@<Z", "Plane")
nested_assy.constrain("TOP@faces@>X", "SECOND/BOTTOM@faces@<X", "Axis")
assert len(nested_assy.constraints) == 2
constraint = nested_assy.constraints[0]
assert constraint.objects == ("TOP", "SECOND")
assert (
constraint.sublocs[0]
.wrapped.Transformation()
.TranslationPart()
.IsEqual(gp_XYZ(), 1e-9)
)
assert constraint.sublocs[1].wrapped.IsEqual(
nested_assy.objects["SECOND/BOTTOM"].loc.wrapped
)
simple_assy.solve()
assert solve_result_check(simple_assy._solve_result)
assert (
simple_assy.loc.wrapped.Transformation()
.TranslationPart()
.IsEqual(gp_XYZ(2, -5, 0), 1e-9)
)
assert (
simple_assy.children[0]
.loc.wrapped.Transformation()
.TranslationPart()
.IsEqual(gp_XYZ(-1, 0.5, 0.5), 1e-6)
)
nested_assy.solve()
assert solve_result_check(nested_assy._solve_result)
assert (
nested_assy.children[0]
.loc.wrapped.Transformation()
.TranslationPart()
.IsEqual(gp_XYZ(2, -4, 0.75), 1e-6)
)
def test_constrain_with_tags(nested_assy):
nested_assy.add(None, name="dummy")
nested_assy.constrain("TOP?top_face", "SECOND/BOTTOM", "Plane")
assert len(nested_assy.constraints) == 1
# test selection of a non-shape object
with pytest.raises(ValueError):
nested_assy.constrain("SECOND/BOTTOM ? pts", "dummy", "Plane")
def test_duplicate_name(nested_assy):
with pytest.raises(ValueError):
nested_assy.add(None, name="SECOND")
def test_empty_solve(nested_assy):
with pytest.raises(ValueError):
nested_assy.solve()
def test_expression_grammar(nested_assy):
nested_assy.constrain(
"TOP@faces@>Z", "SECOND/BOTTOM@vertices@>X and >Y and >Z", "Point"
)
def test_PointInPlane_constraint(box_and_vertex):
# add first constraint
box_and_vertex.constrain(
"vertex",
box_and_vertex.children[0].obj.val(),
"box",
box_and_vertex.obj.faces(">X").val(),
"PointInPlane",
param=0,
)
box_and_vertex.solve()
solve_result_check(box_and_vertex._solve_result)
x_pos = (
box_and_vertex.children[0].loc.wrapped.Transformation().TranslationPart().X()
)
assert x_pos == pytest.approx(0.5)
# add a second PointInPlane constraint
box_and_vertex.constrain("vertex", "box@faces@>Y", "PointInPlane", param=0)
box_and_vertex.solve()
solve_result_check(box_and_vertex._solve_result)
vertex_translation_part = (
box_and_vertex.children[0].loc.wrapped.Transformation().TranslationPart()
)
# should still be on the >X face from the first constraint
assert vertex_translation_part.X() == pytest.approx(0.5)
# now should additionally be on the >Y face
assert vertex_translation_part.Y() == pytest.approx(1)
# add a third PointInPlane constraint
box_and_vertex.constrain("vertex", "box@faces@>Z", "PointInPlane", param=0)
box_and_vertex.solve()
solve_result_check(box_and_vertex._solve_result)
# should now be on the >X and >Y and >Z corner
assert (
box_and_vertex.children[0]
.loc.wrapped.Transformation()
.TranslationPart()
.IsEqual(gp_XYZ(0.5, 1, 1.5), 1e-6)
)
def test_PointInPlane_3_parts(box_and_vertex):
cylinder_height = 2
cylinder = cq.Workplane().circle(0.1).extrude(cylinder_height)
box_and_vertex.add(cylinder, name="cylinder")
box_and_vertex.constrain("box@faces@>Z", "cylinder@faces@<Z", "Plane")
box_and_vertex.constrain("vertex", "cylinder@faces@>Z", "PointInPlane")
box_and_vertex.constrain("vertex", "box@faces@>X", "PointInPlane")
box_and_vertex.solve()
solve_result_check(box_and_vertex._solve_result)
vertex_translation_part = (
box_and_vertex.children[0].loc.wrapped.Transformation().TranslationPart()
)
assert vertex_translation_part.Z() == pytest.approx(1.5 + cylinder_height)
assert vertex_translation_part.X() == pytest.approx(0.5)
@pytest.mark.parametrize("param1", [-1, 0, 2])
@pytest.mark.parametrize("param0", [-2, 0, 0.01])
def test_PointInPlane_param(box_and_vertex, param0, param1):
box_and_vertex.constrain("vertex", "box@faces@>Z", "PointInPlane", param=param0)
box_and_vertex.constrain("vertex", "box@faces@>X", "PointInPlane", param=param1)
box_and_vertex.solve()
solve_result_check(box_and_vertex._solve_result)
vertex_translation_part = (
box_and_vertex.children[0].loc.wrapped.Transformation().TranslationPart()
)
assert vertex_translation_part.Z() - 1.5 == pytest.approx(param0, abs=1e-6)
assert vertex_translation_part.X() - 0.5 == pytest.approx(param1, abs=1e-6)
def test_constraint_getPln():
"""
Test that _getPln does the right thing with different arguments
"""
ids = (0, 1)
sublocs = (cq.Location(), cq.Location())
def make_constraint(shape0):
return cq.Constraint(ids, (shape0, shape0), sublocs, "PointInPlane", 0)
def fail_this(shape0):
c0 = make_constraint(shape0)
with pytest.raises(ValueError):
c0._getPln(c0.args[0])
def resulting_pln(shape0):
c0 = make_constraint(shape0)
return c0._getPln(c0.args[0])
def resulting_plane(shape0):
p0 = resulting_pln(shape0)
return cq.Plane(
cq.Vector(p0.Location()),
cq.Vector(p0.XAxis().Direction()),
cq.Vector(p0.Axis().Direction()),
)
# point should fail
fail_this(cq.Vertex.makeVertex(0, 0, 0))
# line should fail
fail_this(cq.Edge.makeLine(cq.Vector(1, 0, 0), cq.Vector(0, 0, 0)))
# planar edge (circle) should succeed
origin = cq.Vector(1, 2, 3)
direction = cq.Vector(4, 5, 6).normalized()
p1 = resulting_plane(cq.Edge.makeCircle(1, pnt=origin, dir=direction))
assert p1.zDir == direction
assert p1.origin == origin
# planar edge (spline) should succeed
# it's a touch risky calling a spline a planar edge, but lets see if it's within tolerance
points0 = [cq.Vector(x) for x in [(-1, 0, 1), (0, 1, 1), (1, 0, 1), (0, -1, 1)]]
planar_spline = cq.Edge.makeSpline(points0, periodic=True)
p2 = resulting_plane(planar_spline)
assert p2.origin == planar_spline.Center()
assert p2.zDir == cq.Vector(0, 0, 1)
# non-planar edge should fail
points1 = [cq.Vector(x) for x in [(-1, 0, -1), (0, 1, 1), (1, 0, -1), (0, -1, 1)]]
nonplanar_spline = cq.Edge.makeSpline(points1, periodic=True)
fail_this(nonplanar_spline)
# planar wire should succeed
# make a triangle in the XZ plane
points2 = [cq.Vector(x) for x in [(-1, 0, -1), (0, 0, 1), (1, 0, -1)]]
points2.append(points2[0])
triangle = cq.Wire.makePolygon(points2)
p3 = resulting_plane(triangle)
assert p3.origin == triangle.Center()
assert p3.zDir == cq.Vector(0, 1, 0)
# non-planar wire should fail
points3 = [cq.Vector(x) for x in [(-1, 0, -1), (0, 1, 1), (1, 0, 0), (0, -1, 1)]]
wonky_shape = cq.Wire.makePolygon(points3)
fail_this(wonky_shape)
# all makePlane faces should succeed
for length, width in product([None, 10], [None, 11]):
f0 = cq.Face.makePlane(
length=length, width=width, basePnt=(1, 2, 3), dir=(1, 0, 0)
)
p4 = resulting_plane(f0)
if length and width:
assert p4.origin == cq.Vector(1, 2, 3)
assert p4.zDir == cq.Vector(1, 0, 0)
f1 = cq.Face.makeFromWires(triangle, [])
p5 = resulting_plane(f1)
# not sure why, but the origins only roughly line up
assert (p5.origin - triangle.Center()).Length < 0.1
assert p5.zDir == cq.Vector(0, 1, 0)
# shell... not sure?
# solid should fail
fail_this(cq.Solid.makeBox(1, 1, 1))
def test_toCompound(simple_assy, nested_assy):
c0 = simple_assy.toCompound()
assert isinstance(c0, cq.Compound)
assert len(c0.Solids()) == 4
c1 = nested_assy.toCompound()
assert isinstance(c1, cq.Compound)
assert len(c1.Solids()) == 4
# check nested assy location appears in compound
# create four boxes, stack them on top of each other, check highest face is in final compound
box0 = cq.Workplane().box(1, 1, 3, centered=(True, True, False))
box1 = cq.Workplane().box(1, 1, 4)
box2 = cq.Workplane().box(1, 1, 5)
box3 = cq.Workplane().box(1, 1, 6)
# top level assy
assy0 = cq.Assembly(box0, name="box0")
assy0.add(box1, name="box1")
assy0.constrain("box0@faces@>Z", "box1@faces@<Z", "Plane")
# subassy
assy1 = cq.Assembly()
assy1.add(box2, name="box2")
assy1.add(box3, name="box3")
assy1.constrain("box2@faces@>Z", "box3@faces@<Z", "Plane")
assy1.solve()
assy0.add(assy1, name="assy1")
assy0.constrain("box1@faces@>Z", "assy1/box2@faces@<Z", "Plane")
# before solving there should be no face with Center = (0, 0, 18)
c2 = assy0.toCompound()
assert not cq.Vector(0, 0, 18) in [x.Center() for x in c2.Faces()]
# after solving there should be a face with Center = (0, 0, 18)
assy0.solve()
c3 = assy0.toCompound()
assert cq.Vector(0, 0, 18) in [x.Center() for x in c3.Faces()]
# also check with bounding box
assert c3.BoundingBox().zlen == pytest.approx(18)
@pytest.mark.parametrize("origin", [(0, 0, 0), (10, -10, 10)])
@pytest.mark.parametrize("normal", [(0, 0, 1), (-1, -1, 1)])
def test_infinite_face_constraint_PointInPlane(origin, normal):
"""
An OCCT infinite face has a center at (1e99, 1e99), but when a user uses it
in a constraint, the center should be basePnt.
"""
f0 = cq.Face.makePlane(length=None, width=None, basePnt=origin, dir=normal)
c0 = cq.assembly.Constraint(
("point", "plane"),
(cq.Vertex.makeVertex(10, 10, 10), f0),
sublocs=(cq.Location(), cq.Location()),
kind="PointInPlane",
)
p0 = c0._getPln(c0.args[1]) # a gp_Pln
derived_origin = cq.Vector(p0.Location())
assert derived_origin == cq.Vector(origin)
@pytest.mark.parametrize("kind", ["Plane", "PointInPlane", "Point"])
def test_infinite_face_constraint_Plane(kind):
assy = cq.Assembly(cq.Workplane().sphere(1), name="part0")
assy.add(cq.Workplane().sphere(1), name="part1")
assy.constrain(
"part0", cq.Face.makePlane(), "part1", cq.Face.makePlane(), kind,
)
assy.solve()
assert solve_result_check(assy._solve_result)