poppygl 0.0.35 → 0.0.36

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/README.md CHANGED
@@ -136,7 +136,7 @@ const png = await renderGLTFToPNGFromGLB(glb, {
136
136
  - `showEdges`: draw visible boundary and crease edges on all triangle meshes (default `false`).
137
137
  - `showHiddenEdges`: draw visible edges plus faint dashed occluded edges on all meshes. Omit it or use `null` to preserve each model's flag; `false` disables those flags.
138
138
  - `edgeWidth`: width of both visible and hidden mesh edges in output pixels (default `1`).
139
- - `lineWidth`: width of glTF `LINES` and finite grid lines in output pixels (default `1`).
139
+ - `lineWidth`: width of glTF `LINES`, finite grid lines, and explosion guides in output pixels (default `1`).
140
140
  - `hiddenEdgeOpacity`: opacity of dashed occluded mesh edges, from `0` to `1` (default `0.35`).
141
141
  - `fetchImpl`: optional override for resource loading (must match the `fetch` signature).
142
142
 
@@ -167,8 +167,9 @@ finite fractional widths are supported; invalid widths fall back to one pixel.
167
167
  Wider strokes use antialiased screen-space coverage with round caps in the
168
168
  software renderer. They are clipped before the perspective divide and remain
169
169
  depth tested across their full width. No GPU or shader runtime is required.
170
- `lineWidth` also applies to ordinary glTF line primitives and finite grid lines;
171
- the procedural infinite grid keeps its existing independent styling.
170
+ `lineWidth` also applies to ordinary glTF line primitives, finite grid lines,
171
+ and explosion guides, including arrowhead strokes. The procedural infinite grid
172
+ keeps its existing independent styling.
172
173
 
173
174
  ### Hidden edges for selected sub-models
174
175
 
@@ -238,6 +239,53 @@ const png = await encodePNG(bitmap)
238
239
 
239
240
  The only contract is that `buffers` is an array of `Uint8Array` instances and `images` is an array of `BitmapLike` textures (PNG and JPEG are supported out of the box via `decodeImageFromBuffer`).
240
241
 
242
+ ## Explosion guide lines
243
+
244
+ Pass `explosionLines` to any render entrypoint to draw polylines between assembled
245
+ and displaced part positions. The caller moves the geometry and supplies the
246
+ points in the same glTF scene frame and units (normally right-handed, +Y up).
247
+
248
+ This lamp is adapted from [core's reference-surface lamp](https://github.com/tscircuit/core/blob/81bef4337570e3ac4d7dd6d30ab63481304e8147/tests/assembly/fixtures/reference-surface-lamp.tsx).
249
+ Its base, hollow blue stem, and bulb stay in place while the hollow shade,
250
+ mounting collar, and three spokes move together by **+60 mm on scene Y**. The
251
+ shade mount moves from Y=92 to Y=152. Two dashed guides join corresponding points
252
+ on its lower rim, from Y=68 to Y=128. Arrowheads at the displaced endpoints show
253
+ the upward movement:
254
+
255
+ ![Assembled lamp and shade lifted 60 mm with dashed upward arrow guides](tests/render/__snapshots__/explosion-lamp.snap.png)
256
+
257
+ ```ts
258
+ // explodedLampGlb already contains the shade translated +60 mm on scene Y.
259
+ const rimPoints = [[-36 / Math.SQRT2, 36 / Math.SQRT2], [36, 0]] as const
260
+ const png = await renderGLTFToPNGFromGLB(explodedLampGlb, {
261
+ explosionLines: rimPoints.map(([x, z]) => ({
262
+ points: [[x, 68, z], [x, 128, z]],
263
+ color: [0.015, 0.04, 0.08], // Linear RGB, 0..1; optional.
264
+ dashed: true, // Default; false gives a solid guide.
265
+ arrow: true, // Optional; false by default.
266
+ })),
267
+ })
268
+ ```
269
+
270
+ The deterministic [lamp fixture](tests/fixtures/reference-lamp.ts) and
271
+ [visual test](tests/render/explosion-lamp.test.ts) generate the comparison without
272
+ network assets. Open the `explosion-lamp` Cosmos example to toggle shade movement,
273
+ guides, and arrows interactively. The separate synthetic explosion-lines snapshot
274
+ exercises elbows, geometry occlusion, and camera clipping.
275
+
276
+ Guides respect scene depth, do not write to the depth buffer, and render after
277
+ geometry denoising. Their points contribute to automatic camera framing. Lines
278
+ are clipped to the camera frustum before rasterization; dash length scales with
279
+ supersampling. Omitting the option or passing an empty list preserves the existing
280
+ rendering behavior. Each guide needs at least two finite XYZ points.
281
+
282
+ Set `arrow: true` to draw a solid open arrowhead at the final authored point,
283
+ following the final nonzero-length segment's direction. Arrowheads are at most
284
+ 10 output pixels long, capped for short segments, and scale with supersampling.
285
+ They respect scene depth and remain solid when the shaft is dashed. `lineWidth`
286
+ controls both the shaft and arrowhead stroke width. Omit `arrow` or set it to
287
+ `false` for a guide without an arrow.
288
+
241
289
  ## Additional utilities
242
290
 
243
291
  - `loadGLTFWithResourcesFromURL` returns `{ gltf, resources }` if you prefer to inspect or cache the parsed data before rendering.
package/dist/index.d.ts CHANGED
@@ -69,6 +69,21 @@ interface DebugPoint {
69
69
  z: number;
70
70
  };
71
71
  }
72
+ /** Guide polyline in right-handed glTF scene coordinates (normally +Y up),
73
+ * using the model's scene units. Each vertex is a point, not a displacement.
74
+ */
75
+ interface ExplosionLine {
76
+ points: readonly (readonly [number, number, number])[];
77
+ /** Linear RGB, 0..1; defaults to a muted blue. */
78
+ color?: readonly [number, number, number];
79
+ /** Defaults to true. Visible segments respect scene depth. */
80
+ dashed?: boolean;
81
+ /** Add a solid arrowhead at the last point, following the final segment.
82
+ * Defaults to false. The arrowhead is 10 output pixels long (or shorter for
83
+ * short segments), independent of camera distance and supersampling.
84
+ */
85
+ arrow?: boolean;
86
+ }
72
87
  type CameraUp = "y+" | "y-" | "x+" | "x-" | "z+" | "z-";
73
88
  interface CameraRotation {
74
89
  x: number;
@@ -102,13 +117,14 @@ interface RenderOptions {
102
117
  debugFontSize?: number | null;
103
118
  debugPointColor?: readonly [number, number, number] | null;
104
119
  debugLabelColor?: readonly [number, number, number] | null;
120
+ explosionLines?: readonly ExplosionLine[] | null;
105
121
  /** Draw visible boundary and crease edges on every triangle mesh. */
106
122
  showEdges: boolean;
107
123
  /** Override model showHiddenEdges flags; null preserves each model's flag. */
108
124
  showHiddenEdges: boolean | null;
109
125
  /** Width of visible and hidden mesh edges in output pixels. */
110
126
  edgeWidth: number;
111
- /** Width of glTF LINES and finite grid lines in output pixels. */
127
+ /** Width of glTF LINES, finite grid lines, and explosion guides in output pixels. */
112
128
  lineWidth: number;
113
129
  /** Opacity of dashed, occluded mesh edges, from 0 to 1. */
114
130
  hiddenEdgeOpacity: number;
@@ -263,4 +279,4 @@ declare function renderGLTFToPNGFromGLB(glb: ArrayBuffer | Uint8Array, options?:
263
279
 
264
280
  declare function renderSceneFromGLTF(scene: GLTFScene, options?: RenderOptionsInput, imageFactory?: ImageFactory): RenderResult;
265
281
 
266
- export { type BitmapLike, type Camera, type CameraRotation, type CameraUp, DEFAULT_LIGHT_DIR, DEFAULT_RENDER_OPTIONS, type DebugPoint, type DrawCall, type FetchLike, type GLTFResources, type GLTFScene, type GridOptions, type ImageFactory, type LightSettings, type LineRenderOptions, type LoadGLTFWithResourcesFromURLOptions, type Material, type MutableRGBA, type RGBA, type RenderGLTFToPNGFromGLBOptions, type RenderGLTFToPNGFromURLOptions, type RenderOptions, type RenderOptionsInput, type RenderResult, SoftwareRenderer, bufferFromDataURI, buildCamera, computeSmoothNormals, computeWorldAABB, createEdgeDrawCall, createGrid, createInfiniteGrid, createSceneFromGLTF, createUint8Bitmap, decodeImageFromBuffer, drawInfiniteGrid, encodePNG, getDefaultRenderOptions, hexToRgb, isJPG, isPNG, loadGLTFWithResourcesFromURL, renderDrawCalls, renderGLTFToPNGFromGLB, renderGLTFToPNGFromURL, renderSceneFromGLTF, resolveRenderOptions };
282
+ export { type BitmapLike, type Camera, type CameraRotation, type CameraUp, DEFAULT_LIGHT_DIR, DEFAULT_RENDER_OPTIONS, type DebugPoint, type DrawCall, type ExplosionLine, type FetchLike, type GLTFResources, type GLTFScene, type GridOptions, type ImageFactory, type LightSettings, type LineRenderOptions, type LoadGLTFWithResourcesFromURLOptions, type Material, type MutableRGBA, type RGBA, type RenderGLTFToPNGFromGLBOptions, type RenderGLTFToPNGFromURLOptions, type RenderOptions, type RenderOptionsInput, type RenderResult, SoftwareRenderer, bufferFromDataURI, buildCamera, computeSmoothNormals, computeWorldAABB, createEdgeDrawCall, createGrid, createInfiniteGrid, createSceneFromGLTF, createUint8Bitmap, decodeImageFromBuffer, drawInfiniteGrid, encodePNG, getDefaultRenderOptions, hexToRgb, isJPG, isPNG, loadGLTFWithResourcesFromURL, renderDrawCalls, renderGLTFToPNGFromGLB, renderGLTFToPNGFromURL, renderSceneFromGLTF, resolveRenderOptions };
package/dist/index.js CHANGED
@@ -499,6 +499,7 @@ var DEFAULT_RENDER_OPTIONS = {
499
499
  debugFontSize: null,
500
500
  debugPointColor: null,
501
501
  debugLabelColor: null,
502
+ explosionLines: null,
502
503
  showEdges: false,
503
504
  showHiddenEdges: null,
504
505
  edgeWidth: 1,
@@ -2369,13 +2370,190 @@ var StudioLighting = class {
2369
2370
  }
2370
2371
  };
2371
2372
 
2373
+ // lib/render/createExplosionLineDrawCall.ts
2374
+ import { mat4 as mat47, vec4 as vec45 } from "gl-matrix";
2375
+ function createExplosionLineDrawCall(line) {
2376
+ if (line.points.length < 2 || line.points.some(
2377
+ (point) => point.length !== 3 || !point.every(Number.isFinite)
2378
+ )) {
2379
+ throw new Error("Explosion lines need at least two finite XYZ points");
2380
+ }
2381
+ const color = line.color ?? [0.08, 0.2, 0.35];
2382
+ if (color.length !== 3 || color.some(
2383
+ (channel) => !Number.isFinite(channel) || channel < 0 || channel > 1
2384
+ )) {
2385
+ throw new Error(
2386
+ "Explosion line colors must be linear RGB values from 0 to 1"
2387
+ );
2388
+ }
2389
+ const indices = [];
2390
+ for (let vertex = 1; vertex < line.points.length; vertex++) {
2391
+ indices.push(vertex - 1, vertex);
2392
+ }
2393
+ return {
2394
+ positions: new Float32Array(line.points.flatMap((point) => [...point])),
2395
+ indices: new Uint32Array(indices),
2396
+ normals: null,
2397
+ uvs: null,
2398
+ model: mat47.create(),
2399
+ material: { baseColorFactor: [...color, 1], baseColorTexture: null },
2400
+ mode: 1
2401
+ };
2402
+ }
2403
+ function clipExplosionLineDrawCall(drawCall, camera) {
2404
+ const viewProjection = mat47.multiply(mat47.create(), camera.proj, camera.view);
2405
+ const positions = [];
2406
+ const indices = [];
2407
+ const distances = (point) => [
2408
+ point[3] + point[0],
2409
+ point[3] - point[0],
2410
+ point[3] + point[1],
2411
+ point[3] - point[1],
2412
+ point[3] + point[2],
2413
+ point[3] - point[2]
2414
+ ];
2415
+ for (let segment = 0; segment < drawCall.indices.length; segment += 2) {
2416
+ const startIndex = drawCall.indices[segment] * 3;
2417
+ const endIndex = drawCall.indices[segment + 1] * 3;
2418
+ const start = vec45.fromValues(
2419
+ drawCall.positions[startIndex],
2420
+ drawCall.positions[startIndex + 1],
2421
+ drawCall.positions[startIndex + 2],
2422
+ 1
2423
+ );
2424
+ const end = vec45.fromValues(
2425
+ drawCall.positions[endIndex],
2426
+ drawCall.positions[endIndex + 1],
2427
+ drawCall.positions[endIndex + 2],
2428
+ 1
2429
+ );
2430
+ const startDistances = distances(
2431
+ vec45.transformMat4(vec45.create(), start, viewProjection)
2432
+ );
2433
+ const endDistances = distances(
2434
+ vec45.transformMat4(vec45.create(), end, viewProjection)
2435
+ );
2436
+ let from = 0;
2437
+ let to = 1;
2438
+ for (let plane = 0; plane < 6; plane++) {
2439
+ const a = startDistances[plane];
2440
+ const b = endDistances[plane];
2441
+ if (a < 0 && b < 0) {
2442
+ to = -1;
2443
+ break;
2444
+ }
2445
+ if (a < 0 !== b < 0) {
2446
+ const intersection = a / (a - b);
2447
+ if (a < 0) from = Math.max(from, intersection);
2448
+ else to = Math.min(to, intersection);
2449
+ }
2450
+ }
2451
+ if (from > to) continue;
2452
+ const vertex = positions.length / 3;
2453
+ for (const progress of [from, to]) {
2454
+ for (let axis = 0; axis < 3; axis++) {
2455
+ positions.push(start[axis] + progress * (end[axis] - start[axis]));
2456
+ }
2457
+ }
2458
+ indices.push(vertex, vertex + 1);
2459
+ }
2460
+ return {
2461
+ ...drawCall,
2462
+ positions: new Float32Array(positions),
2463
+ indices: new Uint32Array(indices)
2464
+ };
2465
+ }
2466
+
2467
+ // lib/render/createExplosionArrowDrawCall.ts
2468
+ import { mat4 as mat48, vec4 as vec46 } from "gl-matrix";
2469
+ function createExplosionArrowDrawCall(drawCall, camera, width, height, supersampling) {
2470
+ if (width <= 1 || height <= 1) return null;
2471
+ const points = drawCall.positions;
2472
+ const end = points.length - 3;
2473
+ let start = end - 3;
2474
+ while (start >= 0 && points[start] === points[end] && points[start + 1] === points[end + 1] && points[start + 2] === points[end + 2]) {
2475
+ start -= 3;
2476
+ }
2477
+ if (start < 0) return null;
2478
+ const viewProjection = mat48.multiply(mat48.create(), camera.proj, camera.view);
2479
+ const tip = vec46.transformMat4(
2480
+ vec46.create(),
2481
+ vec46.fromValues(points[end], points[end + 1], points[end + 2], 1),
2482
+ viewProjection
2483
+ );
2484
+ if (!Array.from(tip).every(Number.isFinite) || tip[3] <= 0 || Math.abs(tip[0]) > tip[3] || Math.abs(tip[1]) > tip[3] || Math.abs(tip[2]) > tip[3]) {
2485
+ return null;
2486
+ }
2487
+ const clipped = clipExplosionLineDrawCall(
2488
+ {
2489
+ ...drawCall,
2490
+ positions: new Float32Array([
2491
+ points[start],
2492
+ points[start + 1],
2493
+ points[start + 2],
2494
+ points[end],
2495
+ points[end + 1],
2496
+ points[end + 2]
2497
+ ]),
2498
+ indices: new Uint32Array([0, 1])
2499
+ },
2500
+ camera
2501
+ );
2502
+ if (!clipped.indices?.length) return null;
2503
+ const tail = vec46.transformMat4(
2504
+ vec46.create(),
2505
+ vec46.fromValues(
2506
+ clipped.positions[0],
2507
+ clipped.positions[1],
2508
+ clipped.positions[2],
2509
+ 1
2510
+ ),
2511
+ viewProjection
2512
+ );
2513
+ if (tail[3] <= 0) return null;
2514
+ const dx = (tip[0] / tip[3] - tail[0] / tail[3]) * (width - 1) / 2;
2515
+ const dy = (tip[1] / tip[3] - tail[1] / tail[3]) * (height - 1) / 2;
2516
+ const length = Math.hypot(dx, dy);
2517
+ if (!Number.isFinite(length) || length < 1e-3) return null;
2518
+ const arrowLength = Math.min(10 * supersampling, length / 2);
2519
+ const ux = dx / length;
2520
+ const uy = dy / length;
2521
+ const inverse = mat48.invert(mat48.create(), viewProjection);
2522
+ if (!inverse) return null;
2523
+ const positions = [points[end], points[end + 1], points[end + 2]];
2524
+ for (const side of [-1, 1]) {
2525
+ const offsetX = -ux * arrowLength - side * uy * arrowLength * 0.45;
2526
+ const offsetY = -uy * arrowLength + side * ux * arrowLength * 0.45;
2527
+ const wing = vec46.transformMat4(
2528
+ vec46.create(),
2529
+ vec46.fromValues(
2530
+ tip[0] / tip[3] + 2 * offsetX / (width - 1),
2531
+ tip[1] / tip[3] + 2 * offsetY / (height - 1),
2532
+ tip[2] / tip[3],
2533
+ 1
2534
+ ),
2535
+ inverse
2536
+ );
2537
+ if (!Array.from(wing).every(Number.isFinite) || wing[3] === 0) return null;
2538
+ positions.push(wing[0] / wing[3], wing[1] / wing[3], wing[2] / wing[3]);
2539
+ }
2540
+ return {
2541
+ ...drawCall,
2542
+ positions: new Float32Array(positions),
2543
+ indices: new Uint32Array([0, 1, 0, 2])
2544
+ };
2545
+ }
2546
+
2372
2547
  // lib/render/renderDrawCalls.ts
2373
2548
  function renderDrawCalls(drawCalls, optionsInput = {}, imageFactory = createUint8Bitmap) {
2374
2549
  const options = resolveRenderOptions(optionsInput);
2375
2550
  const renderWidth = options.width * options.supersampling;
2376
2551
  const renderHeight = options.height * options.supersampling;
2552
+ const explosionDrawCalls = (options.explosionLines ?? []).map(
2553
+ createExplosionLineDrawCall
2554
+ );
2377
2555
  const camera = buildCamera(
2378
- drawCalls,
2556
+ [...drawCalls, ...explosionDrawCalls],
2379
2557
  renderWidth,
2380
2558
  renderHeight,
2381
2559
  options.fov,
@@ -2534,6 +2712,43 @@ function renderDrawCalls(drawCalls, optionsInput = {}, imageFactory = createUint
2534
2712
  width: options.edgeWidth * options.supersampling
2535
2713
  });
2536
2714
  }
2715
+ for (const [index, drawCall] of explosionDrawCalls.entries()) {
2716
+ renderer.drawLines(
2717
+ clipExplosionLineDrawCall(drawCall, camera),
2718
+ camera,
2719
+ options.gamma,
2720
+ {
2721
+ depthCompare: "less-equal",
2722
+ depthWrite: false,
2723
+ depthBias: 1e-4,
2724
+ dashed: options.explosionLines[index].dashed ?? true,
2725
+ dashLength: 4 * options.supersampling,
2726
+ width: options.lineWidth * options.supersampling
2727
+ }
2728
+ );
2729
+ if (options.explosionLines[index].arrow) {
2730
+ const arrow = createExplosionArrowDrawCall(
2731
+ drawCall,
2732
+ camera,
2733
+ renderWidth,
2734
+ renderHeight,
2735
+ options.supersampling
2736
+ );
2737
+ if (arrow) {
2738
+ renderer.drawLines(
2739
+ clipExplosionLineDrawCall(arrow, camera),
2740
+ camera,
2741
+ options.gamma,
2742
+ {
2743
+ depthCompare: "less-equal",
2744
+ depthWrite: false,
2745
+ depthBias: 1e-4,
2746
+ width: options.lineWidth * options.supersampling
2747
+ }
2748
+ );
2749
+ }
2750
+ }
2751
+ }
2537
2752
  if (options.debugPoints?.length) {
2538
2753
  drawDebugPoints(
2539
2754
  renderer,
@@ -2554,7 +2769,7 @@ function renderDrawCalls(drawCalls, optionsInput = {}, imageFactory = createUint
2554
2769
  }
2555
2770
 
2556
2771
  // lib/gltf/createInfiniteGrid.ts
2557
- import { mat4 as mat47 } from "gl-matrix";
2772
+ import { mat4 as mat49 } from "gl-matrix";
2558
2773
  function createInfiniteGrid(options = {}) {
2559
2774
  const cellSize = options.cellSize ?? 1;
2560
2775
  const sectionSize = options.sectionSize ?? 10;
@@ -2578,9 +2793,9 @@ function createInfiniteGrid(options = {}) {
2578
2793
  size
2579
2794
  ]);
2580
2795
  const indices = new Uint32Array([0, 1, 2, 0, 2, 3]);
2581
- const model = mat47.create();
2796
+ const model = mat49.create();
2582
2797
  const offset = options.offset ?? {};
2583
- mat47.fromTranslation(model, [offset.x ?? 0, offset.y ?? 0, offset.z ?? 0]);
2798
+ mat49.fromTranslation(model, [offset.x ?? 0, offset.y ?? 0, offset.z ?? 0]);
2584
2799
  return {
2585
2800
  positions,
2586
2801
  indices,
@@ -2605,7 +2820,7 @@ function createInfiniteGrid(options = {}) {
2605
2820
  }
2606
2821
 
2607
2822
  // lib/gltf/createSceneFromGLTF.ts
2608
- import { mat4 as mat48 } from "gl-matrix";
2823
+ import { mat4 as mat410 } from "gl-matrix";
2609
2824
  var COMPONENT_INFO = {
2610
2825
  5120: {
2611
2826
  name: "BYTE",
@@ -2812,17 +3027,17 @@ function readIndices(ctx, accessorIndex) {
2812
3027
  }
2813
3028
  function nodeLocalMatrix(node) {
2814
3029
  if (node.matrix) {
2815
- const m2 = mat48.create();
3030
+ const m2 = mat410.create();
2816
3031
  for (let i = 0; i < 16; i++) m2[i] = node.matrix[i];
2817
3032
  return m2;
2818
3033
  }
2819
3034
  const translation = node.translation || [0, 0, 0];
2820
3035
  const rotation = node.rotation || [0, 0, 0, 1];
2821
3036
  const scale = node.scale || [1, 1, 1];
2822
- const m = mat48.create();
2823
- const tmp = mat48.create();
2824
- mat48.fromRotationTranslationScale(tmp, rotation, translation, scale);
2825
- mat48.copy(m, tmp);
3037
+ const m = mat410.create();
3038
+ const tmp = mat410.create();
3039
+ mat410.fromRotationTranslationScale(tmp, rotation, translation, scale);
3040
+ mat410.copy(m, tmp);
2826
3041
  return m;
2827
3042
  }
2828
3043
  function getMaterial(gltf, textures, images, materialIndex) {
@@ -2865,8 +3080,8 @@ function createSceneFromGLTF(gltf, resources) {
2865
3080
  const node = nodes[nodeIndex];
2866
3081
  if (!node) return;
2867
3082
  const local = nodeLocalMatrix(node);
2868
- const world = mat48.create();
2869
- mat48.multiply(world, parentMatrix, local);
3083
+ const world = mat410.create();
3084
+ mat410.multiply(world, parentMatrix, local);
2870
3085
  if (Number.isInteger(node.mesh)) {
2871
3086
  const mesh = meshes[node.mesh];
2872
3087
  if (mesh) {
@@ -2905,7 +3120,7 @@ function createSceneFromGLTF(gltf, resources) {
2905
3120
  traverse(childIndex, world);
2906
3121
  }
2907
3122
  }
2908
- const identity = mat48.create();
3123
+ const identity = mat410.create();
2909
3124
  for (const rootIndex of scene.nodes || []) {
2910
3125
  traverse(rootIndex, identity);
2911
3126
  }
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "poppygl",
3
3
  "main": "dist/index.js",
4
- "version": "0.0.35",
4
+ "version": "0.0.36",
5
5
  "repository": {
6
6
  "type": "git",
7
7
  "url": "https://github.com/tscircuit/poppygl.git"