poppygl 0.0.34 → 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
@@ -133,10 +133,44 @@ const png = await renderGLTFToPNGFromGLB(glb, {
133
133
  - `cull`: enable back-face culling (default `true`).
134
134
  - `backgroundColor`: a hex string such as `"#e5e7eb"`, or an RGB tuple with values from 0 to 1.
135
135
  - `grid`: show a ground grid (default `false`), or provide grid options.
136
+ - `showEdges`: draw visible boundary and crease edges on all triangle meshes (default `false`).
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
+ - `edgeWidth`: width of both visible and hidden mesh edges in output pixels (default `1`).
139
+ - `lineWidth`: width of glTF `LINES`, finite grid lines, and explosion guides in output pixels (default `1`).
140
+ - `hiddenEdgeOpacity`: opacity of dashed occluded mesh edges, from `0` to `1` (default `0.35`).
136
141
  - `fetchImpl`: optional override for resource loading (must match the `fetch` signature).
137
142
 
138
143
  You can inspect the defaults via `getDefaultRenderOptions()` or reuse the internal merge logic with `resolveRenderOptions()`.
139
144
 
145
+ ### Thicker edges and hidden geometry
146
+
147
+ Enable edges directly when rendering an ordinary glTF or GLB, without editing its metadata:
148
+
149
+ ```ts
150
+ const png = await renderGLTFToPNGFromGLB(glb, {
151
+ showHiddenEdges: true,
152
+ edgeWidth: 2.5,
153
+ hiddenEdgeOpacity: 0.35,
154
+ supersampling: 2,
155
+ backgroundColor: "#ffffff",
156
+ })
157
+ ```
158
+
159
+ Use `showEdges: true` for solid visible edges alone. Hidden segments use the
160
+ scene depth buffer and appear dashed; edge overlays never replace geometry depth.
161
+ Boundary and crease edges are extracted from the original triangle geometry,
162
+ excluding coplanar triangulation diagonals. Smooth curved silhouettes are not
163
+ additional extracted edges.
164
+
165
+ Widths stay constant in the final image when supersampling changes. Positive,
166
+ finite fractional widths are supported; invalid widths fall back to one pixel.
167
+ Wider strokes use antialiased screen-space coverage with round caps in the
168
+ software renderer. They are clipped before the perspective divide and remain
169
+ depth tested across their full width. No GPU or shader runtime is required.
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.
173
+
140
174
  ### Hidden edges for selected sub-models
141
175
 
142
176
  Set `showHiddenEdges` in a glTF node's `extras` to render that node with solid
@@ -205,6 +239,53 @@ const png = await encodePNG(bitmap)
205
239
 
206
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`).
207
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
+
208
289
  ## Additional utilities
209
290
 
210
291
  - `loadGLTFWithResourcesFromURL` returns `{ gltf, resources }` if you prefer to inspect or cache the parsed data before rendering.
package/dist/index.d.ts CHANGED
@@ -31,6 +31,8 @@ interface DrawCall {
31
31
  colors?: Float32Array | null;
32
32
  mode?: number;
33
33
  showHiddenEdges?: boolean;
34
+ /** Source triangle third vertices for each extracted edge segment. */
35
+ edgeAdjacentVertices?: number[][];
34
36
  }
35
37
  interface GridOptions {
36
38
  size?: number | readonly [number, number, number];
@@ -67,6 +69,21 @@ interface DebugPoint {
67
69
  z: number;
68
70
  };
69
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
+ }
70
87
  type CameraUp = "y+" | "y-" | "x+" | "x-" | "z+" | "z-";
71
88
  interface CameraRotation {
72
89
  x: number;
@@ -100,6 +117,17 @@ interface RenderOptions {
100
117
  debugFontSize?: number | null;
101
118
  debugPointColor?: readonly [number, number, number] | null;
102
119
  debugLabelColor?: readonly [number, number, number] | null;
120
+ explosionLines?: readonly ExplosionLine[] | null;
121
+ /** Draw visible boundary and crease edges on every triangle mesh. */
122
+ showEdges: boolean;
123
+ /** Override model showHiddenEdges flags; null preserves each model's flag. */
124
+ showHiddenEdges: boolean | null;
125
+ /** Width of visible and hidden mesh edges in output pixels. */
126
+ edgeWidth: number;
127
+ /** Width of glTF LINES, finite grid lines, and explosion guides in output pixels. */
128
+ lineWidth: number;
129
+ /** Opacity of dashed, occluded mesh edges, from 0 to 1. */
130
+ hiddenEdgeOpacity: number;
103
131
  }
104
132
  type RenderOptionsInput = Partial<RenderOptions>;
105
133
  declare const DEFAULT_RENDER_OPTIONS: RenderOptions;
@@ -195,6 +223,8 @@ interface LightSettings {
195
223
  ambient: number;
196
224
  }
197
225
  interface LineRenderOptions {
226
+ /** Stroke width in this renderer's raster pixels. Defaults to one. */
227
+ width?: number;
198
228
  depthCompare?: "less" | "less-equal" | "greater";
199
229
  depthWrite?: boolean;
200
230
  depthBias?: number;
@@ -249,4 +279,4 @@ declare function renderGLTFToPNGFromGLB(glb: ArrayBuffer | Uint8Array, options?:
249
279
 
250
280
  declare function renderSceneFromGLTF(scene: GLTFScene, options?: RenderOptionsInput, imageFactory?: ImageFactory): RenderResult;
251
281
 
252
- 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
@@ -271,7 +271,7 @@ function triangleNormal(positions, i0, i1, i2) {
271
271
  if (length === 0) return null;
272
272
  return [nx / length, ny / length, nz / length];
273
273
  }
274
- function addEdge(edges, positions, i0, i1, normal) {
274
+ function addEdge(edges, positions, i0, i1, thirdVertex, normal) {
275
275
  const key0 = positionKey(positions, i0);
276
276
  const key1 = positionKey(positions, i1);
277
277
  const isForward = key0 < key1;
@@ -280,8 +280,13 @@ function addEdge(edges, positions, i0, i1, normal) {
280
280
  const existing = edges.get(key);
281
281
  if (existing) {
282
282
  if (normal) existing.normals.push(normal);
283
+ existing.adjacentVertices.push(thirdVertex);
283
284
  } else {
284
- edges.set(key, { indices, normals: normal ? [normal] : [] });
285
+ edges.set(key, {
286
+ indices,
287
+ normals: normal ? [normal] : [],
288
+ adjacentVertices: [thirdVertex]
289
+ });
285
290
  }
286
291
  }
287
292
  function hasCrease(normals, minimumDot) {
@@ -307,15 +312,17 @@ function createEdgeDrawCall(mesh, creaseAngleDegrees = DEFAULT_CREASE_ANGLE_DEGR
307
312
  const i1 = triangleIndices[i + 1];
308
313
  const i2 = triangleIndices[i + 2];
309
314
  const normal = triangleNormal(mesh.positions, i0, i1, i2);
310
- addEdge(edges, mesh.positions, i0, i1, normal);
311
- addEdge(edges, mesh.positions, i1, i2, normal);
312
- addEdge(edges, mesh.positions, i2, i0, normal);
315
+ addEdge(edges, mesh.positions, i0, i1, i2, normal);
316
+ addEdge(edges, mesh.positions, i1, i2, i0, normal);
317
+ addEdge(edges, mesh.positions, i2, i0, i1, normal);
313
318
  }
314
319
  const minimumDot = Math.cos(creaseAngleDegrees * Math.PI / 180);
315
320
  const edgeIndices = [];
321
+ const edgeAdjacentVertices = [];
316
322
  for (const edge2 of edges.values()) {
317
323
  if (!hasCrease(edge2.normals, minimumDot)) continue;
318
324
  edgeIndices.push(edge2.indices[0], edge2.indices[1]);
325
+ edgeAdjacentVertices.push(edge2.adjacentVertices);
319
326
  }
320
327
  if (edgeIndices.length === 0) return null;
321
328
  return {
@@ -329,7 +336,8 @@ function createEdgeDrawCall(mesh, creaseAngleDegrees = DEFAULT_CREASE_ANGLE_DEGR
329
336
  baseColorTexture: null,
330
337
  alphaMode: "BLEND"
331
338
  },
332
- mode: 1
339
+ mode: 1,
340
+ edgeAdjacentVertices
333
341
  };
334
342
  }
335
343
 
@@ -490,7 +498,13 @@ var DEFAULT_RENDER_OPTIONS = {
490
498
  debugPoints: null,
491
499
  debugFontSize: null,
492
500
  debugPointColor: null,
493
- debugLabelColor: null
501
+ debugLabelColor: null,
502
+ explosionLines: null,
503
+ showEdges: false,
504
+ showHiddenEdges: null,
505
+ edgeWidth: 1,
506
+ lineWidth: 1,
507
+ hiddenEdgeOpacity: 0.35
494
508
  };
495
509
  function getDefaultRenderOptions() {
496
510
  return { ...DEFAULT_RENDER_OPTIONS };
@@ -679,6 +693,98 @@ var GeometryDenoiser = class {
679
693
  }
680
694
  };
681
695
 
696
+ // lib/render/rasterizeWideLine.ts
697
+ function clipLine(start, end, marginX, marginY) {
698
+ if (![...start, ...end].every(Number.isFinite)) return null;
699
+ const distances = (p) => [
700
+ p[3] * (1 + marginX) + p[0],
701
+ p[3] * (1 + marginX) - p[0],
702
+ p[3] * (1 + marginY) + p[1],
703
+ p[3] * (1 + marginY) - p[1],
704
+ p[3] + p[2],
705
+ p[3] - p[2]
706
+ ];
707
+ const a = distances(start);
708
+ const b = distances(end);
709
+ let from = 0;
710
+ let to = 1;
711
+ for (let plane = 0; plane < 6; plane++) {
712
+ if (a[plane] < 0 && b[plane] < 0) return null;
713
+ if (a[plane] < 0 !== b[plane] < 0) {
714
+ const t = a[plane] / (a[plane] - b[plane]);
715
+ if (a[plane] < 0) from = Math.max(from, t);
716
+ else to = Math.min(to, t);
717
+ }
718
+ }
719
+ if (from > to) return null;
720
+ return [from, to].map(
721
+ (t) => Array.from(start, (value, axis) => value + t * (end[axis] - value))
722
+ );
723
+ }
724
+ function rasterizeWideLine(start, end, width, height, strokeWidth, dashed, dashLength, fragment) {
725
+ const radius = strokeWidth / 2;
726
+ const padding = radius + 0.5;
727
+ const clipped = clipLine(
728
+ start,
729
+ end,
730
+ 2 * padding / Math.max(1, width - 1),
731
+ 2 * padding / Math.max(1, height - 1)
732
+ );
733
+ if (!clipped) return;
734
+ const project = (p) => [
735
+ (p[0] / p[3] * 0.5 + 0.5) * (width - 1),
736
+ (0.5 - p[1] / p[3] * 0.5) * (height - 1),
737
+ // Clipping guarantees this range; absorb near/far-plane roundoff so an
738
+ // endpoint's round cap is not discarded at z = -epsilon.
739
+ Math.max(0, Math.min(1, p[2] / p[3] * 0.5 + 0.5))
740
+ ];
741
+ const a = project(clipped[0]);
742
+ const b = project(clipped[1]);
743
+ if (![...a, ...b].every(Number.isFinite)) return;
744
+ const [x0, y0, z0] = a;
745
+ const [x1, y1, z1] = b;
746
+ const dx = x1 - x0;
747
+ const dy = y1 - y0;
748
+ const lengthSquared = dx * dx + dy * dy;
749
+ const length = Math.sqrt(lengthSquared);
750
+ const original = start[3] > 0 ? project(start) : a;
751
+ const phase = Math.hypot(x0 - original[0], y0 - original[1]);
752
+ const drawPixel = (x, y) => {
753
+ const t = lengthSquared === 0 ? 0 : Math.max(
754
+ 0,
755
+ Math.min(1, ((x - x0) * dx + (y - y0) * dy) / lengthSquared)
756
+ );
757
+ if (dashed && Math.floor((phase + t * length) / dashLength) % 2 === 1)
758
+ return;
759
+ const distance = Math.hypot(x - (x0 + t * dx), y - (y0 + t * dy));
760
+ const coverage = Math.min(1, padding - distance);
761
+ if (coverage > 0) fragment(x, y, z0 + t * (z1 - z0), coverage);
762
+ };
763
+ if (Math.abs(dx) >= Math.abs(dy)) {
764
+ const band = length === 0 ? padding : padding * length / Math.abs(dx);
765
+ const first = Math.max(0, Math.ceil(Math.min(x0, x1) - padding));
766
+ const last = Math.min(width - 1, Math.floor(Math.max(x0, x1) + padding));
767
+ for (let x = first; x <= last; x++) {
768
+ const t = dx === 0 ? 0 : Math.max(0, Math.min(1, (x - x0) / dx));
769
+ const centerY = y0 + t * dy;
770
+ const top = Math.max(0, Math.ceil(centerY - band));
771
+ const bottom = Math.min(height - 1, Math.floor(centerY + band));
772
+ for (let y = top; y <= bottom; y++) drawPixel(x, y);
773
+ }
774
+ } else {
775
+ const band = padding * length / Math.abs(dy);
776
+ const first = Math.max(0, Math.ceil(Math.min(y0, y1) - padding));
777
+ const last = Math.min(height - 1, Math.floor(Math.max(y0, y1) + padding));
778
+ for (let y = first; y <= last; y++) {
779
+ const t = Math.max(0, Math.min(1, (y - y0) / dy));
780
+ const centerX = x0 + t * dx;
781
+ const left = Math.max(0, Math.ceil(centerX - band));
782
+ const right = Math.min(width - 1, Math.floor(centerX + band));
783
+ for (let x = left; x <= right; x++) drawPixel(x, y);
784
+ }
785
+ }
786
+ }
787
+
682
788
  // lib/render/SoftwareRenderer.ts
683
789
  var SoftwareRenderer = class {
684
790
  width;
@@ -731,6 +837,7 @@ var SoftwareRenderer = class {
731
837
  const vScreen = new Array(vertexCount);
732
838
  const vInvW = new Float32Array(vertexCount);
733
839
  const vNDCz = new Float32Array(vertexCount);
840
+ const clipVertices = [];
734
841
  for (let i = 0; i < vertexCount; i++) {
735
842
  const p = vec42.fromValues(
736
843
  positions[i * 3 + 0],
@@ -740,6 +847,7 @@ var SoftwareRenderer = class {
740
847
  );
741
848
  const c = vec42.create();
742
849
  vec42.transformMat4(c, p, mvp);
850
+ clipVertices.push(c);
743
851
  const invW = 1 / c[3];
744
852
  vInvW[i] = invW;
745
853
  if (!isFinite(invW)) {
@@ -774,9 +882,76 @@ var SoftwareRenderer = class {
774
882
  const depthWrite = options.depthWrite ?? true;
775
883
  const depthBias = options.depthBias ?? 0;
776
884
  const dashLength = Math.max(1, options.dashLength ?? 4);
885
+ const strokeWidth = typeof options.width === "number" && Number.isFinite(options.width) && options.width > 0 ? options.width : 1;
886
+ const drawFragment = (xi, yi, z, coverage = 1) => {
887
+ if (z < 0 || z > 1) return;
888
+ const di = yi * this.width + xi;
889
+ const currentDepth = this.depth[di];
890
+ const passesDepth = depthCompare === "greater" ? z > currentDepth + depthBias : depthCompare === "less-equal" ? z <= currentDepth + depthBias : z < currentDepth - depthBias;
891
+ if (!passesDepth) return;
892
+ if (depthWrite) this.depth[di] = z;
893
+ const pixelIndex = di * 4;
894
+ const destinationAlpha = this.buffer[pixelIndex + 3] / 255;
895
+ const alpha = sourceAlpha * coverage;
896
+ const oneMinusSourceAlpha = 1 - alpha;
897
+ const outAlpha = alpha + destinationAlpha * oneMinusSourceAlpha;
898
+ const blend = (channel, offset) => outAlpha === 0 ? 0 : (channel * alpha + this.buffer[pixelIndex + offset] * destinationAlpha * oneMinusSourceAlpha) / outAlpha;
899
+ this.setPixel(
900
+ xi,
901
+ yi,
902
+ blend(r255, 0),
903
+ blend(g255, 1),
904
+ blend(b255, 2),
905
+ outAlpha * 255
906
+ );
907
+ };
777
908
  for (let i = 0; i < indices.length; i += 2) {
778
909
  const i0 = indices[i + 0];
779
910
  const i1 = indices[i + 1];
911
+ if (strokeWidth !== 1) {
912
+ const sourceFaces = (mesh.edgeAdjacentVertices?.[i / 2] ?? []).flatMap(
913
+ (third) => {
914
+ if (clipVertices[i0][3] <= 0 || clipVertices[i1][3] <= 0 || clipVertices[third][3] <= 0)
915
+ return [];
916
+ const a2 = vScreen[i0];
917
+ const b2 = vScreen[i1];
918
+ const c = vScreen[third];
919
+ const area = edge(a2, b2, c);
920
+ if (!Number.isFinite(area) || area === 0) return [];
921
+ return [{ a: a2, b: b2, c, inverseArea: 1 / area, third }];
922
+ }
923
+ );
924
+ rasterizeWideLine(
925
+ clipVertices[i0],
926
+ clipVertices[i1],
927
+ this.width,
928
+ this.height,
929
+ strokeWidth,
930
+ options.dashed ?? false,
931
+ dashLength,
932
+ sourceFaces.length === 0 ? drawFragment : (x2, y2, z2, coverage) => {
933
+ let surfaceDepth = Infinity;
934
+ const sample = [x2 + 0.5, y2 + 0.5];
935
+ for (const face of sourceFaces) {
936
+ const l0 = edge(face.b, face.c, sample) * face.inverseArea;
937
+ const l1 = edge(face.c, face.a, sample) * face.inverseArea;
938
+ const l2 = 1 - l0 - l1;
939
+ if (l0 < -1e-6 || l1 < -1e-6 || l2 < -1e-6) continue;
940
+ const depth = (l0 * vNDCz[i0] + l1 * vNDCz[i1] + l2 * vNDCz[face.third]) * 0.5 + 0.5;
941
+ if (depth >= 0 && depth <= 1) {
942
+ surfaceDepth = Math.min(surfaceDepth, depth);
943
+ }
944
+ }
945
+ drawFragment(
946
+ x2,
947
+ y2,
948
+ surfaceDepth === Infinity ? z2 : surfaceDepth,
949
+ coverage
950
+ );
951
+ }
952
+ );
953
+ continue;
954
+ }
780
955
  const v0s = vScreen[i0];
781
956
  const v1s = vScreen[i1];
782
957
  const z0_ndc = vNDCz[i0];
@@ -809,20 +984,7 @@ var SoftwareRenderer = class {
809
984
  const xi = Math.round(x);
810
985
  const yi = Math.round(y);
811
986
  if (xi >= 0 && xi < this.width && yi >= 0 && yi < this.height && z >= 0 && z <= 1) {
812
- const di = yi * this.width + xi;
813
- const currentDepth = this.depth[di];
814
- const passesDepth = depthCompare === "greater" ? z > currentDepth + depthBias : depthCompare === "less-equal" ? z <= currentDepth + depthBias : z < currentDepth - depthBias;
815
- if (passesDepth) {
816
- if (depthWrite) this.depth[di] = z;
817
- const pixelIndex = di * 4;
818
- const destinationAlpha = this.buffer[pixelIndex + 3] / 255;
819
- const oneMinusSourceAlpha = 1 - sourceAlpha;
820
- const outAlpha = sourceAlpha + destinationAlpha * oneMinusSourceAlpha;
821
- const outR = outAlpha === 0 ? 0 : (r255 * sourceAlpha + this.buffer[pixelIndex + 0] * destinationAlpha * oneMinusSourceAlpha) / outAlpha;
822
- const outG = outAlpha === 0 ? 0 : (g255 * sourceAlpha + this.buffer[pixelIndex + 1] * destinationAlpha * oneMinusSourceAlpha) / outAlpha;
823
- const outB = outAlpha === 0 ? 0 : (b255 * sourceAlpha + this.buffer[pixelIndex + 2] * destinationAlpha * oneMinusSourceAlpha) / outAlpha;
824
- this.setPixel(xi, yi, outR, outG, outB, outAlpha * 255);
825
- }
987
+ drawFragment(xi, yi, z);
826
988
  }
827
989
  x += xinc;
828
990
  y += yinc;
@@ -1495,9 +1657,17 @@ function resolveRenderOptions(options = {}) {
1495
1657
  camPos: options.camPos !== void 0 ? options.camPos : DEFAULT_RENDER_OPTIONS.camPos,
1496
1658
  lookAt: options.lookAt !== void 0 ? options.lookAt : DEFAULT_RENDER_OPTIONS.lookAt,
1497
1659
  up: resolveCameraUp(options.up),
1498
- cameraRotation: resolveCameraRotation(options.cameraRotation)
1660
+ cameraRotation: resolveCameraRotation(options.cameraRotation),
1661
+ showEdges: options.showEdges === true,
1662
+ showHiddenEdges: typeof options.showHiddenEdges === "boolean" ? options.showHiddenEdges : null,
1663
+ edgeWidth: resolveWidth(options.edgeWidth),
1664
+ lineWidth: resolveWidth(options.lineWidth),
1665
+ hiddenEdgeOpacity: typeof options.hiddenEdgeOpacity === "number" && Number.isFinite(options.hiddenEdgeOpacity) ? Math.max(0, Math.min(1, options.hiddenEdgeOpacity)) : DEFAULT_RENDER_OPTIONS.hiddenEdgeOpacity
1499
1666
  };
1500
1667
  }
1668
+ function resolveWidth(width) {
1669
+ return typeof width === "number" && Number.isFinite(width) && width > 0 ? width : 1;
1670
+ }
1501
1671
 
1502
1672
  // lib/render/StudioLighting.ts
1503
1673
  import { mat4 as mat46 } from "gl-matrix";
@@ -2200,13 +2370,190 @@ var StudioLighting = class {
2200
2370
  }
2201
2371
  };
2202
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
+
2203
2547
  // lib/render/renderDrawCalls.ts
2204
2548
  function renderDrawCalls(drawCalls, optionsInput = {}, imageFactory = createUint8Bitmap) {
2205
2549
  const options = resolveRenderOptions(optionsInput);
2206
2550
  const renderWidth = options.width * options.supersampling;
2207
2551
  const renderHeight = options.height * options.supersampling;
2552
+ const explosionDrawCalls = (options.explosionLines ?? []).map(
2553
+ createExplosionLineDrawCall
2554
+ );
2208
2555
  const camera = buildCamera(
2209
- drawCalls,
2556
+ [...drawCalls, ...explosionDrawCalls],
2210
2557
  renderWidth,
2211
2558
  renderHeight,
2212
2559
  options.fov,
@@ -2306,7 +2653,9 @@ function renderDrawCalls(drawCalls, optionsInput = {}, imageFactory = createUint
2306
2653
  for (let i = 0; i < dcs.length; i++) {
2307
2654
  const dc = dcs[i];
2308
2655
  if (dc.mode === 1) {
2309
- renderer.drawLines(dc, camera, options.gamma);
2656
+ renderer.drawLines(dc, camera, options.gamma, {
2657
+ width: options.lineWidth * options.supersampling
2658
+ });
2310
2659
  } else {
2311
2660
  renderer.drawMesh(
2312
2661
  dc,
@@ -2342,23 +2691,64 @@ function renderDrawCalls(drawCalls, optionsInput = {}, imageFactory = createUint
2342
2691
  renderGroup(blendDraws);
2343
2692
  renderer.denoise(options.gamma);
2344
2693
  for (const dc of drawCalls) {
2345
- if (!dc.showHiddenEdges) continue;
2694
+ const showHiddenEdges = options.showHiddenEdges ?? dc.showHiddenEdges;
2695
+ if (!options.showEdges && !showHiddenEdges) continue;
2346
2696
  const edgeDrawCall = createEdgeDrawCall(dc);
2347
2697
  if (!edgeDrawCall) continue;
2348
2698
  renderer.drawLines(edgeDrawCall, camera, options.gamma, {
2349
2699
  depthCompare: "less-equal",
2350
2700
  depthWrite: false,
2351
- depthBias: 1e-4
2701
+ depthBias: 1e-4,
2702
+ width: options.edgeWidth * options.supersampling
2352
2703
  });
2704
+ if (!showHiddenEdges) continue;
2353
2705
  renderer.drawLines(edgeDrawCall, camera, options.gamma, {
2354
2706
  depthCompare: "greater",
2355
2707
  depthWrite: false,
2356
2708
  depthBias: 1e-4,
2357
2709
  dashed: true,
2358
2710
  dashLength: 4 * options.supersampling,
2359
- opacity: 0.35
2711
+ opacity: options.hiddenEdgeOpacity,
2712
+ width: options.edgeWidth * options.supersampling
2360
2713
  });
2361
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
+ }
2362
2752
  if (options.debugPoints?.length) {
2363
2753
  drawDebugPoints(
2364
2754
  renderer,
@@ -2379,7 +2769,7 @@ function renderDrawCalls(drawCalls, optionsInput = {}, imageFactory = createUint
2379
2769
  }
2380
2770
 
2381
2771
  // lib/gltf/createInfiniteGrid.ts
2382
- import { mat4 as mat47 } from "gl-matrix";
2772
+ import { mat4 as mat49 } from "gl-matrix";
2383
2773
  function createInfiniteGrid(options = {}) {
2384
2774
  const cellSize = options.cellSize ?? 1;
2385
2775
  const sectionSize = options.sectionSize ?? 10;
@@ -2403,9 +2793,9 @@ function createInfiniteGrid(options = {}) {
2403
2793
  size
2404
2794
  ]);
2405
2795
  const indices = new Uint32Array([0, 1, 2, 0, 2, 3]);
2406
- const model = mat47.create();
2796
+ const model = mat49.create();
2407
2797
  const offset = options.offset ?? {};
2408
- 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]);
2409
2799
  return {
2410
2800
  positions,
2411
2801
  indices,
@@ -2430,7 +2820,7 @@ function createInfiniteGrid(options = {}) {
2430
2820
  }
2431
2821
 
2432
2822
  // lib/gltf/createSceneFromGLTF.ts
2433
- import { mat4 as mat48 } from "gl-matrix";
2823
+ import { mat4 as mat410 } from "gl-matrix";
2434
2824
  var COMPONENT_INFO = {
2435
2825
  5120: {
2436
2826
  name: "BYTE",
@@ -2637,17 +3027,17 @@ function readIndices(ctx, accessorIndex) {
2637
3027
  }
2638
3028
  function nodeLocalMatrix(node) {
2639
3029
  if (node.matrix) {
2640
- const m2 = mat48.create();
3030
+ const m2 = mat410.create();
2641
3031
  for (let i = 0; i < 16; i++) m2[i] = node.matrix[i];
2642
3032
  return m2;
2643
3033
  }
2644
3034
  const translation = node.translation || [0, 0, 0];
2645
3035
  const rotation = node.rotation || [0, 0, 0, 1];
2646
3036
  const scale = node.scale || [1, 1, 1];
2647
- const m = mat48.create();
2648
- const tmp = mat48.create();
2649
- mat48.fromRotationTranslationScale(tmp, rotation, translation, scale);
2650
- 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);
2651
3041
  return m;
2652
3042
  }
2653
3043
  function getMaterial(gltf, textures, images, materialIndex) {
@@ -2690,8 +3080,8 @@ function createSceneFromGLTF(gltf, resources) {
2690
3080
  const node = nodes[nodeIndex];
2691
3081
  if (!node) return;
2692
3082
  const local = nodeLocalMatrix(node);
2693
- const world = mat48.create();
2694
- mat48.multiply(world, parentMatrix, local);
3083
+ const world = mat410.create();
3084
+ mat410.multiply(world, parentMatrix, local);
2695
3085
  if (Number.isInteger(node.mesh)) {
2696
3086
  const mesh = meshes[node.mesh];
2697
3087
  if (mesh) {
@@ -2730,7 +3120,7 @@ function createSceneFromGLTF(gltf, resources) {
2730
3120
  traverse(childIndex, world);
2731
3121
  }
2732
3122
  }
2733
- const identity = mat48.create();
3123
+ const identity = mat410.create();
2734
3124
  for (const rootIndex of scene.nodes || []) {
2735
3125
  traverse(rootIndex, identity);
2736
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.34",
4
+ "version": "0.0.36",
5
5
  "repository": {
6
6
  "type": "git",
7
7
  "url": "https://github.com/tscircuit/poppygl.git"