pelican-engine 0.3.9 → 0.3.11

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,318 @@
1
+ export class RopeError extends Error {
2
+ code;
3
+ constructor(code, message) {
4
+ super(`${code}: ${message}`);
5
+ this.code = code;
6
+ this.name = "RopeError";
7
+ }
8
+ }
9
+ const fail = (code, reason) => { throw new RopeError(code, reason); };
10
+ const vec = (v) => Array.isArray(v) && v.length === 3 && v.every(x => typeof x === "number" && Number.isFinite(x) && Math.abs(x) <= 1e6);
11
+ const copy = (v) => [v[0], v[1], v[2]];
12
+ const length = (v) => Math.hypot(v[0], v[1], v[2]);
13
+ const validVec = (v, name) => vec(v) ? copy(v) : fail("ROPE_INPUT", `${name} must be a finite 3D vector (|component| <= 1e6)`);
14
+ const positive = (v, name) => typeof v === "number" && Number.isFinite(v) && v > 0 && v <= 1e6 ? v : fail("ROPE_INPUT", `${name} must be positive and <= 1e6`);
15
+ const bounded = (v, name, max) => typeof v === "number" && Number.isFinite(v) && v >= 0 && v <= max ? v : fail("ROPE_INPUT", `${name} must be within [0, ${max}]`);
16
+ const indexOf = (v, count) => Number.isSafeInteger(v) && v >= 0 && v < count ? v : fail("ROPE_INDEX", "particle index out of range");
17
+ const finiteState = (v) => vec(v);
18
+ function readColliders(input) {
19
+ if (!Array.isArray(input) || input.length > 4096)
20
+ fail("ROPE_INPUT", "colliders must be an array (max 4096)");
21
+ const ids = new Set();
22
+ return input.map((c) => {
23
+ if (!c || typeof c !== "object" || typeof c.id !== "string" || !c.id || ids.has(c.id))
24
+ fail("ROPE_INPUT", "collider ids must be unique nonempty strings");
25
+ ids.add(c.id);
26
+ if (c.kind === "sphere")
27
+ return { kind: "sphere", id: c.id, center: validVec(c.center, "center"), radius: positive(c.radius, "sphere radius") };
28
+ if (c.kind === "plane") {
29
+ const normal = validVec(c.normal, "normal"), n = length(normal);
30
+ if (Math.abs(n - 1) > 1e-6)
31
+ fail("ROPE_INPUT", "plane normal must have unit length");
32
+ return { kind: "plane", id: c.id, normal, offset: boundedSigned(c.offset, "plane offset") };
33
+ }
34
+ return fail("ROPE_INPUT", "unsupported collider kind");
35
+ });
36
+ }
37
+ function boundedSigned(v, name) {
38
+ return typeof v === "number" && Number.isFinite(v) && Math.abs(v) <= 1e6 ? v : fail("ROPE_INPUT", `${name} must be finite (|value| <= 1e6)`);
39
+ }
40
+ function readAnchors(input, count) {
41
+ if (!Array.isArray(input) || input.length > count)
42
+ fail("ROPE_INPUT", "anchors must be an array of unique particle indices");
43
+ const result = new Map();
44
+ for (const a of input) {
45
+ if (!a || typeof a !== "object")
46
+ fail("ROPE_INPUT", "invalid anchor");
47
+ const i = indexOf(a.index, count);
48
+ if (result.has(i))
49
+ fail("ROPE_INPUT", "duplicate anchor index");
50
+ result.set(i, validVec(a.position, "anchor position"));
51
+ }
52
+ return result;
53
+ }
54
+ export function createRope(options) {
55
+ if (!options || typeof options !== "object" || !Array.isArray(options.points) || options.points.length < 2 || options.points.length > 4096)
56
+ fail("ROPE_INPUT", "points must contain 2..4096 particles");
57
+ const positions = options.points.map((p, i) => validVec(p, `points[${i}]`));
58
+ const mass = positive(options.mass, "mass");
59
+ const radius = positive(options.radius ?? 0.025, "radius");
60
+ const fixedDt = positive(options.fixedDt ?? 1 / 60, "fixedDt");
61
+ if (fixedDt < 1 / 1000 || fixedDt > 1 / 30)
62
+ fail("ROPE_INPUT", "fixedDt must be in [1/1000, 1/30]");
63
+ const substeps = options.substeps ?? 4, iterations = options.iterations ?? 12;
64
+ if (!Number.isInteger(substeps) || substeps < 1 || substeps > 32 || !Number.isInteger(iterations) || iterations < 1 || iterations > 64)
65
+ fail("ROPE_INPUT", "substeps/iterations must be positive bounded integers");
66
+ const damping = bounded(options.damping ?? 0.15, "damping", 100);
67
+ const gravity = validVec(options.gravity ?? [0, -9.81, 0], "gravity");
68
+ const restLengths = positions.slice(1).map((p, i) => length([p[0] - positions[i][0], p[1] - positions[i][1], p[2] - positions[i][2]]));
69
+ if (restLengths.some(d => !Number.isFinite(d) || d <= 1e-8))
70
+ fail("ROPE_INPUT", "adjacent particles must have distinct positions");
71
+ const anchors = readAnchors(options.anchors ?? [{ index: 0, position: positions[0] }], positions.length);
72
+ for (const [i, p] of anchors)
73
+ positions[i] = copy(p);
74
+ return new RopeSolver({ mass, radius, fixedDt, substeps, iterations, damping, gravity, restLengths }, positions, anchors);
75
+ }
76
+ export class RopeSolver {
77
+ config;
78
+ positions;
79
+ anchors;
80
+ tickValue = 0;
81
+ disposedValue = false;
82
+ velocities;
83
+ forces;
84
+ colliders = [];
85
+ contacts = [];
86
+ constructor(config, positions, anchors) {
87
+ this.config = config;
88
+ this.positions = positions;
89
+ this.anchors = anchors;
90
+ this.velocities = positions.map(() => [0, 0, 0]);
91
+ this.forces = positions.map(() => [0, 0, 0]);
92
+ }
93
+ alive() { if (this.disposedValue)
94
+ fail("ROPE_DISPOSED", "rope has been disposed"); }
95
+ /** Copies all inputs: callers may mutate their arrays after calling this. */
96
+ setColliders(input) {
97
+ this.alive();
98
+ this.colliders = readColliders(input);
99
+ this.contacts = [];
100
+ }
101
+ /** Pins or moves a particle; new anchor position takes effect immediately. */
102
+ setAnchor(index, position) {
103
+ this.alive();
104
+ const i = indexOf(index, this.positions.length), p = validVec(position, "anchor position");
105
+ this.anchors.set(i, p);
106
+ this.positions[i] = copy(p);
107
+ this.velocities[i] = [0, 0, 0];
108
+ this.forces[i] = [0, 0, 0];
109
+ }
110
+ releaseAnchor(index) { this.alive(); this.anchors.delete(indexOf(index, this.positions.length)); }
111
+ /** N·s (kg·m/s); changes velocity immediately, not position. */
112
+ applyImpulse(index, impulse) {
113
+ this.alive();
114
+ const i = indexOf(index, this.positions.length), v = validVec(impulse, "impulse");
115
+ if (this.anchors.has(i))
116
+ fail("ROPE_ANCHORED", "cannot apply impulse to an anchor");
117
+ const next = [0, 1, 2].map(k => this.velocities[i][k] + v[k] / this.config.mass);
118
+ if (!finiteState(next))
119
+ fail("ROPE_NUMERIC", "impulse exceeds numeric bounds");
120
+ this.velocities[i] = next;
121
+ }
122
+ /** Newtons accumulated for exactly one next fixed tick; not a persistent force. */
123
+ applyForce(index, force) {
124
+ this.alive();
125
+ const i = indexOf(index, this.positions.length), v = validVec(force, "force");
126
+ if (this.anchors.has(i))
127
+ fail("ROPE_ANCHORED", "cannot apply force to an anchor");
128
+ const next = [0, 1, 2].map(k => this.forces[i][k] + v[k]);
129
+ if (!finiteState(next))
130
+ fail("ROPE_NUMERIC", "force accumulation exceeds numeric bounds");
131
+ this.forces[i] = next;
132
+ }
133
+ /** Exactly N fixed ticks; a failed batch restores the state before the first tick. */
134
+ step(steps = 1) {
135
+ this.alive();
136
+ if (!Number.isSafeInteger(steps) || steps < 1 || steps > 1000 || this.tickValue + steps > Number.MAX_SAFE_INTEGER)
137
+ fail("ROPE_INPUT", "steps must be an integer in [1,1000] without tick overflow");
138
+ // stepOne replaces these arrays rather than mutating the prior committed state.
139
+ const before = {
140
+ positions: this.positions,
141
+ velocities: this.velocities,
142
+ forces: this.forces,
143
+ contacts: this.contacts,
144
+ tick: this.tickValue,
145
+ };
146
+ try {
147
+ for (let s = 0; s < steps; s++)
148
+ this.stepOne();
149
+ }
150
+ catch (error) {
151
+ this.positions = before.positions;
152
+ this.velocities = before.velocities;
153
+ this.forces = before.forces;
154
+ this.contacts = before.contacts;
155
+ this.tickValue = before.tick;
156
+ throw error;
157
+ }
158
+ return this.inspect();
159
+ }
160
+ stepOne() {
161
+ const { fixedDt, substeps, iterations, mass, gravity, damping, restLengths, radius } = this.config;
162
+ const h = fixedDt / substeps, decay = Math.exp(-damping * h);
163
+ // Work on copies; numeric failures must never leave half a tick applied.
164
+ const positions = this.positions.map(copy), velocities = this.velocities.map(copy);
165
+ const contacts = new Map();
166
+ for (let sub = 0; sub < substeps; sub++) {
167
+ const previous = positions.map(copy);
168
+ for (let i = 0; i < positions.length; i++) {
169
+ const anchor = this.anchors.get(i);
170
+ if (anchor) {
171
+ positions[i] = copy(anchor);
172
+ velocities[i] = [0, 0, 0];
173
+ continue;
174
+ }
175
+ for (let k = 0; k < 3; k++) {
176
+ velocities[i][k] = velocities[i][k] * decay + (gravity[k] + this.forces[i][k] / mass) * h;
177
+ positions[i][k] += velocities[i][k] * h;
178
+ }
179
+ }
180
+ for (let iter = 0; iter < iterations; iter++) {
181
+ for (let j = 0; j < restLengths.length; j++) {
182
+ const a = positions[j], b = positions[j + 1];
183
+ let dx = b[0] - a[0], dy = b[1] - a[1], dz = b[2] - a[2];
184
+ let dist = Math.hypot(dx, dy, dz);
185
+ // If a contact collapses a link, separate it on a deterministic axis.
186
+ if (dist < 1e-12) {
187
+ dx = 1e-6;
188
+ dy = 0;
189
+ dz = 0;
190
+ dist = 1e-6;
191
+ }
192
+ const wa = this.anchors.has(j) ? 0 : 1, wb = this.anchors.has(j + 1) ? 0 : 1;
193
+ if (wa + wb === 0)
194
+ continue; // two incompatible kinematic anchors are intentionally authoritative
195
+ const d = (dist - restLengths[j]) / (dist * (wa + wb));
196
+ for (let k = 0; k < 3; k++) {
197
+ const correction = (k === 0 ? dx : k === 1 ? dy : dz) * d;
198
+ a[k] += correction * wa;
199
+ b[k] -= correction * wb;
200
+ }
201
+ }
202
+ for (let i = 0; i < positions.length; i++) {
203
+ if (this.anchors.has(i))
204
+ continue; // anchors cannot be displaced by external colliders
205
+ const p = positions[i];
206
+ for (const c of this.colliders) {
207
+ let depth = 0;
208
+ let normal;
209
+ if (c.kind === "plane") {
210
+ depth = c.offset + radius - (p[0] * c.normal[0] + p[1] * c.normal[1] + p[2] * c.normal[2]);
211
+ normal = c.normal;
212
+ }
213
+ else {
214
+ const delta = [p[0] - c.center[0], p[1] - c.center[1], p[2] - c.center[2]];
215
+ const distance = length(delta);
216
+ depth = c.radius + radius - distance;
217
+ normal = distance < 1e-12 ? [1, 0, 0] : [delta[0] / distance, delta[1] / distance, delta[2] / distance];
218
+ }
219
+ if (depth <= 0)
220
+ continue;
221
+ for (let k = 0; k < 3; k++)
222
+ p[k] += normal[k] * depth;
223
+ const key = `${i}:${c.id}`;
224
+ const existing = contacts.get(key);
225
+ if (existing)
226
+ existing.penetration = Math.max(existing.penetration, depth);
227
+ else
228
+ contacts.set(key, { tick: this.tickValue + 1, particle: i, colliderId: c.id, penetration: depth });
229
+ }
230
+ }
231
+ }
232
+ for (let i = 0; i < positions.length; i++) {
233
+ if (this.anchors.has(i))
234
+ continue;
235
+ for (let k = 0; k < 3; k++)
236
+ velocities[i][k] = (positions[i][k] - previous[i][k]) / h;
237
+ if (!finiteState(positions[i]) || !finiteState(velocities[i]))
238
+ fail("ROPE_NUMERIC", "solver exceeded finite state bounds");
239
+ }
240
+ }
241
+ this.positions = positions;
242
+ this.velocities = velocities;
243
+ this.forces = positions.map(() => [0, 0, 0]);
244
+ this.contacts = [...contacts.values()];
245
+ this.tickValue++;
246
+ }
247
+ inspect() {
248
+ if (this.disposedValue)
249
+ 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 } };
250
+ const maxDistanceError = Math.max(0, ...this.config.restLengths.map((d, i) => {
251
+ const a = this.positions[i], b = this.positions[i + 1];
252
+ return Math.abs(Math.hypot(a[0] - b[0], a[1] - b[1], a[2] - b[2]) - d);
253
+ }));
254
+ return {
255
+ tick: this.tickValue, simulatedSeconds: this.tickValue * this.config.fixedDt, disposed: false,
256
+ positions: this.positions.map(copy), velocities: this.velocities.map(copy),
257
+ 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,
259
+ resources: { particles: this.positions.length, constraints: this.config.restLengths.length, anchors: this.anchors.size, colliders: this.colliders.length },
260
+ };
261
+ }
262
+ snapshot() {
263
+ this.alive();
264
+ const state = this.inspect();
265
+ return {
266
+ format: "pelican-rope-cpu", version: 1, tick: this.tickValue,
267
+ config: { ...this.config, gravity: copy(this.config.gravity), restLengths: [...this.config.restLengths] },
268
+ positions: state.positions, velocities: state.velocities, anchors: state.anchors,
269
+ forces: this.forces.map(copy), colliders: readColliders(this.colliders), contacts: state.contacts,
270
+ };
271
+ }
272
+ /** Accepts a JSON-round-tripped snapshot only from the same rope configuration. Atomic on failure. */
273
+ restore(input) {
274
+ this.alive();
275
+ if (!input || typeof input !== "object" || input.format !== "pelican-rope-cpu" || input.version !== 1 ||
276
+ !Number.isSafeInteger(input.tick) || input.tick < 0)
277
+ fail("ROPE_SNAPSHOT", "unsupported snapshot format or tick");
278
+ const c = input.config;
279
+ if (!c || c.mass !== this.config.mass || c.radius !== this.config.radius || c.fixedDt !== this.config.fixedDt ||
280
+ c.substeps !== this.config.substeps || c.iterations !== this.config.iterations || c.damping !== this.config.damping ||
281
+ !vec(c.gravity) || c.gravity.some((v, i) => v !== this.config.gravity[i]) ||
282
+ !Array.isArray(c.restLengths) || c.restLengths.length !== this.config.restLengths.length ||
283
+ c.restLengths.some((v, i) => v !== this.config.restLengths[i]))
284
+ fail("ROPE_SNAPSHOT", "rope configuration/topology mismatch");
285
+ const count = this.positions.length;
286
+ if (!Array.isArray(input.positions) || !Array.isArray(input.velocities) || !Array.isArray(input.forces) ||
287
+ [input.positions, input.velocities, input.forces].some(v => v.length !== count || !v.every(vec)))
288
+ fail("ROPE_SNAPSHOT", "invalid particle state");
289
+ const anchors = readAnchors(input.anchors, count);
290
+ if ([...anchors].some(([i, p]) => p.some((v, k) => v !== input.positions[i][k]) || input.velocities[i].some(v => v !== 0) || input.forces[i].some(v => v !== 0)))
291
+ fail("ROPE_SNAPSHOT", "inconsistent anchored particle state");
292
+ const colliders = readColliders(input.colliders);
293
+ if (!Array.isArray(input.contacts) || input.contacts.length > count * Math.max(1, colliders.length) ||
294
+ input.contacts.some(contact => !contact || contact.tick !== input.tick ||
295
+ !Number.isSafeInteger(contact.particle) || contact.particle < 0 || contact.particle >= count ||
296
+ !colliders.some(collider => collider.id === contact.colliderId) ||
297
+ typeof contact.penetration !== "number" || !Number.isFinite(contact.penetration) || contact.penetration <= 0))
298
+ fail("ROPE_SNAPSHOT", "invalid contact state");
299
+ this.positions = input.positions.map(copy);
300
+ this.velocities = input.velocities.map(copy);
301
+ this.forces = input.forces.map(copy);
302
+ this.anchors = anchors;
303
+ this.colliders = colliders;
304
+ this.contacts = input.contacts.map(c => ({ ...c }));
305
+ this.tickValue = input.tick;
306
+ }
307
+ dispose() {
308
+ if (this.disposedValue)
309
+ return;
310
+ this.disposedValue = true;
311
+ this.positions = [];
312
+ this.velocities = [];
313
+ this.forces = [];
314
+ this.colliders = [];
315
+ this.contacts = [];
316
+ this.anchors.clear();
317
+ }
318
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pelican-engine",
3
- "version": "0.3.9",
3
+ "version": "0.3.11",
4
4
  "description": "Pelican Engine public SDK and offline development CLI",
5
5
  "type": "module",
6
6
  "license": "MIT",