circuit-to-canvas 0.0.35 → 0.0.37

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/dist/index.d.ts CHANGED
@@ -341,6 +341,14 @@ declare function drawSoldermaskRingForPill(ctx: CanvasContext, center: {
341
341
  x: number;
342
342
  y: number;
343
343
  }, width: number, height: number, margin: number, rotation: number, realToCanvasMat: Matrix, soldermaskColor: string, padColor: string): void;
344
+ /**
345
+ * Draws a soldermask ring for oval shapes with negative margin
346
+ * (soldermask appears inside the hole boundary)
347
+ */
348
+ declare function drawSoldermaskRingForOval(ctx: CanvasContext, center: {
349
+ x: number;
350
+ y: number;
351
+ }, radius_x: number, radius_y: number, margin: number, rotation: number, realToCanvasMat: Matrix, soldermaskColor: string, holeColor: string): void;
344
352
 
345
353
  interface DrawPcbTraceParams {
346
354
  ctx: CanvasContext;
@@ -502,4 +510,4 @@ interface DrawPcbFabricationNoteDimensionParams {
502
510
  }
503
511
  declare function drawPcbFabricationNoteDimension(params: DrawPcbFabricationNoteDimensionParams): void;
504
512
 
505
- export { type AlphabetLayout, type AnchorAlignment, type CameraBounds, type CanvasContext, CircuitToCanvasDrawer, type CopperColorMap, type CopperLayerName, DEFAULT_PCB_COLOR_MAP, type DrawArrowParams, type DrawCircleParams, type DrawContext, type DrawDimensionLineParams, type DrawElementsOptions, type DrawLineParams, type DrawOvalParams, type DrawPathParams, type DrawPcbBoardParams, type DrawPcbCopperPourParams, type DrawPcbCopperTextParams, type DrawPcbCutoutParams, type DrawPcbFabricationNoteDimensionParams, type DrawPcbFabricationNotePathParams, type DrawPcbFabricationNoteRectParams, type DrawPcbFabricationNoteTextParams, type DrawPcbHoleParams, type DrawPcbNoteDimensionParams, type DrawPcbNotePathParams, type DrawPcbNoteRectParams, type DrawPcbNoteTextParams, type DrawPcbPlatedHoleParams, type DrawPcbSilkscreenCircleParams, type DrawPcbSilkscreenLineParams, type DrawPcbSilkscreenOvalParams, type DrawPcbSilkscreenPathParams, type DrawPcbSilkscreenPillParams, type DrawPcbSilkscreenRectParams, type DrawPcbSilkscreenTextParams, type DrawPcbSmtPadParams, type DrawPcbTraceParams, type DrawPcbViaParams, type DrawPillParams, type DrawPolygonParams, type DrawRectParams, type DrawTextParams, type DrawerConfig, type PcbColorMap, drawArrow, drawCircle, drawDimensionLine, drawLine, drawOval, drawPath, drawPcbBoard, drawPcbCopperPour, drawPcbCopperText, drawPcbCutout, drawPcbFabricationNoteDimension, drawPcbFabricationNotePath, drawPcbFabricationNoteRect, drawPcbFabricationNoteText, drawPcbHole, drawPcbNoteDimension, drawPcbNotePath, drawPcbNoteRect, drawPcbNoteText, drawPcbPlatedHole, drawPcbSilkscreenCircle, drawPcbSilkscreenLine, drawPcbSilkscreenOval, drawPcbSilkscreenPath, drawPcbSilkscreenPill, drawPcbSilkscreenRect, drawPcbSilkscreenText, drawPcbSmtPad, drawPcbTrace, drawPcbVia, drawPill, drawPolygon, drawRect, drawSoldermaskRingForCircle, drawSoldermaskRingForPill, drawSoldermaskRingForRect, drawText, getAlphabetLayout, getTextStartPosition, strokeAlphabetText };
513
+ export { type AlphabetLayout, type AnchorAlignment, type CameraBounds, type CanvasContext, CircuitToCanvasDrawer, type CopperColorMap, type CopperLayerName, DEFAULT_PCB_COLOR_MAP, type DrawArrowParams, type DrawCircleParams, type DrawContext, type DrawDimensionLineParams, type DrawElementsOptions, type DrawLineParams, type DrawOvalParams, type DrawPathParams, type DrawPcbBoardParams, type DrawPcbCopperPourParams, type DrawPcbCopperTextParams, type DrawPcbCutoutParams, type DrawPcbFabricationNoteDimensionParams, type DrawPcbFabricationNotePathParams, type DrawPcbFabricationNoteRectParams, type DrawPcbFabricationNoteTextParams, type DrawPcbHoleParams, type DrawPcbNoteDimensionParams, type DrawPcbNotePathParams, type DrawPcbNoteRectParams, type DrawPcbNoteTextParams, type DrawPcbPlatedHoleParams, type DrawPcbSilkscreenCircleParams, type DrawPcbSilkscreenLineParams, type DrawPcbSilkscreenOvalParams, type DrawPcbSilkscreenPathParams, type DrawPcbSilkscreenPillParams, type DrawPcbSilkscreenRectParams, type DrawPcbSilkscreenTextParams, type DrawPcbSmtPadParams, type DrawPcbTraceParams, type DrawPcbViaParams, type DrawPillParams, type DrawPolygonParams, type DrawRectParams, type DrawTextParams, type DrawerConfig, type PcbColorMap, drawArrow, drawCircle, drawDimensionLine, drawLine, drawOval, drawPath, drawPcbBoard, drawPcbCopperPour, drawPcbCopperText, drawPcbCutout, drawPcbFabricationNoteDimension, drawPcbFabricationNotePath, drawPcbFabricationNoteRect, drawPcbFabricationNoteText, drawPcbHole, drawPcbNoteDimension, drawPcbNotePath, drawPcbNoteRect, drawPcbNoteText, drawPcbPlatedHole, drawPcbSilkscreenCircle, drawPcbSilkscreenLine, drawPcbSilkscreenOval, drawPcbSilkscreenPath, drawPcbSilkscreenPill, drawPcbSilkscreenRect, drawPcbSilkscreenText, drawPcbSmtPad, drawPcbTrace, drawPcbVia, drawPill, drawPolygon, drawRect, drawSoldermaskRingForCircle, drawSoldermaskRingForOval, drawSoldermaskRingForPill, drawSoldermaskRingForRect, drawText, getAlphabetLayout, getTextStartPosition, strokeAlphabetText };
package/dist/index.js CHANGED
@@ -457,7 +457,19 @@ function drawPcbVia(params) {
457
457
  // lib/drawer/elements/pcb-hole.ts
458
458
  function drawPcbHole(params) {
459
459
  const { ctx, hole, realToCanvasMat, colorMap } = params;
460
+ const hasSoldermask = hole.is_covered_with_solder_mask === true && hole.soldermask_margin !== void 0 && hole.soldermask_margin > 0;
461
+ const margin = hasSoldermask ? hole.soldermask_margin : 0;
462
+ const positiveMarginColor = colorMap.substrate;
460
463
  if (hole.hole_shape === "circle") {
464
+ if (hasSoldermask && margin > 0) {
465
+ drawCircle({
466
+ ctx,
467
+ center: { x: hole.x, y: hole.y },
468
+ radius: hole.hole_diameter / 2 + margin,
469
+ fill: positiveMarginColor,
470
+ realToCanvasMat
471
+ });
472
+ }
461
473
  drawCircle({
462
474
  ctx,
463
475
  center: { x: hole.x, y: hole.y },
@@ -468,6 +480,16 @@ function drawPcbHole(params) {
468
480
  return;
469
481
  }
470
482
  if (hole.hole_shape === "square") {
483
+ if (hasSoldermask && margin > 0) {
484
+ drawRect({
485
+ ctx,
486
+ center: { x: hole.x, y: hole.y },
487
+ width: hole.hole_diameter + margin * 2,
488
+ height: hole.hole_diameter + margin * 2,
489
+ fill: positiveMarginColor,
490
+ realToCanvasMat
491
+ });
492
+ }
471
493
  drawRect({
472
494
  ctx,
473
495
  center: { x: hole.x, y: hole.y },
@@ -479,6 +501,16 @@ function drawPcbHole(params) {
479
501
  return;
480
502
  }
481
503
  if (hole.hole_shape === "oval") {
504
+ if (hasSoldermask && margin > 0) {
505
+ drawOval({
506
+ ctx,
507
+ center: { x: hole.x, y: hole.y },
508
+ radius_x: hole.hole_width / 2 + margin,
509
+ radius_y: hole.hole_height / 2 + margin,
510
+ fill: positiveMarginColor,
511
+ realToCanvasMat
512
+ });
513
+ }
482
514
  drawOval({
483
515
  ctx,
484
516
  center: { x: hole.x, y: hole.y },
@@ -490,6 +522,16 @@ function drawPcbHole(params) {
490
522
  return;
491
523
  }
492
524
  if (hole.hole_shape === "rect") {
525
+ if (hasSoldermask && margin > 0) {
526
+ drawRect({
527
+ ctx,
528
+ center: { x: hole.x, y: hole.y },
529
+ width: hole.hole_width + margin * 2,
530
+ height: hole.hole_height + margin * 2,
531
+ fill: positiveMarginColor,
532
+ realToCanvasMat
533
+ });
534
+ }
493
535
  drawRect({
494
536
  ctx,
495
537
  center: { x: hole.x, y: hole.y },
@@ -501,6 +543,16 @@ function drawPcbHole(params) {
501
543
  return;
502
544
  }
503
545
  if (hole.hole_shape === "pill") {
546
+ if (hasSoldermask && margin > 0) {
547
+ drawPill({
548
+ ctx,
549
+ center: { x: hole.x, y: hole.y },
550
+ width: hole.hole_width + margin * 2,
551
+ height: hole.hole_height + margin * 2,
552
+ fill: positiveMarginColor,
553
+ realToCanvasMat
554
+ });
555
+ }
504
556
  drawPill({
505
557
  ctx,
506
558
  center: { x: hole.x, y: hole.y },
@@ -512,6 +564,18 @@ function drawPcbHole(params) {
512
564
  return;
513
565
  }
514
566
  if (hole.hole_shape === "rotated_pill") {
567
+ const rotation = hole.ccw_rotation ?? 0;
568
+ if (hasSoldermask && margin > 0) {
569
+ drawPill({
570
+ ctx,
571
+ center: { x: hole.x, y: hole.y },
572
+ width: hole.hole_width + margin * 2,
573
+ height: hole.hole_height + margin * 2,
574
+ fill: positiveMarginColor,
575
+ realToCanvasMat,
576
+ rotation
577
+ });
578
+ }
515
579
  drawPill({
516
580
  ctx,
517
581
  center: { x: hole.x, y: hole.y },
@@ -519,7 +583,7 @@ function drawPcbHole(params) {
519
583
  height: hole.hole_height,
520
584
  fill: colorMap.drill,
521
585
  realToCanvasMat,
522
- rotation: hole.ccw_rotation ?? 0
586
+ rotation
523
587
  });
524
588
  return;
525
589
  }
@@ -699,6 +763,37 @@ function drawSoldermaskRingForPill(ctx, center, width, height, margin, rotation,
699
763
  }
700
764
  ctx.restore();
701
765
  }
766
+ function drawSoldermaskRingForOval(ctx, center, radius_x, radius_y, margin, rotation, realToCanvasMat, soldermaskColor, holeColor) {
767
+ const [cx, cy] = applyToPoint6(realToCanvasMat, [center.x, center.y]);
768
+ const scaledRadiusX = radius_x * Math.abs(realToCanvasMat.a);
769
+ const scaledRadiusY = radius_y * Math.abs(realToCanvasMat.a);
770
+ const scaledMargin = Math.abs(margin) * Math.abs(realToCanvasMat.a);
771
+ ctx.save();
772
+ ctx.translate(cx, cy);
773
+ if (rotation !== 0) {
774
+ ctx.rotate(-rotation * (Math.PI / 180));
775
+ }
776
+ const prevCompositeOp = ctx.globalCompositeOperation;
777
+ if (ctx.globalCompositeOperation !== void 0) {
778
+ ctx.globalCompositeOperation = "source-atop";
779
+ }
780
+ ctx.beginPath();
781
+ ctx.ellipse(0, 0, scaledRadiusX, scaledRadiusY, 0, 0, Math.PI * 2);
782
+ ctx.fillStyle = soldermaskColor;
783
+ ctx.fill();
784
+ if (ctx.globalCompositeOperation !== void 0) {
785
+ ctx.globalCompositeOperation = prevCompositeOp || "source-over";
786
+ }
787
+ const innerRadiusX = Math.max(0, scaledRadiusX - scaledMargin);
788
+ const innerRadiusY = Math.max(0, scaledRadiusY - scaledMargin);
789
+ if (innerRadiusX > 0 && innerRadiusY > 0) {
790
+ ctx.beginPath();
791
+ ctx.ellipse(0, 0, innerRadiusX, innerRadiusY, 0, 0, Math.PI * 2);
792
+ ctx.fillStyle = holeColor;
793
+ ctx.fill();
794
+ }
795
+ ctx.restore();
796
+ }
702
797
 
703
798
  // lib/drawer/elements/pcb-smtpad.ts
704
799
  function layerToColor(layer, colorMap) {
@@ -1671,78 +1766,76 @@ function drawDimensionLine(params) {
1671
1766
  x: anchor.x + extensionDirection.x * extensionLength,
1672
1767
  y: anchor.y + extensionDirection.y * extensionLength
1673
1768
  };
1674
- const halfWidth2 = strokeWidth / 2;
1769
+ const halfWidth = strokeWidth / 2;
1675
1770
  const extPerpendicular = {
1676
1771
  x: -extensionDirection.y,
1677
1772
  y: extensionDirection.x
1678
1773
  };
1679
1774
  return [
1680
1775
  {
1681
- x: anchor.x + extPerpendicular.x * halfWidth2,
1682
- y: anchor.y + extPerpendicular.y * halfWidth2
1776
+ x: anchor.x + extPerpendicular.x * halfWidth,
1777
+ y: anchor.y + extPerpendicular.y * halfWidth
1683
1778
  },
1684
1779
  {
1685
- x: anchor.x - extPerpendicular.x * halfWidth2,
1686
- y: anchor.y - extPerpendicular.y * halfWidth2
1780
+ x: anchor.x - extPerpendicular.x * halfWidth,
1781
+ y: anchor.y - extPerpendicular.y * halfWidth
1687
1782
  },
1688
1783
  {
1689
- x: endPoint.x - extPerpendicular.x * halfWidth2,
1690
- y: endPoint.y - extPerpendicular.y * halfWidth2
1784
+ x: endPoint.x - extPerpendicular.x * halfWidth,
1785
+ y: endPoint.y - extPerpendicular.y * halfWidth
1691
1786
  },
1692
1787
  {
1693
- x: endPoint.x + extPerpendicular.x * halfWidth2,
1694
- y: endPoint.y + extPerpendicular.y * halfWidth2
1788
+ x: endPoint.x + extPerpendicular.x * halfWidth,
1789
+ y: endPoint.y + extPerpendicular.y * halfWidth
1695
1790
  }
1696
1791
  ];
1697
1792
  };
1698
- const ext1 = getExtensionPoints(from);
1699
- allPoints.push(...ext1, ext1[0]);
1700
- const ext2 = getExtensionPoints(to);
1701
- allPoints.push(...ext2, ext2[0]);
1702
- const halfWidth = strokeWidth / 2;
1703
- const mainLine = [
1704
- {
1705
- x: fromBase.x + perpendicular.x * halfWidth,
1706
- y: fromBase.y + perpendicular.y * halfWidth
1707
- },
1708
- {
1709
- x: fromBase.x - perpendicular.x * halfWidth,
1710
- y: fromBase.y - perpendicular.y * halfWidth
1711
- },
1712
- {
1713
- x: toBase.x - perpendicular.x * halfWidth,
1714
- y: toBase.y - perpendicular.y * halfWidth
1715
- },
1716
- {
1717
- x: toBase.x + perpendicular.x * halfWidth,
1718
- y: toBase.y + perpendicular.y * halfWidth
1719
- }
1720
- ];
1721
- allPoints.push(...mainLine, mainLine[0]);
1722
- const arrow1 = [
1723
- fromOffset,
1724
- {
1725
- x: fromOffset.x + direction.x * arrowSize + perpendicular.x * (arrowSize / 2),
1726
- y: fromOffset.y + direction.y * arrowSize + perpendicular.y * (arrowSize / 2)
1727
- },
1728
- {
1729
- x: fromOffset.x + direction.x * arrowSize - perpendicular.x * (arrowSize / 2),
1730
- y: fromOffset.y + direction.y * arrowSize - perpendicular.y * (arrowSize / 2)
1731
- }
1732
- ];
1733
- allPoints.push(...arrow1, arrow1[0]);
1734
- const arrow2 = [
1735
- toOffset,
1736
- {
1737
- x: toOffset.x - direction.x * arrowSize + perpendicular.x * (arrowSize / 2),
1738
- y: toOffset.y - direction.y * arrowSize + perpendicular.y * (arrowSize / 2)
1739
- },
1740
- {
1741
- x: toOffset.x - direction.x * arrowSize - perpendicular.x * (arrowSize / 2),
1742
- y: toOffset.y - direction.y * arrowSize - perpendicular.y * (arrowSize / 2)
1743
- }
1744
- ];
1745
- allPoints.push(...arrow2, arrow2[0]);
1793
+ allPoints.push(fromOffset);
1794
+ allPoints.push({
1795
+ x: fromBase.x + perpendicular.x * (arrowSize / 2),
1796
+ y: fromBase.y + perpendicular.y * (arrowSize / 2)
1797
+ });
1798
+ allPoints.push({
1799
+ x: fromBase.x + perpendicular.x * (strokeWidth / 2),
1800
+ y: fromBase.y + perpendicular.y * (strokeWidth / 2)
1801
+ });
1802
+ allPoints.push({
1803
+ x: toBase.x + perpendicular.x * (strokeWidth / 2),
1804
+ y: toBase.y + perpendicular.y * (strokeWidth / 2)
1805
+ });
1806
+ allPoints.push({
1807
+ x: toBase.x + perpendicular.x * (arrowSize / 2),
1808
+ y: toBase.y + perpendicular.y * (arrowSize / 2)
1809
+ });
1810
+ allPoints.push(toOffset);
1811
+ allPoints.push({
1812
+ x: toBase.x - perpendicular.x * (arrowSize / 2),
1813
+ y: toBase.y - perpendicular.y * (arrowSize / 2)
1814
+ });
1815
+ allPoints.push({
1816
+ x: toBase.x - perpendicular.x * (strokeWidth / 2),
1817
+ y: toBase.y - perpendicular.y * (strokeWidth / 2)
1818
+ });
1819
+ allPoints.push({
1820
+ x: fromBase.x - perpendicular.x * (strokeWidth / 2),
1821
+ y: fromBase.y - perpendicular.y * (strokeWidth / 2)
1822
+ });
1823
+ allPoints.push({
1824
+ x: fromBase.x - perpendicular.x * (arrowSize / 2),
1825
+ y: fromBase.y - perpendicular.y * (arrowSize / 2)
1826
+ });
1827
+ allPoints.push(fromOffset);
1828
+ const startPoint = allPoints[0];
1829
+ const addTick = (anchor) => {
1830
+ const pts = getExtensionPoints(anchor);
1831
+ allPoints.push(startPoint);
1832
+ allPoints.push(pts[0]);
1833
+ allPoints.push(...pts);
1834
+ allPoints.push(pts[0]);
1835
+ allPoints.push(startPoint);
1836
+ };
1837
+ addTick(from);
1838
+ addTick(to);
1746
1839
  drawPolygon({
1747
1840
  ctx,
1748
1841
  points: allPoints,
@@ -1778,9 +1871,9 @@ function drawDimensionLine(params) {
1778
1871
  const rotationRad = textRotation * Math.PI / 180;
1779
1872
  const sinRot = Math.abs(Math.sin(rotationRad));
1780
1873
  const cosRot = Math.abs(Math.cos(rotationRad));
1781
- const halfWidth2 = textWidth / 2;
1874
+ const halfWidth = textWidth / 2;
1782
1875
  const halfHeight = textHeight / 2;
1783
- const maxExtension = halfWidth2 * sinRot + halfHeight * cosRot;
1876
+ const maxExtension = halfWidth * sinRot + halfHeight * cosRot;
1784
1877
  additionalOffset = maxExtension + fontSize * TEXT_INTERSECTION_PADDING_MULTIPLIER;
1785
1878
  }
1786
1879
  const textOffset = arrowSize * TEXT_OFFSET_MULTIPLIER + additionalOffset;
@@ -1941,8 +2034,11 @@ var CircuitToCanvasDrawer = class {
1941
2034
  const hasSoldermaskPads = elements.some(
1942
2035
  (el) => el.type === "pcb_smtpad" && el.is_covered_with_solder_mask === true
1943
2036
  );
2037
+ const hasSoldermaskHoles = elements.some(
2038
+ (el) => el.type === "pcb_hole" && el.is_covered_with_solder_mask === true
2039
+ );
1944
2040
  for (const element of elements) {
1945
- if (element.type === "pcb_board" && hasSoldermaskPads) {
2041
+ if (element.type === "pcb_board" && (hasSoldermaskPads || hasSoldermaskHoles)) {
1946
2042
  this.drawBoardWithSoldermask(element);
1947
2043
  } else {
1948
2044
  this.drawElement(element, options);
@@ -2269,6 +2365,7 @@ export {
2269
2365
  drawPolygon,
2270
2366
  drawRect,
2271
2367
  drawSoldermaskRingForCircle,
2368
+ drawSoldermaskRingForOval,
2272
2369
  drawSoldermaskRingForPill,
2273
2370
  drawSoldermaskRingForRect,
2274
2371
  drawText,
@@ -160,16 +160,25 @@ export class CircuitToCanvasDrawer {
160
160
  elements: AnyCircuitElement[],
161
161
  options: DrawElementsOptions = {},
162
162
  ): void {
163
- // Check if any pad has is_covered_with_solder_mask: true
163
+ // Check if any pad or hole has is_covered_with_solder_mask: true
164
164
  const hasSoldermaskPads = elements.some(
165
165
  (el) =>
166
166
  el.type === "pcb_smtpad" &&
167
167
  (el as PcbSmtPad).is_covered_with_solder_mask === true,
168
168
  )
169
+ const hasSoldermaskHoles = elements.some(
170
+ (el) =>
171
+ el.type === "pcb_hole" &&
172
+ (el as PcbHole & { is_covered_with_solder_mask?: boolean })
173
+ .is_covered_with_solder_mask === true,
174
+ )
169
175
 
170
176
  for (const element of elements) {
171
- if (element.type === "pcb_board" && hasSoldermaskPads) {
172
- // Draw board with soldermask fill when pads have soldermask
177
+ if (
178
+ element.type === "pcb_board" &&
179
+ (hasSoldermaskPads || hasSoldermaskHoles)
180
+ ) {
181
+ // Draw board with soldermask fill when pads or holes have soldermask
173
182
  this.drawBoardWithSoldermask(element as PcbBoard)
174
183
  } else {
175
184
  this.drawElement(element, options)
@@ -12,6 +12,7 @@ export {
12
12
  drawSoldermaskRingForRect,
13
13
  drawSoldermaskRingForCircle,
14
14
  drawSoldermaskRingForPill,
15
+ drawSoldermaskRingForOval,
15
16
  } from "./soldermask-margin"
16
17
 
17
18
  export { drawPcbTrace, type DrawPcbTraceParams } from "./pcb-trace"
@@ -16,7 +16,26 @@ export interface DrawPcbHoleParams {
16
16
  export function drawPcbHole(params: DrawPcbHoleParams): void {
17
17
  const { ctx, hole, realToCanvasMat, colorMap } = params
18
18
 
19
+ const hasSoldermask =
20
+ hole.is_covered_with_solder_mask === true &&
21
+ hole.soldermask_margin !== undefined &&
22
+ hole.soldermask_margin > 0
23
+ const margin = hasSoldermask ? hole.soldermask_margin! : 0
24
+ const positiveMarginColor = colorMap.substrate
25
+
19
26
  if (hole.hole_shape === "circle") {
27
+ // For positive margins, draw extended mask area first
28
+ if (hasSoldermask && margin > 0) {
29
+ drawCircle({
30
+ ctx,
31
+ center: { x: hole.x, y: hole.y },
32
+ radius: hole.hole_diameter / 2 + margin,
33
+ fill: positiveMarginColor,
34
+ realToCanvasMat,
35
+ })
36
+ }
37
+
38
+ // Draw the hole
20
39
  drawCircle({
21
40
  ctx,
22
41
  center: { x: hole.x, y: hole.y },
@@ -28,6 +47,19 @@ export function drawPcbHole(params: DrawPcbHoleParams): void {
28
47
  }
29
48
 
30
49
  if (hole.hole_shape === "square") {
50
+ // For positive margins, draw extended mask area first
51
+ if (hasSoldermask && margin > 0) {
52
+ drawRect({
53
+ ctx,
54
+ center: { x: hole.x, y: hole.y },
55
+ width: hole.hole_diameter + margin * 2,
56
+ height: hole.hole_diameter + margin * 2,
57
+ fill: positiveMarginColor,
58
+ realToCanvasMat,
59
+ })
60
+ }
61
+
62
+ // Draw the hole
31
63
  drawRect({
32
64
  ctx,
33
65
  center: { x: hole.x, y: hole.y },
@@ -40,6 +72,19 @@ export function drawPcbHole(params: DrawPcbHoleParams): void {
40
72
  }
41
73
 
42
74
  if (hole.hole_shape === "oval") {
75
+ // For positive margins, draw extended mask area first
76
+ if (hasSoldermask && margin > 0) {
77
+ drawOval({
78
+ ctx,
79
+ center: { x: hole.x, y: hole.y },
80
+ radius_x: hole.hole_width / 2 + margin,
81
+ radius_y: hole.hole_height / 2 + margin,
82
+ fill: positiveMarginColor,
83
+ realToCanvasMat,
84
+ })
85
+ }
86
+
87
+ // Draw the hole
43
88
  drawOval({
44
89
  ctx,
45
90
  center: { x: hole.x, y: hole.y },
@@ -52,6 +97,19 @@ export function drawPcbHole(params: DrawPcbHoleParams): void {
52
97
  }
53
98
 
54
99
  if (hole.hole_shape === "rect") {
100
+ // For positive margins, draw extended mask area first
101
+ if (hasSoldermask && margin > 0) {
102
+ drawRect({
103
+ ctx,
104
+ center: { x: hole.x, y: hole.y },
105
+ width: hole.hole_width + margin * 2,
106
+ height: hole.hole_height + margin * 2,
107
+ fill: positiveMarginColor,
108
+ realToCanvasMat,
109
+ })
110
+ }
111
+
112
+ // Draw the hole
55
113
  drawRect({
56
114
  ctx,
57
115
  center: { x: hole.x, y: hole.y },
@@ -64,6 +122,19 @@ export function drawPcbHole(params: DrawPcbHoleParams): void {
64
122
  }
65
123
 
66
124
  if (hole.hole_shape === "pill") {
125
+ // For positive margins, draw extended mask area first
126
+ if (hasSoldermask && margin > 0) {
127
+ drawPill({
128
+ ctx,
129
+ center: { x: hole.x, y: hole.y },
130
+ width: hole.hole_width + margin * 2,
131
+ height: hole.hole_height + margin * 2,
132
+ fill: positiveMarginColor,
133
+ realToCanvasMat,
134
+ })
135
+ }
136
+
137
+ // Draw the hole
67
138
  drawPill({
68
139
  ctx,
69
140
  center: { x: hole.x, y: hole.y },
@@ -76,6 +147,22 @@ export function drawPcbHole(params: DrawPcbHoleParams): void {
76
147
  }
77
148
 
78
149
  if (hole.hole_shape === "rotated_pill") {
150
+ const rotation = (hole as any).ccw_rotation ?? 0
151
+
152
+ // For positive margins, draw extended mask area first
153
+ if (hasSoldermask && margin > 0) {
154
+ drawPill({
155
+ ctx,
156
+ center: { x: hole.x, y: hole.y },
157
+ width: hole.hole_width + margin * 2,
158
+ height: hole.hole_height + margin * 2,
159
+ fill: positiveMarginColor,
160
+ realToCanvasMat,
161
+ rotation,
162
+ })
163
+ }
164
+
165
+ // Draw the hole
79
166
  drawPill({
80
167
  ctx,
81
168
  center: { x: hole.x, y: hole.y },
@@ -83,7 +170,7 @@ export function drawPcbHole(params: DrawPcbHoleParams): void {
83
170
  height: hole.hole_height,
84
171
  fill: colorMap.drill,
85
172
  realToCanvasMat,
86
- rotation: (hole as any).ccw_rotation ?? 0,
173
+ rotation,
87
174
  })
88
175
  return
89
176
  }
@@ -264,3 +264,63 @@ export function drawSoldermaskRingForPill(
264
264
 
265
265
  ctx.restore()
266
266
  }
267
+
268
+ /**
269
+ * Draws a soldermask ring for oval shapes with negative margin
270
+ * (soldermask appears inside the hole boundary)
271
+ */
272
+ export function drawSoldermaskRingForOval(
273
+ ctx: CanvasContext,
274
+ center: { x: number; y: number },
275
+ radius_x: number,
276
+ radius_y: number,
277
+ margin: number,
278
+ rotation: number,
279
+ realToCanvasMat: Matrix,
280
+ soldermaskColor: string,
281
+ holeColor: string,
282
+ ): void {
283
+ const [cx, cy] = applyToPoint(realToCanvasMat, [center.x, center.y])
284
+ const scaledRadiusX = radius_x * Math.abs(realToCanvasMat.a)
285
+ const scaledRadiusY = radius_y * Math.abs(realToCanvasMat.a)
286
+ const scaledMargin = Math.abs(margin) * Math.abs(realToCanvasMat.a)
287
+
288
+ ctx.save()
289
+ ctx.translate(cx, cy)
290
+
291
+ if (rotation !== 0) {
292
+ ctx.rotate(-rotation * (Math.PI / 180))
293
+ }
294
+
295
+ // For negative margins, outer is hole boundary, inner is reduced by margin
296
+ // Use source-atop so the ring only appears on the hole
297
+ const prevCompositeOp = ctx.globalCompositeOperation
298
+ if (ctx.globalCompositeOperation !== undefined) {
299
+ ctx.globalCompositeOperation = "source-atop"
300
+ }
301
+
302
+ // Draw outer oval filled (at hole boundary)
303
+ ctx.beginPath()
304
+ ctx.ellipse(0, 0, scaledRadiusX, scaledRadiusY, 0, 0, Math.PI * 2)
305
+ ctx.fillStyle = soldermaskColor
306
+ ctx.fill()
307
+
308
+ // Reset composite operation and restore hole color in inner area
309
+ if (ctx.globalCompositeOperation !== undefined) {
310
+ ctx.globalCompositeOperation = prevCompositeOp || "source-over"
311
+ }
312
+
313
+ // Restore hole color in inner oval (reduced by margin)
314
+ // For ovals, we reduce both radii by the margin
315
+ const innerRadiusX = Math.max(0, scaledRadiusX - scaledMargin)
316
+ const innerRadiusY = Math.max(0, scaledRadiusY - scaledMargin)
317
+
318
+ if (innerRadiusX > 0 && innerRadiusY > 0) {
319
+ ctx.beginPath()
320
+ ctx.ellipse(0, 0, innerRadiusX, innerRadiusY, 0, 0, Math.PI * 2)
321
+ ctx.fillStyle = holeColor
322
+ ctx.fill()
323
+ }
324
+
325
+ ctx.restore()
326
+ }
@@ -121,85 +121,80 @@ export function drawDimensionLine(params: DrawDimensionLineParams): void {
121
121
  ]
122
122
  }
123
123
 
124
- // Extension lines (ticks)
125
- const ext1 = getExtensionPoints(from)
126
- allPoints.push(...ext1, ext1[0]!)
127
-
128
- const ext2 = getExtensionPoints(to)
129
- allPoints.push(...ext2, ext2[0]!)
130
-
131
- // Main dimension line
132
- const halfWidth = strokeWidth / 2
133
- const mainLine = [
134
- {
135
- x: fromBase.x + perpendicular.x * halfWidth,
136
- y: fromBase.y + perpendicular.y * halfWidth,
137
- },
138
- {
139
- x: fromBase.x - perpendicular.x * halfWidth,
140
- y: fromBase.y - perpendicular.y * halfWidth,
141
- },
142
- {
143
- x: toBase.x - perpendicular.x * halfWidth,
144
- y: toBase.y - perpendicular.y * halfWidth,
145
- },
146
- {
147
- x: toBase.x + perpendicular.x * halfWidth,
148
- y: toBase.y + perpendicular.y * halfWidth,
149
- },
150
- ]
151
- allPoints.push(...mainLine, mainLine[0]!)
152
-
153
- // Arrows
154
- const arrow1 = [
155
- fromOffset,
156
- {
157
- x:
158
- fromOffset.x +
159
- direction.x * arrowSize +
160
- perpendicular.x * (arrowSize / 2),
161
- y:
162
- fromOffset.y +
163
- direction.y * arrowSize +
164
- perpendicular.y * (arrowSize / 2),
165
- },
166
- {
167
- x:
168
- fromOffset.x +
169
- direction.x * arrowSize -
170
- perpendicular.x * (arrowSize / 2),
171
- y:
172
- fromOffset.y +
173
- direction.y * arrowSize -
174
- perpendicular.y * (arrowSize / 2),
175
- },
176
- ]
177
- allPoints.push(...arrow1, arrow1[0]!)
178
-
179
- const arrow2 = [
180
- toOffset,
181
- {
182
- x:
183
- toOffset.x -
184
- direction.x * arrowSize +
185
- perpendicular.x * (arrowSize / 2),
186
- y:
187
- toOffset.y -
188
- direction.y * arrowSize +
189
- perpendicular.y * (arrowSize / 2),
190
- },
191
- {
192
- x:
193
- toOffset.x -
194
- direction.x * arrowSize -
195
- perpendicular.x * (arrowSize / 2),
196
- y:
197
- toOffset.y -
198
- direction.y * arrowSize -
199
- perpendicular.y * (arrowSize / 2),
200
- },
201
- ]
202
- allPoints.push(...arrow2, arrow2[0]!)
124
+ // Unified Perimeter Approach:
125
+ // Draw the main line and both arrows as a single continuous perimeter path.
126
+ // This eliminates self-intersections and winding issues.
127
+
128
+ // 1. Tip 1
129
+ allPoints.push(fromOffset)
130
+
131
+ // 2. Arrow 1 base corner 1
132
+ allPoints.push({
133
+ x: fromBase.x + perpendicular.x * (arrowSize / 2),
134
+ y: fromBase.y + perpendicular.y * (arrowSize / 2),
135
+ })
136
+
137
+ // 3. Main Line corner 1
138
+ allPoints.push({
139
+ x: fromBase.x + perpendicular.x * (strokeWidth / 2),
140
+ y: fromBase.y + perpendicular.y * (strokeWidth / 2),
141
+ })
142
+
143
+ // 4. Main Line corner 2 (at toBase)
144
+ allPoints.push({
145
+ x: toBase.x + perpendicular.x * (strokeWidth / 2),
146
+ y: toBase.y + perpendicular.y * (strokeWidth / 2),
147
+ })
148
+
149
+ // 5. Arrow 2 base corner 1
150
+ allPoints.push({
151
+ x: toBase.x + perpendicular.x * (arrowSize / 2),
152
+ y: toBase.y + perpendicular.y * (arrowSize / 2),
153
+ })
154
+
155
+ // 6. Tip 2
156
+ allPoints.push(toOffset)
157
+
158
+ // 7. Arrow 2 base corner 2
159
+ allPoints.push({
160
+ x: toBase.x - perpendicular.x * (arrowSize / 2),
161
+ y: toBase.y - perpendicular.y * (arrowSize / 2),
162
+ })
163
+
164
+ // 8. Main Line corner 3 (at toBase)
165
+ allPoints.push({
166
+ x: toBase.x - perpendicular.x * (strokeWidth / 2),
167
+ y: toBase.y - perpendicular.y * (strokeWidth / 2),
168
+ })
169
+
170
+ // 9. Main Line corner 4 (at fromBase)
171
+ allPoints.push({
172
+ x: fromBase.x - perpendicular.x * (strokeWidth / 2),
173
+ y: fromBase.y - perpendicular.y * (strokeWidth / 2),
174
+ })
175
+
176
+ // 10. Arrow 1 base corner 2
177
+ allPoints.push({
178
+ x: fromBase.x - perpendicular.x * (arrowSize / 2),
179
+ y: fromBase.y - perpendicular.y * (arrowSize / 2),
180
+ })
181
+
182
+ // 11. Back to Tip 1
183
+ allPoints.push(fromOffset)
184
+
185
+ // Bridge Ticks (Extension lines)
186
+ const startPoint = allPoints[0]!
187
+ const addTick = (anchor: { x: number; y: number }) => {
188
+ const pts = getExtensionPoints(anchor)
189
+ allPoints.push(startPoint)
190
+ allPoints.push(pts[0]!)
191
+ allPoints.push(...pts)
192
+ allPoints.push(pts[0]!)
193
+ allPoints.push(startPoint)
194
+ }
195
+
196
+ addTick(from)
197
+ addTick(to)
203
198
 
204
199
  drawPolygon({
205
200
  ctx,
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "circuit-to-canvas",
3
3
  "main": "dist/index.js",
4
- "version": "0.0.35",
4
+ "version": "0.0.37",
5
5
  "type": "module",
6
6
  "scripts": {
7
7
  "build": "tsup-node ./lib/index.ts --format esm --dts",
@@ -0,0 +1,133 @@
1
+ import { expect, test } from "bun:test"
2
+ import { createCanvas } from "@napi-rs/canvas"
3
+ import { CircuitToCanvasDrawer } from "../../lib/drawer"
4
+
5
+ test("draw holes with positive soldermask margins", async () => {
6
+ const canvas = createCanvas(800, 600)
7
+ const ctx = canvas.getContext("2d")
8
+ const drawer = new CircuitToCanvasDrawer(ctx)
9
+
10
+ ctx.fillStyle = "#1a1a1a"
11
+ ctx.fillRect(0, 0, 800, 600)
12
+
13
+ const circuit: any = [
14
+ {
15
+ type: "pcb_board",
16
+ pcb_board_id: "board0",
17
+ center: { x: 0, y: 0 },
18
+ width: 14,
19
+ height: 10,
20
+ },
21
+ // Circle with positive margin (mask extends beyond hole)
22
+ {
23
+ type: "pcb_hole",
24
+ pcb_hole_id: "hole_circle_positive",
25
+ hole_shape: "circle",
26
+ x: -4,
27
+ y: 2,
28
+ hole_diameter: 1.0,
29
+ is_covered_with_solder_mask: true,
30
+ soldermask_margin: 0.2,
31
+ },
32
+ // Square with positive margin
33
+ {
34
+ type: "pcb_hole",
35
+ pcb_hole_id: "hole_square_positive",
36
+ hole_shape: "square",
37
+ x: -1,
38
+ y: 2,
39
+ hole_diameter: 1.0,
40
+ is_covered_with_solder_mask: true,
41
+ soldermask_margin: 0.15,
42
+ },
43
+ // Oval with positive margin
44
+ {
45
+ type: "pcb_hole",
46
+ pcb_hole_id: "hole_oval_positive",
47
+ hole_shape: "oval",
48
+ x: 2,
49
+ y: 2,
50
+ hole_width: 1.5,
51
+ hole_height: 0.8,
52
+ is_covered_with_solder_mask: true,
53
+ soldermask_margin: 0.1,
54
+ },
55
+ // Rect with positive margin
56
+ {
57
+ type: "pcb_hole",
58
+ pcb_hole_id: "hole_rect_positive",
59
+ hole_shape: "rect",
60
+ x: 5,
61
+ y: 2,
62
+ hole_width: 1.6,
63
+ hole_height: 1.1,
64
+ is_covered_with_solder_mask: true,
65
+ soldermask_margin: 0.15,
66
+ },
67
+ // Pill with positive margin
68
+ {
69
+ type: "pcb_hole",
70
+ pcb_hole_id: "hole_pill_positive",
71
+ hole_shape: "pill",
72
+ x: -2.5,
73
+ y: 0,
74
+ hole_width: 2.0,
75
+ hole_height: 0.8,
76
+ is_covered_with_solder_mask: true,
77
+ soldermask_margin: 0.1,
78
+ },
79
+ // Silkscreen labels for positive margin holes
80
+ {
81
+ type: "pcb_silkscreen_text",
82
+ pcb_silkscreen_text_id: "text_circle_pos",
83
+ layer: "top",
84
+ anchor_position: { x: -4, y: 3.2 },
85
+ anchor_alignment: "center",
86
+ text: "+0.2mm",
87
+ font_size: 0.4,
88
+ },
89
+ {
90
+ type: "pcb_silkscreen_text",
91
+ pcb_silkscreen_text_id: "text_square_pos",
92
+ layer: "top",
93
+ anchor_position: { x: -1, y: 3.2 },
94
+ anchor_alignment: "center",
95
+ text: "+0.15mm",
96
+ font_size: 0.4,
97
+ },
98
+ {
99
+ type: "pcb_silkscreen_text",
100
+ pcb_silkscreen_text_id: "text_oval_pos",
101
+ layer: "top",
102
+ anchor_position: { x: 2, y: 3.2 },
103
+ anchor_alignment: "center",
104
+ text: "+0.1mm",
105
+ font_size: 0.4,
106
+ },
107
+ {
108
+ type: "pcb_silkscreen_text",
109
+ pcb_silkscreen_text_id: "text_rect_pos",
110
+ layer: "top",
111
+ anchor_position: { x: 5, y: 3.2 },
112
+ anchor_alignment: "center",
113
+ text: "+0.15mm",
114
+ font_size: 0.4,
115
+ },
116
+ {
117
+ type: "pcb_silkscreen_text",
118
+ pcb_silkscreen_text_id: "text_pill_pos",
119
+ layer: "top",
120
+ anchor_position: { x: -2.5, y: 1 },
121
+ anchor_alignment: "center",
122
+ text: "+0.1mm",
123
+ font_size: 0.4,
124
+ },
125
+ ]
126
+
127
+ drawer.setCameraBounds({ minX: -7, maxX: 7, minY: -5, maxY: 5 })
128
+ drawer.drawElements(circuit)
129
+
130
+ await expect(canvas.toBuffer("image/png")).toMatchPngSnapshot(
131
+ import.meta.path,
132
+ )
133
+ })