pelican-engine 0.3.11 → 0.3.13
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 +37 -1
- package/distribution.json +14 -14
- package/docs/STATUS.md +3 -2
- package/docs/api/REFERENCE.md +118 -92
- package/docs/api/index.json +120 -94
- package/docs/npm/GAME-AUTHOR.md +37 -1
- package/engine.manifest.json +1 -1
- package/lib/physics/index.js +28 -0
- package/lib/runtime/host.js +1 -0
- package/lib/sdk/index.d.ts +24 -9
- package/lib/sdk/index.js +1 -1
- package/lib/simulation/rope-plugin.js +187 -15
- package/lib/simulation/rope.js +34 -5
- package/package.json +1 -1
package/docs/npm/GAME-AUTHOR.md
CHANGED
|
@@ -137,8 +137,44 @@ 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 and radius, steps the rope, and applies the resulting normal projection
|
|
144
|
+
impulse to the body. That impulse changes body velocity; displacement responds on
|
|
145
|
+
the next body step. Bindings require live dynamic non-trigger spheres and unique
|
|
146
|
+
collider ids and body entities within each rope. Do not use a bound collider id
|
|
147
|
+
in `setColliders`; its manual planes and spheres remain separate. A destroyed body
|
|
148
|
+
is unbound at the next automatic step, and a new entity with the same id is not
|
|
149
|
+
implicitly rebound. Bindings are saved with Host snapshots.
|
|
150
|
+
|
|
151
|
+
Do not manually apply the same contact's reaction when using a body binding: the
|
|
152
|
+
host already applies it. A native failure can still leave the host tick partially
|
|
153
|
+
changed, so restore a known-good checkpoint. This is delayed sampled coupling and
|
|
154
|
+
is not a complete physics solver: body velocity/effective mass, friction,
|
|
155
|
+
continuous collision and coupled substeps are not included. It has not been
|
|
156
|
+
runtime or snapshot validated.
|
|
157
|
+
|
|
158
|
+
For each latest-tick contact, `reactionImpulse` and `reactionAngularImpulse`
|
|
159
|
+
report the opposite impulse produced by the particle's position projections,
|
|
160
|
+
and its moment about the world origin. These optional fields are absent when
|
|
161
|
+
restoring older contact snapshots. Contacts from unbound manual colliders do not
|
|
162
|
+
automatically act on a rigid body.
|
|
163
|
+
For an explicitly mapped dynamic body, game code can read its actual world center
|
|
164
|
+
of mass via `ctx.physics.centerOfMass(id)`, apply the linear impulse, and apply
|
|
165
|
+
`reactionAngularImpulse - centerOfMass × reactionImpulse` as torque impulse.
|
|
166
|
+
Apply each contact at most once for its `tick`; repeated `inspect` calls return
|
|
167
|
+
the same contacts. The source checkout's `examples/rope-reaction` uses the
|
|
168
|
+
automatic binding instead.
|
|
169
|
+
|
|
170
|
+
This sampled, manually applied reaction is delayed and uses positional correction,
|
|
171
|
+
not a coupled contact solver: it omits body velocity/effective mass, friction and
|
|
172
|
+
continuous collision. Applying the linear and angular impulses uses separate
|
|
173
|
+
operations and is not transactional if a tick fails; recover from a known-good
|
|
174
|
+
checkpoint. It must not be represented as complete two-way rigid-body interaction.
|
|
175
|
+
|
|
140
176
|
This is an unverified development-stage increment, not complete rope physics:
|
|
141
|
-
there is no built-in rope mesh, rigid-body
|
|
177
|
+
there is no built-in rope mesh, general rigid-body coupling, segment or self collision,
|
|
142
178
|
or tearing. Moving entities to particle positions is not a rope renderer or
|
|
143
179
|
coupled physics. Plane normals must be unit length; `offset` is the signed
|
|
144
180
|
normal distance from the origin. Host intervals must be between 1/1000 and
|
package/engine.manifest.json
CHANGED
package/lib/physics/index.js
CHANGED
|
@@ -746,6 +746,9 @@ function createPlugin(context) {
|
|
|
746
746
|
]
|
|
747
747
|
: tuple(b.body.linvel());
|
|
748
748
|
},
|
|
749
|
+
centerOfMass(id) {
|
|
750
|
+
return tuple(get(id).body.worldCom());
|
|
751
|
+
},
|
|
749
752
|
setVelocity(id, value) {
|
|
750
753
|
get(id);
|
|
751
754
|
record({ kind: "velocity", id, value });
|
|
@@ -806,10 +809,35 @@ function createPlugin(context) {
|
|
|
806
809
|
};
|
|
807
810
|
},
|
|
808
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
|
+
return { center: tuple(b.collider.translation()), radius: b.collider.radius() };
|
|
817
|
+
};
|
|
809
818
|
return {
|
|
810
819
|
id: pluginId,
|
|
811
820
|
version: "3",
|
|
812
821
|
services: { physics },
|
|
822
|
+
ropePhysicsBridge: {
|
|
823
|
+
sampleSphere: sampleRopeSphere,
|
|
824
|
+
applySphereReactions(reactions) {
|
|
825
|
+
alive();
|
|
826
|
+
if (history.length + reactions.length > limit)
|
|
827
|
+
fail("TC_PHYSICS_HISTORY_LIMIT", "Rope reactions exceed physics history capacity; start a new scene.");
|
|
828
|
+
const seen = new Set();
|
|
829
|
+
const actions = reactions.map(({ entity, impulse }) => {
|
|
830
|
+
if (seen.has(entity))
|
|
831
|
+
fail("TC_PHYSICS_ROPE_BODY", `Duplicate rope reaction body '${entity}'.`);
|
|
832
|
+
seen.add(entity);
|
|
833
|
+
const sample = sampleRopeSphere(entity);
|
|
834
|
+
return { kind: "impulseAtPoint", id: entity, value: tuple(vec(impulse, "rope reaction")), point: sample.center };
|
|
835
|
+
});
|
|
836
|
+
// Validation precedes mutation; a native error can still leave a partial batch.
|
|
837
|
+
for (const action of actions)
|
|
838
|
+
record(action);
|
|
839
|
+
},
|
|
840
|
+
},
|
|
813
841
|
systems: [
|
|
814
842
|
{
|
|
815
843
|
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.13";
|
|
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 rigid-body
|
|
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;
|
|
@@ -135,6 +142,17 @@ export interface RopeDefinition {
|
|
|
135
142
|
position: Vec3;
|
|
136
143
|
}[];
|
|
137
144
|
}
|
|
145
|
+
/** Sampled-collider contact from the most recently completed rope tick. */
|
|
146
|
+
export interface RopeContact {
|
|
147
|
+
tick: number;
|
|
148
|
+
particle: number;
|
|
149
|
+
colliderId: string;
|
|
150
|
+
penetration: number;
|
|
151
|
+
/** Opposite of particle projection impulses, N·s; absent in older snapshots. */
|
|
152
|
+
reactionImpulse?: Vec3;
|
|
153
|
+
/** Angular impulse about the world origin; absent in older snapshots. */
|
|
154
|
+
reactionAngularImpulse?: Vec3;
|
|
155
|
+
}
|
|
138
156
|
export interface RopeState {
|
|
139
157
|
tick: number;
|
|
140
158
|
simulatedSeconds: number;
|
|
@@ -145,12 +163,7 @@ export interface RopeState {
|
|
|
145
163
|
index: number;
|
|
146
164
|
position: Vec3;
|
|
147
165
|
}[];
|
|
148
|
-
contacts:
|
|
149
|
-
tick: number;
|
|
150
|
-
particle: number;
|
|
151
|
-
colliderId: string;
|
|
152
|
-
penetration: number;
|
|
153
|
-
}[];
|
|
166
|
+
contacts: RopeContact[];
|
|
154
167
|
maxDistanceError: number;
|
|
155
168
|
resources: {
|
|
156
169
|
particles: number;
|
|
@@ -396,6 +409,8 @@ export interface PhysicsService {
|
|
|
396
409
|
moveCharacter(entity: string, displacement: Vec3): void;
|
|
397
410
|
grounded(entity: string): boolean;
|
|
398
411
|
velocity(entity: string): Vec3;
|
|
412
|
+
/** World-space rigid-body center of mass, including collider offsets. */
|
|
413
|
+
centerOfMass(entity: string): Vec3;
|
|
399
414
|
setVelocity(entity: string, velocity: Vec3): void;
|
|
400
415
|
impulse(entity: string, impulse: Vec3): void;
|
|
401
416
|
/** Angular velocity in world-space radians per second. */
|
package/lib/sdk/index.js
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { resolveEntityDefinition } from "../runtime/prefabs.js";
|
|
1
2
|
import { cloneSerializable } from "../runtime/random.js";
|
|
2
3
|
import { createRope } from "./rope.js";
|
|
3
4
|
const fail = (code, detail) => { throw new Error(`TC_ROPE_${code}: ${detail}`); };
|
|
@@ -12,8 +13,21 @@ function createEntry(input, fixedDt) {
|
|
|
12
13
|
return fail("FIXED_DT", "Rope fixedDt must exactly match the host fixedDt.");
|
|
13
14
|
if (fixedDt < 1 / 1000 || fixedDt > 1 / 30)
|
|
14
15
|
return fail("FIXED_DT", "Host fixedDt must be within [1/1000, 1/30] seconds for ropes.");
|
|
16
|
+
if (definition.bodyColliders !== undefined) {
|
|
17
|
+
if (!Array.isArray(definition.bodyColliders) || definition.bodyColliders.length > 64)
|
|
18
|
+
return fail("BODY_COLLIDER", "bodyColliders must contain at most 64 bindings.");
|
|
19
|
+
if (definition.bodyColliders.length && !definition.autoStep)
|
|
20
|
+
return fail("BODY_COLLIDER", "Body bindings require autoStep: true.");
|
|
21
|
+
const ids = new Set(), entities = new Set();
|
|
22
|
+
for (const binding of definition.bodyColliders) {
|
|
23
|
+
if (!binding || typeof binding !== "object" || !validId(binding.id) || !validId(binding.entity) || ids.has(binding.id) || entities.has(binding.entity))
|
|
24
|
+
return fail("BODY_COLLIDER", "Each binding requires a unique nonempty collider id and body entity.");
|
|
25
|
+
ids.add(binding.id);
|
|
26
|
+
entities.add(binding.entity);
|
|
27
|
+
}
|
|
28
|
+
}
|
|
15
29
|
const solver = createRope({ ...definition, fixedDt });
|
|
16
|
-
return { definition, solver };
|
|
30
|
+
return { definition, solver, bodyRefs: new Map(), staleBodies: new Set() };
|
|
17
31
|
}
|
|
18
32
|
function disposeEntries(entries) {
|
|
19
33
|
for (const { solver } of entries.values())
|
|
@@ -34,6 +48,11 @@ function parseSnapshot(value, fixedDt) {
|
|
|
34
48
|
fail("SNAPSHOT", "Duplicate rope id.");
|
|
35
49
|
}
|
|
36
50
|
entries.set(entry.definition.id, entry);
|
|
51
|
+
const stale = item.staleBodyColliders === undefined ? [] : item.staleBodyColliders;
|
|
52
|
+
const bound = new Set((entry.definition.bodyColliders ?? []).map(binding => binding.id));
|
|
53
|
+
if (!Array.isArray(stale) || stale.some(id => typeof id !== "string" || !bound.has(id)) || new Set(stale).size !== stale.length)
|
|
54
|
+
fail("SNAPSHOT", "Invalid stale body collider list.");
|
|
55
|
+
entry.staleBodies = new Set(stale);
|
|
37
56
|
entry.solver.restore(item.state);
|
|
38
57
|
}
|
|
39
58
|
return entries;
|
|
@@ -43,17 +62,60 @@ function parseSnapshot(value, fixedDt) {
|
|
|
43
62
|
throw error;
|
|
44
63
|
}
|
|
45
64
|
}
|
|
46
|
-
|
|
47
|
-
|
|
65
|
+
function bridgeFor(context) {
|
|
66
|
+
return context.getRopePhysicsBridge?.() ?? fail("BODY_COLLIDER", "Body colliders require the physics plugin and a live dynamic sphere.");
|
|
67
|
+
}
|
|
68
|
+
function resolveRefs(entry, context, checkPhysics) {
|
|
69
|
+
const refs = new Map();
|
|
70
|
+
const specs = entry.definition.bodyColliders ?? [];
|
|
71
|
+
const bridge = checkPhysics && specs.length ? bridgeFor(context) : undefined;
|
|
72
|
+
for (const spec of specs) {
|
|
73
|
+
if (entry.staleBodies.has(spec.id))
|
|
74
|
+
continue;
|
|
75
|
+
if (!context.world.isAlive(spec.entity))
|
|
76
|
+
fail("BODY_COLLIDER", `Body '${spec.entity}' is not a live entity.`);
|
|
77
|
+
if (bridge)
|
|
78
|
+
bridge.sampleSphere(spec.entity);
|
|
79
|
+
refs.set(spec.id, context.world.resolve(spec.entity));
|
|
80
|
+
}
|
|
81
|
+
return refs;
|
|
82
|
+
}
|
|
83
|
+
function validateWorldBindings(entries, context, world) {
|
|
84
|
+
const records = new Map(world.entities.map(record => [record.id, record]));
|
|
85
|
+
for (const entry of entries.values())
|
|
86
|
+
for (const binding of entry.definition.bodyColliders ?? []) {
|
|
87
|
+
if (entry.staleBodies.has(binding.id))
|
|
88
|
+
continue;
|
|
89
|
+
const record = records.get(binding.entity);
|
|
90
|
+
const definition = record && resolveEntityDefinition(context.scene, record);
|
|
91
|
+
const body = definition?.rigidBody;
|
|
92
|
+
if (!record || !body || body.type !== "dynamic" || body.collider.shape !== "sphere" || body.trigger)
|
|
93
|
+
fail("SNAPSHOT", `Bound body '${binding.entity}' must exist as a dynamic, non-trigger sphere in the restored world.`);
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
/** Position-based sphere reactions are delayed and do not yet solve body effective mass. */
|
|
97
|
+
export const createRopePlugin = (context) => {
|
|
98
|
+
const { fixedDt } = context;
|
|
48
99
|
if (typeof fixedDt !== "number" || !Number.isFinite(fixedDt))
|
|
49
100
|
return fail("FIXED_DT", "RuntimeHost must provide a finite fixed interval.");
|
|
50
101
|
const hostDt = fixedDt;
|
|
51
102
|
let entries = new Map();
|
|
52
|
-
let disposed = false;
|
|
103
|
+
let disposed = false, pendingRefs = false;
|
|
53
104
|
const alive = () => { if (disposed)
|
|
54
105
|
fail("DISPOSED", "Rope service is disposed."); };
|
|
106
|
+
// Host replay restores plugin data before it replaces the World. Resolve handles
|
|
107
|
+
// lazily at the first use, after that World replacement has completed.
|
|
108
|
+
const ensureRefs = () => {
|
|
109
|
+
if (!pendingRefs)
|
|
110
|
+
return;
|
|
111
|
+
const staged = [...entries.values()].map(entry => ({ entry, refs: resolveRefs(entry, context, false) }));
|
|
112
|
+
for (const { entry, refs } of staged)
|
|
113
|
+
entry.bodyRefs = refs;
|
|
114
|
+
pendingRefs = false;
|
|
115
|
+
};
|
|
55
116
|
const get = (id) => {
|
|
56
117
|
alive();
|
|
118
|
+
ensureRefs();
|
|
57
119
|
if (!validId(id))
|
|
58
120
|
return fail("ID", "Expected a nonempty rope id.");
|
|
59
121
|
return entries.get(id) ?? fail("MISSING", `Rope '${id}' does not exist.`);
|
|
@@ -61,6 +123,7 @@ export const createRopePlugin = ({ fixedDt }) => {
|
|
|
61
123
|
const service = {
|
|
62
124
|
create(definition) {
|
|
63
125
|
alive();
|
|
126
|
+
ensureRefs();
|
|
64
127
|
if (!definition || !validId(definition.id))
|
|
65
128
|
return fail("ID", "Expected a nonempty rope id.");
|
|
66
129
|
if (entries.has(definition.id))
|
|
@@ -68,7 +131,14 @@ export const createRopePlugin = ({ fixedDt }) => {
|
|
|
68
131
|
if (entries.size >= 256)
|
|
69
132
|
return fail("CAPACITY", "At most 256 ropes per host.");
|
|
70
133
|
const entry = createEntry(definition, hostDt);
|
|
71
|
-
|
|
134
|
+
try {
|
|
135
|
+
entry.bodyRefs = resolveRefs(entry, context, true);
|
|
136
|
+
entries.set(entry.definition.id, entry);
|
|
137
|
+
}
|
|
138
|
+
catch (error) {
|
|
139
|
+
entry.solver.dispose();
|
|
140
|
+
throw error;
|
|
141
|
+
}
|
|
72
142
|
},
|
|
73
143
|
step(id, ticks) {
|
|
74
144
|
const entry = get(id);
|
|
@@ -77,7 +147,17 @@ export const createRopePlugin = ({ fixedDt }) => {
|
|
|
77
147
|
return entry.solver.step(ticks);
|
|
78
148
|
},
|
|
79
149
|
inspect(id) { return get(id).solver.inspect(); },
|
|
80
|
-
setColliders(id, colliders) {
|
|
150
|
+
setColliders(id, colliders) {
|
|
151
|
+
const entry = get(id);
|
|
152
|
+
if (Array.isArray(colliders)) {
|
|
153
|
+
const bound = new Set((entry.definition.bodyColliders ?? []).map(item => item.id));
|
|
154
|
+
if (colliders.length + bound.size > 4096)
|
|
155
|
+
return fail("BODY_COLLIDER", "Manual and bound colliders together may not exceed 4096.");
|
|
156
|
+
if (colliders.some(collider => collider && bound.has(collider.id)))
|
|
157
|
+
return fail("BODY_COLLIDER", "Manual collider ids must not conflict with body bindings.");
|
|
158
|
+
}
|
|
159
|
+
entry.solver.setColliders(cloneSerializable(colliders));
|
|
160
|
+
},
|
|
81
161
|
setAnchor(id, index, position) { get(id).solver.setAnchor(index, cloneSerializable(position)); },
|
|
82
162
|
releaseAnchor(id, index) { get(id).solver.releaseAnchor(index); },
|
|
83
163
|
applyForce(id, index, force) { get(id).solver.applyForce(index, cloneSerializable(force)); },
|
|
@@ -88,22 +168,67 @@ export const createRopePlugin = ({ fixedDt }) => {
|
|
|
88
168
|
id: "$engine.ropes", version: "1", services: { ropes: service },
|
|
89
169
|
afterPhysics() {
|
|
90
170
|
alive();
|
|
171
|
+
ensureRefs();
|
|
91
172
|
const committed = [];
|
|
173
|
+
const reactions = new Map();
|
|
92
174
|
try {
|
|
93
|
-
// Stable ordering also makes failure and recording behavior independent of insertion order.
|
|
94
175
|
const automatic = [...entries.values()].filter(entry => entry.definition.autoStep)
|
|
95
176
|
.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
|
-
|
|
177
|
+
for (const entry of automatic) {
|
|
178
|
+
const snapshot = entry.solver.snapshot();
|
|
179
|
+
committed.push({ entry, snapshot, definition: entry.definition, refs: entry.bodyRefs, stale: entry.staleBodies });
|
|
180
|
+
const previous = entry.definition.bodyColliders ?? [];
|
|
181
|
+
const live = [];
|
|
182
|
+
for (const binding of previous) {
|
|
183
|
+
const ref = entry.bodyRefs.get(binding.id);
|
|
184
|
+
if (!ref && !entry.staleBodies.has(binding.id))
|
|
185
|
+
fail("BODY_COLLIDER", `Missing live handle for '${binding.id}'; restore a known-good checkpoint.`);
|
|
186
|
+
if (ref && context.world.isAlive(ref))
|
|
187
|
+
live.push(binding);
|
|
188
|
+
}
|
|
189
|
+
if (live.length !== previous.length) {
|
|
190
|
+
entry.definition = { ...entry.definition, bodyColliders: live };
|
|
191
|
+
entry.bodyRefs = new Map(live.map(binding => [binding.id, entry.bodyRefs.get(binding.id)]));
|
|
192
|
+
entry.staleBodies = new Set();
|
|
193
|
+
}
|
|
194
|
+
if (previous.length) {
|
|
195
|
+
const boundIds = new Set(previous.map(item => item.id));
|
|
196
|
+
const manual = snapshot.colliders.filter(collider => !boundIds.has(collider.id));
|
|
197
|
+
const bridge = live.length ? bridgeFor(context) : undefined;
|
|
198
|
+
const sampled = live.map(binding => {
|
|
199
|
+
const sphere = bridge.sampleSphere(binding.entity);
|
|
200
|
+
return { kind: "sphere", id: binding.id, center: [...sphere.center], radius: sphere.radius };
|
|
201
|
+
});
|
|
202
|
+
entry.solver.setColliders([...manual, ...sampled]);
|
|
203
|
+
}
|
|
204
|
+
const state = entry.solver.step();
|
|
205
|
+
const byCollider = new Map(live.map(binding => [binding.id, binding.entity]));
|
|
206
|
+
for (const contact of state.contacts) {
|
|
207
|
+
const entity = byCollider.get(contact.colliderId), impulse = contact.reactionImpulse;
|
|
208
|
+
if (!entity || !impulse)
|
|
209
|
+
continue;
|
|
210
|
+
const total = reactions.get(entity) ?? [0, 0, 0];
|
|
211
|
+
for (let k = 0; k < 3; k++)
|
|
212
|
+
total[k] += impulse[k];
|
|
213
|
+
if (!total.every(Number.isFinite))
|
|
214
|
+
fail("NUMERIC", `Reaction for '${entity}' exceeded finite bounds.`);
|
|
215
|
+
reactions.set(entity, total);
|
|
216
|
+
}
|
|
100
217
|
}
|
|
218
|
+
const batch = [...reactions].filter(([, impulse]) => impulse.some(value => value !== 0))
|
|
219
|
+
.sort(([a], [b]) => a < b ? -1 : a > b ? 1 : 0)
|
|
220
|
+
.map(([entity, impulse]) => ({ entity, impulse }));
|
|
221
|
+
if (batch.length)
|
|
222
|
+
bridgeFor(context).applySphereReactions(batch);
|
|
101
223
|
}
|
|
102
224
|
catch (error) {
|
|
103
225
|
const failures = [];
|
|
104
|
-
for (const
|
|
226
|
+
for (const item of committed.reverse()) {
|
|
227
|
+
item.entry.definition = item.definition;
|
|
228
|
+
item.entry.bodyRefs = item.refs;
|
|
229
|
+
item.entry.staleBodies = item.stale;
|
|
105
230
|
try {
|
|
106
|
-
solver.restore(snapshot);
|
|
231
|
+
item.entry.solver.restore(item.snapshot);
|
|
107
232
|
}
|
|
108
233
|
catch (rollbackError) {
|
|
109
234
|
failures.push(String(rollbackError));
|
|
@@ -115,9 +240,56 @@ export const createRopePlugin = ({ fixedDt }) => {
|
|
|
115
240
|
}
|
|
116
241
|
},
|
|
117
242
|
legacySnapshot: () => ({ version: 1, ropes: [] }),
|
|
118
|
-
snapshot: () =>
|
|
243
|
+
snapshot: () => {
|
|
244
|
+
alive();
|
|
245
|
+
ensureRefs();
|
|
246
|
+
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 => {
|
|
247
|
+
const stale = (entry.definition.bodyColliders ?? []).filter(binding => {
|
|
248
|
+
const ref = entry.bodyRefs.get(binding.id);
|
|
249
|
+
if (!ref && !entry.staleBodies.has(binding.id))
|
|
250
|
+
return fail("SNAPSHOT", `Missing handle for '${binding.id}'.`);
|
|
251
|
+
return !ref || !context.world.isAlive(ref);
|
|
252
|
+
}).map(binding => binding.id);
|
|
253
|
+
return { definition: cloneSerializable(entry.definition), state: entry.solver.snapshot(), ...(stale.length ? { staleBodyColliders: stale } : {}) };
|
|
254
|
+
}) };
|
|
255
|
+
},
|
|
119
256
|
validateSnapshot(value) { const staged = parseSnapshot(value, hostDt); disposeEntries(staged); },
|
|
120
|
-
|
|
257
|
+
validateWorld(value, world) {
|
|
258
|
+
const staged = parseSnapshot(value, hostDt);
|
|
259
|
+
try {
|
|
260
|
+
validateWorldBindings(staged, context, world);
|
|
261
|
+
}
|
|
262
|
+
finally {
|
|
263
|
+
disposeEntries(staged);
|
|
264
|
+
}
|
|
265
|
+
},
|
|
266
|
+
validatePeers(value, peers) {
|
|
267
|
+
const staged = parseSnapshot(value, hostDt);
|
|
268
|
+
try {
|
|
269
|
+
const bindings = [...staged.values()].flatMap(entry => (entry.definition.bodyColliders ?? []).filter(binding => !entry.staleBodies.has(binding.id)));
|
|
270
|
+
if (!bindings.length)
|
|
271
|
+
return;
|
|
272
|
+
const physics = peers["$engine.physics"]?.payload;
|
|
273
|
+
if (!physics || !Array.isArray(physics.bindings) || !Array.isArray(physics.entities))
|
|
274
|
+
return fail("SNAPSHOT", "Body-bound ropes require a matching physics snapshot.");
|
|
275
|
+
const bodyStates = physics.entities, bodyIds = physics.bindings;
|
|
276
|
+
for (const binding of bindings) {
|
|
277
|
+
const found = bodyStates.find(item => item && typeof item === "object" && item.id === binding.entity && item.type === "dynamic");
|
|
278
|
+
if (!found || !bodyIds.includes(binding.entity))
|
|
279
|
+
fail("SNAPSHOT", `Bound body '${binding.entity}' is missing from physics state.`);
|
|
280
|
+
}
|
|
281
|
+
}
|
|
282
|
+
finally {
|
|
283
|
+
disposeEntries(staged);
|
|
284
|
+
}
|
|
285
|
+
},
|
|
286
|
+
restore(value) {
|
|
287
|
+
alive();
|
|
288
|
+
const staged = parseSnapshot(value, hostDt);
|
|
289
|
+
disposeEntries(entries);
|
|
290
|
+
entries = staged;
|
|
291
|
+
pendingRefs = true;
|
|
292
|
+
},
|
|
121
293
|
dispose() { if (disposed)
|
|
122
294
|
return; disposed = true; disposeEntries(entries); },
|
|
123
295
|
};
|
package/lib/simulation/rope.js
CHANGED
|
@@ -9,6 +9,15 @@ export class RopeError extends Error {
|
|
|
9
9
|
const fail = (code, reason) => { throw new RopeError(code, reason); };
|
|
10
10
|
const vec = (v) => Array.isArray(v) && v.length === 3 && v.every(x => typeof x === "number" && Number.isFinite(x) && Math.abs(x) <= 1e6);
|
|
11
11
|
const copy = (v) => [v[0], v[1], v[2]];
|
|
12
|
+
const finiteVector = (v) => Array.isArray(v) && v.length === 3 && v.every(x => typeof x === "number" && Number.isFinite(x));
|
|
13
|
+
const copyContact = (contact) => {
|
|
14
|
+
const { reactionImpulse, reactionAngularImpulse, ...rest } = contact;
|
|
15
|
+
return {
|
|
16
|
+
...rest,
|
|
17
|
+
...(reactionImpulse === undefined ? {} : { reactionImpulse: copy(reactionImpulse) }),
|
|
18
|
+
...(reactionAngularImpulse === undefined ? {} : { reactionAngularImpulse: copy(reactionAngularImpulse) }),
|
|
19
|
+
};
|
|
20
|
+
};
|
|
12
21
|
const length = (v) => Math.hypot(v[0], v[1], v[2]);
|
|
13
22
|
const validVec = (v, name) => vec(v) ? copy(v) : fail("ROPE_INPUT", `${name} must be a finite 3D vector (|component| <= 1e6)`);
|
|
14
23
|
const positive = (v, name) => typeof v === "number" && Number.isFinite(v) && v > 0 && v <= 1e6 ? v : fail("ROPE_INPUT", `${name} must be positive and <= 1e6`);
|
|
@@ -220,12 +229,30 @@ export class RopeSolver {
|
|
|
220
229
|
continue;
|
|
221
230
|
for (let k = 0; k < 3; k++)
|
|
222
231
|
p[k] += normal[k] * depth;
|
|
232
|
+
// Each positional projection changes the particle's velocity by depth / h.
|
|
233
|
+
// Report its equal-and-opposite impulse and moment on the sampled surface;
|
|
234
|
+
// the collider itself remains an immutable input and is not moved here.
|
|
235
|
+
const magnitude = -mass * depth / h;
|
|
236
|
+
const reaction = [normal[0] * magnitude, normal[1] * magnitude, normal[2] * magnitude];
|
|
237
|
+
const point = c.kind === "sphere"
|
|
238
|
+
? [c.center[0] + normal[0] * c.radius, c.center[1] + normal[1] * c.radius, c.center[2] + normal[2] * c.radius]
|
|
239
|
+
: [p[0] - normal[0] * radius, p[1] - normal[1] * radius, p[2] - normal[2] * radius];
|
|
240
|
+
const 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]];
|
|
241
|
+
if (!finiteVector(reaction) || !finiteVector(moment))
|
|
242
|
+
fail("ROPE_NUMERIC", "contact reaction exceeded finite numeric bounds");
|
|
223
243
|
const key = `${i}:${c.id}`;
|
|
224
244
|
const existing = contacts.get(key);
|
|
225
|
-
if (existing)
|
|
245
|
+
if (existing) {
|
|
226
246
|
existing.penetration = Math.max(existing.penetration, depth);
|
|
247
|
+
for (let k = 0; k < 3; k++) {
|
|
248
|
+
existing.reactionImpulse[k] += reaction[k];
|
|
249
|
+
existing.reactionAngularImpulse[k] += moment[k];
|
|
250
|
+
}
|
|
251
|
+
if (!finiteVector(existing.reactionImpulse) || !finiteVector(existing.reactionAngularImpulse))
|
|
252
|
+
fail("ROPE_NUMERIC", "contact reaction accumulation exceeded finite numeric bounds");
|
|
253
|
+
}
|
|
227
254
|
else
|
|
228
|
-
contacts.set(key, { tick: this.tickValue + 1, particle: i, colliderId: c.id, penetration: depth });
|
|
255
|
+
contacts.set(key, { tick: this.tickValue + 1, particle: i, colliderId: c.id, penetration: depth, reactionImpulse: reaction, reactionAngularImpulse: moment });
|
|
229
256
|
}
|
|
230
257
|
}
|
|
231
258
|
}
|
|
@@ -255,7 +282,7 @@ export class RopeSolver {
|
|
|
255
282
|
tick: this.tickValue, simulatedSeconds: this.tickValue * this.config.fixedDt, disposed: false,
|
|
256
283
|
positions: this.positions.map(copy), velocities: this.velocities.map(copy),
|
|
257
284
|
anchors: [...this.anchors].map(([index, position]) => ({ index, position: copy(position) })).sort((a, b) => a.index - b.index),
|
|
258
|
-
contacts: this.contacts.map(
|
|
285
|
+
contacts: this.contacts.map(copyContact), maxDistanceError,
|
|
259
286
|
resources: { particles: this.positions.length, constraints: this.config.restLengths.length, anchors: this.anchors.size, colliders: this.colliders.length },
|
|
260
287
|
};
|
|
261
288
|
}
|
|
@@ -294,14 +321,16 @@ export class RopeSolver {
|
|
|
294
321
|
input.contacts.some(contact => !contact || contact.tick !== input.tick ||
|
|
295
322
|
!Number.isSafeInteger(contact.particle) || contact.particle < 0 || contact.particle >= count ||
|
|
296
323
|
!colliders.some(collider => collider.id === contact.colliderId) ||
|
|
297
|
-
typeof contact.penetration !== "number" || !Number.isFinite(contact.penetration) || contact.penetration <= 0
|
|
324
|
+
typeof contact.penetration !== "number" || !Number.isFinite(contact.penetration) || contact.penetration <= 0 ||
|
|
325
|
+
(contact.reactionImpulse === undefined) !== (contact.reactionAngularImpulse === undefined) ||
|
|
326
|
+
(contact.reactionImpulse !== undefined && (!finiteVector(contact.reactionImpulse) || !finiteVector(contact.reactionAngularImpulse)))))
|
|
298
327
|
fail("ROPE_SNAPSHOT", "invalid contact state");
|
|
299
328
|
this.positions = input.positions.map(copy);
|
|
300
329
|
this.velocities = input.velocities.map(copy);
|
|
301
330
|
this.forces = input.forces.map(copy);
|
|
302
331
|
this.anchors = anchors;
|
|
303
332
|
this.colliders = colliders;
|
|
304
|
-
this.contacts = input.contacts.map(
|
|
333
|
+
this.contacts = input.contacts.map(copyContact);
|
|
305
334
|
this.tickValue = input.tick;
|
|
306
335
|
}
|
|
307
336
|
dispose() {
|