circuit-json-to-lbrn 0.0.42 → 0.0.44

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (96) hide show
  1. package/dist/index.d.ts +12 -1
  2. package/dist/index.js +238 -15
  3. package/lib/ConvertContext.ts +12 -0
  4. package/lib/createCopperCutFillForLayer.ts +217 -0
  5. package/lib/element-handlers/addPcbTrace/index.ts +35 -13
  6. package/lib/getManifold.ts +31 -0
  7. package/lib/index.ts +72 -4
  8. package/package.json +3 -2
  9. package/site/index.html +24 -1
  10. package/site/main.tsx +8 -0
  11. package/tests/basics/__snapshots__/board-outline-does-not-appear-in soldermask.snap.svg +1 -1
  12. package/tests/basics/__snapshots__/board-outline.snap.svg +1 -1
  13. package/tests/basics/__snapshots__/mixed-soldermask-margins.snap.svg +1 -1
  14. package/tests/basics/addPcbCutout/__snapshots__/pcb-cutout-circle.snap.svg +1 -1
  15. package/tests/basics/addPcbCutout/__snapshots__/pcb-cutout-path.snap.svg +1 -1
  16. package/tests/basics/addPcbCutout/__snapshots__/pcb-cutout-polygon.snap.svg +1 -1
  17. package/tests/basics/addPcbCutout/__snapshots__/pcb-cutout-rect.snap.svg +1 -1
  18. package/tests/basics/addPcbCutout/pcb-cutout-circle.test.ts +1 -1
  19. package/tests/basics/addPcbCutout/pcb-cutout-path.test.ts +1 -1
  20. package/tests/basics/addPcbCutout/pcb-cutout-polygon.test.ts +1 -1
  21. package/tests/basics/addPcbCutout/pcb-cutout-rect.test.ts +1 -1
  22. package/tests/basics/addPcbHole/__snapshots__/pcb-hole-circle.snap.svg +1 -1
  23. package/tests/basics/addPcbHole/__snapshots__/pcb-hole-oval.snap.svg +1 -1
  24. package/tests/basics/addPcbHole/__snapshots__/pcb-hole-pill.snap.svg +1 -1
  25. package/tests/basics/addPcbHole/__snapshots__/pcb-hole-rect.snap.svg +1 -1
  26. package/tests/basics/addPcbHole/__snapshots__/pcb-hole-rotated-pill.snap.svg +1 -1
  27. package/tests/basics/addPcbHole/__snapshots__/pcb-hole-soldermask-margin.snap.svg +1 -1
  28. package/tests/basics/addPcbHole/__snapshots__/pcb-hole-with-soldermask.snap.svg +1 -1
  29. package/tests/basics/addPcbHole/pcb-hole-circle.test.ts +1 -1
  30. package/tests/basics/addPcbHole/pcb-hole-oval.test.ts +1 -1
  31. package/tests/basics/addPcbHole/pcb-hole-pill.test.ts +1 -1
  32. package/tests/basics/addPcbHole/pcb-hole-rect.test.ts +1 -1
  33. package/tests/basics/addPcbHole/pcb-hole-rotated-pill.test.ts +1 -1
  34. package/tests/basics/addPcbHole/pcb-hole-soldermask-margin.test.ts +1 -1
  35. package/tests/basics/addPcbHole/pcb-hole-with-soldermask.test.ts +1 -1
  36. package/tests/basics/addPcbVia/__snapshots__/pcb-via-basic.snap.svg +1 -1
  37. package/tests/basics/addPcbVia/__snapshots__/pcb-via-with-net.snap.svg +1 -1
  38. package/tests/basics/addPcbVia/__snapshots__/pcb-via-with-soldermask.snap.svg +1 -1
  39. package/tests/basics/addPcbVia/pcb-via-basic.test.ts +1 -1
  40. package/tests/basics/addPcbVia/pcb-via-with-net.test.ts +1 -1
  41. package/tests/basics/addPcbVia/pcb-via-with-soldermask.test.ts +1 -1
  42. package/tests/basics/addPlatedHole/__snapshots__/pcb-plated-hole-circular-hole-with-rect-pad.snap.svg +1 -1
  43. package/tests/basics/addPlatedHole/__snapshots__/pcb-plated-hole-oval.snap.svg +1 -1
  44. package/tests/basics/addPlatedHole/__snapshots__/pcb-plated-hole-pill-with-rect-pad.snap.svg +1 -1
  45. package/tests/basics/addPlatedHole/__snapshots__/pcb-plated-hole-pill.snap.svg +1 -1
  46. package/tests/basics/addPlatedHole/__snapshots__/pcb-plated-hole-polygon.snap.svg +1 -1
  47. package/tests/basics/addPlatedHole/__snapshots__/pcb-plated-hole-rotated-pill-with-rect-pad.snap.svg +1 -1
  48. package/tests/basics/addPlatedHole/pcb-plated-hole-circle.test.ts +1 -1
  49. package/tests/basics/addPlatedHole/pcb-plated-hole-circular-hole-with-rect-pad.test.ts +1 -1
  50. package/tests/basics/addPlatedHole/pcb-plated-hole-oval.test.ts +1 -1
  51. package/tests/basics/addPlatedHole/pcb-plated-hole-pill-with-rect-pad.test.ts +1 -1
  52. package/tests/basics/addPlatedHole/pcb-plated-hole-pill.test.ts +1 -1
  53. package/tests/basics/addPlatedHole/pcb-plated-hole-polygon.test.ts +1 -1
  54. package/tests/basics/addPlatedHole/pcb-plated-hole-rotated-pill-with-rect-pad.test.ts +1 -1
  55. package/tests/basics/addSmtPad/__snapshots__/circleSmtPad.snap.svg +1 -1
  56. package/tests/basics/addSmtPad/__snapshots__/pillSmtPad.snap.svg +1 -1
  57. package/tests/basics/addSmtPad/__snapshots__/polygonSmtPad.snap.svg +1 -1
  58. package/tests/basics/addSmtPad/__snapshots__/rotatedPillSmtPad.snap.svg +1 -1
  59. package/tests/basics/addSmtPad/__snapshots__/rotatedRectSmtPad.snap.svg +1 -1
  60. package/tests/basics/addSmtPad/circleSmtPad.test.ts +1 -1
  61. package/tests/basics/addSmtPad/pillSmtPad.test.ts +1 -1
  62. package/tests/basics/addSmtPad/polygonSmtPad.test.ts +1 -1
  63. package/tests/basics/addSmtPad/rotatedPillSmtPad.test.ts +1 -1
  64. package/tests/basics/addSmtPad/rotatedRectSmtPad.test.ts +1 -1
  65. package/tests/basics/board-outline-does-not-appear-in soldermask.test.ts +1 -1
  66. package/tests/basics/board-outline.test.ts +1 -1
  67. package/tests/basics/copper-cut-fill/__snapshots__/copper-cut-fill-basic.snap.svg +8 -0
  68. package/tests/basics/copper-cut-fill/__snapshots__/copper-cut-fill-with-pads.snap.svg +8 -0
  69. package/tests/basics/copper-cut-fill/copper-cut-fill-basic.test.ts +73 -0
  70. package/tests/basics/copper-cut-fill/copper-cut-fill-with-pads.test.ts +128 -0
  71. package/tests/basics/keyboard-defaul60/keyboard-both-layer-includeSoldermask.test.ts +1 -1
  72. package/tests/basics/keyboard-defaul60/keyboard-both-layers.test.ts +1 -1
  73. package/tests/basics/keyboard-defaul60/keyboard-bottom-layer.test.ts +1 -1
  74. package/tests/basics/keyboard-defaul60/keyboard-top-layer.test.ts +1 -1
  75. package/tests/basics/laser-profile.test.ts +4 -4
  76. package/tests/basics/lga-interconnect.test.ts +1 -1
  77. package/tests/basics/mixed-soldermask-margins.test.ts +1 -1
  78. package/tests/basics/single-trace.test.ts +1 -1
  79. package/tests/basics/soldermask/copper-and-soldermask.test.ts +1 -1
  80. package/tests/basics/soldermask/copper-only.test.ts +1 -1
  81. package/tests/basics/soldermask/soldermask-only.test.ts +1 -1
  82. package/tests/basics/soldermask-margin/__snapshots__/percent-soldermask-margin.snap.svg +1 -1
  83. package/tests/basics/soldermask-margin/negative-soldermask-margin.test.ts +1 -1
  84. package/tests/basics/soldermask-margin/percent-soldermask-margin.test.ts +1 -1
  85. package/tests/basics/soldermask-margin/positive-soldermask-margin.test.ts +1 -1
  86. package/tests/basics/trace-margin/pico-w-3x5-led-matrix-trace-margin.test.ts +1 -1
  87. package/tests/basics/trace-margin/trace-margin-basic.test.ts +1 -1
  88. package/tests/basics/trace-margin/trace-margin-error.test.ts +4 -4
  89. package/tests/examples/example01/__snapshots__/example01.snap.svg +1 -1
  90. package/tests/examples/example01/example01.test.ts +1 -1
  91. package/tests/examples/example02/example02.test.ts +1 -1
  92. package/tests/examples/example03/example03.test.ts +1 -1
  93. package/tests/examples/example04/example04.test.ts +1 -1
  94. package/tests/examples/example05/__snapshots__/example05.snap.svg +1 -0
  95. package/tests/examples/example05/example05.circuit.json +9562 -0
  96. package/tests/examples/example05/example05.test.ts +31 -0
