@series-inc/rundot-syncplay 5.23.0 → 5.24.0-beta.0
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 +20 -0
- package/dist/browser.d.ts +4 -1
- package/dist/browser.js +1 -0
- package/dist/checksum-basis.d.ts +4 -0
- package/dist/checksum-basis.js +17 -0
- package/dist/cjs/browser.js +4 -2
- package/dist/cjs/checksum-basis.js +22 -0
- package/dist/cjs/collider-cooking.js +834 -0
- package/dist/cjs/deterministic-session.js +2 -0
- package/dist/cjs/engine-parity-matrix.js +32 -9
- package/dist/cjs/engine-parity.js +1 -1
- package/dist/cjs/index.js +8 -2
- package/dist/cjs/instant-replay.js +4 -9
- package/dist/cjs/movement3d.js +17 -1
- package/dist/cjs/networked-client.js +11 -9
- package/dist/cjs/node.js +5 -1
- package/dist/cjs/offline-session.js +52 -24
- package/dist/cjs/physics-cert.js +7 -2
- package/dist/cjs/physics3d-hull.js +381 -0
- package/dist/cjs/physics3d-joints.js +163 -31
- package/dist/cjs/physics3d-recipes.js +197 -0
- package/dist/cjs/physics3d-shared.js +6 -0
- package/dist/cjs/physics3d-solver.js +746 -89
- package/dist/cjs/physics3d-stacking.js +154 -0
- package/dist/cjs/physics3d-toi.js +251 -0
- package/dist/cjs/physics3d-vehicle.js +35 -10
- package/dist/cjs/physics3d.js +401 -20
- package/dist/cjs/runtime-support.js +71 -0
- package/dist/cjs/session-promotion.js +5 -5
- package/dist/cjs/synctest.js +49 -0
- package/dist/cjs/testing.js +4 -1
- package/dist/cli.js +163 -41
- package/dist/collider-cooking.d.ts +141 -0
- package/dist/collider-cooking.js +829 -0
- package/dist/deterministic-session.js +2 -0
- package/dist/engine-parity-matrix.js +32 -9
- package/dist/engine-parity.js +1 -1
- package/dist/index.d.ts +3 -1
- package/dist/index.js +1 -0
- package/dist/instant-replay.d.ts +2 -2
- package/dist/instant-replay.js +4 -9
- package/dist/movement3d.js +18 -2
- package/dist/networked-client.js +11 -9
- package/dist/node.d.ts +2 -0
- package/dist/node.js +2 -0
- package/dist/offline-session.js +53 -25
- package/dist/physics-cert.js +7 -2
- package/dist/physics3d-hull.d.ts +53 -0
- package/dist/physics3d-hull.js +376 -0
- package/dist/physics3d-joints.d.ts +7 -0
- package/dist/physics3d-joints.js +163 -31
- package/dist/physics3d-recipes.d.ts +29 -0
- package/dist/physics3d-recipes.js +193 -0
- package/dist/physics3d-shared.js +6 -0
- package/dist/physics3d-solver.d.ts +65 -1
- package/dist/physics3d-solver.js +744 -89
- package/dist/physics3d-stacking.d.ts +26 -0
- package/dist/physics3d-stacking.js +147 -0
- package/dist/physics3d-toi.d.ts +70 -0
- package/dist/physics3d-toi.js +245 -0
- package/dist/physics3d-vehicle.js +35 -10
- package/dist/physics3d.d.ts +20 -2
- package/dist/physics3d.js +400 -21
- package/dist/runtime-support.d.ts +60 -0
- package/dist/runtime-support.js +66 -0
- package/dist/session-promotion.js +5 -5
- package/dist/synctest.d.ts +1 -1
- package/dist/synctest.js +49 -0
- package/dist/testing.d.ts +2 -0
- package/dist/testing.js +1 -0
- package/dist/types.d.ts +21 -2
- package/package.json +8 -3
|
@@ -0,0 +1,154 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.runDeterministicTower3DScenario = runDeterministicTower3DScenario;
|
|
4
|
+
exports.runDeterministicPyramid3DScenario = runDeterministicPyramid3DScenario;
|
|
5
|
+
exports.runDeterministicMassRatio3DScenario = runDeterministicMassRatio3DScenario;
|
|
6
|
+
exports.runDeterministicKickRecovery3DScenario = runDeterministicKickRecovery3DScenario;
|
|
7
|
+
exports.runDeterministicStackingSuite3D = runDeterministicStackingSuite3D;
|
|
8
|
+
const canonical_js_1 = require("./canonical.js");
|
|
9
|
+
const physics3d_js_1 = require("./physics3d.js");
|
|
10
|
+
const STACK_GRAVITY_Y = -0.02;
|
|
11
|
+
function stackFloor() {
|
|
12
|
+
return {
|
|
13
|
+
id: 'floor', kind: 'static', x: 0, y: -1, z: 0, vx: 0, vy: 0, vz: 0,
|
|
14
|
+
shape: { type: 'box', halfX: 40, halfY: 1, halfZ: 40 }, layer: 1, mask: 1,
|
|
15
|
+
material: { friction: 10, restitution: 0 },
|
|
16
|
+
};
|
|
17
|
+
}
|
|
18
|
+
function stackBox(id, x, y, mass, half) {
|
|
19
|
+
return {
|
|
20
|
+
id, kind: 'dynamic', x, y, z: 0, vx: 0, vy: 0, vz: 0,
|
|
21
|
+
orientation: { x: 0, y: 0, z: 0, w: 1 },
|
|
22
|
+
shape: { type: 'box', halfX: half, halfY: 1, halfZ: half }, layer: 1, mask: 1,
|
|
23
|
+
material: { friction: 8, restitution: 0 }, mass,
|
|
24
|
+
};
|
|
25
|
+
}
|
|
26
|
+
// Tilt is reported as the up-axis dot product (1 = upright): no Math.acos —
|
|
27
|
+
// transcendentals are banned in src/ by the static determinism checker. Tests
|
|
28
|
+
// convert degree thresholds to cosines (1° → 0.99985, 3° → 0.99863).
|
|
29
|
+
function bodyUpDot(body) {
|
|
30
|
+
/* istanbul ignore next -- every dynamic scenario body is authored with an orientation. */
|
|
31
|
+
const q = body.orientation ?? { x: 0, y: 0, z: 0, w: 1 };
|
|
32
|
+
return 1 - 2 * (q.x * q.x + q.z * q.z);
|
|
33
|
+
}
|
|
34
|
+
function runScenario(spec) {
|
|
35
|
+
const runOnce = (bodies) => {
|
|
36
|
+
let world = (0, physics3d_js_1.createDeterministicPhysicsWorld3D)(bodies);
|
|
37
|
+
const snapshotFrame = Math.floor(spec.frames / 2);
|
|
38
|
+
let snapshot = world;
|
|
39
|
+
for (let frame = 0; frame < spec.frames; frame += 1) {
|
|
40
|
+
if (frame === snapshotFrame) {
|
|
41
|
+
snapshot = (0, canonical_js_1.cloneCanonical)(world);
|
|
42
|
+
}
|
|
43
|
+
if (spec.impulseAt !== undefined && frame === spec.impulseAt.frame) {
|
|
44
|
+
world = (0, physics3d_js_1.applyDeterministicImpulse3D)(world, spec.impulseAt.bodyId, spec.impulseAt.impulse, spec.impulseAt.point);
|
|
45
|
+
}
|
|
46
|
+
world = (0, physics3d_js_1.stepDeterministicPhysicsWorld3D)(world, { gravityY: STACK_GRAVITY_Y, computeChecksum: false }).world;
|
|
47
|
+
}
|
|
48
|
+
return { world, snapshot, snapshotFrame };
|
|
49
|
+
};
|
|
50
|
+
const main = runOnce(spec.bodies);
|
|
51
|
+
// rollback: resume from the mid-run snapshot and replay the identical suffix
|
|
52
|
+
let restored = main.snapshot;
|
|
53
|
+
for (let frame = main.snapshotFrame; frame < spec.frames; frame += 1) {
|
|
54
|
+
if (spec.impulseAt !== undefined && frame === spec.impulseAt.frame) {
|
|
55
|
+
restored = (0, physics3d_js_1.applyDeterministicImpulse3D)(restored, spec.impulseAt.bodyId, spec.impulseAt.impulse, spec.impulseAt.point);
|
|
56
|
+
}
|
|
57
|
+
restored = (0, physics3d_js_1.stepDeterministicPhysicsWorld3D)(restored, { gravityY: STACK_GRAVITY_Y, computeChecksum: false }).world;
|
|
58
|
+
}
|
|
59
|
+
const permuted = runOnce([...spec.bodies].reverse());
|
|
60
|
+
const dyn = main.world.bodies.filter((body) => body.kind === 'dynamic');
|
|
61
|
+
let minUpDot = 1;
|
|
62
|
+
let maxXZDrift = 0;
|
|
63
|
+
let sleepingBodies = 0;
|
|
64
|
+
const initialById = new Map(spec.bodies.map((body) => [body.id, body]));
|
|
65
|
+
for (const body of dyn) {
|
|
66
|
+
const initial = initialById.get(body.id);
|
|
67
|
+
minUpDot = Math.min(minUpDot, bodyUpDot(body));
|
|
68
|
+
maxXZDrift = Math.max(maxXZDrift, Math.abs(body.x - initial.x), Math.abs(body.z - initial.z));
|
|
69
|
+
sleepingBodies += body.sleeping === true ? 1 : 0;
|
|
70
|
+
}
|
|
71
|
+
const topBody = dyn.find((body) => body.id === spec.topBodyId);
|
|
72
|
+
const topYError = Math.abs(topBody.y - spec.expectedY.get(spec.topBodyId));
|
|
73
|
+
const sortedByExpected = [...spec.expectedY.entries()].sort((l, r) => l[1] - r[1]).map(([id]) => id);
|
|
74
|
+
const finalYById = new Map(dyn.map((body) => [body.id, body.y]));
|
|
75
|
+
let verticalOrderPreserved = true;
|
|
76
|
+
for (let index = 1; index < sortedByExpected.length; index += 1) {
|
|
77
|
+
const below = finalYById.get(sortedByExpected[index - 1]);
|
|
78
|
+
const above = finalYById.get(sortedByExpected[index]);
|
|
79
|
+
verticalOrderPreserved = verticalOrderPreserved && above > below;
|
|
80
|
+
}
|
|
81
|
+
const details = (0, physics3d_js_1.contactDetailsDeterministicPhysics3D)(main.world);
|
|
82
|
+
const maxPenetration = details.reduce((max, contact) => Math.max(max, contact.penetration), 0);
|
|
83
|
+
const checksum = (0, canonical_js_1.defaultChecksum)(main.world);
|
|
84
|
+
return {
|
|
85
|
+
scenario: spec.scenario,
|
|
86
|
+
frames: spec.frames,
|
|
87
|
+
dynamicBodies: dyn.length,
|
|
88
|
+
minUpDot,
|
|
89
|
+
maxPenetration,
|
|
90
|
+
maxXZDrift,
|
|
91
|
+
topYError,
|
|
92
|
+
sleepingBodies,
|
|
93
|
+
verticalOrderPreserved,
|
|
94
|
+
checksum,
|
|
95
|
+
rollbackEquivalent: checksum === (0, canonical_js_1.defaultChecksum)(restored),
|
|
96
|
+
stableUnderAuthoringOrderPermutation: checksum === (0, canonical_js_1.defaultChecksum)(permuted.world),
|
|
97
|
+
};
|
|
98
|
+
}
|
|
99
|
+
function runDeterministicTower3DScenario(layers = 15, frames = 900) {
|
|
100
|
+
const bodies = [stackFloor(), ...Array.from({ length: layers }, (_, layer) => stackBox(`t${String(layer).padStart(2, '0')}`, 0, 1 + layer * 2, 1, 1))];
|
|
101
|
+
const expectedY = new Map(Array.from({ length: layers }, (_, layer) => [`t${String(layer).padStart(2, '0')}`, 1 + layer * 2]));
|
|
102
|
+
return runScenario({ scenario: `tower-${layers}`, bodies, frames, expectedY, topBodyId: `t${String(layers - 1).padStart(2, '0')}` });
|
|
103
|
+
}
|
|
104
|
+
function runDeterministicPyramid3DScenario(base = 10, frames = 600) {
|
|
105
|
+
const bodies = [stackFloor()];
|
|
106
|
+
const expectedY = new Map();
|
|
107
|
+
let id = 0;
|
|
108
|
+
let topId = '';
|
|
109
|
+
for (let layer = 0; layer < base; layer += 1) {
|
|
110
|
+
const count = base - layer;
|
|
111
|
+
for (let column = 0; column < count; column += 1) {
|
|
112
|
+
const bodyId = `p${String(id).padStart(3, '0')}`;
|
|
113
|
+
bodies.push(stackBox(bodyId, (column - (count - 1) / 2) * 2.05, 1 + layer * 2, 1, 1));
|
|
114
|
+
expectedY.set(bodyId, 1 + layer * 2);
|
|
115
|
+
topId = bodyId;
|
|
116
|
+
id += 1;
|
|
117
|
+
}
|
|
118
|
+
}
|
|
119
|
+
return runScenario({ scenario: `pyramid-${base}`, bodies, frames, expectedY, topBodyId: topId });
|
|
120
|
+
}
|
|
121
|
+
function runDeterministicMassRatio3DScenario(ratio = 50, frames = 900) {
|
|
122
|
+
const bodies = [
|
|
123
|
+
stackFloor(),
|
|
124
|
+
stackBox('b0', 0, 1, 1, 1), stackBox('b1', 0, 3, 1, 1), stackBox('b2', 0, 5, 1, 1),
|
|
125
|
+
stackBox('heavy', 0, 7.2, ratio, 1.5),
|
|
126
|
+
];
|
|
127
|
+
const expectedY = new Map([['b0', 1], ['b1', 3], ['b2', 5], ['heavy', 7.2]]);
|
|
128
|
+
return runScenario({ scenario: `mass-ratio-${ratio}`, bodies, frames, expectedY, topBodyId: 'heavy' });
|
|
129
|
+
}
|
|
130
|
+
/**
|
|
131
|
+
* `impulseFrame` past the mid-run snapshot puts the kick inside the *replayed*
|
|
132
|
+
* suffix, so rollback equivalence proves the impulse is re-applied rather than
|
|
133
|
+
* merely baked into the snapshot.
|
|
134
|
+
*/
|
|
135
|
+
function runDeterministicKickRecovery3DScenario(layers = 8, frames = 900, impulseFrame = 400) {
|
|
136
|
+
const bodies = [stackFloor(), ...Array.from({ length: layers }, (_, layer) => stackBox(`t${layer}`, 0, 1 + layer * 2, 1, 1))];
|
|
137
|
+
const expectedY = new Map(Array.from({ length: layers }, (_, layer) => [`t${layer}`, 1 + layer * 2]));
|
|
138
|
+
return runScenario({
|
|
139
|
+
scenario: `kick-recovery-${layers}`,
|
|
140
|
+
bodies,
|
|
141
|
+
frames,
|
|
142
|
+
impulseAt: { frame: impulseFrame, bodyId: 't3', impulse: { x: 0.2, y: 0, z: 0 }, point: { x: 0, y: 7, z: 0 } },
|
|
143
|
+
expectedY,
|
|
144
|
+
topBodyId: `t${layers - 1}`,
|
|
145
|
+
});
|
|
146
|
+
}
|
|
147
|
+
function runDeterministicStackingSuite3D() {
|
|
148
|
+
return [
|
|
149
|
+
runDeterministicTower3DScenario(),
|
|
150
|
+
runDeterministicPyramid3DScenario(),
|
|
151
|
+
runDeterministicMassRatio3DScenario(),
|
|
152
|
+
runDeterministicKickRecovery3DScenario(),
|
|
153
|
+
];
|
|
154
|
+
}
|
|
@@ -0,0 +1,251 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.__toiInternals = void 0;
|
|
4
|
+
exports.toiProxyOf = toiProxyOf;
|
|
5
|
+
exports.gjkCoreDistance = gjkCoreDistance;
|
|
6
|
+
exports.conservativeToi = conservativeToi;
|
|
7
|
+
const physics3d_shared_js_1 = require("./physics3d-shared.js");
|
|
8
|
+
/**
|
|
9
|
+
* Build a TOI proxy for a solver shape at (pos, q). Returns null for shapes GJK does
|
|
10
|
+
* not handle here (mesh, compound) — the caller then leaves the body to the discrete
|
|
11
|
+
* solver (no TOI clamp).
|
|
12
|
+
*/
|
|
13
|
+
function toiProxyOf(shape, pos, q) {
|
|
14
|
+
if (shape.kind === 'box') {
|
|
15
|
+
const h = shape.half;
|
|
16
|
+
const verts = [];
|
|
17
|
+
for (const sx of [-h.x, h.x]) {
|
|
18
|
+
for (const sy of [-h.y, h.y]) {
|
|
19
|
+
for (const sz of [-h.z, h.z]) {
|
|
20
|
+
verts.push((0, physics3d_shared_js_1.addVec3)(pos, (0, physics3d_shared_js_1.rotatePointByQuat)(q, { x: sx, y: sy, z: sz })));
|
|
21
|
+
}
|
|
22
|
+
}
|
|
23
|
+
}
|
|
24
|
+
return { verts, radius: 0 };
|
|
25
|
+
}
|
|
26
|
+
if (shape.kind === 'sphere') {
|
|
27
|
+
return { verts: [pos], radius: shape.radius };
|
|
28
|
+
}
|
|
29
|
+
if (shape.kind === 'capsule') {
|
|
30
|
+
const axis = (0, physics3d_shared_js_1.rotatePointByQuat)(q, { x: 0, y: shape.halfHeight, z: 0 });
|
|
31
|
+
return { verts: [(0, physics3d_shared_js_1.addVec3)(pos, axis), (0, physics3d_shared_js_1.subVec3)(pos, axis)], radius: shape.radius };
|
|
32
|
+
}
|
|
33
|
+
if (shape.kind === 'hull') {
|
|
34
|
+
return { verts: shape.data.vertices.map((v) => (0, physics3d_shared_js_1.addVec3)(pos, (0, physics3d_shared_js_1.rotatePointByQuat)(q, v))), radius: 0 };
|
|
35
|
+
}
|
|
36
|
+
return null;
|
|
37
|
+
}
|
|
38
|
+
function support(verts, dir) {
|
|
39
|
+
let best = 0;
|
|
40
|
+
let bestDot = (0, physics3d_shared_js_1.dotVec3)(verts[0], dir);
|
|
41
|
+
for (let i = 1; i < verts.length; i += 1) {
|
|
42
|
+
const d = (0, physics3d_shared_js_1.dotVec3)(verts[i], dir);
|
|
43
|
+
if (d > bestDot) {
|
|
44
|
+
bestDot = d;
|
|
45
|
+
best = i;
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
return best;
|
|
49
|
+
}
|
|
50
|
+
/** Closest point of a 1–4 vertex simplex (their `w`) to the origin, with barycentric weights. */
|
|
51
|
+
function closestToOrigin(s) {
|
|
52
|
+
if (s.length === 1) {
|
|
53
|
+
return { point: s[0].w, keep: [0], bary: [1] };
|
|
54
|
+
}
|
|
55
|
+
if (s.length === 2) {
|
|
56
|
+
return segmentClosest(s[0].w, s[1].w, [0, 1]);
|
|
57
|
+
}
|
|
58
|
+
if (s.length === 3) {
|
|
59
|
+
return triangleClosest(s[0].w, s[1].w, s[2].w, [0, 1, 2]);
|
|
60
|
+
}
|
|
61
|
+
return tetraClosest(s[0].w, s[1].w, s[2].w, s[3].w);
|
|
62
|
+
}
|
|
63
|
+
function segmentClosest(a, b, idx) {
|
|
64
|
+
const ab = (0, physics3d_shared_js_1.subVec3)(b, a);
|
|
65
|
+
const t = -(0, physics3d_shared_js_1.dotVec3)(a, ab);
|
|
66
|
+
const denom = (0, physics3d_shared_js_1.dotVec3)(ab, ab);
|
|
67
|
+
if (t <= 0 || denom === 0) {
|
|
68
|
+
return { point: a, keep: [idx[0]], bary: [1] };
|
|
69
|
+
}
|
|
70
|
+
if (t >= denom) {
|
|
71
|
+
return { point: b, keep: [idx[1]], bary: [1] };
|
|
72
|
+
}
|
|
73
|
+
const u = t / denom;
|
|
74
|
+
return { point: (0, physics3d_shared_js_1.addVec3)(a, (0, physics3d_shared_js_1.scaleVec3)(ab, u)), keep: [idx[0], idx[1]], bary: [1 - u, u] };
|
|
75
|
+
}
|
|
76
|
+
// Closest point of triangle (a,b,c) to the origin (Ericson RTCD, specialized to p=0),
|
|
77
|
+
// returning barycentric weights over the retained vertices.
|
|
78
|
+
function triangleClosest(a, b, c, idx) {
|
|
79
|
+
const ab = (0, physics3d_shared_js_1.subVec3)(b, a);
|
|
80
|
+
const ac = (0, physics3d_shared_js_1.subVec3)(c, a);
|
|
81
|
+
const ap = (0, physics3d_shared_js_1.scaleVec3)(a, -1);
|
|
82
|
+
const d1 = (0, physics3d_shared_js_1.dotVec3)(ab, ap);
|
|
83
|
+
const d2 = (0, physics3d_shared_js_1.dotVec3)(ac, ap);
|
|
84
|
+
if (d1 <= 0 && d2 <= 0) {
|
|
85
|
+
return { point: a, keep: [idx[0]], bary: [1] };
|
|
86
|
+
}
|
|
87
|
+
const bp = (0, physics3d_shared_js_1.scaleVec3)(b, -1);
|
|
88
|
+
const d3 = (0, physics3d_shared_js_1.dotVec3)(ab, bp);
|
|
89
|
+
const d4 = (0, physics3d_shared_js_1.dotVec3)(ac, bp);
|
|
90
|
+
if (d3 >= 0 && d4 <= d3) {
|
|
91
|
+
return { point: b, keep: [idx[1]], bary: [1] };
|
|
92
|
+
}
|
|
93
|
+
const vc = d1 * d4 - d3 * d2;
|
|
94
|
+
if (vc <= 0 && d1 >= 0 && d3 <= 0) {
|
|
95
|
+
const v = d1 / (d1 - d3);
|
|
96
|
+
return { point: (0, physics3d_shared_js_1.addVec3)(a, (0, physics3d_shared_js_1.scaleVec3)(ab, v)), keep: [idx[0], idx[1]], bary: [1 - v, v] };
|
|
97
|
+
}
|
|
98
|
+
const cp = (0, physics3d_shared_js_1.scaleVec3)(c, -1);
|
|
99
|
+
const d5 = (0, physics3d_shared_js_1.dotVec3)(ab, cp);
|
|
100
|
+
const d6 = (0, physics3d_shared_js_1.dotVec3)(ac, cp);
|
|
101
|
+
if (d6 >= 0 && d5 <= d6) {
|
|
102
|
+
return { point: c, keep: [idx[2]], bary: [1] };
|
|
103
|
+
}
|
|
104
|
+
const vb = d5 * d2 - d1 * d6;
|
|
105
|
+
if (vb <= 0 && d2 >= 0 && d6 <= 0) {
|
|
106
|
+
const w = d2 / (d2 - d6);
|
|
107
|
+
return { point: (0, physics3d_shared_js_1.addVec3)(a, (0, physics3d_shared_js_1.scaleVec3)(ac, w)), keep: [idx[0], idx[2]], bary: [1 - w, w] };
|
|
108
|
+
}
|
|
109
|
+
const va = d3 * d6 - d5 * d4;
|
|
110
|
+
if (va <= 0 && d4 - d3 >= 0 && d5 - d6 >= 0) {
|
|
111
|
+
const w = (d4 - d3) / (d4 - d3 + (d5 - d6));
|
|
112
|
+
const bc = (0, physics3d_shared_js_1.subVec3)(c, b);
|
|
113
|
+
return { point: (0, physics3d_shared_js_1.addVec3)(b, (0, physics3d_shared_js_1.scaleVec3)(bc, w)), keep: [idx[1], idx[2]], bary: [1 - w, w] };
|
|
114
|
+
}
|
|
115
|
+
const denom = 1 / (va + vb + vc);
|
|
116
|
+
const v = vb * denom;
|
|
117
|
+
const w = vc * denom;
|
|
118
|
+
return {
|
|
119
|
+
point: (0, physics3d_shared_js_1.addVec3)(a, (0, physics3d_shared_js_1.addVec3)((0, physics3d_shared_js_1.scaleVec3)(ab, v), (0, physics3d_shared_js_1.scaleVec3)(ac, w))),
|
|
120
|
+
keep: [idx[0], idx[1], idx[2]],
|
|
121
|
+
bary: [1 - v - w, v, w],
|
|
122
|
+
};
|
|
123
|
+
}
|
|
124
|
+
function outsidePlane(p, a, b, c, d) {
|
|
125
|
+
const ab = (0, physics3d_shared_js_1.subVec3)(b, a);
|
|
126
|
+
const ac = (0, physics3d_shared_js_1.subVec3)(c, a);
|
|
127
|
+
const n = { x: ab.y * ac.z - ab.z * ac.y, y: ab.z * ac.x - ab.x * ac.z, z: ab.x * ac.y - ab.y * ac.x };
|
|
128
|
+
const signP = (0, physics3d_shared_js_1.dotVec3)((0, physics3d_shared_js_1.subVec3)(p, a), n);
|
|
129
|
+
const signD = (0, physics3d_shared_js_1.dotVec3)((0, physics3d_shared_js_1.subVec3)(d, a), n);
|
|
130
|
+
return signP * signD < 0;
|
|
131
|
+
}
|
|
132
|
+
// Closest point of tetrahedron (a,b,c,d) to the origin (Ericson RTCD, specialized to p=0).
|
|
133
|
+
function tetraClosest(a, b, c, d) {
|
|
134
|
+
const origin = { x: 0, y: 0, z: 0 };
|
|
135
|
+
let best = { point: origin, keep: [0, 1, 2, 3], bary: [0, 0, 0, 0] };
|
|
136
|
+
let bestDist2 = Infinity;
|
|
137
|
+
let anyOutside = false;
|
|
138
|
+
const faces = [
|
|
139
|
+
[0, 1, 2, 3],
|
|
140
|
+
[0, 2, 3, 1],
|
|
141
|
+
[0, 3, 1, 2],
|
|
142
|
+
[1, 3, 2, 0],
|
|
143
|
+
];
|
|
144
|
+
const verts = [a, b, c, d];
|
|
145
|
+
for (const [i0, i1, i2, i3] of faces) {
|
|
146
|
+
if (outsidePlane(origin, verts[i0], verts[i1], verts[i2], verts[i3])) {
|
|
147
|
+
anyOutside = true;
|
|
148
|
+
const res = triangleClosest(verts[i0], verts[i1], verts[i2], [i0, i1, i2]);
|
|
149
|
+
const dist2 = (0, physics3d_shared_js_1.dotVec3)(res.point, res.point);
|
|
150
|
+
if (dist2 < bestDist2) {
|
|
151
|
+
bestDist2 = dist2;
|
|
152
|
+
best = res;
|
|
153
|
+
}
|
|
154
|
+
}
|
|
155
|
+
}
|
|
156
|
+
if (!anyOutside) {
|
|
157
|
+
return { point: origin, keep: [0, 1, 2, 3], bary: [0, 0, 0, 0] };
|
|
158
|
+
}
|
|
159
|
+
return best;
|
|
160
|
+
}
|
|
161
|
+
const GJK_MAX_ITERS = 24;
|
|
162
|
+
const GJK_EPS = 1e-10;
|
|
163
|
+
/** GJK closest distance between two convex cores (proxy vertices, ignoring radii). */
|
|
164
|
+
function gjkCoreDistance(vertsA, vertsB) {
|
|
165
|
+
let dir = (0, physics3d_shared_js_1.subVec3)(vertsA[0], vertsB[0]);
|
|
166
|
+
if ((0, physics3d_shared_js_1.dotVec3)(dir, dir) < GJK_EPS) {
|
|
167
|
+
dir = { x: 1, y: 0, z: 0 };
|
|
168
|
+
}
|
|
169
|
+
const mink = (d) => {
|
|
170
|
+
const a = vertsA[support(vertsA, d)];
|
|
171
|
+
const b = vertsB[support(vertsB, (0, physics3d_shared_js_1.scaleVec3)(d, -1))];
|
|
172
|
+
return { w: (0, physics3d_shared_js_1.subVec3)(a, b), a };
|
|
173
|
+
};
|
|
174
|
+
let simplex = [mink((0, physics3d_shared_js_1.scaleVec3)(dir, -1))];
|
|
175
|
+
let closest = simplex[0].w;
|
|
176
|
+
for (let iter = 0; iter < GJK_MAX_ITERS; iter += 1) {
|
|
177
|
+
const res = closestToOrigin(simplex);
|
|
178
|
+
closest = res.point;
|
|
179
|
+
const dist2 = (0, physics3d_shared_js_1.dotVec3)(closest, closest);
|
|
180
|
+
if (dist2 < GJK_EPS) {
|
|
181
|
+
return { distance: 0, normal: { x: 0, y: 0, z: 0 }, overlap: true };
|
|
182
|
+
}
|
|
183
|
+
simplex = res.keep.map((i) => simplex[i]);
|
|
184
|
+
const searchDir = (0, physics3d_shared_js_1.scaleVec3)(closest, -1);
|
|
185
|
+
const v = mink(searchDir);
|
|
186
|
+
// No further progress toward the origin ⇒ `closest` is the nearest point.
|
|
187
|
+
if ((0, physics3d_shared_js_1.dotVec3)(v.w, searchDir) - (0, physics3d_shared_js_1.dotVec3)(closest, searchDir) < GJK_EPS) {
|
|
188
|
+
break;
|
|
189
|
+
}
|
|
190
|
+
/* istanbul ignore next -- a repeated support vertex makes the progress test above zero, so this cycle guard never fires first. */
|
|
191
|
+
if (simplex.some((s) => (0, physics3d_shared_js_1.dotVec3)((0, physics3d_shared_js_1.subVec3)(s.w, v.w), (0, physics3d_shared_js_1.subVec3)(s.w, v.w)) < GJK_EPS)) {
|
|
192
|
+
break;
|
|
193
|
+
}
|
|
194
|
+
simplex.push(v);
|
|
195
|
+
}
|
|
196
|
+
const dist = (0, physics3d_shared_js_1.solverSqrt)((0, physics3d_shared_js_1.dotVec3)(closest, closest));
|
|
197
|
+
/* istanbul ignore next -- a zero `closest` returns early as an overlap, so `dist` is positive here. */
|
|
198
|
+
const inv = dist > 0 ? -1 / dist : 0;
|
|
199
|
+
return { distance: dist, normal: (0, physics3d_shared_js_1.scaleVec3)(closest, inv), overlap: false };
|
|
200
|
+
}
|
|
201
|
+
const TOI_MAX_ITERS = 20;
|
|
202
|
+
const TOI_OVERLAP = { overlapping: true };
|
|
203
|
+
/**
|
|
204
|
+
* Linear conservative-advancement time-of-impact: how far the `fast` proxy may translate
|
|
205
|
+
* along unit `dir` (up to `maxDist`) before its surface first touches the stationary
|
|
206
|
+
* `target` proxy (within `targetSep`), plus the contact normal. Returns `ToiOverlap` if
|
|
207
|
+
* they already touch at the step start (the discrete solver owns a touching pair, but the
|
|
208
|
+
* caller must not advance the fast body *past* it), or undefined if they never touch
|
|
209
|
+
* within `maxDist` (a clean graze / miss).
|
|
210
|
+
*/
|
|
211
|
+
function conservativeToi(fast, dirX, dirY, dirZ, maxDist, target, targetSep) {
|
|
212
|
+
const dir = { x: dirX, y: dirY, z: dirZ };
|
|
213
|
+
const radiusSum = fast.radius + target.radius;
|
|
214
|
+
let s = 0;
|
|
215
|
+
// Fallback contact normal (opposes motion) until a GJK normal is captured.
|
|
216
|
+
let hitNormal = { x: -dirX, y: -dirY, z: -dirZ };
|
|
217
|
+
for (let iter = 0; iter < TOI_MAX_ITERS; iter += 1) {
|
|
218
|
+
const moved = fast.verts.map((v) => ({ x: v.x + dirX * s, y: v.y + dirY * s, z: v.z + dirZ * s }));
|
|
219
|
+
const gjk = gjkCoreDistance(target.verts, moved);
|
|
220
|
+
if (gjk.overlap) {
|
|
221
|
+
// A corner/edge closest feature gives a slightly off-axis normal, so a step can
|
|
222
|
+
// overshoot a hair into overlap; `s` is still a valid first contact (penetration
|
|
223
|
+
// below the advancement step). An overlap at s=0 means already touching at the
|
|
224
|
+
// start — a blocker the caller must not advance past.
|
|
225
|
+
return s <= 0 ? TOI_OVERLAP : { distance: s, normal: hitNormal };
|
|
226
|
+
}
|
|
227
|
+
const sep = gjk.distance - radiusSum;
|
|
228
|
+
if (sep <= targetSep) {
|
|
229
|
+
return s <= 0 ? TOI_OVERLAP : { distance: s, normal: gjk.normal };
|
|
230
|
+
}
|
|
231
|
+
hitNormal = gjk.normal;
|
|
232
|
+
// The target is `sep` away in every direction, so advancing the sweep by `sep`
|
|
233
|
+
// along `dir` can never cross it (fully conservative — no overshoot even when the
|
|
234
|
+
// closest feature is a corner giving an off-axis normal). Bail early if the sweep
|
|
235
|
+
// is moving away from the contact (grazing / separating).
|
|
236
|
+
if (-(0, physics3d_shared_js_1.dotVec3)(dir, gjk.normal) <= GJK_EPS) {
|
|
237
|
+
return undefined;
|
|
238
|
+
}
|
|
239
|
+
s += sep;
|
|
240
|
+
if (s >= maxDist) {
|
|
241
|
+
return undefined;
|
|
242
|
+
}
|
|
243
|
+
}
|
|
244
|
+
return undefined;
|
|
245
|
+
}
|
|
246
|
+
/**
|
|
247
|
+
* Simplex sub-routines, exposed for direct testing. GJK only ever builds a
|
|
248
|
+
* simplex whose closest feature is interior, so the endpoint/vertex clamps
|
|
249
|
+
* inside these are unreachable through `gjkCoreDistance` alone.
|
|
250
|
+
*/
|
|
251
|
+
exports.__toiInternals = { segmentClosest, triangleClosest, tetraClosest };
|
|
@@ -10,9 +10,12 @@ exports.runDeterministicVehicle3DFixture = runDeterministicVehicle3DFixture;
|
|
|
10
10
|
* stateless controller reads the chassis pose/velocity from the world, casts
|
|
11
11
|
* each wheel, computes spring-damper suspension + friction-circle tire
|
|
12
12
|
* impulses, and returns them for the game to apply via
|
|
13
|
-
* `applyDeterministicImpulse3D` before the next physics step.
|
|
14
|
-
*
|
|
15
|
-
*
|
|
13
|
+
* `applyDeterministicImpulse3D` before the next physics step. Suspension travel
|
|
14
|
+
* and tire slip are measured relative to the contacted ground body's surface
|
|
15
|
+
* velocity (v + ω×r), so moving platforms drag the car; every chassis impulse
|
|
16
|
+
* against a *dynamic* ground body emits an equal-and-opposite reaction on it
|
|
17
|
+
* (Newton's third law). Static/kinematic ground absorbs the reaction. The flat
|
|
18
|
+
* ground-normal assumption is unchanged.
|
|
16
19
|
*/
|
|
17
20
|
const canonical_js_1 = require("./canonical.js");
|
|
18
21
|
const math_js_1 = require("./math.js");
|
|
@@ -131,13 +134,18 @@ function castWheel(world, chassis, wheel) {
|
|
|
131
134
|
// No exclude-id query knob exists; post-filter the distance-sorted hits.
|
|
132
135
|
const hit = hits.find((candidate) => candidate.bodyId !== chassis.id);
|
|
133
136
|
if (hit === undefined) {
|
|
134
|
-
return { grounded: false, compression: 0, groundFriction: 0, anchor, suspensionUp };
|
|
137
|
+
return { grounded: false, compression: 0, groundFriction: 0, groundVelAtPoint: { x: 0, y: 0, z: 0 }, anchor, suspensionUp };
|
|
135
138
|
}
|
|
136
139
|
const groundBody = (0, physics3d_js_1.deterministicPhysicsBodyById3D)(world, hit.bodyId);
|
|
137
140
|
// hit.distance <= restLength + radius (the ray's maxDistance), so the raw
|
|
138
141
|
// compression is never negative — only the maxTravel clamp is needed.
|
|
139
142
|
const rawCompression = wheel.suspensionRestLength - (hit.distance - wheel.radius);
|
|
140
143
|
const compression = (0, physics3d_shared_js_1.quantizeDistance)(rawCompression > wheel.suspensionMaxTravel ? wheel.suspensionMaxTravel : rawCompression);
|
|
144
|
+
// Ground-surface velocity at the contact point (v + ω×r); a moving conveyor or
|
|
145
|
+
// floating raft drags/reacts through it. Static ground contributes nothing.
|
|
146
|
+
const groundVelAtPoint = groundBody.kind === 'static'
|
|
147
|
+
? { x: 0, y: 0, z: 0 }
|
|
148
|
+
: (0, physics3d_shared_js_1.addVec3)({ x: groundBody.vx, y: groundBody.vy, z: groundBody.vz }, (0, physics3d_shared_js_1.crossVec3)(groundBody.angularVel ?? { x: 0, y: 0, z: 0 }, (0, physics3d_shared_js_1.subVec3)(hit.point, { x: groundBody.x, y: groundBody.y, z: groundBody.z })));
|
|
141
149
|
return {
|
|
142
150
|
grounded: compression > 0,
|
|
143
151
|
compression,
|
|
@@ -145,6 +153,8 @@ function castWheel(world, chassis, wheel) {
|
|
|
145
153
|
point: hit.point,
|
|
146
154
|
normal: hit.normal,
|
|
147
155
|
groundFriction: groundBody.material?.friction ?? 0,
|
|
156
|
+
groundKind: groundBody.kind,
|
|
157
|
+
groundVelAtPoint,
|
|
148
158
|
anchor,
|
|
149
159
|
suspensionUp,
|
|
150
160
|
};
|
|
@@ -173,8 +183,14 @@ function stepDeterministicVehicle3D(vehicle, input, world, options = {}) {
|
|
|
173
183
|
chassisVel = (0, physics3d_shared_js_1.addVec3)(chassisVel, (0, physics3d_shared_js_1.scaleVec3)(impulseVec, invMass));
|
|
174
184
|
angularVel = (0, physics3d_shared_js_1.addVec3)(angularVel, (0, physics3d_shared_js_1.mulMat3Vec)(invInertiaWorld, (0, physics3d_shared_js_1.crossVec3)((0, physics3d_shared_js_1.subVec3)(point, chassisPos), impulseVec)));
|
|
175
185
|
};
|
|
176
|
-
/**
|
|
177
|
-
|
|
186
|
+
/**
|
|
187
|
+
* Quantize and emit an impulse on the chassis; returns the quantized impulse
|
|
188
|
+
* (undefined if it rounds to zero). When `reactionBodyId` is set (a dynamic
|
|
189
|
+
* ground body), emits the equal-and-opposite reaction on it — Newton's third
|
|
190
|
+
* law so a conveyor/raft feels the car. Reaction entries are NOT fed through
|
|
191
|
+
* `applyDelta` (that tracks only the chassis's internal velocity state).
|
|
192
|
+
*/
|
|
193
|
+
const emitImpulse = (impulseVec, point, reactionBodyId) => {
|
|
178
194
|
const quantized = {
|
|
179
195
|
x: (0, physics3d_shared_js_1.quantizeDistance)(impulseVec.x),
|
|
180
196
|
y: (0, physics3d_shared_js_1.quantizeDistance)(impulseVec.y),
|
|
@@ -184,6 +200,9 @@ function stepDeterministicVehicle3D(vehicle, input, world, options = {}) {
|
|
|
184
200
|
return undefined;
|
|
185
201
|
}
|
|
186
202
|
impulses.push({ bodyId: vehicle.chassisId, impulse: quantized, point });
|
|
203
|
+
if (reactionBodyId !== undefined) {
|
|
204
|
+
impulses.push({ bodyId: reactionBodyId, impulse: { x: -quantized.x, y: -quantized.y, z: -quantized.z }, point });
|
|
205
|
+
}
|
|
187
206
|
return quantized;
|
|
188
207
|
};
|
|
189
208
|
// Phase A — suspension for every wheel before any tire force, so tire slip is
|
|
@@ -196,11 +215,13 @@ function stepDeterministicVehicle3D(vehicle, input, world, options = {}) {
|
|
|
196
215
|
return 0;
|
|
197
216
|
}
|
|
198
217
|
const anchorVel = (0, physics3d_shared_js_1.addVec3)(chassisVel, (0, physics3d_shared_js_1.crossVec3)(angularVel, (0, physics3d_shared_js_1.subVec3)(contact.anchor, chassisPos)));
|
|
199
|
-
|
|
218
|
+
// Compression rate is measured relative to the ground surface velocity so a
|
|
219
|
+
// moving platform doesn't read as spurious suspension travel.
|
|
220
|
+
const compressionVelocity = -(0, physics3d_shared_js_1.dotVec3)((0, physics3d_shared_js_1.subVec3)(anchorVel, contact.groundVelAtPoint), contact.suspensionUp);
|
|
200
221
|
const rawForce = wheel.stiffness * contact.compression + wheel.damping * compressionVelocity;
|
|
201
222
|
const suspensionForce = rawForce > 0 ? rawForce : 0;
|
|
202
223
|
const suspensionImpulseMag = suspensionForce * dt;
|
|
203
|
-
const emitted = emitImpulse((0, physics3d_shared_js_1.scaleVec3)(contact.normal, suspensionImpulseMag), contact.point);
|
|
224
|
+
const emitted = emitImpulse((0, physics3d_shared_js_1.scaleVec3)(contact.normal, suspensionImpulseMag), contact.point, contact.groundKind === 'dynamic' ? contact.bodyId : undefined);
|
|
204
225
|
if (emitted !== undefined) {
|
|
205
226
|
applyDelta(emitted, contact.point);
|
|
206
227
|
}
|
|
@@ -247,6 +268,8 @@ function stepDeterministicVehicle3D(vehicle, input, world, options = {}) {
|
|
|
247
268
|
effLateralMass: effectiveMassAt(invMass, invInertiaWorld, leverArm, sideDir),
|
|
248
269
|
effForwardMass: effectiveMassAt(invMass, invInertiaWorld, leverArm, forwardDir),
|
|
249
270
|
maxTireImpulse: ((wheel.grip * contact.groundFriction) / 10) * suspensionImpulseMag,
|
|
271
|
+
groundVelAtPoint: contact.groundVelAtPoint,
|
|
272
|
+
reactionBodyId: contact.groundKind === 'dynamic' ? contact.bodyId : undefined,
|
|
250
273
|
longitudinal: 0,
|
|
251
274
|
lateral: 0,
|
|
252
275
|
// brake + rolling resistance oppose the forward speed, spent from a fixed
|
|
@@ -258,7 +281,9 @@ function stepDeterministicVehicle3D(vehicle, input, world, options = {}) {
|
|
|
258
281
|
}
|
|
259
282
|
for (let iteration = 0; iteration < TIRE_ITERATIONS; iteration += 1) {
|
|
260
283
|
for (const tire of tireStates) {
|
|
261
|
-
|
|
284
|
+
// Slip is measured against the ground surface velocity so a conveyor drags
|
|
285
|
+
// the car and tire forces react back onto a dynamic ground body.
|
|
286
|
+
const contactVel = (0, physics3d_shared_js_1.subVec3)((0, physics3d_shared_js_1.addVec3)(chassisVel, (0, physics3d_shared_js_1.crossVec3)(angularVel, (0, physics3d_shared_js_1.subVec3)(tire.tirePoint, chassisPos))), tire.groundVelAtPoint);
|
|
262
287
|
const forwardSpeed = (0, physics3d_shared_js_1.dotVec3)(contactVel, tire.forwardDir);
|
|
263
288
|
const lateralSpeed = (0, physics3d_shared_js_1.dotVec3)(contactVel, tire.sideDir);
|
|
264
289
|
if (iteration === 0) {
|
|
@@ -289,7 +314,7 @@ function stepDeterministicVehicle3D(vehicle, input, world, options = {}) {
|
|
|
289
314
|
}
|
|
290
315
|
}
|
|
291
316
|
for (const tire of tireStates) {
|
|
292
|
-
emitImpulse((0, physics3d_shared_js_1.addVec3)((0, physics3d_shared_js_1.scaleVec3)(tire.forwardDir, tire.longitudinal), (0, physics3d_shared_js_1.scaleVec3)(tire.sideDir, tire.lateral)), tire.tirePoint);
|
|
317
|
+
emitImpulse((0, physics3d_shared_js_1.addVec3)((0, physics3d_shared_js_1.scaleVec3)(tire.forwardDir, tire.longitudinal), (0, physics3d_shared_js_1.scaleVec3)(tire.sideDir, tire.lateral)), tire.tirePoint, tire.reactionBodyId);
|
|
293
318
|
}
|
|
294
319
|
const wheels = vehicle.wheels.map((wheel, index) => {
|
|
295
320
|
const contact = contacts[index];
|