flag-cloth 0.1.0

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.
package/LICENSE ADDED
@@ -0,0 +1,19 @@
1
+ Copyright (c) 2026 Gregorio Vargas
2
+
3
+ Permission is hereby granted, free of charge, to any person obtaining a copy
4
+ of this software and associated documentation files (the "Software"), to deal
5
+ in the Software without restriction, including without limitation the rights
6
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
7
+ copies of the Software, and to permit persons to whom the Software is
8
+ furnished to do so, subject to the following conditions:
9
+
10
+ The above copyright notice and this permission notice shall be included in all
11
+ copies or substantial portions of the Software.
12
+
13
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
14
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
15
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
16
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
17
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
18
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
19
+ SOFTWARE.
package/README.md ADDED
@@ -0,0 +1,417 @@
1
+ # flag-cloth
2
+
3
+ `flag-cloth` renders an image as an interactive cloth flag with the browser's native WebGL2 canvas API. The core package has **zero runtime dependencies**.
4
+
5
+ The motion comes from a real particle-and-constraint simulation: Verlet integration, structural/shear/bend constraints, aerodynamic wind forces, and iterative position solving. Canvas is only the renderer; it does not replace the simulation with sine-wave vertex animation.
6
+
7
+ ## Features
8
+
9
+ - Dependency-free TypeScript core using native WebGL2
10
+ - Typed-array particle, constraint, projection, normal, and triangle buffers
11
+ - Fixed-timestep Verlet/PBD simulation independent of display frame rate
12
+ - Aerodynamic wind applied per cloth triangle with small internal value noise
13
+ - Structural, shear, and bend constraints
14
+ - Edge, corner, partial-edge, and custom-point attachment
15
+ - Mouse, pen, and touch dragging through Pointer Events
16
+ - Perspective projection, texture deformation, fold lighting, highlights, and soft shadows
17
+ - Independent texture and simulation resolutions
18
+ - Vanilla and optional React APIs
19
+ - Responsive resizing, pixel-ratio cap, offscreen pausing, and complete cleanup
20
+ - Quality and performance rendering presets
21
+
22
+ ## Installation
23
+
24
+ Vanilla JavaScript or TypeScript needs only the library:
25
+
26
+ ```bash
27
+ pnpm add flag-cloth
28
+ ```
29
+
30
+ The React entry point has React as an optional peer dependency:
31
+
32
+ ```bash
33
+ pnpm add flag-cloth react
34
+ ```
35
+
36
+ React DOM is normally already present in a browser React application, but `flag-cloth/react` does not import it. The package contains no Three.js, physics engine, noise package, state library, or other core runtime dependency.
37
+
38
+ ## Vanilla usage
39
+
40
+ Give the container an explicit size:
41
+
42
+ ```html
43
+ <div id="flag" style="width: min(900px, 100%); height: 560px"></div>
44
+ ```
45
+
46
+ ```ts
47
+ import { createFlagCloth } from "flag-cloth";
48
+
49
+ const container = document.querySelector<HTMLElement>("#flag");
50
+ if (!container) throw new Error("Missing #flag");
51
+
52
+ const flag = createFlagCloth({
53
+ container,
54
+ texture: "/flags/dominican-republic.svg",
55
+ width: 3,
56
+ height: 2,
57
+ segments: { x: 32, y: 20 },
58
+ attachment: "left",
59
+ wind: {
60
+ direction: [1, 0.1, 0.2],
61
+ strength: 7,
62
+ turbulence: 0.35,
63
+ gustFrequency: 0.5,
64
+ },
65
+ simulation: {
66
+ damping: 0.985,
67
+ gravity: [0, 0, 0],
68
+ fixedTimeStep: 1 / 60,
69
+ substeps: 2,
70
+ constraintIterations: 6,
71
+ },
72
+ interaction: {
73
+ enabled: true,
74
+ dragRadius: 1,
75
+ dragStiffness: 0.9,
76
+ },
77
+ renderer: {
78
+ preset: "quality",
79
+ maxPixelRatio: 1.5,
80
+ transparent: true,
81
+ },
82
+ });
83
+
84
+ await flag.ready;
85
+ ```
86
+
87
+ The instance starts automatically unless `autoStart: false` is set.
88
+
89
+ ```ts
90
+ flag.start();
91
+ flag.pause();
92
+ flag.resume();
93
+ flag.resize();
94
+ flag.render();
95
+ flag.update(1 / 60); // useful with an external animation loop
96
+ await flag.setTexture("/flags/another-flag.webp");
97
+ flag.setWind({ strength: 12 });
98
+ await flag.setOptions({
99
+ interaction: { enabled: false },
100
+ material: { foldContrast: 0.55 },
101
+ });
102
+ flag.reset();
103
+ flag.destroy();
104
+ ```
105
+
106
+ `destroy()` cancels animation work, disconnects observers, removes Pointer Event and document listeners, clears the canvas, destroys the optional debug panel, and removes a canvas created by the library. It is safe to call more than once.
107
+
108
+ ## React usage
109
+
110
+ ```tsx
111
+ import { useRef } from "react";
112
+ import {
113
+ FlagCloth,
114
+ type Attachment,
115
+ type FlagClothInstance,
116
+ } from "flag-cloth/react";
117
+
118
+ const mastPins = {
119
+ points: [[0, 0], [0, 5], [0, 10], [0, 15], [0, 20]],
120
+ } satisfies Attachment;
121
+
122
+ export function FlagExample() {
123
+ const flagRef = useRef<FlagClothInstance>(null);
124
+
125
+ return (
126
+ <FlagCloth
127
+ ref={flagRef}
128
+ canvasWidth="100%"
129
+ canvasHeight={560}
130
+ texture="/flags/dominican-republic.svg"
131
+ width={3}
132
+ height={2}
133
+ segments={{ x: 32, y: 20 }}
134
+ attachment={mastPins}
135
+ wind={{
136
+ direction: [1, 0.1, 0.2],
137
+ strength: 7,
138
+ turbulence: 0.35,
139
+ }}
140
+ interaction={{ enabled: true, dragRadius: 1 }}
141
+ renderer={{ preset: "quality", maxPixelRatio: 1.5 }}
142
+ onReady={(instance) => console.log(instance.stats)}
143
+ />
144
+ );
145
+ }
146
+ ```
147
+
148
+ The wrapper creates the core instance on mount, forwards a ref to it, applies mutable prop changes with `setOptions()`, and destroys it on unmount. Particle positions and the animation loop stay outside React state, so animation does not cause React renders. The mount cleanup is safe under React Strict Mode.
149
+
150
+ `width` and `height` are physical cloth dimensions in simulation units. `canvasWidth` and `canvasHeight` size the React-owned container with normal CSS values. The canvas fills that container and its backing store follows through `ResizeObserver`. You can use `className` or `style` instead; explicit canvas dimension props override the corresponding inline style dimensions.
151
+
152
+ Pass stable option objects with `useMemo` when convenient. A new object is supported, but it asks the wrapper to compare and apply that option group after every React render.
153
+
154
+ ## How rendering works
155
+
156
+ The solver remains fully three-dimensional. Each frame, the WebGL renderer:
157
+
158
+ 1. Uploads the current typed-array particle positions into one dynamic GPU buffer.
159
+ 2. Computes area-weighted vertex normals and uploads the reused normal buffer.
160
+ 3. Projects the indexed grid with a compact perspective camera in the vertex shader.
161
+ 4. Maps the complete image with standard UVs in a single indexed texture draw.
162
+ 5. Applies smooth diffuse fold lighting and specular highlights in the fragment shader.
163
+ 6. Draws an optional nine-sample soft silhouette shadow before the cloth.
164
+
165
+ The texture is not chopped into bitmap pieces and geometry is not rebuilt each frame. The GPU interpolates UVs and vertex normals across the mesh, so there are no triangle outlines or per-triangle Canvas clips.
166
+
167
+ This material model is deliberately compact. It creates readable folds and a cloth-like response using two small internal shaders, without a rendering dependency.
168
+
169
+ ## Texture resolution versus simulation resolution
170
+
171
+ These are independent:
172
+
173
+ - `texture` controls source image detail. A 2048×1365 PNG can remain visually sharp.
174
+ - `segments` controls the number of simulated cells. `{ x: 32, y: 20 }` creates 693 particles and 1,280 rendered triangles.
175
+
176
+ There is never one particle per texture pixel. Increasing image resolution does not increase constraint solving cost. Increasing `segments` does increase particle, constraint, wind, normal, buffer-upload, and indexed-triangle work.
177
+
178
+ Supported sources are URL-loaded PNG, JPEG, WebP, SVG, and other formats supported by the browser's image decoder. You may also pass any ready `TexImageSource`, such as an `HTMLImageElement`, `HTMLCanvasElement`, `ImageBitmap`, or `OffscreenCanvas`:
179
+
180
+ ```ts
181
+ await flag.setTexture(imageElement);
182
+ await flag.setTexture(null); // renders material.baseColor
183
+ ```
184
+
185
+ Cross-origin URLs must allow image loading according to normal browser CORS rules. URL images are loaded with `crossOrigin = "anonymous"`.
186
+
187
+ ## Configuration reference
188
+
189
+ ### Cloth and attachment
190
+
191
+ | Option | Default | Meaning |
192
+ | --- | --- | --- |
193
+ | `width` | `3` | Cloth width in simulation units. |
194
+ | `height` | `2` | Cloth height in simulation units. |
195
+ | `segments` | `{ x: 32, y: 20 }` | Physical grid cell count. Particle count is `(x + 1) × (y + 1)`. |
196
+ | `attachment` | `"left"` | Pinned edge, corner, partial edge, custom points, or `"none"`. |
197
+ | `autoStart` | `true` | Starts the internal animation loop after construction. |
198
+
199
+ The exported attachment type has these exact shapes:
200
+
201
+ ```ts
202
+ type EdgeAttachment = "left" | "right" | "top" | "bottom";
203
+ type CornerAttachment =
204
+ | "top-left"
205
+ | "top-right"
206
+ | "bottom-left"
207
+ | "bottom-right";
208
+
209
+ type GridPoint = readonly [x: number, y: number];
210
+
211
+ type Attachment =
212
+ | EdgeAttachment
213
+ | CornerAttachment
214
+ | "none"
215
+ | { edge: EdgeAttachment; every?: number; offset?: number }
216
+ | { points: ReadonlyArray<GridPoint> };
217
+ ```
218
+
219
+ Examples:
220
+
221
+ ```ts
222
+ attachment: "top-left"
223
+ attachment: { edge: "left", every: 2 }
224
+ attachment: { edge: "top", every: 3, offset: 1 }
225
+ attachment: { points: [[0, 0], [0, 5], [4, 4]] }
226
+ ```
227
+
228
+ Custom coordinates address particles in the simulation grid, not pixels, UVs, or world positions. `[0, 0]` is top-left and `[segments.x, segments.y]` is bottom-right. Decimal coordinates are rounded; out-of-range coordinates are ignored. On vertical edges, `offset` counts down from the top; on horizontal edges it counts rightward from the left. `every` defaults to `1` and `offset` defaults to `0`.
229
+
230
+ The same `Attachment` type is exported by `flag-cloth/react`. Change pins at runtime with `flag.setOptions({ attachment })`; this replaces the pin mask without rebuilding cloth topology.
231
+
232
+ ### Wind
233
+
234
+ | Option | Default | Meaning |
235
+ | --- | --- | --- |
236
+ | `direction` | `[1, 0.08, 0.3]` | Normalized internally. |
237
+ | `strength` | `7` | Base wind speed. |
238
+ | `turbulence` | `0.35` | Strength of noisy gust and lateral variation. |
239
+ | `gustFrequency` | `0.5` | Time variation rate. |
240
+ | `spatialScale` | `0.8` | Frequency of spatial wind variation. |
241
+ | `aerodynamicCoefficient` | `0.35` | Pressure-to-force multiplier. |
242
+
243
+ For each cloth triangle, the solver calculates its cross-product normal and area, samples local value noise, subtracts approximate triangle velocity from the wind velocity, projects that relative velocity onto the normal, and distributes the resulting pressure force among the triangle's three particles. Wind never assigns particle positions.
244
+
245
+ ### Simulation
246
+
247
+ | Option | Default | Meaning |
248
+ | --- | --- | --- |
249
+ | `damping` | `0.985` | Velocity retained over a 1/60-second interval. |
250
+ | `gravity` | `[0, 0, 0]` | Acceleration vector; disabled by default for a conventional flag. |
251
+ | `fixedTimeStep` | `1 / 60` | Stable solver step used by the accumulator. |
252
+ | `maxFrameDelta` | `0.1` | Caps long display-frame gaps. |
253
+ | `substeps` | `2` | Integration subdivisions per fixed step. |
254
+ | `constraintIterations` | `6` | Structural/shear/bend solve passes per substep. |
255
+ | `structuralStiffness` | `1` | Horizontal and vertical distance retention. |
256
+ | `shearStiffness` | `0.9` | Diagonal distance retention. |
257
+ | `bendStiffness` | `0.4` | Two-grid-position distance retention. |
258
+ | `maxCorrection` | `0.25` | Per-constraint correction cap; `0` disables the cap. |
259
+ | `mass` | `1` | Total cloth mass, independent of grid density. |
260
+
261
+ Stiffness is converted to an iteration-independent correction so changing iteration count does not multiply the intended stiffness directly.
262
+
263
+ ### Interaction
264
+
265
+ | Option | Default | Meaning |
266
+ | --- | --- | --- |
267
+ | `enabled` | `true` | Enables mouse, pen, and touch dragging. |
268
+ | `dragRadius` | `1` | Grid-space radius of particles affected around the grabbed point. |
269
+ | `dragStiffness` | `0.9` | Strength of the temporary positional constraint. |
270
+ | `maxDragDistance` | `4` | Maximum distance from the original grab position; `0` is unlimited. |
271
+ | `allowPinned` | `false` | Allows temporarily moving attached particles. |
272
+ | `releaseImpulse` | `[0, 0, 0]` | Optional velocity impulse added on release. |
273
+
274
+ Pointer selection uses the renderer's projected indexed triangles. The hit triangle's nearest barycentric vertex becomes the selected particle, and neighboring particles inside `dragRadius` receive weighted temporary constraints. The target moves on a camera-facing plane. Pointer capture keeps the drag active outside the canvas; release restores free motion while retaining the Verlet velocity naturally.
275
+
276
+ ### Renderer and material
277
+
278
+ | Option | Default | Meaning |
279
+ | --- | --- | --- |
280
+ | `renderer.preset` | `"quality"` | Applies a complete profile. `quality` uses a 1.5× pixel cap, shadows, highlights, and per-frame normals; `performance` uses 1×, no shadows or highlights, and every-second-frame normals. Explicit renderer overrides still win. |
281
+ | `renderer.maxPixelRatio` | `1.5` | Caps backing-store resolution. Never uses device pixel ratio uncapped. |
282
+ | `renderer.alpha` | `true` | Requests an alpha-enabled WebGL2 context. Construction-time only. |
283
+ | `renderer.antialias` | `true` | Requests browser multisample antialiasing. Construction-time only. |
284
+ | `renderer.desynchronized` | `false` | Requests a desynchronized context when supported. Construction-time only. |
285
+ | `renderer.powerPreference` | `"high-performance"` | GPU preference supplied during context creation. Construction-time only. |
286
+ | `renderer.textureFiltering` | `"linear"` | Mipmapped linear or nearest-neighbor texture filtering. |
287
+ | `renderer.transparent` | `true` | Clears to transparency instead of filling a background. |
288
+ | `renderer.backgroundColor` | `"transparent"` | Fill used when `transparent` is false. |
289
+ | `renderer.shadows` | `true` | Enables the soft silhouette shadow. The performance profile defaults this to `false`, but it can be overridden. |
290
+ | `renderer.shadingUpdateInterval` | `1` | Recomputes normals every N rendered frames. |
291
+ | `material.baseColor` | `"#f2f4f3"` | Cloth fill when no texture is set. |
292
+ | `material.ambient` | `0.58` | Minimum material brightness. |
293
+ | `material.diffuse` | `0.55` | Directional fold lighting strength. |
294
+ | `material.specular` | `0.16` | Highlight strength in quality mode. |
295
+ | `material.shininess` | `18` | Highlight concentration. |
296
+ | `material.foldContrast` | `0.42` | Dark/light fold contrast. |
297
+ | `material.shadowColor` | `"rgba(0, 0, 0, 0.3)"` | Soft silhouette shadow color. |
298
+ | `material.shadowBlur` | `18` | Shadow blur in CSS pixels. |
299
+ | `material.shadowOffsetX/Y` | `10 / 12` | Shadow offset in CSS pixels. |
300
+ | `lighting.enabled` | `true` | Enables material lighting overlays. |
301
+ | `lighting.intensity` | `1` | Overall light multiplier. |
302
+ | `lighting.direction` | `[-0.4, 0.7, 1]` | World-space light direction. |
303
+
304
+ ### Camera, visibility, and debug
305
+
306
+ | Option | Default | Meaning |
307
+ | --- | --- | --- |
308
+ | `camera.fov` | `40` | Vertical perspective field of view in degrees. |
309
+ | `camera.near` / `far` | `0.01 / 100` | Projection clipping distances. |
310
+ | `camera.position` | `null` | Camera position; `null` auto-fits the cloth. |
311
+ | `camera.lookAt` | `[0, 0, 0]` | Camera target. |
312
+ | `visibility.pauseWhenOffscreen` | `true` | Uses `IntersectionObserver` to pause outside the viewport. |
313
+ | `visibility.pauseWhenDocumentHidden` | `true` | Pauses while the document is hidden. |
314
+ | `debug` | `false` | Enables the optional lightweight debug panel. |
315
+ | `debug.enabled` | `false` | Creates the debug overlay when using the object form. |
316
+ | `debug.updateInterval` | `500` | Overlay refresh interval in milliseconds, clamped to at least 100. |
317
+
318
+ The debug panel reports FPS, simulation time, JavaScript render-submission time, particle count, constraint count, and WebGL draw calls. It creates no panel DOM or update work unless enabled. The repository examples are clean by default; append `?debug` to an example URL to show the panel.
319
+
320
+ The `advanced` group defaults to a library-created canvas and context with `externalAnimationLoop: false`. `advanced`, `autoStart`, and the WebGL context attributes `renderer.alpha`, `antialias`, `desynchronized`, and `powerPreference` are construction-time options. They are intentionally unavailable through `FlagClothUpdateOptions`.
321
+
322
+ ## Performance recommendations
323
+
324
+ - Start at `{ x: 32, y: 20 }`; raise it only when silhouette smoothness needs it.
325
+ - Use `renderer.preset: "performance"` for several simultaneous flags or mobile-first pages.
326
+ - Keep `maxPixelRatio` at `1`–`1.5`; fragment-shader cost scales with backing-store area.
327
+ - Lower `constraintIterations` before lowering substeps if the cloth is stable enough.
328
+ - Quality uses ten draw calls: nine soft-shadow samples and one cloth draw. Performance mode uses one cloth draw.
329
+ - Keep offscreen and hidden-document pausing enabled.
330
+ - Read `instance.stats` or enable `debug` while profiling the actual target devices.
331
+
332
+ At the default 32×20 grid, the simulation holds 693 particles, 3,890 precomputed constraints, and 1,280 indexed triangles. Hot loops use numeric typed arrays, avoid callback-heavy array methods, and reuse normal and GPU-upload buffers. CPU projection runs only when pointer hit-testing needs it. JavaScript creates no per-particle or per-constraint objects each frame.
333
+
334
+ ## Custom canvas and animation loop
335
+
336
+ The normal API creates and sizes its own canvas. Advanced integration can supply a caller-owned canvas or WebGL2 context:
337
+
338
+ ```ts
339
+ const canvas = document.querySelector<HTMLCanvasElement>("#shared-canvas");
340
+ if (!canvas) throw new Error("Missing canvas");
341
+
342
+ const flag = createFlagCloth({
343
+ container: canvas.parentElement!,
344
+ texture: imageElement,
345
+ advanced: {
346
+ canvas,
347
+ context: canvas.getContext("webgl2")!,
348
+ externalAnimationLoop: true,
349
+ },
350
+ autoStart: true,
351
+ });
352
+
353
+ let previous = performance.now();
354
+ function frame(now: number) {
355
+ flag.update((now - previous) / 1000, true);
356
+ previous = now;
357
+ requestAnimationFrame(frame);
358
+ }
359
+ requestAnimationFrame(frame);
360
+ ```
361
+
362
+ When a canvas or context is provided, the caller owns that resource and must place it in the document. `destroy()` removes listeners, deletes the library's GPU buffers, program, texture, and vertex array, and clears the canvas, but does not remove it. A WebGL context is stateful, so coordinate externally before issuing other commands into the same context.
363
+
364
+ ## Browser support
365
+
366
+ The library targets modern browsers with:
367
+
368
+ - WebGL2 and GLSL ES 3.00 shaders
369
+ - ES modules and typed arrays
370
+ - Pointer Events and pointer capture
371
+ - `requestAnimationFrame`
372
+ - `ResizeObserver` for automatic sizing
373
+ - `IntersectionObserver` for optional offscreen pausing
374
+
375
+ Current Chromium, Firefox, and Safari releases provide these APIs. The simulation itself can be unit-tested without WebGL.
376
+
377
+ ## Examples and development
378
+
379
+ This repository includes:
380
+
381
+ - `examples/vanilla` — TypeScript API and live controls
382
+ - `examples/react` — React wrapper under Strict Mode
383
+ - `examples/stress` — multiple flags and resolution controls
384
+
385
+ ```bash
386
+ pnpm install
387
+ pnpm dev
388
+ pnpm typecheck
389
+ pnpm test
390
+ pnpm build
391
+ pnpm examples:build
392
+ pnpm bench
393
+ ```
394
+
395
+ The tests cover pinned and forced particles, all constraint families, long-run finiteness, fixed-step delta capping, reset, drag solving, Pointer Event cleanup, and instance lifecycle cleanup.
396
+
397
+ ## Publishing
398
+
399
+ 1. Choose a semver version and update `package.json`.
400
+ 2. Run `pnpm typecheck && pnpm test && pnpm build && pnpm examples:build`.
401
+ 3. Inspect the package with `pnpm pack --dry-run`.
402
+ 4. Publish with `pnpm publish --access public`.
403
+
404
+ The package exports `flag-cloth` and `flag-cloth/react`. Only `dist`, this README, and the license are published. React remains external and optional; the core entry has no peer or runtime dependency.
405
+
406
+ ## Known limitations
407
+
408
+ - The compact lighting shader is visually motivated rather than physically based.
409
+ - The soft shadow uses a small screen-space sample kernel; it is not ray-traced self-shadowing.
410
+ - The cloth solver is CPU-driven. Prefer several hundred or a few thousand particles, not GPU-compute-sized grids.
411
+ - There is no collision, tearing, self-collision, pole model, or rigid-body coupling in the first version.
412
+ - URL texture loading follows browser image-decoding and CORS behavior.
413
+ - A WebGPU compute solver could be added later, but it would be a separate optional path rather than complicating the readable CPU solver.
414
+
415
+ ## License
416
+
417
+ MIT