Skip to content
Open
Show file tree
Hide file tree
Changes from 22 commits
Commits
Show all changes
32 commits
Select commit Hold shift + click to select a range
bb8abc4
Add UserReferencePlane support in BTLx processing and XML export
papachap Mar 20, 2026
d6b9aea
Add UserReferencePlane management methods to TimberElement class
papachap Mar 20, 2026
f523de8
Rename reference plane methods to user_ref_plane for clarity and cons…
papachap Mar 20, 2026
a8f1f27
Add user_plane_id to BTLxProcessing for reference plane selection
papachap Mar 20, 2026
ec42333
Update reference plane handling to use user_ref_planes in BTLxWriter …
papachap Mar 20, 2026
5997035
Refactor reference plane handling to use user_ref_planes in TimberEle…
papachap Mar 20, 2026
eef736b
Add method to construct FreeContour from polyline, reference plane, a…
papachap Mar 20, 2026
92e205c
organize params and docstrings in BTLxProcessing
papachap Mar 23, 2026
61a70a2
Add Optional type hint for get_user_ref_plane method
papachap Mar 24, 2026
ad9e3fc
wip
papachap Mar 26, 2026
58217cc
Merge branch 'main' of https://github.com/gramaziokohler/compas_timbe…
papachap Mar 26, 2026
e05ed77
use separate ref_side_index/user_plane_id attr in BTLxProcessing
papachap Mar 27, 2026
bf69228
unify ref_side_index to hold user plane IDs (>=100) directly, drop us…
papachap Mar 27, 2026
aec9d1b
fix transformation logic in BTLxPart and FreeContour classes for user…
papachap Apr 2, 2026
88effa3
move UserReferencePlane class to base from the fabrication package an…
papachap Jul 17, 2026
3a6232d
add tests for user reference plane functionality in Beam and BTLx int…
papachap Jul 17, 2026
385bace
Merge branch 'main' of https://github.com/gramaziokohler/compas_timbe…
papachap Jul 17, 2026
41a6923
changelog
papachap Jul 17, 2026
e036a61
revert changes to free_contour
papachap Jul 17, 2026
5eecc89
refactor: remove unused CutPlaneSpec and MiterPlaneSpec classes; upda…
papachap Jul 17, 2026
638c278
update tests
papachap Jul 17, 2026
319a218
changelog
papachap Jul 17, 2026
885a5a6
typo
papachap Jul 20, 2026
9e3fdb0
typo2
papachap Jul 20, 2026
30aba2c
docstring oversight
papachap Jul 20, 2026
d5815b0
docstring oversight 2
papachap Jul 20, 2026
24c9ff2
fix: remove user_plane_id from BTLxProcessing Attributes docstring, u…
Copilot Jul 20, 2026
5f3fece
refactor: rename UserReferencePlane to UserReferencePlaneCollection a…
papachap Jul 20, 2026
93690a3
Merge branch 'main' of https://github.com/gramaziokohler/compas_timbe…
papachap Jul 20, 2026
710fb48
update changelog
papachap Jul 20, 2026
8eb7cba
docstring discrepancies
papachap Jul 20, 2026
559b9ca
fix: user_reference_planes are now relative to TimberElement.frame. t…
papachap Jul 21, 2026
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
9 changes: 9 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -25,10 +25,17 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
* Added `Joint.clear_features()` and `Joint.clear_extensions()`, which remove the features/extensions this joint previously applied to its elements. `self.features` is now initialized on all joints and is expected to hold every feature a joint applies, so that `clear_features()` can fully undo it.
* Added `TimberModel.get_joint(element_a, element_b)`, which returns the joint connecting two given elements, or `None`.
* Added `joints_to_process` parameter to `TimberModel.process_joinery()`, to process a subset of the model's joints instead of all of them.
* Added `TimberElement.user_ref_planes`, `TimberElement.add_user_ref_plane()`, `TimberElement.get_user_ref_plane()`, and `TimberElement.remove_user_ref_plane()` for attaching arbitrary named reference planes (BTLx `UserReferencePlane`, integer ID >= 100) to a beam or plate.
* Added `UserReferencePlane` data class representing a single registered reference plane.
* Added `BTLxPart.et_user_reference_planes` and wired `BTLxWriter` to emit a part's `UserReferencePlanes` XML element when any are registered on its element.
* Added mechanism in `BTLxReader` to read `UserReferencePlanes` back from BTLx XML and add them to the corresponding element.
* Added `FreeContour.from_polyline_ref_plane_and_beam()` classmethod to build a `FreeContour` processing cut relative to a custom, user-supplied reference plane instead of one of the element's standard reference sides.

