pelican-engine 0.3.15 → 0.3.17
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 +28 -3
- package/distribution.json +13 -12
- package/docs/STATUS.md +2 -2
- package/docs/api/REFERENCE.md +133 -91
- package/docs/api/index.json +128 -93
- package/docs/npm/GAME-AUTHOR.md +28 -3
- package/engine.manifest.json +1 -1
- package/lib/sdk/index.d.ts +34 -2
- package/lib/sdk/index.js +1 -1
- package/lib/simulation/rope-plugin.js +19 -3
- package/lib/simulation/rope-self-collision.js +102 -0
- package/lib/simulation/rope.js +145 -5
- package/package.json +1 -1
package/docs/npm/GAME-AUTHOR.md
CHANGED
|
@@ -137,6 +137,31 @@ 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; the segment solver and snapshot migration
|
|
151
|
+
have not been runtime validated.
|
|
152
|
+
|
|
153
|
+
New ropes also resolve discrete self-contact between non-adjacent segments using
|
|
154
|
+
sweep-and-prune and endpoint projection. `inspect(id).selfContacts` reports
|
|
155
|
+
segment indices, closest-point fractions and maximum overlap; `selfCollisions`
|
|
156
|
+
reports the active mode. Set `selfCollisions: false` to disable it. Adjacent
|
|
157
|
+
segments and collisions between separate ropes are not handled. Immovable anchored
|
|
158
|
+
endpoints stay fixed, and contacts introduced by corrections can wait until the
|
|
159
|
+
next iteration. Dense overlap can approach quadratic work. Snapshots from before
|
|
160
|
+
this feature retain the disabled mode when migrated; Host engine-version rules
|
|
161
|
+
still apply. This is unverified discrete contact, not a guarantee against
|
|
162
|
+
penetration, continuous collision, friction or full rope physics. The source
|
|
163
|
+
checkout includes `examples/rope-self`.
|
|
164
|
+
|
|
140
165
|
For optional visual presentation, declare a material in `defineGame({ materials:
|
|
141
166
|
[...] })` and create the rope with `visual: { material: "your-material-id" }`.
|
|
142
167
|
The renderer draws capped cylindrical instances between consecutive simulated
|
|
@@ -181,8 +206,8 @@ automatically act on a rigid body.
|
|
|
181
206
|
For an explicitly mapped dynamic body, game code can read its actual world center
|
|
182
207
|
of mass via `ctx.physics.centerOfMass(id)`, apply the linear impulse, and apply
|
|
183
208
|
`reactionAngularImpulse - centerOfMass × reactionImpulse` as torque impulse.
|
|
184
|
-
|
|
185
|
-
the same contacts. The source checkout's `examples/rope-reaction` uses the
|
|
209
|
+
For manual coupling, include both `contacts` and `segmentContacts` and apply each
|
|
210
|
+
contact at most once for its `tick`; repeated `inspect` calls return the same contacts. The source checkout's `examples/rope-reaction` uses the
|
|
186
211
|
automatic binding instead.
|
|
187
212
|
|
|
188
213
|
This sampled, manually applied reaction is delayed and uses positional correction,
|
|
@@ -193,7 +218,7 @@ checkpoint. It must not be represented as complete two-way rigid-body interactio
|
|
|
193
218
|
|
|
194
219
|
This is an unverified development-stage increment, not complete rope physics:
|
|
195
220
|
the optional segmented visual does not provide a continuous deformable rope mesh,
|
|
196
|
-
general rigid-body coupling,
|
|
221
|
+
general rigid-body coupling, continuous collision, cross-rope collision, or tearing. Plane normals must be unit length; `offset` is the signed
|
|
197
222
|
normal distance from the origin. Host intervals must be between 1/1000 and
|
|
198
223
|
1/30 seconds inclusive.
|
|
199
224
|
|
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.17";
|
|
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
|
|
107
|
+
* contact impulses to dynamic spheres; there is no full rigid-body coupling. */
|
|
108
108
|
export type RopeCollider = {
|
|
109
109
|
kind: "sphere";
|
|
110
110
|
id: string;
|
|
@@ -151,6 +151,10 @@ 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;
|
|
156
|
+
/** Resolve discrete non-adjacent segment self contacts; defaults to true for new ropes. */
|
|
157
|
+
selfCollisions?: boolean;
|
|
154
158
|
/** Optional segmented presentation using an already-declared material id. */
|
|
155
159
|
visual?: RopeVisualOptions;
|
|
156
160
|
substeps?: number;
|
|
@@ -173,10 +177,36 @@ export interface RopeContact {
|
|
|
173
177
|
/** Angular impulse about the world origin; absent in older snapshots. */
|
|
174
178
|
reactionAngularImpulse?: Vec3;
|
|
175
179
|
}
|
|
180
|
+
/** Contact between an interior rope segment and a sampled sphere. */
|
|
181
|
+
export interface RopeSegmentContact {
|
|
182
|
+
tick: number;
|
|
183
|
+
/** Zero-based index of the first endpoint; the second is segment + 1. */
|
|
184
|
+
segment: number;
|
|
185
|
+
colliderId: string;
|
|
186
|
+
/** Last closest-point fraction from the first endpoint toward the second. */
|
|
187
|
+
fraction: number;
|
|
188
|
+
penetration: number;
|
|
189
|
+
reactionImpulse?: Vec3;
|
|
190
|
+
reactionAngularImpulse?: Vec3;
|
|
191
|
+
}
|
|
192
|
+
/** Discrete self-contact between two non-adjacent rope segments. */
|
|
193
|
+
export interface RopeSelfContact {
|
|
194
|
+
tick: number;
|
|
195
|
+
/** Zero-based first endpoint of each non-adjacent segment, ordered ascending. */
|
|
196
|
+
firstSegment: number;
|
|
197
|
+
secondSegment: number;
|
|
198
|
+
/** Last closest-point fractions along the respective segments. */
|
|
199
|
+
firstFraction: number;
|
|
200
|
+
secondFraction: number;
|
|
201
|
+
/** Maximum observed overlap in metres during this tick. */
|
|
202
|
+
penetration: number;
|
|
203
|
+
}
|
|
176
204
|
export interface RopeState {
|
|
177
205
|
tick: number;
|
|
178
206
|
simulatedSeconds: number;
|
|
179
207
|
disposed: boolean;
|
|
208
|
+
segmentCollisions: boolean;
|
|
209
|
+
selfCollisions: boolean;
|
|
180
210
|
positions: Vec3[];
|
|
181
211
|
velocities: Vec3[];
|
|
182
212
|
anchors: {
|
|
@@ -184,6 +214,8 @@ export interface RopeState {
|
|
|
184
214
|
position: Vec3;
|
|
185
215
|
}[];
|
|
186
216
|
contacts: RopeContact[];
|
|
217
|
+
segmentContacts: RopeSegmentContact[];
|
|
218
|
+
selfContacts: RopeSelfContact[];
|
|
187
219
|
maxDistanceError: number;
|
|
188
220
|
resources: {
|
|
189
221
|
particles: number;
|
package/lib/sdk/index.js
CHANGED
|
@@ -4,12 +4,20 @@ 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, 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.");
|
|
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 (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.");
|
|
19
|
+
if (restoredSegmentMode !== undefined && definition.segmentCollisions !== undefined && definition.segmentCollisions !== restoredSegmentMode)
|
|
20
|
+
return fail("SNAPSHOT", "Segment collision configuration does not match the saved solver.");
|
|
13
21
|
if (definition.fixedDt !== undefined && definition.fixedDt !== fixedDt)
|
|
14
22
|
return fail("FIXED_DT", "Rope fixedDt must exactly match the host fixedDt.");
|
|
15
23
|
if (fixedDt < 1 / 1000 || fixedDt > 1 / 30)
|
|
@@ -40,7 +48,8 @@ function createEntry(input, fixedDt, materials) {
|
|
|
40
48
|
entities.add(binding.entity);
|
|
41
49
|
}
|
|
42
50
|
}
|
|
43
|
-
const solver = createRope({ ...definition, fixedDt })
|
|
51
|
+
const solver = createRope({ ...definition, fixedDt, ...(restoredSegmentMode === undefined ? {} : { segmentCollisions: restoredSegmentMode }),
|
|
52
|
+
...(restoredSelfMode === undefined ? {} : { selfCollisions: restoredSelfMode }) });
|
|
44
53
|
return { definition, solver, bodyRefs: new Map(), staleBodies: new Set() };
|
|
45
54
|
}
|
|
46
55
|
function disposeEntries(entries) {
|
|
@@ -56,7 +65,14 @@ function parseSnapshot(value, fixedDt, materials) {
|
|
|
56
65
|
for (const item of value.ropes) {
|
|
57
66
|
if (!item || typeof item !== "object")
|
|
58
67
|
fail("SNAPSHOT", "Invalid rope entry.");
|
|
59
|
-
const
|
|
68
|
+
const savedSegmentMode = item.state?.config?.segmentCollisions;
|
|
69
|
+
if (savedSegmentMode !== undefined && typeof savedSegmentMode !== "boolean")
|
|
70
|
+
fail("SNAPSHOT", "Invalid segment collision configuration.");
|
|
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);
|
|
60
76
|
if (entries.has(entry.definition.id)) {
|
|
61
77
|
entry.solver.dispose();
|
|
62
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) {
|
|
@@ -18,6 +19,11 @@ const copyContact = (contact) => {
|
|
|
18
19
|
...(reactionAngularImpulse === undefined ? {} : { reactionAngularImpulse: copy(reactionAngularImpulse) }),
|
|
19
20
|
};
|
|
20
21
|
};
|
|
22
|
+
const copySegmentContact = (contact) => {
|
|
23
|
+
const { reactionImpulse, reactionAngularImpulse, ...rest } = contact;
|
|
24
|
+
return { ...rest, ...(reactionImpulse === undefined ? {} : { reactionImpulse: copy(reactionImpulse) }),
|
|
25
|
+
...(reactionAngularImpulse === undefined ? {} : { reactionAngularImpulse: copy(reactionAngularImpulse) }) };
|
|
26
|
+
};
|
|
21
27
|
const length = (v) => Math.hypot(v[0], v[1], v[2]);
|
|
22
28
|
const validVec = (v, name) => vec(v) ? copy(v) : fail("ROPE_INPUT", `${name} must be a finite 3D vector (|component| <= 1e6)`);
|
|
23
29
|
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 +72,12 @@ export function createRope(options) {
|
|
|
66
72
|
const positions = options.points.map((p, i) => validVec(p, `points[${i}]`));
|
|
67
73
|
const mass = positive(options.mass, "mass");
|
|
68
74
|
const radius = positive(options.radius ?? 0.025, "radius");
|
|
75
|
+
if (options.segmentCollisions !== undefined && typeof options.segmentCollisions !== "boolean")
|
|
76
|
+
fail("ROPE_INPUT", "segmentCollisions must be boolean");
|
|
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;
|
|
69
81
|
const fixedDt = positive(options.fixedDt ?? 1 / 60, "fixedDt");
|
|
70
82
|
if (fixedDt < 1 / 1000 || fixedDt > 1 / 30)
|
|
71
83
|
fail("ROPE_INPUT", "fixedDt must be in [1/1000, 1/30]");
|
|
@@ -80,7 +92,7 @@ export function createRope(options) {
|
|
|
80
92
|
const anchors = readAnchors(options.anchors ?? [{ index: 0, position: positions[0] }], positions.length);
|
|
81
93
|
for (const [i, p] of anchors)
|
|
82
94
|
positions[i] = copy(p);
|
|
83
|
-
return new RopeSolver({ mass, radius, fixedDt, substeps, iterations, damping, gravity, restLengths }, positions, anchors);
|
|
95
|
+
return new RopeSolver({ mass, radius, fixedDt, substeps, iterations, damping, gravity, restLengths, segmentCollisions, selfCollisions }, positions, anchors);
|
|
84
96
|
}
|
|
85
97
|
export class RopeSolver {
|
|
86
98
|
config;
|
|
@@ -92,6 +104,8 @@ export class RopeSolver {
|
|
|
92
104
|
forces;
|
|
93
105
|
colliders = [];
|
|
94
106
|
contacts = [];
|
|
107
|
+
segmentContacts = [];
|
|
108
|
+
selfContacts = [];
|
|
95
109
|
constructor(config, positions, anchors) {
|
|
96
110
|
this.config = config;
|
|
97
111
|
this.positions = positions;
|
|
@@ -106,6 +120,7 @@ export class RopeSolver {
|
|
|
106
120
|
this.alive();
|
|
107
121
|
this.colliders = readColliders(input);
|
|
108
122
|
this.contacts = [];
|
|
123
|
+
this.segmentContacts = [];
|
|
109
124
|
}
|
|
110
125
|
/** Pins or moves a particle; new anchor position takes effect immediately. */
|
|
111
126
|
setAnchor(index, position) {
|
|
@@ -152,6 +167,8 @@ export class RopeSolver {
|
|
|
152
167
|
velocities: this.velocities,
|
|
153
168
|
forces: this.forces,
|
|
154
169
|
contacts: this.contacts,
|
|
170
|
+
segmentContacts: this.segmentContacts,
|
|
171
|
+
selfContacts: this.selfContacts,
|
|
155
172
|
tick: this.tickValue,
|
|
156
173
|
};
|
|
157
174
|
try {
|
|
@@ -163,6 +180,8 @@ export class RopeSolver {
|
|
|
163
180
|
this.velocities = before.velocities;
|
|
164
181
|
this.forces = before.forces;
|
|
165
182
|
this.contacts = before.contacts;
|
|
183
|
+
this.segmentContacts = before.segmentContacts;
|
|
184
|
+
this.selfContacts = before.selfContacts;
|
|
166
185
|
this.tickValue = before.tick;
|
|
167
186
|
throw error;
|
|
168
187
|
}
|
|
@@ -174,6 +193,9 @@ export class RopeSolver {
|
|
|
174
193
|
// Work on copies; numeric failures must never leave half a tick applied.
|
|
175
194
|
const positions = this.positions.map(copy), velocities = this.velocities.map(copy);
|
|
176
195
|
const contacts = new Map();
|
|
196
|
+
const segmentContacts = new Map();
|
|
197
|
+
const selfContacts = new Map();
|
|
198
|
+
const spheres = this.config.segmentCollisions ? this.colliders.filter((collider) => collider.kind === "sphere") : [];
|
|
177
199
|
for (let sub = 0; sub < substeps; sub++) {
|
|
178
200
|
const previous = positions.map(copy);
|
|
179
201
|
for (let i = 0; i < positions.length; i++) {
|
|
@@ -269,6 +291,82 @@ export class RopeSolver {
|
|
|
269
291
|
contacts.set(key, { tick: this.tickValue + 1, particle: i, colliderId: c.id, penetration: depth, reactionImpulse: reaction, reactionAngularImpulse: moment });
|
|
270
292
|
}
|
|
271
293
|
}
|
|
294
|
+
// Resolve interior segment/sphere overlaps after particle contacts. Endpoint
|
|
295
|
+
// overlaps are already handled by the particle pass above.
|
|
296
|
+
for (let j = 0; j < restLengths.length; j++) {
|
|
297
|
+
const a = positions[j], b = positions[j + 1];
|
|
298
|
+
for (const collider of spheres) {
|
|
299
|
+
const dynamic = coupling?.sphere(collider.id);
|
|
300
|
+
const center = dynamic?.center ?? collider.center, colliderRadius = dynamic?.radius ?? collider.radius;
|
|
301
|
+
const direction = [b[0] - a[0], b[1] - a[1], b[2] - a[2]];
|
|
302
|
+
const squared = direction[0] * direction[0] + direction[1] * direction[1] + direction[2] * direction[2];
|
|
303
|
+
if (squared < 1e-24)
|
|
304
|
+
continue; // collapsed segment is covered by particle contacts
|
|
305
|
+
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));
|
|
306
|
+
if (fraction <= 0 || fraction >= 1)
|
|
307
|
+
continue;
|
|
308
|
+
const closest = [a[0] + direction[0] * fraction, a[1] + direction[1] * fraction, a[2] + direction[2] * fraction];
|
|
309
|
+
const delta = [closest[0] - center[0], closest[1] - center[1], closest[2] - center[2]];
|
|
310
|
+
const distance = length(delta), depth = colliderRadius + radius - distance;
|
|
311
|
+
if (depth <= 0)
|
|
312
|
+
continue;
|
|
313
|
+
let normal;
|
|
314
|
+
if (distance >= 1e-12)
|
|
315
|
+
normal = [delta[0] / distance, delta[1] / distance, delta[2] / distance];
|
|
316
|
+
else {
|
|
317
|
+
// Choose a deterministic perpendicular if the sphere center lies exactly on the segment.
|
|
318
|
+
const [x, y, z] = direction.map(Math.abs);
|
|
319
|
+
const axis = x <= y && x <= z ? [1, 0, 0] : y <= z ? [0, 1, 0] : [0, 0, 1];
|
|
320
|
+
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]];
|
|
321
|
+
const magnitude = length(perpendicular);
|
|
322
|
+
normal = [perpendicular[0] / magnitude, perpendicular[1] / magnitude, perpendicular[2] / magnitude];
|
|
323
|
+
}
|
|
324
|
+
const first = this.anchors.has(j) ? 0 : 1 - fraction, second = this.anchors.has(j + 1) ? 0 : fraction;
|
|
325
|
+
const inverseWeight = first * first + second * second;
|
|
326
|
+
if (inverseWeight === 0)
|
|
327
|
+
continue; // two authoritative anchors cannot move
|
|
328
|
+
const contactMass = mass / inverseWeight;
|
|
329
|
+
if (!Number.isFinite(contactMass) || contactMass <= 0)
|
|
330
|
+
fail("ROPE_NUMERIC", "segment contact mass exceeded finite bounds");
|
|
331
|
+
const previousPoint = [0, 1, 2].map(k => previous[j][k] * (1 - fraction) + previous[j + 1][k] * fraction);
|
|
332
|
+
let correction, reaction, moment;
|
|
333
|
+
if (dynamic && coupling) {
|
|
334
|
+
const resolved = coupling.resolve(collider.id, normal, depth, closest, previousPoint, contactMass, h);
|
|
335
|
+
correction = resolved.correction;
|
|
336
|
+
reaction = resolved.reactionImpulse;
|
|
337
|
+
moment = resolved.reactionAngularImpulse;
|
|
338
|
+
}
|
|
339
|
+
else {
|
|
340
|
+
correction = [normal[0] * depth, normal[1] * depth, normal[2] * depth];
|
|
341
|
+
const magnitude = -contactMass * depth / h;
|
|
342
|
+
reaction = [normal[0] * magnitude, normal[1] * magnitude, normal[2] * magnitude];
|
|
343
|
+
const point = [center[0] + normal[0] * colliderRadius, center[1] + normal[1] * colliderRadius, center[2] + normal[2] * colliderRadius];
|
|
344
|
+
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]];
|
|
345
|
+
}
|
|
346
|
+
if (!finiteVector(correction) || !finiteVector(reaction) || !finiteVector(moment))
|
|
347
|
+
fail("ROPE_NUMERIC", "segment contact exceeded finite bounds");
|
|
348
|
+
// Gradients of the closest point distribute correction to movable endpoints.
|
|
349
|
+
for (let k = 0; k < 3; k++) {
|
|
350
|
+
a[k] += correction[k] * first / inverseWeight;
|
|
351
|
+
b[k] += correction[k] * second / inverseWeight;
|
|
352
|
+
}
|
|
353
|
+
const key = `${j}:${collider.id}`, existing = segmentContacts.get(key);
|
|
354
|
+
if (existing) {
|
|
355
|
+
existing.penetration = Math.max(existing.penetration, depth);
|
|
356
|
+
existing.fraction = fraction;
|
|
357
|
+
for (let k = 0; k < 3; k++) {
|
|
358
|
+
existing.reactionImpulse[k] += reaction[k];
|
|
359
|
+
existing.reactionAngularImpulse[k] += moment[k];
|
|
360
|
+
}
|
|
361
|
+
if (!finiteVector(existing.reactionImpulse) || !finiteVector(existing.reactionAngularImpulse))
|
|
362
|
+
fail("ROPE_NUMERIC", "segment reaction accumulation exceeded finite bounds");
|
|
363
|
+
}
|
|
364
|
+
else
|
|
365
|
+
segmentContacts.set(key, { tick: this.tickValue + 1, segment: j, colliderId: collider.id, fraction, penetration: depth, reactionImpulse: reaction, reactionAngularImpulse: moment });
|
|
366
|
+
}
|
|
367
|
+
}
|
|
368
|
+
if (this.config.selfCollisions && positions.length >= 4)
|
|
369
|
+
resolveRopeSelfCollisions(positions, this.anchors, radius, this.tickValue + 1, selfContacts);
|
|
272
370
|
}
|
|
273
371
|
for (let i = 0; i < positions.length; i++) {
|
|
274
372
|
if (this.anchors.has(i))
|
|
@@ -283,22 +381,24 @@ export class RopeSolver {
|
|
|
283
381
|
this.velocities = velocities;
|
|
284
382
|
this.forces = positions.map(() => [0, 0, 0]);
|
|
285
383
|
this.contacts = [...contacts.values()];
|
|
384
|
+
this.segmentContacts = [...segmentContacts.values()];
|
|
385
|
+
this.selfContacts = [...selfContacts.values()];
|
|
286
386
|
this.tickValue++;
|
|
287
387
|
}
|
|
288
388
|
/** Detached positions for renderer synchronization without copying contact state. */
|
|
289
389
|
particlePositions() { this.alive(); return this.positions.map(copy); }
|
|
290
390
|
inspect() {
|
|
291
391
|
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 } };
|
|
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 } };
|
|
293
393
|
const maxDistanceError = Math.max(0, ...this.config.restLengths.map((d, i) => {
|
|
294
394
|
const a = this.positions[i], b = this.positions[i + 1];
|
|
295
395
|
return Math.abs(Math.hypot(a[0] - b[0], a[1] - b[1], a[2] - b[2]) - d);
|
|
296
396
|
}));
|
|
297
397
|
return {
|
|
298
|
-
tick: this.tickValue, simulatedSeconds: this.tickValue * this.config.fixedDt, disposed: false,
|
|
398
|
+
tick: this.tickValue, simulatedSeconds: this.tickValue * this.config.fixedDt, disposed: false, segmentCollisions: this.config.segmentCollisions, selfCollisions: this.config.selfCollisions,
|
|
299
399
|
positions: this.positions.map(copy), velocities: this.velocities.map(copy),
|
|
300
400
|
anchors: [...this.anchors].map(([index, position]) => ({ index, position: copy(position) })).sort((a, b) => a.index - b.index),
|
|
301
|
-
contacts: this.contacts.map(copyContact), maxDistanceError,
|
|
401
|
+
contacts: this.contacts.map(copyContact), segmentContacts: this.segmentContacts.map(copySegmentContact), selfContacts: this.selfContacts.map(contact => ({ ...contact })), maxDistanceError,
|
|
302
402
|
resources: { particles: this.positions.length, constraints: this.config.restLengths.length, anchors: this.anchors.size, colliders: this.colliders.length },
|
|
303
403
|
};
|
|
304
404
|
}
|
|
@@ -309,7 +409,7 @@ export class RopeSolver {
|
|
|
309
409
|
format: "pelican-rope-cpu", version: 1, tick: this.tickValue,
|
|
310
410
|
config: { ...this.config, gravity: copy(this.config.gravity), restLengths: [...this.config.restLengths] },
|
|
311
411
|
positions: state.positions, velocities: state.velocities, anchors: state.anchors,
|
|
312
|
-
forces: this.forces.map(copy), colliders: readColliders(this.colliders), contacts: state.contacts,
|
|
412
|
+
forces: this.forces.map(copy), colliders: readColliders(this.colliders), contacts: state.contacts, segmentContacts: state.segmentContacts, selfContacts: state.selfContacts,
|
|
313
413
|
};
|
|
314
414
|
}
|
|
315
415
|
/** Accepts a JSON-round-tripped snapshot only from the same rope configuration. Atomic on failure. */
|
|
@@ -321,6 +421,8 @@ export class RopeSolver {
|
|
|
321
421
|
const c = input.config;
|
|
322
422
|
if (!c || c.mass !== this.config.mass || c.radius !== this.config.radius || c.fixedDt !== this.config.fixedDt ||
|
|
323
423
|
c.substeps !== this.config.substeps || c.iterations !== this.config.iterations || c.damping !== this.config.damping ||
|
|
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 ||
|
|
324
426
|
!vec(c.gravity) || c.gravity.some((v, i) => v !== this.config.gravity[i]) ||
|
|
325
427
|
!Array.isArray(c.restLengths) || c.restLengths.length !== this.config.restLengths.length ||
|
|
326
428
|
c.restLengths.some((v, i) => v !== this.config.restLengths[i]))
|
|
@@ -341,12 +443,48 @@ export class RopeSolver {
|
|
|
341
443
|
(contact.reactionImpulse === undefined) !== (contact.reactionAngularImpulse === undefined) ||
|
|
342
444
|
(contact.reactionImpulse !== undefined && (!finiteVector(contact.reactionImpulse) || !finiteVector(contact.reactionAngularImpulse)))))
|
|
343
445
|
fail("ROPE_SNAPSHOT", "invalid contact state");
|
|
446
|
+
const segments = input.segmentContacts === undefined ? [] : input.segmentContacts;
|
|
447
|
+
if (!Array.isArray(segments) || segments.length > (count - 1) * colliders.length || (!this.config.segmentCollisions && segments.length > 0) ||
|
|
448
|
+
segments.some(contact => !contact || contact.tick !== input.tick ||
|
|
449
|
+
!Number.isSafeInteger(contact.segment) || contact.segment < 0 || contact.segment >= count - 1 ||
|
|
450
|
+
!colliders.some(collider => collider.id === contact.colliderId && collider.kind === "sphere") ||
|
|
451
|
+
typeof contact.fraction !== "number" || !Number.isFinite(contact.fraction) || contact.fraction <= 0 || contact.fraction >= 1 ||
|
|
452
|
+
typeof contact.penetration !== "number" || !Number.isFinite(contact.penetration) || contact.penetration <= 0 ||
|
|
453
|
+
(contact.reactionImpulse === undefined) !== (contact.reactionAngularImpulse === undefined) ||
|
|
454
|
+
(contact.reactionImpulse !== undefined && (!finiteVector(contact.reactionImpulse) || !finiteVector(contact.reactionAngularImpulse)))))
|
|
455
|
+
fail("ROPE_SNAPSHOT", "invalid segment contact state");
|
|
456
|
+
const segmentKeys = new Set();
|
|
457
|
+
for (const contact of segments) {
|
|
458
|
+
const key = `${contact.segment}:${contact.colliderId}`;
|
|
459
|
+
if (segmentKeys.has(key))
|
|
460
|
+
fail("ROPE_SNAPSHOT", "duplicate segment contact");
|
|
461
|
+
segmentKeys.add(key);
|
|
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
|
+
}
|
|
344
480
|
this.positions = input.positions.map(copy);
|
|
345
481
|
this.velocities = input.velocities.map(copy);
|
|
346
482
|
this.forces = input.forces.map(copy);
|
|
347
483
|
this.anchors = anchors;
|
|
348
484
|
this.colliders = colliders;
|
|
349
485
|
this.contacts = input.contacts.map(copyContact);
|
|
486
|
+
this.segmentContacts = segments.map(copySegmentContact);
|
|
487
|
+
this.selfContacts = self.map(contact => ({ ...contact }));
|
|
350
488
|
this.tickValue = input.tick;
|
|
351
489
|
}
|
|
352
490
|
dispose() {
|
|
@@ -358,6 +496,8 @@ export class RopeSolver {
|
|
|
358
496
|
this.forces = [];
|
|
359
497
|
this.colliders = [];
|
|
360
498
|
this.contacts = [];
|
|
499
|
+
this.segmentContacts = [];
|
|
500
|
+
this.selfContacts = [];
|
|
361
501
|
this.anchors.clear();
|
|
362
502
|
}
|
|
363
503
|
}
|