pelican-engine 0.3.9 → 0.3.10

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.
@@ -118,6 +118,24 @@ batch, and the last update wins if a cell appears more than once. Entity and
118
118
  transform changes are separate from this grid-only batch. This API is new and
119
119
  has not been runtime or browser validated under the current project policy.
120
120
 
121
+ ### Development-stage rope particles
122
+
123
+ `ctx.ropes.create({ id, points, mass })` creates a CPU particle rope. Call
124
+ `ctx.ropes.step(id)` **once per game fixed tick** when movement is desired;
125
+ the host does not advance it automatically. The first particle is pinned by
126
+ default; `anchors: []` creates a free rope. `setAnchor`, `releaseAnchor`,
127
+ `applyForce` (next tick only), `applyImpulse`, `inspect`, and `destroy` manage
128
+ it. `setColliders` accepts sampled planes and spheres; contacts affect only
129
+ particles, not rigid bodies. Host snapshots include rope state. An example in
130
+ the source checkout is `examples/rope-particles`.
131
+
132
+ This is an unverified development-stage increment, not complete rope physics:
133
+ there is no built-in rope mesh, automatic fixed-tick scheduling, rigid-body
134
+ reaction, segment or self collision, or tearing. Game code may use `positions`
135
+ to move entities but this is not a substitute for a rope renderer or coupled
136
+ physics. Plane normals must be unit length; `offset` is the plane's signed
137
+ normal distance from the origin. The host interval must be 1/1000?1/30 seconds.
138
+
121
139
  Keyboard bindings use the browser's `KeyboardEvent.key` values, not physical
122
140
  `KeyboardEvent.code` values. For example, use `input: { up: ["w", "W", "ArrowUp"],
123
141
  restart: ["r", "R"] }`, not `"KeyW"` or `"KeyR"`. Letter case depends on the
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pelican-engine",
3
- "version": "0.3.9",
3
+ "version": "0.3.10",
4
4
  "milestone": "M3",
5
5
  "stage": "advanced-rendering-development",
6
6
  "license": "MIT",
@@ -4,6 +4,7 @@ import { RoomEnvironment } from "three/addons/environments/RoomEnvironment.js";
4
4
  import { createGameServices } from "../runtime/game-services.js";
5
5
  import { createPhysicsFactory } from "../physics/index.js";
6
6
  import { createNavigationPlugin } from "../navigation/index.js";
7
+ import { createRopePlugin } from "../simulation/rope-plugin.js";
7
8
  import { createModelLibrary } from "./models.js";
8
9
  import { resolveEntityDefinition } from "../runtime/prefabs.js";
9
10
  /** Private presentation ownership. Games receive only SDK services, never browser/library objects. */
