@forgeax/engine-physics 0.1.26 → 0.1.28
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/README.md +116 -1
- package/dist/__tests__/derived-physics.unit.test.d.ts +2 -0
- package/dist/__tests__/derived-physics.unit.test.d.ts.map +1 -0
- package/dist/derived-physics.d.ts +218 -0
- package/dist/derived-physics.d.ts.map +1 -0
- package/dist/index.d.ts +2 -0
- package/dist/index.d.ts.map +1 -1
- package/dist/index.mjs +415 -2
- package/dist/index.mjs.map +1 -1
- package/dist/physics-world.d.ts +60 -2
- package/dist/physics-world.d.ts.map +1 -1
- package/package.json +22 -10
- package/src/__tests__/derived-physics.unit.test.ts +156 -0
- package/src/derived-physics.ts +740 -0
- package/src/index.ts +31 -0
- package/src/physics-world.ts +79 -2
- package/src/plugin-factory.ts +2 -2
|
@@ -0,0 +1,740 @@
|
|
|
1
|
+
import { err, ok, type Result } from '@forgeax/engine-types';
|
|
2
|
+
|
|
3
|
+
/** A portable integer grid coordinate. */
|
|
4
|
+
export type VoxelCell = readonly [number, number, number];
|
|
5
|
+
|
|
6
|
+
/** A portable quaternion used by derived-physics inputs. */
|
|
7
|
+
export type PhysicsQuaternion = readonly [number, number, number, number];
|
|
8
|
+
|
|
9
|
+
/** A portable three-component vector used by derived-physics inputs. */
|
|
10
|
+
export type PhysicsVector = readonly [number, number, number];
|
|
11
|
+
|
|
12
|
+
/**
|
|
13
|
+
* One local voxel shape. `cells` contains contiguous x/y/z triples. The
|
|
14
|
+
* backend copies it while preparing a candidate, so callers may reuse their
|
|
15
|
+
* scratch buffer after the call returns.
|
|
16
|
+
*/
|
|
17
|
+
export interface VoxelShapeInput {
|
|
18
|
+
readonly id: string;
|
|
19
|
+
readonly revision: number;
|
|
20
|
+
readonly cells: Int32Array | readonly VoxelCell[];
|
|
21
|
+
readonly voxelSize: PhysicsVector;
|
|
22
|
+
readonly origin?: PhysicsVector;
|
|
23
|
+
readonly rotation?: PhysicsQuaternion;
|
|
24
|
+
readonly friction?: number;
|
|
25
|
+
readonly restitution?: number;
|
|
26
|
+
readonly density?: number;
|
|
27
|
+
readonly isSensor?: boolean;
|
|
28
|
+
readonly collisionGroups?: number;
|
|
29
|
+
readonly solverGroups?: number;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/** Stable producer identity and revision for one constraint endpoint. */
|
|
33
|
+
export interface PhysicsConstraintBodyDependency {
|
|
34
|
+
readonly sourceKey: string;
|
|
35
|
+
readonly revision: number;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/** Explicit or density-derived rigid-body mass properties. */
|
|
39
|
+
export type PhysicsMassProperties =
|
|
40
|
+
| {
|
|
41
|
+
readonly mode: 'automatic';
|
|
42
|
+
readonly density?: number;
|
|
43
|
+
}
|
|
44
|
+
| {
|
|
45
|
+
readonly mode: 'explicit';
|
|
46
|
+
readonly mass: number;
|
|
47
|
+
readonly centerOfMass: PhysicsVector;
|
|
48
|
+
readonly principalInertia: PhysicsVector;
|
|
49
|
+
readonly principalInertiaLocalFrame?: PhysicsQuaternion;
|
|
50
|
+
};
|
|
51
|
+
|
|
52
|
+
/** How a topology replacement carries the committed body's motion. */
|
|
53
|
+
export type PhysicsVelocityPolicy = 'preserve' | 'reset';
|
|
54
|
+
|
|
55
|
+
/**
|
|
56
|
+
* Consumer-visible movement state for one committed derived body.
|
|
57
|
+
*
|
|
58
|
+
* `centerOfMass` is world-space (the same frame as the body's transform),
|
|
59
|
+
* while both velocity vectors are world-space. Keeping this as one POD
|
|
60
|
+
* value makes snapshot/recovery carry the complete motion boundary instead
|
|
61
|
+
* of exposing Rapier objects or asking consumers to infer velocity from
|
|
62
|
+
* successive transforms.
|
|
63
|
+
*/
|
|
64
|
+
export interface DerivedPhysicsMotion {
|
|
65
|
+
readonly centerOfMass: PhysicsVector;
|
|
66
|
+
readonly linearVelocity: PhysicsVector;
|
|
67
|
+
readonly angularVelocity: PhysicsVector;
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
/** Stable, consumer-owned constraint input. */
|
|
71
|
+
export type PhysicsConstraintInput =
|
|
72
|
+
| {
|
|
73
|
+
readonly id: string;
|
|
74
|
+
readonly revision: number;
|
|
75
|
+
readonly kind: 'spring';
|
|
76
|
+
readonly bodyA: number;
|
|
77
|
+
readonly bodyB: number;
|
|
78
|
+
readonly bodyASource: PhysicsConstraintBodyDependency;
|
|
79
|
+
readonly bodyBSource: PhysicsConstraintBodyDependency;
|
|
80
|
+
readonly anchorA: PhysicsVector;
|
|
81
|
+
readonly anchorB: PhysicsVector;
|
|
82
|
+
readonly restLength: number;
|
|
83
|
+
readonly stiffness: number;
|
|
84
|
+
readonly damping: number;
|
|
85
|
+
}
|
|
86
|
+
| {
|
|
87
|
+
readonly id: string;
|
|
88
|
+
readonly revision: number;
|
|
89
|
+
readonly kind: 'hinge';
|
|
90
|
+
readonly bodyA: number;
|
|
91
|
+
readonly bodyB: number;
|
|
92
|
+
readonly bodyASource: PhysicsConstraintBodyDependency;
|
|
93
|
+
readonly bodyBSource: PhysicsConstraintBodyDependency;
|
|
94
|
+
readonly anchorA: PhysicsVector;
|
|
95
|
+
readonly anchorB: PhysicsVector;
|
|
96
|
+
readonly axis: PhysicsVector;
|
|
97
|
+
readonly limits?: readonly [number, number];
|
|
98
|
+
};
|
|
99
|
+
|
|
100
|
+
/** All data needed to prepare one body's derived shape replacement. */
|
|
101
|
+
export interface DerivedPhysicsCandidateInput {
|
|
102
|
+
readonly entity: number;
|
|
103
|
+
readonly revision: number;
|
|
104
|
+
readonly sourceKey: string;
|
|
105
|
+
readonly shapes: readonly VoxelShapeInput[];
|
|
106
|
+
readonly seams?: readonly DerivedShapeSeamInput[];
|
|
107
|
+
readonly bodyType?: 'static' | 'dynamic' | 'kinematic';
|
|
108
|
+
readonly massProperties?: PhysicsMassProperties;
|
|
109
|
+
readonly velocityPolicy?: PhysicsVelocityPolicy;
|
|
110
|
+
/** Optional committed motion to restore after native mass admission. */
|
|
111
|
+
readonly motion?: DerivedPhysicsMotion;
|
|
112
|
+
readonly constraints?: readonly PhysicsConstraintInput[];
|
|
113
|
+
/** Optional World identity; it is checked when the backend is ECS-bound. */
|
|
114
|
+
readonly worldIdentity?: object;
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
/** A same-body integer-grid seam maintained by the Rapier voxel backend. */
|
|
118
|
+
export interface DerivedShapeSeamInput {
|
|
119
|
+
readonly shapeA: string;
|
|
120
|
+
readonly shapeB: string;
|
|
121
|
+
/** Grid-space origin of B relative to A; all components must be integers. */
|
|
122
|
+
readonly offset: PhysicsVector;
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
/** Candidate lifecycle visible to a consumer. */
|
|
126
|
+
export type DerivedPhysicsCandidateState =
|
|
127
|
+
| 'ready'
|
|
128
|
+
| 'queued'
|
|
129
|
+
| 'published'
|
|
130
|
+
| 'cancelled'
|
|
131
|
+
| 'invalidated'
|
|
132
|
+
| 'failed';
|
|
133
|
+
|
|
134
|
+
/** Opaque-enough prepared candidate. Native handles never cross this type. */
|
|
135
|
+
export interface DerivedPhysicsCandidate {
|
|
136
|
+
readonly candidateId: string;
|
|
137
|
+
readonly generation: number;
|
|
138
|
+
/** Per-PhysicsWorld owner identity; candidate IDs are not global. */
|
|
139
|
+
readonly owner: object;
|
|
140
|
+
readonly input: Readonly<DerivedPhysicsCandidateInput>;
|
|
141
|
+
readonly state: DerivedPhysicsCandidateState;
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
/** Stable receipt emitted when a candidate becomes the committed shape set. */
|
|
145
|
+
export interface DerivedPhysicsPublication {
|
|
146
|
+
readonly candidateId: string;
|
|
147
|
+
readonly entity: number;
|
|
148
|
+
readonly revision: number;
|
|
149
|
+
readonly fixedStep: number;
|
|
150
|
+
readonly shapeIds: readonly string[];
|
|
151
|
+
readonly generation: number;
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
/** Structured admission failure that leaves the prior publication queryable. */
|
|
155
|
+
export interface DerivedPhysicsFailure {
|
|
156
|
+
readonly candidateId: string;
|
|
157
|
+
readonly entity: number;
|
|
158
|
+
readonly revision: number;
|
|
159
|
+
readonly fixedStep: number;
|
|
160
|
+
readonly error: DerivedPhysicsError;
|
|
161
|
+
readonly recovery: 'old-state-retained' | 'rebuild-required';
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
/** Public projection of an active local shape. */
|
|
165
|
+
export interface DerivedShapeState {
|
|
166
|
+
readonly id: string;
|
|
167
|
+
readonly revision: number;
|
|
168
|
+
readonly entity: number;
|
|
169
|
+
readonly voxelSize: PhysicsVector;
|
|
170
|
+
readonly origin: PhysicsVector;
|
|
171
|
+
readonly rotation: PhysicsQuaternion;
|
|
172
|
+
readonly generation: number;
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
/** Real backend contact observation, including stable shape identity when known. */
|
|
176
|
+
export interface PhysicsContactObservation {
|
|
177
|
+
readonly phase: 'started' | 'stopped';
|
|
178
|
+
readonly fixedStep: number;
|
|
179
|
+
readonly entityA: number;
|
|
180
|
+
readonly entityB: number;
|
|
181
|
+
readonly shapeA?: string;
|
|
182
|
+
readonly shapeB?: string;
|
|
183
|
+
readonly point?: PhysicsVector;
|
|
184
|
+
readonly normal?: PhysicsVector;
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
/** Portable recovery input produced from a committed derived state. */
|
|
188
|
+
export interface DerivedPhysicsSnapshot {
|
|
189
|
+
readonly generation: number;
|
|
190
|
+
readonly fixedStep: number;
|
|
191
|
+
readonly bodies: readonly {
|
|
192
|
+
readonly entity: number;
|
|
193
|
+
readonly revision: number;
|
|
194
|
+
readonly sourceKey: string;
|
|
195
|
+
readonly shapes: readonly VoxelShapeInput[];
|
|
196
|
+
readonly seams?: readonly DerivedShapeSeamInput[];
|
|
197
|
+
readonly bodyType?: 'static' | 'dynamic' | 'kinematic';
|
|
198
|
+
readonly massProperties?: PhysicsMassProperties;
|
|
199
|
+
readonly velocityPolicy?: PhysicsVelocityPolicy;
|
|
200
|
+
/** Committed world-space COM and velocities at capture time. */
|
|
201
|
+
readonly motion?: DerivedPhysicsMotion;
|
|
202
|
+
readonly constraints: readonly PhysicsConstraintInput[];
|
|
203
|
+
}[];
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
export type DerivedPhysicsErrorCode =
|
|
207
|
+
| 'derived-physics-disposed'
|
|
208
|
+
| 'derived-body-not-found'
|
|
209
|
+
| 'derived-world-mismatch'
|
|
210
|
+
| 'derived-candidate-not-found'
|
|
211
|
+
| 'derived-candidate-stale'
|
|
212
|
+
| 'derived-candidate-pending'
|
|
213
|
+
| 'derived-candidate-cancelled'
|
|
214
|
+
| 'derived-candidate-invalid'
|
|
215
|
+
| 'derived-candidate-budget-exceeded'
|
|
216
|
+
| 'derived-shape-invalid'
|
|
217
|
+
| 'derived-shape-duplicate'
|
|
218
|
+
| 'derived-seam-invalid'
|
|
219
|
+
| 'derived-mass-invalid'
|
|
220
|
+
| 'derived-constraint-invalid'
|
|
221
|
+
| 'derived-constraint-not-found'
|
|
222
|
+
| 'derived-constraint-stale'
|
|
223
|
+
| 'derived-backend-failed'
|
|
224
|
+
| 'derived-recovery-invalid';
|
|
225
|
+
|
|
226
|
+
/** Bounded derived-physics preparation envelope. */
|
|
227
|
+
export const DERIVED_PHYSICS_LIMITS = Object.freeze({
|
|
228
|
+
maxCandidates: 32,
|
|
229
|
+
maxShapesPerCandidate: 64,
|
|
230
|
+
maxConstraintsPerCandidate: 64,
|
|
231
|
+
maxCellsPerCandidate: 262_144,
|
|
232
|
+
maxCandidateBytes: 8 * 1024 * 1024,
|
|
233
|
+
});
|
|
234
|
+
|
|
235
|
+
export interface DerivedPhysicsErrorDetail {
|
|
236
|
+
readonly code: DerivedPhysicsErrorCode;
|
|
237
|
+
readonly entity?: number;
|
|
238
|
+
readonly candidateId?: string;
|
|
239
|
+
readonly shapeId?: string;
|
|
240
|
+
readonly constraintId?: string;
|
|
241
|
+
readonly expected?: string;
|
|
242
|
+
readonly actual?: unknown;
|
|
243
|
+
readonly reason?: string;
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
/** Closed, structured failure for the public derived-physics surface. */
|
|
247
|
+
export class DerivedPhysicsError extends Error {
|
|
248
|
+
readonly code: DerivedPhysicsErrorCode;
|
|
249
|
+
readonly expected: string;
|
|
250
|
+
readonly hint: string;
|
|
251
|
+
readonly detail: DerivedPhysicsErrorDetail;
|
|
252
|
+
|
|
253
|
+
constructor(
|
|
254
|
+
code: DerivedPhysicsErrorCode,
|
|
255
|
+
expected: string,
|
|
256
|
+
hint: string,
|
|
257
|
+
detail: Omit<DerivedPhysicsErrorDetail, 'code'> = {},
|
|
258
|
+
) {
|
|
259
|
+
super(`${code}: ${expected}`);
|
|
260
|
+
this.name = 'DerivedPhysicsError';
|
|
261
|
+
this.code = code;
|
|
262
|
+
this.expected = expected;
|
|
263
|
+
this.hint = hint;
|
|
264
|
+
this.detail = Object.freeze({ code, ...detail });
|
|
265
|
+
}
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
const ID_RE = /\S/;
|
|
269
|
+
|
|
270
|
+
function finite(value: number): boolean {
|
|
271
|
+
return Number.isFinite(value);
|
|
272
|
+
}
|
|
273
|
+
|
|
274
|
+
function vectorFinite(vector: readonly number[], length: number): boolean {
|
|
275
|
+
return vector.length === length && vector.every(finite);
|
|
276
|
+
}
|
|
277
|
+
|
|
278
|
+
/** Rotate a local origin delta into the shared grid frame used by a seam. */
|
|
279
|
+
function rotateVectorByQuaternion(
|
|
280
|
+
vector: readonly [number, number, number],
|
|
281
|
+
rotation: readonly [number, number, number, number],
|
|
282
|
+
): [number, number, number] {
|
|
283
|
+
const [x, y, z] = vector;
|
|
284
|
+
const [qx, qy, qz, qw] = rotation;
|
|
285
|
+
const tx = 2 * (qy * z - qz * y);
|
|
286
|
+
const ty = 2 * (qz * x - qx * z);
|
|
287
|
+
const tz = 2 * (qx * y - qy * x);
|
|
288
|
+
return [
|
|
289
|
+
x + qw * tx + qy * tz - qz * ty,
|
|
290
|
+
y + qw * ty + qz * tx - qx * tz,
|
|
291
|
+
z + qw * tz + qx * ty - qy * tx,
|
|
292
|
+
];
|
|
293
|
+
}
|
|
294
|
+
|
|
295
|
+
function normalizedQuaternion(rotation: PhysicsQuaternion): PhysicsQuaternion | undefined {
|
|
296
|
+
if (!vectorFinite(rotation, 4)) return undefined;
|
|
297
|
+
const length = Math.hypot(rotation[0], rotation[1], rotation[2], rotation[3]);
|
|
298
|
+
if (!finite(length) || length < 1e-6) return undefined;
|
|
299
|
+
return [rotation[0] / length, rotation[1] / length, rotation[2] / length, rotation[3] / length];
|
|
300
|
+
}
|
|
301
|
+
|
|
302
|
+
function copyCells(cells: Int32Array | readonly VoxelCell[]): Int32Array | undefined {
|
|
303
|
+
if (cells instanceof Int32Array) return new Int32Array(cells);
|
|
304
|
+
const result = new Int32Array(cells.length * 3);
|
|
305
|
+
for (let index = 0; index < cells.length; index += 1) {
|
|
306
|
+
const cell = cells[index];
|
|
307
|
+
if (cell === undefined || cell.length !== 3 || cell.some((value) => !Number.isInteger(value))) {
|
|
308
|
+
return undefined;
|
|
309
|
+
}
|
|
310
|
+
result[index * 3] = cell[0] ?? 0;
|
|
311
|
+
result[index * 3 + 1] = cell[1] ?? 0;
|
|
312
|
+
result[index * 3 + 2] = cell[2] ?? 0;
|
|
313
|
+
}
|
|
314
|
+
return result;
|
|
315
|
+
}
|
|
316
|
+
|
|
317
|
+
/** Validate and snapshot one voxel input before native resources are created. */
|
|
318
|
+
export function normalizeVoxelShapeInput(input: VoxelShapeInput): Result<
|
|
319
|
+
VoxelShapeInput & {
|
|
320
|
+
readonly cells: Int32Array;
|
|
321
|
+
readonly origin: PhysicsVector;
|
|
322
|
+
readonly rotation: PhysicsQuaternion;
|
|
323
|
+
},
|
|
324
|
+
DerivedPhysicsError
|
|
325
|
+
> {
|
|
326
|
+
if (typeof input.id !== 'string' || !ID_RE.test(input.id)) {
|
|
327
|
+
return err(
|
|
328
|
+
new DerivedPhysicsError(
|
|
329
|
+
'derived-shape-invalid',
|
|
330
|
+
'voxel shape id is a non-empty stable string',
|
|
331
|
+
'provide a stable shape identity from the consumer state',
|
|
332
|
+
{ shapeId: input.id },
|
|
333
|
+
),
|
|
334
|
+
);
|
|
335
|
+
}
|
|
336
|
+
if (!Number.isInteger(input.revision) || input.revision < 0) {
|
|
337
|
+
return err(
|
|
338
|
+
new DerivedPhysicsError(
|
|
339
|
+
'derived-shape-invalid',
|
|
340
|
+
'voxel shape revision is a non-negative integer',
|
|
341
|
+
'increment the shape revision when its cells or transform changes',
|
|
342
|
+
{ shapeId: input.id, actual: input.revision },
|
|
343
|
+
),
|
|
344
|
+
);
|
|
345
|
+
}
|
|
346
|
+
const cells = copyCells(input.cells);
|
|
347
|
+
if (cells === undefined || cells.length === 0 || cells.length % 3 !== 0) {
|
|
348
|
+
return err(
|
|
349
|
+
new DerivedPhysicsError(
|
|
350
|
+
'derived-shape-invalid',
|
|
351
|
+
'voxel cells contain at least one complete integer x/y/z triple',
|
|
352
|
+
'supply a non-empty Int32Array or cell tuple list',
|
|
353
|
+
{ shapeId: input.id, actual: cells?.length },
|
|
354
|
+
),
|
|
355
|
+
);
|
|
356
|
+
}
|
|
357
|
+
const voxelSize = input.voxelSize;
|
|
358
|
+
if (!vectorFinite(voxelSize, 3) || voxelSize.some((value) => value <= 0)) {
|
|
359
|
+
return err(
|
|
360
|
+
new DerivedPhysicsError(
|
|
361
|
+
'derived-shape-invalid',
|
|
362
|
+
'voxelSize contains finite positive components',
|
|
363
|
+
'choose a finite positive voxel size for every axis',
|
|
364
|
+
{ shapeId: input.id, actual: voxelSize },
|
|
365
|
+
),
|
|
366
|
+
);
|
|
367
|
+
}
|
|
368
|
+
for (const value of cells) {
|
|
369
|
+
if (!Number.isInteger(value)) {
|
|
370
|
+
return err(
|
|
371
|
+
new DerivedPhysicsError(
|
|
372
|
+
'derived-shape-invalid',
|
|
373
|
+
'voxel coordinates are integers',
|
|
374
|
+
'quantize cells before submitting a physics candidate',
|
|
375
|
+
{ shapeId: input.id, actual: value },
|
|
376
|
+
),
|
|
377
|
+
);
|
|
378
|
+
}
|
|
379
|
+
}
|
|
380
|
+
const origin = input.origin ?? [0, 0, 0];
|
|
381
|
+
if (!vectorFinite(origin, 3)) {
|
|
382
|
+
return err(
|
|
383
|
+
new DerivedPhysicsError(
|
|
384
|
+
'derived-shape-invalid',
|
|
385
|
+
'voxel origin contains finite coordinates',
|
|
386
|
+
'supply a finite local origin',
|
|
387
|
+
{ shapeId: input.id, actual: origin },
|
|
388
|
+
),
|
|
389
|
+
);
|
|
390
|
+
}
|
|
391
|
+
const rotation = normalizedQuaternion(input.rotation ?? [0, 0, 0, 1]);
|
|
392
|
+
if (rotation === undefined) {
|
|
393
|
+
return err(
|
|
394
|
+
new DerivedPhysicsError(
|
|
395
|
+
'derived-shape-invalid',
|
|
396
|
+
'voxel rotation is a finite non-degenerate quaternion',
|
|
397
|
+
'normalize the local voxel orientation before submitting it',
|
|
398
|
+
{ shapeId: input.id, actual: input.rotation },
|
|
399
|
+
),
|
|
400
|
+
);
|
|
401
|
+
}
|
|
402
|
+
for (const [name, value] of [
|
|
403
|
+
['friction', input.friction],
|
|
404
|
+
['restitution', input.restitution],
|
|
405
|
+
['density', input.density],
|
|
406
|
+
] as const) {
|
|
407
|
+
if (value !== undefined && (!finite(value) || value < 0)) {
|
|
408
|
+
return err(
|
|
409
|
+
new DerivedPhysicsError(
|
|
410
|
+
'derived-shape-invalid',
|
|
411
|
+
`${name} is finite and non-negative`,
|
|
412
|
+
`repair the ${name} input before native preparation`,
|
|
413
|
+
{ shapeId: input.id, actual: value },
|
|
414
|
+
),
|
|
415
|
+
);
|
|
416
|
+
}
|
|
417
|
+
}
|
|
418
|
+
return ok(
|
|
419
|
+
Object.freeze({
|
|
420
|
+
...input,
|
|
421
|
+
cells,
|
|
422
|
+
voxelSize: [voxelSize[0], voxelSize[1], voxelSize[2]] as PhysicsVector,
|
|
423
|
+
origin: [origin[0], origin[1], origin[2]] as PhysicsVector,
|
|
424
|
+
rotation,
|
|
425
|
+
}),
|
|
426
|
+
);
|
|
427
|
+
}
|
|
428
|
+
|
|
429
|
+
/** Validate explicit mass/inertia values before touching the backend. */
|
|
430
|
+
export function validateMassProperties(
|
|
431
|
+
properties: PhysicsMassProperties | undefined,
|
|
432
|
+
): Result<PhysicsMassProperties | undefined, DerivedPhysicsError> {
|
|
433
|
+
if (properties === undefined || properties.mode === 'automatic') {
|
|
434
|
+
if (
|
|
435
|
+
properties?.density !== undefined &&
|
|
436
|
+
(!finite(properties.density) || properties.density <= 0)
|
|
437
|
+
) {
|
|
438
|
+
return err(
|
|
439
|
+
new DerivedPhysicsError(
|
|
440
|
+
'derived-mass-invalid',
|
|
441
|
+
'automatic density is finite and positive',
|
|
442
|
+
'omit density to use backend density or provide a positive density',
|
|
443
|
+
{ actual: properties.density },
|
|
444
|
+
),
|
|
445
|
+
);
|
|
446
|
+
}
|
|
447
|
+
return ok(properties);
|
|
448
|
+
}
|
|
449
|
+
if (
|
|
450
|
+
!finite(properties.mass) ||
|
|
451
|
+
properties.mass <= 0 ||
|
|
452
|
+
!vectorFinite(properties.centerOfMass, 3) ||
|
|
453
|
+
!vectorFinite(properties.principalInertia, 3) ||
|
|
454
|
+
properties.principalInertia.some((value) => !finite(value) || value <= 0)
|
|
455
|
+
) {
|
|
456
|
+
return err(
|
|
457
|
+
new DerivedPhysicsError(
|
|
458
|
+
'derived-mass-invalid',
|
|
459
|
+
'explicit mass, center of mass, and principal inertia are finite and non-degenerate',
|
|
460
|
+
'provide positive mass/inertia and a finite center of mass',
|
|
461
|
+
{ actual: properties },
|
|
462
|
+
),
|
|
463
|
+
);
|
|
464
|
+
}
|
|
465
|
+
const frame = normalizedQuaternion(properties.principalInertiaLocalFrame ?? [0, 0, 0, 1]);
|
|
466
|
+
if (frame === undefined) {
|
|
467
|
+
return err(
|
|
468
|
+
new DerivedPhysicsError(
|
|
469
|
+
'derived-mass-invalid',
|
|
470
|
+
'principal inertia local frame is a finite non-degenerate quaternion',
|
|
471
|
+
'normalize the inertia frame before submitting it',
|
|
472
|
+
{ actual: properties.principalInertiaLocalFrame },
|
|
473
|
+
),
|
|
474
|
+
);
|
|
475
|
+
}
|
|
476
|
+
return ok(
|
|
477
|
+
Object.freeze({
|
|
478
|
+
...properties,
|
|
479
|
+
centerOfMass: [...properties.centerOfMass] as PhysicsVector,
|
|
480
|
+
principalInertia: [...properties.principalInertia] as PhysicsVector,
|
|
481
|
+
principalInertiaLocalFrame: frame,
|
|
482
|
+
}),
|
|
483
|
+
);
|
|
484
|
+
}
|
|
485
|
+
|
|
486
|
+
/** The required linear velocity correction for a committed COM change. */
|
|
487
|
+
export function preserveCenterOfMassVelocity(
|
|
488
|
+
linearVelocity: PhysicsVector,
|
|
489
|
+
angularVelocity: PhysicsVector,
|
|
490
|
+
previousWorldCom: PhysicsVector,
|
|
491
|
+
nextWorldCom: PhysicsVector,
|
|
492
|
+
): PhysicsVector {
|
|
493
|
+
const dx = nextWorldCom[0] - previousWorldCom[0];
|
|
494
|
+
const dy = nextWorldCom[1] - previousWorldCom[1];
|
|
495
|
+
const dz = nextWorldCom[2] - previousWorldCom[2];
|
|
496
|
+
return [
|
|
497
|
+
linearVelocity[0] + angularVelocity[1] * dz - angularVelocity[2] * dy,
|
|
498
|
+
linearVelocity[1] + angularVelocity[2] * dx - angularVelocity[0] * dz,
|
|
499
|
+
linearVelocity[2] + angularVelocity[0] * dy - angularVelocity[1] * dx,
|
|
500
|
+
];
|
|
501
|
+
}
|
|
502
|
+
|
|
503
|
+
/** Snapshot an input without retaining caller-owned typed-array references. */
|
|
504
|
+
export function cloneDerivedPhysicsInput(
|
|
505
|
+
input: DerivedPhysicsCandidateInput,
|
|
506
|
+
): Result<DerivedPhysicsCandidateInput, DerivedPhysicsError> {
|
|
507
|
+
if (!Number.isInteger(input.entity) || input.entity < 0) {
|
|
508
|
+
return err(
|
|
509
|
+
new DerivedPhysicsError(
|
|
510
|
+
'derived-candidate-invalid',
|
|
511
|
+
'candidate entity is a non-negative ECS entity value',
|
|
512
|
+
'submit a live entity from the same World',
|
|
513
|
+
{ entity: input.entity },
|
|
514
|
+
),
|
|
515
|
+
);
|
|
516
|
+
}
|
|
517
|
+
if (!Number.isInteger(input.revision) || input.revision < 0) {
|
|
518
|
+
return err(
|
|
519
|
+
new DerivedPhysicsError(
|
|
520
|
+
'derived-candidate-invalid',
|
|
521
|
+
'candidate revision is a non-negative integer',
|
|
522
|
+
'advance the consumer topology revision monotonically',
|
|
523
|
+
{ entity: input.entity, actual: input.revision },
|
|
524
|
+
),
|
|
525
|
+
);
|
|
526
|
+
}
|
|
527
|
+
if (typeof input.sourceKey !== 'string' || !ID_RE.test(input.sourceKey)) {
|
|
528
|
+
return err(
|
|
529
|
+
new DerivedPhysicsError(
|
|
530
|
+
'derived-candidate-invalid',
|
|
531
|
+
'candidate sourceKey is a non-empty stable producer identity',
|
|
532
|
+
'carry the producer sourceKey with every derived body revision',
|
|
533
|
+
{ entity: input.entity, actual: input.sourceKey },
|
|
534
|
+
),
|
|
535
|
+
);
|
|
536
|
+
}
|
|
537
|
+
if (
|
|
538
|
+
input.shapes.length === 0 ||
|
|
539
|
+
input.shapes.length > DERIVED_PHYSICS_LIMITS.maxShapesPerCandidate
|
|
540
|
+
) {
|
|
541
|
+
return err(
|
|
542
|
+
new DerivedPhysicsError(
|
|
543
|
+
'derived-candidate-budget-exceeded',
|
|
544
|
+
`one candidate contains between one and ${DERIVED_PHYSICS_LIMITS.maxShapesPerCandidate} derived shapes`,
|
|
545
|
+
'split the consumer operation at a body boundary and retry',
|
|
546
|
+
{ entity: input.entity, actual: input.shapes.length },
|
|
547
|
+
),
|
|
548
|
+
);
|
|
549
|
+
}
|
|
550
|
+
const seen = new Set<string>();
|
|
551
|
+
const shapes: VoxelShapeInput[] = [];
|
|
552
|
+
for (const shape of input.shapes) {
|
|
553
|
+
if (seen.has(shape.id)) {
|
|
554
|
+
return err(
|
|
555
|
+
new DerivedPhysicsError(
|
|
556
|
+
'derived-shape-duplicate',
|
|
557
|
+
'one candidate has one identity per derived shape',
|
|
558
|
+
'merge or rename duplicate shape inputs before preparation',
|
|
559
|
+
{ entity: input.entity, shapeId: shape.id },
|
|
560
|
+
),
|
|
561
|
+
);
|
|
562
|
+
}
|
|
563
|
+
seen.add(shape.id);
|
|
564
|
+
const normalized = normalizeVoxelShapeInput(shape);
|
|
565
|
+
if (!normalized.ok) return normalized;
|
|
566
|
+
shapes.push(normalized.value);
|
|
567
|
+
}
|
|
568
|
+
const mass = validateMassProperties(input.massProperties);
|
|
569
|
+
if (!mass.ok) return mass;
|
|
570
|
+
if (
|
|
571
|
+
input.motion !== undefined &&
|
|
572
|
+
(!vectorFinite(input.motion.centerOfMass, 3) ||
|
|
573
|
+
!vectorFinite(input.motion.linearVelocity, 3) ||
|
|
574
|
+
!vectorFinite(input.motion.angularVelocity, 3))
|
|
575
|
+
) {
|
|
576
|
+
return err(
|
|
577
|
+
new DerivedPhysicsError(
|
|
578
|
+
'derived-candidate-invalid',
|
|
579
|
+
'optional movement state contains finite world-space COM and velocity vectors',
|
|
580
|
+
'capture or provide three finite components for centerOfMass, linearVelocity, and angularVelocity',
|
|
581
|
+
{ entity: input.entity, actual: input.motion },
|
|
582
|
+
),
|
|
583
|
+
);
|
|
584
|
+
}
|
|
585
|
+
const constraints = input.constraints ?? [];
|
|
586
|
+
if (constraints.length > DERIVED_PHYSICS_LIMITS.maxConstraintsPerCandidate) {
|
|
587
|
+
return err(
|
|
588
|
+
new DerivedPhysicsError(
|
|
589
|
+
'derived-candidate-budget-exceeded',
|
|
590
|
+
`one candidate contains at most ${DERIVED_PHYSICS_LIMITS.maxConstraintsPerCandidate} constraint updates`,
|
|
591
|
+
'submit a bounded constraint set for this body',
|
|
592
|
+
{ entity: input.entity, actual: constraints.length },
|
|
593
|
+
),
|
|
594
|
+
);
|
|
595
|
+
}
|
|
596
|
+
const seams = input.seams ?? [];
|
|
597
|
+
const shapeById = new Map(shapes.map((shape) => [shape.id, shape]));
|
|
598
|
+
for (const seam of seams) {
|
|
599
|
+
const a = shapeById.get(seam.shapeA);
|
|
600
|
+
const b = shapeById.get(seam.shapeB);
|
|
601
|
+
const aRotation = a?.rotation ?? [0, 0, 0, 1];
|
|
602
|
+
const bRotation = b?.rotation ?? [0, 0, 0, 1];
|
|
603
|
+
const aOrigin = a?.origin ?? [0, 0, 0];
|
|
604
|
+
const bOrigin = b?.origin ?? [0, 0, 0];
|
|
605
|
+
const quaternionDot = aRotation.reduce(
|
|
606
|
+
(sum, value, index) => sum + value * (bRotation[index] ?? 0),
|
|
607
|
+
0,
|
|
608
|
+
);
|
|
609
|
+
const localOriginDelta: [number, number, number] = [
|
|
610
|
+
(bOrigin[0] ?? 0) - (aOrigin[0] ?? 0),
|
|
611
|
+
(bOrigin[1] ?? 0) - (aOrigin[1] ?? 0),
|
|
612
|
+
(bOrigin[2] ?? 0) - (aOrigin[2] ?? 0),
|
|
613
|
+
];
|
|
614
|
+
// `origin` is expressed in the body's frame while Rapier's seam shift is
|
|
615
|
+
// expressed in shape A's voxel frame. Move the body-local delta through
|
|
616
|
+
// the inverse of A's local-to-body rotation before quantizing it.
|
|
617
|
+
const sharedGridDelta = rotateVectorByQuaternion(localOriginDelta, [
|
|
618
|
+
-aRotation[0],
|
|
619
|
+
-aRotation[1],
|
|
620
|
+
-aRotation[2],
|
|
621
|
+
aRotation[3],
|
|
622
|
+
]);
|
|
623
|
+
const alignedOrigins =
|
|
624
|
+
a !== undefined &&
|
|
625
|
+
b !== undefined &&
|
|
626
|
+
seam.offset.every(
|
|
627
|
+
(value, index) =>
|
|
628
|
+
Math.abs((sharedGridDelta[index] ?? 0) / (a?.voxelSize[index] ?? 1) - value) <= 1e-5,
|
|
629
|
+
);
|
|
630
|
+
if (
|
|
631
|
+
a === undefined ||
|
|
632
|
+
b === undefined ||
|
|
633
|
+
a.id === b.id ||
|
|
634
|
+
!vectorFinite(seam.offset, 3) ||
|
|
635
|
+
seam.offset.some((value) => !Number.isInteger(value)) ||
|
|
636
|
+
a.voxelSize.some((value, index) => Math.abs(value - (b.voxelSize[index] ?? 0)) > 1e-6) ||
|
|
637
|
+
Math.abs(Math.abs(quaternionDot) - 1) > 1e-5 ||
|
|
638
|
+
!alignedOrigins
|
|
639
|
+
) {
|
|
640
|
+
return err(
|
|
641
|
+
new DerivedPhysicsError(
|
|
642
|
+
'derived-seam-invalid',
|
|
643
|
+
'a voxel seam joins same-grid shapes with integer offset in the shared rotated grid frame',
|
|
644
|
+
'rotate the local origin delta into the shared grid frame and use an integer grid offset',
|
|
645
|
+
{ entity: input.entity, shapeId: seam.shapeA },
|
|
646
|
+
),
|
|
647
|
+
);
|
|
648
|
+
}
|
|
649
|
+
}
|
|
650
|
+
const seenConstraints = new Set<string>();
|
|
651
|
+
for (const constraint of constraints) {
|
|
652
|
+
if (seenConstraints.has(constraint.id)) {
|
|
653
|
+
return err(
|
|
654
|
+
new DerivedPhysicsError(
|
|
655
|
+
'derived-constraint-invalid',
|
|
656
|
+
'one candidate contains one update per constraint identity',
|
|
657
|
+
'merge duplicate constraint updates before preparation',
|
|
658
|
+
{ entity: input.entity, constraintId: constraint.id },
|
|
659
|
+
),
|
|
660
|
+
);
|
|
661
|
+
}
|
|
662
|
+
seenConstraints.add(constraint.id);
|
|
663
|
+
for (const dependency of [constraint.bodyASource, constraint.bodyBSource]) {
|
|
664
|
+
if (
|
|
665
|
+
typeof dependency.sourceKey !== 'string' ||
|
|
666
|
+
!ID_RE.test(dependency.sourceKey) ||
|
|
667
|
+
!Number.isInteger(dependency.revision) ||
|
|
668
|
+
dependency.revision < 0
|
|
669
|
+
) {
|
|
670
|
+
return err(
|
|
671
|
+
new DerivedPhysicsError(
|
|
672
|
+
'derived-constraint-invalid',
|
|
673
|
+
'constraint endpoint sourceKey and revision are stable and non-negative',
|
|
674
|
+
'refresh both endpoint dependencies before preparing the constraint',
|
|
675
|
+
{ entity: input.entity, constraintId: constraint.id },
|
|
676
|
+
),
|
|
677
|
+
);
|
|
678
|
+
}
|
|
679
|
+
}
|
|
680
|
+
}
|
|
681
|
+
const cellCount = shapes.reduce((sum, shape) => sum + shape.cells.length / 3, 0);
|
|
682
|
+
if (cellCount > DERIVED_PHYSICS_LIMITS.maxCellsPerCandidate) {
|
|
683
|
+
return err(
|
|
684
|
+
new DerivedPhysicsError(
|
|
685
|
+
'derived-candidate-budget-exceeded',
|
|
686
|
+
`one candidate contains at most ${DERIVED_PHYSICS_LIMITS.maxCellsPerCandidate} cells`,
|
|
687
|
+
'reduce the voxel input or split it at a body boundary',
|
|
688
|
+
{ entity: input.entity, actual: cellCount },
|
|
689
|
+
),
|
|
690
|
+
);
|
|
691
|
+
}
|
|
692
|
+
if (
|
|
693
|
+
estimateDerivedPhysicsInputBytes({ ...input, shapes, constraints, seams }) >
|
|
694
|
+
DERIVED_PHYSICS_LIMITS.maxCandidateBytes
|
|
695
|
+
) {
|
|
696
|
+
return err(
|
|
697
|
+
new DerivedPhysicsError(
|
|
698
|
+
'derived-candidate-budget-exceeded',
|
|
699
|
+
`one candidate stages at most ${DERIVED_PHYSICS_LIMITS.maxCandidateBytes} bytes`,
|
|
700
|
+
'reduce cells and constraint metadata before preparing the candidate',
|
|
701
|
+
{ entity: input.entity },
|
|
702
|
+
),
|
|
703
|
+
);
|
|
704
|
+
}
|
|
705
|
+
return ok(
|
|
706
|
+
Object.freeze({
|
|
707
|
+
...input,
|
|
708
|
+
shapes: Object.freeze(shapes),
|
|
709
|
+
seams: Object.freeze(
|
|
710
|
+
seams.map((seam) => ({ ...seam, offset: [...seam.offset] as PhysicsVector })),
|
|
711
|
+
),
|
|
712
|
+
...(mass.value === undefined ? {} : { massProperties: mass.value }),
|
|
713
|
+
...(input.motion === undefined
|
|
714
|
+
? {}
|
|
715
|
+
: {
|
|
716
|
+
motion: Object.freeze({
|
|
717
|
+
centerOfMass: [...input.motion.centerOfMass] as PhysicsVector,
|
|
718
|
+
linearVelocity: [...input.motion.linearVelocity] as PhysicsVector,
|
|
719
|
+
angularVelocity: [...input.motion.angularVelocity] as PhysicsVector,
|
|
720
|
+
}),
|
|
721
|
+
}),
|
|
722
|
+
constraints: Object.freeze([...constraints]),
|
|
723
|
+
velocityPolicy: input.velocityPolicy ?? 'preserve',
|
|
724
|
+
}),
|
|
725
|
+
);
|
|
726
|
+
}
|
|
727
|
+
|
|
728
|
+
/** Estimate staged CPU/native input bytes for the bounded admission budget. */
|
|
729
|
+
export function estimateDerivedPhysicsInputBytes(input: DerivedPhysicsCandidateInput): number {
|
|
730
|
+
const shapeBytes = input.shapes.reduce(
|
|
731
|
+
(sum, shape) =>
|
|
732
|
+
sum +
|
|
733
|
+
(shape.cells instanceof Int32Array ? shape.cells.length / 3 : shape.cells.length) * 32 +
|
|
734
|
+
128,
|
|
735
|
+
0,
|
|
736
|
+
);
|
|
737
|
+
const seamBytes = (input.seams?.length ?? 0) * 64;
|
|
738
|
+
const constraintBytes = (input.constraints?.length ?? 0) * 192;
|
|
739
|
+
return shapeBytes + seamBytes + constraintBytes + 256;
|
|
740
|
+
}
|