Skip to content
Merged
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
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -293,7 +293,7 @@ The hash is a list of `key=value` pairs. Keys may repeat.
| `rot` | Board rotation: `0`, `90`, `180` or `270`. Added with the view. A view without `rot` is at `0`. |
| `viewF`, `viewB` | Visible area per side: `cx,cy,w,h` in board units. `w` and `h` are along the screen at the link's rotation. |
| `mark` | Marker at a board point: side, then `x,y` in board units, e.g. `mark=F:167.75,144.23`. Drawn on top of everything, on that side only; it does not change the selection. May repeat. Without `side`, the link shows the sides the markers are on. Esc removes them. |
| `zoom` | `board`, `selected` or `highlight`, same as W, E and Z. Used when there is no view. `selected` (and E) also fits the markers. |
| `zoom` | `board`, `selected` or `highlight`, same as W, E and Z. Used when there is no view. `selected` (and E) fits the highlighted nets as drawn, on layers that are on or that show them with Net, and also fits the markers. |
| `copper` | Shown copper layer, as named in the CAD tool, e.g. `copper=F`. Layers not listed are off; `copper=` alone hides them all. Add `:` and letters to show only some kinds: `t` tracks, `z` zones, `v` vias, `s` silk, `f` fab. `copper=In1.Cu:tv` shows tracks and vias on In1.Cu. |
| `netcopper` | Copper layer that is off but still shows the highlighted nets on it, e.g. `netcopper=In1.Cu`. Takes the same `:LETTERS` as `copper`, e.g. `netcopper=In1.Cu:t` for tracks only. May repeat. Read only together with `copper`; layers not listed show nothing while off. |
| `netlayers` | `1` turns on every layer the linked nets are routed on. |
Expand Down
155 changes: 155 additions & 0 deletions tests/browser/fit.spec.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,155 @@
// E (zoom=selected) fits the highlighted nets as the layer table draws them:
// a layer counts when it is on, or off with Net ticked, and only for the
// kinds ticked on its row. These read the points E fits, fitPoints(side), and
// compare their bounds with the net's copper in the fixture.

const { test, expect } = require('@playwright/test');
const fs = require('fs');
const path = require('path');

const PAGE = 'file://' + path.join(__dirname, 'board.html');
const FIXTURE = path.join(__dirname, '..', 'fixtures', 'netdaq-small.json');

const pcbdata = JSON.parse(fs.readFileSync(FIXTURE, 'utf8')).pcbdata;

// Nets whose copper on one layer reaches well outside their copper on F.
const INNER_NET = '/ADCIN28'; // In2.Cu tracks
const BACK_NET = '/Ethernet PHY/MODE0_RXD0'; // B tracks
const ZONE_NET = '+5V_USB'; // a zone on F

function hashOf(pairs) {
return '#' + pairs.map(([k, v]) => encodeURIComponent(k) + '=' + encodeURIComponent(v)).join('&');
}

async function load(page, hash) {
await page.goto('about:blank');
await page.goto(PAGE + hash);
await page.waitForFunction(() => window.__pcbaTest && window.__pcbaTest.ready(), null, { timeout: 30000 });
await page.waitForFunction(() => window.__pcbaTest.idle(), null, { timeout: 30000 });
}

function boxOf(points) {
const b = { minx: Infinity, miny: Infinity, maxx: -Infinity, maxy: -Infinity };
for (const [x, y] of points) {
b.minx = Math.min(b.minx, x); b.maxx = Math.max(b.maxx, x);
b.miny = Math.min(b.miny, y); b.maxy = Math.max(b.maxy, y);
}
return b;
}

// Track ends of a net on a layer, vias left out.
function trackBox(net, layer) {
const pts = [];
for (const t of pcbdata.tracks[layer]) {
if (t.net !== net || !t.start) continue;
if (t.start[0] === t.end[0] && t.start[1] === t.end[1]) continue;
pts.push(t.start, t.end);
}
return boxOf(pts);
}

function zoneBox(net, layer) {
const pts = [];
for (const z of pcbdata.zones[layer]) if (z.net === net) for (const poly of z.polygons) pts.push(...poly);
return boxOf(pts);
}

