pelican-engine 0.3.13 → 0.3.15

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.
@@ -1,9 +1,10 @@
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
- function createEntry(input, fixedDt) {
7
+ function createEntry(input, fixedDt, materials) {
7
8
  const definition = cloneSerializable(input);
8
9
  if (!definition || !validId(definition.id))
9
10
  return fail("ID", "Expected a nonempty id of at most 128 characters.");
@@ -13,6 +14,19 @@ function createEntry(input, fixedDt) {
13
14
  return fail("FIXED_DT", "Rope fixedDt must exactly match the host fixedDt.");
14
15
  if (fixedDt < 1 / 1000 || fixedDt > 1 / 30)
15
16
  return fail("FIXED_DT", "Host fixedDt must be within [1/1000, 1/30] seconds for ropes.");
17
+ if (definition.visual !== undefined) {
18
+ const visual = definition.visual;
19
+ if (!visual || typeof visual !== "object" || Array.isArray(visual) || !validId(visual.material) || !materials.has(visual.material))
20
+ return fail("VISUAL_MATERIAL", "visual.material must name a declared engine material.");
21
+ const radius = visual.radius ?? definition.radius ?? 0.025;
22
+ if (typeof radius !== "number" || !Number.isFinite(radius) || radius <= 0 || radius > 1e6 || Math.fround(radius) === 0)
23
+ return fail("VISUAL_RADIUS", "visual radius must be positive, finite, representable and at most 1e6.");
24
+ const sides = visual.radialSegments ?? 8;
25
+ if (!Number.isSafeInteger(sides) || sides < 3 || sides > 32)
26
+ return fail("VISUAL_SEGMENTS", "radialSegments must be an integer in [3,32].");
27
+ if ((visual.castShadow !== undefined && typeof visual.castShadow !== "boolean") || (visual.receiveShadow !== undefined && typeof visual.receiveShadow !== "boolean"))
28
+ return fail("VISUAL_SHADOW", "Visual shadow options must be boolean.");
29
+ }
16
30
  if (definition.bodyColliders !== undefined) {
17
31
  if (!Array.isArray(definition.bodyColliders) || definition.bodyColliders.length > 64)
18
32
  return fail("BODY_COLLIDER", "bodyColliders must contain at most 64 bindings.");
@@ -34,7 +48,7 @@ function disposeEntries(entries) {
34
48
  solver.dispose();
35
49
  entries.clear();
36
50
  }
37
- function parseSnapshot(value, fixedDt) {
51
+ function parseSnapshot(value, fixedDt, materials) {
38
52
  if (!value || typeof value !== "object" || value.version !== 1 || !Array.isArray(value.ropes) || value.ropes.length > 256)
39
53
  return fail("SNAPSHOT", "Expected a version 1 rope snapshot with at most 256 ropes.");
40
54
  const entries = new Map();
@@ -42,7 +56,7 @@ function parseSnapshot(value, fixedDt) {
42
56
  for (const item of value.ropes) {
43
57
  if (!item || typeof item !== "object")
44
58
  fail("SNAPSHOT", "Invalid rope entry.");
45
- const entry = createEntry(item.definition, fixedDt);
59
+ const entry = createEntry(item.definition, fixedDt, materials);
46
60
  if (entries.has(entry.definition.id)) {
47
61
  entry.solver.dispose();
48
62
  fail("SNAPSHOT", "Duplicate rope id.");
@@ -93,12 +107,13 @@ function validateWorldBindings(entries, context, world) {
93
107
  fail("SNAPSHOT", `Bound body '${binding.entity}' must exist as a dynamic, non-trigger sphere in the restored world.`);
94
108
  }
95
109
  }
96
- /** Position-based sphere reactions are delayed and do not yet solve body effective mass. */
110
+ /** Split-step sphere contact uses virtual mass/velocity state; native commit is deferred. */
97
111
  export const createRopePlugin = (context) => {
98
112
  const { fixedDt } = context;
99
113
  if (typeof fixedDt !== "number" || !Number.isFinite(fixedDt))
100
114
  return fail("FIXED_DT", "RuntimeHost must provide a finite fixed interval.");
101
115
  const hostDt = fixedDt;
116
+ const materials = new Set(context.scene.materials.map(material => material.id));
102
117
  let entries = new Map();
103
118
  let disposed = false, pendingRefs = false;
104
119
  const alive = () => { if (disposed)
@@ -130,7 +145,7 @@ export const createRopePlugin = (context) => {
130
145
  return fail("DUPLICATE", `Rope '${definition.id}' already exists.`);
131
146
  if (entries.size >= 256)
132
147
  return fail("CAPACITY", "At most 256 ropes per host.");
133
- const entry = createEntry(definition, hostDt);
148
+ const entry = createEntry(definition, hostDt, materials);
134
149
  try {
135
150
  entry.bodyRefs = resolveRefs(entry, context, true);
136
151
  entries.set(entry.definition.id, entry);
@@ -147,6 +162,18 @@ export const createRopePlugin = (context) => {
147
162
  return entry.solver.step(ticks);
148
163
  },
149
164
  inspect(id) { return get(id).solver.inspect(); },
165
+ visuals() {
166
+ alive();
167
+ ensureRefs();
168
+ return [...entries.values()].filter(entry => entry.definition.visual !== undefined)
169
+ .sort((a, b) => a.definition.id < b.definition.id ? -1 : a.definition.id > b.definition.id ? 1 : 0)
170
+ .map(entry => {
171
+ const visual = entry.definition.visual;
172
+ return { id: entry.definition.id, material: visual.material, radius: visual.radius ?? entry.definition.radius ?? 0.025,
173
+ radialSegments: visual.radialSegments ?? 8, castShadow: visual.castShadow ?? true, receiveShadow: visual.receiveShadow ?? true,
174
+ positions: entry.solver.particlePositions() };
175
+ });
176
+ },
150
177
  setColliders(id, colliders) {
151
178
  const entry = get(id);
152
179
  if (Array.isArray(colliders)) {
@@ -170,7 +197,7 @@ export const createRopePlugin = (context) => {
170
197
  alive();
171
198
  ensureRefs();
172
199
  const committed = [];
173
- const reactions = new Map();
200
+ const bodies = new Map();
174
201
  try {
175
202
  const automatic = [...entries.values()].filter(entry => entry.definition.autoStep)
176
203
  .sort((a, b) => a.definition.id < b.definition.id ? -1 : a.definition.id > b.definition.id ? 1 : 0);
@@ -191,33 +218,27 @@ export const createRopePlugin = (context) => {
191
218
  entry.bodyRefs = new Map(live.map(binding => [binding.id, entry.bodyRefs.get(binding.id)]));
192
219
  entry.staleBodies = new Set();
193
220
  }
221
+ const coupled = new Map();
194
222
  if (previous.length) {
195
223
  const boundIds = new Set(previous.map(item => item.id));
196
224
  const manual = snapshot.colliders.filter(collider => !boundIds.has(collider.id));
197
225
  const bridge = live.length ? bridgeFor(context) : undefined;
198
226
  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 };
227
+ let body = bodies.get(binding.entity);
228
+ if (!body) {
229
+ body = new VirtualRopeSphere(bridge.sampleSphere(binding.entity));
230
+ bodies.set(binding.entity, body);
231
+ }
232
+ coupled.set(binding.id, body);
233
+ return { kind: "sphere", id: binding.id, center: [...body.center], radius: body.radius };
201
234
  });
202
235
  entry.solver.setColliders([...manual, ...sampled]);
203
236
  }
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
- }
237
+ entry.solver.step(1, coupled.size ? createRopeBodyCoupling(coupled) : undefined);
217
238
  }
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 }));
239
+ const batch = [...bodies].map(([entity, body]) => ({ entity, ...body.reaction() }))
240
+ .filter(item => item.impulse.some(value => value !== 0) || item.moment.some(value => value !== 0))
241
+ .sort((a, b) => a.entity < b.entity ? -1 : a.entity > b.entity ? 1 : 0);
221
242
  if (batch.length)
222
243
  bridgeFor(context).applySphereReactions(batch);
223
244
  }
@@ -253,9 +274,9 @@ export const createRopePlugin = (context) => {
253
274
  return { definition: cloneSerializable(entry.definition), state: entry.solver.snapshot(), ...(stale.length ? { staleBodyColliders: stale } : {}) };
254
275
  }) };
255
276
  },
256
- validateSnapshot(value) { const staged = parseSnapshot(value, hostDt); disposeEntries(staged); },
277
+ validateSnapshot(value) { const staged = parseSnapshot(value, hostDt, materials); disposeEntries(staged); },
257
278
  validateWorld(value, world) {
258
- const staged = parseSnapshot(value, hostDt);
279
+ const staged = parseSnapshot(value, hostDt, materials);
259
280
  try {
260
281
  validateWorldBindings(staged, context, world);
261
282
  }
@@ -264,7 +285,7 @@ export const createRopePlugin = (context) => {
264
285
  }
265
286
  },
266
287
  validatePeers(value, peers) {
267
- const staged = parseSnapshot(value, hostDt);
288
+ const staged = parseSnapshot(value, hostDt, materials);
268
289
  try {
269
290
  const bindings = [...staged.values()].flatMap(entry => (entry.definition.bodyColliders ?? []).filter(binding => !entry.staleBodies.has(binding.id)));
270
291
  if (!bindings.length)
@@ -285,7 +306,7 @@ export const createRopePlugin = (context) => {
285
306
  },
286
307
  restore(value) {
287
308
  alive();
288
- const staged = parseSnapshot(value, hostDt);
309
+ const staged = parseSnapshot(value, hostDt, materials);
289
310
  disposeEntries(entries);
290
311
  entries = staged;
291
312
  pendingRefs = true;
@@ -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 delta = [p[0] - c.center[0], p[1] - c.center[1], p[2] - c.center[2]];
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 = c.radius + radius - distance;
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
- for (let k = 0; k < 3; k++)
231
- p[k] += normal[k] * depth;
232
- // Each positional projection changes the particle's velocity by depth / h.
233
- // Report its equal-and-opposite impulse and moment on the sampled surface;
234
- // the collider itself remains an immutable input and is not moved here.
235
- const magnitude = -mass * depth / h;
236
- const reaction = [normal[0] * magnitude, normal[1] * magnitude, normal[2] * magnitude];
237
- const point = c.kind === "sphere"
238
- ? [c.center[0] + normal[0] * c.radius, c.center[1] + normal[1] * c.radius, c.center[2] + normal[2] * c.radius]
239
- : [p[0] - normal[0] * radius, p[1] - normal[1] * radius, p[2] - normal[2] * radius];
240
- const 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]];
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}`;
@@ -271,6 +285,8 @@ export class RopeSolver {
271
285
  this.contacts = [...contacts.values()];
272
286
  this.tickValue++;
273
287
  }
288
+ /** Detached positions for renderer synchronization without copying contact state. */
289
+ particlePositions() { this.alive(); return this.positions.map(copy); }
274
290
  inspect() {
275
291
  if (this.disposedValue)
276
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 } };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pelican-engine",
3
- "version": "0.3.13",
3
+ "version": "0.3.15",
4
4
  "description": "Pelican Engine public SDK and offline development CLI",
5
5
  "type": "module",
6
6
  "license": "MIT",