Skip to main content

carapace_core/model/elements/
zero_length.rs

1use nalgebra::{Matrix3, SMatrix, SVector, Vector3};
2
3use super::super::{
4    DofMask, FiberSection, FiberSection3, Material, Node, Node3, Node3Id, NodeId, ELEMENT_DOF, NDF,
5    SPATIAL_NDF,
6};
7use super::truss::{SpatialElementMatrix, SpatialElementVector};
8
9/// Local axes of a zero-length element, built the OpenSees way. In 3D that
10/// is `-orient x1 x2 x3 yp1 yp2 yp3`: local x is `x`, local z is `x × yp`, and
11/// local y is `z × x`. In 2D OpenSees takes only `x` (see `in_plane`) and
12/// derives y by a 90 degree turn. Every per-DOF material (and a section's axial and
13/// flexural directions) is then evaluated on the relative displacement and
14/// rotation *projected onto these axes* rather than the global ones.
15/// Without an orientation the local axes are the global axes.
16///
17/// Translations and rotations share one frame, as in OpenSees: local `rz`
18/// is the rotation about the local z axis, and so on.
19#[derive(Debug, Clone, Copy, PartialEq)]
20pub struct Orientation {
21    /// Rows are the local x, y, z axes in global coordinates.
22    rotation: Matrix3<f64>,
23}
24
25#[derive(Debug, Clone, Copy, PartialEq, Eq)]
26pub enum OrientationError {
27    /// `x` is zero, or `yp` is zero or parallel to `x`, so the frame is undefined.
28    Degenerate,
29    /// A 2D element's local x and y must lie in the model (xy) plane (a 3D
30    /// frame handed to a 2D element that leaves it).
31    OutOfPlane,
32}
33
34impl std::fmt::Display for OrientationError {
35    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
36        match self {
37            OrientationError::Degenerate => {
38                write!(
39                    f,
40                    "orient vectors are zero or parallel, so the local frame is undefined"
41                )
42            }
43            OrientationError::OutOfPlane => {
44                write!(
45                    f,
46                    "orient vectors must lie in the xy plane for a 2D element"
47                )
48            }
49        }
50    }
51}
52
53impl std::error::Error for OrientationError {}
54
55impl Orientation {
56    pub fn global() -> Self {
57        Orientation {
58            rotation: Matrix3::identity(),
59        }
60    }
61
62    pub fn new(x: [f64; 3], yp: [f64; 3]) -> Result<Self, OrientationError> {
63        let x = Vector3::from(x);
64        let yp = Vector3::from(yp);
65        let z = x.cross(&yp);
66        // Relative tolerance: parallel vectors give a cross product that is
67        // zero only up to rounding.
68        if x.norm() == 0.0 || z.norm() <= 1e-12 * x.norm() * yp.norm() {
69            return Err(OrientationError::Degenerate);
70        }
71        let ex = x.normalize();
72        let ez = z.normalize();
73        let ey = ez.cross(&ex);
74        Ok(Orientation {
75            rotation: Matrix3::from_rows(&[ex.transpose(), ey.transpose(), ez.transpose()]),
76        })
77    }
78
79    /// OpenSees' 2D `-orient x1 x2`: local x is `(x1, x2)` and local y is that
80    /// vector turned 90 degrees counter-clockwise, so the frame is always
81    /// right-handed about z and local `rz` is global `rz`.
82    pub fn in_plane(x1: f64, x2: f64) -> Result<Self, OrientationError> {
83        Self::new([x1, x2, 0.0], [-x2, x1, 0.0])
84    }
85
86    /// The 6x6 (3D) map from global relative `[ux, uy, uz, rx, ry, rz]` to
87    /// the same quantities along the local axes.
88    fn frame3(&self) -> SMatrix<f64, SPATIAL_NDF, SPATIAL_NDF> {
89        let mut t = SMatrix::<f64, SPATIAL_NDF, SPATIAL_NDF>::zeros();
90        t.fixed_view_mut::<3, 3>(0, 0).copy_from(&self.rotation);
91        t.fixed_view_mut::<3, 3>(3, 3).copy_from(&self.rotation);
92        t
93    }
94
95    /// The 3x3 (2D) map from global relative `[ux, uy, rz]` to local. The
96    /// local x and y axes must lie in the xy plane; local `rz` then equals
97    /// global `rz`, or `-rz` for a left-handed 3D frame (which `in_plane`
98    /// never produces).
99    fn frame2(&self) -> Result<SMatrix<f64, NDF, NDF>, OrientationError> {
100        const TOL: f64 = 1e-9;
101        let r = &self.rotation;
102        if r[(0, 2)].abs() > TOL || r[(1, 2)].abs() > TOL {
103            return Err(OrientationError::OutOfPlane);
104        }
105        Ok(SMatrix::<f64, NDF, NDF>::new(
106            r[(0, 0)],
107            r[(0, 1)],
108            0.0,
109            r[(1, 0)],
110            r[(1, 1)],
111            0.0,
112            0.0,
113            0.0,
114            r[(2, 2)],
115        ))
116    }
117}
118
119/// Relative displacement of `dof` along its local axis: row `dof` of the
120/// frame applied to `uj - ui`.
121fn local_relative<const N: usize>(
122    frame: &SMatrix<f64, N, N>,
123    dof: usize,
124    ui: &[f64; N],
125    uj: &[f64; N],
126) -> f64 {
127    (0..N).map(|c| frame[(dof, c)] * (uj[c] - ui[c])).sum()
128}
129
130/// Node slots touched by local direction `dof`: the nonzero entries of its
131/// frame row (round-off from an orientation is not a coupling).
132fn frame_row_mask<const N: usize>(frame: &SMatrix<f64, N, N>, dof: usize) -> DofMask {
133    (0..N)
134        .filter(|&slot| frame[(dof, slot)].abs() > 1e-12)
135        .fold(DofMask::none(), |mask, slot| mask.with(slot))
136}
137
138fn frame_mask<const N: usize>(
139    frame: &SMatrix<f64, N, N>,
140    dofs: impl IntoIterator<Item = usize>,
141) -> DofMask {
142    dofs.into_iter().fold(DofMask::none(), |mask, dof| {
143        mask.union(frame_row_mask(frame, dof))
144    })
145}
146
147fn material_dofs<const N: usize>(
148    materials: &[Option<Material>; N],
149) -> impl Iterator<Item = usize> + '_ {
150    materials
151        .iter()
152        .enumerate()
153        .filter_map(|(dof, m)| m.is_some().then_some(dof))
154}
155
156/// Strain-displacement row for local `dof`: `-row` on node i's DOFs and
157/// `+row` on node j's, so `row · [ui; uj]` is `local_relative`.
158fn dof_row_2d(frame: &SMatrix<f64, NDF, NDF>, dof: usize) -> SVector<f64, ELEMENT_DOF> {
159    let mut b = SVector::<f64, ELEMENT_DOF>::zeros();
160    for c in 0..NDF {
161        b[c] = -frame[(dof, c)];
162        b[NDF + c] = frame[(dof, c)];
163    }
164    b
165}
166
167fn dof_row_3d(
168    frame: &SMatrix<f64, SPATIAL_NDF, SPATIAL_NDF>,
169    dof: usize,
170) -> SVector<f64, { 2 * SPATIAL_NDF }> {
171    let mut b = SVector::<f64, { 2 * SPATIAL_NDF }>::zeros();
172    for c in 0..SPATIAL_NDF {
173        b[c] = -frame[(dof, c)];
174        b[SPATIAL_NDF + c] = frame[(dof, c)];
175    }
176    b
177}
178
179/// Couples one "shear" DOF to a *different* DOF's own material — the
180/// normal direction's current force — via a bilinear-kinematic-hardening
181/// Coulomb return map: sticks (stiffness `k0`) until `|V|` reaches
182/// `mu * yield-eligible normal force`, then slides with post-slip tangent
183/// `b*k0` (see the `b` field doc comment for why `b` is required, not
184/// optional).
185///
186/// This is deliberately *not* a `Material` variant: `Material::evaluate` is
187/// a pure function of one scalar strain (every call site — `Truss`,
188/// `ZeroLength`, `Fiber`, the `Parallel`/`Series`/`MinMax` composites —
189/// depends on that signature), and friction's yield force needs a second
190/// DOF's current force, which a single `Material` has no way to see. So the
191/// coupling lives here, in the element, not the material catalog.
192///
193/// Sign convention: this codebase is compression-negative (see
194/// `Material::Ent`/`Gap` — `strain <= 0.0` is the compression/engaged
195/// branch). Friction is mobilized only when the normal direction is in
196/// compression: yield force is `mu * (-normal_force).max(0.0)`.
197///
198/// The return map: `trial = k0*(shear_relative - slip)` is the elastic
199/// predictor (same shape as `ElasticPP`'s `trial_stress = e*(strain - ep)`,
200/// `elastic_like.rs`). Inside the yield surface, `force = trial`. Past it,
201/// this is standard 1D linear-kinematic-hardening plasticity with
202/// (fixed, for one call) yield force `yield_force` and hardening modulus
203/// chosen so the post-yield tangent is exactly `b*k0`:
204/// `force = b*trial + (1-b)*yield_force*sign(trial)`, which by construction
205/// satisfies `k0*(shear_relative - next_slip) == force` — so, exactly like
206/// `ElasticPP`'s `next_ep = strain - stress/e`, `next_slip =
207/// shear_relative - force/k0`.
208#[derive(Debug, Clone, Copy)]
209pub struct Friction {
210    pub normal_dof: usize,
211    pub shear_dof: usize,
212    pub mu: f64,
213    pub k0: f64,
214    /// Post-slip hardening ratio (fraction of `k0`) — required at
215    /// construction, not defaulted, so callers make an active choice. A
216    /// small nonzero value (recommended starting range `1e-3`-`1e-4`) keeps
217    /// the post-slip tangent away from exactly zero: while sliding, this
218    /// DOF is commonly the *only* stiffness path on its shear direction, so
219    /// an exactly-zero tangent risks a structurally singular row/column in
220    /// the global system (`AnalysisError::SingularSystem`) — the same
221    /// reasoning behind `Material::MinMax`'s nonzero failed-state tangent
222    /// floor, except here `b` is part of the actual force law (rounding the
223    /// stick/slip corner, reducing Newton bouncing at the transition), not
224    /// a tangent-only fudge. Set to `0.0` for an exact textbook Coulomb
225    /// comparison and accept the singularity risk that comes back with it.
226    pub b: f64,
227    slip: f64,
228}
229
230impl Friction {
231    pub fn new(normal_dof: usize, shear_dof: usize, mu: f64, k0: f64, b: f64) -> Self {
232        Friction {
233            normal_dof,
234            shear_dof,
235            mu,
236            k0,
237            b,
238            slip: 0.0,
239        }
240    }
241
242    /// `(shear_force, d(force)/d(shear_relative), d(force)/d(normal_force),
243    /// next_slip)` — same `(value, tangent, next_committed_state)` shape as
244    /// `Material::trial_stress_tangent`/committed-state pair, plus the extra
245    /// coupling derivative friction needs so the caller can chain-rule it
246    /// through the normal material's own tangent. Pure — safe to call every
247    /// Newton iteration, same contract as `Material::trial_stress_tangent`.
248    fn evaluate(&self, normal_force: f64, shear_relative: f64) -> (f64, f64, f64, f64) {
249        let yield_force = self.mu * (-normal_force).max(0.0);
250        let trial = self.k0 * (shear_relative - self.slip);
251        if trial.abs() <= yield_force {
252            (trial, self.k0, 0.0, self.slip)
253        } else {
254            let force = (self.b * trial.abs() + (1.0 - self.b) * yield_force).copysign(trial);
255            // d(yield_force)/d(normal_force): -mu in the engaged
256            // (compression) branch, 0 past it — matches the `max(.., 0.0)`
257            // clamp above, including at the boundary (subgradient
258            // convention, same as `Gap`/`Ent`'s `<=`).
259            let d_yield_d_normal = if normal_force < 0.0 { -self.mu } else { 0.0 };
260            let d_force_d_normal = (1.0 - self.b) * trial.signum() * d_yield_d_normal;
261            let next_slip = shear_relative - force / self.k0;
262            (force, self.b * self.k0, d_force_d_normal, next_slip)
263        }
264    }
265}
266
267/// `Friction`'s spatial counterpart: one normal DOF, two shear DOFs, each
268/// run independently through `Friction`'s return map against the same
269/// normal force (a square, not circular, interaction surface between the
270/// two shear directions — the physically correct circular friction cone is
271/// a materially harder shared-slip-vector return-map problem, deferred
272/// until needed).
273#[derive(Debug, Clone, Copy)]
274pub struct Friction3 {
275    pub normal_dof: usize,
276    pub shear_dofs: [usize; 2],
277    pub mu: f64,
278    pub k0: f64,
279    /// See `Friction::b`'s doc comment.
280    pub b: f64,
281    slip: [f64; 2],
282}
283
284impl Friction3 {
285    pub fn new(normal_dof: usize, shear_dofs: [usize; 2], mu: f64, k0: f64, b: f64) -> Self {
286        Friction3 {
287            normal_dof,
288            shear_dofs,
289            mu,
290            k0,
291            b,
292            slip: [0.0, 0.0],
293        }
294    }
295
296    /// Runs `Friction::evaluate`'s return map independently for shear axis
297    /// `i` (`0` or `1`) against the shared normal force.
298    fn evaluate(
299        &self,
300        axis: usize,
301        normal_force: f64,
302        shear_relative: f64,
303    ) -> (f64, f64, f64, f64) {
304        let leg = Friction {
305            normal_dof: self.normal_dof,
306            shear_dof: self.shear_dofs[axis],
307            mu: self.mu,
308            k0: self.k0,
309            b: self.b,
310            slip: self.slip[axis],
311        };
312        leg.evaluate(normal_force, shear_relative)
313    }
314}
315
316/// A 2-node, zero-length connector: no geometry or integration, just direct
317/// per-DOF material evaluation — each direction with a material
318/// assigned independently relates that DOF's relative displacement between
319/// the two nodes to a force along that same direction. Directions are the
320/// global DOF axes (including rotation) unless an
321/// `Orientation` (OpenSees' `-orient`) rotates them to local axes.
322///
323/// `friction` adds one cross-DOF coupling term on top of the independent
324/// `materials` (see `Friction`'s doc comment for why this lives here rather
325/// than as a `Material` variant): the shear DOF's own slot in `materials`
326/// must stay `None` — `friction` owns that direction's resistance entirely.
327#[derive(Debug, Clone)]
328pub struct ZeroLength {
329    pub node_i: NodeId,
330    pub node_j: NodeId,
331    materials: [Option<Material>; NDF],
332    friction: Option<Friction>,
333    frame: SMatrix<f64, NDF, NDF>,
334}
335
336impl ZeroLength {
337    pub fn new(node_i: NodeId, node_j: NodeId) -> Self {
338        ZeroLength {
339            node_i,
340            node_j,
341            materials: std::array::from_fn(|_| None),
342            friction: None,
343            frame: SMatrix::identity(),
344        }
345    }
346
347    pub fn with_material(mut self, dof: usize, material: Material) -> Self {
348        self.materials[dof] = Some(material);
349        self
350    }
351
352    /// Evaluate every DOF along the local axes of `orientation` instead of
353    /// the global ones (see `Orientation`). Errors if the frame leaves the
354    /// model plane.
355    pub fn with_orientation(mut self, orientation: Orientation) -> Result<Self, OrientationError> {
356        self.frame = orientation.frame2()?;
357        Ok(self)
358    }
359
360    /// `friction.normal_dof` must already have a material set via
361    /// `with_material` (debug-asserted, since evaluating friction reads the
362    /// normal direction's current force from it); `friction.shear_dof`
363    /// must *not*.
364    pub fn with_friction(mut self, friction: Friction) -> Self {
365        debug_assert!(
366            self.materials[friction.shear_dof].is_none(),
367            "friction owns its shear DOF's resistance; don't also assign it a material"
368        );
369        debug_assert!(
370            self.materials[friction.normal_dof].is_some(),
371            "friction reads its normal DOF's force from that DOF's own material"
372        );
373        self.friction = Some(friction);
374        self
375    }
376
377    /// Node slots touched: every local direction with a material or a
378    /// friction coupling, through the orientation frame.
379    pub(super) fn dof_mask(&self) -> DofMask {
380        let friction = self
381            .friction
382            .iter()
383            .flat_map(|f| [f.normal_dof, f.shear_dof]);
384        frame_mask(&self.frame, material_dofs(&self.materials).chain(friction))
385    }
386
387    pub(super) fn form_tangent_and_resistance(
388        &self,
389        node_i: &Node,
390        node_j: &Node,
391    ) -> (
392        SMatrix<f64, ELEMENT_DOF, ELEMENT_DOF>,
393        SVector<f64, ELEMENT_DOF>,
394    ) {
395        let mut k = SMatrix::<f64, ELEMENT_DOF, ELEMENT_DOF>::zeros();
396        let mut resistance = SVector::<f64, ELEMENT_DOF>::zeros();
397
398        for (dof, material) in self.materials.iter().enumerate() {
399            let Some(material) = material else { continue };
400
401            let relative =
402                local_relative(&self.frame, dof, &node_i.displacement, &node_j.displacement);
403            let (force, tangent_modulus) = material.trial_stress_tangent(relative);
404
405            // Local DOF order [ux_i, uy_i, rz_i, ux_j, uy_j, rz_j]; this
406            // direction only couples node_i's and node_j's copy of the same
407            // dof.
408            let b = dof_row_2d(&self.frame, dof);
409
410            k += tangent_modulus * (b * b.transpose());
411            resistance += force * b;
412        }
413
414        if let Some(fr) = &self.friction {
415            let normal_material = self.materials[fr.normal_dof]
416                .as_ref()
417                .expect("friction needs a normal-direction material");
418            let normal_rel = local_relative(
419                &self.frame,
420                fr.normal_dof,
421                &node_i.displacement,
422                &node_j.displacement,
423            );
424            let (normal_force, normal_tangent) = normal_material.trial_stress_tangent(normal_rel);
425            let shear_rel = local_relative(
426                &self.frame,
427                fr.shear_dof,
428                &node_i.displacement,
429                &node_j.displacement,
430            );
431            let (force, dv_dshear, dv_dnormal, _) = fr.evaluate(normal_force, shear_rel);
432
433            let b_v = dof_row_2d(&self.frame, fr.shear_dof);
434            let b_n = dof_row_2d(&self.frame, fr.normal_dof);
435
436            // Only a `b_v * b_nᵀ` block, not its transpose — `k` is not
437            // symmetric in general. Fine here: the solver uses a general
438            // sparse LU, not a symmetric-only factorization.
439            k += dv_dshear * (b_v * b_v.transpose())
440                + (dv_dnormal * normal_tangent) * (b_v * b_n.transpose());
441            resistance += force * b_v;
442        }
443
444        (k, resistance)
445    }
446
447    pub(super) fn commit(&mut self, node_i: &Node, node_j: &Node) {
448        for (dof, material) in self.materials.iter_mut().enumerate() {
449            let Some(material) = material else { continue };
450            let relative =
451                local_relative(&self.frame, dof, &node_i.displacement, &node_j.displacement);
452            *material = material.commit(relative);
453        }
454
455        if let Some(fr) = &mut self.friction {
456            // The normal DOF's own slot in `materials` already advanced its
457            // state in the loop above; read (not commit) it here via
458            // `trial_stress_tangent` — every leaf material in this catalog
459            // reproduces the same force whether read just before or just
460            // after its own `commit()`, so this ordering is safe, but
461            // committing it a second time from here would not be.
462            let normal_rel = local_relative(
463                &self.frame,
464                fr.normal_dof,
465                &node_i.displacement,
466                &node_j.displacement,
467            );
468            let normal_force = self.materials[fr.normal_dof]
469                .as_ref()
470                .unwrap()
471                .trial_stress_tangent(normal_rel)
472                .0;
473            let shear_rel = local_relative(
474                &self.frame,
475                fr.shear_dof,
476                &node_i.displacement,
477                &node_j.displacement,
478            );
479            let (.., next_slip) = fr.evaluate(normal_force, shear_rel);
480            fr.slip = next_slip;
481        }
482    }
483
484    /// No separate local-frame force — see `Truss::local_force`'s doc
485    /// comment for why this is just the resistance vector (already in
486    /// global element DOFs, whatever the orientation), recomputed fresh from
487    /// each direction's current committed material state.
488    pub(super) fn local_force(&self, node_i: &Node, node_j: &Node) -> SVector<f64, ELEMENT_DOF> {
489        self.form_tangent_and_resistance(node_i, node_j).1
490    }
491}
492
493/// `ZeroLength`'s spatial counterpart: independent per-DOF materials along
494/// the six directions `[ux, uy, uz, rx, ry, rz]` — global by default, or the
495/// local axes of an `Orientation` (OpenSees' `-orient`).
496/// `friction` — see `ZeroLength`'s doc comment — adds two independent
497/// (see `Friction3`) shear-DOF couplings on top of the
498/// independent `materials`.
499#[derive(Debug, Clone)]
500pub struct ZeroLength3 {
501    pub node_i: Node3Id,
502    pub node_j: Node3Id,
503    materials: [Option<Material>; SPATIAL_NDF],
504    friction: Option<Friction3>,
505    frame: SMatrix<f64, SPATIAL_NDF, SPATIAL_NDF>,
506}
507
508impl ZeroLength3 {
509    pub fn new(node_i: Node3Id, node_j: Node3Id) -> Self {
510        ZeroLength3 {
511            node_i,
512            node_j,
513            materials: std::array::from_fn(|_| None),
514            friction: None,
515            frame: SMatrix::identity(),
516        }
517    }
518
519    pub fn with_material(mut self, dof: usize, material: Material) -> Self {
520        self.materials[dof] = Some(material);
521        self
522    }
523
524    /// Evaluate every DOF along the local axes of `orientation` instead of
525    /// the global ones (see `Orientation`).
526    pub fn with_orientation(mut self, orientation: Orientation) -> Self {
527        self.frame = orientation.frame3();
528        self
529    }
530
531    /// See `ZeroLength::with_friction`'s doc comment — same preconditions,
532    /// checked for both entries of `friction.shear_dofs`.
533    pub fn with_friction(mut self, friction: Friction3) -> Self {
534        for shear_dof in friction.shear_dofs {
535            debug_assert!(
536                self.materials[shear_dof].is_none(),
537                "friction owns its shear DOFs' resistance; don't also assign them a material"
538            );
539        }
540        debug_assert!(
541            self.materials[friction.normal_dof].is_some(),
542            "friction reads its normal DOF's force from that DOF's own material"
543        );
544        self.friction = Some(friction);
545        self
546    }
547
548    /// See `ZeroLength::dof_mask`; friction couples its normal and both shear
549    /// directions.
550    pub(super) fn dof_mask(&self) -> DofMask {
551        let friction = self
552            .friction
553            .iter()
554            .flat_map(|f| [f.normal_dof, f.shear_dofs[0], f.shear_dofs[1]]);
555        frame_mask(&self.frame, material_dofs(&self.materials).chain(friction))
556    }
557
558    pub(super) fn form_tangent_and_resistance(
559        &self,
560        node_i: &Node3,
561        node_j: &Node3,
562    ) -> (SpatialElementMatrix, SpatialElementVector) {
563        let mut k = SpatialElementMatrix::zeros();
564        let mut resistance = SpatialElementVector::zeros();
565
566        for (dof, material) in self.materials.iter().enumerate() {
567            let Some(material) = material else { continue };
568
569            let relative =
570                local_relative(&self.frame, dof, &node_i.displacement, &node_j.displacement);
571            let (force, tangent_modulus) = material.trial_stress_tangent(relative);
572
573            let b = dof_row_3d(&self.frame, dof);
574
575            k += tangent_modulus * (b * b.transpose());
576            resistance += force * b;
577        }
578
579        if let Some(fr) = &self.friction {
580            let normal_material = self.materials[fr.normal_dof]
581                .as_ref()
582                .expect("friction needs a normal-direction material");
583            let normal_rel = local_relative(
584                &self.frame,
585                fr.normal_dof,
586                &node_i.displacement,
587                &node_j.displacement,
588            );
589            let (normal_force, normal_tangent) = normal_material.trial_stress_tangent(normal_rel);
590
591            let b_n = dof_row_3d(&self.frame, fr.normal_dof);
592
593            for (axis, &shear_dof) in fr.shear_dofs.iter().enumerate() {
594                let shear_rel = local_relative(
595                    &self.frame,
596                    shear_dof,
597                    &node_i.displacement,
598                    &node_j.displacement,
599                );
600                let (force, dv_dshear, dv_dnormal, _) = fr.evaluate(axis, normal_force, shear_rel);
601
602                let b_v = dof_row_3d(&self.frame, shear_dof);
603
604                k += dv_dshear * (b_v * b_v.transpose())
605                    + (dv_dnormal * normal_tangent) * (b_v * b_n.transpose());
606                resistance += force * b_v;
607            }
608        }
609
610        (k, resistance)
611    }
612
613    pub(super) fn commit(&mut self, node_i: &Node3, node_j: &Node3) {
614        for (dof, material) in self.materials.iter_mut().enumerate() {
615            let Some(material) = material else { continue };
616            let relative =
617                local_relative(&self.frame, dof, &node_i.displacement, &node_j.displacement);
618            *material = material.commit(relative);
619        }
620
621        if let Some(fr) = &mut self.friction {
622            // See `ZeroLength::commit`'s doc comment for why reading (not
623            // committing) the normal material here is correct.
624            let normal_rel = local_relative(
625                &self.frame,
626                fr.normal_dof,
627                &node_i.displacement,
628                &node_j.displacement,
629            );
630            let normal_force = self.materials[fr.normal_dof]
631                .as_ref()
632                .unwrap()
633                .trial_stress_tangent(normal_rel)
634                .0;
635
636            let mut next_slip = fr.slip;
637            for (axis, &shear_dof) in fr.shear_dofs.iter().enumerate() {
638                let shear_rel = local_relative(
639                    &self.frame,
640                    shear_dof,
641                    &node_i.displacement,
642                    &node_j.displacement,
643                );
644                let (.., slip) = fr.evaluate(axis, normal_force, shear_rel);
645                next_slip[axis] = slip;
646            }
647            fr.slip = next_slip;
648        }
649    }
650
651    /// See `ZeroLength::local_force`'s doc comment.
652    pub(super) fn local_force(&self, node_i: &Node3, node_j: &Node3) -> SpatialElementVector {
653        self.form_tangent_and_resistance(node_i, node_j).1
654    }
655}
656
657/// A 2-node, zero-length connector driven by a coupled `FiberSection`
658/// instead of `ZeroLength`'s independent per-DOF materials — the axial
659/// (`ux`) and flexural (`rz`) response come out of the same fiber
660/// discretization, so axial force and bending moment interact through
661/// shared fiber strain, the way a concentrated-plasticity "fiber hinge"
662/// needs (e.g. at a beam-column end) and `ZeroLength` structurally cannot
663/// produce. Modeled on OpenSees/Xara's `ZeroLengthSection`
664/// (`SRC/element/Point/ZeroLengthSection.cpp`): the section's basic
665/// deformation is the direct nodal DOF difference (no shape functions or
666/// integration — there's no length to integrate over), and `K`/`P` are
667/// `Bᵀ*k_section*B`/`Bᵀ*q` with `B` selecting `[ux, rz]`, the same pattern
668/// `DispBeamColumn::form_tangent_and_resistance` uses per integration point
669/// (here with a single "point" and unit scale).
670///
671/// `uy` — the one DOF `FiberSection` has no resultant for — can still carry
672/// an independent uniaxial `Material` spring via `with_material`, exactly
673/// like `ZeroLength`, additive with (and uncoupled from) the section's
674/// axial-moment response.
675#[derive(Debug, Clone)]
676pub struct ZeroLengthSection {
677    pub node_i: NodeId,
678    pub node_j: NodeId,
679    section: FiberSection,
680    materials: [Option<Material>; NDF],
681    frame: SMatrix<f64, NDF, NDF>,
682}
683
684impl ZeroLengthSection {
685    pub fn new(node_i: NodeId, node_j: NodeId, section: FiberSection) -> Self {
686        ZeroLengthSection {
687            node_i,
688            node_j,
689            section,
690            materials: std::array::from_fn(|_| None),
691            frame: SMatrix::identity(),
692        }
693    }
694
695    /// Independent spring for a DOF the section doesn't drive — intended
696    /// for `dof = 1` (`uy`, shear), since the section already owns `ux`
697    /// (dof 0) and `rz` (dof 2).
698    pub fn with_material(mut self, dof: usize, material: Material) -> Self {
699        self.materials[dof] = Some(material);
700        self
701    }
702
703    /// The section's axial direction is local x and its flexure is about
704    /// local z (see `Orientation`); the default is the global axes.
705    pub fn with_orientation(mut self, orientation: Orientation) -> Result<Self, OrientationError> {
706        self.frame = orientation.frame2()?;
707        Ok(self)
708    }
709
710    /// The section drives local `ux` (axial) and `rz` (flexure); springs add
711    /// whatever other directions carry a material.
712    pub(super) fn dof_mask(&self) -> DofMask {
713        frame_mask(
714            &self.frame,
715            [0, 2].into_iter().chain(material_dofs(&self.materials)),
716        )
717    }
718
719    pub(super) fn form_tangent_and_resistance(
720        &self,
721        node_i: &Node,
722        node_j: &Node,
723    ) -> (
724        SMatrix<f64, ELEMENT_DOF, ELEMENT_DOF>,
725        SVector<f64, ELEMENT_DOF>,
726    ) {
727        let eps0 = local_relative(&self.frame, 0, &node_i.displacement, &node_j.displacement);
728        let kappa = local_relative(&self.frame, 2, &node_i.displacement, &node_j.displacement);
729        let (n, m, k_section) = self.section.trial(eps0, kappa);
730
731        let b_eps0 = dof_row_2d(&self.frame, 0);
732        let b_kappa = dof_row_2d(&self.frame, 2);
733
734        let mut k = k_section[0][0] * (b_eps0 * b_eps0.transpose())
735            + k_section[0][1] * (b_eps0 * b_kappa.transpose())
736            + k_section[1][0] * (b_kappa * b_eps0.transpose())
737            + k_section[1][1] * (b_kappa * b_kappa.transpose());
738        let mut resistance = b_eps0 * n + b_kappa * m;
739
740        for (dof, material) in self.materials.iter().enumerate() {
741            let Some(material) = material else { continue };
742
743            let relative =
744                local_relative(&self.frame, dof, &node_i.displacement, &node_j.displacement);
745            let (force, tangent_modulus) = material.trial_stress_tangent(relative);
746
747            let b = dof_row_2d(&self.frame, dof);
748
749            k += tangent_modulus * (b * b.transpose());
750            resistance += force * b;
751        }
752
753        (k, resistance)
754    }
755
756    pub(super) fn commit(&mut self, node_i: &Node, node_j: &Node) {
757        let eps0 = local_relative(&self.frame, 0, &node_i.displacement, &node_j.displacement);
758        let kappa = local_relative(&self.frame, 2, &node_i.displacement, &node_j.displacement);
759        self.section.commit(eps0, kappa);
760
761        for (dof, material) in self.materials.iter_mut().enumerate() {
762            let Some(material) = material else { continue };
763            let relative =
764                local_relative(&self.frame, dof, &node_i.displacement, &node_j.displacement);
765            *material = material.commit(relative);
766        }
767    }
768
769    /// See `ZeroLength::local_force`'s doc comment.
770    pub(super) fn local_force(&self, node_i: &Node, node_j: &Node) -> SVector<f64, ELEMENT_DOF> {
771        self.form_tangent_and_resistance(node_i, node_j).1
772    }
773}
774
775/// `ZeroLengthSection`'s spatial counterpart: coupled axial-biaxial-moment
776/// response (`ux`, `ry`, `rz`) from a `FiberSection3`, plus optional
777/// independent springs (via `with_material`) for `uy`, `uz` (shear) and
778/// `rx` (torsion) — the three DOFs the section has no resultant for.
779/// `FiberSection3` deliberately excludes torsion from its fiber loop (see
780/// its doc comment), and there's no meaningful `G*J/length` term at zero
781/// length the way `DispBeamColumn3` has, so an explicit `rx` material is
782/// the only way to give this element torsional stiffness at all.
783#[derive(Debug, Clone)]
784pub struct ZeroLengthSection3 {
785    pub node_i: Node3Id,
786    pub node_j: Node3Id,
787    section: FiberSection3,
788    materials: [Option<Material>; SPATIAL_NDF],
789    frame: SMatrix<f64, SPATIAL_NDF, SPATIAL_NDF>,
790}
791
792impl ZeroLengthSection3 {
793    pub fn new(node_i: Node3Id, node_j: Node3Id, section: FiberSection3) -> Self {
794        ZeroLengthSection3 {
795            node_i,
796            node_j,
797            section,
798            materials: std::array::from_fn(|_| None),
799            frame: SMatrix::identity(),
800        }
801    }
802
803    /// Independent spring for a DOF the section doesn't drive — `uy`/`uz`
804    /// (shear, dofs 1/2) or `rx` (torsion, dof 3) — since the section
805    /// already owns `ux` (dof 0), `ry` (dof 4) and `rz` (dof 5).
806    pub fn with_material(mut self, dof: usize, material: Material) -> Self {
807        self.materials[dof] = Some(material);
808        self
809    }
810
811    /// The section's axial direction is local x and its flexure is about
812    /// local y and z (see `Orientation`); the default is the global axes.
813    pub fn with_orientation(mut self, orientation: Orientation) -> Self {
814        self.frame = orientation.frame3();
815        self
816    }
817
818    /// The section drives local `ux`, `ry` and `rz`; springs add the other
819    /// directions that carry a material.
820    pub(super) fn dof_mask(&self) -> DofMask {
821        frame_mask(
822            &self.frame,
823            [0, 4, 5].into_iter().chain(material_dofs(&self.materials)),
824        )
825    }
826
827    pub(super) fn form_tangent_and_resistance(
828        &self,
829        node_i: &Node3,
830        node_j: &Node3,
831    ) -> (SpatialElementMatrix, SpatialElementVector) {
832        let eps0 = local_relative(&self.frame, 0, &node_i.displacement, &node_j.displacement);
833        let kappa_z = local_relative(&self.frame, 5, &node_i.displacement, &node_j.displacement);
834        let kappa_y = local_relative(&self.frame, 4, &node_i.displacement, &node_j.displacement);
835        let (n, mz, my, k_section) = self.section.trial(eps0, kappa_z, kappa_y);
836
837        let b_eps0 = dof_row_3d(&self.frame, 0);
838        let b_kappa_z = dof_row_3d(&self.frame, 5);
839        let b_kappa_y = dof_row_3d(&self.frame, 4);
840
841        let mut k = SpatialElementMatrix::zeros();
842        let mut resistance = b_eps0 * n + b_kappa_z * mz + b_kappa_y * my;
843
844        let b = [&b_eps0, &b_kappa_z, &b_kappa_y];
845        for (bi, row_i) in b.iter().enumerate() {
846            for (bj, row_j) in b.iter().enumerate() {
847                k += k_section[bi][bj] * (*row_i * row_j.transpose());
848            }
849        }
850
851        for (dof, material) in self.materials.iter().enumerate() {
852            let Some(material) = material else { continue };
853
854            let relative =
855                local_relative(&self.frame, dof, &node_i.displacement, &node_j.displacement);
856            let (force, tangent_modulus) = material.trial_stress_tangent(relative);
857
858            let bm = dof_row_3d(&self.frame, dof);
859
860            k += tangent_modulus * (bm * bm.transpose());
861            resistance += force * bm;
862        }
863
864        (k, resistance)
865    }
866
867    pub(super) fn commit(&mut self, node_i: &Node3, node_j: &Node3) {
868        let eps0 = local_relative(&self.frame, 0, &node_i.displacement, &node_j.displacement);
869        let kappa_z = local_relative(&self.frame, 5, &node_i.displacement, &node_j.displacement);
870        let kappa_y = local_relative(&self.frame, 4, &node_i.displacement, &node_j.displacement);
871        self.section.commit(eps0, kappa_z, kappa_y);
872
873        for (dof, material) in self.materials.iter_mut().enumerate() {
874            let Some(material) = material else { continue };
875            let relative =
876                local_relative(&self.frame, dof, &node_i.displacement, &node_j.displacement);
877            *material = material.commit(relative);
878        }
879    }
880
881    /// See `ZeroLength::local_force`'s doc comment.
882    pub(super) fn local_force(&self, node_i: &Node3, node_j: &Node3) -> SpatialElementVector {
883        self.form_tangent_and_resistance(node_i, node_j).1
884    }
885}
886
887#[cfg(test)]
888mod tests {
889    use super::*;
890    use crate::model::{Fiber, Fiber3, Material, Node};
891
892    /// Proves the `Material` enum's dispatch generalizes to `ZeroLength`
893    /// across every material variant and regime (elastic, plastic plateau, open
894    /// gap, closed gap, tension, compression) by driving the element
895    /// directly off manually-set node displacements — independent of
896    /// `Analysis`/`Algorithm`, which is linear-only here (no
897    /// Newton iteration) and so can't itself resolve equilibrium
898    /// across a material's nonlinear regimes in one step.
899    #[test]
900    fn zero_length_dispatches_across_material_regimes() {
901        let node_i = Node::new([0.0, 0.0]);
902
903        let mut node_j = Node::new([0.0, 0.0]);
904        node_j.displacement[0] = -0.02; // compression, past both EPP yield and Gap closure
905
906        // `resistance = force * b` with `b[dof_i] = -1`, so node_i's
907        // component of the resistance vector is `-force`.
908        let epp = ZeroLength::new(NodeId::default(), NodeId::default())
909            .with_material(0, Material::elastic_pp(100.0, 0.01));
910        let (_, r) = epp.form_tangent_and_resistance(&node_i, &node_j);
911        assert_eq!(r[0], 100.0 * 0.01, "should be clamped to yield force");
912
913        let gap = ZeroLength::new(NodeId::default(), NodeId::default()).with_material(
914            0,
915            Material::Gap {
916                e: 100.0,
917                gap: 0.01,
918            },
919        );
920        let (_, r) = gap.form_tangent_and_resistance(&node_i, &node_j);
921        assert_eq!(r[0], 100.0 * (0.02 - 0.01), "gap engaged past closure");
922
923        let ent = ZeroLength::new(NodeId::default(), NodeId::default())
924            .with_material(0, Material::Ent { e: 100.0 });
925        let (_, r) = ent.form_tangent_and_resistance(&node_i, &node_j);
926        assert_eq!(r[0], 100.0 * 0.02, "ENT carries full compression");
927
928        let mut node_tension = Node::new([0.0, 0.0]);
929        node_tension.displacement[0] = 0.02; // tension
930        let (k, r) = ent.form_tangent_and_resistance(&node_i, &node_tension);
931        assert_eq!(r[0], 0.0, "ENT carries no tension");
932        assert_eq!(k[(0, 0)], 0.0, "no tangent stiffness while open");
933    }
934
935    /// `ZeroLength3`'s spatial counterpart, exercising a rotational DOF
936    /// (index 3, `SpatialDof::Rx`) in addition to a translational one — the
937    /// planar `ZeroLength` test above only ever touches dof 0.
938    #[test]
939    fn zero_length3_dispatches_independently_per_global_direction() {
940        let node_i = Node3::new([0.0, 0.0, 0.0]);
941        let mut node_j = Node3::new([0.0, 0.0, 0.0]);
942        node_j.displacement[0] = 0.01; // Ux
943        node_j.displacement[3] = -0.02; // Rx
944
945        let zl = ZeroLength3::new(Node3Id::default(), Node3Id::default())
946            .with_material(0, Material::Elastic { e: 100.0 })
947            .with_material(3, Material::elastic_pp(50.0, 0.01));
948
949        let (k, r) = zl.form_tangent_and_resistance(&node_i, &node_j);
950        // `resistance = force * b` with `b[dof_i] = -1`, so node_i's
951        // component of the resistance vector is `-force` (see the planar
952        // `ZeroLength` test's doc comment).
953        assert_eq!(r[0], -(100.0 * 0.01), "Ux: linear elastic");
954        assert_eq!(r[SPATIAL_NDF], 100.0 * 0.01);
955        assert_eq!(
956            r[3],
957            50.0 * 0.01,
958            "Rx: clamped to yield force (sign follows relative disp)"
959        );
960        assert_eq!(r[SPATIAL_NDF + 3], -(50.0 * 0.01));
961        assert_eq!(k[(0, 0)], 100.0, "Ux tangent unaffected by Rx material");
962        assert_eq!(k[(3, 3)], 0.0, "Rx tangent is zero past yield");
963        for dof in [1, 2, 4, 5] {
964            assert_eq!(r[dof], 0.0, "unassigned directions carry no force");
965        }
966    }
967
968    /// `ZeroLengthSection`: two symmetric elastic fibers reproduce the
969    /// closed-form `EA`/`EI` (already validated directly against
970    /// `FiberSection::trial` in `fiber_section.rs`'s
971    /// `two_symmetric_elastic_fibers_reproduce_ea_ei_exactly`), now routed
972    /// through the element's `B`-matrix mapping onto `[ux, rz]`.
973    #[test]
974    fn zero_length_section_reproduces_symmetric_ea_ei() {
975        let (e, area, iz): (f64, f64, f64) = (30000.0, 2.0, 1000.0);
976        let h = (iz / area).sqrt();
977        let section = FiberSection::new(vec![
978            Fiber::new(h, area / 2.0, Material::Elastic { e }),
979            Fiber::new(-h, area / 2.0, Material::Elastic { e }),
980        ]);
981
982        let node_i = Node::new([0.0, 0.0]);
983        let mut node_j = Node::new([0.0, 0.0]);
984        node_j.displacement[0] = 0.001; // ux -> eps0
985        node_j.displacement[2] = 0.0002; // rz -> kappa
986
987        let zl = ZeroLengthSection::new(NodeId::default(), NodeId::default(), section);
988        let (k, r) = zl.form_tangent_and_resistance(&node_i, &node_j);
989
990        assert!(
991            (k[(0, 0)] - e * area).abs() < 1e-6,
992            "EA mismatch: {}",
993            k[(0, 0)]
994        );
995        assert!(
996            (k[(2, 2)] - e * iz).abs() < 1e-6,
997            "EI mismatch: {}",
998            k[(2, 2)]
999        );
1000        assert!(
1001            k[(0, 2)].abs() < 1e-9,
1002            "no axial-moment coupling for a symmetric section"
1003        );
1004
1005        let n = e * area * 0.001;
1006        let m = e * iz * 0.0002;
1007        assert!((r[3] - n).abs() < 1e-6, "node_j axial force");
1008        assert!((r[0] + n).abs() < 1e-6, "node_i axial force");
1009        assert!((r[5] - m).abs() < 1e-6, "node_j moment");
1010        assert!((r[2] + m).abs() < 1e-6, "node_i moment");
1011        assert_eq!(r[1], 0.0, "no material assigned to uy");
1012    }
1013
1014    /// An off-centroid single fiber couples axial and moment response
1015    /// (`k_section[0][1] != 0`) — something `ZeroLength`'s independent
1016    /// per-DOF materials can never produce, since each of its directions is
1017    /// evaluated against its own material in isolation.
1018    #[test]
1019    fn zero_length_section_couples_axial_and_moment_for_an_asymmetric_section() {
1020        let (e, area, y): (f64, f64, f64) = (1000.0, 2.0, 3.0);
1021        let section = FiberSection::new(vec![Fiber::new(y, area, Material::Elastic { e })]);
1022
1023        let (_n, _m, k_section) = section.trial(0.001, 0.0002);
1024        assert!(
1025            k_section[0][1].abs() > 1e-9,
1026            "test setup: section must actually couple"
1027        );
1028
1029        let node_i = Node::new([0.0, 0.0]);
1030        let mut node_j = Node::new([0.0, 0.0]);
1031        node_j.displacement[0] = 0.001;
1032        node_j.displacement[2] = 0.0002;
1033
1034        let zl = ZeroLengthSection::new(NodeId::default(), NodeId::default(), section);
1035        let (k, _r) = zl.form_tangent_and_resistance(&node_i, &node_j);
1036
1037        // b_eps0[0] = -1, b_kappa[2] = -1, so k(0,2) = k_section[0][1].
1038        assert!(
1039            (k[(0, 2)] - k_section[0][1]).abs() < 1e-9,
1040            "axial-moment coupling should carry through to the element tangent"
1041        );
1042    }
1043
1044    /// `ZeroLengthSection3`: four corner elastic fibers reproduce `EA`,
1045    /// `EIy`, `EIz` (see `fiber_section.rs`'s
1046    /// `four_corner_elastic_fibers_reproduce_ea_eiy_eiz_with_zero_cross_coupling`)
1047    /// routed through the element's `B`-matrix mapping onto `[ux, ry, rz]`;
1048    /// `uy`/`uz`/`rx` — DOFs the section has no resultant for — stay zero.
1049    #[test]
1050    fn zero_length_section3_reproduces_biaxial_ea_eiy_eiz() {
1051        let (e, area, iy, iz): (f64, f64, f64, f64) = (30000.0, 4.0, 500.0, 2000.0);
1052        let hz = (iy / area).sqrt();
1053        let hy = (iz / area).sqrt();
1054        let a4 = area / 4.0;
1055        let section = FiberSection3::new(vec![
1056            Fiber3::new(hy, hz, a4, Material::Elastic { e }),
1057            Fiber3::new(hy, -hz, a4, Material::Elastic { e }),
1058            Fiber3::new(-hy, hz, a4, Material::Elastic { e }),
1059            Fiber3::new(-hy, -hz, a4, Material::Elastic { e }),
1060        ]);
1061
1062        let node_i = Node3::new([0.0, 0.0, 0.0]);
1063        let mut node_j = Node3::new([0.0, 0.0, 0.0]);
1064        node_j.displacement[0] = 0.001; // ux -> eps0
1065        node_j.displacement[5] = 0.0002; // rz -> kappa_z
1066        node_j.displacement[4] = 0.0003; // ry -> kappa_y
1067
1068        let zl = ZeroLengthSection3::new(Node3Id::default(), Node3Id::default(), section);
1069        let (k, r) = zl.form_tangent_and_resistance(&node_i, &node_j);
1070
1071        assert!((k[(0, 0)] - e * area).abs() < 1e-6, "EA mismatch");
1072        assert!((k[(5, 5)] - e * iz).abs() < 1e-6, "EIz mismatch");
1073        assert!((k[(4, 4)] - e * iy).abs() < 1e-6, "EIy mismatch");
1074
1075        for dof in [1usize, 2, 3] {
1076            for local in [dof, SPATIAL_NDF + dof] {
1077                assert_eq!(r[local], 0.0, "unmodeled DOF carries no force");
1078                for col in 0..2 * SPATIAL_NDF {
1079                    assert_eq!(k[(local, col)], 0.0, "unmodeled DOF has no stiffness");
1080                }
1081            }
1082        }
1083    }
1084
1085    /// A section commit propagates into the element's subsequent trial
1086    /// response, the same commit/trial split `Material` and `ZeroLength`
1087    /// already rely on — proves `ZeroLengthSection::commit` actually drives
1088    /// `FiberSection::commit` (not just recomputing `eps0`/`kappa` and
1089    /// discarding them).
1090    #[test]
1091    fn zero_length_section_commit_propagates_fiber_history() {
1092        let mat = || Material::steel01(60.0, 29000.0, 0.01, 0.9, 5.0, 0.9, 5.0);
1093
1094        // Pure axial fiber (y = 0) isolates the material's own history from
1095        // any bending coupling.
1096        let section = FiberSection::new(vec![Fiber::new(0.0, 1.0, mat())]);
1097        let mut zl = ZeroLengthSection::new(NodeId::default(), NodeId::default(), section);
1098
1099        // Independent "by hand" copy, committed in lockstep, to compute the
1100        // expected post-yield residual stress from scratch (same pattern as
1101        // `fiber_section.rs`'s RC test).
1102        let mut hand = mat();
1103
1104        let node_i = Node::new([0.0, 0.0]);
1105        let mut node_j = Node::new([0.0, 0.0]);
1106        node_j.displacement[0] = 0.01; // well past yield (epsy = 60/29000 ~= 0.00207)
1107
1108        zl.commit(&node_i, &node_j);
1109        hand = hand.commit(0.01);
1110
1111        // Fully unload back to zero relative displacement — with committed
1112        // plastic strain, elastic unload from 0.01 leaves residual stress.
1113        node_j.displacement[0] = 0.0;
1114        let (_k, r) = zl.form_tangent_and_resistance(&node_i, &node_j);
1115
1116        let (expected_n, _tangent) = hand.trial_stress_tangent(0.0);
1117        assert!(
1118            expected_n.abs() > 1e-6,
1119            "test setup: material should have residual stress after yield"
1120        );
1121        assert!(
1122            (r[3] - expected_n).abs() < 1e-9,
1123            "committed plastic history should carry into subsequent trial"
1124        );
1125    }
1126
1127    /// A `uy` spring alongside the section is additive and uncoupled: it
1128    /// contributes independently to `k`/`r` on top of (not instead of) the
1129    /// section's coupled axial-moment response.
1130    #[test]
1131    fn zero_length_section_combines_with_an_independent_shear_spring() {
1132        let (e, area, iz): (f64, f64, f64) = (30000.0, 2.0, 1000.0);
1133        let h = (iz / area).sqrt();
1134        let section = FiberSection::new(vec![
1135            Fiber::new(h, area / 2.0, Material::Elastic { e }),
1136            Fiber::new(-h, area / 2.0, Material::Elastic { e }),
1137        ]);
1138        let shear_e = 500.0;
1139
1140        let zl = ZeroLengthSection::new(NodeId::default(), NodeId::default(), section)
1141            .with_material(1, Material::Elastic { e: shear_e });
1142
1143        let node_i = Node::new([0.0, 0.0]);
1144        let mut node_j = Node::new([0.0, 0.0]);
1145        node_j.displacement[0] = 0.001;
1146        node_j.displacement[1] = 0.002; // uy -> shear spring
1147        node_j.displacement[2] = 0.0002;
1148
1149        let (k, r) = zl.form_tangent_and_resistance(&node_i, &node_j);
1150
1151        assert!((k[(1, 1)] - shear_e).abs() < 1e-9, "shear spring stiffness");
1152        assert_eq!(k[(1, 0)], 0.0, "shear spring uncoupled from axial");
1153        assert_eq!(k[(1, 2)], 0.0, "shear spring uncoupled from moment");
1154        assert!((r[4] - shear_e * 0.002).abs() < 1e-9, "node_j shear force");
1155
1156        // Section response unchanged by the added spring.
1157        assert!(
1158            (k[(0, 0)] - e * area).abs() < 1e-6,
1159            "EA unaffected by shear spring"
1160        );
1161        assert!(
1162            (k[(2, 2)] - e * iz).abs() < 1e-6,
1163            "EI unaffected by shear spring"
1164        );
1165    }
1166
1167    /// `ZeroLengthSection3` analogue: an `rx` torsion spring alongside the
1168    /// section's biaxial axial-moment response — the only way this element
1169    /// gets torsional stiffness at all (see the type's doc comment).
1170    #[test]
1171    fn zero_length_section3_combines_with_an_independent_torsion_spring() {
1172        let (e, area, iy, iz): (f64, f64, f64, f64) = (30000.0, 4.0, 500.0, 2000.0);
1173        let hz = (iy / area).sqrt();
1174        let hy = (iz / area).sqrt();
1175        let a4 = area / 4.0;
1176        let section = FiberSection3::new(vec![
1177            Fiber3::new(hy, hz, a4, Material::Elastic { e }),
1178            Fiber3::new(hy, -hz, a4, Material::Elastic { e }),
1179            Fiber3::new(-hy, hz, a4, Material::Elastic { e }),
1180            Fiber3::new(-hy, -hz, a4, Material::Elastic { e }),
1181        ]);
1182        let torsion_e = 700.0;
1183
1184        let zl = ZeroLengthSection3::new(Node3Id::default(), Node3Id::default(), section)
1185            .with_material(3, Material::Elastic { e: torsion_e });
1186
1187        let node_i = Node3::new([0.0, 0.0, 0.0]);
1188        let mut node_j = Node3::new([0.0, 0.0, 0.0]);
1189        node_j.displacement[0] = 0.001;
1190        node_j.displacement[3] = 0.004; // rx -> torsion spring
1191        node_j.displacement[5] = 0.0002;
1192        node_j.displacement[4] = 0.0003;
1193
1194        let (k, r) = zl.form_tangent_and_resistance(&node_i, &node_j);
1195
1196        assert!(
1197            (k[(3, 3)] - torsion_e).abs() < 1e-9,
1198            "torsion spring stiffness"
1199        );
1200        assert!((r[9] - torsion_e * 0.004).abs() < 1e-9, "node_j torque");
1201        assert!(
1202            (k[(0, 0)] - e * area).abs() < 1e-6,
1203            "EA unaffected by torsion spring"
1204        );
1205        assert!(
1206            (k[(5, 5)] - e * iz).abs() < 1e-6,
1207            "EIz unaffected by torsion spring"
1208        );
1209        assert!(
1210            (k[(4, 4)] - e * iy).abs() < 1e-6,
1211            "EIy unaffected by torsion spring"
1212        );
1213    }
1214
1215    /// Common friction setup for the 2D tests below: dof 0 is the normal
1216    /// direction (an `Elastic` spring, `e = 1000`), dof 1 is the friction
1217    /// shear direction (`mu = 0.3`, `k0 = 500`, `b = 0.01`).
1218    fn friction_fixture() -> (Node, ZeroLength) {
1219        let node_i = Node::new([0.0, 0.0]);
1220        let zl = ZeroLength::new(NodeId::default(), NodeId::default())
1221            .with_material(0, Material::Elastic { e: 1000.0 })
1222            .with_friction(Friction::new(0, 1, 0.3, 500.0, 0.01));
1223        (node_i, zl)
1224    }
1225
1226    /// Below the yield surface, friction is a plain elastic spring:
1227    /// `V == k0 * shear_rel`, tangent `== k0`.
1228    #[test]
1229    fn friction_sticks_below_yield_surface() {
1230        let (node_i, zl) = friction_fixture();
1231        let mut node_j = Node::new([0.0, 0.0]);
1232        node_j.displacement[0] = -1.0; // compression -> normal_force = -1000, yield_force = 300
1233        node_j.displacement[1] = 0.1; // trial = 500*0.1 = 50, well under 300
1234
1235        let (k, r) = zl.form_tangent_and_resistance(&node_i, &node_j);
1236        assert!(
1237            (r[NDF + 1] - 50.0).abs() < 1e-9,
1238            "V == k0*shear_rel while sticking"
1239        );
1240        assert!(
1241            (k[(1, 1)] - 500.0).abs() < 1e-9,
1242            "tangent == k0 while sticking"
1243        );
1244    }
1245
1246    /// Past the yield surface, `V` follows the bilinear-kinematic-hardening
1247    /// return map (`Friction::evaluate`'s doc comment): `V = b*trial +
1248    /// (1-b)*yield_force*sign(trial)`, tangent `== b*k0`.
1249    #[test]
1250    fn friction_slides_past_yield_surface() {
1251        let (node_i, zl) = friction_fixture();
1252        let mut node_j = Node::new([0.0, 0.0]);
1253        node_j.displacement[0] = -1.0; // normal_force = -1000, yield_force = 300
1254        node_j.displacement[1] = 5.0; // trial = 500*5 = 2500, well past 300
1255
1256        let (k, r) = zl.form_tangent_and_resistance(&node_i, &node_j);
1257
1258        let (mu, k0, b) = (0.3, 500.0, 0.01);
1259        let yield_force = mu * 1000.0;
1260        let trial = k0 * 5.0;
1261        let expected_v = b * trial + (1.0 - b) * yield_force;
1262
1263        assert!(
1264            (r[NDF + 1] - expected_v).abs() < 1e-9,
1265            "V matches the hardening-branch return map"
1266        );
1267        assert!(
1268            (k[(1, 1)] - b * k0).abs() < 1e-9,
1269            "tangent == b*k0 while sliding"
1270        );
1271    }
1272
1273    /// The same shear displacement against two different normal-direction
1274    /// states: shear resistance depends on axial load level, not just a
1275    /// generic plasticity check — a lighter normal force slides at the same
1276    /// shear displacement a heavier one still sticks at.
1277    #[test]
1278    fn friction_yield_surface_scales_with_normal_force() {
1279        let (node_i, zl) = friction_fixture();
1280        let shear_rel = 1.0; // trial = k0*1.0 = 500
1281
1282        let mut node_j_light = Node::new([0.0, 0.0]);
1283        node_j_light.displacement[0] = -0.5; // normal_force = -500, yield_force = 150 < 500: slides
1284        node_j_light.displacement[1] = shear_rel;
1285
1286        let mut node_j_heavy = Node::new([0.0, 0.0]);
1287        node_j_heavy.displacement[0] = -2.0; // normal_force = -2000, yield_force = 600 > 500: sticks
1288        node_j_heavy.displacement[1] = shear_rel;
1289
1290        let (_, r_light) = zl.form_tangent_and_resistance(&node_i, &node_j_light);
1291        let (_, r_heavy) = zl.form_tangent_and_resistance(&node_i, &node_j_heavy);
1292
1293        let (mu, k0, b) = (0.3, 500.0, 0.01);
1294        let expected_v_light = b * (k0 * shear_rel) + (1.0 - b) * (mu * 500.0);
1295        assert!(
1296            (r_light[NDF + 1] - expected_v_light).abs() < 1e-9,
1297            "lighter normal force: sliding"
1298        );
1299        assert!(
1300            (r_heavy[NDF + 1] - k0 * shear_rel).abs() < 1e-9,
1301            "heavier normal force: still sticking, V == trial"
1302        );
1303    }
1304
1305    /// Mirrors `elastic_pp_remembers_permanent_set_after_commit`
1306    /// (`elastic_like.rs`): slide past the yield surface, commit, partially
1307    /// unload — the response must be elastic from the new committed `slip`,
1308    /// not from zero.
1309    #[test]
1310    fn friction_remembers_permanent_slip_after_commit() {
1311        let (node_i, mut zl) = friction_fixture();
1312        let (mu, k0, b) = (0.3, 500.0, 0.01);
1313
1314        let mut node_j = Node::new([0.0, 0.0]);
1315        node_j.displacement[0] = -1.0; // normal_force = -1000, yield_force = 300
1316        node_j.displacement[1] = 5.0; // well past yield -> slides, accrues slip
1317        zl.commit(&node_i, &node_j);
1318
1319        let yield_force = mu * 1000.0;
1320        let trial = k0 * 5.0;
1321        let force = b * trial + (1.0 - b) * yield_force;
1322        let committed_slip = 5.0 - force / k0;
1323
1324        // Small elastic excursion from the newly committed slip.
1325        node_j.displacement[1] = committed_slip + 0.05;
1326        let (k, r) = zl.form_tangent_and_resistance(&node_i, &node_j);
1327
1328        assert!(
1329            (r[NDF + 1] - k0 * 0.05).abs() < 1e-9,
1330            "elastic unload from committed slip, not from zero"
1331        );
1332        assert!(
1333            (k[(1, 1)] - k0).abs() < 1e-9,
1334            "tangent back to k0 (elastic) after unload"
1335        );
1336    }
1337
1338    /// Out of compression (tension, per this codebase's sign convention),
1339    /// `yield_force` clamps to `0`: any nonzero shear displacement slides
1340    /// immediately, with only the post-slip hardening term surviving.
1341    #[test]
1342    fn friction_drops_capacity_when_normal_dof_is_in_tension() {
1343        let (node_i, zl) = friction_fixture();
1344        let mut node_j = Node::new([0.0, 0.0]);
1345        node_j.displacement[0] = 1.0; // tension -> normal_force = +1000 -> yield_force = 0
1346        node_j.displacement[1] = 0.001;
1347
1348        let (k, r) = zl.form_tangent_and_resistance(&node_i, &node_j);
1349
1350        let (k0, b) = (500.0, 0.01);
1351        let expected_v = b * (k0 * 0.001); // yield_force == 0, so only the hardening term survives
1352        assert!(
1353            (r[NDF + 1] - expected_v).abs() < 1e-9,
1354            "near-zero force: no normal-direction capacity"
1355        );
1356        assert!(
1357            (k[(1, 1)] - b * k0).abs() < 1e-9,
1358            "post-slip tangent even at zero capacity"
1359        );
1360    }
1361
1362    /// The most important test here, and the easiest place to get a sign or
1363    /// chain-rule factor wrong: perturb the normal DOF's relative
1364    /// displacement by a small `h` and confirm the analytical
1365    /// `k[shear_dof, normal_dof]` block matches `dV/d(normal_rel)` by
1366    /// finite difference while sliding — validates `Friction::evaluate`'s
1367    /// `d_force_d_normal` (and the chain rule through the normal material's
1368    /// own tangent in `form_tangent_and_resistance`) directly, rather than
1369    /// trusting the derivation by inspection.
1370    #[test]
1371    fn friction_off_diagonal_tangent_matches_finite_difference() {
1372        let (node_i, zl) = friction_fixture();
1373        let mut node_j = Node::new([0.0, 0.0]);
1374        node_j.displacement[0] = -1.0; // normal_force = -1000
1375        node_j.displacement[1] = 5.0; // well past yield -> sliding, d(force)/d(normal_force) != 0
1376
1377        let (k, r0) = zl.form_tangent_and_resistance(&node_i, &node_j);
1378        let analytical = k[(1, 0)];
1379
1380        let h = 1e-6;
1381        let mut node_j_plus = node_j.clone();
1382        node_j_plus.displacement[0] += h;
1383        let (_, r_plus) = zl.form_tangent_and_resistance(&node_i, &node_j_plus);
1384
1385        let finite_diff = (r_plus[NDF + 1] - r0[NDF + 1]) / h;
1386
1387        assert!(
1388            (analytical - finite_diff).abs() < 1e-6,
1389            "analytical dV/d(normal_rel) = {analytical}, finite difference = {finite_diff}"
1390        );
1391    }
1392
1393    /// `ZeroLength3` with one normal DOF and two independent shear
1394    /// DOFs — each slides/sticks independently against the same normal
1395    /// force with no cross-talk between the two shear axes (the absence of
1396    /// coupling that distinguishes this from the deferred, circular-
1397    /// interaction-surface model).
1398    #[test]
1399    fn friction3_shear_axes_slide_independently_against_shared_normal_force() {
1400        let node_i = Node3::new([0.0, 0.0, 0.0]);
1401        let mut node_j = Node3::new([0.0, 0.0, 0.0]);
1402        node_j.displacement[0] = -1.0; // normal DOF (Ux): normal_force = -1000
1403        node_j.displacement[1] = 5.0; // shear axis 0 (Uy): well past yield -> slides
1404        node_j.displacement[2] = 0.1; // shear axis 1 (Uz): well under yield -> sticks
1405
1406        let (mu, k0, b) = (0.3, 500.0, 0.01);
1407        let zl = ZeroLength3::new(Node3Id::default(), Node3Id::default())
1408            .with_material(0, Material::Elastic { e: 1000.0 })
1409            .with_friction(Friction3::new(0, [1, 2], mu, k0, b));
1410
1411        let (k, r) = zl.form_tangent_and_resistance(&node_i, &node_j);
1412
1413        let yield_force = mu * 1000.0;
1414        let expected_v_uy = b * (k0 * 5.0) + (1.0 - b) * yield_force;
1415        let expected_v_uz = k0 * 0.1;
1416
1417        assert!(
1418            (r[SPATIAL_NDF + 1] - expected_v_uy).abs() < 1e-9,
1419            "Uy slides independently"
1420        );
1421        assert!(
1422            (r[SPATIAL_NDF + 2] - expected_v_uz).abs() < 1e-9,
1423            "Uz sticks independently"
1424        );
1425        assert!(
1426            (k[(1, 1)] - b * k0).abs() < 1e-9,
1427            "Uy tangent == b*k0 (sliding)"
1428        );
1429        assert!((k[(2, 2)] - k0).abs() < 1e-9, "Uz tangent == k0 (sticking)");
1430        assert_eq!(k[(1, 2)], 0.0, "no cross-talk between the two shear axes");
1431        assert_eq!(k[(2, 1)], 0.0, "no cross-talk between the two shear axes");
1432    }
1433
1434    fn approx(a: f64, b: f64) -> bool {
1435        (a - b).abs() < 1e-9
1436    }
1437
1438    /// local x = global y (a 90 degree turn about z): a spring on dof 0 must
1439    /// resist relative `uy`, and nothing along global `ux`.
1440    #[test]
1441    fn oriented_zero_length_spring_follows_the_local_x_axis() {
1442        let orient = Orientation::in_plane(0.0, 1.0).unwrap();
1443        let zl = ZeroLength::new(NodeId::default(), NodeId::default())
1444            .with_material(0, Material::Elastic { e: 100.0 })
1445            .with_orientation(orient)
1446            .unwrap();
1447        let node_i = Node::new([0.0, 0.0]);
1448        let mut node_j = Node::new([0.0, 0.0]);
1449        node_j.displacement[1] = 0.01;
1450        let (k, r) = zl.form_tangent_and_resistance(&node_i, &node_j);
1451        assert!(approx(r[4], 1.0) && approx(r[1], -1.0), "uy force");
1452        assert!(approx(k[(4, 4)], 100.0), "uy stiffness");
1453        assert!(approx(k[(3, 3)], 0.0), "no ux stiffness");
1454    }
1455
1456    /// 45 degrees: a unit global `ux` is `cos 45` of local x, so the force
1457    /// splits equally between global x and y, and `K` stays consistent with
1458    /// the resistance (linear material: `r = K u`).
1459    #[test]
1460    fn oriented_zero_length_at_45_degrees_splits_force_and_stays_consistent() {
1461        let orient = Orientation::in_plane(1.0, 1.0).unwrap();
1462        let zl = ZeroLength::new(NodeId::default(), NodeId::default())
1463            .with_material(0, Material::Elastic { e: 100.0 })
1464            .with_orientation(orient)
1465            .unwrap();
1466        let node_i = Node::new([0.0, 0.0]);
1467        let mut node_j = Node::new([0.0, 0.0]);
1468        node_j.displacement[0] = 0.02;
1469        let (k, r) = zl.form_tangent_and_resistance(&node_i, &node_j);
1470        assert!(approx(r[3], 100.0 * 0.02 * 0.5));
1471        assert!(approx(r[4], 100.0 * 0.02 * 0.5));
1472        let u = SVector::<f64, ELEMENT_DOF>::from_column_slice(&[0.0, 0.0, 0.0, 0.02, 0.0, 0.0]);
1473        assert!((k * u - r).norm() < 1e-9);
1474    }
1475
1476    #[test]
1477    fn oriented_zero_length_section_axis_follows_local_x() {
1478        let (e, area, iz): (f64, f64, f64) = (30000.0, 2.0, 1000.0);
1479        let h = (iz / area).sqrt();
1480        let section = FiberSection::new(vec![
1481            Fiber::new(h, area / 2.0, Material::Elastic { e }),
1482            Fiber::new(-h, area / 2.0, Material::Elastic { e }),
1483        ]);
1484        let orient = Orientation::in_plane(0.0, 1.0).unwrap();
1485        let zl = ZeroLengthSection::new(NodeId::default(), NodeId::default(), section)
1486            .with_orientation(orient)
1487            .unwrap();
1488        let node_i = Node::new([0.0, 0.0]);
1489        let mut node_j = Node::new([0.0, 0.0]);
1490        node_j.displacement[1] = 0.001;
1491        let (k, r) = zl.form_tangent_and_resistance(&node_i, &node_j);
1492        assert!((k[(4, 4)] - e * area).abs() < 1e-6, "axial stiffness on uy");
1493        assert!(k[(3, 3)].abs() < 1e-9, "no axial stiffness on ux");
1494        assert!((r[4] - e * area * 0.001).abs() < 1e-6);
1495    }
1496
1497    #[test]
1498    fn oriented_zero_length_2d_rejects_out_of_plane_and_degenerate_frames() {
1499        assert_eq!(
1500            Orientation::in_plane(0.0, 0.0),
1501            Err(OrientationError::Degenerate)
1502        );
1503        // in_plane is the general frame with yp = x turned 90 degrees.
1504        assert_eq!(
1505            Orientation::in_plane(1.0, 2.0),
1506            Orientation::new([1.0, 2.0, 0.0], [-2.0, 1.0, 0.0])
1507        );
1508        assert_eq!(
1509            Orientation::new([0.0, 0.0, 0.0], [0.0, 1.0, 0.0]),
1510            Err(OrientationError::Degenerate)
1511        );
1512        assert_eq!(
1513            Orientation::new([1.0, 0.0, 0.0], [2.0, 0.0, 0.0]),
1514            Err(OrientationError::Degenerate)
1515        );
1516        let tilted = Orientation::new([0.0, 0.0, 1.0], [1.0, 0.0, 0.0]).unwrap();
1517        assert!(matches!(
1518            ZeroLength::new(NodeId::default(), NodeId::default()).with_orientation(tilted),
1519            Err(OrientationError::OutOfPlane)
1520        ));
1521    }
1522
1523    /// local x = global Z, local y = global X, local z = global Y: so local
1524    /// `rx` is global `rz` — rotations ride the same frame as translations.
1525    #[test]
1526    fn oriented_zero_length3_rotates_translations_and_rotations_together() {
1527        let orient = Orientation::new([0.0, 0.0, 1.0], [1.0, 0.0, 0.0]).unwrap();
1528        let zl = ZeroLength3::new(Node3Id::default(), Node3Id::default())
1529            .with_material(0, Material::Elastic { e: 10.0 })
1530            .with_material(3, Material::Elastic { e: 7.0 })
1531            .with_orientation(orient);
1532        let node_i = Node3::new([0.0, 0.0, 0.0]);
1533        let mut node_j = Node3::new([0.0, 0.0, 0.0]);
1534        node_j.displacement[2] = 0.1; // uz = local ux
1535        node_j.displacement[5] = 0.2; // rz = local rx
1536        let (k, r) = zl.form_tangent_and_resistance(&node_i, &node_j);
1537        assert!(
1538            approx(r[SPATIAL_NDF + 2], 1.0),
1539            "uz force from the local x spring"
1540        );
1541        assert!(
1542            approx(r[SPATIAL_NDF + 5], 1.4),
1543            "rz moment from the local rx spring"
1544        );
1545        assert!(approx(r[SPATIAL_NDF], 0.0) && approx(r[SPATIAL_NDF + 3], 0.0));
1546        let mut u = SpatialElementVector::zeros();
1547        u[SPATIAL_NDF + 2] = 0.1;
1548        u[SPATIAL_NDF + 5] = 0.2;
1549        assert!((k * u - r).norm() < 1e-9);
1550    }
1551
1552    #[test]
1553    fn oriented_zero_length_section3_axis_follows_local_x() {
1554        let (e, area, iy, iz): (f64, f64, f64, f64) = (30000.0, 2.0, 800.0, 1000.0);
1555        let (hy, hz) = ((iz / area).sqrt(), (iy / area).sqrt());
1556        let section = FiberSection3::new(vec![
1557            Fiber3::new(hy, hz, area / 4.0, Material::Elastic { e }),
1558            Fiber3::new(-hy, hz, area / 4.0, Material::Elastic { e }),
1559            Fiber3::new(hy, -hz, area / 4.0, Material::Elastic { e }),
1560            Fiber3::new(-hy, -hz, area / 4.0, Material::Elastic { e }),
1561        ]);
1562        let orient = Orientation::new([0.0, 1.0, 0.0], [-1.0, 0.0, 0.0]).unwrap();
1563        let zl = ZeroLengthSection3::new(Node3Id::default(), Node3Id::default(), section)
1564            .with_orientation(orient);
1565        let node_i = Node3::new([0.0, 0.0, 0.0]);
1566        let mut node_j = Node3::new([0.0, 0.0, 0.0]);
1567        node_j.displacement[1] = 0.001; // uy = local ux
1568        let (k, r) = zl.form_tangent_and_resistance(&node_i, &node_j);
1569        assert!((k[(SPATIAL_NDF + 1, SPATIAL_NDF + 1)] - e * area).abs() < 1e-6);
1570        assert!(k[(SPATIAL_NDF, SPATIAL_NDF)].abs() < 1e-9);
1571        assert!((r[SPATIAL_NDF + 1] - e * area * 0.001).abs() < 1e-6);
1572    }
1573}