pelican-engine 0.3.11 → 0.3.12

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.
@@ -137,8 +137,24 @@ all earlier automatic ropes from that phase; the host tick may still have other
137
137
  partial changes and must be recovered from a known-good checkpoint. The source
138
138
  checkout includes `examples/rope-particles`.
139
139
 
140
+ For each latest-tick contact, `reactionImpulse` and `reactionAngularImpulse`
141
+ report the opposite impulse produced by the particle's position projections,
142
+ and its moment about the world origin. These optional fields are absent when
143
+ restoring older contact snapshots. They do not automatically act on a rigid body.
144
+ For an explicitly mapped dynamic body, game code can read its actual world center
145
+ of mass via `ctx.physics.centerOfMass(id)`, apply the linear impulse, and apply
146
+ `reactionAngularImpulse - centerOfMass × reactionImpulse` as torque impulse.
147
+ Apply each contact at most once for its `tick`; repeated `inspect` calls return
148
+ the same contacts. The source checkout has an illustrative `examples/rope-reaction`.
149
+
150
+ This sampled, manually applied reaction is delayed and uses positional correction,
151
+ not a coupled contact solver: it omits body velocity/effective mass, friction and
152
+ continuous collision. Applying the linear and angular impulses uses separate
153
+ operations and is not transactional if a tick fails; recover from a known-good
154
+ checkpoint. It must not be represented as complete two-way rigid-body interaction.
155
+
140
156
  This is an unverified development-stage increment, not complete rope physics:
141
- there is no built-in rope mesh, rigid-body reaction, segment or self collision,
157
+ there is no built-in rope mesh, automatic rigid-body reaction, segment or self collision,
142
158
  or tearing. Moving entities to particle positions is not a rope renderer or
143
159
  coupled physics. Plane normals must be unit length; `offset` is the signed
144
160
  normal distance from the origin. Host intervals must be between 1/1000 and
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pelican-engine",
3
- "version": "0.3.11",
3
+ "version": "0.3.12",
4
4
  "milestone": "M3",
5
5
  "stage": "advanced-rendering-development",
6
6
  "license": "MIT",
@@ -746,6 +746,9 @@ function createPlugin(context) {
746
746
  ]
747
747
  : tuple(b.body.linvel());
748
748
  },
749
+ centerOfMass(id) {
750
+ return tuple(get(id).body.worldCom());
751
+ },
749
752
  setVelocity(id, value) {
750
753
  get(id);
751
754
  record({ kind: "velocity", id, value });
@@ -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.11";
2
+ export declare const SDK_VERSION = "0.3.12";
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];
@@ -104,7 +104,7 @@ export interface SystemContext extends EngineFacade {
104
104
  readonly ropes: RopeService;
105
105
  }
106
106
  /** CPU particles with sampled sphere/plane collisions. Collision only moves rope particles;
107
- * no rigid-body reaction, segment/self collision, rendering or complete M4 physics. */
107
+ * no automatic rigid-body reaction, segment/self collision, rendering or complete physics. */
108
108
  export type RopeCollider = {
109
109
  kind: "sphere";
110
110
  id: string;
@@ -135,6 +135,17 @@ export interface RopeDefinition {
135
135
  position: Vec3;
136
136
  }[];
137
137
  }