@@ -134,6 +135,7 @@ export async function prepareGameplay(ir, baseURL, renderer, materials = new Map
134
135
  const plugins = [
135
136
  services.factory,
136
137
  createNavigationPlugin,
138
+ createRopePlugin,
137
139
  (context) => ({
138
140
  id: "$engine.models",
139
141
  version: "2",
@@ -103,6 +103,7 @@ export class RuntimeHost {
103
103
  this.scheduler = createdScheduler;
104
104
  for (const factory of options.plugins ?? []) {
105
105
  const plugin = factory({
106
+ fixedDt: this.fixedDt,
106
107
  world: this.world,
107
108
  scene: this.scene,
108
109
  events: (type) => this.scheduler.events(type),
@@ -197,6 +198,9 @@ export class RuntimeHost {
197
198
  get navigation() {
198
199
  return host.service("navigation");
199
200
  },
201
+ get ropes() {
202
+ return host.service("ropes");
203
+ },
200
204
  entities: {
201
205
  resolve: (entity) => this.world.resolve(entity),
202
206
  isAlive: (entity) => this.world.isAlive(entity),
@@ -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.9";
2
+ export declare const SDK_VERSION = "0.3.10";
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];
@@ -101,6 +101,73 @@ export interface SystemContext extends EngineFacade {
101
101
  readonly render: RenderService;
102
102
  readonly compute: ComputeService;
103
103
  readonly navigation: NavigationService;
104
+ readonly ropes: RopeService;
105
+ }
106
+ /** CPU particles with sampled sphere/plane collisions. Collision only moves rope particles;
107
+ * no rigid-body reaction, segment/self collision, rendering or complete M4 physics. */
108
+ export type RopeCollider = {
109
+ kind: "sphere";
110
+ id: string;
111
+ center: Vec3;
112
+ radius: number;
113
+ } | {
114
+ kind: "plane";
115
+ id: string;
116
+ normal: Vec3;
117
+ offset: number;
118
+ };
119
+ export interface RopeDefinition {
120
+ id: string;
121
+ points: readonly Vec3[];
122
+ /** Kilograms per particle, positive and at most 1e6. */
123
+ mass: number;
124
+ radius?: number;
125
+ /** Defaults to the host interval; an explicit value must exactly match it. */
126
+ fixedDt?: number;
127
+ substeps?: number;
128
+ iterations?: number;
129
+ damping?: number;
130
+ gravity?: Vec3;
131
+ anchors?: readonly {
132
+ index: number;
133
+ position: Vec3;
134
+ }[];
135
+ }
136
+ export interface RopeState {
137
+ tick: number;
138
+ simulatedSeconds: number;
139
+ disposed: boolean;
140
+ positions: Vec3[];
141
+ velocities: Vec3[];
142
+ anchors: {
143
+ index: number;
144
+ position: Vec3;
145
+ }[];
146
+ contacts: {
147
+ tick: number;
148
+ particle: number;
149
+ colliderId: string;
150
+ penetration: number;
151
+ }[];
152
+ maxDistanceError: number;
153
+ resources: {
154
+ particles: number;
155
+ constraints: number;
156
+ anchors: number;
157
+ colliders: number;
158
+ };
159
+ }
160
+ export interface RopeService {
161
+ create(definition: RopeDefinition): void;
162
+ /** Explicit fixed solver ticks; not automatically advanced with the host. A failed batch leaves the rope unchanged. */
163
+ step(id: string, ticks?: number): RopeState;
164
+ inspect(id: string): RopeState;
165
+ setColliders(id: string, colliders: readonly RopeCollider[]): void;
166
+ setAnchor(id: string, index: number, position: Vec3): void;
167
+ releaseAnchor(id: string, index: number): void;
168
+ applyForce(id: string, index: number, force: Vec3): void;
169
+ applyImpulse(id: string, index: number, impulse: Vec3): void;
170
+ destroy(id: string): void;
104
171
  }
105
172
  /** Integer cells on a finite 2D orthogonal grid; not continuous, flying or underwater navigation.
106
173
  * Searches allocate sparse state and depend on available memory. At most 2^32-1 cells may be defined.
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.9";
2
+ export const SDK_VERSION = "0.3.10";
3
3
  export const SYSTEM_PHASES = [
4
4
  "input",
5
5
  "prePhysics",
@@ -0,0 +1,89 @@
1
+ import { cloneSerializable } from "../runtime/random.js";
2
+ import { createRope } from "./rope.js";
3
+ const fail = (code, detail) => { throw new Error(`TC_ROPE_${code}: ${detail}`); };
4
+ const validId = (id) => typeof id === "string" && id.trim() === id && id.length > 0 && id.length <= 128;
5
+ function createEntry(input, fixedDt) {
6
+ const definition = cloneSerializable(input);
7
+ if (!definition || !validId(definition.id))
8
+ return fail("ID", "Expected a nonempty id of at most 128 characters.");
9
+ if (definition.fixedDt !== undefined && definition.fixedDt !== fixedDt)
10
+ return fail("FIXED_DT", "Rope fixedDt must exactly match the host fixedDt.");
11
+ if (fixedDt < 1 / 1000 || fixedDt > 1 / 30)
12
+ return fail("FIXED_DT", "Host fixedDt must be within [1/1000, 1/30] seconds for ropes.");
13
+ const solver = createRope({ ...definition, fixedDt });
14
+ return { definition, solver };
15
+ }
16
+ function disposeEntries(entries) {
17
+ for (const { solver } of entries.values())
18
+ solver.dispose();
19
+ entries.clear();
20
+ }
21
+ function parseSnapshot(value, fixedDt) {
22
+ if (!value || typeof value !== "object" || value.version !== 1 || !Array.isArray(value.ropes) || value.ropes.length > 256)
23
+ return fail("SNAPSHOT", "Expected a version 1 rope snapshot with at most 256 ropes.");
24
+ const entries = new Map();
25
+ try {
26
+ for (const item of value.ropes) {
27
+ if (!item || typeof item !== "object")
28
+ fail("SNAPSHOT", "Invalid rope entry.");
29
+ const entry = createEntry(item.definition, fixedDt);
30
+ if (entries.has(entry.definition.id)) {
31
+ entry.solver.dispose();
32
+ fail("SNAPSHOT", "Duplicate rope id.");
33
+ }
34
+ entries.set(entry.definition.id, entry);
35
+ entry.solver.restore(item.state);
36
+ }
37
+ return entries;
38
+ }
39
+ catch (error) {
40
+ disposeEntries(entries);
41
+ throw error;
42
+ }
43
+ }
44
+ /** Explicitly stepped CPU rope ownership; independent from the Rapier physics plugin. */
45
+ export const createRopePlugin = ({ fixedDt }) => {
46
+ if (typeof fixedDt !== "number" || !Number.isFinite(fixedDt))
47
+ return fail("FIXED_DT", "RuntimeHost must provide a finite fixed interval.");
48
+ const hostDt = fixedDt;
49
+ let entries = new Map();
50
+ let disposed = false;
51
+ const alive = () => { if (disposed)
52
+ fail("DISPOSED", "Rope service is disposed."); };
53
+ const get = (id) => {
54
+ alive();
55
+ if (!validId(id))
56
+ return fail("ID", "Expected a nonempty rope id.");
57
+ return entries.get(id) ?? fail("MISSING", `Rope '${id}' does not exist.`);
58
+ };
59
+ const service = {
60
+ create(definition) {
61
+ alive();
62
+ if (!definition || !validId(definition.id))
63
+ return fail("ID", "Expected a nonempty rope id.");
64
+ if (entries.has(definition.id))
65
+ return fail("DUPLICATE", `Rope '${definition.id}' already exists.`);
66
+ if (entries.size >= 256)
67
+ return fail("CAPACITY", "At most 256 ropes per host.");
68
+ const entry = createEntry(definition, hostDt);
69
+ entries.set(entry.definition.id, entry);
70
+ },
71
+ step(id, ticks) { return get(id).solver.step(ticks); },
72
+ inspect(id) { return get(id).solver.inspect(); },
73
+ setColliders(id, colliders) { get(id).solver.setColliders(cloneSerializable(colliders)); },
74
+ setAnchor(id, index, position) { get(id).solver.setAnchor(index, cloneSerializable(position)); },
75
+ releaseAnchor(id, index) { get(id).solver.releaseAnchor(index); },
76
+ applyForce(id, index, force) { get(id).solver.applyForce(index, cloneSerializable(force)); },
77
+ applyImpulse(id, index, impulse) { get(id).solver.applyImpulse(index, cloneSerializable(impulse)); },
78
+ destroy(id) { const entry = get(id); entry.solver.dispose(); entries.delete(id); },
79
+ };
80
+ return {
81
+ id: "$engine.ropes", version: "1", services: { ropes: service },
82
+ legacySnapshot: () => ({ version: 1, ropes: [] }),
83
+ 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() })) }),
84
+ validateSnapshot(value) { const staged = parseSnapshot(value, hostDt); disposeEntries(staged); },
85
+ restore(value) { alive(); const staged = parseSnapshot(value, hostDt); disposeEntries(entries); entries = staged; },
86
+ dispose() { if (disposed)
87
+ return; disposed = true; disposeEntries(entries); },
88
+ };
89
+ };
@@ -0,0 +1,318 @@
1
+ export class RopeError extends Error {
2
+ code;
3
+ constructor(code, message) {
4
+ super(`${code}: ${message}`);
5
+ this.code = code;
6
+ this.name = "RopeError";
7
+ }
8
+ }
9
+ const fail = (code, reason) => { throw new RopeError(code, reason); };
10
+ const vec = (v) => Array.isArray(v) && v.length === 3 && v.every(x => typeof x === "number" && Number.isFinite(x) && Math.abs(x) <= 1e6);
11
+ const copy = (v) => [v[0], v[1], v[2]];
12
+ const length = (v) => Math.hypot(v[0], v[1], v[2]);
13
+ const validVec = (v, name) => vec(v) ? copy(v) : fail("ROPE_INPUT", `${name} must be a finite 3D vector (|component| <= 1e6)`);
14
+ const positive = (v, name) => typeof v === "number" && Number.isFinite(v) && v > 0 && v <= 1e6 ? v : fail("ROPE_INPUT", `${name} must be positive and <= 1e6`);
15
+ const bounded = (v, name, max) => typeof v === "number" && Number.isFinite(v) && v >= 0 && v <= max ? v : fail("ROPE_INPUT", `${name} must be within [0, ${max}]`);
16
+ const indexOf = (v, count) => Number.isSafeInteger(v) && v >= 0 && v < count ? v : fail("ROPE_INDEX", "particle index out of range");
17
+ const finiteState = (v) => vec(v);
18
+ function readColliders(input) {
19
+ if (!Array.isArray(input) || input.length > 4096)
20
+ fail("ROPE_INPUT", "colliders must be an array (max 4096)");
21
+ const ids = new Set();
22
+ return input.map((c) => {
23
+ if (!c || typeof c !== "object" || typeof c.id !== "string" || !c.id || ids.has(c.id))
24
+ fail("ROPE_INPUT", "collider ids must be unique nonempty strings");
25
+ ids.add(c.id);
26
+ if (c.kind === "sphere")
27
+ return { kind: "sphere", id: c.id, center: validVec(c.center, "center"), radius: positive(c.radius, "sphere radius") };
28
+ if (c.kind === "plane") {
29
+ const normal = validVec(c.normal, "normal"), n = length(normal);
30
+ if (Math.abs(n - 1) > 1e-6)
31
+ fail("ROPE_INPUT", "plane normal must have unit length");
32
+ return { kind: "plane", id: c.id, normal, offset: boundedSigned(c.offset, "plane offset") };
33
+ }
34
+ return fail("ROPE_INPUT", "unsupported collider kind");
35
+ });
36
+ }
37
+ function boundedSigned(v, name) {
38
+ return typeof v === "number" && Number.isFinite(v) && Math.abs(v) <= 1e6 ? v : fail("ROPE_INPUT", `${name} must be finite (|value| <= 1e6)`);
39
+ }
40
+ function readAnchors(input, count) {
41
+ if (!Array.isArray(input) || input.length > count)
42
+ fail("ROPE_INPUT", "anchors must be an array of unique particle indices");
43
+ const result = new Map();
44
+ for (const a of input) {
45
+ if (!a || typeof a !== "object")
46
+ fail("ROPE_INPUT", "invalid anchor");
47
+ const i = indexOf(a.index, count);
48
+ if (result.has(i))
49
+ fail("ROPE_INPUT", "duplicate anchor index");
50
+ result.set(i, validVec(a.position, "anchor position"));
51
+ }
52
+ return result;
53
+ }
54
+ export function createRope(options) {
55
+ if (!options || typeof options !== "object" || !Array.isArray(options.points) || options.points.length < 2 || options.points.length > 4096)
56
+ fail("ROPE_INPUT", "points must contain 2..4096 particles");
57
+ const positions = options.points.map((p, i) => validVec(p, `points[${i}]`));
58
+ const mass = positive(options.mass, "mass");
59
+ const radius = positive(options.radius ?? 0.025, "radius");
60
+ const fixedDt = positive(options.fixedDt ?? 1 / 60, "fixedDt");
61
+ if (fixedDt < 1 / 1000 || fixedDt > 1 / 30)
62
+ fail("ROPE_INPUT", "fixedDt must be in [1/1000, 1/30]");
63
+ const substeps = options.substeps ?? 4, iterations = options.iterations ?? 12;
64
+ if (!Number.isInteger(substeps) || substeps < 1 || substeps > 32 || !Number.isInteger(iterations) || iterations < 1 || iterations > 64)
65
+ fail("ROPE_INPUT", "substeps/iterations must be positive bounded integers");
66
+ const damping = bounded(options.damping ?? 0.15, "damping", 100);
67
+ const gravity = validVec(options.gravity ?? [0, -9.81, 0], "gravity");
68
+ const restLengths = positions.slice(1).map((p, i) => length([p[0] - positions[i][0], p[1] - positions[i][1], p[2] - positions[i][2]]));
69
+ if (restLengths.some(d => !Number.isFinite(d) || d <= 1e-8))
70
+ fail("ROPE_INPUT", "adjacent particles must have distinct positions");
71
+ const anchors = readAnchors(options.anchors ?? [{ index: 0, position: positions[0] }], positions.length);
72
+ for (const [i, p] of anchors)
73
+ positions[i] = copy(p);
74
+ return new RopeSolver({ mass, radius, fixedDt, substeps, iterations, damping, gravity, restLengths }, positions, anchors);
75
+ }
76
+ export class RopeSolver {
77
+ config;
78
+ positions;
79
+ anchors;
80
+ tickValue = 0;
81
+ disposedValue = false;
82
+ velocities;
83
+ forces;
84
+ colliders = [];
85
+ contacts = [];
86
+ constructor(config, positions, anchors) {
87
+ this.config = config;
88
+ this.positions = positions;
89
+ this.anchors = anchors;
90
+ this.velocities = positions.map(() => [0, 0, 0]);
91
+ this.forces = positions.map(() => [0, 0, 0]);
92
+ }
93
+ alive() { if (this.disposedValue)
94
+ fail("ROPE_DISPOSED", "rope has been disposed"); }
95
+ /** Copies all inputs: callers may mutate their arrays after calling this. */
96
+ setColliders(input) {
97
+ this.alive();
98
+ this.colliders = readColliders(input);
99
+ this.contacts = [];
100
+ }
101
+ /** Pins or moves a particle; new anchor position takes effect immediately. */
102
+ setAnchor(index, position) {
103
+ this.alive();
104
+ const i = indexOf(index, this.positions.length), p = validVec(position, "anchor position");
105
+ this.anchors.set(i, p);
106
+ this.positions[i] = copy(p);
107
+ this.velocities[i] = [0, 0, 0];
108
+ this.forces[i] = [0, 0, 0];
109
+ }
110
+ releaseAnchor(index) { this.alive(); this.anchors.delete(indexOf(index, this.positions.length)); }
111
+ /** N·s (kg·m/s); changes velocity immediately, not position. */
112
+ applyImpulse(index, impulse) {
113
+ this.alive();
114
+ const i = indexOf(index, this.positions.length), v = validVec(impulse, "impulse");
115
+ if (this.anchors.has(i))
116
+ fail("ROPE_ANCHORED", "cannot apply impulse to an anchor");
117
+ const next = [0, 1, 2].map(k => this.velocities[i][k] + v[k] / this.config.mass);
118
+ if (!finiteState(next))
119
+ fail("ROPE_NUMERIC", "impulse exceeds numeric bounds");
120
+ this.velocities[i] = next;
121
+ }
122
+ /** Newtons accumulated for exactly one next fixed tick; not a persistent force. */
123
+ applyForce(index, force) {
124
+ this.alive();
125
+ const i = indexOf(index, this.positions.length), v = validVec(force, "force");
126
+ if (this.anchors.has(i))
127
+ fail("ROPE_ANCHORED", "cannot apply force to an anchor");
128
+ const next = [0, 1, 2].map(k => this.forces[i][k] + v[k]);
129
+ if (!finiteState(next))
130
+ fail("ROPE_NUMERIC", "force accumulation exceeds numeric bounds");
131
+ this.forces[i] = next;
132
+ }
133
+ /** Exactly N fixed ticks; a failed batch restores the state before the first tick. */
134
+ step(steps = 1) {
135
+ this.alive();
136
+ if (!Number.isSafeInteger(steps) || steps < 1 || steps > 1000 || this.tickValue + steps > Number.MAX_SAFE_INTEGER)
137
+ fail("ROPE_INPUT", "steps must be an integer in [1,1000] without tick overflow");
138
+ // stepOne replaces these arrays rather than mutating the prior committed state.
139
+ const before = {
140
+ positions: this.positions,
141
+ velocities: this.velocities,
142
+ forces: this.forces,
143
+ contacts: this.contacts,
144
+ tick: this.tickValue,
145
+ };
146
+ try {
147
+ for (let s = 0; s < steps; s++)
148
+ this.stepOne();
149
+ }
150
+ catch (error) {
151
+ this.positions = before.positions;
152
+ this.velocities = before.velocities;
153
+ this.forces = before.forces;
154
+ this.contacts = before.contacts;
155
+ this.tickValue = before.tick;
156
+ throw error;
157
+ }
158
+ return this.inspect();
159
+ }
160
+ stepOne() {
161
+ const { fixedDt, substeps, iterations, mass, gravity, damping, restLengths, radius } = this.config;
162
+ const h = fixedDt / substeps, decay = Math.exp(-damping * h);
163
+ // Work on copies; numeric failures must never leave half a tick applied.
164
+ const positions = this.positions.map(copy), velocities = this.velocities.map(copy);
165
+ const contacts = new Map();
166
+ for (let sub = 0; sub < substeps; sub++) {
167
+ const previous = positions.map(copy);
168
+ for (let i = 0; i < positions.length; i++) {
169
+ const anchor = this.anchors.get(i);
170
+ if (anchor) {
171
+ positions[i] = copy(anchor);
172
+ velocities[i] = [0, 0, 0];
173
+ continue;
174
+ }
175
+ for (let k = 0; k < 3; k++) {
176
+ velocities[i][k] = velocities[i][k] * decay + (gravity[k] + this.forces[i][k] / mass) * h;
177
+ positions[i][k] += velocities[i][k] * h;
178
+ }
179
+ }
180
+ for (let iter = 0; iter < iterations; iter++) {
181
+ for (let j = 0; j < restLengths.length; j++) {
182
+ const a = positions[j], b = positions[j + 1];
183
+ let dx = b[0] - a[0], dy = b[1] - a[1], dz = b[2] - a[2];
184
+ let dist = Math.hypot(dx, dy, dz);
185
+ // If a contact collapses a link, separate it on a deterministic axis.
186
+ if (dist < 1e-12) {
187
+ dx = 1e-6;
188
+ dy = 0;
189
+ dz = 0;
190
+ dist = 1e-6;
191
+ }
192
+ const wa = this.anchors.has(j) ? 0 : 1, wb = this.anchors.has(j + 1) ? 0 : 1;
193
+ if (wa + wb === 0)
194
+ continue; // two incompatible kinematic anchors are intentionally authoritative
195
+ const d = (dist - restLengths[j]) / (dist * (wa + wb));
196
+ for (let k = 0; k < 3; k++) {
197
+ const correction = (k === 0 ? dx : k === 1 ? dy : dz) * d;
198
+ a[k] += correction * wa;
199
+ b[k] -= correction * wb;
200
+ }
201
+ }
202
+ for (let i = 0; i < positions.length; i++) {
203
+ if (this.anchors.has(i))
204
+ continue; // anchors cannot be displaced by external colliders
205
+ const p = positions[i];
206
+ for (const c of this.colliders) {
207
+ let depth = 0;
208
+ let normal;
209
+ if (c.kind === "plane") {
210
+ depth = c.offset + radius - (p[0] * c.normal[0] + p[1] * c.normal[1] + p[2] * c.normal[2]);
211
+ normal = c.normal;
212
+ }
213
+ else {
214
+ const delta = [p[0] - c.center[0], p[1] - c.center[1], p[2] - c.center[2]];
215
+ const distance = length(delta);
216
+ depth = c.radius + radius - distance;
217
+ normal = distance < 1e-12 ? [1, 0, 0] : [delta[0] / distance, delta[1] / distance, delta[2] / distance];
218
+ }
219
+ if (depth <= 0)
220
+ continue;
221
+ for (let k = 0; k < 3; k++)
222
+ p[k] += normal[k] * depth;
223
+ const key = `${i}:${c.id}`;
224
+ const existing = contacts.get(key);
225
+ if (existing)
226
+ existing.penetration = Math.max(existing.penetration, depth);
227
+ else
228
+ contacts.set(key, { tick: this.tickValue + 1, particle: i, colliderId: c.id, penetration: depth });
229
+ }
230
+ }
231
+ }
232
+ for (let i = 0; i < positions.length; i++) {
233
+ if (this.anchors.has(i))
234
+ continue;
235
+ for (let k = 0; k < 3; k++)
236
+ velocities[i][k] = (positions[i][k] - previous[i][k]) / h;
237
+ if (!finiteState(positions[i]) || !finiteState(velocities[i]))
238
+ fail("ROPE_NUMERIC", "solver exceeded finite state bounds");
239
+ }
240
+ }
241
+ this.positions = positions;
242
+ this.velocities = velocities;
243
+ this.forces = positions.map(() => [0, 0, 0]);
244
+ this.contacts = [...contacts.values()];
245
+ this.tickValue++;
246
+ }
247
+ inspect() {
248
+ if (this.disposedValue)
249
+ 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 } };
250
+ const maxDistanceError = Math.max(0, ...this.config.restLengths.map((d, i) => {
251
+ const a = this.positions[i], b = this.positions[i + 1];
252
+ return Math.abs(Math.hypot(a[0] - b[0], a[1] - b[1], a[2] - b[2]) - d);
253
+ }));
254
+ return {
255
+ tick: this.tickValue, simulatedSeconds: this.tickValue * this.config.fixedDt, disposed: false,
256
+ positions: this.positions.map(copy), velocities: this.velocities.map(copy),
257
+ anchors: [...this.anchors].map(([index, position]) => ({ index, position: copy(position) })).sort((a, b) => a.index - b.index),
258
+ contacts: this.contacts.map(c => ({ ...c })), maxDistanceError,
259
+ resources: { particles: this.positions.length, constraints: this.config.restLengths.length, anchors: this.anchors.size, colliders: this.colliders.length },
260
+ };
261
+ }
262
+ snapshot() {
263
+ this.alive();
264
+ const state = this.inspect();
265
+ return {
266
+ format: "pelican-rope-cpu", version: 1, tick: this.tickValue,
267
+ config: { ...this.config, gravity: copy(this.config.gravity), restLengths: [...this.config.restLengths] },
268
+ positions: state.positions, velocities: state.velocities, anchors: state.anchors,
269
+ forces: this.forces.map(copy), colliders: readColliders(this.colliders), contacts: state.contacts,
270
+ };
271
+ }
272
+ /** Accepts a JSON-round-tripped snapshot only from the same rope configuration. Atomic on failure. */
273
+ restore(input) {
274
+ this.alive();
275
+ if (!input || typeof input !== "object" || input.format !== "pelican-rope-cpu" || input.version !== 1 ||
276
+ !Number.isSafeInteger(input.tick) || input.tick < 0)
277
+ fail("ROPE_SNAPSHOT", "unsupported snapshot format or tick");
278
+ const c = input.config;
279
+ if (!c || c.mass !== this.config.mass || c.radius !== this.config.radius || c.fixedDt !== this.config.fixedDt ||
280
+ c.substeps !== this.config.substeps || c.iterations !== this.config.iterations || c.damping !== this.config.damping ||
281
+ !vec(c.gravity) || c.gravity.some((v, i) => v !== this.config.gravity[i]) ||
282
+ !Array.isArray(c.restLengths) || c.restLengths.length !== this.config.restLengths.length ||
283
+ c.restLengths.some((v, i) => v !== this.config.restLengths[i]))
284
+ fail("ROPE_SNAPSHOT", "rope configuration/topology mismatch");
285
+ const count = this.positions.length;
286
+ if (!Array.isArray(input.positions) || !Array.isArray(input.velocities) || !Array.isArray(input.forces) ||
287
+ [input.positions, input.velocities, input.forces].some(v => v.length !== count || !v.every(vec)))
288
+ fail("ROPE_SNAPSHOT", "invalid particle state");
289
+ const anchors = readAnchors(input.anchors, count);
290
+ if ([...anchors].some(([i, p]) => p.some((v, k) => v !== input.positions[i][k]) || input.velocities[i].some(v => v !== 0) || input.forces[i].some(v => v !== 0)))
291
+ fail("ROPE_SNAPSHOT", "inconsistent anchored particle state");
292
+ const colliders = readColliders(input.colliders);
293
+ if (!Array.isArray(input.contacts) || input.contacts.length > count * Math.max(1, colliders.length) ||
294
+ input.contacts.some(contact => !contact || contact.tick !== input.tick ||
295
+ !Number.isSafeInteger(contact.particle) || contact.particle < 0 || contact.particle >= count ||
296
+ !colliders.some(collider => collider.id === contact.colliderId) ||
297
+ typeof contact.penetration !== "number" || !Number.isFinite(contact.penetration) || contact.penetration <= 0))
298
+ fail("ROPE_SNAPSHOT", "invalid contact state");
299
+ this.positions = input.positions.map(copy);
300
+ this.velocities = input.velocities.map(copy);
301
+ this.forces = input.forces.map(copy);
302
+ this.anchors = anchors;
303
+ this.colliders = colliders;
304
+ this.contacts = input.contacts.map(c => ({ ...c }));
305
+ this.tickValue = input.tick;
306
+ }
307
+ dispose() {
308
+ if (this.disposedValue)
309
+ return;
310
+ this.disposedValue = true;
311
+ this.positions = [];
312
+ this.velocities = [];
313
+ this.forces = [];
314
+ this.colliders = [];
315
+ this.contacts = [];
316
+ this.anchors.clear();
317
+ }
318
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pelican-engine",
3
- "version": "0.3.9",
3
+ "version": "0.3.10",
4
4
  "description": "Pelican Engine public SDK and offline development CLI",
5
5
  "type": "module",
6
6
  "license": "MIT",