Skip to main content

carapace_core/model/
transform.rs

1use nalgebra::{Matrix3, SMatrix, SVector, Vector3};
2
3use super::{Node, Node3, SpatialElementMatrix};
4
5/// Coordinate-transformation strategy for planar frame elements: relates
6/// an element's local (basic) stiffness/forces to the global system. The
7/// co-rotational option follows the deformed chord and removes rigid-body
8/// translation/rotation from the element's basic deformations.
9///
10/// `Linear` and `PDelta` select the existing small-displacement paths.
11/// `Corotational2d` centralizes the current-chord kinematics used by the
12/// planar elastic, displacement-based, and force-based frame elements.
13/// `GeomTransf3` (below) remains a separate spatial type.
14#[derive(Debug, Clone, Copy, PartialEq, Eq)]
15pub enum GeomTransf {
16    /// Small-displacement: local/global relationship fixed at the element's
17    /// original (undeformed) orientation, no correction for the effect of
18    /// axial force on bending stiffness.
19    Linear,
20    /// Same small-displacement geometry as `Linear`, plus a linearized
21    /// geometric-stiffness correction from the current axial force (a
22    /// first-order P-Delta effect) — not full corotational tracking.
23    PDelta,
24    /// Finite-rotation planar transformation. The current chord defines the
25    /// element axes; axial extension and end rotations relative to that
26    /// chord are the three objective basic deformations.
27    Corotational,
28}
29
30/// Current co-rotational kinematics for one planar two-node frame member.
31/// Basic displacement order is `[axial extension, theta_i - chord_rotation,
32/// theta_j - chord_rotation]`, matching Xara/OpenSees `CorotCrdTransf2d`.
33#[derive(Debug, Clone, Copy)]
34pub(crate) struct Corotational2d {
35    initial_length: f64,
36    deformed_length: f64,
37    basic: SVector<f64, 3>,
38    /// Derivative of basic deformations with respect to
39    /// `[ux_i, uy_i, rz_i, ux_j, uy_j, rz_j]`.
40    jacobian: SMatrix<f64, 3, 6>,
41    /// Block-diagonal global-to-current-local rotation, used for local loads.
42    global_to_local: SMatrix<f64, 6, 6>,
43    /// Current chord direction in global coordinates.
44    direction: SVector<f64, 2>,
45}
46
47impl Corotational2d {
48    pub(crate) fn new(node_i: &Node, node_j: &Node) -> Self {
49        let reference_dx = node_j.coords[0] - node_i.coords[0];
50        let reference_dy = node_j.coords[1] - node_i.coords[1];
51        let initial_length = reference_dx.hypot(reference_dy);
52        assert!(
53            initial_length > 0.0,
54            "Corotational2d: element endpoints must not coincide"
55        );
56        let reference_angle = reference_dy.atan2(reference_dx);
57
58        let dx = reference_dx + node_j.displacement[0] - node_i.displacement[0];
59        let dy = reference_dy + node_j.displacement[1] - node_i.displacement[1];
60        let deformed_length = dx.hypot(dy);
61        assert!(
62            deformed_length > 1e-12,
63            "Corotational2d: deformed element length must be nonzero"
64        );
65
66        let direction = SVector::<f64, 2>::new(dx / deformed_length, dy / deformed_length);
67        let relative_angle = dy.atan2(dx) - reference_angle;
68        let chord_rotation = relative_angle.sin().atan2(relative_angle.cos());
69        let basic = SVector::<f64, 3>::new(
70            deformed_length - initial_length,
71            node_i.displacement[2] - chord_rotation,
72            node_j.displacement[2] - chord_rotation,
73        );
74
75        // d(theta_chord)/d(relative_position) = [-dy, dx] / length^2.
76        let inv_l2 = 1.0 / (deformed_length * deformed_length);
77        let alpha_dx = -dy * inv_l2;
78        let alpha_dy = dx * inv_l2;
79        let mut jacobian = SMatrix::<f64, 3, 6>::zeros();
80        jacobian[(0, 0)] = -direction[0];
81        jacobian[(0, 1)] = -direction[1];
82        jacobian[(0, 3)] = direction[0];
83        jacobian[(0, 4)] = direction[1];
84        for row in [1, 2] {
85            jacobian[(row, 0)] = alpha_dx;
86            jacobian[(row, 1)] = alpha_dy;
87            jacobian[(row, 3)] = -alpha_dx;
88            jacobian[(row, 4)] = -alpha_dy;
89        }
90        jacobian[(1, 2)] = 1.0;
91        jacobian[(2, 5)] = 1.0;
92
93        let cx = direction[0];
94        let cy = direction[1];
95        #[rustfmt::skip]
96        let block = SMatrix::<f64, 3, 3>::new(
97             cx,  cy, 0.0,
98            -cy,  cx, 0.0,
99            0.0, 0.0, 1.0,
100        );
101        let mut global_to_local = SMatrix::<f64, 6, 6>::zeros();
102        global_to_local
103            .fixed_view_mut::<3, 3>(0, 0)
104            .copy_from(&block);
105        global_to_local
106            .fixed_view_mut::<3, 3>(3, 3)
107            .copy_from(&block);
108
109        Self {
110            initial_length,
111            deformed_length,
112            basic,
113            jacobian,
114            global_to_local,
115            direction,
116        }
117    }
118
119    pub(crate) fn initial_length(&self) -> f64 {
120        self.initial_length
121    }
122
123    pub(crate) fn basic_deformation(&self) -> SVector<f64, 3> {
124        self.basic
125    }
126
127    /// Map work-conjugate basic forces into the global nodal residual.
128    pub(crate) fn global_resistance(&self, basic_force: &SVector<f64, 3>) -> SVector<f64, 6> {
129        self.jacobian.transpose() * basic_force
130    }
131
132    /// Same nodal force as `global_resistance`, expressed in the *current*
133    /// (deformed-chord) local frame instead of global coordinates — the
134    /// corotational counterpart of `Linear`/`PDelta`'s fixed-orientation
135    /// `t * r_global` recovery, used for element-force recording so a
136    /// corotational member reports axial/shear/moment against its own
137    /// current axis, not raw global components.
138    pub(crate) fn local_resistance(&self, basic_force: &SVector<f64, 3>) -> SVector<f64, 6> {
139        self.global_to_local * self.global_resistance(basic_force)
140    }
141
142    /// Consistent tangent: `Bᵀ k_basic B` plus the geometric Hessian of the
143    /// basic deformation map (axial-force and end-moment contributions).
144    pub(crate) fn global_tangent(
145        &self,
146        basic_tangent: &SMatrix<f64, 3, 3>,
147        basic_force: &SVector<f64, 3>,
148    ) -> SMatrix<f64, 6, 6> {
149        let mut tangent = self.jacobian.transpose() * basic_tangent * self.jacobian;
150        let dx = self.direction[0] * self.deformed_length;
151        let dy = self.direction[1] * self.deformed_length;
152        let l = self.deformed_length;
153        let inv_l2 = 1.0 / (l * l);
154        let inv_l4 = inv_l2 * inv_l2;
155
156        let h_length =
157            (SMatrix::<f64, 2, 2>::identity() - self.direction * self.direction.transpose()) / l;
158        let h_angle = SMatrix::<f64, 2, 2>::new(
159            2.0 * dx * dy * inv_l4,
160            (dy * dy - dx * dx) * inv_l4,
161            (dy * dy - dx * dx) * inv_l4,
162            -2.0 * dx * dy * inv_l4,
163        );
164        let h_relative = basic_force[0] * h_length - (basic_force[1] + basic_force[2]) * h_angle;
165        for i in 0..2 {
166            for j in 0..2 {
167                tangent[(i, j)] += h_relative[(i, j)];
168                tangent[(i, j + 3)] -= h_relative[(i, j)];
169                tangent[(i + 3, j)] -= h_relative[(i, j)];
170                tangent[(i + 3, j + 3)] += h_relative[(i, j)];
171            }
172        }
173        tangent
174    }
175
176    /// Rotation from global components into the current (deformed-chord)
177    /// local frame; its transpose maps a follower load given in that frame
178    /// (integrated over the undeformed length, like the reference
179    /// formulation) back to global.
180    pub(crate) fn global_to_local(&self) -> &SMatrix<f64, 6, 6> {
181        &self.global_to_local
182    }
183
184    /// Local nodal modes for the three basic deformations, used to integrate
185    /// displacement-based fiber section response in the objective basic system.
186    pub(crate) fn local_basic_modes() -> SMatrix<f64, 6, 3> {
187        #[rustfmt::skip]
188        let modes = SMatrix::<f64, 6, 3>::from_row_slice(&[
189            0.0, 0.0, 0.0,
190            0.0, 0.0, 0.0,
191            0.0, 1.0, 0.0,
192            1.0, 0.0, 0.0,
193            0.0, 0.0, 0.0,
194            0.0, 0.0, 1.0,
195        ]);
196        modes
197    }
198}
199
200/// `GeomTransf`'s spatial counterpart: builds a member's local basis and
201/// local↔global rotation from its two end-node coordinates and an
202/// orientation vector (`vec_xz`): `x = normalize(pJ - pI)`, `y =
203/// normalize(vec_xz × x)`, `z = x × y`). Unlike planar `GeomTransf`, this
204/// isn't just a behavior flag: the local-axis construction is real,
205/// nontrivial, and shared by every spatial frame element (`ElasticBeamColumn3`
206/// today; `DispBeamColumn3`/`ForceBeamColumn3` will reuse it once they
207/// exist), so it's a real type rather than something each element inlines a
208/// copy of. Verified against Xara's `LinearCrdTransf3d`.
209///
210/// `Linear3` and `PDelta3` are both "cheap" per the same reasoning as
211/// planar `GeomTransf`'s doc comment — full spatial corotational geometry
212/// is not yet supported, so it is not a third variant here.
213#[derive(Debug, Clone, Copy, PartialEq)]
214pub enum GeomTransf3 {
215    /// Small-displacement: local/global relationship fixed at the member's
216    /// original (undeformed) orientation, no correction for the effect of
217    /// axial force on bending stiffness.
218    Linear3 {
219        /// A vector not parallel to the member axis, fixing the local y/z
220        /// orientation (roll) — not a physical direction. E.g. `[0.0, 0.0,
221        /// 1.0]` (global z) for a horizontal member picks local z as the
222        /// in-plane bending axis for gravity loads.
223        vec_xz: [f64; 3],
224    },
225    /// Same small-displacement geometry as `Linear3`, plus a linearized
226    /// geometric-stiffness correction from the current axial force in
227    /// *both* bending planes (a first-order P-Delta effect) — not full
228    /// corotational tracking. See `ElasticBeamColumn3::geometric_stiffness`
229    /// for the actual correction; this variant only selects it.
230    PDelta3 { vec_xz: [f64; 3] },
231}
232
233impl GeomTransf3 {
234    pub fn linear(vec_xz: [f64; 3]) -> Self {
235        GeomTransf3::Linear3 { vec_xz }
236    }
237
238    pub fn p_delta(vec_xz: [f64; 3]) -> Self {
239        GeomTransf3::PDelta3 { vec_xz }
240    }
241
242    /// Member length and the local-axis rotation `R` (rows = local x/y/z
243    /// unit vectors, each expressed in global coordinates), so `R *
244    /// global_vec` is that same vector in local coordinates.
245    pub(crate) fn local_axes(&self, node_i: &Node3, node_j: &Node3) -> (f64, Matrix3<f64>) {
246        let (GeomTransf3::Linear3 { vec_xz } | GeomTransf3::PDelta3 { vec_xz }) = self;
247
248        let p_i = Vector3::from(node_i.coords);
249        let p_j = Vector3::from(node_j.coords);
250        let d = p_j - p_i;
251        let length = d.norm();
252        assert!(
253            length > 0.0,
254            "GeomTransf3: element endpoints must not coincide"
255        );
256        let x_axis = d / length;
257
258        let v = Vector3::from(*vec_xz);
259        let y_unnormalized = v.cross(&x_axis);
260        let y_norm = y_unnormalized.norm();
261        assert!(
262            y_norm > 1e-12,
263            "GeomTransf3: vec_xz must not be parallel to the member axis"
264        );
265        let y_axis = y_unnormalized / y_norm;
266        let z_axis = x_axis.cross(&y_axis);
267
268        #[rustfmt::skip]
269        let r = Matrix3::new(
270            x_axis[0], x_axis[1], x_axis[2],
271            y_axis[0], y_axis[1], y_axis[2],
272            z_axis[0], z_axis[1], z_axis[2],
273        );
274        (length, r)
275    }
276
277    /// Block-diagonal rotation from global to local DOFs — four copies of
278    /// `r` (translation@i, rotation@i, translation@j, rotation@j).
279    pub(crate) fn rotation_matrix(r: &Matrix3<f64>) -> SpatialElementMatrix {
280        let mut t = SpatialElementMatrix::zeros();
281        for offset in [0, 3, 6, 9] {
282            t.fixed_view_mut::<3, 3>(offset, offset).copy_from(r);
283        }
284        t
285    }
286}
287
288#[cfg(test)]
289mod geom_transf3_tests {
290    use super::*;
291
292    #[test]
293    fn axis_aligned_member_with_z_up_gives_identity_rotation() {
294        let node_i = Node3::new([0.0, 0.0, 0.0]);
295        let node_j = Node3::new([5.0, 0.0, 0.0]);
296        let transform = GeomTransf3::linear([0.0, 0.0, 1.0]);
297        let (length, r) = transform.local_axes(&node_i, &node_j);
298        assert_eq!(length, 5.0);
299        assert!((r - Matrix3::identity()).norm() < 1e-12);
300    }
301
302    #[test]
303    #[should_panic(expected = "must not coincide")]
304    fn coincident_endpoints_panics() {
305        let node = Node3::new([1.0, 2.0, 3.0]);
306        GeomTransf3::linear([0.0, 0.0, 1.0]).local_axes(&node, &node);
307    }
308
309    #[test]
310    #[should_panic(expected = "must not be parallel")]
311    fn vec_xz_parallel_to_axis_panics() {
312        let node_i = Node3::new([0.0, 0.0, 0.0]);
313        let node_j = Node3::new([5.0, 0.0, 0.0]);
314        GeomTransf3::linear([1.0, 0.0, 0.0]).local_axes(&node_i, &node_j);
315    }
316}