├── .github └── workflows │ └── release.yml ├── .gitignore ├── .goreleaser.yml ├── Makefile ├── README.md ├── cmd └── panelgen │ └── panelgen.go ├── data └── ref.brd ├── enclosures ├── spec-test-cornerradius1.yaml └── spec-test.yaml ├── go.mod ├── go.sum ├── internal ├── boardops │ ├── apply.go │ ├── standard │ │ └── standard.go │ └── util │ │ └── util.go └── outline │ └── outline.go ├── main.go ├── pkg ├── eagle │ ├── attributes.go │ ├── eagle.go │ ├── types.go │ └── utility.go ├── format │ ├── eurorack │ │ └── eurorack.go │ ├── intellijel │ │ └── intellijel.go │ ├── pulplogic │ │ └── pulplogic.go │ └── spec │ │ └── spec.go ├── geometry │ └── geometry.go └── panel │ └── panel.go └── script └── generate-test-boards.sh /.github/workflows/release.yml: -------------------------------------------------------------------------------- 1 | name: goreleaser 2 | 3 | on: 4 | push: 5 | branches: 6 | - master 7 | tags: 8 | - 'v[0-9]+.[0-9]+.[0-9]+' 9 | 10 | jobs: 11 | goreleaser: 12 | runs-on: ubuntu-latest 13 | steps: 14 | - name: Checkout 15 | uses: actions/checkout@v2 16 | 17 | # apparently required for Goreleaser changelog to work 18 | - name: Unshallow 19 | run: git fetch --prune --unshallow 20 | 21 | - name: Set up Go 22 | uses: actions/setup-go@v2 23 | with: 24 | go-version: 1.17 25 | 26 | - name: Run GoReleaser 27 | uses: goreleaser/goreleaser-action@v2 28 | with: 29 | version: latest 30 | args: release --rm-dist 31 | env: 32 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} 33 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # current binaries 2 | /panelgen 3 | /go-eagle 4 | 5 | # in case someone has this lying around in a repo still 6 | /schroff 7 | 8 | # goreleaser 9 | /dist 10 | 11 | # Eagle .brd board files and .b## backup files 12 | *.b* 13 | -------------------------------------------------------------------------------- /.goreleaser.yml: -------------------------------------------------------------------------------- 1 | builds: 2 | - env: 3 | - CGO_ENABLED=0 4 | goos: 5 | - linux 6 | - darwin 7 | - windows 8 | goarch: 9 | - amd64 10 | - arm64 11 | checksum: 12 | name_template: '{{ .ProjectName }}_checksums.txt' 13 | changelog: 14 | sort: asc 15 | filters: 16 | exclude: 17 | - '^docs:' 18 | - '^test:' 19 | - Merge pull request 20 | - Merge branch 21 | -------------------------------------------------------------------------------- /Makefile: -------------------------------------------------------------------------------- 1 | binaries: schroff panelgen 2 | 3 | schroff: 4 | go build ./cmd/schroff 5 | 6 | panelgen: 7 | go build ./cmd/panelgen 8 | 9 | clean: 10 | $(RM) schroff panelgen 11 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # overview 2 | 3 | This repository contains code and tools for interacting with Autodesk Eagle 4 | files, and particularly for creating Eagle board files that can be used to 5 | manufacture front panels for electronics. Originally this was intended for 6 | Eurorack synthesizer systems, but now also has crude support for custom 7 | enclosures, such as the ubiquitous plastic "jiffy boxes". 8 | 9 | At present, the below tools are included: 10 | 11 | * `panelgen`: create a new blank panel board file 12 | * `go-eagle`: derive a new panel board file from the board file for your circuit 13 | 14 | The below panel formats are supported: 15 | 16 | * Eurorack 3U, per Doepfer spec 17 | * Pulplogic 1U, per Pulplogic spec 18 | * Intellijel 1U, per Intellijel spec 19 | * custom enclosure specs defined in a YAML file 20 | 21 | # installing (releases) 22 | 23 | Grab one of the prebuilt binaries from the release page if there is one for 24 | your operating platform. If not... 25 | 26 | # installing (source) 27 | 28 | Firstly, install a Golang toolchain and `git`. Then... 29 | 30 | ``` 31 | $ git clone git@github.com:jsleeio/go-eagle.git 32 | $ go build 33 | $ go build ./cmd/panelgen 34 | ``` 35 | 36 | # go-eagle (formerly named 'schroff') 37 | 38 | `go-eagle` is used for deriving 39 | [Eurorack module front panels](http://www.doepfer.de/a100_man/a100m_e.htm) 40 | from the Eagle board file for the module's actual circuitry. That is, you 41 | design your module's circuit board in Eagle, and then `go-eagle` examines the 42 | board file to discover: 43 | 44 | * which circuit components (potentiometers, jacks, LEDs, etc) require panel drill holes 45 | * the size of any such drill holes (via the component's `PANEL_DRILL_MM` attribute) 46 | * where the holes should be placed (via the component's origin coordinates) 47 | * where the legend text should be placed (via the component's origin coordinates, and optional offset) 48 | * header text to be placed in silkscreen at the top of the panel (via the board's `PANEL_HEADER_TEXT` attribute) 49 | * footer text to be placed in silkscreen at the bottom of the panel (via the board's `PANEL_FOOTER_TEXT` attribute) 50 | 51 | Components that need panel holes must have a `PANEL_DRILL_MM` attribute. 52 | 53 | ## list of global and component attributes 54 | 55 | attribute name | type | default value | purpose 56 | --------------------------------- | --------- | ---------------- | -------------------------------------------------------------------- 57 | `PANEL_HEADER_LAYER` | global | `tStop` | layer to place header text on 58 | `PANEL_HEADER_OFFSET_X` | global | `0.0` | nudge panel header text left or right (millimetres) 59 | `PANEL_HEADER_OFFSET_Y` | global | `0.0` | nudge panel header text up or down (millimetres) 60 | `PANEL_HEADER_TEXT` | global | `` | text for header section of panel 61 | `PANEL_FOOTER_LAYER` | global | `tStop` | layer to place footer text on 62 | `PANEL_FOOTER_OFFSET_X` | global | `0.0` | nudge panel footer text left or right (millimetres) 63 | `PANEL_FOOTER_OFFSET_Y` | global | `0.0` | nudge panel footer text up or down (millimetres) 64 | `PANEL_FOOTER_TEXT` | global | `` | text for footer section of panel 65 | `PANEL_LEGEND_LAYER` | global | `tStop` | layer to place panel legend text on 66 | `PANEL_LEGEND_SKIP_RE` | global | _none_ | [RE2](https://github.com/google/re2/wiki/Syntax) expression; if a component name matches, legend text is skipped 67 | `PANEL_DRILL_MM` | component | _none_ | panel drill size to create for a component. Required for drill holes. 68 | `PANEL_HOLE_STOP_WIDTH` | component | `2.0` | override the width of the stop-mask ring around the component hole 69 | `PANEL_LEGEND_LOCATION` | component | `above` | set to `below` to place the legend text `below` the component instead of `above` 70 | `PANEL_LEGEND_OFFSET_X` | component | `0.0` | nudge panel legend text left or right (millimetres) 71 | `PANEL_LEGEND_OFFSET_Y` | component | `0.0` | nudge panel legend text up or down (millimetres) 72 | `PANEL_LEGEND_TICKS` | component | `no` | set to `yes` to add tick marks around component hole, eg. for potentiometers 73 | `PANEL_LEGEND_TICKS_COUNT` | component | `11` | number of ticks to draw 74 | `PANEL_LEGEND_TICKS_END_ANGLE` | component | `240.0` | ending polar angle to which to draw ticks, in degrees. Zero degrees is at 9 o'clock 75 | `PANEL_LEGEND_TICKS_LENGTH` | component | `1.5` | length of ticks 76 | `PANEL_LEGEND_TICKS_LABELS` | component | `no` | set to `yes` to add text labels next to tick marks 77 | `PANEL_LEGEND_TICKS_LABELS_TEXTS` | component | _none_ | labels for tick marks, separated with `,`. Quantity must match `PANEL_LEGEND_TICKS_COUNT` 78 | `PANEL_LEGEND_TICKS_START_ANGLE` | component | `-60.0` | starting polar angle from which to draw ticks, in degrees. Zero degrees is at 9 o'clock 79 | `PANEL_LEGEND_TICKS_WIDTH` | component | `0.25` | width of ticks 80 | `PANEL_LEGEND` | component | _component name_ | override panel legend text for a component 81 | 82 | ## commandline options 83 | 84 | ``` 85 | $ ./go-eagle --help 86 | Usage of ./go-eagle: 87 | -format string 88 | panel format to create (eurorack, pulplogic, intellijel) (default "eurorack") 89 | -hole-stop-radius float 90 | Radius to pull back soldermask around a hole (default 2) 91 | -text-size float 92 | label text size (default 2.25) 93 | -text-spacing float 94 | spacing between a hole and its related label (default 3.5) 95 | ``` 96 | 97 | ## generating board files 98 | 99 | To generate a panel board file: 100 | 101 | ``` 102 | $ go-eagle morphlag-rev2.brd 103 | 2019/06/02 17:48:17 FALL: found PANEL_DRILL_MM attribute with value 7 104 | 2019/06/02 17:48:17 IN: found PANEL_DRILL_MM attribute with value 6 105 | 2019/06/02 17:48:17 OUT: found PANEL_DRILL_MM attribute with value 6 106 | 2019/06/02 17:48:17 OUTPOL: found PANEL_DRILL_MM attribute with value 6 107 | 2019/06/02 17:48:17 RISE: found PANEL_DRILL_MM attribute with value 7 108 | 2019/06/02 17:48:17 SHAPE: found PANEL_DRILL_MM attribute with value 7 109 | 2019/06/02 17:48:17 SW1: found PANEL_DRILL_MM attribute with value 4.5 110 | 2019/06/02 17:48:17 POLARIZE: found PANEL_DRILL_MM attribute with value 7 111 | 2019/06/02 17:48:17 OUTINV: found PANEL_DRILL_MM attribute with value 6 112 | 2019/06/02 17:48:17 MANUAL: found PANEL_DRILL_MM attribute with value 7.5 113 | ``` 114 | 115 | The output panel file takes the name of the input file and adds the suffix `.panel.brd`: 116 | 117 | ``` 118 | $ ls -l wavolver2-rev1.brd.panel.brd 119 | -rw-r--r-- 1 jslee staff 17912 28 Apr 17:02 wavolver2-rev1.brd.panel.brd 120 | ``` 121 | 122 | ## compatibility 123 | 124 | At present the generated board files load just fine in Eagle 9.3.2+ (probably 125 | many earlier versions also!) but are _not_ accepted by 126 | [OSHPark](https://oshpark.com/)'s Eagle board loader. I'm not sure why this 127 | is, but it's most likely *not* OSHPark's fault, so please *don't* complain to 128 | them if you try to use this. Just generate some Gerber files instead. 129 | 130 | ## custom panel specifications 131 | 132 | These are now supported by `panelgen` and `go-eagle`, and are defined in YAML 133 | files that look like the below: 134 | 135 | name: testEnclosure 136 | width: 100.0 137 | height: 75.0 138 | horizontalFit: 0.0 139 | cornerRadius: 0.0 140 | mountingHoleDiameter: 3.1 141 | mountingHoles: 142 | - { x: 10, y: 10 } 143 | - { x: 90, y: 10 } 144 | - { x: 10, y: 65 } 145 | - { x: 90, y: 65 } 146 | 147 | Usage wth `panelgen`: 148 | 149 | $ ./panelgen -format=spec -spec-file=enclosures/spec-test.yaml \ 150 | -reference-board=data/ref.brd -output=test.brd 151 | 152 | Usage with `go-eagle`: 153 | 154 | $ ./go-eagle -format=spec -spec-file=enclosure.yaml test.brd 155 | 156 | This is extremely preliminary at present. 157 | 158 | # panelgen 159 | 160 | `panelgen` is used for creating new, blank panels in Eurorack, Pulplogic 1U or 161 | Intellijel 1U formats. An existing Eagle board file is required in order to 162 | derive the desired set of Eagle layer information. This can be any Eagle board 163 | file. 164 | 165 | Demonstration usage, creating a 6hp Pulplogic tile: 166 | 167 | ``` 168 | $ ./panelgen -format=pulplogic -reference-board=data/ref.brd -output=mytile.brd -width=6 169 | ``` 170 | 171 | ## commandline options 172 | 173 | ``` 174 | $ ./panelgen -help 175 | Usage of ./panelgen: 176 | -format string 177 | panel format to create (eurorack,pulplogic,intellijel,spec) (default "eurorack") 178 | -outline-layer string 179 | layer to draw board outline in (default "Dimension") 180 | -output string 181 | filename to write new Eagle board file to (default "newpanel.brd") 182 | -reference-board string 183 | reference Eagle board file to read layer information from 184 | -spec-file string 185 | filename to read YAML panel spec from 186 | -width int 187 | width of the panel, in integer units appropriate for the format (default 4) 188 | ``` 189 | 190 | 191 | # to-do 192 | 193 | * exhaustively scan the Eagle DTD and add the various missing items (libraries!) 194 | * BOM generation tool 195 | * custom panel format should support defining a list of keepouts in at least 196 | rectangular and circular shapes 197 | * a tool to generate an Eagle library for a custom enclosure, including keepouts 198 | and cutouts features inside the case, and a Dimension layer outline. This 199 | would be useful for a layout quick-start on the PCB to go inside the enclosure 200 | 201 | # copyright 202 | 203 | Copyright 2021 John Slee . 204 | 205 | Permission is hereby granted, free of charge, to any person obtaining a copy of 206 | this software and associated documentation files (the "Software"), to deal in 207 | the Software without restriction, including without limitation the rights to 208 | use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies 209 | of the Software, and to permit persons to whom the Software is furnished to do 210 | so, subject to the following conditions: 211 | 212 | The above copyright notice and this permission notice shall be included in all 213 | copies or substantial portions of the Software. 214 | 215 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 216 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 217 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 218 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 219 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 220 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 221 | SOFTWARE. 222 | 223 | -------------------------------------------------------------------------------- /cmd/panelgen/panelgen.go: -------------------------------------------------------------------------------- 1 | // Copyright 2019 John Slee 2 | // 3 | // Permission is hereby granted, free of charge, to any person obtaining a copy 4 | // of this software and associated documentation files (the "Software"), to 5 | // deal in the Software without restriction, including without limitation the 6 | // rights to use, copy, modify, merge, publish, distribute, sublicense, and/or 7 | // sell copies of the Software, and to permit persons to whom the Software is 8 | // furnished to do so, subject to the following conditions: 9 | // 10 | // The above copyright notice and this permission notice shall be included in 11 | // all copies or substantial portions of the Software. 12 | // 13 | // THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 14 | // IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 15 | // FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 16 | // AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 17 | // LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING 18 | // FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS 19 | // IN THE SOFTWARE. 20 | 21 | package main 22 | 23 | import ( 24 | "flag" 25 | "fmt" 26 | "os" 27 | "strings" 28 | 29 | "github.com/jsleeio/go-eagle/pkg/eagle" 30 | "github.com/jsleeio/go-eagle/pkg/format/eurorack" 31 | "github.com/jsleeio/go-eagle/pkg/format/intellijel" 32 | "github.com/jsleeio/go-eagle/pkg/format/pulplogic" 33 | filespec "github.com/jsleeio/go-eagle/pkg/format/spec" 34 | "github.com/jsleeio/go-eagle/pkg/panel" 35 | 36 | "github.com/jsleeio/go-eagle/internal/boardops/standard" 37 | ) 38 | 39 | const ( 40 | // FormatEurorack is the Doepfer-defined 3U specification. Not Eurocard! 41 | FormatEurorack = "eurorack" 42 | // FormatPulplogic is the PulpLogic-defined 1U specification 43 | FormatPulplogic = "pulplogic" 44 | // FormatIntellijel is the Intellijel-defined 1U specification 45 | FormatIntellijel = "intellijel" 46 | // FormatSpec is the YAML-derived panel specification 47 | FormatSpec = "spec" 48 | ) 49 | 50 | type config struct { 51 | Width *int 52 | Format *string 53 | Output *string 54 | RefBoard *string 55 | OutlineLayer *string 56 | SpecFile *string 57 | } 58 | 59 | func configureFromFlags() (*config, error) { 60 | formatList := "(" + strings.Join([]string{FormatEurorack, FormatPulplogic, FormatIntellijel, FormatSpec}, ",") + ")" 61 | c := &config{ 62 | Width: flag.Int("width", 4, "width of the panel, in integer units appropriate for the format"), 63 | Format: flag.String("format", FormatEurorack, "panel format to create "+formatList), 64 | RefBoard: flag.String("reference-board", "", "reference Eagle board file to read layer information from"), 65 | Output: flag.String("output", "newpanel.brd", "filename to write new Eagle board file to"), 66 | OutlineLayer: flag.String("outline-layer", "Dimension", "layer to draw board outline in"), 67 | SpecFile: flag.String("spec-file", "", "filename to read YAML panel spec from"), 68 | } 69 | flag.Parse() 70 | if *c.RefBoard == "" { 71 | return nil, fmt.Errorf("a reference board file (-reference-board option) is required to acquire a list of Eagle layers") 72 | } 73 | return c, nil 74 | } 75 | 76 | func generatePanelBoardFile(cfg *config, spec panel.Panel) error { 77 | // the user very likely already has an Eagle board file nearby, so use it to 78 | // acquire a list of layers --- avoids hardcoding them, lets users use their 79 | // own mix/subset of layers if desired 80 | ref, err := eagle.LoadEagleFile(*cfg.RefBoard) 81 | if err != nil { 82 | return fmt.Errorf("can't load reference board: %v", err) 83 | } 84 | panel := ref.CloneEmpty() 85 | if err := standard.ApplyStandardBoardOperations(panel, spec); err != nil { 86 | return fmt.Errorf("error creating panel features: %v", err) 87 | } 88 | if err := panel.WriteFile(*cfg.Output); err != nil { 89 | return fmt.Errorf("can't write output board: %v", err) 90 | } 91 | return nil 92 | } 93 | 94 | func main() { 95 | cfg, err := configureFromFlags() 96 | if err != nil { 97 | fmt.Printf("configuration error: %v\n", err) 98 | os.Exit(1) 99 | } 100 | var spec panel.Panel 101 | switch *cfg.Format { 102 | case FormatEurorack: 103 | spec = eurorack.NewEurorack(*cfg.Width) 104 | case FormatPulplogic: 105 | spec = pulplogic.NewPulplogic(*cfg.Width) 106 | case FormatIntellijel: 107 | spec = intellijel.NewIntellijel(*cfg.Width) 108 | case FormatSpec: 109 | spec, err = filespec.LoadSpec(*cfg.SpecFile) 110 | if err != nil { 111 | fmt.Printf("error loading YAML panel spec from '%v': %v", *cfg.SpecFile, err) 112 | os.Exit(1) 113 | } 114 | default: 115 | fmt.Printf("unsupported format: %s\n", *cfg.Format) 116 | os.Exit(3) 117 | } 118 | if err := generatePanelBoardFile(cfg, spec); err != nil { 119 | fmt.Printf("error generating panel: %v\n", err) 120 | os.Exit(2) 121 | } 122 | } 123 | -------------------------------------------------------------------------------- /data/ref.brd: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | 23 | 24 | 25 | 26 | 27 | 28 | 29 | 30 | 31 | 32 | 33 | 34 | 35 | 36 | 37 | 38 | 39 | 40 | 41 | 42 | 43 | 44 | 45 | 46 | 47 | 48 | 49 | 50 | 51 | 52 | 53 | 54 | 55 | 56 | 57 | 58 | 59 | 60 | 61 | 62 | 63 | 64 | 65 | 66 | 67 | 68 | 69 | 70 | 71 | 72 | 73 | 74 | 75 | 76 | 77 | 78 | 79 | 80 | 81 | 82 | 83 | 84 | 85 | 86 | 87 | 88 | 89 | 90 | 91 | 92 | 93 | 94 | 95 | 96 | 97 | 98 | 99 | 100 | 101 | 102 | 103 | 104 | 105 | 106 | 107 | 108 | 109 | 110 | 111 | 112 | 113 | 114 | 115 | 116 | 117 | 118 | 119 | 120 | 121 | 122 | 123 | 124 | 125 | 126 | 127 | 128 | 129 | 130 | 131 | 132 | 133 | 134 | 135 | 136 | 137 | 138 | 139 | 140 | 141 | 142 | 143 | 144 | 145 | 146 | 147 | 148 | 149 | 150 | 151 | 152 | 153 | 154 | 155 | 156 | 157 | 158 | 159 | 160 | 161 | 162 | 163 | 164 | 165 | -------------------------------------------------------------------------------- /enclosures/spec-test-cornerradius1.yaml: -------------------------------------------------------------------------------- 1 | # note that this doesn't represent any actual enclosure; it only exists for testing! 2 | # 3 | name: testEnclosure 4 | width: 100.0 5 | height: 75.0 6 | horizontalFit: 0.0 7 | cornerRadius: 2.0 8 | mountingHoleDiameter: 3.1 9 | mountingHoles: 10 | - { x: 10, y: 10 } 11 | - { x: 90, y: 10 } 12 | - { x: 10, y: 65 } 13 | - { x: 90, y: 65 } 14 | -------------------------------------------------------------------------------- /enclosures/spec-test.yaml: -------------------------------------------------------------------------------- 1 | # note that this doesn't represent any actual enclosure; it only exists for testing! 2 | # 3 | name: testEnclosure 4 | width: 100.0 5 | height: 75.0 6 | horizontalFit: 0.0 7 | mountingHoleDiameter: 3.1 8 | mountingHoles: 9 | - { x: 10, y: 10 } 10 | - { x: 90, y: 10 } 11 | - { x: 10, y: 65 } 12 | - { x: 90, y: 65 } 13 | -------------------------------------------------------------------------------- /go.mod: -------------------------------------------------------------------------------- 1 | module github.com/jsleeio/go-eagle 2 | 3 | go 1.12 4 | 5 | require gopkg.in/yaml.v2 v2.4.0 6 | -------------------------------------------------------------------------------- /go.sum: -------------------------------------------------------------------------------- 1 | gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM= 2 | gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= 3 | gopkg.in/yaml.v2 v2.4.0 h1:D8xgwECY7CYvx+Y2n4sBz93Jn9JRvxdiyyo8CTfuKaY= 4 | gopkg.in/yaml.v2 v2.4.0/go.mod h1:RDklbk79AGWmwhnvt/jBztapEOGDOx6ZbXqjP6csGnQ= 5 | -------------------------------------------------------------------------------- /internal/boardops/apply.go: -------------------------------------------------------------------------------- 1 | // Copyright 2019 John Slee 2 | // 3 | // Permission is hereby granted, free of charge, to any person obtaining a copy 4 | // of this software and associated documentation files (the "Software"), to 5 | // deal in the Software without restriction, including without limitation the 6 | // rights to use, copy, modify, merge, publish, distribute, sublicense, and/or 7 | // sell copies of the Software, and to permit persons to whom the Software is 8 | // furnished to do so, subject to the following conditions: 9 | // 10 | // The above copyright notice and this permission notice shall be included in 11 | // all copies or substantial portions of the Software. 12 | // 13 | // THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 14 | // IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 15 | // FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 16 | // AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 17 | // LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING 18 | // FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS 19 | // IN THE SOFTWARE. 20 | 21 | package boardops 22 | 23 | import ( 24 | "github.com/jsleeio/go-eagle/pkg/eagle" 25 | "github.com/jsleeio/go-eagle/pkg/panel" 26 | ) 27 | 28 | // BoardOperation functions do ... something ... to an Eagle board 29 | type BoardOperation func(*eagle.Eagle, panel.Panel) error 30 | 31 | // ApplyBoardOperations works through a list of board operation 32 | // functions and applies them to an Eagle board object 33 | func ApplyBoardOperations(board *eagle.Eagle, spec panel.Panel, ops []BoardOperation) error { 34 | for _, op := range ops { 35 | if err := op(board, spec); err != nil { 36 | return err 37 | } 38 | } 39 | return nil 40 | } 41 | -------------------------------------------------------------------------------- /internal/boardops/standard/standard.go: -------------------------------------------------------------------------------- 1 | // Copyright 2019 John Slee 2 | // 3 | // Permission is hereby granted, free of charge, to any person obtaining a copy 4 | // of this software and associated documentation files (the "Software"), to 5 | // deal in the Software without restriction, including without limitation the 6 | // rights to use, copy, modify, merge, publish, distribute, sublicense, and/or 7 | // sell copies of the Software, and to permit persons to whom the Software is 8 | // furnished to do so, subject to the following conditions: 9 | // 10 | // The above copyright notice and this permission notice shall be included in 11 | // all copies or substantial portions of the Software. 12 | // 13 | // THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 14 | // IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 15 | // FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 16 | // AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 17 | // LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING 18 | // FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS 19 | // IN THE SOFTWARE. 20 | 21 | package standard 22 | 23 | import ( 24 | "github.com/jsleeio/go-eagle/internal/boardops" 25 | "github.com/jsleeio/go-eagle/internal/boardops/util" 26 | "github.com/jsleeio/go-eagle/pkg/eagle" 27 | "github.com/jsleeio/go-eagle/pkg/panel" 28 | ) 29 | 30 | const ( 31 | // CopperPullback indicates how far a copper pour will be "pulled 32 | // back" from the edge of a board. This shouldn't really need to 33 | // be configurable, so keep it simple and use a constant 34 | CopperPullback = 0.5 35 | ) 36 | 37 | // ApplyStandardBoardOperations applies the minimal set of baseline 38 | // board features to an Eagle board: an outline, some mounting holes 39 | // and copper pours in the Top and Bottom copper layers. 40 | func ApplyStandardBoardOperations(board *eagle.Eagle, spec panel.Panel) error { 41 | ops := []boardops.BoardOperation{ 42 | outlineWiresOp, 43 | mountingHolesOp, 44 | copperFillOp, 45 | railKeepoutsOp, 46 | } 47 | return boardops.ApplyBoardOperations(board, spec, ops) 48 | } 49 | 50 | func outlineWiresOp(board *eagle.Eagle, spec panel.Panel) error { 51 | adjust := spec.HorizontalFit() / 2 // half on left edge, half on right edge 52 | outline := util.WireRectangle( 53 | 0+adjust, 54 | 0, 55 | spec.Width()-adjust, 56 | spec.Height(), 57 | board.LayerByName("Dimension"), 58 | 0, // outline wires must be zero-width 59 | spec.CornerRadius(), 60 | ) 61 | for _, wire := range outline { 62 | board.Board.Plain.Wires = append(board.Board.Plain.Wires, wire) 63 | } 64 | return nil 65 | } 66 | 67 | func mountingHolesOp(board *eagle.Eagle, spec panel.Panel) error { 68 | for _, hole := range spec.MountingHoles() { 69 | board.Board.Plain.Holes = append(board.Board.Plain.Holes, eagle.Hole{ 70 | X: hole.X, 71 | Y: hole.Y, 72 | Drill: spec.MountingHoleDiameter(), 73 | }) 74 | } 75 | return nil 76 | } 77 | 78 | func railKeepoutsOp(board *eagle.Eagle, spec panel.Panel) error { 79 | // format may not have rails. 80 | // FIXME: find a better way to do this now that custom formats are 81 | // supported. Maybe add a new operation that creates keepouts 82 | // around panel holes to account for mounting hole posts in 83 | // typical off-the-shelf enclosures? 84 | if railheight := spec.RailHeightFromMountingHole(); railheight > 0 { 85 | layer := board.LayerByName("tKeepout") 86 | bRail := eagle.Rectangle{ 87 | X1: panel.LeftX(spec), 88 | Y1: spec.MountingHoleBottomY(), 89 | X2: panel.RightX(spec), 90 | Y2: spec.MountingHoleBottomY() + spec.RailHeightFromMountingHole(), 91 | Layer: layer, 92 | } 93 | tRail := eagle.Rectangle{ 94 | X1: panel.LeftX(spec), 95 | Y1: spec.MountingHoleTopY() - spec.RailHeightFromMountingHole(), 96 | X2: panel.RightX(spec), 97 | Y2: spec.MountingHoleTopY(), 98 | Layer: layer, 99 | } 100 | board.Board.Plain.Rectangles = append(board.Board.Plain.Rectangles, bRail) 101 | board.Board.Plain.Rectangles = append(board.Board.Plain.Rectangles, tRail) 102 | } 103 | return nil 104 | } 105 | 106 | func copperFillOp(board *eagle.Eagle, spec panel.Panel) error { 107 | // normally the horizontal fit value shouldn't be used anywhere except the 108 | // panel outline, but if we don't include it here, the pullback will be 109 | // wrong, or possibly even completely ineffective. So, include it. 110 | adjust := spec.HorizontalFit() / 2 111 | x1 := CopperPullback + adjust 112 | y1 := CopperPullback 113 | x2 := spec.Width() - (CopperPullback + adjust) 114 | y2 := spec.Height() - CopperPullback 115 | r := spec.CornerRadius() 116 | vertices := []eagle.Vertex{} 117 | if r < 0.01 { // effectively zero { 118 | vertices = append(vertices, 119 | eagle.Vertex{X: x1, Y: y1}, // bottom left, 120 | eagle.Vertex{X: x1, Y: y2}, // top left, 121 | eagle.Vertex{X: x2, Y: y2}, // top right, 122 | eagle.Vertex{X: x2, Y: y1}) // bottom right 123 | } else { 124 | vertices = append(vertices, 125 | eagle.Vertex{X: x1 + r, Y: y1, Curve: -90.0}, // bottom left corner radius start 126 | eagle.Vertex{X: x1, Y: y1 + r}, // bottom left corner radius end 127 | eagle.Vertex{X: x1, Y: y2 - r, Curve: -90.0}, // left edge edge end 128 | eagle.Vertex{X: x1 + r, Y: y2}, // top left corner radius end 129 | eagle.Vertex{X: x2 - r, Y: y2, Curve: -90.0}, // top edge end 130 | eagle.Vertex{X: x2, Y: y2 - r}, // top right corner radius end 131 | eagle.Vertex{X: x2, Y: y1 + r, Curve: -90.0}, // right edge end 132 | eagle.Vertex{X: x2 - r, Y: y1}, // bottom right corner radius end 133 | eagle.Vertex{X: x1 + r, Y: y1}) // bottom edge end 134 | } 135 | top := eagle.Polygon{ 136 | Vertices: []eagle.Vertex{}, 137 | Layer: board.LayerByName("Top"), 138 | } 139 | top.Vertices = append(top.Vertices, vertices...) // copy to avoid later pass-by-reference traps 140 | bottom := eagle.Polygon{ 141 | Vertices: []eagle.Vertex{}, 142 | Layer: board.LayerByName("Bottom"), 143 | } 144 | bottom.Vertices = append(bottom.Vertices, vertices...) // copy to avoid later pass-by-reference traps 145 | board.Board.Plain.Polygons = append(board.Board.Plain.Polygons, top) 146 | board.Board.Plain.Polygons = append(board.Board.Plain.Polygons, bottom) 147 | return nil 148 | } 149 | -------------------------------------------------------------------------------- /internal/boardops/util/util.go: -------------------------------------------------------------------------------- 1 | // Copyright 2019 John Slee 2 | // 3 | // Permission is hereby granted, free of charge, to any person obtaining a copy 4 | // of this software and associated documentation files (the "Software"), to 5 | // deal in the Software without restriction, including without limitation the 6 | // rights to use, copy, modify, merge, publish, distribute, sublicense, and/or 7 | // sell copies of the Software, and to permit persons to whom the Software is 8 | // furnished to do so, subject to the following conditions: 9 | // 10 | // The above copyright notice and this permission notice shall be included in 11 | // all copies or substantial portions of the Software. 12 | // 13 | // THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 14 | // IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 15 | // FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 16 | // AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 17 | // LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING 18 | // FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS 19 | // IN THE SOFTWARE. 20 | 21 | package util 22 | 23 | import ( 24 | "github.com/jsleeio/go-eagle/pkg/eagle" 25 | ) 26 | 27 | // WireRectangle generates a rectangle 28 | func WireRectangle(x1, y1, x2, y2 float64, layer int, width float64, radius float64) []eagle.Wire { 29 | segments := []eagle.Wire{ 30 | {X1: x1 + radius, Y1: y1, X2: x2 - radius, Y2: y1, Layer: layer, Width: width}, // bottom 31 | {X1: x1 + radius, Y1: y2, X2: x2 - radius, Y2: y2, Layer: layer, Width: width}, // top 32 | {X1: x1, Y1: y1 + radius, X2: x1, Y2: y2 - radius, Layer: layer, Width: width}, // left 33 | {X1: x2, Y1: y1 + radius, X2: x2, Y2: y2 - radius, Layer: layer, Width: width}, // right 34 | } 35 | if radius > 0.0 { 36 | segments = append(segments, 37 | eagle.Wire{Curve: -90.0, Layer: layer, X1: x1 + radius, Y1: y1, X2: x1, Y2: y1 + radius, Width: width}, // bottom-left 38 | eagle.Wire{Curve: -90.0, Layer: layer, X1: x1, Y1: y2 - radius, X2: x1 + radius, Y2: y2, Width: width}, // top-left 39 | eagle.Wire{Curve: -90.0, Layer: layer, X1: x2 - radius, Y1: y2, X2: x2, Y2: y2 - radius, Width: width}, // top-right 40 | eagle.Wire{Curve: -90.0, Layer: layer, X1: x2, Y1: y1 + radius, X2: x2 - radius, Y2: y1, Width: width}) // bottom-right 41 | } 42 | return segments 43 | } 44 | -------------------------------------------------------------------------------- /internal/outline/outline.go: -------------------------------------------------------------------------------- 1 | // Copyright 2019 John Slee 2 | // 3 | // Permission is hereby granted, free of charge, to any person obtaining a copy 4 | // of this software and associated documentation files (the "Software"), to 5 | // deal in the Software without restriction, including without limitation the 6 | // rights to use, copy, modify, merge, publish, distribute, sublicense, and/or 7 | // sell copies of the Software, and to permit persons to whom the Software is 8 | // furnished to do so, subject to the following conditions: 9 | // 10 | // The above copyright notice and this permission notice shall be included in 11 | // all copies or substantial portions of the Software. 12 | // 13 | // THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 14 | // IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 15 | // FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 16 | // AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 17 | // LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING 18 | // FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS 19 | // IN THE SOFTWARE. 20 | 21 | package outline 22 | 23 | import ( 24 | "math" 25 | 26 | "github.com/jsleeio/go-eagle/pkg/eagle" 27 | ) 28 | 29 | // FindBoardOutlineWires searches through Wires in the Plain section of 30 | // the board for zero-width wires in the Dimension layer 31 | func FindBoardOutlineWires(e *eagle.Eagle) []eagle.Wire { 32 | wires := []eagle.Wire{} 33 | dimension := e.LayerByName("Dimension") 34 | for _, wire := range e.Board.Plain.Wires { 35 | if wire.Layer == dimension && wire.Width == 0.0 { 36 | wires = append(wires, wire) 37 | } 38 | } 39 | return wires 40 | } 41 | 42 | // BoardCoords holds information about a board outline and its place in 43 | // the coordinate space. This is used to determine panel width and to 44 | // correctly align the board with the panel 45 | type BoardCoords struct { 46 | XMin, XMax, YMin, YMax float64 47 | XOffset, YOffset float64 48 | HP int 49 | } 50 | 51 | // Width returns the width of the board outline 52 | func (bc BoardCoords) Width() float64 { 53 | return bc.XMax - bc.XMin 54 | } 55 | 56 | // Height returns the height of the board outline 57 | func (bc BoardCoords) Height() float64 { 58 | return bc.YMax - bc.YMin 59 | } 60 | 61 | // DeriveBoardCoords creates a BoardCoords object from the discovered outline 62 | // wires in the Plain section of a board 63 | func DeriveBoardCoords(e *eagle.Eagle) BoardCoords { 64 | bc := BoardCoords{} 65 | for _, wire := range FindBoardOutlineWires(e) { 66 | txmin, txmax := fsort2(wire.X1, wire.X2) 67 | tymin, tymax := fsort2(wire.Y1, wire.Y2) 68 | bc.XMin = math.Min(bc.XMin, txmin) 69 | bc.XMax = math.Max(bc.XMax, txmax) 70 | bc.YMin = math.Min(bc.YMin, tymin) 71 | bc.YMax = math.Max(bc.YMax, tymax) 72 | } 73 | if bc.XMin != 0 { 74 | bc.XOffset = -bc.XMin 75 | } 76 | if bc.YMin != 0 { 77 | bc.YOffset = -bc.YMin 78 | } 79 | bc.HP = int(math.Ceil(math.Ceil(bc.XMax-bc.XMin) / 5.08)) 80 | return bc 81 | } 82 | 83 | func fsort2(a, b float64) (float64, float64) { 84 | if a > b { 85 | return b, a 86 | } 87 | return a, b 88 | } 89 | -------------------------------------------------------------------------------- /main.go: -------------------------------------------------------------------------------- 1 | // Copyright 2019 John Slee 2 | // 3 | // Permission is hereby granted, free of charge, to any person obtaining a copy 4 | // of this software and associated documentation files (the "Software"), to 5 | // deal in the Software without restriction, including without limitation the 6 | // rights to use, copy, modify, merge, publish, distribute, sublicense, and/or 7 | // sell copies of the Software, and to permit persons to whom the Software is 8 | // furnished to do so, subject to the following conditions: 9 | // 10 | // The above copyright notice and this permission notice shall be included in 11 | // all copies or substantial portions of the Software. 12 | // 13 | // THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 14 | // IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 15 | // FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 16 | // AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 17 | // LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING 18 | // FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS 19 | // IN THE SOFTWARE. 20 | 21 | package main 22 | 23 | import ( 24 | "flag" 25 | "fmt" 26 | "log" 27 | "path/filepath" 28 | "regexp" 29 | "strings" 30 | 31 | "github.com/jsleeio/go-eagle/pkg/eagle" 32 | "github.com/jsleeio/go-eagle/pkg/format/eurorack" 33 | "github.com/jsleeio/go-eagle/pkg/format/intellijel" 34 | "github.com/jsleeio/go-eagle/pkg/format/pulplogic" 35 | filespec "github.com/jsleeio/go-eagle/pkg/format/spec" 36 | "github.com/jsleeio/go-eagle/pkg/geometry" 37 | "github.com/jsleeio/go-eagle/pkg/panel" 38 | 39 | "github.com/jsleeio/go-eagle/internal/boardops/standard" 40 | "github.com/jsleeio/go-eagle/internal/outline" 41 | ) 42 | 43 | const ( 44 | // FormatEurorack is the Doepfer-defined 3U specification. Not Eurocard! 45 | FormatEurorack = "eurorack" 46 | // FormatPulplogic is the PulpLogic-defined 1U specification 47 | FormatPulplogic = "pulplogic" 48 | // FormatIntellijel is the Intellijel-defined 1U specification 49 | FormatIntellijel = "intellijel" 50 | // FormatSpec is the YAML-derived panel specification 51 | FormatSpec = "spec" 52 | ) 53 | 54 | // wrap up all of the context required for creating panel features 55 | // into one place to simplify and reduce error 56 | type panelLayoutContext struct { 57 | format string 58 | bc outline.BoardCoords 59 | board *eagle.Eagle 60 | panel *eagle.Eagle 61 | cfg config 62 | spec panel.Panel 63 | // legendSkipRe is pulled from the board global attribute PANEL_LEGEND_SKIP_RE. 64 | // If a component name matches this regexp, it will NOT have a panel legend 65 | // text object created. 66 | legendSkipRe *regexp.Regexp 67 | legendLayer string 68 | headerLayer string 69 | footerLayer string 70 | } 71 | 72 | func (plc *panelLayoutContext) panelSpecForFormat() (err error) { 73 | err = nil 74 | switch *plc.cfg.Format { 75 | case FormatEurorack: 76 | plc.spec = eurorack.NewEurorack(plc.bc.HP) 77 | case FormatPulplogic: 78 | plc.spec = pulplogic.NewPulplogic(plc.bc.HP) 79 | case FormatIntellijel: 80 | plc.spec = intellijel.NewIntellijel(plc.bc.HP) 81 | case FormatSpec: 82 | plc.spec, err = filespec.LoadSpec(*plc.cfg.SpecFile) 83 | if err != nil { 84 | err = fmt.Errorf("error loading YAML panel spec from '%v': %v", *plc.cfg.SpecFile, err) 85 | } 86 | default: 87 | err = fmt.Errorf("unsupported format: %s", *plc.cfg.Format) 88 | } 89 | return 90 | } 91 | 92 | func setupPanelLayoutContext(board *eagle.Eagle, c config) (panelLayoutContext, error) { 93 | plc := panelLayoutContext{ 94 | cfg: c, 95 | board: board, 96 | bc: outline.DeriveBoardCoords(board), 97 | legendLayer: eagle.AttributeString(board.Board, "PANEL_LEGEND_LAYER", "tStop"), 98 | headerLayer: eagle.AttributeString(board.Board, "PANEL_HEADER_LAYER", "tStop"), 99 | footerLayer: eagle.AttributeString(board.Board, "PANEL_FOOTER_LAYER", "tStop"), 100 | legendSkipRe: nil, 101 | } 102 | if lsre := eagle.AttributeString(board.Board, "PANEL_LEGEND_SKIP_RE", ""); lsre != "" { 103 | plc.legendSkipRe = regexp.MustCompile(lsre) 104 | } 105 | err := plc.panelSpecForFormat() 106 | if err != nil { 107 | return panelLayoutContext{}, err 108 | } 109 | plc.panel = plc.board.CloneEmpty() 110 | if err := standard.ApplyStandardBoardOperations(plc.panel, plc.spec); err != nil { 111 | return panelLayoutContext{}, fmt.Errorf("error creating panel features: %v", err) 112 | } 113 | // centre the board on the panel 114 | plc.bc.XOffset += (plc.spec.Width()-plc.bc.Width())/2 + plc.spec.HorizontalFit()/2 115 | plc.bc.YOffset += (plc.spec.Height() - plc.bc.Height()) / 2 116 | return plc, nil 117 | } 118 | 119 | func headerOp(plc panelLayoutContext) { 120 | offsets := make(map[string]float64) 121 | offsets["PANEL_HEADER_OFFSET_X"] = 0.0 122 | offsets["PANEL_HEADER_OFFSET_Y"] = 0.0 123 | offsets["PANEL_FOOTER_OFFSET_X"] = 0.0 124 | offsets["PANEL_FOOTER_OFFSET_Y"] = 0.0 125 | for k, defval := range offsets { 126 | if v, err := eagle.AttributeFloat(plc.board.Board, k, defval); err == nil { 127 | offsets[k] = v 128 | } else { 129 | log.Fatalf("invalid global attribute numeric value: %s: %v", err) 130 | } 131 | } 132 | // add the header and footer 133 | headerloc := plc.spec.HeaderLocation() 134 | header := eagle.Text{ 135 | X: headerloc.X + offsets["PANEL_HEADER_OFFSET_X"], 136 | Y: headerloc.Y + offsets["PANEL_HEADER_OFFSET_Y"], 137 | Align: "center", 138 | Size: 3.0, 139 | Text: eagle.AttributeString(plc.board.Board, "PANEL_HEADER_TEXT", "
"), 140 | Layer: plc.panel.LayerByName(plc.headerLayer), 141 | } 142 | footerloc := plc.spec.FooterLocation() 143 | plc.panel.Board.Plain.Texts = append(plc.panel.Board.Plain.Texts, header) 144 | footer := eagle.Text{ 145 | X: footerloc.X + offsets["PANEL_FOOTER_OFFSET_X"], 146 | Y: footerloc.Y + offsets["PANEL_FOOTER_OFFSET_Y"], 147 | Align: "center", 148 | Size: 3.0, 149 | Text: eagle.AttributeString(plc.board.Board, "PANEL_FOOTER_TEXT", "