pelican-engine 0.3.13 → 0.3.15
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 -11
- package/distribution.json +17 -15
- package/docs/STATUS.md +3 -3
- package/docs/api/REFERENCE.md +127 -93
- package/docs/api/index.json +125 -95
- package/docs/npm/GAME-AUTHOR.md +28 -11
- package/engine.manifest.json +1 -1
- package/lib/physics/index.js +20 -7
- package/lib/renderer/browser.js +24 -3
- package/lib/renderer/geometry.js +26 -16
- package/lib/renderer/rope-visuals.js +122 -0
- package/lib/sdk/index.d.ts +26 -4
- package/lib/sdk/index.js +1 -1
- package/lib/simulation/rope-body-coupling.js +107 -0
- package/lib/simulation/rope-plugin.js +49 -28
- package/lib/simulation/rope.js +32 -16
- package/package.json +1 -1
|
@@ -1,9 +1,10 @@
|
|
|
1
1
|
import { resolveEntityDefinition } from "../runtime/prefabs.js";
|
|
2
2
|
import { cloneSerializable } from "../runtime/random.js";
|
|
3
3
|
import { createRope } from "./rope.js";
|
|
4
|
+
import { createRopeBodyCoupling, VirtualRopeSphere } from "./rope-body-coupling.js";
|
|
4
5
|
const fail = (code, detail) => { throw new Error(`TC_ROPE_${code}: ${detail}`); };
|
|
5
6
|
const validId = (id) => typeof id === "string" && id.trim() === id && id.length > 0 && id.length <= 128;
|
|
6
|
-
function createEntry(input, fixedDt) {
|
|
7
|
+
function createEntry(input, fixedDt, materials) {
|
|
7
8
|
const definition = cloneSerializable(input);
|
|
8
9
|
if (!definition || !validId(definition.id))
|
|
9
10
|
return fail("ID", "Expected a nonempty id of at most 128 characters.");
|
|
@@ -13,6 +14,19 @@ function createEntry(input, fixedDt) {
|
|
|
13
14
|
return fail("FIXED_DT", "Rope fixedDt must exactly match the host fixedDt.");
|
|
14
15
|
if (fixedDt < 1 / 1000 || fixedDt > 1 / 30)
|
|
15
16
|
return fail("FIXED_DT", "Host fixedDt must be within [1/1000, 1/30] seconds for ropes.");
|
|
17
|
+
if (definition.visual !== undefined) {
|
|
18
|
+
const visual = definition.visual;
|
|
19
|
+
if (!visual || typeof visual !== "object" || Array.isArray(visual) || !validId(visual.material) || !materials.has(visual.material))
|
|
20
|
+
return fail("VISUAL_MATERIAL", "visual.material must name a declared engine material.");
|
|
21
|
+
const radius = visual.radius ?? definition.radius ?? 0.025;
|
|
22
|
+
if (typeof radius !== "number" || !Number.isFinite(radius) || radius <= 0 || radius > 1e6 || Math.fround(radius) === 0)
|
|
23
|
+
return fail("VISUAL_RADIUS", "visual radius must be positive, finite, representable and at most 1e6.");
|
|
24
|
+
const sides = visual.radialSegments ?? 8;
|
|
25
|
+
if (!Number.isSafeInteger(sides) || sides < 3 || sides > 32)
|
|
26
|
+
return fail("VISUAL_SEGMENTS", "radialSegments must be an integer in [3,32].");
|
|
27
|
+
if ((visual.castShadow !== undefined && typeof visual.castShadow !== "boolean") || (visual.receiveShadow !== undefined && typeof visual.receiveShadow !== "boolean"))
|
|
28
|
+
return fail("VISUAL_SHADOW", "Visual shadow options must be boolean.");
|
|
29
|
+
}
|
|
16
30
|
if (definition.bodyColliders !== undefined) {
|
|
17
31
|
if (!Array.isArray(definition.bodyColliders) || definition.bodyColliders.length > 64)
|
|
18
32
|
return fail("BODY_COLLIDER", "bodyColliders must contain at most 64 bindings.");
|
|
@@ -34,7 +48,7 @@ function disposeEntries(entries) {
|
|
|
34
48
|
solver.dispose();
|
|
35
49
|
entries.clear();
|
|
36
50
|
}
|
|
37
|
-
function parseSnapshot(value, fixedDt) {
|
|
51
|
+
function parseSnapshot(value, fixedDt, materials) {
|
|
38
52
|
if (!value || typeof value !== "object" || value.version !== 1 || !Array.isArray(value.ropes) || value.ropes.length > 256)
|
|
39
53
|
return fail("SNAPSHOT", "Expected a version 1 rope snapshot with at most 256 ropes.");
|
|
40
54
|
const entries = new Map();
|
|
@@ -42,7 +56,7 @@ function parseSnapshot(value, fixedDt) {
|
|
|
42
56
|
for (const item of value.ropes) {
|
|
43
57
|
if (!item || typeof item !== "object")
|
|
44
58
|
fail("SNAPSHOT", "Invalid rope entry.");
|
|
45
|
-
const entry = createEntry(item.definition, fixedDt);
|
|
59
|
+
const entry = createEntry(item.definition, fixedDt, materials);
|
|
46
60
|
if (entries.has(entry.definition.id)) {
|
|
47
61
|
entry.solver.dispose();
|
|
48
62
|
fail("SNAPSHOT", "Duplicate rope id.");
|
|
@@ -93,12 +107,13 @@ function validateWorldBindings(entries, context, world) {
|
|
|
93
107
|
fail("SNAPSHOT", `Bound body '${binding.entity}' must exist as a dynamic, non-trigger sphere in the restored world.`);
|
|
94
108
|
}
|
|
95
109
|
}
|
|
96
|
-
/**
|
|
110
|
+
/** Split-step sphere contact uses virtual mass/velocity state; native commit is deferred. */
|
|
97
111
|
export const createRopePlugin = (context) => {
|
|
98
112
|
const { fixedDt } = context;
|
|
99
113
|
if (typeof fixedDt !== "number" || !Number.isFinite(fixedDt))
|
|
100
114
|
return fail("FIXED_DT", "RuntimeHost must provide a finite fixed interval.");
|
|
101
115
|
const hostDt = fixedDt;
|
|
116
|
+
const materials = new Set(context.scene.materials.map(material => material.id));
|
|
102
117
|
let entries = new Map();
|
|
103
118
|
let disposed = false, pendingRefs = false;
|
|
104
119
|
const alive = () => { if (disposed)
|
|
@@ -130,7 +145,7 @@ export const createRopePlugin = (context) => {
|
|
|
130
145
|
return fail("DUPLICATE", `Rope '${definition.id}' already exists.`);
|
|
131
146
|
if (entries.size >= 256)
|
|
132
147
|
return fail("CAPACITY", "At most 256 ropes per host.");
|
|
133
|
-
const entry = createEntry(definition, hostDt);
|
|
148
|
+
const entry = createEntry(definition, hostDt, materials);
|
|
134
149
|
try {
|
|
135
150
|
entry.bodyRefs = resolveRefs(entry, context, true);
|
|
136
151
|
entries.set(entry.definition.id, entry);
|
|
@@ -147,6 +162,18 @@ export const createRopePlugin = (context) => {
|
|
|
147
162
|
return entry.solver.step(ticks);
|
|
148
163
|
},
|
|
149
164
|
inspect(id) { return get(id).solver.inspect(); },
|
|
165
|
+
visuals() {
|
|
166
|
+
alive();
|
|
167
|
+
ensureRefs();
|
|
168
|
+
return [...entries.values()].filter(entry => entry.definition.visual !== undefined)
|
|
169
|
+
.sort((a, b) => a.definition.id < b.definition.id ? -1 : a.definition.id > b.definition.id ? 1 : 0)
|
|
170
|
+
.map(entry => {
|
|
171
|
+
const visual = entry.definition.visual;
|
|
172
|
+
return { id: entry.definition.id, material: visual.material, radius: visual.radius ?? entry.definition.radius ?? 0.025,
|
|
173
|
+
radialSegments: visual.radialSegments ?? 8, castShadow: visual.castShadow ?? true, receiveShadow: visual.receiveShadow ?? true,
|
|
174
|
+
positions: entry.solver.particlePositions() };
|
|
175
|
+
});
|
|
176
|
+
},
|
|
150
177
|
setColliders(id, colliders) {
|
|
151
178
|
const entry = get(id);
|
|
152
179
|
if (Array.isArray(colliders)) {
|
|
@@ -170,7 +197,7 @@ export const createRopePlugin = (context) => {
|
|
|
170
197
|
alive();
|
|
171
198
|
ensureRefs();
|
|
172
199
|
const committed = [];
|
|
173
|
-
const
|
|
200
|
+
const bodies = new Map();
|
|
174
201
|
try {
|
|
175
202
|
const automatic = [...entries.values()].filter(entry => entry.definition.autoStep)
|
|
176
203
|
.sort((a, b) => a.definition.id < b.definition.id ? -1 : a.definition.id > b.definition.id ? 1 : 0);
|
|
@@ -191,33 +218,27 @@ export const createRopePlugin = (context) => {
|
|
|
191
218
|
entry.bodyRefs = new Map(live.map(binding => [binding.id, entry.bodyRefs.get(binding.id)]));
|
|
192
219
|
entry.staleBodies = new Set();
|
|
193
220
|
}
|
|
221
|
+
const coupled = new Map();
|
|
194
222
|
if (previous.length) {
|
|
195
223
|
const boundIds = new Set(previous.map(item => item.id));
|
|
196
224
|
const manual = snapshot.colliders.filter(collider => !boundIds.has(collider.id));
|
|
197
225
|
const bridge = live.length ? bridgeFor(context) : undefined;
|
|
198
226
|
const sampled = live.map(binding => {
|
|
199
|
-
|
|
200
|
-
|
|
227
|
+
let body = bodies.get(binding.entity);
|
|
228
|
+
if (!body) {
|
|
229
|
+
body = new VirtualRopeSphere(bridge.sampleSphere(binding.entity));
|
|
230
|
+
bodies.set(binding.entity, body);
|
|
231
|
+
}
|
|
232
|
+
coupled.set(binding.id, body);
|
|
233
|
+
return { kind: "sphere", id: binding.id, center: [...body.center], radius: body.radius };
|
|
201
234
|
});
|
|
202
235
|
entry.solver.setColliders([...manual, ...sampled]);
|
|
203
236
|
}
|
|
204
|
-
|
|
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
|
-
}
|
|
237
|
+
entry.solver.step(1, coupled.size ? createRopeBodyCoupling(coupled) : undefined);
|
|
217
238
|
}
|
|
218
|
-
const batch = [...
|
|
219
|
-
.
|
|
220
|
-
.
|
|
239
|
+
const batch = [...bodies].map(([entity, body]) => ({ entity, ...body.reaction() }))
|
|
240
|
+
.filter(item => item.impulse.some(value => value !== 0) || item.moment.some(value => value !== 0))
|
|
241
|
+
.sort((a, b) => a.entity < b.entity ? -1 : a.entity > b.entity ? 1 : 0);
|
|
221
242
|
if (batch.length)
|
|
222
243
|
bridgeFor(context).applySphereReactions(batch);
|
|
223
244
|
}
|
|
@@ -253,9 +274,9 @@ export const createRopePlugin = (context) => {
|
|
|
253
274
|
return { definition: cloneSerializable(entry.definition), state: entry.solver.snapshot(), ...(stale.length ? { staleBodyColliders: stale } : {}) };
|
|
254
275
|
}) };
|
|
255
276
|
},
|
|
256
|
-
validateSnapshot(value) { const staged = parseSnapshot(value, hostDt); disposeEntries(staged); },
|
|
277
|
+
validateSnapshot(value) { const staged = parseSnapshot(value, hostDt, materials); disposeEntries(staged); },
|
|
257
278
|
validateWorld(value, world) {
|
|
258
|
-
const staged = parseSnapshot(value, hostDt);
|
|
279
|
+
const staged = parseSnapshot(value, hostDt, materials);
|
|
259
280
|
try {
|
|
260
281
|
validateWorldBindings(staged, context, world);
|
|
261
282
|
}
|
|
@@ -264,7 +285,7 @@ export const createRopePlugin = (context) => {
|
|
|
264
285
|
}
|
|
265
286
|
},
|
|
266
287
|
validatePeers(value, peers) {
|
|
267
|
-
const staged = parseSnapshot(value, hostDt);
|
|
288
|
+
const staged = parseSnapshot(value, hostDt, materials);
|
|
268
289
|
try {
|
|
269
290
|
const bindings = [...staged.values()].flatMap(entry => (entry.definition.bodyColliders ?? []).filter(binding => !entry.staleBodies.has(binding.id)));
|
|
270
291
|
if (!bindings.length)
|
|
@@ -285,7 +306,7 @@ export const createRopePlugin = (context) => {
|
|
|
285
306
|
},
|
|
286
307
|
restore(value) {
|
|
287
308
|
alive();
|
|
288
|
-
const staged = parseSnapshot(value, hostDt);
|
|
309
|
+
const staged = parseSnapshot(value, hostDt, materials);
|
|
289
310
|
disposeEntries(entries);
|
|
290
311
|
entries = staged;
|
|
291
312
|
pendingRefs = true;
|
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}`;
|
|
@@ -271,6 +285,8 @@ export class RopeSolver {
|
|
|
271
285
|
this.contacts = [...contacts.values()];
|
|
272
286
|
this.tickValue++;
|
|
273
287
|
}
|
|
288
|
+
/** Detached positions for renderer synchronization without copying contact state. */
|
|
289
|
+
particlePositions() { this.alive(); return this.positions.map(copy); }
|
|
274
290
|
inspect() {
|
|
275
291
|
if (this.disposedValue)
|
|
276
292
|
return { tick: this.tickValue, simulatedSeconds: this.tickValue * this.config.fixedDt, disposed: true, positions: [], velocities: [], anchors: [], contacts: [], maxDistanceError: 0, resources: { particles: 0, constraints: 0, anchors: 0, colliders: 0 } };
|