pelican-engine 0.3.16 → 0.3.18

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,327 @@
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, gravity = readVec(input.gravity ?? [0, -9.81, 0], "gravity");
114
+ const { triangles, constraints } = topology(positions, input.triangles, bending);
115
+ const anchors = readAnchors(input.anchors ?? [], positions.length);
116
+ for (const [index, point] of anchors)
117
+ positions[index] = copy(point);
118
+ return new ClothSolver({ mass, radius, fixedDt, substeps, iterations, damping, gravity, bending, triangles, constraints }, positions, anchors);
119
+ }
120
+ export class ClothSolver {
121
+ config;
122
+ positions;
123
+ anchors;
124
+ tickValue = 0;
125
+ disposed = false;
126
+ velocities;
127
+ forces;
128
+ colliders = [];
129
+ contacts = [];
130
+ constructor(config, positions, anchors) {
131
+ this.config = config;
132
+ this.positions = positions;
133
+ this.anchors = anchors;
134
+ this.velocities = positions.map(() => [0, 0, 0]);
135
+ this.forces = positions.map(() => [0, 0, 0]);
136
+ }
137
+ alive() { if (this.disposed)
138
+ fail("CLOTH_DISPOSED", "cloth has been disposed"); }
139
+ setColliders(input) { this.alive(); this.colliders = readColliders(input); this.contacts = []; }
140
+ setAnchor(index, position) {
141
+ this.alive();
142
+ const i = indexOf(index, this.positions.length), p = readVec(position, "anchor position");
143
+ this.anchors.set(i, p);
144
+ this.positions[i] = copy(p);
145
+ this.velocities[i] = [0, 0, 0];
146
+ this.forces[i] = [0, 0, 0];
147
+ }
148
+ releaseAnchor(index) { this.alive(); this.anchors.delete(indexOf(index, this.positions.length)); }
149
+ applyForce(index, input) {
150
+ this.alive();
151
+ const i = indexOf(index, this.positions.length), force = readVec(input, "force");
152
+ if (this.anchors.has(i))
153
+ fail("CLOTH_ANCHORED", "cannot apply force to an anchor");
154
+ const next = [0, 1, 2].map(axis => this.forces[i][axis] + force[axis]);
155
+ if (!vec(next))
156
+ fail("CLOTH_NUMERIC", "force accumulation exceeded bounds");
157
+ this.forces[i] = next;
158
+ }
159
+ applyImpulse(index, input) {
160
+ this.alive();
161
+ const i = indexOf(index, this.positions.length), impulse = readVec(input, "impulse");
162
+ if (this.anchors.has(i))
163
+ fail("CLOTH_ANCHORED", "cannot apply impulse to an anchor");
164
+ const next = [0, 1, 2].map(axis => this.velocities[i][axis] + impulse[axis] / this.config.mass);
165
+ if (!vec(next))
166
+ fail("CLOTH_NUMERIC", "impulse exceeded bounds");
167
+ this.velocities[i] = next;
168
+ }
169
+ /** A failed batch restores its pre-batch state. */
170
+ step(steps = 1) {
171
+ this.alive();
172
+ if (!Number.isSafeInteger(steps) || steps < 1 || steps > 1000 || this.tickValue + steps > Number.MAX_SAFE_INTEGER)
173
+ 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 };
175
+ try {
176
+ for (let i = 0; i < steps; i++)
177
+ this.stepOne();
178
+ }
179
+ catch (error) {
180
+ this.positions = before.positions;
181
+ this.velocities = before.velocities;
182
+ this.forces = before.forces;
183
+ this.contacts = before.contacts;
184
+ this.tickValue = before.tick;
185
+ throw error;
186
+ }
187
+ return this.inspect();
188
+ }
189
+ stepOne() {
190
+ const { fixedDt, substeps, iterations, mass, gravity, damping, constraints, radius } = this.config;
191
+ const h = fixedDt / substeps, decay = Math.exp(-damping * h);
192
+ const positions = this.positions.map(copy), velocities = this.velocities.map(copy), contacts = new Map();
193
+ for (let sub = 0; sub < substeps; sub++) {
194
+ const previous = positions.map(copy);
195
+ for (let i = 0; i < positions.length; i++) {
196
+ const anchor = this.anchors.get(i);
197
+ if (anchor) {
198
+ positions[i] = copy(anchor);
199
+ velocities[i] = [0, 0, 0];
200
+ continue;
201
+ }
202
+ for (let k = 0; k < 3; k++) {
203
+ velocities[i][k] = velocities[i][k] * decay + (gravity[k] + this.forces[i][k] / mass) * h;
204
+ positions[i][k] += velocities[i][k] * h;
205
+ }
206
+ }
207
+ for (let iter = 0; iter < iterations; iter++) {
208
+ for (const [i, j, rest] of constraints) {
209
+ const a = positions[i], b = positions[j];
210
+ let dx = b[0] - a[0], dy = b[1] - a[1], dz = b[2] - a[2], dist = Math.hypot(dx, dy, dz);
211
+ if (dist < 1e-12) {
212
+ dx = 1e-6;
213
+ dy = 0;
214
+ dz = 0;
215
+ dist = 1e-6;
216
+ }
217
+ const wa = this.anchors.has(i) ? 0 : 1, wb = this.anchors.has(j) ? 0 : 1;
218
+ if (wa + wb === 0)
219
+ continue;
220
+ const factor = (dist - rest) / (dist * (wa + wb));
221
+ for (let k = 0; k < 3; k++) {
222
+ const correction = (k === 0 ? dx : k === 1 ? dy : dz) * factor;
223
+ a[k] += correction * wa;
224
+ b[k] -= correction * wb;
225
+ }
226
+ }
227
+ for (let i = 0; i < positions.length; i++) {
228
+ if (this.anchors.has(i))
229
+ continue;
230
+ const p = positions[i];
231
+ for (const collider of this.colliders) {
232
+ let depth, normal;
233
+ if (collider.kind === "plane") {
234
+ normal = collider.normal;
235
+ depth = collider.offset + radius - (p[0] * normal[0] + p[1] * normal[1] + p[2] * normal[2]);
236
+ }
237
+ else {
238
+ const delta = [p[0] - collider.center[0], p[1] - collider.center[1], p[2] - collider.center[2]], length = Math.hypot(...delta);
239
+ depth = collider.radius + radius - length;
240
+ normal = length < 1e-12 ? [1, 0, 0] : [delta[0] / length, delta[1] / length, delta[2] / length];
241
+ }
242
+ if (depth <= 0)
243
+ continue;
244
+ for (let k = 0; k < 3; k++)
245
+ p[k] += normal[k] * depth;
246
+ const key = `${i}:${collider.id}`, old = contacts.get(key);
247
+ if (old)
248
+ old.penetration = Math.max(old.penetration, depth);
249
+ else
250
+ contacts.set(key, { tick: this.tickValue + 1, particle: i, colliderId: collider.id, penetration: depth });
251
+ }
252
+ }
253
+ }
254
+ for (let i = 0; i < positions.length; i++) {
255
+ if (this.anchors.has(i))
256
+ continue;
257
+ for (let k = 0; k < 3; k++)
258
+ velocities[i][k] = (positions[i][k] - previous[i][k]) / h;
259
+ if (!vec(positions[i]) || !vec(velocities[i]))
260
+ fail("CLOTH_NUMERIC", "solver exceeded finite state bounds");
261
+ }
262
+ }
263
+ this.positions = positions;
264
+ this.velocities = velocities;
265
+ this.forces = positions.map(() => [0, 0, 0]);
266
+ this.contacts = [...contacts.values()];
267
+ this.tickValue++;
268
+ }
269
+ inspect() {
270
+ 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 } };
272
+ let maxDistanceError = 0;
273
+ for (const [a, b, rest] of this.config.constraints)
274
+ 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,
276
+ positions: this.positions.map(copy), velocities: this.velocities.map(copy), triangles: this.config.triangles.map(tuple),
277
+ 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,
279
+ resources: { particles: this.positions.length, triangles: this.config.triangles.length, constraints: this.config.constraints.length, anchors: this.anchors.size, colliders: this.colliders.length } };
280
+ }
281
+ snapshot() {
282
+ this.alive();
283
+ const state = this.inspect();
284
+ return { format: "pelican-cloth-cpu", version: 1, tick: this.tickValue,
285
+ 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 };
287
+ }
288
+ restore(input) {
289
+ this.alive();
290
+ if (!input || typeof input !== "object" || input.format !== "pelican-cloth-cpu" || input.version !== 1 || !Number.isSafeInteger(input.tick) || input.tick < 0)
291
+ fail("CLOTH_SNAPSHOT", "unsupported snapshot format or tick");
292
+ const config = input.config, expected = this.config;
293
+ 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 ||
295
+ !vec(config.gravity) || config.gravity.some((value, i) => value !== expected.gravity[i]) ||
296
+ !Array.isArray(config.triangles) || JSON.stringify(config.triangles) !== JSON.stringify(expected.triangles) ||
297
+ !Array.isArray(config.constraints) || JSON.stringify(config.constraints) !== JSON.stringify(expected.constraints))
298
+ fail("CLOTH_SNAPSHOT", "cloth configuration or topology mismatch");
299
+ const count = this.positions.length;
300
+ if (!Array.isArray(input.positions) || !Array.isArray(input.velocities) || !Array.isArray(input.forces) ||
301
+ [input.positions, input.velocities, input.forces].some(values => values.length !== count || !values.every(vec)))
302
+ fail("CLOTH_SNAPSHOT", "invalid particle state");
303
+ const anchors = readAnchors(input.anchors, count);
304
+ 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)))
305
+ fail("CLOTH_SNAPSHOT", "inconsistent anchored particle state");
306
+ const colliders = readColliders(input.colliders), keys = new Set();
307
+ if (!Array.isArray(input.contacts) || input.contacts.length > count * colliders.length || input.contacts.some(contact => !contact || contact.tick !== input.tick ||
308
+ !Number.isSafeInteger(contact.particle) || contact.particle < 0 || contact.particle >= count || !colliders.some(collider => collider.id === contact.colliderId) ||
309
+ typeof contact.penetration !== "number" || !Number.isFinite(contact.penetration) || contact.penetration <= 0))
310
+ fail("CLOTH_SNAPSHOT", "invalid contact state");
311
+ for (const contact of input.contacts) {
312
+ const key = `${contact.particle}:${contact.colliderId}`;
313
+ if (keys.has(key))
314
+ fail("CLOTH_SNAPSHOT", "duplicate contact");
315
+ keys.add(key);
316
+ }
317
+ this.positions = input.positions.map(copy);
318
+ this.velocities = input.velocities.map(copy);
319
+ this.forces = input.forces.map(copy);
320
+ this.anchors = anchors;
321
+ this.colliders = colliders;
322
+ this.contacts = input.contacts.map(contact => ({ ...contact }));
323
+ this.tickValue = input.tick;
324
+ }
325
+ dispose() { if (this.disposed)
326
+ return; this.disposed = true; this.positions = []; this.velocities = []; this.forces = []; this.colliders = []; this.contacts = []; this.anchors.clear(); }
327
+ }
@@ -4,7 +4,7 @@ import { createRope } from "./rope.js";
4
4
  import { createRopeBodyCoupling, VirtualRopeSphere } from "./rope-body-coupling.js";
