pelican-engine 0.3.12 → 0.3.13

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.
@@ -137,15 +137,35 @@ 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
+ To bind a rope to existing dynamic sphere bodies, create it with `autoStep: true`
141
+ and `bodyColliders: [{ id: "rope-collider-id", entity: "body-id" }]`. After the
142
+ rigid-body physics phase, the host samples each bound sphere's actual collider
143
+ center and radius, steps the rope, and applies the resulting normal projection
144
+ impulse to the body. That impulse changes body velocity; displacement responds on
145
+ the next body step. Bindings require live dynamic non-trigger spheres and unique
146
+ collider ids and body entities within each rope. Do not use a bound collider id
147
+ in `setColliders`; its manual planes and spheres remain separate. A destroyed body
148
+ is unbound at the next automatic step, and a new entity with the same id is not
149
+ implicitly rebound. Bindings are saved with Host snapshots.
150
+
151
+ Do not manually apply the same contact's reaction when using a body binding: the
152
+ host already applies it. A native failure can still leave the host tick partially
153
+ changed, so restore a known-good checkpoint. This is delayed sampled coupling and
154
+ is not a complete physics solver: body velocity/effective mass, friction,
155
+ continuous collision and coupled substeps are not included. It has not been
156
+ runtime or snapshot validated.
157
+
140
158
  For each latest-tick contact, `reactionImpulse` and `reactionAngularImpulse`
141
159
  report the opposite impulse produced by the particle's position projections,
142
160
  and its moment about the world origin. These optional fields are absent when
143
- restoring older contact snapshots. They do not automatically act on a rigid body.
161
+ restoring older contact snapshots. Contacts from unbound manual colliders do not
162
+ automatically act on a rigid body.
144
163
  For an explicitly mapped dynamic body, game code can read its actual world center
145
164
  of mass via `ctx.physics.centerOfMass(id)`, apply the linear impulse, and apply
146
165
  `reactionAngularImpulse - centerOfMass × reactionImpulse` as torque impulse.
147
166
  Apply each contact at most once for its `tick`; repeated `inspect` calls return
148
- the same contacts. The source checkout has an illustrative `examples/rope-reaction`.
167
+ the same contacts. The source checkout's `examples/rope-reaction` uses the
168
+ automatic binding instead.
149
169
 
150
170
  This sampled, manually applied reaction is delayed and uses positional correction,
151
171
  not a coupled contact solver: it omits body velocity/effective mass, friction and
@@ -154,7 +174,7 @@ operations and is not transactional if a tick fails; recover from a known-good
154
174
  checkpoint. It must not be represented as complete two-way rigid-body interaction.
155
175
 
156
176
  This is an unverified development-stage increment, not complete rope physics:
157
- there is no built-in rope mesh, automatic rigid-body reaction, segment or self collision,
177
+ there is no built-in rope mesh, general rigid-body coupling, segment or self collision,
158
178
  or tearing. Moving entities to particle positions is not a rope renderer or
159
179
  coupled physics. Plane normals must be unit length; `offset` is the signed
160
180
  normal distance from the origin. Host intervals must be between 1/1000 and
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pelican-engine",
3
- "version": "0.3.12",
3
+ "version": "0.3.13",
4
4
  "milestone": "M3",
5
5
  "stage": "advanced-rendering-development",
6
6
  "license": "MIT",
@@ -809,10 +809,35 @@ function createPlugin(context) {
809
809
  };
810
810
  },
811
811
  };
