Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Jack cut feature #267

Closed
wants to merge 16 commits into from
Closed
Show file tree
Hide file tree
Changes from 13 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,10 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0

### Added

* Added new temporary package `_fabrication`.
* Added new `compas_timber._fabrication.JackRafterCut`.
* Added `side_as_surface` to `compas_timber.elements.Beam`.

### Changed

### Removed
Expand Down
8 changes: 8 additions & 0 deletions src/compas_timber/_fabrication/__init__.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
# This module is a tepmorary solution to the problem of circular imports in the compas_timber package.
# It will be reoved or merged with the `fabrication` module once the migration to the new feature system is complete.
from .btlx_process import BTLxProcess
from .btlx_process import OrientationType
from .jack_cut import JackRafterCut


__all__ = ["JackRafterCut", "BTLxProcess", "OrientationType"]
34 changes: 34 additions & 0 deletions src/compas_timber/_fabrication/btlx_process.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,34 @@
from compas.data import Data


class BTLxProcess(Data):
"""Base class for BTLx processes.

Attributes
----------
ref_side_index : int
The reference side, zero-based, index of the beam to be cut. 0-5 correspond to RS1-RS6.
"""

@property
def __data__(self):
return {"ref_side_index": self.ref_side_index}

def __init__(self, ref_side_index):
super(BTLxProcess, self).__init__()
self.ref_side_index = ref_side_index


class OrientationType(object):
"""Enum for the orientation of the cut.

Attributes
----------
START : int
The start of the beam is cut away.
END : int
The end of the beam is cut away.
"""

START = 0
END = 1
274 changes: 274 additions & 0 deletions src/compas_timber/_fabrication/jack_cut.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,274 @@
import math

from compas.geometry import BrepTrimmingError
from compas.geometry import Frame
from compas.geometry import Line
from compas.geometry import Plane
from compas.geometry import Rotation
from compas.geometry import Vector
from compas.geometry import angle_vectors_signed
from compas.geometry import distance_point_point
from compas.geometry import intersection_line_plane
from compas.geometry import is_point_behind_plane

from compas_timber.elements import FeatureApplicationError

from .btlx_process import BTLxProcess
from .btlx_process import OrientationType


class JackRafterCut(BTLxProcess):
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Where does the serialization of the btlx process parameters happen? shouldn't there be a class method for that? Also does this mean that header attributes are being removed?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

You're right, that's missing. I introduced this in one of the last commits, might have not pushed yet.

I'm still very much debating though. I'd like to extract the XML schema specific stuff out of these classes, but they are nevertheless related. In the meantime, I created a JackRafterCutParams class whose sole responsibility is to populate the parameter values from JackRafterCut in they appropriate key-value pair according to the specification.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

it's in the latest commit aec2795

"""Represents a Jack Rafter Cut feature to be made on a beam.

Parameters
----------
orientation : int
The orientation of the cut. Must be either OrientationType.START or OrientationType.END.
start_x : float
The start x-coordinate of the cut in parametric space of the reference side. -100000.0 < start_x < 100000.0.
start_y : float
The start y-coordinate of the cut in parametric space of the reference side. 0.0 < start_y < 50000.0.
start_depth : float
The start depth of the cut. 0.0 < start_depth < 50000.0.
angle : float
The horizontal angle of the cut. 0.1 < angle < 179.9.
inclination : float
The vertical angle of the cut. 0.1 < inclination < 179.9.

"""

@property
def __data__(self):
data = super(JackRafterCut, self).__data__
data["orientation"] = self.orientation
data["start_x"] = self.start_x
data["start_y"] = self.start_y
data["start_depth"] = self.start_depth
data["angle"] = self.angle
data["inclination"] = self.inclination
return data

def __init__(self, orientation, start_x=0.0, start_y=0.0, start_depth=0.0, angle=90.0, inclination=90.0, **kwargs):
super(JackRafterCut, self).__init__(**kwargs)
self._orientation = None
self._start_x = None
self._start_y = None
self._start_depth = None
self._angle = None
self._inclination = None

self.orientation = orientation
self.start_x = start_x
self.start_y = start_y
self.start_depth = start_depth
self.angle = angle
self.inclination = inclination

########################################################################
# Properties
########################################################################

@property
def orientation(self):
return self._orientation

@orientation.setter
Copy link
Contributor

@papachap papachap Jul 19, 2024

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Question: what is the benefit of using setters and private properties for this class?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

this an object oriented thing that's strongly related to the encapsulation principle. In statically typed languages where private access is enforced you'd typically always need setters and getters. In python, since nothing is enforced by the language, it can get blurry.