package/dist/index.d.ts CHANGED
@@ -15,6 +15,17 @@ interface ConvertCircuitJsonToLbrnOptions {
15
15
  includeLayers?: Array<"top" | "bottom">;
16
16
  traceMargin?: number;
17
17
  laserSpotSize?: number;
18
+ /**
19
+ * Whether to generate copper cut fill layers.
20
+ * Creates a ring/band around traces and pads that will be laser cut
21
+ * to remove copper, without cutting into the traces or pads themselves.
22
+ */
23
+ includeCopperCutFill?: boolean;
24
+ /**
25
+ * Margin to expand the copper outline for the cut fill band (in mm).
26
+ * This determines how wide the band of copper removal will be around traces/pads.
27
+ */
28
+ copperCutFillMargin?: number;
18
29
  laserProfile?: {
19
30
  copper?: {
20
31
  speed?: number;
@@ -30,6 +41,6 @@ interface ConvertCircuitJsonToLbrnOptions {
30
41
  };
31
42
  };
32
43
  }
33
- declare const convertCircuitJsonToLbrn: (circuitJson: CircuitJson, options?: ConvertCircuitJsonToLbrnOptions) => LightBurnProject;
44
+ declare const convertCircuitJsonToLbrn: (circuitJson: CircuitJson, options?: ConvertCircuitJsonToLbrnOptions) => Promise<LightBurnProject>;
34
45
 
35
46
  export { type ConvertCircuitJsonToLbrnOptions, convertCircuitJsonToLbrn };
package/dist/index.js CHANGED
@@ -1728,6 +1728,9 @@ var createCirclePolygon = (center, radius, numPoints = 16) => {
1728
1728
  }
1729
1729
  return new Flatten4.Polygon(points);
1730
1730
  };
