pelican-engine 0.3.16 → 0.3.18

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.
@@ -147,8 +147,20 @@ solver snapshots without this field retain particle-only behavior when migrated;
147
147
  engine-version restrictions still apply. Plane contacts use particle endpoints.
148
148
  The segment index names its first particle; `fraction` runs toward the next.
149
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.
150
+ provide continuous collision detection; the segment solver and snapshot migration
151
+ have not been runtime validated.
152
+
153
+ New ropes also resolve discrete self-contact between non-adjacent segments using
154
+ sweep-and-prune and endpoint projection. `inspect(id).selfContacts` reports
155
+ segment indices, closest-point fractions and maximum overlap; `selfCollisions`
156
+ reports the active mode. Set `selfCollisions: false` to disable it. Adjacent
157
+ segments and collisions between separate ropes are not handled. Immovable anchored
158
+ endpoints stay fixed, and contacts introduced by corrections can wait until the
159
+ next iteration. Dense overlap can approach quadratic work. Snapshots from before
160
+ this feature retain the disabled mode when migrated; Host engine-version rules
161
+ still apply. This is unverified discrete contact, not a guarantee against
162
+ penetration, continuous collision, friction or full rope physics. The source
163
+ checkout includes `examples/rope-self`.
152
164
 
153
165
  For optional visual presentation, declare a material in `defineGame({ materials:
154
166
  [...] })` and create the rope with `visual: { material: "your-material-id" }`.
@@ -206,10 +218,32 @@ checkpoint. It must not be represented as complete two-way rigid-body interactio
206
218
 
207
219
  This is an unverified development-stage increment, not complete rope physics:
208
220
  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
221
+ general rigid-body coupling, continuous collision, cross-rope collision, or tearing. Plane normals must be unit length; `offset` is the signed
210
222
  normal distance from the origin. Host intervals must be between 1/1000 and
211
223
  1/30 seconds inclusive.
212
224
 
225
+ ### Development-stage triangular cloth
226
+
227
+ Declare a material in `defineGame({ materials: [...] })`, then use
228
+ `ctx.cloth.create({ id, points, triangles, mass, autoStep: true, visual: {
229
+ material: "material-id" } })`. Triangles reference particle indices and shared
230
+ edges must be manifold with consistent winding. Anchors are explicit; no particles
231
+ are pinned by default. Automatic cloth advances after the physics phase;
232
+ manual cloth uses `ctx.cloth.step`. Use `setAnchor`, `releaseAnchor`, `applyForce`
233
+ (next tick), `applyImpulse`, `setColliders`, `inspect`, and `destroy` to manage it.
234
+ The source checkout includes `examples/cloth-sheet`.
235
+
236
+ The CPU solver uses edge distance constraints and an optional distance-based
237
+ bending approximation (`bending: true` by default). Sampled planes and spheres
238
+ only move cloth particles; they do not receive reactions. Contacts do not cover
239
+ triangle interiors or edges. There is no self-contact, cloth–rope interaction,
240
+ rigid-body coupling, tearing, friction or continuous collision. The optional
241
+ visual deforms the actual triangle mesh; UVs may be provided per particle or use a
242
+ planar projection. Use a double-sided material when both sides must be visible.
243
+ Collapsed or unrepresentable triangles return `TC_CLOTH_VISUAL_DEGENERATE` rather
244
+ than being silently replaced. Runtime, GPU, performance, stability and snapshot
245
+ compatibility have not been validated; this is not complete cloth physics.
246
+
213
247
  Keyboard bindings use the browser's `KeyboardEvent.key` values, not physical