### Changed
* `FeatureApplicationError` raised from `BTLxProcessing.apply()` now carries geometry in the model's global coordinate system (previously local/element space), matching errors raised elsewhere.
* Fixed a live crash (`TypeError`) and two other constructor-argument bugs on `BeamJoiningError` call sites.
* `ButtJoint` / `LButtJoint` / `TButtJoint` no longer use `butt_plane_spec` / `back_plane_spec`. They now take `butt_plane_id` (and `back_plane_id` on `LButtJoint`) that reference beam `user_ref_plane` entries.
* `LMiterJoint` no longer uses `miter_plane: MiterPlaneSpec`. It now takes `miter_plane_id`, referencing a `user_ref_plane` on `beam_a`.
* Fixed wrong `RefPosition` assigned to one beam in `LFrenchRidgeLapJoint` for 90° configurations where floating-point drift caused `_calculate_ref_position` to miss the orthogonal-connection branch (`angle == 90.0` replaced with `TOL.is_close(angle, 90.0)`). Also removed a stray `print(90)` debug statement.
* `TimberModel.remove_joint()` now calls `Joint.reset_location()`.
* `TimberModel.connect_adjacent_beams()`, `connect_adjacent_plates()`, and `connect_adjacent_panels()` now share a single `TimberModel.compute_topologies()` implementation. Joint-candidate clearing is now unconditional (all candidates, not just the connected element type) and no longer removes existing concrete joints.
Expand All @@ -43,10 +50,12 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
* Fixed `Beam.remove_blank_extension()` raising `KeyError` when called for a joint/element pair that was never extended (e.g. `ButtJoint` only extends its `main_beam`, never `cross_beam`).
* Fixed `BallNodeJoint`, `YButtJoint`, and `TOliGinaJoint` not recording all of the features they apply in `self.features`, which meant `clear_features()` (or the old per-joint clearing logic) could leave some features permanently stuck on the beams.
* Fixed `PlateJoint.clear_extensions()` resetting *all* of an element's extensions when the joint never set one (e.g. `PlateTButtJoint`'s cross plate), instead of leaving unrelated joints' extensions untouched.
* `BTLxProcessing`'s `ReferencePlaneID` header attribute (read via `HEADER_ATTRIBUTE_MAP` and written via `BTLxProcessingParams.header_attributes`) now passes user reference plane IDs (>= 100) through as-is, instead of always applying the standard side's 1-based/0-based conversion.

### Removed
* Removed depricated `features.py` module and related imports.
* Removed `test_features.py` and moved extension tests to `test_beam.py`.
* Removed `CutPlaneSpec` and `MiterPlaneSpec` APIs from the joint plane-override workflow since the `UserReferencePlane` mechanism serves for encoding arbitrary planes relative to a beam.

## [2.2.0] 2026-07-02

Expand Down
157 changes: 156 additions & 1 deletion src/compas_timber/base.py
Original file line number Diff line number Diff line change
@@ -1,6 +1,8 @@
import abc
from functools import wraps
from typing import Optional

from compas.data import Data
from compas.geometry import Frame
from compas.geometry import Line
from compas.geometry import PlanarSurface
Expand All @@ -10,6 +12,47 @@
from compas_model.elements import reset_computed


class UserReferencePlane(Data):
"""A reference plane attached to a timber element for use in BTLx processings.

``UserReferencePlane`` objects are registered on a :class:`TimberElement` via
:meth:`TimberElement.add_user_ref_plane`, which stores this ``frame`` relative
to the element's ``ref_frame`` (BTLx's ``PartRef``: "The ReferencePlane refers to the PartRef").
Use :meth:`TimberElement.get_user_ref_plane` to resolve it to model (world) coordinates.

Parameters
----------
frame : :class:`compas.geometry.Frame`
The plane expressed relative to the owning element's ``ref_frame``.
ID : int
The BTLx integer ID for this plane. Must be an integer >= 100.

Attributes
----------
frame : :class:`compas.geometry.Frame`
The plane relative to the owning element's ``ref_frame``.
ID : int
The BTLx integer ID of this plane.

"""

def __init__(self, frame: Frame, ID: int):
super(UserReferencePlane, self).__init__()
if type(ID) is not int:
Comment thread
papachap marked this conversation as resolved.
Outdated
raise TypeError("BTLx reference plane IDs must be integers.")
if ID < 100:
raise ValueError("BTLx reference plane IDs must be >= 100.")
self.frame = frame
self.ID = ID

