diff --git a/README.md b/README.md index 069c1ab..1bd2211 100644 --- a/README.md +++ b/README.md @@ -295,7 +295,7 @@ The hash is a list of `key=value` pairs. Keys may repeat. | `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) 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. | +| `netcopper` | Copper layer that is off but still shows the highlighted nets on it, e.g. `netcopper=In1.Cu`. May repeat. Read only together with `copper`; layers not listed show nothing while off. Letters after the name, from older links, are ignored. | | `netlayers` | `1` turns on every layer the linked nets have copper on: tracks, zones or pads. | Example: `board.html#net=/ADC1/CS&net=GND&netlayers=1&zoom=selected`. diff --git a/tests/browser/fit.spec.js b/tests/browser/fit.spec.js index 6196496..75fd55c 100644 --- a/tests/browser/fit.spec.js +++ b/tests/browser/fit.spec.js @@ -102,7 +102,6 @@ test('E leaves out the side in view when that layer is off', async ({ page }) => 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'); diff --git a/tests/browser/interaction.spec.js b/tests/browser/interaction.spec.js index a766e96..19756a5 100644 --- a/tests/browser/interaction.spec.js +++ b/tests/browser/interaction.spec.js @@ -676,24 +676,85 @@ test('old saved layer settings are carried over', async ({ page }) => { const s = (await state(page)).layers; expect(s[layer].all).toBe(true); expect(s.F.all).toBe(true); - expect(s.B.all).toBe(false); - for (const l of ['F', 'B']) { - expect(s[l].silk, l).toBe(false); - expect(s[l].fab, l).toBe(true); - } + // B was off, so it has nothing on. + expect(s.B).toMatchObject({ all: false, silk: false, fab: false }); + expect(s.F.silk).toBe(false); + expect(s.F.fab).toBe(true); +}); + +// Settings saved while All was a switch over the other boxes. +test('saved layer rows with an All switch are carried over', async ({ page }) => { + await load(page); + const [on, off] = innerLayers; + await page.evaluate(([on, off]) => writeStorage('settings', JSON.stringify({ layers: { + F: { all: true, net: false, tracks: true, zones: false, vias: true, silk: true, fab: false }, + B: { all: false, net: true, tracks: true, zones: true, vias: true, silk: true, fab: true }, + [on]: { all: true, net: false, tracks: false, zones: false, vias: false }, + [off]: { all: false, net: false, tracks: true, zones: true, vias: true }, + } })), [on, off]); + await page.reload(); + await page.waitForFunction(() => window.__pcbaTest && window.__pcbaTest.ready()); + const s = (await state(page)).layers; + expect(s.F).toEqual({ all: true, net: false, tracks: true, zones: false, vias: true, silk: true, fab: false }); + expect(s.B).toEqual({ all: false, net: true, tracks: false, zones: false, vias: false, silk: false, fab: false }); + // On with nothing ticked stays on, with everything. + expect(s[on]).toEqual({ all: true, net: false, tracks: true, zones: true, vias: true }); + expect(s[off]).toEqual({ all: false, net: false, tracks: false, zones: false, vias: false }); }); -// A row that is off keeps the state of its other boxes. -test('turning a row off and on keeps its other boxes', async ({ page }) => { +// All on a row is ticked when every kind is on, clear when none is, and mixed +// in between. A click on a mixed box turns every kind on. +test('a row All box is clear, mixed or ticked', async ({ page }) => { await load(page); await openLayers(page); + const all = layerBox(page, 'F', 'all'); await layerBox(page, 'F', 'vias').uncheck(); - await layerBox(page, 'F', 'all').uncheck(); + expect(await all.evaluate((cb) => cb.indeterminate)).toBe(true); + await all.click(); + expect(await all.evaluate((cb) => cb.indeterminate)).toBe(false); + expect((await state(page)).layers.F).toMatchObject({ tracks: true, zones: true, vias: true, silk: true, fab: true }); + await all.uncheck(); await expect(page.locator('#layer-table tr[data-layer="F"]')).toHaveClass(/layer-off/); - await layerBox(page, 'F', 'all').check(); - const s = (await state(page)).layers.F; - expect(s.vias).toBe(false); - expect(s.tracks).toBe(true); + expect((await state(page)).layers.F).toMatchObject({ all: false, tracks: false, zones: false, vias: false, silk: false, fab: false }); + // Ticking one kind turns the layer back on. + await layerBox(page, 'F', 'tracks').check(); + expect((await state(page)).layers.F.all).toBe(true); + expect(await all.evaluate((cb) => cb.indeterminate)).toBe(true); +}); + +// The All layers row: one box per column for every layer that has it. +test('the All layers row sets a column on every layer', async ({ page }) => { + await load(page); + await openLayers(page); + const layers = ['F', ...innerLayers, 'B']; + const box = (kind) => page.locator(`#layer-table tr.layer-every input[data-kind="${kind}"]`); + const mixed = (kind) => box(kind).evaluate((cb) => cb.indeterminate); + + // Only F is on at first. + expect(await mixed('tracks')).toBe(true); + await box('tracks').click(); + let s = (await state(page)).layers; + for (const l of layers) expect(s[l].tracks, l).toBe(true); + for (const l of layers) expect(s[l].all, l).toBe(true); + await expect(box('tracks')).toBeChecked(); + await box('tracks').click(); + s = (await state(page)).layers; + for (const l of layers) expect(s[l].tracks, l).toBe(false); + + // Silk is only on F and B. B starts with it off. + expect(await mixed('silk')).toBe(true); + await box('silk').check(); + s = (await state(page)).layers; + for (const l of ['F', 'B']) expect(s[l].silk, l).toBe(true); + for (const l of innerLayers) expect(s[l].silk, l).toBeUndefined(); + + await box('all').check(); + s = (await state(page)).layers; + for (const l of layers) expect(s[l].vias, l).toBe(true); + await expect(box('tracks')).toBeChecked(); + await box('all').uncheck(); + s = (await state(page)).layers; + for (const l of layers) expect(s[l].all, l).toBe(false); }); test('layer filter limits the net list to nets on that layer', async ({ page }) => { @@ -752,12 +813,6 @@ test('Net shows the selected net on an inner layer that is off', async ({ page } expect((await state(page)).layers[layer]).toMatchObject({ all: false, net: true }); expect(await countColorIn(page, 'IL_comp_hl', PALETTE[0])).toBeGreaterThan(0); await expect(page.locator('#IL_comp_bg')).toBeHidden(); - - // The layer's own boxes still pick what is drawn. - await layerBox(page, layer, 'tracks').uncheck(); - await layerBox(page, layer, 'zones').uncheck(); - await layerBox(page, layer, 'vias').uncheck(); - expect(await countColorIn(page, 'IL_comp_hl', PALETTE[0])).toBe(0); }); // Net on the far side draws the net's far side tracks on the viewed side. @@ -946,15 +1001,14 @@ test('T ticks Net on a layer where the selected net has only pads', async ({ pag expect((await state(page)).layers.B.net).toBe(true); }); -// On a row that is off with Net ticked, tracks, zones and vias pick what is -// drawn, so they are not dimmed. Silk stays dimmed. -test('Net undims tracks, zones and vias on a row that is off', async ({ page }) => { +// Net matters only on a row that is off, so it is dimmed on a row that is on. +// The other boxes are never dimmed. +test('Net is dimmed only on a row that is on', async ({ page }) => { await load(page); await openLayers(page); - await layerBox(page, 'B', 'net').check(); - const opacity = (kind) => layerBox(page, 'B', kind).evaluate((el) => getComputedStyle(el).opacity); - for (const k of ['tracks', 'zones', 'vias', 'net']) expect(await opacity(k), k).toBe('1'); - expect(await opacity('silk')).not.toBe('1'); + const opacity = (l, kind) => layerBox(page, l, kind).evaluate((el) => getComputedStyle(el).opacity); + expect(await opacity('F', 'net')).not.toBe('1'); + for (const k of ['all', 'tracks', 'zones', 'vias', 'silk', 'net']) expect(await opacity('B', k), k).toBe('1'); }); test('M toggles dark mode', async ({ page }) => { diff --git a/tests/browser/layers.spec.js b/tests/browser/layers.spec.js index f2556ed..5ccaa07 100644 --- a/tests/browser/layers.spec.js +++ b/tests/browser/layers.spec.js @@ -36,7 +36,7 @@ test('inner layer list is in number order', async ({ page }) => { const rows = await page.locator('#layer-table tbody tr') .evaluateAll((trs) => trs.map((tr) => tr.dataset.layer)); expect(rows).toEqual(['F', ...INNER, 'B']); - const names = await page.locator('#layer-table td.layer-name').allTextContents(); + const names = await page.locator('#layer-table tbody td.layer-name').allTextContents(); expect(names.map((l) => l.trim())).toEqual(['F', ...INNER, 'B']); }); diff --git a/tests/browser/links.spec.js b/tests/browser/links.spec.js index ddd4325..c7ad16f 100644 --- a/tests/browser/links.spec.js +++ b/tests/browser/links.spec.js @@ -237,12 +237,13 @@ test('a link carries layers that show only the highlighted nets', async ({ page expect(pairs).not.toContainEqual(['netcopper', 'B']); }); -test('a netcopper link carries which kinds are drawn', async ({ page }) => { +// Older links wrote which kinds to draw. A layer that is off has none now. +test('letters after a netcopper layer are ignored', async ({ page }) => { await load(page, hashOf([['copper', 'F'], ['netcopper', innerLayer + ':t']])); const s = await state(page); - expect(s.layers[innerLayer]).toMatchObject({ all: false, net: true, tracks: true, zones: false, vias: false }); + expect(s.layers[innerLayer]).toMatchObject({ all: false, net: true, tracks: false, zones: false, vias: false }); const pairs = await page.evaluate(() => shareLinkPairs({ layers: true })); - expect(pairs).toContainEqual(['netcopper', innerLayer + ':t']); + expect(pairs).toContainEqual(['netcopper', innerLayer]); }); test('a link sets side and layers for this visit only', async ({ page }) => { @@ -261,7 +262,9 @@ test('a link sets side and layers for this visit only', async ({ page }) => { expect(s.layers.B).toEqual({ all: true, net: false, tracks: true, zones: true, vias: true, silk: true, fab: false }); expect(s.layers[innerLayer]).toEqual({ all: true, net: false, tracks: true, zones: false, vias: true }); await page.locator('#inner-layers-menu .menu-btn').hover(); - await expect(layerBox(page, innerLayer, 'all')).toBeChecked(); + // Some kinds on: All is mixed. + expect(await layerBox(page, innerLayer, 'all').evaluate((cb) => cb.indeterminate)).toBe(true); + await expect(layerBox(page, 'B', 'all')).not.toBeChecked(); await expect(layerBox(page, innerLayer, 'zones')).not.toBeChecked(); await expect(layerBox(page, 'F', 'all')).not.toBeChecked(); @@ -270,11 +273,11 @@ test('a link sets side and layers for this visit only', async ({ page }) => { await page.keyboard.press('o'); const stored = await storedSettings(page); expect(stored.layers.B.fab).toBe(true); - expect(stored.layers.B.all).toBe(false); - expect(stored.layers.F.all).toBe(true); + expect(stored.layers.B.tracks).not.toBe(true); + expect(stored.layers.F.tracks).toBe(true); expect(stored.canvaslayout).toBe(before.canvasLayout); - expect(stored.layers[innerLayer].all).toBe(false); - expect(stored.layers[innerLayer].zones).toBe(true); + expect(stored.layers[innerLayer].tracks).not.toBe(true); + expect(stored.layers[innerLayer].vias).not.toBe(true); // A setting the link set is saved once the user changes it. await page.keyboard.press('g'); @@ -300,6 +303,11 @@ test('old layers, xray and overlay keys still work', async ({ page }) => { expect(s.layers.F.all).toBe(true); expect(s.layers.B.all).toBe(false); expect(s.layers[innerLayer].all).toBe(false); + + await load(page, hashOf([['side', 'F'], ['overlay', 'silk']])); + s = await state(page); + expect(s.layers.F.silk).toBe(true); + expect(s.layers.B.all).toBe(false); }); test('netlayers=1 turns on the layers of the linked nets', async ({ page }) => { diff --git a/tests/browser/smoke.spec.js b/tests/browser/smoke.spec.js index c46b3ae..af1d351 100644 --- a/tests/browser/smoke.spec.js +++ b/tests/browser/smoke.spec.js @@ -43,7 +43,7 @@ async function load(page) { test('test API is present at the expected version', async ({ page }) => { await load(page); const version = await page.evaluate(() => window.__pcbaTest.version); - expect(version).toBe(2); + expect(version).toBe(3); }); test('board loads with no page errors', async ({ page }) => { diff --git a/web/app.js b/web/app.js index 3a55b7c..1e09476 100644 --- a/web/app.js +++ b/web/app.js @@ -129,6 +129,7 @@ function loadSettings() { try { Object.assign(settings, JSON.parse(stored)); } catch(e) {} } migrateLayerSettings(settings); + migrateLayerAll(settings); } // Settings saved before the layer table had inner layer visibility, x-ray and @@ -142,6 +143,7 @@ function migrateLayerSettings(s) { }); ["F", "B"].forEach(function(l) { s.layers[l] = s.layers[l] || {}; + if (typeof s.layers[l].all !== "boolean") s.layers[l].all = l === "F"; if (s.renderSilkscreen !== undefined) s.layers[l].silk = !!s.renderSilkscreen; if (s.renderFabrication !== undefined) s.layers[l].fab = !!s.renderFabrication; }); @@ -149,12 +151,27 @@ function migrateLayerSettings(s) { "showBackOnFront", "showFrontOnBack"].forEach(function(k) { delete s[k]; }); } +// Settings saved while "all" was a switch over the kinds. A layer that was +// off has every kind off. One that was on keeps its kinds, or gets them all +// if none was on. A kind not stored was on. +function migrateLayerAll(s) { + Object.keys(s.layers).forEach(function(l) { + var row = s.layers[l]; + if (typeof row.all !== "boolean") return; + var kinds = layerKinds(l); + var any = kinds.some(function(k) { return row[k] !== false; }); + kinds.forEach(function(k) { row[k] = row.all && (!any || row[k] !== false); }); + delete row.all; + }); +} + // ---- Layer table ---- // -// One row per copper layer, F to B. "all" turns the whole layer on or off; the -// other flags keep their state underneath it. Silk and fab exist only on F -// and B. Only F is shown at first. "net" on a layer that is off still shows -// the highlighted nets on it. +// One row per copper layer, F to B. A layer is on while any of its kinds is +// on. "all" is not stored: it is on when every kind is on, off when none is, +// and mixed in between. Silk and fab exist only on F and B. Only F is shown +// at first. "net" on a layer that is off still shows the highlighted nets on +// it. var LAYER_KINDS = ["tracks", "zones", "vias", "silk", "fab"]; @@ -165,8 +182,8 @@ function layerKinds(l) { } function defaultLayerRow(l) { - var row = { all: l === "F", net: false }; - layerKinds(l).forEach(function(k) { row[k] = true; }); + var row = { net: false }; + layerKinds(l).forEach(function(k) { row[k] = l === "F"; }); return row; } @@ -182,18 +199,45 @@ function layerRow(l) { return row; } +// "all" of a layer: true when every kind is on, false when none is, else +// null. +function layerAll(l) { + var row = layerRow(l), kinds = layerKinds(l); + var on = kinds.filter(function(k) { return row[k]; }).length; + return on === kinds.length ? true : on === 0 ? false : null; +} + +function layerOn(l) { return layerAll(l) !== false; } + +// One box of a row: true, false, or null for a mixed "all". +function layerFlag(l, kind) { + return kind === "all" ? layerAll(l) : layerRow(l)[kind]; +} + +// Set one box of a row, without saving. "all" sets every kind. +function putLayerFlag(l, kind, val) { + var row = layerRow(l); + (kind === "all" ? layerKinds(l) : [kind]).forEach(function(k) { row[k] = val; }); +} + +// The layers that have a box in a column. +function layersWithKind(kind) { + return getCopperLayers().filter(function(l) { + return kind === "all" || kind === "net" || layerKinds(l).includes(kind); + }); +} + // Whether to draw or hit-test one kind of thing on a layer: all, tracks, // zones, vias, pads, silk or fab. renderTracks, renderZones and renderPads // are global switches on top of the table. function layerShows(l, kind) { - return layerRow(l).all && kindShows(l, kind); + return layerOn(l) && kindShows(l, kind); } // The same for the highlighted nets on a layer that is off with net ticked: // all, tracks, zones, vias or pads. False for a layer that is on. function layerShowsNet(l, kind) { - var row = layerRow(l); - return !row.all && row.net && kindShows(l, kind); + return !layerOn(l) && layerRow(l).net && kind !== "silk" && kind !== "fab" && switchShows(l, kind); } // Whether the highlighted nets on a layer are drawn: the layer is on, or off @@ -202,17 +246,22 @@ function netCopperShows(l, kind) { return layerShows(l, kind) || layerShowsNet(l, kind); } -// The flags under "all". +// The kind's own box and the global switch over it. function kindShows(l, kind) { - if (kind === "all") return true; + if (kind === "all" || kind === "pads") return switchShows(l, kind); + return switchShows(l, kind) && layerRow(l)[kind] === true; +} + +// The global switches. Pads are only on F and B. +function switchShows(l, kind) { if (kind === "pads") return isOuterLayer(l) && settings.renderPads !== false; - if (kind === "tracks" && settings.renderTracks === false) return false; - if (kind === "zones" && settings.renderZones === false) return false; - return layerRow(l)[kind] === true; + if (kind === "tracks") return settings.renderTracks !== false; + if (kind === "zones") return settings.renderZones !== false; + return true; } function setLayerFlag(l, kind, val) { - layerRow(l)[kind] = val; + putLayerFlag(l, kind, val); saveSettings(); syncLayerControls(); redrawAllIfDone(); @@ -223,10 +272,11 @@ function viewedSides() { return settings.canvaslayout === "FB" ? ["F", "B"] : [settings.canvaslayout]; } -// Turn one flag on for every layer in the list if any is off, else off. +// Turn one flag on for every layer in the list if any is off or mixed, else +// off. The same as a click on a box that is clear, mixed or ticked. function toggleLayerFlag(layers, kind) { - var anyOff = layers.some(function(l) { return !layerRow(l)[kind]; }); - layers.forEach(function(l) { layerRow(l)[kind] = anyOff; }); + var anyOff = layers.some(function(l) { return layerFlag(l, kind) !== true; }); + layers.forEach(function(l) { putLayerFlag(l, kind, anyOff); }); saveSettings(); syncLayerControls(); redrawAllIfDone(); @@ -242,11 +292,11 @@ function toggleOffViewLayers(kind) { if (selectedNet && netToLayers[selectedNet] && netToLayers[selectedNet].size > 0) { var netLayers = netToLayers[selectedNet]; rows = rows.filter(function(l) { return netLayers.has(l); }); - newVal = !rows.every(function(l) { return layerRow(l)[kind]; }); + newVal = !rows.every(function(l) { return layerFlag(l, kind) === true; }); } else { - newVal = !rows.some(function(l) { return layerRow(l)[kind]; }); + newVal = !rows.some(function(l) { return layerFlag(l, kind) !== false; }); } - rows.forEach(function(l) { layerRow(l)[kind] = newVal; }); + rows.forEach(function(l) { putLayerFlag(l, kind, newVal); }); saveSettings(); syncLayerControls(); redrawAllIfDone(); @@ -778,7 +828,7 @@ function activateNetLayer(layerName) { // Make a copper layer visible. The far side shows through the board. function revealLayer(layerName) { - if (!layerRow(layerName).all) setLayerFlag(layerName, "all", true); + if (!layerOn(layerName)) setLayerFlag(layerName, "all", true); } function setNetTypeFilter(type) { @@ -1308,12 +1358,14 @@ var LAYER_COLUMNS = [ ["net", "Net"], ]; -// One row per copper layer, F to B, one checkbox column per kind. +// One row per copper layer, F to B, one checkbox column per kind. Above them +// a row with one box per column for every layer at once. function buildLayerControls() { var table = document.getElementById("layer-table"); if (!table) return; table.innerHTML = ""; - var head = table.createTHead().insertRow(); + var thead = table.createTHead(); + var head = thead.insertRow(); head.insertCell().outerHTML = "