Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
84 changes: 80 additions & 4 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@ Give it a try: **[🚀 Open Web Editor](https://splitbrain.github.io/pinoutleaf/
* Generates clean, scalable SVG diagrams.
* Default sizes match the real-world PCB exactly — just print at 100%.
* Fully self-contained, font and image resources are embedded into the file so it's fully portable.
* Images can be PNG, JPEG, GIF, WebP, AVIF, BMP or SVG, from local files or `https://` URLs.

* **Configuration:**
* Define pinouts, board dimensions, labels, and types using simple YAML or JSON files.
Expand All @@ -24,23 +25,29 @@ Give it a try: **[🚀 Open Web Editor](https://splitbrain.github.io/pinoutleaf/
* **Web Editor:**
* Real-time preview of the diagram as you edit the configuration.
* Syntax highlighting for YAML.
* Download generated SVG diagrams.
* Switch PCB images between grayscale and color and adjust their opacity with a slider (sets `grayscale` and `opacity` of the images in the YAML).
* Download the front, the back or both (as two files or combined into one) as SVG or PNG. PNGs have 300 DPI and print in real size, too.
* Remote images are embedded into the download. Servers that block this are linked instead, the editor shows a notice when that happens.

* **CLI Tool:**
* Process multiple configuration files or entire directories to generate SVG diagrams programmatically.

* **Front & Back Views:**
* Automatically generates diagrams for both the front and back sides of the PCB.

* **Photo First Layouts:**
* Start with a photo of your PCB and place pin headers on it by clicking their first and last hole.
* The pin pitch is used to derive the real-world scale, no fiddling with image offsets.
* Front and back photos can be placed independently.

## Screenshot

![Screenshot](screenshot.png)

## Limits

* Only four rows of pins are supported (left, right, top, bottom).
* No weird arbitrary layouts.
* Only the standard 0.1-inch pin raster is supported (might change in the future).
* Grid layouts: only four rows of pins are supported (left, right, top, bottom) on the standard 0.1-inch raster.
* Photo first layouts: headers have one or two rows, pins within a row are equally spaced along a straight line. Labels always point left, right, up or down.

## Alternatives

Expand Down Expand Up @@ -69,6 +76,75 @@ Check the [pinouts](https://github.com/splitbrain/pinoutleaf/tree/gh-pages/pinou

Open a [pull request](https://github.com/splitbrain/pinoutleaf/pulls) or [ticket](https://github.com/splitbrain/pinoutleaf/issues) if you made one and want it included in the repo.

### Photo First Layouts

Instead of describing a pin grid and nudging the image until it matches, you can start with a photo and place the pins on it. Use `headers` instead of `width`, `height`, `offsets` and `pins`:

```yaml
title: "SIM7080G Module"

image:
front:
src: "SIM7080G-front-pcb.png"
back:
src: "SIM7080G-back-pcb.png"

headers:
- start: [ 672, 1682 ] # pixel position of the first hole on the front image
end: [ 657, 2550 ] # pixel position of the last hole
back: # optional: the same header on the back image
start: [ 1728, 1695 ]
end: [ 1728, 2518 ]
pins:
- [ "DTR:uart" ]
- [ "PWR" ]
- [ "RTS:uart" ]
- [ ] # a hole without labels
- [ ]
- [ ]
- [ "3V3:power" ]
- [ "GND:gnd" ]
```

1. Set the front (and back) image and add a header with its `start` and `end`.
2. In the web editor, check **📍 Pick coordinates**, put the editor cursor where the coordinates should go and click the hole on the photo. The pixel coordinates are inserted for you. Clicking the back photo gives back image coordinates.
3. Add as many entries to `pins` as the header has holes. The holes are spaced equally between `start` and `end`.
4. Label the pins just like in grid layouts.

Things to know:

* Coordinates are in image pixels. For SVG images they are in the units of the SVG's `viewBox`.
* The distance between the holes and the pin pitch determine the scale of the photo. Until a header with at least two pins exists, the diagram is marked as *not to scale*.
* `pitch` sets the pin spacing in mm, either globally or per header. It defaults to `2.54`.
* `count` sets the number of holes when you don't want to list every pin yet.
* Use `null` (an empty list item) instead of `[ ]` to skip a hole.
* Double-row headers (like 2×20 GPIO headers) use `rows: 2` plus `rowStart` or `rowEnd`, the position of the second row's first or last hole. See below.
* `side` (`left`, `right`, `top`, `bottom`) sets where labels are drawn. It is guessed from the header's position when omitted and mirrored for the back view. Use `back.side` to override it for the back.
* The back photo is scaled and rotated so its holes match the front ones. Without `back` coordinates, it is centered at the front image's scale. Without a back image, the front photo is mirrored.
* `back` `start` and `end` must point to the same first and last pin as on the front. Check the console for warnings when things don't line up.

#### Double-Row Headers

```yaml
headers:
- start: [ 300, 200 ] # first hole of the first row (pin 1)
end: [ 300, 700 ] # last hole of the first row
rows: 2
rowEnd: [ 400, 700 ] # last hole of the second row (or rowStart for its first hole)
order: zigzag # pin numbering, see below
pins:
- [ "1", "VCC:power" ]
- [ "2", "5V:power" ]
# ...
```

* `pins` lists both rows. `order` sets how they are numbered:
* `zigzag` (default): pin 1 in the first row, pin 2 next to it in the second row, pin 3 below pin 1, and so on.
* `rows`: the whole first row, then the second row in the same direction.
* `around`: the whole first row, then back along the second row, like DIP chips.
* The labels of both rows point away from each other. `side` sets the side of the first row.
* On the back, `rowStart` or `rowEnd` are optional. Without them, the second row keeps the same distance to the first row as on the front.

### Printing

* Download the SVG and open it in an SVG-capable tool — your browser is fine.
Expand Down
35 changes: 34 additions & 1 deletion public/editor.css
Original file line number Diff line number Diff line change
Expand Up @@ -83,13 +83,46 @@ a {
border: 1px solid #ccc;
cursor: pointer;
}

.image-controls, .download-controls {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: 0.5em 2em;

label {
display: inline-flex;
align-items: center;
gap: 0.5em;
}

output {
min-width: 4em;
}
}

#output.picking svg {
cursor: crosshair;
}

#output .error {
color: #cc322d;
}

#output .notice {
margin-bottom: 1em;
padding: 0.5em 1em;
border: 1px solid #e38022;
background: #fdf3e8;
overflow-wrap: anywhere;
}
}

