pelican-engine 0.3.21 → 0.3.22

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,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 detected sphere overlaps against triangle surface interiors
237
+ using closest-point barycentric projection. `inspect(id).surfaceContacts` reports
238
+ the triangle and barycentric position. `surfaceCollisions` defaults to true for new
239
+ cloth and can be disabled. This is sphere-only discrete surface contact; planes
240
+ remain particle-only, and there is no edge/face continuous collision or rigid-body
241
+ reaction. Surface contact state is included in snapshots and invalid state is
242
+ rejected atomically. It has not been runtime or stability validated.
243
+
236
244
  New cloth also resolves discrete self-contact between non-adjacent particles by
237
245
  projecting movable particles apart; shared triangle edges are excluded.
238
246
  `selfCollisions` defaults to true for new cloth and is reported by
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pelican-engine",
3
- "version": "0.3.21",
3
+ "version": "0.3.22",
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.21";
2
+ export declare const SDK_VERSION = "0.3.22";
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];
@@ -271,6 +271,8 @@ export interface ClothDefinition {
271
271
  visual?: ClothVisualOptions;
272
272
  /** Discrete non-adjacent particle self-contact; defaults to true for new cloth. */
273
273
  selfCollisions?: boolean;
274
+ /** Sphere triangle-surface contacts; defaults to true for new cloth. */
275
+ surfaceCollisions?: boolean;
274
276
  }
