@cazala/party 0.1.0-next.45.433a20a → 0.1.0-next.48.389a3a7

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/dist/index.js CHANGED
@@ -399,6 +399,33 @@ class GPUResources {
399
399
  stagingBuffer.destroy();
400
400
  }
401
401
  }
402
+ /**
403
+ * Read an arbitrary GPUBuffer (storage/uniform) into an ArrayBuffer.
404
+ * Caller is responsible for interpreting the bytes.
405
+ */
406
+ async readBuffer(buffer, sizeBytes) {
407
+ const bytes = Math.max(0, Math.floor(sizeBytes));
408
+ if (bytes === 0)
409
+ return new ArrayBuffer(0);
410
+ const stagingBuffer = this.getDevice().createBuffer({
411
+ size: bytes,
412
+ usage: GPUBufferUsage.COPY_DST | GPUBufferUsage.MAP_READ,
413
+ });
414
+ try {
415
+ const encoder = this.getDevice().createCommandEncoder();
416
+ encoder.copyBufferToBuffer(buffer, 0, stagingBuffer, 0, bytes);
417
+ this.getDevice().queue.submit([encoder.finish()]);
418
+ await this.getDevice().queue.onSubmittedWorkDone();
419
+ await stagingBuffer.mapAsync(GPUMapMode.READ);
420
+ const mapped = stagingBuffer.getMappedRange();
421
+ const out = mapped.slice(0);
422
+ stagingBuffer.unmap();
423
+ return out;
424
+ }
425
+ finally {
426
+ stagingBuffer.destroy();
427
+ }
428
+ }
402
429
  createModuleUniformBuffers(layouts) {
403
430
  // Destroy old
404
431
  this.moduleUniformBuffers.forEach(({ buffer }) => buffer.destroy());
@@ -1160,9 +1187,22 @@ class ParticleStore {
1160
1187
  }
1161
1188
  addParticle(p) {
1162
1189
  if (this.count >= this.maxParticles)
1163
- return;
1164
- this.writeAtIndex(this.count, p);
1190
+ return -1;
1191
+ const index = this.count;
1192
+ this.writeAtIndex(index, p);
1165
1193
  this.count++;
1194
+ return index;
1195
+ }
1196
+ setParticle(index, p) {
1197
+ if (index < 0 || index >= this.count)
1198
+ return;
1199
+ this.writeAtIndex(index, p);
1200
+ }
1201
+ setParticleMass(index, mass) {
1202
+ if (index < 0 || index >= this.count)
1203
+ return;
1204
+ const base = index * this.floatsPerParticle;
1205
+ this.data[base + 7] = mass;
1166
1206
  }
1167
1207
  clear() {
1168
1208
  this.count = 0;
@@ -1192,6 +1232,28 @@ class ParticleStore {
1192
1232
  },
1193
1233
  };
1194
1234
  }
