diff --git a/meshchatx.rsm b/meshchatx.rsm index d27fa721..0dad8798 100644 Binary files a/meshchatx.rsm and b/meshchatx.rsm differ diff --git a/meshchatx/src/frontend/js/networkVisualiserWebGL.js b/meshchatx/src/frontend/js/networkVisualiserWebGL.js index dcddf708..7ef0b375 100644 --- a/meshchatx/src/frontend/js/networkVisualiserWebGL.js +++ b/meshchatx/src/frontend/js/networkVisualiserWebGL.js @@ -14,6 +14,12 @@ const ATLAS_COLS = 16; const ATLAS_ROWS = 16; const ATLAS_CAPACITY = ATLAS_COLS * ATLAS_ROWS; +/** Soft-edge AA width in UV radius units (1.0 = disc edge). Keep tight to avoid fuzzy blobs. */ +export const NODE_EDGE_INNER = 0.96; +/** Border ring starts inside the disc (untextured and under glyphs). */ +export const NODE_BORDER_INNER = 0.78; +export const NODE_BORDER_OUTER = 0.96; + const NODE_VS = `#version 300 es layout(location=0) in vec2 a_corner; layout(location=1) in vec2 a_center; @@ -54,20 +60,24 @@ out vec4 outColor; void main() { float d = length(v_uv); if (d > 1.0) discard; - float edge = smoothstep(1.0, 0.82, d); - float rim = smoothstep(0.92, 1.0, d); + // Tight AA so nodes read as crisp discs, not soft fuzzy blobs. + float edge = smoothstep(1.0, ${NODE_EDGE_INNER.toFixed(2)}, d); + float border = smoothstep(${NODE_BORDER_INNER.toFixed(2)}, ${NODE_BORDER_OUTER.toFixed(2)}, d); + vec3 fill = v_color.rgb; + vec3 rim = fill * 0.55; if (v_useTex > 0.5) { vec2 local = v_uv * 0.5 + 0.5; vec2 texUV = v_uvOrigin + local * u_cellUv; vec4 tex = texture(u_atlas, texUV); - // Opaque RGB icons (and RGB canvases) may report a=0 in some uploads. - float texA = max(tex.a, 0.001); - float a = texA * edge * v_color.a; - if (a < 0.01) discard; - vec3 rgb = mix(tex.rgb, v_color.rgb, rim * 0.85); + float texA = clamp(tex.a, 0.0, 1.0); + // Colored disc from node color, optional glyph/logo from atlas on top. + vec3 rgb = mix(fill, tex.rgb, texA); + rgb = mix(rgb, rim, border * (1.0 - texA)); + float a = edge * v_color.a; + if (a < 0.02) discard; outColor = vec4(rgb, a); } else { - vec3 rgb = mix(v_color.rgb * 0.92, v_color.rgb, 1.0 - rim); + vec3 rgb = mix(fill, rim, border); outColor = vec4(rgb, v_color.a * edge); } } @@ -214,6 +224,64 @@ export function resolveVisualiserAssetUrl(url) { return trimmed; } +/** + * True when the asset is a solid-fill network-visualiser badge (colored disc + light glyph). + * Those should be converted to white-on-transparent glyphs so the shader can paint node color. + * @param {string} url + */ +export function isGlyphStyleVisualiserIcon(url) { + if (!url || typeof url !== "string") return false; + return /\/network-visualiser\//i.test(url); +} + +/** + * Prepare atlas RGBA pixels for upload. + * + * - opaque: force non-empty pixels to a=255 (RGB PNG uploads) + * - glyph: keep near-white / bright pixels as white glyphs, clear the solid fill + * + * @param {Uint8ClampedArray|Uint8Array} data RGBA buffer (mutated) + * @param {"opaque"|"glyph"} mode + * @returns {{painted:number,glyphPixels:number}} + */ +export function prepareVisualiserIconPixels(data, mode = "opaque") { + let painted = 0; + let glyphPixels = 0; + if (!data || data.length < 4) return { painted: 0, glyphPixels: 0 }; + const glyphMode = mode === "glyph"; + for (let i = 0; i < data.length; i += 4) { + const r = data[i]; + const g = data[i + 1]; + const b = data[i + 2]; + const a = data[i + 3]; + if (!(r | g | b | a)) continue; + painted += 1; + if (glyphMode) { + // Stock badges: bright glyph on saturated fill. Keep bright pixels. + const luma = 0.299 * r + 0.587 * g + 0.114 * b; + const maxc = Math.max(r, g, b); + const minc = Math.min(r, g, b); + const sat = maxc - minc; + const isGlyph = luma >= 185 || (luma >= 150 && sat < 40); + if (isGlyph) { + data[i] = 255; + data[i + 1] = 255; + data[i + 2] = 255; + data[i + 3] = 255; + glyphPixels += 1; + } else { + data[i] = 0; + data[i + 1] = 0; + data[i + 2] = 0; + data[i + 3] = 0; + } + } else { + data[i + 3] = 255; + } + } + return { painted, glyphPixels }; +} + function createIconAtlas(gl) { const width = ATLAS_COLS * ATLAS_CELL; const height = ATLAS_ROWS * ATLAS_CELL; @@ -242,7 +310,7 @@ function createIconAtlas(gl) { return nextSlot++; } - function paintSlot(slot, source) { + function paintSlot(slot, source, url) { if (!scratchCtx || !scratch) return false; scratchCtx.save(); scratchCtx.setTransform(1, 0, 0, 1, 0, 0); @@ -259,19 +327,26 @@ function createIconAtlas(gl) { const dx = Math.floor((ATLAS_CELL - dw) / 2); const dy = Math.floor((ATLAS_CELL - dh) / 2); scratchCtx.drawImage(source, dx, dy, dw, dh); - // Force full opacity for RGB sources that leave alpha at 0 after upload. const pixels = scratchCtx.getImageData(0, 0, ATLAS_CELL, ATLAS_CELL); const data = pixels.data; - let painted = 0; - for (let i = 0; i < data.length; i += 4) { - if (data[i] | data[i + 1] | data[i + 2] | data[i + 3]) { - data[i + 3] = 255; - painted += 1; + const mode = isGlyphStyleVisualiserIcon(url) ? "glyph" : "opaque"; + if (mode === "glyph") { + const backup = new Uint8ClampedArray(data); + const { painted, glyphPixels } = prepareVisualiserIconPixels(data, "glyph"); + if (painted < 8) { + scratchCtx.restore(); + return false; + } + if (glyphPixels < 8) { + data.set(backup); + prepareVisualiserIconPixels(data, "opaque"); + } + } else { + const { painted } = prepareVisualiserIconPixels(data, "opaque"); + if (painted < 8) { + scratchCtx.restore(); + return false; } - } - if (painted < 8) { - scratchCtx.restore(); - return false; } scratchCtx.putImageData(pixels, 0, 0); scratchCtx.restore(); @@ -325,7 +400,7 @@ function createIconAtlas(gl) { if (slot == null) return null; const work = loadImageSource(url) .then((img) => { - const ok = paintSlot(slot, img); + const ok = paintSlot(slot, img, url); if (typeof img.close === "function") { try { img.close(); diff --git a/meshchatx/src/frontend/js/networkVisualiserWebGLEngine.js b/meshchatx/src/frontend/js/networkVisualiserWebGLEngine.js index feae91ff..21de699c 100644 --- a/meshchatx/src/frontend/js/networkVisualiserWebGLEngine.js +++ b/meshchatx/src/frontend/js/networkVisualiserWebGLEngine.js @@ -83,8 +83,9 @@ function colorFromNode(node) { if (rgb) return rgb; } if (c && typeof c === "object") { - const border = c.border || c.background; - const rgb = hexToRgb01(border); + // Border is the vivid badge color; background is a pale tint for vis-network. + const fill = c.border || c.background; + const rgb = hexToRgb01(fill); if (rgb) return rgb; } return null; @@ -121,12 +122,18 @@ function kindForNode(node) { function sizeForNode(node, kind) { const s = Number(node?.size); if (Number.isFinite(s) && s > 0) { - return Math.max(10, Math.min(34, s * 0.55)); + // Keep WebGL radii close to vis-network so glyphs stay readable. + return Math.max(18, Math.min(48, s * 0.9)); } - if (kind === KIND_ME) return 26; - if (kind === KIND_IFACE_ON || kind === KIND_IFACE_OFF) return 18; - if (kind === KIND_DISCOVERED) return 14; - return 16; + if (kind === KIND_ME) return 32; + if (kind === KIND_IFACE_ON || kind === KIND_IFACE_OFF) return 24; + if (kind === KIND_DISCOVERED) return 20; + return 22; +} + +/** Exported for unit tests. */ +export function webglNodeSizeFor(node, kind) { + return sizeForNode(node, kind == null ? kindForNode(node) : kind); } function imageForNode(node, kind) { diff --git a/tests/frontend/networkVisualiserNodeLook.test.js b/tests/frontend/networkVisualiserNodeLook.test.js new file mode 100644 index 00000000..1cc0278d --- /dev/null +++ b/tests/frontend/networkVisualiserNodeLook.test.js @@ -0,0 +1,140 @@ +// SPDX-License-Identifier: 0BSD + +/** + * Regression tests for WebGL visualiser node look: + * crisp discs, glyph-on-color badges, readable sizes, no soft fuzzy blobs. + */ + +import { describe, expect, it } from "vitest"; +import { + isGlyphStyleVisualiserIcon, + NODE_BORDER_INNER, + NODE_BORDER_OUTER, + NODE_EDGE_INNER, + prepareVisualiserIconPixels, + resolveVisualiserAssetUrl, +} from "@/js/networkVisualiserWebGL.js"; +import { + graphToSceneRequest, + KIND_IFACE_ON, + KIND_ME, + KIND_PEER, + webglNodeSizeFor, +} from "@/js/networkVisualiserWebGLEngine.js"; + +describe("visualiser WebGL node look regressions", () => { + it("keeps disc soft-edge tight (no fuzzy blob AA)", () => { + expect(NODE_EDGE_INNER).toBeGreaterThanOrEqual(0.94); + expect(NODE_BORDER_INNER).toBeLessThan(NODE_BORDER_OUTER); + expect(NODE_BORDER_OUTER).toBeLessThanOrEqual(NODE_EDGE_INNER + 0.001); + }); + + it("treats network-visualiser badge PNGs as glyph-style icons", () => { + expect(isGlyphStyleVisualiserIcon("/assets/images/network-visualiser/user.png")).toBe(true); + expect(isGlyphStyleVisualiserIcon("/assets/images/network-visualiser/interface_connected.png")).toBe( + true + ); + expect(isGlyphStyleVisualiserIcon("/assets/images/reticulum_logo_512.png")).toBe(false); + expect(isGlyphStyleVisualiserIcon("")).toBe(false); + }); + + it("extracts white glyph and clears solid fill from badge pixels", () => { + // 2x2: blue fill, white glyph, blue fill, empty + const data = new Uint8ClampedArray([ + 59, + 130, + 246, + 255, + 255, + 255, + 255, + 255, + 59, + 130, + 246, + 255, + 0, + 0, + 0, + 0, + ]); + const { painted, glyphPixels } = prepareVisualiserIconPixels(data, "glyph"); + expect(painted).toBe(3); + expect(glyphPixels).toBe(1); + expect(data[0]).toBe(0); + expect(data[3]).toBe(0); + expect(data[4]).toBe(255); + expect(data[7]).toBe(255); + expect(data[8]).toBe(0); + expect(data[11]).toBe(0); + }); + + it("opaque mode forces alpha on RGB pixels for logo uploads", () => { + const data = new Uint8ClampedArray([10, 20, 30, 0, 0, 0, 0, 0]); + const { painted } = prepareVisualiserIconPixels(data, "opaque"); + expect(painted).toBe(1); + expect(data[3]).toBe(255); + }); + + it("falls back to opaque when glyph extraction would wipe the icon", () => { + // All mid-saturation blue: no bright glyph pixels. + const data = new Uint8ClampedArray(16); + for (let i = 0; i < 16; i += 4) { + data[i] = 59; + data[i + 1] = 130; + data[i + 2] = 246; + data[i + 3] = 255; + } + const backup = new Uint8ClampedArray(data); + const first = prepareVisualiserIconPixels(data, "glyph"); + expect(first.glyphPixels).toBe(0); + expect(first.painted).toBe(4); + // paintSlot restores the pre-glyph buffer before opaque fallback. + data.set(backup); + prepareVisualiserIconPixels(data, "opaque"); + expect(data[3]).toBe(255); + expect(data[0]).toBe(59); + }); + + it("WebGL node sizes stay large enough for glyphs", () => { + expect(webglNodeSizeFor({ size: 25 }, KIND_PEER)).toBeGreaterThanOrEqual(18); + expect(webglNodeSizeFor({ size: 50 }, KIND_ME)).toBeGreaterThanOrEqual(30); + expect(webglNodeSizeFor({ size: 35 }, KIND_IFACE_ON)).toBeGreaterThanOrEqual(24); + // Must not shrink to the old fuzzy 0.55 scale (~13px for size 25). + expect(webglNodeSizeFor({ size: 25 }, KIND_PEER)).toBeGreaterThan(16); + }); + + it("graph scene colors use vivid border for disc fill", () => { + const req = graphToSceneRequest( + [ + { + id: "peer", + group: "announce", + size: 25, + color: { border: "#3b82f6", background: "#eff6ff" }, + }, + { + id: "direct", + group: "announce", + size: 25, + color: { border: "#10b981", background: "#ecfdf5" }, + }, + ], + [], + { width: 100, height: 100, zoom: 1 } + ); + expect(req.nodes[0].r).toBeCloseTo(0x3b / 255, 2); + expect(req.nodes[0].g).toBeCloseTo(0x82 / 255, 2); + expect(req.nodes[0].b).toBeCloseTo(0xf6 / 255, 2); + expect(req.nodes[1].r).toBeCloseTo(0x10 / 255, 2); + expect(req.nodes[1].g).toBeCloseTo(0xb9 / 255, 2); + // Pale backgrounds must not become the disc color (that looked washed out). + expect(req.nodes[0].r).toBeGreaterThan(0.15); + expect(req.nodes[0].size).toBeGreaterThanOrEqual(18); + }); + + it("resolveVisualiserAssetUrl keeps absolute asset paths resolvable", () => { + const url = resolveVisualiserAssetUrl("/assets/images/network-visualiser/user.png"); + expect(url).toContain("/assets/images/network-visualiser/user.png"); + }); +});