275
277
  export interface ClothContact {
276
278
  tick: number;
@@ -284,11 +286,19 @@ export interface ClothSelfContact {
284
286
  secondParticle: number;
285
287
  penetration: number;
286
288
  }
289
+ export interface ClothSurfaceContact {
290
+ tick: number;
291
+ triangle: number;
292
+ colliderId: string;
293
+ penetration: number;
294
+ barycentric: [number, number, number];
295
+ }
287
296
  export interface ClothState {
288
297
  tick: number;
289
298
  simulatedSeconds: number;
290
299
  disposed: boolean;
291
300
  selfCollisions: boolean;
301
+ surfaceCollisions: boolean;
292
302
  positions: Vec3[];
293
303
  velocities: Vec3[];
294
304
  triangles: ClothTriangle[];
@@ -298,6 +308,7 @@ export interface ClothState {
298
308
  }[];
299
309
  contacts: ClothContact[];
300
310
  selfContacts: ClothSelfContact[];
311
+ surfaceContacts: ClothSurfaceContact[];
301
312
  maxDistanceError: number;
302
313
  resources: {
303
314
  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.21";
2
+ export const SDK_VERSION = "0.3.22";
3
3
  export const SYSTEM_PHASES = [
4
4
  "input",
5
5
  "prePhysics",
@@ -15,7 +15,7 @@ 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, restoredSelfMode) {
18
+ function createEntry(input, fixedDt, materials, restoredSelfMode, restoredSurfaceMode) {
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.");
@@ -23,6 +23,10 @@ function createEntry(input, fixedDt, materials, restoredSelfMode) {
23
23
  return fail("AUTO_STEP", "autoStep must be boolean.");
24
24
  if (definition.selfCollisions !== undefined && typeof definition.selfCollisions !== "boolean")
25
25
  return fail("SELF_COLLISION", "selfCollisions must be boolean.");
26
+ if (definition.surfaceCollisions !== undefined && typeof definition.surfaceCollisions !== "boolean")
27
+ return fail("SURFACE_COLLISION", "surfaceCollisions must be boolean.");
28
+ if (restoredSurfaceMode !== undefined && definition.surfaceCollisions !== undefined && definition.surfaceCollisions !== restoredSurfaceMode)
29
+ return fail("SNAPSHOT", "Surface collision configuration does not match the saved solver.");
26
30
  if (restoredSelfMode !== undefined && definition.selfCollisions !== undefined && definition.selfCollisions !== restoredSelfMode)
27
31
  return fail("SNAPSHOT", "Self collision configuration does not match the saved solver.");
28
32
  if (definition.fixedDt !== undefined && definition.fixedDt !== fixedDt)
@@ -39,7 +43,7 @@ function createEntry(input, fixedDt, materials, restoredSelfMode) {
39
43
  visual.uvs.some(uv => !Array.isArray(uv) || uv.length !== 2 || !uv.every(value => typeof value === "number" && Number.isFinite(value) && Math.abs(value) <= 1e6))))
40
44
  return fail("VISUAL_UV", "UVs must contain one finite 2D coordinate per particle.");
41
45
  }
42
- const solver = createCloth({ ...definition, fixedDt, ...(restoredSelfMode === undefined ? {} : { selfCollisions: restoredSelfMode }) });
46
+ const solver = createCloth({ ...definition, fixedDt, ...(restoredSelfMode === undefined ? {} : { selfCollisions: restoredSelfMode }), ...(restoredSurfaceMode === undefined ? {} : { surfaceCollisions: restoredSurfaceMode }) });
43
47
  try {
44
48
  const uvs = definition.visual ? (definition.visual.uvs ? definition.visual.uvs.map(uv => [uv[0], uv[1]]) : planarUvs(definition.points)) : [];
45
49
  return { definition, solver, uvs };
@@ -62,7 +66,10 @@ function parseSnapshot(value, fixedDt, materials) {
62
66
  const savedSelfMode = item.state?.config?.selfCollisions;
63
67
  if (savedSelfMode !== undefined && typeof savedSelfMode !== "boolean")
64
68
  fail("SNAPSHOT", "Invalid self collision configuration.");
65
- const entry = createEntry(item.definition, fixedDt, materials, savedSelfMode ?? false);
69
+ const savedSurfaceMode = item.state?.config?.surfaceCollisions;
70
+ if (savedSurfaceMode !== undefined && typeof savedSurfaceMode !== "boolean")
71
+ fail("SNAPSHOT", "Invalid surface collision configuration.");
72
+ const entry = createEntry(item.definition, fixedDt, materials, savedSelfMode ?? false, savedSurfaceMode ?? false);
66
73
  if (entries.has(entry.definition.id)) {
67
74
  entry.solver.dispose();
68
75
  fail("SNAPSHOT", "Duplicate cloth id.");
@@ -96,6 +96,35 @@ function topology(points, input, bending) {
96
96
  add(edge.opposite[0], edge.opposite[1]);
97
97
  return { triangles, constraints };
98
98
  }
99
+ function closestTriangle(point, a, b, c) {
100
+ const ab = [b[0] - a[0], b[1] - a[1], b[2] - a[2]], ac = [c[0] - a[0], c[1] - a[1], c[2] - a[2]], ap = [point[0] - a[0], point[1] - a[1], point[2] - a[2]];
101
+ const d1 = ab[0] * ap[0] + ab[1] * ap[1] + ab[2] * ap[2], d2 = ac[0] * ap[0] + ac[1] * ap[1] + ac[2] * ap[2];
102
+ if (d1 <= 0 && d2 <= 0)
103
+ return { point: copy(a), barycentric: [1, 0, 0] };
104
+ const bp = [point[0] - b[0], point[1] - b[1], point[2] - b[2]], d3 = ab[0] * bp[0] + ab[1] * bp[1] + ab[2] * bp[2], d4 = ac[0] * bp[0] + ac[1] * bp[1] + ac[2] * bp[2];
105
+ if (d3 >= 0 && d4 <= d3)
106
+ return { point: copy(b), barycentric: [0, 1, 0] };
107
+ const vc = d1 * d4 - d3 * d2;
108
+ if (vc <= 0 && d1 >= 0 && d3 <= 0) {
109
+ const t = d1 / (d1 - d3);
110
+ return { point: [a[0] + ab[0] * t, a[1] + ab[1] * t, a[2] + ab[2] * t], barycentric: [1 - t, t, 0] };
111
+ }
112
+ const cp = [point[0] - c[0], point[1] - c[1], point[2] - c[2]], d5 = ab[0] * cp[0] + ab[1] * cp[1] + ab[2] * cp[2], d6 = ac[0] * cp[0] + ac[1] * cp[1] + ac[2] * cp[2];
113
+ if (d6 >= 0 && d5 <= d6)
114
+ return { point: copy(c), barycentric: [0, 0, 1] };
115
+ const vb = d5 * d2 - d1 * d6;
116
+ if (vb <= 0 && d2 >= 0 && d6 <= 0) {
117
+ const t = d2 / (d2 - d6);
118
+ return { point: [a[0] + ac[0] * t, a[1] + ac[1] * t, a[2] + ac[2] * t], barycentric: [1 - t, 0, t] };
119
+ }
120
+ const va = d3 * d6 - d5 * d4;
121
+ if (va <= 0 && (d4 - d3) >= 0 && (d5 - d6) >= 0) {
122
+ const edge = [c[0] - b[0], c[1] - b[1], c[2] - b[2]], t = (d4 - d3) / ((d4 - d3) + (d5 - d6));
123
+ return { point: [b[0] + edge[0] * t, b[1] + edge[1] * t, b[2] + edge[2] * t], barycentric: [0, 1 - t, t] };
124
+ }
125
+ const denominator = 1 / (va + vb + vc), v = vb * denominator, w = vc * denominator;
126
+ return { point: [a[0] + ab[0] * v + ac[0] * w, a[1] + ab[1] * v + ac[1] * w, a[2] + ab[2] * v + ac[2] * w], barycentric: [1 - v - w, v, w] };
127
+ }
99
128
  export function createCloth(input) {
100
129
  if (!input || typeof input !== "object" || !Array.isArray(input.points) || input.points.length < 3 || input.points.length > 4096)
101
130
  fail("CLOTH_INPUT", "points must contain 3..4096 particles");
@@ -110,14 +139,14 @@ export function createCloth(input) {
110
139
  fail("CLOTH_INPUT", "damping must be in [0,100]");
111
140
  if (input.bending !== undefined && typeof input.bending !== "boolean")
112
141
  fail("CLOTH_INPUT", "bending must be boolean");
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");
142
+ const bending = input.bending ?? true, selfCollisions = input.selfCollisions ?? true, surfaceCollisions = input.surfaceCollisions ?? true, gravity = readVec(input.gravity ?? [0, -9.81, 0], "gravity");
143
+ if (typeof selfCollisions !== "boolean" || typeof surfaceCollisions !== "boolean")
144
+ fail("CLOTH_INPUT", "collision options must be boolean");
116
145
  const { triangles, constraints } = topology(positions, input.triangles, bending);
117
146
  const anchors = readAnchors(input.anchors ?? [], positions.length);
118
147
  for (const [index, point] of anchors)
119
148
  positions[index] = copy(point);
120
- return new ClothSolver({ mass, radius, fixedDt, substeps, iterations, damping, gravity, bending, selfCollisions, triangles, constraints }, positions, anchors);
149
+ return new ClothSolver({ mass, radius, fixedDt, substeps, iterations, damping, gravity, bending, selfCollisions, surfaceCollisions, triangles, constraints }, positions, anchors);
121
150
  }
122
151
  export class ClothSolver {
123
152
  config;
@@ -130,6 +159,7 @@ export class ClothSolver {
130
159
  colliders = [];
131
160
  contacts = [];
132
161
  selfContacts = [];
162
+ surfaceContacts = [];
133
163
  adjacent;
134
164
  constructor(config, positions, anchors) {
135
165
  this.config = config;
@@ -176,7 +206,7 @@ export class ClothSolver {
176
206
  this.alive();
177
207
  if (!Number.isSafeInteger(steps) || steps < 1 || steps > 1000 || this.tickValue + steps > Number.MAX_SAFE_INTEGER)
178
208
  fail("CLOTH_INPUT", "steps must be in [1,1000] without tick overflow");
179
- const before = { positions: this.positions, velocities: this.velocities, forces: this.forces, contacts: this.contacts, selfContacts: this.selfContacts, tick: this.tickValue };
209
+ const before = { positions: this.positions, velocities: this.velocities, forces: this.forces, contacts: this.contacts, selfContacts: this.selfContacts, surfaceContacts: this.surfaceContacts, tick: this.tickValue };
180
210
  try {
181
211
  for (let i = 0; i < steps; i++)
182
212
  this.stepOne();
@@ -187,6 +217,7 @@ export class ClothSolver {
187
217
  this.forces = before.forces;
188
218
  this.contacts = before.contacts;
189
219
  this.selfContacts = before.selfContacts;
220
+ this.surfaceContacts = before.surfaceContacts;
190
221
  this.tickValue = before.tick;
191
222
  throw error;
192
223
  }
@@ -195,7 +226,7 @@ export class ClothSolver {
195
226
  stepOne() {
196
227
  const { fixedDt, substeps, iterations, mass, gravity, damping, constraints, radius } = this.config;
197
228
  const h = fixedDt / substeps, decay = Math.exp(-damping * h);
198
- const positions = this.positions.map(copy), velocities = this.velocities.map(copy), contacts = new Map(), selfContacts = new Map();
229
+ const positions = this.positions.map(copy), velocities = this.velocities.map(copy), contacts = new Map(), selfContacts = new Map(), surfaceContacts = new Map();
199
230
  for (let sub = 0; sub < substeps; sub++) {
200
231
  const previous = positions.map(copy);
201
232
  for (let i = 0; i < positions.length; i++) {
@@ -256,6 +287,29 @@ export class ClothSolver {
256
287
  contacts.set(key, { tick: this.tickValue + 1, particle: i, colliderId: collider.id, penetration: depth });
257
288
  }
258
289
  }
290
+ if (this.config.surfaceCollisions)
291
+ for (let triangleIndex = 0; triangleIndex < this.config.triangles.length; triangleIndex++) {
292
+ const triangle = this.config.triangles[triangleIndex], a = positions[triangle[0]], b = positions[triangle[1]], c = positions[triangle[2]];
293
+ 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;
297
+ if (depth <= 0)
298
+ 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);
300
+ if (denominator === 0)
301
+ continue;
302
+ const correction = depth / denominator;
303
+ for (let vertex = 0; vertex < 3; vertex++)
304
+ for (let axis = 0; axis < 3; axis++)
305
+ positions[triangle[vertex]][axis] += normal[axis] * correction * weights[vertex];
306
+ const key = `${triangleIndex}:${collider.id}`, old = surfaceContacts.get(key);
307
+ if (old)
308
+ old.penetration = Math.max(old.penetration, depth);
309
+ else
310
+ surfaceContacts.set(key, { tick: this.tickValue + 1, triangle: triangleIndex, colliderId: collider.id, penetration: depth, barycentric: closest.barycentric });
311
+ }
312
+ }
259
313
  if (this.config.selfCollisions)
260
314
  for (let i = 0; i < positions.length; i++)
261
315
  for (let j = i + 1; j < positions.length; j++) {
@@ -294,18 +348,19 @@ export class ClothSolver {
294
348
  this.forces = positions.map(() => [0, 0, 0]);
295
349
  this.contacts = [...contacts.values()];
296
350
  this.selfContacts = [...selfContacts.values()];
351
+ this.surfaceContacts = [...surfaceContacts.values()];
297
352
  this.tickValue++;
298
353
  }
299
354
  inspect() {
300
355
  if (this.disposed)
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 } };
356
+ return { tick: this.tickValue, simulatedSeconds: this.tickValue * this.config.fixedDt, disposed: true, selfCollisions: this.config.selfCollisions, surfaceCollisions: this.config.surfaceCollisions, positions: [], velocities: [], triangles: [], anchors: [], contacts: [], selfContacts: [], surfaceContacts: [], maxDistanceError: 0, resources: { particles: 0, triangles: 0, constraints: 0, anchors: 0, colliders: 0 } };
302
357
  let maxDistanceError = 0;
303
358
  for (const [a, b, rest] of this.config.constraints)
304
359
  maxDistanceError = Math.max(maxDistanceError, Math.abs(distance(this.positions[a], this.positions[b]) - rest));
305
- return { tick: this.tickValue, simulatedSeconds: this.tickValue * this.config.fixedDt, disposed: false, selfCollisions: this.config.selfCollisions,
360
+ return { tick: this.tickValue, simulatedSeconds: this.tickValue * this.config.fixedDt, disposed: false, selfCollisions: this.config.selfCollisions, surfaceCollisions: this.config.surfaceCollisions,
306
361
  positions: this.positions.map(copy), velocities: this.velocities.map(copy), triangles: this.config.triangles.map(tuple),
307
362
  anchors: [...this.anchors].map(([index, position]) => ({ index, position: copy(position) })).sort((a, b) => a.index - b.index),
308
- contacts: this.contacts.map(contact => ({ ...contact })), selfContacts: this.selfContacts.map(contact => ({ ...contact })), maxDistanceError,
363
+ contacts: this.contacts.map(contact => ({ ...contact })), selfContacts: this.selfContacts.map(contact => ({ ...contact })), surfaceContacts: this.surfaceContacts.map(contact => ({ ...contact })), maxDistanceError,
309
364
  resources: { particles: this.positions.length, triangles: this.config.triangles.length, constraints: this.config.constraints.length, anchors: this.anchors.size, colliders: this.colliders.length } };
310
365
  }
311
366
  snapshot() {
@@ -313,7 +368,7 @@ export class ClothSolver {
313
368
  const state = this.inspect();
314
369
  return { format: "pelican-cloth-cpu", version: 1, tick: this.tickValue,
315
370
  config: { ...this.config, gravity: copy(this.config.gravity), triangles: this.config.triangles.map(tuple), constraints: this.config.constraints.map(tuple) },
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 };
371
+ positions: state.positions, velocities: state.velocities, forces: this.forces.map(copy), anchors: state.anchors, colliders: readColliders(this.colliders), contacts: state.contacts, selfContacts: state.selfContacts, surfaceContacts: state.surfaceContacts };
317
372
  }
318
373
  restore(input) {
319
374
  this.alive();
@@ -321,7 +376,7 @@ export class ClothSolver {
321
376
  fail("CLOTH_SNAPSHOT", "unsupported snapshot format or tick");
322
377
  const config = input.config, expected = this.config;
323
378
  if (!config || config.mass !== expected.mass || config.radius !== expected.radius || config.fixedDt !== expected.fixedDt || config.substeps !== expected.substeps ||
324
- config.iterations !== expected.iterations || config.damping !== expected.damping || config.bending !== expected.bending || (config.selfCollisions ?? false) !== expected.selfCollisions ||
379
+ config.iterations !== expected.iterations || config.damping !== expected.damping || config.bending !== expected.bending || (config.selfCollisions ?? false) !== expected.selfCollisions || (config.surfaceCollisions ?? false) !== expected.surfaceCollisions ||
325
380
  !vec(config.gravity) || config.gravity.some((value, i) => value !== expected.gravity[i]) ||
326
381
  !Array.isArray(config.triangles) || JSON.stringify(config.triangles) !== JSON.stringify(expected.triangles) ||
327
382
  !Array.isArray(config.constraints) || JSON.stringify(config.constraints) !== JSON.stringify(expected.constraints))
@@ -357,6 +412,20 @@ export class ClothSolver {
357
412
  fail("CLOTH_SNAPSHOT", "duplicate self contact");
358
413
  selfKeys.add(key);
359
414
  }
415
+ const surfaceContacts = input.surfaceContacts ?? [];
416
+ const surfaceKeys = new Set();
417
+ if (!Array.isArray(surfaceContacts) || surfaceContacts.length > this.config.triangles.length * colliders.length || (!this.config.surfaceCollisions && surfaceContacts.length > 0) ||
418
+ 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 ||
420
+ !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))
422
+ fail("CLOTH_SNAPSHOT", "invalid surface contact state");
423
+ for (const contact of surfaceContacts) {
424
+ const key = `${contact.triangle}:${contact.colliderId}`;
425
+ if (surfaceKeys.has(key))
426
+ fail("CLOTH_SNAPSHOT", "duplicate surface contact");
427
+ surfaceKeys.add(key);
428
+ }
360
429
  this.positions = input.positions.map(copy);
361
430
  this.velocities = input.velocities.map(copy);
362
431
  this.forces = input.forces.map(copy);
@@ -364,8 +433,9 @@ export class ClothSolver {
364
433
  this.colliders = colliders;
365
434
  this.contacts = input.contacts.map(contact => ({ ...contact }));
366
435
  this.selfContacts = selfContacts.map(contact => ({ ...contact }));
436
+ this.surfaceContacts = surfaceContacts.map(contact => ({ ...contact, barycentric: [...contact.barycentric] }));
367
437
  this.tickValue = input.tick;
368
438
  }
369
439
  dispose() { if (this.disposed)
370
- return; this.disposed = true; this.positions = []; this.velocities = []; this.forces = []; this.colliders = []; this.contacts = []; this.selfContacts = []; this.anchors.clear(); }
440
+ return; this.disposed = true; this.positions = []; this.velocities = []; this.forces = []; this.colliders = []; this.contacts = []; this.selfContacts = []; this.surfaceContacts = []; this.anchors.clear(); }
371
441
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pelican-engine",
3
- "version": "0.3.21",
3
+ "version": "0.3.22",
4
4
  "description": "Pelican Engine public SDK and offline development CLI",
5
5
  "type": "module",
6
6
  "license": "MIT",