1235
+ getFloatsPerParticle() {
1236
+ return this.floatsPerParticle;
1237
+ }
1238
+ /**
1239
+ * Write a full particle record at index into GPU storage.
1240
+ */
1241
+ syncParticleToGPU(resources, index) {
1242
+ if (index < 0 || index >= this.count)
1243
+ return;
1244
+ const base = index * this.floatsPerParticle;
1245
+ const slice = this.data.subarray(base, base + this.floatsPerParticle);
1246
+ resources.writeParticleSlice(base, slice);
1247
+ }
1248
+ /**
1249
+ * Write a single mass value at index into GPU storage.
1250
+ */
1251
+ syncParticleMassToGPU(resources, index) {
1252
+ if (index < 0 || index >= this.count)
1253
+ return;
1254
+ const offset = index * this.floatsPerParticle + 7;
1255
+ resources.writeParticleSlice(offset, new Float32Array([this.data[offset]]));
1256
+ }
1195
1257
  /**
1196
1258
  * Writes the currently active particle slice to the GPU particle buffer.
1197
1259
  * Assumes the GPU storage buffer has already been created with matching capacity.
@@ -3437,6 +3499,219 @@ class RenderPipeline {
3437
3499
  }
3438
3500
  }
3439
3501
 
3502
+ /**
3503
+ * LocalQuery
3504
+ *
3505
+ * A small WebGPU compute pipeline used to query a bounded set of particles
3506
+ * in a region without doing a full GPU→CPU readback of the entire particle buffer.
3507
+ *
3508
+ * This is used by tool-like features (brush/pin/remove) that need local occupancy.
3509
+ */
3510
+ class LocalQuery {
3511
+ constructor() {
3512
+ Object.defineProperty(this, "pipeline", {
3513
+ enumerable: true,
3514
+ configurable: true,
3515
+ writable: true,
3516
+ value: null
3517
+ });
3518
+ Object.defineProperty(this, "uniform", {
3519
+ enumerable: true,
3520
+ configurable: true,
3521
+ writable: true,
3522
+ value: null
3523
+ });
3524
+ Object.defineProperty(this, "count", {
3525
+ enumerable: true,
3526
+ configurable: true,
3527
+ writable: true,
3528
+ value: null
3529
+ });
3530
+ Object.defineProperty(this, "out", {
3531
+ enumerable: true,
3532
+ configurable: true,
3533
+ writable: true,
3534
+ value: null
3535
+ });
3536
+ Object.defineProperty(this, "outIdx", {
3537
+ enumerable: true,
3538
+ configurable: true,
3539
+ writable: true,
3540
+ value: null
3541
+ });
3542
+ Object.defineProperty(this, "capacity", {
3543
+ enumerable: true,
3544
+ configurable: true,
3545
+ writable: true,
3546
+ value: 0
3547
+ });
3548
+ Object.defineProperty(this, "device", {
3549
+ enumerable: true,
3550
+ configurable: true,
3551
+ writable: true,
3552
+ value: null
3553
+ });
3554
+ }
3555
+ dispose() {
3556
+ this.pipeline = null;
3557
+ this.uniform?.destroy();
3558
+ this.count?.destroy();
3559
+ this.out?.destroy();
3560
+ this.outIdx?.destroy();
3561
+ this.uniform = null;
3562
+ this.count = null;
3563
+ this.out = null;
3564
+ this.outIdx = null;
3565
+ this.capacity = 0;
3566
+ this.device = null;
3567
+ }
3568
+ ensure(resources, maxResults) {
3569
+ const device = resources.getDevice();
3570
+ // If device changed (runtime toggle / recreate), rebuild everything.
3571
+ if (this.device && this.device !== device) {
3572
+ this.dispose();
3573
+ }
3574
+ this.device = device;
3575
+ // (Re)allocate buffers if capacity changed
3576
+ if (this.capacity !== maxResults) {
3577
+ this.capacity = maxResults;
3578
+ this.uniform?.destroy();
3579
+ this.count?.destroy();
3580
+ this.out?.destroy();
3581
+ this.outIdx?.destroy();
3582
+ this.uniform = device.createBuffer({
3583
+ size: 8 * 4,
3584
+ usage: GPUBufferUsage.UNIFORM | GPUBufferUsage.COPY_DST,
3585
+ });
3586
+ this.count = device.createBuffer({
3587
+ size: 4,
3588
+ usage: GPUBufferUsage.STORAGE |
3589
+ GPUBufferUsage.COPY_DST |
3590
+ GPUBufferUsage.COPY_SRC,
3591
+ });
3592
+ this.out = device.createBuffer({
3593
+ size: maxResults * 16,
3594
+ usage: GPUBufferUsage.STORAGE | GPUBufferUsage.COPY_SRC,
3595
+ });
3596
+ this.outIdx = device.createBuffer({
3597
+ size: maxResults * 4,
3598
+ usage: GPUBufferUsage.STORAGE | GPUBufferUsage.COPY_SRC,
3599
+ });
3600
+ }
3601
+ if (this.pipeline)
3602
+ return;
3603
+ const code = `
3604
+ struct Particle {
3605
+ position: vec2<f32>,
3606
+ velocity: vec2<f32>,
3607
+ acceleration: vec2<f32>,
3608
+ size: f32,
3609
+ mass: f32,
3610
+ color: vec4<f32>,
3611
+ };
3612
+
3613
+ struct QueryUniforms {
3614
+ v0: vec4<f32>, // center.x, center.y, radius, maxResults (as f32)
3615
+ v1: vec4<f32>, // particleCount, 0,0,0
3616
+ };
3617
+
3618
+ @group(0) @binding(0) var<storage, read> particles: array<Particle>;
3619
+ @group(0) @binding(1) var<uniform> query: QueryUniforms;
3620
+ @group(0) @binding(2) var<storage, read_write> outCount: atomic<u32>;
3621
+ @group(0) @binding(3) var<storage, read_write> outData: array<vec4<f32>>;
3622
+ @group(0) @binding(4) var<storage, read_write> outIndex: array<u32>;
3623
+
3624
+ @compute @workgroup_size(256)
3625
+ fn main(@builtin(global_invocation_id) gid: vec3<u32>) {
3626
+ let i = gid.x;
3627
+ let count = u32(query.v1.x);
3628
+ if (i >= count) { return; }
3629
+
3630
+ let p = particles[i];
3631
+ if (p.mass == 0.0) { return; }
3632
+
3633
+ let cx = query.v0.x;
3634
+ let cy = query.v0.y;
3635
+ let radius = query.v0.z;
3636
+ let maxResults = u32(query.v0.w);
3637
+
3638
+ // Disc-intersection semantics: dist <= radius + p.size
3639
+ let dx = p.position.x - cx;
3640
+ let dy = p.position.y - cy;
3641
+ let rr = radius + p.size;
3642
+ if (dx * dx + dy * dy > rr * rr) { return; }
3643
+
3644
+ let outIdx = atomicAdd(&outCount, 1u);
3645
+ if (outIdx >= maxResults) { return; }
3646
+ outData[outIdx] = vec4<f32>(p.position.x, p.position.y, p.size, p.mass);
3647
+ outIndex[outIdx] = i;
3648
+ }
3649
+ `;
3650
+ this.pipeline = device.createComputePipeline({
3651
+ layout: "auto",
3652
+ compute: { module: device.createShaderModule({ code }), entryPoint: "main" },
3653
+ });
3654
+ }
3655
+ async getParticlesInRadius(resources, center, radius, particleCount, opts) {
3656
+ const maxResults = Math.max(1, Math.floor(opts?.maxResults ?? 20000));
3657
+ this.ensure(resources, maxResults);
3658
+ const device = resources.getDevice();
3659
+ const particleBuffer = resources.getParticleBuffer();
3660
+ if (!particleBuffer)
3661
+ return { particles: [], truncated: false };
3662
+ // Reset atomic counter
3663
+ device.queue.writeBuffer(this.count, 0, new Uint32Array([0]));
3664
+ // Uniform: v0 = [cx, cy, radius, maxResults], v1 = [particleCount, 0, 0, 0]
3665
+ const u = new Float32Array(8);
3666
+ u[0] = center.x;
3667
+ u[1] = center.y;
3668
+ u[2] = radius;
3669
+ u[3] = maxResults;
3670
+ u[4] = particleCount;
3671
+ device.queue.writeBuffer(this.uniform, 0, u);
3672
+ const bindGroup = device.createBindGroup({
3673
+ layout: this.pipeline.getBindGroupLayout(0),
3674
+ entries: [
3675
+ { binding: 0, resource: { buffer: particleBuffer } },
3676
+ { binding: 1, resource: { buffer: this.uniform } },
3677
+ { binding: 2, resource: { buffer: this.count } },
3678
+ { binding: 3, resource: { buffer: this.out } },
3679
+ { binding: 4, resource: { buffer: this.outIdx } },
3680
+ ],
3681
+ });
3682
+ const encoder = device.createCommandEncoder();
3683
+ const pass = encoder.beginComputePass();
3684
+ pass.setPipeline(this.pipeline);
3685
+ pass.setBindGroup(0, bindGroup);
3686
+ const wg = 256;
3687
+ const n = Math.max(0, Math.floor(particleCount));
3688
+ pass.dispatchWorkgroups(Math.ceil(n / wg));
3689
+ pass.end();
3690
+ device.queue.submit([encoder.finish()]);
3691
+ const countBuf = await resources.readBuffer(this.count, 4);
3692
+ const found = new Uint32Array(countBuf)[0] ?? 0;
3693
+ const truncated = found > maxResults;
3694
+ const readCount = Math.min(found, maxResults);
3695
+ if (readCount === 0)
3696
+ return { particles: [], truncated };
3697
+ const idxBuf = await resources.readBuffer(this.outIdx, readCount * 4);
3698
+ const indices = new Uint32Array(idxBuf);
3699
+ const outBuf = await resources.readBuffer(this.out, readCount * 16);
3700
+ const outFloats = new Float32Array(outBuf);
3701
+ const particles = [];
3702
+ for (let i = 0; i < readCount; i++) {
3703
+ const base = i * 4;
3704
+ particles.push({
3705
+ index: indices[i] ?? 0,
3706
+ position: { x: outFloats[base + 0], y: outFloats[base + 1] },
3707
+ size: outFloats[base + 2],
3708
+ mass: outFloats[base + 3],
3709
+ });
3710
+ }
3711
+ return { particles, truncated };
3712
+ }
3713
+ }
3714
+
3440
3715
  class WebGPUEngine extends AbstractEngine {
3441
3716
  constructor(options) {
3442
3717
  super({
@@ -3515,6 +3790,12 @@ class WebGPUEngine extends AbstractEngine {
3515
3790
  writable: true,
3516
3791
  value: false
3517
3792
  });
3793
+ Object.defineProperty(this, "localQuery", {
3794
+ enumerable: true,
3795
+ configurable: true,
3796
+ writable: true,
3797
+ value: void 0
3798
+ });
3518
3799
  Object.defineProperty(this, "animate", {
3519
3800
  enumerable: true,
3520
3801
  configurable: true,
@@ -3584,6 +3865,7 @@ class WebGPUEngine extends AbstractEngine {
3584
3865
  this.sim = new SimulationPipeline();
3585
3866
  this.render = new RenderPipeline();
3586
3867
  this.grid = new SpacialGrid(this.cellSize);
3868
+ this.localQuery = new LocalQuery();
3587
3869
  }
3588
3870
  async initialize() {
3589
3871
  await this.resources.initialize();
@@ -3640,6 +3922,7 @@ class WebGPUEngine extends AbstractEngine {
3640
3922
  cancelAnimationFrame(this.animationId);
3641
3923
  this.animationId = null;
3642
3924
  }
3925
+ this.localQuery.dispose();
3643
3926
  await this.resources.dispose();
3644
3927
  }
3645
3928
  // Override setSize to also update WebGPU-specific resources
@@ -3663,12 +3946,24 @@ class WebGPUEngine extends AbstractEngine {
3663
3946
  this.updateMaxSize(particle.size);
3664
3947
  }
3665
3948
  }
3666
- async addParticle(p) {
3667
- await this.particles.syncFromGPU(this.resources);
3668
- this.particles.addParticle(p);
3669
- this.particles.syncToGPU(this.resources);
3949
+ addParticle(p) {
3950
+ const index = this.particles.addParticle(p);
3951
+ if (index < 0)
3952
+ return -1;
3953
+ // Push only the new particle record to GPU (no full-scene readback).
3954
+ this.particles.syncParticleToGPU(this.resources, index);
3670
3955
  // Update maxSize tracking
3671
3956
  this.updateMaxSize(p.size);
3957
+ return index;
3958
+ }
3959
+ setParticle(index, p) {
3960
+ this.particles.setParticle(index, p);
3961
+ this.particles.syncParticleToGPU(this.resources, index);
3962
+ this.updateMaxSize(p.size);
3963
+ }
3964
+ setParticleMass(index, mass) {
3965
+ this.particles.setParticleMass(index, mass);
3966
+ this.particles.syncParticleMassToGPU(this.resources, index);
3672
3967
  }
3673
3968
  /**
3674
3969
  * Forces GPU-to-CPU synchronization and returns current particle data.
@@ -3682,6 +3977,9 @@ class WebGPUEngine extends AbstractEngine {
3682
3977
  await this.particles.syncFromGPU(this.resources);
3683
3978
  return this.particles.getParticle(index);
3684
3979
  }
3980
+ async getParticlesInRadius(center, radius, opts) {
3981
+ return await this.localQuery.getParticlesInRadius(this.resources, center, radius, this.getCount(), opts);
3982
+ }
3685
3983
  getCount() {
3686
3984
  const actualCount = this.particles.getCount();
3687
3985
  if (this.maxParticles === null) {
@@ -4298,6 +4596,12 @@ class CPUEngine extends AbstractEngine {
4298
4596
  writable: true,
4299
4597
  value: null
4300
4598
  });
4599
+ Object.defineProperty(this, "particleIdToIndex", {
4600
+ enumerable: true,
4601
+ configurable: true,
4602
+ writable: true,
4603
+ value: new Map()
4604
+ });
4301
4605
  Object.defineProperty(this, "animate", {
4302
4606
  enumerable: true,
4303
4607
  configurable: true,
@@ -4372,6 +4676,7 @@ class CPUEngine extends AbstractEngine {
4372
4676
  this.fpsEstimate = 60;
4373
4677
  // Reset maxSize tracking
4374
4678
  this.resetMaxSize();
4679
+ this.particleIdToIndex.clear();
4375
4680
  }
4376
4681
  getCount() {
4377
4682
  const actualCount = this.particles.length;
@@ -4395,11 +4700,33 @@ class CPUEngine extends AbstractEngine {
4395
4700
  for (const p of particle) {
4396
4701
  this.updateMaxSize(p.size);
4397
4702
  }
4703
+ this.particleIdToIndex.clear();
4398
4704
  }
4399
4705
  addParticle(particle) {
4706
+ const index = this.particles.length;
4400
4707
  this.particles.push(new Particle(particle));
4401
4708
  // Update maxSize tracking
4402
4709
  this.updateMaxSize(particle.size);
4710
+ const created = this.particles[index];
4711
+ if (created)
4712
+ this.particleIdToIndex.set(created.id, index);
4713
+ return index;
4714
+ }
4715
+ setParticle(index, p) {
4716
+ if (index < 0)
4717
+ return;
4718
+ if (index >= this.particles.length)
4719
+ return;
4720
+ this.particles[index] = new Particle(p);
4721
+ // Best-effort maxSize tracking (monotonic)
4722
+ this.updateMaxSize(p.size);
4723
+ }
4724
+ setParticleMass(index, mass) {
4725
+ if (index < 0)
4726
+ return;
4727
+ if (index >= this.particles.length)
4728
+ return;
4729
+ this.particles[index].mass = mass;
4403
4730
  }
4404
4731
  getParticles() {
4405
4732
  return Promise.resolve(this.particles.map((p) => p.toJSON()));
@@ -4407,10 +4734,47 @@ class CPUEngine extends AbstractEngine {
4407
4734
  getParticle(index) {
4408
4735
  return Promise.resolve(this.particles[index]);
4409
4736
  }
4737
+ async getParticlesInRadius(center, radius, opts) {
4738
+ const maxResults = Math.max(1, Math.floor(opts?.maxResults ?? 20000));
4739
+ // Expand search radius to ensure we can find large particles whose discs
4740
+ // intersect the query circle: dist <= radius + p.size.
4741
+ const searchRadius = Math.max(0, radius) + this.getMaxSize();
4742
+ // Use the existing spatial grid (built during the last simulation tick).
4743
+ // Snapshot semantics: this is "as of last grid build" which is good enough
4744
+ // for tool usage and avoids global scans.
4745
+ const neighbors = this.grid.getParticles(new Vector(center.x, center.y), searchRadius,
4746
+ // Ask for up to maxResults+1 so we can mark truncated more reliably.
4747
+ maxResults + 1);
4748
+ const out = [];
4749
+ const r = Math.max(0, radius);
4750
+ for (const p of neighbors) {
4751
+ if (p.mass === 0)
4752
+ continue;
4753
+ const index = this.particleIdToIndex.get(p.id);
4754
+ if (index === undefined)
4755
+ continue;
4756
+ const dx = p.position.x - center.x;
4757
+ const dy = p.position.y - center.y;
4758
+ const rr = r + p.size;
4759
+ if (dx * dx + dy * dy <= rr * rr) {
4760
+ out.push({
4761
+ index,
4762
+ position: { x: p.position.x, y: p.position.y },
4763
+ size: p.size,
4764
+ mass: p.mass,
4765
+ });
4766
+ if (out.length >= maxResults + 1)
4767
+ break;
4768
+ }
4769
+ }
4770
+ const truncated = out.length > maxResults;
4771
+ return { particles: truncated ? out.slice(0, maxResults) : out, truncated };
4772
+ }
4410
4773
  destroy() {
4411
4774
  this.pause();
4412
4775
  this.particles = [];
4413
4776
  this.grid.clear();
4777
+ this.particleIdToIndex.clear();
4414
4778
  return Promise.resolve();
4415
4779
  }
4416
4780
  // Handle configuration changes
@@ -4457,8 +4821,10 @@ class CPUEngine extends AbstractEngine {
4457
4821
  // Update spatial grid with current particle positions and camera
4458
4822
  this.grid.setCamera(this.view.getCamera().x, this.view.getCamera().y, this.view.getZoom());
4459
4823
  this.grid.clear();
4824
+ this.particleIdToIndex.clear();
4460
4825
  for (let i = 0; i < effectiveCount; i++) {
4461
4826
  this.grid.insert(this.particles[i]);
4827
+ this.particleIdToIndex.set(this.particles[i].id, i);
4462
4828
  }
4463
4829
  // Global state for modules that need it
4464
4830
  const globalState = {};
@@ -4948,7 +5314,13 @@ class Engine {
4948
5314
  this.engine.setParticles(p);
4949
5315
  }
4950
5316
  addParticle(p) {
4951
- this.engine.addParticle(p);
5317
+ return this.engine.addParticle(p);
5318
+ }
5319
+ setParticle(index, p) {
5320
+ this.engine.setParticle(index, p);
5321
+ }
5322
+ setParticleMass(index, mass) {
5323
+ this.engine.setParticleMass(index, mass);
4952
5324
  }
4953
5325
  getParticles() {
4954
5326
  return this.engine.getParticles();
@@ -4956,6 +5328,9 @@ class Engine {
4956
5328
  getParticle(index) {
4957
5329
  return this.engine.getParticle(index);
4958
5330
  }
5331
+ getParticlesInRadius(center, radius, opts) {
5332
+ return this.engine.getParticlesInRadius(center, radius, opts);
5333
+ }
4959
5334
  // Helpers for pinning/unpinning
4960
5335
  async pinParticles(indexes) {
4961
5336
  const particles = await this.getParticles();
@@ -5056,6 +5431,7 @@ class Engine {
5056
5431
  }
5057
5432
  }
5058
5433
 
5434
+ const TEXT_SPAWNER_FONTS = ["sans-serif", "serif", "monospace"];
5059
5435
  function calculateVelocity(position, center, cfg) {
5060
5436
  if (!cfg || cfg.speed === 0)
5061
5437
  return { vx: 0, vy: 0 };
@@ -5097,7 +5473,7 @@ function calculateVelocity(position, center, cfg) {
5097
5473
  }
5098
5474
  class Spawner {
5099
5475
  initParticles(options) {
5100
- const { count, shape, center, spacing = 25, radius = 100, innerRadius = 50, squareSize = 200, cornerRadius = 0, size = 5, mass = 1, bounds, velocity, colors, } = options;
5476
+ const { count, shape, center, spacing = 25, radius = 100, innerRadius = 50, squareSize = 200, cornerRadius = 0, size = 5, mass = 1, bounds, velocity, colors, text = "Party", font = "sans-serif", textSize = 64, position, align, } = options;
5101
5477
  const particles = [];
5102
5478
  if (count <= 0)
5103
5479
  return particles;
@@ -5124,6 +5500,93 @@ class Spawner {
5124
5500
  return { r: 1, g: 1, b: 1, a: 1 };
5125
5501
  return toColor(colors[Math.floor(Math.random() * colors.length)]);
5126
5502
  };
5503
+ if (shape === "text") {
5504
+ const textValue = text.trim();
5505
+ if (!textValue)
5506
+ return particles;
5507
+ const createCanvas = () => {
5508
+ if (typeof OffscreenCanvas !== "undefined") {
5509
+ return new OffscreenCanvas(1, 1);
5510
+ }
5511
+ if (typeof document !== "undefined") {
5512
+ return document.createElement("canvas");
5513
+ }
5514
+ return null;
5515
+ };
5516
+ const canvas = createCanvas();
5517
+ if (!canvas)
5518
+ return particles;
5519
+ const ctx = canvas.getContext("2d");
5520
+ if (!ctx)
5521
+ return particles;
5522
+ const fontSize = Math.max(1, Math.floor(textSize));
5523
+ const fontSpec = `${fontSize}px ${font}`;
5524
+ ctx.font = fontSpec;
5525
+ ctx.textBaseline = "alphabetic";
5526
+ ctx.textAlign = "left";
5527
+ const metrics = ctx.measureText(textValue);
5528
+ const ascent = metrics.actualBoundingBoxAscent || fontSize;
5529
+ const descent = metrics.actualBoundingBoxDescent || fontSize * 0.25;
5530
+ const left = metrics.actualBoundingBoxLeft || 0;
5531
+ const right = metrics.actualBoundingBoxRight || metrics.width;
5532
+ const textWidth = Math.max(1, Math.ceil(left + right));
5533
+ const textHeight = Math.max(1, Math.ceil(ascent + descent));
5534
+ const padding = Math.ceil(fontSize * 0.25);
5535
+ canvas.width = textWidth + padding * 2;
5536
+ canvas.height = textHeight + padding * 2;
5537
+ ctx.clearRect(0, 0, canvas.width, canvas.height);
5538
+ ctx.font = fontSpec;
5539
+ ctx.textBaseline = "alphabetic";
5540
+ ctx.textAlign = "left";
5541
+ ctx.fillStyle = "#ffffff";
5542
+ ctx.fillText(textValue, padding + left, padding + ascent);
5543
+ const imageData = ctx.getImageData(0, 0, canvas.width, canvas.height);
5544
+ const data = imageData.data;
5545
+ const sampleStep = Math.max(1, Math.round(size));
5546
+ const points = [];
5547
+ for (let y = 0; y < canvas.height; y += sampleStep) {
5548
+ for (let x = 0; x < canvas.width; x += sampleStep) {
5549
+ const idx = (y * canvas.width + x) * 4 + 3;
5550
+ if (data[idx] > 0) {
5551
+ points.push({ x, y });
5552
+ }
5553
+ }
5554
+ }
5555
+ const maxCount = Math.min(count, points.length);
5556
+ if (maxCount <= 0)
5557
+ return particles;
5558
+ const textPosition = position ?? center;
5559
+ const horizontal = align?.horizontal ?? "center";
5560
+ const vertical = align?.vertical ?? "center";
5561
+ const originX = horizontal === "left"
5562
+ ? textPosition.x
5563
+ : horizontal === "right"
5564
+ ? textPosition.x - textWidth
5565
+ : textPosition.x - textWidth / 2;
5566
+ const originY = vertical === "top"
5567
+ ? textPosition.y
5568
+ : vertical === "bottom"
5569
+ ? textPosition.y - textHeight
5570
+ : textPosition.y - textHeight / 2;
5571
+ const stride = points.length / maxCount;
5572
+ for (let i = 0; i < maxCount; i++) {
5573
+ const idx = Math.floor(i * stride);
5574
+ const point = points[idx];
5575
+ if (!point)
5576
+ continue;
5577
+ const x = originX + (point.x - padding);
5578
+ const y = originY + (point.y - padding);
5579
+ const { vx, vy } = calculateVelocity({ x, y }, textPosition, velocity);
5580
+ particles.push({
5581
+ position: { x, y },
5582
+ velocity: { x: vx, y: vy },
5583
+ size,
5584
+ mass,
5585
+ color: getColor(),
5586
+ });
5587
+ }
5588
+ return particles;
5589
+ }
5127
5590
  if (shape === "grid") {
5128
5591
  const cols = Math.ceil(Math.sqrt(count));
5129
5592
  const rows = Math.ceil(count / cols);
@@ -10017,5 +10480,5 @@ class Particles extends Module {
10017
10480
  }
10018
10481
  }
10019
10482
 
10020
- export { AbstractEngine, Behavior, Boundary, CanvasComposition, Collisions, DEFAULT_BEHAVIOR_ALIGNMENT, DEFAULT_BEHAVIOR_AVOID, DEFAULT_BEHAVIOR_CHASE, DEFAULT_BEHAVIOR_COHESION, DEFAULT_BEHAVIOR_REPULSION, DEFAULT_BEHAVIOR_SEPARATION, DEFAULT_BEHAVIOR_VIEW_ANGLE, DEFAULT_BEHAVIOR_VIEW_RADIUS, DEFAULT_BEHAVIOR_WANDER, DEFAULT_BOUNDARY_FRICTION, DEFAULT_BOUNDARY_MODE, DEFAULT_BOUNDARY_REPEL_DISTANCE, DEFAULT_BOUNDARY_REPEL_STRENGTH, DEFAULT_BOUNDARY_RESTITUTION, DEFAULT_COLLISIONS_RESTITUTION, DEFAULT_ENVIRONMENT_DAMPING, DEFAULT_ENVIRONMENT_FRICTION, DEFAULT_ENVIRONMENT_GRAVITY_ANGLE, DEFAULT_ENVIRONMENT_GRAVITY_DIRECTION, DEFAULT_ENVIRONMENT_GRAVITY_STRENGTH, DEFAULT_ENVIRONMENT_INERTIA, DEFAULT_FLUIDS_ENABLE_NEAR_PRESSURE, DEFAULT_FLUIDS_INFLUENCE_RADIUS, DEFAULT_FLUIDS_MAX_ACCELERATION, DEFAULT_FLUIDS_NEAR_PRESSURE_MULTIPLIER, DEFAULT_FLUIDS_NEAR_THRESHOLD, DEFAULT_FLUIDS_PRESSURE_MULTIPLIER, DEFAULT_FLUIDS_TARGET_DENSITY, DEFAULT_FLUIDS_VISCOSITY, DEFAULT_GRAB_GRABBED_INDEX, DEFAULT_GRAB_POSITION_X, DEFAULT_GRAB_POSITION_Y, DEFAULT_INTERACTION_MODE, DEFAULT_INTERACTION_RADIUS, DEFAULT_INTERACTION_STRENGTH, DEFAULT_JOINTS_ENABLE_JOINT_COLLISIONS, DEFAULT_JOINTS_ENABLE_PARTICLE_COLLISIONS, DEFAULT_JOINTS_FRICTION, DEFAULT_JOINTS_MOMENTUM, DEFAULT_JOINTS_RESTITUTION, DEFAULT_JOINTS_SEPARATION, DEFAULT_JOINTS_STEPS, DEFAULT_PICFLIP_DENSITY, DEFAULT_PICFLIP_FLIP_RATIO, DEFAULT_PICFLIP_INFLUENCE_RADIUS, DEFAULT_PICFLIP_INTERNAL_MAX_ACCELERATION, DEFAULT_PICFLIP_PRESSURE, DEFAULT_PICFLIP_PRESSURE_MULTIPLIER, DEFAULT_PICFLIP_RADIUS, DEFAULT_PICFLIP_TARGET_DENSITY, DEFAULT_SENSORS_COLOR_SIMILARITY_THRESHOLD, DEFAULT_SENSORS_FLEE_ANGLE, DEFAULT_SENSORS_FLEE_BEHAVIOR, DEFAULT_SENSORS_FOLLOW_BEHAVIOR, DEFAULT_SENSORS_SENSOR_ANGLE, DEFAULT_SENSORS_SENSOR_DISTANCE, DEFAULT_SENSORS_SENSOR_RADIUS, DEFAULT_SENSORS_SENSOR_STRENGTH, DEFAULT_SENSORS_SENSOR_THRESHOLD, DEFAULT_TRAILS_TRAIL_DECAY, DEFAULT_TRAILS_TRAIL_DIFFUSE, DataType, Engine, Environment, Fluids, FluidsMethod, Grab, Interaction, Joints, Lines, Module, ModuleRole, OscillatorManager, Particles, ParticlesColorType, RenderPassKind, Sensors, Spawner, Trails, Vector, degToRad, radToDeg };
10483
+ export { AbstractEngine, Behavior, Boundary, CanvasComposition, Collisions, DEFAULT_BEHAVIOR_ALIGNMENT, DEFAULT_BEHAVIOR_AVOID, DEFAULT_BEHAVIOR_CHASE, DEFAULT_BEHAVIOR_COHESION, DEFAULT_BEHAVIOR_REPULSION, DEFAULT_BEHAVIOR_SEPARATION, DEFAULT_BEHAVIOR_VIEW_ANGLE, DEFAULT_BEHAVIOR_VIEW_RADIUS, DEFAULT_BEHAVIOR_WANDER, DEFAULT_BOUNDARY_FRICTION, DEFAULT_BOUNDARY_MODE, DEFAULT_BOUNDARY_REPEL_DISTANCE, DEFAULT_BOUNDARY_REPEL_STRENGTH, DEFAULT_BOUNDARY_RESTITUTION, DEFAULT_COLLISIONS_RESTITUTION, DEFAULT_ENVIRONMENT_DAMPING, DEFAULT_ENVIRONMENT_FRICTION, DEFAULT_ENVIRONMENT_GRAVITY_ANGLE, DEFAULT_ENVIRONMENT_GRAVITY_DIRECTION, DEFAULT_ENVIRONMENT_GRAVITY_STRENGTH, DEFAULT_ENVIRONMENT_INERTIA, DEFAULT_FLUIDS_ENABLE_NEAR_PRESSURE, DEFAULT_FLUIDS_INFLUENCE_RADIUS, DEFAULT_FLUIDS_MAX_ACCELERATION, DEFAULT_FLUIDS_NEAR_PRESSURE_MULTIPLIER, DEFAULT_FLUIDS_NEAR_THRESHOLD, DEFAULT_FLUIDS_PRESSURE_MULTIPLIER, DEFAULT_FLUIDS_TARGET_DENSITY, DEFAULT_FLUIDS_VISCOSITY, DEFAULT_GRAB_GRABBED_INDEX, DEFAULT_GRAB_POSITION_X, DEFAULT_GRAB_POSITION_Y, DEFAULT_INTERACTION_MODE, DEFAULT_INTERACTION_RADIUS, DEFAULT_INTERACTION_STRENGTH, DEFAULT_JOINTS_ENABLE_JOINT_COLLISIONS, DEFAULT_JOINTS_ENABLE_PARTICLE_COLLISIONS, DEFAULT_JOINTS_FRICTION, DEFAULT_JOINTS_MOMENTUM, DEFAULT_JOINTS_RESTITUTION, DEFAULT_JOINTS_SEPARATION, DEFAULT_JOINTS_STEPS, DEFAULT_PICFLIP_DENSITY, DEFAULT_PICFLIP_FLIP_RATIO, DEFAULT_PICFLIP_INFLUENCE_RADIUS, DEFAULT_PICFLIP_INTERNAL_MAX_ACCELERATION, DEFAULT_PICFLIP_PRESSURE, DEFAULT_PICFLIP_PRESSURE_MULTIPLIER, DEFAULT_PICFLIP_RADIUS, DEFAULT_PICFLIP_TARGET_DENSITY, DEFAULT_SENSORS_COLOR_SIMILARITY_THRESHOLD, DEFAULT_SENSORS_FLEE_ANGLE, DEFAULT_SENSORS_FLEE_BEHAVIOR, DEFAULT_SENSORS_FOLLOW_BEHAVIOR, DEFAULT_SENSORS_SENSOR_ANGLE, DEFAULT_SENSORS_SENSOR_DISTANCE, DEFAULT_SENSORS_SENSOR_RADIUS, DEFAULT_SENSORS_SENSOR_STRENGTH, DEFAULT_SENSORS_SENSOR_THRESHOLD, DEFAULT_TRAILS_TRAIL_DECAY, DEFAULT_TRAILS_TRAIL_DIFFUSE, DataType, Engine, Environment, Fluids, FluidsMethod, Grab, Interaction, Joints, Lines, Module, ModuleRole, OscillatorManager, Particles, ParticlesColorType, RenderPassKind, Sensors, Spawner, TEXT_SPAWNER_FONTS, Trails, Vector, degToRad, radToDeg };
10021
10484
  //# sourceMappingURL=index.js.map