pelican-engine 0.3.14 → 0.3.16
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 +31 -5
- package/distribution.json +15 -14
- package/docs/STATUS.md +2 -2
- package/docs/api/REFERENCE.md +148 -92
- package/docs/api/index.json +143 -95
- package/docs/npm/GAME-AUTHOR.md +31 -5
- package/engine.manifest.json +1 -1
- 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 +41 -3
- package/lib/sdk/index.js +1 -1
- package/lib/simulation/rope-plugin.js +43 -9
- package/lib/simulation/rope.js +116 -5
- package/package.json +1 -1
package/lib/simulation/rope.js
CHANGED
|
@@ -18,6 +18,11 @@ const copyContact = (contact) => {
|
|
|
18
18
|
...(reactionAngularImpulse === undefined ? {} : { reactionAngularImpulse: copy(reactionAngularImpulse) }),
|
|
19
19
|
};
|
|
20
20
|
};
|
|
21
|
+
const copySegmentContact = (contact) => {
|
|
22
|
+
const { reactionImpulse, reactionAngularImpulse, ...rest } = contact;
|
|
23
|
+
return { ...rest, ...(reactionImpulse === undefined ? {} : { reactionImpulse: copy(reactionImpulse) }),
|
|
24
|
+
...(reactionAngularImpulse === undefined ? {} : { reactionAngularImpulse: copy(reactionAngularImpulse) }) };
|
|
25
|
+
};
|
|
21
26
|
const length = (v) => Math.hypot(v[0], v[1], v[2]);
|
|
22
27
|
const validVec = (v, name) => vec(v) ? copy(v) : fail("ROPE_INPUT", `${name} must be a finite 3D vector (|component| <= 1e6)`);
|
|
23
28
|
const positive = (v, name) => typeof v === "number" && Number.isFinite(v) && v > 0 && v <= 1e6 ? v : fail("ROPE_INPUT", `${name} must be positive and <= 1e6`);
|
|
@@ -66,6 +71,9 @@ export function createRope(options) {
|
|
|
66
71
|
const positions = options.points.map((p, i) => validVec(p, `points[${i}]`));
|
|
67
72
|
const mass = positive(options.mass, "mass");
|
|
68
73
|
const radius = positive(options.radius ?? 0.025, "radius");
|
|
74
|
+
if (options.segmentCollisions !== undefined && typeof options.segmentCollisions !== "boolean")
|
|
75
|
+
fail("ROPE_INPUT", "segmentCollisions must be boolean");
|
|
76
|
+
const segmentCollisions = options.segmentCollisions ?? true;
|
|
69
77
|
const fixedDt = positive(options.fixedDt ?? 1 / 60, "fixedDt");
|
|
70
78
|
if (fixedDt < 1 / 1000 || fixedDt > 1 / 30)
|
|
71
79
|
fail("ROPE_INPUT", "fixedDt must be in [1/1000, 1/30]");
|
|
@@ -80,7 +88,7 @@ export function createRope(options) {
|
|
|
80
88
|
const anchors = readAnchors(options.anchors ?? [{ index: 0, position: positions[0] }], positions.length);
|
|
81
89
|
for (const [i, p] of anchors)
|
|
82
90
|
positions[i] = copy(p);
|
|
83
|
-
return new RopeSolver({ mass, radius, fixedDt, substeps, iterations, damping, gravity, restLengths }, positions, anchors);
|
|
91
|
+
return new RopeSolver({ mass, radius, fixedDt, substeps, iterations, damping, gravity, restLengths, segmentCollisions }, positions, anchors);
|
|
84
92
|
}
|
|
85
93
|
export class RopeSolver {
|
|
86
94
|
config;
|
|
@@ -92,6 +100,7 @@ export class RopeSolver {
|
|
|
92
100
|
forces;
|
|
93
101
|
colliders = [];
|
|
94
102
|
contacts = [];
|
|
103
|
+
segmentContacts = [];
|
|
95
104
|
constructor(config, positions, anchors) {
|
|
96
105
|
this.config = config;
|
|
97
106
|
this.positions = positions;
|
|
@@ -106,6 +115,7 @@ export class RopeSolver {
|
|
|
106
115
|
this.alive();
|
|
107
116
|
this.colliders = readColliders(input);
|
|
108
117
|
this.contacts = [];
|
|
118
|
+
this.segmentContacts = [];
|
|
109
119
|
}
|
|
110
120
|
/** Pins or moves a particle; new anchor position takes effect immediately. */
|
|
111
121
|
setAnchor(index, position) {
|
|
@@ -152,6 +162,7 @@ export class RopeSolver {
|
|
|
152
162
|
velocities: this.velocities,
|
|
153
163
|
forces: this.forces,
|
|
154
164
|
contacts: this.contacts,
|
|
165
|
+
segmentContacts: this.segmentContacts,
|
|
155
166
|
tick: this.tickValue,
|
|
156
167
|
};
|
|
157
168
|
try {
|
|
@@ -163,6 +174,7 @@ export class RopeSolver {
|
|
|
163
174
|
this.velocities = before.velocities;
|
|
164
175
|
this.forces = before.forces;
|
|
165
176
|
this.contacts = before.contacts;
|
|
177
|
+
this.segmentContacts = before.segmentContacts;
|
|
166
178
|
this.tickValue = before.tick;
|
|
167
179
|
throw error;
|
|
168
180
|
}
|
|
@@ -174,6 +186,8 @@ export class RopeSolver {
|
|
|
174
186
|
// Work on copies; numeric failures must never leave half a tick applied.
|
|
175
187
|
const positions = this.positions.map(copy), velocities = this.velocities.map(copy);
|
|
176
188
|
const contacts = new Map();
|
|
189
|
+
const segmentContacts = new Map();
|
|
190
|
+
const spheres = this.config.segmentCollisions ? this.colliders.filter((collider) => collider.kind === "sphere") : [];
|
|
177
191
|
for (let sub = 0; sub < substeps; sub++) {
|
|
178
192
|
const previous = positions.map(copy);
|
|
179
193
|
for (let i = 0; i < positions.length; i++) {
|
|
@@ -269,6 +283,80 @@ export class RopeSolver {
|
|
|
269
283
|
contacts.set(key, { tick: this.tickValue + 1, particle: i, colliderId: c.id, penetration: depth, reactionImpulse: reaction, reactionAngularImpulse: moment });
|
|
270
284
|
}
|
|
271
285
|
}
|
|
286
|
+
// Resolve interior segment/sphere overlaps after particle contacts. Endpoint
|
|
287
|
+
// overlaps are already handled by the particle pass above.
|
|
288
|
+
for (let j = 0; j < restLengths.length; j++) {
|
|
289
|
+
const a = positions[j], b = positions[j + 1];
|
|
290
|
+
for (const collider of spheres) {
|
|
291
|
+
const dynamic = coupling?.sphere(collider.id);
|
|
292
|
+
const center = dynamic?.center ?? collider.center, colliderRadius = dynamic?.radius ?? collider.radius;
|
|
293
|
+
const direction = [b[0] - a[0], b[1] - a[1], b[2] - a[2]];
|
|
294
|
+
const squared = direction[0] * direction[0] + direction[1] * direction[1] + direction[2] * direction[2];
|
|
295
|
+
if (squared < 1e-24)
|
|
296
|
+
continue; // collapsed segment is covered by particle contacts
|
|
297
|
+
const fraction = Math.max(0, Math.min(1, ((center[0] - a[0]) * direction[0] + (center[1] - a[1]) * direction[1] + (center[2] - a[2]) * direction[2]) / squared));
|
|
298
|
+
if (fraction <= 0 || fraction >= 1)
|
|
299
|
+
continue;
|
|
300
|
+
const closest = [a[0] + direction[0] * fraction, a[1] + direction[1] * fraction, a[2] + direction[2] * fraction];
|
|
301
|
+
const delta = [closest[0] - center[0], closest[1] - center[1], closest[2] - center[2]];
|
|
302
|
+
const distance = length(delta), depth = colliderRadius + radius - distance;
|
|
303
|
+
if (depth <= 0)
|
|
304
|
+
continue;
|
|
305
|
+
let normal;
|
|
306
|
+
if (distance >= 1e-12)
|
|
307
|
+
normal = [delta[0] / distance, delta[1] / distance, delta[2] / distance];
|
|
308
|
+
else {
|
|
309
|
+
// Choose a deterministic perpendicular if the sphere center lies exactly on the segment.
|
|
310
|
+
const [x, y, z] = direction.map(Math.abs);
|
|
311
|
+
const axis = x <= y && x <= z ? [1, 0, 0] : y <= z ? [0, 1, 0] : [0, 0, 1];
|
|
312
|
+
const perpendicular = [direction[1] * axis[2] - direction[2] * axis[1], direction[2] * axis[0] - direction[0] * axis[2], direction[0] * axis[1] - direction[1] * axis[0]];
|
|
313
|
+
const magnitude = length(perpendicular);
|
|
314
|
+
normal = [perpendicular[0] / magnitude, perpendicular[1] / magnitude, perpendicular[2] / magnitude];
|
|
315
|
+
}
|
|
316
|
+
const first = this.anchors.has(j) ? 0 : 1 - fraction, second = this.anchors.has(j + 1) ? 0 : fraction;
|
|
317
|
+
const inverseWeight = first * first + second * second;
|
|
318
|
+
if (inverseWeight === 0)
|
|
319
|
+
continue; // two authoritative anchors cannot move
|
|
320
|
+
const contactMass = mass / inverseWeight;
|
|
321
|
+
if (!Number.isFinite(contactMass) || contactMass <= 0)
|
|
322
|
+
fail("ROPE_NUMERIC", "segment contact mass exceeded finite bounds");
|
|
323
|
+
const previousPoint = [0, 1, 2].map(k => previous[j][k] * (1 - fraction) + previous[j + 1][k] * fraction);
|
|
324
|
+
let correction, reaction, moment;
|
|
325
|
+
if (dynamic && coupling) {
|
|
326
|
+
const resolved = coupling.resolve(collider.id, normal, depth, closest, previousPoint, contactMass, h);
|
|
327
|
+
correction = resolved.correction;
|
|
328
|
+
reaction = resolved.reactionImpulse;
|
|
329
|
+
moment = resolved.reactionAngularImpulse;
|
|
330
|
+
}
|
|
331
|
+
else {
|
|
332
|
+
correction = [normal[0] * depth, normal[1] * depth, normal[2] * depth];
|
|
333
|
+
const magnitude = -contactMass * depth / h;
|
|
334
|
+
reaction = [normal[0] * magnitude, normal[1] * magnitude, normal[2] * magnitude];
|
|
335
|
+
const point = [center[0] + normal[0] * colliderRadius, center[1] + normal[1] * colliderRadius, center[2] + normal[2] * colliderRadius];
|
|
336
|
+
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]];
|
|
337
|
+
}
|
|
338
|
+
if (!finiteVector(correction) || !finiteVector(reaction) || !finiteVector(moment))
|
|
339
|
+
fail("ROPE_NUMERIC", "segment contact exceeded finite bounds");
|
|
340
|
+
// Gradients of the closest point distribute correction to movable endpoints.
|
|
341
|
+
for (let k = 0; k < 3; k++) {
|
|
342
|
+
a[k] += correction[k] * first / inverseWeight;
|
|
343
|
+
b[k] += correction[k] * second / inverseWeight;
|
|
344
|
+
}
|
|
345
|
+
const key = `${j}:${collider.id}`, existing = segmentContacts.get(key);
|
|
346
|
+
if (existing) {
|
|
347
|
+
existing.penetration = Math.max(existing.penetration, depth);
|
|
348
|
+
existing.fraction = fraction;
|
|
349
|
+
for (let k = 0; k < 3; k++) {
|
|
350
|
+
existing.reactionImpulse[k] += reaction[k];
|
|
351
|
+
existing.reactionAngularImpulse[k] += moment[k];
|
|
352
|
+
}
|
|
353
|
+
if (!finiteVector(existing.reactionImpulse) || !finiteVector(existing.reactionAngularImpulse))
|
|
354
|
+
fail("ROPE_NUMERIC", "segment reaction accumulation exceeded finite bounds");
|
|
355
|
+
}
|
|
356
|
+
else
|
|
357
|
+
segmentContacts.set(key, { tick: this.tickValue + 1, segment: j, colliderId: collider.id, fraction, penetration: depth, reactionImpulse: reaction, reactionAngularImpulse: moment });
|
|
358
|
+
}
|
|
359
|
+
}
|
|
272
360
|
}
|
|
273
361
|
for (let i = 0; i < positions.length; i++) {
|
|
274
362
|
if (this.anchors.has(i))
|
|
@@ -283,20 +371,23 @@ export class RopeSolver {
|
|
|
283
371
|
this.velocities = velocities;
|
|
284
372
|
this.forces = positions.map(() => [0, 0, 0]);
|
|
285
373
|
this.contacts = [...contacts.values()];
|
|
374
|
+
this.segmentContacts = [...segmentContacts.values()];
|
|
286
375
|
this.tickValue++;
|
|
287
376
|
}
|
|
377
|
+
/** Detached positions for renderer synchronization without copying contact state. */
|
|
378
|
+
particlePositions() { this.alive(); return this.positions.map(copy); }
|
|
288
379
|
inspect() {
|
|
289
380
|
if (this.disposedValue)
|
|
290
|
-
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 } };
|
|
381
|
+
return { tick: this.tickValue, simulatedSeconds: this.tickValue * this.config.fixedDt, disposed: true, segmentCollisions: this.config.segmentCollisions, positions: [], velocities: [], anchors: [], contacts: [], segmentContacts: [], maxDistanceError: 0, resources: { particles: 0, constraints: 0, anchors: 0, colliders: 0 } };
|
|
291
382
|
const maxDistanceError = Math.max(0, ...this.config.restLengths.map((d, i) => {
|
|
292
383
|
const a = this.positions[i], b = this.positions[i + 1];
|
|
293
384
|
return Math.abs(Math.hypot(a[0] - b[0], a[1] - b[1], a[2] - b[2]) - d);
|
|
294
385
|
}));
|
|
295
386
|
return {
|
|
296
|
-
tick: this.tickValue, simulatedSeconds: this.tickValue * this.config.fixedDt, disposed: false,
|
|
387
|
+
tick: this.tickValue, simulatedSeconds: this.tickValue * this.config.fixedDt, disposed: false, segmentCollisions: this.config.segmentCollisions,
|
|
297
388
|
positions: this.positions.map(copy), velocities: this.velocities.map(copy),
|
|
298
389
|
anchors: [...this.anchors].map(([index, position]) => ({ index, position: copy(position) })).sort((a, b) => a.index - b.index),
|
|
299
|
-
contacts: this.contacts.map(copyContact), maxDistanceError,
|
|
390
|
+
contacts: this.contacts.map(copyContact), segmentContacts: this.segmentContacts.map(copySegmentContact), maxDistanceError,
|
|
300
391
|
resources: { particles: this.positions.length, constraints: this.config.restLengths.length, anchors: this.anchors.size, colliders: this.colliders.length },
|
|
301
392
|
};
|
|
302
393
|
}
|
|
@@ -307,7 +398,7 @@ export class RopeSolver {
|
|
|
307
398
|
format: "pelican-rope-cpu", version: 1, tick: this.tickValue,
|
|
308
399
|
config: { ...this.config, gravity: copy(this.config.gravity), restLengths: [...this.config.restLengths] },
|
|
309
400
|
positions: state.positions, velocities: state.velocities, anchors: state.anchors,
|
|
310
|
-
forces: this.forces.map(copy), colliders: readColliders(this.colliders), contacts: state.contacts,
|
|
401
|
+
forces: this.forces.map(copy), colliders: readColliders(this.colliders), contacts: state.contacts, segmentContacts: state.segmentContacts,
|
|
311
402
|
};
|
|
312
403
|
}
|
|
313
404
|
/** Accepts a JSON-round-tripped snapshot only from the same rope configuration. Atomic on failure. */
|
|
@@ -319,6 +410,7 @@ export class RopeSolver {
|
|
|
319
410
|
const c = input.config;
|
|
320
411
|
if (!c || c.mass !== this.config.mass || c.radius !== this.config.radius || c.fixedDt !== this.config.fixedDt ||
|
|
321
412
|
c.substeps !== this.config.substeps || c.iterations !== this.config.iterations || c.damping !== this.config.damping ||
|
|
413
|
+
(c.segmentCollisions !== undefined && typeof c.segmentCollisions !== "boolean") || (c.segmentCollisions ?? false) !== this.config.segmentCollisions ||
|
|
322
414
|
!vec(c.gravity) || c.gravity.some((v, i) => v !== this.config.gravity[i]) ||
|
|
323
415
|
!Array.isArray(c.restLengths) || c.restLengths.length !== this.config.restLengths.length ||
|
|
324
416
|
c.restLengths.some((v, i) => v !== this.config.restLengths[i]))
|
|
@@ -339,12 +431,30 @@ export class RopeSolver {
|
|
|
339
431
|
(contact.reactionImpulse === undefined) !== (contact.reactionAngularImpulse === undefined) ||
|
|
340
432
|
(contact.reactionImpulse !== undefined && (!finiteVector(contact.reactionImpulse) || !finiteVector(contact.reactionAngularImpulse)))))
|
|
341
433
|
fail("ROPE_SNAPSHOT", "invalid contact state");
|
|
434
|
+
const segments = input.segmentContacts === undefined ? [] : input.segmentContacts;
|
|
435
|
+
if (!Array.isArray(segments) || segments.length > (count - 1) * colliders.length || (!this.config.segmentCollisions && segments.length > 0) ||
|
|
436
|
+
segments.some(contact => !contact || contact.tick !== input.tick ||
|
|
437
|
+
!Number.isSafeInteger(contact.segment) || contact.segment < 0 || contact.segment >= count - 1 ||
|
|
438
|
+
!colliders.some(collider => collider.id === contact.colliderId && collider.kind === "sphere") ||
|
|
439
|
+
typeof contact.fraction !== "number" || !Number.isFinite(contact.fraction) || contact.fraction <= 0 || contact.fraction >= 1 ||
|
|
440
|
+
typeof contact.penetration !== "number" || !Number.isFinite(contact.penetration) || contact.penetration <= 0 ||
|
|
441
|
+
(contact.reactionImpulse === undefined) !== (contact.reactionAngularImpulse === undefined) ||
|
|
442
|
+
(contact.reactionImpulse !== undefined && (!finiteVector(contact.reactionImpulse) || !finiteVector(contact.reactionAngularImpulse)))))
|
|
443
|
+
fail("ROPE_SNAPSHOT", "invalid segment contact state");
|
|
444
|
+
const segmentKeys = new Set();
|
|
445
|
+
for (const contact of segments) {
|
|
446
|
+
const key = `${contact.segment}:${contact.colliderId}`;
|
|
447
|
+
if (segmentKeys.has(key))
|
|
448
|
+
fail("ROPE_SNAPSHOT", "duplicate segment contact");
|
|
449
|
+
segmentKeys.add(key);
|
|
450
|
+
}
|
|
342
451
|
this.positions = input.positions.map(copy);
|
|
343
452
|
this.velocities = input.velocities.map(copy);
|
|
344
453
|
this.forces = input.forces.map(copy);
|
|
345
454
|
this.anchors = anchors;
|
|
346
455
|
this.colliders = colliders;
|
|
347
456
|
this.contacts = input.contacts.map(copyContact);
|
|
457
|
+
this.segmentContacts = segments.map(copySegmentContact);
|
|
348
458
|
this.tickValue = input.tick;
|
|
349
459
|
}
|
|
350
460
|
dispose() {
|
|
@@ -356,6 +466,7 @@ export class RopeSolver {
|
|
|
356
466
|
this.forces = [];
|
|
357
467
|
this.colliders = [];
|
|
358
468
|
this.contacts = [];
|
|
469
|
+
this.segmentContacts = [];
|
|
359
470
|
this.anchors.clear();
|
|
360
471
|
}
|
|
361
472
|
}
|