pelican-engine 0.3.24 → 0.3.26

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,12 @@ 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.
237
242
 
238
243
  New cloth also resolves detected sphere overlaps against triangle surface interiors
239
244
  using closest-point barycentric projection. `inspect(id).surfaceContacts` reports
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pelican-engine",
3
- "version": "0.3.24",
3
+ "version": "0.3.26",
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.24";
2
+ export declare const SDK_VERSION = "0.3.26";
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.24";
2
+ export const SDK_VERSION = "0.3.26";
3
3
  export const SYSTEM_PHASES = [
4
4
  "input",
5
5
  "prePhysics",
@@ -1,3 +1,4 @@
1
+ import { resolveEntityDefinition } from "../runtime/prefabs.js";
1
2
  import { cloneSerializable } from "../runtime/random.js";
2
3
  import { createCloth } from "./cloth.js";
3
4
  const fail = (code, detail) => { throw new Error(`TC_CLOTH_${code}: ${detail}`); };
@@ -21,6 +22,10 @@ function createEntry(input, fixedDt, materials, restoredSelfMode, restoredSurfac
21
22
  return fail("ID", "Expected a nonempty id of at most 128 characters.");
22
23
  if (definition.autoStep !== undefined && typeof definition.autoStep !== "boolean")
23
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.");
24
29
  if (definition.bodyColliders !== undefined) {
25
30
  if (!Array.isArray(definition.bodyColliders) || definition.bodyColliders.length > 64)
26
31
  return fail("BODY_COLLIDER", "bodyColliders must contain at most 64 bindings.");
@@ -36,6 +41,8 @@ function createEntry(input, fixedDt, materials, restoredSelfMode, restoredSurfac
36
41
  }
37
42
  if (definition.selfCollisions !== undefined && typeof definition.selfCollisions !== "boolean")
38
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.");
39
46
  if (definition.surfaceCollisions !== undefined && typeof definition.surfaceCollisions !== "boolean")
40
47
  return fail("SURFACE_COLLISION", "surfaceCollisions must be boolean.");
41
48
  if (restoredSurfaceMode !== undefined && definition.surfaceCollisions !== undefined && definition.surfaceCollisions !== restoredSurfaceMode)
@@ -68,6 +75,22 @@ function createEntry(input, fixedDt, materials, restoredSelfMode, restoredSurfac
68
75
  }
69
76
  function disposeEntries(entries) { for (const entry of entries.values())
70
77
  entry.solver.dispose(); entries.clear(); }
78
+ function validateBodyBindings(entries, context, world) {
79
+ const records = world ? new Map(world.entities.map(record => [record.id, record])) : undefined;
80
+ const bridge = [...entries].some(entry => (entry.definition.bodyColliders?.length ?? 0) > 0)
81
+ ? context.getRopePhysicsBridge?.() : undefined;
82
+ for (const entry of entries)
83
+ for (const binding of entry.definition.bodyColliders ?? []) {
84
+ const record = records?.get(binding.entity);
85
+ if (records && (!record || !resolveEntityDefinition(context.scene, record)?.rigidBody))
86
+ fail("SNAPSHOT", `Bound body '${binding.entity}' is missing from the restored world.`);
87
+ if (!context.world.isAlive(binding.entity))
88
+ fail("BODY_COLLIDER", `Body '${binding.entity}' is not a live entity.`);
89
+ if (!bridge)
90
+ fail("BODY_COLLIDER", "Body bindings require the physics plugin.");
91
+ bridge.sampleSphere(binding.entity);
92
+ }
93
+ }
71
94
  function parseSnapshot(value, fixedDt, materials) {
72
95
  if (!value || typeof value !== "object" || value.version !== 1 || !Array.isArray(value.cloths) || value.cloths.length > 256)
73
96
  return fail("SNAPSHOT", "Expected a version 1 snapshot containing at most 256 cloths.");
@@ -118,12 +141,7 @@ export const createClothPlugin = context => {
118
141
  return fail("CAPACITY", "At most 256 cloths per host.");
119
142
  const entry = createEntry(definition, hostDt, materials);
120
143
  try {
121
- const bridge = entry.definition.bodyColliders?.length ? context.getRopePhysicsBridge?.() : undefined;
122
- for (const binding of entry.definition.bodyColliders ?? []) {
123
- if (!context.world.isAlive(binding.entity))
124
- return fail("BODY_COLLIDER", `Body '${binding.entity}' is not a live entity.`);
125
- bridge?.sampleSphere(binding.entity);
126
- }
144
+ validateBodyBindings([entry], context);
127
145
  entries.set(entry.definition.id, entry);
128
146
  }
129
147
  catch (error) {
@@ -179,7 +197,25 @@ export const createClothPlugin = context => {
179
197
  entry.definition = { ...entry.definition, bodyColliders: live };
180
198
  entry.solver.setColliders([...manual, ...sampled]);
181
199
  }
182
- 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
+ }
183
219
  }
184
220
  }
185
221
  catch (error) {
@@ -200,7 +236,28 @@ export const createClothPlugin = context => {
200
236
  snapshot: () => ({ version: 1, cloths: [...entries.values()].sort((a, b) => a.definition.id < b.definition.id ? -1 : a.definition.id > b.definition.id ? 1 : 0)
201
237
  .map(({ definition, solver }) => ({ definition: cloneSerializable(definition), state: solver.snapshot() })) }),
202
238
  validateSnapshot(value) { const staged = parseSnapshot(value, hostDt, materials); disposeEntries(staged); },
203
- restore(value) { alive(); const staged = parseSnapshot(value, hostDt, materials); disposeEntries(entries); entries = staged; },
239
+ validateWorld(value, world) {
240
+ const staged = parseSnapshot(value, hostDt, materials);
241
+ try {
242
+ validateBodyBindings(staged.values(), context, world);
243
+ }
244
+ finally {
245
+ disposeEntries(staged);
246
+ }
247
+ },
248
+ restore(value) {
249
+ alive();
250
+ const staged = parseSnapshot(value, hostDt, materials);
251
+ try {
252
+ validateBodyBindings(staged.values(), context);
253
+ }
254
+ catch (error) {
255
+ disposeEntries(staged);
256
+ throw error;
257
+ }
258
+ disposeEntries(entries);
259
+ entries = staged;
260
+ },
204
261
  dispose() { if (disposed)
205
262
  return; disposed = true; disposeEntries(entries); },
206
263
  };
@@ -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,11 +281,18 @@ 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)
@@ -303,11 +311,17 @@ export class ClothSolver {
303
311
  for (let vertex = 0; vertex < 3; vertex++)
304
312
  for (let axis = 0; axis < 3; axis++)
305
313
  positions[triangle[vertex]][axis] += normal[axis] * correction * weights[vertex];
314
+ const magnitude = -mass * depth / h, reactionImpulse = [normal[0] * magnitude, normal[1] * magnitude, normal[2] * magnitude], reactionAngularImpulse = cross(closest.point, reactionImpulse);
306
315
  const key = `${triangleIndex}:${collider.id}`, old = surfaceContacts.get(key);
307
- if (old)
316
+ if (old) {
308
317
  old.penetration = Math.max(old.penetration, depth);
318
+ for (let k = 0; k < 3; k++) {
319
+ old.reactionImpulse[k] += reactionImpulse[k];
320
+ old.reactionAngularImpulse[k] += reactionAngularImpulse[k];
321
+ }
322
+ }
309
323
  else
310
- surfaceContacts.set(key, { tick: this.tickValue + 1, triangle: triangleIndex, colliderId: collider.id, penetration: depth, barycentric: closest.barycentric });
324
+ surfaceContacts.set(key, { tick: this.tickValue + 1, triangle: triangleIndex, colliderId: collider.id, penetration: depth, barycentric: closest.barycentric, reactionImpulse, reactionAngularImpulse });
311
325
  }
312
326
  }
313
327
  if (this.config.selfCollisions)
@@ -391,7 +405,7 @@ export class ClothSolver {
391
405
  const colliders = readColliders(input.colliders), keys = new Set();
392
406
  if (!Array.isArray(input.contacts) || input.contacts.length > count * colliders.length || input.contacts.some(contact => !contact || contact.tick !== input.tick ||
393
407
  !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))
408
+ 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
409
  fail("CLOTH_SNAPSHOT", "invalid contact state");
396
410
  for (const contact of input.contacts) {
397
411
  const key = `${contact.particle}:${contact.colliderId}`;
@@ -418,7 +432,7 @@ export class ClothSolver {
418
432
  surfaceContacts.some(contact => !contact || contact.tick !== input.tick || !Number.isSafeInteger(contact.triangle) || contact.triangle < 0 || contact.triangle >= this.config.triangles.length ||
419
433
  !colliders.some(collider => collider.id === contact.colliderId && collider.kind === "sphere") || typeof contact.penetration !== "number" || !Number.isFinite(contact.penetration) || contact.penetration <= 0 ||
420
434
  !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))
435
+ 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
436
  fail("CLOTH_SNAPSHOT", "invalid surface contact state");
423
437
  for (const contact of surfaceContacts) {
424
438
  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.24",
3
+ "version": "0.3.26",
4
4
  "description": "Pelican Engine public SDK and offline development CLI",
5
5
  "type": "module",
6
6
  "license": "MIT",