jspsych-tangram 0.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.
Files changed (72) hide show
  1. package/README.md +25 -0
  2. package/dist/construct/index.browser.js +20431 -0
  3. package/dist/construct/index.browser.js.map +1 -0
  4. package/dist/construct/index.browser.min.js +42 -0
  5. package/dist/construct/index.browser.min.js.map +1 -0
  6. package/dist/construct/index.cjs +3720 -0
  7. package/dist/construct/index.cjs.map +1 -0
  8. package/dist/construct/index.d.ts +204 -0
  9. package/dist/construct/index.js +3718 -0
  10. package/dist/construct/index.js.map +1 -0
  11. package/dist/index.cjs +3920 -0
  12. package/dist/index.cjs.map +1 -0
  13. package/dist/index.d.ts +340 -0
  14. package/dist/index.js +3917 -0
  15. package/dist/index.js.map +1 -0
  16. package/dist/prep/index.browser.js +20455 -0
  17. package/dist/prep/index.browser.js.map +1 -0
  18. package/dist/prep/index.browser.min.js +42 -0
  19. package/dist/prep/index.browser.min.js.map +1 -0
  20. package/dist/prep/index.cjs +3744 -0
  21. package/dist/prep/index.cjs.map +1 -0
  22. package/dist/prep/index.d.ts +139 -0
  23. package/dist/prep/index.js +3742 -0
  24. package/dist/prep/index.js.map +1 -0
  25. package/package.json +77 -0
  26. package/src/core/components/README.md +249 -0
  27. package/src/core/components/board/BoardView.tsx +352 -0
  28. package/src/core/components/board/GameBoard.tsx +682 -0
  29. package/src/core/components/board/index.ts +70 -0
  30. package/src/core/components/board/useAnchorGrid.ts +110 -0
  31. package/src/core/components/board/useClickController.ts +436 -0
  32. package/src/core/components/board/useDragController.ts +1051 -0
  33. package/src/core/components/board/usePieceState.ts +178 -0
  34. package/src/core/components/board/utils.ts +76 -0
  35. package/src/core/components/index.ts +33 -0
  36. package/src/core/components/pieces/BlueprintRing.tsx +238 -0
  37. package/src/core/config/config.ts +85 -0
  38. package/src/core/domain/blueprints.ts +25 -0
  39. package/src/core/domain/layout.ts +159 -0
  40. package/src/core/domain/primitives.ts +159 -0
  41. package/src/core/domain/solve.ts +184 -0
  42. package/src/core/domain/types.ts +111 -0
  43. package/src/core/engine/collision/grid-snapping.ts +283 -0
  44. package/src/core/engine/collision/index.ts +4 -0
  45. package/src/core/engine/collision/sat-collision.ts +46 -0
  46. package/src/core/engine/collision/validation.ts +166 -0
  47. package/src/core/engine/geometry/bounds.ts +91 -0
  48. package/src/core/engine/geometry/collision.ts +64 -0
  49. package/src/core/engine/geometry/index.ts +19 -0
  50. package/src/core/engine/geometry/math.ts +101 -0
  51. package/src/core/engine/geometry/pieces.ts +290 -0
  52. package/src/core/engine/geometry/polygons.ts +43 -0
  53. package/src/core/engine/state/BaseGameController.ts +368 -0
  54. package/src/core/engine/validation/border-rendering.ts +318 -0
  55. package/src/core/engine/validation/complete.ts +102 -0
  56. package/src/core/engine/validation/face-to-face.ts +217 -0
  57. package/src/core/index.ts +3 -0
  58. package/src/core/io/InteractionTracker.ts +742 -0
  59. package/src/core/io/data-tracking.ts +271 -0
  60. package/src/core/io/json-to-tangram-spec.ts +110 -0
  61. package/src/core/io/quickstash.ts +141 -0
  62. package/src/core/io/stims.ts +110 -0
  63. package/src/core/types/index.ts +5 -0
  64. package/src/core/types/plugin-interfaces.ts +101 -0
  65. package/src/index.spec.ts +19 -0
  66. package/src/index.ts +2 -0
  67. package/src/plugins/tangram-construct/ConstructionApp.tsx +105 -0
  68. package/src/plugins/tangram-construct/index.ts +156 -0
  69. package/src/plugins/tangram-prep/PrepApp.tsx +182 -0
  70. package/src/plugins/tangram-prep/index.ts +122 -0
  71. package/tangram-construct.min.js +42 -0
  72. package/tangram-prep.min.js +42 -0
