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.
@@ -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
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pelican-engine",
3
- "version": "0.3.14",
3
+ "version": "0.3.16",
4
4
  "description": "Pelican Engine public SDK and offline development CLI",
5
5
  "type": "module",
6
6
  "license": "MIT",