pelican-engine 0.3.18 → 0.3.20

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.
@@ -157,8 +157,8 @@ reports the active mode. Set `selfCollisions: false` to disable it. Adjacent
157
157
  segments and collisions between separate ropes are not handled. Immovable anchored
158
158
  endpoints stay fixed, and contacts introduced by corrections can wait until the
159
159
  next iteration. Dense overlap can approach quadratic work. Snapshots from before
160
- this feature retain the disabled mode when migrated; Host engine-version rules
161
- still apply. This is unverified discrete contact, not a guarantee against
160
+ this feature retain the disabled mode when migrated; invalid or duplicate self
161
+ contacts are rejected atomically; Host engine-version rules still apply. This is unverified discrete contact, not a guarantee against
162
162
  penetration, continuous collision, friction or full rope physics. The source
163
163
  checkout includes `examples/rope-self`.
164
164
 
@@ -233,6 +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
+ New cloth also resolves discrete self-contact between non-adjacent particles by
237
+ projecting movable particles apart; shared triangle edges are excluded.
238
+ `selfCollisions` defaults to true for new cloth and is reported by
239
+ `ctx.cloth.inspect(id)`. Set it to false for particle-only cloth. Self-contact
240
+ state is included in snapshots. This is particle self-contact, not triangle
241
+ interior/edge collision, continuous collision, friction or a guarantee against
242
+ penetration. It has not been runtime or stability validated.
243
+
236
244
  The CPU solver uses edge distance constraints and an optional distance-based
237
245
  bending approximation (`bending: true` by default). Sampled planes and spheres
238
246
  only move cloth particles; they do not receive reactions. Contacts do not cover
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pelican-engine",
3
- "version": "0.3.18",
3
+ "version": "0.3.20",
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.18";
2
+ export declare const SDK_VERSION = "0.3.20";
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];
@@ -269,6 +269,8 @@ export interface ClothDefinition {
269
269
  position: Vec3;
270
270
  }[];
271
271
  visual?: ClothVisualOptions;
272
+ /** Discrete non-adjacent particle self-contact; defaults to true for new cloth. */
273
+ selfCollisions?: boolean;
272
274
  }
