pelican-engine 0.3.17 → 0.3.19

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.
@@ -0,0 +1,358 @@
1
+ export class ClothError extends Error {
2
+ code;
3
+ constructor(code, message) {
4
+ super(`${code}: ${message}`);
5
+ this.code = code;
6
+ this.name = "ClothError";
7
+ }
8
+ }
9
+ const fail = (code, message) => { throw new ClothError(code, message); };
10
+ const vec = (input) => Array.isArray(input) && input.length === 3 && input.every(value => typeof value === "number" && Number.isFinite(value) && Math.abs(value) <= 1e6);
11
+ const copy = (value) => [value[0], value[1], value[2]];
12
+ const readVec = (input, label) => vec(input) ? copy(input) : fail("CLOTH_INPUT", `${label} must be a finite 3D vector with component magnitude <= 1e6`);
13
+ const positive = (input, label) => typeof input === "number" && Number.isFinite(input) && input > 0 && input <= 1e6 ? input : fail("CLOTH_INPUT", `${label} must be positive and <= 1e6`);
14
+ const indexOf = (input, count) => Number.isSafeInteger(input) && input >= 0 && input < count ? input : fail("CLOTH_INDEX", "particle index is out of range");
15
+ const distance = (a, b) => Math.hypot(a[0] - b[0], a[1] - b[1], a[2] - b[2]);
16
+ const tuple = (a) => [a[0], a[1], a[2]];
17
+ function readAnchors(input, count) {
18
+ if (!Array.isArray(input) || input.length > count)
19
+ fail("CLOTH_INPUT", "anchors must be an array of unique particle indices");
20
+ const result = new Map();
21
+ for (const anchor of input) {
22
+ if (!anchor || typeof anchor !== "object")
23
+ fail("CLOTH_INPUT", "invalid anchor");
24
+ const index = indexOf(anchor.index, count);
25
+ if (result.has(index))
26
+ fail("CLOTH_INPUT", "duplicate anchor index");
27
+ result.set(index, readVec(anchor.position, "anchor position"));
28
+ }
29
+ return result;
30
+ }
31
+ function readColliders(input) {
32
+ if (!Array.isArray(input) || input.length > 4096)
33
+ fail("CLOTH_INPUT", "colliders must be an array of at most 4096 items");
34
+ const ids = new Set();
35
+ return input.map(collider => {
36
+ if (!collider || typeof collider !== "object" || typeof collider.id !== "string" || !collider.id || ids.has(collider.id))
37
+ fail("CLOTH_INPUT", "collider ids must be unique nonempty strings");
38
+ ids.add(collider.id);
39
+ if (collider.kind === "sphere")
40
+ return { kind: "sphere", id: collider.id, center: readVec(collider.center, "sphere center"), radius: positive(collider.radius, "sphere radius") };
41
+ if (collider.kind === "plane") {
42
+ const normal = readVec(collider.normal, "plane normal"), magnitude = Math.hypot(...normal);
43
+ if (Math.abs(magnitude - 1) > 1e-6 || typeof collider.offset !== "number" || !Number.isFinite(collider.offset) || Math.abs(collider.offset) > 1e6)
44
+ fail("CLOTH_INPUT", "plane requires a unit normal and finite offset with magnitude <= 1e6");
45
+ return { kind: "plane", id: collider.id, normal, offset: collider.offset };
46
+ }
47
+ return fail("CLOTH_INPUT", "unsupported collider kind");
48
+ });
49
+ }
50
+ function topology(points, input, bending) {
51
+ if (!Array.isArray(input) || input.length < 1 || input.length > 8192)
52
+ fail("CLOTH_INPUT", "triangles must contain 1..8192 faces");
53
+ const triangles = [], faces = new Set();
54
+ const edges = new Map();
55
+ for (const raw of input) {
56
+ if (!Array.isArray(raw) || raw.length !== 3)
57
+ fail("CLOTH_INPUT", "triangle must contain three particle indices");
58
+ const [a, b, c] = raw.map(value => indexOf(value, points.length));
59
+ if (a === b || b === c || a === c)
60
+ fail("CLOTH_INPUT", "triangle indices must be distinct");
61
+ const key = [a, b, c].sort((x, y) => x - y).join(":");
62
+ if (faces.has(key))
63
+ fail("CLOTH_INPUT", "duplicate triangle");
64
+ faces.add(key);
65
+ const u = [points[b][0] - points[a][0], points[b][1] - points[a][1], points[b][2] - points[a][2]];
66
+ const v = [points[c][0] - points[a][0], points[c][1] - points[a][1], points[c][2] - points[a][2]];
67
+ if (Math.hypot(u[1] * v[2] - u[2] * v[1], u[2] * v[0] - u[0] * v[2], u[0] * v[1] - u[1] * v[0]) <= 1e-10)
68
+ fail("CLOTH_INPUT", "initial triangle area must be nonzero");
69
+ triangles.push([a, b, c]);
70
+ for (const [from, to, opposite] of [[a, b, c], [b, c, a], [c, a, b]]) {
71
+ const low = Math.min(from, to), high = Math.max(from, to), edgeKey = `${low}:${high}`;
72
+ const edge = edges.get(edgeKey) ?? { a: low, b: high, opposite: [], directions: [] };
73
+ edge.opposite.push(opposite);
74
+ edge.directions.push(from === low ? 1 : -1);
75
+ if (edge.opposite.length > 2 || (edge.opposite.length === 2 && edge.directions[0] === edge.directions[1]))
76
+ fail("CLOTH_INPUT", "shared edges require manifold faces with consistent winding");
77
+ edges.set(edgeKey, edge);
78
+ }
79
+ }
80
+ const constraints = [], seen = new Set();
81
+ const add = (a, b) => {
82
+ const low = Math.min(a, b), high = Math.max(a, b), key = `${low}:${high}`;
83
+ if (seen.has(key))
84
+ return;
85
+ const rest = distance(points[low], points[high]);
86
+ if (!Number.isFinite(rest) || rest <= 1e-8)
87
+ fail("CLOTH_INPUT", "constraint endpoints must be distinct");
88
+ seen.add(key);
89
+ constraints.push([low, high, rest]);
90
+ };
91
+ for (const edge of edges.values())
92
+ add(edge.a, edge.b);
93
+ if (bending)
94
+ for (const edge of edges.values())
95
+ if (edge.opposite.length === 2)
96
+ add(edge.opposite[0], edge.opposite[1]);
97
+ return { triangles, constraints };
98
+ }
99
+ export function createCloth(input) {
100
+ if (!input || typeof input !== "object" || !Array.isArray(input.points) || input.points.length < 3 || input.points.length > 4096)
101
+ fail("CLOTH_INPUT", "points must contain 3..4096 particles");
102
+ const positions = input.points.map((point, index) => readVec(point, `points[${index}]`));
103
+ const mass = positive(input.mass, "mass"), radius = positive(input.radius ?? 0.025, "radius"), fixedDt = positive(input.fixedDt ?? 1 / 60, "fixedDt");
104
+ if (fixedDt < 1 / 1000 || fixedDt > 1 / 30)
105
+ fail("CLOTH_INPUT", "fixedDt must be in [1/1000, 1/30]");
106
+ const substeps = input.substeps ?? 4, iterations = input.iterations ?? 12, damping = input.damping ?? 0.15;
107
+ if (!Number.isInteger(substeps) || substeps < 1 || substeps > 32 || !Number.isInteger(iterations) || iterations < 1 || iterations > 64)
108
+ fail("CLOTH_INPUT", "substeps and iterations are outside their supported bounds");
109
+ if (typeof damping !== "number" || !Number.isFinite(damping) || damping < 0 || damping > 100)
110
+ fail("CLOTH_INPUT", "damping must be in [0,100]");
111
+ if (input.bending !== undefined && typeof input.bending !== "boolean")
112
+ 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");
116
+ const { triangles, constraints } = topology(positions, input.triangles, bending);
117
+ const anchors = readAnchors(input.anchors ?? [], positions.length);
118
+ for (const [index, point] of anchors)
119
+ positions[index] = copy(point);
120
+ return new ClothSolver({ mass, radius, fixedDt, substeps, iterations, damping, gravity, bending, selfCollisions, triangles, constraints }, positions, anchors);
121
+ }
122
+ export class ClothSolver {
123
+ config;
124
+ positions;
125
+ anchors;
126
+ tickValue = 0;
127
+ disposed = false;
128
+ velocities;
129
+ forces;
130
+ colliders = [];
131
+ contacts = [];
132
+ selfContacts = [];
133
+ adjacent;
134
+ constructor(config, positions, anchors) {
135
+ this.config = config;
136
+ this.positions = positions;
137
+ this.anchors = anchors;
138
+ this.velocities = positions.map(() => [0, 0, 0]);
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)}`]));
141
+ }
142
+ alive() { if (this.disposed)
143
+ fail("CLOTH_DISPOSED", "cloth has been disposed"); }
144
+ setColliders(input) { this.alive(); this.colliders = readColliders(input); this.contacts = []; }
145
+ setAnchor(index, position) {
146
+ this.alive();
147
+ const i = indexOf(index, this.positions.length), p = readVec(position, "anchor position");
148
+ this.anchors.set(i, p);
149
+ this.positions[i] = copy(p);
150
+ this.velocities[i] = [0, 0, 0];
151
+ this.forces[i] = [0, 0, 0];
152
+ }
153
+ releaseAnchor(index) { this.alive(); this.anchors.delete(indexOf(index, this.positions.length)); }
154
+ applyForce(index, input) {
155
+ this.alive();
156
+ const i = indexOf(index, this.positions.length), force = readVec(input, "force");
157
+ if (this.anchors.has(i))
158
+ fail("CLOTH_ANCHORED", "cannot apply force to an anchor");
159
+ const next = [0, 1, 2].map(axis => this.forces[i][axis] + force[axis]);
160
+ if (!vec(next))
161
+ fail("CLOTH_NUMERIC", "force accumulation exceeded bounds");
162
+ this.forces[i] = next;
163
+ }
164
+ applyImpulse(index, input) {
165
+ this.alive();
166
+ const i = indexOf(index, this.positions.length), impulse = readVec(input, "impulse");
167
+ if (this.anchors.has(i))
168
+ fail("CLOTH_ANCHORED", "cannot apply impulse to an anchor");
169
+ const next = [0, 1, 2].map(axis => this.velocities[i][axis] + impulse[axis] / this.config.mass);
170
+ if (!vec(next))
171
+ fail("CLOTH_NUMERIC", "impulse exceeded bounds");
172
+ this.velocities[i] = next;
173
+ }
174
+ /** A failed batch restores its pre-batch state. */
175
+ step(steps = 1) {
176
+ this.alive();
177
+ if (!Number.isSafeInteger(steps) || steps < 1 || steps > 1000 || this.tickValue + steps > Number.MAX_SAFE_INTEGER)
178
+ 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 };
180
+ try {
181
+ for (let i = 0; i < steps; i++)
182
+ this.stepOne();
183
+ }
184
+ catch (error) {
185
+ this.positions = before.positions;
186
+ this.velocities = before.velocities;
187
+ this.forces = before.forces;
188
+ this.contacts = before.contacts;
189
+ this.selfContacts = before.selfContacts;
190
+ this.tickValue = before.tick;
191
+ throw error;
192
+ }
193
+ return this.inspect();
194
+ }
195
+ stepOne() {
196
+ const { fixedDt, substeps, iterations, mass, gravity, damping, constraints, radius } = this.config;
197
+ 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();
199
+ for (let sub = 0; sub < substeps; sub++) {
200
+ const previous = positions.map(copy);
201
+ for (let i = 0; i < positions.length; i++) {
202
+ const anchor = this.anchors.get(i);
203
+ if (anchor) {
204
+ positions[i] = copy(anchor);
205
+ velocities[i] = [0, 0, 0];
206
+ continue;
207
+ }
208
+ for (let k = 0; k < 3; k++) {
209
+ velocities[i][k] = velocities[i][k] * decay + (gravity[k] + this.forces[i][k] / mass) * h;
210
+ positions[i][k] += velocities[i][k] * h;
211
+ }
212
+ }
213
+ for (let iter = 0; iter < iterations; iter++) {
214
+ for (const [i, j, rest] of constraints) {
215
+ const a = positions[i], b = positions[j];
216
+ let dx = b[0] - a[0], dy = b[1] - a[1], dz = b[2] - a[2], dist = Math.hypot(dx, dy, dz);
217
+ if (dist < 1e-12) {
218
+ dx = 1e-6;
219
+ dy = 0;
220
+ dz = 0;
221
+ dist = 1e-6;
222
+ }
223
+ const wa = this.anchors.has(i) ? 0 : 1, wb = this.anchors.has(j) ? 0 : 1;
224
+ if (wa + wb === 0)
225
+ continue;
226
+ const factor = (dist - rest) / (dist * (wa + wb));
227
+ for (let k = 0; k < 3; k++) {
228
+ const correction = (k === 0 ? dx : k === 1 ? dy : dz) * factor;
229
+ a[k] += correction * wa;
230
+ b[k] -= correction * wb;
231
+ }
232
+ }
233
+ for (let i = 0; i < positions.length; i++) {
234
+ if (this.anchors.has(i))
235
+ continue;
236
+ const p = positions[i];
237
+ for (const collider of this.colliders) {
238
+ let depth, normal;
239
+ if (collider.kind === "plane") {
240
+ normal = collider.normal;
241
+ depth = collider.offset + radius - (p[0] * normal[0] + p[1] * normal[1] + p[2] * normal[2]);
242
+ }
243
+ else {
244
+ const delta = [p[0] - collider.center[0], p[1] - collider.center[1], p[2] - collider.center[2]], length = Math.hypot(...delta);
245
+ depth = collider.radius + radius - length;
246
+ normal = length < 1e-12 ? [1, 0, 0] : [delta[0] / length, delta[1] / length, delta[2] / length];
247
+ }
248
+ if (depth <= 0)
249
+ continue;
250
+ for (let k = 0; k < 3; k++)
251
+ p[k] += normal[k] * depth;
252
+ const key = `${i}:${collider.id}`, old = contacts.get(key);
253
+ if (old)
254
+ old.penetration = Math.max(old.penetration, depth);
255
+ else
256
+ contacts.set(key, { tick: this.tickValue + 1, particle: i, colliderId: collider.id, penetration: depth });
257
+ }
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
+ }
282
+ }
283
+ for (let i = 0; i < positions.length; i++) {
284
+ if (this.anchors.has(i))
285
+ continue;
286
+ for (let k = 0; k < 3; k++)
287
+ velocities[i][k] = (positions[i][k] - previous[i][k]) / h;
288
+ if (!vec(positions[i]) || !vec(velocities[i]))
289
+ fail("CLOTH_NUMERIC", "solver exceeded finite state bounds");
290
+ }
291
+ }
292
+ this.positions = positions;
293
+ this.velocities = velocities;
294
+ this.forces = positions.map(() => [0, 0, 0]);
295
+ this.contacts = [...contacts.values()];
296
+ this.selfContacts = [...selfContacts.values()];
297
+ this.tickValue++;
298
+ }
299
+ inspect() {
300
+ 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 } };
302
+ let maxDistanceError = 0;
303
+ for (const [a, b, rest] of this.config.constraints)
304
+ 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,
306
+ positions: this.positions.map(copy), velocities: this.velocities.map(copy), triangles: this.config.triangles.map(tuple),
307
+ 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,
309
+ resources: { particles: this.positions.length, triangles: this.config.triangles.length, constraints: this.config.constraints.length, anchors: this.anchors.size, colliders: this.colliders.length } };
310
+ }
311
+ snapshot() {
312
+ this.alive();
313
+ const state = this.inspect();
314
+ return { format: "pelican-cloth-cpu", version: 1, tick: this.tickValue,
315
+ 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 };
317
+ }
318
+ restore(input) {
319
+ this.alive();
320
+ if (!input || typeof input !== "object" || input.format !== "pelican-cloth-cpu" || input.version !== 1 || !Number.isSafeInteger(input.tick) || input.tick < 0)
321
+ fail("CLOTH_SNAPSHOT", "unsupported snapshot format or tick");
322
+ const config = input.config, expected = this.config;
323
+ 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 ||
325
+ !vec(config.gravity) || config.gravity.some((value, i) => value !== expected.gravity[i]) ||
326
+ !Array.isArray(config.triangles) || JSON.stringify(config.triangles) !== JSON.stringify(expected.triangles) ||
327
+ !Array.isArray(config.constraints) || JSON.stringify(config.constraints) !== JSON.stringify(expected.constraints))
328
+ fail("CLOTH_SNAPSHOT", "cloth configuration or topology mismatch");
329
+ const count = this.positions.length;
330
+ if (!Array.isArray(input.positions) || !Array.isArray(input.velocities) || !Array.isArray(input.forces) ||
331
+ [input.positions, input.velocities, input.forces].some(values => values.length !== count || !values.every(vec)))
332
+ fail("CLOTH_SNAPSHOT", "invalid particle state");
333
+ const anchors = readAnchors(input.anchors, count);
334
+ if ([...anchors].some(([i, point]) => point.some((value, k) => value !== input.positions[i][k]) || input.velocities[i].some(value => value !== 0) || input.forces[i].some(value => value !== 0)))
335
+ fail("CLOTH_SNAPSHOT", "inconsistent anchored particle state");
336
+ const colliders = readColliders(input.colliders), keys = new Set();
337
+ if (!Array.isArray(input.contacts) || input.contacts.length > count * colliders.length || input.contacts.some(contact => !contact || contact.tick !== input.tick ||
338
+ !Number.isSafeInteger(contact.particle) || contact.particle < 0 || contact.particle >= count || !colliders.some(collider => collider.id === contact.colliderId) ||
339
+ typeof contact.penetration !== "number" || !Number.isFinite(contact.penetration) || contact.penetration <= 0))
340
+ fail("CLOTH_SNAPSHOT", "invalid contact state");
341
+ for (const contact of input.contacts) {
342
+ const key = `${contact.particle}:${contact.colliderId}`;
343
+ if (keys.has(key))
344
+ fail("CLOTH_SNAPSHOT", "duplicate contact");
345
+ keys.add(key);
346
+ }
347
+ this.positions = input.positions.map(copy);
348
+ this.velocities = input.velocities.map(copy);
349
+ this.forces = input.forces.map(copy);
350
+ this.anchors = anchors;
351
+ this.colliders = colliders;
352
+ this.contacts = input.contacts.map(contact => ({ ...contact }));
353
+ this.selfContacts = (input.selfContacts ?? []).map(contact => ({ ...contact }));
354
+ this.tickValue = input.tick;
355
+ }
356
+ dispose() { if (this.disposed)
357
+ return; this.disposed = true; this.positions = []; this.velocities = []; this.forces = []; this.colliders = []; this.contacts = []; this.selfContacts = []; this.anchors.clear(); }
358
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pelican-engine",
3
- "version": "0.3.17",
3
+ "version": "0.3.19",
4
4
  "description": "Pelican Engine public SDK and offline development CLI",
5
5
  "type": "module",
6
6
  "license": "MIT",