pelican-engine 0.3.12 → 0.3.14
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/AGENTS.md +28 -4
- package/distribution.json +15 -14
- package/docs/STATUS.md +3 -3
- package/docs/api/REFERENCE.md +103 -93
- package/docs/api/index.json +105 -94
- package/docs/npm/GAME-AUTHOR.md +28 -4
- package/engine.manifest.json +1 -1
- package/lib/physics/index.js +38 -0
- package/lib/runtime/host.js +1 -0
- package/lib/sdk/index.d.ts +11 -4
- package/lib/sdk/index.js +1 -1
- package/lib/simulation/rope-body-coupling.js +107 -0
- package/lib/simulation/rope-plugin.js +182 -15
- package/lib/simulation/rope.js +30 -16
- package/package.json +1 -1
package/docs/npm/GAME-AUTHOR.md
CHANGED
|
@@ -137,15 +137,39 @@ all earlier automatic ropes from that phase; the host tick may still have other
|
|
|
137
137
|
partial changes and must be recovered from a known-good checkpoint. The source
|
|
138
138
|
checkout includes `examples/rope-particles`.
|
|
139
139
|
|
|
140
|
+
To bind a rope to existing dynamic sphere bodies, create it with `autoStep: true`
|
|
141
|
+
and `bodyColliders: [{ id: "rope-collider-id", entity: "body-id" }]`. After the
|
|
142
|
+
rigid-body physics phase, the host samples each bound sphere's actual collider
|
|
143
|
+
center, radius, velocity and effective inverse mass/inertia, then steps the rope
|
|
144
|
+
and applies the resulting normal contact impulse to the body. At detected overlaps,
|
|
145
|
+
particle and virtual body corrections share effective mass and address closing
|
|
146
|
+
normal velocity; contacts are not predicted before overlap.
|
|
147
|
+
The actual body's displacement responds on the next body step. Bindings require live dynamic non-trigger spheres and unique
|
|
148
|
+
collider ids and body entities within each rope. Do not use a bound collider id
|
|
149
|
+
in `setColliders`; its manual planes and spheres remain separate. A destroyed body
|
|
150
|
+
is unbound at the next automatic step, and a new entity with the same id is not
|
|
151
|
+
implicitly rebound. Bindings are saved with Host snapshots.
|
|
152
|
+
|
|
153
|
+
Do not manually apply the same contact's reaction when using a body binding: the
|
|
154
|
+
host already applies it. A native failure can still leave the host tick partially
|
|
155
|
+
changed, so restore a known-good checkpoint. This is an approximate split-step
|
|
156
|
+
solver: virtual positions use a first-order update and the real body moves on its
|
|
157
|
+
next step. Ropes are processed sequentially, without globally coupled substeps;
|
|
158
|
+
friction, restitution and continuous collision are not included. Penetration
|
|
159
|
+
correction can inject energy; stability, conservation, runtime and snapshot
|
|
160
|
+
compatibility have not been validated.
|
|
161
|
+
|
|
140
162
|
For each latest-tick contact, `reactionImpulse` and `reactionAngularImpulse`
|
|
141
|
-
report the opposite impulse produced by the particle's
|
|
163
|
+
report the opposite impulse produced by the particle's contact response,
|
|
142
164
|
and its moment about the world origin. These optional fields are absent when
|
|
143
|
-
restoring older contact snapshots.
|
|
165
|
+
restoring older contact snapshots. Contacts from unbound manual colliders do not
|
|
166
|
+
automatically act on a rigid body.
|
|
144
167
|
For an explicitly mapped dynamic body, game code can read its actual world center
|
|
145
168
|
of mass via `ctx.physics.centerOfMass(id)`, apply the linear impulse, and apply
|
|
146
169
|
`reactionAngularImpulse - centerOfMass × reactionImpulse` as torque impulse.
|
|
147
170
|
Apply each contact at most once for its `tick`; repeated `inspect` calls return
|
|
148
|
-
the same contacts. The source checkout
|
|
171
|
+
the same contacts. The source checkout's `examples/rope-reaction` uses the
|
|
172
|
+
automatic binding instead.
|
|
149
173
|
|
|
150
174
|
This sampled, manually applied reaction is delayed and uses positional correction,
|
|
151
175
|
not a coupled contact solver: it omits body velocity/effective mass, friction and
|
|
@@ -154,7 +178,7 @@ operations and is not transactional if a tick fails; recover from a known-good
|
|
|
154
178
|
checkpoint. It must not be represented as complete two-way rigid-body interaction.
|
|
155
179
|
|
|
156
180
|
This is an unverified development-stage increment, not complete rope physics:
|
|
157
|
-
there is no built-in rope mesh,
|
|
181
|
+
there is no built-in rope mesh, general rigid-body coupling, segment or self collision,
|
|
158
182
|
or tearing. Moving entities to particle positions is not a rope renderer or
|
|
159
183
|
coupled physics. Plane normals must be unit length; `offset` is the signed
|
|
160
184
|
normal distance from the origin. Host intervals must be between 1/1000 and
|
package/engine.manifest.json
CHANGED
package/lib/physics/index.js
CHANGED
|
@@ -809,10 +809,48 @@ function createPlugin(context) {
|
|
|
809
809
|
};
|
|
810
810
|
},
|
|
811
811
|
};
|
|
812
|
+
const sampleRopeSphere = (id) => {
|
|
813
|
+
const b = get(id);
|
|
814
|
+
if (b.type !== "dynamic" || b.definition.rigidBody?.collider.shape !== "sphere" || b.collider.isSensor())
|
|
815
|
+
fail("TC_PHYSICS_ROPE_BODY", `Rope body '${id}' must be a live dynamic, non-trigger sphere.`);
|
|
816
|
+
const inertia = b.body.effectiveWorldInvInertia();
|
|
817
|
+
return {
|
|
818
|
+
center: tuple(b.collider.translation()), radius: b.collider.radius(),
|
|
819
|
+
centerOfMass: tuple(b.body.worldCom()), linearVelocity: tuple(b.body.linvel()),
|
|
820
|
+
angularVelocity: tuple(b.body.angvel()), inverseMass: tuple(b.body.effectiveInvMass()),
|
|
821
|
+
inverseInertia: [inertia.m11, inertia.m12, inertia.m13, inertia.m22, inertia.m23, inertia.m33],
|
|
822
|
+
};
|
|
823
|
+
};
|
|
812
824
|
return {
|
|
813
825
|
id: pluginId,
|
|
814
826
|
version: "3",
|
|
815
827
|
services: { physics },
|
|
828
|
+
ropePhysicsBridge: {
|
|
829
|
+
sampleSphere: sampleRopeSphere,
|
|
830
|
+
applySphereReactions(reactions) {
|
|
831
|
+
alive();
|
|
832
|
+
const seen = new Set();
|
|
833
|
+
const actions = [];
|
|
834
|
+
for (const { entity, impulse, moment } of reactions) {
|
|
835
|
+
if (seen.has(entity))
|
|
836
|
+
fail("TC_PHYSICS_ROPE_BODY", `Duplicate rope reaction body '${entity}'.`);
|
|
837
|
+
seen.add(entity);
|
|
838
|
+
const sample = sampleRopeSphere(entity), j = tuple(vec(impulse, "rope reaction"));
|
|
839
|
+
const m = tuple(vec(moment, "rope reaction moment")), c = sample.centerOfMass;
|
|
840
|
+
const torque = [m[0] - (c[1] * j[2] - c[2] * j[1]), m[1] - (c[2] * j[0] - c[0] * j[2]), m[2] - (c[0] * j[1] - c[1] * j[0])];
|
|
841
|
+
vec(torque, "rope reaction torque");
|
|
842
|
+
if (j.some(value => value !== 0))
|
|
843
|
+
actions.push({ kind: "impulse", id: entity, value: j });
|
|
844
|
+
if (torque.some(value => value !== 0))
|
|
845
|
+
actions.push({ kind: "torqueImpulse", id: entity, value: torque });
|
|
846
|
+
}
|
|
847
|
+
if (history.length + actions.length > limit)
|
|
848
|
+
fail("TC_PHYSICS_HISTORY_LIMIT", "Rope reactions exceed physics history capacity; start a new scene.");
|
|
849
|
+
// Validation precedes mutation; a native error can still leave a partial batch.
|
|
850
|
+
for (const action of actions)
|
|
851
|
+
record(action);
|
|
852
|
+
},
|
|
853
|
+
},
|
|
816
854
|
systems: [
|
|
817
855
|
{
|
|
818
856
|
id: pluginId,
|
package/lib/runtime/host.js
CHANGED
|
@@ -105,6 +105,7 @@ export class RuntimeHost {
|
|
|
105
105
|
for (const factory of options.plugins ?? []) {
|
|
106
106
|
const plugin = factory({
|
|
107
107
|
fixedDt: this.fixedDt,
|
|
108
|
+
getRopePhysicsBridge: () => this.plugins.find((plugin) => plugin.id === "$engine.physics")?.ropePhysicsBridge,
|
|
108
109
|
world: this.world,
|
|
109
110
|
scene: this.scene,
|
|
110
111
|
events: (type) => this.scheduler.events(type),
|
package/lib/sdk/index.d.ts
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
/** Pelican Engine public API. No renderer or GPU implementation types escape this module. */
|
|
2
|
-
export declare const SDK_VERSION = "0.3.
|
|
2
|
+
export declare const SDK_VERSION = "0.3.14";
|
|
3
3
|
export type Vec2 = readonly [number, number];
|
|
4
4
|
export type Vec3 = readonly [number, number, number];
|
|
5
5
|
export type Vec4 = readonly [number, number, number, number];
|
|
@@ -103,8 +103,8 @@ export interface SystemContext extends EngineFacade {
|
|
|
103
103
|
readonly navigation: NavigationService;
|
|
104
104
|
readonly ropes: RopeService;
|
|
105
105
|
}
|
|
106
|
-
/** CPU particles with sampled sphere/plane collisions.
|
|
107
|
-
* no
|
|
106
|
+
/** CPU particles with sampled sphere/plane collisions. Explicit body bindings apply a delayed
|
|
107
|
+
* projection reaction to dynamic spheres; no full rigid-body coupling, segment/self collision or rope rendering. */
|
|
108
108
|
export type RopeCollider = {
|
|
109
109
|
kind: "sphere";
|
|
110
110
|
id: string;
|
|
@@ -116,6 +116,11 @@ export type RopeCollider = {
|
|
|
116
116
|
normal: Vec3;
|
|
117
117
|
offset: number;
|
|
118
118
|
};
|
|
119
|
+
/** Explicit connection between a sampled rope collider id and a dynamic sphere body. */
|
|
120
|
+
export interface RopeBodyCollider {
|
|
121
|
+
readonly id: string;
|
|
122
|
+
readonly entity: string;
|
|
123
|
+
}
|
|
119
124
|
export interface RopeDefinition {
|
|
120
125
|
id: string;
|
|
121
126
|
points: readonly Vec3[];
|
|
@@ -126,6 +131,8 @@ export interface RopeDefinition {
|
|
|
126
131
|
fixedDt?: number;
|
|
127
132
|
/** When true, step once after each physics phase; defaults to false (manual stepping). */
|
|
128
133
|
autoStep?: boolean;
|
|
134
|
+
/** Automatically sample and react to existing dynamic, non-trigger sphere bodies. Requires autoStep. */
|
|
135
|
+
bodyColliders?: readonly RopeBodyCollider[];
|
|
129
136
|
substeps?: number;
|
|
130
137
|
iterations?: number;
|
|
131
138
|
damping?: number;
|
|
@@ -141,7 +148,7 @@ export interface RopeContact {
|
|
|
141
148
|
particle: number;
|
|
142
149
|
colliderId: string;
|
|
143
150
|
penetration: number;
|
|
144
|
-
/** Opposite of particle
|
|
151
|
+
/** Opposite of particle contact impulses, N·s; absent in older snapshots. */
|
|
145
152
|
reactionImpulse?: Vec3;
|
|
146
153
|
/** Angular impulse about the world origin; absent in older snapshots. */
|
|
147
154
|
reactionAngularImpulse?: Vec3;
|
package/lib/sdk/index.js
CHANGED
|
@@ -0,0 +1,107 @@
|
|
|
1
|
+
const error = (message) => { throw new Error(`TC_ROPE_BODY_NUMERIC: ${message}`); };
|
|
2
|
+
const vector = (value, label) => {
|
|
3
|
+
if (!Array.isArray(value) || value.length !== 3 || !value.every(Number.isFinite))
|
|
4
|
+
return error(`${label} must be a finite 3D vector.`);
|
|
5
|
+
return [value[0], value[1], value[2]];
|
|
6
|
+
};
|
|
7
|
+
const add = (a, b) => [a[0] + b[0], a[1] + b[1], a[2] + b[2]];
|
|
8
|
+
const sub = (a, b) => [a[0] - b[0], a[1] - b[1], a[2] - b[2]];
|
|
9
|
+
const scale = (a, value) => [a[0] * value, a[1] * value, a[2] * value];
|
|
10
|
+
const cross = (a, b) => [a[1] * b[2] - a[2] * b[1], a[2] * b[0] - a[0] * b[2], a[0] * b[1] - a[1] * b[0]];
|
|
11
|
+
const dot = (a, b) => a[0] * b[0] + a[1] * b[1] + a[2] * b[2];
|
|
12
|
+
const multiply = (m, v) => [m[0] * v[0] + m[1] * v[1] + m[2] * v[2], m[1] * v[0] + m[3] * v[1] + m[4] * v[2], m[2] * v[0] + m[4] * v[1] + m[5] * v[2]];
|
|
13
|
+
/** Mutable per-host-tick approximation. Native rigid bodies remain untouched until commit. */
|
|
14
|
+
export class VirtualRopeSphere {
|
|
15
|
+
center;
|
|
16
|
+
radius;
|
|
17
|
+
centerOfMass;
|
|
18
|
+
linearVelocity;
|
|
19
|
+
angularVelocity;
|
|
20
|
+
inverseMass;
|
|
21
|
+
inverseInertia;
|
|
22
|
+
impulse = [0, 0, 0];
|
|
23
|
+
moment = [0, 0, 0];
|
|
24
|
+
constructor(sample) {
|
|
25
|
+
this.center = vector(sample.center, "sphere center");
|
|
26
|
+
this.centerOfMass = vector(sample.centerOfMass, "center of mass");
|
|
27
|
+
this.linearVelocity = vector(sample.linearVelocity, "linear velocity");
|
|
28
|
+
this.angularVelocity = vector(sample.angularVelocity, "angular velocity");
|
|
29
|
+
this.inverseMass = vector(sample.inverseMass, "inverse mass");
|
|
30
|
+
if (this.inverseMass.some(value => value < 0))
|
|
31
|
+
error("inverse mass cannot be negative.");
|
|
32
|
+
if (!Array.isArray(sample.inverseInertia) || sample.inverseInertia.length !== 6 || !sample.inverseInertia.every(Number.isFinite))
|
|
33
|
+
throw new Error("TC_ROPE_BODY_NUMERIC: world inverse inertia must have six finite elements.");
|
|
34
|
+
this.inverseInertia = [...sample.inverseInertia];
|
|
35
|
+
if (this.inverseInertia[0] < 0 || this.inverseInertia[3] < 0 || this.inverseInertia[5] < 0)
|
|
36
|
+
error("world inverse inertia diagonal cannot be negative.");
|
|
37
|
+
if (!Number.isFinite(sample.radius) || sample.radius <= 0 || sample.radius > 1e6)
|
|
38
|
+
error("sphere radius is outside (0, 1e6].");
|
|
39
|
+
this.radius = sample.radius;
|
|
40
|
+
}
|
|
41
|
+
point(normal) { return add(this.center, scale(normal, this.radius)); }
|
|
42
|
+
pointVelocity(point) { return add(this.linearVelocity, cross(this.angularVelocity, sub(point, this.centerOfMass))); }
|
|
43
|
+
inverseEffectiveMass(point, normal) {
|
|
44
|
+
const rCrossN = cross(sub(point, this.centerOfMass), normal);
|
|
45
|
+
const linear = normal[0] * normal[0] * this.inverseMass[0] + normal[1] * normal[1] * this.inverseMass[1] + normal[2] * normal[2] * this.inverseMass[2];
|
|
46
|
+
const angular = dot(rCrossN, multiply(this.inverseInertia, rCrossN));
|
|
47
|
+
const result = linear + angular;
|
|
48
|
+
if (!Number.isFinite(result) || result < -1e-8)
|
|
49
|
+
return error("invalid effective inverse mass.");
|
|
50
|
+
return Math.max(0, result);
|
|
51
|
+
}
|
|
52
|
+
apply(impulse, point, dt) {
|
|
53
|
+
const r = sub(point, this.centerOfMass);
|
|
54
|
+
const deltaLinear = [this.inverseMass[0] * impulse[0], this.inverseMass[1] * impulse[1], this.inverseMass[2] * impulse[2]];
|
|
55
|
+
const deltaAngular = multiply(this.inverseInertia, cross(r, impulse));
|
|
56
|
+
const relativeCenter = sub(this.center, this.centerOfMass);
|
|
57
|
+
const translation = scale(deltaLinear, dt);
|
|
58
|
+
// First-order virtual transform update; the actual rigid body moves on its next physics step.
|
|
59
|
+
this.center = vector(add(add(this.center, translation), cross(scale(deltaAngular, dt), relativeCenter)), "virtual sphere center");
|
|
60
|
+
this.centerOfMass = vector(add(this.centerOfMass, translation), "virtual center of mass");
|
|
61
|
+
this.linearVelocity = vector(add(this.linearVelocity, deltaLinear), "virtual linear velocity");
|
|
62
|
+
this.angularVelocity = vector(add(this.angularVelocity, deltaAngular), "virtual angular velocity");
|
|
63
|
+
this.impulse = vector(add(this.impulse, impulse), "accumulated impulse");
|
|
64
|
+
this.moment = vector(add(this.moment, cross(point, impulse)), "accumulated moment");
|
|
65
|
+
}
|
|
66
|
+
resolve(normal, depth, position, previous, mass, dt) {
|
|
67
|
+
const invParticle = 1 / mass;
|
|
68
|
+
if (!Number.isFinite(invParticle) || invParticle <= 0 || !Number.isFinite(dt) || dt <= 0)
|
|
69
|
+
return error("particle mass and substep must be finite and positive.");
|
|
70
|
+
// n·M⁻¹n + (r×n)·I⁻¹(r×n) accounts for translation, rotation and locked axes.
|
|
71
|
+
const firstPoint = this.point(normal), denominator = invParticle + this.inverseEffectiveMass(firstPoint, normal);
|
|
72
|
+
if (!Number.isFinite(denominator) || denominator <= 0)
|
|
73
|
+
return error("contact inverse mass must be positive and finite.");
|
|
74
|
+
// Position projection is shared between particle and body by effective inverse mass.
|
|
75
|
+
const positionImpulse = depth / (dt * denominator);
|
|
76
|
+
const firstImpulse = scale(normal, -positionImpulse);
|
|
77
|
+
let correction = scale(normal, positionImpulse * invParticle * dt);
|
|
78
|
+
let reactionImpulse = firstImpulse, reactionAngularImpulse = cross(firstPoint, firstImpulse);
|
|
79
|
+
this.apply(firstImpulse, firstPoint, dt);
|
|
80
|
+
// Remove remaining closing normal velocity using the virtual body's updated velocity.
|
|
81
|
+
const secondPoint = this.point(normal);
|
|
82
|
+
const particleVelocity = scale(sub(add(position, correction), previous), 1 / dt);
|
|
83
|
+
const relativeNormal = dot(sub(particleVelocity, this.pointVelocity(secondPoint)), normal);
|
|
84
|
+
const velocityDenominator = invParticle + this.inverseEffectiveMass(secondPoint, normal);
|
|
85
|
+
if (!Number.isFinite(relativeNormal) || !Number.isFinite(velocityDenominator) || velocityDenominator <= 0)
|
|
86
|
+
return error("contact velocity or inverse mass is not finite.");
|
|
87
|
+
if (relativeNormal < 0) {
|
|
88
|
+
const velocityImpulse = -relativeNormal / velocityDenominator;
|
|
89
|
+
const secondImpulse = scale(normal, -velocityImpulse);
|
|
90
|
+
correction = add(correction, scale(normal, velocityImpulse * invParticle * dt));
|
|
91
|
+
reactionImpulse = add(reactionImpulse, secondImpulse);
|
|
92
|
+
reactionAngularImpulse = add(reactionAngularImpulse, cross(secondPoint, secondImpulse));
|
|
93
|
+
this.apply(secondImpulse, secondPoint, dt);
|
|
94
|
+
}
|
|
95
|
+
return { correction: vector(correction, "particle correction"), reactionImpulse: vector(reactionImpulse, "reaction impulse"), reactionAngularImpulse: vector(reactionAngularImpulse, "reaction moment") };
|
|
96
|
+
}
|
|
97
|
+
reaction() { return { impulse: vector(this.impulse, "reaction impulse"), moment: vector(this.moment, "reaction moment") }; }
|
|
98
|
+
}
|
|
99
|
+
export function createRopeBodyCoupling(bodies) {
|
|
100
|
+
return {
|
|
101
|
+
sphere(id) { const body = bodies.get(id); return body ? { center: body.center, radius: body.radius } : undefined; },
|
|
102
|
+
resolve(id, normal, depth, position, previous, mass, dt) {
|
|
103
|
+
const body = bodies.get(id) ?? error(`Missing virtual sphere '${id}'.`);
|
|
104
|
+
return body.resolve(normal, depth, position, previous, mass, dt);
|
|
105
|
+
},
|
|
106
|
+
};
|
|
107
|
+
}
|
|
@@ -1,5 +1,7 @@
|
|
|
1
|
+
import { resolveEntityDefinition } from "../runtime/prefabs.js";
|
|
1
2
|
import { cloneSerializable } from "../runtime/random.js";
|
|
2
3
|
import { createRope } from "./rope.js";
|
|
4
|
+
import { createRopeBodyCoupling, VirtualRopeSphere } from "./rope-body-coupling.js";
|
|
3
5
|
const fail = (code, detail) => { throw new Error(`TC_ROPE_${code}: ${detail}`); };
|
|
4
6
|
const validId = (id) => typeof id === "string" && id.trim() === id && id.length > 0 && id.length <= 128;
|
|
5
7
|
function createEntry(input, fixedDt) {
|
|
@@ -12,8 +14,21 @@ function createEntry(input, fixedDt) {
|
|
|
12
14
|
return fail("FIXED_DT", "Rope fixedDt must exactly match the host fixedDt.");
|
|
13
15
|
if (fixedDt < 1 / 1000 || fixedDt > 1 / 30)
|
|
14
16
|
return fail("FIXED_DT", "Host fixedDt must be within [1/1000, 1/30] seconds for ropes.");
|
|
17
|
+
if (definition.bodyColliders !== undefined) {
|
|
18
|
+
if (!Array.isArray(definition.bodyColliders) || definition.bodyColliders.length > 64)
|
|
19
|
+
return fail("BODY_COLLIDER", "bodyColliders must contain at most 64 bindings.");
|
|
20
|
+
if (definition.bodyColliders.length && !definition.autoStep)
|
|
21
|
+
return fail("BODY_COLLIDER", "Body bindings require autoStep: true.");
|
|
22
|
+
const ids = new Set(), entities = new Set();
|
|
23
|
+
for (const binding of definition.bodyColliders) {
|
|
24
|
+
if (!binding || typeof binding !== "object" || !validId(binding.id) || !validId(binding.entity) || ids.has(binding.id) || entities.has(binding.entity))
|
|
25
|
+
return fail("BODY_COLLIDER", "Each binding requires a unique nonempty collider id and body entity.");
|
|
26
|
+
ids.add(binding.id);
|
|
27
|
+
entities.add(binding.entity);
|
|
28
|
+
}
|
|
29
|
+
}
|
|
15
30
|
const solver = createRope({ ...definition, fixedDt });
|
|
16
|
-
return { definition, solver };
|
|
31
|
+
return { definition, solver, bodyRefs: new Map(), staleBodies: new Set() };
|
|
17
32
|
}
|
|
18
33
|
function disposeEntries(entries) {
|
|
19
34
|
for (const { solver } of entries.values())
|
|
@@ -34,6 +49,11 @@ function parseSnapshot(value, fixedDt) {
|
|
|
34
49
|
fail("SNAPSHOT", "Duplicate rope id.");
|
|
35
50
|
}
|
|
36
51
|
entries.set(entry.definition.id, entry);
|
|
52
|
+
const stale = item.staleBodyColliders === undefined ? [] : item.staleBodyColliders;
|
|
53
|
+
const bound = new Set((entry.definition.bodyColliders ?? []).map(binding => binding.id));
|
|
54
|
+
if (!Array.isArray(stale) || stale.some(id => typeof id !== "string" || !bound.has(id)) || new Set(stale).size !== stale.length)
|
|
55
|
+
fail("SNAPSHOT", "Invalid stale body collider list.");
|
|
56
|
+
entry.staleBodies = new Set(stale);
|
|
37
57
|
entry.solver.restore(item.state);
|
|
38
58
|
}
|
|
39
59
|
return entries;
|
|
@@ -43,17 +63,60 @@ function parseSnapshot(value, fixedDt) {
|
|
|
43
63
|
throw error;
|
|
44
64
|
}
|
|
45
65
|
}
|
|
46
|
-
|
|
47
|
-
|
|
66
|
+
function bridgeFor(context) {
|
|
67
|
+
return context.getRopePhysicsBridge?.() ?? fail("BODY_COLLIDER", "Body colliders require the physics plugin and a live dynamic sphere.");
|
|
68
|
+
}
|
|
69
|
+
function resolveRefs(entry, context, checkPhysics) {
|
|
70
|
+
const refs = new Map();
|
|
71
|
+
const specs = entry.definition.bodyColliders ?? [];
|
|
72
|
+
const bridge = checkPhysics && specs.length ? bridgeFor(context) : undefined;
|
|
73
|
+
for (const spec of specs) {
|
|
74
|
+
if (entry.staleBodies.has(spec.id))
|
|
75
|
+
continue;
|
|
76
|
+
if (!context.world.isAlive(spec.entity))
|
|
77
|
+
fail("BODY_COLLIDER", `Body '${spec.entity}' is not a live entity.`);
|
|
78
|
+
if (bridge)
|
|
79
|
+
bridge.sampleSphere(spec.entity);
|
|
80
|
+
refs.set(spec.id, context.world.resolve(spec.entity));
|
|
81
|
+
}
|
|
82
|
+
return refs;
|
|
83
|
+
}
|
|
84
|
+
function validateWorldBindings(entries, context, world) {
|
|
85
|
+
const records = new Map(world.entities.map(record => [record.id, record]));
|
|
86
|
+
for (const entry of entries.values())
|
|
87
|
+
for (const binding of entry.definition.bodyColliders ?? []) {
|
|
88
|
+
if (entry.staleBodies.has(binding.id))
|
|
89
|
+
continue;
|
|
90
|
+
const record = records.get(binding.entity);
|
|
91
|
+
const definition = record && resolveEntityDefinition(context.scene, record);
|
|
92
|
+
const body = definition?.rigidBody;
|
|
93
|
+
if (!record || !body || body.type !== "dynamic" || body.collider.shape !== "sphere" || body.trigger)
|
|
94
|
+
fail("SNAPSHOT", `Bound body '${binding.entity}' must exist as a dynamic, non-trigger sphere in the restored world.`);
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
/** Split-step sphere contact uses virtual mass/velocity state; native commit is deferred. */
|
|
98
|
+
export const createRopePlugin = (context) => {
|
|
99
|
+
const { fixedDt } = context;
|
|
48
100
|
if (typeof fixedDt !== "number" || !Number.isFinite(fixedDt))
|
|
49
101
|
return fail("FIXED_DT", "RuntimeHost must provide a finite fixed interval.");
|
|
50
102
|
const hostDt = fixedDt;
|
|
51
103
|
let entries = new Map();
|
|
52
|
-
let disposed = false;
|
|
104
|
+
let disposed = false, pendingRefs = false;
|
|
53
105
|
const alive = () => { if (disposed)
|
|
54
106
|
fail("DISPOSED", "Rope service is disposed."); };
|
|
107
|
+
// Host replay restores plugin data before it replaces the World. Resolve handles
|
|
108
|
+
// lazily at the first use, after that World replacement has completed.
|
|
109
|
+
const ensureRefs = () => {
|
|
110
|
+
if (!pendingRefs)
|
|
111
|
+
return;
|
|
112
|
+
const staged = [...entries.values()].map(entry => ({ entry, refs: resolveRefs(entry, context, false) }));
|
|
113
|
+
for (const { entry, refs } of staged)
|
|
114
|
+
entry.bodyRefs = refs;
|
|
115
|
+
pendingRefs = false;
|
|
116
|
+
};
|
|
55
117
|
const get = (id) => {
|
|
56
118
|
alive();
|
|
119
|
+
ensureRefs();
|
|
57
120
|
if (!validId(id))
|
|
58
121
|
return fail("ID", "Expected a nonempty rope id.");
|
|
59
122
|
return entries.get(id) ?? fail("MISSING", `Rope '${id}' does not exist.`);
|
|
@@ -61,6 +124,7 @@ export const createRopePlugin = ({ fixedDt }) => {
|
|
|
61
124
|
const service = {
|
|
62
125
|
create(definition) {
|
|
63
126
|
alive();
|
|
127
|
+
ensureRefs();
|
|
64
128
|
if (!definition || !validId(definition.id))
|
|
65
129
|
return fail("ID", "Expected a nonempty rope id.");
|
|
66
130
|
if (entries.has(definition.id))
|
|
@@ -68,7 +132,14 @@ export const createRopePlugin = ({ fixedDt }) => {
|
|
|
68
132
|
if (entries.size >= 256)
|
|
69
133
|
return fail("CAPACITY", "At most 256 ropes per host.");
|
|
70
134
|
const entry = createEntry(definition, hostDt);
|
|
71
|
-
|
|
135
|
+
try {
|
|
136
|
+
entry.bodyRefs = resolveRefs(entry, context, true);
|
|
137
|
+
entries.set(entry.definition.id, entry);
|
|
138
|
+
}
|
|
139
|
+
catch (error) {
|
|
140
|
+
entry.solver.dispose();
|
|
141
|
+
throw error;
|
|
142
|
+
}
|
|
72
143
|
},
|
|
73
144
|
step(id, ticks) {
|
|
74
145
|
const entry = get(id);
|
|
@@ -77,7 +148,17 @@ export const createRopePlugin = ({ fixedDt }) => {
|
|
|
77
148
|
return entry.solver.step(ticks);
|
|
78
149
|
},
|
|
79
150
|
inspect(id) { return get(id).solver.inspect(); },
|
|
80
|
-
setColliders(id, colliders) {
|
|
151
|
+
setColliders(id, colliders) {
|
|
152
|
+
const entry = get(id);
|
|
153
|
+
if (Array.isArray(colliders)) {
|
|
154
|
+
const bound = new Set((entry.definition.bodyColliders ?? []).map(item => item.id));
|
|
155
|
+
if (colliders.length + bound.size > 4096)
|
|
156
|
+
return fail("BODY_COLLIDER", "Manual and bound colliders together may not exceed 4096.");
|
|
157
|
+
if (colliders.some(collider => collider && bound.has(collider.id)))
|
|
158
|
+
return fail("BODY_COLLIDER", "Manual collider ids must not conflict with body bindings.");
|
|
159
|
+
}
|
|
160
|
+
entry.solver.setColliders(cloneSerializable(colliders));
|
|
161
|
+
},
|
|
81
162
|
setAnchor(id, index, position) { get(id).solver.setAnchor(index, cloneSerializable(position)); },
|
|
82
163
|
releaseAnchor(id, index) { get(id).solver.releaseAnchor(index); },
|
|
83
164
|
applyForce(id, index, force) { get(id).solver.applyForce(index, cloneSerializable(force)); },
|
|
@@ -88,22 +169,61 @@ export const createRopePlugin = ({ fixedDt }) => {
|
|
|
88
169
|
id: "$engine.ropes", version: "1", services: { ropes: service },
|
|
89
170
|
afterPhysics() {
|
|
90
171
|
alive();
|
|
172
|
+
ensureRefs();
|
|
91
173
|
const committed = [];
|
|
174
|
+
const bodies = new Map();
|
|
92
175
|
try {
|
|
93
|
-
// Stable ordering also makes failure and recording behavior independent of insertion order.
|
|
94
176
|
const automatic = [...entries.values()].filter(entry => entry.definition.autoStep)
|
|
95
177
|
.sort((a, b) => a.definition.id < b.definition.id ? -1 : a.definition.id > b.definition.id ? 1 : 0);
|
|
96
|
-
for (const
|
|
97
|
-
const snapshot = solver.snapshot();
|
|
98
|
-
|
|
99
|
-
|
|
178
|
+
for (const entry of automatic) {
|
|
179
|
+
const snapshot = entry.solver.snapshot();
|
|
180
|
+
committed.push({ entry, snapshot, definition: entry.definition, refs: entry.bodyRefs, stale: entry.staleBodies });
|
|
181
|
+
const previous = entry.definition.bodyColliders ?? [];
|
|
182
|
+
const live = [];
|
|
183
|
+
for (const binding of previous) {
|
|
184
|
+
const ref = entry.bodyRefs.get(binding.id);
|
|
185
|
+
if (!ref && !entry.staleBodies.has(binding.id))
|
|
186
|
+
fail("BODY_COLLIDER", `Missing live handle for '${binding.id}'; restore a known-good checkpoint.`);
|
|
187
|
+
if (ref && context.world.isAlive(ref))
|
|
188
|
+
live.push(binding);
|
|
189
|
+
}
|
|
190
|
+
if (live.length !== previous.length) {
|
|
191
|
+
entry.definition = { ...entry.definition, bodyColliders: live };
|
|
192
|
+
entry.bodyRefs = new Map(live.map(binding => [binding.id, entry.bodyRefs.get(binding.id)]));
|
|
193
|
+
entry.staleBodies = new Set();
|
|
194
|
+
}
|
|
195
|
+
const coupled = new Map();
|
|
196
|
+
if (previous.length) {
|
|
197
|
+
const boundIds = new Set(previous.map(item => item.id));
|
|
198
|
+
const manual = snapshot.colliders.filter(collider => !boundIds.has(collider.id));
|
|
199
|
+
const bridge = live.length ? bridgeFor(context) : undefined;
|
|
200
|
+
const sampled = live.map(binding => {
|
|
201
|
+
let body = bodies.get(binding.entity);
|
|
202
|
+
if (!body) {
|
|
203
|
+
body = new VirtualRopeSphere(bridge.sampleSphere(binding.entity));
|
|
204
|
+
bodies.set(binding.entity, body);
|
|
205
|
+
}
|
|
206
|
+
coupled.set(binding.id, body);
|
|
207
|
+
return { kind: "sphere", id: binding.id, center: [...body.center], radius: body.radius };
|
|
208
|
+
});
|
|
209
|
+
entry.solver.setColliders([...manual, ...sampled]);
|
|
210
|
+
}
|
|
211
|
+
entry.solver.step(1, coupled.size ? createRopeBodyCoupling(coupled) : undefined);
|
|
100
212
|
}
|
|
213
|
+
const batch = [...bodies].map(([entity, body]) => ({ entity, ...body.reaction() }))
|
|
214
|
+
.filter(item => item.impulse.some(value => value !== 0) || item.moment.some(value => value !== 0))
|
|
215
|
+
.sort((a, b) => a.entity < b.entity ? -1 : a.entity > b.entity ? 1 : 0);
|
|
216
|
+
if (batch.length)
|
|
217
|
+
bridgeFor(context).applySphereReactions(batch);
|
|
101
218
|
}
|
|
102
219
|
catch (error) {
|
|
103
220
|
const failures = [];
|
|
104
|
-
for (const
|
|
221
|
+
for (const item of committed.reverse()) {
|
|
222
|
+
item.entry.definition = item.definition;
|
|
223
|
+
item.entry.bodyRefs = item.refs;
|
|
224
|
+
item.entry.staleBodies = item.stale;
|
|
105
225
|
try {
|
|
106
|
-
solver.restore(snapshot);
|
|
226
|
+
item.entry.solver.restore(item.snapshot);
|
|
107
227
|
}
|
|
108
228
|
catch (rollbackError) {
|
|
109
229
|
failures.push(String(rollbackError));
|
|
@@ -115,9 +235,56 @@ export const createRopePlugin = ({ fixedDt }) => {
|
|
|
115
235
|
}
|
|
116
236
|
},
|
|
117
237
|
legacySnapshot: () => ({ version: 1, ropes: [] }),
|
|
118
|
-
snapshot: () =>
|
|
238
|
+
snapshot: () => {
|
|
239
|
+
alive();
|
|
240
|
+
ensureRefs();
|
|
241
|
+
return { version: 1, ropes: [...entries.values()].sort((a, b) => a.definition.id < b.definition.id ? -1 : a.definition.id > b.definition.id ? 1 : 0).map(entry => {
|
|
242
|
+
const stale = (entry.definition.bodyColliders ?? []).filter(binding => {
|
|
243
|
+
const ref = entry.bodyRefs.get(binding.id);
|
|
244
|
+
if (!ref && !entry.staleBodies.has(binding.id))
|
|
245
|
+
return fail("SNAPSHOT", `Missing handle for '${binding.id}'.`);
|
|
246
|
+
return !ref || !context.world.isAlive(ref);
|
|
247
|
+
}).map(binding => binding.id);
|
|
248
|
+
return { definition: cloneSerializable(entry.definition), state: entry.solver.snapshot(), ...(stale.length ? { staleBodyColliders: stale } : {}) };
|
|
249
|
+
}) };
|
|
250
|
+
},
|
|
119
251
|
validateSnapshot(value) { const staged = parseSnapshot(value, hostDt); disposeEntries(staged); },
|
|
120
|
-
|
|
252
|
+
validateWorld(value, world) {
|
|
253
|
+
const staged = parseSnapshot(value, hostDt);
|
|
254
|
+
try {
|
|
255
|
+
validateWorldBindings(staged, context, world);
|
|
256
|
+
}
|
|
257
|
+
finally {
|
|
258
|
+
disposeEntries(staged);
|
|
259
|
+
}
|
|
260
|
+
},
|
|
261
|
+
validatePeers(value, peers) {
|
|
262
|
+
const staged = parseSnapshot(value, hostDt);
|
|
263
|
+
try {
|
|
264
|
+
const bindings = [...staged.values()].flatMap(entry => (entry.definition.bodyColliders ?? []).filter(binding => !entry.staleBodies.has(binding.id)));
|
|
265
|
+
if (!bindings.length)
|
|
266
|
+
return;
|
|
267
|
+
const physics = peers["$engine.physics"]?.payload;
|
|
268
|
+
if (!physics || !Array.isArray(physics.bindings) || !Array.isArray(physics.entities))
|
|
269
|
+
return fail("SNAPSHOT", "Body-bound ropes require a matching physics snapshot.");
|
|
270
|
+
const bodyStates = physics.entities, bodyIds = physics.bindings;
|
|
271
|
+
for (const binding of bindings) {
|
|
272
|
+
const found = bodyStates.find(item => item && typeof item === "object" && item.id === binding.entity && item.type === "dynamic");
|
|
273
|
+
if (!found || !bodyIds.includes(binding.entity))
|
|
274
|
+
fail("SNAPSHOT", `Bound body '${binding.entity}' is missing from physics state.`);
|
|
275
|
+
}
|
|
276
|
+
}
|
|
277
|
+
finally {
|
|
278
|
+
disposeEntries(staged);
|
|
279
|
+
}
|
|
280
|
+
},
|
|
281
|
+
restore(value) {
|
|
282
|
+
alive();
|
|
283
|
+
const staged = parseSnapshot(value, hostDt);
|
|
284
|
+
disposeEntries(entries);
|
|
285
|
+
entries = staged;
|
|
286
|
+
pendingRefs = true;
|
|
287
|
+
},
|
|
121
288
|
dispose() { if (disposed)
|
|
122
289
|
return; disposed = true; disposeEntries(entries); },
|
|
123
290
|
};
|
package/lib/simulation/rope.js
CHANGED
|
@@ -140,8 +140,10 @@ export class RopeSolver {
|
|
|
140
140
|
this.forces[i] = next;
|
|
141
141
|
}
|
|
142
142
|
/** Exactly N fixed ticks; a failed batch restores the state before the first tick. */
|
|
143
|
-
step(steps = 1) {
|
|
143
|
+
step(steps = 1, coupling) {
|
|
144
144
|
this.alive();
|
|
145
|
+
if (coupling && steps !== 1)
|
|
146
|
+
fail("ROPE_INPUT", "coupled stepping requires exactly one tick");
|
|
145
147
|
if (!Number.isSafeInteger(steps) || steps < 1 || steps > 1000 || this.tickValue + steps > Number.MAX_SAFE_INTEGER)
|
|
146
148
|
fail("ROPE_INPUT", "steps must be an integer in [1,1000] without tick overflow");
|
|
147
149
|
// stepOne replaces these arrays rather than mutating the prior committed state.
|
|
@@ -154,7 +156,7 @@ export class RopeSolver {
|
|
|
154
156
|
};
|
|
155
157
|
try {
|
|
156
158
|
for (let s = 0; s < steps; s++)
|
|
157
|
-
this.stepOne();
|
|
159
|
+
this.stepOne(coupling);
|
|
158
160
|
}
|
|
159
161
|
catch (error) {
|
|
160
162
|
this.positions = before.positions;
|
|
@@ -166,7 +168,7 @@ export class RopeSolver {
|
|
|
166
168
|
}
|
|
167
169
|
return this.inspect();
|
|
168
170
|
}
|
|
169
|
-
stepOne() {
|
|
171
|
+
stepOne(coupling) {
|
|
170
172
|
const { fixedDt, substeps, iterations, mass, gravity, damping, restLengths, radius } = this.config;
|
|
171
173
|
const h = fixedDt / substeps, decay = Math.exp(-damping * h);
|
|
172
174
|
// Work on copies; numeric failures must never leave half a tick applied.
|
|
@@ -215,29 +217,41 @@ export class RopeSolver {
|
|
|
215
217
|
for (const c of this.colliders) {
|
|
216
218
|
let depth = 0;
|
|
217
219
|
let normal;
|
|
220
|
+
const dynamic = c.kind === "sphere" ? coupling?.sphere(c.id) : undefined;
|
|
218
221
|
if (c.kind === "plane") {
|
|
219
222
|
depth = c.offset + radius - (p[0] * c.normal[0] + p[1] * c.normal[1] + p[2] * c.normal[2]);
|
|
220
223
|
normal = c.normal;
|
|
221
224
|
}
|
|
222
225
|
else {
|
|
223
|
-
const
|
|
226
|
+
const center = dynamic?.center ?? c.center, colliderRadius = dynamic?.radius ?? c.radius;
|
|
227
|
+
const delta = [p[0] - center[0], p[1] - center[1], p[2] - center[2]];
|
|
224
228
|
const distance = length(delta);
|
|
225
|
-
depth =
|
|
229
|
+
depth = colliderRadius + radius - distance;
|
|
226
230
|
normal = distance < 1e-12 ? [1, 0, 0] : [delta[0] / distance, delta[1] / distance, delta[2] / distance];
|
|
227
231
|
}
|
|
228
232
|
if (depth <= 0)
|
|
229
233
|
continue;
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
234
|
+
let reaction, moment;
|
|
235
|
+
if (dynamic && coupling) {
|
|
236
|
+
const result = coupling.resolve(c.id, normal, depth, p, previous[i], mass, h);
|
|
237
|
+
if (!finiteVector(result.correction) || !finiteVector(result.reactionImpulse) || !finiteVector(result.reactionAngularImpulse))
|
|
238
|
+
fail("ROPE_NUMERIC", "coupled contact exceeded finite numeric bounds");
|
|
239
|
+
for (let k = 0; k < 3; k++)
|
|
240
|
+
p[k] += result.correction[k];
|
|
241
|
+
reaction = result.reactionImpulse;
|
|
242
|
+
moment = result.reactionAngularImpulse;
|
|
243
|
+
}
|
|
244
|
+
else {
|
|
245
|
+
for (let k = 0; k < 3; k++)
|
|
246
|
+
p[k] += normal[k] * depth;
|
|
247
|
+
// A manually sampled collider remains immovable; expose its projection reaction.
|
|
248
|
+
const magnitude = -mass * depth / h;
|
|
249
|
+
reaction = [normal[0] * magnitude, normal[1] * magnitude, normal[2] * magnitude];
|
|
250
|
+
const point = c.kind === "sphere"
|
|
251
|
+
? [c.center[0] + normal[0] * c.radius, c.center[1] + normal[1] * c.radius, c.center[2] + normal[2] * c.radius]
|
|
252
|
+
: [p[0] - normal[0] * radius, p[1] - normal[1] * radius, p[2] - normal[2] * radius];
|
|
253
|
+
moment = [point[1] * reaction[2] - point[2] * reaction[1], point[2] * reaction[0] - point[0] * reaction[2], point[0] * reaction[1] - point[1] * reaction[0]];
|
|
254
|
+
}
|
|
241
255
|
if (!finiteVector(reaction) || !finiteVector(moment))
|
|
242
256
|
fail("ROPE_NUMERIC", "contact reaction exceeded finite numeric bounds");
|
|
243
257
|
const key = `${i}:${c.id}`;
|