5
5
  const fail = (code, detail) => { throw new Error(`TC_ROPE_${code}: ${detail}`); };
6
6
  const validId = (id) => typeof id === "string" && id.trim() === id && id.length > 0 && id.length <= 128;
7
- function createEntry(input, fixedDt, materials, restoredSegmentMode) {
7
+ function createEntry(input, fixedDt, materials, restoredSegmentMode, restoredSelfMode) {
8
8
  const definition = cloneSerializable(input);
9
9
  if (!definition || !validId(definition.id))
10
10
  return fail("ID", "Expected a nonempty id of at most 128 characters.");
@@ -12,6 +12,10 @@ function createEntry(input, fixedDt, materials, restoredSegmentMode) {
12
12
  return fail("AUTO_STEP", "autoStep must be a boolean.");
13
13
  if (definition.segmentCollisions !== undefined && typeof definition.segmentCollisions !== "boolean")
14
14
  return fail("SEGMENT_COLLISION", "segmentCollisions must be a boolean.");
15
+ if (definition.selfCollisions !== undefined && typeof definition.selfCollisions !== "boolean")
16
+ return fail("SELF_COLLISION", "selfCollisions must be a boolean.");
17
+ if (restoredSelfMode !== undefined && definition.selfCollisions !== undefined && definition.selfCollisions !== restoredSelfMode)
18
+ return fail("SNAPSHOT", "Self collision configuration does not match the saved solver.");
15
19
  if (restoredSegmentMode !== undefined && definition.segmentCollisions !== undefined && definition.segmentCollisions !== restoredSegmentMode)
16
20
  return fail("SNAPSHOT", "Segment collision configuration does not match the saved solver.");
17
21
  if (definition.fixedDt !== undefined && definition.fixedDt !== fixedDt)
@@ -44,7 +48,8 @@ function createEntry(input, fixedDt, materials, restoredSegmentMode) {
44
48
  entities.add(binding.entity);
45
49
  }
46
50
  }
47
- const solver = createRope({ ...definition, fixedDt, ...(restoredSegmentMode === undefined ? {} : { segmentCollisions: restoredSegmentMode }) });
51
+ const solver = createRope({ ...definition, fixedDt, ...(restoredSegmentMode === undefined ? {} : { segmentCollisions: restoredSegmentMode }),
52
+ ...(restoredSelfMode === undefined ? {} : { selfCollisions: restoredSelfMode }) });
48
53
  return { definition, solver, bodyRefs: new Map(), staleBodies: new Set() };
49
54
  }
50
55
  function disposeEntries(entries) {
@@ -63,8 +68,11 @@ function parseSnapshot(value, fixedDt, materials) {
63
68
  const savedSegmentMode = item.state?.config?.segmentCollisions;
64
69
  if (savedSegmentMode !== undefined && typeof savedSegmentMode !== "boolean")
65
70
  fail("SNAPSHOT", "Invalid segment collision configuration.");
66
- // Snapshots made before segment contacts represent particle-only behavior.
67
- const entry = createEntry(item.definition, fixedDt, materials, savedSegmentMode ?? false);
71
+ const savedSelfMode = item.state?.config?.selfCollisions;
72
+ if (savedSelfMode !== undefined && typeof savedSelfMode !== "boolean")
73
+ fail("SNAPSHOT", "Invalid self collision configuration.");
74
+ // Missing flags in historical snapshots preserve their original disabled modes.
75
+ const entry = createEntry(item.definition, fixedDt, materials, savedSegmentMode ?? false, savedSelfMode ?? false);
68
76
  if (entries.has(entry.definition.id)) {
69
77
  entry.solver.dispose();
70
78
  fail("SNAPSHOT", "Duplicate rope id.");
@@ -0,0 +1,102 @@
1
+ const dot = (a, b) => a[0] * b[0] + a[1] * b[1] + a[2] * b[2];
2
+ const sub = (a, b) => [a[0] - b[0], a[1] - b[1], a[2] - b[2]];
3
+ const cross = (a, b) => [a[1] * b[2] - a[2] * b[1], a[2] * b[0] - a[0] * b[2], a[0] * b[1] - a[1] * b[0]];
4
+ const clamp = (value) => Math.max(0, Math.min(1, value));
5
+ const fail = (message) => { throw new Error(`ROPE_NUMERIC: ${message}`); };
6
+ /** Closest points of finite line segments; handles collapsed and near-parallel links. */
7
+ function closest(a, b, c, d) {
8
+ const u = sub(b, a), v = sub(d, c), w = sub(a, c);
9
+ const uu = dot(u, u), vv = dot(v, v), uv = dot(u, v), uw = dot(u, w), vw = dot(v, w);
10
+ let first = 0, second = 0;
11
+ if (uu <= 1e-24 && vv <= 1e-24) { /* both points */ }
12
+ else if (uu <= 1e-24)
13
+ second = clamp(vw / vv);
14
+ else if (vv <= 1e-24)
15
+ first = clamp(-uw / uu);
16
+ else {
17
+ const denominator = uu * vv - uv * uv;
18
+ if (denominator > 1e-12 * uu * vv)
19
+ first = clamp((uv * vw - uw * vv) / denominator);
20
+ second = (uv * first + vw) / vv;
21
+ if (second < 0) {
22
+ second = 0;
23
+ first = clamp(-uw / uu);
24
+ }
25
+ else if (second > 1) {
26
+ second = 1;
27
+ first = clamp((uv - uw) / uu);
28
+ }
29
+ }
30
+ const firstPoint = [a[0] + u[0] * first, a[1] + u[1] * first, a[2] + u[2] * first];
31
+ const secondPoint = [c[0] + v[0] * second, c[1] + v[1] * second, c[2] + v[2] * second];
32
+ if (![first, second, ...firstPoint, ...secondPoint].every(Number.isFinite))
33
+ fail("self-contact closest point is not finite");
34
+ return { first, second, firstPoint, secondPoint };
35
+ }
36
+ function fallbackNormal(firstDirection, secondDirection) {
37
+ let normal = cross(firstDirection, secondDirection), magnitude = Math.hypot(...normal);
38
+ if (magnitude < 1e-12) {
39
+ const direction = dot(firstDirection, firstDirection) >= dot(secondDirection, secondDirection) ? firstDirection : secondDirection;
40
+ const [x, y, z] = direction.map(Math.abs);
41
+ const axis = x <= y && x <= z ? [1, 0, 0] : y <= z ? [0, 1, 0] : [0, 0, 1];
42
+ normal = cross(direction, axis);
43
+ magnitude = Math.hypot(...normal);
44
+ }
45
+ return magnitude < 1e-12 ? [1, 0, 0] : [normal[0] / magnitude, normal[1] / magnitude, normal[2] / magnitude];
46
+ }
47
+ /** Sweep-and-prune broadphase followed by discrete capsule-segment projection.
48
+ * Bounds are rebuilt each iteration; contacts created by a correction may wait for the next pass.
49
+ * Adjacent segments are excluded. The caller owns rollback for these temporary positions. */
50
+ export function resolveRopeSelfCollisions(positions, anchors, radius, tick, contacts) {
51
+ if (positions.some(point => point.some(value => !Number.isFinite(value) || Math.abs(value) > 1e6)))
52
+ fail("particle position exceeded finite bounds before self-contact");
53
+ const bounds = [];
54
+ for (let index = 0; index < positions.length - 1; index++) {
55
+ const a = positions[index], b = positions[index + 1];
56
+ bounds.push({ index, minX: Math.min(a[0], b[0]) - radius, maxX: Math.max(a[0], b[0]) + radius,
57
+ minY: Math.min(a[1], b[1]) - radius, maxY: Math.max(a[1], b[1]) + radius,
58
+ minZ: Math.min(a[2], b[2]) - radius, maxZ: Math.max(a[2], b[2]) + radius });
59
+ }
60
+ bounds.sort((a, b) => a.minX - b.minX || a.index - b.index);
61
+ for (let left = 0; left < bounds.length; left++) {
62
+ const x = bounds[left];
63
+ for (let right = left + 1; right < bounds.length && bounds[right].minX <= x.maxX; right++) {
64
+ const y = bounds[right];
65
+ if (Math.abs(x.index - y.index) <= 1 || x.minY > y.maxY || y.minY > x.maxY || x.minZ > y.maxZ || y.minZ > x.maxZ)
66
+ continue;
67
+ const firstSegment = Math.min(x.index, y.index), secondSegment = Math.max(x.index, y.index);
68
+ const a = positions[firstSegment], b = positions[firstSegment + 1], c = positions[secondSegment], d = positions[secondSegment + 1];
69
+ const contact = closest(a, b, c, d), delta = sub(contact.firstPoint, contact.secondPoint), distance = Math.hypot(...delta);
70
+ const penetration = 2 * radius - distance;
71
+ if (!Number.isFinite(penetration))
72
+ fail("self-contact penetration is not finite");
73
+ if (penetration <= 0)
74
+ continue;
75
+ const normal = distance < 1e-12 ? fallbackNormal(sub(b, a), sub(d, c)) : [delta[0] / distance, delta[1] / distance, delta[2] / distance];
76
+ const indices = [firstSegment, firstSegment + 1, secondSegment, secondSegment + 1];
77
+ const gradients = [1 - contact.first, contact.first, -(1 - contact.second), -contact.second]
78
+ .map((weight, index) => anchors.has(indices[index]) ? 0 : weight);
79
+ const weight = gradients.reduce((sum, value) => sum + value * value, 0);
80
+ if (weight === 0)
81
+ continue; // four immovable endpoints cannot be projected
82
+ const magnitude = penetration / weight;
83
+ if (!Number.isFinite(magnitude))
84
+ fail("self-contact projection exceeded finite bounds");
85
+ for (let i = 0; i < 4; i++)
86
+ for (let axis = 0; axis < 3; axis++) {
87
+ const value = positions[indices[i]][axis] + normal[axis] * magnitude * gradients[i];
88
+ if (!Number.isFinite(value) || Math.abs(value) > 1e6)
89
+ fail("self-contact correction exceeded finite bounds");
90
+ positions[indices[i]][axis] = value;
91
+ }
92
+ const key = `${firstSegment}:${secondSegment}`, existing = contacts.get(key);
93
+ if (existing) {
94
+ existing.penetration = Math.max(existing.penetration, penetration);
95
+ existing.firstFraction = contact.first;
96
+ existing.secondFraction = contact.second;
97
+ }
98
+ else
99
+ contacts.set(key, { tick, firstSegment, secondSegment, firstFraction: contact.first, secondFraction: contact.second, penetration });
100
+ }
101
+ }
102
+ }
@@ -1,3 +1,4 @@
1
+ import { resolveRopeSelfCollisions } from "./rope-self-collision.js";
1
2
  export class RopeError extends Error {
2
3
  code;
3
4
  constructor(code, message) {
@@ -74,6 +75,9 @@ export function createRope(options) {
74
75
  if (options.segmentCollisions !== undefined && typeof options.segmentCollisions !== "boolean")
75
76
  fail("ROPE_INPUT", "segmentCollisions must be boolean");
76
77
  const segmentCollisions = options.segmentCollisions ?? true;
78
+ if (options.selfCollisions !== undefined && typeof options.selfCollisions !== "boolean")
79
+ fail("ROPE_INPUT", "selfCollisions must be boolean");
80
+ const selfCollisions = options.selfCollisions ?? true;
77
81
  const fixedDt = positive(options.fixedDt ?? 1 / 60, "fixedDt");
78
82
  if (fixedDt < 1 / 1000 || fixedDt > 1 / 30)
79
83
  fail("ROPE_INPUT", "fixedDt must be in [1/1000, 1/30]");
@@ -88,7 +92,7 @@ export function createRope(options) {
88
92
  const anchors = readAnchors(options.anchors ?? [{ index: 0, position: positions[0] }], positions.length);
89
93
  for (const [i, p] of anchors)
90
94
  positions[i] = copy(p);
91
- return new RopeSolver({ mass, radius, fixedDt, substeps, iterations, damping, gravity, restLengths, segmentCollisions }, positions, anchors);
95
+ return new RopeSolver({ mass, radius, fixedDt, substeps, iterations, damping, gravity, restLengths, segmentCollisions, selfCollisions }, positions, anchors);
92
96
  }
93
97
  export class RopeSolver {
94
98
  config;
@@ -101,6 +105,7 @@ export class RopeSolver {
101
105
  colliders = [];
102
106
  contacts = [];
103
107
  segmentContacts = [];
108
+ selfContacts = [];
104
109
  constructor(config, positions, anchors) {
105
110
  this.config = config;
106
111
  this.positions = positions;
@@ -163,6 +168,7 @@ export class RopeSolver {
163
168
  forces: this.forces,
164
169
  contacts: this.contacts,
165
170
  segmentContacts: this.segmentContacts,
171
+ selfContacts: this.selfContacts,
166
172
  tick: this.tickValue,
167
173
  };
168
174
  try {
@@ -175,6 +181,7 @@ export class RopeSolver {
175
181
  this.forces = before.forces;
176
182
  this.contacts = before.contacts;
177
183
  this.segmentContacts = before.segmentContacts;
184
+ this.selfContacts = before.selfContacts;
178
185
  this.tickValue = before.tick;
179
186
  throw error;
180
187
  }
@@ -187,6 +194,7 @@ export class RopeSolver {
187
194
  const positions = this.positions.map(copy), velocities = this.velocities.map(copy);
188
195
  const contacts = new Map();
189
196
  const segmentContacts = new Map();
197
+ const selfContacts = new Map();
190
198
  const spheres = this.config.segmentCollisions ? this.colliders.filter((collider) => collider.kind === "sphere") : [];
191
199
  for (let sub = 0; sub < substeps; sub++) {
192
200
  const previous = positions.map(copy);
@@ -357,6 +365,8 @@ export class RopeSolver {
357
365
  segmentContacts.set(key, { tick: this.tickValue + 1, segment: j, colliderId: collider.id, fraction, penetration: depth, reactionImpulse: reaction, reactionAngularImpulse: moment });
358
366
  }
359
367
  }
368
+ if (this.config.selfCollisions && positions.length >= 4)
369
+ resolveRopeSelfCollisions(positions, this.anchors, radius, this.tickValue + 1, selfContacts);
360
370
  }
361
371
  for (let i = 0; i < positions.length; i++) {
362
372
  if (this.anchors.has(i))
@@ -372,22 +382,23 @@ export class RopeSolver {
372
382
  this.forces = positions.map(() => [0, 0, 0]);
373
383
  this.contacts = [...contacts.values()];
374
384
  this.segmentContacts = [...segmentContacts.values()];
385
+ this.selfContacts = [...selfContacts.values()];
375
386
  this.tickValue++;
376
387
  }
377
388
  /** Detached positions for renderer synchronization without copying contact state. */
378
389
  particlePositions() { this.alive(); return this.positions.map(copy); }
379
390
  inspect() {
380
391
  if (this.disposedValue)
381
- return { tick: this.tickValue, simulatedSeconds: this.tickValue * this.config.fixedDt, disposed: true, segmentCollisions: this.config.segmentCollisions, positions: [], velocities: [], anchors: [], contacts: [], segmentContacts: [], maxDistanceError: 0, resources: { particles: 0, constraints: 0, anchors: 0, colliders: 0 } };
392
+ return { tick: this.tickValue, simulatedSeconds: this.tickValue * this.config.fixedDt, disposed: true, segmentCollisions: this.config.segmentCollisions, selfCollisions: this.config.selfCollisions, positions: [], velocities: [], anchors: [], contacts: [], segmentContacts: [], selfContacts: [], maxDistanceError: 0, resources: { particles: 0, constraints: 0, anchors: 0, colliders: 0 } };
382
393
  const maxDistanceError = Math.max(0, ...this.config.restLengths.map((d, i) => {
383
394
  const a = this.positions[i], b = this.positions[i + 1];
384
395
  return Math.abs(Math.hypot(a[0] - b[0], a[1] - b[1], a[2] - b[2]) - d);
385
396
  }));
386
397
  return {
387
- tick: this.tickValue, simulatedSeconds: this.tickValue * this.config.fixedDt, disposed: false, segmentCollisions: this.config.segmentCollisions,
398
+ tick: this.tickValue, simulatedSeconds: this.tickValue * this.config.fixedDt, disposed: false, segmentCollisions: this.config.segmentCollisions, selfCollisions: this.config.selfCollisions,
388
399
  positions: this.positions.map(copy), velocities: this.velocities.map(copy),
389
400
  anchors: [...this.anchors].map(([index, position]) => ({ index, position: copy(position) })).sort((a, b) => a.index - b.index),
390
- contacts: this.contacts.map(copyContact), segmentContacts: this.segmentContacts.map(copySegmentContact), maxDistanceError,
401
+ contacts: this.contacts.map(copyContact), segmentContacts: this.segmentContacts.map(copySegmentContact), selfContacts: this.selfContacts.map(contact => ({ ...contact })), maxDistanceError,
391
402
  resources: { particles: this.positions.length, constraints: this.config.restLengths.length, anchors: this.anchors.size, colliders: this.colliders.length },
392
403
  };
393
404
  }
@@ -398,7 +409,7 @@ export class RopeSolver {
398
409
  format: "pelican-rope-cpu", version: 1, tick: this.tickValue,
399
410
  config: { ...this.config, gravity: copy(this.config.gravity), restLengths: [...this.config.restLengths] },
400
411
  positions: state.positions, velocities: state.velocities, anchors: state.anchors,
401
- forces: this.forces.map(copy), colliders: readColliders(this.colliders), contacts: state.contacts, segmentContacts: state.segmentContacts,
412
+ forces: this.forces.map(copy), colliders: readColliders(this.colliders), contacts: state.contacts, segmentContacts: state.segmentContacts, selfContacts: state.selfContacts,
402
413
  };
403
414
  }
404
415
  /** Accepts a JSON-round-tripped snapshot only from the same rope configuration. Atomic on failure. */
@@ -411,6 +422,7 @@ export class RopeSolver {
411
422
  if (!c || c.mass !== this.config.mass || c.radius !== this.config.radius || c.fixedDt !== this.config.fixedDt ||
412
423
  c.substeps !== this.config.substeps || c.iterations !== this.config.iterations || c.damping !== this.config.damping ||
413
424
  (c.segmentCollisions !== undefined && typeof c.segmentCollisions !== "boolean") || (c.segmentCollisions ?? false) !== this.config.segmentCollisions ||
425
+ (c.selfCollisions !== undefined && typeof c.selfCollisions !== "boolean") || (c.selfCollisions ?? false) !== this.config.selfCollisions ||
414
426
  !vec(c.gravity) || c.gravity.some((v, i) => v !== this.config.gravity[i]) ||
415
427
  !Array.isArray(c.restLengths) || c.restLengths.length !== this.config.restLengths.length ||
416
428
  c.restLengths.some((v, i) => v !== this.config.restLengths[i]))
@@ -448,6 +460,23 @@ export class RopeSolver {
448
460
  fail("ROPE_SNAPSHOT", "duplicate segment contact");
449
461
  segmentKeys.add(key);
450
462
  }
463
+ const self = input.selfContacts === undefined ? [] : input.selfContacts;
464
+ const maxSelfContacts = Math.max(0, (count - 2) * (count - 3) / 2);
465
+ if (!Array.isArray(self) || self.length > maxSelfContacts || (!this.config.selfCollisions && self.length > 0) ||
466
+ self.some(contact => !contact || contact.tick !== input.tick ||
467
+ !Number.isSafeInteger(contact.firstSegment) || !Number.isSafeInteger(contact.secondSegment) ||
468
+ contact.firstSegment < 0 || contact.secondSegment >= count - 1 || contact.secondSegment - contact.firstSegment <= 1 ||
469
+ typeof contact.firstFraction !== "number" || !Number.isFinite(contact.firstFraction) || contact.firstFraction < 0 || contact.firstFraction > 1 ||
470
+ typeof contact.secondFraction !== "number" || !Number.isFinite(contact.secondFraction) || contact.secondFraction < 0 || contact.secondFraction > 1 ||
471
+ typeof contact.penetration !== "number" || !Number.isFinite(contact.penetration) || contact.penetration <= 0))
472
+ fail("ROPE_SNAPSHOT", "invalid self contact state");
473
+ const selfKeys = new Set();
474
+ for (const contact of self) {
475
+ const key = `${contact.firstSegment}:${contact.secondSegment}`;
476
+ if (selfKeys.has(key))
477
+ fail("ROPE_SNAPSHOT", "duplicate self contact");
478
+ selfKeys.add(key);
479
+ }
451
480
  this.positions = input.positions.map(copy);
452
481
  this.velocities = input.velocities.map(copy);
453
482
  this.forces = input.forces.map(copy);
@@ -455,6 +484,7 @@ export class RopeSolver {
455
484
  this.colliders = colliders;
456
485
  this.contacts = input.contacts.map(copyContact);
457
486
  this.segmentContacts = segments.map(copySegmentContact);
487
+ this.selfContacts = self.map(contact => ({ ...contact }));
458
488
  this.tickValue = input.tick;
459
489
  }
460
490
  dispose() {
@@ -467,6 +497,7 @@ export class RopeSolver {
467
497
  this.colliders = [];
468
498
  this.contacts = [];
469
499
  this.segmentContacts = [];
500
+ this.selfContacts = [];
470
501
  this.anchors.clear();
471
502
  }
472
503
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pelican-engine",
3
- "version": "0.3.16",
3
+ "version": "0.3.18",
4
4
  "description": "Pelican Engine public SDK and offline development CLI",
5
5
  "type": "module",
6
6
  "license": "MIT",