pelican-engine 0.3.25 → 0.3.27
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 +8 -1
- package/distribution.json +12 -12
- package/docs/STATUS.md +4 -0
- package/docs/api/REFERENCE.md +99 -97
- package/docs/api/index.json +105 -100
- package/docs/npm/GAME-AUTHOR.md +8 -1
- package/engine.manifest.json +1 -1
- package/lib/sdk/index.d.ts +8 -2
- package/lib/sdk/index.js +1 -1
- package/lib/simulation/cloth-plugin.js +25 -1
- package/lib/simulation/cloth.js +36 -12
- package/package.json +1 -1
package/docs/npm/GAME-AUTHOR.md
CHANGED
|
@@ -233,7 +233,14 @@ manual cloth uses `ctx.cloth.step`. Use `setAnchor`, `releaseAnchor`, `applyForc
|
|
|
233
233
|
(next tick), `applyImpulse`, `setColliders`, `inspect`, and `destroy` to manage it.
|
|
234
234
|
The source checkout includes `examples/cloth-sheet`.
|
|
235
235
|
|
|
236
|
-
An automatic cloth may bind a live dynamic sphere with `bodyColliders: [{ id: "sphere-id", entity: "body-id" }]`. The plugin samples the real sphere after physics and uses it as a one-way moving obstacle.
|
|
236
|
+
An automatic cloth may bind a live dynamic sphere with `bodyColliders: [{ id: "sphere-id", entity: "body-id" }]`. The plugin samples the real sphere after physics and uses it as a one-way moving obstacle. Set `bodyReactions: true` to submit approximate normal reaction impulses from
|
|
237
|
+
particle and triangle-surface contacts through the physics bridge. This changes
|
|
238
|
+
body velocity on the next physics step; it is delayed, projection-derived and
|
|
239
|
+
not mass-coupled with cloth velocity. The body receives no reaction when the
|
|
240
|
+
option is omitted. Destroyed bodies are removed on the next automatic step.
|
|
241
|
+
This is not complete cloth-rigid-body coupling.
|
|
242
|
+
|
|
243
|
+
Sampled planes use the triangle centroid for discrete surface projection; this is not continuous triangle-plane collision.
|
|
237
244
|
|
|
238
245
|
New cloth also resolves detected sphere overlaps against triangle surface interiors
|
|
239
246
|
using closest-point barycentric projection. `inspect(id).surfaceContacts` reports
|
package/engine.manifest.json
CHANGED
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.27";
|
|
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];
|
|
@@ -277,14 +277,18 @@ export interface ClothDefinition {
|
|
|
277
277
|
selfCollisions?: boolean;
|
|
278
278
|
/** Sphere triangle-surface contacts; defaults to true for new cloth. */
|
|
279
279
|
surfaceCollisions?: boolean;
|
|
280
|
-
/** Optional live dynamic sphere bindings; sampled after the physics phase
|
|
280
|
+
/** Optional live dynamic sphere bindings; sampled after the physics phase. */
|
|
281
281
|
bodyColliders?: readonly ClothBodyCollider[];
|
|
282
|
+
/** When true, detected contacts submit approximate reaction impulses to bound bodies. */
|
|
283
|
+
bodyReactions?: boolean;
|
|
282
284
|
}
|
|
283
285
|
export interface ClothContact {
|
|
284
286
|
tick: number;
|
|
285
287
|
particle: number;
|
|
286
288
|
colliderId: string;
|
|
287
289
|
penetration: number;
|
|
290
|
+
reactionImpulse?: Vec3;
|
|
291
|
+
reactionAngularImpulse?: Vec3;
|
|
288
292
|
}
|
|
289
293
|
export interface ClothSelfContact {
|
|
290
294
|
tick: number;
|
|
@@ -298,6 +302,8 @@ export interface ClothSurfaceContact {
|
|
|
298
302
|
colliderId: string;
|
|
299
303
|
penetration: number;
|
|
300
304
|
barycentric: [number, number, number];
|
|
305
|
+
reactionImpulse?: Vec3;
|
|
306
|
+
reactionAngularImpulse?: Vec3;
|
|
301
307
|
}
|
|
302
308
|
export interface ClothState {
|
|
303
309
|
tick: number;
|
package/lib/sdk/index.js
CHANGED
|
@@ -22,6 +22,10 @@ function createEntry(input, fixedDt, materials, restoredSelfMode, restoredSurfac
|
|
|
22
22
|
return fail("ID", "Expected a nonempty id of at most 128 characters.");
|
|
23
23
|
if (definition.autoStep !== undefined && typeof definition.autoStep !== "boolean")
|
|
24
24
|
return fail("AUTO_STEP", "autoStep must be boolean.");
|
|
25
|
+
if (definition.bodyReactions !== undefined && typeof definition.bodyReactions !== "boolean")
|
|
26
|
+
return fail("BODY_REACTION", "bodyReactions must be boolean.");
|
|
27
|
+
if (definition.bodyReactions && !definition.bodyColliders?.length)
|
|
28
|
+
return fail("BODY_REACTION", "bodyReactions require bodyColliders.");
|
|
25
29
|
if (definition.bodyColliders !== undefined) {
|
|
26
30
|
if (!Array.isArray(definition.bodyColliders) || definition.bodyColliders.length > 64)
|
|
27
31
|
return fail("BODY_COLLIDER", "bodyColliders must contain at most 64 bindings.");
|
|
@@ -37,6 +41,8 @@ function createEntry(input, fixedDt, materials, restoredSelfMode, restoredSurfac
|
|
|
37
41
|
}
|
|
38
42
|
if (definition.selfCollisions !== undefined && typeof definition.selfCollisions !== "boolean")
|
|
39
43
|
return fail("SELF_COLLISION", "selfCollisions must be boolean.");
|
|
44
|
+
if (definition.bodyReactions !== undefined && typeof definition.bodyReactions !== "boolean")
|
|
45
|
+
return fail("BODY_REACTION", "bodyReactions must be boolean.");
|
|
40
46
|
if (definition.surfaceCollisions !== undefined && typeof definition.surfaceCollisions !== "boolean")
|
|
41
47
|
return fail("SURFACE_COLLISION", "surfaceCollisions must be boolean.");
|
|
42
48
|
if (restoredSurfaceMode !== undefined && definition.surfaceCollisions !== undefined && definition.surfaceCollisions !== restoredSurfaceMode)
|
|
@@ -191,7 +197,25 @@ export const createClothPlugin = context => {
|
|
|
191
197
|
entry.definition = { ...entry.definition, bodyColliders: live };
|
|
192
198
|
entry.solver.setColliders([...manual, ...sampled]);
|
|
193
199
|
}
|
|
194
|
-
entry.solver.step();
|
|
200
|
+
const state = entry.solver.step();
|
|
201
|
+
if (entry.definition.bodyReactions && entry.definition.bodyColliders?.length) {
|
|
202
|
+
const bridge = context.getRopePhysicsBridge?.() ?? fail("BODY_REACTION", "bodyReactions require the physics plugin.");
|
|
203
|
+
const byCollider = new Map((entry.definition.bodyColliders ?? []).map(binding => [binding.id, binding.entity]));
|
|
204
|
+
const reactions = new Map();
|
|
205
|
+
for (const contact of [...state.contacts, ...state.surfaceContacts]) {
|
|
206
|
+
const entity = byCollider.get(contact.colliderId);
|
|
207
|
+
if (!entity || !contact.reactionImpulse || !contact.reactionAngularImpulse)
|
|
208
|
+
continue;
|
|
209
|
+
const current = reactions.get(entity) ?? { impulse: [0, 0, 0], moment: [0, 0, 0] };
|
|
210
|
+
for (let k = 0; k < 3; k++) {
|
|
211
|
+
current.impulse[k] += contact.reactionImpulse[k];
|
|
212
|
+
current.moment[k] += contact.reactionAngularImpulse[k];
|
|
213
|
+
}
|
|
214
|
+
reactions.set(entity, current);
|
|
215
|
+
}
|
|
216
|
+
if (reactions.size)
|
|
217
|
+
bridge.applySphereReactions([...reactions].sort(([a], [b]) => a < b ? -1 : a > b ? 1 : 0).map(([entity, value]) => ({ entity, ...value })));
|
|
218
|
+
}
|
|
195
219
|
}
|
|
196
220
|
}
|
|
197
221
|
catch (error) {
|
package/lib/simulation/cloth.js
CHANGED
|
@@ -14,6 +14,7 @@ const positive = (input, label) => typeof input === "number" && Number.isFinite(
|
|
|
14
14
|
const indexOf = (input, count) => Number.isSafeInteger(input) && input >= 0 && input < count ? input : fail("CLOTH_INDEX", "particle index is out of range");
|
|
15
15
|
const distance = (a, b) => Math.hypot(a[0] - b[0], a[1] - b[1], a[2] - b[2]);
|
|
16
16
|
const tuple = (a) => [a[0], a[1], a[2]];
|
|
17
|
+
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]];
|
|
17
18
|
function readAnchors(input, count) {
|
|
18
19
|
if (!Array.isArray(input) || input.length > count)
|
|
19
20
|
fail("CLOTH_INPUT", "anchors must be an array of unique particle indices");
|
|
@@ -280,34 +281,57 @@ export class ClothSolver {
|
|
|
280
281
|
continue;
|
|
281
282
|
for (let k = 0; k < 3; k++)
|
|
282
283
|
p[k] += normal[k] * depth;
|
|
283
|
-
const
|
|
284
|
-
|
|
284
|
+
const magnitude = -mass * depth / h, reactionImpulse = [normal[0] * magnitude, normal[1] * magnitude, normal[2] * magnitude];
|
|
285
|
+
const point = collider.kind === "sphere" ? [collider.center[0] + normal[0] * collider.radius, collider.center[1] + normal[1] * collider.radius, collider.center[2] + normal[2] * collider.radius] : [p[0] - normal[0] * radius, p[1] - normal[1] * radius, p[2] - normal[2] * radius];
|
|
286
|
+
const reactionAngularImpulse = cross(point, reactionImpulse), key = `${i}:${collider.id}`, old = contacts.get(key);
|
|
287
|
+
if (old) {
|
|
285
288
|
old.penetration = Math.max(old.penetration, depth);
|
|
289
|
+
for (let k = 0; k < 3; k++) {
|
|
290
|
+
old.reactionImpulse[k] += reactionImpulse[k];
|
|
291
|
+
old.reactionAngularImpulse[k] += reactionAngularImpulse[k];
|
|
292
|
+
}
|
|
293
|
+
}
|
|
286
294
|
else
|
|
287
|
-
contacts.set(key, { tick: this.tickValue + 1, particle: i, colliderId: collider.id, penetration: depth });
|
|
295
|
+
contacts.set(key, { tick: this.tickValue + 1, particle: i, colliderId: collider.id, penetration: depth, reactionImpulse, reactionAngularImpulse });
|
|
288
296
|
}
|
|
289
297
|
}
|
|
290
298
|
if (this.config.surfaceCollisions)
|
|
291
299
|
for (let triangleIndex = 0; triangleIndex < this.config.triangles.length; triangleIndex++) {
|
|
292
300
|
const triangle = this.config.triangles[triangleIndex], a = positions[triangle[0]], b = positions[triangle[1]], c = positions[triangle[2]];
|
|
293
301
|
for (const collider of this.colliders) {
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
302
|
+
let closest, normal, depth;
|
|
303
|
+
if (collider.kind === "sphere") {
|
|
304
|
+
closest = closestTriangle(collider.center, a, b, c);
|
|
305
|
+
const delta = [closest.point[0] - collider.center[0], closest.point[1] - collider.center[1], closest.point[2] - collider.center[2]], length = Math.hypot(...delta);
|
|
306
|
+
depth = collider.radius + radius - length;
|
|
307
|
+
normal = length < 1e-12 ? [0, 1, 0] : [delta[0] / length, delta[1] / length, delta[2] / length];
|
|
308
|
+
}
|
|
309
|
+
else {
|
|
310
|
+
const centroid = [(a[0] + b[0] + c[0]) / 3, (a[1] + b[1] + c[1]) / 3, (a[2] + b[2] + c[2]) / 3];
|
|
311
|
+
normal = collider.normal;
|
|
312
|
+
depth = collider.offset + radius - (centroid[0] * normal[0] + centroid[1] * normal[1] + centroid[2] * normal[2]);
|
|
313
|
+
closest = { point: centroid, barycentric: [1 / 3, 1 / 3, 1 / 3] };
|
|
314
|
+
}
|
|
297
315
|
if (depth <= 0)
|
|
298
316
|
continue;
|
|
299
|
-
const
|
|
317
|
+
const weights = [this.anchors.has(triangle[0]) ? 0 : closest.barycentric[0], this.anchors.has(triangle[1]) ? 0 : closest.barycentric[1], this.anchors.has(triangle[2]) ? 0 : closest.barycentric[2]], denominator = weights.reduce((sum, value) => sum + value * value, 0);
|
|
300
318
|
if (denominator === 0)
|
|
301
319
|
continue;
|
|
302
320
|
const correction = depth / denominator;
|
|
303
321
|
for (let vertex = 0; vertex < 3; vertex++)
|
|
304
322
|
for (let axis = 0; axis < 3; axis++)
|
|
305
323
|
positions[triangle[vertex]][axis] += normal[axis] * correction * weights[vertex];
|
|
324
|
+
const magnitude = -mass * depth / h, reactionImpulse = [normal[0] * magnitude, normal[1] * magnitude, normal[2] * magnitude], reactionAngularImpulse = cross(closest.point, reactionImpulse);
|
|
306
325
|
const key = `${triangleIndex}:${collider.id}`, old = surfaceContacts.get(key);
|
|
307
|
-
if (old)
|
|
326
|
+
if (old) {
|
|
308
327
|
old.penetration = Math.max(old.penetration, depth);
|
|
328
|
+
for (let k = 0; k < 3; k++) {
|
|
329
|
+
old.reactionImpulse[k] += reactionImpulse[k];
|
|
330
|
+
old.reactionAngularImpulse[k] += reactionAngularImpulse[k];
|
|
331
|
+
}
|
|
332
|
+
}
|
|
309
333
|
else
|
|
310
|
-
surfaceContacts.set(key, { tick: this.tickValue + 1, triangle: triangleIndex, colliderId: collider.id, penetration: depth, barycentric: closest.barycentric });
|
|
334
|
+
surfaceContacts.set(key, { tick: this.tickValue + 1, triangle: triangleIndex, colliderId: collider.id, penetration: depth, barycentric: closest.barycentric, reactionImpulse, reactionAngularImpulse });
|
|
311
335
|
}
|
|
312
336
|
}
|
|
313
337
|
if (this.config.selfCollisions)
|
|
@@ -391,7 +415,7 @@ export class ClothSolver {
|
|
|
391
415
|
const colliders = readColliders(input.colliders), keys = new Set();
|
|
392
416
|
if (!Array.isArray(input.contacts) || input.contacts.length > count * colliders.length || input.contacts.some(contact => !contact || contact.tick !== input.tick ||
|
|
393
417
|
!Number.isSafeInteger(contact.particle) || contact.particle < 0 || contact.particle >= count || !colliders.some(collider => collider.id === contact.colliderId) ||
|
|
394
|
-
typeof contact.penetration !== "number" || !Number.isFinite(contact.penetration) || contact.penetration <= 0))
|
|
418
|
+
typeof contact.penetration !== "number" || !Number.isFinite(contact.penetration) || contact.penetration <= 0 || (contact.reactionImpulse === undefined) !== (contact.reactionAngularImpulse === undefined) || (contact.reactionImpulse !== undefined && (!vec(contact.reactionImpulse) || !vec(contact.reactionAngularImpulse)))))
|
|
395
419
|
fail("CLOTH_SNAPSHOT", "invalid contact state");
|
|
396
420
|
for (const contact of input.contacts) {
|
|
397
421
|
const key = `${contact.particle}:${contact.colliderId}`;
|
|
@@ -416,9 +440,9 @@ export class ClothSolver {
|
|
|
416
440
|
const surfaceKeys = new Set();
|
|
417
441
|
if (!Array.isArray(surfaceContacts) || surfaceContacts.length > this.config.triangles.length * colliders.length || (!this.config.surfaceCollisions && surfaceContacts.length > 0) ||
|
|
418
442
|
surfaceContacts.some(contact => !contact || contact.tick !== input.tick || !Number.isSafeInteger(contact.triangle) || contact.triangle < 0 || contact.triangle >= this.config.triangles.length ||
|
|
419
|
-
!colliders.some(collider => collider.id === contact.colliderId
|
|
443
|
+
!colliders.some(collider => collider.id === contact.colliderId) || typeof contact.penetration !== "number" || !Number.isFinite(contact.penetration) || contact.penetration <= 0 ||
|
|
420
444
|
!Array.isArray(contact.barycentric) || contact.barycentric.length !== 3 || !contact.barycentric.every(value => typeof value === "number" && Number.isFinite(value) && value >= 0 && value <= 1) ||
|
|
421
|
-
Math.abs(contact.barycentric[0] + contact.barycentric[1] + contact.barycentric[2] - 1) > 1e-5))
|
|
445
|
+
Math.abs(contact.barycentric[0] + contact.barycentric[1] + contact.barycentric[2] - 1) > 1e-5 || (contact.reactionImpulse === undefined) !== (contact.reactionAngularImpulse === undefined) || (contact.reactionImpulse !== undefined && (!vec(contact.reactionImpulse) || !vec(contact.reactionAngularImpulse)))))
|
|
422
446
|
fail("CLOTH_SNAPSHOT", "invalid surface contact state");
|
|
423
447
|
for (const contact of surfaceContacts) {
|
|
424
448
|
const key = `${contact.triangle}:${contact.colliderId}`;
|