1731
+ var positionKey = (x, y) => {
1732
+ return `${x.toFixed(6)},${y.toFixed(6)}`;
1733
+ };
1731
1734
  var addPcbTrace = (trace, ctx) => {
1732
1735
  const {
1733
1736
  topCutNetGeoms,
@@ -1738,7 +1741,9 @@ var addPcbTrace = (trace, ctx) => {
1738
1741
  origin,
1739
1742
  includeCopper,
1740
1743
  includeLayers,
1741
- traceMargin
1744
+ traceMargin,
1745
+ topTraceEndpoints,
1746
+ bottomTraceEndpoints
1742
1747
  } = ctx;
1743
1748
  if (!includeCopper) {
1744
1749
  return;
@@ -1779,22 +1784,32 @@ var addPcbTrace = (trace, ctx) => {
1779
1784
  cutNetGeoms.get(netId)?.push(result);
1780
1785
  }
1781
1786
  const segments = layerSegments.get(layer);
1787
+ const endpointSet = layer === "top" ? topTraceEndpoints : bottomTraceEndpoints;
1782
1788
  if (segments) {
1783
1789
  for (const segment of segments) {
1784
1790
  if (segment.length >= 2) {
1785
1791
  const firstPoint = segment[0];
1786
1792
  const lastPoint = segment[segment.length - 1];
1787
1793
  const radius = traceWidth / 2 * 1.1;
1788
- const startCircle = createCirclePolygon(
1789
- { x: firstPoint.x + origin.x, y: firstPoint.y + origin.y },
1790
- radius
1791
- );
1792
- cutNetGeoms.get(netId)?.push(startCircle);
1793
- const endCircle = createCirclePolygon(
1794
- { x: lastPoint.x + origin.x, y: lastPoint.y + origin.y },
1795
- radius
1796
- );
1797
- cutNetGeoms.get(netId)?.push(endCircle);
1794
+ const startX = firstPoint.x + origin.x;
1795
+ const startY = firstPoint.y + origin.y;
1796
+ const startKey = positionKey(startX, startY);
1797
+ if (!endpointSet.has(startKey)) {
1798
+ endpointSet.add(startKey);
1799
+ const startCircle = createCirclePolygon(
1800
+ { x: startX, y: startY },
1801
+ radius
1802
+ );
1803
+ cutNetGeoms.get(netId)?.push(startCircle);
1804
+ }
1805
+ const endX = lastPoint.x + origin.x;
1806
+ const endY = lastPoint.y + origin.y;
1807
+ const endKey = positionKey(endX, endY);
1808
+ if (!endpointSet.has(endKey)) {
1809
+ endpointSet.add(endKey);
1810
+ const endCircle = createCirclePolygon({ x: endX, y: endY }, radius);
1811
+ cutNetGeoms.get(netId)?.push(endCircle);
1812
+ }
1798
1813
  }
1799
1814
  }
1800
1815
  }
@@ -2481,8 +2496,168 @@ var createTraceClearanceAreasForLayer = ({
2481
2496
  }
2482
2497
  };
2483
2498
 
2499
+ // lib/createCopperCutFillForLayer.ts
2500
+ import { Polygon as Polygon5, Box as Box4, Point as Point2 } from "@flatten-js/core";
2501
+ import { ShapePath as ShapePath28, ShapeGroup } from "lbrnts";
2502
+
2503
+ // lib/getManifold.ts
2504
+ var manifoldInstance = null;
2505
+ var getManifold = async () => {
2506
+ if (!manifoldInstance) {
2507
+ let ManifoldModule;
2508
+ try {
2509
+ const moduleName = "manifold-3d";
2510
+ ManifoldModule = (await import(
2511
+ /* @vite-ignore */
2512
+ moduleName
2513
+ )).default;
2514
+ } catch {
2515
+ try {
2516
+ const cdnUrl = "https://cdn.jsdelivr.net/npm/manifold-3d@3.0.0/manifold.js";
2517
+ ManifoldModule = (await import(
2518
+ /* @vite-ignore */
2519
+ cdnUrl
2520
+ )).default;
2521
+ } catch (cdnError) {
2522
+ throw new Error(
2523
+ `Failed to load manifold-3d: not available in Node.js or via CDN. ${cdnError}`
2524
+ );
2525
+ }
2526
+ }
2527
+ manifoldInstance = await ManifoldModule();
2528
+ manifoldInstance.setup();
2529
+ }
2530
+ return manifoldInstance;
2531
+ };
2532
+
2533
+ // lib/createCopperCutFillForLayer.ts
2534
+ var polygonToContours = (polygon) => {
2535
+ const contours = [];
2536
+ if (polygon instanceof Box4) {
2537
+ const { xmin, ymin, xmax, ymax } = polygon;
2538
+ contours.push([
2539
+ [xmin, ymin],
2540
+ [xmax, ymin],
2541
+ [xmax, ymax],
2542
+ [xmin, ymax]
2543
+ ]);
2544
+ } else {
2545
+ for (const face of polygon.faces) {
2546
+ const contour = [];
2547
+ for (const edge of face) {
2548
+ contour.push([edge.start.x, edge.start.y]);
2549
+ }
2550
+ if (contour.length >= 3) {
2551
+ contours.push(contour);
2552
+ }
2553
+ }
2554
+ }
2555
+ return contours;
2556
+ };
2557
+ var contourToPolygon = (contour) => {
2558
+ if (contour.length < 3) return null;
2559
+ const points = contour.map(([x, y]) => new Point2(x, y));
2560
+ try {
2561
+ const polygon = new Polygon5(points);
2562
+ if (polygon.faces.size > 0) {
2563
+ return polygon;
2564
+ }
2565
+ } catch {
2566
+ }
2567
+ return null;
2568
+ };
2569
+ var createCopperCutFillForLayer = async ({
2570
+ layer,
2571
+ ctx
2572
+ }) => {
2573
+ const {
2574
+ project,
2575
+ connMap,
2576
+ topCutNetGeoms,
2577
+ bottomCutNetGeoms,
2578
+ topCopperCutFillCutSetting,
2579
+ bottomCopperCutFillCutSetting,
2580
+ copperCutFillMargin
2581
+ } = ctx;
2582
+ const cutSetting = layer === "top" ? topCopperCutFillCutSetting : bottomCopperCutFillCutSetting;
2583
+ if (!cutSetting) {
2584
+ return;
2585
+ }
2586
+ const netGeomMap = layer === "top" ? topCutNetGeoms : bottomCutNetGeoms;
2587
+ const allGeoms = [];
2588
+ for (const net of Object.keys(connMap.netMap)) {
2589
+ const netGeoms = netGeomMap.get(net);
2590
+ if (netGeoms && netGeoms.length > 0) {
2591
+ allGeoms.push(...netGeoms);
2592
+ }
2593
+ }
2594
+ if (allGeoms.length === 0) {
2595
+ return;
2596
+ }
2597
+ try {
2598
+ const manifold = await getManifold();
2599
+ const { CrossSection } = manifold;
2600
+ const allContours = [];
2601
+ for (const geom of allGeoms) {
2602
+ const contours = polygonToContours(geom);
2603
+ allContours.push(...contours);
2604
+ }
2605
+ if (allContours.length === 0) {
2606
+ return;
2607
+ }
2608
+ const copperInside = new CrossSection(allContours, "Positive");
2609
+ const outerBoundary = copperInside.offset(
2610
+ copperCutFillMargin,
2611
+ "Round",
2612
+ // joinType for smooth corners
2613
+ 2,
2614
+ // miterLimit
2615
+ 32
2616
+ // circularSegments for round corners
2617
+ );
2618
+ const cutFillArea = outerBoundary.subtract(copperInside);
2619
+ const simplifiedArea = cutFillArea.simplify(1e-3);
2620
+ const resultContours = simplifiedArea.toPolygons();
2621
+ if (resultContours.length === 0) {
2622
+ copperInside.delete();
2623
+ outerBoundary.delete();
2624
+ cutFillArea.delete();
2625
+ simplifiedArea.delete();
2626
+ return;
2627
+ }
2628
+ const shapeGroup = new ShapeGroup();
2629
+ for (const contour of resultContours) {
2630
+ const polygon = contourToPolygon(contour);
2631
+ if (!polygon) continue;
2632
+ for (const island of polygon.splitToIslands()) {
2633
+ const { verts, prims } = polygonToShapePathData(island);
2634
+ if (verts.length > 0) {
2635
+ shapeGroup.children.push(
2636
+ new ShapePath28({
2637
+ cutIndex: cutSetting.index,
2638
+ verts,
2639
+ prims,
2640
+ isClosed: true
2641
+ // Filled shapes should be closed
2642
+ })
2643
+ );
2644
+ }
2645
+ }
2646
+ }
2647
+ if (shapeGroup.children.length > 0) {
2648
+ project.children.push(shapeGroup);
2649
+ }
2650
+ copperInside.delete();
2651
+ outerBoundary.delete();
2652
+ cutFillArea.delete();
2653
+ simplifiedArea.delete();
2654
+ } catch (error) {
2655
+ console.warn(`Failed to create copper cut fill for ${layer} layer:`, error);
2656
+ }
2657
+ };
2658
+
2484
2659
  // lib/index.ts
2485
- var convertCircuitJsonToLbrn = (circuitJson, options = {}) => {
2660
+ var convertCircuitJsonToLbrn = async (circuitJson, options = {}) => {
2486
2661
  const db = cju2(circuitJson);
2487
2662
  const project = new LightBurnProject({
2488
2663
  appVersion: "1.7.03",
@@ -2496,6 +2671,8 @@ var convertCircuitJsonToLbrn = (circuitJson, options = {}) => {
2496
2671
  const globalCopperSoldermaskMarginAdjustment = options.globalCopperSoldermaskMarginAdjustment ?? 0;
2497
2672
  const solderMaskMarginPercent = options.solderMaskMarginPercent ?? 0;
2498
2673
  const laserProfile = options.laserProfile;
2674
+ const includeCopperCutFill = options.includeCopperCutFill ?? false;
2675
+ const copperCutFillMargin = options.copperCutFillMargin ?? 0.5;
2499
2676
  const defaultCopperSettings = {
2500
2677
  speed: 300,
2501
2678
  numPasses: 100,
@@ -2553,13 +2730,14 @@ var convertCircuitJsonToLbrn = (circuitJson, options = {}) => {
2553
2730
  crossHatch: true
2554
2731
  });
2555
2732
  project.children.push(soldermaskCutSetting);
2733
+ let nextCutIndex = 4;
2556
2734
  let topTraceClearanceAreaCutSetting;
2557
2735
  let bottomTraceClearanceAreaCutSetting;
2558
2736
  if (shouldGenerateTraceClearanceZones) {
2559
2737
  if (includeLayers.includes("top")) {
2560
2738
  topTraceClearanceAreaCutSetting = new CutSetting({
2561
2739
  type: "Scan",
2562
- index: 4,
2740
+ index: nextCutIndex++,
2563
2741
  name: "Clear Top Trace Clearance Areas",
2564
2742
  numPasses: 12,
2565
2743
  speed: 100,
@@ -2573,7 +2751,7 @@ var convertCircuitJsonToLbrn = (circuitJson, options = {}) => {
2573
2751
  if (includeLayers.includes("bottom")) {
2574
2752
  bottomTraceClearanceAreaCutSetting = new CutSetting({
2575
2753
  type: "Scan",
2576
- index: 5,
2754
+ index: nextCutIndex++,
2577
2755
  name: "Clear Bottom Trace Clearance Areas",
2578
2756
  numPasses: 12,
2579
2757
  speed: 100,
@@ -2585,6 +2763,38 @@ var convertCircuitJsonToLbrn = (circuitJson, options = {}) => {
2585
2763
  project.children.push(bottomTraceClearanceAreaCutSetting);
2586
2764
  }
2587
2765
  }
2766
+ let topCopperCutFillCutSetting;
2767
+ let bottomCopperCutFillCutSetting;
2768
+ if (includeCopperCutFill && includeCopper) {
2769
+ if (includeLayers.includes("top")) {
2770
+ topCopperCutFillCutSetting = new CutSetting({
2771
+ type: "Scan",
2772
+ index: nextCutIndex++,
2773
+ name: "Top Copper Cut Fill",
2774
+ numPasses: copperSettings.numPasses,
2775
+ speed: copperSettings.speed,
2776
+ scanOpt: "individual",
2777
+ interval: laserSpotSize,
2778
+ angle: 45,
2779
+ crossHatch: true
2780
+ });
2781
+ project.children.push(topCopperCutFillCutSetting);
2782
+ }
2783
+ if (includeLayers.includes("bottom")) {
2784
+ bottomCopperCutFillCutSetting = new CutSetting({
2785
+ type: "Scan",
2786
+ index: nextCutIndex++,
2787
+ name: "Bottom Copper Cut Fill",
2788
+ numPasses: copperSettings.numPasses,
2789
+ speed: copperSettings.speed,
2790
+ scanOpt: "individual",
2791
+ interval: laserSpotSize,
2792
+ angle: 45,
2793
+ crossHatch: true
2794
+ });
2795
+ project.children.push(bottomCopperCutFillCutSetting);
2796
+ }
2797
+ }
2588
2798
  const connMap = getFullConnectivityMapFromCircuitJson(circuitJson);
2589
2799
  let origin = options.origin;
2590
2800
  if (!origin) {
@@ -2612,7 +2822,12 @@ var convertCircuitJsonToLbrn = (circuitJson, options = {}) => {
2612
2822
  laserSpotSize,
2613
2823
  topTraceClearanceAreaCutSetting,
2614
2824
  bottomTraceClearanceAreaCutSetting,
2615
- solderMaskMarginPercent
2825
+ solderMaskMarginPercent,
2826
+ topCopperCutFillCutSetting,
2827
+ bottomCopperCutFillCutSetting,
2828
+ copperCutFillMargin,
2829
+ topTraceEndpoints: /* @__PURE__ */ new Set(),
2830
+ bottomTraceEndpoints: /* @__PURE__ */ new Set()
2616
2831
  };
2617
2832
  for (const net of Object.keys(connMap.netMap)) {
2618
2833
  ctx.topCutNetGeoms.set(net, []);
@@ -2657,6 +2872,14 @@ var convertCircuitJsonToLbrn = (circuitJson, options = {}) => {
2657
2872
  createTraceClearanceAreasForLayer({ layer: "bottom", ctx });
2658
2873
  }
2659
2874
  }
2875
+ if (includeCopperCutFill && includeCopper) {
2876
+ if (includeLayers.includes("top")) {
2877
+ await createCopperCutFillForLayer({ layer: "top", ctx });
2878
+ }
2879
+ if (includeLayers.includes("bottom")) {
2880
+ await createCopperCutFillForLayer({ layer: "bottom", ctx });
2881
+ }
2882
+ }
2660
2883
  return project;
2661
2884
  };
2662
2885
  export {
@@ -44,6 +44,18 @@ export interface ConvertContext {
44
44
  topTraceClearanceAreaCutSetting?: CutSetting
45
45
  bottomTraceClearanceAreaCutSetting?: CutSetting
46
46
 
47
+ // Cut settings for copper cut fill layers
48
+ topCopperCutFillCutSetting?: CutSetting
49
+ bottomCopperCutFillCutSetting?: CutSetting
50
+
47
51
  // Percent-based solder mask margin (scales with element size)
48
52
  solderMaskMarginPercent: number
53
+
54
+ // Copper cut fill margin (how far to expand the copper outline for the cut fill band)
55
+ copperCutFillMargin: number
56
+
57
+ // Track trace endpoint positions to avoid duplicate circles
58
+ // Key is "x,y" rounded to 6 decimal places
59
+ topTraceEndpoints: Set<string>
60
+ bottomTraceEndpoints: Set<string>
49
61
  }
@@ -0,0 +1,217 @@
1
+ import { Polygon, Box, Point } from "@flatten-js/core"
2
+ import type { ConvertContext } from "./ConvertContext"
3
+ import { polygonToShapePathData } from "./polygon-to-shape-path"
4
+ import { ShapePath, ShapeGroup } from "lbrnts"
5
+ import { getManifold } from "./getManifold"
6
+
7
+ type Contour = Array<[number, number]>
8
+
9
+ /**
10
+ * Calculates the signed area of a contour.
11
+ * Positive area = counter-clockwise winding (outer boundary)
12
+ * Negative area = clockwise winding (hole)
13
+ */
14
+ const getSignedArea = (contour: Contour): number => {
15
+ let area = 0
16
+ const n = contour.length
17
+ for (let i = 0; i < n; i++) {
18
+ const j = (i + 1) % n
19
+ area += contour[i]![0] * contour[j]![1]
20
+ area -= contour[j]![0] * contour[i]![1]
21
+ }
22
+ return area / 2
23
+ }
24
+
25
+ /**
26
+ * Converts a flatten-js Polygon to an array of contours for manifold CrossSection
27
+ * Each contour is an array of [x, y] coordinates
28
+ */
29
+ const polygonToContours = (polygon: Polygon | Box): Contour[] => {
30
+ const contours: Contour[] = []
31
+
32
+ if (polygon instanceof Box) {
33
+ // Convert Box to contour
34
+ const { xmin, ymin, xmax, ymax } = polygon
35
+ contours.push([
36
+ [xmin, ymin],
37
+ [xmax, ymin],
38
+ [xmax, ymax],
39
+ [xmin, ymax],
40
+ ])
41
+ } else {
42
+ // Handle Polygon faces
43
+ for (const face of polygon.faces) {
44
+ const contour: Contour = []
45
+ for (const edge of face) {
46
+ contour.push([edge.start.x, edge.start.y])
47
+ }
48
+ if (contour.length >= 3) {
49
+ contours.push(contour)
50
+ }
51
+ }
52
+ }
53
+
54
+ return contours
55
+ }
56
+
57
+ /**
58
+ * Converts a single contour to a flatten-js Polygon
59
+ */
60
+ const contourToPolygon = (contour: Contour): Polygon | null => {
61
+ if (contour.length < 3) return null
62
+
63
+ const points = contour.map(([x, y]) => new Point(x, y))
64
+ try {
65
+ const polygon = new Polygon(points)
66
+ if (polygon.faces.size > 0) {
67
+ return polygon
68
+ }
69
+ } catch {
70
+ // Skip invalid polygons
71
+ }
72
+ return null
73
+ }
74
+
75
+ /**
76
+ * Creates copper cut fill for a given layer using manifold3d CrossSection for offset operations.
77
+ *
78
+ * The algorithm:
79
+ * 1. Collect all copper geometries for the layer (traces, pads, vias, plated holes)
80
+ * 2. Union all copper geometries into a single CrossSection (the "inside")
81
+ * 3. Offset (expand) the unified copper outward by copperCutFillMargin (the "outer boundary")
82
+ * 4. Subtract the inside (original copper) from the outer boundary (expanded copper)
83
+ * 5. The result is the ring/band area around copper that needs to be lasered to remove more copper
84
+ *
85
+ * This creates a laser cut path that removes copper around traces and pads but never
86
+ * cuts into the traces or pads themselves.
87
+ */
88
+ export const createCopperCutFillForLayer = async ({
89
+ layer,
90
+ ctx,
91
+ }: {
92
+ layer: "top" | "bottom"
93
+ ctx: ConvertContext
94
+ }): Promise<void> => {
95
+ const {
96
+ project,
97
+ connMap,
98
+ topCutNetGeoms,
99
+ bottomCutNetGeoms,
100
+ topCopperCutFillCutSetting,
101
+ bottomCopperCutFillCutSetting,
102
+ copperCutFillMargin,
103
+ } = ctx
104
+
105
+ // Get the appropriate cut setting for this layer
106
+ const cutSetting =
107
+ layer === "top" ? topCopperCutFillCutSetting : bottomCopperCutFillCutSetting
108
+
109
+ if (!cutSetting) {
110
+ return
111
+ }
112
+
113
+ // Get the appropriate geometry map
114
+ const netGeomMap = layer === "top" ? topCutNetGeoms : bottomCutNetGeoms
115
+
116
+ // Collect all geometries for this layer across all nets
117
+ const allGeoms: Array<Polygon | Box> = []
118
+ for (const net of Object.keys(connMap.netMap)) {
119
+ const netGeoms = netGeomMap.get(net)
120
+ if (netGeoms && netGeoms.length > 0) {
121
+ allGeoms.push(...netGeoms)
122
+ }
123
+ }
124
+
125
+ if (allGeoms.length === 0) {
126
+ return
127
+ }
128
+
129
+ try {
130
+ // Initialize manifold
131
+ const manifold = await getManifold()
132
+ const { CrossSection } = manifold
133
+
134
+ // Collect all contours from all geometries
135
+ const allContours: Contour[] = []
136
+ for (const geom of allGeoms) {
137
+ const contours = polygonToContours(geom)
138
+ allContours.push(...contours)
139
+ }
140
+
141
+ if (allContours.length === 0) {
142
+ return
143
+ }
144
+
145
+ // Create a unified CrossSection from all copper contours (the "inside")
146
+ // The constructor performs a boolean union with Positive fill rule
147
+ const copperInside = new CrossSection(allContours, "Positive")
148
+
149
+ // Offset (expand) the copper outward by the margin to get the "outer boundary"
150
+ // Positive delta expands outward
151
+ const outerBoundary = copperInside.offset(
152
+ copperCutFillMargin,
153
+ "Round", // joinType for smooth corners
154
+ 2.0, // miterLimit
155
+ 32, // circularSegments for round corners
156
+ )
157
+
158
+ // Subtract the inside (original copper) from the outer boundary (expanded copper)
159
+ // This gives us the ring/band area to laser cut
160
+ const cutFillArea = outerBoundary.subtract(copperInside)
161
+
162
+ // Simplify to clean up any spurious tiny segments
163
+ const simplifiedArea = cutFillArea.simplify(0.001)
164
+
165
+ // Get the resulting contours
166
+ const resultContours: Contour[] = simplifiedArea.toPolygons()
167
+
168
+ if (resultContours.length === 0) {
169
+ // Clean up WASM memory
170
+ copperInside.delete()
171
+ outerBoundary.delete()
172
+ cutFillArea.delete()
173
+ simplifiedArea.delete()
174
+ return
175
+ }
176
+
177
+ // Create a ShapeGroup to hold all contours
178
+ // LightBurn uses nonzero winding rule: CCW (positive area) = outer boundary, CW (negative area) = hole
179
+ // By grouping all contours together, LightBurn should properly fill only the ring area
180
+ const shapeGroup = new ShapeGroup()
181
+
182
+ // Convert all contours to ShapePaths and add to the group
183
+ // Preserve winding order: positive area (CCW) = outer boundary, negative area (CW) = hole
184
+ for (const contour of resultContours) {
185
+ const polygon = contourToPolygon(contour)
186
+ if (!polygon) continue
187
+
188
+ for (const island of polygon.splitToIslands()) {
189
+ const { verts, prims } = polygonToShapePathData(island)
190
+
191
+ if (verts.length > 0) {
192
+ shapeGroup.children.push(
193
+ new ShapePath({
194
+ cutIndex: cutSetting.index,
195
+ verts,
196
+ prims,
197
+ isClosed: true, // Filled shapes should be closed
198
+ }),
199
+ )
200
+ }
201
+ }
202
+ }
203
+
204
+ // Add the group to the project if it has shapes
205
+ if (shapeGroup.children.length > 0) {
206
+ project.children.push(shapeGroup)
207
+ }
208
+
209
+ // Clean up WASM memory
210
+ copperInside.delete()
211
+ outerBoundary.delete()
212
+ cutFillArea.delete()
213
+ simplifiedArea.delete()
214
+ } catch (error) {
215
+ console.warn(`Failed to create copper cut fill for ${layer} layer:`, error)
216
+ }
217
+ }
@@ -27,6 +27,14 @@ const createCirclePolygon = (
27
27
  return new Flatten.Polygon(points)
28
28
  }
29
29
 
30
+ /**
31
+ * Creates a position key for deduplication.
32
+ * Rounds to 6 decimal places to handle floating point precision.
33
+ */
34
+ const positionKey = (x: number, y: number): string => {
35
+ return `${x.toFixed(6)},${y.toFixed(6)}`
36
+ }
37
+
30
38
  export const addPcbTrace = (trace: PcbTrace, ctx: ConvertContext) => {
31
39
  const {
32
40
  topCutNetGeoms,
@@ -38,6 +46,8 @@ export const addPcbTrace = (trace: PcbTrace, ctx: ConvertContext) => {
38
46
  includeCopper,
39
47
  includeLayers,
40
48
  traceMargin,
49
+ topTraceEndpoints,
50
+ bottomTraceEndpoints,
41
51
  } = ctx
42
52
 
43
53
  // Only include traces when including copper
@@ -95,7 +105,10 @@ export const addPcbTrace = (trace: PcbTrace, ctx: ConvertContext) => {
95
105
  // Add circular pads at trace endpoints to ensure overlap at junctions
96
106
  // This is crucial for boolean union to properly merge traces that share endpoints
97
107
  // Use a slightly larger radius (1.1x) to ensure proper overlap despite floating point issues
108
+ // Deduplicate endpoints to avoid identical polygons that cause boolean operation failures
98
109
  const segments = layerSegments.get(layer)
110
+ const endpointSet =
111
+ layer === "top" ? topTraceEndpoints : bottomTraceEndpoints
99
112
  if (segments) {
100
113
  for (const segment of segments) {
101
114
  if (segment.length >= 2) {
@@ -103,19 +116,28 @@ export const addPcbTrace = (trace: PcbTrace, ctx: ConvertContext) => {
103
116
  const lastPoint = segment[segment.length - 1]!
104
117
  const radius = (traceWidth / 2) * 1.1 // Slightly larger for reliable overlap
105
118
 
106
- // Add circle at start point (translated by origin)
107
- const startCircle = createCirclePolygon(
108
- { x: firstPoint.x + origin.x, y: firstPoint.y + origin.y },
109
- radius,
110
- )
111
- cutNetGeoms.get(netId)?.push(startCircle)
112
-
113
- // Add circle at end point (translated by origin)
114
- const endCircle = createCirclePolygon(
115
- { x: lastPoint.x + origin.x, y: lastPoint.y + origin.y },
116
- radius,
117
- )
118
- cutNetGeoms.get(netId)?.push(endCircle)
119
+ // Add circle at start point (translated by origin) if not already added
120
+ const startX = firstPoint.x + origin.x
121
+ const startY = firstPoint.y + origin.y
122
+ const startKey = positionKey(startX, startY)
123
+ if (!endpointSet.has(startKey)) {
124
+ endpointSet.add(startKey)
125
+ const startCircle = createCirclePolygon(
126
+ { x: startX, y: startY },
127
+ radius,
128
+ )
129
+ cutNetGeoms.get(netId)?.push(startCircle)
130
+ }
131
+
132
+ // Add circle at end point (translated by origin) if not already added
133
+ const endX = lastPoint.x + origin.x
134
+ const endY = lastPoint.y + origin.y
135
+ const endKey = positionKey(endX, endY)
136
+ if (!endpointSet.has(endKey)) {
137
+ endpointSet.add(endKey)
138
+ const endCircle = createCirclePolygon({ x: endX, y: endY }, radius)
139
+ cutNetGeoms.get(netId)?.push(endCircle)
140
+ }
119
141
  }
120
142
  }
121
143
  }