function contains(outer, inner) {
const eps = 1e-6;
return outer.minx <= inner.minx + eps && outer.miny <= inner.miny + eps &&
outer.maxx >= inner.maxx - eps && outer.maxy >= inner.maxy - eps;
}

async function fitBox(page, side) {
return boxOf(await page.evaluate((side) => fitPoints(side), side));
}

async function fitBoxWith(page, pairs, side = 'F') {
await load(page, hashOf(pairs));
return fitBox(page, side);
}

test('the fixture nets reach outside their copper on F', async ({ page }) => {
for (const [net, box] of [[INNER_NET, trackBox(INNER_NET, 'In2.Cu')],
[BACK_NET, trackBox(BACK_NET, 'B')],
[ZONE_NET, zoneBox(ZONE_NET, 'F')]]) {
const onF = await fitBoxWith(page, [['side', 'F'], ['copper', 'F:tv'], ['net', net]]);
expect(contains(onF, box), net).toBe(false);
}
});

test('E leaves out an inner layer that is off, and counts it when on', async ({ page }) => {
const inner = trackBox(INNER_NET, 'In2.Cu');
expect(contains(await fitBoxWith(page, [['side', 'F'], ['copper', 'F'], ['net', INNER_NET]]), inner)).toBe(false);
expect(contains(await fitBoxWith(page, [['side', 'F'], ['copper', 'F'], ['copper', 'In2.Cu'], ['net', INNER_NET]]), inner)).toBe(true);
// Tracks off on the row: its tracks do not count.
expect(contains(await fitBoxWith(page, [['side', 'F'], ['copper', 'F'], ['copper', 'In2.Cu:zv'], ['net', INNER_NET]]), inner)).toBe(false);
});

test('E leaves out the side in view when that layer is off', async ({ page }) => {
// Only the tracks on In2.Cu are on: the net's pad on F, left of them, does
// not count. Nor does its via, which is further left still.
const fit = await fitBoxWith(page, [['side', 'F'], ['copper', 'In2.Cu:t'], ['net', INNER_NET]]);
const inner = trackBox(INNER_NET, 'In2.Cu');
expect(contains(fit, inner)).toBe(true);
const slack = 1;
expect(fit.minx).toBeGreaterThan(inner.minx - slack);
expect(fit.miny).toBeGreaterThan(inner.miny - slack);
expect(fit.maxx).toBeLessThan(inner.maxx + slack);
expect(fit.maxy).toBeLessThan(inner.maxy + slack);
});

test('E counts a layer that is off with Net ticked', async ({ page }) => {
const inner = trackBox(INNER_NET, 'In2.Cu');
expect(contains(await fitBoxWith(page, [['side', 'F'], ['copper', 'F'], ['netcopper', 'In2.Cu'], ['net', INNER_NET]]), inner)).toBe(true);
expect(contains(await fitBoxWith(page, [['side', 'F'], ['copper', 'F'], ['netcopper', 'In2.Cu:zv'], ['net', INNER_NET]]), inner)).toBe(false);

// The far side.
const back = trackBox(BACK_NET, 'B');
expect(contains(await fitBoxWith(page, [['side', 'F'], ['copper', 'F'], ['net', BACK_NET]]), back)).toBe(false);
expect(contains(await fitBoxWith(page, [['side', 'F'], ['copper', 'F'], ['netcopper', 'B'], ['net', BACK_NET]]), back)).toBe(true);

// An inner layer seen from the back.
expect(contains(await fitBoxWith(page, [['side', 'B'], ['copper', 'B'], ['netcopper', 'In2.Cu'], ['net', INNER_NET]], 'B'), inner)).toBe(true);
});

test('E fits zones', async ({ page }) => {
const zone = zoneBox(ZONE_NET, 'F');
expect(contains(await fitBoxWith(page, [['side', 'F'], ['copper', 'F'], ['net', ZONE_NET]]), zone)).toBe(true);
expect(contains(await fitBoxWith(page, [['side', 'F'], ['copper', 'F:tv'], ['net', ZONE_NET]]), zone)).toBe(false);
});