214
248
  `KeyboardEvent.code` values. For example, use `input: { up: ["w", "W", "ArrowUp"],
215
249
  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.16",
3
+ "version": "0.3.18",
4
4
  "milestone": "M3",
5
5
  "stage": "advanced-rendering-development",
6
6
  "license": "MIT",
@@ -1,6 +1,7 @@
1
1
  import * as THREE from "three/webgpu";
2
2
  import { prepareGameplay } from "./gameplay.js";
3
3
  import { createRopeVisualOwner } from "./rope-visuals.js";
4
+ import { createClothVisualOwner } from "./cloth-visuals.js";
4
5
  import { GPUTracer } from "./pathtrace.js";
5
6
  import { SurfaceTracer } from "./surface-tracer.js";
6
7
  import { createRenderSettingsDriver, resolveRenderSettings, } from "../runtime/render-settings.js";
@@ -136,10 +137,14 @@ const gameObjects = new Map();
136
137
  const gameRoot = new THREE.Group();
137
138
  scene.add(gameRoot);
138
139
  const ropeVisuals = createRopeVisualOwner();
139
- scene.add(ropeVisuals.root);
140
- let ropeVisualRevision = 0, renderedRopeVisualRevision = 0;
141
- const clearRopeVisuals = () => { if (ropeVisuals.clear())
142
- ropeVisualRevision++; };
140
+ const clothVisuals = createClothVisualOwner();
141
+ scene.add(ropeVisuals.root, clothVisuals.root);
142
+ let dynamicVisualRevision = 0, renderedDynamicVisualRevision = 0;
143
+ const clearDynamicVisuals = () => {
144
+ const ropesChanged = ropeVisuals.clear(), clothChanged = clothVisuals.clear();
145
+ if (ropesChanged || clothChanged)
146
+ dynamicVisualRevision++;
147
+ };
143
148
  let authoredCamera = { position: [0, 3.4, 7], target: [0, 0.9, 0] };
144
149
  let authoredLights = [];
145
150
  let compiledIR = null;
@@ -359,8 +364,8 @@ function createEntityView(entity, materials) {
359
364
  }
360
365
  function syncRuntime() {
361
366
  if (!runtime) {
362
- clearRopeVisuals();
363
- return ropeVisualRevision !== renderedRopeVisualRevision;
367
+ clearDynamicVisuals();
368
+ return dynamicVisualRevision !== renderedDynamicVisualRevision;
364
369
  }
365
370
  transforms.clear();
366
371
  componentState.clear();
@@ -438,11 +443,13 @@ function syncRuntime() {
438
443
  }
439
444
  if (gameplay) {
440
445
  if (ropeVisuals.sync(runtime.facade.ropes.visuals(), gameMaterials))
441
- ropeVisualRevision++;
446
+ dynamicVisualRevision++;
447
+ if (clothVisuals.sync(runtime.facade.cloth.visuals(), gameMaterials))
448
+ dynamicVisualRevision++;
442
449
  }
443
450
  else
444
- clearRopeVisuals();
445
- return ropeVisualRevision !== renderedRopeVisualRevision;
451
+ clearDynamicVisuals();
452
+ return dynamicVisualRevision !== renderedDynamicVisualRevision;
446
453
  }
447
454
  let simulation;
448
455
  const simulationGroup = new THREE.Group();
@@ -735,7 +742,7 @@ async function loadGame(sceneId, saved, loadSlot) {
735
742
  contentScope = nextScope;
736
743
  gameMaterials = nextMaterials;
737
744
  committed = true;
738
- clearRopeVisuals();
745
+ clearDynamicVisuals();
739
746
  if (nextRoot.children.length)
740
747
  gameRoot.add(...nextRoot.children);
741
748
  gameObjects.clear();
@@ -967,7 +974,7 @@ async function drawFrame(accumulate = false, changed = true) {
967
974
  await computeControl?.flush();
968
975
  const start = performance.now();
969
976
  const ropeChanged = syncRuntime();
970
- const ropeRevision = ropeVisualRevision;
977
+ const ropeRevision = dynamicVisualRevision;
971
978
  changed = changed || ropeChanged;
972
979
  animate();
973
980
  renderBufferBridge?.update();
@@ -1048,7 +1055,7 @@ async function drawFrame(accumulate = false, changed = true) {
1048
1055
  triangles: triangleCount,
1049
1056
  faulted: terminalFault,
1050
1057
  });
1051
- renderedRopeVisualRevision = ropeRevision;
1058
+ renderedDynamicVisualRevision = ropeRevision;
1052
1059
  return;
1053
1060
  }
1054
1061
  if (changed) {
@@ -1101,7 +1108,7 @@ async function drawFrame(accumulate = false, changed = true) {
1101
1108
  triangles: triangleCount,
1102
1109
  faulted: terminalFault,
1103
1110
  });
1104
- renderedRopeVisualRevision = ropeRevision;
1111
+ renderedDynamicVisualRevision = ropeRevision;
1105
1112
  }
1106
1113
  async function applyInput(key) {
1107
1114
  if (!probeEnabled) {
@@ -1348,6 +1355,7 @@ async function disposeBrowser() {
1348
1355
  clean(() => world?.dispose());
1349
1356
  clean(() => resourceManager.dispose());
1350
1357
  clean(() => ropeVisuals.dispose());
1358
+ clean(() => clothVisuals.dispose());
1351
1359
  clean(() => releaseObject(scene, true, gameMaterials.values()));
1352
1360
  clean(() => pendingSurface?.abort());
1353
1361
  clean(() => tracer?.dispose());
@@ -0,0 +1,140 @@
1
+ import * as THREE from "three/webgpu";
2
+ /** Owns dynamic cloth geometry and buffers; scene materials remain borrowed. */
3
+ export function createClothVisualOwner() {
4
+ const root = new THREE.Group();
5
+ root.name = "pelican-cloth-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.castShadow, state.receiveShadow, state.positions.length, state.triangles, state.uvs]);
12
+ const create = (state, material) => {
13
+ const geometry = new THREE.BufferGeometry();
14
+ let mesh;
15
+ try {
16
+ const count = state.triangles.length * 3;
17
+ const position = new THREE.BufferAttribute(new Float32Array(count * 3), 3), normal = new THREE.BufferAttribute(new Float32Array(count * 3), 3);
18
+ position.setUsage(THREE.DynamicDrawUsage);
19
+ normal.setUsage(THREE.DynamicDrawUsage);
20
+ geometry.setAttribute("position", position);
21
+ geometry.setAttribute("normal", normal);
22
+ const values = new Float32Array(count * 2);
23
+ let offset = 0;
24
+ for (const triangle of state.triangles)
25
+ for (const index of triangle) {
26
+ const uv = state.uvs[index];
27
+ values[offset++] = uv[0];
28
+ values[offset++] = uv[1];
29
+ }
30
+ const uv = new THREE.BufferAttribute(values, 2);
31
+ geometry.setAttribute("uv", uv);
32
+ geometry.setAttribute("uv1", uv.clone());
33
+ mesh = new THREE.Mesh(geometry, material);
34
+ mesh.name = `cloth:${state.id}`;
35
+ mesh.castShadow = state.castShadow;
36
+ mesh.receiveShadow = state.receiveShadow;
37
+ return { mesh, geometry, signature: signature(state), positions: [] };
38
+ }
39
+ catch (error) {
40
+ mesh?.dispose();
41
+ geometry.dispose();
42
+ throw error;
43
+ }
44
+ };
45
+ const geometryData = (state) => {
46
+ const vertices = new Float32Array(state.triangles.length * 9), normals = new Float32Array(vertices.length);
47
+ const accumulated = Array.from({ length: state.positions.length }, () => [0, 0, 0]);
48
+ const faces = [];
49
+ let offset = 0;
50
+ for (let face = 0; face < state.triangles.length; face++) {
51
+ const triangle = state.triangles[face];
52
+ const points = triangle.map(index => state.positions[index].map(Math.fround));
53
+ const u = points[1].map((value, axis) => value - points[0][axis]), v = points[2].map((value, axis) => value - points[0][axis]);
54
+ const n = [u[1] * v[2] - u[2] * v[1], u[2] * v[0] - u[0] * v[2], u[0] * v[1] - u[1] * v[0]];
55
+ const length = Math.hypot(...n);
56
+ if (!Number.isFinite(length) || length === 0)
57
+ throw new Error(`TC_CLOTH_VISUAL_DEGENERATE: Triangle ${face} of '${state.id}' collapsed or is not representable; adjust cloth geometry or simulation.`);
58
+ faces.push([n[0] / length, n[1] / length, n[2] / length]);
59
+ for (let vertex = 0; vertex < 3; vertex++) {
60
+ const index = triangle[vertex];
61
+ for (let axis = 0; axis < 3; axis++) {
62
+ vertices[offset++] = points[vertex][axis];
63
+ accumulated[index][axis] += n[axis];
64
+ }
65
+ }
66
+ }
67
+ offset = 0;
68
+ for (let face = 0; face < state.triangles.length; face++)
69
+ for (const index of state.triangles[face]) {
70
+ const n = accumulated[index], length = Math.hypot(...n), source = length > 1e-20 ? [n[0] / length, n[1] / length, n[2] / length] : faces[face];
71
+ for (const value of source) {
72
+ if (!Number.isFinite(value))
73
+ throw new Error(`TC_CLOTH_VISUAL_NUMERIC: Invalid normal in '${state.id}'.`);
74
+ normals[offset++] = value;
75
+ }
76
+ }
77
+ return { vertices, normals };
78
+ };
79
+ const sync = (states, materials) => {
80
+ if (disposed)
81
+ throw new Error("TC_CLOTH_VISUAL_DISPOSED: Create a new presentation owner.");
82
+ const staged = new Map(), created = [];
83
+ try {
84
+ for (const state of states) {
85
+ if (staged.has(state.id))
86
+ throw new Error(`TC_CLOTH_VISUAL_ID: Duplicate cloth '${state.id}'.`);
87
+ const material = materials.get(state.material);
88
+ if (!material)
89
+ throw new Error(`TC_CLOTH_VISUAL_MATERIAL: Material '${state.material}' is not declared.`);
90
+ if (!Array.isArray(state.positions) || state.positions.length < 3 || state.positions.length > 4096 ||
91
+ state.positions.some(point => !Array.isArray(point) || point.length !== 3 || !point.every(value => Number.isFinite(value) && Number.isFinite(Math.fround(value)))) ||
92
+ !Array.isArray(state.triangles) || state.triangles.length < 1 || state.triangles.length > 8192 ||
93
+ state.triangles.some(triangle => !Array.isArray(triangle) || triangle.length !== 3 || triangle.some(index => !Number.isSafeInteger(index) || index < 0 || index >= state.positions.length)) ||
94
+ !Array.isArray(state.uvs) || state.uvs.length !== state.positions.length || state.uvs.some(uv => !Array.isArray(uv) || uv.length !== 2 || !uv.every(value => Number.isFinite(value) && Number.isFinite(Math.fround(value)))))
95
+ throw new Error(`TC_CLOTH_VISUAL_INPUT: Cloth '${state.id}' has invalid positions, topology or UVs.`);
96
+ const old = views.get(state.id), same = old && old.signature === signature(state) && old.mesh.material === material;
97
+ const view = same ? old : create(state, material);
98
+ if (!same)
99
+ created.push(view);
100
+ const positions = state.positions.flatMap(point => [...point]);
101
+ const changed = !same || positions.length !== view.positions.length || positions.some((value, index) => value !== view.positions[index]);
102
+ const update = changed ? geometryData(state) : undefined;
103
+ staged.set(state.id, { view, created: !same, positions, ...update });
104
+ }
105
+ }
106
+ catch (error) {
107
+ for (const view of created)
108
+ remove(view);
109
+ throw error;
110
+ }
111
+ let changed = false;
112
+ for (const [id, view] of views)
113
+ if (!staged.has(id) || staged.get(id).view !== view) {
114
+ remove(view);
115
+ changed = true;
116
+ }
117
+ views.clear();
118
+ for (const [id, item] of staged) {
119
+ if (item.created) {
120
+ root.add(item.view.mesh);
121
+ changed = true;
122
+ }
123
+ if (item.vertices && item.normals) {
124
+ const position = item.view.geometry.getAttribute("position"), normal = item.view.geometry.getAttribute("normal");
125
+ position.array.set(item.vertices);
126
+ normal.array.set(item.normals);
127
+ position.needsUpdate = true;
128
+ normal.needsUpdate = true;
129
+ item.view.geometry.computeBoundingBox();
130
+ item.view.geometry.computeBoundingSphere();
131
+ item.view.positions = item.positions;
132
+ changed = true;
133
+ }
134
+ views.set(id, item.view);
135
+ }
136
+ return changed;
137
+ };
138
+ return { root, sync, clear, dispose() { if (disposed)
139
+ return; clear(); root.removeFromParent(); disposed = true; } };
140
+ }
@@ -5,6 +5,7 @@ import { createGameServices } from "../runtime/game-services.js";
5
5
  import { createPhysicsFactory } from "../physics/index.js";
6
6
  import { createNavigationPlugin } from "../navigation/index.js";
7
7
  import { createRopePlugin } from "../simulation/rope-plugin.js";
8
+ import { createClothPlugin } from "../simulation/cloth-plugin.js";
8
9
  import { createModelLibrary } from "./models.js";
9
10
  import { resolveEntityDefinition } from "../runtime/prefabs.js";
10
11
  /** Private presentation ownership. Games receive only SDK services, never browser/library objects. */
@@ -136,6 +137,7 @@ export async function prepareGameplay(ir, baseURL, renderer, materials = new Map
136
137
  services.factory,
137
138
  createNavigationPlugin,
138
139
  createRopePlugin,
140
+ createClothPlugin,
139
141
  (context) => ({
140
142
  id: "$engine.models",
141
143
  version: "2",
@@ -203,6 +203,9 @@ export class RuntimeHost {
203
203
  get ropes() {
204
204
  return host.service("ropes");
205
205
  },
206
+ get cloth() {
207
+ return host.service("cloth");
208
+ },
206
209
  entities: {
207
210
  resolve: (entity) => this.world.resolve(entity),
208
211
  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.16";
2
+ export declare const SDK_VERSION = "0.3.18";
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];
@@ -102,9 +102,10 @@ export interface SystemContext extends EngineFacade {
102
102
  readonly compute: ComputeService;
103
103
  readonly navigation: NavigationService;
104
104
  readonly ropes: RopeService;
105
+ readonly cloth: ClothService;
105
106
  }
106
107
  /** 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
+ * contact impulses to dynamic spheres; there is no full rigid-body coupling. */
108
109
  export type RopeCollider = {
109
110
  kind: "sphere";
110
111
  id: string;
@@ -153,6 +154,8 @@ export interface RopeDefinition {
153
154
  bodyColliders?: readonly RopeBodyCollider[];
154
155
  /** Resolve sphere contacts on segment interiors; defaults to true for new ropes. */
155
156
  segmentCollisions?: boolean;
157
+ /** Resolve discrete non-adjacent segment self contacts; defaults to true for new ropes. */
158
+ selfCollisions?: boolean;
156
159
  /** Optional segmented presentation using an already-declared material id. */
157
160
  visual?: RopeVisualOptions;
158
161
  substeps?: number;
@@ -187,11 +190,24 @@ export interface RopeSegmentContact {
187
190
  reactionImpulse?: Vec3;
188
191
  reactionAngularImpulse?: Vec3;
189
192
  }
193
+ /** Discrete self-contact between two non-adjacent rope segments. */
194
+ export interface RopeSelfContact {
195
+ tick: number;
196
+ /** Zero-based first endpoint of each non-adjacent segment, ordered ascending. */
197
+ firstSegment: number;
198
+ secondSegment: number;
199
+ /** Last closest-point fractions along the respective segments. */
200
+ firstFraction: number;
201
+ secondFraction: number;
202
+ /** Maximum observed overlap in metres during this tick. */
203
+ penetration: number;
204
+ }
190
205
  export interface RopeState {
191
206
  tick: number;
192
207
  simulatedSeconds: number;
193
208
  disposed: boolean;
194
209
  segmentCollisions: boolean;
210
+ selfCollisions: boolean;
195
211
  positions: Vec3[];
196
212
  velocities: Vec3[];
197
213
  anchors: {
@@ -200,6 +216,7 @@ export interface RopeState {
200
216
  }[];
201
217
  contacts: RopeContact[];
202
218
  segmentContacts: RopeSegmentContact[];
219
+ selfContacts: RopeSelfContact[];
203
220
  maxDistanceError: number;
204
221
  resources: {
205
222
  particles: number;
@@ -222,6 +239,86 @@ export interface RopeService {
222
239
  applyImpulse(id: string, index: number, impulse: Vec3): void;
223
240
  destroy(id: string): void;
224
241
  }
242
+ /** CPU triangulated cloth with particle contacts against sampled spheres and planes. */
243
+ export type ClothCollider = RopeCollider;
244
+ export type ClothTriangle = readonly [number, number, number];
245
+ export interface ClothVisualOptions {
246
+ material: string;
247
+ /** Optional per-particle UV coordinates; defaults to a planar projection. */
248
+ uvs?: readonly Vec2[];
249
+ castShadow?: boolean;
250
+ receiveShadow?: boolean;
251
+ }
252
+ export interface ClothDefinition {
253
+ id: string;
254
+ points: readonly Vec3[];
255
+ triangles: readonly ClothTriangle[];
256
+ /** Kilograms per movable particle. */
257
+ mass: number;
258
+ radius?: number;
259
+ fixedDt?: number;
260
+ autoStep?: boolean;
261
+ substeps?: number;
262
+ iterations?: number;
263
+ damping?: number;
264
+ gravity?: Vec3;
265
+ /** Distance-based bending approximation across shared triangle edges; defaults to true. */
266
+ bending?: boolean;
267
+ anchors?: readonly {
268
+ index: number;
269
+ position: Vec3;
270
+ }[];
271
+ visual?: ClothVisualOptions;
272
+ }
273
+ export interface ClothContact {
274
+ tick: number;
275
+ particle: number;
276
+ colliderId: string;
277
+ penetration: number;
278
+ }
279
+ export interface ClothState {
280
+ tick: number;
281
+ simulatedSeconds: number;
282
+ disposed: boolean;
283
+ positions: Vec3[];
284
+ velocities: Vec3[];
285
+ triangles: ClothTriangle[];
286
+ anchors: {
287
+ index: number;
288
+ position: Vec3;
289
+ }[];
290
+ contacts: ClothContact[];
291
+ maxDistanceError: number;
292
+ resources: {
293
+ particles: number;
294
+ triangles: number;
295
+ constraints: number;
296
+ anchors: number;
297
+ colliders: number;
298
+ };
299
+ }
300
+ export interface ClothVisualState {
301
+ id: string;
302
+ material: string;
303
+ positions: Vec3[];
304
+ triangles: ClothTriangle[];
305
+ uvs: Vec2[];
306
+ castShadow: boolean;
307
+ receiveShadow: boolean;
308
+ }
309
+ export interface ClothService {
310
+ create(definition: ClothDefinition): void;
311
+ /** Explicit ticks for manual cloth only; automatic cloth rejects this call. */
312
+ step(id: string, ticks?: number): ClothState;
313
+ inspect(id: string): ClothState;
314
+ visuals(): readonly ClothVisualState[];
315
+ setColliders(id: string, colliders: readonly ClothCollider[]): void;
316
+ setAnchor(id: string, index: number, position: Vec3): void;
317
+ releaseAnchor(id: string, index: number): void;
318
+ applyForce(id: string, index: number, force: Vec3): void;
319
+ applyImpulse(id: string, index: number, impulse: Vec3): void;
320
+ destroy(id: string): void;
321
+ }
225
322
  /** Integer cells on a finite 2D orthogonal grid; not continuous, flying or underwater navigation.
226
323
  * Searches allocate sparse state and depend on available memory. At most 2^32-1 cells may be defined.
227
324
  * Owners are explicit strings and are not automatically tied to entities. */
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.16";
2
+ export const SDK_VERSION = "0.3.18";
3
3
  export const SYSTEM_PHASES = [
4
4
  "input",
5
5
  "prePhysics",
@@ -0,0 +1,152 @@
1
+ import { cloneSerializable } from "../runtime/random.js";
2
+ import { createCloth } from "./cloth.js";
3
+ const fail = (code, detail) => { throw new Error(`TC_CLOTH_${code}: ${detail}`); };
4
+ const validId = (value) => typeof value === "string" && value.trim() === value && value.length > 0 && value.length <= 128;
5
+ function planarUvs(points) {
6
+ const min = [Infinity, Infinity, Infinity], max = [-Infinity, -Infinity, -Infinity];
7
+ for (const point of points)
8
+ for (let axis = 0; axis < 3; axis++) {
9
+ min[axis] = Math.min(min[axis], point[axis]);
10
+ max[axis] = Math.max(max[axis], point[axis]);
11
+ }
12
+ const axes = [0, 1, 2].sort((a, b) => (max[b] - min[b]) - (max[a] - min[a]) || a - b), first = axes[0], second = axes[1];
13
+ const width = max[first] - min[first], height = max[second] - min[second];
14
+ if (!(width > 0) || !(height > 0))
15
+ return fail("VISUAL_UV", "Initial points need two nonzero projection axes or explicit UVs.");
16
+ return points.map(point => [(point[first] - min[first]) / width, (point[second] - min[second]) / height]);
17
+ }
18
+ function createEntry(input, fixedDt, materials) {
19
+ const definition = cloneSerializable(input);
20
+ if (!definition || !validId(definition.id))
21
+ return fail("ID", "Expected a nonempty id of at most 128 characters.");
22
+ if (definition.autoStep !== undefined && typeof definition.autoStep !== "boolean")
23
+ return fail("AUTO_STEP", "autoStep must be boolean.");
24
+ if (definition.fixedDt !== undefined && definition.fixedDt !== fixedDt)
25
+ return fail("FIXED_DT", "Cloth fixedDt must match the host fixedDt.");
26
+ if (fixedDt < 1 / 1000 || fixedDt > 1 / 30)
27
+ return fail("FIXED_DT", "Host fixedDt must be in [1/1000,1/30].");
28
+ if (definition.visual !== undefined) {
29
+ const visual = definition.visual;
30
+ if (!visual || typeof visual !== "object" || Array.isArray(visual) || !validId(visual.material) || !materials.has(visual.material))
31
+ return fail("VISUAL_MATERIAL", "visual.material must name a declared engine material.");
32
+ if ((visual.castShadow !== undefined && typeof visual.castShadow !== "boolean") || (visual.receiveShadow !== undefined && typeof visual.receiveShadow !== "boolean"))
33
+ return fail("VISUAL_SHADOW", "Shadow options must be boolean.");
34
+ if (visual.uvs !== undefined && (!Array.isArray(visual.uvs) || !Array.isArray(definition.points) || visual.uvs.length !== definition.points.length ||
35
+ visual.uvs.some(uv => !Array.isArray(uv) || uv.length !== 2 || !uv.every(value => typeof value === "number" && Number.isFinite(value) && Math.abs(value) <= 1e6))))
36
+ return fail("VISUAL_UV", "UVs must contain one finite 2D coordinate per particle.");
37
+ }
38
+ const solver = createCloth({ ...definition, fixedDt });
39
+ try {
40
+ const uvs = definition.visual ? (definition.visual.uvs ? definition.visual.uvs.map(uv => [uv[0], uv[1]]) : planarUvs(definition.points)) : [];
41
+ return { definition, solver, uvs };
42
+ }
43
+ catch (error) {
44
+ solver.dispose();
45
+ throw error;
46
+ }
47
+ }
48
+ function disposeEntries(entries) { for (const entry of entries.values())
49
+ entry.solver.dispose(); entries.clear(); }
50
+ function parseSnapshot(value, fixedDt, materials) {
51
+ if (!value || typeof value !== "object" || value.version !== 1 || !Array.isArray(value.cloths) || value.cloths.length > 256)
52
+ return fail("SNAPSHOT", "Expected a version 1 snapshot containing at most 256 cloths.");
53
+ const entries = new Map();
54
+ try {
55
+ for (const item of value.cloths) {
56
+ if (!item || typeof item !== "object")
57
+ fail("SNAPSHOT", "Invalid cloth entry.");
58
+ const entry = createEntry(item.definition, fixedDt, materials);
59
+ if (entries.has(entry.definition.id)) {
60
+ entry.solver.dispose();
61
+ fail("SNAPSHOT", "Duplicate cloth id.");
62
+ }
63
+ entries.set(entry.definition.id, entry);
64
+ entry.solver.restore(item.state);
65
+ }
66
+ return entries;
67
+ }
68
+ catch (error) {
69
+ disposeEntries(entries);
70
+ throw error;
71
+ }
72
+ }
73
+ export const createClothPlugin = context => {
74
+ const { fixedDt } = context;
75
+ if (typeof fixedDt !== "number" || !Number.isFinite(fixedDt))
76
+ return fail("FIXED_DT", "RuntimeHost must provide a finite fixed interval.");
77
+ const hostDt = fixedDt, materials = new Set(context.scene.materials.map(material => material.id));
78
+ let entries = new Map(), disposed = false;
79
+ const alive = () => { if (disposed)
80
+ fail("DISPOSED", "Cloth service is disposed."); };
81
+ const get = (id) => { alive(); if (!validId(id))
82
+ return fail("ID", "Expected a nonempty cloth id."); return entries.get(id) ?? fail("MISSING", `Cloth '${id}' does not exist.`); };
83
+ const service = {
84
+ create(definition) {
85
+ alive();
86
+ if (!definition || !validId(definition.id))
87
+ return fail("ID", "Expected a nonempty cloth id.");
88
+ if (entries.has(definition.id))
89
+ return fail("DUPLICATE", `Cloth '${definition.id}' already exists.`);
90
+ if (entries.size >= 256)
91
+ return fail("CAPACITY", "At most 256 cloths per host.");
92
+ const entry = createEntry(definition, hostDt, materials);
93
+ entries.set(entry.definition.id, entry);
94
+ },
95
+ step(id, ticks) { const entry = get(id); if (entry.definition.autoStep)
96
+ return fail("AUTO_STEP", `Cloth '${id}' is automatically stepped.`); return entry.solver.step(ticks); },
97
+ inspect(id) { return get(id).solver.inspect(); },
98
+ visuals() {
99
+ alive();
100
+ return [...entries.values()].filter(entry => entry.definition.visual !== undefined)
101
+ .sort((a, b) => a.definition.id < b.definition.id ? -1 : a.definition.id > b.definition.id ? 1 : 0)
102
+ .map(entry => {
103
+ const state = entry.solver.inspect(), visual = entry.definition.visual;
104
+ return { id: entry.definition.id, material: visual.material,
105
+ positions: state.positions, triangles: state.triangles, uvs: entry.uvs.map(uv => [uv[0], uv[1]]),
106
+ castShadow: visual.castShadow ?? true, receiveShadow: visual.receiveShadow ?? true };
107
+ });
108
+ },
109
+ setColliders(id, colliders) { get(id).solver.setColliders(cloneSerializable(colliders)); },
110
+ setAnchor(id, index, position) { get(id).solver.setAnchor(index, cloneSerializable(position)); },
111
+ releaseAnchor(id, index) { get(id).solver.releaseAnchor(index); },
112
+ applyForce(id, index, force) { get(id).solver.applyForce(index, cloneSerializable(force)); },
113
+ applyImpulse(id, index, impulse) { get(id).solver.applyImpulse(index, cloneSerializable(impulse)); },
114
+ destroy(id) { const entry = get(id); entry.solver.dispose(); entries.delete(id); },
115
+ };
116
+ return {
117
+ id: "$engine.cloth", version: "1", services: { cloth: service },
118
+ afterPhysics() {
119
+ alive();
120
+ const committed = [];
121
+ try {
122
+ const automatic = [...entries.values()].filter(entry => entry.definition.autoStep)
123
+ .sort((a, b) => a.definition.id < b.definition.id ? -1 : a.definition.id > b.definition.id ? 1 : 0);
124
+ for (const { solver } of automatic) {
125
+ const snapshot = solver.snapshot();
126
+ committed.push({ solver, snapshot });
127
+ solver.step();
128
+ }
129
+ }
130
+ catch (error) {
131
+ const failures = [];
132
+ for (const { solver, snapshot } of committed.reverse())
133
+ try {
134
+ solver.restore(snapshot);
135
+ }
136
+ catch (rollbackError) {
137
+ failures.push(String(rollbackError));
138
+ }
139
+ if (failures.length)
140
+ throw new Error(`TC_CLOTH_ROLLBACK: ${String(error)}; partialState=true; rollback failures: ${failures.join("; ")}`);
141
+ throw error;
142
+ }
143
+ },
144
+ legacySnapshot: () => ({ version: 1, cloths: [] }),
145
+ snapshot: () => ({ version: 1, cloths: [...entries.values()].sort((a, b) => a.definition.id < b.definition.id ? -1 : a.definition.id > b.definition.id ? 1 : 0)
146
+ .map(({ definition, solver }) => ({ definition: cloneSerializable(definition), state: solver.snapshot() })) }),
147
+ validateSnapshot(value) { const staged = parseSnapshot(value, hostDt, materials); disposeEntries(staged); },
148
+ restore(value) { alive(); const staged = parseSnapshot(value, hostDt, materials); disposeEntries(entries); entries = staged; },
149
+ dispose() { if (disposed)
150
+ return; disposed = true; disposeEntries(entries); },
151
+ };
152
+ };