poppygl 0.0.34 → 0.0.35

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,43 @@ 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` and finite grid lines 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 and finite grid lines;
171
+ the procedural infinite grid keeps its existing independent styling.
172
+
140
173
  ### Hidden edges for selected sub-models
141
174
 
142
175
  Set `showHiddenEdges` in a glTF node's `extras` to render that node with solid
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];
@@ -100,6 +102,16 @@ interface RenderOptions {
100
102
  debugFontSize?: number | null;
101
103
  debugPointColor?: readonly [number, number, number] | null;
102
104
  debugLabelColor?: readonly [number, number, number] | null;
105
+ /** Draw visible boundary and crease edges on every triangle mesh. */
106
+ showEdges: boolean;
107
+ /** Override model showHiddenEdges flags; null preserves each model's flag. */
108
+ showHiddenEdges: boolean | null;
109
+ /** Width of visible and hidden mesh edges in output pixels. */
110
+ edgeWidth: number;
111
+ /** Width of glTF LINES and finite grid lines in output pixels. */
112
+ lineWidth: number;
113
+ /** Opacity of dashed, occluded mesh edges, from 0 to 1. */
114
+ hiddenEdgeOpacity: number;
103
115
  }
104
116
  type RenderOptionsInput = Partial<RenderOptions>;
105
117
  declare const DEFAULT_RENDER_OPTIONS: RenderOptions;
@@ -195,6 +207,8 @@ interface LightSettings {
195
207
  ambient: number;
196
208
  }
197
209
  interface LineRenderOptions {
210
+ /** Stroke width in this renderer's raster pixels. Defaults to one. */
211
+ width?: number;
198
212
  depthCompare?: "less" | "less-equal" | "greater";
199
213
  depthWrite?: boolean;
200
214
  depthBias?: number;
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,12 @@ var DEFAULT_RENDER_OPTIONS = {
490
498
  debugPoints: null,
491
499
  debugFontSize: null,
492
500
  debugPointColor: null,
493
- debugLabelColor: null
501
+ debugLabelColor: null,
502
+ showEdges: false,
503
+ showHiddenEdges: null,
504
+ edgeWidth: 1,
505
+ lineWidth: 1,
506
+ hiddenEdgeOpacity: 0.35
494
507
  };
495
508
  function getDefaultRenderOptions() {
496
509
  return { ...DEFAULT_RENDER_OPTIONS };
@@ -679,6 +692,98 @@ var GeometryDenoiser = class {
679
692
  }
680
693
  };
681
694
 
695
+ // lib/render/rasterizeWideLine.ts
696
+ function clipLine(start, end, marginX, marginY) {
697
+ if (![...start, ...end].every(Number.isFinite)) return null;
698
+ const distances = (p) => [
699
+ p[3] * (1 + marginX) + p[0],
700
+ p[3] * (1 + marginX) - p[0],
701
+ p[3] * (1 + marginY) + p[1],
702
+ p[3] * (1 + marginY) - p[1],
703
+ p[3] + p[2],
704
+ p[3] - p[2]
705
+ ];
706
+ const a = distances(start);
707
+ const b = distances(end);
708
+ let from = 0;
709
+ let to = 1;
710
+ for (let plane = 0; plane < 6; plane++) {
711
+ if (a[plane] < 0 && b[plane] < 0) return null;
712
+ if (a[plane] < 0 !== b[plane] < 0) {
713
+ const t = a[plane] / (a[plane] - b[plane]);
714
+ if (a[plane] < 0) from = Math.max(from, t);
715
+ else to = Math.min(to, t);
716
+ }
717
+ }
718
+ if (from > to) return null;
719
+ return [from, to].map(
720
+ (t) => Array.from(start, (value, axis) => value + t * (end[axis] - value))
721
+ );
722
+ }
723
+ function rasterizeWideLine(start, end, width, height, strokeWidth, dashed, dashLength, fragment) {
724
+ const radius = strokeWidth / 2;
725
+ const padding = radius + 0.5;
726
+ const clipped = clipLine(
727
+ start,
728
+ end,
729
+ 2 * padding / Math.max(1, width - 1),
730
+ 2 * padding / Math.max(1, height - 1)
731
+ );
732
+ if (!clipped) return;
733
+ const project = (p) => [
734
+ (p[0] / p[3] * 0.5 + 0.5) * (width - 1),
735
+ (0.5 - p[1] / p[3] * 0.5) * (height - 1),
736
+ // Clipping guarantees this range; absorb near/far-plane roundoff so an
737
+ // endpoint's round cap is not discarded at z = -epsilon.
738
+ Math.max(0, Math.min(1, p[2] / p[3] * 0.5 + 0.5))
739
+ ];
740
+ const a = project(clipped[0]);
741
+ const b = project(clipped[1]);
742
+ if (![...a, ...b].every(Number.isFinite)) return;
743
+ const [x0, y0, z0] = a;
744
+ const [x1, y1, z1] = b;
745
+ const dx = x1 - x0;
746
+ const dy = y1 - y0;
747
+ const lengthSquared = dx * dx + dy * dy;
748
+ const length = Math.sqrt(lengthSquared);
749
+ const original = start[3] > 0 ? project(start) : a;
750
+ const phase = Math.hypot(x0 - original[0], y0 - original[1]);
751
+ const drawPixel = (x, y) => {
752
+ const t = lengthSquared === 0 ? 0 : Math.max(
753
+ 0,
754
+ Math.min(1, ((x - x0) * dx + (y - y0) * dy) / lengthSquared)
755
+ );
756
+ if (dashed && Math.floor((phase + t * length) / dashLength) % 2 === 1)
757
+ return;
758
+ const distance = Math.hypot(x - (x0 + t * dx), y - (y0 + t * dy));
759
+ const coverage = Math.min(1, padding - distance);
760
+ if (coverage > 0) fragment(x, y, z0 + t * (z1 - z0), coverage);
761
+ };
762
+ if (Math.abs(dx) >= Math.abs(dy)) {
763
+ const band = length === 0 ? padding : padding * length / Math.abs(dx);
764
+ const first = Math.max(0, Math.ceil(Math.min(x0, x1) - padding));
765
+ const last = Math.min(width - 1, Math.floor(Math.max(x0, x1) + padding));
766
+ for (let x = first; x <= last; x++) {
767
+ const t = dx === 0 ? 0 : Math.max(0, Math.min(1, (x - x0) / dx));
768
+ const centerY = y0 + t * dy;
769
+ const top = Math.max(0, Math.ceil(centerY - band));
770
+ const bottom = Math.min(height - 1, Math.floor(centerY + band));
771
+ for (let y = top; y <= bottom; y++) drawPixel(x, y);
772
+ }
773
+ } else {
774
+ const band = padding * length / Math.abs(dy);
775
+ const first = Math.max(0, Math.ceil(Math.min(y0, y1) - padding));
776
+ const last = Math.min(height - 1, Math.floor(Math.max(y0, y1) + padding));
777
+ for (let y = first; y <= last; y++) {
778
+ const t = Math.max(0, Math.min(1, (y - y0) / dy));
779
+ const centerX = x0 + t * dx;
780
+ const left = Math.max(0, Math.ceil(centerX - band));
781
+ const right = Math.min(width - 1, Math.floor(centerX + band));
782
+ for (let x = left; x <= right; x++) drawPixel(x, y);
783
+ }
784
+ }
785
+ }
786
+
682
787
  // lib/render/SoftwareRenderer.ts
683
788
  var SoftwareRenderer = class {
684
789
  width;
@@ -731,6 +836,7 @@ var SoftwareRenderer = class {
731
836
  const vScreen = new Array(vertexCount);
732
837
  const vInvW = new Float32Array(vertexCount);
733
838
  const vNDCz = new Float32Array(vertexCount);
839
+ const clipVertices = [];
734
840
  for (let i = 0; i < vertexCount; i++) {
735
841
  const p = vec42.fromValues(
736
842
  positions[i * 3 + 0],
@@ -740,6 +846,7 @@ var SoftwareRenderer = class {
740
846
  );
741
847
  const c = vec42.create();
742
848
  vec42.transformMat4(c, p, mvp);
849
+ clipVertices.push(c);
743
850
  const invW = 1 / c[3];
744
851
  vInvW[i] = invW;
745
852
  if (!isFinite(invW)) {
@@ -774,9 +881,76 @@ var SoftwareRenderer = class {
774
881
  const depthWrite = options.depthWrite ?? true;
775
882
  const depthBias = options.depthBias ?? 0;
776
883
  const dashLength = Math.max(1, options.dashLength ?? 4);
884
+ const strokeWidth = typeof options.width === "number" && Number.isFinite(options.width) && options.width > 0 ? options.width : 1;
885
+ const drawFragment = (xi, yi, z, coverage = 1) => {
886
+ if (z < 0 || z > 1) return;
887
+ const di = yi * this.width + xi;
888
+ const currentDepth = this.depth[di];
889
+ const passesDepth = depthCompare === "greater" ? z > currentDepth + depthBias : depthCompare === "less-equal" ? z <= currentDepth + depthBias : z < currentDepth - depthBias;
890
+ if (!passesDepth) return;
891
+ if (depthWrite) this.depth[di] = z;
892
+ const pixelIndex = di * 4;
893
+ const destinationAlpha = this.buffer[pixelIndex + 3] / 255;
894
+ const alpha = sourceAlpha * coverage;
895
+ const oneMinusSourceAlpha = 1 - alpha;
896
+ const outAlpha = alpha + destinationAlpha * oneMinusSourceAlpha;
897
+ const blend = (channel, offset) => outAlpha === 0 ? 0 : (channel * alpha + this.buffer[pixelIndex + offset] * destinationAlpha * oneMinusSourceAlpha) / outAlpha;
898
+ this.setPixel(
899
+ xi,
900
+ yi,
901
+ blend(r255, 0),
902
+ blend(g255, 1),
903
+ blend(b255, 2),
904
+ outAlpha * 255
905
+ );
906
+ };
777
907
  for (let i = 0; i < indices.length; i += 2) {
778
908
  const i0 = indices[i + 0];
779
909
  const i1 = indices[i + 1];
910
+ if (strokeWidth !== 1) {
911
+ const sourceFaces = (mesh.edgeAdjacentVertices?.[i / 2] ?? []).flatMap(
912
+ (third) => {
913
+ if (clipVertices[i0][3] <= 0 || clipVertices[i1][3] <= 0 || clipVertices[third][3] <= 0)
914
+ return [];
915
+ const a2 = vScreen[i0];
916
+ const b2 = vScreen[i1];
917
+ const c = vScreen[third];
918
+ const area = edge(a2, b2, c);
919
+ if (!Number.isFinite(area) || area === 0) return [];
920
+ return [{ a: a2, b: b2, c, inverseArea: 1 / area, third }];
921
+ }
922
+ );
923
+ rasterizeWideLine(
924
+ clipVertices[i0],
925
+ clipVertices[i1],
926
+ this.width,
927
+ this.height,
928
+ strokeWidth,
929
+ options.dashed ?? false,
930
+ dashLength,
931
+ sourceFaces.length === 0 ? drawFragment : (x2, y2, z2, coverage) => {
932
+ let surfaceDepth = Infinity;
933
+ const sample = [x2 + 0.5, y2 + 0.5];
934
+ for (const face of sourceFaces) {
935
+ const l0 = edge(face.b, face.c, sample) * face.inverseArea;
936
+ const l1 = edge(face.c, face.a, sample) * face.inverseArea;
937
+ const l2 = 1 - l0 - l1;
938
+ if (l0 < -1e-6 || l1 < -1e-6 || l2 < -1e-6) continue;
939
+ const depth = (l0 * vNDCz[i0] + l1 * vNDCz[i1] + l2 * vNDCz[face.third]) * 0.5 + 0.5;
940
+ if (depth >= 0 && depth <= 1) {
941
+ surfaceDepth = Math.min(surfaceDepth, depth);
942
+ }
943
+ }
944
+ drawFragment(
945
+ x2,
946
+ y2,
947
+ surfaceDepth === Infinity ? z2 : surfaceDepth,
948
+ coverage
949
+ );
950
+ }
951
+ );
952
+ continue;
953
+ }
780
954
  const v0s = vScreen[i0];
781
955
  const v1s = vScreen[i1];
782
956
  const z0_ndc = vNDCz[i0];
@@ -809,20 +983,7 @@ var SoftwareRenderer = class {
809
983
  const xi = Math.round(x);
810
984
  const yi = Math.round(y);
811
985
  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
- }
986
+ drawFragment(xi, yi, z);
826
987
  }
827
988
  x += xinc;
828
989
  y += yinc;
@@ -1495,9 +1656,17 @@ function resolveRenderOptions(options = {}) {
1495
1656
  camPos: options.camPos !== void 0 ? options.camPos : DEFAULT_RENDER_OPTIONS.camPos,
1496
1657
  lookAt: options.lookAt !== void 0 ? options.lookAt : DEFAULT_RENDER_OPTIONS.lookAt,
1497
1658
  up: resolveCameraUp(options.up),
1498
- cameraRotation: resolveCameraRotation(options.cameraRotation)
1659
+ cameraRotation: resolveCameraRotation(options.cameraRotation),
1660
+ showEdges: options.showEdges === true,
1661
+ showHiddenEdges: typeof options.showHiddenEdges === "boolean" ? options.showHiddenEdges : null,
1662
+ edgeWidth: resolveWidth(options.edgeWidth),
1663
+ lineWidth: resolveWidth(options.lineWidth),
1664
+ hiddenEdgeOpacity: typeof options.hiddenEdgeOpacity === "number" && Number.isFinite(options.hiddenEdgeOpacity) ? Math.max(0, Math.min(1, options.hiddenEdgeOpacity)) : DEFAULT_RENDER_OPTIONS.hiddenEdgeOpacity
1499
1665
  };
1500
1666
  }
1667
+ function resolveWidth(width) {
1668
+ return typeof width === "number" && Number.isFinite(width) && width > 0 ? width : 1;
1669
+ }
1501
1670
 
1502
1671
  // lib/render/StudioLighting.ts
1503
1672
  import { mat4 as mat46 } from "gl-matrix";
@@ -2306,7 +2475,9 @@ function renderDrawCalls(drawCalls, optionsInput = {}, imageFactory = createUint
2306
2475
  for (let i = 0; i < dcs.length; i++) {
2307
2476
  const dc = dcs[i];
2308
2477
  if (dc.mode === 1) {
2309
- renderer.drawLines(dc, camera, options.gamma);
2478
+ renderer.drawLines(dc, camera, options.gamma, {
2479
+ width: options.lineWidth * options.supersampling
2480
+ });
2310
2481
  } else {
2311
2482
  renderer.drawMesh(
2312
2483
  dc,
@@ -2342,21 +2513,25 @@ function renderDrawCalls(drawCalls, optionsInput = {}, imageFactory = createUint
2342
2513
  renderGroup(blendDraws);
2343
2514
  renderer.denoise(options.gamma);
2344
2515
  for (const dc of drawCalls) {
2345
- if (!dc.showHiddenEdges) continue;
2516
+ const showHiddenEdges = options.showHiddenEdges ?? dc.showHiddenEdges;
2517
+ if (!options.showEdges && !showHiddenEdges) continue;
2346
2518
  const edgeDrawCall = createEdgeDrawCall(dc);
2347
2519
  if (!edgeDrawCall) continue;
2348
2520
  renderer.drawLines(edgeDrawCall, camera, options.gamma, {
2349
2521
  depthCompare: "less-equal",
2350
2522
  depthWrite: false,
2351
- depthBias: 1e-4
2523
+ depthBias: 1e-4,
2524
+ width: options.edgeWidth * options.supersampling
2352
2525
  });
2526
+ if (!showHiddenEdges) continue;
2353
2527
  renderer.drawLines(edgeDrawCall, camera, options.gamma, {
2354
2528
  depthCompare: "greater",
2355
2529
  depthWrite: false,
2356
2530
  depthBias: 1e-4,
2357
2531
  dashed: true,
2358
2532
  dashLength: 4 * options.supersampling,
2359
- opacity: 0.35
2533
+ opacity: options.hiddenEdgeOpacity,
2534
+ width: options.edgeWidth * options.supersampling
2360
2535
  });
2361
2536
  }
2362
2537
  if (options.debugPoints?.length) {
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.35",
5
5
  "repository": {
6
6
  "type": "git",
7
7
  "url": "https://github.com/tscircuit/poppygl.git"