From e157d9390322950243e85ee6711309940236f73b Mon Sep 17 00:00:00 2001 From: obucklin Date: Wed, 29 Jan 2025 11:23:51 +0100 Subject: [PATCH 01/15] start --- .../fabrication/simple_contour.py | 371 ++++++++++++++++++ 1 file changed, 371 insertions(+) create mode 100644 src/compas_timber/fabrication/simple_contour.py diff --git a/src/compas_timber/fabrication/simple_contour.py b/src/compas_timber/fabrication/simple_contour.py new file mode 100644 index 000000000..f87cec50b --- /dev/null +++ b/src/compas_timber/fabrication/simple_contour.py @@ -0,0 +1,371 @@ +import math + +from compas.geometry import Brep +from compas.geometry import Cylinder +from compas.geometry import Frame +from compas.geometry import Line +from compas.geometry import Plane +from compas.geometry import Point +from compas.geometry import Transformation +from compas.geometry import Vector +from compas.geometry import angle_vectors_signed +from compas.geometry import distance_point_plane +from compas.geometry import intersection_line_plane +from compas.geometry import intersection_segment_plane +from compas.geometry import is_point_behind_plane +from compas.geometry import is_point_in_polyhedron +from compas.geometry import project_point_plane +from compas.tolerance import TOL + +from compas_timber.errors import FeatureApplicationError + +from .btlx import BTLxProcessing +from .btlx import BTLxProcessingParams + + +class SimpleCountour(BTLxProcessing): + """Represents a drilling processing. + + Parameters + ---------- + start_x : float + The x-coordinate of the start point of the drilling. In the local coordinate system of the reference side. + start_y : float + The y-coordinate of the start point of the drilling. In the local coordinate system of the reference side. + angle : float + The rotation angle of the drilling. In degrees. Around the z-axis of the reference side. + inclination : float + The inclination angle of the drilling. In degrees. Around the y-axis of the reference side. + depth_limited : bool, default True + If True, the drilling depth is limited to `depth`. Otherwise, drilling will go through the element. + depth : float, default 50.0 + The depth of the drilling. In mm. + diameter : float, default 20.0 + The diameter of the drilling. In mm. + """ + + # TODO: add __data__ + + PROCESSING_NAME = "Drilling" # type: ignore + + def __init__(self, start_x=0.0, start_y=0.0, angle=0.0, inclination=90.0, depth_limited=False, depth=50.0, diameter=20.0, **kwargs): + super(SimpleCountour, self).__init__(**kwargs) + self._start_x = None + self._start_y = None + self._angle = None + self._inclination = None + self._depth_limited = None + self._depth = None + self._diameter = None + + self.start_x = start_x + self.start_y = start_y + self.angle = angle + self.inclination = inclination + self.depth_limited = depth_limited + self.depth = depth + self.diameter = diameter + + ######################################################################## + # Properties + ######################################################################## + + @property + def simple_contour_dict(self): + return SimpleCountourParams(self).as_dict() + + @property + def start_x(self): + return self._start_x + + @start_x.setter + def start_x(self, value): + if -100000 <= value <= 100000: + self._start_x = value + else: + raise ValueError("Start x-coordinate should be between -100000 and 100000. Got: {}".format(value)) + + @property + def start_y(self): + return self._start_y + + @start_y.setter + def start_y(self, value): + if -50000 <= value <= 50000: + self._start_y = value + else: + raise ValueError("Start y-coordinate should be between -50000 and 50000. Got: {}".format(value)) + + @property + def angle(self): + return self._angle + + @angle.setter + def angle(self, value): + if 0.0 <= value <= 360.0: + self._angle = value + else: + raise ValueError("Angle should be between 0 and 360. Got: {}".format(value)) + + @property + def inclination(self): + return self._inclination + + @inclination.setter + def inclination(self, value): + if 0.1 <= value <= 179.0: + self._inclination = value + else: + raise ValueError("Inclination should be between 0 and 180. Got: {}".format(value)) + + @property + def depth_limited(self): + return self._depth_limited + + @depth_limited.setter + def depth_limited(self, value): + self._depth_limited = value + + @property + def depth(self): + return self._depth + + @depth.setter + def depth(self, value): + if 0.0 <= value <= 50000.0: + self._depth = value + else: + raise ValueError("Depth should be between 0 and 50000. Got: {}".format(value)) + + @property + def diameter(self): + return self._diameter + + @diameter.setter + def diameter(self, value): + if 0.0 <= value <= 50000.0: + self._diameter = value + else: + raise ValueError("Diameter should be between 0 and 50000. Got: {}".format(value)) + + ######################################################################## + # Alternative constructors + ######################################################################## + + @classmethod + def from_line_and_beam(cls, line, diameter, beam): + """Construct a drilling processing from a line and diameter. + + # TODO: change this to point + vector instead of line. line is too fragile, it can be flipped and cause issues. + # TODO: make a from point alt. constructor that takes a point and a reference side and makes a straight drilling through. + + Parameters + ---------- + line : :class:`compas.geometry.Line` + The line on which the drilling is to be made. + diameter : float + The diameter of the drilling. + length : float + The length (depth?) of the drilling. + beam : :class:`compas_timber.elements.Beam` + The beam to drill. + + Returns + ------- + :class:`compas_timber.fabrication.Drilling` + The constructed drilling processing. + + """ + ref_side_index, xy_point = cls._calculate_ref_side_index(line, beam) + line = cls._flip_line_if_start_inside(line, beam, ref_side_index) + depth_limited = cls._is_depth_limited(line, beam) + ref_surface = beam.side_as_surface(ref_side_index) + depth = cls._calculate_depth(line, ref_surface) if depth_limited else 0.0 + x_start, y_start = cls._xy_to_ref_side_space(xy_point, ref_surface) + angle = cls._calculate_angle(ref_surface, line, xy_point) + inclination = cls._calculate_inclination(ref_surface.frame, line, angle, xy_point) + try: + return cls(x_start, y_start, angle, inclination, depth_limited, depth, diameter, ref_side_index=ref_side_index) + except ValueError as e: + raise FeatureApplicationError( + message=str(e), + feature_geometry=line, + element_geometry=beam.blank, + ) + + @staticmethod + def _flip_line_if_start_inside(line, beam, ref_side_index): + side_plane = beam.side_as_surface(ref_side_index).to_plane() + if is_point_behind_plane(line.start, side_plane): + return Line(line.end, line.start) # TODO: use line.flip() instead + return line + + @staticmethod + def _calculate_ref_side_index(line, beam): + # TODO: upstream this to compas.geometry + def is_point_on_surface(point, surface): + point = Point(*point) + local_point = point.transformed(Transformation.from_change_of_basis(Frame.worldXY(), surface.frame)) + return 0.0 <= local_point.x <= surface.xsize and 0.0 <= local_point.y <= surface.ysize + + intersections = {} + for index, side in enumerate(beam.ref_sides): + intersection = intersection_segment_plane(line, Plane.from_frame(side)) + if intersection is not None and is_point_on_surface(intersection, beam.side_as_surface(index)): + intersections[index] = Point(*intersection) + + if not intersections: + raise FeatureApplicationError( + message="The drill line must intersect with at lease one of the beam's reference sides.", + feature_geometry=line, + element_geometry=beam.blank, + ) + + ref_side_index = min(intersections, key=lambda i: intersections[i].distance_to_point(line.start)) + return ref_side_index, intersections[ref_side_index] + + @staticmethod + def _is_depth_limited(line, beam): + # check if the end point of the line is within the beam + # if it is, return True + # otherwise, return False + return is_point_in_polyhedron(line.end, beam.blank.to_polyhedron()) + + @staticmethod + def _xy_to_ref_side_space(point, ref_surface): + # type: (Point, PlanSurface) -> Tuple[float, float] + # calculate the x and y coordinates of the start point of the drilling + # based on the intersection point and the reference side index + vec_to_intersection = Vector.from_start_end(ref_surface.frame.point, point) + + # from global to surface local space + vec_to_intersection.transform(Transformation.from_change_of_basis(Frame.worldXY(), ref_surface.frame)) + return vec_to_intersection.x, vec_to_intersection.y + + @staticmethod + def _calculate_angle(ref_surface, line, intersection): + # type: (PlanarSurface, Line, Point) -> float + # this the angle between the direction projected by the drill line onto the reference plane and the reference side x-axis + vector_end_point = project_point_plane(line.end, ref_surface.to_plane()) + drill_horizontal_vector = Vector.from_start_end(intersection, vector_end_point) + reference_vector = -ref_surface.xaxis # angle = 0 when the drill is parallel to -x axis + measurement_axis = -ref_surface.zaxis # measure clockwise around the z-axis (sign flips the direction) + angle = angle_vectors_signed(reference_vector, drill_horizontal_vector, measurement_axis, deg=True) + + # angle goes between -180 and 180 but we need it between 0 and 360 + if angle < 0: + angle += 360 + + return angle + + @staticmethod + def _calculate_inclination(ref_side, line, angle, xy_point): + # type: (Frame, Line, float, Point) -> float + # inclination is the rotation around `ref_side.yaxis` between the `ref_side.xaxis` and the line vector + # we need a reference frame because the rotation axis is not the standard y-axis, but the one rotated by the angle + ref_frame = Frame(xy_point, -ref_side.xaxis, -ref_side.yaxis) + ref_frame.rotate(math.radians(angle), -ref_side.zaxis, point=xy_point) + return angle_vectors_signed(ref_frame.xaxis, line.vector, ref_frame.yaxis, deg=True) + + @staticmethod + def _calculate_depth(line, ref_surface): + return distance_point_plane(line.end, Plane.from_frame(ref_surface.frame)) + + ######################################################################## + # Methods + ######################################################################## + + def apply(self, geometry, beam): + """Apply the feature to the beam geometry. + + Raises + ------ + :class:`compas_timber.errors.FeatureApplicationError` + If the cutting plane does not intersect with the beam geometry. + + Returns + ------- + :class:`compas.geometry.Brep` + The resulting geometry after processing. + + """ + drill_geometry = Brep.from_cylinder(self.cylinder_from_params_and_beam(beam)) + try: + return geometry - drill_geometry + except IndexError: + raise FeatureApplicationError( + drill_geometry, + geometry, + "The drill geometry does not intersect with beam geometry.", + ) + + def cylinder_from_params_and_beam(self, beam): + """Construct the geometry of the drilling using the parameters in this instance and the beam object. + + Parameters + ---------- + beam : :class:`compas_timber.elements.Beam` + The beam to drill. + + Returns + ------- + :class:`compas.geometry.Cylinder` + The constructed cylinder. + + """ + assert self.diameter is not None + assert self.angle is not None + assert self.inclination is not None + assert self.depth is not None + + ref_surface = beam.side_as_surface(self.ref_side_index) + xy_world = ref_surface.point_at(self.start_x, self.start_y) + + # x and y flipped because we want z pointting down into the beam, that'll be the cylinder long direction + cylinder_frame = Frame(xy_world, ref_surface.zaxis, -ref_surface.yaxis) + cylinder_frame.rotate(math.radians(self.angle), -ref_surface.zaxis, point=xy_world) + cylinder_frame.rotate(math.radians(self.inclination), cylinder_frame.yaxis, point=xy_world) + + drill_line = self._calculate_drill_line(beam, xy_world, cylinder_frame) + + # scale both ends so is protrudes nicely from the surface + # TODO: this is a best-effort solution. this can be done more accurately taking the angle into account. consider doing that in the future. + drill_line = self._scaled_line_by_factor(drill_line, 1.2) + return Cylinder.from_line_and_radius(drill_line, self.diameter * 0.5) + + def _scaled_line_by_factor(self, line, factor): + direction = line.vector.unitized() + scale_factor = line.length * 0.5 * factor + start = line.midpoint - direction * scale_factor + end = line.midpoint + direction * scale_factor + return Line(start, end) + + def _calculate_drill_line(self, beam, xy_world, cylinder_frame): + drill_line_direction = Line.from_point_and_vector(xy_world, cylinder_frame.zaxis) + if self.depth_limited: + drill_bottom_plane = beam.side_as_surface(self.ref_side_index).to_plane() + drill_bottom_plane.point -= drill_bottom_plane.normal * self.depth + else: + # this is not always the correct plane, but it's good enough for now, btlx viewer seems to be using the same method.. + # TODO: this is a best-effort solution. consider calculating intersection with other sides to always find the right one. + drill_bottom_plane = beam.side_as_surface(beam.opposing_side_index(self.ref_side_index)).to_plane() + + intersection_point = intersection_line_plane(drill_line_direction, drill_bottom_plane) + assert intersection_point # if this fails, it means space and time as we know it has collapsed + return Line(xy_world, intersection_point) + + +class SimpleCountourParams(BTLxProcessingParams): + def __init__(self, instance): + super(SimpleCountourParams, self).__init__(instance) + + def as_dict(self): + result = super(SimpleCountourParams, self).as_dict() + result["StartX"] = "{:.{prec}f}".format(float(self._instance.start_x), prec=TOL.precision) + result["StartY"] = "{:.{prec}f}".format(float(self._instance.start_y), prec=TOL.precision) + result["Angle"] = "{:.{prec}f}".format(float(self._instance.angle), prec=TOL.precision) + result["Inclination"] = "{:.{prec}f}".format(float(self._instance.inclination), prec=TOL.precision) + result["DepthLimited"] = "yes" if self._instance.depth_limited else "no" + result["Depth"] = "{:.{prec}f}".format(float(self._instance.depth), prec=TOL.precision) + result["Diameter"] = "{:.{prec}f}".format(float(self._instance.diameter), prec=TOL.precision) + return result From 484498484cdc7020199879714b5062d423b95dba Mon Sep 17 00:00:00 2001 From: obucklin Date: Wed, 29 Jan 2025 12:10:00 +0100 Subject: [PATCH 02/15] free_contour --- src/compas_timber/fabrication/__init__.py | 2 + src/compas_timber/fabrication/btlx.py | 23 ++ src/compas_timber/fabrication/free_contour.py | 181 +++++++++ .../fabrication/simple_contour.py | 371 ------------------ 4 files changed, 206 insertions(+), 371 deletions(-) create mode 100644 src/compas_timber/fabrication/free_contour.py delete mode 100644 src/compas_timber/fabrication/simple_contour.py diff --git a/src/compas_timber/fabrication/__init__.py b/src/compas_timber/fabrication/__init__.py index adc4d1246..547dbdd3b 100644 --- a/src/compas_timber/fabrication/__init__.py +++ b/src/compas_timber/fabrication/__init__.py @@ -14,6 +14,7 @@ from .tenon import Tenon from .mortise import Mortise from .slot import Slot +from .free_contour import FreeContour from .btlx import TenonShapeType from .btlx import EdgePositionType from .btlx import LimitationTopType @@ -37,6 +38,7 @@ "Tenon", "Mortise", "Slot", + "FreeContour", "TenonShapeType", "EdgePositionType", "LimitationTopType", diff --git a/src/compas_timber/fabrication/btlx.py b/src/compas_timber/fabrication/btlx.py index 4e0bd2d07..ffcf562eb 100644 --- a/src/compas_timber/fabrication/btlx.py +++ b/src/compas_timber/fabrication/btlx.py @@ -657,3 +657,26 @@ class EdgePositionType(object): REFEDGE = "refedge" OPPEDGE = "oppedge" + + +class AlignmentType(object): + """Enum for the alignment of the cut. + Attributes + ---------- + TOP : literal("top") + Top alignment. + BOTTOM : literal("bottom") + Bottom alignment. + LEFT : literal("left") + Left alignment. + RIGHT : literal("right") + Right alignment. + CENTER : literal("center") + Center alignment. + """ + + TOP = "top" + BOTTOM = "bottom" + LEFT = "left" + RIGHT = "right" + CENTER = "center" diff --git a/src/compas_timber/fabrication/free_contour.py b/src/compas_timber/fabrication/free_contour.py new file mode 100644 index 000000000..899308598 --- /dev/null +++ b/src/compas_timber/fabrication/free_contour.py @@ -0,0 +1,181 @@ +import math +from re import L + +from compas.geometry import Brep +from compas.geometry import Cylinder +from compas.geometry import Frame +from compas.geometry import Line +from compas.geometry import Plane +from compas.geometry import Point +from compas.geometry import Transformation +from compas.geometry import Vector +from compas.geometry import angle_vectors_signed +from compas.geometry import distance_point_plane +from compas.geometry import intersection_line_plane +from compas.geometry import intersection_segment_plane +from compas.geometry import is_point_behind_plane +from compas.geometry import is_point_in_polyhedron +from compas.geometry import project_point_plane +from compas.tolerance import TOL + +from compas_timber.errors import FeatureApplicationError + +from .btlx import BTLxProcessing +from .btlx import BTLxProcessingParams +from .btlx import BTLxPart +from .btlx import AlignmentType + + +class FreeContour(BTLxProcessing): + """Represents a drilling processing. + + Parameters + ---------- + start_x : float + The x-coordinate of the start point of the drilling. In the local coordinate system of the reference side. + start_y : float + The y-coordinate of the start point of the drilling. In the local coordinate system of the reference side. + angle : float + The rotation angle of the drilling. In degrees. Around the z-axis of the reference side. + inclination : float + The inclination angle of the drilling. In degrees. Around the y-axis of the reference side. + depth_limited : bool, default True + If True, the drilling depth is limited to `depth`. Otherwise, drilling will go through the element. + depth : float, default 50.0 + The depth of the drilling. In mm. + diameter : float, default 20.0 + The diameter of the drilling. In mm. + """ + + # TODO: add __data__ + + PROCESSING_NAME = "Drilling" # type: ignore + + def __init__(self, start_point, contours, **kwargs): + super(FreeContour, self).__init__(**kwargs) + self._start_point = None + self._contours = None + + self.start_point = start_point + self.contours = contours + + ######################################################################## + # Properties + ######################################################################## + + @property + def start_point(self): + return self._start_point + + @start_point.setter + def start_point(self, value): + self._start_point = Point(*value) + + @property + def contours(self): + return self._contours + + @contours.setter + def contours(self, value): + self._contours = value + + + @property + def header_attributes(self): + """Return the attributes to be included in the XML element. + CounterSink="yes" ToolID="0" ToolPosition="left" Process="yes" ReferencePlaneID="101" Name="Contour"""" + return { + "Name": self.PROCESSING_NAME, + "ToolID":"0", + "Process": "yes", + "ToolPosition":AlignmentType.LEFT, + "ReferencePlaneID": str(self.ref_side_index + 1), + } + + + @property + def simple_contour_dict(self): + return SimpleCountourParams(self).as_dict() + + + + ######################################################################## + # Alternative constructors + ######################################################################## + + @classmethod + def from_polyline_and_element(cls, polyline, element, ref_side_index=0): + """Construct a drilling processing from a line and diameter. + + # TODO: change this to point + vector instead of line. line is too fragile, it can be flipped and cause issues. + # TODO: make a from point alt. constructor that takes a point and a reference side and makes a straight drilling through. + + Parameters + ---------- + line : :class:`compas.geometry.Line` + The line on which the drilling is to be made. + diameter : float + The diameter of the drilling. + length : float + The length (depth?) of the drilling. + beam : :class:`compas_timber.elements.Beam` + The beam to drill. + + Returns + ------- + :class:`compas_timber.fabrication.Drilling` + The constructed drilling processing. + + """ + frame = element.ref_sides[ref_side_index] + xform = Transformation.from_frame_to_frame(frame, Frame.worldXY()) + points = [pt.transformed(xform) for pt in polyline] + return cls(points[0], polyline[1:], ref_side_index=ref_side_index) + + + ######################################################################## + # Methods + ######################################################################## + + def apply(self, geometry, beam): + """Apply the feature to the beam geometry. + + Raises + ------ + :class:`compas_timber.errors.FeatureApplicationError` + If the cutting plane does not intersect with the beam geometry. + + Returns + ------- + :class:`compas.geometry.Brep` + The resulting geometry after processing. + + """ + drill_geometry = Brep.from_cylinder(self.cylinder_from_params_and_beam(beam)) + try: + return geometry - drill_geometry + except IndexError: + raise FeatureApplicationError( + drill_geometry, + geometry, + "The drill geometry does not intersect with beam geometry.", + ) + + + @staticmethod + def polyline_to_contour(polyline): + result = [{"StartPoint": BTLxPart.et_point_vals(polyline[0])}] + for point in polyline[1:]: + result.append({"Line": {"EndPoint": BTLxPart.et_point_vals(point)}}) + + + +class SimpleCountourParams(BTLxProcessingParams): + def __init__(self, instance): + super(SimpleCountourParams, self).__init__(instance) + + def as_dict(self): + result = super(SimpleCountourParams, self).as_dict() + result["StartPoint"] = "{:.{prec}f}".format(float(self._instance.start_point), prec=TOL.precision) + result["Contour"] = self._instance.polyline_to_contour(self._instance.contours) + return result diff --git a/src/compas_timber/fabrication/simple_contour.py b/src/compas_timber/fabrication/simple_contour.py deleted file mode 100644 index f87cec50b..000000000 --- a/src/compas_timber/fabrication/simple_contour.py +++ /dev/null @@ -1,371 +0,0 @@ -import math - -from compas.geometry import Brep -from compas.geometry import Cylinder -from compas.geometry import Frame -from compas.geometry import Line -from compas.geometry import Plane -from compas.geometry import Point -from compas.geometry import Transformation -from compas.geometry import Vector -from compas.geometry import angle_vectors_signed -from compas.geometry import distance_point_plane -from compas.geometry import intersection_line_plane -from compas.geometry import intersection_segment_plane -from compas.geometry import is_point_behind_plane -from compas.geometry import is_point_in_polyhedron -from compas.geometry import project_point_plane -from compas.tolerance import TOL - -from compas_timber.errors import FeatureApplicationError - -from .btlx import BTLxProcessing -from .btlx import BTLxProcessingParams - - -class SimpleCountour(BTLxProcessing): - """Represents a drilling processing. - - Parameters - ---------- - start_x : float - The x-coordinate of the start point of the drilling. In the local coordinate system of the reference side. - start_y : float - The y-coordinate of the start point of the drilling. In the local coordinate system of the reference side. - angle : float - The rotation angle of the drilling. In degrees. Around the z-axis of the reference side. - inclination : float - The inclination angle of the drilling. In degrees. Around the y-axis of the reference side. - depth_limited : bool, default True - If True, the drilling depth is limited to `depth`. Otherwise, drilling will go through the element. - depth : float, default 50.0 - The depth of the drilling. In mm. - diameter : float, default 20.0 - The diameter of the drilling. In mm. - """ - - # TODO: add __data__ - - PROCESSING_NAME = "Drilling" # type: ignore - - def __init__(self, start_x=0.0, start_y=0.0, angle=0.0, inclination=90.0, depth_limited=False, depth=50.0, diameter=20.0, **kwargs): - super(SimpleCountour, self).__init__(**kwargs) - self._start_x = None - self._start_y = None - self._angle = None - self._inclination = None - self._depth_limited = None - self._depth = None - self._diameter = None - - self.start_x = start_x - self.start_y = start_y - self.angle = angle - self.inclination = inclination - self.depth_limited = depth_limited - self.depth = depth - self.diameter = diameter - - ######################################################################## - # Properties - ######################################################################## - - @property - def simple_contour_dict(self): - return SimpleCountourParams(self).as_dict() - - @property - def start_x(self): - return self._start_x - - @start_x.setter - def start_x(self, value): - if -100000 <= value <= 100000: - self._start_x = value - else: - raise ValueError("Start x-coordinate should be between -100000 and 100000. Got: {}".format(value)) - - @property - def start_y(self): - return self._start_y - - @start_y.setter - def start_y(self, value): - if -50000 <= value <= 50000: - self._start_y = value - else: - raise ValueError("Start y-coordinate should be between -50000 and 50000. Got: {}".format(value)) - - @property - def angle(self): - return self._angle - - @angle.setter - def angle(self, value): - if 0.0 <= value <= 360.0: - self._angle = value - else: - raise ValueError("Angle should be between 0 and 360. Got: {}".format(value)) - - @property - def inclination(self): - return self._inclination - - @inclination.setter - def inclination(self, value): - if 0.1 <= value <= 179.0: - self._inclination = value - else: - raise ValueError("Inclination should be between 0 and 180. Got: {}".format(value)) - - @property - def depth_limited(self): - return self._depth_limited - - @depth_limited.setter - def depth_limited(self, value): - self._depth_limited = value - - @property - def depth(self): - return self._depth - - @depth.setter - def depth(self, value): - if 0.0 <= value <= 50000.0: - self._depth = value - else: - raise ValueError("Depth should be between 0 and 50000. Got: {}".format(value)) - - @property - def diameter(self): - return self._diameter - - @diameter.setter - def diameter(self, value): - if 0.0 <= value <= 50000.0: - self._diameter = value - else: - raise ValueError("Diameter should be between 0 and 50000. Got: {}".format(value)) - - ######################################################################## - # Alternative constructors - ######################################################################## - - @classmethod - def from_line_and_beam(cls, line, diameter, beam): - """Construct a drilling processing from a line and diameter. - - # TODO: change this to point + vector instead of line. line is too fragile, it can be flipped and cause issues. - # TODO: make a from point alt. constructor that takes a point and a reference side and makes a straight drilling through. - - Parameters - ---------- - line : :class:`compas.geometry.Line` - The line on which the drilling is to be made. - diameter : float - The diameter of the drilling. - length : float - The length (depth?) of the drilling. - beam : :class:`compas_timber.elements.Beam` - The beam to drill. - - Returns - ------- - :class:`compas_timber.fabrication.Drilling` - The constructed drilling processing. - - """ - ref_side_index, xy_point = cls._calculate_ref_side_index(line, beam) - line = cls._flip_line_if_start_inside(line, beam, ref_side_index) - depth_limited = cls._is_depth_limited(line, beam) - ref_surface = beam.side_as_surface(ref_side_index) - depth = cls._calculate_depth(line, ref_surface) if depth_limited else 0.0 - x_start, y_start = cls._xy_to_ref_side_space(xy_point, ref_surface) - angle = cls._calculate_angle(ref_surface, line, xy_point) - inclination = cls._calculate_inclination(ref_surface.frame, line, angle, xy_point) - try: - return cls(x_start, y_start, angle, inclination, depth_limited, depth, diameter, ref_side_index=ref_side_index) - except ValueError as e: - raise FeatureApplicationError( - message=str(e), - feature_geometry=line, - element_geometry=beam.blank, - ) - - @staticmethod - def _flip_line_if_start_inside(line, beam, ref_side_index): - side_plane = beam.side_as_surface(ref_side_index).to_plane() - if is_point_behind_plane(line.start, side_plane): - return Line(line.end, line.start) # TODO: use line.flip() instead - return line - - @staticmethod - def _calculate_ref_side_index(line, beam): - # TODO: upstream this to compas.geometry - def is_point_on_surface(point, surface): - point = Point(*point) - local_point = point.transformed(Transformation.from_change_of_basis(Frame.worldXY(), surface.frame)) - return 0.0 <= local_point.x <= surface.xsize and 0.0 <= local_point.y <= surface.ysize - - intersections = {} - for index, side in enumerate(beam.ref_sides): - intersection = intersection_segment_plane(line, Plane.from_frame(side)) - if intersection is not None and is_point_on_surface(intersection, beam.side_as_surface(index)): - intersections[index] = Point(*intersection) - - if not intersections: - raise FeatureApplicationError( - message="The drill line must intersect with at lease one of the beam's reference sides.", - feature_geometry=line, - element_geometry=beam.blank, - ) - - ref_side_index = min(intersections, key=lambda i: intersections[i].distance_to_point(line.start)) - return ref_side_index, intersections[ref_side_index] - - @staticmethod - def _is_depth_limited(line, beam): - # check if the end point of the line is within the beam - # if it is, return True - # otherwise, return False - return is_point_in_polyhedron(line.end, beam.blank.to_polyhedron()) - - @staticmethod - def _xy_to_ref_side_space(point, ref_surface): - # type: (Point, PlanSurface) -> Tuple[float, float] - # calculate the x and y coordinates of the start point of the drilling - # based on the intersection point and the reference side index - vec_to_intersection = Vector.from_start_end(ref_surface.frame.point, point) - - # from global to surface local space - vec_to_intersection.transform(Transformation.from_change_of_basis(Frame.worldXY(), ref_surface.frame)) - return vec_to_intersection.x, vec_to_intersection.y - - @staticmethod - def _calculate_angle(ref_surface, line, intersection): - # type: (PlanarSurface, Line, Point) -> float - # this the angle between the direction projected by the drill line onto the reference plane and the reference side x-axis - vector_end_point = project_point_plane(line.end, ref_surface.to_plane()) - drill_horizontal_vector = Vector.from_start_end(intersection, vector_end_point) - reference_vector = -ref_surface.xaxis # angle = 0 when the drill is parallel to -x axis - measurement_axis = -ref_surface.zaxis # measure clockwise around the z-axis (sign flips the direction) - angle = angle_vectors_signed(reference_vector, drill_horizontal_vector, measurement_axis, deg=True) - - # angle goes between -180 and 180 but we need it between 0 and 360 - if angle < 0: - angle += 360 - - return angle - - @staticmethod - def _calculate_inclination(ref_side, line, angle, xy_point): - # type: (Frame, Line, float, Point) -> float - # inclination is the rotation around `ref_side.yaxis` between the `ref_side.xaxis` and the line vector - # we need a reference frame because the rotation axis is not the standard y-axis, but the one rotated by the angle - ref_frame = Frame(xy_point, -ref_side.xaxis, -ref_side.yaxis) - ref_frame.rotate(math.radians(angle), -ref_side.zaxis, point=xy_point) - return angle_vectors_signed(ref_frame.xaxis, line.vector, ref_frame.yaxis, deg=True) - - @staticmethod - def _calculate_depth(line, ref_surface): - return distance_point_plane(line.end, Plane.from_frame(ref_surface.frame)) - - ######################################################################## - # Methods - ######################################################################## - - def apply(self, geometry, beam): - """Apply the feature to the beam geometry. - - Raises - ------ - :class:`compas_timber.errors.FeatureApplicationError` - If the cutting plane does not intersect with the beam geometry. - - Returns - ------- - :class:`compas.geometry.Brep` - The resulting geometry after processing. - - """ - drill_geometry = Brep.from_cylinder(self.cylinder_from_params_and_beam(beam)) - try: - return geometry - drill_geometry - except IndexError: - raise FeatureApplicationError( - drill_geometry, - geometry, - "The drill geometry does not intersect with beam geometry.", - ) - - def cylinder_from_params_and_beam(self, beam): - """Construct the geometry of the drilling using the parameters in this instance and the beam object. - - Parameters - ---------- - beam : :class:`compas_timber.elements.Beam` - The beam to drill. - - Returns - ------- - :class:`compas.geometry.Cylinder` - The constructed cylinder. - - """ - assert self.diameter is not None - assert self.angle is not None - assert self.inclination is not None - assert self.depth is not None - - ref_surface = beam.side_as_surface(self.ref_side_index) - xy_world = ref_surface.point_at(self.start_x, self.start_y) - - # x and y flipped because we want z pointting down into the beam, that'll be the cylinder long direction - cylinder_frame = Frame(xy_world, ref_surface.zaxis, -ref_surface.yaxis) - cylinder_frame.rotate(math.radians(self.angle), -ref_surface.zaxis, point=xy_world) - cylinder_frame.rotate(math.radians(self.inclination), cylinder_frame.yaxis, point=xy_world) - - drill_line = self._calculate_drill_line(beam, xy_world, cylinder_frame) - - # scale both ends so is protrudes nicely from the surface - # TODO: this is a best-effort solution. this can be done more accurately taking the angle into account. consider doing that in the future. - drill_line = self._scaled_line_by_factor(drill_line, 1.2) - return Cylinder.from_line_and_radius(drill_line, self.diameter * 0.5) - - def _scaled_line_by_factor(self, line, factor): - direction = line.vector.unitized() - scale_factor = line.length * 0.5 * factor - start = line.midpoint - direction * scale_factor - end = line.midpoint + direction * scale_factor - return Line(start, end) - - def _calculate_drill_line(self, beam, xy_world, cylinder_frame): - drill_line_direction = Line.from_point_and_vector(xy_world, cylinder_frame.zaxis) - if self.depth_limited: - drill_bottom_plane = beam.side_as_surface(self.ref_side_index).to_plane() - drill_bottom_plane.point -= drill_bottom_plane.normal * self.depth - else: - # this is not always the correct plane, but it's good enough for now, btlx viewer seems to be using the same method.. - # TODO: this is a best-effort solution. consider calculating intersection with other sides to always find the right one. - drill_bottom_plane = beam.side_as_surface(beam.opposing_side_index(self.ref_side_index)).to_plane() - - intersection_point = intersection_line_plane(drill_line_direction, drill_bottom_plane) - assert intersection_point # if this fails, it means space and time as we know it has collapsed - return Line(xy_world, intersection_point) - - -class SimpleCountourParams(BTLxProcessingParams): - def __init__(self, instance): - super(SimpleCountourParams, self).__init__(instance) - - def as_dict(self): - result = super(SimpleCountourParams, self).as_dict() - result["StartX"] = "{:.{prec}f}".format(float(self._instance.start_x), prec=TOL.precision) - result["StartY"] = "{:.{prec}f}".format(float(self._instance.start_y), prec=TOL.precision) - result["Angle"] = "{:.{prec}f}".format(float(self._instance.angle), prec=TOL.precision) - result["Inclination"] = "{:.{prec}f}".format(float(self._instance.inclination), prec=TOL.precision) - result["DepthLimited"] = "yes" if self._instance.depth_limited else "no" - result["Depth"] = "{:.{prec}f}".format(float(self._instance.depth), prec=TOL.precision) - result["Diameter"] = "{:.{prec}f}".format(float(self._instance.diameter), prec=TOL.precision) - return result From 85d914f57b505334da0a9bce794814c37987d2c1 Mon Sep 17 00:00:00 2001 From: obucklin Date: Wed, 29 Jan 2025 16:59:59 +0100 Subject: [PATCH 03/15] working_but_basic --- src/compas_timber/elements/plate.py | 25 ++++- src/compas_timber/fabrication/btlx.py | 77 ++++++++++++---- src/compas_timber/fabrication/free_contour.py | 91 ++++++++++--------- 3 files changed, 127 insertions(+), 66 deletions(-) diff --git a/src/compas_timber/elements/plate.py b/src/compas_timber/elements/plate.py index b9f6d33b4..3d7ac8aeb 100644 --- a/src/compas_timber/elements/plate.py +++ b/src/compas_timber/elements/plate.py @@ -1,4 +1,5 @@ from compas.geometry import Box +from compas.geometry import Point from compas.geometry import Brep from compas.geometry import Frame from compas.geometry import NurbsCurve @@ -62,6 +63,7 @@ def __init__(self, outline, thickness, vector=None, frame=None, **kwargs): self.attributes = {} self.attributes.update(kwargs) self.debug_info = [] + self._ref_frame = None def __repr__(self): # type: () -> str @@ -87,10 +89,28 @@ def is_plate(self): def blank(self): return self.obb + @property + def blank_length(self): + return self.obb.xsize + + @property + def width(self): + return self.obb.ysize + + @property + def height(self): + return self.obb.zsize + @property def vector(self): return self.frame.zaxis * self.thickness + @property + def ref_frame(self): + if not self._ref_frame: + self.compute_obb() + return self._ref_frame + @property def shape(self): brep = Brep.from_extrusion(NurbsCurve.from_points(self.outline.points, degree=1), self.vector) @@ -148,7 +168,7 @@ def compute_geometry(self, include_features=True): if include_features: for feature in self.features: try: - plate_geo = feature.apply(plate_geo) + plate_geo = feature.apply(plate_geo, self) except FeatureApplicationError as error: self.debug_info.append(error) return plate_geo @@ -201,9 +221,10 @@ def compute_obb(self, inflate=0.0): obb.xsize += inflate obb.ysize += inflate obb.zsize += inflate + self._ref_frame = Frame([obb.xmin, obb.ymin, obb.zmin], Vector.Xaxis(), Vector.Yaxis()) obb.transform(Transformation.from_change_of_basis(self.frame, Frame.worldXY())) - + self._ref_frame.transform(Transformation.from_change_of_basis(self.frame, Frame.worldXY())) return obb def compute_collision_mesh(self): diff --git a/src/compas_timber/fabrication/btlx.py b/src/compas_timber/fabrication/btlx.py index ffcf562eb..6722d12a9 100644 --- a/src/compas_timber/fabrication/btlx.py +++ b/src/compas_timber/fabrication/btlx.py @@ -13,6 +13,8 @@ from compas.geometry import Transformation from compas.geometry import angle_vectors from compas.tolerance import TOL +from compas_timber.elements import Beam +from compas_timber.elements import Plate class BTLxWriter(object): @@ -155,12 +157,13 @@ def _create_project_element(self, model): # create parts element parts_element = ET.SubElement(project_element, "Parts") # create part elements for each beam - for i, beam in enumerate(model.beams): - part_element = self._create_part(beam, i) - parts_element.append(part_element) + for i, element in enumerate(model.elements()): + if isinstance(element, Beam) or isinstance(element, Plate): + part_element = self._create_part(element, i) + parts_element.append(part_element) return project_element - def _create_part(self, beam, order_num): + def _create_part(self, element, order_num): """Creates a part element. This method creates the processing elements and appends them to the part element. Parameters @@ -177,16 +180,16 @@ def _create_part(self, beam, order_num): """ # create part element - part = BTLxPart(beam, order_num=order_num) + part = BTLxPart(element, order_num=order_num) part_element = ET.Element("Part", part.attr) part_element.extend([part.et_transformations, part.et_grain_direction, part.et_reference_side]) # create processings element for the part if there are any - if beam.features: + if element.features: processings_element = ET.Element("Processings") - for feature in beam.features: + for feature in element.features: # TODO: This is a temporary hack to skip features from the old system that don't generate a processing, until they are removed or updated. if hasattr(feature, "PROCESSING_NAME"): - processing_element = self._create_processing(feature) + processing_element = feature.create_processing() processings_element.append(processing_element) else: warn("Unsupported feature will be skipped: {}".format(feature)) @@ -215,6 +218,7 @@ def _create_processing(self, processing): ) # create parameter subelements for key, value in processing.params_dict.items(): + print(key, value) if key not in processing.header_attributes: child = ET.SubElement(processing_element, key) if isinstance(value, dict): @@ -257,8 +261,6 @@ class BTLxPart(object): The blank of the beam. blank_frame : :class:`~compas.geometry.Frame` The frame of the blank. - blank_length : float - The blank length of the beam. processings : list A list of the processings applied to the beam. et_element : :class:`~xml.etree.ElementTree.Element` @@ -266,14 +268,13 @@ class BTLxPart(object): """ - def __init__(self, beam, order_num): - self.beam = beam + def __init__(self, element, order_num): + self.element = element self.order_num = order_num - self.length = beam.blank_length - self.width = beam.width - self.height = beam.height - self.frame = beam.ref_frame - self.blank_length = beam.blank_length + self.length = element.blank_length + self.width = element.width + self.height = element.height + self.frame = element.ref_frame self.processings = [] self._et_element = None @@ -326,7 +327,7 @@ def attr(self): "TimberGrade": "", "QualityGrade": "", "Count": "1", - "Length": "{:.{prec}f}".format(self.blank_length, prec=BTLxWriter.POINT_PRECISION), + "Length": "{:.{prec}f}".format(self.length, prec=BTLxWriter.POINT_PRECISION), "Height": "{:.{prec}f}".format(self.height, prec=BTLxWriter.POINT_PRECISION), "Width": "{:.{prec}f}".format(self.width, prec=BTLxWriter.POINT_PRECISION), "Weight": "0", @@ -337,7 +338,8 @@ def attr(self): "ModuleNumber": "", } - def et_point_vals(self, point): + @staticmethod + def et_point_vals(point): """Returns the ET point values for a given point. Parameters @@ -484,6 +486,43 @@ def add_subprocessing(self, subprocessing): self.subprocessings.append(subprocessing) + def create_processing(self): + """Creates a processing element. This method creates the subprocess elements and appends them to the processing element. + moved to BTLxProcessing because some processings are significantly different and need to be overridden. + + Parameters + ---------- + processing : :class:`~compas_timber.fabrication.btlx.BTLxProcessing` + The processing object. + + Returns + ------- + :class:`~xml.etree.ElementTree.Element` + The processing element. + + """ + # create processing element + processing_element = ET.Element( + self.PROCESSING_NAME, + self.header_attributes, + ) + # create parameter subelements + for key, value in self.params_dict.items(): + print(key, value) + if key not in self.header_attributes: + child = ET.SubElement(processing_element, key) + if isinstance(value, dict): + for sub_key, sub_value in value.items(): + child.set(sub_key, sub_value) + else: + child.text = str(value) + # create subprocessing elements + if self.subprocessings: + for subprocessing in self.subprocessings: + processing_element.append(self._create_processing(subprocessing)) + return processing_element + + class BTLxProcessingParams(object): """Base class for BTLx processing parameters. This creates the dictionary of key-value pairs for the processing as expected by the BTLx file format. diff --git a/src/compas_timber/fabrication/free_contour.py b/src/compas_timber/fabrication/free_contour.py index 899308598..00b017155 100644 --- a/src/compas_timber/fabrication/free_contour.py +++ b/src/compas_timber/fabrication/free_contour.py @@ -1,5 +1,5 @@ import math -from re import L +import xml.etree.ElementTree as ET from compas.geometry import Brep from compas.geometry import Cylinder @@ -49,41 +49,21 @@ class FreeContour(BTLxProcessing): # TODO: add __data__ - PROCESSING_NAME = "Drilling" # type: ignore + PROCESSING_NAME = "FreeContour" # type: ignore - def __init__(self, start_point, contours, **kwargs): + def __init__(self, contour_points, **kwargs): super(FreeContour, self).__init__(**kwargs) - self._start_point = None - self._contours = None - self.start_point = start_point - self.contours = contours + self.contour_points = contour_points ######################################################################## # Properties ######################################################################## - @property - def start_point(self): - return self._start_point - - @start_point.setter - def start_point(self, value): - self._start_point = Point(*value) - - @property - def contours(self): - return self._contours - - @contours.setter - def contours(self, value): - self._contours = value - @property def header_attributes(self): - """Return the attributes to be included in the XML element. - CounterSink="yes" ToolID="0" ToolPosition="left" Process="yes" ReferencePlaneID="101" Name="Contour"""" + """Return the attributes to be included in the XML element.""" return { "Name": self.PROCESSING_NAME, "ToolID":"0", @@ -94,9 +74,9 @@ def header_attributes(self): @property - def simple_contour_dict(self): - return SimpleCountourParams(self).as_dict() - + def params_dict(self): + print("params_dict", FreeCountourParams(self).as_dict()) + return FreeCountourParams(self).as_dict() ######################################################################## @@ -127,17 +107,17 @@ def from_polyline_and_element(cls, polyline, element, ref_side_index=0): The constructed drilling processing. """ - frame = element.ref_sides[ref_side_index] + frame = element.ref_frame xform = Transformation.from_frame_to_frame(frame, Frame.worldXY()) points = [pt.transformed(xform) for pt in polyline] - return cls(points[0], polyline[1:], ref_side_index=ref_side_index) + return cls(points, ref_side_index=ref_side_index) ######################################################################## # Methods ######################################################################## - def apply(self, geometry, beam): + def apply(self, geometry, element): """Apply the feature to the beam geometry. Raises @@ -151,15 +131,7 @@ def apply(self, geometry, beam): The resulting geometry after processing. """ - drill_geometry = Brep.from_cylinder(self.cylinder_from_params_and_beam(beam)) - try: - return geometry - drill_geometry - except IndexError: - raise FeatureApplicationError( - drill_geometry, - geometry, - "The drill geometry does not intersect with beam geometry.", - ) + return geometry @staticmethod @@ -167,15 +139,44 @@ def polyline_to_contour(polyline): result = [{"StartPoint": BTLxPart.et_point_vals(polyline[0])}] for point in polyline[1:]: result.append({"Line": {"EndPoint": BTLxPart.et_point_vals(point)}}) + print("polyline_to_contour", result) + return result + def create_processing(self): + """Creates a processing element. This method creates the subprocess elements and appends them to the processing element. + moved to BTLxProcessing because some processings are significantly different and need to be overridden. + Parameters + ---------- + processing : :class:`~compas_timber.fabrication.btlx.BTLxProcessing` + The processing object. -class SimpleCountourParams(BTLxProcessingParams): + Returns + ------- + :class:`~xml.etree.ElementTree.Element` + The processing element. + + """ + # create processing element + processing_element = ET.Element( + self.PROCESSING_NAME, + self.header_attributes, + ) + # create parameter subelements + contour_element = ET.SubElement(processing_element, "Contour") + ET.SubElement(contour_element, "StartPoint", BTLxPart.et_point_vals(self.contour_points[0])) + for pt in self.contour_points[1:]: + point_element = ET.SubElement(contour_element, "Line") + point_element.append(ET.Element("EndPoint", BTLxPart.et_point_vals(pt))) + return processing_element + + + +class FreeCountourParams(BTLxProcessingParams): def __init__(self, instance): - super(SimpleCountourParams, self).__init__(instance) + super(FreeCountourParams, self).__init__(instance) def as_dict(self): - result = super(SimpleCountourParams, self).as_dict() - result["StartPoint"] = "{:.{prec}f}".format(float(self._instance.start_point), prec=TOL.precision) - result["Contour"] = self._instance.polyline_to_contour(self._instance.contours) + result = {} + result["Contour"] = FreeContour.polyline_to_contour(self._instance.contour) return result From 410b7f2bad2febf3fc38d1fd8b2cc2c164775e7d Mon Sep 17 00:00:00 2001 From: obucklin Date: Wed, 29 Jan 2025 18:33:15 +0100 Subject: [PATCH 04/15] orientation correct --- src/compas_timber/design/wall_from_surface.py | 14 ++++++++++ .../elements/fasteners/ball_node_fastener.py | 27 +------------------ src/compas_timber/elements/plate.py | 20 +++++++------- src/compas_timber/utils/__init__.py | 26 +++++++++++++++++- 4 files changed, 49 insertions(+), 38 deletions(-) diff --git a/src/compas_timber/design/wall_from_surface.py b/src/compas_timber/design/wall_from_surface.py index 3572e3874..484b16e33 100644 --- a/src/compas_timber/design/wall_from_surface.py +++ b/src/compas_timber/design/wall_from_surface.py @@ -2,6 +2,7 @@ from compas.geometry import Brep from compas.geometry import Frame +from compas.geometry import Plane from compas.geometry import Line from compas.geometry import NurbsCurve from compas.geometry import Point @@ -11,6 +12,7 @@ from compas.geometry import angle_vectors_signed from compas.geometry import bounding_box_xy from compas.geometry import closest_point_on_segment +from compas.geometry import closest_point_on_plane from compas.geometry import cross_vectors from compas.geometry import distance_point_point_sqrd from compas.geometry import dot_vectors @@ -20,6 +22,7 @@ from compas.geometry import offset_line from compas.geometry import offset_polyline from compas.tolerance import Tolerance +from compas_timber.fabrication import FreeContour from compas_timber.connections import ConnectionSolver from compas_timber.connections import JointTopology @@ -483,12 +486,15 @@ def distance_between_elements(self, element_one, element_two): def generate_plates(self): if self.sheeting_inside: + self._elements.append(Plate(self.outer_polyline, self.sheeting_inside)) if self.sheeting_outside: pline = self.outer_polyline.copy() pline.translate(self.frame.zaxis * (self.frame_depth + self.sheeting_outside)) self._elements.append(Plate(pline, self.sheeting_outside)) for window in self.windows: + for plate in self.plate_elements: + window.apply_contour_to_plate(plate) self._features.append(FeatureDefinition(window.boolean_feature, [plate for plate in self.plate_elements])) class Window(object): @@ -596,6 +602,14 @@ def boolean_feature(self): vol = Brep.from_extrusion(NurbsCurve.from_points(crv.points, degree=1), self.normal * thickness) return BrepSubtraction(vol) + + def apply_contour_to_plate(self, plate): + projected_points = [] + for point in self.outline.points: + projected_points.append(closest_point_on_plane(point, Plane.from_frame(plate.frame))) + feature = FreeContour.from_polyline_and_element(Polyline(projected_points), plate) + plate.add_feature(feature) + def process_outlines(self): for i, segment in enumerate(self.outline.lines): beam_def = SurfaceModel.BeamDefinition(segment, parent=self) diff --git a/src/compas_timber/elements/fasteners/ball_node_fastener.py b/src/compas_timber/elements/fasteners/ball_node_fastener.py index 9d1ec2234..1277b8728 100644 --- a/src/compas_timber/elements/fasteners/ball_node_fastener.py +++ b/src/compas_timber/elements/fasteners/ball_node_fastener.py @@ -7,8 +7,7 @@ from compas.geometry import Sphere from compas.geometry import Transformation from compas.geometry import Vector -from compas.geometry import angle_vectors_signed - +from compas_timber.utils import correct_polyline_direction from compas_timber.elements import CutFeature from compas_timber.elements import Fastener from compas_timber.elements import FastenerTimberInterface @@ -156,27 +155,3 @@ def interface_shape(self): self._interface_shape += geometry return self._interface_shape - -def correct_polyline_direction(polyline, normal_vector): - """Corrects the direction of a polyline to be counter-clockwise around a given vector. - - Parameters - ---------- - polyline : :class:`compas.geometry.Polyline` - The polyline to correct. - - Returns - ------- - :class:`compas.geometry.Polyline` - The corrected polyline. - - """ - angle_sum = 0 - for i in range(len(polyline) - 1): - u = Vector.from_start_end(polyline[i - 1], polyline[i]) - v = Vector.from_start_end(polyline[i], polyline[i + 1]) - angle = angle_vectors_signed(u, v, normal_vector) - angle_sum += angle - if angle_sum > 0: - polyline = polyline[::-1] - return polyline diff --git a/src/compas_timber/elements/plate.py b/src/compas_timber/elements/plate.py index 3d7ac8aeb..4134d2cb3 100644 --- a/src/compas_timber/elements/plate.py +++ b/src/compas_timber/elements/plate.py @@ -95,11 +95,11 @@ def blank_length(self): @property def width(self): - return self.obb.ysize + return self.obb.zsize @property def height(self): - return self.obb.zsize + return self.obb.ysize @property def vector(self): @@ -146,7 +146,6 @@ def set_frame_and_outline(self, outline, vector=None): frame = Frame(frame.point, frame.yaxis, frame.xaxis) self.outline.reverse() # flips the frame if the frame.point is at an exterior corner - self.frame = frame def compute_geometry(self, include_features=True): @@ -212,19 +211,18 @@ def compute_obb(self, inflate=0.0): The OBB of the element. """ - vertices = [point for point in self.outline.points] + vertices = [] for point in self.outline.points: - vertices.append(point + self.vector) - for point in vertices: - point.transform(Transformation.from_change_of_basis(Frame.worldXY(), self.frame)) + vertices.append(point.transformed(Transformation.from_frame_to_frame(self.frame, Frame.worldXY()))) obb = Box.from_points(vertices) obb.xsize += inflate obb.ysize += inflate - obb.zsize += inflate + obb.zsize = self.thickness + obb.translate([0, 0, self.thickness / 2]) self._ref_frame = Frame([obb.xmin, obb.ymin, obb.zmin], Vector.Xaxis(), Vector.Yaxis()) - - obb.transform(Transformation.from_change_of_basis(self.frame, Frame.worldXY())) - self._ref_frame.transform(Transformation.from_change_of_basis(self.frame, Frame.worldXY())) + xform_back = Transformation.from_frame_to_frame(Frame.worldXY(), self.frame) + obb.transform(xform_back) + self._ref_frame.transform(xform_back) return obb def compute_collision_mesh(self): diff --git a/src/compas_timber/utils/__init__.py b/src/compas_timber/utils/__init__.py index 00051cd0b..e911a832d 100644 --- a/src/compas_timber/utils/__init__.py +++ b/src/compas_timber/utils/__init__.py @@ -154,4 +154,28 @@ def intersection_line_beam_param(line, beam, ignore_ends=False): return [Point(*coords) for coords in pts], ref_side_indices -__all__ = ["intersection_line_line_param", "intersection_line_plane_param", "intersection_line_beam_param"] +def correct_polyline_direction(polyline, normal_vector): + """Corrects the direction of a polyline to be counter-clockwise around a given vector. + + Parameters + ---------- + polyline : :class:`compas.geometry.Polyline` + The polyline to correct. + + Returns + ------- + :class:`compas.geometry.Polyline` + The corrected polyline. + + """ + angle_sum = 0 + for i in range(len(polyline) - 1): + u = Vector.from_start_end(polyline[i - 1], polyline[i]) + v = Vector.from_start_end(polyline[i], polyline[i + 1]) + angle = angle_vectors_signed(u, v, normal_vector) + angle_sum += angle + if angle_sum > 0: + polyline = polyline[::-1] + return polyline + +__all__ = ["intersection_line_line_param", "intersection_line_plane_param", "intersection_line_beam_param", "correct_polyline_direction"] From 26dc2a0be2bce789f37f6290059d1918eab9f549 Mon Sep 17 00:00:00 2001 From: obucklin Date: Thu, 30 Jan 2025 10:36:57 +0100 Subject: [PATCH 05/15] works, gonna break it now --- src/compas_timber/fabrication/btlx.py | 1 + src/compas_timber/fabrication/free_contour.py | 27 ++++++++++++++----- 2 files changed, 21 insertions(+), 7 deletions(-) diff --git a/src/compas_timber/fabrication/btlx.py b/src/compas_timber/fabrication/btlx.py index 6722d12a9..5015f5e27 100644 --- a/src/compas_timber/fabrication/btlx.py +++ b/src/compas_timber/fabrication/btlx.py @@ -288,6 +288,7 @@ def et_grain_direction(self): @property def et_reference_side(self): + side = "1" if isinstance(self.element, Beam) else "2" return ET.Element("ReferenceSide", Side="1", Align="no") def ref_side_from_face(self, beam_face): diff --git a/src/compas_timber/fabrication/free_contour.py b/src/compas_timber/fabrication/free_contour.py index 00b017155..6f687ead8 100644 --- a/src/compas_timber/fabrication/free_contour.py +++ b/src/compas_timber/fabrication/free_contour.py @@ -51,10 +51,15 @@ class FreeContour(BTLxProcessing): PROCESSING_NAME = "FreeContour" # type: ignore - def __init__(self, contour_points, **kwargs): + def __init__(self, contour_points, depth, couter_sink = True, tool_position = AlignmentType.LEFT, depth_bounded = False, inclination = 0, **kwargs): super(FreeContour, self).__init__(**kwargs) - self.contour_points = contour_points + self.depth = depth + self.couter_sink = couter_sink + self.tool_position = tool_position + self.depth_bounded = depth_bounded + self.inclination = inclination + ######################################################################## # Properties @@ -66,10 +71,11 @@ def header_attributes(self): """Return the attributes to be included in the XML element.""" return { "Name": self.PROCESSING_NAME, + "CounterSink": "yes" if self.couter_sink else "no", "ToolID":"0", "Process": "yes", - "ToolPosition":AlignmentType.LEFT, - "ReferencePlaneID": str(self.ref_side_index + 1), + "ToolPosition":self.tool_position, + "ReferencePlaneID": "3" } @@ -84,7 +90,7 @@ def params_dict(self): ######################################################################## @classmethod - def from_polyline_and_element(cls, polyline, element, ref_side_index=0): + def from_polyline_and_element(cls, polyline, element, depth = None, ref_side_index=4): """Construct a drilling processing from a line and diameter. # TODO: change this to point + vector instead of line. line is too fragile, it can be flipped and cause issues. @@ -107,10 +113,11 @@ def from_polyline_and_element(cls, polyline, element, ref_side_index=0): The constructed drilling processing. """ + depth = depth or element.width frame = element.ref_frame xform = Transformation.from_frame_to_frame(frame, Frame.worldXY()) points = [pt.transformed(xform) for pt in polyline] - return cls(points, ref_side_index=ref_side_index) + return cls(points, depth, ref_side_index=ref_side_index) ######################################################################## @@ -163,7 +170,13 @@ def create_processing(self): self.header_attributes, ) # create parameter subelements - contour_element = ET.SubElement(processing_element, "Contour") + contour_params = { + "Depth": str(self.depth), + "DepthBounded": "yes" if self.depth_bounded else "no", + "Inclination": str(self.inclination) + } + + contour_element = ET.SubElement(processing_element, "Contour", contour_params) ET.SubElement(contour_element, "StartPoint", BTLxPart.et_point_vals(self.contour_points[0])) for pt in self.contour_points[1:]: point_element = ET.SubElement(contour_element, "Line") From a72c9dd69044d9aa8ee539c449c6351885d61226 Mon Sep 17 00:00:00 2001 From: obucklin Date: Thu, 30 Jan 2025 13:16:08 +0100 Subject: [PATCH 06/15] boom working good --- src/compas_timber/design/wall_from_surface.py | 1 - src/compas_timber/elements/beam.py | 2 +- src/compas_timber/elements/plate.py | 44 +++++++------- src/compas_timber/fabrication/btlx.py | 10 +--- src/compas_timber/fabrication/free_contour.py | 60 +++++++++++-------- src/compas_timber/utils/__init__.py | 2 + 6 files changed, 63 insertions(+), 56 deletions(-) diff --git a/src/compas_timber/design/wall_from_surface.py b/src/compas_timber/design/wall_from_surface.py index 484b16e33..3dd10eea1 100644 --- a/src/compas_timber/design/wall_from_surface.py +++ b/src/compas_timber/design/wall_from_surface.py @@ -486,7 +486,6 @@ def distance_between_elements(self, element_one, element_two): def generate_plates(self): if self.sheeting_inside: - self._elements.append(Plate(self.outer_polyline, self.sheeting_inside)) if self.sheeting_outside: pline = self.outer_polyline.copy() diff --git a/src/compas_timber/elements/beam.py b/src/compas_timber/elements/beam.py index 23bd87306..8de4116a5 100644 --- a/src/compas_timber/elements/beam.py +++ b/src/compas_timber/elements/beam.py @@ -301,7 +301,7 @@ def compute_geometry(self, include_features=True): if include_features: for feature in self.features: try: - blank_geo = feature.apply(blank_geo, beam=self) + blank_geo = feature.apply(blank_geo, self) except FeatureApplicationError as error: self.debug_info.append(error) return blank_geo # type: ignore diff --git a/src/compas_timber/elements/plate.py b/src/compas_timber/elements/plate.py index 4134d2cb3..26b682a37 100644 --- a/src/compas_timber/elements/plate.py +++ b/src/compas_timber/elements/plate.py @@ -9,11 +9,12 @@ from compas.geometry import dot_vectors from compas_model.elements import reset_computed -from compas_timber.errors import FeatureApplicationError -from .timber import TimberElement +from .timber import TimberElement +from compas_timber.errors import FeatureApplicationError +from compas_timber.fabrication import FreeContour class Plate(TimberElement): """ A class to represent timber plates (plywood, CLT, etc.) with uniform thickness. @@ -64,6 +65,9 @@ def __init__(self, outline, thickness, vector=None, frame=None, **kwargs): self.attributes.update(kwargs) self.debug_info = [] self._ref_frame = None + self._blank = None + contour_feature = FreeContour.from_polyline_and_element(self.outline.points, self, interior = False) + self.add_feature(contour_feature) def __repr__(self): # type: () -> str @@ -87,19 +91,19 @@ def is_plate(self): @property def blank(self): - return self.obb + return self._blank @property def blank_length(self): - return self.obb.xsize + return self._blank.xsize @property def width(self): - return self.obb.zsize + return self._blank.zsize @property def height(self): - return self.obb.ysize + return self._blank.ysize @property def vector(self): @@ -111,11 +115,6 @@ def ref_frame(self): self.compute_obb() return self._ref_frame - @property - def shape(self): - brep = Brep.from_extrusion(NurbsCurve.from_points(self.outline.points, degree=1), self.vector) - return brep - @property def has_features(self): # TODO: consider removing, this is not used anywhere @@ -163,13 +162,12 @@ def compute_geometry(self, include_features=True): :class:`compas.datastructures.Mesh` | :class:`compas.geometry.Brep` """ - plate_geo = self.shape - if include_features: - for feature in self.features: - try: - plate_geo = feature.apply(plate_geo, self) - except FeatureApplicationError as error: - self.debug_info.append(error) + plate_geo = Brep.from_box(self.blank) + for feature in self.features: + try: + plate_geo = feature.apply(plate_geo, self) + except FeatureApplicationError as error: + self.debug_info.append(error) return plate_geo def compute_aabb(self, inflate=0.0): @@ -196,7 +194,7 @@ def compute_aabb(self, inflate=0.0): box.zsize += inflate return box - def compute_obb(self, inflate=0.0): + def compute_obb(self): # type: (float | None) -> compas.geometry.Box """Computes the Oriented Bounding Box (OBB) of the element. @@ -215,13 +213,15 @@ def compute_obb(self, inflate=0.0): for point in self.outline.points: vertices.append(point.transformed(Transformation.from_frame_to_frame(self.frame, Frame.worldXY()))) obb = Box.from_points(vertices) - obb.xsize += inflate - obb.ysize += inflate obb.zsize = self.thickness obb.translate([0, 0, self.thickness / 2]) - self._ref_frame = Frame([obb.xmin, obb.ymin, obb.zmin], Vector.Xaxis(), Vector.Yaxis()) + self._blank = obb.copy() + self._blank.xsize += self.thickness + self._blank.ysize += self.thickness + self._ref_frame = Frame([self._blank.xmin, self._blank.ymin, self._blank.zmin], Vector.Xaxis(), Vector.Yaxis()) xform_back = Transformation.from_frame_to_frame(Frame.worldXY(), self.frame) obb.transform(xform_back) + self._blank.transform(xform_back) self._ref_frame.transform(xform_back) return obb diff --git a/src/compas_timber/fabrication/btlx.py b/src/compas_timber/fabrication/btlx.py index 5015f5e27..725ea1020 100644 --- a/src/compas_timber/fabrication/btlx.py +++ b/src/compas_timber/fabrication/btlx.py @@ -13,8 +13,6 @@ from compas.geometry import Transformation from compas.geometry import angle_vectors from compas.tolerance import TOL -from compas_timber.elements import Beam -from compas_timber.elements import Plate class BTLxWriter(object): @@ -157,10 +155,9 @@ def _create_project_element(self, model): # create parts element parts_element = ET.SubElement(project_element, "Parts") # create part elements for each beam - for i, element in enumerate(model.elements()): - if isinstance(element, Beam) or isinstance(element, Plate): - part_element = self._create_part(element, i) - parts_element.append(part_element) + for i, element in enumerate(list(model.beams) + list(model.plates)): + part_element = self._create_part(element, i) + parts_element.append(part_element) return project_element def _create_part(self, element, order_num): @@ -288,7 +285,6 @@ def et_grain_direction(self): @property def et_reference_side(self): - side = "1" if isinstance(self.element, Beam) else "2" return ET.Element("ReferenceSide", Side="1", Align="no") def ref_side_from_face(self, beam_face): diff --git a/src/compas_timber/fabrication/free_contour.py b/src/compas_timber/fabrication/free_contour.py index 6f687ead8..3e0414888 100644 --- a/src/compas_timber/fabrication/free_contour.py +++ b/src/compas_timber/fabrication/free_contour.py @@ -2,7 +2,7 @@ import xml.etree.ElementTree as ET from compas.geometry import Brep -from compas.geometry import Cylinder +from compas.geometry import NurbsCurve from compas.geometry import Frame from compas.geometry import Line from compas.geometry import Plane @@ -19,6 +19,7 @@ from compas.tolerance import TOL from compas_timber.errors import FeatureApplicationError +from compas_timber.utils import correct_polyline_direction from .btlx import BTLxProcessing from .btlx import BTLxProcessingParams @@ -51,7 +52,7 @@ class FreeContour(BTLxProcessing): PROCESSING_NAME = "FreeContour" # type: ignore - def __init__(self, contour_points, depth, couter_sink = True, tool_position = AlignmentType.LEFT, depth_bounded = False, inclination = 0, **kwargs): + def __init__(self, contour_points, depth, couter_sink = False, tool_position = AlignmentType.LEFT, depth_bounded = False, inclination = 0, **kwargs): super(FreeContour, self).__init__(**kwargs) self.contour_points = contour_points self.depth = depth @@ -71,11 +72,11 @@ def header_attributes(self): """Return the attributes to be included in the XML element.""" return { "Name": self.PROCESSING_NAME, - "CounterSink": "yes" if self.couter_sink else "no", + "CounterSink": "no", "ToolID":"0", "Process": "yes", "ToolPosition":self.tool_position, - "ReferencePlaneID": "3" + "ReferencePlaneID": "4" } @@ -90,34 +91,32 @@ def params_dict(self): ######################################################################## @classmethod - def from_polyline_and_element(cls, polyline, element, depth = None, ref_side_index=4): - """Construct a drilling processing from a line and diameter. - - # TODO: change this to point + vector instead of line. line is too fragile, it can be flipped and cause issues. - # TODO: make a from point alt. constructor that takes a point and a reference side and makes a straight drilling through. + def from_polyline_and_element(cls, polyline, element, depth = None, interior=True, ref_side_index=4): + """Construct a Contour processing from a polyline and element. Parameters ---------- - line : :class:`compas.geometry.Line` - The line on which the drilling is to be made. - diameter : float - The diameter of the drilling. - length : float - The length (depth?) of the drilling. - beam : :class:`compas_timber.elements.Beam` - The beam to drill. - - Returns - ------- - :class:`compas_timber.fabrication.Drilling` - The constructed drilling processing. + polyline : list of :class:`compas.geometry.Point` + The polyline of the contour. + element : :class:`compas_timber.elements.Beam` or :class:`compas_timber.elements.Plate` + The element. + depth : float, optional + The depth of the contour. Default is the width of the element. + interior : bool, optional + If True, the contour is an interior contour. Default is True. + ref_side_index : int, optional """ + pline = [pt.copy() for pt in polyline] + pline = correct_polyline_direction(pline, element.ref_frame.normal) + tool_position = AlignmentType.LEFT if interior else AlignmentType.RIGHT + couter_sink = True if interior else False + depth = depth or element.width frame = element.ref_frame xform = Transformation.from_frame_to_frame(frame, Frame.worldXY()) - points = [pt.transformed(xform) for pt in polyline] - return cls(points, depth, ref_side_index=ref_side_index) + points = [pt.transformed(xform) for pt in pline] + return cls(points, depth, tool_position = tool_position, couter_sink = couter_sink, ref_side_index=ref_side_index) ######################################################################## @@ -138,7 +137,18 @@ def apply(self, geometry, element): The resulting geometry after processing. """ - return geometry + if self.tool_position == AlignmentType.LEFT: # contour should remove material inside of the contour + xform = Transformation.from_frame_to_frame(Frame.worldXY(), element.ref_frame) + pts = [pt.transformed(xform) for pt in self.contour_points] + vol = Brep.from_extrusion(NurbsCurve.from_points(pts, degree=1), element.ref_frame.normal * self.depth) + return geometry - vol + else: + volume = Brep.from_box(element.blank) + xform = Transformation.from_frame_to_frame(Frame.worldXY(), element.ref_frame) + pts = [pt.transformed(xform) for pt in self.contour_points] + vol = Brep.from_extrusion(NurbsCurve.from_points(pts, degree=1), element.ref_frame.normal * self.depth) + volume = volume - vol + return geometry - volume @staticmethod diff --git a/src/compas_timber/utils/__init__.py b/src/compas_timber/utils/__init__.py index e911a832d..beed73125 100644 --- a/src/compas_timber/utils/__init__.py +++ b/src/compas_timber/utils/__init__.py @@ -2,6 +2,8 @@ from compas.geometry import Plane from compas.geometry import Point +from compas.geometry import Vector +from compas.geometry import angle_vectors_signed from compas.geometry import add_vectors from compas.geometry import cross_vectors from compas.geometry import distance_point_point From 90aed7114795e6c31a98ba20c9a48ebec9aca09a Mon Sep 17 00:00:00 2001 From: obucklin Date: Thu, 30 Jan 2025 13:20:23 +0100 Subject: [PATCH 07/15] removed brepSubtraction feature --- src/compas_timber/design/wall_from_surface.py | 15 +-------------- 1 file changed, 1 insertion(+), 14 deletions(-) diff --git a/src/compas_timber/design/wall_from_surface.py b/src/compas_timber/design/wall_from_surface.py index 3dd10eea1..48d966d73 100644 --- a/src/compas_timber/design/wall_from_surface.py +++ b/src/compas_timber/design/wall_from_surface.py @@ -494,7 +494,7 @@ def generate_plates(self): for window in self.windows: for plate in self.plate_elements: window.apply_contour_to_plate(plate) - self._features.append(FeatureDefinition(window.boolean_feature, [plate for plate in self.plate_elements])) + class Window(object): """ @@ -589,19 +589,6 @@ def frame(self): self._frame, self._panel_length, self._panel_height = get_frame(self.points, self.parent.normal, self.zaxis) return self._frame - @property - def boolean_feature(self): - offset = self.parent.sheeting_inside if self.parent.sheeting_inside else 0 - so = self.parent.sheeting_outside if self.parent.sheeting_outside else 0 - thickness = offset + so + self.parent.frame_depth - - crv = self.outline.copy() - crv.translate(self.normal * -offset) - - vol = Brep.from_extrusion(NurbsCurve.from_points(crv.points, degree=1), self.normal * thickness) - return BrepSubtraction(vol) - - def apply_contour_to_plate(self, plate): projected_points = [] for point in self.outline.points: From 9775798218719a57eeee3fca029870410ee012bd Mon Sep 17 00:00:00 2001 From: obucklin Date: Thu, 30 Jan 2025 14:51:46 +0100 Subject: [PATCH 08/15] format lint --- src/compas_timber/design/wall_from_surface.py | 11 ++-- .../elements/fasteners/ball_node_fastener.py | 4 +- src/compas_timber/elements/plate.py | 10 ++-- src/compas_timber/fabrication/btlx.py | 1 - src/compas_timber/fabrication/free_contour.py | 54 ++++--------------- src/compas_timber/utils/__init__.py | 6 ++- 6 files changed, 24 insertions(+), 62 deletions(-) diff --git a/src/compas_timber/design/wall_from_surface.py b/src/compas_timber/design/wall_from_surface.py index 48d966d73..b9b6a6beb 100644 --- a/src/compas_timber/design/wall_from_surface.py +++ b/src/compas_timber/design/wall_from_surface.py @@ -1,18 +1,16 @@ import math -from compas.geometry import Brep from compas.geometry import Frame -from compas.geometry import Plane from compas.geometry import Line -from compas.geometry import NurbsCurve +from compas.geometry import Plane from compas.geometry import Point from compas.geometry import Polyline from compas.geometry import Vector from compas.geometry import angle_vectors from compas.geometry import angle_vectors_signed from compas.geometry import bounding_box_xy -from compas.geometry import closest_point_on_segment from compas.geometry import closest_point_on_plane +from compas.geometry import closest_point_on_segment from compas.geometry import cross_vectors from compas.geometry import distance_point_point_sqrd from compas.geometry import dot_vectors @@ -22,17 +20,15 @@ from compas.geometry import offset_line from compas.geometry import offset_polyline from compas.tolerance import Tolerance -from compas_timber.fabrication import FreeContour from compas_timber.connections import ConnectionSolver from compas_timber.connections import JointTopology from compas_timber.connections import LButtJoint from compas_timber.connections import TButtJoint from compas_timber.design import CategoryRule -from compas_timber.design import FeatureDefinition from compas_timber.elements import Beam from compas_timber.elements import Plate -from compas_timber.elements.features import BrepSubtraction +from compas_timber.fabrication import FreeContour from compas_timber.model import TimberModel @@ -495,7 +491,6 @@ def generate_plates(self): for plate in self.plate_elements: window.apply_contour_to_plate(plate) - class Window(object): """ A window object for the SurfaceAssembly. diff --git a/src/compas_timber/elements/fasteners/ball_node_fastener.py b/src/compas_timber/elements/fasteners/ball_node_fastener.py index 1277b8728..8d030c555 100644 --- a/src/compas_timber/elements/fasteners/ball_node_fastener.py +++ b/src/compas_timber/elements/fasteners/ball_node_fastener.py @@ -7,10 +7,11 @@ from compas.geometry import Sphere from compas.geometry import Transformation from compas.geometry import Vector -from compas_timber.utils import correct_polyline_direction + from compas_timber.elements import CutFeature from compas_timber.elements import Fastener from compas_timber.elements import FastenerTimberInterface +from compas_timber.utils import correct_polyline_direction class BallNodeFastener(Fastener): @@ -154,4 +155,3 @@ def interface_shape(self): for geometry in geometries[1:]: self._interface_shape += geometry return self._interface_shape - diff --git a/src/compas_timber/elements/plate.py b/src/compas_timber/elements/plate.py index 26b682a37..98dff416b 100644 --- a/src/compas_timber/elements/plate.py +++ b/src/compas_timber/elements/plate.py @@ -1,20 +1,18 @@ from compas.geometry import Box -from compas.geometry import Point from compas.geometry import Brep from compas.geometry import Frame -from compas.geometry import NurbsCurve from compas.geometry import Transformation from compas.geometry import Vector from compas.geometry import angle_vectors_signed from compas.geometry import dot_vectors from compas_model.elements import reset_computed - +from compas_timber.errors import FeatureApplicationError +from compas_timber.fabrication import FreeContour from .timber import TimberElement -from compas_timber.errors import FeatureApplicationError -from compas_timber.fabrication import FreeContour + class Plate(TimberElement): """ A class to represent timber plates (plywood, CLT, etc.) with uniform thickness. @@ -66,7 +64,7 @@ def __init__(self, outline, thickness, vector=None, frame=None, **kwargs): self.debug_info = [] self._ref_frame = None self._blank = None - contour_feature = FreeContour.from_polyline_and_element(self.outline.points, self, interior = False) + contour_feature = FreeContour.from_polyline_and_element(self.outline.points, self, interior=False) self.add_feature(contour_feature) def __repr__(self): diff --git a/src/compas_timber/fabrication/btlx.py b/src/compas_timber/fabrication/btlx.py index 725ea1020..06723d9a8 100644 --- a/src/compas_timber/fabrication/btlx.py +++ b/src/compas_timber/fabrication/btlx.py @@ -482,7 +482,6 @@ def add_subprocessing(self, subprocessing): self.subprocessings = [] self.subprocessings.append(subprocessing) - def create_processing(self): """Creates a processing element. This method creates the subprocess elements and appends them to the processing element. moved to BTLxProcessing because some processings are significantly different and need to be overridden. diff --git a/src/compas_timber/fabrication/free_contour.py b/src/compas_timber/fabrication/free_contour.py index 3e0414888..08a522a7c 100644 --- a/src/compas_timber/fabrication/free_contour.py +++ b/src/compas_timber/fabrication/free_contour.py @@ -1,30 +1,16 @@ -import math import xml.etree.ElementTree as ET from compas.geometry import Brep -from compas.geometry import NurbsCurve from compas.geometry import Frame -from compas.geometry import Line -from compas.geometry import Plane -from compas.geometry import Point +from compas.geometry import NurbsCurve from compas.geometry import Transformation -from compas.geometry import Vector -from compas.geometry import angle_vectors_signed -from compas.geometry import distance_point_plane -from compas.geometry import intersection_line_plane -from compas.geometry import intersection_segment_plane -from compas.geometry import is_point_behind_plane -from compas.geometry import is_point_in_polyhedron -from compas.geometry import project_point_plane -from compas.tolerance import TOL - -from compas_timber.errors import FeatureApplicationError + from compas_timber.utils import correct_polyline_direction +from .btlx import AlignmentType +from .btlx import BTLxPart from .btlx import BTLxProcessing from .btlx import BTLxProcessingParams -from .btlx import BTLxPart -from .btlx import AlignmentType class FreeContour(BTLxProcessing): @@ -52,7 +38,7 @@ class FreeContour(BTLxProcessing): PROCESSING_NAME = "FreeContour" # type: ignore - def __init__(self, contour_points, depth, couter_sink = False, tool_position = AlignmentType.LEFT, depth_bounded = False, inclination = 0, **kwargs): + def __init__(self, contour_points, depth, couter_sink=False, tool_position=AlignmentType.LEFT, depth_bounded=False, inclination=0, **kwargs): super(FreeContour, self).__init__(**kwargs) self.contour_points = contour_points self.depth = depth @@ -61,37 +47,26 @@ def __init__(self, contour_points, depth, couter_sink = False, tool_position = A self.depth_bounded = depth_bounded self.inclination = inclination - ######################################################################## # Properties ######################################################################## - @property def header_attributes(self): """Return the attributes to be included in the XML element.""" - return { - "Name": self.PROCESSING_NAME, - "CounterSink": "no", - "ToolID":"0", - "Process": "yes", - "ToolPosition":self.tool_position, - "ReferencePlaneID": "4" - } - + return {"Name": self.PROCESSING_NAME, "CounterSink": "no", "ToolID": "0", "Process": "yes", "ToolPosition": self.tool_position, "ReferencePlaneID": "4"} @property def params_dict(self): print("params_dict", FreeCountourParams(self).as_dict()) return FreeCountourParams(self).as_dict() - ######################################################################## # Alternative constructors ######################################################################## @classmethod - def from_polyline_and_element(cls, polyline, element, depth = None, interior=True, ref_side_index=4): + def from_polyline_and_element(cls, polyline, element, depth=None, interior=True, ref_side_index=4): """Construct a Contour processing from a polyline and element. Parameters @@ -116,8 +91,7 @@ def from_polyline_and_element(cls, polyline, element, depth = None, interior=Tru frame = element.ref_frame xform = Transformation.from_frame_to_frame(frame, Frame.worldXY()) points = [pt.transformed(xform) for pt in pline] - return cls(points, depth, tool_position = tool_position, couter_sink = couter_sink, ref_side_index=ref_side_index) - + return cls(points, depth, tool_position=tool_position, couter_sink=couter_sink, ref_side_index=ref_side_index) ######################################################################## # Methods @@ -137,12 +111,12 @@ def apply(self, geometry, element): The resulting geometry after processing. """ - if self.tool_position == AlignmentType.LEFT: # contour should remove material inside of the contour + if self.tool_position == AlignmentType.LEFT: # contour should remove material inside of the contour xform = Transformation.from_frame_to_frame(Frame.worldXY(), element.ref_frame) pts = [pt.transformed(xform) for pt in self.contour_points] vol = Brep.from_extrusion(NurbsCurve.from_points(pts, degree=1), element.ref_frame.normal * self.depth) return geometry - vol - else: + else: # TODO: see if we can use the extrusion directly instead of using a heavy BrepSubtraction. volume = Brep.from_box(element.blank) xform = Transformation.from_frame_to_frame(Frame.worldXY(), element.ref_frame) pts = [pt.transformed(xform) for pt in self.contour_points] @@ -150,7 +124,6 @@ def apply(self, geometry, element): volume = volume - vol return geometry - volume - @staticmethod def polyline_to_contour(polyline): result = [{"StartPoint": BTLxPart.et_point_vals(polyline[0])}] @@ -180,11 +153,7 @@ def create_processing(self): self.header_attributes, ) # create parameter subelements - contour_params = { - "Depth": str(self.depth), - "DepthBounded": "yes" if self.depth_bounded else "no", - "Inclination": str(self.inclination) - } + contour_params = {"Depth": str(self.depth), "DepthBounded": "yes" if self.depth_bounded else "no", "Inclination": str(self.inclination)} contour_element = ET.SubElement(processing_element, "Contour", contour_params) ET.SubElement(contour_element, "StartPoint", BTLxPart.et_point_vals(self.contour_points[0])) @@ -194,7 +163,6 @@ def create_processing(self): return processing_element - class FreeCountourParams(BTLxProcessingParams): def __init__(self, instance): super(FreeCountourParams, self).__init__(instance) diff --git a/src/compas_timber/utils/__init__.py b/src/compas_timber/utils/__init__.py index beed73125..a4b287ab3 100644 --- a/src/compas_timber/utils/__init__.py +++ b/src/compas_timber/utils/__init__.py @@ -156,7 +156,7 @@ def intersection_line_beam_param(line, beam, ignore_ends=False): return [Point(*coords) for coords in pts], ref_side_indices -def correct_polyline_direction(polyline, normal_vector): +def correct_polyline_direction(polyline, normal_vector, clockwise=False): """Corrects the direction of a polyline to be counter-clockwise around a given vector. Parameters @@ -176,8 +176,10 @@ def correct_polyline_direction(polyline, normal_vector): v = Vector.from_start_end(polyline[i], polyline[i + 1]) angle = angle_vectors_signed(u, v, normal_vector) angle_sum += angle - if angle_sum > 0: + if angle_sum > 0 and not clockwise or angle_sum < 0 and clockwise: polyline = polyline[::-1] + return polyline + __all__ = ["intersection_line_line_param", "intersection_line_plane_param", "intersection_line_beam_param", "correct_polyline_direction"] From 3b569477efe6e0064c563b4d97de7d1e3869aa3c Mon Sep 17 00:00:00 2001 From: obucklin Date: Fri, 31 Jan 2025 14:27:27 +0100 Subject: [PATCH 09/15] added tests --- src/compas_timber/elements/plate.py | 3 +- src/compas_timber/fabrication/free_contour.py | 40 +++++----- src/compas_timber/utils/__init__.py | 4 +- tests/compas_timber/test_free_contour.py | 76 +++++++++++++++++++ 4 files changed, 101 insertions(+), 22 deletions(-) create mode 100644 tests/compas_timber/test_free_contour.py diff --git a/src/compas_timber/elements/plate.py b/src/compas_timber/elements/plate.py index 98dff416b..e0277315a 100644 --- a/src/compas_timber/elements/plate.py +++ b/src/compas_timber/elements/plate.py @@ -3,6 +3,7 @@ from compas.geometry import Frame from compas.geometry import Transformation from compas.geometry import Vector +from compas.geometry import Polyline from compas.geometry import angle_vectors_signed from compas.geometry import dot_vectors from compas_model.elements import reset_computed @@ -141,7 +142,7 @@ def set_frame_and_outline(self, outline, vector=None): if vector is not None and dot_vectors(frame.zaxis, vector) < 0: # if the vector is pointing in the opposite direction from self.frame.normal frame = Frame(frame.point, frame.yaxis, frame.xaxis) - self.outline.reverse() + self.outline = Polyline(self.outline[::-1]) # flips the frame if the frame.point is at an exterior corner self.frame = frame diff --git a/src/compas_timber/fabrication/free_contour.py b/src/compas_timber/fabrication/free_contour.py index 08a522a7c..b26e03439 100644 --- a/src/compas_timber/fabrication/free_contour.py +++ b/src/compas_timber/fabrication/free_contour.py @@ -54,11 +54,14 @@ def __init__(self, contour_points, depth, couter_sink=False, tool_position=Align @property def header_attributes(self): """Return the attributes to be included in the XML element.""" - return {"Name": self.PROCESSING_NAME, "CounterSink": "no", "ToolID": "0", "Process": "yes", "ToolPosition": self.tool_position, "ReferencePlaneID": "4"} + return {"Name": self.PROCESSING_NAME, "CounterSink": "yes" if self.couter_sink else "no", "ToolID": "0", "Process": "yes", "ToolPosition": self.tool_position, "ReferencePlaneID": "4"} + + @property + def contour_attributes(self): + return {"Depth": str(self.depth), "DepthBounded": "yes" if self.depth_bounded else "no", "Inclination": str(self.inclination)} @property def params_dict(self): - print("params_dict", FreeCountourParams(self).as_dict()) return FreeCountourParams(self).as_dict() ######################################################################## @@ -83,8 +86,8 @@ def from_polyline_and_element(cls, polyline, element, depth=None, interior=True, """ pline = [pt.copy() for pt in polyline] - pline = correct_polyline_direction(pline, element.ref_frame.normal) - tool_position = AlignmentType.LEFT if interior else AlignmentType.RIGHT + pline = correct_polyline_direction(pline, element.ref_frame.normal, clockwise=True) + tool_position = AlignmentType.RIGHT if interior else AlignmentType.LEFT # TODO: see if we can have CCW contours. for now only CW. couter_sink = True if interior else False depth = depth or element.width @@ -111,30 +114,30 @@ def apply(self, geometry, element): The resulting geometry after processing. """ - if self.tool_position == AlignmentType.LEFT: # contour should remove material inside of the contour + if self.tool_position == AlignmentType.RIGHT: # contour should remove material inside of the contour xform = Transformation.from_frame_to_frame(Frame.worldXY(), element.ref_frame) pts = [pt.transformed(xform) for pt in self.contour_points] - vol = Brep.from_extrusion(NurbsCurve.from_points(pts, degree=1), element.ref_frame.normal * self.depth) + pts = correct_polyline_direction(pts, element.ref_frame.normal, clockwise=True) + vol = Brep.from_extrusion(NurbsCurve.from_points(pts, degree=1), element.ref_frame.normal * self.depth* 2.0) + vol.translate(element.ref_frame.normal * -self.depth) return geometry - vol - else: # TODO: see if we can use the extrusion directly instead of using a heavy BrepSubtraction. - volume = Brep.from_box(element.blank) + else: xform = Transformation.from_frame_to_frame(Frame.worldXY(), element.ref_frame) pts = [pt.transformed(xform) for pt in self.contour_points] + pts = correct_polyline_direction(pts, element.ref_frame.normal, clockwise=True) vol = Brep.from_extrusion(NurbsCurve.from_points(pts, degree=1), element.ref_frame.normal * self.depth) - volume = volume - vol - return geometry - volume + return geometry & vol @staticmethod def polyline_to_contour(polyline): result = [{"StartPoint": BTLxPart.et_point_vals(polyline[0])}] for point in polyline[1:]: result.append({"Line": {"EndPoint": BTLxPart.et_point_vals(point)}}) - print("polyline_to_contour", result) return result def create_processing(self): """Creates a processing element. This method creates the subprocess elements and appends them to the processing element. - moved to BTLxProcessing because some processings are significantly different and need to be overridden. + NOTE: moved to BTLxProcessing because some processings are significantly different and need to be overridden. Parameters ---------- @@ -152,13 +155,10 @@ def create_processing(self): self.PROCESSING_NAME, self.header_attributes, ) - # create parameter subelements - contour_params = {"Depth": str(self.depth), "DepthBounded": "yes" if self.depth_bounded else "no", "Inclination": str(self.inclination)} - - contour_element = ET.SubElement(processing_element, "Contour", contour_params) + contour_element = ET.SubElement(processing_element, "Contour", self.contour_attributes) ET.SubElement(contour_element, "StartPoint", BTLxPart.et_point_vals(self.contour_points[0])) for pt in self.contour_points[1:]: - point_element = ET.SubElement(contour_element, "Line") + point_element = ET.SubElement(contour_element, "Line") # TODO: consider implementing arcs. maybe as tuple? (Point,Point) point_element.append(ET.Element("EndPoint", BTLxPart.et_point_vals(pt))) return processing_element @@ -167,7 +167,9 @@ class FreeCountourParams(BTLxProcessingParams): def __init__(self, instance): super(FreeCountourParams, self).__init__(instance) - def as_dict(self): + def as_dict(self): # don't run super().as_dict() because it will return the default values result = {} - result["Contour"] = FreeContour.polyline_to_contour(self._instance.contour) + result["header_attributes"] = self._instance.header_attributes + result["contour_attributes"] = self._instance.contour_attributes + result["contour_points"] = FreeContour.polyline_to_contour(self._instance.contour_points) return result diff --git a/src/compas_timber/utils/__init__.py b/src/compas_timber/utils/__init__.py index c79cc48f8..2917c882e 100644 --- a/src/compas_timber/utils/__init__.py +++ b/src/compas_timber/utils/__init__.py @@ -193,10 +193,10 @@ def correct_polyline_direction(polyline, normal_vector, clockwise=False): v = Vector.from_start_end(polyline[i], polyline[i + 1]) angle = angle_vectors_signed(u, v, normal_vector) angle_sum += angle - if angle_sum > 0 and not clockwise or angle_sum < 0 and clockwise: + if angle_sum > 0 and clockwise or angle_sum < 0 and not clockwise: polyline = polyline[::-1] + print("correcting polyline direction", polyline) return polyline - __all__ = ["intersection_line_line_param", "intersection_line_plane_param", "intersection_line_beam_param", "correct_polyline_direction"] diff --git a/tests/compas_timber/test_free_contour.py b/tests/compas_timber/test_free_contour.py new file mode 100644 index 000000000..7e0b8e41e --- /dev/null +++ b/tests/compas_timber/test_free_contour.py @@ -0,0 +1,76 @@ +import pytest +from collections import OrderedDict + +from compas.geometry import Frame +from compas.geometry import Point +from compas.geometry import Polyline +from compas.geometry import Vector +from compas.tolerance import TOL + +from compas_timber.elements import Plate +from compas_timber.fabrication import FreeContour + + +@pytest.fixture +def plate(): + pline = Polyline([Point(0, 0, 0), Point(0, 200, 0), Point(100, 200, 0), Point(100, 0, 0), Point(0, 0, 0)]) + return Plate(pline, 10.0) + + +def test_plate_blank(): + pline = Polyline([Point(0, 0, 0), Point(0, 200, 0), Point(100, 200, 0), Point(100, 0, 0), Point(0, 0, 0)]) + plate = Plate(pline, 10.0) + + assert len(plate.features) == 1 + assert isinstance(plate.features[0], FreeContour) + assert TOL.is_zero(plate.blank.xsize - 210.0) # x-axis is the vector from `plate.outline[0]` to `plate.outline[1]` + assert TOL.is_zero(plate.blank.ysize - 110.0) + assert TOL.is_zero(plate.blank.zsize - 10.0) + + +def test_plate_blank_reversed(): + pline = Polyline([Point(0, 0, 0), Point(100, 0, 0), Point(100, 200, 0), Point(0, 200, 0), Point(0, 0, 0)]) + plate = Plate(pline, 10.0) + + assert len(plate.features) == 1 + assert isinstance(plate.features[0], FreeContour) + assert TOL.is_zero(plate.blank.xsize - 110.0) # x-axis is the vector from `plate.outline[0]` to `plate.outline[1]` + assert TOL.is_zero(plate.blank.ysize - 210.0) + assert TOL.is_zero(plate.blank.zsize - 10.0) + + + +def test_plate_contour(): + pline = Polyline([Point(0, 0, 0), Point(0, 200, 0), Point(100, 200, 0), Point(100, 0, 0), Point(0, 0, 0)]) + thickness = 10.0 + plate = Plate(pline, thickness) + + expected = { + "header_attributes": {"ToolID": "0", "Name": "FreeContour", "ToolPosition": "left", "ReferencePlaneID": "4", "CounterSink": "no", "Process": "yes"}, + "contour_attributes": {"Inclination": "0", "DepthBounded": "no", "Depth": "10.0"}, + "contour_points": [ + {"StartPoint": {"Y": "105.000", "X": "5.000", "Z": "0.000"}}, + {"Line": {"EndPoint": {"Y": "105.000", "X": "205.000", "Z": "0.000"}}}, + {"Line": {"EndPoint": {"Y": "5.000", "X": "205.000", "Z": "0.000"}}}, + {"Line": {"EndPoint": {"Y": "5.000", "X": "5.000", "Z": "0.000"}}}, + {"Line": {"EndPoint": {"Y": "105.000", "X": "5.000", "Z": "0.000"}}}, + ], + } + + assert plate.features[0].header_attributes == expected["header_attributes"] + assert plate.features[0].contour_attributes["Depth"] == str(thickness) + +def test_plate_contour(): + plate_pline = Polyline([Point(0, 0, 0), Point(0, 200, 0), Point(100, 200, 0), Point(100, 0, 0), Point(0, 0, 0)]) + thickness = 10.0 + depth = 5.0 + plate = Plate(plate_pline, thickness) + contour_pline = Polyline([Point(25, 50, 0), Point(25, 150, 0), Point(75, 150, 0), Point(75, 50, 0), Point(25, 50, 0)]) + contour = FreeContour.from_polyline_and_element(contour_pline, plate, depth=depth) + plate.add_feature(contour) + + assert len(plate.features) == 2 + assert plate.features[1] == contour + assert contour.header_attributes["ToolPosition"] == "right" + assert contour.header_attributes["CounterSink"] == "yes" + assert contour.contour_attributes["Depth"] == str(depth) From 67ba3bc79286a6710677cfb33f29258a2c45d579 Mon Sep 17 00:00:00 2001 From: obucklin Date: Fri, 31 Jan 2025 14:31:08 +0100 Subject: [PATCH 10/15] ready --- CHANGELOG.md | 1 + src/compas_timber/elements/plate.py | 2 +- src/compas_timber/fabrication/free_contour.py | 13 ++++++++++--- src/compas_timber/utils/__init__.py | 1 + tests/compas_timber/test_free_contour.py | 11 ++++------- 5 files changed, 17 insertions(+), 11 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 808d555d2..504625ad6 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,6 +9,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Added * Added `YButtJoint` which joins the ends of three joints where the `cross_beams` get a miter cut and the `main_beam` gets a double cut. +* Added `FreeContour` BTLx processing and applied it to the `Plate` type so that plates can be machined. ### Changed diff --git a/src/compas_timber/elements/plate.py b/src/compas_timber/elements/plate.py index e0277315a..ae41790f6 100644 --- a/src/compas_timber/elements/plate.py +++ b/src/compas_timber/elements/plate.py @@ -1,9 +1,9 @@ from compas.geometry import Box from compas.geometry import Brep from compas.geometry import Frame +from compas.geometry import Polyline from compas.geometry import Transformation from compas.geometry import Vector -from compas.geometry import Polyline from compas.geometry import angle_vectors_signed from compas.geometry import dot_vectors from compas_model.elements import reset_computed diff --git a/src/compas_timber/fabrication/free_contour.py b/src/compas_timber/fabrication/free_contour.py index b26e03439..68a2d8f11 100644 --- a/src/compas_timber/fabrication/free_contour.py +++ b/src/compas_timber/fabrication/free_contour.py @@ -54,7 +54,14 @@ def __init__(self, contour_points, depth, couter_sink=False, tool_position=Align @property def header_attributes(self): """Return the attributes to be included in the XML element.""" - return {"Name": self.PROCESSING_NAME, "CounterSink": "yes" if self.couter_sink else "no", "ToolID": "0", "Process": "yes", "ToolPosition": self.tool_position, "ReferencePlaneID": "4"} + return { + "Name": self.PROCESSING_NAME, + "CounterSink": "yes" if self.couter_sink else "no", + "ToolID": "0", + "Process": "yes", + "ToolPosition": self.tool_position, + "ReferencePlaneID": "4", + } @property def contour_attributes(self): @@ -87,7 +94,7 @@ def from_polyline_and_element(cls, polyline, element, depth=None, interior=True, """ pline = [pt.copy() for pt in polyline] pline = correct_polyline_direction(pline, element.ref_frame.normal, clockwise=True) - tool_position = AlignmentType.RIGHT if interior else AlignmentType.LEFT # TODO: see if we can have CCW contours. for now only CW. + tool_position = AlignmentType.RIGHT if interior else AlignmentType.LEFT # TODO: see if we can have CCW contours. for now only CW. couter_sink = True if interior else False depth = depth or element.width @@ -118,7 +125,7 @@ def apply(self, geometry, element): xform = Transformation.from_frame_to_frame(Frame.worldXY(), element.ref_frame) pts = [pt.transformed(xform) for pt in self.contour_points] pts = correct_polyline_direction(pts, element.ref_frame.normal, clockwise=True) - vol = Brep.from_extrusion(NurbsCurve.from_points(pts, degree=1), element.ref_frame.normal * self.depth* 2.0) + vol = Brep.from_extrusion(NurbsCurve.from_points(pts, degree=1), element.ref_frame.normal * self.depth * 2.0) vol.translate(element.ref_frame.normal * -self.depth) return geometry - vol else: diff --git a/src/compas_timber/utils/__init__.py b/src/compas_timber/utils/__init__.py index 2917c882e..45aa61629 100644 --- a/src/compas_timber/utils/__init__.py +++ b/src/compas_timber/utils/__init__.py @@ -199,4 +199,5 @@ def correct_polyline_direction(polyline, normal_vector, clockwise=False): return polyline + __all__ = ["intersection_line_line_param", "intersection_line_plane_param", "intersection_line_beam_param", "correct_polyline_direction"] diff --git a/tests/compas_timber/test_free_contour.py b/tests/compas_timber/test_free_contour.py index 7e0b8e41e..73a94ed45 100644 --- a/tests/compas_timber/test_free_contour.py +++ b/tests/compas_timber/test_free_contour.py @@ -1,10 +1,7 @@ import pytest -from collections import OrderedDict -from compas.geometry import Frame from compas.geometry import Point from compas.geometry import Polyline -from compas.geometry import Vector from compas.tolerance import TOL from compas_timber.elements import Plate @@ -39,7 +36,6 @@ def test_plate_blank_reversed(): assert TOL.is_zero(plate.blank.zsize - 10.0) - def test_plate_contour(): pline = Polyline([Point(0, 0, 0), Point(0, 200, 0), Point(100, 200, 0), Point(100, 0, 0), Point(0, 0, 0)]) thickness = 10.0 @@ -60,13 +56,14 @@ def test_plate_contour(): assert plate.features[0].header_attributes == expected["header_attributes"] assert plate.features[0].contour_attributes["Depth"] == str(thickness) -def test_plate_contour(): + +def test_plate_aperture_contour(): plate_pline = Polyline([Point(0, 0, 0), Point(0, 200, 0), Point(100, 200, 0), Point(100, 0, 0), Point(0, 0, 0)]) thickness = 10.0 depth = 5.0 plate = Plate(plate_pline, thickness) - contour_pline = Polyline([Point(25, 50, 0), Point(25, 150, 0), Point(75, 150, 0), Point(75, 50, 0), Point(25, 50, 0)]) - contour = FreeContour.from_polyline_and_element(contour_pline, plate, depth=depth) + aperture_pline = Polyline([Point(25, 50, 0), Point(25, 150, 0), Point(75, 150, 0), Point(75, 50, 0), Point(25, 50, 0)]) + contour = FreeContour.from_polyline_and_element(aperture_pline, plate, depth=depth) plate.add_feature(contour) assert len(plate.features) == 2 From 2cfa5e522e9b2fec2e630353012f5d0f0ce046af Mon Sep 17 00:00:00 2001 From: obucklin Date: Fri, 31 Jan 2025 15:21:49 +0100 Subject: [PATCH 11/15] doc stuff --- src/compas_timber/fabrication/free_contour.py | 34 +++++++++---------- 1 file changed, 17 insertions(+), 17 deletions(-) diff --git a/src/compas_timber/fabrication/free_contour.py b/src/compas_timber/fabrication/free_contour.py index 68a2d8f11..5c7206747 100644 --- a/src/compas_timber/fabrication/free_contour.py +++ b/src/compas_timber/fabrication/free_contour.py @@ -14,24 +14,23 @@ class FreeContour(BTLxProcessing): - """Represents a drilling processing. + """Represents a free contour processing. Parameters ---------- - start_x : float - The x-coordinate of the start point of the drilling. In the local coordinate system of the reference side. - start_y : float - The y-coordinate of the start point of the drilling. In the local coordinate system of the reference side. - angle : float - The rotation angle of the drilling. In degrees. Around the z-axis of the reference side. - inclination : float - The inclination angle of the drilling. In degrees. Around the y-axis of the reference side. - depth_limited : bool, default True - If True, the drilling depth is limited to `depth`. Otherwise, drilling will go through the element. - depth : float, default 50.0 - The depth of the drilling. In mm. - diameter : float, default 20.0 - The diameter of the drilling. In mm. + contour_points : list of :class:`compas.geometry.Point` + The points of the contour. + depth : float + The depth of the contour. + couter_sink : bool, optional + If True, the contour is a counter sink. Default is False. + tool_position : str, optional + The position of the tool. Default is "left". + depth_bounded : bool, optional + If True, the depth is bounded. Default is False, meaning the machining will cut all the way through the element. + inclination : float, optional + The inclination of the contour. Default is 0. This is not yet implemented. + """ # TODO: add __data__ @@ -45,7 +44,8 @@ def __init__(self, contour_points, depth, couter_sink=False, tool_position=Align self.couter_sink = couter_sink self.tool_position = tool_position self.depth_bounded = depth_bounded - self.inclination = inclination + if inclination != 0: + raise NotImplementedError("Inclination is not yet implemented.") ######################################################################## # Properties @@ -90,7 +90,7 @@ def from_polyline_and_element(cls, polyline, element, depth=None, interior=True, interior : bool, optional If True, the contour is an interior contour. Default is True. ref_side_index : int, optional - + The reference side index. Default is 4. """ pline = [pt.copy() for pt in polyline] pline = correct_polyline_direction(pline, element.ref_frame.normal, clockwise=True) From 4cbed3bff93f5b137a6877e2dd7a606aad7f4bc5 Mon Sep 17 00:00:00 2001 From: oliver bucklin Date: Wed, 5 Feb 2025 13:42:19 +0100 Subject: [PATCH 12/15] fixed inclination setter --- src/compas_timber/fabrication/free_contour.py | 1 + 1 file changed, 1 insertion(+) diff --git a/src/compas_timber/fabrication/free_contour.py b/src/compas_timber/fabrication/free_contour.py index 5c7206747..8b65b2dde 100644 --- a/src/compas_timber/fabrication/free_contour.py +++ b/src/compas_timber/fabrication/free_contour.py @@ -46,6 +46,7 @@ def __init__(self, contour_points, depth, couter_sink=False, tool_position=Align self.depth_bounded = depth_bounded if inclination != 0: raise NotImplementedError("Inclination is not yet implemented.") + self.inclination = inclination ######################################################################## # Properties From 651c2370a1cc04705289028e9e6d903d4a306f9c Mon Sep 17 00:00:00 2001 From: obucklin Date: Fri, 7 Feb 2025 09:29:25 +0100 Subject: [PATCH 13/15] forgot to push last night added test, removed writer._create_part --- src/compas_timber/fabrication/btlx.py | 71 +------------------ src/compas_timber/fabrication/free_contour.py | 12 ++++ tests/compas_timber/test_free_contour.py | 20 ++++++ 3 files changed, 33 insertions(+), 70 deletions(-) diff --git a/src/compas_timber/fabrication/btlx.py b/src/compas_timber/fabrication/btlx.py index 70f3a5df0..588fd3819 100644 --- a/src/compas_timber/fabrication/btlx.py +++ b/src/compas_timber/fabrication/btlx.py @@ -160,75 +160,6 @@ def _create_project_element(self, model): parts_element.append(part_element) return project_element - def _create_part(self, element, order_num): - """Creates a part element. This method creates the processing elements and appends them to the part element. - - Parameters - ---------- - beam : :class:`~compas_timber.elements.Beam` - The beam object. - num : int - The order number of the part. - - Returns - ------- - :class:`~xml.etree.ElementTree.Element` - The part element. - - """ - # create part element - part = BTLxPart(element, order_num=order_num) - part_element = ET.Element("Part", part.attr) - part_element.extend([part.et_transformations, part.et_grain_direction, part.et_reference_side]) - # create processings element for the part if there are any - if element.features: - processings_element = ET.Element("Processings") - for feature in element.features: - # TODO: This is a temporary hack to skip features from the old system that don't generate a processing, until they are removed or updated. - if hasattr(feature, "PROCESSING_NAME"): - processing_element = feature.create_processing() - processings_element.append(processing_element) - else: - warn("Unsupported feature will be skipped: {}".format(feature)) - part_element.append(processings_element) - part_element.append(part.et_shape) - return part_element - - def _create_processing(self, processing): - """Creates a processing element. This method creates the subprocess elements and appends them to the processing element. - - Parameters - ---------- - processing : :class:`~compas_timber.fabrication.btlx.BTLxProcessing` - The processing object. - - Returns - ------- - :class:`~xml.etree.ElementTree.Element` - The processing element. - - """ - # create processing element - processing_element = ET.Element( - processing.PROCESSING_NAME, - processing.header_attributes, - ) - # create parameter subelements - for key, value in processing.params_dict.items(): - print(key, value) - if key not in processing.header_attributes: - child = ET.SubElement(processing_element, key) - if isinstance(value, dict): - for sub_key, sub_value in value.items(): - child.set(sub_key, sub_value) - else: - child.text = str(value) - # create subprocessing elements - if processing.subprocessings: - for subprocessing in processing.subprocessings: - processing_element.append(self._create_processing(subprocessing)) - return processing_element - class BTLxPart(object): """Class representing a BTLx part. This acts as a wrapper for a Beam object. @@ -515,7 +446,7 @@ def create_processing(self): # create subprocessing elements if self.subprocessings: for subprocessing in self.subprocessings: - processing_element.append(self._create_processing(subprocessing)) + processing_element.append(self.create_processing(subprocessing)) return processing_element diff --git a/src/compas_timber/fabrication/free_contour.py b/src/compas_timber/fabrication/free_contour.py index 8b65b2dde..5fea1206c 100644 --- a/src/compas_timber/fabrication/free_contour.py +++ b/src/compas_timber/fabrication/free_contour.py @@ -48,10 +48,22 @@ def __init__(self, contour_points, depth, couter_sink=False, tool_position=Align raise NotImplementedError("Inclination is not yet implemented.") self.inclination = inclination + ######################################################################## # Properties ######################################################################## + @property + def __data__(self): + data = super(FreeContour, self).__data__ + data["contour_points"] = self.contour_points + data["depth"] = self.depth + data["couter_sink"] = self.couter_sink + data["tool_position"] = self.tool_position + data["depth_bounded"] = self.depth_bounded + data["inclination"] = self.inclination + return data + @property def header_attributes(self): """Return the attributes to be included in the XML element.""" diff --git a/tests/compas_timber/test_free_contour.py b/tests/compas_timber/test_free_contour.py index 73a94ed45..ed10eb634 100644 --- a/tests/compas_timber/test_free_contour.py +++ b/tests/compas_timber/test_free_contour.py @@ -6,6 +6,8 @@ from compas_timber.elements import Plate from compas_timber.fabrication import FreeContour +from compas.data import json_loads +from compas.data import json_dumps @pytest.fixture @@ -71,3 +73,21 @@ def test_plate_aperture_contour(): assert contour.header_attributes["ToolPosition"] == "right" assert contour.header_attributes["CounterSink"] == "yes" assert contour.contour_attributes["Depth"] == str(depth) + + +def test_plate_aperture_contour_serialization(): + plate_pline = Polyline([Point(0, 0, 0), Point(0, 200, 0), Point(100, 200, 0), Point(100, 0, 0), Point(0, 0, 0)]) + thickness = 10.0 + depth = 5.0 + plate = Plate(plate_pline, thickness) + aperture_pline = Polyline([Point(25, 50, 0), Point(25, 150, 0), Point(75, 150, 0), Point(75, 50, 0), Point(25, 50, 0)]) + contour = FreeContour.from_polyline_and_element(aperture_pline, plate, depth=depth) + + contour_copy = json_loads(json_dumps(contour)) + plate.add_feature(contour_copy) + + assert len(plate.features) == 2 + assert plate.features[1] == contour_copy + assert contour_copy.header_attributes["ToolPosition"] == "right" + assert contour_copy.header_attributes["CounterSink"] == "yes" + assert contour_copy.contour_attributes["Depth"] == str(depth) From 7e273475d4ac65363b0510f8ad3a40df2815a90b Mon Sep 17 00:00:00 2001 From: obucklin Date: Fri, 7 Feb 2025 11:14:12 +0100 Subject: [PATCH 14/15] fixed unit-tests --- src/compas_timber/fabrication/btlx.py | 34 +++++++++++++++++++ src/compas_timber/fabrication/free_contour.py | 1 - tests/compas_timber/test_btlx.py | 6 ++-- 3 files changed, 37 insertions(+), 4 deletions(-) diff --git a/src/compas_timber/fabrication/btlx.py b/src/compas_timber/fabrication/btlx.py index 588fd3819..36bd55144 100644 --- a/src/compas_timber/fabrication/btlx.py +++ b/src/compas_timber/fabrication/btlx.py @@ -160,6 +160,40 @@ def _create_project_element(self, model): parts_element.append(part_element) return project_element + def _create_part(self, beam, order_num): + """Creates a part element. This method creates the processing elements and appends them to the part element. + + Parameters + ---------- + beam : :class:`~compas_timber.elements.Beam` + The beam object. + num : int + The order number of the part. + + Returns + ------- + :class:`~xml.etree.ElementTree.Element` + The part element. + + """ + # create part element + part = BTLxPart(beam, order_num=order_num) + part_element = ET.Element("Part", part.attr) + part_element.extend([part.et_transformations, part.et_grain_direction, part.et_reference_side]) + # create processings element for the part if there are any + if beam.features: + processings_element = ET.Element("Processings") + for feature in beam.features: + # TODO: This is a temporary hack to skip features from the old system that don't generate a processing, until they are removed or updated. + if hasattr(feature, "PROCESSING_NAME"): + processing_element = feature.create_processing() + processings_element.append(processing_element) + else: + warn("Unsupported feature will be skipped: {}".format(feature)) + part_element.append(processings_element) + part_element.append(part.et_shape) + return part_element + class BTLxPart(object): """Class representing a BTLx part. This acts as a wrapper for a Beam object. diff --git a/src/compas_timber/fabrication/free_contour.py b/src/compas_timber/fabrication/free_contour.py index 5fea1206c..6163786a1 100644 --- a/src/compas_timber/fabrication/free_contour.py +++ b/src/compas_timber/fabrication/free_contour.py @@ -48,7 +48,6 @@ def __init__(self, contour_points, depth, couter_sink=False, tool_position=Align raise NotImplementedError("Inclination is not yet implemented.") self.inclination = inclination - ######################################################################## # Properties ######################################################################## diff --git a/tests/compas_timber/test_btlx.py b/tests/compas_timber/test_btlx.py index 9cb2ddf39..642809160 100644 --- a/tests/compas_timber/test_btlx.py +++ b/tests/compas_timber/test_btlx.py @@ -10,6 +10,7 @@ import compas import compas_timber from compas_timber.fabrication import BTLxWriter +from compas_timber.fabrication import BTLxProcessing from compas_timber.fabrication import JackRafterCut from compas_timber.fabrication import OrientationType from compas_timber.elements import Beam @@ -183,15 +184,14 @@ def test_float_formatting_of_param_dicts(): def test_create_processing_with_dict_params(): - class MockProcessing: + class MockProcessing(BTLxProcessing): PROCESSING_NAME = "MockProcessing" header_attributes = {"Name": "MockProcessing", "Priority": "1", "Process": "yes", "ProcessID": "1", "ReferencePlaneID": "1"} params_dict = {"Param1": "Value1", "Param2": {"SubParam1": "SubValue1", "SubParam2": "SubValue2"}, "Param3": "Value3"} subprocessings = [] - writer = BTLxWriter() processing = MockProcessing() - processing_element = writer._create_processing(processing) + processing_element = processing.create_processing() assert processing_element.tag == "MockProcessing" assert processing_element.attrib == processing.header_attributes From 28397d1404a4c3a2e52323d08aae294cbce2f0ea Mon Sep 17 00:00:00 2001 From: obucklin Date: Fri, 7 Feb 2025 11:19:32 +0100 Subject: [PATCH 15/15] fixed subprocessing --- src/compas_timber/fabrication/btlx.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/compas_timber/fabrication/btlx.py b/src/compas_timber/fabrication/btlx.py index 36bd55144..c665ed1f1 100644 --- a/src/compas_timber/fabrication/btlx.py +++ b/src/compas_timber/fabrication/btlx.py @@ -480,7 +480,7 @@ def create_processing(self): # create subprocessing elements if self.subprocessings: for subprocessing in self.subprocessings: - processing_element.append(self.create_processing(subprocessing)) + processing_element.append(subprocessing.create_processing()) return processing_element