/**
* Print Styles
*/
@media print {
p {
p, #output .notice {
display: none;
}

Expand Down
45 changes: 43 additions & 2 deletions public/index.html
Original file line number Diff line number Diff line change
Expand Up @@ -33,9 +33,50 @@
<div class="panel panel-output">
<div id="output"></div>

<p class="download-controls">
<label>
⬇️ Download
<select id="download-sides">
<option value="separate">front and back as two files</option>
<option value="combined">front and back in one file</option>
<option value="front">front only</option>
<option value="back">back only</option>
</select>
</label>
<label>
as
<select id="download-format">
<option value="svg">SVG</option>
<option value="png">PNG (300 DPI)</option>
</select>
</label>
<button type="button" id="download-button">Download</button>
<a href="javascript:print()">🖨️ Print</a>
</p>
<p>
☝️ Or click an image to download just that side.
<span id="download-status"></span>
</p>

<p class="image-controls">
<label>
<input type="checkbox" id="grayscale-toggle" checked disabled>
🎨 Grayscale images
</label>
<label>
🌓 Image opacity
<input type="range" id="opacity-slider" min="0" max="100" step="5" value="50" disabled>
<output id="opacity-value"></output>
</label>
</p>

<p>
☝️ Click the images to download.
<a href="javascript:print()">🖨️ Click here to print them</a>.
<label>
<input type="checkbox" id="pick-toggle">
📍 Pick coordinates: clicking a photo inserts its pixel position at the editor cursor
(for <code>start</code>, <code>end</code>, <code>rowStart</code> and <code>rowEnd</code> of <code>headers</code>, on the front or back photo).
</label>
<code id="pick-status"></code>
</p>
</div>
</div>
Expand Down
64 changes: 45 additions & 19 deletions src/Builder.js
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,15 @@ import {Title} from "./components/Title.js";
import {Pcb} from "./components/Pcb.js";
import merge from 'lodash.merge';
import {RootGroup} from "./components/RootGroup.js";
import {PhotoPcb} from "./components/PhotoPcb.js";
import {PhotoLayout} from "./PhotoLayout.js";

const SIDE_ALIGNMENT = {
left: 'leftof',
right: 'rightof',
top: 'above',
bottom: 'under',
};

export class Builder {

Expand Down Expand Up @@ -93,6 +102,7 @@ export class Builder {
},

// pin label:type, each pin can have multiple labels
// Alternatively use `headers` to place pins on the front image by pixel coordinates (see PhotoLayout)
pins: {
left: [],
right: [],
Expand All @@ -106,7 +116,8 @@ export class Builder {
*/
constructor(setup) {
this.setup = merge(this.setup, setup);
this.normalizePinArrays();
this.photo = this.setup.headers ? new PhotoLayout(this.setup) : null;
if (!this.photo) this.normalizePinArrays();
this.isFlipped = false;
}

Expand All @@ -122,24 +133,37 @@ export class Builder {
svg.append(root);


// Create pin rows
const pinLayoutGroup = new Group();
pinLayoutGroup.append(this.createPinRow('left', 'leftof'));
pinLayoutGroup.append(this.createPinRow('right', 'rightof'));
pinLayoutGroup.append(this.createPinRow('top', 'above'));
pinLayoutGroup.append(this.createPinRow('bottom', 'under'));
root.append(pinLayoutGroup);
if (this.photo) {
// Create holes placed on the photo
this.photo.holes(this.isFlipped).forEach(hole => {
pinLayoutGroup.append(this.createPinWithLabels(hole, hole.labels, SIDE_ALIGNMENT[hole.side]));
});

// Add the PCB background
const pcb = new Pcb(this.setup.width, this.setup.height, this.setup.image);
pinLayoutGroup.prepend(pcb);
const {placement, bounds} = this.photo.pcb(this.isFlipped);
pinLayoutGroup.prepend(new PhotoPcb(placement, bounds));
} else {
// Create pin rows
pinLayoutGroup.append(this.createPinRow('left', 'leftof'));
pinLayoutGroup.append(this.createPinRow('right', 'rightof'));
pinLayoutGroup.append(this.createPinRow('top', 'above'));
pinLayoutGroup.append(this.createPinRow('bottom', 'under'));

// Add the PCB background
const pcb = new Pcb(this.setup.width, this.setup.height, this.setup.image);
pinLayoutGroup.prepend(pcb);
}
root.append(pinLayoutGroup);

// Create the title
const title = new Title(this.setup.title + (this.isFlipped ? ' (back)' : ' (front)'));
let titleText = this.setup.title + (this.isFlipped ? ' (back)' : ' (front)');
if (this.photo && !this.photo.calibrated) titleText += ' - not to scale';
const title = new Title(titleText);
root.append(title)

// Create the legend
const legend = new Legend(this.setup.types, this.setup.pins, this.isFlipped);
const pinsData = this.photo ? this.photo.pinsData() : this.setup.pins;
const legend = new Legend(this.setup.types, pinsData, this.isFlipped);
root.append(legend);

// Get bounding box of the main pin layout
Expand All @@ -149,7 +173,9 @@ export class Builder {

// Position legend to the bottom aligned to the right/left of the pin layout with padding
let rootBBox = root.getBoundingBox();
if(this.isFlipped) {
if (!legendBBox) {
// no labels, no legend
} else if(this.isFlipped) {
// FIXME
// it's hacky:
// rect returns the visual bounding box that includes the stroke width
Expand Down Expand Up @@ -184,6 +210,7 @@ export class Builder {
*/
flip() {
this.isFlipped = !this.isFlipped;
if (this.photo) return; // photo layouts mirror their coordinates when building

// Swap left and right pins
const tempLeftPins = this.setup.pins.left;
Expand Down Expand Up @@ -251,19 +278,18 @@ export class Builder {

/**
* Creates a pin with its labels
* @param {string} row The row identifier ('left', 'right', 'top', 'bottom')
* @param {number} pinIndex The index of the pin in the row
* @param {{x: number, y: number}} pos The position of the pin
* @param {string[]} labels The labels for this pin in label:type format
* @param {string} alignment The alignment of labels ('leftof', 'rightof', 'above', 'under')
* @returns {Group} A group containing the pin and its labels
*/
createPinWithLabels(row, pinIndex, alignment) {
createPinWithLabels(pos, labels, alignment) {
const group = new Group();
const pos = this.pinPosition(row, pinIndex);
const pinElement = new Circle(pos.x, pos.y, PINSIZE, 'gold');
group.append(pinElement);

let last = pinElement;
this.setup.pins[row][pinIndex].forEach((pindata, index) => {
labels.forEach((pindata, index) => {
const [text, type] = pindata.split(':');
const {bgcolor, fgcolor} = this.setup.types[type] || this.setup.types.default;

Expand Down Expand Up @@ -292,7 +318,7 @@ export class Builder {
for (let pin = 0; pin < pinCount; pin++) {
if (!this.setup.pins[row][pin]) continue; // pin is null
if (!this.setup.pins[row][pin].length) continue; // No definition for this pin, skip it
const pinGroup = this.createPinWithLabels(row, pin, alignment);
const pinGroup = this.createPinWithLabels(this.pinPosition(row, pin), this.setup.pins[row][pin], alignment);
rowGroup.append(pinGroup);
}

Expand Down
Loading