pelican-engine 0.3.17 → 0.3.19

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.
@@ -222,6 +222,36 @@ general rigid-body coupling, continuous collision, cross-rope collision, or tear
222
222
  normal distance from the origin. Host intervals must be between 1/1000 and
223
223
  1/30 seconds inclusive.
224
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
+ New cloth also resolves discrete self-contact between non-adjacent particles by
237
+ projecting movable particles apart; shared triangle edges are excluded.
238
+ `selfCollisions` defaults to true for new cloth and is reported by
239
+ `ctx.cloth.inspect(id)`. Set it to false for particle-only cloth. Self-contact
240
+ state is included in snapshots. This is particle self-contact, not triangle
241
+ interior/edge collision, continuous collision, friction or a guarantee against
242
+ penetration. It has not been runtime or stability validated.
243
+
244
+ The CPU solver uses edge distance constraints and an optional distance-based
245
+ bending approximation (`bending: true` by default). Sampled planes and spheres
246
+ only move cloth particles; they do not receive reactions. Contacts do not cover
247
+ triangle interiors or edges. There is no self-contact, cloth–rope interaction,
248
+ rigid-body coupling, tearing, friction or continuous collision. The optional
249
+ visual deforms the actual triangle mesh; UVs may be provided per particle or use a
250
+ planar projection. Use a double-sided material when both sides must be visible.
251
+ Collapsed or unrepresentable triangles return `TC_CLOTH_VISUAL_DEGENERATE` rather
252
+ than being silently replaced. Runtime, GPU, performance, stability and snapshot
253
+ compatibility have not been validated; this is not complete cloth physics.
254
+
225
255
  Keyboard bindings use the browser's `KeyboardEvent.key` values, not physical
226
256
  `KeyboardEvent.code` values. For example, use `input: { up: ["w", "W", "ArrowUp"],
227
257
  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.17",
3
+ "version": "0.3.19",
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.17";
2
+ export declare const SDK_VERSION = "0.3.19";
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,6 +102,7 @@ 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
108
  * contact impulses to dynamic spheres; there is no full rigid-body coupling. */
@@ -238,6 +239,96 @@ export interface RopeService {
238
239
  applyImpulse(id: string, index: number, impulse: Vec3): void;
239
240
  destroy(id: string): void;
240
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
+ /** Discrete non-adjacent particle self-contact; defaults to true for new cloth. */
273
+ selfCollisions?: boolean;
274
+ }
275
+ export interface ClothContact {
276
+ tick: number;
277
+ particle: number;
278
+ colliderId: string;
279
+ penetration: number;
280
+ }
281
+ export interface ClothSelfContact {
282
+ tick: number;
283
+ firstParticle: number;
284
+ secondParticle: number;
285
+ penetration: number;
286
+ }
287
+ export interface ClothState {
288
+ tick: number;
289
+ simulatedSeconds: number;
290
+ disposed: boolean;
291
+ selfCollisions: boolean;
292
+ positions: Vec3[];
293
+ velocities: Vec3[];
294
+ triangles: ClothTriangle[];
295
+ anchors: {
296
+ index: number;
297
+ position: Vec3;
298
+ }[];
299
+ contacts: ClothContact[];
300
+ selfContacts: ClothSelfContact[];
301
+ maxDistanceError: number;
302
+ resources: {
303
+ particles: number;
304
+ triangles: number;
305
+ constraints: number;
306
+ anchors: number;
307
+ colliders: number;
308
+ };
309
+ }
310
+ export interface ClothVisualState {
311
+ id: string;
312
+ material: string;
313
+ positions: Vec3[];
314
+ triangles: ClothTriangle[];
315
+ uvs: Vec2[];
316
+ castShadow: boolean;
317
+ receiveShadow: boolean;
318
+ }
319
+ export interface ClothService {
320
+ create(definition: ClothDefinition): void;
321
+ /** Explicit ticks for manual cloth only; automatic cloth rejects this call. */
322
+ step(id: string, ticks?: number): ClothState;
323
+ inspect(id: string): ClothState;
324
+ visuals(): readonly ClothVisualState[];
325
+ setColliders(id: string, colliders: readonly ClothCollider[]): void;
326
+ setAnchor(id: string, index: number, position: Vec3): void;
327
+ releaseAnchor(id: string, index: number): void;
328
+ applyForce(id: string, index: number, force: Vec3): void;
329
+ applyImpulse(id: string, index: number, impulse: Vec3): void;
330
+ destroy(id: string): void;
331
+ }
241
332
  /** Integer cells on a finite 2D orthogonal grid; not continuous, flying or underwater navigation.
242
333
  * Searches allocate sparse state and depend on available memory. At most 2^32-1 cells may be defined.
243
334
  * 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.17";
2
+ export const SDK_VERSION = "0.3.19";
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
+ };