circuit-json-to-lbrn 0.0.84 → 0.0.85

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
@@ -43,6 +43,8 @@ const defaultLbrn = convertCircuitJsonToLbrn(circuitJson)
43
43
  - `includeLayers?: Array<"top" | "bottom">` - Specify which layers to include (default: `["top", "bottom"]`)
44
44
  - `mirrorBottomLayer?: boolean` - Mirror bottom layer across the board X-center for flipped-board cutting (default: `false`)
45
45
  - `includeHolePunch?: boolean` - Include "Hole Punch Top" / "Hole Punch Bottom" layers that mark hole centers for drilling (default: `true`)
46
+ - `includeSoldermaskAblation?: boolean` - Include a top-layer scan outline around all copper for soldermask ablation (default: `false`)
47
+ - `soldermaskAblationClearance?: number` - Clearance from copper to the soldermask ablation outline in mm (default: `1`)
46
48
  - `toolingLayerIncludeRefs?: string[]` - Copy the PCB copper lands for component selectors such as `".U1"` or `".TP1"` to LightBurn's native, non-output T1 tooling layer (default: `[]`)
47
49
 
48
50
  ## Soldermask Support
package/dist/index.d.ts CHANGED
@@ -11,6 +11,10 @@ interface ConvertCircuitJsonToLbrnOptions {
11
11
  includeCopper?: boolean;
12
12
  includeSoldermask?: boolean;
13
13
  includeSoldermaskCure?: boolean;
14
+ /** Generate a top-layer outer contour around copper for soldermask ablation. */
15
+ includeSoldermaskAblation?: boolean;
16
+ /** Clearance between copper and the soldermask ablation outline, in mm. Defaults to 1. */
17
+ soldermaskAblationClearance?: number;
14
18
  globalCopperSoldermaskMarginAdjustment?: number;
15
19
  solderMaskMarginPercent?: number;
16
20
  includeLayers?: Array<"top" | "bottom">;
package/dist/index.js CHANGED
@@ -26,6 +26,7 @@ var LAYER_INDEXES = {
26
26
  reflectedBottomBoardCut: 13,
27
27
  topHolePunch: 14,
28
28
  bottomHolePunch: 15,
29
+ topSoldermaskAblation: 16,
29
30
  // Native LightBurn non-output tool layers
30
31
  tool1: 30
31
32
  };
@@ -116,6 +117,9 @@ var calculateOriginFromBounds = (bounds, margin) => {
116
117
  return { x: originX, y: originY };
117
118
  };
118
119
 
120
+ // lib/create-soldermask-ablation-outline.ts
121
+ import { ShapeGroup as ShapeGroup2 } from "lbrnts";
122
+
119
123
  // lib/createCopperCutFillForLayer.ts
120
124
  import { Box, Point, Polygon } from "@flatten-js/core";
121
125
  import { ShapeGroup } from "lbrnts";
@@ -338,6 +342,76 @@ var createCopperCutFillForLayer = async ({
338
342
  }
339
343
  };
340
344
 
345
+ // lib/create-soldermask-ablation-outline.ts
346
+ var createSoldermaskAblationOutline = async (ctx) => {
347
+ const {
348
+ boardOutlineContour,
349
+ clipCopperCutFillToBoardOutline,
350
+ project,
351
+ soldermaskAblationClearance,
352
+ topCutNetGeoms,
353
+ topSoldermaskAblationCutSetting
354
+ } = ctx;
355
+ if (!topSoldermaskAblationCutSetting) return;
356
+ const copperContours = [];
357
+ for (const netGeometries of topCutNetGeoms.values()) {
358
+ for (const geometry of netGeometries) {
359
+ copperContours.push(
360
+ ...polygonToContours(geometry).map(normalizeContourToCcw)
361
+ );
362
+ }
363
+ }
364
+ if (copperContours.length === 0) return;
365
+ try {
366
+ const { CrossSection } = await getManifold();
367
+ const copperArea = new CrossSection(copperContours, "NonZero");
368
+ const expandedCopperArea = copperArea.offset(
369
+ soldermaskAblationClearance,
370
+ "Round",
371
+ 2,
372
+ 32
373
+ );
374
+ let outlineArea = expandedCopperArea;
375
+ if (clipCopperCutFillToBoardOutline && boardOutlineContour && boardOutlineContour.length >= 3) {
376
+ const boardArea = new CrossSection(
377
+ [normalizeContourToCcw(boardOutlineContour)],
378
+ "NonZero"
379
+ );
380
+ outlineArea = expandedCopperArea.intersect(boardArea);
381
+ expandedCopperArea.delete();
382
+ boardArea.delete();
383
+ }
384
+ const simplifiedOutline = outlineArea.simplify(1e-3);
385
+ const outerContours = simplifiedOutline.toPolygons().filter((contour) => signedArea(contour) > 0);
386
+ const outlineGroup = new ShapeGroup2();
387
+ for (const contour of outerContours) {
388
+ const polygon = contourToPolygon(contour);
389
+ if (!polygon) continue;
390
+ for (const island of polygon.splitToIslands()) {
391
+ const pathData = polygonToShapePathData(island);
392
+ if (pathData.verts.length === 0) continue;
393
+ outlineGroup.children.push(
394
+ createLayerShapePath({
395
+ cutIndex: topSoldermaskAblationCutSetting.index,
396
+ pathData,
397
+ layer: "top",
398
+ isClosed: true,
399
+ ctx
400
+ })
401
+ );
402
+ }
403
+ }
404
+ if (outlineGroup.children.length > 0) {
405
+ project.children.push(outlineGroup);
406
+ }
407
+ copperArea.delete();
408
+ outlineArea.delete();
409
+ simplifiedOutline.delete();
410
+ } catch (error) {
411
+ console.warn("Failed to create top soldermask ablation outline:", error);
412
+ }
413
+ };
414
+
341
415
  // lib/createCopperShapesForLayer.ts
342
416
  import { Box as Box2, Point as Point2, Polygon as Polygon2 } from "@flatten-js/core";
343
417
  var signedArea2 = (contour) => {
@@ -708,7 +782,7 @@ var createOxidationCleaningLayerForLayer = async ({
708
782
 
709
783
  // lib/createSoldermaskCureLayer.ts
710
784
  import { Point as Point3, Polygon as Polygon3 } from "@flatten-js/core";
711
- import { ShapeGroup as ShapeGroup2, ShapePath as ShapePath3 } from "lbrnts";
785
+ import { ShapeGroup as ShapeGroup3, ShapePath as ShapePath3 } from "lbrnts";
712
786
  var contourToPolygon3 = (contour) => {
713
787
  if (contour.length < 3) return null;
714
788
  const points = contour.map(([x, y]) => new Point3(x, y));
@@ -748,7 +822,7 @@ var collectSoldermaskContours = (project, soldermaskCutIndex) => {
748
822
  }
749
823
  continue;
750
824
  }
751
- if (child instanceof ShapeGroup2) {
825
+ if (child instanceof ShapeGroup3) {
752
826
  for (const groupChild of child.children) {
753
827
  if (groupChild instanceof ShapePath3) {
754
828
  if (groupChild.cutIndex !== soldermaskCutIndex) {
@@ -800,7 +874,7 @@ var createSoldermaskCureLayerForLayer = async ({
800
874
  simplifiedArea.delete();
801
875
  return;
802
876
  }
803
- const shapeGroup = new ShapeGroup2();
877
+ const shapeGroup = new ShapeGroup3();
804
878
  for (const contour of resultContours) {
805
879
  const polygon = contourToPolygon3(contour);
806
880
  if (!polygon) continue;
@@ -3306,6 +3380,8 @@ var convertCircuitJsonToLbrn = async (circuitJson, options = {}) => {
3306
3380
  const includeCopper = options.includeCopper ?? true;
3307
3381
  const includeSoldermask = options.includeSoldermask ?? false;
3308
3382
  const includeSoldermaskCure = options.includeSoldermaskCure ?? false;
3383
+ const includeSoldermaskAblation = options.includeSoldermaskAblation ?? false;
3384
+ const soldermaskAblationClearance = options.soldermaskAblationClearance ?? 1;
3309
3385
  const includeHolePunch = options.includeHolePunch ?? true;
3310
3386
  const shouldIncludeSoldermaskCure = includeSoldermask && includeSoldermaskCure;
3311
3387
  const globalCopperSoldermaskMarginAdjustment = options.globalCopperSoldermaskMarginAdjustment ?? 0;
@@ -3342,6 +3418,9 @@ var convertCircuitJsonToLbrn = async (circuitJson, options = {}) => {
3342
3418
  if (traceMargin > 0 && !includeCopper) {
3343
3419
  throw new Error("traceMargin requires includeCopper to be true");
3344
3420
  }
3421
+ if (soldermaskAblationClearance < 0) {
3422
+ throw new Error("soldermaskAblationClearance must not be negative");
3423
+ }
3345
3424
  const topCopperCutSetting = new CutSetting2({
3346
3425
  index: LAYER_INDEXES.topCopper,
3347
3426
  name: "Cut Top Copper",
@@ -3552,6 +3631,24 @@ var convertCircuitJsonToLbrn = async (circuitJson, options = {}) => {
3552
3631
  project.children.push(bottomCopperCutFillCutSetting);
3553
3632
  }
3554
3633
  }
3634
+ let topSoldermaskAblationCutSetting;
3635
+ if (includeSoldermaskAblation && includeCopper && includeLayers.includes("top")) {
3636
+ topSoldermaskAblationCutSetting = new CutSetting2({
3637
+ type: "Scan",
3638
+ index: LAYER_INDEXES.topSoldermaskAblation,
3639
+ name: "Top Soldermask Ablation",
3640
+ numPasses: 5,
3641
+ speed: 7e3,
3642
+ frequency: 4e4,
3643
+ scanOpt: "mergeAll",
3644
+ interval: 0.1,
3645
+ qPulseWidth: 1,
3646
+ crossHatch: true,
3647
+ wobbleEnable: true,
3648
+ anglePerPass: 1
3649
+ });
3650
+ project.children.push(topSoldermaskAblationCutSetting);
3651
+ }
3555
3652
  let topOxidationCleaningCutSetting;
3556
3653
  let bottomOxidationCleaningCutSetting;
3557
3654
  if (includeOxidationCleaningLayer) {
@@ -3600,6 +3697,7 @@ var convertCircuitJsonToLbrn = async (circuitJson, options = {}) => {
3600
3697
  topSoldermaskCureCutSetting,
3601
3698
  bottomSoldermaskCureCutSetting,
3602
3699
  reflectedBottomBoardCutSetting,
3700
+ topSoldermaskAblationCutSetting,
3603
3701
  tool1CutSetting,
3604
3702
  connMap,
3605
3703
  topCutNetGeoms: /* @__PURE__ */ new Map(),
@@ -3621,6 +3719,7 @@ var convertCircuitJsonToLbrn = async (circuitJson, options = {}) => {
3621
3719
  bottomCopperCutFillCutSetting,
3622
3720
  copperCutFillMargin,
3623
3721
  clipCopperCutFillToBoardOutline,
3722
+ soldermaskAblationClearance,
3624
3723
  topOxidationCleaningCutSetting,
3625
3724
  bottomOxidationCleaningCutSetting,
3626
3725
  topTraceEndpoints: /* @__PURE__ */ new Set(),
@@ -3728,6 +3827,9 @@ var convertCircuitJsonToLbrn = async (circuitJson, options = {}) => {
3728
3827
  await createCopperCutFillForLayer({ layer: "bottom", ctx });
3729
3828
  }
3730
3829
  }
3830
+ if (includeSoldermaskAblation && includeCopper && includeLayers.includes("top")) {
3831
+ await createSoldermaskAblationOutline(ctx);
3832
+ }
3731
3833
  if (includeOxidationCleaningLayer) {
3732
3834
  if (includeLayers.includes("top")) {
3733
3835
  await createOxidationCleaningLayerForLayer({ layer: "top", ctx });
@@ -24,6 +24,7 @@ export interface ConvertContext {
24
24
  topSoldermaskCureCutSetting?: CutSetting
25
25
  bottomSoldermaskCureCutSetting?: CutSetting
26
26
  reflectedBottomBoardCutSetting?: CutSetting
27
+ topSoldermaskAblationCutSetting?: CutSetting
27
28
  tool1CutSetting?: CutSetting
28
29
 
29
30
  connMap: ConnectivityMap
@@ -73,6 +74,7 @@ export interface ConvertContext {
73
74
  // Copper cut fill margin (how far to expand the copper outline for the cut fill band)
74
75
  copperCutFillMargin: number
75
76
  clipCopperCutFillToBoardOutline: boolean
77
+ soldermaskAblationClearance: number
76
78
 
77
79
  // Track trace endpoint positions to avoid duplicate circles
78
80
  // Key is "x,y" rounded to 6 decimal places
@@ -0,0 +1,99 @@
1
+ import { ShapeGroup } from "lbrnts"
2
+ import type { ConvertContext } from "./ConvertContext"
3
+ import {
4
+ type Contour,
5
+ contourToPolygon,
6
+ normalizeContourToCcw,
7
+ polygonToContours,
8
+ signedArea,
9
+ } from "./createCopperCutFillForLayer"
10
+ import { getManifold } from "./getManifold"
11
+ import { createLayerShapePath } from "./helpers/createLayerShapePath"
12
+ import { polygonToShapePathData } from "./polygon-to-shape-path"
13
+
14
+ export const createSoldermaskAblationOutline = async (
15
+ ctx: ConvertContext,
16
+ ): Promise<void> => {
17
+ const {
18
+ boardOutlineContour,
19
+ clipCopperCutFillToBoardOutline,
20
+ project,
21
+ soldermaskAblationClearance,
22
+ topCutNetGeoms,
23
+ topSoldermaskAblationCutSetting,
24
+ } = ctx
25
+
26
+ if (!topSoldermaskAblationCutSetting) return
27
+
28
+ const copperContours: Contour[] = []
29
+ for (const netGeometries of topCutNetGeoms.values()) {
30
+ for (const geometry of netGeometries) {
31
+ copperContours.push(
32
+ ...polygonToContours(geometry).map(normalizeContourToCcw),
33
+ )
34
+ }
35
+ }
36
+ if (copperContours.length === 0) return
37
+
38
+ try {
39
+ const { CrossSection } = await getManifold()
40
+ const copperArea = new CrossSection(copperContours, "NonZero")
41
+ const expandedCopperArea = copperArea.offset(
42
+ soldermaskAblationClearance,
43
+ "Round",
44
+ 2,
45
+ 32,
46
+ )
47
+ let outlineArea = expandedCopperArea
48
+
49
+ if (
50
+ clipCopperCutFillToBoardOutline &&
51
+ boardOutlineContour &&
52
+ boardOutlineContour.length >= 3
53
+ ) {
54
+ const boardArea = new CrossSection(
55
+ [normalizeContourToCcw(boardOutlineContour)],
56
+ "NonZero",
57
+ )
58
+ outlineArea = expandedCopperArea.intersect(boardArea)
59
+ expandedCopperArea.delete()
60
+ boardArea.delete()
61
+ }
62
+
63
+ const simplifiedOutline = outlineArea.simplify(0.001)
64
+ const outerContours = simplifiedOutline
65
+ .toPolygons()
66
+ .filter((contour: Contour) => signedArea(contour) > 0)
67
+ const outlineGroup = new ShapeGroup()
68
+
69
+ for (const contour of outerContours) {
70
+ const polygon = contourToPolygon(contour)
71
+ if (!polygon) continue
72
+
73
+ for (const island of polygon.splitToIslands()) {
74
+ const pathData = polygonToShapePathData(island)
75
+ if (pathData.verts.length === 0) continue
76
+
77
+ outlineGroup.children.push(
78
+ createLayerShapePath({
79
+ cutIndex: topSoldermaskAblationCutSetting.index,
80
+ pathData,
81
+ layer: "top",
82
+ isClosed: true,
83
+ ctx,
84
+ }),
85
+ )
86
+ }
87
+ }
88
+
89
+ if (outlineGroup.children.length > 0) {
90
+ project.children.push(outlineGroup)
91
+ }
92
+
93
+ copperArea.delete()
94
+ outlineArea.delete()
95
+ simplifiedOutline.delete()
96
+ } catch (error) {
97
+ console.warn("Failed to create top soldermask ablation outline:", error)
98
+ }
99
+ }
@@ -5,9 +5,9 @@ import { getManifold } from "./getManifold"
5
5
  import { createLayerShapePath } from "./helpers/createLayerShapePath"
6
6
  import { polygonToShapePathData } from "./polygon-to-shape-path"
7
7
 
8
- type Contour = Array<[number, number]>
8
+ export type Contour = Array<[number, number]>
9
9
 
10
- const signedArea = (contour: Contour): number => {
10
+ export const signedArea = (contour: Contour): number => {
11
11
  let area = 0
12
12
  for (let i = 0; i < contour.length; i++) {
13
13
  const [x1, y1] = contour[i]!
@@ -17,7 +17,7 @@ const signedArea = (contour: Contour): number => {
17
17
  return area / 2
18
18
  }
19
19
 
20
- const normalizeContourToCcw = (contour: Contour): Contour =>
20
+ export const normalizeContourToCcw = (contour: Contour): Contour =>
21
21
  contour.length >= 3 && signedArea(contour) < 0
22
22
  ? [...contour].reverse()
23
23
  : contour
@@ -26,7 +26,7 @@ const normalizeContourToCcw = (contour: Contour): Contour =>
26
26
  * Converts a flatten-js Polygon to an array of contours for manifold CrossSection
27
27
  * Each contour is an array of [x, y] coordinates
28
28
  */
29
- const polygonToContours = (polygon: Polygon | Box): Contour[] => {
29
+ export const polygonToContours = (polygon: Polygon | Box): Contour[] => {
30
30
  const contours: Contour[] = []
31
31
 
32
32
  if (polygon instanceof Box) {
@@ -57,7 +57,7 @@ const polygonToContours = (polygon: Polygon | Box): Contour[] => {
57
57
  /**
58
58
  * Converts a single contour to a flatten-js Polygon
59
59
  */
60
- const contourToPolygon = (contour: Contour): Polygon | null => {
60
+ export const contourToPolygon = (contour: Contour): Polygon | null => {
61
61
  if (contour.length < 3) return null
62
62
 
63
63
  const points = contour.map(([x, y]) => new Point(x, y))
package/lib/index.ts CHANGED
@@ -8,6 +8,7 @@ import {
8
8
  calculateCircuitBounds,
9
9
  calculateOriginFromBounds,
10
10
  } from "./calculateBounds"
11
+ import { createSoldermaskAblationOutline } from "./create-soldermask-ablation-outline"
11
12
  import { createCopperCutFillForLayer } from "./createCopperCutFillForLayer"
12
13
  import { createCopperShapesForLayer } from "./createCopperShapesForLayer"
13
14
  import { createHolePunchLayers } from "./createHolePunchLayers"
@@ -34,6 +35,10 @@ export interface ConvertCircuitJsonToLbrnOptions {
34
35
  includeCopper?: boolean
35
36
  includeSoldermask?: boolean
36
37
  includeSoldermaskCure?: boolean
38
+ /** Generate a top-layer outer contour around copper for soldermask ablation. */
39
+ includeSoldermaskAblation?: boolean
40
+ /** Clearance between copper and the soldermask ablation outline, in mm. Defaults to 1. */
41
+ soldermaskAblationClearance?: number
37
42
  globalCopperSoldermaskMarginAdjustment?: number
38
43
  solderMaskMarginPercent?: number
39
44
  includeLayers?: Array<"top" | "bottom">
@@ -102,6 +107,8 @@ export const convertCircuitJsonToLbrn = async (
102
107
  const includeCopper = options.includeCopper ?? true
103
108
  const includeSoldermask = options.includeSoldermask ?? false
104
109
  const includeSoldermaskCure = options.includeSoldermaskCure ?? false
110
+ const includeSoldermaskAblation = options.includeSoldermaskAblation ?? false
111
+ const soldermaskAblationClearance = options.soldermaskAblationClearance ?? 1
105
112
  const includeHolePunch = options.includeHolePunch ?? true
106
113
  const shouldIncludeSoldermaskCure = includeSoldermask && includeSoldermaskCure
107
114
  const globalCopperSoldermaskMarginAdjustment =
@@ -153,6 +160,9 @@ export const convertCircuitJsonToLbrn = async (
153
160
  if (traceMargin > 0 && !includeCopper) {
154
161
  throw new Error("traceMargin requires includeCopper to be true")
155
162
  }
163
+ if (soldermaskAblationClearance < 0) {
164
+ throw new Error("soldermaskAblationClearance must not be negative")
165
+ }
156
166
 
157
167
  // Create cut settings
158
168
  const topCopperCutSetting = new CutSetting({
@@ -385,6 +395,29 @@ export const convertCircuitJsonToLbrn = async (
385
395
  }
386
396
  }
387
397
 
398
+ let topSoldermaskAblationCutSetting: CutSetting | undefined
399
+ if (
400
+ includeSoldermaskAblation &&
401
+ includeCopper &&
402
+ includeLayers.includes("top")
403
+ ) {
404
+ topSoldermaskAblationCutSetting = new CutSetting({
405
+ type: "Scan",
406
+ index: LAYER_INDEXES.topSoldermaskAblation,
407
+ name: "Top Soldermask Ablation",
408
+ numPasses: 5,
409
+ speed: 7000,
410
+ frequency: 40000,
411
+ scanOpt: "mergeAll",
412
+ interval: 0.1,
413
+ qPulseWidth: 1,
414
+ crossHatch: true,
415
+ wobbleEnable: true,
416
+ anglePerPass: 1,
417
+ })
418
+ project.children.push(topSoldermaskAblationCutSetting)
419
+ }
420
+
388
421
  // Create oxidation cleaning layer cut settings if needed
389
422
  let topOxidationCleaningCutSetting: CutSetting | undefined
390
423
  let bottomOxidationCleaningCutSetting: CutSetting | undefined
@@ -441,6 +474,7 @@ export const convertCircuitJsonToLbrn = async (
441
474
  topSoldermaskCureCutSetting,
442
475
  bottomSoldermaskCureCutSetting,
443
476
  reflectedBottomBoardCutSetting,
477
+ topSoldermaskAblationCutSetting,
444
478
  tool1CutSetting,
445
479
  connMap,
446
480
  topCutNetGeoms: new Map(),
@@ -462,6 +496,7 @@ export const convertCircuitJsonToLbrn = async (
462
496
  bottomCopperCutFillCutSetting,
463
497
  copperCutFillMargin,
464
498
  clipCopperCutFillToBoardOutline,
499
+ soldermaskAblationClearance,
465
500
  topOxidationCleaningCutSetting,
466
501
  bottomOxidationCleaningCutSetting,
467
502
  topTraceEndpoints: new Set(),
@@ -605,6 +640,14 @@ export const convertCircuitJsonToLbrn = async (
605
640
  }
606
641
  }
607
642
 
643
+ if (
644
+ includeSoldermaskAblation &&
645
+ includeCopper &&
646
+ includeLayers.includes("top")
647
+ ) {
648
+ await createSoldermaskAblationOutline(ctx)
649
+ }
650
+
608
651
  // Create oxidation cleaning layer for each layer
609
652
  if (includeOxidationCleaningLayer) {
610
653
  if (includeLayers.includes("top")) {
@@ -23,6 +23,7 @@ export const LAYER_INDEXES = {
23
23
  reflectedBottomBoardCut: 13,
24
24
  topHolePunch: 14,
25
25
  bottomHolePunch: 15,
26
+ topSoldermaskAblation: 16,
26
27
 
27
28
  // Native LightBurn non-output tool layers
28
29
  tool1: 30,
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "circuit-json-to-lbrn",
3
3
  "main": "dist/index.js",
4
- "version": "0.0.84",
4
+ "version": "0.0.85",
5
5
  "type": "module",
6
6
  "scripts": {
7
7
  "start": "bun run site/index.html",
@@ -30,7 +30,7 @@
30
30
  "typescript": "^5"
31
31
  },
32
32
  "dependencies": {
33
- "lbrnts": "^0.0.19",
33
+ "lbrnts": "^0.0.22",
34
34
  "manifold-3d": "^3.3.2"
35
35
  }
36
36
  }
@@ -0,0 +1,111 @@
1
+ import { expect, test } from "bun:test"
2
+ import type { CircuitJson } from "circuit-json"
3
+ import { CutSetting } from "lbrnts"
4
+ import { convertCircuitJsonToLbrn } from "../../lib"
5
+ import { LAYER_INDEXES } from "../../lib/layer-indexes"
6
+ import circuitJson from "../examples/example05/example05.circuit.json" with {
7
+ type: "json",
8
+ }
9
+
10
+ type ProjectNode = {
11
+ children?: ProjectNode[]
12
+ cutIndex?: number
13
+ index?: number
14
+ name?: string
15
+ type?: string
16
+ verts?: Array<{ x: number; y: number }>
17
+ }
18
+
19
+ const flattenProject = (nodes: ProjectNode[]): ProjectNode[] =>
20
+ nodes.flatMap((node) => [node, ...flattenProject(node.children ?? [])])
21
+
22
+ const getBounds = (nodes: ProjectNode[]) => {
23
+ const vertices = nodes.flatMap((node) => node.verts ?? [])
24
+ return {
25
+ minX: Math.min(...vertices.map((vertex) => vertex.x)),
26
+ maxX: Math.max(...vertices.map((vertex) => vertex.x)),
27
+ minY: Math.min(...vertices.map((vertex) => vertex.y)),
28
+ maxY: Math.max(...vertices.map((vertex) => vertex.y)),
29
+ }
30
+ }
31
+
32
+ test("creates a 1mm top soldermask ablation outer outline", async () => {
33
+ const project = await convertCircuitJsonToLbrn(circuitJson as CircuitJson, {
34
+ includeLayers: ["top"],
35
+ includeCopper: true,
36
+ includeSoldermaskAblation: true,
37
+ soldermaskAblationClearance: 1,
38
+ origin: { x: 0, y: 0 },
39
+ })
40
+ const projectNodes = flattenProject(project.children as ProjectNode[])
41
+
42
+ expect(
43
+ projectNodes.find(
44
+ (node) => node.index === LAYER_INDEXES.topSoldermaskAblation,
45
+ ),
46
+ ).toMatchObject({
47
+ name: "Top Soldermask Ablation",
48
+ type: "Scan",
49
+ })
50
+
51
+ const copperShapes = projectNodes.filter(
52
+ (node) => node.cutIndex === LAYER_INDEXES.topCopper,
53
+ )
54
+ const ablationOutlineShapes = projectNodes.filter(
55
+ (node) => node.cutIndex === LAYER_INDEXES.topSoldermaskAblation,
56
+ )
57
+
58
+ expect(ablationOutlineShapes).toHaveLength(1)
59
+
60
+ const copperBounds = getBounds(copperShapes)
61
+ const outlineBounds = getBounds(ablationOutlineShapes)
62
+ expect(copperBounds.minX - outlineBounds.minX).toBeCloseTo(1, 1)
63
+ expect(outlineBounds.maxX - copperBounds.maxX).toBeCloseTo(1, 1)
64
+ expect(copperBounds.minY - outlineBounds.minY).toBeCloseTo(1, 1)
65
+ expect(outlineBounds.maxY - copperBounds.maxY).toBeCloseTo(1, 1)
66
+ })
67
+
68
+ test("uses the production soldermask ablation scan settings", async () => {
69
+ const project = await convertCircuitJsonToLbrn(circuitJson as CircuitJson, {
70
+ includeLayers: ["top"],
71
+ includeCopper: true,
72
+ includeSoldermaskAblation: true,
73
+ })
74
+ const soldermaskAblation = project.children.find(
75
+ (child): child is CutSetting =>
76
+ child instanceof CutSetting &&
77
+ child.index === LAYER_INDEXES.topSoldermaskAblation,
78
+ )
79
+
80
+ expect(soldermaskAblation).toMatchObject({
81
+ type: "Scan",
82
+ speed: 7000,
83
+ frequency: 40000,
84
+ qPulseWidth: 1,
85
+ interval: 0.1,
86
+ numPasses: 5,
87
+ scanOpt: "mergeAll",
88
+ crossHatch: true,
89
+ wobbleEnable: true,
90
+ anglePerPass: 1,
91
+ })
92
+
93
+ const xml = project.getString()
94
+ const soldermaskAblationXml = xml.match(
95
+ /<CutSetting type="Scan">\s*<index Value="16"\/>[\s\S]*?<\/CutSetting>/,
96
+ )?.[0]
97
+
98
+ expect(soldermaskAblationXml).toContain('<scanOpt Value="mergeAll"/>')
99
+ expect(soldermaskAblationXml).toContain('<crossHatch Value="1"/>')
100
+ expect(soldermaskAblationXml).toContain('<wobbleEnable Value="1"/>')
101
+ expect(soldermaskAblationXml).toContain('<anglePerPass Value="1"/>')
102
+ })
103
+
104
+ test("rejects a negative soldermask ablation clearance", async () => {
105
+ await expect(
106
+ convertCircuitJsonToLbrn(circuitJson as CircuitJson, {
107
+ includeSoldermaskAblation: true,
108
+ soldermaskAblationClearance: -1,
109
+ }),
110
+ ).rejects.toThrow("soldermaskAblationClearance must not be negative")
111
+ })