There's different reasons why you'd want to use properties in python. In this particular case, I used properties and their setters for input validation. Since BTLx has a clear specification for the allowed value ranges for each parameter it seemed like a good place to validate those.

def orientation(self, orientation):
if orientation not in [OrientationType.START, OrientationType.END]:
raise ValueError("Orientation must be either OrientationType.START or OrientationType.END.")
self._orientation = orientation

@property
def start_x(self):
return self._start_x

@start_x.setter
def start_x(self, start_x):
if start_x > 100000.0 or start_x < -100000.0:
raise ValueError("Start X must be between -100000.0 and 100000.")
self._start_x = start_x

@property
def start_y(self):
return self._start_y

@start_y.setter
def start_y(self, start_y):
if start_y > 50000.0:
raise ValueError("Start Y must be less than 50000.0.")
self._start_y = start_y

@property
def start_depth(self):
return self._start_depth

@start_depth.setter
def start_depth(self, start_depth):
if start_depth > 50000.0:
raise ValueError("Start Depth must be less than 50000.0.")
self._start_depth = start_depth

@property
def angle(self):
return self._angle

@angle.setter
def angle(self, angle):
if angle > 179.9 or angle < 0.1:
raise ValueError("Angle must be between 0.1 and 179.9.")
self._angle = angle

@property
def inclination(self):
return self._inclination

@inclination.setter
def inclination(self, inclination):
if inclination > 179.9 or inclination < 0.1:
raise ValueError("Inclination must be between 0.1 and 179.9.")
self._inclination = inclination

########################################################################
# Alternative constructors
########################################################################

@classmethod
def from_plane_and_beam(cls, plane, beam, ref_side_index=0):
"""Create a JackRafterCut instance from a cutting plane and the beam it should cut.

Parameters
----------
plane : :class:`~compas.geometry.Plane` or :class:`~compas.geometry.Frame`
The cutting plane.
beam : :class:`~compas_timber.elements.Beam`
The beam that is cut by this instance.
ref_side_index : int, optional
The reference side index of the beam to be cut. Default is 0 (i.e. RS1).

Returns
-------
:class:`~compas_timber.fabrication.JackRafterCut`

"""
# type: (Plane | Frame, Beam, int) -> JackRafterCut
if isinstance(plane, Frame):
plane = Plane.from_frame(plane)
start_y = 0.0
start_depth = 0.0
ref_side = beam.ref_sides[ref_side_index] # TODO: is this arbitrary?
ref_edge = Line.from_point_and_vector(ref_side.point, ref_side.xaxis)
orientation = cls._calculate_orientation(ref_side, plane)

point_start_x = intersection_line_plane(ref_edge, plane)
if point_start_x is None:
raise ValueError("Plane does not intersect with beam.")

start_x = distance_point_point(ref_edge.point, point_start_x)
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
start_x = distance_point_point(ref_edge.point, point_start_x)
start_x = distance_point_point(ref_side.point, point_start_x)

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

why? :)

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

ref_edge is a compas Line, no? in this case shouldn't it be ref_edge.start? I thought it was a typo or smth ;p

angle = cls._calculate_angle(ref_side, plane, orientation)
inclination = cls._calculate_inclination(ref_side, plane, orientation)
return cls(orientation, start_x, start_y, start_depth, angle, inclination, ref_side_index=ref_side_index)
Copy link
Contributor

@papachap papachap Jul 23, 2024

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

should the ref_side_index be in this class constructor, since it is not a parameter of the class?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

ref_side_index is an attribute of the parent class, as all processes need this parameter. notice JackRafterCut knows nothing about it, it gets it as a kwarg and passes it through to the parent in its call to super().__init__().


@staticmethod
def _calculate_orientation(ref_side, cutting_plane):
# orientation is START if cutting plane normal points towards the start of the beam and END otherwise
# essentially if the start is being cut or the end
if is_point_behind_plane(ref_side.point, cutting_plane):
return OrientationType.END
else:
return OrientationType.START

@staticmethod
def _calculate_angle(ref_side, plane, orientation):
# vector rotation direction of the plane's normal in the vertical direction
angle_vector = Vector.cross(ref_side.zaxis, plane.normal)
angle = angle_vectors_signed(ref_side.xaxis, angle_vector, ref_side.zaxis, deg=True)
if orientation == OrientationType.START:
return 180 - abs(angle) # get the other side of the angle
else:
return abs(angle)

