pelican-engine 0.3.13 → 0.3.14
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 +12 -8
- package/distribution.json +14 -13
- package/docs/STATUS.md +2 -2
- package/docs/api/REFERENCE.md +3 -3
- package/docs/api/index.json +3 -3
- package/docs/npm/GAME-AUTHOR.md +12 -8
- package/engine.manifest.json +1 -1
- package/lib/physics/index.js +20 -7
- package/lib/sdk/index.d.ts +2 -2
- package/lib/sdk/index.js +1 -1
- package/lib/simulation/rope-body-coupling.js +107 -0
- package/lib/simulation/rope-plugin.js +15 -20
- package/lib/simulation/rope.js +30 -16
- package/package.json +1 -1
package/AGENTS.md
CHANGED
|
@@ -140,9 +140,11 @@ checkout includes `examples/rope-particles`.
|
|
|
140
140
|
To bind a rope to existing dynamic sphere bodies, create it with `autoStep: true`
|
|
141
141
|
and `bodyColliders: [{ id: "rope-collider-id", entity: "body-id" }]`. After the
|
|
142
142
|
rigid-body physics phase, the host samples each bound sphere's actual collider
|
|
143
|
-
center
|
|
144
|
-
|
|
145
|
-
|
|
143
|
+
center, radius, velocity and effective inverse mass/inertia, then steps the rope
|
|
144
|
+
and applies the resulting normal contact impulse to the body. At detected overlaps,
|
|
145
|
+
particle and virtual body corrections share effective mass and address closing
|
|
146
|
+
normal velocity; contacts are not predicted before overlap.
|
|
147
|
+
The actual body's displacement responds on the next body step. Bindings require live dynamic non-trigger spheres and unique
|
|
146
148
|
collider ids and body entities within each rope. Do not use a bound collider id
|
|
147
149
|
in `setColliders`; its manual planes and spheres remain separate. A destroyed body
|
|
148
150
|
is unbound at the next automatic step, and a new entity with the same id is not
|
|
@@ -150,13 +152,15 @@ implicitly rebound. Bindings are saved with Host snapshots.
|
|
|
150
152
|
|
|
151
153
|
Do not manually apply the same contact's reaction when using a body binding: the
|
|
152
154
|
host already applies it. A native failure can still leave the host tick partially
|
|
153
|
-
changed, so restore a known-good checkpoint. This is
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
155
|
+
changed, so restore a known-good checkpoint. This is an approximate split-step
|
|
156
|
+
solver: virtual positions use a first-order update and the real body moves on its
|
|
157
|
+
next step. Ropes are processed sequentially, without globally coupled substeps;
|
|
158
|
+
friction, restitution and continuous collision are not included. Penetration
|
|
159
|
+
correction can inject energy; stability, conservation, runtime and snapshot
|
|
160
|
+
compatibility have not been validated.
|
|
157
161
|
|
|
158
162
|
For each latest-tick contact, `reactionImpulse` and `reactionAngularImpulse`
|
|
159
|
-
report the opposite impulse produced by the particle's
|
|
163
|
+
report the opposite impulse produced by the particle's contact response,
|
|
160
164
|
and its moment about the world origin. These optional fields are absent when
|
|
161
165
|
restoring older contact snapshots. Contacts from unbound manual colliders do not
|
|
162
166
|
automatically act on a rigid body.
|
package/distribution.json
CHANGED
|
@@ -1,12 +1,12 @@
|
|
|
1
1
|
{
|
|
2
2
|
"format": "pelican.npm-distribution",
|
|
3
|
-
"version": "0.3.
|
|
3
|
+
"version": "0.3.14",
|
|
4
4
|
"files": {
|
|
5
|
-
"AGENTS.md": "
|
|
5
|
+
"AGENTS.md": "4e031adeec0446317141f17c9f04c8d6c03f7339f00e27687331dd8051a4c807",
|
|
6
6
|
"CONTRIBUTING.md": "0ffad5d841d90f319fc50a75b81d3c0ad6dab8d5d9bec2846c65c7f8358d5a50",
|
|
7
7
|
"docs/AI-WORKFLOW.md": "aa992686df93f791795f6f3fce01713d812eea6a1b9556f07411ac5847278efa",
|
|
8
|
-
"docs/api/index.json": "
|
|
9
|
-
"docs/api/REFERENCE.md": "
|
|
8
|
+
"docs/api/index.json": "31ea1aa6c3531cdd2df129ae10fed54f666be4cb69c005c556690339f7b9a420",
|
|
9
|
+
"docs/api/REFERENCE.md": "ba657a3fb169e0f6c6e77ab1233cfb94125daf6ce4914ec0e960e4b1bf07d884",
|
|
10
10
|
"docs/ARCHITECTURE.md": "3184b73592d466390e7793b1cf39bbb80d6052b523fba7e7cb377b5207074627",
|
|
11
11
|
"docs/assets/pelican-mark.svg": "407b10cd7e292ea83cf5dba4046fb2f01c7d566a14014c55deda3a2984562b4d",
|
|
12
12
|
"docs/CAPABILITIES.md": "92950817497d8c63db07830db96dc93c877befa8d038320feed1ae1f1a053cb1",
|
|
@@ -14,14 +14,14 @@
|
|
|
14
14
|
"docs/GETTING-STARTED.md": "5519ebe4e513e8ae4517c04948ee9dc70bcb1e3d04a65d468a6537c838502663",
|
|
15
15
|
"docs/GRAPHICS-GUIDE.md": "e3d0f7df6189ca3f88d81af5ffaf393f5d6bf39b9e66167bb2f64b39194b514c",
|
|
16
16
|
"docs/npm/DISTRIBUTION.md": "ff25e039921a85ba253c27608c77ed614a036117d7a04f908a78d1e3ed3f19ef",
|
|
17
|
-
"docs/npm/GAME-AUTHOR.md": "
|
|
17
|
+
"docs/npm/GAME-AUTHOR.md": "4e031adeec0446317141f17c9f04c8d6c03f7339f00e27687331dd8051a4c807",
|
|
18
18
|
"docs/PELICAN-MIGRATION.md": "c5d6ab8e5186f4d36720a0a01d1a7e4157a5d386c5400e2db99571a7d10a1e49",
|
|
19
19
|
"docs/PHYSICS-LIFECYCLE-ISSUE.md": "cc78917462de4e7e9b68e77c742192ce213b4be5b516d2ba943251fff46977ac",
|
|
20
20
|
"docs/RENDER-BACKENDS.md": "d981ea94742a835d028db37d536fb7fba05102e37bc6274a37baf74f5034b954",
|
|
21
21
|
"docs/ROADMAP.md": "29ac1c54f36064e23c17cfe7f549d6860889257a57d1227e322958aa9c281568",
|
|
22
|
-
"docs/STATUS.md": "
|
|
22
|
+
"docs/STATUS.md": "75dc60834515914a7177198a01f7bbdd7a6437feac62cf555657dde7823b0a95",
|
|
23
23
|
"docs/THIRD-PARTY-NOTICES.md": "58f9b72e2b4dfaaaf2a4c08b9bde9ecef3d2b7e7d65fccd782ed54f39fa76fdc",
|
|
24
|
-
"engine.manifest.json": "
|
|
24
|
+
"engine.manifest.json": "e6b474c204d00972619b80f4157c57b4d748e89913fa5455583c961e11dcf875",
|
|
25
25
|
"lib/assets/audio.js": "6facdce4e053afd172af5b9407569b035169844ea17b49f93f8b67f72b78895f",
|
|
26
26
|
"lib/assets/errors.js": "70572678655c6e53179d31920d71f29882d373a0e12d560220bb0fc2cd74f2cc",
|
|
27
27
|
"lib/assets/glb.js": "b808479a36299c154db97f50c16863a3b5a2c61f725e66bf01d01a5f62a288a5",
|
|
@@ -51,7 +51,7 @@
|
|
|
51
51
|
"lib/experiments/world/types.js": "8e609bb71c20b858c77f0e9f90bb1319db8477b13f9f965f1a1e18524bf50881",
|
|
52
52
|
"lib/experiments/world/world.js": "e335a96b1a2b217b69077f8e4b01aed7fd54b87e39bd5ac4138555d27ad9afe1",
|
|
53
53
|
"lib/navigation/index.js": "5ee447333ff2152b1f08b34a68e16dd058448b0a4d2f8b3eae0fb8e76f52a1a9",
|
|
54
|
-
"lib/physics/index.js": "
|
|
54
|
+
"lib/physics/index.js": "d2f61506b26078c75ac12ea73a6ee17c62b833145dfe950fd66835659ede30b3",
|
|
55
55
|
"lib/protocol/index.js": "875c9f7ba4e67adc182aa177bc5017da0907ff9c584ba523021a219b26e38c89",
|
|
56
56
|
"lib/renderer/acceleration.js": "50ccafc9bc5b463470a23cffbdff8dc1a29a77bada44d21caf1eb0c3f9de2c65",
|
|
57
57
|
"lib/renderer/browser.js": "0ef885517bef24b12a68e898d274643f5a8ee164d1956c27dc470a9a6c5a431e",
|
|
@@ -123,10 +123,11 @@
|
|
|
123
123
|
"lib/runtime/world.js": "c43621fbaca8bd2b0d6477a96e60e895312f0f7b1eb97614b6e4f9b716129ea1",
|
|
124
124
|
"lib/schema/index.js": "594429eecb4494c4af2f3d04537be3211f2c9297a1fc555bbba5d028d8627965",
|
|
125
125
|
"lib/sdk/graph-values.js": "a58c00f127cb82aba5fd2824981942844e06d9a59a88a05e35d1d3df2fc7ed2f",
|
|
126
|
-
"lib/sdk/index.d.ts": "
|
|
127
|
-
"lib/sdk/index.js": "
|
|
128
|
-
"lib/simulation/rope-
|
|
129
|
-
"lib/simulation/rope.js": "
|
|
126
|
+
"lib/sdk/index.d.ts": "f8b095dc14e5a0804a0ba5e102f144c8775f4f4e0194ce2a24ff5bd7904c425c",
|
|
127
|
+
"lib/sdk/index.js": "3d70f1320a1739e255b461724b91d1a1fa3caeeba17a4d162155727d96f8026a",
|
|
128
|
+
"lib/simulation/rope-body-coupling.js": "70694b73d17c79f223803a51fbf4a4dae551b717cd29bbea1fa38386ac98d621",
|
|
129
|
+
"lib/simulation/rope-plugin.js": "050b64e82d196087aafe47047b3849e22fe8af362e3a7436dc0299c7a470d385",
|
|
130
|
+
"lib/simulation/rope.js": "ae653f44a8edaf206ba5a0cfe8d5bf5ebe7d3fc97714368c5b97e4f870a6ec9f",
|
|
130
131
|
"lib/tools/api-index.js": "6aedc153a0ce8eaf18239b0368a76a8ae8e4397bd8e6cdf571889bc39265be5d",
|
|
131
132
|
"lib/tools/browser-session.js": "1fa7f78d4752d8a59a55dc2283b4d42cd1df230f0d5a5dc80c6d78ade45b4cdb",
|
|
132
133
|
"lib/tools/cli.js": "ad5e915f046cc2e6bf612edd1a88cc33758b1d2b0ce2f057be50e0a2c4770062",
|
|
@@ -197,7 +198,7 @@
|
|
|
197
198
|
"licenses/undici-types/LICENSE": "a6db8096b2707bc0102d256917d4d33f298ba36d8c3f25de067a2b5bb379db27",
|
|
198
199
|
"licenses/uniq/LICENSE": "e67aed7df22dc8031e4fcf5338fe91cb33e3817e5c58a99a2a2802eea9069791",
|
|
199
200
|
"licenses/vite/LICENSE.md": "a22152baa1aef74402443008e767e7c689a524c0909d19b0431d97f5ab066a13",
|
|
200
|
-
"package.json": "
|
|
201
|
+
"package.json": "d5874a4eba7869c56faef55c73a9b107eb209b6d3850f25943452e8fcfd83525",
|
|
201
202
|
"README.md": "ff25e039921a85ba253c27608c77ed614a036117d7a04f908a78d1e3ed3f19ef",
|
|
202
203
|
"web/index.html": "d89d6a5b5be3beae3627a8ed506ac308b165d264fb8696be3afd79fcca2f26ee"
|
|
203
204
|
}
|
package/docs/STATUS.md
CHANGED
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
|
|
3
3
|
## 版本与发布
|
|
4
4
|
|
|
5
|
-
- 当前版本为 `0.3.
|
|
5
|
+
- 当前版本为 `0.3.14`,包、锁文件、引擎、SDK、编译器和示例中的版本保持一致。下一项功能完成后统一递增并推送;相同版本不能重复发布。
|
|
6
6
|
- 推送主线会通过 GitHub Actions 和 npm 可信发布更新 `latest`;GitHub Release 用于记录里程碑。历史版本和旧标签保持原样,不代表当前源码通过。
|
|
7
7
|
- 当前按用户要求仅允许编译和构建,不运行测试、浏览器、GPU、性能或其他验证。普通版本号不意味着运行行为、硬件或完整引擎已验收。
|
|
8
8
|
|
|
@@ -13,7 +13,7 @@
|
|
|
13
13
|
- 刚体首步前删除存在已知缺陷;历史候选存在旧快照兼容及危险历史回放风险,不得通过修改夹具或在主进程回放危险历史掩盖。有关背景与原始证据保留在历史任务文档中。
|
|
14
14
|
- 正交网格导航新增批量障碍更新接口;它只处理单个网格的阻挡状态,不等于世界流送、实体变换或物理联动。该增量尚未进行运行行为验证。
|
|
15
15
|
- CPU 粒子绳索已接入 `ctx.ropes` 和 Host 快照,支持手动或自动固定步进。显式绑定的动态非触发球体可在物理阶段后被采样,并接收绳索投影产生的反冲量;其他采样球体/平面仍为单向。尚无绳索网格渲染、完整双向求解、绳段/自碰撞或撕裂。该增量尚未进行运行行为或快照兼容验证。
|
|
16
|
-
-
|
|
16
|
+
- 动态球体绑定的接触响应已纳入刚体接触点速度、有效逆质量和转动惯量;绳索阶段使用虚拟状态、按绳索顺序求解并延迟提交冲量。真实刚体位移在下一物理步更新,仍无全局耦合子步、摩擦、恢复系数或连续碰撞,不代表完整双向物理。稳定性、守恒、运行和快照兼容尚未验证。
|
|
17
17
|
- 布料、绳索、软体、流体、连续世界、导航、动画、音频和其他首发能力仍需按各自依赖完成,不能把独立候选或局部实现写成整项完成。
|
|
18
18
|
|
|
19
19
|
## 单线接续
|
package/docs/api/REFERENCE.md
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
# pelican 0.3.
|
|
1
|
+
# pelican 0.3.14 API 索引
|
|
2
2
|
|
|
3
3
|
Installed declarations: `lib/sdk/index.d.ts` (relative to the package root).
|
|
4
4
|
The signatures below come from the same public SDK source as repository games.
|
|
@@ -11,7 +11,7 @@ Source-file paths and line numbers are build-time provenance only; TypeScript so
|
|
|
11
11
|
来源:`packages/sdk/src/index.ts:2`
|
|
12
12
|
|
|
13
13
|
```ts
|
|
14
|
-
SDK_VERSION = "0.3.
|
|
14
|
+
SDK_VERSION = "0.3.14"
|
|
15
15
|
```
|
|
16
16
|
|
|
17
17
|
## Vec2
|
|
@@ -308,7 +308,7 @@ export interface RopeDefinition {
|
|
|
308
308
|
```ts
|
|
309
309
|
export interface RopeContact {
|
|
310
310
|
tick: number; particle: number; colliderId: string; penetration: number;
|
|
311
|
-
/** Opposite of particle
|
|
311
|
+
/** Opposite of particle contact impulses, N·s; absent in older snapshots. */
|
|
312
312
|
reactionImpulse?: Vec3;
|
|
313
313
|
/** Angular impulse about the world origin; absent in older snapshots. */
|
|
314
314
|
reactionAngularImpulse?: Vec3;
|
package/docs/api/index.json
CHANGED
|
@@ -1,14 +1,14 @@
|
|
|
1
1
|
{
|
|
2
2
|
"format": "pelican.api-index",
|
|
3
3
|
"version": 1,
|
|
4
|
-
"engineVersion": "0.3.
|
|
4
|
+
"engineVersion": "0.3.14",
|
|
5
5
|
"entry": "pelican-engine",
|
|
6
6
|
"source": "packages/sdk/src/index.ts",
|
|
7
7
|
"symbols": [
|
|
8
8
|
{
|
|
9
9
|
"name": "SDK_VERSION",
|
|
10
10
|
"kind": "constant",
|
|
11
|
-
"signature": "SDK_VERSION = \"0.3.
|
|
11
|
+
"signature": "SDK_VERSION = \"0.3.14\"",
|
|
12
12
|
"line": 2
|
|
13
13
|
},
|
|
14
14
|
{
|
|
@@ -232,7 +232,7 @@
|
|
|
232
232
|
{
|
|
233
233
|
"name": "RopeContact",
|
|
234
234
|
"kind": "InterfaceDeclaration",
|
|
235
|
-
"signature": "export interface RopeContact {\n tick: number; particle: number; colliderId: string; penetration: number;\n /** Opposite of particle
|
|
235
|
+
"signature": "export interface RopeContact {\n tick: number; particle: number; colliderId: string; penetration: number;\n /** Opposite of particle contact impulses, N·s; absent in older snapshots. */\n reactionImpulse?: Vec3;\n /** Angular impulse about the world origin; absent in older snapshots. */\n reactionAngularImpulse?: Vec3;\n}",
|
|
236
236
|
"line": 147,
|
|
237
237
|
"fields": [
|
|
238
238
|
"tick",
|
package/docs/npm/GAME-AUTHOR.md
CHANGED
|
@@ -140,9 +140,11 @@ checkout includes `examples/rope-particles`.
|
|
|
140
140
|
To bind a rope to existing dynamic sphere bodies, create it with `autoStep: true`
|
|
141
141
|
and `bodyColliders: [{ id: "rope-collider-id", entity: "body-id" }]`. After the
|
|
142
142
|
rigid-body physics phase, the host samples each bound sphere's actual collider
|
|
143
|
-
center
|
|
144
|
-
|
|
145
|
-
|
|
143
|
+
center, radius, velocity and effective inverse mass/inertia, then steps the rope
|
|
144
|
+
and applies the resulting normal contact impulse to the body. At detected overlaps,
|
|
145
|
+
particle and virtual body corrections share effective mass and address closing
|
|
146
|
+
normal velocity; contacts are not predicted before overlap.
|
|
147
|
+
The actual body's displacement responds on the next body step. Bindings require live dynamic non-trigger spheres and unique
|
|
146
148
|
collider ids and body entities within each rope. Do not use a bound collider id
|
|
147
149
|
in `setColliders`; its manual planes and spheres remain separate. A destroyed body
|
|
148
150
|
is unbound at the next automatic step, and a new entity with the same id is not
|
|
@@ -150,13 +152,15 @@ implicitly rebound. Bindings are saved with Host snapshots.
|
|
|
150
152
|
|
|
151
153
|
Do not manually apply the same contact's reaction when using a body binding: the
|
|
152
154
|
host already applies it. A native failure can still leave the host tick partially
|
|
153
|
-
changed, so restore a known-good checkpoint. This is
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
155
|
+
changed, so restore a known-good checkpoint. This is an approximate split-step
|
|
156
|
+
solver: virtual positions use a first-order update and the real body moves on its
|
|
157
|
+
next step. Ropes are processed sequentially, without globally coupled substeps;
|
|
158
|
+
friction, restitution and continuous collision are not included. Penetration
|
|
159
|
+
correction can inject energy; stability, conservation, runtime and snapshot
|
|
160
|
+
compatibility have not been validated.
|
|
157
161
|
|
|
158
162
|
For each latest-tick contact, `reactionImpulse` and `reactionAngularImpulse`
|
|
159
|
-
report the opposite impulse produced by the particle's
|
|
163
|
+
report the opposite impulse produced by the particle's contact response,
|
|
160
164
|
and its moment about the world origin. These optional fields are absent when
|
|
161
165
|
restoring older contact snapshots. Contacts from unbound manual colliders do not
|
|
162
166
|
automatically act on a rigid body.
|
package/engine.manifest.json
CHANGED
package/lib/physics/index.js
CHANGED
|
@@ -813,7 +813,13 @@ function createPlugin(context) {
|
|
|
813
813
|
const b = get(id);
|
|
814
814
|
if (b.type !== "dynamic" || b.definition.rigidBody?.collider.shape !== "sphere" || b.collider.isSensor())
|
|
815
815
|
fail("TC_PHYSICS_ROPE_BODY", `Rope body '${id}' must be a live dynamic, non-trigger sphere.`);
|
|
816
|
-
|
|
816
|
+
const inertia = b.body.effectiveWorldInvInertia();
|
|
817
|
+
return {
|
|
818
|
+
center: tuple(b.collider.translation()), radius: b.collider.radius(),
|
|
819
|
+
centerOfMass: tuple(b.body.worldCom()), linearVelocity: tuple(b.body.linvel()),
|
|
820
|
+
angularVelocity: tuple(b.body.angvel()), inverseMass: tuple(b.body.effectiveInvMass()),
|
|
821
|
+
inverseInertia: [inertia.m11, inertia.m12, inertia.m13, inertia.m22, inertia.m23, inertia.m33],
|
|
822
|
+
};
|
|
817
823
|
};
|
|
818
824
|
return {
|
|
819
825
|
id: pluginId,
|
|
@@ -823,16 +829,23 @@ function createPlugin(context) {
|
|
|
823
829
|
sampleSphere: sampleRopeSphere,
|
|
824
830
|
applySphereReactions(reactions) {
|
|
825
831
|
alive();
|
|
826
|
-
if (history.length + reactions.length > limit)
|
|
827
|
-
fail("TC_PHYSICS_HISTORY_LIMIT", "Rope reactions exceed physics history capacity; start a new scene.");
|
|
828
832
|
const seen = new Set();
|
|
829
|
-
const actions =
|
|
833
|
+
const actions = [];
|
|
834
|
+
for (const { entity, impulse, moment } of reactions) {
|
|
830
835
|
if (seen.has(entity))
|
|
831
836
|
fail("TC_PHYSICS_ROPE_BODY", `Duplicate rope reaction body '${entity}'.`);
|
|
832
837
|
seen.add(entity);
|
|
833
|
-
const sample = sampleRopeSphere(entity);
|
|
834
|
-
|
|
835
|
-
|
|
838
|
+
const sample = sampleRopeSphere(entity), j = tuple(vec(impulse, "rope reaction"));
|
|
839
|
+
const m = tuple(vec(moment, "rope reaction moment")), c = sample.centerOfMass;
|
|
840
|
+
const torque = [m[0] - (c[1] * j[2] - c[2] * j[1]), m[1] - (c[2] * j[0] - c[0] * j[2]), m[2] - (c[0] * j[1] - c[1] * j[0])];
|
|
841
|
+
vec(torque, "rope reaction torque");
|
|
842
|
+
if (j.some(value => value !== 0))
|
|
843
|
+
actions.push({ kind: "impulse", id: entity, value: j });
|
|
844
|
+
if (torque.some(value => value !== 0))
|
|
845
|
+
actions.push({ kind: "torqueImpulse", id: entity, value: torque });
|
|
846
|
+
}
|
|
847
|
+
if (history.length + actions.length > limit)
|
|
848
|
+
fail("TC_PHYSICS_HISTORY_LIMIT", "Rope reactions exceed physics history capacity; start a new scene.");
|
|
836
849
|
// Validation precedes mutation; a native error can still leave a partial batch.
|
|
837
850
|
for (const action of actions)
|
|
838
851
|
record(action);
|
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.14";
|
|
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];
|
|
@@ -148,7 +148,7 @@ export interface RopeContact {
|
|
|
148
148
|
particle: number;
|
|
149
149
|
colliderId: string;
|
|
150
150
|
penetration: number;
|
|
151
|
-
/** Opposite of particle
|
|
151
|
+
/** Opposite of particle contact impulses, N·s; absent in older snapshots. */
|
|
152
152
|
reactionImpulse?: Vec3;
|
|
153
153
|
/** Angular impulse about the world origin; absent in older snapshots. */
|
|
154
154
|
reactionAngularImpulse?: Vec3;
|
package/lib/sdk/index.js
CHANGED
|
@@ -0,0 +1,107 @@
|
|
|
1
|
+
const error = (message) => { throw new Error(`TC_ROPE_BODY_NUMERIC: ${message}`); };
|
|
2
|
+
const vector = (value, label) => {
|
|
3
|
+
if (!Array.isArray(value) || value.length !== 3 || !value.every(Number.isFinite))
|
|
4
|
+
return error(`${label} must be a finite 3D vector.`);
|
|
5
|
+
return [value[0], value[1], value[2]];
|
|
6
|
+
};
|
|
7
|
+
const add = (a, b) => [a[0] + b[0], a[1] + b[1], a[2] + b[2]];
|
|
8
|
+
const sub = (a, b) => [a[0] - b[0], a[1] - b[1], a[2] - b[2]];
|
|
9
|
+
const scale = (a, value) => [a[0] * value, a[1] * value, a[2] * value];
|
|
10
|
+
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]];
|
|
11
|
+
const dot = (a, b) => a[0] * b[0] + a[1] * b[1] + a[2] * b[2];
|
|
12
|
+
const multiply = (m, v) => [m[0] * v[0] + m[1] * v[1] + m[2] * v[2], m[1] * v[0] + m[3] * v[1] + m[4] * v[2], m[2] * v[0] + m[4] * v[1] + m[5] * v[2]];
|
|
13
|
+
/** Mutable per-host-tick approximation. Native rigid bodies remain untouched until commit. */
|
|
14
|
+
export class VirtualRopeSphere {
|
|
15
|
+
center;
|
|
16
|
+
radius;
|
|
17
|
+
centerOfMass;
|
|
18
|
+
linearVelocity;
|
|
19
|
+
angularVelocity;
|
|
20
|
+
inverseMass;
|
|
21
|
+
inverseInertia;
|
|
22
|
+
impulse = [0, 0, 0];
|
|
23
|
+
moment = [0, 0, 0];
|
|
24
|
+
constructor(sample) {
|
|
25
|
+
this.center = vector(sample.center, "sphere center");
|
|
26
|
+
this.centerOfMass = vector(sample.centerOfMass, "center of mass");
|
|
27
|
+
this.linearVelocity = vector(sample.linearVelocity, "linear velocity");
|
|
28
|
+
this.angularVelocity = vector(sample.angularVelocity, "angular velocity");
|
|
29
|
+
this.inverseMass = vector(sample.inverseMass, "inverse mass");
|
|
30
|
+
if (this.inverseMass.some(value => value < 0))
|
|
31
|
+
error("inverse mass cannot be negative.");
|
|
32
|
+
if (!Array.isArray(sample.inverseInertia) || sample.inverseInertia.length !== 6 || !sample.inverseInertia.every(Number.isFinite))
|
|
33
|
+
throw new Error("TC_ROPE_BODY_NUMERIC: world inverse inertia must have six finite elements.");
|
|
34
|
+
this.inverseInertia = [...sample.inverseInertia];
|
|
35
|
+
if (this.inverseInertia[0] < 0 || this.inverseInertia[3] < 0 || this.inverseInertia[5] < 0)
|
|
36
|
+
error("world inverse inertia diagonal cannot be negative.");
|
|
37
|
+
if (!Number.isFinite(sample.radius) || sample.radius <= 0 || sample.radius > 1e6)
|
|
38
|
+
error("sphere radius is outside (0, 1e6].");
|
|
39
|
+
this.radius = sample.radius;
|
|
40
|
+
}
|
|
41
|
+
point(normal) { return add(this.center, scale(normal, this.radius)); }
|
|
42
|
+
pointVelocity(point) { return add(this.linearVelocity, cross(this.angularVelocity, sub(point, this.centerOfMass))); }
|
|
43
|
+
inverseEffectiveMass(point, normal) {
|
|
44
|
+
const rCrossN = cross(sub(point, this.centerOfMass), normal);
|
|
45
|
+
const linear = normal[0] * normal[0] * this.inverseMass[0] + normal[1] * normal[1] * this.inverseMass[1] + normal[2] * normal[2] * this.inverseMass[2];
|
|
46
|
+
const angular = dot(rCrossN, multiply(this.inverseInertia, rCrossN));
|
|
47
|
+
const result = linear + angular;
|
|
48
|
+
if (!Number.isFinite(result) || result < -1e-8)
|
|
49
|
+
return error("invalid effective inverse mass.");
|
|
50
|
+
return Math.max(0, result);
|
|
51
|
+
}
|
|
52
|
+
apply(impulse, point, dt) {
|
|
53
|
+
const r = sub(point, this.centerOfMass);
|
|
54
|
+
const deltaLinear = [this.inverseMass[0] * impulse[0], this.inverseMass[1] * impulse[1], this.inverseMass[2] * impulse[2]];
|
|
55
|
+
const deltaAngular = multiply(this.inverseInertia, cross(r, impulse));
|
|
56
|
+
const relativeCenter = sub(this.center, this.centerOfMass);
|
|
57
|
+
const translation = scale(deltaLinear, dt);
|
|
58
|
+
// First-order virtual transform update; the actual rigid body moves on its next physics step.
|
|
59
|
+
this.center = vector(add(add(this.center, translation), cross(scale(deltaAngular, dt), relativeCenter)), "virtual sphere center");
|
|
60
|
+
this.centerOfMass = vector(add(this.centerOfMass, translation), "virtual center of mass");
|
|
61
|
+
this.linearVelocity = vector(add(this.linearVelocity, deltaLinear), "virtual linear velocity");
|
|
62
|
+
this.angularVelocity = vector(add(this.angularVelocity, deltaAngular), "virtual angular velocity");
|
|
63
|
+
this.impulse = vector(add(this.impulse, impulse), "accumulated impulse");
|
|
64
|
+
this.moment = vector(add(this.moment, cross(point, impulse)), "accumulated moment");
|
|
65
|
+
}
|
|
66
|
+
resolve(normal, depth, position, previous, mass, dt) {
|
|
67
|
+
const invParticle = 1 / mass;
|
|
68
|
+
if (!Number.isFinite(invParticle) || invParticle <= 0 || !Number.isFinite(dt) || dt <= 0)
|
|
69
|
+
return error("particle mass and substep must be finite and positive.");
|
|
70
|
+
// n·M⁻¹n + (r×n)·I⁻¹(r×n) accounts for translation, rotation and locked axes.
|
|
71
|
+
const firstPoint = this.point(normal), denominator = invParticle + this.inverseEffectiveMass(firstPoint, normal);
|
|
72
|
+
if (!Number.isFinite(denominator) || denominator <= 0)
|
|
73
|
+
return error("contact inverse mass must be positive and finite.");
|
|
74
|
+
// Position projection is shared between particle and body by effective inverse mass.
|
|
75
|
+
const positionImpulse = depth / (dt * denominator);
|
|
76
|
+
const firstImpulse = scale(normal, -positionImpulse);
|
|
77
|
+
let correction = scale(normal, positionImpulse * invParticle * dt);
|
|
78
|
+
let reactionImpulse = firstImpulse, reactionAngularImpulse = cross(firstPoint, firstImpulse);
|
|
79
|
+
this.apply(firstImpulse, firstPoint, dt);
|
|
80
|
+
// Remove remaining closing normal velocity using the virtual body's updated velocity.
|
|
81
|
+
const secondPoint = this.point(normal);
|
|
82
|
+
const particleVelocity = scale(sub(add(position, correction), previous), 1 / dt);
|
|
83
|
+
const relativeNormal = dot(sub(particleVelocity, this.pointVelocity(secondPoint)), normal);
|
|
84
|
+
const velocityDenominator = invParticle + this.inverseEffectiveMass(secondPoint, normal);
|
|
85
|
+
if (!Number.isFinite(relativeNormal) || !Number.isFinite(velocityDenominator) || velocityDenominator <= 0)
|
|
86
|
+
return error("contact velocity or inverse mass is not finite.");
|
|
87
|
+
if (relativeNormal < 0) {
|
|
88
|
+
const velocityImpulse = -relativeNormal / velocityDenominator;
|
|
89
|
+
const secondImpulse = scale(normal, -velocityImpulse);
|
|
90
|
+
correction = add(correction, scale(normal, velocityImpulse * invParticle * dt));
|
|
91
|
+
reactionImpulse = add(reactionImpulse, secondImpulse);
|
|
92
|
+
reactionAngularImpulse = add(reactionAngularImpulse, cross(secondPoint, secondImpulse));
|
|
93
|
+
this.apply(secondImpulse, secondPoint, dt);
|
|
94
|
+
}
|
|
95
|
+
return { correction: vector(correction, "particle correction"), reactionImpulse: vector(reactionImpulse, "reaction impulse"), reactionAngularImpulse: vector(reactionAngularImpulse, "reaction moment") };
|
|
96
|
+
}
|
|
97
|
+
reaction() { return { impulse: vector(this.impulse, "reaction impulse"), moment: vector(this.moment, "reaction moment") }; }
|
|
98
|
+
}
|
|
99
|
+
export function createRopeBodyCoupling(bodies) {
|
|
100
|
+
return {
|
|
101
|
+
sphere(id) { const body = bodies.get(id); return body ? { center: body.center, radius: body.radius } : undefined; },
|
|
102
|
+
resolve(id, normal, depth, position, previous, mass, dt) {
|
|
103
|
+
const body = bodies.get(id) ?? error(`Missing virtual sphere '${id}'.`);
|
|
104
|
+
return body.resolve(normal, depth, position, previous, mass, dt);
|
|
105
|
+
},
|
|
106
|
+
};
|
|
107
|
+
}
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { resolveEntityDefinition } from "../runtime/prefabs.js";
|
|
2
2
|
import { cloneSerializable } from "../runtime/random.js";
|
|
3
3
|
import { createRope } from "./rope.js";
|
|
4
|
+
import { createRopeBodyCoupling, VirtualRopeSphere } from "./rope-body-coupling.js";
|
|
4
5
|
const fail = (code, detail) => { throw new Error(`TC_ROPE_${code}: ${detail}`); };
|
|
5
6
|
const validId = (id) => typeof id === "string" && id.trim() === id && id.length > 0 && id.length <= 128;
|
|
6
7
|
function createEntry(input, fixedDt) {
|
|
@@ -93,7 +94,7 @@ function validateWorldBindings(entries, context, world) {
|
|
|
93
94
|
fail("SNAPSHOT", `Bound body '${binding.entity}' must exist as a dynamic, non-trigger sphere in the restored world.`);
|
|
94
95
|
}
|
|
95
96
|
}
|
|
96
|
-
/**
|
|
97
|
+
/** Split-step sphere contact uses virtual mass/velocity state; native commit is deferred. */
|
|
97
98
|
export const createRopePlugin = (context) => {
|
|
98
99
|
const { fixedDt } = context;
|
|
99
100
|
if (typeof fixedDt !== "number" || !Number.isFinite(fixedDt))
|
|
@@ -170,7 +171,7 @@ export const createRopePlugin = (context) => {
|
|
|
170
171
|
alive();
|
|
171
172
|
ensureRefs();
|
|
172
173
|
const committed = [];
|
|
173
|
-
const
|
|
174
|
+
const bodies = new Map();
|
|
174
175
|
try {
|
|
175
176
|
const automatic = [...entries.values()].filter(entry => entry.definition.autoStep)
|
|
176
177
|
.sort((a, b) => a.definition.id < b.definition.id ? -1 : a.definition.id > b.definition.id ? 1 : 0);
|
|
@@ -191,33 +192,27 @@ export const createRopePlugin = (context) => {
|
|
|
191
192
|
entry.bodyRefs = new Map(live.map(binding => [binding.id, entry.bodyRefs.get(binding.id)]));
|
|
192
193
|
entry.staleBodies = new Set();
|
|
193
194
|
}
|
|
195
|
+
const coupled = new Map();
|
|
194
196
|
if (previous.length) {
|
|
195
197
|
const boundIds = new Set(previous.map(item => item.id));
|
|
196
198
|
const manual = snapshot.colliders.filter(collider => !boundIds.has(collider.id));
|
|
197
199
|
const bridge = live.length ? bridgeFor(context) : undefined;
|
|
198
200
|
const sampled = live.map(binding => {
|
|
199
|
-
|
|
200
|
-
|
|
201
|
+
let body = bodies.get(binding.entity);
|
|
202
|
+
if (!body) {
|
|
203
|
+
body = new VirtualRopeSphere(bridge.sampleSphere(binding.entity));
|
|
204
|
+
bodies.set(binding.entity, body);
|
|
205
|
+
}
|
|
206
|
+
coupled.set(binding.id, body);
|
|
207
|
+
return { kind: "sphere", id: binding.id, center: [...body.center], radius: body.radius };
|
|
201
208
|
});
|
|
202
209
|
entry.solver.setColliders([...manual, ...sampled]);
|
|
203
210
|
}
|
|
204
|
-
|
|
205
|
-
const byCollider = new Map(live.map(binding => [binding.id, binding.entity]));
|
|
206
|
-
for (const contact of state.contacts) {
|
|
207
|
-
const entity = byCollider.get(contact.colliderId), impulse = contact.reactionImpulse;
|
|
208
|
-
if (!entity || !impulse)
|
|
209
|
-
continue;
|
|
210
|
-
const total = reactions.get(entity) ?? [0, 0, 0];
|
|
211
|
-
for (let k = 0; k < 3; k++)
|
|
212
|
-
total[k] += impulse[k];
|
|
213
|
-
if (!total.every(Number.isFinite))
|
|
214
|
-
fail("NUMERIC", `Reaction for '${entity}' exceeded finite bounds.`);
|
|
215
|
-
reactions.set(entity, total);
|
|
216
|
-
}
|
|
211
|
+
entry.solver.step(1, coupled.size ? createRopeBodyCoupling(coupled) : undefined);
|
|
217
212
|
}
|
|
218
|
-
const batch = [...
|
|
219
|
-
.
|
|
220
|
-
.
|
|
213
|
+
const batch = [...bodies].map(([entity, body]) => ({ entity, ...body.reaction() }))
|
|
214
|
+
.filter(item => item.impulse.some(value => value !== 0) || item.moment.some(value => value !== 0))
|
|
215
|
+
.sort((a, b) => a.entity < b.entity ? -1 : a.entity > b.entity ? 1 : 0);
|
|
221
216
|
if (batch.length)
|
|
222
217
|
bridgeFor(context).applySphereReactions(batch);
|
|
223
218
|
}
|
package/lib/simulation/rope.js
CHANGED
|
@@ -140,8 +140,10 @@ export class RopeSolver {
|
|
|
140
140
|
this.forces[i] = next;
|
|
141
141
|
}
|
|
142
142
|
/** Exactly N fixed ticks; a failed batch restores the state before the first tick. */
|
|
143
|
-
step(steps = 1) {
|
|
143
|
+
step(steps = 1, coupling) {
|
|
144
144
|
this.alive();
|
|
145
|
+
if (coupling && steps !== 1)
|
|
146
|
+
fail("ROPE_INPUT", "coupled stepping requires exactly one tick");
|
|
145
147
|
if (!Number.isSafeInteger(steps) || steps < 1 || steps > 1000 || this.tickValue + steps > Number.MAX_SAFE_INTEGER)
|
|
146
148
|
fail("ROPE_INPUT", "steps must be an integer in [1,1000] without tick overflow");
|
|
147
149
|
// stepOne replaces these arrays rather than mutating the prior committed state.
|
|
@@ -154,7 +156,7 @@ export class RopeSolver {
|
|
|
154
156
|
};
|
|
155
157
|
try {
|
|
156
158
|
for (let s = 0; s < steps; s++)
|
|
157
|
-
this.stepOne();
|
|
159
|
+
this.stepOne(coupling);
|
|
158
160
|
}
|
|
159
161
|
catch (error) {
|
|
160
162
|
this.positions = before.positions;
|
|
@@ -166,7 +168,7 @@ export class RopeSolver {
|
|
|
166
168
|
}
|
|
167
169
|
return this.inspect();
|
|
168
170
|
}
|
|
169
|
-
stepOne() {
|
|
171
|
+
stepOne(coupling) {
|
|
170
172
|
const { fixedDt, substeps, iterations, mass, gravity, damping, restLengths, radius } = this.config;
|
|
171
173
|
const h = fixedDt / substeps, decay = Math.exp(-damping * h);
|
|
172
174
|
// Work on copies; numeric failures must never leave half a tick applied.
|
|
@@ -215,29 +217,41 @@ export class RopeSolver {
|
|
|
215
217
|
for (const c of this.colliders) {
|
|
216
218
|
let depth = 0;
|
|
217
219
|
let normal;
|
|
220
|
+
const dynamic = c.kind === "sphere" ? coupling?.sphere(c.id) : undefined;
|
|
218
221
|
if (c.kind === "plane") {
|
|
219
222
|
depth = c.offset + radius - (p[0] * c.normal[0] + p[1] * c.normal[1] + p[2] * c.normal[2]);
|
|
220
223
|
normal = c.normal;
|
|
221
224
|
}
|
|
222
225
|
else {
|
|
223
|
-
const
|
|
226
|
+
const center = dynamic?.center ?? c.center, colliderRadius = dynamic?.radius ?? c.radius;
|
|
227
|
+
const delta = [p[0] - center[0], p[1] - center[1], p[2] - center[2]];
|
|
224
228
|
const distance = length(delta);
|
|
225
|
-
depth =
|
|
229
|
+
depth = colliderRadius + radius - distance;
|
|
226
230
|
normal = distance < 1e-12 ? [1, 0, 0] : [delta[0] / distance, delta[1] / distance, delta[2] / distance];
|
|
227
231
|
}
|
|
228
232
|
if (depth <= 0)
|
|
229
233
|
continue;
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
234
|
+
let reaction, moment;
|
|
235
|
+
if (dynamic && coupling) {
|
|
236
|
+
const result = coupling.resolve(c.id, normal, depth, p, previous[i], mass, h);
|
|
237
|
+
if (!finiteVector(result.correction) || !finiteVector(result.reactionImpulse) || !finiteVector(result.reactionAngularImpulse))
|
|
238
|
+
fail("ROPE_NUMERIC", "coupled contact exceeded finite numeric bounds");
|
|
239
|
+
for (let k = 0; k < 3; k++)
|
|
240
|
+
p[k] += result.correction[k];
|
|
241
|
+
reaction = result.reactionImpulse;
|
|
242
|
+
moment = result.reactionAngularImpulse;
|
|
243
|
+
}
|
|
244
|
+
else {
|
|
245
|
+
for (let k = 0; k < 3; k++)
|
|
246
|
+
p[k] += normal[k] * depth;
|
|
247
|
+
// A manually sampled collider remains immovable; expose its projection reaction.
|
|
248
|
+
const magnitude = -mass * depth / h;
|
|
249
|
+
reaction = [normal[0] * magnitude, normal[1] * magnitude, normal[2] * magnitude];
|
|
250
|
+
const point = c.kind === "sphere"
|
|
251
|
+
? [c.center[0] + normal[0] * c.radius, c.center[1] + normal[1] * c.radius, c.center[2] + normal[2] * c.radius]
|
|
252
|
+
: [p[0] - normal[0] * radius, p[1] - normal[1] * radius, p[2] - normal[2] * radius];
|
|
253
|
+
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]];
|
|
254
|
+
}
|
|
241
255
|
if (!finiteVector(reaction) || !finiteVector(moment))
|
|
242
256
|
fail("ROPE_NUMERIC", "contact reaction exceeded finite numeric bounds");
|
|
243
257
|
const key = `${i}:${c.id}`;
|