pelican-engine 0.3.15 → 0.3.16
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 +16 -3
- package/distribution.json +12 -12
- package/docs/STATUS.md +2 -2
- package/docs/api/REFERENCE.md +113 -91
- package/docs/api/index.json +111 -93
- package/docs/npm/GAME-AUTHOR.md +16 -3
- package/engine.manifest.json +1 -1
- package/lib/sdk/index.d.ts +18 -2
- package/lib/sdk/index.js +1 -1
- package/lib/simulation/rope-plugin.js +11 -3
- package/lib/simulation/rope.js +114 -5
- package/package.json +1 -1
package/docs/npm/GAME-AUTHOR.md
CHANGED
|
@@ -137,6 +137,19 @@ all earlier automatic ropes from that phase; the host tick may still have other
|
|
|
137
137
|
partial changes and must be recovered from a known-good checkpoint. The source
|
|
138
138
|
checkout includes `examples/rope-particles`.
|
|
139
139
|
|
|
140
|
+
New ropes resolve discrete sphere overlaps along segment interiors as well as at
|
|
141
|
+
particles. Corrections are distributed to movable endpoints; a segment with two
|
|
142
|
+
fixed anchors cannot move. `ctx.ropes.inspect(id).segmentContacts` reports the
|
|
143
|
+
segment index, closest-point fraction and reaction impulses. Set
|
|
144
|
+
`segmentCollisions: false` to retain particle-only behavior. Historical rope
|
|
145
|
+
solver snapshots without this field retain particle-only behavior when migrated;
|
|
146
|
+
`inspect(id).segmentCollisions` reports the active mode. Normal Host snapshot
|
|
147
|
+
engine-version restrictions still apply. Plane contacts use particle endpoints.
|
|
148
|
+
The segment index names its first particle; `fraction` runs toward the next.
|
|
149
|
+
Work increases with segment, sphere, substep and iteration counts. This does not
|
|
150
|
+
provide continuous collision detection or self collision, and the segment solver
|
|
151
|
+
and snapshot migration have not been runtime validated.
|
|
152
|
+
|
|
140
153
|
For optional visual presentation, declare a material in `defineGame({ materials:
|
|
141
154
|
[...] })` and create the rope with `visual: { material: "your-material-id" }`.
|
|
142
155
|
The renderer draws capped cylindrical instances between consecutive simulated
|
|
@@ -181,8 +194,8 @@ automatically act on a rigid body.
|
|
|
181
194
|
For an explicitly mapped dynamic body, game code can read its actual world center
|
|
182
195
|
of mass via `ctx.physics.centerOfMass(id)`, apply the linear impulse, and apply
|
|
183
196
|
`reactionAngularImpulse - centerOfMass × reactionImpulse` as torque impulse.
|
|
184
|
-
|
|
185
|
-
the same contacts. The source checkout's `examples/rope-reaction` uses the
|
|
197
|
+
For manual coupling, include both `contacts` and `segmentContacts` and apply each
|
|
198
|
+
contact at most once for its `tick`; repeated `inspect` calls return the same contacts. The source checkout's `examples/rope-reaction` uses the
|
|
186
199
|
automatic binding instead.
|
|
187
200
|
|
|
188
201
|
This sampled, manually applied reaction is delayed and uses positional correction,
|
|
@@ -193,7 +206,7 @@ checkpoint. It must not be represented as complete two-way rigid-body interactio
|
|
|
193
206
|
|
|
194
207
|
This is an unverified development-stage increment, not complete rope physics:
|
|
195
208
|
the optional segmented visual does not provide a continuous deformable rope mesh,
|
|
196
|
-
general rigid-body coupling,
|
|
209
|
+
general rigid-body coupling, self collision, or tearing. Plane normals must be unit length; `offset` is the signed
|
|
197
210
|
normal distance from the origin. Host intervals must be between 1/1000 and
|
|
198
211
|
1/30 seconds inclusive.
|
|
199
212
|
|
package/engine.manifest.json
CHANGED
package/lib/sdk/index.d.ts
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
/** Pelican Engine public API. No renderer or GPU implementation types escape this module. */
|
|
2
|
-
export declare const SDK_VERSION = "0.3.
|
|
2
|
+
export declare const SDK_VERSION = "0.3.16";
|
|
3
3
|
export type Vec2 = readonly [number, number];
|
|
4
4
|
export type Vec3 = readonly [number, number, number];
|
|
5
5
|
export type Vec4 = readonly [number, number, number, number];
|
|
@@ -104,7 +104,7 @@ export interface SystemContext extends EngineFacade {
|
|
|
104
104
|
readonly ropes: RopeService;
|
|
105
105
|
}
|
|
106
106
|
/** CPU particles with sampled sphere/plane collisions. Explicit body bindings apply delayed
|
|
107
|
-
* contact impulses to dynamic spheres; there is no full rigid-body coupling or
|
|
107
|
+
* contact impulses to dynamic spheres; there is no full rigid-body coupling or self collision. */
|
|
108
108
|
export type RopeCollider = {
|
|
109
109
|
kind: "sphere";
|
|
110
110
|
id: string;
|
|
@@ -151,6 +151,8 @@ export interface RopeDefinition {
|
|
|
151
151
|
autoStep?: boolean;
|
|
152
152
|
/** Automatically sample and react to existing dynamic, non-trigger sphere bodies. Requires autoStep. */
|
|
153
153
|
bodyColliders?: readonly RopeBodyCollider[];
|
|
154
|
+
/** Resolve sphere contacts on segment interiors; defaults to true for new ropes. */
|
|
155
|
+
segmentCollisions?: boolean;
|
|
154
156
|
/** Optional segmented presentation using an already-declared material id. */
|
|
155
157
|
visual?: RopeVisualOptions;
|
|
156
158
|
substeps?: number;
|
|
@@ -173,10 +175,23 @@ export interface RopeContact {
|
|
|
173
175
|
/** Angular impulse about the world origin; absent in older snapshots. */
|
|
174
176
|
reactionAngularImpulse?: Vec3;
|
|
175
177
|
}
|
|
178
|
+
/** Contact between an interior rope segment and a sampled sphere. */
|
|
179
|
+
export interface RopeSegmentContact {
|
|
180
|
+
tick: number;
|
|
181
|
+
/** Zero-based index of the first endpoint; the second is segment + 1. */
|
|
182
|
+
segment: number;
|
|
183
|
+
colliderId: string;
|
|
184
|
+
/** Last closest-point fraction from the first endpoint toward the second. */
|
|
185
|
+
fraction: number;
|
|
186
|
+
penetration: number;
|
|
187
|
+
reactionImpulse?: Vec3;
|
|
188
|
+
reactionAngularImpulse?: Vec3;
|
|
189
|
+
}
|
|
176
190
|
export interface RopeState {
|
|
177
191
|
tick: number;
|
|
178
192
|
simulatedSeconds: number;
|
|
179
193
|
disposed: boolean;
|
|
194
|
+
segmentCollisions: boolean;
|
|
180
195
|
positions: Vec3[];
|
|
181
196
|
velocities: Vec3[];
|
|
182
197
|
anchors: {
|
|
@@ -184,6 +199,7 @@ export interface RopeState {
|
|
|
184
199
|
position: Vec3;
|
|
185
200
|
}[];
|
|
186
201
|
contacts: RopeContact[];
|
|
202
|
+
segmentContacts: RopeSegmentContact[];
|
|
187
203
|
maxDistanceError: number;
|
|
188
204
|
resources: {
|
|
189
205
|
particles: number;
|
package/lib/sdk/index.js
CHANGED
|
@@ -4,12 +4,16 @@ 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) {
|
|
7
|
+
function createEntry(input, fixedDt, materials, restoredSegmentMode) {
|
|
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.");
|
|
11
11
|
if (definition.autoStep !== undefined && typeof definition.autoStep !== "boolean")
|
|
12
12
|
return fail("AUTO_STEP", "autoStep must be a boolean.");
|
|
13
|
+
if (definition.segmentCollisions !== undefined && typeof definition.segmentCollisions !== "boolean")
|
|
14
|
+
return fail("SEGMENT_COLLISION", "segmentCollisions must be a boolean.");
|
|
15
|
+
if (restoredSegmentMode !== undefined && definition.segmentCollisions !== undefined && definition.segmentCollisions !== restoredSegmentMode)
|
|
16
|
+
return fail("SNAPSHOT", "Segment collision configuration does not match the saved solver.");
|
|
13
17
|
if (definition.fixedDt !== undefined && definition.fixedDt !== fixedDt)
|
|
14
18
|
return fail("FIXED_DT", "Rope fixedDt must exactly match the host fixedDt.");
|
|
15
19
|
if (fixedDt < 1 / 1000 || fixedDt > 1 / 30)
|
|
@@ -40,7 +44,7 @@ function createEntry(input, fixedDt, materials) {
|
|
|
40
44
|
entities.add(binding.entity);
|
|
41
45
|
}
|
|
42
46
|
}
|
|
43
|
-
const solver = createRope({ ...definition, fixedDt });
|
|
47
|
+
const solver = createRope({ ...definition, fixedDt, ...(restoredSegmentMode === undefined ? {} : { segmentCollisions: restoredSegmentMode }) });
|
|
44
48
|
return { definition, solver, bodyRefs: new Map(), staleBodies: new Set() };
|
|
45
49
|
}
|
|
46
50
|
function disposeEntries(entries) {
|
|
@@ -56,7 +60,11 @@ function parseSnapshot(value, fixedDt, materials) {
|
|
|
56
60
|
for (const item of value.ropes) {
|
|
57
61
|
if (!item || typeof item !== "object")
|
|
58
62
|
fail("SNAPSHOT", "Invalid rope entry.");
|
|
59
|
-
const
|
|
63
|
+
const savedSegmentMode = item.state?.config?.segmentCollisions;
|
|
64
|
+
if (savedSegmentMode !== undefined && typeof savedSegmentMode !== "boolean")
|
|
65
|
+
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);
|
|
60
68
|
if (entries.has(entry.definition.id)) {
|
|
61
69
|
entry.solver.dispose();
|
|
62
70
|
fail("SNAPSHOT", "Duplicate rope id.");
|
package/lib/simulation/rope.js
CHANGED
|
@@ -18,6 +18,11 @@ const copyContact = (contact) => {
|
|
|
18
18
|
...(reactionAngularImpulse === undefined ? {} : { reactionAngularImpulse: copy(reactionAngularImpulse) }),
|
|
19
19
|
};
|
|
20
20
|
};
|
|
21
|
+
const copySegmentContact = (contact) => {
|
|
22
|
+
const { reactionImpulse, reactionAngularImpulse, ...rest } = contact;
|
|
23
|
+
return { ...rest, ...(reactionImpulse === undefined ? {} : { reactionImpulse: copy(reactionImpulse) }),
|
|
24
|
+
...(reactionAngularImpulse === undefined ? {} : { reactionAngularImpulse: copy(reactionAngularImpulse) }) };
|
|
25
|
+
};
|
|
21
26
|
const length = (v) => Math.hypot(v[0], v[1], v[2]);
|
|
22
27
|
const validVec = (v, name) => vec(v) ? copy(v) : fail("ROPE_INPUT", `${name} must be a finite 3D vector (|component| <= 1e6)`);
|
|
23
28
|
const positive = (v, name) => typeof v === "number" && Number.isFinite(v) && v > 0 && v <= 1e6 ? v : fail("ROPE_INPUT", `${name} must be positive and <= 1e6`);
|
|
@@ -66,6 +71,9 @@ export function createRope(options) {
|
|
|
66
71
|
const positions = options.points.map((p, i) => validVec(p, `points[${i}]`));
|
|
67
72
|
const mass = positive(options.mass, "mass");
|
|
68
73
|
const radius = positive(options.radius ?? 0.025, "radius");
|
|
74
|
+
if (options.segmentCollisions !== undefined && typeof options.segmentCollisions !== "boolean")
|
|
75
|
+
fail("ROPE_INPUT", "segmentCollisions must be boolean");
|
|
76
|
+
const segmentCollisions = options.segmentCollisions ?? true;
|
|
69
77
|
const fixedDt = positive(options.fixedDt ?? 1 / 60, "fixedDt");
|
|
70
78
|
if (fixedDt < 1 / 1000 || fixedDt > 1 / 30)
|
|
71
79
|
fail("ROPE_INPUT", "fixedDt must be in [1/1000, 1/30]");
|
|
@@ -80,7 +88,7 @@ export function createRope(options) {
|
|
|
80
88
|
const anchors = readAnchors(options.anchors ?? [{ index: 0, position: positions[0] }], positions.length);
|
|
81
89
|
for (const [i, p] of anchors)
|
|
82
90
|
positions[i] = copy(p);
|
|
83
|
-
return new RopeSolver({ mass, radius, fixedDt, substeps, iterations, damping, gravity, restLengths }, positions, anchors);
|
|
91
|
+
return new RopeSolver({ mass, radius, fixedDt, substeps, iterations, damping, gravity, restLengths, segmentCollisions }, positions, anchors);
|
|
84
92
|
}
|
|
85
93
|
export class RopeSolver {
|
|
86
94
|
config;
|
|
@@ -92,6 +100,7 @@ export class RopeSolver {
|
|
|
92
100
|
forces;
|
|
93
101
|
colliders = [];
|
|
94
102
|
contacts = [];
|
|
103
|
+
segmentContacts = [];
|
|
95
104
|
constructor(config, positions, anchors) {
|
|
96
105
|
this.config = config;
|
|
97
106
|
this.positions = positions;
|
|
@@ -106,6 +115,7 @@ export class RopeSolver {
|
|
|
106
115
|
this.alive();
|
|
107
116
|
this.colliders = readColliders(input);
|
|
108
117
|
this.contacts = [];
|
|
118
|
+
this.segmentContacts = [];
|
|
109
119
|
}
|
|
110
120
|
/** Pins or moves a particle; new anchor position takes effect immediately. */
|
|
111
121
|
setAnchor(index, position) {
|
|
@@ -152,6 +162,7 @@ export class RopeSolver {
|
|
|
152
162
|
velocities: this.velocities,
|
|
153
163
|
forces: this.forces,
|
|
154
164
|
contacts: this.contacts,
|
|
165
|
+
segmentContacts: this.segmentContacts,
|
|
155
166
|
tick: this.tickValue,
|
|
156
167
|
};
|
|
157
168
|
try {
|
|
@@ -163,6 +174,7 @@ export class RopeSolver {
|
|
|
163
174
|
this.velocities = before.velocities;
|
|
164
175
|
this.forces = before.forces;
|
|
165
176
|
this.contacts = before.contacts;
|
|
177
|
+
this.segmentContacts = before.segmentContacts;
|
|
166
178
|
this.tickValue = before.tick;
|
|
167
179
|
throw error;
|
|
168
180
|
}
|
|
@@ -174,6 +186,8 @@ export class RopeSolver {
|
|
|
174
186
|
// Work on copies; numeric failures must never leave half a tick applied.
|
|
175
187
|
const positions = this.positions.map(copy), velocities = this.velocities.map(copy);
|
|
176
188
|
const contacts = new Map();
|
|
189
|
+
const segmentContacts = new Map();
|
|
190
|
+
const spheres = this.config.segmentCollisions ? this.colliders.filter((collider) => collider.kind === "sphere") : [];
|
|
177
191
|
for (let sub = 0; sub < substeps; sub++) {
|
|
178
192
|
const previous = positions.map(copy);
|
|
179
193
|
for (let i = 0; i < positions.length; i++) {
|
|
@@ -269,6 +283,80 @@ export class RopeSolver {
|
|
|
269
283
|
contacts.set(key, { tick: this.tickValue + 1, particle: i, colliderId: c.id, penetration: depth, reactionImpulse: reaction, reactionAngularImpulse: moment });
|
|
270
284
|
}
|
|
271
285
|
}
|
|
286
|
+
// Resolve interior segment/sphere overlaps after particle contacts. Endpoint
|
|
287
|
+
// overlaps are already handled by the particle pass above.
|
|
288
|
+
for (let j = 0; j < restLengths.length; j++) {
|
|
289
|
+
const a = positions[j], b = positions[j + 1];
|
|
290
|
+
for (const collider of spheres) {
|
|
291
|
+
const dynamic = coupling?.sphere(collider.id);
|
|
292
|
+
const center = dynamic?.center ?? collider.center, colliderRadius = dynamic?.radius ?? collider.radius;
|
|
293
|
+
const direction = [b[0] - a[0], b[1] - a[1], b[2] - a[2]];
|
|
294
|
+
const squared = direction[0] * direction[0] + direction[1] * direction[1] + direction[2] * direction[2];
|
|
295
|
+
if (squared < 1e-24)
|
|
296
|
+
continue; // collapsed segment is covered by particle contacts
|
|
297
|
+
const fraction = Math.max(0, Math.min(1, ((center[0] - a[0]) * direction[0] + (center[1] - a[1]) * direction[1] + (center[2] - a[2]) * direction[2]) / squared));
|
|
298
|
+
if (fraction <= 0 || fraction >= 1)
|
|
299
|
+
continue;
|
|
300
|
+
const closest = [a[0] + direction[0] * fraction, a[1] + direction[1] * fraction, a[2] + direction[2] * fraction];
|
|
301
|
+
const delta = [closest[0] - center[0], closest[1] - center[1], closest[2] - center[2]];
|
|
302
|
+
const distance = length(delta), depth = colliderRadius + radius - distance;
|
|
303
|
+
if (depth <= 0)
|
|
304
|
+
continue;
|
|
305
|
+
let normal;
|
|
306
|
+
if (distance >= 1e-12)
|
|
307
|
+
normal = [delta[0] / distance, delta[1] / distance, delta[2] / distance];
|
|
308
|
+
else {
|
|
309
|
+
// Choose a deterministic perpendicular if the sphere center lies exactly on the segment.
|
|
310
|
+
const [x, y, z] = direction.map(Math.abs);
|
|
311
|
+
const axis = x <= y && x <= z ? [1, 0, 0] : y <= z ? [0, 1, 0] : [0, 0, 1];
|
|
312
|
+
const perpendicular = [direction[1] * axis[2] - direction[2] * axis[1], direction[2] * axis[0] - direction[0] * axis[2], direction[0] * axis[1] - direction[1] * axis[0]];
|
|
313
|
+
const magnitude = length(perpendicular);
|
|
314
|
+
normal = [perpendicular[0] / magnitude, perpendicular[1] / magnitude, perpendicular[2] / magnitude];
|
|
315
|
+
}
|
|
316
|
+
const first = this.anchors.has(j) ? 0 : 1 - fraction, second = this.anchors.has(j + 1) ? 0 : fraction;
|
|
317
|
+
const inverseWeight = first * first + second * second;
|
|
318
|
+
if (inverseWeight === 0)
|
|
319
|
+
continue; // two authoritative anchors cannot move
|
|
320
|
+
const contactMass = mass / inverseWeight;
|
|
321
|
+
if (!Number.isFinite(contactMass) || contactMass <= 0)
|
|
322
|
+
fail("ROPE_NUMERIC", "segment contact mass exceeded finite bounds");
|
|
323
|
+
const previousPoint = [0, 1, 2].map(k => previous[j][k] * (1 - fraction) + previous[j + 1][k] * fraction);
|
|
324
|
+
let correction, reaction, moment;
|
|
325
|
+
if (dynamic && coupling) {
|
|
326
|
+
const resolved = coupling.resolve(collider.id, normal, depth, closest, previousPoint, contactMass, h);
|
|
327
|
+
correction = resolved.correction;
|
|
328
|
+
reaction = resolved.reactionImpulse;
|
|
329
|
+
moment = resolved.reactionAngularImpulse;
|
|
330
|
+
}
|
|
331
|
+
else {
|
|
332
|
+
correction = [normal[0] * depth, normal[1] * depth, normal[2] * depth];
|
|
333
|
+
const magnitude = -contactMass * depth / h;
|
|
334
|
+
reaction = [normal[0] * magnitude, normal[1] * magnitude, normal[2] * magnitude];
|
|
335
|
+
const point = [center[0] + normal[0] * colliderRadius, center[1] + normal[1] * colliderRadius, center[2] + normal[2] * colliderRadius];
|
|
336
|
+
moment = [point[1] * reaction[2] - point[2] * reaction[1], point[2] * reaction[0] - point[0] * reaction[2], point[0] * reaction[1] - point[1] * reaction[0]];
|
|
337
|
+
}
|
|
338
|
+
if (!finiteVector(correction) || !finiteVector(reaction) || !finiteVector(moment))
|
|
339
|
+
fail("ROPE_NUMERIC", "segment contact exceeded finite bounds");
|
|
340
|
+
// Gradients of the closest point distribute correction to movable endpoints.
|
|
341
|
+
for (let k = 0; k < 3; k++) {
|
|
342
|
+
a[k] += correction[k] * first / inverseWeight;
|
|
343
|
+
b[k] += correction[k] * second / inverseWeight;
|
|
344
|
+
}
|
|
345
|
+
const key = `${j}:${collider.id}`, existing = segmentContacts.get(key);
|
|
346
|
+
if (existing) {
|
|
347
|
+
existing.penetration = Math.max(existing.penetration, depth);
|
|
348
|
+
existing.fraction = fraction;
|
|
349
|
+
for (let k = 0; k < 3; k++) {
|
|
350
|
+
existing.reactionImpulse[k] += reaction[k];
|
|
351
|
+
existing.reactionAngularImpulse[k] += moment[k];
|
|
352
|
+
}
|
|
353
|
+
if (!finiteVector(existing.reactionImpulse) || !finiteVector(existing.reactionAngularImpulse))
|
|
354
|
+
fail("ROPE_NUMERIC", "segment reaction accumulation exceeded finite bounds");
|
|
355
|
+
}
|
|
356
|
+
else
|
|
357
|
+
segmentContacts.set(key, { tick: this.tickValue + 1, segment: j, colliderId: collider.id, fraction, penetration: depth, reactionImpulse: reaction, reactionAngularImpulse: moment });
|
|
358
|
+
}
|
|
359
|
+
}
|
|
272
360
|
}
|
|
273
361
|
for (let i = 0; i < positions.length; i++) {
|
|
274
362
|
if (this.anchors.has(i))
|
|
@@ -283,22 +371,23 @@ export class RopeSolver {
|
|
|
283
371
|
this.velocities = velocities;
|
|
284
372
|
this.forces = positions.map(() => [0, 0, 0]);
|
|
285
373
|
this.contacts = [...contacts.values()];
|
|
374
|
+
this.segmentContacts = [...segmentContacts.values()];
|
|
286
375
|
this.tickValue++;
|
|
287
376
|
}
|
|
288
377
|
/** Detached positions for renderer synchronization without copying contact state. */
|
|
289
378
|
particlePositions() { this.alive(); return this.positions.map(copy); }
|
|
290
379
|
inspect() {
|
|
291
380
|
if (this.disposedValue)
|
|
292
|
-
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 } };
|
|
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 } };
|
|
293
382
|
const maxDistanceError = Math.max(0, ...this.config.restLengths.map((d, i) => {
|
|
294
383
|
const a = this.positions[i], b = this.positions[i + 1];
|
|
295
384
|
return Math.abs(Math.hypot(a[0] - b[0], a[1] - b[1], a[2] - b[2]) - d);
|
|
296
385
|
}));
|
|
297
386
|
return {
|
|
298
|
-
tick: this.tickValue, simulatedSeconds: this.tickValue * this.config.fixedDt, disposed: false,
|
|
387
|
+
tick: this.tickValue, simulatedSeconds: this.tickValue * this.config.fixedDt, disposed: false, segmentCollisions: this.config.segmentCollisions,
|
|
299
388
|
positions: this.positions.map(copy), velocities: this.velocities.map(copy),
|
|
300
389
|
anchors: [...this.anchors].map(([index, position]) => ({ index, position: copy(position) })).sort((a, b) => a.index - b.index),
|
|
301
|
-
contacts: this.contacts.map(copyContact), maxDistanceError,
|
|
390
|
+
contacts: this.contacts.map(copyContact), segmentContacts: this.segmentContacts.map(copySegmentContact), maxDistanceError,
|
|
302
391
|
resources: { particles: this.positions.length, constraints: this.config.restLengths.length, anchors: this.anchors.size, colliders: this.colliders.length },
|
|
303
392
|
};
|
|
304
393
|
}
|
|
@@ -309,7 +398,7 @@ export class RopeSolver {
|
|
|
309
398
|
format: "pelican-rope-cpu", version: 1, tick: this.tickValue,
|
|
310
399
|
config: { ...this.config, gravity: copy(this.config.gravity), restLengths: [...this.config.restLengths] },
|
|
311
400
|
positions: state.positions, velocities: state.velocities, anchors: state.anchors,
|
|
312
|
-
forces: this.forces.map(copy), colliders: readColliders(this.colliders), contacts: state.contacts,
|
|
401
|
+
forces: this.forces.map(copy), colliders: readColliders(this.colliders), contacts: state.contacts, segmentContacts: state.segmentContacts,
|
|
313
402
|
};
|
|
314
403
|
}
|
|
315
404
|
/** Accepts a JSON-round-tripped snapshot only from the same rope configuration. Atomic on failure. */
|
|
@@ -321,6 +410,7 @@ export class RopeSolver {
|
|
|
321
410
|
const c = input.config;
|
|
322
411
|
if (!c || c.mass !== this.config.mass || c.radius !== this.config.radius || c.fixedDt !== this.config.fixedDt ||
|
|
323
412
|
c.substeps !== this.config.substeps || c.iterations !== this.config.iterations || c.damping !== this.config.damping ||
|
|
413
|
+
(c.segmentCollisions !== undefined && typeof c.segmentCollisions !== "boolean") || (c.segmentCollisions ?? false) !== this.config.segmentCollisions ||
|
|
324
414
|
!vec(c.gravity) || c.gravity.some((v, i) => v !== this.config.gravity[i]) ||
|
|
325
415
|
!Array.isArray(c.restLengths) || c.restLengths.length !== this.config.restLengths.length ||
|
|
326
416
|
c.restLengths.some((v, i) => v !== this.config.restLengths[i]))
|
|
@@ -341,12 +431,30 @@ export class RopeSolver {
|
|
|
341
431
|
(contact.reactionImpulse === undefined) !== (contact.reactionAngularImpulse === undefined) ||
|
|
342
432
|
(contact.reactionImpulse !== undefined && (!finiteVector(contact.reactionImpulse) || !finiteVector(contact.reactionAngularImpulse)))))
|
|
343
433
|
fail("ROPE_SNAPSHOT", "invalid contact state");
|
|
434
|
+
const segments = input.segmentContacts === undefined ? [] : input.segmentContacts;
|
|
435
|
+
if (!Array.isArray(segments) || segments.length > (count - 1) * colliders.length || (!this.config.segmentCollisions && segments.length > 0) ||
|
|
436
|
+
segments.some(contact => !contact || contact.tick !== input.tick ||
|
|
437
|
+
!Number.isSafeInteger(contact.segment) || contact.segment < 0 || contact.segment >= count - 1 ||
|
|
438
|
+
!colliders.some(collider => collider.id === contact.colliderId && collider.kind === "sphere") ||
|
|
439
|
+
typeof contact.fraction !== "number" || !Number.isFinite(contact.fraction) || contact.fraction <= 0 || contact.fraction >= 1 ||
|
|
440
|
+
typeof contact.penetration !== "number" || !Number.isFinite(contact.penetration) || contact.penetration <= 0 ||
|
|
441
|
+
(contact.reactionImpulse === undefined) !== (contact.reactionAngularImpulse === undefined) ||
|
|
442
|
+
(contact.reactionImpulse !== undefined && (!finiteVector(contact.reactionImpulse) || !finiteVector(contact.reactionAngularImpulse)))))
|
|
443
|
+
fail("ROPE_SNAPSHOT", "invalid segment contact state");
|
|
444
|
+
const segmentKeys = new Set();
|
|
445
|
+
for (const contact of segments) {
|
|
446
|
+
const key = `${contact.segment}:${contact.colliderId}`;
|
|
447
|
+
if (segmentKeys.has(key))
|
|
448
|
+
fail("ROPE_SNAPSHOT", "duplicate segment contact");
|
|
449
|
+
segmentKeys.add(key);
|
|
450
|
+
}
|
|
344
451
|
this.positions = input.positions.map(copy);
|
|
345
452
|
this.velocities = input.velocities.map(copy);
|
|
346
453
|
this.forces = input.forces.map(copy);
|
|
347
454
|
this.anchors = anchors;
|
|
348
455
|
this.colliders = colliders;
|
|
349
456
|
this.contacts = input.contacts.map(copyContact);
|
|
457
|
+
this.segmentContacts = segments.map(copySegmentContact);
|
|
350
458
|
this.tickValue = input.tick;
|
|
351
459
|
}
|
|
352
460
|
dispose() {
|
|
@@ -358,6 +466,7 @@ export class RopeSolver {
|
|
|
358
466
|
this.forces = [];
|
|
359
467
|
this.colliders = [];
|
|
360
468
|
this.contacts = [];
|
|
469
|
+
this.segmentContacts = [];
|
|
361
470
|
this.anchors.clear();
|
|
362
471
|
}
|
|
363
472
|
}
|