diff --git a/cnceye/shape.py b/cnceye/shape.py index 628c7dc..ca273fe 100644 --- a/cnceye/shape.py +++ b/cnceye/shape.py @@ -138,18 +138,123 @@ def combine_same_arc(self, arc_group): new_arc_group.append(prev_points) return new_arc_group - def get_lines_and_arcs(self, arc_threshold: int = 1): + def connect_same_group(self, point_groups): + """ + Connect point groups that are the same \n + Check if the first and last point are the same \n + """ + groups = [] + missing_point_groups = [] + for point_group in point_groups: + if np.array_equal(point_group[0], point_group[-1]): + groups.append(point_group) + else: + found_pair = False + for missing_point_group in missing_point_groups: + if np.array_equal(missing_point_group[0], point_group[-1]): + combined_group = np.vstack( + (point_group, missing_point_group[1:]) + ) + elif np.array_equal(missing_point_group[-1], point_group[0]): + combined_group = np.vstack( + (missing_point_group, point_group[1:]) + ) + elif np.array_equal(missing_point_group[0], point_group[0]): + combined_group = np.vstack( + (point_group[::-1], missing_point_group[1:]) + ) + elif np.array_equal(missing_point_group[-1], point_group[-1]): + combined_group = np.vstack( + (missing_point_group[:-1], point_group[::-1]) + ) + else: + continue + if np.array_equal(combined_group[0], combined_group[-1]): + groups.append(combined_group) + missing_point_groups.remove(missing_point_group) + else: + # replace missing_point_group with combined_group + missing_point_groups.remove(missing_point_group) + missing_point_groups.append(combined_group) + found_pair = True + break + if not found_pair: + missing_point_groups.append(point_group) + + # first and last point should be the same + for group in groups: + assert np.array_equal(group[0], group[-1]) + return groups + + def group_by_common_point(self, coplanar_shapes): + """ + Group coplanar shapes by common point + """ + + def _get_point(_coplanar_shape): + point0 = self.mesh.vertices[_coplanar_shape[0]] + point1 = self.mesh.vertices[_coplanar_shape[1]] + return np.array([point0, point1]) + + point_groups = [_get_point(coplanar_shapes[0])] + for i in range(1, len(coplanar_shapes)): + point = _get_point(coplanar_shapes[i]) + + duplicate = False + for i in range(len(point_groups)): + point_group = point_groups[i] + first_and_last_point = np.array([point_group[0], point_group[-1]]) + common_point = self.get_common_point(first_and_last_point, point) + if common_point is not None: + mask = np.any(point != common_point, axis=1) + new_point = point[mask] + if np.array_equal(point_groups[i][-1], common_point): + point_groups[i] = np.vstack((point_groups[i], new_point)) + else: + point_groups[i] = np.vstack((new_point, point_groups[i])) + duplicate = True + break + if not duplicate: + point_groups.append(point) + + return self.connect_same_group(point_groups) + + def get_line_angle(self, line0: np.array, line1: np.array): + """ + Get the angle between two lines + """ + line0 = line0[1] - line0[0] + line1 = line1[1] - line1[0] + return np.arccos( + np.dot(line0, line1) / (np.linalg.norm(line0) * np.linalg.norm(line1)) + ) + + def get_line_length(self, line: np.array): + """ + Get the length of a line + """ + return np.linalg.norm(line[1] - line[0]) + + def get_line_diff_percentage(self, line0: np.array, line1: np.array): + """ + Get the difference in length between two lines + """ + line_length0 = self.get_line_length(line0) + line_length1 = self.get_line_length(line1) + return abs(line_length0 - line_length1) / line_length0 + + def get_lines_and_arcs(self, angle_threshold: float = 0.1): """ Extract lines and arcs from an STL file \n - If the line length is less than 1, it is considered an arc. - If the line length for an arc is close to the previous arc length, - it is considered part of the previous arc. \n + If the line angle between two lines is close to 0 and + the line length is close to the previous line length, + it is considered an arc. \n Note: This is not a robust algorithm. Parameters ---------- - arc_threshold : int - Length threshold to determine if a line is an arc + angle_threshold : int + Angle threshold for arc Returns ------- @@ -162,38 +267,40 @@ def get_lines_and_arcs(self, arc_threshold: int = 1): lines = [] arcs = [] - previous_length = 0 - previous_arc_points = None for coplanar_shapes in shapes: + point_groups = self.group_by_common_point(coplanar_shapes) line_group = [] arc_group = [] - for i in range(len(coplanar_shapes)): - point0 = self.mesh.vertices[coplanar_shapes[i][0]] - point1 = self.mesh.vertices[coplanar_shapes[i][1]] - point = np.array([point0, point1]) - line_length = np.linalg.norm(point0 - point1) - if line_length > arc_threshold: - # line - line_group.append(point) - else: - # arc - # if there is a common point and the length is - # close to previous length, add to previous arc - common_point = self.get_common_point(previous_arc_points, point) - if common_point is not None and np.isclose( - line_length, previous_length, atol=1e-3 + + for point_group in point_groups: + arc_start_idx = None + for i in range(len(point_group) - 2): + line0 = np.array([point_group[i], point_group[i + 1]]) + line1 = np.array([point_group[i + 1], point_group[i + 2]]) + line_angle = self.get_line_angle(line0, line1) + if ( + abs(line_angle) < angle_threshold + and self.get_line_diff_percentage(line0, line1) < 0.01 ): - mask = np.any(point != common_point, axis=1) - new_point = point[mask] - if np.array_equal(arc_group[-1][-1], common_point): - arc_group[-1] = np.vstack((arc_group[-1], new_point)) + # arc + if arc_start_idx is None: + arc_start_idx = i + elif i == len(point_group) - 3: + arc_points = point_group[arc_start_idx:] + arc_group.append(arc_points) + arc_start_idx = None else: - arc_group[-1] = np.vstack((new_point, arc_group[-1])) + continue else: - # new arc - arc_group.append(point) - previous_arc_points = point - previous_length = line_length + # line + if arc_start_idx is not None: + arc_points = point_group[arc_start_idx : i + 1] + arc_group.append(arc_points) + arc_start_idx = None + else: + line_group.append(line0) + if i == len(point_group) - 3: + line_group.append(line1) if line_group: lines.append(line_group) diff --git a/pyproject.toml b/pyproject.toml index 23bff6b..8e17ed3 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [tool.poetry] name = "cnceye" -version = "0.5.0" +version = "0.5.1" description = "CMM python library" license = "MIT" authors = ["yuichiroaoki <45054071+yuichiroaoki@users.noreply.github.com>"] diff --git a/tests/test_shape.py b/tests/test_shape.py index f395310..a0c4f4d 100644 --- a/tests/test_shape.py +++ b/tests/test_shape.py @@ -1,5 +1,6 @@ from cnceye import Shape import cadquery as cq +import pytest def test_are_facets_on_same_plane(): @@ -137,6 +138,38 @@ def test_cadquery_models_more_holes(): assert radius == small_hole_diameter / 2 or radius == diameter / 2 +def test_group_by_common_point_cadquery_models_filleting(): + height = 60.0 + width = 80.0 + thickness = 10.0 + diameter = 22.0 + padding = 12.0 + small_hole_diameter = 4.4 + + result = ( + cq.Workplane("XY") + .box(height, width, thickness) + .faces(">Z") + .workplane() + .hole(diameter) + .faces(">Z") + .workplane() + .rect(height - padding, width - padding, forConstruction=True) + .vertices() + .cboreHole(2.4, small_hole_diameter, 2.1) + .edges("|Z") + .fillet(2.0) + ) + stl_filename = "tests/fixtures/stl/cq/cadquery_model.stl" + cq.exporters.export(result, stl_filename) + shape = Shape(stl_filename) + shapes = shape.get_shapes() + point_groups = shape.group_by_common_point(shapes[0]) + assert len(point_groups) == 6 + point_groups = shape.group_by_common_point(shapes[1]) + assert len(point_groups) == 8 + + def test_cadquery_models_filleting(): height = 60.0 width = 80.0 @@ -174,3 +207,329 @@ def test_cadquery_models_filleting(): # assert is_circle is True print(radius, center, is_circle) # assert radius == small_hole_diameter / 2 or radius == diameter / 2 + + +def test_group_by_common_point(): + height = 20.0 + width = 30.0 + thickness = 10.0 + radius = 82.0 + + result = cq.Workplane("front").circle(radius).rect(height, width).extrude(thickness) + + stl_filename = "tests/fixtures/stl/cq/cadquery_model.stl" + cq.exporters.export(result, stl_filename) + shape = Shape(stl_filename) + shapes = shape.get_shapes() + point_groups = shape.group_by_common_point(shapes[0]) + assert len(point_groups) == 2 + assert len(point_groups[0]) == 127 + assert len(point_groups[1]) == 5 + + +def test_group_by_common_point_with_line_and_arc(): + shape = Shape("tests/fixtures/stl/sample.stl") + shapes = shape.get_shapes() + point_groups = shape.group_by_common_point(shapes[0]) + assert len(point_groups) == 3 + + +def test_cadquery_model_rectangle_inside_circle(): + height = 20.0 + width = 30.0 + thickness = 10.0 + radius = 82.0 + + result = cq.Workplane("front").circle(radius).rect(height, width).extrude(thickness) + + stl_filename = "tests/fixtures/stl/cq/cadquery_model.stl" + cq.exporters.export(result, stl_filename) + shape = Shape(stl_filename) + lines, arcs = shape.get_lines_and_arcs() + assert len(lines) == 1 + assert len(arcs) == 1 + assert len(lines[0]) == 4 + assert len(arcs[0]) == 1 + + for arc_points in arcs[0]: + _radius, center, is_circle = shape.get_arc_info(arc_points) + # assert is_circle is True + assert radius == _radius + + +def test_with_a_large_model(): + height = 200.0 + width = 300.0 + thickness = 100.0 + radius = 802.0 + + result = cq.Workplane("front").circle(radius).rect(height, width).extrude(thickness) + + stl_filename = "tests/fixtures/stl/cq/cadquery_model.stl" + cq.exporters.export(result, stl_filename) + shape = Shape(stl_filename) + lines, arcs = shape.get_lines_and_arcs() + assert len(lines) == 1 + assert len(arcs) == 1 + assert len(lines[0]) == 4 + assert len(arcs[0]) == 1 + + for arc_points in arcs[0]: + _radius, center, is_circle = shape.get_arc_info(arc_points) + # assert is_circle is True + assert radius == _radius + + +def test_with_a_small_model(): + height = 0.02 + width = 0.03 + thickness = 0.1 + radius = 0.8 + + result = cq.Workplane("front").circle(radius).rect(height, width).extrude(thickness) + + stl_filename = "tests/fixtures/stl/cq/cadquery_model.stl" + cq.exporters.export(result, stl_filename) + shape = Shape(stl_filename) + lines, arcs = shape.get_lines_and_arcs() + assert len(lines) == 1 + assert len(arcs) == 1 + assert len(lines[0]) == 4 + assert len(arcs[0]) == 1 + + for arc_points in arcs[0]: + _radius, center, is_circle = shape.get_arc_info(arc_points) + # assert is_circle is True + assert radius == _radius + + +def test_with_a_prismatic_solid(): + result = ( + cq.Workplane("front") + .lineTo(2.0, 0) + .lineTo(2.0, 1.0) + .threePointArc((1.0, 1.5), (0.0, 1.0)) + .close() + .extrude(0.25) + ) + + stl_filename = "tests/fixtures/stl/cq/cadquery_model.stl" + cq.exporters.export(result, stl_filename) + shape = Shape(stl_filename) + lines, arcs = shape.get_lines_and_arcs() + assert len(lines) == 1 + assert len(arcs) == 1 + assert len(lines[0]) == 3 + assert len(arcs[0]) == 1 + + for arc_points in arcs[0]: + _radius, center, is_circle = shape.get_arc_info(arc_points) + print(_radius, center, is_circle) + + +def test_multiple_holes_inside_circle(): + result = cq.Workplane("front").circle( + 3.0 + ) # current point is the center of the circle, at (0, 0) + result = result.center(1.5, 0.0).rect(0.5, 0.5) # new work center is (1.5, 0.0) + + result = result.center(-1.5, 1.5).circle(0.25) # new work center is (0.0, 1.5). + # The new center is specified relative to the previous center, not global coordinates! + + result = result.extrude(0.25) + + stl_filename = "tests/fixtures/stl/cq/cadquery_model.stl" + cq.exporters.export(result, stl_filename) + shape = Shape(stl_filename) + lines, arcs = shape.get_lines_and_arcs() + assert len(lines) == 1 + assert len(arcs) == 1 + assert len(lines[0]) == 4 + assert len(arcs[0]) == 2 + + for arc_points in arcs[0]: + _radius, center, is_circle = shape.get_arc_info(arc_points) + print(_radius, center, is_circle) + + +def test_using_join_list(): + r = cq.Workplane("front").circle(2.0) # make base + r = r.pushPoints( + [(1.5, 0), (0, 1.5), (-1.5, 0), (0, -1.5)] + ) # now four points are on the stack + r = r.circle(0.25) # circle will operate on all four points + result = r.extrude(0.125) # make prism + + stl_filename = "tests/fixtures/stl/cq/cadquery_model.stl" + cq.exporters.export(result, stl_filename) + shape = Shape(stl_filename) + lines, arcs = shape.get_lines_and_arcs() + assert len(lines) == 0 + assert len(arcs) == 1 + assert len(arcs[0]) == 5 + + for arc_points in arcs[0]: + _radius, center, is_circle = shape.get_arc_info(arc_points) + print(_radius, center, is_circle) + + +def test_polygons(): + result = ( + cq.Workplane("front") + .box(3.0, 4.0, 0.25) + .pushPoints([(0, 0.75), (0, -0.75)]) + .polygon(6, 1.0) + .cutThruAll() + ) + + stl_filename = "tests/fixtures/stl/cq/cadquery_model.stl" + cq.exporters.export(result, stl_filename) + shape = Shape(stl_filename) + lines, arcs = shape.get_lines_and_arcs() + assert len(lines) == 1 + assert len(arcs) == 0 + assert len(lines[0]) == 16 + + +def test_polylines(): + (L, H, W, t) = (100.0, 20.0, 20.0, 1.0) + pts = [ + (0, H / 2.0), + (W / 2.0, H / 2.0), + (W / 2.0, (H / 2.0 - t)), + (t / 2.0, (H / 2.0 - t)), + (t / 2.0, (t - H / 2.0)), + (W / 2.0, (t - H / 2.0)), + (W / 2.0, H / -2.0), + (0, H / -2.0), + ] + result = cq.Workplane("front").polyline(pts).mirrorY().extrude(L) + + stl_filename = "tests/fixtures/stl/cq/cadquery_model.stl" + cq.exporters.export(result, stl_filename) + shape = Shape(stl_filename) + lines, arcs = shape.get_lines_and_arcs() + assert len(lines) == 1 + assert len(arcs) == 0 + assert len(lines[0]) == 12 + + +@pytest.mark.skip(reason="Not implemented yet") +def test_spline(): + s = cq.Workplane("XY") + sPnts = [ + (2.75, 1.5), + (2.5, 1.75), + (2.0, 1.5), + (1.5, 1.0), + (1.0, 1.25), + (0.5, 1.0), + (0, 1.0), + ] + r = s.lineTo(3.0, 0).lineTo(3.0, 1.0).spline(sPnts, includeCurrent=True).close() + result = r.extrude(0.5) + + stl_filename = "tests/fixtures/stl/cq/cadquery_model.stl" + cq.exporters.export(result, stl_filename) + shape = Shape(stl_filename) + lines, arcs = shape.get_lines_and_arcs() + assert len(lines) == 1 + assert len(arcs) > 0 + assert len(lines[0]) == 3 + + +def test_mirror_2D_geometry(): + r = cq.Workplane("front").hLine(1.0) # 1.0 is the distance, not coordinate + r = ( + r.vLine(0.5).hLine(-0.25).vLine(-0.25).hLineTo(0.0) + ) # hLineTo allows using xCoordinate not distance + result = r.mirrorY().extrude(0.25) # mirror the geometry and extrude + + stl_filename = "tests/fixtures/stl/cq/cadquery_model.stl" + cq.exporters.export(result, stl_filename) + shape = Shape(stl_filename) + lines, arcs = shape.get_lines_and_arcs() + assert len(lines) == 1 + assert len(arcs) == 0 + assert len(lines[0]) == 8 + + +def test_mirror_from_faces(): + result = cq.Workplane("XY").line(0, 1).line(1, 0).line(0, -0.5).close().extrude(1) + result = result.mirror(result.faces(">X"), union=True) + + stl_filename = "tests/fixtures/stl/cq/cadquery_model.stl" + cq.exporters.export(result, stl_filename) + shape = Shape(stl_filename) + lines, arcs = shape.get_lines_and_arcs() + assert len(lines) == 1 + assert len(arcs) == 0 + assert len(lines[0]) == 5 + + +def test_cut_a_corner_out(): + result = cq.Workplane("front").box(10, 6, 2.0) # make a basic prism + result = ( + result.faces(">Z").vertices("Z") + .workplane() + .rect(1.5, 1.5, forConstruction=True) + .vertices() + .hole(0.125) + ) + stl_filename = "tests/fixtures/stl/cq/cadquery_model.stl" + cq.exporters.export(result, stl_filename) + shape = Shape(stl_filename) + lines, arcs = shape.get_lines_and_arcs(0.3) + assert len(lines) == 1 + assert len(arcs) == 1 + assert len(lines[0]) == 4 + assert len(arcs[0]) == 4 + + +def test_shelling(): + result = cq.Workplane("front").box(2, 2, 2).faces("+Z or -X or +X").shell(0.1) + + stl_filename = "tests/fixtures/stl/cq/cadquery_model.stl" + cq.exporters.export(result, stl_filename) + shape = Shape(stl_filename) + lines, arcs = shape.get_lines_and_arcs() + assert len(lines) == 2 + assert len(arcs) == 0 + assert len(lines[0]) == 4 + assert len(lines[1]) == 8 + + +@pytest.mark.skip(reason="Not implemented yet") +def test_making_lofts(): + result = ( + cq.Workplane("front") + .box(4.0, 4.0, 0.25) + .faces(">Z") + .circle(1.5) + .workplane(offset=3.0) + .rect(0.75, 0.5) + .loft(combine=True) + ) + + stl_filename = "tests/fixtures/stl/cq/cadquery_model.stl" + cq.exporters.export(result, stl_filename) + shape = Shape(stl_filename) + lines, arcs = shape.get_lines_and_arcs()