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.
@@ -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. The body receives no cloth reaction impulse. Destroyed bodies are removed on the next automatic step. This is not complete cloth-rigid-body coupling.
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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pelican-engine",
3
- "version": "0.3.25",
3
+ "version": "0.3.27",
4
4
  "milestone": "M3",
5
5
  "stage": "advanced-rendering-development",
6
6
  "license": "MIT",
@@ -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.25";
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, one-way only. */
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
@@ -1,5 +1,5 @@
1
1
  /** Pelican Engine public API. No renderer or GPU implementation types escape this module. */
2
- export const SDK_VERSION = "0.3.25";
2
+ export const SDK_VERSION = "0.3.27";
3
3
  export const SYSTEM_PHASES = [
4
4
  "input",
5
5
  "prePhysics",
@@ -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) {
@@ -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 key = `${i}:${collider.id}`, old = contacts.get(key);
284
- if (old)
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
- if (collider.kind !== "sphere")
295
- continue;
296
- const closest = closestTriangle(collider.center, a, b, c), delta = [closest.point[0] - collider.center[0], closest.point[1] - collider.center[1], closest.point[2] - collider.center[2]], length = Math.hypot(...delta), depth = collider.radius + radius - length;
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 normal = length < 1e-12 ? [0, 1, 0] : [delta[0] / length, delta[1] / length, delta[2] / length], 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);
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 && collider.kind === "sphere") || typeof contact.penetration !== "number" || !Number.isFinite(contact.penetration) || contact.penetration <= 0 ||
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}`;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pelican-engine",
3
- "version": "0.3.25",
3
+ "version": "0.3.27",
4
4
  "description": "Pelican Engine public SDK and offline development CLI",
5
5
  "type": "module",
6
6
  "license": "MIT",