@@ -0,0 +1,101 @@
1
+ /**
2
+ * Pure vector and polygon mathematical operations
3
+ * No game logic dependencies - just geometric math
4
+ */
5
+
6
+ import type { Vec, Poly } from '@/core/domain/types';
7
+
8
+ // ===== Constants =====
9
+
10
+ export const EPS = 1e-6;
11
+
12
+ // ===== Vector Operations =====
13
+
14
+ export const vsub = (a: Vec, b: Vec): Vec => ({ x: a.x - b.x, y: a.y - b.y });
15
+ export const vlen = (v: Vec) => Math.hypot(v.x, v.y);
16
+ export const vscale = (v: Vec, s: number): Vec => ({ x: v.x * s, y: v.y * s });
17
+ export const vadd = (a: Vec, b: Vec): Vec => ({ x: a.x + b.x, y: a.y + b.y });
18
+
19
+ /** Squared distance between two points (faster than full distance) */
20
+ export const dist2 = (a: Vec, b: Vec): number => {
21
+ const dx = a.x - b.x, dy = a.y - b.y;
22
+ return dx * dx + dy * dy;
23
+ };
24
+
25
+ /**
26
+ * Check if point p lies on the line segment from a to b (within epsilon tolerance)
27
+ */
28
+ export function pointOnSegment(p: Vec, a: Vec, b: Vec, eps = 1e-9): boolean {
29
+ // collinear?
30
+ const cross = (b.y - a.y) * (p.x - a.x) - (b.x - a.x) * (p.y - a.y);
31
+ if (Math.abs(cross) > eps) return false;
32
+ // within bounding box?
33
+ const minX = Math.min(a.x, b.x) - eps, maxX = Math.max(a.x, b.x) + eps;
34
+ const minY = Math.min(a.y, b.y) - eps, maxY = Math.max(a.y, b.y) + eps;
35
+ return p.x >= minX && p.x <= maxX && p.y >= minY && p.y <= maxY;
36
+ }
37
+
38
+ /**
39
+ * Return an array of points subdividing the segment [a,b] into `n` equal parts (excluding `a`).
40
+ */
41
+ export function subdivideSegment(a: Vec, b: Vec, n: number): Vec[] {
42
+ if (n <= 1) return [b];
43
+ const ab = vsub(b, a);
44
+ const step = 1 / n;
45
+ const pts: Vec[] = [];
46
+ for (let i = 1; i <= n; i++) pts.push(vadd(a, vscale(ab, step * i)));
47
+ return pts;
48
+ }
49
+
50
+ // ===== Polygon Operations =====
51
+
52
+ /** Boundary-inclusive even–odd point-in-polygon test */
53
+ export function pointInPolygon(pt: Vec, poly: Poly): boolean {
54
+ // 1) On-edge / on-vertex counts as inside
55
+ for (let i = 0, j = poly.length - 1; i < poly.length; j = i++) {
56
+ const vertexJ = poly[j];
57
+ const vertexI = poly[i];
58
+ if (!vertexJ || !vertexI) continue;
59
+ if (pointOnSegment(pt, vertexJ, vertexI)) return true;
60
+ }
61
+
62
+ // 2) Standard even–odd ray casting (edges strictly above/below pivot)
63
+ // Using the common (yi > y) !== (yj > y) test avoids counting horizontal edges twice.
64
+ let inside = false;
65
+ for (let i = 0, j = poly.length - 1; i < poly.length; j = i++) {
66
+ const vertexI = poly[i];
67
+ const vertexJ = poly[j];
68
+ if (!vertexI || !vertexJ) continue;
69
+ const xi = vertexI.x, yi = vertexI.y;
70
+ const xj = vertexJ.x, yj = vertexJ.y;
71
+
72
+ const intersects = (yi > pt.y) !== (yj > pt.y)
73
+ && pt.x < ((xj - xi) * (pt.y - yi)) / (yj - yi + 1e-12) + xi;
74
+
75
+ if (intersects) inside = !inside;
76
+ }
77
+ return inside;
78
+ }
79
+
80
+ /** Project a polygon onto axis (nx, ny) and return min/max projection values */
81
+ export function project(poly: Poly, nx: number, ny: number) {
82
+ let min = Infinity, max = -Infinity;
83
+ for (const p of poly) {
84
+ const s = p.x * nx + p.y * ny;
85
+ if (s < min) min = s;
86
+ if (s > max) max = s;
87
+ }
88
+ return { min, max };
89
+ }
90
+
91
+ /** Calculate axis-aligned bounding box for a polygon */
92
+ export function polygonAABB(poly: Poly) {
93
+ let minX = Infinity, minY = Infinity, maxX = -Infinity, maxY = -Infinity;
94
+ for (const pt of poly) {
95
+ if (pt.x < minX) minX = pt.x;
96
+ if (pt.y < minY) minY = pt.y;
97
+ if (pt.x > maxX) maxX = pt.x;
98
+ if (pt.y > maxY) maxY = pt.y;
99
+ }
100
+ return { minX, minY, maxX, maxY };
101
+ }
@@ -0,0 +1,290 @@
1
+ /**
2
+ * Game-specific piece geometry operations
3
+ * Includes: piece positioning, placement, grid snapping, and anchor generation
4
+ *
5
+ * Consolidated from: piece.ts, placement.ts, grid.ts, anchors.ts
6
+ */
7
+
8
+ import type { Blueprint, Vec, Poly, Anchor } from "@/core/domain/types";
9
+ import type { CircleLayout, SectorGeom } from "@/core/domain/layout";
10
+ import { polysAABB } from "./bounds";
11
+ import { pointInPolygon, dist2 } from "./math";
12
+ import { CONFIG } from "@/core/config/config";
13
+
14
+ const GRID_PX = CONFIG.layout.grid.stepPx;
15
+
16
+ // ===== Piece Positioning (from piece.ts) =====
17
+
18
+ /** Translate blueprint polys to world coords for a given top-left (TL). */
19
+ export function piecePolysAt(
20
+ bp: Blueprint,
21
+ bb: { min: { x: number; y: number } },
22
+ tl: { x: number; y: number }
23
+ ) {
24
+ const ox = tl.x - bb.min.x;
25
+ const oy = tl.y - bb.min.y;
26
+ const polys = ("shape" in bp && bp.shape) ? bp.shape : [];
27
+ return polys.map(poly => poly.map(p => ({ x: p.x + ox, y: p.y + oy })));
28
+ }
29
+
30
+ /** Build support offsets: each vertex relative to the AABB center. */
31
+ export function computeSupportOffsets(
32
+ bp: Blueprint,
33
+ bb: { min: { x: number; y: number }; width: number; height: number }
34
+ ) {
35
+ const cx = bb.min.x + bb.width / 2;
36
+ const cy = bb.min.y + bb.height / 2;
37
+
38
+ const polys = ("shape" in bp && bp.shape && bp.shape.length)
39
+ ? bp.shape
40
+ : [[
41
+ { x: bb.min.x, y: bb.min.y },
42
+ { x: bb.min.x + bb.width, y: bb.min.y },
43
+ { x: bb.min.x + bb.width, y: bb.min.y + bb.height },
44
+ { x: bb.min.x, y: bb.min.y + bb.height },
45
+ ]];
46
+
47
+ const offs: Vec[] = [];
48
+ for (const poly of polys) for (const v of poly) offs.push({ x: v.x - cx, y: v.y - cy });
49
+ return offs;
50
+ }
51
+
52
+ /** Clamp using directional vertex support → exact contact with the ring. */
53
+ export function clampTopLeftBySupport(
54
+ tlx: number,
55
+ tly: number,
56
+ d: {
57
+ aabb: { width: number; height: number };
58
+ support: { x: number; y: number }[];
59
+ },
60
+ layout: CircleLayout,
61
+ target: "workspace" | "silhouette",
62
+ pointerInsideCenter: boolean
63
+ ) {
64
+ const cx0 = tlx + d.aabb.width / 2;
65
+ const cy0 = tly + d.aabb.height / 2;
66
+
67
+ const vx = cx0 - layout.cx;
68
+ const vy = cy0 - layout.cy;
69
+ const r = Math.hypot(vx, vy);
70
+ if (r === 0) return { x: tlx, y: tly };
71
+
72
+ const ux = vx / r, uy = vy / r;
73
+
74
+ let h_out = -Infinity, h_in = -Infinity;
75
+ for (const o of d.support) {
76
+ const outProj = o.x * ux + o.y * uy;
77
+ if (outProj > h_out) h_out = outProj;
78
+ const inProj = -(o.x * ux + o.y * uy);
79
+ if (inProj > h_in) h_in = inProj;
80
+ }
81
+
82
+ if (target === "workspace") {
83
+ const [rIn, rOut] = layout.bands.workspace;
84
+ const minR = pointerInsideCenter ? 0 : (rIn + h_in);
85
+ const maxR = rOut - h_out;
86
+ const rClamped = Math.min(Math.max(r, minR), maxR);
87
+ if (Math.abs(rClamped - r) < 1e-6) return { x: tlx, y: tly };
88
+ const newCx = layout.cx + rClamped * ux;
89
+ const newCy = layout.cy + rClamped * uy;
90
+ return { x: newCx - d.aabb.width / 2, y: newCy - d.aabb.height / 2 };
91
+ } else {
92
+ const rOut = layout.bands.silhouette[1];
93
+ const maxR = rOut - h_out;
94
+ if (r <= maxR) return { x: tlx, y: tly };
95
+ const newCx = layout.cx + maxR * ux;
96
+ const newCy = layout.cy + maxR * uy;
97
+ return { x: newCx - d.aabb.width / 2, y: newCy - d.aabb.height / 2 };
98
+ }
99
+ }
100
+
101
+ // ===== Placement (from placement.ts) =====
102
+
103
+ /** gcd for positive integers */
104
+ function igcd(a: number, b: number) {
105
+ a = Math.round(Math.abs(a)); b = Math.round(Math.abs(b));
106
+ while (b) [a, b] = [b, a % b];
107
+ return a || 1;
108
+ }
109
+
110
+ /** Infer the base lattice unit in raw silhouette coordinates. */
111
+ export function inferUnitFromPolys(polys: Poly[]): number {
112
+ let g = 0;
113
+ for (const poly of polys) {
114
+ for (let i = 0; i < poly.length; i++) {
115
+ const a = poly[i], b = poly[(i + 1) % poly.length];
116
+ if (!a || !b) continue;
117
+ const dx = Math.round(Math.abs(b.x - a.x));
118
+ const dy = Math.round(Math.abs(b.y - a.y));
119
+ if (dx) g = g ? igcd(g, dx) : dx;
120
+ if (dy) g = g ? igcd(g, dy) : dy;
121
+ }
122
+ }
123
+ return g || 1;
124
+ }
125
+
126
+ /**
127
+ * Place polys using scale S, re-centered, and translation snapped to GRID_PX; returns polys.
128
+ * The center is snapped to the grid so the lattice points under the silhouette stay invariant.
129
+ */
130
+ export function placeSilhouetteGridAlignedAsPolys(
131
+ polys: Poly[],
132
+ S: number,
133
+ rectCenter: { cx: number; cy: number }
134
+ ): Poly[] {
135
+ if (!polys || polys.length === 0) return [];
136
+ const a = polysAABB(polys);
137
+ const cx0 = (a.min.x + a.max.x) / 2;
138
+ const cy0 = (a.min.y + a.max.y) / 2;
139
+ const cx = Math.round(rectCenter.cx / GRID_PX) * GRID_PX;
140
+ const cy = Math.round(rectCenter.cy / GRID_PX) * GRID_PX;
141
+ const tx = cx - S * cx0;
142
+ const ty = cy - S * cy0;
143
+ const stx = Math.round(tx / GRID_PX) * GRID_PX;
144
+ const sty = Math.round(ty / GRID_PX) * GRID_PX;
145
+ return polys.map(poly => poly.map(p => ({ x: S * p.x + stx, y: S * p.y + sty })));
146
+ }
147
+
148
+ // ===== Grid Operations (from grid.ts) =====
149
+
150
+ export function nearestGridNode(p: Vec): Vec {
151
+ return { x: Math.round(p.x / GRID_PX) * GRID_PX, y: Math.round(p.y / GRID_PX) * GRID_PX };
152
+ }
153
+
154
+ /** Generate grid nodes inside an AABB. */
155
+ export function gridNodesInAABB(min: Vec, max: Vec): Vec[] {
156
+ const out: Vec[] = [];
157
+ const x0 = Math.ceil(min.x / GRID_PX) * GRID_PX;
158
+ const y0 = Math.ceil(min.y / GRID_PX) * GRID_PX;
159
+ for (let y = y0; y <= max.y; y += GRID_PX) {
160
+ for (let x = x0; x <= max.x; x += GRID_PX) out.push({ x, y });
161
+ }
162
+ return out;
163
+ }
164
+
165
+ /** Keep nodes in a band (and optional sector wedge). */
166
+ export function filterNodesToBandAndSector(
167
+ nodes: Vec[],
168
+ layout: CircleLayout,
169
+ band: "workspace" | "silhouette",
170
+ sector?: SectorGeom
171
+ ): Vec[] {
172
+ const [rIn, rOut] = layout.bands[band];
173
+ const out: Vec[] = [];
174
+ for (const n of nodes) {
175
+ const dx = n.x - layout.cx, dy = n.y - layout.cy;
176
+ const r = Math.hypot(dx, dy);
177
+ if (r < rIn || r > rOut) continue;
178
+
179
+ if (sector) {
180
+ let theta = Math.atan2(dy, dx);
181
+ if (layout.mode === "circle") {
182
+ if (theta < -Math.PI / 2) theta += 2 * Math.PI;
183
+ } else {
184
+ if (theta < Math.PI) theta += 2 * Math.PI;
185
+ }
186
+ if (theta < sector.start || theta >= sector.end) continue;
187
+ }
188
+ out.push(n);
189
+ }
190
+ return out;
191
+ }
192
+
193
+ /** Keep nodes that lie inside any of the polygons. */
194
+ export function filterNodesInPolys(
195
+ nodes: Vec[],
196
+ polys: Poly[],
197
+ pointInPolyFn: (pt: Vec, poly: Poly) => boolean = pointInPolygon
198
+ ): Vec[] {
199
+ const out: Vec[] = [];
200
+ node: for (const n of nodes) {
201
+ for (const poly of polys) {
202
+ if (pointInPolyFn(n, poly)) { out.push(n); continue node; }
203
+ }
204
+ }
205
+ return out;
206
+ }
207
+
208
+ // ===== Anchor Operations (from anchors.ts) =====
209
+
210
+ /**
211
+ * Find the nearest anchor that "accepts" a piece signature (kind/compositeSignature).
212
+ * Returns null if none within the optional radius.
213
+ */
214
+ export function nearestAcceptingAnchor(
215
+ anchors: Anchor[] | undefined,
216
+ point: Vec,
217
+ accepts: { kind?: string; compositeSignature?: string },
218
+ withinPx: number = Infinity
219
+ ): { anchor: Anchor; d2: number } | null {
220
+ if (!anchors || !anchors.length) return null;
221
+
222
+ const { kind, compositeSignature } = accepts;
223
+ const r2 = withinPx === Infinity ? Infinity : withinPx * withinPx;
224
+
225
+ let best: { anchor: Anchor; d2: number } | null = null;
226
+ for (const a of anchors) {
227
+ const ok = a.accepts.some(acc =>
228
+ (acc.kind === undefined || acc.kind === kind) &&
229
+ (acc.compositeSignature === undefined || acc.compositeSignature === compositeSignature)
230
+ );
231
+ if (!ok) continue;
232
+
233
+ const d2val = dist2(point, a.position);
234
+ if (d2val <= r2 && (!best || d2val < best.d2)) {
235
+ best = { anchor: a, d2: d2val };
236
+ }
237
+ }
238
+ return best;
239
+ }
240
+
241
+ /** True if `point` is within `withinPx` of the given anchor. */
242
+ export function withinAnchor(anchor: Anchor, point: Vec, withinPx: number): boolean {
243
+ const r2 = withinPx * withinPx;
244
+ return dist2(point, anchor.position) <= r2;
245
+ }
246
+
247
+ // Node-grid helpers for anchor generation
248
+ export function workspaceNodes(layout: CircleLayout, sector: SectorGeom): Vec[] {
249
+ const pad = 6;
250
+ const min = { x: layout.cx - layout.outerR, y: layout.cy - layout.outerR - pad };
251
+ const max = { x: layout.cx + layout.outerR, y: layout.cy + layout.outerR + pad };
252
+ const nodes = gridNodesInAABB(min, max);
253
+ return filterNodesToBandAndSector(nodes, layout, "workspace", sector);
254
+ }
255
+
256
+ export function silhouetteNodes(layout: CircleLayout, sector: SectorGeom, fittedMask: Poly[]): Vec[] {
257
+ const pad = 6;
258
+ const min = { x: layout.cx - layout.outerR, y: layout.cy - layout.outerR - pad };
259
+ const max = { x: layout.cx + layout.outerR, y: layout.cy + layout.outerR + pad };
260
+ const nodes = gridNodesInAABB(min, max);
261
+ const banded = filterNodesToBandAndSector(nodes, layout, "silhouette", sector);
262
+ return filterNodesInPolys(banded, fittedMask);
263
+ }
264
+
265
+ export function silhouetteBandNodes(layout: CircleLayout, sector: SectorGeom): Vec[] {
266
+ const pad = 6;
267
+ const min = { x: layout.cx - layout.outerR, y: layout.cy - layout.outerR - pad };
268
+ const max = { x: layout.cx + layout.outerR, y: layout.cy + layout.outerR + pad };
269
+ const nodes = gridNodesInAABB(min, max);
270
+ return filterNodesToBandAndSector(nodes, layout, "silhouette", sector);
271
+ }
272
+
273
+ /** Generate anchor grid nodes for the inner ring (radius < innerR) */
274
+ export function innerRingNodes(layout: CircleLayout): Vec[] {
275
+ const pad = 6;
276
+ const min = { x: layout.cx - layout.innerR, y: layout.cy - layout.innerR - pad };
277
+ const max = { x: layout.cx + layout.innerR, y: layout.cy + layout.innerR + pad };
278
+ const nodes = gridNodesInAABB(min, max);
279
+ // Filter to nodes within the inner circle
280
+ const out: Vec[] = [];
281
+ for (const n of nodes) {
282
+ const dx = n.x - layout.cx, dy = n.y - layout.cy;
283
+ const r = Math.hypot(dx, dy);
284
+ if (r < layout.innerR) out.push(n);
285
+ }
286
+ return out;
287
+ }
288
+
289
+ export const anchorsDiameterToPx = (anchorsDiag: number, gridPx: number = GRID_PX) =>
290
+ anchorsDiag * Math.SQRT2 * gridPx;
@@ -0,0 +1,43 @@
1
+ /**
2
+ * Polygon-specific operations (subdivision, etc.)
3
+ * Uses pure math functions from math.ts
4
+ */
5
+
6
+ import type { Poly, Vec } from "@/core/domain/types";
7
+ import { vsub, vlen, subdivideSegment, EPS } from './math';
8
+
9
+ /**
10
+ * "Half-edge" subdivision like the Python spec:
11
+ * - Axis-aligned legs split into steps of unit/2.
12
+ * - 45° diagonals split into steps of unit*sqrt(2)/2.
13
+ *
14
+ * Works for triangles/squares/parallelograms. Keeps polygon closed (no duplicate final vertex).
15
+ */
16
+ export function subdivideForHalfEdges(poly: Poly, unit: number): Poly {
17
+ if (poly.length < 3) return poly.slice();
18
+ const diagStep = (unit * Math.SQRT2) / 2; // √2/2 * unit
19
+ const axisStep = unit / 2;
20
+
21
+ const out: Vec[] = [];
22
+ for (let i = 0; i < poly.length; i++) {
23
+ const a = poly[i];
24
+ const b = poly[(i + 1) % poly.length];
25
+ if (!a || !b) continue;
26
+ const seg = vsub(b, a);
27
+ const L = vlen(seg);
28
+ // detect 45° diagonal (|dx| == |dy| within eps)
29
+ const isDiag = Math.abs(Math.abs(seg.x) - Math.abs(seg.y)) < EPS && L > EPS;
30
+ const step = isDiag ? diagStep : axisStep;
31
+
32
+ // ensure an integer number of steps (round to nearest)
33
+ const n = Math.max(1, Math.round(L / step));
34
+ const pts = subdivideSegment(a, b, n);
35
+ if (i === 0) out.push(a);
36
+ // push all but the last vertex (to avoid duplicating the starting vertex at the end)
37
+ for (let k = 0; k < pts.length - (i === poly.length - 1 ? 1 : 0); k++) {
38
+ const pt = pts[k];
39
+ if (pt) out.push(pt);
40
+ }
41
+ }
42
+ return out;
43
+ }