pelican-engine 0.3.14 → 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.
@@ -137,6 +137,20 @@ 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
+ For optional visual presentation, declare a material in `defineGame({ materials:
141
+ [...] })` and create the rope with `visual: { material: "your-material-id" }`.
142
+ The renderer draws capped cylindrical instances between consecutive simulated
143
+ particles and updates their transforms as the rope moves. `radius` defaults to
144
+ the particle collision radius (or 0.025); `radialSegments` defaults to 8 and
145
+ accepts 3–32. `castShadow` and `receiveShadow` default to true. A collapsed
146
+ zero-length segment has no visible surface. `ctx.ropes.visuals()` returns
147
+ detached descriptions, not renderer objects. Visual configuration is included
148
+ in the rope snapshot and is disposed with the owning content.
149
+
150
+ This is segmented geometry with overlapping caps, not a continuous deformable
151
+ tube. Large ropes may be expensive. Raster, ray-traced modes, GPU resource
152
+ lifecycle and snapshot restoration have not been runtime validated.
153
+
140
154
  To bind a rope to existing dynamic sphere bodies, create it with `autoStep: true`
141
155
  and `bodyColliders: [{ id: "rope-collider-id", entity: "body-id" }]`. After the
142
156
  rigid-body physics phase, the host samples each bound sphere's actual collider
@@ -178,9 +192,8 @@ operations and is not transactional if a tick fails; recover from a known-good
178
192
  checkpoint. It must not be represented as complete two-way rigid-body interaction.
179
193
 
180
194
  This is an unverified development-stage increment, not complete rope physics:
181
- there is no built-in rope mesh, general rigid-body coupling, segment or self collision,
182
- or tearing. Moving entities to particle positions is not a rope renderer or
183
- coupled physics. Plane normals must be unit length; `offset` is the signed
195
+ the optional segmented visual does not provide a continuous deformable rope mesh,
196
+ general rigid-body coupling, segment or self collision, or tearing. Plane normals must be unit length; `offset` is the signed
184
197
  normal distance from the origin. Host intervals must be between 1/1000 and
185
198
  1/30 seconds inclusive.
186
199
 
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pelican-engine",
3
- "version": "0.3.14",
3
+ "version": "0.3.15",
4
4
  "milestone": "M3",
5
5
  "stage": "advanced-rendering-development",
6
6
  "license": "MIT",
@@ -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
- return;
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());
@@ -18,22 +18,32 @@ export function extractTriangles(scene) {
18
18
  new THREE.Vector3(),
19
19
  new THREE.Vector3(),
20
20
  ];
