1use nalgebra::{SMatrix, SVector};
5
6use super::plane_common::{
7 b_matrix, check_section, load_vector, plane_coords, plane_displacement, plane_dofs, PlaneView,
8};
9use super::{DofMask, ElementForce, GaussResponse, TangentSink, VectorSink};
10use crate::model::continuum::{physical_derivatives, tri3_dshape, validate_triangle, Jacobian};
11use crate::model::{ElementLoad, NodeId, PlaneMaterial, PlaneVector};
12
13#[derive(Debug, Clone)]
14pub struct Tri3 {
15 pub nodes: [NodeId; 3],
16 pub thickness: f64,
17 pub density: f64,
18 material: PlaneMaterial,
20 cache: Option<Geometry>,
21}
22
23#[derive(Debug, Clone, Copy, PartialEq)]
25struct Geometry {
26 b: SMatrix<f64, 3, 6>,
27 area: f64,
28}
29
30impl Tri3 {
31 pub fn new(
32 n1: NodeId,
33 n2: NodeId,
34 n3: NodeId,
35 thickness: f64,
36 material: PlaneMaterial,
37 ) -> Self {
38 Tri3 {
39 nodes: [n1, n2, n3],
40 thickness,
41 density: 0.0,
42 material,
43 cache: None,
44 }
45 }
46
47 pub fn with_density(mut self, density: f64) -> Self {
48 self.density = density;
49 self
50 }
51
52 pub fn material(&self) -> &PlaneMaterial {
53 &self.material
54 }
55
56 fn compute_geometry(&self, view: &PlaneView<'_>) -> Geometry {
57 let coords = plane_coords(&self.nodes, view);
58 let dn = tri3_dshape();
59 let jacobian = Jacobian::new(&coords, &dn);
60 Geometry {
61 b: b_matrix(&physical_derivatives(&jacobian, &dn)),
62 area: 0.5 * jacobian.det,
63 }
64 }
65
66 fn geometry(&self, view: &PlaneView<'_>) -> Geometry {
67 self.cache.unwrap_or_else(|| self.compute_geometry(view))
68 }
69
70 pub(super) fn dof_mask(&self) -> DofMask {
71 DofMask::none().with(0).with(1)
72 }
73
74 pub(super) fn validate(&self, view: &PlaneView<'_>) -> Result<(), &'static str> {
75 check_section(self.thickness, self.density, &self.material)?;
76 validate_triangle(&plane_coords(&self.nodes, view))
77 }
78
79 pub(super) fn prepare(&mut self, view: &PlaneView<'_>) {
80 self.cache = Some(self.compute_geometry(view));
81 }
82
83 fn strain(&self, g: &Geometry, view: &PlaneView<'_>) -> PlaneVector {
84 g.b * plane_displacement::<3, 6>(&self.nodes, view)
85 }
86
87 pub(super) fn assemble_tangent<S: TangentSink<NodeId>>(
88 &self,
89 view: &PlaneView<'_>,
90 sink: &mut S,
91 ) {
92 let g = self.geometry(view);
93 let (stress, d) = self.material.trial_stress_tangent(&self.strain(&g, view));
94 let scale = self.thickness * g.area;
95 let k = scale * g.b.transpose() * d * g.b;
96 let r = scale * g.b.transpose() * stress;
97 sink.add(&plane_dofs::<3, 6>(&self.nodes), &k, &r);
98 }
99
100 pub(super) fn assemble_mass<S: VectorSink<NodeId>>(&self, view: &PlaneView<'_>, sink: &mut S) {
102 let m = self.density * self.thickness * self.geometry(view).area / 3.0;
103 sink.add(
104 &plane_dofs::<3, 6>(&self.nodes),
105 &SVector::<f64, 6>::repeat(m),
106 );
107 }
108
109 pub(super) fn load_vector(&self, view: &PlaneView<'_>, load: &ElementLoad) -> SVector<f64, 6> {
112 let share = self.thickness * self.geometry(view).area / 3.0;
113 load_vector(
114 &plane_coords(&self.nodes, view),
115 &[share; 3],
116 self.thickness,
117 load,
118 )
119 }
120
121 pub(super) fn assemble_load<S: VectorSink<NodeId>>(
122 &self,
123 view: &PlaneView<'_>,
124 load: &ElementLoad,
125 sink: &mut S,
126 ) {
127 sink.add(
128 &plane_dofs::<3, 6>(&self.nodes),
129 &self.load_vector(view, load),
130 );
131 }
132
133 pub(super) fn commit(&mut self, view: &PlaneView<'_>) {
134 let g = self.geometry(view);
135 self.material = self.material.commit(&self.strain(&g, view));
136 }
137
138 pub(super) fn local_force(&self, view: &PlaneView<'_>) -> ElementForce {
140 let g = self.geometry(view);
141 let (stress, _) = self.material.trial_stress_tangent(&self.strain(&g, view));
142 (self.thickness * g.area * g.b.transpose() * stress).into()
143 }
144
145 pub(super) fn gauss_responses(&self, view: &PlaneView<'_>) -> Vec<GaussResponse> {
146 let g = self.geometry(view);
147 let strain = self.strain(&g, view);
148 let (stress, _) = self.material.trial_stress_tangent(&strain);
149 vec![GaussResponse {
150 strain: strain.into(),
151 stress: stress.into(),
152 }]
153 }
154
155 #[cfg(test)]
156 pub(crate) fn cached(&self) -> bool {
157 self.cache.is_some()
158 }
159}
160
161#[cfg(test)]
162mod tests {
163 use nalgebra::SymmetricEigen;
164 use slotmap::SlotMap;
165
166 use super::*;
167 use crate::model::{DofRef, ElementLoad, Node};
168
169 struct Collect {
170 k: SMatrix<f64, 6, 6>,
171 r: SVector<f64, 6>,
172 }
173
174 impl TangentSink<NodeId> for Collect {
175 fn add<const N: usize>(
176 &mut self,
177 _dofs: &[DofRef<NodeId>; N],
178 k: &SMatrix<f64, N, N>,
179 r: &SVector<f64, N>,
180 ) {
181 self.k = SMatrix::from_fn(|i, j| k[(i, j)]);
182 self.r = SVector::from_fn(|i, _| r[i]);
183 }
184 }
185
186 struct Mass(SVector<f64, 6>);
187
188 impl VectorSink<NodeId> for Mass {
189 fn add<const N: usize>(&mut self, _dofs: &[DofRef<NodeId>; N], v: &SVector<f64, N>) {
190 self.0 = SVector::from_fn(|i, _| v[i]);
191 }
192 }
193
194 fn setup(coords: [[f64; 2]; 3]) -> (SlotMap<NodeId, Node>, [NodeId; 3]) {
195 let mut nodes = SlotMap::with_key();
196 let ids = coords.map(|c| nodes.insert(Node::new(c)));
197 (nodes, ids)
198 }
199
200 fn assemble(el: &Tri3, nodes: &SlotMap<NodeId, Node>) -> Collect {
201 let mut sink = Collect {
202 k: SMatrix::zeros(),
203 r: SVector::zeros(),
204 };
205 el.assemble_tangent(&PlaneView::new(nodes), &mut sink);
206 sink
207 }
208
209 fn unit_triangle(e: f64, nu: f64, t: f64) -> (Tri3, SlotMap<NodeId, Node>) {
210 let (nodes, [a, b, c]) = setup([[0.0, 0.0], [1.0, 0.0], [0.0, 1.0]]);
211 (
212 Tri3::new(a, b, c, t, PlaneMaterial::plane_stress(e, nu).unwrap()),
213 nodes,
214 )
215 }
216
217 #[test]
218 fn stiffness_matches_the_closed_form_for_the_unit_right_triangle() {
219 let (el, nodes) = unit_triangle(1.0, 0.0, 1.0);
222 let k = assemble(&el, &nodes).k;
223 #[rustfmt::skip]
224 let expected = SMatrix::<f64, 6, 6>::from_row_slice(&[
225 0.75, 0.25, -0.5, -0.25, -0.25, 0.0,
226 0.25, 0.75, 0.0, -0.25, -0.25, -0.5,
227 -0.5, 0.0, 0.5, 0.0, 0.0, 0.0,
228 -0.25, -0.25, 0.0, 0.25, 0.25, 0.0,
229 -0.25, -0.25, 0.0, 0.25, 0.25, 0.0,
230 0.0, -0.5, 0.0, 0.0, 0.0, 0.5,
231 ]);
232 assert!((k - expected).abs().max() < 1e-14, "{k}");
233 }
234
235 #[test]
236 fn stiffness_is_symmetric_with_exactly_three_rigid_body_modes() {
237 let (nodes, [a, b, c]) = setup([[0.3, 0.1], [2.1, 0.4], [0.9, 1.7]]);
238 let el = Tri3::new(
239 a,
240 b,
241 c,
242 0.4,
243 PlaneMaterial::plane_strain(30e3, 0.2).unwrap(),
244 );
245 let k = assemble(&el, &nodes).k;
246 assert!((k - k.transpose()).abs().max() < 1e-9 * k.abs().max());
247 let eigen = SymmetricEigen::new(k).eigenvalues;
248 let largest = eigen.max();
249 let zero = eigen.iter().filter(|&&l| l.abs() < 1e-9 * largest).count();
250 assert_eq!(zero, 3, "{eigen}");
251 assert!(
252 eigen.iter().all(|&l| l > -1e-9 * largest),
253 "not positive semidefinite: {eigen}"
254 );
255 }
256
257 #[test]
258 fn a_rigid_motion_is_stress_free() {
259 let (mut nodes, ids) = setup([[0.3, 0.1], [2.1, 0.4], [0.9, 1.7]]);
260 let el = Tri3::new(
261 ids[0],
262 ids[1],
263 ids[2],
264 0.4,
265 PlaneMaterial::plane_stress(30e3, 0.3).unwrap(),
266 );
267 let (t, theta) = ([0.01, -0.02], 1e-5);
269 for id in ids {
270 let [x, y] = nodes[id].coords;
271 nodes[id].displacement = [t[0] - theta * y, t[1] + theta * x, 0.0];
272 }
273 let r = assemble(&el, &nodes).r;
274 assert!(r.abs().max() < 1e-9, "{r}");
275 }
276
277 #[test]
278 fn lumped_mass_sums_to_rho_t_a_in_each_direction() {
279 let (nodes, [a, b, c]) = setup([[0.0, 0.0], [4.0, 0.0], [1.0, 3.0]]);
280 let (rho, t) = (2.5, 0.2);
281 let el =
282 Tri3::new(a, b, c, t, PlaneMaterial::plane_stress(1.0, 0.0).unwrap()).with_density(rho);
283 let mut sink = Mass(SVector::zeros());
284 el.assemble_mass(&PlaneView::new(&nodes), &mut sink);
285 let area = 6.0;
286 let (sum_x, sum_y): (f64, f64) = (
287 (0..3).map(|i| sink.0[2 * i]).sum(),
288 (0..3).map(|i| sink.0[2 * i + 1]).sum(),
289 );
290 assert!((sum_x - rho * t * area).abs() < 1e-12 && (sum_y - rho * t * area).abs() < 1e-12);
291 assert!(sink
292 .0
293 .iter()
294 .all(|&m| (m - rho * t * area / 3.0).abs() < 1e-12));
295 }
296
297 #[test]
298 fn prepare_is_idempotent_and_the_cache_equals_recomputation() {
299 let (nodes, [a, b, c]) = setup([[0.3, 0.1], [2.1, 0.4], [0.9, 1.7]]);
300 let mut el = Tri3::new(
301 a,
302 b,
303 c,
304 0.4,
305 PlaneMaterial::plane_stress(30e3, 0.3).unwrap(),
306 );
307 let before = assemble(&el, &nodes).k;
308 assert!(!el.cached());
309 let view = PlaneView::new(&nodes);
310 el.prepare(&view);
311 el.prepare(&view);
312 assert!(el.cached());
313 assert_eq!(before, assemble(&el, &nodes).k);
314 assert_eq!(el.cache, Some(el.compute_geometry(&view)));
315 }
316
317 #[test]
318 fn invalid_geometry_and_section_are_reported() {
319 let (nodes, [a, b, c]) = setup([[0.0, 0.0], [1.0, 0.0], [0.0, 1.0]]);
320 let view = PlaneView::new(&nodes);
321 let material = PlaneMaterial::plane_stress(1.0, 0.0).unwrap();
322 assert!(Tri3::new(a, b, c, 1.0, material.clone())
323 .validate(&view)
324 .is_ok());
325 assert!(Tri3::new(a, c, b, 1.0, material.clone())
326 .validate(&view)
327 .unwrap_err()
328 .contains("clockwise"));
329 assert!(Tri3::new(a, b, c, 0.0, material.clone())
330 .validate(&view)
331 .is_err());
332 assert!(Tri3::new(a, b, c, 1.0, material.clone())
333 .with_density(-1.0)
334 .validate(&view)
335 .is_err());
336 let bad = PlaneMaterial::PlaneStress { e: -1.0, nu: 0.0 };
337 assert!(Tri3::new(a, b, c, 1.0, bad).validate(&view).is_err());
338 }
339
340 #[test]
341 fn gauss_response_reports_strain_and_the_stress_it_implies() {
342 let (mut nodes, ids) = setup([[0.0, 0.0], [1.0, 0.0], [0.0, 1.0]]);
343 let material = PlaneMaterial::plane_stress(100.0, 0.25).unwrap();
344 let el = Tri3::new(ids[0], ids[1], ids[2], 1.0, material.clone());
345 let (a, b, c, d) = (1e-3, 2e-3, -3e-4, 5e-4);
347 for id in ids {
348 let [x, y] = nodes[id].coords;
349 nodes[id].displacement = [a * x + b * y, c * x + d * y, 0.0];
350 }
351 let response = el.gauss_responses(&PlaneView::new(&nodes));
352 assert_eq!(response.len(), 1);
353 let strain = PlaneVector::new(a, d, b + c);
354 assert!((PlaneVector::from(response[0].strain) - strain).norm() < 1e-15);
355 assert!(
356 (PlaneVector::from(response[0].stress) - material.d_matrix() * strain).norm() < 1e-12
357 );
358 }
359
360 #[test]
361 fn body_force_and_edge_loads_total_their_resultants() {
362 let (nodes, [a, b, c]) = setup([[0.0, 0.0], [4.0, 0.0], [1.0, 3.0]]);
363 let el = Tri3::new(a, b, c, 0.2, PlaneMaterial::plane_stress(1.0, 0.0).unwrap());
364 let view = PlaneView::new(&nodes);
365 let sums = |v: SVector<f64, 6>| {
366 [
367 (0..3).map(|i| v[2 * i]).sum::<f64>(),
368 (0..3).map(|i| v[2 * i + 1]).sum::<f64>(),
369 ]
370 };
371 let body = sums(el.load_vector(&view, &ElementLoad::body(1.5, -4.0)));
372 assert!(
373 (body[0] - 1.5 * 0.2 * 6.0).abs() < 1e-14 && (body[1] + 4.0 * 0.2 * 6.0).abs() < 1e-14
374 );
375 let base = sums(el.load_vector(&view, &ElementLoad::edge_traction(0, 2.0, 3.0)));
377 assert!(
378 (base[0] - 2.0 * 0.2 * 4.0).abs() < 1e-13 && (base[1] - 3.0 * 0.2 * 4.0).abs() < 1e-13
379 );
380 let pressure = sums(el.load_vector(&view, &ElementLoad::edge_pressure(0, 5.0)));
382 assert!(pressure[0].abs() < 1e-13 && (pressure[1] - 5.0 * 0.2 * 4.0).abs() < 1e-13);
383 let slanted = sums(el.load_vector(&view, &ElementLoad::edge_pressure(1, 1.0)));
385 assert!((slanted[0] + 0.6).abs() < 1e-13 && (slanted[1] + 0.6).abs() < 1e-13);
387 }
388}