@holoscript/plugin-civil-engineering 2.0.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +14 -0
- package/LICENSE +21 -0
- package/package.json +13 -0
- package/src/__tests__/frame2d.test.ts +338 -0
- package/src/__tests__/runtime-integration.test.ts +197 -0
- package/src/frame2d.ts +638 -0
- package/src/index.ts +28 -0
- package/src/runtime.ts +207 -0
- package/src/traits/LoadBearingTrait.ts +25 -0
- package/src/traits/MaterialFatigueTrait.ts +28 -0
- package/src/traits/StructuralAnalysisTrait.ts +28 -0
- package/src/traits/types.ts +4 -0
- package/tsconfig.json +1 -0
- package/vitest.config.ts +22 -0
package/CHANGELOG.md
ADDED
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2025-2026 HoloScript Contributors
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/package.json
ADDED
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@holoscript/plugin-civil-engineering",
|
|
3
|
+
"version": "2.0.1",
|
|
4
|
+
"main": "src/index.ts",
|
|
5
|
+
"peerDependencies": {
|
|
6
|
+
"@holoscript/core": "8.0.6"
|
|
7
|
+
},
|
|
8
|
+
"license": "MIT",
|
|
9
|
+
"scripts": {
|
|
10
|
+
"test": "vitest run --passWithNoTests",
|
|
11
|
+
"test:coverage": "vitest run --coverage --passWithNoTests"
|
|
12
|
+
}
|
|
13
|
+
}
|
|
@@ -0,0 +1,338 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 2D Frame Solver tests — civil-engineering-plugin
|
|
3
|
+
*
|
|
4
|
+
* All expected values verified against textbook closed-form solutions.
|
|
5
|
+
*
|
|
6
|
+
* References:
|
|
7
|
+
* - Simply-supported beam midspan deflection: δ = PL³/(48EI)
|
|
8
|
+
* - Cantilever tip deflection: δ = PL³/(3EI)
|
|
9
|
+
* - Portal frame sway: standard DSM reference (McGuire et al.)
|
|
10
|
+
*/
|
|
11
|
+
|
|
12
|
+
import { describe, it, expect } from 'vitest';
|
|
13
|
+
import {
|
|
14
|
+
solveFrame2D,
|
|
15
|
+
validateFrame2DModel,
|
|
16
|
+
buildFrame2DReceipt,
|
|
17
|
+
type Frame2DModel,
|
|
18
|
+
} from '../frame2d';
|
|
19
|
+
|
|
20
|
+
// ─── Steel W-section defaults used across tests ────────────────────────────────
|
|
21
|
+
const E_GPa = 200; // GPa (structural steel)
|
|
22
|
+
const I_m4 = 1e-4; // m⁴ (moderate W-section)
|
|
23
|
+
const A_m2 = 5e-3; // m² (moderate W-section area)
|
|
24
|
+
|
|
25
|
+
// ─── Simply-supported beam ─────────────────────────────────────────────────────
|
|
26
|
+
|
|
27
|
+
describe('simply-supported beam under point load', () => {
|
|
28
|
+
/**
|
|
29
|
+
* Geometry: span L = 6 m, 3-node beam (two elements of 3 m each).
|
|
30
|
+
* Load: P = 100 kN downward at midspan node.
|
|
31
|
+
* Supports: pinned at left (ux+uy restrained), roller at right (uy restrained).
|
|
32
|
+
*
|
|
33
|
+
* Closed-form midspan deflection: δ = PL³/(48EI)
|
|
34
|
+
* = 100 × 6³ / (48 × 200e6 × 1e-4)
|
|
35
|
+
* = 100 × 216 / (48 × 20000)
|
|
36
|
+
* = 21600 / 960000 ≈ 0.0225 m
|
|
37
|
+
*/
|
|
38
|
+
const L = 6;
|
|
39
|
+
const P = 100; // kN
|
|
40
|
+
const expectedDeflection = (P * L ** 3) / (48 * E_GPa * 1e6 * I_m4); // m
|
|
41
|
+
|
|
42
|
+
const model: Frame2DModel = {
|
|
43
|
+
id: 'simply-supported-beam',
|
|
44
|
+
nodes: [
|
|
45
|
+
{ id: 'A', x: 0, y: 0 },
|
|
46
|
+
{ id: 'M', x: 3, y: 0 },
|
|
47
|
+
{ id: 'B', x: 6, y: 0 },
|
|
48
|
+
],
|
|
49
|
+
elements: [
|
|
50
|
+
{ id: 'e1', fromNodeId: 'A', toNodeId: 'M', elasticModulusGPa: E_GPa, momentOfInertiaM4: I_m4, areaM2: A_m2 },
|
|
51
|
+
{ id: 'e2', fromNodeId: 'M', toNodeId: 'B', elasticModulusGPa: E_GPa, momentOfInertiaM4: I_m4, areaM2: A_m2 },
|
|
52
|
+
],
|
|
53
|
+
supports: [
|
|
54
|
+
{ nodeId: 'A', ux: true, uy: true }, // pin
|
|
55
|
+
{ nodeId: 'B', uy: true }, // roller
|
|
56
|
+
],
|
|
57
|
+
nodalLoads: [
|
|
58
|
+
{ nodeId: 'M', Fy: -P }, // downward
|
|
59
|
+
],
|
|
60
|
+
};
|
|
61
|
+
|
|
62
|
+
it('converges', () => {
|
|
63
|
+
const result = solveFrame2D(model);
|
|
64
|
+
expect(result.converged).toBe(true);
|
|
65
|
+
});
|
|
66
|
+
|
|
67
|
+
it('midspan deflection matches closed-form PL³/(48EI) within 1%', () => {
|
|
68
|
+
const result = solveFrame2D(model);
|
|
69
|
+
const midNode = result.nodeDisplacements.find((d) => d.nodeId === 'M')!;
|
|
70
|
+
const delta = Math.abs(midNode.uy);
|
|
71
|
+
const error = Math.abs(delta - expectedDeflection) / expectedDeflection;
|
|
72
|
+
expect(error).toBeLessThan(0.01);
|
|
73
|
+
});
|
|
74
|
+
|
|
75
|
+
it('reaction forces sum to applied load (vertical equilibrium)', () => {
|
|
76
|
+
const result = solveFrame2D(model);
|
|
77
|
+
const totalRy = result.reactions.reduce((s, r) => s + r.Ry, 0);
|
|
78
|
+
// Applied Fy = -100 kN; reactions should sum to +100 kN
|
|
79
|
+
expect(totalRy).toBeCloseTo(P, 1);
|
|
80
|
+
});
|
|
81
|
+
|
|
82
|
+
it('end nodes have zero vertical displacement', () => {
|
|
83
|
+
const result = solveFrame2D(model);
|
|
84
|
+
const nodeA = result.nodeDisplacements.find((d) => d.nodeId === 'A')!;
|
|
85
|
+
const nodeB = result.nodeDisplacements.find((d) => d.nodeId === 'B')!;
|
|
86
|
+
expect(Math.abs(nodeA.uy)).toBeLessThan(1e-8);
|
|
87
|
+
expect(Math.abs(nodeB.uy)).toBeLessThan(1e-8);
|
|
88
|
+
});
|
|
89
|
+
});
|
|
90
|
+
|
|
91
|
+
// ─── Cantilever beam ───────────────────────────────────────────────────────────
|
|
92
|
+
|
|
93
|
+
describe('cantilever beam under tip load', () => {
|
|
94
|
+
/**
|
|
95
|
+
* Fixed at A, free at B. Load P = 50 kN downward at B.
|
|
96
|
+
* δ_tip = PL³/(3EI) = 50 × 4³ / (3 × 200e6 × 1e-4)
|
|
97
|
+
* = 50 × 64 / 60000 ≈ 0.05333 m
|
|
98
|
+
*/
|
|
99
|
+
const L = 4;
|
|
100
|
+
const P = 50;
|
|
101
|
+
const expectedTipDeflection = (P * L ** 3) / (3 * E_GPa * 1e6 * I_m4);
|
|
102
|
+
|
|
103
|
+
const model: Frame2DModel = {
|
|
104
|
+
id: 'cantilever-beam',
|
|
105
|
+
nodes: [
|
|
106
|
+
{ id: 'A', x: 0, y: 0 },
|
|
107
|
+
{ id: 'B', x: L, y: 0 },
|
|
108
|
+
],
|
|
109
|
+
elements: [
|
|
110
|
+
{ id: 'e1', fromNodeId: 'A', toNodeId: 'B', elasticModulusGPa: E_GPa, momentOfInertiaM4: I_m4, areaM2: A_m2 },
|
|
111
|
+
],
|
|
112
|
+
supports: [
|
|
113
|
+
{ nodeId: 'A', ux: true, uy: true, theta: true }, // fixed
|
|
114
|
+
],
|
|
115
|
+
nodalLoads: [
|
|
116
|
+
{ nodeId: 'B', Fy: -P },
|
|
117
|
+
],
|
|
118
|
+
};
|
|
119
|
+
|
|
120
|
+
it('tip deflection matches closed-form PL³/(3EI) within 1%', () => {
|
|
121
|
+
const result = solveFrame2D(model);
|
|
122
|
+
const tipNode = result.nodeDisplacements.find((d) => d.nodeId === 'B')!;
|
|
123
|
+
const delta = Math.abs(tipNode.uy);
|
|
124
|
+
const error = Math.abs(delta - expectedTipDeflection) / expectedTipDeflection;
|
|
125
|
+
expect(error).toBeLessThan(0.01);
|
|
126
|
+
});
|
|
127
|
+
|
|
128
|
+
it('fixed support carries full shear and moment', () => {
|
|
129
|
+
const result = solveFrame2D(model);
|
|
130
|
+
const reaction = result.reactions.find((r) => r.nodeId === 'A')!;
|
|
131
|
+
expect(reaction.Ry).toBeCloseTo(P, 1);
|
|
132
|
+
// Fixed-end moment = P * L
|
|
133
|
+
expect(Math.abs(reaction.Mz)).toBeCloseTo(P * L, 1);
|
|
134
|
+
});
|
|
135
|
+
|
|
136
|
+
it('fixed end has zero displacement and rotation', () => {
|
|
137
|
+
const result = solveFrame2D(model);
|
|
138
|
+
const fixedNode = result.nodeDisplacements.find((d) => d.nodeId === 'A')!;
|
|
139
|
+
expect(Math.abs(fixedNode.ux)).toBeLessThan(1e-6);
|
|
140
|
+
expect(Math.abs(fixedNode.uy)).toBeLessThan(1e-6);
|
|
141
|
+
expect(Math.abs(fixedNode.theta)).toBeLessThan(1e-6);
|
|
142
|
+
});
|
|
143
|
+
});
|
|
144
|
+
|
|
145
|
+
// ─── Inclined element (truss-like) ────────────────────────────────────────────
|
|
146
|
+
|
|
147
|
+
describe('inclined element — coordinate transformation', () => {
|
|
148
|
+
/**
|
|
149
|
+
* Diagonal element from (0,0) to (3,4) (L=5m).
|
|
150
|
+
* Fixed at A, roller allowing Y at B.
|
|
151
|
+
* Horizontal load Fx = 100 kN at B.
|
|
152
|
+
* Verifies transformation matrix T is applied correctly.
|
|
153
|
+
*/
|
|
154
|
+
const model: Frame2DModel = {
|
|
155
|
+
id: 'inclined-element',
|
|
156
|
+
nodes: [
|
|
157
|
+
{ id: 'A', x: 0, y: 0 },
|
|
158
|
+
{ id: 'B', x: 3, y: 4 },
|
|
159
|
+
],
|
|
160
|
+
elements: [
|
|
161
|
+
{ id: 'e1', fromNodeId: 'A', toNodeId: 'B', elasticModulusGPa: E_GPa, momentOfInertiaM4: I_m4, areaM2: A_m2 },
|
|
162
|
+
],
|
|
163
|
+
supports: [
|
|
164
|
+
{ nodeId: 'A', ux: true, uy: true, theta: true },
|
|
165
|
+
],
|
|
166
|
+
nodalLoads: [
|
|
167
|
+
{ nodeId: 'B', Fx: 100 },
|
|
168
|
+
],
|
|
169
|
+
};
|
|
170
|
+
|
|
171
|
+
it('converges for inclined element', () => {
|
|
172
|
+
expect(() => solveFrame2D(model)).not.toThrow();
|
|
173
|
+
const result = solveFrame2D(model);
|
|
174
|
+
expect(result.converged).toBe(true);
|
|
175
|
+
});
|
|
176
|
+
|
|
177
|
+
it('horizontal equilibrium holds', () => {
|
|
178
|
+
const result = solveFrame2D(model);
|
|
179
|
+
const Rx = result.reactions.find((r) => r.nodeId === 'A')?.Rx ?? 0;
|
|
180
|
+
// Reaction must balance applied horizontal force
|
|
181
|
+
expect(Rx).toBeCloseTo(-100, 1);
|
|
182
|
+
});
|
|
183
|
+
});
|
|
184
|
+
|
|
185
|
+
// ─── UDL on simply-supported beam ────────────────────────────────────────────
|
|
186
|
+
|
|
187
|
+
describe('simply-supported beam under UDL', () => {
|
|
188
|
+
/**
|
|
189
|
+
* L = 6 m, w = 10 kN/m → total load = 60 kN.
|
|
190
|
+
* Reactions at each end = 30 kN (symmetric).
|
|
191
|
+
* Max moment at midspan = wL²/8 = 10×36/8 = 45 kN·m
|
|
192
|
+
* Midspan deflection: 5wL⁴/(384EI) = 5×10×1296/(384×200e6×1e-4) ≈ 0.008438 m
|
|
193
|
+
*/
|
|
194
|
+
const L = 6;
|
|
195
|
+
const w = 10; // kN/m
|
|
196
|
+
const expectedDeflection = (5 * w * L ** 4) / (384 * E_GPa * 1e6 * I_m4);
|
|
197
|
+
|
|
198
|
+
const model: Frame2DModel = {
|
|
199
|
+
id: 'udl-ss-beam',
|
|
200
|
+
nodes: [
|
|
201
|
+
{ id: 'A', x: 0, y: 0 },
|
|
202
|
+
{ id: 'M', x: 3, y: 0 },
|
|
203
|
+
{ id: 'B', x: 6, y: 0 },
|
|
204
|
+
],
|
|
205
|
+
elements: [
|
|
206
|
+
{ id: 'e1', fromNodeId: 'A', toNodeId: 'M', elasticModulusGPa: E_GPa, momentOfInertiaM4: I_m4, areaM2: A_m2 },
|
|
207
|
+
{ id: 'e2', fromNodeId: 'M', toNodeId: 'B', elasticModulusGPa: E_GPa, momentOfInertiaM4: I_m4, areaM2: A_m2 },
|
|
208
|
+
],
|
|
209
|
+
supports: [
|
|
210
|
+
{ nodeId: 'A', ux: true, uy: true },
|
|
211
|
+
{ nodeId: 'B', uy: true },
|
|
212
|
+
],
|
|
213
|
+
distributedLoads: [
|
|
214
|
+
{ elementId: 'e1', w },
|
|
215
|
+
{ elementId: 'e2', w },
|
|
216
|
+
],
|
|
217
|
+
};
|
|
218
|
+
|
|
219
|
+
it('vertical reactions each = wL/2', () => {
|
|
220
|
+
const result = solveFrame2D(model);
|
|
221
|
+
const Ra = result.reactions.find((r) => r.nodeId === 'A')?.Ry ?? 0;
|
|
222
|
+
const Rb = result.reactions.find((r) => r.nodeId === 'B')?.Ry ?? 0;
|
|
223
|
+
expect(Ra).toBeCloseTo((w * L) / 2, 1);
|
|
224
|
+
expect(Rb).toBeCloseTo((w * L) / 2, 1);
|
|
225
|
+
});
|
|
226
|
+
|
|
227
|
+
it('midspan deflection approximates 5wL⁴/(384EI) within 5%', () => {
|
|
228
|
+
const result = solveFrame2D(model);
|
|
229
|
+
const midNode = result.nodeDisplacements.find((d) => d.nodeId === 'M')!;
|
|
230
|
+
const delta = Math.abs(midNode.uy);
|
|
231
|
+
const error = Math.abs(delta - expectedDeflection) / expectedDeflection;
|
|
232
|
+
// 2-element DSM gives good approximation of mid-span deflection for UDL
|
|
233
|
+
expect(error).toBeLessThan(0.05);
|
|
234
|
+
});
|
|
235
|
+
});
|
|
236
|
+
|
|
237
|
+
// ─── Validation ───────────────────────────────────────────────────────────────
|
|
238
|
+
|
|
239
|
+
describe('validateFrame2DModel', () => {
|
|
240
|
+
it('returns valid for a well-formed model', () => {
|
|
241
|
+
const model: Frame2DModel = {
|
|
242
|
+
id: 'valid',
|
|
243
|
+
nodes: [{ id: 'A', x: 0, y: 0 }, { id: 'B', x: 5, y: 0 }],
|
|
244
|
+
elements: [{ id: 'e1', fromNodeId: 'A', toNodeId: 'B', elasticModulusGPa: 200, momentOfInertiaM4: 1e-4, areaM2: 5e-3 }],
|
|
245
|
+
supports: [{ nodeId: 'A', ux: true, uy: true, theta: true }],
|
|
246
|
+
};
|
|
247
|
+
const v = validateFrame2DModel(model);
|
|
248
|
+
expect(v.valid).toBe(true);
|
|
249
|
+
expect(v.errors).toHaveLength(0);
|
|
250
|
+
});
|
|
251
|
+
|
|
252
|
+
it('errors on missing node reference in element', () => {
|
|
253
|
+
const model: Frame2DModel = {
|
|
254
|
+
id: 'bad-elem',
|
|
255
|
+
nodes: [{ id: 'A', x: 0, y: 0 }],
|
|
256
|
+
elements: [{ id: 'e1', fromNodeId: 'A', toNodeId: 'MISSING', elasticModulusGPa: 200, momentOfInertiaM4: 1e-4, areaM2: 5e-3 }],
|
|
257
|
+
supports: [{ nodeId: 'A', ux: true, uy: true, theta: true }],
|
|
258
|
+
};
|
|
259
|
+
const v = validateFrame2DModel(model);
|
|
260
|
+
expect(v.valid).toBe(false);
|
|
261
|
+
expect(v.errors.some((e) => e.includes('MISSING'))).toBe(true);
|
|
262
|
+
});
|
|
263
|
+
|
|
264
|
+
it('errors when fewer than 3 DOF are restrained', () => {
|
|
265
|
+
const model: Frame2DModel = {
|
|
266
|
+
id: 'unstable',
|
|
267
|
+
nodes: [{ id: 'A', x: 0, y: 0 }, { id: 'B', x: 5, y: 0 }],
|
|
268
|
+
elements: [{ id: 'e1', fromNodeId: 'A', toNodeId: 'B', elasticModulusGPa: 200, momentOfInertiaM4: 1e-4, areaM2: 5e-3 }],
|
|
269
|
+
supports: [{ nodeId: 'A', ux: true }], // only 1 DOF
|
|
270
|
+
};
|
|
271
|
+
const v = validateFrame2DModel(model);
|
|
272
|
+
expect(v.valid).toBe(false);
|
|
273
|
+
expect(v.errors.some((e) => e.includes('insufficient supports'))).toBe(true);
|
|
274
|
+
});
|
|
275
|
+
|
|
276
|
+
it('solver throws for invalid model', () => {
|
|
277
|
+
const model: Frame2DModel = {
|
|
278
|
+
id: 'invalid',
|
|
279
|
+
nodes: [{ id: 'A', x: 0, y: 0 }, { id: 'B', x: 5, y: 0 }],
|
|
280
|
+
elements: [{ id: 'e1', fromNodeId: 'A', toNodeId: 'B', elasticModulusGPa: 200, momentOfInertiaM4: 1e-4, areaM2: 5e-3 }],
|
|
281
|
+
supports: [], // no supports
|
|
282
|
+
};
|
|
283
|
+
expect(() => solveFrame2D(model)).toThrow();
|
|
284
|
+
});
|
|
285
|
+
});
|
|
286
|
+
|
|
287
|
+
// ─── Receipt ──────────────────────────────────────────────────────────────────
|
|
288
|
+
|
|
289
|
+
describe('buildFrame2DReceipt', () => {
|
|
290
|
+
const model: Frame2DModel = {
|
|
291
|
+
id: 'receipt-test',
|
|
292
|
+
nodes: [
|
|
293
|
+
{ id: 'A', x: 0, y: 0 },
|
|
294
|
+
{ id: 'B', x: 6, y: 0 },
|
|
295
|
+
],
|
|
296
|
+
elements: [
|
|
297
|
+
{
|
|
298
|
+
id: 'e1', fromNodeId: 'A', toNodeId: 'B',
|
|
299
|
+
elasticModulusGPa: E_GPa, momentOfInertiaM4: I_m4, areaM2: A_m2,
|
|
300
|
+
plasticModulusM3: 5e-4, yieldStrengthMPa: 250,
|
|
301
|
+
},
|
|
302
|
+
],
|
|
303
|
+
supports: [
|
|
304
|
+
{ nodeId: 'A', ux: true, uy: true, theta: true },
|
|
305
|
+
],
|
|
306
|
+
nodalLoads: [{ nodeId: 'B', Fy: -10 }],
|
|
307
|
+
};
|
|
308
|
+
|
|
309
|
+
it('produces receipt with plugin=civil-engineering and CAEL event', () => {
|
|
310
|
+
const result = solveFrame2D(model);
|
|
311
|
+
const receipt = buildFrame2DReceipt(model, result);
|
|
312
|
+
expect(receipt.plugin).toBe('civil-engineering');
|
|
313
|
+
expect(receipt.cael.event).toBe('civil_engineering.frame_analysis');
|
|
314
|
+
expect(receipt.payloadHash).toBeTruthy();
|
|
315
|
+
});
|
|
316
|
+
|
|
317
|
+
it('accepted=true for structurally adequate result', () => {
|
|
318
|
+
// Very light load — utilisation should be well below 1.0
|
|
319
|
+
const result = solveFrame2D(model);
|
|
320
|
+
const receipt = buildFrame2DReceipt(model, result);
|
|
321
|
+
// Don't assert accepted=true (depends on section modulus vs load)
|
|
322
|
+
// but verify acceptance object has the correct structure
|
|
323
|
+
expect(typeof receipt.acceptance.accepted).toBe('boolean');
|
|
324
|
+
expect(Array.isArray(receipt.acceptance.violations)).toBe(true);
|
|
325
|
+
});
|
|
326
|
+
|
|
327
|
+
it('uses provided runId', () => {
|
|
328
|
+
const result = solveFrame2D(model);
|
|
329
|
+
const receipt = buildFrame2DReceipt(model, result, { runId: 'frame-42' });
|
|
330
|
+
expect(receipt.runId).toBe('frame-42');
|
|
331
|
+
});
|
|
332
|
+
|
|
333
|
+
it('resultSummary.maxDisplacementMm is positive for a deflected structure', () => {
|
|
334
|
+
const result = solveFrame2D(model);
|
|
335
|
+
const receipt = buildFrame2DReceipt(model, result);
|
|
336
|
+
expect(receipt.resultSummary.maxDisplacementMm).toBeGreaterThan(0);
|
|
337
|
+
});
|
|
338
|
+
});
|
|
@@ -0,0 +1,197 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Integration proof: the civil-engineering `dsm_frame_2d` trait, once registered
|
|
3
|
+
* via the runtime's real `registerTrait` seam, is dispatched BY THE RUNTIME and
|
|
4
|
+
* runs the deterministic Direct-Stiffness-Method 2D frame solver — NOT called
|
|
5
|
+
* directly as a handler object.
|
|
6
|
+
*
|
|
7
|
+
* Mirrors government-civic-plugin's runtime-integration reference (civic_decision).
|
|
8
|
+
* Drives the real path: executeNode(orb) -> orb-executor -> applyDirectives ->
|
|
9
|
+
* traitHandlers.get('dsm_frame_2d').onAttach -> solveFrame2D.
|
|
10
|
+
* The negative control proves the registration is load-bearing (without it, the
|
|
11
|
+
* trait is a dead no-op — which is exactly the tier's status quo).
|
|
12
|
+
*/
|
|
13
|
+
import { describe, it, expect } from 'vitest';
|
|
14
|
+
import { HoloScriptRuntime } from '@holoscript/core/runtime';
|
|
15
|
+
import { registerCivilEngineeringTraitHandlers } from '../runtime';
|
|
16
|
+
import type { Frame2DModel } from '../frame2d';
|
|
17
|
+
|
|
18
|
+
// ─── Hand-derived statically-determinate cantilever ────────────────────────────
|
|
19
|
+
//
|
|
20
|
+
// A single horizontal member, fixed at A, free at B, with a downward tip load.
|
|
21
|
+
// A cantilever is statically determinate, so its support reactions follow
|
|
22
|
+
// EXACTLY from rigid-body global equilibrium — independent of the FEM mesh.
|
|
23
|
+
//
|
|
24
|
+
// A=(0,0) ●━━━━━━━━━━━━● B=(4,0) Fy = -50 kN (down) at B
|
|
25
|
+
// (fixed: ux,uy,θz) (free)
|
|
26
|
+
//
|
|
27
|
+
// Steel member: E = 200 GPa, I = 1e-4 m⁴, A_area = 5e-3 m², L = 4 m.
|
|
28
|
+
//
|
|
29
|
+
// GLOBAL STATIC EQUILIBRIUM (exact, mesh-independent):
|
|
30
|
+
// ΣFx = 0 => Rx_A = 0
|
|
31
|
+
// ΣFy = 0 => Ry_A + (-50) = 0 => Ry_A = +50 kN
|
|
32
|
+
// ΣM_about_A = 0 => moment of applied load about A = x·Fy - y·Fx
|
|
33
|
+
// = 4·(-50) - 0·0 = -200 kN·m
|
|
34
|
+
// => Mz_A - 200 = 0 => |Mz_A| = P·L = 50·4 = 200 kN·m
|
|
35
|
+
//
|
|
36
|
+
// TIP DEFLECTION (Euler-Bernoulli cantilever closed form):
|
|
37
|
+
// uy_B = -P·L³/(3·E·I)
|
|
38
|
+
// = -50 · 4³ / (3 · 200e6 kN/m² · 1e-4 m⁴)
|
|
39
|
+
// = -(50·64) / (60000)
|
|
40
|
+
// = -3200 / 60000
|
|
41
|
+
// = -0.0533333… m (≈ 53.33 mm downward)
|
|
42
|
+
//
|
|
43
|
+
// The existing frame2d.test.ts confirms the solver reproduces both within 1%.
|
|
44
|
+
// Here we assert against THIS hand derivation, not solver output.
|
|
45
|
+
const E_GPa = 200;
|
|
46
|
+
const I_m4 = 1e-4;
|
|
47
|
+
const A_m2 = 5e-3;
|
|
48
|
+
const L = 4;
|
|
49
|
+
const P = 50; // kN
|
|
50
|
+
|
|
51
|
+
const EXPECTED_RY = P; // +50 kN (ΣFy = 0)
|
|
52
|
+
const EXPECTED_MZ_MAG = P * L; // 200 kN·m (ΣM_A = 0)
|
|
53
|
+
const EXPECTED_TIP_UY = -(P * L ** 3) / (3 * E_GPa * 1e6 * I_m4); // -0.0533333… m
|
|
54
|
+
|
|
55
|
+
const CANTILEVER: Frame2DModel = {
|
|
56
|
+
id: 'cantilever-hand-check',
|
|
57
|
+
nodes: [
|
|
58
|
+
{ id: 'A', x: 0, y: 0 },
|
|
59
|
+
{ id: 'B', x: L, y: 0 },
|
|
60
|
+
],
|
|
61
|
+
elements: [
|
|
62
|
+
{ id: 'e1', fromNodeId: 'A', toNodeId: 'B', elasticModulusGPa: E_GPa, momentOfInertiaM4: I_m4, areaM2: A_m2 },
|
|
63
|
+
],
|
|
64
|
+
supports: [
|
|
65
|
+
{ nodeId: 'A', ux: true, uy: true, theta: true }, // fixed
|
|
66
|
+
],
|
|
67
|
+
nodalLoads: [{ nodeId: 'B', Fy: -P }],
|
|
68
|
+
};
|
|
69
|
+
|
|
70
|
+
// Under-constrained model: only 1 restrained DOF (< 3 required to prevent
|
|
71
|
+
// rigid-body motion). validateFrame2DModel flags "insufficient supports", so
|
|
72
|
+
// the handler emits dsm_frame_2d_error rather than letting the solver throw.
|
|
73
|
+
const UNDER_CONSTRAINED: Frame2DModel = {
|
|
74
|
+
id: 'under-constrained',
|
|
75
|
+
nodes: [
|
|
76
|
+
{ id: 'A', x: 0, y: 0 },
|
|
77
|
+
{ id: 'B', x: L, y: 0 },
|
|
78
|
+
],
|
|
79
|
+
elements: [
|
|
80
|
+
{ id: 'e1', fromNodeId: 'A', toNodeId: 'B', elasticModulusGPa: E_GPa, momentOfInertiaM4: I_m4, areaM2: A_m2 },
|
|
81
|
+
],
|
|
82
|
+
supports: [{ nodeId: 'A', ux: true }], // only 1 DOF restrained
|
|
83
|
+
nodalLoads: [{ nodeId: 'B', Fy: -P }],
|
|
84
|
+
};
|
|
85
|
+
|
|
86
|
+
function dsmFrame2dOrb(config: Record<string, unknown>): unknown {
|
|
87
|
+
return {
|
|
88
|
+
type: 'orb',
|
|
89
|
+
name: 'frame',
|
|
90
|
+
properties: {},
|
|
91
|
+
methods: [],
|
|
92
|
+
position: [0, 0, 0],
|
|
93
|
+
hologram: { shape: 'orb', color: '#fff', size: 1, glow: false, interactive: false },
|
|
94
|
+
directives: [{ type: 'trait', name: 'dsm_frame_2d', config }],
|
|
95
|
+
};
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
/** Flush the runtime's async emit dispatch so `on` listeners have fired. */
|
|
99
|
+
const flush = (): Promise<void> => new Promise((resolve) => setTimeout(resolve, 0));
|
|
100
|
+
|
|
101
|
+
describe('civil-engineering -> HoloScript runtime integration (dsm_frame_2d)', () => {
|
|
102
|
+
it('runtime dispatch runs the DSM frame solver for a registered @dsm_frame_2d orb', async () => {
|
|
103
|
+
const runtime = new HoloScriptRuntime();
|
|
104
|
+
registerCivilEngineeringTraitHandlers(runtime);
|
|
105
|
+
|
|
106
|
+
const solved: Array<Record<string, unknown>> = [];
|
|
107
|
+
runtime.on('dsm_frame_2d_solved', (e: unknown) => {
|
|
108
|
+
solved.push(e as Record<string, unknown>);
|
|
109
|
+
});
|
|
110
|
+
|
|
111
|
+
await runtime.executeNode(dsmFrame2dOrb({ model: CANTILEVER }) as never);
|
|
112
|
+
await flush();
|
|
113
|
+
|
|
114
|
+
expect(solved).toHaveLength(1);
|
|
115
|
+
const summary = solved[0];
|
|
116
|
+
expect(summary.converged).toBe(true);
|
|
117
|
+
expect(summary.nodeCount).toBe(2);
|
|
118
|
+
expect(summary.elementCount).toBe(1);
|
|
119
|
+
|
|
120
|
+
// Hand-checked against global static equilibrium of the cantilever:
|
|
121
|
+
// Ry_A = +P = +50 kN ; |Mz_A| = P·L = 200 kN·m ; Rx_A = 0.
|
|
122
|
+
const reactions = summary.reactions as Array<{ nodeId: string; Rx: number; Ry: number; Mz: number }>;
|
|
123
|
+
const rA = reactions.find((r) => r.nodeId === 'A')!;
|
|
124
|
+
expect(rA.Rx).toBeCloseTo(0, 1);
|
|
125
|
+
expect(rA.Ry).toBeCloseTo(EXPECTED_RY, 1); // +50 kN
|
|
126
|
+
expect(Math.abs(rA.Mz)).toBeCloseTo(EXPECTED_MZ_MAG, 1); // 200 kN·m
|
|
127
|
+
});
|
|
128
|
+
|
|
129
|
+
it('runtime dispatch produces the hand-derived tip deflection in the solved event', async () => {
|
|
130
|
+
const runtime = new HoloScriptRuntime();
|
|
131
|
+
registerCivilEngineeringTraitHandlers(runtime);
|
|
132
|
+
|
|
133
|
+
const solved: Array<Record<string, unknown>> = [];
|
|
134
|
+
runtime.on('dsm_frame_2d_solved', (e: unknown) => {
|
|
135
|
+
solved.push(e as Record<string, unknown>);
|
|
136
|
+
});
|
|
137
|
+
|
|
138
|
+
await runtime.executeNode(dsmFrame2dOrb({ model: CANTILEVER }) as never);
|
|
139
|
+
await flush();
|
|
140
|
+
|
|
141
|
+
expect(solved).toHaveLength(1);
|
|
142
|
+
const disp = solved[0].nodeDisplacements as Array<{ nodeId: string; uy: number }>;
|
|
143
|
+
const tip = disp.find((d) => d.nodeId === 'B')!;
|
|
144
|
+
// Hand-checked: uy_B = -P·L³/(3EI) = -0.0533333… m (FEM rounding -> toBeCloseTo).
|
|
145
|
+
expect(tip.uy).toBeCloseTo(EXPECTED_TIP_UY, 4);
|
|
146
|
+
});
|
|
147
|
+
|
|
148
|
+
it('NEGATIVE CONTROL: without registration the @dsm_frame_2d trait is a dead no-op', async () => {
|
|
149
|
+
const runtime = new HoloScriptRuntime(); // intentionally NOT registered
|
|
150
|
+
const solved: unknown[] = [];
|
|
151
|
+
runtime.on('dsm_frame_2d_solved', (e: unknown) => solved.push(e));
|
|
152
|
+
|
|
153
|
+
await runtime.executeNode(dsmFrame2dOrb({ model: CANTILEVER }) as never);
|
|
154
|
+
await flush();
|
|
155
|
+
|
|
156
|
+
expect(solved).toHaveLength(0);
|
|
157
|
+
});
|
|
158
|
+
|
|
159
|
+
it('persists the solver result into durable runtime state on ATTACH', async () => {
|
|
160
|
+
const runtime = new HoloScriptRuntime();
|
|
161
|
+
registerCivilEngineeringTraitHandlers(runtime);
|
|
162
|
+
|
|
163
|
+
await runtime.executeNode(dsmFrame2dOrb({ model: CANTILEVER }) as never);
|
|
164
|
+
await flush();
|
|
165
|
+
|
|
166
|
+
const state = runtime.getState() as Record<string, unknown>;
|
|
167
|
+
const persisted = state['dsm_frame_2d:frame'] as
|
|
168
|
+
| { converged?: boolean; nodeCount?: number; reactions?: Array<{ nodeId: string; Ry: number }> }
|
|
169
|
+
| undefined;
|
|
170
|
+
expect(persisted).toBeDefined();
|
|
171
|
+
expect(persisted?.converged).toBe(true);
|
|
172
|
+
expect(persisted?.nodeCount).toBe(2);
|
|
173
|
+
const rA = persisted?.reactions?.find((r) => r.nodeId === 'A');
|
|
174
|
+
expect(rA?.Ry).toBeCloseTo(EXPECTED_RY, 1); // hand-derived +50 kN survives into state
|
|
175
|
+
});
|
|
176
|
+
|
|
177
|
+
it('emits dsm_frame_2d_error (does not throw through the runtime) for an under-constrained model', async () => {
|
|
178
|
+
const runtime = new HoloScriptRuntime();
|
|
179
|
+
registerCivilEngineeringTraitHandlers(runtime);
|
|
180
|
+
|
|
181
|
+
const errors: Array<Record<string, unknown>> = [];
|
|
182
|
+
const solved: unknown[] = [];
|
|
183
|
+
runtime.on('dsm_frame_2d_error', (e: unknown) => {
|
|
184
|
+
errors.push(e as Record<string, unknown>);
|
|
185
|
+
});
|
|
186
|
+
runtime.on('dsm_frame_2d_solved', (e: unknown) => solved.push(e));
|
|
187
|
+
|
|
188
|
+
// Only 1 restrained DOF — validateFrame2DModel reports "insufficient
|
|
189
|
+
// supports", which the handler turns into a single error event, no throw.
|
|
190
|
+
await runtime.executeNode(dsmFrame2dOrb({ model: UNDER_CONSTRAINED }) as never);
|
|
191
|
+
await flush();
|
|
192
|
+
|
|
193
|
+
expect(errors).toHaveLength(1);
|
|
194
|
+
expect(solved).toHaveLength(0);
|
|
195
|
+
expect(String(errors[0].error)).toContain('insufficient supports');
|
|
196
|
+
});
|
|
197
|
+
});
|