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.
- package/AGENTS.md +37 -3
- package/distribution.json +19 -15
- package/docs/STATUS.md +3 -2
- package/docs/api/REFERENCE.md +227 -97
- package/docs/api/index.json +228 -101
- package/docs/npm/GAME-AUTHOR.md +37 -3
- package/engine.manifest.json +1 -1
- package/lib/renderer/browser.js +21 -13
- package/lib/renderer/cloth-visuals.js +140 -0
- package/lib/renderer/gameplay.js +2 -0
- package/lib/runtime/host.js +3 -0
- package/lib/sdk/index.d.ts +99 -2
- package/lib/sdk/index.js +1 -1
- package/lib/simulation/cloth-plugin.js +152 -0
- package/lib/simulation/cloth.js +327 -0
- package/lib/simulation/rope-plugin.js +12 -4
- package/lib/simulation/rope-self-collision.js +102 -0
- package/lib/simulation/rope.js +36 -5
- package/package.json +1 -1
|
@@ -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
|
-
|
|
67
|
-
|
|
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
|
+
}
|
package/lib/simulation/rope.js
CHANGED
|
@@ -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
|
}
|