A COMPAS-based solver for generating reciprocal frame structures from meshes.
Based on and inspired by the Future tree and the algorithms by Aleksandra Anna Apolinarska (ETH Zurich).
graph TD
subgraph Input [INPUT]
Mesh[COMPAS Mesh <br/> triangles]
Params[Parameters: <br/> xi, weights, srf]
end
subgraph RF [ReciprocalFrame Graph]
Nodes["Nodes = Beams <br/> key: (u, v) <br/> beam: COMPAS Line"]
Edges["Edges = Connections <br/> connections: [{'ends': (t1, t2), 'face': fkey, 'xi': val}]"]
end
subgraph Solver [SOLVER scipy.least_squares]
Detect[1. Build vectorized index arrays]
Sparsity["2. Build sparse Jacobian pattern (n>50)"]
Opt[3. Minimize weighted residuals]
ResWT[wt: reciprocal target]
ResWE["we: eccentricity (min separation)"]
ResWF[wf: fidelity]
ResWS[ws: surface projection]
Detect --> Sparsity --> Opt
Opt --- ResWT & ResWE & ResWF & ResWS
end
subgraph Output [OUTPUT]
OutGraph["rf: ReciprocalFrame Graph <br/> (solved positions)"]
OutLines["lines: List(COMPAS Line)"]
OutInfo["info: Solver stats"]
end
Mesh --> RF
Params --> RF
RF --> Solver
Solver --> Output
flowchart TD
Input[COMPAS Mesh <br/>] --> Dual
subgraph Conversion [Dual Graph Conversion]
Dual[Face Centroids -> Nodes <br/> Shared Edges -> Beams]
end
Dual --> RF
subgraph DataStruct [ReciprocalFrame Class]
RF[Graph Subclass]
NodeData["Nodes: <br/> key=(u,v) <br/> beam=Line(p0,p1)"]
EdgeData["Edges: <br/> connections list <br/> {ends, face, xi}"]
RF --- NodeData
RF --- EdgeData
end
RF --> SolveStep
subgraph SolveStep [Optimization]
Solver["scipy.optimize.least_squares <br/> method='trf' or 'lm'"]
end
SolveStep --> Output[List of COMPAS Lines]
The key parameter xi (ξ) controls where beam endpoints land on their supporting beams (parameter between 0 to 1 corresponding to point along length of beam from start to midpoint):
Target point formula:
Where:
-
$\mathbf{p}_j^{ref}$ = reference endpoint of beam j (the closer one) -
$\mathbf{p}_j^{other}$ = the other endpoint of beam j $\xi \in [0, 1]$
We solve a nonlinear least-squares problem to find beam positions that satisfy reciprocal constraints:
Where
For each connection where beam i's endpoint lands on beam j:
This generates 3 residuals per connection (x, y, z components).
Physical meaning: Beam endpoints should lie on their supporting beams at the engagement position.
Prevents beams from intersecting by enforcing minimum distance:
Point-to-line distance uses clamped projection:
1 residual per connection. Default
Keeps beams close to their initial dual positions:
6 residuals per beam (all coordinates). Prevents excessive deformation.
Projects beam endpoints to a horizontal plane at height
2 residuals per beam (one per endpoint z-coordinate).
compas >= 2.0
scipy >= 1.0
numpy >= 1.20
from compas.datastructures import Mesh
from core_graph import ReciprocalFrame
# Create or load a mesh
mesh = #...
# Optional: set engagement parameter per face
for fkey in mesh.faces():
mesh.face_attribute(fkey, 'xi', 0.3)
# Build reciprocal frame from mesh
rf = ReciprocalFrame.from_mesh(mesh)
# Solve with custom weights
info = rf.solve(
weights=(1.0, 0.1, 0.1, 0.0), # (wt, we, wf, ws)
eccentricity=0.00, # min beam separation
srf=None # surface (optional)
)
print(f"Converged: {info['converged']}, Residual: {info['residual']:.6f}")
# Export geometry
beams = rf.to_lines() # beam axes as COMPAS Lines
connectors = rf.get_connection_lines() # eccentricity lines between beams
# Access individual beams
for key in rf.nodes():
line = rf.get_beam(key)
print(f"Beam {key}: {line.start} -> {line.end}")reciprocal_from_mesh/
├── __init__.py # exports ReciprocalFrame, reciprocal_from_mesh
├── core_graph.py # main implementation (ReciprocalFrame class)
├── gh_component.py # Grasshopper wrapper script
├── .gitignore
└── README.md
-
Apolinarska, A. A. (2018). Complex Timber Structures from Simple Elements: Computational Design of Novel Bar Structures and Robotic Assembly. ETH Zurich Dissertation.
-
Song, P., Fu, C. W., Goswami, P., Zheng, J., Mitra, N. J., & Cohen-Or, D. (2013). Reciprocal Frame Structures Made Easy. ACM Transactions on Graphics (SIGGRAPH).