138
+ /** Sampled-collider contact from the most recently completed rope tick. */
139
+ export interface RopeContact {
140
+ tick: number;
141
+ particle: number;
142
+ colliderId: string;
143
+ penetration: number;
144
+ /** Opposite of particle projection impulses, N·s; absent in older snapshots. */
145
+ reactionImpulse?: Vec3;
146
+ /** Angular impulse about the world origin; absent in older snapshots. */
147
+ reactionAngularImpulse?: Vec3;
148
+ }
138
149
  export interface RopeState {
139
150
  tick: number;
140
151
  simulatedSeconds: number;
@@ -145,12 +156,7 @@ export interface RopeState {
145
156
  index: number;
146
157
  position: Vec3;
147
158
  }[];
148
- contacts: {
149
- tick: number;
150
- particle: number;
151
- colliderId: string;
152
- penetration: number;
153
- }[];
159
+ contacts: RopeContact[];
154
160
  maxDistanceError: number;
155
161
  resources: {
156
162
  particles: number;
@@ -396,6 +402,8 @@ export interface PhysicsService {
396
402
  moveCharacter(entity: string, displacement: Vec3): void;
397
403
  grounded(entity: string): boolean;
398
404
  velocity(entity: string): Vec3;
405
+ /** World-space rigid-body center of mass, including collider offsets. */
406
+ centerOfMass(entity: string): Vec3;
399
407
  setVelocity(entity: string, velocity: Vec3): void;
400
408
  impulse(entity: string, impulse: Vec3): void;
401
409
  /** Angular velocity in world-space radians per second. */
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.11";
2
+ export const SDK_VERSION = "0.3.12";
3
3
  export const SYSTEM_PHASES = [
4
4
  "input",
5
5
  "prePhysics",
@@ -9,6 +9,15 @@ export class RopeError extends Error {
9
9
  const fail = (code, reason) => { throw new RopeError(code, reason); };
10
10
  const vec = (v) => Array.isArray(v) && v.length === 3 && v.every(x => typeof x === "number" && Number.isFinite(x) && Math.abs(x) <= 1e6);
11
11
  const copy = (v) => [v[0], v[1], v[2]];
12
+ const finiteVector = (v) => Array.isArray(v) && v.length === 3 && v.every(x => typeof x === "number" && Number.isFinite(x));
13
+ const copyContact = (contact) => {
14
+ const { reactionImpulse, reactionAngularImpulse, ...rest } = contact;
15
+ return {
16
+ ...rest,
17
+ ...(reactionImpulse === undefined ? {} : { reactionImpulse: copy(reactionImpulse) }),
18
+ ...(reactionAngularImpulse === undefined ? {} : { reactionAngularImpulse: copy(reactionAngularImpulse) }),
19
+ };
20
+ };
12
21
  const length = (v) => Math.hypot(v[0], v[1], v[2]);
13
22
  const validVec = (v, name) => vec(v) ? copy(v) : fail("ROPE_INPUT", `${name} must be a finite 3D vector (|component| <= 1e6)`);
14
23
  const positive = (v, name) => typeof v === "number" && Number.isFinite(v) && v > 0 && v <= 1e6 ? v : fail("ROPE_INPUT", `${name} must be positive and <= 1e6`);
@@ -220,12 +229,30 @@ export class RopeSolver {
220
229
  continue;
221
230
  for (let k = 0; k < 3; k++)
222
231
  p[k] += normal[k] * depth;
232
+ // Each positional projection changes the particle's velocity by depth / h.
233
+ // Report its equal-and-opposite impulse and moment on the sampled surface;
234
+ // the collider itself remains an immutable input and is not moved here.
235
+ const magnitude = -mass * depth / h;
236
+ const reaction = [normal[0] * magnitude, normal[1] * magnitude, normal[2] * magnitude];
237
+ const point = c.kind === "sphere"
238
+ ? [c.center[0] + normal[0] * c.radius, c.center[1] + normal[1] * c.radius, c.center[2] + normal[2] * c.radius]
239
+ : [p[0] - normal[0] * radius, p[1] - normal[1] * radius, p[2] - normal[2] * radius];
240
+ const 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]];
241
+ if (!finiteVector(reaction) || !finiteVector(moment))
242
+ fail("ROPE_NUMERIC", "contact reaction exceeded finite numeric bounds");
223
243
  const key = `${i}:${c.id}`;
224
244
  const existing = contacts.get(key);
225
- if (existing)
245
+ if (existing) {
226
246
  existing.penetration = Math.max(existing.penetration, depth);
247
+ for (let k = 0; k < 3; k++) {
248
+ existing.reactionImpulse[k] += reaction[k];
249
+ existing.reactionAngularImpulse[k] += moment[k];
250
+ }
251
+ if (!finiteVector(existing.reactionImpulse) || !finiteVector(existing.reactionAngularImpulse))
252
+ fail("ROPE_NUMERIC", "contact reaction accumulation exceeded finite numeric bounds");
253
+ }
227
254
  else
228
- contacts.set(key, { tick: this.tickValue + 1, particle: i, colliderId: c.id, penetration: depth });
255
+ contacts.set(key, { tick: this.tickValue + 1, particle: i, colliderId: c.id, penetration: depth, reactionImpulse: reaction, reactionAngularImpulse: moment });
229
256
  }
230
257
  }
231
258
  }
@@ -255,7 +282,7 @@ export class RopeSolver {
255
282
  tick: this.tickValue, simulatedSeconds: this.tickValue * this.config.fixedDt, disposed: false,
256
283
  positions: this.positions.map(copy), velocities: this.velocities.map(copy),
257
284
  anchors: [...this.anchors].map(([index, position]) => ({ index, position: copy(position) })).sort((a, b) => a.index - b.index),
258
- contacts: this.contacts.map(c => ({ ...c })), maxDistanceError,
285
+ contacts: this.contacts.map(copyContact), maxDistanceError,
259
286
  resources: { particles: this.positions.length, constraints: this.config.restLengths.length, anchors: this.anchors.size, colliders: this.colliders.length },
260
287
  };
261
288
  }
@@ -294,14 +321,16 @@ export class RopeSolver {
294
321
  input.contacts.some(contact => !contact || contact.tick !== input.tick ||
295
322
  !Number.isSafeInteger(contact.particle) || contact.particle < 0 || contact.particle >= count ||
296
323
  !colliders.some(collider => collider.id === contact.colliderId) ||
297
- typeof contact.penetration !== "number" || !Number.isFinite(contact.penetration) || contact.penetration <= 0))
324
+ typeof contact.penetration !== "number" || !Number.isFinite(contact.penetration) || contact.penetration <= 0 ||
325
+ (contact.reactionImpulse === undefined) !== (contact.reactionAngularImpulse === undefined) ||
326
+ (contact.reactionImpulse !== undefined && (!finiteVector(contact.reactionImpulse) || !finiteVector(contact.reactionAngularImpulse)))))
298
327
  fail("ROPE_SNAPSHOT", "invalid contact state");
299
328
  this.positions = input.positions.map(copy);
300
329
  this.velocities = input.velocities.map(copy);
301
330
  this.forces = input.forces.map(copy);
302
331
  this.anchors = anchors;
303
332
  this.colliders = colliders;
304
- this.contacts = input.contacts.map(c => ({ ...c }));
333
+ this.contacts = input.contacts.map(copyContact);
305
334
  this.tickValue = input.tick;
306
335
  }
307
336
  dispose() {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pelican-engine",
3
- "version": "0.3.11",
3
+ "version": "0.3.12",
4
4
  "description": "Pelican Engine public SDK and offline development CLI",
5
5
  "type": "module",
6
6
  "license": "MIT",