@staticmethod
def _calculate_inclination(ref_side, plane, orientation):
# vector rotation direction of the plane's normal in the horizontal direction
inclination_vector = Vector.cross(ref_side.yaxis, plane.normal)
inclination = angle_vectors_signed(ref_side.xaxis, inclination_vector, ref_side.yaxis, deg=True)
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This was a problem with the equivalent drilling module because it was returning values out of range. you could consider using the angle_planes() module or angle_vectors() with the plane normals.

if orientation == OrientationType.START:
return 180 - abs(inclination) # get the other side of the angle
else:
return abs(inclination)

########################################################################
# Methods
########################################################################

def apply(self, geometry, beam):
"""Apply the feature to the beam geometry.

Parameters
----------
geometry : :class:`~compas.geometry.Brep`
The beam geometry to be cut.
beam : :class:`compas_timber.elements.Beam`
The beam that is cut by this instance.

Raises
------
:class:`~compas_timber.elements.FeatureApplicationError`
If the cutting plane does not intersect with beam geometry.

Returns
-------
:class:`~compas.geometry.Brep`
The resulting geometry after processing

"""
# type: (Brep, Beam) -> Brep
cutting_plane = self.plane_from_params_and_beam(beam)
try:
return geometry.trimmed(cutting_plane)
except BrepTrimmingError:
raise FeatureApplicationError(
cutting_plane,
geometry,
"The cutting plane does not intersect with beam geometry.",
)

def plane_from_params_and_beam(self, beam):
"""Calculates the cutting plane from the machining parameters in this instance and the given beam

Parameters
----------
beam : :class:`compas_timber.elements.Beam`
The beam that is cut by this instance.

Returns
-------
:class:`compas.geometry.Plane`
The cutting plane.

"""
# type: (Beam) -> Plane
assert self.angle is not None
assert self.inclination is not None

# start with a plane aligned with the ref side but shifted to the start_x of the cut
ref_side = beam.side_as_surface(self.ref_side_index)
p_origin = ref_side.point_at(self.start_x, 0.0)
cutting_plane = Frame(p_origin, ref_side.frame.xaxis, ref_side.frame.yaxis)

# normal pointing towards xaxis so just need the delta
horizontal_angle = math.radians(self.angle - 90)
rot_a = Rotation.from_axis_and_angle(cutting_plane.zaxis, horizontal_angle, point=p_origin)

# normal pointing towards xaxis so just need the delta
vertical_angle = math.radians(self.inclination - 90)
rot_b = Rotation.from_axis_and_angle(cutting_plane.yaxis, vertical_angle, point=p_origin)

cutting_plane.transform(rot_a * rot_b)
# for simplicity, we always start with normal pointing towards xaxis.
# if start is cut, we need to flip the normal
if self.orientation == OrientationType.END:
plane_normal = cutting_plane.xaxis
else:
plane_normal = -cutting_plane.xaxis
return Plane(cutting_plane.point, plane_normal)
12 changes: 6 additions & 6 deletions src/compas_timber/connections/l_miter.py
Copy link
Contributor

@papachap papachap Jul 19, 2024

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

With these changes does the l_mitter factory module gets removed?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

yup. that's the intention. LMiterJoint becomes the de-facto factory as it directly assigns the processes/features to the respective beams.

Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@
from compas.geometry import Vector
from compas.geometry import cross_vectors

from compas_timber.elements.features import CutFeature
from compas_timber._fabrication import JackRafterCut
from compas_timber.utils import intersection_line_line_3D

from .joint import BeamJoinningError
Expand Down Expand Up @@ -63,7 +63,6 @@ def get_cutting_planes(self):
assert self.beam_a and self.beam_b
vA = Vector(*self.beam_a.frame.xaxis) # frame.axis gives a reference, not a copy
vB = Vector(*self.beam_b.frame.xaxis)

# intersection point (average) of both centrelines
[pxA, tA], [pxB, tB] = intersection_line_line_3D(
self.beam_a.centerline,
Expand Down Expand Up @@ -124,10 +123,11 @@ def add_features(self):

self.beam_a.add_blank_extension(start_a, end_a, self.guid)
self.beam_b.add_blank_extension(start_b, end_b, self.guid)
f1, f2 = CutFeature(plane_a), CutFeature(plane_b)
self.beam_a.add_features(f1)
self.beam_b.add_features(f2)
self.features = [f1, f2]
cut1 = JackRafterCut.from_plane_and_beam(plane_a, self.beam_a)
cut2 = JackRafterCut.from_plane_and_beam(plane_b, self.beam_b)
self.beam_a.add_features(cut1)
self.beam_b.add_features(cut2)
self.features = [cut1, cut2]

def restore_beams_from_keys(self, model):
"""After de-serialization, restores references to the main and cross beams saved in the model."""
Expand Down
Loading
Loading