pts 1.0.0 → 1.0.1

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pts",
3
- "version": "1.0.0",
3
+ "version": "1.0.1",
4
4
  "description": "A library for visualization and creative coding.",
5
5
  "type": "commonjs",
6
6
  "main": "./dist/index.js",
@@ -62,7 +62,8 @@
62
62
  "prepack": "pnpm build",
63
63
  "docs": "node scripts/build-docs-runtime.mjs && node scripts/generate-docs.mjs && node scripts/generate-guides.mjs && node scripts/generate-markdown-docs.mjs",
64
64
  "build:docs-runtime": "node scripts/build-docs-runtime.mjs",
65
- "build:editor": "node scripts/build-editor.mjs"
65
+ "build:editor": "node scripts/build-editor.mjs",
66
+ "test:path:fuzz": "node scripts/fuzz-path.mjs"
66
67
  },
67
68
  "keywords": [
68
69
  "canvas",
package/src/Canvas.ts CHANGED
@@ -1296,8 +1296,44 @@ export class CanvasForm<
1296
1296
  * @param pts a Group or an Iterable<PtLike> representing a line
1297
1297
  */
1298
1298
  line(pts: PtLikeIterable): this {
1299
- CanvasForm.line(this._ctx, pts);
1300
- this._paint();
1299
+ const p = Util.iterToArray(pts);
1300
+ if (Util.arrayCheck(p)) {
1301
+ CanvasForm.line(this._ctx, p);
1302
+ this._paint();
1303
+ }
1304
+ return this;
1305
+ }
1306
+
1307
+ /**
1308
+ * A static function to draw a chain of cubic Bezier curves as one native path.
1309
+ * @param ctx canvas rendering context
1310
+ * @param pts a Group or an Iterable<PtLike> in the layout of [`Curve.bezier`](#link): an anchor followed by 2 control points and an anchor per segment
1311
+ */
1312
+ static bezier(ctx: RenderingContext2D, pts: PtLikeIterable) {
1313
+ const p = Util.iterToArray(pts);
1314
+ if (p.length < 4) return;
1315
+ ctx.beginPath();
1316
+ ctx.moveTo(p[0][0], p[0][1]);
1317
+ for (let i = 3; i < p.length; i += 3) {
1318
+ const c1 = p[i - 2];
1319
+ const c2 = p[i - 1];
1320
+ ctx.bezierCurveTo(c1[0], c1[1], c2[0], c2[1], p[i][0], p[i][1]);
1321
+ }
1322
+ }
1323
+
1324
+ /**
1325
+ * Draw a chain of cubic Bezier curves as one native path. Unlike a polyline from [`Curve.bezier`](#link),
1326
+ * the path stays smooth at any zoom and exports as compact SVG. Use [`Curve.cardinalToBezier`](#link) or
1327
+ * [`Curve.bsplineToBezier`](#link) to draw those curves this way.
1328
+ * @param pts a Group or an Iterable<PtLike> in the layout of [`Curve.bezier`](#link): an anchor followed by 2 control points and an anchor per segment
1329
+ * @example `form.bezier( Curve.cardinalToBezier( pts ) )`
1330
+ */
1331
+ bezier(pts: PtLikeIterable): this {
1332
+ const p = Util.iterToArray(pts);
1333
+ if (Util.arrayCheck(p, 4)) {
1334
+ CanvasForm.bezier(this._ctx, p);
1335
+ this._paint();
1336
+ }
1301
1337
  return this;
1302
1338
  }
1303
1339
 
@@ -1317,8 +1353,67 @@ export class CanvasForm<
1317
1353
  * @param pts a Group or an Iterable<PtLike> representingg a polygon
1318
1354
  */
1319
1355
  polygon(pts: PtLikeIterable): this {
1320
- CanvasForm.polygon(this._ctx, pts);
1321
- this._paint();
1356
+ const p = Util.iterToArray(pts);
1357
+ if (Util.arrayCheck(p)) {
1358
+ CanvasForm.polygon(this._ctx, p);
1359
+ this._paint();
1360
+ }
1361
+ return this;
1362
+ }
1363
+
1364
+ /**
1365
+ * A static function to draw a compound polygon: several rings as one path, so that a ring inside another with the opposite orientation becomes a hole (the nonzero winding rule).
1366
+ * @param ctx canvas rendering context
1367
+ * @param rings an Array/Iterable of rings, each a Group or an Iterable<PtLike>; rings with fewer than 2 points are skipped
1368
+ */
1369
+ static compound(ctx: RenderingContext2D, rings: Iterable<PtLikeIterable>) {
1370
+ let started = false;
1371
+ for (const ring of rings) {
1372
+ const p = Util.iterToArray(ring);
1373
+ if (p.length < 2) continue;
1374
+ if (typeof p[0][0] !== "number") {
1375
+ // a list of polygons (such as a Path.divide result) instead of a list of rings
1376
+ Util.warn(
1377
+ "compound expects rings of points; draw each polygon of a divide or crop result separately",
1378
+ );
1379
+ return;
1380
+ }
1381
+ if (!started) {
1382
+ ctx.beginPath();
1383
+ started = true;
1384
+ }
1385
+ ctx.moveTo(p[0][0], p[0][1]);
1386
+ for (let i = 1, len = p.length; i < len; i++)
1387
+ ctx.lineTo(p[i][0], p[i][1]);
1388
+ ctx.closePath();
1389
+ }
1390
+ }
1391
+
1392
+ /**
1393
+ * Draw a compound polygon: several rings as one path, so that a ring inside another with the opposite orientation becomes a hole
1394
+ * (the nonzero winding rule). This is how a [`Path`](#link) result is drawn; [`CanvasForm.polygons`](#link) would fill the holes.
1395
+ * @param rings an Array/Iterable of rings, each a Group or an Iterable<PtLike>; rings with fewer than 2 points are skipped, and nothing is drawn if no ring remains
1396
+ * @example `form.fillOnly("#f03").compound( Path.minusFront( [disc, hole] ) )`
1397
+ */
1398
+ compound(rings: Iterable<PtLikeIterable>): this {
1399
+ const list: PtLike[][] = [];
1400
+ let drawable = false;
1401
+ for (const ring of rings) {
1402
+ const p = Util.iterToArray(ring);
1403
+ if (p.length > 0 && typeof p[0][0] !== "number") {
1404
+ // a list of polygons (such as a Path.divide result) instead of a list of rings
1405
+ return Util.warn(
1406
+ "compound expects rings of points; draw each polygon of a divide or crop result separately",
1407
+ this,
1408
+ );
1409
+ }
1410
+ if (p.length >= 2) drawable = true;
1411
+ list.push(p);
1412
+ }
1413
+ if (drawable) {
1414
+ CanvasForm.compound(this._ctx, list);
1415
+ this._paint();
1416
+ }
1322
1417
  return this;
1323
1418
  }
1324
1419
 
package/src/Create.ts CHANGED
@@ -1,6 +1,6 @@
1
1
  /*! Pts.js is licensed under Apache License 2.0. Copyright © 2017-current William Ngan and contributors. (https://github.com/williamngan/pts) */
2
2
 
3
- import { Pt, Group, type Bound } from "./Pt";
3
+ import { Pt, Group, Bound } from "./Pt";
4
4
  import { Line, Triangle } from "./Op";
5
5
  import { Const, Util } from "./Util";
6
6
  import { Num, Geom } from "./Num";
@@ -14,6 +14,7 @@ import {
14
14
  type DelaunayShape,
15
15
  type FlockBoundary,
16
16
  type FlockOptions,
17
+ type PoissonDiskOptions,
17
18
  } from "./Types";
18
19
 
19
20
  /**
@@ -190,6 +191,31 @@ export class Create {
190
191
  for (const p of pts) flock.addBoid(p);
191
192
  return flock;
192
193
  }
194
+
195
+ /**
196
+ * Create a set of Pts that are randomly placed but never closer than `radius` to each other,
197
+ * using Poisson-disk sampling (also called blue noise).
198
+ * Compared with [`Create.distributeRandom`](#link), the points avoid clumping.
199
+ * Sampling uses a finite candidate budget, so gaps can remain when it finishes.
200
+ * The returned [`PoissonDisk`](#link) is a complete Group; to grow a set gradually instead,
201
+ * construct a `PoissonDisk` and call its [`PoissonDisk.step`](#link) or [`PoissonDisk.sample`](#link).
202
+ * See a [demo here](https://ptsjs.org/demo/?name=create.sampling).
203
+ *
204
+ * Randomness comes from [`Num.random`](#link), so seeding with [`Num.seed`](#link) makes the set reproducible.
205
+ *
206
+ * @param bound the rectangular boundary
207
+ * @param radius minimum distance between any two points
208
+ * @param options optional [`PoissonDiskOptions`](#link)
209
+ * @returns an instance of the PoissonDisk class, which is a Group of Pts
210
+ * @example `Create.sampling( space.innerBound, 10 )`
211
+ */
212
+ static sampling(
213
+ bound: Bound,
214
+ radius: number,
215
+ options: PoissonDiskOptions = {},
216
+ ): PoissonDisk {
217
+ return new PoissonDisk().setup(bound, radius, options).sample();
218
+ }
193
219
  }