def __repr__(self):
return "UserReferencePlane(ID={!r}, frame={!r})".format(self.ID, self.frame)

@property
def __data__(self):
return {"frame": self.frame, "ID": self.ID}


def reset_timber_attrs(f):
"""Decorator to reset cached timber-specific attributes."""

Expand All @@ -32,7 +75,7 @@ class TimberElement(Element, abc.ABC):
Parameters
----------
frame : :class:`compas.geometry.Frame`, optional
The frame representing the beam's local coordinate system in its hierarchical context.
The frame representing the elements's local coordinate system in its hierarchical context.
Comment thread
Copilot marked this conversation as resolved.
Outdated
Comment thread
Copilot marked this conversation as resolved.
Outdated
Defaults to ``None``, in which case the world coordinate system is used.
length : float
Length of the timber element.
Expand Down Expand Up @@ -383,3 +426,115 @@ def get_dimensions_relative_to_side(self, ref_side_index):
if ref_side_index in [1, 3]:
return self.height, self.width
return self.width, self.height

########################################################################
# User Reference Planes
########################################################################

@property
def user_ref_planes(self):
"""User reference planes attached to this element.

These correspond to the BTLx ``UserReferencePlane`` concept. The BTLx
integer ``ID`` is the zero-based insertion index of the plane plus 100,
so the first plane gets ID 100, second 101, and so on.
Comment thread
papachap marked this conversation as resolved.
Outdated

Each plane's ``frame`` is stored relative to :attr:`ref_frame` (BTLx's ``PartRef``,
per the BTLx spec: "The ReferencePlane refers to the PartRef"), the same coordinate
system BTLx processing parameters are defined in when a custom reference plane is declared.

Use :meth:`add_user_ref_plane` to add a plane in model (world) coordinates, and
:meth:`get_user_ref_plane` to resolve a plane back to model (world) coordinates.

Returns
-------
list[:class:`UserReferencePlane`]
"""
return self.attributes.get("user_ref_planes", [])
Comment thread
papachap marked this conversation as resolved.
Outdated

def _register_user_ref_plane(self, local_frame: Frame, ID: int = None) -> int:
Comment thread
papachap marked this conversation as resolved.
Outdated
"""Store a reference plane already expressed relative to :attr:`ref_frame`.

Used internally by :meth:`add_user_ref_plane` (which converts from model coordinates first)
and by the BTLx reader (which parses planes already in ref_frame-local coordinates, so it can
register them directly without a needless local -> world -> local round trip).

Parameters
----------
local_frame : :class:`compas.geometry.Frame`
The plane expressed relative to :attr:`ref_frame`.
ID : int, optional
The BTLx integer ID to assign to this plane. See :meth:`add_user_ref_plane`.

Returns
-------
int
The BTLx integer ID assigned to this plane (>= 100).

"""
if ID is not None:
if any(ID == p.ID for p in self.attributes.get("user_ref_planes", [])):
raise ValueError("A reference plane with ID {} already exists. Call remove_user_ref_plane first.".format(ID))
else:
ID = len(self.attributes.get("user_ref_planes", [])) + 100

self.attributes.setdefault("user_ref_planes", []).append(UserReferencePlane(frame=local_frame, ID=ID))
return ID
Comment thread
papachap marked this conversation as resolved.
Outdated
Comment thread
papachap marked this conversation as resolved.
Outdated

def add_user_ref_plane(self, frame: Frame, ID: int = None) -> int:
Comment thread
papachap marked this conversation as resolved.
Outdated

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

we should probably guard here against planes which are completely unrelated to the beam. like a plane whose origin is on the other side of the scene

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Hmm yeah it makes sense.. But I am wondering if add_user_ref_plane is the best spot for this. It's generic storage with no idea what the plane will be used for, so there's no principled distance threshold to check against.

The BTLx processing itself will throw a ValueError if the geometry can't be used, or a joint using that plane would have its own lazy checks against it. Maybe that's enough already?

Happy to hear your ideas on it.

"""Add a named reference plane to this element.

The BTLx ``ID`` is assigned as the current number of registered planes
plus 100 (first plane → 100, second → 101, …).

Parameters
----------
frame : :class:`compas.geometry.Frame`
The plane expressed in model (world) coordinates. It is converted to and stored relative
to :attr:`ref_frame` (BTLx's ``PartRef``), matching how BTLx itself defines a custom
reference plane.
ID : int, optional
The BTLx integer ID to assign to this plane. This should be a unique integer >= 100.
If None, the ID will be assigned as the current number of registered planes plus 100 (first plane → 100, second → 101, …).

Returns
-------
int
The BTLx integer ID assigned to this plane (>= 100).

"""
local_frame = frame.transformed(Transformation.from_frame(self.ref_frame).inverted())
return self._register_user_ref_plane(local_frame, ID)