test('a zoom=selected link fits zones', async ({ page }) => {
await load(page, hashOf([['side', 'F'], ['copper', 'F'], ['net', ZONE_NET], ['zoom', 'selected']]));
const v = await page.evaluate(() => window.__pcbaTest.viewBox('F'));
const zone = zoneBox(ZONE_NET, 'F');
expect(v.cx - v.w / 2).toBeLessThanOrEqual(zone.minx);
expect(v.cx + v.w / 2).toBeGreaterThanOrEqual(zone.maxx);
expect(v.cy - v.h / 2).toBeLessThanOrEqual(zone.miny);
expect(v.cy + v.h / 2).toBeGreaterThanOrEqual(zone.maxy);
});

// A part turned a quarter turn: its box on the board has width and height
// swapped from bbox.size.
const TURNED = pcbdata.footprints.find((fp) => fp.layer === 'F' && Math.abs(fp.bbox.angle) === 90 &&
Math.abs(fp.bbox.size[0] - fp.bbox.size[1]) > 1);

test('E fits a pinned part turned by its angle', async ({ page }) => {
expect(TURNED).toBeTruthy();
const fit = await fitBoxWith(page, [['side', 'F'], ['copper', 'F'], ['comp', TURNED.ref], ['zoom', 'board']]);
const [w, h] = TURNED.bbox.size;
expect(fit.maxx - fit.minx).toBeCloseTo(h, 3);
expect(fit.maxy - fit.miny).toBeCloseTo(w, 3);
});

test('pressing E zooms to the inner layer copper', async ({ page }) => {
await load(page, hashOf([['side', 'F'], ['copper', 'F'], ['netcopper', 'In2.Cu'], ['net', INNER_NET]]));
await page.keyboard.press('e');
await page.waitForFunction(() => window.__pcbaTest.idle(), null, { timeout: 30000 });
const v = await page.evaluate(() => window.__pcbaTest.viewBox('F'));
const inner = trackBox(INNER_NET, 'In2.Cu');
expect(v.cx - v.w / 2).toBeLessThanOrEqual(inner.minx);
expect(v.cx + v.w / 2).toBeGreaterThanOrEqual(inner.maxx);
expect(v.cy - v.h / 2).toBeLessThanOrEqual(inner.miny);
expect(v.cy + v.h / 2).toBeGreaterThanOrEqual(inner.maxy);
});
141 changes: 74 additions & 67 deletions web/app.js
Original file line number Diff line number Diff line change
Expand Up @@ -192,6 +192,12 @@ function layerShowsNet(l, kind) {
return !row.all && row.net && kindShows(l, kind);
}

// Whether the highlighted nets on a layer are drawn: the layer is on, or off
// with net ticked.
function netCopperShows(l, kind) {
return layerShows(l, kind) || layerShowsNet(l, kind);
}

