diff --git a/README.md b/README.md index d0c561c..fe63a64 100644 --- a/README.md +++ b/README.md @@ -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. | diff --git a/tests/browser/fit.spec.js b/tests/browser/fit.spec.js new file mode 100644 index 0000000..61878e9 --- /dev/null +++ b/tests/browser/fit.spec.js @@ -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); +}); diff --git a/web/app.js b/web/app.js index e58dbec..ec56647 100644 --- a/web/app.js +++ b/web/app.js @@ -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; @@ -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. @@ -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 { diff --git a/web/render-worker.js b/web/render-worker.js index 45d48e9..6e85520 100644 --- a/web/render-worker.js +++ b/web/render-worker.js @@ -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 } @@ -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; @@ -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]); } } @@ -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; diff --git a/web/render.js b/web/render.js index 2588e87..0c363d1 100644 --- a/web/render.js +++ b/web/render.js @@ -35,6 +35,9 @@ var BACKDROP_DELAY_MS = 100; // ---- Worker ---- var _worker = null; var _workerReady = false; +// Resolves on the worker's ready message. +var _resolveWorkerReady; +var workerReady = new Promise(function(resolve) { _resolveWorkerReady = resolve; }); var _pendingRenders = {}; // side -> true (de-duplicate in-flight requests) var _dirtyRenders = {}; // side -> true (re-render needed after in-flight completes) var _piecesPending = {}; // side -> true while overscan pieces are still to come @@ -163,6 +166,8 @@ function handleWorkerMessage(e) { if (msg.type === "ready") { _workerReady = true; _innerLayerNames = msg.innerLayers || []; + _zoneBoxes = msg.zoneBoxes || {}; + _resolveWorkerReady(); // Now do initial render renderBuffers(allcanvas.front); renderBuffers(allcanvas.back); @@ -1411,6 +1416,16 @@ function zoomFitPoints(layerdict, points) { return true; } +// The four corners of a part's box on the board, turned by its angle as +// drawFootprint() draws it. +function footprintCorners(fp) { + var b = fp.bbox; + return [[0, 0], [b.size[0], 0], [0, b.size[1]], [b.size[0], b.size[1]]].map(function(c) { + var v = rotateVector([b.relpos[0] + c[0], b.relpos[1] + c[1]], -b.angle); + return [v[0] + b.pos[0], v[1] + b.pos[1]]; + }); +} + function zoomToFootprint(fpIdx, layerdict) { var fp = pcbdata.footprints[fpIdx]; if (!fp) return; @@ -1518,6 +1533,15 @@ function getInnerLayers() { return layers; } +// Box of zone i on a layer, from the worker. Null before the worker is ready +// and for a zone it could not box. +var _zoneBoxes = {}; + +function zoneBox(layer, i) { + var b = _zoneBoxes[layer] && _zoneBoxes[layer][i]; + return b && isFinite(b.minx) ? b : null; +} + // ---- Initialization ---- function initRender() {