def get_user_ref_plane(self, ID: int) -> Optional[Frame]:
"""Retrieve the frame of a reference plane stored under ``ID``.

The returned frame is transformed to model coordinates, so it can be used directly in the model space.

Parameters
----------
ID : int
The BTLx integer ID of the reference plane to retrieve.

Returns
-------
:class:`compas.geometry.Frame` or None
The frame of the reference plane with the given ID, transformed to model coordinates,
or None if no such plane exists.
"""
planes = self.attributes.get("user_ref_planes", [])
for plane in planes:
if plane.ID == ID:
return plane.frame.transformed(Transformation.from_frame(self.ref_frame))
return None

def remove_user_ref_plane(self, ID: int):
"""Remove the reference plane stored under ``ID``.

Parameters
----------
ID : int
The BTLx integer ID of the reference plane to remove.
"""
planes = self.attributes.get("user_ref_planes", [])
self.attributes["user_ref_planes"] = [p for p in planes if p.ID != ID]
48 changes: 48 additions & 0 deletions src/compas_timber/btlx/reader.py
Original file line number Diff line number Diff line change
Expand Up @@ -191,6 +191,9 @@ def _parse_part(self, part_element):
element.name = annotation
element.attributes["single_member_number"] = single_member_number

# Parse UserReferencePlanes
self._parse_user_reference_planes(part_element, element)

# Parse Processings
self._parse_processings(part_element, element)
return element
Expand Down Expand Up @@ -296,6 +299,51 @@ def _convert_value(value, type_info):
# Handle standard types (int, float, str)
return type_info(value)

def _parse_user_reference_planes(self, part_elem, element):
"""Parse the UserReferencePlanes XML element and add planes to the element.

Planes are stored in ref_frame-local coordinates in the BTLx file, which is the same
coordinate system :class:`~compas_timber.base.TimberElement` stores them in, so they are
registered directly without converting to world coordinates and back.
"""
planes_elem = part_elem.find("{*}UserReferencePlanes")
if planes_elem is None:
return

for plane_elem in planes_elem.findall("{*}UserReferencePlane"):
try:
plane_id = int(plane_elem.get("ID"))
position = plane_elem.find("{*}Position")
ref_point = position.find("{*}ReferencePoint")
x_vector = position.find("{*}XVector")
y_vector = position.find("{*}YVector")

local_point = Point(
float(ref_point.get("X")),
float(ref_point.get("Y")),
float(ref_point.get("Z")),
)
xaxis = Vector(
float(x_vector.get("X")),
float(x_vector.get("Y")),
float(x_vector.get("Z")),
)
yaxis = Vector(
float(y_vector.get("X")),
float(y_vector.get("Y")),
float(y_vector.get("Z")),
)

local_frame = Frame(local_point, xaxis, yaxis)
element._register_user_ref_plane(local_frame, ID=plane_id)
except Exception as e:
self._errors.append(
BTLxParsingError(
"Failed to parse UserReferencePlane ID={}: {}".format(plane_elem.get("ID", "?"), e),
part_id=element.attributes.get("single_member_number"),
)
)

def _parse_transformation(self, part_elem):
"""Extract GUID and Frame from a Part's Transformation element."""
# Find Transformation element (wildcard namespace)
Expand Down
4 changes: 0 additions & 4 deletions src/compas_timber/connections/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,6 @@
from .l_butt import LButtJoint
from .l_lap import LLapJoint
from .l_miter import LMiterJoint
from .l_miter import MiterPlaneSpec
from .l_french_ridge_lap import LFrenchRidgeLapJoint
from .lap_joint import LapJoint
from .joint_candidate import JointCandidate
Expand All @@ -27,7 +26,6 @@
from .ball_node import BallNodeJoint
from .y_butt import YButtJoint
from .oligina import TOliGinaJoint
from .butt_joint import CutPlaneSpec
from .utilities import beam_ref_side_incidence
from .utilities import beam_ref_side_incidence_with_vector
from .utilities import point_centerline_towards_joint
Expand Down Expand Up @@ -76,8 +74,6 @@
"LTenonMortiseJoint",
"YButtJoint",
"TOliGinaJoint",
"CutPlaneSpec",
"MiterPlaneSpec",
"beam_ref_side_incidence",
"beam_ref_side_incidence_with_vector",
"point_centerline_towards_joint",
Expand Down
Loading
Loading