// The flags under "all".
function kindShows(l, kind) {
if (kind === "all") return true;
Expand Down Expand Up @@ -1385,86 +1391,85 @@ function zoomFitBoardAll() {
zoomAll(function(ld) { zoomFitBoard(ld); });
}

// E: zoom to fit all highlighted objects still visible given current layer
// filters, and the markers.
// E: zoom to fit the highlighted nets as drawn, the pinned parts and the
// markers.
function zoomFitSelected() {
var layout = settings.canvaslayout;
if (layout !== "B") {
var fPts = fitPoints("F");
if (fPts.length > 0) zoomFitPoints(allcanvas.front, fPts);
else zoomFitBoard(allcanvas.front);
}
if (layout !== "F") {
var bPts = fitPoints("B");
if (bPts.length > 0) zoomFitPoints(allcanvas.back, bPts);
else zoomFitBoard(allcanvas.back);
}
}

// Build the set of points visible on a given canvas side ("F" or "B").
// Respects xray: when B is shown, B-layer things are also visible on the front canvas.
function buildPoints(canvasSide) {
var primaryLayer = canvasSide; // "F" or "B"
var crossLayer = canvasSide === "F" ? "B" : "F";
var showCross = layerShows(crossLayer, "all");
var pts = [];

function addFpCorners(fp) {
if (!fp) return;
var b = fp.bbox;
var x0 = b.pos[0] + b.relpos[0];
var y0 = b.pos[1] + b.relpos[1];
pts.push([x0, y0]);
pts.push([x0 + b.size[0], y0]);
pts.push([x0, y0 + b.size[1]]);
pts.push([x0 + b.size[0], y0 + b.size[1]]);
}

function wantLayer(fpLayer) {
return fpLayer === primaryLayer || (showCross && fpLayer === crossLayer);
}

// Markers show on their own side only.
linkMarkers.forEach(function(m) {
if (m.side === primaryLayer) pts.push([m.x, m.y]);
});

// Pinned components
Object.keys(pinnedComponents).forEach(function(idx) {
var fp = pcbdata.footprints[parseInt(idx)];
if (fp && wantLayer(fp.layer)) addFpCorners(fp);
});
// The points E fits on one side's canvas. Each copper layer is drawn on both
// canvases, so a net's copper counts where the layer table draws it, see
// netCopperShows(). Pads follow the drawing too: those on the side in view,
// and on the far side those of parts on that side.
function fitPoints(side) {
var crossLayer = side === "F" ? "B" : "F";
var showCross = layerShows(crossLayer, "all");
var pts = [];

// Collect all active nets
var activeNets = new Set();
if (highlightedNet !== null) activeNets.add(highlightedNet);
selectionRegistry.forEach(function(s) { if (s.type === 'net') activeNets.add(s.value); });
function addBox(minx, miny, maxx, maxy) {
pts.push([minx, miny], [maxx, miny], [minx, maxy], [maxx, maxy]);
}

activeNets.forEach(function(netName) {
function addNetCopper(netName, l) {
if (netCopperShows(l, "pads")) {
pcbdata.footprints.forEach(function(fp) {
if (!wantLayer(fp.layer)) return;
if (l !== side && fp.layer !== l) return;
fp.pads.forEach(function(pad) {
if (pad.net === netName) pts.push(pad.pos);
if (pad.net === netName && pad.layers.includes(l)) pts.push(pad.pos);
});
});
if (pcbdata.tracks) {
Object.keys(pcbdata.tracks).forEach(function(layer) {
// Only include track layers that are rendered on this canvas side
// Track layer keys in pcbdata are "F", "B", or inner-layer names ("In1.Cu" etc.)
var trackSide = layer === "B" ? "B" : "F";
if (!wantLayer(trackSide)) return;
pcbdata.tracks[layer].forEach(function(t) {
if (t.net === netName && t.start) {
pts.push(t.start);
if (t.end && (t.start[0] !== t.end[0] || t.start[1] !== t.end[1])) pts.push(t.end);
}
});
});
}
var tracks = netCopperShows(l, "tracks"), vias = netCopperShows(l, "vias");
((pcbdata.tracks && pcbdata.tracks[l]) || []).forEach(function(t) {
if (t.net !== netName) return;
var hw = t.width / 2;
if ("radius" in t) {
// The whole circle: never less than the arc.
var r = t.radius + hw;
if (tracks) addBox(t.center[0] - r, t.center[1] - r, t.center[0] + r, t.center[1] + r);
return;
}
var isVia = t.start[0] === t.end[0] && t.start[1] === t.end[1];
if (isVia ? !vias : !tracks) return;
addBox(Math.min(t.start[0], t.end[0]) - hw, Math.min(t.start[1], t.end[1]) - hw,
Math.max(t.start[0], t.end[0]) + hw, Math.max(t.start[1], t.end[1]) + hw);
});

return pts;
if (netCopperShows(l, "zones")) {
((pcbdata.zones && pcbdata.zones[l]) || []).forEach(function(z, i) {
var b = zoneBox(l, i);
if (z.net === netName && b) addBox(b.minx, b.miny, b.maxx, b.maxy);
});
}
}

if (layout !== "B") {
var fPts = buildPoints("F");
if (fPts.length > 0) zoomFitPoints(allcanvas.front, fPts);
else zoomFitBoard(allcanvas.front);
}
if (layout !== "F") {
var bPts = buildPoints("B");
if (bPts.length > 0) zoomFitPoints(allcanvas.back, bPts);
else zoomFitBoard(allcanvas.back);
}
// Markers show on their own side only.
linkMarkers.forEach(function(m) {
if (m.side === side) pts.push([m.x, m.y]);
});

Object.keys(pinnedComponents).forEach(function(idx) {
var fp = pcbdata.footprints[parseInt(idx)];
if (fp && (fp.layer === side || (showCross && fp.layer === crossLayer))) pts.push(...footprintCorners(fp));
});

var activeNets = new Set();
if (highlightedNet !== null) activeNets.add(highlightedNet);
selectionRegistry.forEach(function(s) { if (s.type === 'net') activeNets.add(s.value); });
activeNets.forEach(function(netName) {
getCopperLayers().forEach(function(l) { addNetCopper(netName, l); });
});

return pts;
}

// Z: zoom into the currently selected / last highlighted footprint, or fit the current net.
Expand Down Expand Up @@ -1627,6 +1632,8 @@ async function applyHashState() {
if (view.F && settings.canvaslayout !== "B") setViewBox(allcanvas.front, view.F);
if (view.B && settings.canvaslayout !== "F") setViewBox(allcanvas.back, view.B);
} else if (zoom) {
// E needs the zone boxes, which come with the worker's ready.
await workerReady;
zoom();
}
} finally {
Expand Down
13 changes: 9 additions & 4 deletions web/render-worker.js
Original file line number Diff line number Diff line change
Expand Up @@ -21,7 +21,7 @@
* { type: "updateHighlights", highlights }
*
* Worker → Main:
* { type: "ready", innerLayers }
* { type: "ready", innerLayers, zoneBoxes }
* { type: "rendered", side, gen, rect, bitmaps: {bg, silk, fab, highlight}, bufferState, elapsed, phases, drawCalls, itemsVisited, done }
* { type: "piece", side, gen, rect, bitmaps, innerComposite, elapsed, drawCalls, itemsVisited, done }
* { type: "backdrop", side, bitmap, elapsed, drawCalls, itemsVisited }
Expand Down Expand Up @@ -455,6 +455,9 @@ var _vias = {};
// "silkscreen"/"fabrication" -> layer -> grid over pcbdata.drawings.
var _viaGrids = {};
var _zoneGrids = {};
// layer -> box of each zone in pcbdata.zones[layer], grown by its outline
// width. Also sent to the main thread, which has no box of its own.
var _zoneBoxes = {};
var _footprintGrid = null;
var _drawingGrids = {};
var _viaDrillSizeCache = null;
Expand Down Expand Up @@ -518,14 +521,16 @@ function buildDrawingIndices() {
}
}

_zoneBoxes = {};
if (pcbdata.zones) {
for (var layer in pcbdata.zones) {
for (var zone of pcbdata.zones[layer]) {
if (!zone._bbox) zone._bbox = computeZoneBBox(zone);
}
_zoneGrids[layer] = buildGrid(pcbdata.zones[layer].map(function(z) {
_zoneBoxes[layer] = pcbdata.zones[layer].map(function(z) {
return z.width > 0 ? growBox(z._bbox, z.width / 2) : z._bbox;
}));
});
_zoneGrids[layer] = buildGrid(_zoneBoxes[layer]);
}
}

Expand Down Expand Up @@ -2094,7 +2099,7 @@ self.onmessage = function(e) {
_highlights = msg.highlights || _highlights;
_boardOutlinePath = undefined; // reset cache for new board
buildDrawingIndices();
self.postMessage({ type: "ready", innerLayers: getInnerLayers() });
self.postMessage({ type: "ready", innerLayers: getInnerLayers(), zoneBoxes: _zoneBoxes });
}
else if (msg.type === "render") {
_settings = msg.settings;
Expand Down
Loading
Loading