194
220
 
195
221
  /**
@@ -1545,3 +1571,264 @@ export class Flock extends Group {
1545
1571
  }
1546
1572
  }
1547
1573
  }
1574
+
1575
+ // Cell offsets in scan order for `PoissonDisk`, nearest first
1576
+ const __near = [0, -1, 1, -2, 2];
1577
+
1578
+ /**
1579
+ * PoissonDisk is a Group of Pts produced by Poisson-disk sampling: every point is at least
1580
+ * [`PoissonDisk.radius`](#link) away from every other. Create a finished set with
1581
+ * [`Create.sampling`](#link), or construct one directly and grow it with [`PoissonDisk.step`](#link)
1582
+ * (one point at a time) or [`PoissonDisk.sample`](#link) (a batch at a time), which is how the
1583
+ * [demo](https://ptsjs.org/demo/?name=create.sampling) shows the packing as it forms.
1584
+ *
1585
+ * The sampler is Bridson's grid-accelerated algorithm with Roberts' candidate placement: each
1586
+ * visit to an active point tries up to [`PoissonDisk.candidates`](#link) candidates on the circle
1587
+ * just outside `radius` around it, at evenly spaced angles from a random offset, and accepts the
1588
+ * first one with no existing point within `radius`. It runs in linear time on one small integer
1589
+ * grid. In a bound thinner than the radius, candidates take random positions across the thin
1590
+ * axis and alternate along the long one, since a circle of candidates would miss the strip.
1591
+ *
1592
+ * Three traits to know: most points sit just beyond `radius` from the point that spawned them,
1593
+ * which packs tighter than candidates at random distances; the candidate budget is finite, so
1594
+ * a finished set can still have gaps; and coordinates are compared as float32 (the precision of
1595
+ * a Pt), exact at pixel scales but rejecting some candidates when coordinates exceed roughly
1596
+ * 8000 times the radius.
1597
+ *
1598
+ * Treat the Group as read-only while sampling: pushing or moving its Pts by hand would
1599
+ * desynchronize the grid that enforces the spacing. A PoissonDisk produced by `map`, `filter`
1600
+ * or `slice` is a plain copy that reports radius 0 and `done`, and needs `setup` before sampling.
1601
+ */
1602
+ export class PoissonDisk extends Group {
1603
+ private _radius = 0;
1604
+ private _candidates = 8;
1605
+ // per-setup constants of the candidate placement, hoisted out of step()
1606
+ private _dist = 0; // radius with an allowance for float32 rounding
1607
+ private _cos = 1; // rotation between candidates
1608
+ private _sin = 0;
1609
+ private _narrowX = false; // the bound is thinner than a candidate circle across x
1610
+ private _narrowY = false; // or across y
1611
+ private _x0 = 0;
1612
+ private _y0 = 0;
1613
+ private _x1 = 0;
1614
+ private _y1 = 0;
1615
+ private _cell = 1;
1616
+ private _cols = 0;
1617
+ private _rows = 0;
1618
+ private _grid = new Int32Array(0); // one sample index per cell, or -1
1619
+ private _active: number[] = []; // indices of samples that may still spawn neighbors
1620
+
1621
+ /**
1622
+ * Reset this sampler and place its first sample. Calling `setup` again empties the group and
1623
+ * starts over, which is how a sketch restarts sampling after a resize.
1624
+ * @param bound the rectangular boundary
1625
+ * @param radius minimum distance between any two points
1626
+ * @param options optional [`PoissonDiskOptions`](#link)
1627
+ * @returns this
1628
+ * @example `new PoissonDisk().setup( space.innerBound, 12 ).sample( 40 )`
1629
+ */
1630
+ setup(bound: Bound, radius: number, options: PoissonDiskOptions = {}): this {
1631
+ if (!(radius > 0) || !Number.isFinite(radius)) {
1632
+ throw new Error("PoissonDisk radius must be a positive finite number");
1633
+ }
1634
+ const x0 = bound.x ?? NaN;
1635
+ const y0 = bound.y ?? NaN;
1636
+ const width = bound.width;
1637
+ const height = bound.height;
1638
+ const x1 = x0 + width;
1639
+ const y1 = y0 + height;
1640
+ if (![x0, y0, x1, y1].every(Number.isFinite) || width < 0 || height < 0) {
1641
+ throw new Error("PoissonDisk bound must have a finite position and size");
1642
+ }
1643
+ // A size can vanish when added to a far larger position (e.g. 1 at 1e20)
1644
+ if ((width > 0 && x1 <= x0) || (height > 0 && y1 <= y0)) {
1645
+ throw new Error(
1646
+ "PoissonDisk bound size must be representable at its position",
1647
+ );
1648
+ }
1649
+ const k = options.candidates ?? 8;
1650
+ if (!(k >= 1) || !Number.isFinite(k)) {
1651
+ throw new Error("PoissonDisk candidates must be a number of at least 1");
1652
+ }
1653
+
1654
+ // Each grid cell is small enough to hold at most one sample
1655
+ const cell = radius / Math.SQRT2;
1656
+ const hasArea = width > 0 && height > 0;
1657
+ const cols = hasArea ? Math.ceil(width / cell) : 0;
1658
+ const rows = hasArea ? Math.ceil(height / cell) : 0;
1659
+ if (cols * rows > 1 << 26) {
1660
+ throw new Error(
1661
+ "PoissonDisk radius is too small for this bound: the grid would exceed 2^26 cells",
1662
+ );
1663
+ }
1664
+
1665
+ this.length = 0;
1666
+ this._active.length = 0;
1667
+ this._radius = radius;
1668
+ this._candidates = Math.floor(k);
1669
+ this._x0 = x0;
1670
+ this._y0 = y0;
1671
+ this._x1 = x1;
1672
+ this._y1 = y1;
1673
+ this._cell = cell;
1674
+ this._cols = cols;
1675
+ this._rows = rows;
1676
+ this._grid = new Int32Array(cols * rows).fill(-1);
1677
+ this._dist = radius * 1.001; // allow for float32 rounding at ordinary canvas scales
1678
+ this._cos = Math.cos(Const.two_pi / this._candidates);
1679
+ this._sin = Math.sin(Const.two_pi / this._candidates);
1680
+ // A full circle of candidates can miss a strip thinner than `dist` entirely; there,
1681
+ // candidates take a random position across the strip and alternate along it instead.
1682
+ this._narrowX = width < this._dist && width <= height;
1683
+ this._narrowY = height < this._dist && !this._narrowX;
1684
+
1685
+ if (options.start !== undefined) {
1686
+ const s = options.start;
1687
+ if (!this._tryAdd(Math.fround(s[0]), Math.fround(s[1]))) {
1688
+ throw new Error(
1689
+ "PoissonDisk start point must lie on or after the bound's top-left edges and before its bottom-right edges",
1690
+ );
1691
+ }
1692
+ } else if (cols * rows > 0) {
1693
+ // Redraw on the rare float32 rounding that lands exactly on the far edge
1694
+ while (
1695
+ !this._tryAdd(
1696
+ Math.fround(x0 + Num.random() * width),
1697
+ Math.fround(y0 + Num.random() * height),
1698
+ )
1699
+ );
1700
+ }
1701
+ return this;
1702
+ }
1703
+
1704
+ /**
1705
+ * Minimum distance between any two points in this set.
1706
+ */
1707
+ get radius(): number {
1708
+ return this._radius;
1709
+ }
1710
+
1711
+ /**
1712
+ * Maximum candidates tried per visit to an active sample before retiring it if none succeed.
1713
+ */
1714
+ get candidates(): number {
1715
+ return this._candidates;
1716
+ }
1717
+
1718
+ /**
1719
+ * The rectangular boundary that the samples fill.
1720
+ */
1721
+ get bound(): Bound {
1722
+ return new Bound(new Pt(this._x0, this._y0), new Pt(this._x1, this._y1));
1723
+ }
1724
+
1725
+ /**
1726
+ * Whether no active samples remain. Gaps may still fit further points, but sampling has stopped.
1727
+ */
1728
+ get done(): boolean {
1729
+ return this._active.length === 0;
1730
+ }
1731
+
1732
+ /**
1733
+ * Add the next sample and return it, or return `undefined` once no active samples remain.
1734
+ * The new Pt is also the last element of this group.
1735
+ * @example `let p = pd.step(); if (p) form.point( p, 2 );`
1736
+ */
1737
+ step(): Pt | undefined {
1738
+ const active = this._active;
1739
+ const k = this._candidates;
1740
+ const dist = this._dist;
1741
+ const cos = this._cos;
1742
+ const sin = this._sin;
1743
+ const width = this._x1 - this._x0;
1744
+ const height = this._y1 - this._y0;
1745
+ const narrowX = this._narrowX;
1746
+ const narrowY = this._narrowY;
1747
+
1748
+ while (active.length > 0) {
1749
+ const ai = Math.floor(Num.random() * active.length);
1750
+ const p = this[active[ai]];
1751
+ const a0 = Num.random() * Const.two_pi;
1752
+ let dx = dist * Math.cos(a0);
1753
+ let dy = dist * Math.sin(a0);
1754
+ let along = a0 < Math.PI ? 1 : -1;
1755
+ for (let j = 0; j < k; j++) {
1756
+ if (narrowX) {
1757
+ dx = this._x0 + Num.random() * width - p[0];
1758
+ dy = along * Math.sqrt(Math.max(0, dist * dist - dx * dx));
1759
+ along = -along;
1760
+ } else if (narrowY) {
1761
+ dy = this._y0 + Num.random() * height - p[1];
1762
+ dx = along * Math.sqrt(Math.max(0, dist * dist - dy * dy));
1763
+ along = -along;
1764
+ } else if (j > 0) {
1765
+ const x = dx * cos - dy * sin;
1766
+ dy = dx * sin + dy * cos;
1767
+ dx = x;
1768
+ }
1769
+ if (this._tryAdd(Math.fround(p[0] + dx), Math.fround(p[1] + dy))) {
1770
+ return this[this.length - 1];
1771
+ }
1772
+ }
1773
+ // This visit exhausted its candidate budget: retire the sample
1774
+ active[ai] = active[active.length - 1];
1775
+ active.pop();
1776
+ }
1777
+ return undefined;
1778
+ }
1779
+
1780
+ /**
1781
+ * Add up to `count` more samples, or every remaining sample by default.
1782
+ * @param count maximum number of samples to add, rounded down; nonpositive values and NaN add none
1783
+ * @example `pd.sample( 20 )` adds twenty points per frame; `pd.sample()` completes the set
1784
+ */
1785
+ sample(count: number = Infinity): this {
1786
+ const limit = Math.floor(count);
1787
+ for (let i = 0; i < limit; i++) {
1788
+ if (this.step() === undefined) break;
1789
+ }
1790
+ return this;
1791
+ }
1792
+
1793
+ /**
1794
+ * Store the point at (x, y) if it lies inside the bound and no sample is within `radius` of it.
1795
+ * Coordinates must already be float32 values, so what is tested is exactly what is stored.
1796
+ */
1797
+ private _tryAdd(x: number, y: number): boolean {
1798
+ if (!(x >= this._x0 && x < this._x1 && y >= this._y0 && y < this._y1)) {
1799
+ return false;
1800
+ }
1801
+ const cols = this._cols;
1802
+ const rows = this._rows;
1803
+ const cx = Math.min(cols - 1, Math.floor((x - this._x0) / this._cell));
1804
+ const cy = Math.min(rows - 1, Math.floor((y - this._y0) / this._cell));
1805
+ const grid = this._grid;
1806
+
1807
+ // A conflicting sample lies within two cells in each direction, but never in the four
1808
+ // corners of that window. Scan from the center outward: a rejected candidate usually
1809
+ // conflicts with a sample close to it, so the scan ends after a few cells.
1810
+ const r2 = this._radius * this._radius;
1811
+ for (const dj of __near) {
1812
+ const j = cy + dj;
1813
+ if (j < 0 || j >= rows) continue;
1814
+ const row = j * cols;
1815
+ const reach = dj === -2 || dj === 2 ? 1 : 2;
1816
+ for (const di of __near) {
1817
+ const i = cx + di;
1818
+ if (di > reach || di < -reach || i < 0 || i >= cols) continue;
1819
+ const s = grid[row + i];
1820
+ if (s >= 0) {
1821
+ const q = this[s];
1822
+ const dx = q[0] - x;
1823
+ const dy = q[1] - y;
1824
+ if (dx * dx + dy * dy < r2) return false;
1825
+ }
1826
+ }
1827
+ }
1828
+
1829
+ grid[cy * cols + cx] = this.length;
1830
+ this._active.push(this.length);
1831
+ this.push(new Pt(x, y));
1832
+ return true;
1833
+ }
1834
+ }
package/src/Dom.ts CHANGED
@@ -347,7 +347,7 @@ export class DOMSpace extends MultiTouchSpace {
347
347
 
348
348
  /**
349
349
  * @deprecated HTML rendering is deprecated and will be removed in a future major version. Use [`SVGSpace`](#link) for DOM-based output instead — it shares the supported subset of the [`CanvasForm`](#link) drawing API.
350
- * **[Experimental]** HTMLSpace is a subclass of DOMSpace that works with HTML elements. See [a demo here](https://ptsjs.org/demo/?name=htmlform.scope).
350
+ * **[Experimental]** HTMLSpace is a subclass of DOMSpace that works with HTML elements.
351
351
  */
352
352
  export class HTMLSpace extends DOMSpace {
353
353
  /**
package/src/Num.ts CHANGED
@@ -26,7 +26,9 @@ export class Num {
26
26
  * @param threshold threshold value that specifies the minimum difference within which the two numbers are considered equal
27
27
  */
28
28
  static equals(a: number, b: number, threshold = 0.00001): boolean {
29
- return Math.abs(a - b) < threshold;
29
+ // A value equals itself (infinities included, whose difference is NaN),
30
+ // and a difference equal to the threshold is included, as in Pt.equals.
31
+ return a === b || Math.abs(a - b) <= threshold;
30
32
  }
31
33
 
32
34
  /**