pelican-engine 0.3.14 → 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 +31 -5
- package/distribution.json +15 -14
- package/docs/STATUS.md +2 -2
- package/docs/api/REFERENCE.md +148 -92
- package/docs/api/index.json +143 -95
- package/docs/npm/GAME-AUTHOR.md +31 -5
- package/engine.manifest.json +1 -1
- package/lib/renderer/browser.js +24 -3
- package/lib/renderer/geometry.js +26 -16
- package/lib/renderer/rope-visuals.js +122 -0
- package/lib/sdk/index.d.ts +41 -3
- package/lib/sdk/index.js +1 -1
- package/lib/simulation/rope-plugin.js +43 -9
- package/lib/simulation/rope.js +116 -5
- package/package.json +1 -1
package/docs/npm/GAME-AUTHOR.md
CHANGED
|
@@ -137,6 +137,33 @@ 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
|
+
|
|
153
|
+
For optional visual presentation, declare a material in `defineGame({ materials:
|
|
154
|
+
[...] })` and create the rope with `visual: { material: "your-material-id" }`.
|
|
155
|
+
The renderer draws capped cylindrical instances between consecutive simulated
|
|
156
|
+
particles and updates their transforms as the rope moves. `radius` defaults to
|
|
157
|
+
the particle collision radius (or 0.025); `radialSegments` defaults to 8 and
|
|
158
|
+
accepts 3–32. `castShadow` and `receiveShadow` default to true. A collapsed
|
|
159
|
+
zero-length segment has no visible surface. `ctx.ropes.visuals()` returns
|
|
160
|
+
detached descriptions, not renderer objects. Visual configuration is included
|
|
161
|
+
in the rope snapshot and is disposed with the owning content.
|
|
162
|
+
|
|
163
|
+
This is segmented geometry with overlapping caps, not a continuous deformable
|
|
164
|
+
tube. Large ropes may be expensive. Raster, ray-traced modes, GPU resource
|
|
165
|
+
lifecycle and snapshot restoration have not been runtime validated.
|
|
166
|
+
|
|
140
167
|
To bind a rope to existing dynamic sphere bodies, create it with `autoStep: true`
|
|
141
168
|
and `bodyColliders: [{ id: "rope-collider-id", entity: "body-id" }]`. After the
|
|
142
169
|
rigid-body physics phase, the host samples each bound sphere's actual collider
|
|
@@ -167,8 +194,8 @@ automatically act on a rigid body.
|
|
|
167
194
|
For an explicitly mapped dynamic body, game code can read its actual world center
|
|
168
195
|
of mass via `ctx.physics.centerOfMass(id)`, apply the linear impulse, and apply
|
|
169
196
|
`reactionAngularImpulse - centerOfMass × reactionImpulse` as torque impulse.
|
|
170
|
-
|
|
171
|
-
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
|
|
172
199
|
automatic binding instead.
|
|
173
200
|
|
|
174
201
|
This sampled, manually applied reaction is delayed and uses positional correction,
|
|
@@ -178,9 +205,8 @@ operations and is not transactional if a tick fails; recover from a known-good
|
|
|
178
205
|
checkpoint. It must not be represented as complete two-way rigid-body interaction.
|
|
179
206
|
|
|
180
207
|
This is an unverified development-stage increment, not complete rope physics:
|
|
181
|
-
|
|
182
|
-
or tearing.
|
|
183
|
-
coupled physics. Plane normals must be unit length; `offset` is the signed
|
|
208
|
+
the optional segmented visual does not provide a continuous deformable rope mesh,
|
|
209
|
+
general rigid-body coupling, self collision, or tearing. Plane normals must be unit length; `offset` is the signed
|
|
184
210
|
normal distance from the origin. Host intervals must be between 1/1000 and
|
|
185
211
|
1/30 seconds inclusive.
|
|
186
212
|
|
package/engine.manifest.json
CHANGED
package/lib/renderer/browser.js
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import * as THREE from "three/webgpu";
|
|
2
2
|
import { prepareGameplay } from "./gameplay.js";
|
|
3
|
+
import { createRopeVisualOwner } from "./rope-visuals.js";
|
|
3
4
|
import { GPUTracer } from "./pathtrace.js";
|
|
4
5
|
import { SurfaceTracer } from "./surface-tracer.js";
|
|
5
6
|
import { createRenderSettingsDriver, resolveRenderSettings, } from "../runtime/render-settings.js";
|
|
@@ -134,6 +135,11 @@ const recoveryEvents = [];
|
|
|
134
135
|
const gameObjects = new Map();
|
|
135
136
|
const gameRoot = new THREE.Group();
|
|
136
137
|
scene.add(gameRoot);
|
|
138
|
+
const ropeVisuals = createRopeVisualOwner();
|
|
139
|
+
scene.add(ropeVisuals.root);
|
|
140
|
+
let ropeVisualRevision = 0, renderedRopeVisualRevision = 0;
|
|
141
|
+
const clearRopeVisuals = () => { if (ropeVisuals.clear())
|
|
142
|
+
ropeVisualRevision++; };
|
|
137
143
|
let authoredCamera = { position: [0, 3.4, 7], target: [0, 0.9, 0] };
|
|
138
144
|
let authoredLights = [];
|
|
139
145
|
let compiledIR = null;
|
|
@@ -352,8 +358,10 @@ function createEntityView(entity, materials) {
|
|
|
352
358
|
return node;
|
|
353
359
|
}
|
|
354
360
|
function syncRuntime() {
|
|
355
|
-
if (!runtime)
|
|
356
|
-
|
|
361
|
+
if (!runtime) {
|
|
362
|
+
clearRopeVisuals();
|
|
363
|
+
return ropeVisualRevision !== renderedRopeVisualRevision;
|
|
364
|
+
}
|
|
357
365
|
transforms.clear();
|
|
358
366
|
componentState.clear();
|
|
359
367
|
const records = runtime.world.records();
|
|
@@ -428,6 +436,13 @@ function syncRuntime() {
|
|
|
428
436
|
if (light)
|
|
429
437
|
authoredLights.push({ definition: entity.light, object: light });
|
|
430
438
|
}
|
|
439
|
+
if (gameplay) {
|
|
440
|
+
if (ropeVisuals.sync(runtime.facade.ropes.visuals(), gameMaterials))
|
|
441
|
+
ropeVisualRevision++;
|
|
442
|
+
}
|
|
443
|
+
else
|
|
444
|
+
clearRopeVisuals();
|
|
445
|
+
return ropeVisualRevision !== renderedRopeVisualRevision;
|
|
431
446
|
}
|
|
432
447
|
let simulation;
|
|
433
448
|
const simulationGroup = new THREE.Group();
|
|
@@ -720,6 +735,7 @@ async function loadGame(sceneId, saved, loadSlot) {
|
|
|
720
735
|
contentScope = nextScope;
|
|
721
736
|
gameMaterials = nextMaterials;
|
|
722
737
|
committed = true;
|
|
738
|
+
clearRopeVisuals();
|
|
723
739
|
if (nextRoot.children.length)
|
|
724
740
|
gameRoot.add(...nextRoot.children);
|
|
725
741
|
gameObjects.clear();
|
|
@@ -950,7 +966,9 @@ async function drawFrame(accumulate = false, changed = true) {
|
|
|
950
966
|
throw new Error("TC_BROWSER_DISPOSED");
|
|
951
967
|
await computeControl?.flush();
|
|
952
968
|
const start = performance.now();
|
|
953
|
-
syncRuntime();
|
|
969
|
+
const ropeChanged = syncRuntime();
|
|
970
|
+
const ropeRevision = ropeVisualRevision;
|
|
971
|
+
changed = changed || ropeChanged;
|
|
954
972
|
animate();
|
|
955
973
|
renderBufferBridge?.update();
|
|
956
974
|
renderTextureBridge?.update();
|
|
@@ -1030,6 +1048,7 @@ async function drawFrame(accumulate = false, changed = true) {
|
|
|
1030
1048
|
triangles: triangleCount,
|
|
1031
1049
|
faulted: terminalFault,
|
|
1032
1050
|
});
|
|
1051
|
+
renderedRopeVisualRevision = ropeRevision;
|
|
1033
1052
|
return;
|
|
1034
1053
|
}
|
|
1035
1054
|
if (changed) {
|
|
@@ -1082,6 +1101,7 @@ async function drawFrame(accumulate = false, changed = true) {
|
|
|
1082
1101
|
triangles: triangleCount,
|
|
1083
1102
|
faulted: terminalFault,
|
|
1084
1103
|
});
|
|
1104
|
+
renderedRopeVisualRevision = ropeRevision;
|
|
1085
1105
|
}
|
|
1086
1106
|
async function applyInput(key) {
|
|
1087
1107
|
if (!probeEnabled) {
|
|
@@ -1327,6 +1347,7 @@ async function disposeBrowser() {
|
|
|
1327
1347
|
clean(() => simulation?.dispose());
|
|
1328
1348
|
clean(() => world?.dispose());
|
|
1329
1349
|
clean(() => resourceManager.dispose());
|
|
1350
|
+
clean(() => ropeVisuals.dispose());
|
|
1330
1351
|
clean(() => releaseObject(scene, true, gameMaterials.values()));
|
|
1331
1352
|
clean(() => pendingSurface?.abort());
|
|
1332
1353
|
clean(() => tracer?.dispose());
|
package/lib/renderer/geometry.js
CHANGED
|
@@ -18,22 +18,32 @@ export function extractTriangles(scene) {
|
|
|
18
18
|
new THREE.Vector3(),
|
|
19
19
|
new THREE.Vector3(),
|
|
20
20
|
];
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
21
|
+
const instanced = object instanceof THREE.InstancedMesh ? object : undefined;
|
|
22
|
+
const instance = new THREE.Matrix4(), transform = new THREE.Matrix4();
|
|
23
|
+
for (let slot = 0; slot < (instanced?.count ?? 1); slot++) {
|
|
24
|
+
if (instanced) {
|
|
25
|
+
instanced.getMatrixAt(slot, instance);
|
|
26
|
+
transform.multiplyMatrices(object.matrixWorld, instance);
|
|
27
|
+
}
|
|
28
|
+
else
|
|
29
|
+
transform.copy(object.matrixWorld);
|
|
30
|
+
for (let i = 0; i < count; i += 3) {
|
|
31
|
+
for (let k = 0; k < 3; k++)
|
|
32
|
+
object
|
|
33
|
+
.getVertexPosition(geometry.index?.getX(i + k) ?? i + k, points[k])
|
|
34
|
+
.applyMatrix4(transform);
|
|
35
|
+
const color = material.color ?? new THREE.Color(0.6, 0.6, 0.6);
|
|
36
|
+
triangles.push({
|
|
37
|
+
a: points[0].toArray(),
|
|
38
|
+
b: points[1].toArray(),
|
|
39
|
+
c: points[2].toArray(),
|
|
40
|
+
color: color.toArray(),
|
|
41
|
+
emission: material.emissiveIntensity && material.emissive?.getHex()
|
|
42
|
+
? material.emissiveIntensity
|
|
43
|
+
: 0,
|
|
44
|
+
roughness: material.roughness ?? 0.8,
|
|
45
|
+
});
|
|
46
|
+
}
|
|
37
47
|
}
|
|
38
48
|
});
|
|
39
49
|
return triangles;
|
|
@@ -0,0 +1,122 @@
|
|
|
1
|
+
import * as THREE from "three/webgpu";
|
|
2
|
+
/** Presentation owns its instance buffers and geometries; scene materials remain borrowed. */
|
|
3
|
+
export function createRopeVisualOwner() {
|
|
4
|
+
const root = new THREE.Group();
|
|
5
|
+
root.name = "pelican-rope-visuals";
|
|
6
|
+
const views = new Map();
|
|
7
|
+
let disposed = false;
|
|
8
|
+
const remove = (view) => { view.mesh.removeFromParent(); view.mesh.dispose(); view.geometry.dispose(); };
|
|
9
|
+
const clear = () => { const changed = views.size > 0; for (const view of views.values())
|
|
10
|
+
remove(view); views.clear(); return changed; };
|
|
11
|
+
const signature = (state) => JSON.stringify([state.material, state.radius, state.radialSegments, state.castShadow, state.receiveShadow, state.positions.length]);
|
|
12
|
+
const create = (state, material) => {
|
|
13
|
+
let geometry;
|
|
14
|
+
let mesh;
|
|
15
|
+
try {
|
|
16
|
+
geometry = new THREE.CylinderGeometry(state.radius, state.radius, 1, state.radialSegments, 1, false);
|
|
17
|
+
const uv = geometry.getAttribute("uv");
|
|
18
|
+
if (uv)
|
|
19
|
+
geometry.setAttribute("uv1", uv.clone());
|
|
20
|
+
mesh = new THREE.InstancedMesh(geometry, material, state.positions.length - 1);
|
|
21
|
+
mesh.name = `rope:${state.id}`;
|
|
22
|
+
mesh.castShadow = state.castShadow;
|
|
23
|
+
mesh.receiveShadow = state.receiveShadow;
|
|
24
|
+
mesh.count = 0;
|
|
25
|
+
mesh.visible = false;
|
|
26
|
+
mesh.instanceMatrix.setUsage(THREE.DynamicDrawUsage);
|
|
27
|
+
return { mesh, geometry, signature: signature(state), positions: [] };
|
|
28
|
+
}
|
|
29
|
+
catch (error) {
|
|
30
|
+
mesh?.dispose();
|
|
31
|
+
geometry?.dispose();
|
|
32
|
+
throw error;
|
|
33
|
+
}
|
|
34
|
+
};
|
|
35
|
+
const matrices = (state) => {
|
|
36
|
+
const data = new Float32Array((state.positions.length - 1) * 16);
|
|
37
|
+
const axis = new THREE.Vector3(0, 1, 0), direction = new THREE.Vector3();
|
|
38
|
+
const position = new THREE.Vector3(), scale = new THREE.Vector3(), rotation = new THREE.Quaternion();
|
|
39
|
+
const matrix = new THREE.Matrix4();
|
|
40
|
+
let count = 0;
|
|
41
|
+
for (let index = 0; index < state.positions.length - 1; index++) {
|
|
42
|
+
const a = state.positions[index], b = state.positions[index + 1];
|
|
43
|
+
direction.set(b[0] - a[0], b[1] - a[1], b[2] - a[2]);
|
|
44
|
+
const length = direction.length();
|
|
45
|
+
// A collapsed segment has no surface; never submit a singular instance transform.
|
|
46
|
+
if (length === 0)
|
|
47
|
+
continue;
|
|
48
|
+
if (!Number.isFinite(length) || Math.fround(length) === 0)
|
|
49
|
+
throw new Error(`TC_ROPE_VISUAL_NUMERIC: Segment ${index} of '${state.id}' has an unrepresentable length.`);
|
|
50
|
+
position.set((a[0] + b[0]) / 2, (a[1] + b[1]) / 2, (a[2] + b[2]) / 2);
|
|
51
|
+
rotation.setFromUnitVectors(axis, direction.multiplyScalar(1 / length));
|
|
52
|
+
scale.set(1, length, 1);
|
|
53
|
+
matrix.compose(position, rotation, scale);
|
|
54
|
+
for (const value of matrix.elements)
|
|
55
|
+
if (!Number.isFinite(value) || !Number.isFinite(Math.fround(value)))
|
|
56
|
+
throw new Error(`TC_ROPE_VISUAL_NUMERIC: Segment ${index} of '${state.id}' has a non-finite transform.`);
|
|
57
|
+
data.set(matrix.elements, count++ * 16);
|
|
58
|
+
}
|
|
59
|
+
return { data, count };
|
|
60
|
+
};
|
|
61
|
+
const sync = (states, materials) => {
|
|
62
|
+
if (disposed)
|
|
63
|
+
throw new Error("TC_ROPE_VISUAL_DISPOSED: Create a new presentation owner.");
|
|
64
|
+
const staged = new Map();
|
|
65
|
+
const created = [];
|
|
66
|
+
try {
|
|
67
|
+
for (const state of states) {
|
|
68
|
+
if (staged.has(state.id))
|
|
69
|
+
throw new Error(`TC_ROPE_VISUAL_ID: Duplicate rope '${state.id}'.`);
|
|
70
|
+
const material = materials.get(state.material);
|
|
71
|
+
if (!material)
|
|
72
|
+
throw new Error(`TC_ROPE_VISUAL_MATERIAL: Material '${state.material}' is not declared.`);
|
|
73
|
+
if (!Array.isArray(state.positions) || state.positions.length < 2 || state.positions.length > 4096 ||
|
|
74
|
+
state.positions.some(point => !Array.isArray(point) || point.length !== 3 || !point.every(Number.isFinite)))
|
|
75
|
+
throw new Error(`TC_ROPE_VISUAL_POSITION: Rope '${state.id}' requires finite particle positions.`);
|
|
76
|
+
if (!Number.isFinite(state.radius) || state.radius <= 0 || Math.fround(state.radius) === 0 ||
|
|
77
|
+
!Number.isInteger(state.radialSegments) || state.radialSegments < 3 || state.radialSegments > 32)
|
|
78
|
+
throw new Error(`TC_ROPE_VISUAL_SHAPE: Rope '${state.id}' has an invalid radius or radial segment count.`);
|
|
79
|
+
const old = views.get(state.id), same = old && old.signature === signature(state) && old.mesh.material === material;
|
|
80
|
+
const view = same ? old : create(state, material);
|
|
81
|
+
if (!same)
|
|
82
|
+
created.push(view);
|
|
83
|
+
const positions = state.positions.flatMap(point => [...point]);
|
|
84
|
+
const changed = !same || positions.length !== view.positions.length || positions.some((value, index) => value !== view.positions[index]);
|
|
85
|
+
const update = changed ? matrices(state) : undefined;
|
|
86
|
+
staged.set(state.id, { view, created: !same, positions, ...(update ? { matrices: update.data, count: update.count } : {}) });
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
catch (error) {
|
|
90
|
+
for (const view of created)
|
|
91
|
+
remove(view);
|
|
92
|
+
throw error;
|
|
93
|
+
}
|
|
94
|
+
let changed = false;
|
|
95
|
+
for (const [id, view] of views)
|
|
96
|
+
if (!staged.has(id) || staged.get(id).view !== view) {
|
|
97
|
+
remove(view);
|
|
98
|
+
changed = true;
|
|
99
|
+
}
|
|
100
|
+
views.clear();
|
|
101
|
+
for (const [id, item] of staged) {
|
|
102
|
+
if (item.created) {
|
|
103
|
+
root.add(item.view.mesh);
|
|
104
|
+
changed = true;
|
|
105
|
+
}
|
|
106
|
+
if (item.matrices) {
|
|
107
|
+
item.view.mesh.instanceMatrix.array.set(item.matrices);
|
|
108
|
+
item.view.mesh.count = item.count;
|
|
109
|
+
item.view.mesh.visible = item.count > 0;
|
|
110
|
+
item.view.mesh.instanceMatrix.needsUpdate = true;
|
|
111
|
+
if (item.count > 0)
|
|
112
|
+
item.view.mesh.computeBoundingSphere();
|
|
113
|
+
item.view.positions = item.positions;
|
|
114
|
+
changed = true;
|
|
115
|
+
}
|
|
116
|
+
views.set(id, item.view);
|
|
117
|
+
}
|
|
118
|
+
return changed;
|
|
119
|
+
};
|
|
120
|
+
return { root, sync, clear, dispose() { if (disposed)
|
|
121
|
+
return; clear(); root.removeFromParent(); disposed = true; } };
|
|
122
|
+
}
|
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];
|
|
@@ -103,8 +103,8 @@ export interface SystemContext extends EngineFacade {
|
|
|
103
103
|
readonly navigation: NavigationService;
|
|
104
104
|
readonly ropes: RopeService;
|
|
105
105
|
}
|
|
106
|
-
/** CPU particles with sampled sphere/plane collisions. Explicit body bindings apply
|
|
107
|
-
*
|
|
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 self collision. */
|
|
108
108
|
export type RopeCollider = {
|
|
109
109
|
kind: "sphere";
|
|
110
110
|
id: string;
|
|
@@ -121,6 +121,24 @@ export interface RopeBodyCollider {
|
|
|
121
121
|
readonly id: string;
|
|
122
122
|
readonly entity: string;
|
|
123
123
|
}
|
|
124
|
+
/** Optional renderer-owned cylindrical segments following simulated particle positions. */
|
|
125
|
+
export interface RopeVisualOptions {
|
|
126
|
+
material: string;
|
|
127
|
+
radius?: number;
|
|
128
|
+
radialSegments?: number;
|
|
129
|
+
castShadow?: boolean;
|
|
130
|
+
receiveShadow?: boolean;
|
|
131
|
+
}
|
|
132
|
+
/** Detached presentation state; native renderer objects are not exposed. */
|
|
133
|
+
export interface RopeVisualState {
|
|
134
|
+
id: string;
|
|
135
|
+
material: string;
|
|
136
|
+
radius: number;
|
|
137
|
+
radialSegments: number;
|
|
138
|
+
castShadow: boolean;
|
|
139
|
+
receiveShadow: boolean;
|
|
140
|
+
positions: Vec3[];
|
|
141
|
+
}
|
|
124
142
|
export interface RopeDefinition {
|
|
125
143
|
id: string;
|
|
126
144
|
points: readonly Vec3[];
|
|
@@ -133,6 +151,10 @@ export interface RopeDefinition {
|
|
|
133
151
|
autoStep?: boolean;
|
|
134
152
|
/** Automatically sample and react to existing dynamic, non-trigger sphere bodies. Requires autoStep. */
|
|
135
153
|
bodyColliders?: readonly RopeBodyCollider[];
|
|
154
|
+
/** Resolve sphere contacts on segment interiors; defaults to true for new ropes. */
|
|
155
|
+
segmentCollisions?: boolean;
|
|
156
|
+
/** Optional segmented presentation using an already-declared material id. */
|
|
157
|
+
visual?: RopeVisualOptions;
|
|
136
158
|
substeps?: number;
|
|
137
159
|
iterations?: number;
|
|
138
160
|
damping?: number;
|
|
@@ -153,10 +175,23 @@ export interface RopeContact {
|
|
|
153
175
|
/** Angular impulse about the world origin; absent in older snapshots. */
|
|
154
176
|
reactionAngularImpulse?: Vec3;
|
|
155
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
|
+
}
|
|
156
190
|
export interface RopeState {
|
|
157
191
|
tick: number;
|
|
158
192
|
simulatedSeconds: number;
|
|
159
193
|
disposed: boolean;
|
|
194
|
+
segmentCollisions: boolean;
|
|
160
195
|
positions: Vec3[];
|
|
161
196
|
velocities: Vec3[];
|
|
162
197
|
anchors: {
|
|
@@ -164,6 +199,7 @@ export interface RopeState {
|
|
|
164
199
|
position: Vec3;
|
|
165
200
|
}[];
|
|
166
201
|
contacts: RopeContact[];
|
|
202
|
+
segmentContacts: RopeSegmentContact[];
|
|
167
203
|
maxDistanceError: number;
|
|
168
204
|
resources: {
|
|
169
205
|
particles: number;
|
|
@@ -177,6 +213,8 @@ export interface RopeService {
|
|
|
177
213
|
/** Explicit ticks for manual ropes only. Auto-stepped ropes reject this call. A failed batch leaves the rope unchanged. */
|
|
178
214
|
step(id: string, ticks?: number): RopeState;
|
|
179
215
|
inspect(id: string): RopeState;
|
|
216
|
+
/** Detached visual descriptions, sorted by rope id; empty when no visuals are requested. */
|
|
217
|
+
visuals(): readonly RopeVisualState[];
|
|
180
218
|
setColliders(id: string, colliders: readonly RopeCollider[]): void;
|
|
181
219
|
setAnchor(id: string, index: number, position: Vec3): void;
|
|
182
220
|
releaseAnchor(id: string, index: number): void;
|
package/lib/sdk/index.js
CHANGED
|
@@ -4,16 +4,33 @@ 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) {
|
|
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)
|
|
16
20
|
return fail("FIXED_DT", "Host fixedDt must be within [1/1000, 1/30] seconds for ropes.");
|
|
21
|
+
if (definition.visual !== undefined) {
|
|
22
|
+
const visual = definition.visual;
|
|
23
|
+
if (!visual || typeof visual !== "object" || Array.isArray(visual) || !validId(visual.material) || !materials.has(visual.material))
|
|
24
|
+
return fail("VISUAL_MATERIAL", "visual.material must name a declared engine material.");
|
|
25
|
+
const radius = visual.radius ?? definition.radius ?? 0.025;
|
|
26
|
+
if (typeof radius !== "number" || !Number.isFinite(radius) || radius <= 0 || radius > 1e6 || Math.fround(radius) === 0)
|
|
27
|
+
return fail("VISUAL_RADIUS", "visual radius must be positive, finite, representable and at most 1e6.");
|
|
28
|
+
const sides = visual.radialSegments ?? 8;
|
|
29
|
+
if (!Number.isSafeInteger(sides) || sides < 3 || sides > 32)
|
|
30
|
+
return fail("VISUAL_SEGMENTS", "radialSegments must be an integer in [3,32].");
|
|
31
|
+
if ((visual.castShadow !== undefined && typeof visual.castShadow !== "boolean") || (visual.receiveShadow !== undefined && typeof visual.receiveShadow !== "boolean"))
|
|
32
|
+
return fail("VISUAL_SHADOW", "Visual shadow options must be boolean.");
|
|
33
|
+
}
|
|
17
34
|
if (definition.bodyColliders !== undefined) {
|
|
18
35
|
if (!Array.isArray(definition.bodyColliders) || definition.bodyColliders.length > 64)
|
|
19
36
|
return fail("BODY_COLLIDER", "bodyColliders must contain at most 64 bindings.");
|
|
@@ -27,7 +44,7 @@ function createEntry(input, fixedDt) {
|
|
|
27
44
|
entities.add(binding.entity);
|
|
28
45
|
}
|
|
29
46
|
}
|
|
30
|
-
const solver = createRope({ ...definition, fixedDt });
|
|
47
|
+
const solver = createRope({ ...definition, fixedDt, ...(restoredSegmentMode === undefined ? {} : { segmentCollisions: restoredSegmentMode }) });
|
|
31
48
|
return { definition, solver, bodyRefs: new Map(), staleBodies: new Set() };
|
|
32
49
|
}
|
|
33
50
|
function disposeEntries(entries) {
|
|
@@ -35,7 +52,7 @@ function disposeEntries(entries) {
|
|
|
35
52
|
solver.dispose();
|
|
36
53
|
entries.clear();
|
|
37
54
|
}
|
|
38
|
-
function parseSnapshot(value, fixedDt) {
|
|
55
|
+
function parseSnapshot(value, fixedDt, materials) {
|
|
39
56
|
if (!value || typeof value !== "object" || value.version !== 1 || !Array.isArray(value.ropes) || value.ropes.length > 256)
|
|
40
57
|
return fail("SNAPSHOT", "Expected a version 1 rope snapshot with at most 256 ropes.");
|
|
41
58
|
const entries = new Map();
|
|
@@ -43,7 +60,11 @@ function parseSnapshot(value, fixedDt) {
|
|
|
43
60
|
for (const item of value.ropes) {
|
|
44
61
|
if (!item || typeof item !== "object")
|
|
45
62
|
fail("SNAPSHOT", "Invalid rope entry.");
|
|
46
|
-
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);
|
|
47
68
|
if (entries.has(entry.definition.id)) {
|
|
48
69
|
entry.solver.dispose();
|
|
49
70
|
fail("SNAPSHOT", "Duplicate rope id.");
|
|
@@ -100,6 +121,7 @@ export const createRopePlugin = (context) => {
|
|
|
100
121
|
if (typeof fixedDt !== "number" || !Number.isFinite(fixedDt))
|
|
101
122
|
return fail("FIXED_DT", "RuntimeHost must provide a finite fixed interval.");
|
|
102
123
|
const hostDt = fixedDt;
|
|
124
|
+
const materials = new Set(context.scene.materials.map(material => material.id));
|
|
103
125
|
let entries = new Map();
|
|
104
126
|
let disposed = false, pendingRefs = false;
|
|
105
127
|
const alive = () => { if (disposed)
|
|
@@ -131,7 +153,7 @@ export const createRopePlugin = (context) => {
|
|
|
131
153
|
return fail("DUPLICATE", `Rope '${definition.id}' already exists.`);
|
|
132
154
|
if (entries.size >= 256)
|
|
133
155
|
return fail("CAPACITY", "At most 256 ropes per host.");
|
|
134
|
-
const entry = createEntry(definition, hostDt);
|
|
156
|
+
const entry = createEntry(definition, hostDt, materials);
|
|
135
157
|
try {
|
|
136
158
|
entry.bodyRefs = resolveRefs(entry, context, true);
|
|
137
159
|
entries.set(entry.definition.id, entry);
|
|
@@ -148,6 +170,18 @@ export const createRopePlugin = (context) => {
|
|
|
148
170
|
return entry.solver.step(ticks);
|
|
149
171
|
},
|
|
150
172
|
inspect(id) { return get(id).solver.inspect(); },
|
|
173
|
+
visuals() {
|
|
174
|
+
alive();
|
|
175
|
+
ensureRefs();
|
|
176
|
+
return [...entries.values()].filter(entry => entry.definition.visual !== undefined)
|
|
177
|
+
.sort((a, b) => a.definition.id < b.definition.id ? -1 : a.definition.id > b.definition.id ? 1 : 0)
|
|
178
|
+
.map(entry => {
|
|
179
|
+
const visual = entry.definition.visual;
|
|
180
|
+
return { id: entry.definition.id, material: visual.material, radius: visual.radius ?? entry.definition.radius ?? 0.025,
|
|
181
|
+
radialSegments: visual.radialSegments ?? 8, castShadow: visual.castShadow ?? true, receiveShadow: visual.receiveShadow ?? true,
|
|
182
|
+
positions: entry.solver.particlePositions() };
|
|
183
|
+
});
|
|
184
|
+
},
|
|
151
185
|
setColliders(id, colliders) {
|
|
152
186
|
const entry = get(id);
|
|
153
187
|
if (Array.isArray(colliders)) {
|
|
@@ -248,9 +282,9 @@ export const createRopePlugin = (context) => {
|
|
|
248
282
|
return { definition: cloneSerializable(entry.definition), state: entry.solver.snapshot(), ...(stale.length ? { staleBodyColliders: stale } : {}) };
|
|
249
283
|
}) };
|
|
250
284
|
},
|
|
251
|
-
validateSnapshot(value) { const staged = parseSnapshot(value, hostDt); disposeEntries(staged); },
|
|
285
|
+
validateSnapshot(value) { const staged = parseSnapshot(value, hostDt, materials); disposeEntries(staged); },
|
|
252
286
|
validateWorld(value, world) {
|
|
253
|
-
const staged = parseSnapshot(value, hostDt);
|
|
287
|
+
const staged = parseSnapshot(value, hostDt, materials);
|
|
254
288
|
try {
|
|
255
289
|
validateWorldBindings(staged, context, world);
|
|
256
290
|
}
|
|
@@ -259,7 +293,7 @@ export const createRopePlugin = (context) => {
|
|
|
259
293
|
}
|
|
260
294
|
},
|
|
261
295
|
validatePeers(value, peers) {
|
|
262
|
-
const staged = parseSnapshot(value, hostDt);
|
|
296
|
+
const staged = parseSnapshot(value, hostDt, materials);
|
|
263
297
|
try {
|
|
264
298
|
const bindings = [...staged.values()].flatMap(entry => (entry.definition.bodyColliders ?? []).filter(binding => !entry.staleBodies.has(binding.id)));
|
|
265
299
|
if (!bindings.length)
|
|
@@ -280,7 +314,7 @@ export const createRopePlugin = (context) => {
|
|
|
280
314
|
},
|
|
281
315
|
restore(value) {
|
|
282
316
|
alive();
|
|
283
|
-
const staged = parseSnapshot(value, hostDt);
|
|
317
|
+
const staged = parseSnapshot(value, hostDt, materials);
|
|
284
318
|
disposeEntries(entries);
|
|
285
319
|
entries = staged;
|
|
286
320
|
pendingRefs = true;
|