21
- for (let i = 0; i < count; i += 3) {
22
- for (let k = 0; k < 3; k++)
23
- object
24
- .getVertexPosition(geometry.index?.getX(i + k) ?? i + k, points[k])
25
- .applyMatrix4(object.matrixWorld);
26
- const color = material.color ?? new THREE.Color(0.6, 0.6, 0.6);
27
- triangles.push({
28
- a: points[0].toArray(),
29
- b: points[1].toArray(),
30
- c: points[2].toArray(),
31
- color: color.toArray(),
32
- emission: material.emissiveIntensity && material.emissive?.getHex()
33
- ? material.emissiveIntensity
34
- : 0,
35
- roughness: material.roughness ?? 0.8,
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
+ }
@@ -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.14";
2
+ export declare const SDK_VERSION = "0.3.15";
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 a delayed
107
- * projection reaction to dynamic spheres; no full rigid-body coupling, segment/self collision or rope rendering. */
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 segment/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,8 @@ 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
+ /** Optional segmented presentation using an already-declared material id. */
155
+ visual?: RopeVisualOptions;
136
156
  substeps?: number;
137
157
  iterations?: number;
138
158
  damping?: number;
@@ -177,6 +197,8 @@ export interface RopeService {
177
197
  /** Explicit ticks for manual ropes only. Auto-stepped ropes reject this call. A failed batch leaves the rope unchanged. */
178
198
  step(id: string, ticks?: number): RopeState;
179
199
  inspect(id: string): RopeState;
200
+ /** Detached visual descriptions, sorted by rope id; empty when no visuals are requested. */
201
+ visuals(): readonly RopeVisualState[];
180
202
  setColliders(id: string, colliders: readonly RopeCollider[]): void;
181
203
  setAnchor(id: string, index: number, position: Vec3): void;
182
204
  releaseAnchor(id: string, index: number): void;
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.14";
2
+ export const SDK_VERSION = "0.3.15";
3
3
  export const SYSTEM_PHASES = [
4
4
  "input",
5
5
  "prePhysics",
@@ -4,7 +4,7 @@ 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) {
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.");
@@ -14,6 +14,19 @@ function createEntry(input, fixedDt) {
14
14
  return fail("FIXED_DT", "Rope fixedDt must exactly match the host fixedDt.");
15
15
  if (fixedDt < 1 / 1000 || fixedDt > 1 / 30)
16
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
+ }
17
30
  if (definition.bodyColliders !== undefined) {
18
31
  if (!Array.isArray(definition.bodyColliders) || definition.bodyColliders.length > 64)
19
32
  return fail("BODY_COLLIDER", "bodyColliders must contain at most 64 bindings.");
@@ -35,7 +48,7 @@ function disposeEntries(entries) {
35
48
  solver.dispose();
36
49
  entries.clear();
37
50
  }
38
- function parseSnapshot(value, fixedDt) {
51
+ function parseSnapshot(value, fixedDt, materials) {
39
52
  if (!value || typeof value !== "object" || value.version !== 1 || !Array.isArray(value.ropes) || value.ropes.length > 256)
40
53
  return fail("SNAPSHOT", "Expected a version 1 rope snapshot with at most 256 ropes.");
41
54
  const entries = new Map();
@@ -43,7 +56,7 @@ function parseSnapshot(value, fixedDt) {
43
56
  for (const item of value.ropes) {
44
57
  if (!item || typeof item !== "object")
45
58
  fail("SNAPSHOT", "Invalid rope entry.");
46
- const entry = createEntry(item.definition, fixedDt);
59
+ const entry = createEntry(item.definition, fixedDt, materials);
47
60
  if (entries.has(entry.definition.id)) {
48
61
  entry.solver.dispose();
49
62
  fail("SNAPSHOT", "Duplicate rope id.");
@@ -100,6 +113,7 @@ export const createRopePlugin = (context) => {
100
113
  if (typeof fixedDt !== "number" || !Number.isFinite(fixedDt))
101
114
  return fail("FIXED_DT", "RuntimeHost must provide a finite fixed interval.");
102
115
  const hostDt = fixedDt;
116
+ const materials = new Set(context.scene.materials.map(material => material.id));
103
117
  let entries = new Map();
104
118
  let disposed = false, pendingRefs = false;
105
119
  const alive = () => { if (disposed)
@@ -131,7 +145,7 @@ export const createRopePlugin = (context) => {
131
145
  return fail("DUPLICATE", `Rope '${definition.id}' already exists.`);
132
146
  if (entries.size >= 256)
133
147
  return fail("CAPACITY", "At most 256 ropes per host.");
134
- const entry = createEntry(definition, hostDt);
148
+ const entry = createEntry(definition, hostDt, materials);
135
149
  try {
136
150
  entry.bodyRefs = resolveRefs(entry, context, true);
137
151
  entries.set(entry.definition.id, entry);
@@ -148,6 +162,18 @@ export const createRopePlugin = (context) => {
148
162
  return entry.solver.step(ticks);
149
163
  },
150
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
+ },
151
177
  setColliders(id, colliders) {
152
178
  const entry = get(id);
153
179
  if (Array.isArray(colliders)) {
@@ -248,9 +274,9 @@ export const createRopePlugin = (context) => {
248
274
  return { definition: cloneSerializable(entry.definition), state: entry.solver.snapshot(), ...(stale.length ? { staleBodyColliders: stale } : {}) };
249
275
  }) };
250
276
  },
251
- validateSnapshot(value) { const staged = parseSnapshot(value, hostDt); disposeEntries(staged); },
277
+ validateSnapshot(value) { const staged = parseSnapshot(value, hostDt, materials); disposeEntries(staged); },
252
278
  validateWorld(value, world) {
253
- const staged = parseSnapshot(value, hostDt);
279
+ const staged = parseSnapshot(value, hostDt, materials);
254
280
  try {
255
281
  validateWorldBindings(staged, context, world);
256
282
  }
@@ -259,7 +285,7 @@ export const createRopePlugin = (context) => {
259
285
  }
260
286
  },
261
287
  validatePeers(value, peers) {
262
- const staged = parseSnapshot(value, hostDt);
288
+ const staged = parseSnapshot(value, hostDt, materials);
263
289
  try {
264
290
  const bindings = [...staged.values()].flatMap(entry => (entry.definition.bodyColliders ?? []).filter(binding => !entry.staleBodies.has(binding.id)));
265
291
  if (!bindings.length)
@@ -280,7 +306,7 @@ export const createRopePlugin = (context) => {
280
306
  },
281
307
  restore(value) {
282
308
  alive();
283
- const staged = parseSnapshot(value, hostDt);
309
+ const staged = parseSnapshot(value, hostDt, materials);
284
310
  disposeEntries(entries);
285
311
  entries = staged;
286
312
  pendingRefs = true;
@@ -285,6 +285,8 @@ export class RopeSolver {
285
285
  this.contacts = [...contacts.values()];
286
286
  this.tickValue++;
287
287
  }
288
+ /** Detached positions for renderer synchronization without copying contact state. */
289
+ particlePositions() { this.alive(); return this.positions.map(copy); }
288
290
  inspect() {
289
291
  if (this.disposedValue)
290
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.14",
3
+ "version": "0.3.15",
4
4
  "description": "Pelican Engine public SDK and offline development CLI",
5
5
  "type": "module",
6
6
  "license": "MIT",