Skip to main content

carapace_core/model/
domain.rs

1use std::collections::HashSet;
2
3use faer::sparse::Triplet;
4use nalgebra::{DVector, SMatrix, SVector};
5use slotmap::{Key, SlotMap};
6
7use super::constraint::{self, LinearConstraint, ResolveError};
8use super::dof_table::{DofEntry, DofTable};
9use super::load_pattern::{
10    active_element_patterns, effective_element_load, ElementLoadComponents, LoadPattern,
11};
12use super::{
13    Axis3, DofRef, Element, Element3, ElementForce, ElementOps, GaussResponse, LoadPatternId,
14    LoadSeries, ModelError, Node, Node3Id, NodeId, NodeView, ShellResponse, SparseMatrix,
15    TangentSink, VectorSink, NDF, PLANAR_NDIM, SPATIAL_NDF, SPATIAL_NDIM,
16};
17
18/// Every `(equation, coefficient)` term one node-dof contributes to the
19/// assembled free-DOF system: none for a fixed or inactive dof, a single
20/// `(eq, 1.0)` for an ordinary free or identity-tied dof, or any number of
21/// terms for a constrained dof (a slave's value is a linear combination of
22/// free unknowns). Borrowed from the `DofTable`'s term arena, so there is no
23/// cap on the number of terms and no allocation in the assembly loop.
24#[derive(Debug, Clone, Copy)]
25enum DofTerms<'a> {
26    None,
27    One([(usize, f64); 1]),
28    Many(&'a [(usize, f64)]),
29}
30
31impl DofTerms<'_> {
32    fn as_slice(&self) -> &[(usize, f64)] {
33        match self {
34            DofTerms::None => &[],
35            DofTerms::One(term) => term,
36            DofTerms::Many(terms) => terms,
37        }
38    }
39
40    fn iter(&self) -> std::slice::Iter<'_, (usize, f64)> {
41        self.as_slice().iter()
42    }
43}
44
45/// Owns all nodes, elements, and load patterns. No serialization/broker
46/// machinery — this is the whole model, in memory, for one worker.
47/// `Clone` backs `Analysis`/`TransientAnalysis`'s snapshot-and-restore-on-
48/// failure (a failed step shouldn't leave nodal displacement/velocity/
49/// acceleration partway through a discarded Newton iteration) — see
50/// `Material`'s doc comment for why materials themselves don't need this
51/// protection. Also what makes multi-phase analysis composition possible:
52/// `Analysis::into_domain`/`TransientAnalysis::into_domain` hand this same
53/// `Domain` (nodal state, committed material history, and load-pattern
54/// freeze state all intact) to the next phase's builder.
55///
56/// Generic over the kinematic profile: `NDIM`/`NDOF`/`ELEMENT_DOF` (const
57/// generics, kept as separate parameters rather than computed from each
58/// other — stable Rust can't evaluate `2 * NDOF` inside a generic item), the
59/// node ID type, and `E: ElementOps<...>` (the element catalog — `Element`
60/// or `Element3`; its `Domain`-element-store key comes from `E::Id`, an
61/// `ElementOps` associated type rather than a further generic parameter
62/// here — see `ElementOps::Id`'s doc comment for why: a truly independent
63/// `EId` parameter had nothing else to constrain it and broke type
64/// inference at every `Domain::new()` call site). This bookkeeping
65/// (equation numbering, sparse assembly, state gather/scatter) doesn't care
66/// about element physics, only about DOF counts and `ElementOps`'s uniform
67/// shape, so it's genuinely one implementation rather than two — unlike
68/// `Truss`/`Truss3`, whose actual formulas differ and stay separate
69/// concrete types (see `ElementOps`'s doc comment for why that part can't
70/// be unified the same way).
71///
72/// All five parameters default to the planar profile, the same trick `Node`
73/// uses, so every existing bare `Domain` usage (`Domain::new()`, `fn f(d:
74/// &Domain)`, ...) keeps compiling unchanged. `Domain3` is the spatial
75/// instantiation.
76#[derive(Debug)]
77pub struct Domain<
78    const NDIM: usize = PLANAR_NDIM,
79    const NDOF: usize = NDF,
80    NId = NodeId,
81    E = Element,
82> where
83    NId: Key,
84    E: ElementOps<NDIM, NDOF, NId>,
85{
86    nodes: SlotMap<NId, Node<NDIM, NDOF>>,
87    elements: SlotMap<E::Id, E>,
88    constraints: Vec<LinearConstraint<NId>>,
89    /// The first structural problem `number_dofs` found in `constraints`
90    /// (duplicate slave, cycle, fixed slave); reported by `validate`.
91    constraint_error: Option<ModelError>,
92    load_patterns: SlotMap<LoadPatternId, LoadPattern<NDOF, NId, E::Id, E::Load>>,
93    default_pattern: LoadPatternId,
94    num_free_dofs: usize,
95    /// Pseudo-time of the last `commit` — what analyses that run on the
96    /// committed state without a time of their own (modal) evaluate element
97    /// loads at.
98    committed_time: f64,
99    /// How every node DOF maps onto the free-DOF system (equation number,
100    /// or resolved terms for constrained DOFs),
101    /// rebuilt by `number_dofs`.
102    dofs: DofTable<NId, NDOF>,
103}
104
105/// `Domain`'s spatial instantiation — six-DOF `Node3`/`Element3`. See
106/// `Domain`'s doc comment for why this is a type alias over one generic
107/// implementation rather than a hand-duplicated struct.
108pub type Domain3 = Domain<SPATIAL_NDIM, SPATIAL_NDF, Node3Id, Element3>;
109
110/// Manual rather than `#[derive(Clone)]`: the derive macro only adds `E:
111/// Clone`, not `E::Load: Clone` (it can't see through the associated type),
112/// so it under-constrains this struct and fails to compile at every call
113/// site instead. `Analysis::step`'s snapshot-and-restore-on-failure needs
114/// this — see `Domain`'s doc comment.
115impl<const NDIM: usize, const NDOF: usize, NId, E> Clone for Domain<NDIM, NDOF, NId, E>
116where
117    NId: Key,
118    E: ElementOps<NDIM, NDOF, NId> + Clone,
119    E::Load: Clone,
120{
121    fn clone(&self) -> Self {
122        Domain {
123            nodes: self.nodes.clone(),
124            elements: self.elements.clone(),
125            constraints: self.constraints.clone(),
126            constraint_error: self.constraint_error,
127            load_patterns: self.load_patterns.clone(),
128            default_pattern: self.default_pattern,
129            num_free_dofs: self.num_free_dofs,
130            committed_time: self.committed_time,
131            dofs: self.dofs.clone(),
132        }
133    }
134}
135
136impl<const NDIM: usize, const NDOF: usize, NId, E> Default for Domain<NDIM, NDOF, NId, E>
137where
138    NId: Key,
139    E: ElementOps<NDIM, NDOF, NId>,
140{
141    fn default() -> Self {
142        Self::new()
143    }
144}
145
146impl<const NDIM: usize, const NDOF: usize, NId, E> Domain<NDIM, NDOF, NId, E>
147where
148    NId: Key,
149    E: ElementOps<NDIM, NDOF, NId>,
150{
151    /// A fresh, empty model — already carrying one `LoadPattern`
152    /// (`default_pattern`, `LoadSeries::Linear { slope: 1.0 }`, unscaled)
153    /// so simple single-pattern models (the common case) don't need to
154    /// name a pattern at all: `domain.load_node(id, dof, value)` reaches
155    /// for it implicitly. Reproduces, exactly, the single-pattern behavior
156    /// every `Analysis` had before multi-pattern support existed.
157    pub fn new() -> Self {
158        let mut load_patterns = SlotMap::default();
159        let default_pattern =
160            load_patterns.insert(LoadPattern::new(LoadSeries::Linear { slope: 1.0 }));
161        Domain {
162            nodes: SlotMap::default(),
163            elements: SlotMap::default(),
164            constraints: Vec::new(),
165            constraint_error: None,
166            load_patterns,
167            default_pattern,
168            num_free_dofs: 0,
169            committed_time: 0.0,
170            dofs: DofTable::default(),
171        }
172    }
173
174    pub fn add_node(&mut self, node: Node<NDIM, NDOF>) -> NId {
175        self.nodes.insert(node)
176    }
177
178    pub fn add_element(&mut self, element: E) -> E::Id {
179        self.elements.insert(element)
180    }
181
182    pub fn node(&self, id: NId) -> &Node<NDIM, NDOF> {
183        &self.nodes[id]
184    }
185
186    pub fn element(&self, id: E::Id) -> &E {
187        &self.elements[id]
188    }
189
190    fn node_view(&self) -> NodeView<'_, NDIM, NDOF, NId> {
191        NodeView::new(&self.nodes)
192    }
193
194    /// An element's local nodal force at its current committed state — see
195    /// `ElementOps::local_force`'s doc comment. Never called from the
196    /// assembly hot loop (only from results recording), so looking the
197    /// element and its nodes up here rather than caching anything is fine.
198    pub fn element_local_force(&self, id: E::Id) -> ElementForce {
199        self.elements[id].local_force(&self.node_view())
200    }
201
202    /// Component `component` of the load `id` carries at `pseudo_time` — every
203    /// pattern's load on it, scaled by that pattern's factor (frozen patterns
204    /// at their frozen value), summed; `0.0` for an unloaded element. Local
205    /// axes: `ElementLoad` is `[wx, wy]`, `ElementLoad3` is `[wx, wy, wz]`.
206    pub fn element_load_component(&self, id: E::Id, pseudo_time: f64, component: usize) -> f64 {
207        let active = active_element_patterns(self.load_patterns.values(), pseudo_time);
208        effective_element_load(&active, id).map_or(0.0, |load| load.component(component))
209    }
210
211    /// `element_local_force` including the fixed-end effect of element loads:
212    /// the member end forces a loaded element actually carries
213    /// (`k·d − Σ factor·f_eq`, OpenSees's `K·u + p0`), with every pattern's
214    /// factor evaluated at `pseudo_time`. Identical to `element_local_force`
215    /// for an element with no load.
216    pub fn element_end_force(&self, id: E::Id, pseudo_time: f64) -> ElementForce {
217        let element = &self.elements[id];
218        let view = self.node_view();
219        let mut force = element.local_force(&view);
220        for (_, pattern) in self.load_patterns.iter() {
221            let factor = pattern.factor(pseudo_time);
222            if factor == 0.0 {
223                continue;
224            }
225            if let Some(load) = pattern.element_load(id) {
226                force.subtract_scaled(factor, &element.local_load_force(&view, Some(load)));
227            }
228        }
229        force
230    }
231
232    /// Every integration point's per-fiber `(strain, stress)` at `id`'s
233    /// current committed state — `None` for every element kind that isn't
234    /// fiber-discretized (`ElementOps::fiber_responses`'s doc comment).
235    /// Outer `Vec` (when `Some`) is one entry per integration point, inner
236    /// `Vec` one entry per fiber, in the order each was originally given to
237    /// the element's constructor — a fiber recorder resolves its `(section,
238    /// fiber)` indices against that same order.
239    pub fn element_fiber_responses(&self, id: E::Id) -> Option<Vec<Vec<(f64, f64)>>> {
240        self.elements[id].fiber_responses(&self.node_view())
241    }
242
243    /// Number of Gauss points of the continuum element `id` (zero for any other kind).
244    pub fn element_gauss_point_count(&self, id: E::Id) -> usize {
245        self.elements[id].gauss_point_count()
246    }
247
248    /// Components per Gauss-point strain/stress vector of `id` (3 for plane elements, 8 for shells).
249    pub fn element_gauss_component_count(&self, id: E::Id) -> usize {
250        self.elements[id].gauss_component_count()
251    }
252
253    /// Every Gauss point's committed strain and stress at `id` — `None` for
254    /// every element kind that is not a continuum element.
255    pub fn element_gauss_responses(&self, id: E::Id) -> Option<Vec<GaussResponse>> {
256        self.elements[id].gauss_responses(&self.node_view())
257    }
258
259    /// Every Gauss point's committed generalized strain and resultants at the shell `id` — `None`
260    /// for every element kind that is not a shell.
261    pub fn element_shell_responses(&self, id: E::Id) -> Option<Vec<ShellResponse>> {
262        self.elements[id].shell_responses(&self.node_view())
263    }
264
265    /// Support/equilibrium reaction at `(node, dof)`: net internal element
266    /// resistance there minus the total applied reference load at
267    /// `pseudo_time` (Chopra's standard reaction definition) — most useful
268    /// at a *fixed* DOF (a free DOF's residual is, by construction, what
269    /// `Algorithm::Newton` already drives to ~0 at convergence, so
270    /// reading its "reaction" is redundant but harmless). Needs no
271    /// equation number — unlike every other accessor here, this bypasses
272    /// `dof_terms`/the free-DOF system entirely by summing every element's
273    /// and pattern's own contribution to this one `(node, dof)` directly
274    /// (the same per-element/per-pattern loops `assemble_stiffness_
275    /// triplets`/`assemble_load_with` run, just for one DOF instead of
276    /// every free one), so it's well-defined even at a DOF `number_dofs`
277    /// never assigned an equation number to.
278    ///
279    /// Ground-motion excitation (`TransientAnalysis`) never needs a
280    /// separate term here. This returns the structural reaction;
281    /// `TransientAnalysis::reaction` adds the equivalent ground inertial load
282    /// at restrained DOFs. Relative velocity/acceleration remain zero there.
283    pub fn reaction(&self, node: NId, dof: usize, pseudo_time: f64) -> f64 {
284        let view = self.node_view();
285        let active = active_element_patterns(self.load_patterns.values(), pseudo_time);
286        let touches = |element: &E| element.nodes().contains(&node);
287
288        let mut resistance = PickDof::new(node, dof);
289        for (element_id, element) in self.elements.iter() {
290            if !touches(element) {
291                continue;
292            }
293            let load = effective_element_load(&active, element_id);
294            element.assemble_tangent(&view, load.as_ref(), &mut resistance);
295        }
296
297        let mut applied = 0.0;
298        for (_, pattern) in self.load_patterns.iter() {
299            let factor = pattern.factor(pseudo_time);
300            if factor == 0.0 {
301                continue;
302            }
303            if let Some(nodal_load) = pattern.nodal_load(node) {
304                applied += factor * nodal_load[dof];
305            }
306            for (element_id, element) in self.elements.iter() {
307                let Some(element_load) = pattern.element_load(element_id) else {
308                    continue;
309                };
310                if !touches(element) {
311                    continue;
312                }
313                let mut load = PickDof::new(node, dof);
314                element.assemble_load(&view, Some(element_load), &mut load);
315                applied += factor * load.total;
316            }
317        }
318
319        resistance.total - applied
320    }
321
322    /// Total lumped mass at a physical node/DOF, including attached elements.
323    /// Unlike the reduced mass diagonal this also includes restrained DOFs.
324    pub fn lumped_mass_at(&self, node: NId, dof: usize) -> f64 {
325        let view = self.node_view();
326        let mut mass = PickDof::new(node, dof);
327        for element in self.elements.values() {
328            if element.nodes().contains(&node) {
329                element.assemble_mass(&view, &mut mass);
330            }
331        }
332        self.nodes[node].mass[dof] + mass.total
333    }
334
335    /// Imposes `value` as the displacement of a *fixed* (single-point
336    /// constrained) DOF — a non-homogeneous SP constraint. A fixed DOF has
337    /// no equation number, so the imposed value simply lives in the node's
338    /// displacement and every element reads it like any other nodal
339    /// displacement; the free DOFs then respond to it on the next
340    /// `Analysis::step` (see `Analysis::step_prescribed`, which also makes
341    /// the update atomic with respect to a failed step). `reaction` reads
342    /// the force needed to hold it there. Returns `false`, changing
343    /// nothing, if the DOF isn't fixed or `value` is nonfinite.
344    pub fn prescribe_displacement(&mut self, node: NId, dof: usize, value: f64) -> bool {
345        if !self.can_prescribe(node, dof, value) {
346            return false;
347        }
348        self.nodes[node].displacement[dof] = value;
349        true
350    }
351
352    /// Whether `prescribe_displacement` would accept these arguments.
353    ///
354    /// A DOF that a constraint uses as a master is refused: constraints are
355    /// homogeneous and have already eliminated a fixed master's
356    /// contribution, so moving it would silently leave its slaves behind.
357    pub fn can_prescribe(&self, node: NId, dof: usize, value: f64) -> bool {
358        value.is_finite()
359            && dof < NDOF
360            && self.nodes.get(node).is_some_and(|n| n.fixed[dof])
361            && !self
362                .constraints
363                .iter()
364                .any(|c| c.terms.iter().any(|&(n, d, _)| n == node && d == dof))
365    }
366
367    /// `Domain::new()`'s always-present pattern — see its doc comment.
368    pub fn default_pattern(&self) -> LoadPatternId {
369        self.default_pattern
370    }
371
372    /// Add a new, independently-scaled `LoadPattern` (scale factor 1.0;
373    /// chain `.with_scale_factor` via `add_load_pattern_scaled` if needed).
374    /// Xara/OpenSees's `pattern Plain <tag> <series> {...}`.
375    pub fn add_load_pattern(&mut self, series: LoadSeries) -> LoadPatternId {
376        self.load_patterns.insert(LoadPattern::new(series))
377    }
378
379    /// Same as `add_load_pattern`, with an explicit scale factor (e.g. a
380    /// ground-motion PGA scale, or a load-factor-vs-displacement-target
381    /// unit conversion) applied on top of the series' own factor.
382    pub fn add_load_pattern_scaled(
383        &mut self,
384        series: LoadSeries,
385        scale_factor: f64,
386    ) -> LoadPatternId {
387        self.load_patterns
388            .insert(LoadPattern::new(series).with_scale_factor(scale_factor))
389    }
390
391    /// Add a nodal load to `pattern` — Xara/OpenSees's `load <node> <...>`
392    /// inside a `pattern` block.
393    pub fn add_nodal_load(&mut self, pattern: LoadPatternId, node: NId, dof: usize, value: f64) {
394        self.load_patterns[pattern].add_nodal_load(node, dof, value);
395    }
396
397    /// Convenience for the common single-pattern case: adds `value` to
398    /// `default_pattern()` directly, so simple models don't need to name a
399    /// pattern (`domain.load_node(id, 0, force)` instead of
400    /// `domain.add_nodal_load(domain.default_pattern(), id, 0, force)`).
401    pub fn load_node(&mut self, node: NId, dof: usize, value: f64) {
402        self.add_nodal_load(self.default_pattern, node, dof, value);
403    }
404
405    /// Add an element load (e.g. a beam-column's uniform transverse load)
406    /// to `pattern` — Xara/OpenSees's `eleLoad` inside a `pattern` block.
407    /// `load`'s type is this profile's `ElementOps::Load` — `ElementLoad`
408    /// for the planar profile, `ElementLoad3` for the spatial one (see
409    /// `ElementOps`'s doc comment).
410    pub fn add_element_load(&mut self, pattern: LoadPatternId, element: E::Id, load: E::Load) {
411        self.load_patterns[pattern].add_element_load(element, load);
412    }
413
414    /// Freeze `pattern`'s contribution at its value as of `pseudo_time` —
415    /// Xara/OpenSees's `loadConst -time <pseudo_time>` (there, applied to
416    /// every pattern in the domain; here, one pattern at a time, so a
417    /// caller composing phases can freeze only the pattern(s) that should
418    /// stop ramping, e.g. gravity, while others continue). Call this with
419    /// whatever pseudo-time the phase you're ending actually stopped at
420    /// (`StepResult::load_factor` / `TransientStepResult::time`) — see
421    /// `LoadPattern`'s doc comment for why the frozen value is captured
422    /// explicitly here rather than lazily cached on every assemble call.
423    pub fn hold_pattern_constant(&mut self, pattern: LoadPatternId, pseudo_time: f64) {
424        self.load_patterns[pattern].hold_constant(pseudo_time);
425    }
426
427    /// Constrains `slave`'s DOF to a linear combination of master DOFs on
428    /// any nodes: `u_slave = sum(coeff * u_master)`. Repeated masters are
429    /// merged and vanishing terms dropped; an empty result means
430    /// `u_slave = 0`. Problems that cannot be seen from one constraint (a
431    /// slave defined twice, a fixed slave, a dependency cycle, a master that
432    /// carries a prescribed value, an inconsistent initial state) are
433    /// reported by `validate`. Requires `ConstraintHandler::Transformation`.
434    pub fn add_constraint(&mut self, slave: (NId, usize), terms: &[(NId, usize, f64)]) {
435        assert!(
436            slave.1 < NDOF,
437            "constraint slave DOF {} out of range",
438            slave.1
439        );
440        assert!(
441            terms.iter().all(|t| t.1 < NDOF),
442            "constraint master DOF out of range"
443        );
444        self.constraints.push(LinearConstraint {
445            slave,
446            terms: constraint::normalize(terms),
447        });
448    }
449
450    /// Tie `dofs` of `constrained` exactly to the same DOFs of `retained`
451    /// (`u_c = u_r` for each listed dof) — Xara/OpenSees's `equalDOF`.
452    /// Requires `ConstraintHandler::Transformation`; see its doc comment
453    /// for how this is resolved.
454    pub fn equal_dof(&mut self, retained: NId, constrained: NId, dofs: &[usize]) {
455        for &dof in dofs {
456            self.add_constraint((constrained, dof), &[(retained, dof, 1.0)]);
457        }
458    }
459
460    /// Equation number of a node's DOF **if it is a free unknown** (a DOF
461    /// identity-tied to one shares its equation), or `None` for a fixed DOF,
462    /// an inactive DOF, or a constrained DOF that is a combination of
463    /// several unknowns. It says where an unknown lives, not what a DOF's
464    /// value is: to read a node DOF out of a free-DOF-indexed vector use
465    /// `value_at`. Used
466    /// internally by `Integrator::DisplacementControl` to locate its
467    /// controlled DOF in the free-DOF system, and `pub` (not just
468    /// `pub(crate)`) so a caller holding a free-DOF-indexed vector — e.g.
469    /// `modal_analysis`'s `Mode::shape` — can map one of its entries back
470    /// to a specific node/DOF without `core` needing to expose any of
471    /// `gather_displacement`/`scatter_state`'s broader internal-state
472    /// surface just for that one lookup.
473    pub fn equation_of(&self, node: NId, dof: usize) -> Option<usize> {
474        match self.dofs.entry(node, dof) {
475            DofEntry::Free(eq) => Some(eq),
476            DofEntry::Fixed | DofEntry::Inactive | DofEntry::Affine { .. } => None,
477        }
478    }
479
480    /// The value of `(node, dof)` in the free-DOF-indexed vector `q`: the
481    /// entry for a free unknown, the constraint's combination of entries for
482    /// a constrained DOF, and `0.0` for a fixed or inactive DOF. This is how
483    /// a mode shape, an influence vector, or any other free-DOF vector is
484    /// read back onto nodes; `equation_of` cannot do it for a constrained DOF
485    /// that depends on several unknowns (a rigid-diaphragm slave).
486    pub fn value_at(&self, q: &DVector<f64>, node: NId, dof: usize) -> f64 {
487        match self.dofs.entry(node, dof) {
488            DofEntry::Free(eq) => q[eq],
489            DofEntry::Affine { start, len } => self
490                .dofs
491                .terms(start, len)
492                .iter()
493                .map(|&(eq, coeff)| coeff * q[eq])
494                .sum(),
495            DofEntry::Fixed | DofEntry::Inactive => 0.0,
496        }
497    }
498
499    /// `true` for every free-DOF equation that is a rotation (local DOF
500    /// index `>= NDIM` — `rz` in the planar profile, `rx`/`ry`/`rz` in the
501    /// spatial one). Identity-tied DOFs share their retained equation and
502    /// DOF index, and a rigid diaphragm's retained rotation keeps its own,
503    /// so the classification is per independent coordinate. Used to pick
504    /// moment rather than force tolerances and rotation rather than
505    /// translation scales.
506    pub(crate) fn rotational_equations(&self) -> Vec<bool> {
507        let mut rotational = vec![false; self.num_free_dofs];
508        for (id, _) in self.nodes.iter() {
509            for dof in NDIM..NDOF {
510                if let DofEntry::Free(eq) = self.dofs.entry(id, dof) {
511                    rotational[eq] = true;
512                }
513            }
514        }
515        rotational
516    }
517
518    /// Whether any load pattern still follows an unfrozen
519    /// `LoadSeries::Path` (see `LoadPattern::is_unfrozen_path`).
520    pub(crate) fn has_unfrozen_path_series(&self) -> bool {
521        self.load_patterns
522            .iter()
523            .any(|(_, pattern)| pattern.is_unfrozen_path())
524    }
525
526    /// Whether `(node, dof)` can take part in the analysis: it is fixed,
527    /// constrained, stiffened by some element, used as a constraint master,
528    /// or carries a nodal mass. A DOF that is none of these has no equation
529    /// and nothing to resist a load. Reflects the last numbering
530    /// (`validate`/`AnalysisBuilder::build` run it).
531    pub fn is_active(&self, node: NId, dof: usize) -> bool {
532        self.dofs.entry(node, dof) != DofEntry::Inactive
533    }
534
535    /// Numbers the DOFs and checks the model for mistakes that would
536    /// otherwise be silently wrong: constraint problems (a slave defined
537    /// twice or fixed, a dependency cycle, a master carrying a prescribed
538    /// value, mass on a slave that combines several unknowns), an element
539    /// rejecting its own geometry, nodal loads on DOFs nothing uses, and a
540    /// constrained slave whose initial state contradicts its masters. Also
541    /// makes the initial state consistent with the constraints: a slave
542    /// whose initial displacement, velocity or acceleration is exactly zero
543    /// (the default) takes the value its masters imply; a nonzero value that
544    /// disagrees is an error, never overwritten. Called by
545    /// `AnalysisBuilder::build`, `modal_analysis` and `TransientAnalysis`.
546    pub fn validate(&mut self) -> Result<(), ModelError> {
547        self.number_dofs();
548        if let Some(error) = self.constraint_error {
549            return Err(error);
550        }
551        let view = NodeView::new(&self.nodes);
552        for (index, (_, element)) in self.elements.iter_mut().enumerate() {
553            element
554                .validate(&view)
555                .map_err(|reason| ModelError::InvalidElement {
556                    element: index,
557                    reason,
558                })?;
559            element.prepare(&view);
560        }
561        for pattern in self.load_patterns.values() {
562            for (id, load) in pattern.element_loads() {
563                if !self.elements[*id].accepts_load(load) {
564                    let element = self.elements.keys().position(|key| key == *id).unwrap_or(0);
565                    return Err(ModelError::IncompatibleElementLoad { element });
566                }
567            }
568        }
569        for (index, (id, _)) in self.nodes.iter().enumerate() {
570            for dof in 0..NDOF {
571                if self.dofs.entry(id, dof) != DofEntry::Inactive {
572                    continue;
573                }
574                let loaded = self
575                    .load_patterns
576                    .values()
577                    .any(|pattern| pattern.nodal_load(id).is_some_and(|load| load[dof] != 0.0));
578                if loaded {
579                    return Err(ModelError::LoadOnInactiveDof { node: index, dof });
580                }
581            }
582        }
583        self.validate_constraint_state()?;
584        self.validate_element_mass_on_constraints()
585    }
586
587    /// Element mass (continuum and frame elements with a density) landing on a DOF that
588    /// is constrained to several unknowns cannot go on a diagonal mass vector; report it
589    /// as a structured error instead of letting `assemble_mass_diagonal` panic.
590    fn validate_element_mass_on_constraints(&self) -> Result<(), ModelError> {
591        if self.constraints.is_empty() {
592            return Ok(());
593        }
594        struct Probe<'a, const NDIM: usize, const NDOF: usize, NId, E>
595        where
596            NId: Key,
597            E: ElementOps<NDIM, NDOF, NId>,
598        {
599            domain: &'a Domain<NDIM, NDOF, NId, E>,
600            offender: Option<(NId, usize)>,
601        }
602        impl<const NDIM: usize, const NDOF: usize, NId, E> VectorSink<NId> for Probe<'_, NDIM, NDOF, NId, E>
603        where
604            NId: Key,
605            E: ElementOps<NDIM, NDOF, NId>,
606        {
607            fn add<const N: usize>(&mut self, dofs: &[DofRef<NId>; N], v: &SVector<f64, N>) {
608                for (a, &(node, slot)) in dofs.iter().enumerate() {
609                    let terms = self.domain.dof_terms(node, slot as usize);
610                    if v[a] != 0.0 && terms.as_slice().len() > 1 && self.offender.is_none() {
611                        self.offender = Some((node, slot as usize));
612                    }
613                }
614            }
615        }
616        let view = self.node_view();
617        let mut probe = Probe {
618            domain: self,
619            offender: None,
620        };
621        for element in self.elements.values() {
622            element.assemble_mass(&view, &mut probe);
623        }
624        match probe.offender {
625            Some((node, dof)) => Err(ModelError::MassOnConstrainedDof {
626                node: self.node_index(node),
627                dof,
628            }),
629            None => Ok(()),
630        }
631    }
632
633    fn node_index(&self, id: NId) -> usize {
634        self.nodes.keys().position(|key| key == id).unwrap_or(0)
635    }
636
637    /// The constraint-related half of `validate` that needs node state.
638    fn validate_constraint_state(&mut self) -> Result<(), ModelError> {
639        for constraint in &self.constraints {
640            for &(node, dof, _) in &constraint.terms {
641                // A fixed master's contribution has been eliminated, which is
642                // only right while it stays zero.
643                if self.nodes[node].fixed[dof] && self.nodes[node].displacement[dof] != 0.0 {
644                    return Err(ModelError::ConstraintOnPrescribedDof {
645                        node: self.node_index(node),
646                        dof,
647                    });
648                }
649            }
650            let (slave, dof) = constraint.slave;
651            let several_unknowns = matches!(
652                self.dofs.entry(slave, dof),
653                DofEntry::Affine { len, .. } if len > 1
654            );
655            if several_unknowns && self.nodes[slave].mass[dof] != 0.0 {
656                return Err(ModelError::MassOnConstrainedDof {
657                    node: self.node_index(slave),
658                    dof,
659                });
660            }
661        }
662
663        // Project zero-valued slave state from the masters, in dependency
664        // order (repeat until nothing changes; a chain settles in at most
665        // one pass per link).
666        for _ in 0..=self.constraints.len() {
667            let mut changed = false;
668            for index in 0..self.constraints.len() {
669                let (slave, dof) = self.constraints[index].slave;
670                let implied = self.implied_state(index);
671                let node = &mut self.nodes[slave];
672                let targets = [
673                    &mut node.displacement[dof],
674                    &mut node.velocity[dof],
675                    &mut node.acceleration[dof],
676                ];
677                for (target, value) in targets.into_iter().zip(implied) {
678                    if *target == 0.0 && value != 0.0 {
679                        *target = value;
680                        changed = true;
681                    }
682                }
683            }
684            if !changed {
685                break;
686            }
687        }
688        for index in 0..self.constraints.len() {
689            let (slave, dof) = self.constraints[index].slave;
690            let implied = self.implied_state(index);
691            let node = &self.nodes[slave];
692            let actual = [
693                node.displacement[dof],
694                node.velocity[dof],
695                node.acceleration[dof],
696            ];
697            for (actual, implied) in actual.into_iter().zip(implied) {
698                if (actual - implied).abs() > 1e-12 * (1.0 + actual.abs() + implied.abs()) {
699                    return Err(ModelError::InconsistentInitialState {
700                        node: self.node_index(slave),
701                        dof,
702                    });
703                }
704            }
705        }
706        Ok(())
707    }
708
709    /// The displacement, velocity and acceleration constraint `index`'s
710    /// masters imply for its slave.
711    fn implied_state(&self, index: usize) -> [f64; 3] {
712        let mut implied = [0.0; 3];
713        for &(node, dof, coeff) in &self.constraints[index].terms {
714            let node = &self.nodes[node];
715            implied[0] += coeff * node.displacement[dof];
716            implied[1] += coeff * node.velocity[dof];
717            implied[2] += coeff * node.acceleration[dof];
718        }
719        implied
720    }
721
722    /// Assign a sequential equation number to every free, unconstrained
723    /// DOF, in node insertion order, then alias every multi-point-
724    /// constrained DOF to its retained node's equation number for that DOF
725    /// (`ConstraintHandler::Transformation`). Fixed
726    /// DOFs, and constrained DOFs whose retained DOF is itself fixed, get
727    /// no equation number. This is the entire numbering pass — done once,
728    /// not re-checked every step (no `hasDomainChanged()`-style
729    /// machinery).
730    ///
731    /// Constrained DOFs must be excluded from the *first* pass (not just
732    /// overwritten afterwards) — otherwise the equation number allocated
733    /// to them before being overwritten is never referenced by any node,
734    /// leaving an all-zero row/column in the assembled system.
735    ///
736    /// Returns the number of free DOFs (the size of the global system).
737    pub(crate) fn number_dofs(&mut self) -> usize {
738        let slaves: HashSet<(NId, usize)> = self.constraints.iter().map(|c| c.slave).collect();
739        self.constraint_error = None;
740
741        // A DOF is active if an element stiffens it, a constraint uses it as
742        // a master (a diaphragm's retained node typically has no element at
743        // all), or it carries a nodal mass. Everything else is not an
744        // equation.
745        let mut active: HashSet<(NId, usize)> = HashSet::new();
746        for (_, element) in self.elements.iter() {
747            let mask = element.dof_mask();
748            for node in element.nodes() {
749                for dof in (0..NDOF).filter(|&dof| mask.contains(dof)) {
750                    active.insert((node, dof));
751                }
752            }
753        }
754        // A nodal mass gives its DOF inertia even with no stiffness (a free
755        // mass integrates fine in a transient analysis), so it is active.
756        for (id, node) in self.nodes.iter() {
757            for dof in (0..NDOF).filter(|&dof| node.mass[dof] != 0.0) {
758                active.insert((id, dof));
759            }
760        }
761        for constraint in &self.constraints {
762            for &(node, dof, _) in &constraint.terms {
763                active.insert((node, dof));
764            }
765        }
766
767        let mut table = DofTable::<NId, NDOF>::for_nodes(self.nodes.keys());
768        let mut next = 0;
769        for (index, (id, node)) in self.nodes.iter().enumerate() {
770            for dof in 0..NDOF {
771                if slaves.contains(&(id, dof)) {
772                    if node.fixed[dof] {
773                        self.constraint_error
774                            .get_or_insert(ModelError::SlaveIsFixed { node: index, dof });
775                    }
776                    continue;
777                }
778                if node.fixed[dof] {
779                    continue;
780                }
781                if !active.contains(&(id, dof)) {
782                    table.set(id, dof, DofEntry::Inactive);
783                    continue;
784                }
785                table.set(id, dof, DofEntry::Free(next));
786                next += 1;
787            }
788        }
789
790        if let Err(error) = constraint::resolve(&self.constraints, &mut table) {
791            let (ResolveError::DuplicateSlave((id, dof)) | ResolveError::Cycle((id, dof))) = error;
792            let node = self.nodes.keys().position(|k| k == id).unwrap_or(0);
793            self.constraint_error.get_or_insert(match error {
794                ResolveError::DuplicateSlave(_) => ModelError::DuplicateSlave { node, dof },
795                ResolveError::Cycle(_) => ModelError::ConstraintCycle { node, dof },
796            });
797        }
798
799        self.dofs = table;
800        self.num_free_dofs = next;
801        next
802    }
803
804    /// Pseudo-time of the last `commit` (zero before any).
805    pub(crate) fn committed_time(&self) -> f64 {
806        self.committed_time
807    }
808
809    pub fn num_free_dofs(&self) -> usize {
810        self.num_free_dofs
811    }
812
813    /// Whether any `equal_dof`/`rigid_diaphragm` constraint has been added —
814    /// used by `AnalysisBuilder<Ready>::build` to reject `ConstraintHandler
815    /// ::Plain` (which can't resolve them) early, at model-construction
816    /// time rather than as a silently-wrong solve. Also used by callers
817    /// (e.g. `carapace-wasm`'s decoder) to pick `ConstraintHandler::
818    /// Transformation` automatically whenever a domain actually has any.
819    pub fn has_mp_constraints(&self) -> bool {
820        !self.constraints.is_empty()
821    }
822
823    /// Every `(equation, coefficient)` term `node_id`'s `dof` contributes
824    /// to the assembled free-DOF system — the single source every assembly
825    /// hot loop (`assemble_stiffness_triplets`, `assemble_mass_diagonal`,
826    /// `assemble_load_with`) reads instead of `node.equation[dof]`
827    /// directly, so each of them handles ordinary free dofs, identity-tied
828    /// dofs, and affine-tied (rigid-diaphragm) dofs uniformly rather than
829    /// three separate code paths.
830    fn dof_terms(&self, node_id: NId, dof: usize) -> DofTerms<'_> {
831        match self.dofs.entry(node_id, dof) {
832            DofEntry::Free(eq) => DofTerms::One([(eq, 1.0)]),
833            DofEntry::Affine { start, len } => DofTerms::Many(self.dofs.terms(start, len)),
834            DofEntry::Fixed | DofEntry::Inactive => DofTerms::None,
835        }
836    }
837
838    /// Debug-build guard behind `ElementOps::dof_mask`: an element that
839    /// produces a stiffness, resistance, load or mass on a DOF that is
840    /// inactive (not fixed, not constrained, not stiffened by any element,
841    /// not a constraint master, no nodal mass) has under-declared its mask, and the
842    /// contribution would be silently dropped. Contributions below a
843    /// relative 1e-10 of the element's largest are treated as zero (an
844    /// orientation frame leaves round-off in entries that are zero in exact
845    /// arithmetic).
846    #[cfg(debug_assertions)]
847    fn assert_declared<const N: usize>(
848        &self,
849        dofs: &[DofRef<NId>; N],
850        k: Option<&SMatrix<f64, N, N>>,
851        values: &[f64],
852    ) {
853        let scale = |it: &mut dyn Iterator<Item = f64>| it.fold(0.0_f64, |m, v| m.max(v.abs()));
854        let vector_scale = scale(&mut values.iter().copied());
855        let diagonal_scale = k.map_or(0.0, |k| scale(&mut (0..N).map(|i| k[(i, i)])));
856        for (a, &(node, slot)) in dofs.iter().enumerate() {
857            if self.dofs.entry(node, slot as usize) != DofEntry::Inactive {
858                continue;
859            }
860            let stray_value = values[a].abs() > 1e-10 * vector_scale;
861            let stray_stiffness = k.is_some_and(|k| k[(a, a)].abs() > 1e-10 * diagonal_scale);
862            assert!(
863                !(stray_value || stray_stiffness),
864                "an element contributes to DOF {slot} of a node on which it did not declare a DOF in \
865                 `ElementOps::dof_mask` (and nothing else uses that DOF), so the contribution would be \
866                 silently dropped"
867            );
868        }
869    }
870
871    /// Number DOFs (idempotent given a fixed set of nodes) and assemble the
872    /// lumped-mass matrix's diagonal over free DOFs — user-assigned
873    /// `Node::mass` plus each element's own lumped mass (`Element::
874    /// form_mass`; zero unless a `Truss`/`ElasticBeamColumn` was given
875    /// a nonzero `density`). Needed by modal and time-history analysis. Just the diagonal, not a full (dense or sparse) N×N
876    /// matrix — `M` is diagonal by construction (lumped mass), so storing
877    /// anything more is pure waste, the same class of mistake a dense
878    /// stiffness matrix would be (see `SparseMatrix`'s doc comment).
879    pub fn assemble_mass_diagonal(&mut self) -> DVector<f64> {
880        self.number_dofs();
881        let n = self.num_free_dofs;
882        let mut mass = DVector::<f64>::zeros(n);
883
884        for (id, node) in self.nodes.iter() {
885            for dof in 0..NDOF {
886                accumulate_mass(&mut mass, self.dof_terms(id, dof), node.mass[dof]);
887            }
888        }
889
890        let view = self.node_view();
891        let mut sink = MassSink {
892            domain: self,
893            mass: &mut mass,
894        };
895        for (_, element) in self.elements.iter() {
896            element.assemble_mass(&view, &mut sink);
897        }
898
899        mass
900    }
901
902    /// Per-element tangent-stiffness triplets and the assembled internal
903    /// resisting force, over free DOFs, at the current nodal displacement
904    /// state. Duplicate `(row, col)` triplets (every DOF shared by more
905    /// than one element) are summed by whoever consumes them — `faer`'s
906    /// triplet constructor does this automatically.
907    ///
908    /// `pseudo_time` fixes the element loads that state-dependent elements
909    /// (`ForceBeamColumn`) fold into their own resistance — see
910    /// `ElementOps::form_tangent_and_resistance`.
911    fn assemble_stiffness_triplets(
912        &self,
913        pseudo_time: f64,
914    ) -> (Vec<Triplet<usize, usize, f64>>, DVector<f64>) {
915        let n = self.num_free_dofs;
916        let active = active_element_patterns(self.load_patterns.values(), pseudo_time);
917        let view = self.node_view();
918        let mut sink = TripletSink {
919            domain: self,
920            triplets: Vec::new(),
921            resistance: DVector::<f64>::zeros(n),
922        };
923        for (element_id, element) in self.elements.iter() {
924            let load = effective_element_load(&active, element_id);
925            element.assemble_tangent(&view, load.as_ref(), &mut sink);
926        }
927        (sink.triplets, sink.resistance)
928    }
929
930    /// Assemble the global tangent stiffness (sparse) and internal
931    /// resisting force over free DOFs only, at the current nodal
932    /// displacement state. No load-pattern contribution — see
933    /// `assemble_reference_load`.
934    pub(crate) fn assemble_tangent_and_resistance(
935        &self,
936        pseudo_time: f64,
937    ) -> (SparseMatrix, DVector<f64>) {
938        let n = self.num_free_dofs;
939        let (triplets, resistance) = self.assemble_stiffness_triplets(pseudo_time);
940        let k = SparseMatrix::try_new_from_triplets(n, n, &triplets)
941            .expect("equation numbers are always in [0, num_free_dofs)");
942        (k, resistance)
943    }
944
945    /// Benchmark hook (`core/examples/benchmark_continuum.rs`): wall time of
946    /// the element tangent loop, sparse-matrix construction, the first
947    /// factorization (symbolic + numeric), a repeat factorization (numeric
948    /// only) and one solve, as `[elements, matrix, first_factor,
949    /// repeat_factor, solve]` seconds plus the triplet count. Not for wasm
950    /// (it reads `Instant`).
951    #[doc(hidden)]
952    pub fn bench_assembly(&self, pseudo_time: f64) -> ([f64; 5], usize) {
953        let n = self.num_free_dofs;
954        let lap = |start: std::time::Instant| start.elapsed().as_secs_f64();
955        let start = std::time::Instant::now();
956        let (triplets, resistance) = self.assemble_stiffness_triplets(pseudo_time);
957        let elements = lap(start);
958        let start = std::time::Instant::now();
959        let k = SparseMatrix::try_new_from_triplets(n, n, &triplets)
960            .expect("equation numbers are always in [0, num_free_dofs)");
961        let matrix = lap(start);
962        let solver = crate::analysis::SparseSolver::new();
963        let start = std::time::Instant::now();
964        let factor = solver.factor(&k).expect("benchmark system is nonsingular");
965        let first = lap(start);
966        let start = std::time::Instant::now();
967        let _ = solver.factor(&k).expect("benchmark system is nonsingular");
968        let repeat = lap(start);
969        let rhs = DVector::from_element(n, 1.0) + resistance;
970        let start = std::time::Instant::now();
971        let _ = factor.solve(&rhs);
972        (
973            [elements, matrix, first, repeat, lap(start)],
974            triplets.len(),
975        )
976    }
977
978    /// Assemble the total applied load (every `LoadPattern`'s nodal +
979    /// element equivalent loads, each scaled by its own factor at
980    /// `pseudo_time` — frozen patterns use their frozen value regardless of
981    /// `pseudo_time`, see `LoadPattern::factor`) over free DOFs. Already
982    /// fully scaled — unlike the single-pattern design this replaced,
983    /// callers no longer multiply the result by an external load factor
984    /// (see `form_tangent_and_residual`).
985    pub(crate) fn assemble_reference_load(&self, pseudo_time: f64) -> DVector<f64> {
986        self.assemble_load_with(|p| p.factor(pseudo_time))
987    }
988
989    /// `d(assemble_reference_load)/d(pseudo_time)` at `pseudo_time` — the
990    /// unit-load probe `Integrator::DisplacementControl` needs (how much
991    /// the *total* applied load changes per unit pseudo-time increment;
992    /// frozen/`Constant`-series patterns contribute zero, see `LoadPattern::
993    /// sensitivity`/`LoadSeries::slope`'s doc comments). With the single
994    /// default `Linear { slope: 1.0 }` pattern (every model that hasn't
995    /// added a second pattern), this is numerically identical to
996    /// `assemble_reference_load` — no behavior change for single-pattern
997    /// models.
998    pub(crate) fn assemble_reference_load_sensitivity(&self, pseudo_time: f64) -> DVector<f64> {
999        self.assemble_load_with(|p| p.sensitivity(pseudo_time))
1000    }
1001
1002    /// Shared core of `assemble_reference_load`/`_sensitivity`: sum every
1003    /// pattern's nodal + element reference load, each scaled by whatever
1004    /// `pattern_factor` computes for that pattern (its current factor, or
1005    /// its sensitivity — the only difference between the two callers).
1006    fn assemble_load_with(
1007        &self,
1008        pattern_factor: impl Fn(&LoadPattern<NDOF, NId, E::Id, E::Load>) -> f64,
1009    ) -> DVector<f64> {
1010        let n = self.num_free_dofs;
1011        let mut load = DVector::<f64>::zeros(n);
1012
1013        for (_, pattern) in self.load_patterns.iter() {
1014            let factor = pattern_factor(pattern);
1015            if factor == 0.0 {
1016                continue;
1017            }
1018
1019            for (node_id, _node) in self.nodes.iter() {
1020                let Some(nodal_load) = pattern.nodal_load(node_id) else {
1021                    continue;
1022                };
1023                for (dof, &value) in nodal_load.iter().enumerate() {
1024                    for &(eq, coeff) in self.dof_terms(node_id, dof).iter() {
1025                        load[eq] += coeff * factor * value;
1026                    }
1027                }
1028            }
1029
1030            let view = self.node_view();
1031            for (element_id, element) in self.elements.iter() {
1032                let Some(element_load) = pattern.element_load(element_id) else {
1033                    continue;
1034                };
1035                let mut sink = LoadSink {
1036                    domain: self,
1037                    factor,
1038                    load: &mut load,
1039                };
1040                element.assemble_load(&view, Some(element_load), &mut sink);
1041            }
1042        }
1043
1044        load
1045    }
1046
1047    /// Assemble the global tangent stiffness and unbalanced-force residual
1048    /// (`reference_load(pseudo_time) - internal_resistance`) over free DOFs
1049    /// only. Called once per Newton iteration by `Analysis::step`. Unlike
1050    /// before multi-pattern support, `pseudo_time` is *not* an external
1051    /// multiplier on a single reference-load vector — each `LoadPattern`
1052    /// scales its own contribution internally (`assemble_reference_load`),
1053    /// so this is just their difference from internal resistance.
1054    pub(crate) fn form_tangent_and_residual(
1055        &self,
1056        pseudo_time: f64,
1057    ) -> (SparseMatrix, DVector<f64>) {
1058        let (k, resistance) = self.assemble_tangent_and_resistance(pseudo_time);
1059        let residual = self.assemble_reference_load(pseudo_time) - resistance;
1060        (k, residual)
1061    }
1062
1063    /// A unit "influence vector" over free DOFs: `1.0` at every DOF whose
1064    /// local index is `dof_direction`, `0.0` elsewhere — Chopra's ι, what
1065    /// `TransientAnalysis`'s ground-motion excitation needs to turn a
1066    /// scalar ground acceleration into an effective nodal force vector
1067    /// (`-M·ι·ag(t)`, mass-proportional, not reference-load-proportional —
1068    /// see `GroundMotion`). Computed once at `TransientAnalysis::new` time,
1069    /// not every step (like `mass` itself).
1070    pub fn direction_incidence(&self, dof_direction: usize) -> DVector<f64> {
1071        let mut incidence = DVector::<f64>::zeros(self.num_free_dofs);
1072        for (id, _) in self.nodes.iter() {
1073            if let DofEntry::Free(eq) = self.dofs.entry(id, dof_direction) {
1074                incidence[eq] = 1.0;
1075            }
1076        }
1077        incidence
1078    }
1079
1080    /// Commit every element's material(s) at the current (final, converged)
1081    /// nodal state — called once per successful `Analysis`/
1082    /// `TransientAnalysis` step, never during Newton iteration itself. See
1083    /// `Material`'s doc comment for the trial/commit design this closes
1084    /// the loop on.
1085    ///
1086    /// `pseudo_time` is the converged step's pseudo-time: a state-dependent
1087    /// element (`ForceBeamColumn`) commits the state that is consistent with
1088    /// the element load at that time.
1089    pub(crate) fn commit(&mut self, pseudo_time: f64) {
1090        self.committed_time = pseudo_time;
1091        let active = active_element_patterns(self.load_patterns.values(), pseudo_time);
1092        // `self.nodes`, `self.elements` and `self.load_patterns` are
1093        // disjoint fields, so the node view can borrow `nodes` while
1094        // `elements` is iterated mutably.
1095        let view = NodeView::new(&self.nodes);
1096        for (element_id, element) in self.elements.iter_mut() {
1097            let load = effective_element_load(&active, element_id);
1098            element.commit(&view, load.as_ref());
1099        }
1100    }
1101
1102    /// Scatter a global free-DOF displacement increment back onto nodes.
1103    /// Every ordinarily-numbered dof (free, or identity-tied via
1104    /// an identity tie) reads its increment directly out of `du`; every
1105    /// constrained dof (a rigid diaphragm's lever-arm
1106    /// coupling) then gets its increment *derived* from the same `du`,
1107    /// applying the constraint's linear combination directly to the
1108    /// increment rather than to an absolute value — valid because the
1109    /// relation is linear and homogeneous (constant geometry-derived
1110    /// coefficients, no offset term), so it holds identically for an
1111    /// increment as it does for an absolute displacement.
1112    pub(crate) fn apply_displacement_increment(&mut self, du: &DVector<f64>) {
1113        for (id, node) in self.nodes.iter_mut() {
1114            for dof in 0..NDOF {
1115                match self.dofs.entry(id, dof) {
1116                    DofEntry::Free(eq) => node.displacement[dof] += du[eq],
1117                    DofEntry::Affine { start, len } => {
1118                        let delta: f64 = self
1119                            .dofs
1120                            .terms(start, len)
1121                            .iter()
1122                            .map(|&(eq, coeff)| coeff * du[eq])
1123                            .sum();
1124                        node.displacement[dof] += delta;
1125                    }
1126                    DofEntry::Fixed | DofEntry::Inactive => {}
1127                }
1128            }
1129        }
1130    }
1131
1132    /// Gather free-DOF displacement/velocity/acceleration into dense
1133    /// vectors — `TransientAnalysis` state, unlike static `Analysis`
1134    /// which only ever mutates displacement incrementally.
1135    pub(crate) fn gather_displacement(&self) -> DVector<f64> {
1136        self.gather(|node, dof| node.displacement[dof])
1137    }
1138    pub(crate) fn gather_velocity(&self) -> DVector<f64> {
1139        self.gather(|node, dof| node.velocity[dof])
1140    }
1141    pub(crate) fn gather_acceleration(&self) -> DVector<f64> {
1142        self.gather(|node, dof| node.acceleration[dof])
1143    }
1144    fn gather(&self, get: impl Fn(&Node<NDIM, NDOF>, usize) -> f64) -> DVector<f64> {
1145        let mut v = DVector::<f64>::zeros(self.num_free_dofs);
1146        for (id, node) in self.nodes.iter() {
1147            for dof in 0..NDOF {
1148                if let DofEntry::Free(eq) = self.dofs.entry(id, dof) {
1149                    v[eq] = get(node, dof);
1150                }
1151            }
1152        }
1153        v
1154    }
1155
1156    /// Replace (not increment — every `TransientAnalysis` step computes a
1157    /// full new state, not a correction) each free DOF's displacement,
1158    /// velocity, and acceleration. Affine-tied dofs (see
1159    /// `apply_displacement_increment`'s doc comment) are derived from `u`/
1160    /// `v`/`a` the same way — the linear relation holds identically for
1161    /// displacement, velocity, and acceleration.
1162    pub(crate) fn scatter_state(&mut self, u: &DVector<f64>, v: &DVector<f64>, a: &DVector<f64>) {
1163        for (id, node) in self.nodes.iter_mut() {
1164            for dof in 0..NDOF {
1165                match self.dofs.entry(id, dof) {
1166                    DofEntry::Free(eq) => {
1167                        node.displacement[dof] = u[eq];
1168                        node.velocity[dof] = v[eq];
1169                        node.acceleration[dof] = a[eq];
1170                    }
1171                    DofEntry::Affine { start, len } => {
1172                        let (mut du, mut dv, mut da) = (0.0, 0.0, 0.0);
1173                        for &(eq, coeff) in self.dofs.terms(start, len) {
1174                            du += coeff * u[eq];
1175                            dv += coeff * v[eq];
1176                            da += coeff * a[eq];
1177                        }
1178                        node.displacement[dof] = du;
1179                        node.velocity[dof] = dv;
1180                        node.acceleration[dof] = da;
1181                    }
1182                    DofEntry::Fixed | DofEntry::Inactive => {}
1183                }
1184            }
1185        }
1186    }
1187
1188    /// `K * v` for an arbitrary free-DOF vector `v` — used once, to compute
1189    /// `TransientAnalysis`'s initial acceleration from equilibrium (`M*a0 =
1190    /// F0 - K*u0 - C*v0`, and `C` has a `K*v0` term). Reuses the same
1191    /// triplets `assemble_tangent_and_resistance` builds its sparse matrix
1192    /// from, computed directly (no `faer` matrix construction needed for a
1193    /// single matrix-vector product).
1194    pub(crate) fn multiply_stiffness(&self, v: &DVector<f64>, pseudo_time: f64) -> DVector<f64> {
1195        let (triplets, _resistance) = self.assemble_stiffness_triplets(pseudo_time);
1196        let mut result = DVector::<f64>::zeros(self.num_free_dofs);
1197        for t in &triplets {
1198            result[t.row] += t.val * v[t.col];
1199        }
1200        result
1201    }
1202
1203    /// Newmark's effective dynamic operator and internal resistance, at the
1204    /// domain's *current* state (whatever trial displacement is on it when
1205    /// called): `K_eff = stiffness_coeff*K(u) + mass_coeff*diag(mass)`,
1206    /// `resistance = F_int(u)` — the `Op`/`F_int` half of `docs/
1207    /// algorithms.md` §6.1's Newton corrector residual (`TransientAnalysis::
1208    /// try_step` composes the rest — the damping/inertial terms, which need
1209    /// `v_trial`/`a_trial`, not just `u` — around this). `stiffness_coeff`/
1210    /// `mass_coeff` are `docs/algorithms.md` §6.1's `c1`/`c3` (Newmark's
1211    /// `a1 + a4*alpha_m`/`1 + a4*beta_k`).
1212    pub(crate) fn assemble_newmark_system(
1213        &self,
1214        mass: &DVector<f64>,
1215        mass_coeff: f64,
1216        stiffness_coeff: f64,
1217        pseudo_time: f64,
1218    ) -> (SparseMatrix, DVector<f64>) {
1219        let n = self.num_free_dofs;
1220        let (triplets, resistance) = self.assemble_stiffness_triplets(pseudo_time);
1221
1222        let mut eff_triplets = Vec::with_capacity(triplets.len() + n);
1223        for t in &triplets {
1224            eff_triplets.push(Triplet::new(t.row, t.col, stiffness_coeff * t.val));
1225        }
1226        for i in 0..n {
1227            eff_triplets.push(Triplet::new(i, i, mass_coeff * mass[i]));
1228        }
1229
1230        let k_eff = SparseMatrix::try_new_from_triplets(n, n, &eff_triplets)
1231            .expect("equation numbers are always in [0, num_free_dofs)");
1232        (k_eff, resistance)
1233    }
1234}
1235
1236/// Adds a lumped-mass value on one node DOF to the diagonal mass vector.
1237/// A DOF that is a single scaled unknown (`u = c * q`) contributes
1238/// `c^2 * m`; one that combines several unknowns would need the full
1239/// `T^T M T`, which a diagonal mass vector cannot hold.
1240fn accumulate_mass(mass: &mut DVector<f64>, terms: DofTerms<'_>, value: f64) {
1241    if value == 0.0 {
1242        return;
1243    }
1244    match terms.as_slice() {
1245        [] => {}
1246        [(eq, coeff)] => mass[*eq] += coeff * coeff * value,
1247        _ => panic!(
1248            "assemble_mass_diagonal: mass on a DOF constrained to a combination of several unknowns (a \
1249             rigid diaphragm or rigid link slave) isn't supported — the lumped mass diagonal can't \
1250             represent the rotational inertia this would induce at the master; assign the mass at the \
1251             master node (or an unconstrained node) instead"
1252        ),
1253    }
1254}
1255
1256/// Scatters element tangents into triplets and element resistances into the
1257/// free-DOF resistance vector, through each DOF's equation terms.
1258struct TripletSink<'a, const NDIM: usize, const NDOF: usize, NId, E>
1259where
1260    NId: Key,
1261    E: ElementOps<NDIM, NDOF, NId>,
1262{
1263    domain: &'a Domain<NDIM, NDOF, NId, E>,
1264    triplets: Vec<Triplet<usize, usize, f64>>,
1265    resistance: DVector<f64>,
1266}
1267
1268impl<const NDIM: usize, const NDOF: usize, NId, E> TangentSink<NId>
1269    for TripletSink<'_, NDIM, NDOF, NId, E>
1270where
1271    NId: Key,
1272    E: ElementOps<NDIM, NDOF, NId>,
1273{
1274    fn add<const N: usize>(
1275        &mut self,
1276        dofs: &[DofRef<NId>; N],
1277        k: &SMatrix<f64, N, N>,
1278        r: &SVector<f64, N>,
1279    ) {
1280        #[cfg(debug_assertions)]
1281        self.domain.assert_declared(dofs, Some(k), r.as_slice());
1282        let dof_terms: [DofTerms<'_>; N] =
1283            std::array::from_fn(|a| self.domain.dof_terms(dofs[a].0, dofs[a].1 as usize));
1284        for (a, terms_a) in dof_terms.iter().enumerate() {
1285            for &(eq_a, coeff_a) in terms_a.iter() {
1286                self.resistance[eq_a] += coeff_a * r[a];
1287                for (b, terms_b) in dof_terms.iter().enumerate() {
1288                    for &(eq_b, coeff_b) in terms_b.iter() {
1289                        self.triplets
1290                            .push(Triplet::new(eq_a, eq_b, coeff_a * coeff_b * k[(a, b)]));
1291                    }
1292                }
1293            }
1294        }
1295    }
1296}
1297
1298/// Scatters scaled element load vectors into the free-DOF load vector.
1299struct LoadSink<'a, const NDIM: usize, const NDOF: usize, NId, E>
1300where
1301    NId: Key,
1302    E: ElementOps<NDIM, NDOF, NId>,
1303{
1304    domain: &'a Domain<NDIM, NDOF, NId, E>,
1305    factor: f64,
1306    load: &'a mut DVector<f64>,
1307}
1308
1309impl<const NDIM: usize, const NDOF: usize, NId, E> VectorSink<NId>
1310    for LoadSink<'_, NDIM, NDOF, NId, E>
1311where
1312    NId: Key,
1313    E: ElementOps<NDIM, NDOF, NId>,
1314{
1315    fn add<const N: usize>(&mut self, dofs: &[DofRef<NId>; N], v: &SVector<f64, N>) {
1316        #[cfg(debug_assertions)]
1317        self.domain.assert_declared(dofs, None, v.as_slice());
1318        for (a, &(node, slot)) in dofs.iter().enumerate() {
1319            for &(eq, coeff) in self.domain.dof_terms(node, slot as usize).iter() {
1320                self.load[eq] += coeff * self.factor * v[a];
1321            }
1322        }
1323    }
1324}
1325
1326/// Scatters element lumped masses into the free-DOF mass diagonal.
1327struct MassSink<'a, const NDIM: usize, const NDOF: usize, NId, E>
1328where
1329    NId: Key,
1330    E: ElementOps<NDIM, NDOF, NId>,
1331{
1332    domain: &'a Domain<NDIM, NDOF, NId, E>,
1333    mass: &'a mut DVector<f64>,
1334}
1335
1336impl<const NDIM: usize, const NDOF: usize, NId, E> VectorSink<NId>
1337    for MassSink<'_, NDIM, NDOF, NId, E>
1338where
1339    NId: Key,
1340    E: ElementOps<NDIM, NDOF, NId>,
1341{
1342    fn add<const N: usize>(&mut self, dofs: &[DofRef<NId>; N], v: &SVector<f64, N>) {
1343        #[cfg(debug_assertions)]
1344        self.domain.assert_declared(dofs, None, v.as_slice());
1345        for (a, &(node, slot)) in dofs.iter().enumerate() {
1346            accumulate_mass(self.mass, self.domain.dof_terms(node, slot as usize), v[a]);
1347        }
1348    }
1349}
1350
1351/// Picks out the entry of an element's resistance or load vector that
1352/// belongs to one `(node, dof)` — what `Domain::reaction` needs, without
1353/// involving equation numbers at all.
1354struct PickDof<NId> {
1355    node: NId,
1356    dof: u8,
1357    total: f64,
1358}
1359
1360impl<NId> PickDof<NId> {
1361    fn new(node: NId, dof: usize) -> Self {
1362        PickDof {
1363            node,
1364            dof: dof as u8,
1365            total: 0.0,
1366        }
1367    }
1368}
1369
1370impl<NId: Key> TangentSink<NId> for PickDof<NId> {
1371    fn add<const N: usize>(
1372        &mut self,
1373        dofs: &[DofRef<NId>; N],
1374        _k: &SMatrix<f64, N, N>,
1375        r: &SVector<f64, N>,
1376    ) {
1377        for (a, &(node, slot)) in dofs.iter().enumerate() {
1378            if node == self.node && slot == self.dof {
1379                self.total += r[a];
1380            }
1381        }
1382    }
1383}
1384
1385impl<NId: Key> VectorSink<NId> for PickDof<NId> {
1386    fn add<const N: usize>(&mut self, dofs: &[DofRef<NId>; N], v: &SVector<f64, N>) {
1387        for (a, &(node, slot)) in dofs.iter().enumerate() {
1388            if node == self.node && slot == self.dof {
1389                self.total += v[a];
1390            }
1391        }
1392    }
1393}
1394
1395/// Tie the x-translation DOF of every node in `constrained` to `retained`'s
1396/// — the standard 2D-frame simplification for a rigid floor diaphragm
1397/// (Xara/OpenSees's `rigidDiaphragm` is inherently 3D: it ties in-plane
1398/// translations *and* rotation-about-axis through a lever arm to nodes in a
1399/// plane perpendicular to a given axis, which doesn't map onto a
1400/// single-plane 2D model). Equivalent to calling `equal_dof(retained, c,
1401/// &[0])` for each `c` in `constrained`.
1402///
1403/// Planar-only, in its own impl block rather than the shared generic one:
1404/// naively reusing this identity-tie approach for the spatial profile would
1405/// silently produce a wrong diaphragm (missing the lever-arm term) instead
1406/// of refusing to compile — see `Domain3::rigid_diaphragm` for the real
1407/// (affine, lever-arm-aware) spatial version below.
1408impl Domain {
1409    pub fn rigid_diaphragm(&mut self, retained: NodeId, constrained: &[NodeId]) {
1410        for &c in constrained {
1411            self.equal_dof(retained, c, &[0]);
1412        }
1413    }
1414
1415    /// Rigid link: `slave` moves with `master` as a rigid body. Its rotation
1416    /// equals the master's and its translations equal the master's plus the
1417    /// small-rotation lever arm, `u_s = u_m + theta_m x d` with
1418    /// `d = pos(slave) - pos(master)` from the reference coordinates at the
1419    /// time of the call (`ux_s = ux_m - theta*dy`, `uy_s = uy_m + theta*dx`).
1420    /// Requires `ConstraintHandler::Transformation`.
1421    pub fn rigid_link(&mut self, master: NodeId, slave: NodeId) {
1422        let m = self.nodes[master].coords;
1423        let s = self.nodes[slave].coords;
1424        let (dx, dy) = (s[0] - m[0], s[1] - m[1]);
1425        self.add_constraint((slave, 0), &[(master, 0, 1.0), (master, 2, -dy)]);
1426        self.add_constraint((slave, 1), &[(master, 1, 1.0), (master, 2, dx)]);
1427        self.add_constraint((slave, 2), &[(master, 2, 1.0)]);
1428    }
1429}
1430
1431/// Spatial rigid diaphragm: for each node in `constrained`, ties its two
1432/// in-plane translational dofs (the two coordinate axes other than
1433/// `normal`) to `retained`'s same in-plane translations plus a lever-arm
1434/// term from `retained`'s rotation about `normal` — a genuine
1435/// general linear constraint (`ConstraintHandler::Transformation`), not a further
1436/// identity alias like planar `Domain::rigid_diaphragm` (see its doc
1437/// comment for why identity aliasing can't express a real diaphragm's
1438/// lever-arm kinematics).
1439///
1440/// Deliberately narrower than Xara/OpenSees's `rigidDiaphragm`: only the
1441/// two in-plane translational dofs of each constrained node are tied.
1442/// Every rotational dof of the constrained node — including its rotation
1443/// about `normal` itself — and its out-of-plane translation are left
1444/// completely free, unconstrained by this call. A floor diaphragm is rigid
1445/// in its own plane; it doesn't impose a shared rotation on whatever's
1446/// attached to it, and a linear frame model gives it no out-of-plane
1447/// (vertical) stiffness of its own. This matches planar `rigid_diaphragm`'s
1448/// existing scope (which likewise only ties translation, never `rz`), just
1449/// generalized from "the one translational in-plane dof a 2D model has" to
1450/// "the two a 3D diaphragm plane has, with the lever-arm term a real 3D
1451/// diaphragm needs".
1452///
1453/// Kinematics (small-rotation rigid-body motion in the plane perpendicular
1454/// to `normal`, at retained node `r`, evaluated at constrained node `c`):
1455/// with `n̂` the unit normal and `d = pos(c) - pos(r)`, a rotation
1456/// `theta_n` about `n̂` displaces `c` by `theta_n * (n̂ × d)`. Writing `a =
1457/// (normal+1) % 3`, `b = (normal+2) % 3` for the other two axes (so `(n, a,
1458/// b)` is a right-handed triad):
1459///
1460/// ```text
1461/// u_c[a] = u_r[a] - theta_r[normal] * (coord[b]_c - coord[b]_r)
1462/// u_c[b] = u_r[b] + theta_r[normal] * (coord[a]_c - coord[a]_r)
1463/// ```
1464///
1465/// Cross-checked against Xara/OpenSees's `RigidDiaphragm` constraint
1466/// equations for the z-normal case (`perpDirn = 3`, their default: `Ux_c =
1467/// Ux_r - Rz_r*(Yc-Yr)`, `Uy_c = Uy_r + Rz_r*(Xc-Xr)`) — substituting
1468/// `normal = Z` (`a = X, b = Y`) into the formula above reproduces exactly
1469/// those two equations. Verified through the full `Domain3`/`Analysis3`
1470/// stack for rigid-rotation invariance and lever-arm displacement magnitude
1471/// (`core/tests/constraints/rigid_diaphragm_3d.rs`), not just asserted by that
1472/// cross-check.
1473///
1474/// Lever arms are computed once here, from each node's fixed reference
1475/// `coords` at the time this is called — not re-evaluated from current
1476/// displacement, matching every other `Linear`/`Linear3` (small-
1477/// displacement) geometric transform's scope in this crate.
1478///
1479/// Planar-only, in its own impl block: `normal` requires a full 3D
1480/// coordinate/rotation set (`Axis3`, and a rotation-about-`normal` dof)
1481/// that only the spatial profile's `Node3`/`SpatialDof` provide.
1482impl Domain3 {
1483    /// `rigid_diaphragm_about` with `normal = Axis3::Y` — the "up" axis in
1484    /// this crate's y-up global convention,
1485    /// so the diaphragm plane is the horizontal `x`-`z` plane: the common
1486    /// case (an ordinary floor diaphragm).
1487    pub fn rigid_diaphragm(&mut self, retained: Node3Id, constrained: &[Node3Id]) {
1488        self.rigid_diaphragm_about(retained, constrained, Axis3::Y);
1489    }
1490
1491    /// `rigid_diaphragm` with an explicit diaphragm-plane normal — see this
1492    /// impl block's doc comment for the kinematics and scope.
1493    pub fn rigid_diaphragm_about(
1494        &mut self,
1495        retained: Node3Id,
1496        constrained: &[Node3Id],
1497        normal: Axis3,
1498    ) {
1499        let n = normal as usize;
1500        let a = (n + 1) % SPATIAL_NDIM;
1501        let b = (n + 2) % SPATIAL_NDIM;
1502        let rot_normal = SPATIAL_NDIM + n;
1503
1504        let retained_coords = self.nodes[retained].coords;
1505        for &c in constrained {
1506            let coords = self.nodes[c].coords;
1507            let lever_a = coords[a] - retained_coords[a];
1508            let lever_b = coords[b] - retained_coords[b];
1509            self.add_constraint(
1510                (c, a),
1511                &[(retained, a, 1.0), (retained, rot_normal, -lever_b)],
1512            );
1513            self.add_constraint(
1514                (c, b),
1515                &[(retained, b, 1.0), (retained, rot_normal, lever_a)],
1516            );
1517        }
1518    }
1519
1520    /// Rigid link: `slave` moves with `master` as a rigid body. All six slave
1521    /// DOFs are tied: its rotations equal the master's, and its translations
1522    /// equal the master's plus the small-rotation lever arm,
1523    /// `u_s = u_m + theta_m x d` with `d = pos(slave) - pos(master)` taken
1524    /// from the reference coordinates at the time of the call. Joins, for
1525    /// example, a beam end to the edge of a shell, or an eccentric load point
1526    /// to the member it loads. Requires `ConstraintHandler::Transformation`.
1527    pub fn rigid_link(&mut self, master: Node3Id, slave: Node3Id) {
1528        let m = self.nodes[master].coords;
1529        let s = self.nodes[slave].coords;
1530        let (dx, dy, dz) = (s[0] - m[0], s[1] - m[1], s[2] - m[2]);
1531        // Rotation dofs are 3 (rx), 4 (ry), 5 (rz).
1532        self.add_constraint(
1533            (slave, 0),
1534            &[(master, 0, 1.0), (master, 4, dz), (master, 5, -dy)],
1535        );
1536        self.add_constraint(
1537            (slave, 1),
1538            &[(master, 1, 1.0), (master, 5, dx), (master, 3, -dz)],
1539        );
1540        self.add_constraint(
1541            (slave, 2),
1542            &[(master, 2, 1.0), (master, 3, dy), (master, 4, -dx)],
1543        );
1544        for rotation in 3..6 {
1545            self.add_constraint((slave, rotation), &[(master, rotation, 1.0)]);
1546        }
1547    }
1548}
1549
1550#[cfg(test)]
1551mod domain3_tests {
1552    use super::*;
1553    use crate::analysis::SparseSolver;
1554    use crate::model::{Material, SpatialDof, Truss3};
1555
1556    /// A skew 3D truss cantilever: end load projected onto the bar axis
1557    /// should reproduce simple axial-bar elongation, exercised through the
1558    /// full `Domain3` assembly/equation-numbering path (not just `Truss3`
1559    /// in isolation, which `truss.rs`'s own tests already cover).
1560    #[test]
1561    fn cantilever_truss3_matches_analytical_axial_stiffness() {
1562        let mut domain = Domain3::new();
1563        let fixed = domain.add_node(
1564            crate::model::Node3::new([0.0, 0.0, 0.0])
1565                .fix(SpatialDof::Ux as usize)
1566                .fix(SpatialDof::Uy as usize)
1567                .fix(SpatialDof::Uz as usize)
1568                .fix(SpatialDof::Rx as usize)
1569                .fix(SpatialDof::Ry as usize)
1570                .fix(SpatialDof::Rz as usize),
1571        );
1572        let free = domain.add_node(
1573            crate::model::Node3::new([3.0, 4.0, 0.0])
1574                .fix(SpatialDof::Uz as usize)
1575                .fix(SpatialDof::Rx as usize)
1576                .fix(SpatialDof::Ry as usize)
1577                .fix(SpatialDof::Rz as usize),
1578        );
1579
1580        let area = 2.0;
1581        let e = 1000.0;
1582        let length = 5.0; // 3-4-5 triangle
1583        domain.add_element(Element3::Truss3(Truss3::new(
1584            fixed,
1585            free,
1586            area,
1587            Material::Elastic { e },
1588        )));
1589
1590        let force = 100.0;
1591        domain.load_node(free, SpatialDof::Ux as usize, force * 3.0 / 5.0);
1592        domain.load_node(free, SpatialDof::Uy as usize, force * 4.0 / 5.0);
1593
1594        domain.number_dofs();
1595        assert_eq!(domain.num_free_dofs(), 2);
1596
1597        let (k, residual) = domain.form_tangent_and_residual(1.0);
1598        let du = SparseSolver::new()
1599            .solve(&k, &residual)
1600            .expect("well-posed system");
1601        domain.apply_displacement_increment(&du);
1602
1603        let expected_elongation = force * length / (area * e);
1604        let node = domain.node(free);
1605        let axial_disp = node.displacement[SpatialDof::Ux as usize] * 3.0 / 5.0
1606            + node.displacement[SpatialDof::Uy as usize] * 4.0 / 5.0;
1607        assert!((axial_disp - expected_elongation).abs() < 1e-9);
1608    }
1609}
1610
1611#[cfg(test)]
1612mod numbering_tests {
1613    use super::*;
1614    use crate::model::{Material, Node3, SpatialDof, Truss, ZeroLength3};
1615
1616    /// Equation numbers are assigned to free, unconstrained DOFs in node
1617    /// insertion order; an identity-tied DOF shares its retained DOF's
1618    /// equation; a fixed retained DOF leaves its tied DOF without one.
1619    #[test]
1620    fn identity_ties_alias_equations_and_fixed_retained_dofs_drop_out() {
1621        let mut domain = Domain::new();
1622        let a = domain.add_node(Node::new([0.0, 0.0]).fix(0).fix(1).fix(2));
1623        let b = domain.add_node(Node::new([1.0, 0.0]).fix(2));
1624        let c = domain.add_node(Node::new([2.0, 0.0]).fix(1).fix(2));
1625        let d = domain.add_node(Node::new([3.0, 0.0]).fix(2));
1626        domain.add_element(Element::Truss(Truss::new(
1627            a,
1628            b,
1629            1.0,
1630            Material::Elastic { e: 1.0 },
1631        )));
1632        domain.equal_dof(b, c, &[0]);
1633        // `a` is fully fixed, so `d`'s tied DOF has no equation.
1634        domain.equal_dof(a, d, &[1]);
1635
1636        assert_eq!(domain.number_dofs(), 2);
1637        assert_eq!(domain.equation_of(a, 0), None);
1638        assert_eq!(domain.equation_of(b, 0), Some(0));
1639        assert_eq!(domain.equation_of(b, 1), Some(1));
1640        assert_eq!(
1641            domain.equation_of(c, 0),
1642            Some(0),
1643            "tied dof shares the retained equation"
1644        );
1645        assert_eq!(domain.equation_of(c, 1), None);
1646        // No element stiffens `d`'s ux, so it is not an equation at all.
1647        assert_eq!(domain.equation_of(d, 0), None);
1648        assert!(!domain.is_active(d, 0));
1649        assert_eq!(domain.equation_of(d, 1), None);
1650        assert_eq!(
1651            domain.dof_terms(c, 0).iter().copied().collect::<Vec<_>>(),
1652            vec![(0, 1.0)]
1653        );
1654        assert!(domain.dof_terms(d, 1).iter().next().is_none());
1655    }
1656
1657    /// A 3D rigid diaphragm: the slave's in-plane translations are affine
1658    /// in the master's translation and rotation about the normal, with the
1659    /// lever-arm coefficients, and the slave has no equation of its own.
1660    #[test]
1661    fn rigid_diaphragm_slave_dofs_resolve_to_affine_terms() {
1662        let fixed_but = |free: &[SpatialDof]| {
1663            let mut node = Node3::new([0.0, 0.0, 0.0]);
1664            for dof in [
1665                SpatialDof::Ux,
1666                SpatialDof::Uy,
1667                SpatialDof::Uz,
1668                SpatialDof::Rx,
1669                SpatialDof::Ry,
1670                SpatialDof::Rz,
1671            ] {
1672                if !free.iter().any(|f| *f as usize == dof as usize) {
1673                    node = node.fix(dof as usize);
1674                }
1675            }
1676            node
1677        };
1678        let mut domain = Domain3::new();
1679        let master = domain.add_node(fixed_but(&[SpatialDof::Ux, SpatialDof::Uz, SpatialDof::Ry]));
1680        let mut slave_node = fixed_but(&[SpatialDof::Ux, SpatialDof::Uz]);
1681        slave_node.coords = [2.0, 0.0, 3.0];
1682        let slave = domain.add_node(slave_node);
1683        let ground = domain.add_node(fixed_but(&[]));
1684        domain.add_element(Element3::ZeroLength3(
1685            ZeroLength3::new(ground, slave).with_material(0, Material::Elastic { e: 1.0 }),
1686        ));
1687        domain.rigid_diaphragm(master, &[slave]);
1688
1689        assert_eq!(domain.number_dofs(), 3);
1690        let (ux, uz, ry) = (
1691            domain.equation_of(master, SpatialDof::Ux as usize).unwrap(),
1692            domain.equation_of(master, SpatialDof::Uz as usize).unwrap(),
1693            domain.equation_of(master, SpatialDof::Ry as usize).unwrap(),
1694        );
1695        assert_eq!(domain.equation_of(slave, SpatialDof::Ux as usize), None);
1696        // Normal Y: a = Z, b = X, so u_c[Z] = u_r[Z] - theta_Y * (x_c - x_r)
1697        // and u_c[X] = u_r[X] + theta_Y * (z_c - z_r).
1698        let terms = |dof: SpatialDof| {
1699            domain
1700                .dof_terms(slave, dof as usize)
1701                .iter()
1702                .copied()
1703                .collect::<Vec<_>>()
1704        };
1705        assert_eq!(terms(SpatialDof::Ux), vec![(ux, 1.0), (ry, 3.0)]);
1706        assert_eq!(terms(SpatialDof::Uz), vec![(uz, 1.0), (ry, -2.0)]);
1707    }
1708}