Skip to main content

carapace_wasm/input_v1/
tables.rs

1//! Bulk per-(entity-kind) tables of the wire format (`docs/input-format.md`), one
2//! set for both profiles (`header.ndm` 2 or 3). Dimension-agnostic entities
3//! (nodes, trusses, zero-lengths, fibers, constraints, loads) have one table;
4//! formulations that differ between 2D and 3D (beam-columns) have one table
5//! each, suffixed `2d`/`3d`, and the table of the other profile must be empty
6//! (`DecodeError::TableNotInProfile`).
7//!
8//! Node/element/pattern references inside these tables are already
9//! resolved to dense 0-based row indices by the (not-yet-written)
10//! `pysees` compiler — the "duplicate/missing tags and every reference
11//! between entities" validation the compiler is responsible for
12//! happens upstream of decode, so decode only ever sees plain array
13//! indices, never sparse OpenSees-style tags.
14//!
15//! Each field is a plain `Vec`, not yet the transferable `Float64Array`/
16//! `Uint8Array` values the wire format ultimately specifies for
17//! `postMessage`: `boundary.rs`'s `serde-wasm-bindgen` decoding today
18//! accepts an ordinary JS array for each of these (or a typed array,
19//! copied element-by-element) rather than transferring one. Once real
20//! transferable-array support lands, each `Vec<f64>`/`Vec<u32>` here is
21//! exactly the shape a `Float64Array`/`Uint32Array` copies into. Every table
22//! of [`super::CarapaceInputV1`] may be omitted, which is the same as empty.
23//!
24//! `#[serde(rename_all = "camelCase")]` throughout so the JS/TS shape
25//! matches the PySees naming (`nodeI`, not `node_i`).
26
27use serde::{Deserialize, Serialize};
28
29/// Node table: `coords` stride `ndm` (x, y[, z]); `fixed` one bitmask byte per
30/// node (bit `k` = DOF `k`: ux, uy, rz in 2D; ux, uy, uz, rx, ry, rz in 3D);
31/// mass is sparse, addressed via a parallel node-index array since most nodes
32/// carry none.
33#[derive(Debug, Clone, Default, Serialize, Deserialize, tsify::Tsify)]
34#[serde(rename_all = "camelCase")]
35pub struct NodeTable {
36    pub coords: Vec<f64>,
37    pub fixed: Vec<u8>,
38    pub mass_node_index: Vec<u32>,
39    /// Stride `ndf` (3 in 2D, 6 in 3D), parallel to `mass_node_index`.
40    pub mass: Vec<f64>,
41}
42
43impl NodeTable {
44    pub fn node_count(&self, ndm: usize) -> usize {
45        self.coords.len() / ndm
46    }
47}
48
49#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, tsify::Tsify)]
50#[serde(rename_all = "camelCase")]
51pub enum TransformSpec {
52    Linear,
53    PDelta,
54    Corotational,
55}
56
57/// `GeomTransf3`'s wire mirror. No `Corotational3` — spatial corotational
58/// geometry doesn't exist in `core` yet (`GeomTransf3`'s own doc comment).
59#[derive(Debug, Clone, Copy, PartialEq, Serialize, Deserialize, tsify::Tsify)]
60#[serde(
61    tag = "kind",
62    rename_all = "camelCase",
63    rename_all_fields = "camelCase"
64)]
65pub enum TransformSpec3 {
66    Linear3 { vec_xz: [f64; 3] },
67    PDelta3 { vec_xz: [f64; 3] },
68}
69
70#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, tsify::Tsify)]
71#[serde(
72    tag = "kind",
73    rename_all = "camelCase",
74    rename_all_fields = "camelCase"
75)]
76pub enum IntegrationSpec {
77    Legendre { points: u32 },
78    Lobatto { points: u32 },
79}
80
81/// Same fields in 2D and 3D.
82#[derive(Debug, Clone, Default, Serialize, Deserialize, tsify::Tsify)]
83#[serde(rename_all = "camelCase")]
84pub struct TrussTable {
85    pub node_i: Vec<u32>,
86    pub node_j: Vec<u32>,
87    pub area: Vec<f64>,
88    /// Index into the material arena.
89    pub material: Vec<u32>,
90    pub density: Vec<f64>,
91}
92
93#[derive(Debug, Clone, Default, Serialize, Deserialize, tsify::Tsify)]
94#[serde(rename_all = "camelCase")]
95pub struct ElasticBeamColumn2dTable {
96    pub node_i: Vec<u32>,
97    pub node_j: Vec<u32>,
98    pub e: Vec<f64>,
99    pub a: Vec<f64>,
100    pub iz: Vec<f64>,
101    pub transform: Vec<TransformSpec>,
102    pub density: Vec<f64>,
103}
104
105#[derive(Debug, Clone, Default, Serialize, Deserialize, tsify::Tsify)]
106#[serde(rename_all = "camelCase")]
107pub struct ElasticBeamColumn3dTable {
108    pub node_i: Vec<u32>,
109    pub node_j: Vec<u32>,
110    pub e: Vec<f64>,
111    pub g: Vec<f64>,
112    pub a: Vec<f64>,
113    /// Torsional constant.
114    pub j: Vec<f64>,
115    pub iy: Vec<f64>,
116    pub iz: Vec<f64>,
117    pub transform: Vec<TransformSpec3>,
118    pub density: Vec<f64>,
119}
120
121/// Shared shape for 2D `DispBeamColumn` and `ForceBeamColumn` — both are one
122/// prismatic fiber section (see [`FiberTable`]) replicated across
123/// integration points by `core`'s own element constructors.
124#[derive(Debug, Clone, Default, Serialize, Deserialize, tsify::Tsify)]
125#[serde(rename_all = "camelCase")]
126pub struct FiberBeamColumn2dTable {
127    pub node_i: Vec<u32>,
128    pub node_j: Vec<u32>,
129    /// Index into `FiberTable::section_offsets`.
130    pub fiber_section: Vec<u32>,
131    pub integration: Vec<IntegrationSpec>,
132    pub corotational: Vec<bool>,
133    pub density: Vec<f64>,
134}
135
136/// Shared shape for `DispBeamColumn3` and `ForceBeamColumn3` — both are one
137/// prismatic biaxial fiber section (see [`FiberTable`]) replicated across
138/// integration points by `core`'s own element constructors. Unlike
139/// [`FiberBeamColumn2dTable`], there is no `corotational` field: neither
140/// spatial fiber element supports it yet, and no separate `transform` field —
141/// `g`/`j`/`vec_xz` are passed directly to the constructor, since these
142/// elements only ever use `Linear`-type geometry.
143#[derive(Debug, Clone, Default, Serialize, Deserialize, tsify::Tsify)]
144#[serde(rename_all = "camelCase")]
145pub struct FiberBeamColumn3dTable {
146    pub node_i: Vec<u32>,
147    pub node_j: Vec<u32>,
148    pub g: Vec<f64>,
149    /// Torsional constant — fiber sections don't carry torsion (see
150    /// [`FiberTable`]'s doc comment), so it's supplied here as a decoupled
151    /// elastic `G*J` term, same as `core::DispBeamColumn3`/`ForceBeamColumn3`.
152    pub j: Vec<f64>,
153    /// A vector not parallel to the member axis, fixing the local y/z
154    /// orientation — see `core::GeomTransf3::Linear3`'s doc comment.
155    pub vec_xz: Vec<[f64; 3]>,
156    /// Index into `FiberTable::section_offsets`.
157    pub fiber_section: Vec<u32>,
158    pub integration: Vec<IntegrationSpec>,
159    pub density: Vec<f64>,
160}
161
162/// One sparse `friction` row of [`ZeroLengthTable`]: the row's one `Friction`
163/// (2D) / `Friction3` (3D) — see their doc comments for the field meanings.
164/// `normal_dof` must already have a `materials` entry on the same row, and the
165/// shear DOFs must not (checked by `core` via `debug_assert`).
166#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, tsify::Tsify)]
167#[serde(rename_all = "camelCase")]
168pub struct FrictionRow {
169    pub row: u32,
170    pub normal_dof: u8,
171    /// One shear DOF in 2D, two in 3D (coupled to the same normal force).
172    pub shear_dofs: Vec<u8>,
173    pub mu: f64,
174    pub k0: f64,
175    pub b: f64,
176}
177
178/// One sparse `orient` row — OpenSees' `-orient x1 x2 x3 [yp1 yp2 yp3]`: local
179/// x is `x`; in 3D local z is `x × yp` and local y completes the frame; in 2D
180/// `yp` is absent, `x[2]` must be 0 and local y is local x turned 90 degrees
181/// counter-clockwise. Every DOF of the row is evaluated along those axes.
182/// Rows without an entry use the global axes; at most one entry per row.
183#[derive(Debug, Clone, Copy, PartialEq, Serialize, Deserialize, tsify::Tsify)]
184#[serde(rename_all = "camelCase")]
185pub struct OrientRow {
186    pub row: u32,
187    pub x: [f64; 3],
188    /// Required in 3D, must be absent in 2D.
189    #[serde(default)]
190    #[tsify(optional)]
191    pub yp: Option<[f64; 3]>,
192}
193
194#[derive(Debug, Clone, Default, Serialize, Deserialize, tsify::Tsify)]
195#[serde(rename_all = "camelCase")]
196pub struct ZeroLengthTable {
197    pub node_i: Vec<u32>,
198    pub node_j: Vec<u32>,
199    /// Sparse `(zero_length row index, dof, material arena index)` — most
200    /// DOFs on a given `ZeroLength` carry no material at all.
201    pub materials: Vec<(u32, u8, u32)>,
202    /// At most one entry per row (`core::ZeroLength` allows only one friction).
203    #[serde(default)]
204    #[tsify(optional)]
205    pub friction: Vec<FrictionRow>,
206    #[serde(default)]
207    #[tsify(optional)]
208    pub orient: Vec<OrientRow>,
209}
210
211/// A `ZeroLength` driven by a coupled `FiberSection` (axial + moment
212/// response, `[ux, rz]` in 2D; axial + biaxial moment, `[ux, ry, rz]` in 3D)
213/// instead of `ZeroLength`'s independent per-DOF materials — see
214/// `core::ZeroLengthSection`'s doc comment.
215#[derive(Debug, Clone, Default, Serialize, Deserialize, tsify::Tsify)]
216#[serde(rename_all = "camelCase")]
217pub struct ZeroLengthSectionTable {
218    pub node_i: Vec<u32>,
219    pub node_j: Vec<u32>,
220    /// Index into `FiberTable::section_offsets`.
221    pub fiber_section: Vec<u32>,
222    /// Sparse `(zero_length_section row index, dof, material arena index)` —
223    /// an independent spring for a DOF the section has no resultant for: `uy`
224    /// in 2D; `uy`/`uz` (shear) or `rx` (torsion) in 3D.
225    pub materials: Vec<(u32, u8, u32)>,
226    #[serde(default)]
227    #[tsify(optional)]
228    pub orient: Vec<OrientRow>,
229}
230
231/// Fibers for every `DispBeamColumn`/`ForceBeamColumn` section, flattened
232/// and offset-indexed: section `k` occupies
233/// `section_offsets[k]..section_offsets[k + 1]` in `y`/`z`/`area`/`material`.
234/// `section_offsets` therefore has `num_sections + 1` entries. `z` is empty in
235/// a 2D model and parallel to `y` in a 3D model (biaxial bending) — torsion is
236/// deliberately excluded from the fiber loop in `core` (`FiberSection3`'s doc
237/// comment) and supplied instead as each owning table's own `g`/`j` fields.
238#[derive(Debug, Clone, Default, Serialize, Deserialize, tsify::Tsify)]
239#[serde(rename_all = "camelCase")]
240pub struct FiberTable {
241    pub section_offsets: Vec<u32>,
242    pub y: Vec<f64>,
243    #[serde(default)]
244    #[tsify(optional)]
245    pub z: Vec<f64>,
246    pub area: Vec<f64>,
247    /// Index into the material arena, parallel to `y`/`area`.
248    pub material: Vec<u32>,
249}
250
251impl FiberTable {
252    pub fn section_count(&self) -> usize {
253        self.section_offsets.len().saturating_sub(1)
254    }
255}
256
257#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, tsify::Tsify)]
258#[serde(
259    tag = "kind",
260    rename_all = "camelCase",
261    rename_all_fields = "camelCase"
262)]
263pub enum TimeSeriesSpec {
264    Constant,
265    Linear {
266        slope: f64,
267    },
268    Path {
269        times: Vec<f64>,
270        factors: Vec<f64>,
271    },
272    /// Zero before the first sample; zero after the last unless use_last is true.
273    BoundedPath {
274        times: Vec<f64>,
275        factors: Vec<f64>,
276        use_last: bool,
277    },
278}
279
280#[derive(Debug, Clone, Default, Serialize, Deserialize, tsify::Tsify)]
281#[serde(rename_all = "camelCase")]
282pub struct LoadPatternTable {
283    pub series: Vec<TimeSeriesSpec>,
284    pub scale_factor: Vec<f64>,
285}
286
287#[derive(Debug, Clone, Default, Serialize, Deserialize, tsify::Tsify)]
288#[serde(rename_all = "camelCase")]
289pub struct NodalLoadTable {
290    /// Index into `LoadPatternTable`.
291    pub pattern: Vec<u32>,
292    /// Index into `NodeTable`.
293    pub node: Vec<u32>,
294    pub dof: Vec<u8>,
295    pub value: Vec<f64>,
296    /// Index into `SequenceSpec::stages`: this load is only registered on
297    /// the `Domain` when that stage starts, not at decode time. This
298    /// matters whenever an earlier stage's `LoadControl` shares one
299    /// pseudo-time with every unfrozen pattern (core/src/analysis/
300    /// integrator.rs) — a pattern's reference load must not exist yet if
301    /// an earlier stage isn't meant to ramp it too (the same reason
302    /// core/tests/elements/force_beam.rs's native two-phase test adds its
303    /// lateral pattern's load only between phases, not upfront).
304    pub stage: Vec<u32>,
305}
306
307/// A plane (2D continuum) material, referenced by `triangles`/`quads` rows
308/// through the `planeMaterials` arena. Strain is `[eps_x, eps_y, gamma_xy]`
309/// with engineering shear.
310#[derive(Debug, Clone, Copy, PartialEq, Serialize, Deserialize, tsify::Tsify)]
311#[serde(
312    tag = "kind",
313    rename_all = "camelCase",
314    rename_all_fields = "camelCase"
315)]
316pub enum PlaneMaterialSpec {
317    /// Isotropic linear elastic.
318    Isotropic {
319        e: f64,
320        nu: f64,
321        state: PlaneStateSpec,
322    },
323    /// Orthotropic plane stress; `angle` is the counter-clockwise angle in
324    /// radians from global x to material axis 1.
325    Orthotropic {
326        ex: f64,
327        ey: f64,
328        nu_xy: f64,
329        g_xy: f64,
330        angle: f64,
331    },
332    /// A symmetric positive-definite `D`, packed upper triangle row by row:
333    /// `[d11, d12, d13, d22, d23, d33]`.
334    ElasticMatrix { d: [f64; 6] },
335}
336
337#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, tsify::Tsify)]
338#[serde(rename_all = "camelCase")]
339pub enum PlaneStateSpec {
340    PlaneStress,
341    PlaneStrain,
342}
343
344/// 3-node constant-strain triangles (2D models only). `nodeIds` has stride 3
345/// (counter-clockwise); `material` indexes `planeMaterials`.
346#[derive(Debug, Clone, Default, Serialize, Deserialize, tsify::Tsify)]
347#[serde(rename_all = "camelCase")]
348pub struct TriangleTable {
349    pub node_ids: Vec<u32>,
350    pub thickness: Vec<f64>,
351    pub material: Vec<u32>,
352    pub density: Vec<f64>,
353}
354
355#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, tsify::Tsify)]
356#[serde(rename_all = "camelCase")]
357pub enum Quad4FormulationSpec {
358    Full,
359    /// Wilson-Taylor incompatible modes; linear materials only.
360    Enhanced,
361}
362
363/// 4-node bilinear quadrilaterals (2D models only). `nodeIds` has stride 4
364/// (counter-clockwise); `material` indexes `planeMaterials`.
365#[derive(Debug, Clone, Default, Serialize, Deserialize, tsify::Tsify)]
366#[serde(rename_all = "camelCase")]
367pub struct QuadTable {
368    pub node_ids: Vec<u32>,
369    pub thickness: Vec<f64>,
370    pub material: Vec<u32>,
371    pub density: Vec<f64>,
372    pub formulation: Vec<Quad4FormulationSpec>,
373}
374
375/// A shell section, referenced by `shell4s` rows through the `shellSections` arena. Generalized
376/// strain and resultants follow OpenSees' `ElasticMembranePlateSection` ordering.
377#[derive(Debug, Clone, Copy, PartialEq, Serialize, Deserialize, tsify::Tsify)]
378#[serde(
379    tag = "kind",
380    rename_all = "camelCase",
381    rename_all_fields = "camelCase"
382)]
383pub enum ShellSectionSpec {
384    /// Isotropic elastic membrane + plate bending + transverse shear: modulus, Poisson ratio,
385    /// thickness and mass density (per unit volume).
386    ElasticMembranePlate { e: f64, nu: f64, h: f64, rho: f64 },
387}
388
389/// 3-node DKT/Allman shells (3D models only). `nodeIds` has stride 3 (the node order sets the local normal by the
390/// right-hand rule); `section` indexes `shellSections`.
391#[derive(Debug, Clone, Default, Serialize, Deserialize, tsify::Tsify)]
392#[serde(rename_all = "camelCase")]
393pub struct Shell3Table {
394    pub node_ids: Vec<u32>,
395    pub section: Vec<u32>,
396}
397
398/// 4-node MITC4 shells (3D models only). `nodeIds` has stride 4 (the node order fixes the
399/// local normal by the right-hand rule); `section` indexes `shellSections`.
400#[derive(Debug, Clone, Default, Serialize, Deserialize, tsify::Tsify)]
401#[serde(rename_all = "camelCase")]
402pub struct Shell4Table {
403    pub node_ids: Vec<u32>,
404    pub section: Vec<u32>,
405}
406
407/// Every element formulation, 2D and 3D. A kind that does not belong to the
408/// model's `ndm` is `DecodeError::ElementKindNotInProfile`.
409#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, tsify::Tsify)]
410#[serde(rename_all = "camelCase")]
411pub enum ElementKind {
412    Truss,
413    ElasticBeamColumn2d,
414    ElasticBeamColumn3d,
415    DispBeamColumn2d,
416    DispBeamColumn3d,
417    ForceBeamColumn2d,
418    ForceBeamColumn3d,
419    ZeroLength,
420    ZeroLengthSection,
421    Tri3,
422    Quad4,
423    Shell3,
424    Shell4,
425}
426
427impl ElementKind {
428    pub const ALL: [ElementKind; 13] = [
429        ElementKind::Truss,
430        ElementKind::ElasticBeamColumn2d,
431        ElementKind::ElasticBeamColumn3d,
432        ElementKind::DispBeamColumn2d,
433        ElementKind::DispBeamColumn3d,
434        ElementKind::ForceBeamColumn2d,
435        ElementKind::ForceBeamColumn3d,
436        ElementKind::ZeroLength,
437        ElementKind::ZeroLengthSection,
438        ElementKind::Tri3,
439        ElementKind::Quad4,
440        ElementKind::Shell3,
441        ElementKind::Shell4,
442    ];
443
444    /// The kind's table name as `DecodeError`s report it (snake_case).
445    pub fn table(self) -> &'static str {
446        match self {
447            ElementKind::Truss => "trusses",
448            ElementKind::ElasticBeamColumn2d => "elastic_beam_columns_2d",
449            ElementKind::ElasticBeamColumn3d => "elastic_beam_columns_3d",
450            ElementKind::DispBeamColumn2d => "disp_beam_columns_2d",
451            ElementKind::DispBeamColumn3d => "disp_beam_columns_3d",
452            ElementKind::ForceBeamColumn2d => "force_beam_columns_2d",
453            ElementKind::ForceBeamColumn3d => "force_beam_columns_3d",
454            ElementKind::ZeroLength => "zero_lengths",
455            ElementKind::ZeroLengthSection => "zero_length_sections",
456            ElementKind::Tri3 => "triangles",
457            ElementKind::Quad4 => "quads",
458            ElementKind::Shell3 => "shell3s",
459            ElementKind::Shell4 => "shell4s",
460        }
461    }
462
463    /// `Some(2)`/`Some(3)` for a formulation that exists only in that profile,
464    /// `None` for one shared by both.
465    pub fn only_ndm(self) -> Option<u8> {
466        match self {
467            ElementKind::Truss | ElementKind::ZeroLength | ElementKind::ZeroLengthSection => None,
468            ElementKind::ElasticBeamColumn2d
469            | ElementKind::DispBeamColumn2d
470            | ElementKind::ForceBeamColumn2d
471            | ElementKind::Tri3
472            | ElementKind::Quad4 => Some(2),
473            ElementKind::ElasticBeamColumn3d
474            | ElementKind::DispBeamColumn3d
475            | ElementKind::ForceBeamColumn3d
476            | ElementKind::Shell3
477            | ElementKind::Shell4 => Some(3),
478        }
479    }
480
481    pub fn in_profile(self, ndm: u8) -> bool {
482        self.only_ndm().is_none_or(|only| only == ndm)
483    }
484
485    /// The 2D continuum elements (they take body and edge loads).
486    pub fn is_continuum(self) -> bool {
487        matches!(self, ElementKind::Tri3 | ElementKind::Quad4)
488    }
489
490    /// The 3D shell elements (they take self-weight and pressure loads).
491    pub fn is_shell(self) -> bool {
492        matches!(self, ElementKind::Shell3 | ElementKind::Shell4)
493    }
494
495    /// Number of edges of a continuum element (zero for every other kind).
496    pub fn edge_count(self) -> u8 {
497        match self {
498            ElementKind::Tri3 => 3,
499            ElementKind::Quad4 => 4,
500            _ => 0,
501        }
502    }
503
504    /// Whether `core` applies a beam `Uniform` load to this kind (the beam-columns).
505    pub fn accepts_uniform_load(self) -> bool {
506        matches!(
507            self,
508            ElementKind::ElasticBeamColumn2d
509                | ElementKind::ElasticBeamColumn3d
510                | ElementKind::DispBeamColumn2d
511                | ElementKind::DispBeamColumn3d
512                | ElementKind::ForceBeamColumn2d
513                | ElementKind::ForceBeamColumn3d
514        )
515    }
516}
517
518#[derive(Debug, Clone, Copy, PartialEq, Serialize, Deserialize, tsify::Tsify)]
519#[serde(
520    tag = "kind",
521    rename_all = "camelCase",
522    rename_all_fields = "camelCase"
523)]
524pub enum ElementLoadSpec {
525    /// Uniform load (force/length) in the element's *local* axes: `wx`
526    /// along the member axis, `wy` transverse in local +y, and in 3D `wz`
527    /// transverse in local +z (`vec_xz` defines `y`/`z`). `wz` is absent in a
528    /// 2D model and defaults to 0 in a 3D one.
529    ///
530    /// Only the beam-column kinds currently honor this (`core`'s
531    /// `Element::form_load_vector` falls through to zero for every other
532    /// kind) — decode rejects any other `element_kind` explicitly rather
533    /// than silently accepting a load that will never apply. Several loads
534    /// on one element in one pattern sum.
535    Uniform {
536        wx: f64,
537        wy: f64,
538        #[serde(default)]
539        #[tsify(optional)]
540        wz: Option<f64>,
541    },
542    /// Body force per unit volume in global axes (continuum elements, 2D).
543    Body { bx: f64, by: f64 },
544    /// Traction per unit length of edge `edge` in global axes; edge `k` joins
545    /// local node `k` to `k + 1` (continuum elements, 2D).
546    EdgeTraction { edge: u8, tx: f64, ty: f64 },
547    /// Pressure on edge `edge`, positive into the element along the inward
548    /// normal (continuum elements, 2D).
549    EdgePressure { edge: u8, pressure: f64 },
550    /// Pressure per unit area on a shell, positive along its local normal `e3` (right-hand rule
551    /// from the node order). Applied as consistent nodal forces.
552    ShellPressure { pressure: f64 },
553    /// Body acceleration on a shell in global axes (a gravity vector `g` points down); the force
554    /// per unit area is `rho h b`. Note OpenSees' `ShellMITC4` `-selfWeight` has the opposite sign.
555    ShellBody { bx: f64, by: f64, bz: f64 },
556}
557
558/// Identity multi-point constraints (`core::Domain::equal_dof`'s doc
559/// comment): row `i` ties `constrained[i]`'s dofs listed in `dofs` exactly
560/// to the same dofs of `retained[i]`. `dofs` is sparse per row — `(row,
561/// dof)` pairs, mirroring `ZeroLengthTable::materials`'s own sparse
562/// convention — since most ties only ever list one or two dofs, not every
563/// dof a node has.
564#[derive(Debug, Clone, Default, Serialize, Deserialize, tsify::Tsify)]
565#[serde(rename_all = "camelCase")]
566pub struct EqualDofTable {
567    pub retained: Vec<u32>,
568    pub constrained: Vec<u32>,
569    pub dofs: Vec<(u32, u8)>,
570}
571
572/// `core::Axis3`'s wire mirror — core's own type carries no `serde` derive
573/// (`Axis3`'s doc comment: it's a plain `repr(usize)` enum for internal
574/// indexing, not wire-facing), so this is decode's own translation, the
575/// same reason `TransformSpec`/`TransformSpec3` mirror `GeomTransf`/
576/// `GeomTransf3` rather than deriving on the `core` type directly.
577#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, tsify::Tsify)]
578#[serde(rename_all = "camelCase")]
579pub enum Axis3Spec {
580    X,
581    Y,
582    Z,
583}
584
585/// Rigid diaphragm: row `i` ties every node listed against it in
586/// `constrained` to `retained[i]` — in 2D the `ux` dof (`core::Domain::
587/// rigid_diaphragm`'s doc comment); in 3D the two in-plane translational dofs
588/// (perpendicular to `normal[i]`) plus the lever-arm rotation term
589/// (`core::Domain3::rigid_diaphragm_about`). `constrained` is sparse per row —
590/// `(row, node index)` pairs — since a diaphragm's node count varies.
591/// `normal` is 3D only: omitted or empty means every row's normal is Y; a
592/// 2D model must leave it empty.
593#[derive(Debug, Clone, Default, Serialize, Deserialize, tsify::Tsify)]
594#[serde(rename_all = "camelCase")]
595pub struct RigidDiaphragmTable {
596    pub retained: Vec<u32>,
597    #[serde(default)]
598    #[tsify(optional)]
599    pub normal: Vec<Axis3Spec>,
600    pub constrained: Vec<(u32, u32)>,
601}
602
603/// Rigid links (`core::Domain::rigid_link`'s doc comment): `slave` moves with
604/// `master` as a rigid body, small-rotation lever arm taken from the node
605/// coordinates.
606#[derive(Debug, Clone, Default, Serialize, Deserialize, tsify::Tsify)]
607#[serde(rename_all = "camelCase")]
608pub struct RigidLinkTable {
609    pub master: Vec<u32>,
610    pub slave: Vec<u32>,
611}
612
613/// General linear multi-point constraints (`core::Domain::add_constraint`):
614/// row `i` defines `u[slaveNode[i], slaveDof[i]] = sum coeff * u[node, dof]`
615/// over the terms `termOffsets[i]..termOffsets[i + 1]` of the flattened sparse
616/// `termNode`/`termDof`/`termCoeff` arrays. `termOffsets` has `rows + 1`
617/// entries (or none at all for an empty table).
618#[derive(Debug, Clone, Default, Serialize, Deserialize, tsify::Tsify)]
619#[serde(rename_all = "camelCase")]
620pub struct LinearConstraintTable {
621    pub slave_node: Vec<u32>,
622    pub slave_dof: Vec<u8>,
623    pub term_offsets: Vec<u32>,
624    pub term_node: Vec<u32>,
625    pub term_dof: Vec<u8>,
626    pub term_coeff: Vec<f64>,
627}
628
629#[derive(Debug, Clone, Default, Serialize, Deserialize, tsify::Tsify)]
630#[serde(rename_all = "camelCase")]
631pub struct ElementLoadTable {
632    pub pattern: Vec<u32>,
633    pub element_kind: Vec<ElementKind>,
634    /// Row index into the table named by the parallel `element_kind` entry.
635    pub element_index: Vec<u32>,
636    pub load: Vec<ElementLoadSpec>,
637    /// See [`NodalLoadTable::stage`] — same per-stage registration timing.
638    pub stage: Vec<u32>,
639}