812
+ const sampleRopeSphere = (id) => {
813
+ const b = get(id);
814
+ if (b.type !== "dynamic" || b.definition.rigidBody?.collider.shape !== "sphere" || b.collider.isSensor())
815
+ fail("TC_PHYSICS_ROPE_BODY", `Rope body '${id}' must be a live dynamic, non-trigger sphere.`);
816
+ return { center: tuple(b.collider.translation()), radius: b.collider.radius() };
817
+ };
812
818
  return {
813
819
  id: pluginId,
814
820
  version: "3",
815
821
  services: { physics },
822
+ ropePhysicsBridge: {
823
+ sampleSphere: sampleRopeSphere,
824
+ applySphereReactions(reactions) {
825
+ 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
+ const seen = new Set();
829
+ const actions = reactions.map(({ entity, impulse }) => {
830
+ if (seen.has(entity))
831
+ fail("TC_PHYSICS_ROPE_BODY", `Duplicate rope reaction body '${entity}'.`);
832
+ seen.add(entity);
833
+ const sample = sampleRopeSphere(entity);
834
+ return { kind: "impulseAtPoint", id: entity, value: tuple(vec(impulse, "rope reaction")), point: sample.center };
835
+ });
836
+ // Validation precedes mutation; a native error can still leave a partial batch.
837
+ for (const action of actions)
838
+ record(action);
839
+ },
840
+ },
816
841
  systems: [
817
842
  {
818
843
  id: pluginId,
@@ -105,6 +105,7 @@ export class RuntimeHost {
105
105
  for (const factory of options.plugins ?? []) {
106
106
  const plugin = factory({
107
107
  fixedDt: this.fixedDt,
108
+ getRopePhysicsBridge: () => this.plugins.find((plugin) => plugin.id === "$engine.physics")?.ropePhysicsBridge,
108
109
  world: this.world,
109
110
  scene: this.scene,
110
111
  events: (type) => this.scheduler.events(type),
@@ -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.12";
2
+ export declare const SDK_VERSION = "0.3.13";
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. Collision only moves rope particles;
107
- * no automatic rigid-body reaction, segment/self collision, rendering or complete physics. */
106
+ /** CPU particles with sampled sphere/plane collisions. Explicit body bindings apply a delayed
107
+ * projection reaction to dynamic spheres; no full rigid-body coupling, segment/self collision or rope rendering. */
108
108
  export type RopeCollider = {
109
109
  kind: "sphere";
110
110
  id: string;
@@ -116,6 +116,11 @@ export type RopeCollider = {
116
116
  normal: Vec3;
117
117
  offset: number;
118
118
  };
119
+ /** Explicit connection between a sampled rope collider id and a dynamic sphere body. */
120
+ export interface RopeBodyCollider {
121
+ readonly id: string;
122
+ readonly entity: string;
123
+ }
119
124
  export interface RopeDefinition {
120
125
  id: string;
121
126
  points: readonly Vec3[];
@@ -126,6 +131,8 @@ export interface RopeDefinition {
126
131
  fixedDt?: number;
127
132
  /** When true, step once after each physics phase; defaults to false (manual stepping). */
128
133
  autoStep?: boolean;
134
+ /** Automatically sample and react to existing dynamic, non-trigger sphere bodies. Requires autoStep. */
135
+ bodyColliders?: readonly RopeBodyCollider[];
129
136
  substeps?: number;
130
137
  iterations?: number;
131
138
  damping?: number;
package/lib/sdk/index.js CHANGED
@@ -1,5 +1,5 @@
1
1
  /** Pelican Engine public API. No renderer or GPU implementation types escape this module. */
2
- export const SDK_VERSION = "0.3.12";
2
+ export const SDK_VERSION = "0.3.13";
3
3
  export const SYSTEM_PHASES = [
4
4
  "input",
5
5
  "prePhysics",
@@ -1,3 +1,4 @@
1
+ import { resolveEntityDefinition } from "../runtime/prefabs.js";
1
2
  import { cloneSerializable } from "../runtime/random.js";
2
3
  import { createRope } from "./rope.js";
3
4
  const fail = (code, detail) => { throw new Error(`TC_ROPE_${code}: ${detail}`); };
@@ -12,8 +13,21 @@ function createEntry(input, fixedDt) {
12
13
  return fail("FIXED_DT", "Rope fixedDt must exactly match the host fixedDt.");
13
14
  if (fixedDt < 1 / 1000 || fixedDt > 1 / 30)
14
15
  return fail("FIXED_DT", "Host fixedDt must be within [1/1000, 1/30] seconds for ropes.");
16
+ if (definition.bodyColliders !== undefined) {
17
+ if (!Array.isArray(definition.bodyColliders) || definition.bodyColliders.length > 64)
18
+ return fail("BODY_COLLIDER", "bodyColliders must contain at most 64 bindings.");
19
+ if (definition.bodyColliders.length && !definition.autoStep)
20
+ return fail("BODY_COLLIDER", "Body bindings require autoStep: true.");
21
+ const ids = new Set(), entities = new Set();
22
+ for (const binding of definition.bodyColliders) {
23
+ if (!binding || typeof binding !== "object" || !validId(binding.id) || !validId(binding.entity) || ids.has(binding.id) || entities.has(binding.entity))
24
+ return fail("BODY_COLLIDER", "Each binding requires a unique nonempty collider id and body entity.");
25
+ ids.add(binding.id);
26
+ entities.add(binding.entity);
27
+ }
28
+ }
15
29
  const solver = createRope({ ...definition, fixedDt });
16
- return { definition, solver };
30
+ return { definition, solver, bodyRefs: new Map(), staleBodies: new Set() };
17
31
  }
18
32
  function disposeEntries(entries) {
19
33
  for (const { solver } of entries.values())
@@ -34,6 +48,11 @@ function parseSnapshot(value, fixedDt) {
34
48
  fail("SNAPSHOT", "Duplicate rope id.");
35
49
  }
36
50
  entries.set(entry.definition.id, entry);
51
+ const stale = item.staleBodyColliders === undefined ? [] : item.staleBodyColliders;
52
+ const bound = new Set((entry.definition.bodyColliders ?? []).map(binding => binding.id));
53
+ if (!Array.isArray(stale) || stale.some(id => typeof id !== "string" || !bound.has(id)) || new Set(stale).size !== stale.length)
54
+ fail("SNAPSHOT", "Invalid stale body collider list.");
55
+ entry.staleBodies = new Set(stale);
37
56
  entry.solver.restore(item.state);
38
57
  }
39
58
  return entries;
@@ -43,17 +62,60 @@ function parseSnapshot(value, fixedDt) {
43
62
  throw error;
44
63
  }
45
64
  }
46
- /** CPU rope ownership; optional host-scheduled stepping remains independent from Rapier. */
47
- export const createRopePlugin = ({ fixedDt }) => {
65
+ function bridgeFor(context) {
66
+ return context.getRopePhysicsBridge?.() ?? fail("BODY_COLLIDER", "Body colliders require the physics plugin and a live dynamic sphere.");
67
+ }
68
+ function resolveRefs(entry, context, checkPhysics) {
69
+ const refs = new Map();
70
+ const specs = entry.definition.bodyColliders ?? [];
71
+ const bridge = checkPhysics && specs.length ? bridgeFor(context) : undefined;
72
+ for (const spec of specs) {
73
+ if (entry.staleBodies.has(spec.id))
74
+ continue;
75
+ if (!context.world.isAlive(spec.entity))
76
+ fail("BODY_COLLIDER", `Body '${spec.entity}' is not a live entity.`);
77
+ if (bridge)
78
+ bridge.sampleSphere(spec.entity);
79
+ refs.set(spec.id, context.world.resolve(spec.entity));
80
+ }
81
+ return refs;
82
+ }
83
+ function validateWorldBindings(entries, context, world) {
84
+ const records = new Map(world.entities.map(record => [record.id, record]));
85
+ for (const entry of entries.values())
86
+ for (const binding of entry.definition.bodyColliders ?? []) {
87
+ if (entry.staleBodies.has(binding.id))
88
+ continue;
89
+ const record = records.get(binding.entity);
90
+ const definition = record && resolveEntityDefinition(context.scene, record);
91
+ const body = definition?.rigidBody;
92
+ if (!record || !body || body.type !== "dynamic" || body.collider.shape !== "sphere" || body.trigger)
93
+ fail("SNAPSHOT", `Bound body '${binding.entity}' must exist as a dynamic, non-trigger sphere in the restored world.`);
94
+ }
95
+ }
96
+ /** Position-based sphere reactions are delayed and do not yet solve body effective mass. */
97
+ export const createRopePlugin = (context) => {
98
+ const { fixedDt } = context;
48
99
  if (typeof fixedDt !== "number" || !Number.isFinite(fixedDt))
49
100
  return fail("FIXED_DT", "RuntimeHost must provide a finite fixed interval.");
50
101
  const hostDt = fixedDt;
51
102
  let entries = new Map();
52
- let disposed = false;
103
+ let disposed = false, pendingRefs = false;
53
104
  const alive = () => { if (disposed)
54
105
  fail("DISPOSED", "Rope service is disposed."); };
106
+ // Host replay restores plugin data before it replaces the World. Resolve handles
107
+ // lazily at the first use, after that World replacement has completed.
108
+ const ensureRefs = () => {
109
+ if (!pendingRefs)
110
+ return;
111
+ const staged = [...entries.values()].map(entry => ({ entry, refs: resolveRefs(entry, context, false) }));
112
+ for (const { entry, refs } of staged)
113
+ entry.bodyRefs = refs;
114
+ pendingRefs = false;
115
+ };
55
116
  const get = (id) => {
56
117
  alive();
118
+ ensureRefs();
57
119
  if (!validId(id))
58
120
  return fail("ID", "Expected a nonempty rope id.");
59
121
  return entries.get(id) ?? fail("MISSING", `Rope '${id}' does not exist.`);
@@ -61,6 +123,7 @@ export const createRopePlugin = ({ fixedDt }) => {
61
123
  const service = {
62
124
  create(definition) {
63
125
  alive();
126
+ ensureRefs();
64
127
  if (!definition || !validId(definition.id))
65
128
  return fail("ID", "Expected a nonempty rope id.");
66
129
  if (entries.has(definition.id))
@@ -68,7 +131,14 @@ export const createRopePlugin = ({ fixedDt }) => {
68
131
  if (entries.size >= 256)
69
132
  return fail("CAPACITY", "At most 256 ropes per host.");
70
133
  const entry = createEntry(definition, hostDt);
71
- entries.set(entry.definition.id, entry);
134
+ try {
135
+ entry.bodyRefs = resolveRefs(entry, context, true);
136
+ entries.set(entry.definition.id, entry);
137
+ }
138
+ catch (error) {
139
+ entry.solver.dispose();
140
+ throw error;
141
+ }
72
142
  },
73
143
  step(id, ticks) {
74
144
  const entry = get(id);
@@ -77,7 +147,17 @@ export const createRopePlugin = ({ fixedDt }) => {
77
147
  return entry.solver.step(ticks);
78
148
  },
79
149
  inspect(id) { return get(id).solver.inspect(); },
80
- setColliders(id, colliders) { get(id).solver.setColliders(cloneSerializable(colliders)); },
150
+ setColliders(id, colliders) {
151
+ const entry = get(id);
152
+ if (Array.isArray(colliders)) {
153
+ const bound = new Set((entry.definition.bodyColliders ?? []).map(item => item.id));
154
+ if (colliders.length + bound.size > 4096)
155
+ return fail("BODY_COLLIDER", "Manual and bound colliders together may not exceed 4096.");
156
+ if (colliders.some(collider => collider && bound.has(collider.id)))
157
+ return fail("BODY_COLLIDER", "Manual collider ids must not conflict with body bindings.");
158
+ }
159
+ entry.solver.setColliders(cloneSerializable(colliders));
160
+ },
81
161
  setAnchor(id, index, position) { get(id).solver.setAnchor(index, cloneSerializable(position)); },
82
162
  releaseAnchor(id, index) { get(id).solver.releaseAnchor(index); },
83
163
  applyForce(id, index, force) { get(id).solver.applyForce(index, cloneSerializable(force)); },
@@ -88,22 +168,67 @@ export const createRopePlugin = ({ fixedDt }) => {
88
168
  id: "$engine.ropes", version: "1", services: { ropes: service },
89
169
  afterPhysics() {
90
170
  alive();
171
+ ensureRefs();
91
172
  const committed = [];
173
+ const reactions = new Map();
92
174
  try {
93
- // Stable ordering also makes failure and recording behavior independent of insertion order.
94
175
  const automatic = [...entries.values()].filter(entry => entry.definition.autoStep)
95
176
  .sort((a, b) => a.definition.id < b.definition.id ? -1 : a.definition.id > b.definition.id ? 1 : 0);
96
- for (const { solver } of automatic) {
97
- const snapshot = solver.snapshot();
98
- solver.step(); // A failed solver step rolls itself back.
99
- committed.push({ solver, snapshot });
177
+ for (const entry of automatic) {
178
+ const snapshot = entry.solver.snapshot();
179
+ committed.push({ entry, snapshot, definition: entry.definition, refs: entry.bodyRefs, stale: entry.staleBodies });
180
+ const previous = entry.definition.bodyColliders ?? [];
181
+ const live = [];
182
+ for (const binding of previous) {
183
+ const ref = entry.bodyRefs.get(binding.id);
184
+ if (!ref && !entry.staleBodies.has(binding.id))
185
+ fail("BODY_COLLIDER", `Missing live handle for '${binding.id}'; restore a known-good checkpoint.`);
186
+ if (ref && context.world.isAlive(ref))
187
+ live.push(binding);
188
+ }
189
+ if (live.length !== previous.length) {
190
+ entry.definition = { ...entry.definition, bodyColliders: live };
191
+ entry.bodyRefs = new Map(live.map(binding => [binding.id, entry.bodyRefs.get(binding.id)]));
192
+ entry.staleBodies = new Set();
193
+ }
194
+ if (previous.length) {
195
+ const boundIds = new Set(previous.map(item => item.id));
196
+ const manual = snapshot.colliders.filter(collider => !boundIds.has(collider.id));
197
+ const bridge = live.length ? bridgeFor(context) : undefined;
198
+ const sampled = live.map(binding => {
199
+ const sphere = bridge.sampleSphere(binding.entity);
200
+ return { kind: "sphere", id: binding.id, center: [...sphere.center], radius: sphere.radius };
201
+ });
202
+ entry.solver.setColliders([...manual, ...sampled]);
203
+ }
204
+ const state = entry.solver.step();
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
+ }
100
217
  }
218
+ const batch = [...reactions].filter(([, impulse]) => impulse.some(value => value !== 0))
219
+ .sort(([a], [b]) => a < b ? -1 : a > b ? 1 : 0)
220
+ .map(([entity, impulse]) => ({ entity, impulse }));
221
+ if (batch.length)
222
+ bridgeFor(context).applySphereReactions(batch);
101
223
  }
102
224
  catch (error) {
103
225
  const failures = [];
104
- for (const { solver, snapshot } of committed.reverse()) {
226
+ for (const item of committed.reverse()) {
227
+ item.entry.definition = item.definition;
228
+ item.entry.bodyRefs = item.refs;
229
+ item.entry.staleBodies = item.stale;
105
230
  try {
106
- solver.restore(snapshot);
231
+ item.entry.solver.restore(item.snapshot);
107
232
  }
108
233
  catch (rollbackError) {
109
234
  failures.push(String(rollbackError));
@@ -115,9 +240,56 @@ export const createRopePlugin = ({ fixedDt }) => {
115
240
  }
116
241
  },
117
242
  legacySnapshot: () => ({ version: 1, ropes: [] }),
118
- snapshot: () => ({ version: 1, ropes: [...entries.values()].sort((a, b) => a.definition.id < b.definition.id ? -1 : a.definition.id > b.definition.id ? 1 : 0).map(({ definition, solver }) => ({ definition: cloneSerializable(definition), state: solver.snapshot() })) }),
243
+ snapshot: () => {
244
+ alive();
245
+ ensureRefs();
246
+ return { version: 1, ropes: [...entries.values()].sort((a, b) => a.definition.id < b.definition.id ? -1 : a.definition.id > b.definition.id ? 1 : 0).map(entry => {
247
+ const stale = (entry.definition.bodyColliders ?? []).filter(binding => {
248
+ const ref = entry.bodyRefs.get(binding.id);
249
+ if (!ref && !entry.staleBodies.has(binding.id))
250
+ return fail("SNAPSHOT", `Missing handle for '${binding.id}'.`);
251
+ return !ref || !context.world.isAlive(ref);
252
+ }).map(binding => binding.id);
253
+ return { definition: cloneSerializable(entry.definition), state: entry.solver.snapshot(), ...(stale.length ? { staleBodyColliders: stale } : {}) };
254
+ }) };
255
+ },
119
256
  validateSnapshot(value) { const staged = parseSnapshot(value, hostDt); disposeEntries(staged); },
120
- restore(value) { alive(); const staged = parseSnapshot(value, hostDt); disposeEntries(entries); entries = staged; },
257
+ validateWorld(value, world) {
258
+ const staged = parseSnapshot(value, hostDt);
259
+ try {
260
+ validateWorldBindings(staged, context, world);
261
+ }
262
+ finally {
263
+ disposeEntries(staged);
264
+ }
265
+ },
266
+ validatePeers(value, peers) {
267
+ const staged = parseSnapshot(value, hostDt);
268
+ try {
269
+ const bindings = [...staged.values()].flatMap(entry => (entry.definition.bodyColliders ?? []).filter(binding => !entry.staleBodies.has(binding.id)));
270
+ if (!bindings.length)
271
+ return;
272
+ const physics = peers["$engine.physics"]?.payload;
273
+ if (!physics || !Array.isArray(physics.bindings) || !Array.isArray(physics.entities))
274
+ return fail("SNAPSHOT", "Body-bound ropes require a matching physics snapshot.");
275
+ const bodyStates = physics.entities, bodyIds = physics.bindings;
276
+ for (const binding of bindings) {
277
+ const found = bodyStates.find(item => item && typeof item === "object" && item.id === binding.entity && item.type === "dynamic");
278
+ if (!found || !bodyIds.includes(binding.entity))
279
+ fail("SNAPSHOT", `Bound body '${binding.entity}' is missing from physics state.`);
280
+ }
281
+ }
282
+ finally {
283
+ disposeEntries(staged);
284
+ }
285
+ },
286
+ restore(value) {
287
+ alive();
288
+ const staged = parseSnapshot(value, hostDt);
289
+ disposeEntries(entries);
290
+ entries = staged;
291
+ pendingRefs = true;
292
+ },
121
293
  dispose() { if (disposed)
122
294
  return; disposed = true; disposeEntries(entries); },
123
295
  };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pelican-engine",
3
- "version": "0.3.12",
3
+ "version": "0.3.13",
4
4
  "description": "Pelican Engine public SDK and offline development CLI",
5
5
  "type": "module",
6
6
  "license": "MIT",