273
275
  export interface ClothContact {
274
276
  tick: number;
@@ -276,10 +278,17 @@ export interface ClothContact {
276
278
  colliderId: string;
277
279
  penetration: number;
278
280
  }
281
+ export interface ClothSelfContact {
282
+ tick: number;
283
+ firstParticle: number;
284
+ secondParticle: number;
285
+ penetration: number;
286
+ }
279
287
  export interface ClothState {
280
288
  tick: number;
281
289
  simulatedSeconds: number;
282
290
  disposed: boolean;
291
+ selfCollisions: boolean;
283
292
  positions: Vec3[];
284
293
  velocities: Vec3[];
285
294
  triangles: ClothTriangle[];
@@ -288,6 +297,7 @@ export interface ClothState {
288
297
  position: Vec3;
289
298
  }[];
290
299
  contacts: ClothContact[];
300
+ selfContacts: ClothSelfContact[];
291
301
  maxDistanceError: number;
292
302
  resources: {
293
303
  particles: 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.18";
2
+ export const SDK_VERSION = "0.3.20";
3
3
  export const SYSTEM_PHASES = [
4
4
  "input",
5
5
  "prePhysics",
@@ -15,12 +15,16 @@ function planarUvs(points) {
15
15
  return fail("VISUAL_UV", "Initial points need two nonzero projection axes or explicit UVs.");
16
16
  return points.map(point => [(point[first] - min[first]) / width, (point[second] - min[second]) / height]);
17
17
  }
18
- function createEntry(input, fixedDt, materials) {
18
+ function createEntry(input, fixedDt, materials, restoredSelfMode) {
19
19
  const definition = cloneSerializable(input);
20
20
  if (!definition || !validId(definition.id))
21
21
  return fail("ID", "Expected a nonempty id of at most 128 characters.");
22
22
  if (definition.autoStep !== undefined && typeof definition.autoStep !== "boolean")
23
23
  return fail("AUTO_STEP", "autoStep must be boolean.");
24
+ if (definition.selfCollisions !== undefined && typeof definition.selfCollisions !== "boolean")
25
+ return fail("SELF_COLLISION", "selfCollisions must be boolean.");
26
+ if (restoredSelfMode !== undefined && definition.selfCollisions !== undefined && definition.selfCollisions !== restoredSelfMode)
27
+ return fail("SNAPSHOT", "Self collision configuration does not match the saved solver.");
24
28
  if (definition.fixedDt !== undefined && definition.fixedDt !== fixedDt)
25
29
  return fail("FIXED_DT", "Cloth fixedDt must match the host fixedDt.");
26
30
  if (fixedDt < 1 / 1000 || fixedDt > 1 / 30)
@@ -35,7 +39,7 @@ function createEntry(input, fixedDt, materials) {
35
39
  visual.uvs.some(uv => !Array.isArray(uv) || uv.length !== 2 || !uv.every(value => typeof value === "number" && Number.isFinite(value) && Math.abs(value) <= 1e6))))
36
40
  return fail("VISUAL_UV", "UVs must contain one finite 2D coordinate per particle.");
37
41
  }
38
- const solver = createCloth({ ...definition, fixedDt });
42
+ const solver = createCloth({ ...definition, fixedDt, ...(restoredSelfMode === undefined ? {} : { selfCollisions: restoredSelfMode }) });
39
43
  try {
40
44
  const uvs = definition.visual ? (definition.visual.uvs ? definition.visual.uvs.map(uv => [uv[0], uv[1]]) : planarUvs(definition.points)) : [];
41
45
  return { definition, solver, uvs };
@@ -55,7 +59,10 @@ function parseSnapshot(value, fixedDt, materials) {
55
59
  for (const item of value.cloths) {
56
60
  if (!item || typeof item !== "object")
57
61
  fail("SNAPSHOT", "Invalid cloth entry.");
58
- const entry = createEntry(item.definition, fixedDt, materials);
62
+ const savedSelfMode = item.state?.config?.selfCollisions;
63
+ if (savedSelfMode !== undefined && typeof savedSelfMode !== "boolean")
64
+ fail("SNAPSHOT", "Invalid self collision configuration.");
65
+ const entry = createEntry(item.definition, fixedDt, materials, savedSelfMode ?? false);
59
66
  if (entries.has(entry.definition.id)) {
60
67
  entry.solver.dispose();
61
68
  fail("SNAPSHOT", "Duplicate cloth id.");
@@ -110,12 +110,14 @@ export function createCloth(input) {
110
110
  fail("CLOTH_INPUT", "damping must be in [0,100]");
111
111
  if (input.bending !== undefined && typeof input.bending !== "boolean")
112
112
  fail("CLOTH_INPUT", "bending must be boolean");
113
- const bending = input.bending ?? true, gravity = readVec(input.gravity ?? [0, -9.81, 0], "gravity");
113
+ const bending = input.bending ?? true, selfCollisions = input.selfCollisions ?? true, gravity = readVec(input.gravity ?? [0, -9.81, 0], "gravity");
114
+ if (typeof selfCollisions !== "boolean")
115
+ fail("CLOTH_INPUT", "selfCollisions must be boolean");
114
116
  const { triangles, constraints } = topology(positions, input.triangles, bending);
115
117
  const anchors = readAnchors(input.anchors ?? [], positions.length);
116
118
  for (const [index, point] of anchors)
117
119
  positions[index] = copy(point);
118
- return new ClothSolver({ mass, radius, fixedDt, substeps, iterations, damping, gravity, bending, triangles, constraints }, positions, anchors);
120
+ return new ClothSolver({ mass, radius, fixedDt, substeps, iterations, damping, gravity, bending, selfCollisions, triangles, constraints }, positions, anchors);
119
121
  }
120
122
  export class ClothSolver {
121
123
  config;
@@ -127,12 +129,15 @@ export class ClothSolver {
127
129
  forces;
128
130
  colliders = [];
129
131
  contacts = [];
132
+ selfContacts = [];
133
+ adjacent;
130
134
  constructor(config, positions, anchors) {
131
135
  this.config = config;
132
136
  this.positions = positions;
133
137
  this.anchors = anchors;
134
138
  this.velocities = positions.map(() => [0, 0, 0]);
135
139
  this.forces = positions.map(() => [0, 0, 0]);
140
+ this.adjacent = new Set(config.triangles.flatMap(([a, b, c]) => [`${Math.min(a, b)}:${Math.max(a, b)}`, `${Math.min(b, c)}:${Math.max(b, c)}`, `${Math.min(c, a)}:${Math.max(c, a)}`]));
136
141
  }
137
142
  alive() { if (this.disposed)
138
143
  fail("CLOTH_DISPOSED", "cloth has been disposed"); }
@@ -171,7 +176,7 @@ export class ClothSolver {
171
176
  this.alive();
172
177
  if (!Number.isSafeInteger(steps) || steps < 1 || steps > 1000 || this.tickValue + steps > Number.MAX_SAFE_INTEGER)
173
178
  fail("CLOTH_INPUT", "steps must be in [1,1000] without tick overflow");
174
- const before = { positions: this.positions, velocities: this.velocities, forces: this.forces, contacts: this.contacts, tick: this.tickValue };
179
+ const before = { positions: this.positions, velocities: this.velocities, forces: this.forces, contacts: this.contacts, selfContacts: this.selfContacts, tick: this.tickValue };
175
180
  try {
176
181
  for (let i = 0; i < steps; i++)
177
182
  this.stepOne();
@@ -181,6 +186,7 @@ export class ClothSolver {
181
186
  this.velocities = before.velocities;
182
187
  this.forces = before.forces;
183
188
  this.contacts = before.contacts;
189
+ this.selfContacts = before.selfContacts;
184
190
  this.tickValue = before.tick;
185
191
  throw error;
186
192
  }
@@ -189,7 +195,7 @@ export class ClothSolver {
189
195
  stepOne() {
190
196
  const { fixedDt, substeps, iterations, mass, gravity, damping, constraints, radius } = this.config;
191
197
  const h = fixedDt / substeps, decay = Math.exp(-damping * h);
192
- const positions = this.positions.map(copy), velocities = this.velocities.map(copy), contacts = new Map();
198
+ const positions = this.positions.map(copy), velocities = this.velocities.map(copy), contacts = new Map(), selfContacts = new Map();
193
199
  for (let sub = 0; sub < substeps; sub++) {
194
200
  const previous = positions.map(copy);
195
201
  for (let i = 0; i < positions.length; i++) {
@@ -250,6 +256,29 @@ export class ClothSolver {
250
256
  contacts.set(key, { tick: this.tickValue + 1, particle: i, colliderId: collider.id, penetration: depth });
251
257
  }
252
258
  }
259
+ if (this.config.selfCollisions)
260
+ for (let i = 0; i < positions.length; i++)
261
+ for (let j = i + 1; j < positions.length; j++) {
262
+ if (this.adjacent.has(`${i}:${j}`))
263
+ continue;
264
+ const a = positions[i], b = positions[j], dx = a[0] - b[0], dy = a[1] - b[1], dz = a[2] - b[2], length = Math.hypot(dx, dy, dz), depth = 2 * radius - length;
265
+ if (depth <= 0)
266
+ continue;
267
+ const normal = length < 1e-12 ? [1, 0, 0] : [dx / length, dy / length, dz / length];
268
+ const wa = this.anchors.has(i) ? 0 : 1, wb = this.anchors.has(j) ? 0 : 1;
269
+ if (wa + wb === 0)
270
+ continue;
271
+ const correction = depth / (wa + wb);
272
+ for (let axis = 0; axis < 3; axis++) {
273
+ a[axis] += normal[axis] * correction * wa;
274
+ b[axis] -= normal[axis] * correction * wb;
275
+ }
276
+ const key = `${i}:${j}`, old = selfContacts.get(key);
277
+ if (old)
278
+ old.penetration = Math.max(old.penetration, depth);
279
+ else
280
+ selfContacts.set(key, { tick: this.tickValue + 1, firstParticle: i, secondParticle: j, penetration: depth });
281
+ }
253
282
  }
254
283
  for (let i = 0; i < positions.length; i++) {
255
284
  if (this.anchors.has(i))
@@ -264,18 +293,19 @@ export class ClothSolver {
264
293
  this.velocities = velocities;
265
294
  this.forces = positions.map(() => [0, 0, 0]);
266
295
  this.contacts = [...contacts.values()];
296
+ this.selfContacts = [...selfContacts.values()];
267
297
  this.tickValue++;
268
298
  }
269
299
  inspect() {
270
300
  if (this.disposed)
271
- return { tick: this.tickValue, simulatedSeconds: this.tickValue * this.config.fixedDt, disposed: true, positions: [], velocities: [], triangles: [], anchors: [], contacts: [], maxDistanceError: 0, resources: { particles: 0, triangles: 0, constraints: 0, anchors: 0, colliders: 0 } };
301
+ return { tick: this.tickValue, simulatedSeconds: this.tickValue * this.config.fixedDt, disposed: true, selfCollisions: this.config.selfCollisions, positions: [], velocities: [], triangles: [], anchors: [], contacts: [], selfContacts: [], maxDistanceError: 0, resources: { particles: 0, triangles: 0, constraints: 0, anchors: 0, colliders: 0 } };
272
302
  let maxDistanceError = 0;
273
303
  for (const [a, b, rest] of this.config.constraints)
274
304
  maxDistanceError = Math.max(maxDistanceError, Math.abs(distance(this.positions[a], this.positions[b]) - rest));
275
- return { tick: this.tickValue, simulatedSeconds: this.tickValue * this.config.fixedDt, disposed: false,
305
+ return { tick: this.tickValue, simulatedSeconds: this.tickValue * this.config.fixedDt, disposed: false, selfCollisions: this.config.selfCollisions,
276
306
  positions: this.positions.map(copy), velocities: this.velocities.map(copy), triangles: this.config.triangles.map(tuple),
277
307
  anchors: [...this.anchors].map(([index, position]) => ({ index, position: copy(position) })).sort((a, b) => a.index - b.index),
278
- contacts: this.contacts.map(contact => ({ ...contact })), maxDistanceError,
308
+ contacts: this.contacts.map(contact => ({ ...contact })), selfContacts: this.selfContacts.map(contact => ({ ...contact })), maxDistanceError,
279
309
  resources: { particles: this.positions.length, triangles: this.config.triangles.length, constraints: this.config.constraints.length, anchors: this.anchors.size, colliders: this.colliders.length } };
280
310
  }
281
311
  snapshot() {
@@ -283,7 +313,7 @@ export class ClothSolver {
283
313
  const state = this.inspect();
284
314
  return { format: "pelican-cloth-cpu", version: 1, tick: this.tickValue,
285
315
  config: { ...this.config, gravity: copy(this.config.gravity), triangles: this.config.triangles.map(tuple), constraints: this.config.constraints.map(tuple) },
286
- positions: state.positions, velocities: state.velocities, forces: this.forces.map(copy), anchors: state.anchors, colliders: readColliders(this.colliders), contacts: state.contacts };
316
+ positions: state.positions, velocities: state.velocities, forces: this.forces.map(copy), anchors: state.anchors, colliders: readColliders(this.colliders), contacts: state.contacts, selfContacts: state.selfContacts };
287
317
  }
288
318
  restore(input) {
289
319
  this.alive();
@@ -291,7 +321,7 @@ export class ClothSolver {
291
321
  fail("CLOTH_SNAPSHOT", "unsupported snapshot format or tick");
292
322
  const config = input.config, expected = this.config;
293
323
  if (!config || config.mass !== expected.mass || config.radius !== expected.radius || config.fixedDt !== expected.fixedDt || config.substeps !== expected.substeps ||
294
- config.iterations !== expected.iterations || config.damping !== expected.damping || config.bending !== expected.bending ||
324
+ config.iterations !== expected.iterations || config.damping !== expected.damping || config.bending !== expected.bending || (config.selfCollisions ?? false) !== expected.selfCollisions ||
295
325
  !vec(config.gravity) || config.gravity.some((value, i) => value !== expected.gravity[i]) ||
296
326
  !Array.isArray(config.triangles) || JSON.stringify(config.triangles) !== JSON.stringify(expected.triangles) ||
297
327
  !Array.isArray(config.constraints) || JSON.stringify(config.constraints) !== JSON.stringify(expected.constraints))
@@ -314,14 +344,28 @@ export class ClothSolver {
314
344
  fail("CLOTH_SNAPSHOT", "duplicate contact");
315
345
  keys.add(key);
316
346
  }
347
+ const selfContacts = input.selfContacts ?? [];
348
+ const selfKeys = new Set();
349
+ if (!Array.isArray(selfContacts) || selfContacts.length > count * Math.max(0, count - 1) / 2 || (!this.config.selfCollisions && selfContacts.length > 0) ||
350
+ selfContacts.some(contact => !contact || contact.tick !== input.tick || !Number.isSafeInteger(contact.firstParticle) || !Number.isSafeInteger(contact.secondParticle) ||
351
+ contact.firstParticle < 0 || contact.secondParticle >= count || contact.firstParticle >= contact.secondParticle ||
352
+ this.adjacent.has(`${contact.firstParticle}:${contact.secondParticle}`) || typeof contact.penetration !== "number" || !Number.isFinite(contact.penetration) || contact.penetration <= 0))
353
+ fail("CLOTH_SNAPSHOT", "invalid self contact state");
354
+ for (const contact of selfContacts) {
355
+ const key = `${contact.firstParticle}:${contact.secondParticle}`;
356
+ if (selfKeys.has(key))
357
+ fail("CLOTH_SNAPSHOT", "duplicate self contact");
358
+ selfKeys.add(key);
359
+ }
317
360
  this.positions = input.positions.map(copy);
318
361
  this.velocities = input.velocities.map(copy);
319
362
  this.forces = input.forces.map(copy);
320
363
  this.anchors = anchors;
321
364
  this.colliders = colliders;
322
365
  this.contacts = input.contacts.map(contact => ({ ...contact }));
366
+ this.selfContacts = selfContacts.map(contact => ({ ...contact }));
323
367
  this.tickValue = input.tick;
324
368
  }
325
369
  dispose() { if (this.disposed)
326
- return; this.disposed = true; this.positions = []; this.velocities = []; this.forces = []; this.colliders = []; this.contacts = []; this.anchors.clear(); }
370
+ return; this.disposed = true; this.positions = []; this.velocities = []; this.forces = []; this.colliders = []; this.contacts = []; this.selfContacts = []; this.anchors.clear(); }
327
371
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pelican-engine",
3
- "version": "0.3.18",
3
+ "version": "0.3.20",
4
4
  "description": "Pelican Engine public SDK and offline development CLI",
5
5
  "type": "module",
6
6
  "license": "MIT",